From 1dae2de8354f1f7fe188379f0edf7674a6061e37 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 10:35:40 +0100 Subject: [PATCH] Issue enrolments and keep dead letters without the lease (issue 1126, A2) The controller ended by its unassignment, so its lease key is absent for good and mesh-sdk reads that as not handed away: mesh-enrolment's issue stayed off and the watchman never took DEAD_LETTER_NOTICES, whose notices age out after a week. Both now act without reading the lease; the watchman still waits, and says so, while the controller's consumer on the notices stands. Neither manifest reads the lease any more. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- .../cmd/mesh-delivery-watchman-tools/main.go | 6 +- .../integration_test.go | 19 ++----- .../cmd/mesh-delivery-watchman/main.go | 21 +------ .../cmd/mesh-delivery-watchman/signals.go | 55 ++++++------------- .../internal/deadletters/deadletters.go | 22 +++----- .../internal/deadletters/deadletters_test.go | 34 +++++------- .../mesh-delivery-watchman/manifest_test.go | 2 +- modules/mesh-delivery-watchman/module.json | 1 - .../cmd/mesh-enrolment/holder.go | 9 ++- .../cmd/mesh-enrolment/integration_test.go | 6 +- .../cmd/mesh-enrolment/issue.go | 6 +- .../cmd/mesh-enrolment/issue_test.go | 6 +- .../cmd/mesh-enrolment/kit_test.go | 3 +- .../cmd/mesh-enrolment/tokens_test.go | 15 +++-- .../cmd/mesh-enrolment/tools.go | 6 +- modules/mesh-enrolment/module.json | 1 - 16 files changed, 70 insertions(+), 142 deletions(-) diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman-tools/main.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman-tools/main.go index 1ae2d78..b3a0c2c 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman-tools/main.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman-tools/main.go @@ -140,9 +140,9 @@ func Tools(states func(string) state, ask func(socket.Ask) (json.RawMessage, err {Name: verb(VerbDeadLetters), Run: dead, Description: "What a consumer gave up on, kept in DEAD_LETTERS (novox/hq ADR 0564 §5, ADR 0264, ADR 0376): newest first, " + "for one consumer (its name or .), at most limit; one whole by id (a restricted one's body " + - "withheld). Who keeps them now: the controller until the lease names no controller, the watchman from then. " + - "Delivering again and dropping (act deliver or drop, with why) are refused here until the hand-act log's home " + - "(issue 1953): before the lease write use mesh-controller.dead-letters.", + "withheld). The watchman keeps them once the controller's consumer on DEAD_LETTER_NOTICES is gone, and says the " + + "notices wait while it stands. Delivering again and dropping (act deliver or drop, with why) are refused here until " + + "the hand-act log's home (issue 1953).", Input: map[string]any{"type": "object", "properties": map[string]any{ "consumer": map[string]any{"type": "string", "description": "only this consumer's, its name or ."}, "limit": map[string]any{"type": "integer", "description": "at most this many, newest first"}, diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/integration_test.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/integration_test.go index b32baa3..5f94991 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/integration_test.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/integration_test.go @@ -117,12 +117,8 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8 kvs[c.Bucket] = kv } - // The controller's lease, its consumer on DEAD_LETTER_NOTICES, the dead letters' streams and a source stream with one - // message a consumer will give up on (1952/WM5). - lease, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: buscompose.LeaseBucket}) - if err != nil { - t.Fatal(err) - } + // The controller's consumer on DEAD_LETTER_NOTICES, the dead letters' streams and a source stream with one message a + // consumer will give up on (1952/WM5); no lease bucket at all (issue 1126). for _, c := range []jetstream.StreamConfig{ {Name: buscompose.DeadLetterNoticesStream, Subjects: []string{buscompose.MaxDeliveriesAdvisories}, Retention: jetstream.WorkQueuePolicy}, {Name: buscompose.DeadLettersStream, Subjects: []string{"mesh.events.dead.>"}, Duplicates: 24 * time.Hour, AllowMsgTTL: true}, @@ -269,15 +265,8 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8 }) until("node n1's silence", func() bool { return finding("machine.n1.silent") }) - // 1952/WM5: while the lease names a controller the watchman takes no notice; handed away while the controller's consumer - // stands, it says the notices wait; the consumer removed (the lease write's step), it makes its own and keeps a notice - // with its headers. - if _, err := js.Consumer(ctx, buscompose.DeadLetterNoticesStream, deadletters.Consumer); err == nil { - t.Fatal("the watchman made its consumer while the lease names a controller") - } - if _, err := lease.Put(ctx, buscompose.ControllerLeaseKey, []byte(`{"handed-to":"mesh-vault","handed-by":"operator","why":"the lease write"}`)); err != nil { - t.Fatal(err) - } + // 1952/WM5, 1126/A2/AC2: with no lease written, while the controller's consumer stands the watchman says the notices + // wait; the consumer removed, it makes its own and keeps a notice with its headers. waits := watch.FindingKey(watch.ScopeBus, "DEAD_LETTER_NOTICES.controller", watch.KindNoticesWaiting) until("the notices said waiting behind the controller's consumer", func() bool { return finding(waits) }) if _, err := js.Consumer(ctx, buscompose.DeadLetterNoticesStream, deadletters.Consumer); err == nil { diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go index a513fdc..1ded4bd 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go @@ -22,7 +22,6 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/provisions" @@ -171,7 +170,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // Its own presence, pulses and stuck rows are what it keeps: each stops the start, naming its grant. The assigner's wants // and the node-engine's haves only make the pulses of a move: without one the watchman watches presence and the build // seats' steps, and says that moves are not watched (issue 1945). - var kvs struct{ presence, pulse, stuck, findings, wants, haves, lease jetstream.KeyValue } + var kvs struct{ presence, pulse, stuck, findings, wants, haves jetstream.KeyValue } for _, b := range []struct { name string into *jetstream.KeyValue @@ -186,7 +185,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro kvs.wants, kvs.haves = nil, nil // a want is a pulse only against the have that ends it } - kvs.lease = process.OptionalBucket(ctx, js, buscompose.LeaseBucket, "the lease is read as naming a controller, so the dead letters stay the controller's", logger.Printf) w := watch.New(&kvOut{ctx: ctx, presence: kvs.presence, pulse: kvs.pulse, stuk: kvs.stuck, findings: kvs.findings}, time.Now) w.Logf(logger.Printf) // A test shortens how long a move may take; the mesh never sets it. @@ -379,23 +377,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro errs <- follow(ctx, provWatch, func(e jetstream.KeyValueEntry) { w.Provision(e.Key(), e.Value(), opOf(e)) }) }() } - if kvs.lease != nil { - lw, err := kvs.lease.Watch(ctx, buscompose.ControllerLeaseKey) - if err != nil { - process.NotWatched(logger.Printf, buscompose.LeaseBucket, err, "the lease is read as naming a controller, so the dead letters stay the controller's") - } else { - go func() { - errs <- follow(ctx, lw, func(e jetstream.KeyValueEntry) { - var raw []byte - if opOf(e) == watch.Put { - raw = e.Value() - } - on, _ := buscompose.LeaseHandedAway(raw, len(raw) > 0, nil) - dead.lease(on) - }) - }() - } - } if l, err := socket.Listen(socket.PathOf(getenv)); err != nil { logger.Printf("the tools' socket cannot be opened (%v): dead-letters and resync answer nothing until the watchman starts again", err) } else { diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/signals.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/signals.go index c5922f8..d2b9913 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/signals.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/signals.go @@ -105,7 +105,7 @@ func heard(w *watch.Watchman, dead *deadKeeper, m *nats.Msg, logf func(string, . return } if dead.isTheControllersNotices(a.ID) { - dead.check() // the lease write's step removed it: the watchman may take the notices now + dead.check() // the controller's consumer removed: the watchman may take the notices now w.RemovalGone(watch.RemovalConsumer) return } @@ -164,17 +164,16 @@ func hear(conn *nats.Conn, w *watch.Watchman, dead *deadKeeper, logf func(string return conn.Flush() } -// deadKeeper keeps what consumers gave up on from the lease write (ADR 0564 §5): it counts DEAD_LETTERS into findings, -// and takes DEAD_LETTER_NOTICES on its own consumer only once the lease names no controller and `controller` is gone. +// deadKeeper keeps what consumers gave up on (ADR 0564 §5): it counts DEAD_LETTERS into findings, and takes +// DEAD_LETTER_NOTICES on its own consumer once `controller` is gone (issue 1126: no lease is read). type deadKeeper struct { ctx context.Context js jetstream.JetStream w *watch.Watchman logf func(string, ...any) - mu sync.Mutex - handedAway bool - taking jetstream.ConsumeContext + mu sync.Mutex + taking jetstream.ConsumeContext } // ages pulses each consumer's restricted letters by when they age out, read from DEAD_LETTERS' headers (at the start and @@ -225,38 +224,20 @@ func (d *deadKeeper) count() { d.w.DeadLettersHeld(held, deadletters.ConsumerInWords) } -// lease is the lease's change heard: handed away or not. -func (d *deadKeeper) lease(handedAway bool) { - d.mu.Lock() - d.handedAway = handedAway - d.mu.Unlock() - d.check() -} - -func (d *deadKeeper) away() bool { - d.mu.Lock() - defer d.mu.Unlock() - return d.handedAway -} - -// isTheControllersNotices says an advisory is about the controller's consumer on DEAD_LETTER_NOTICES once the lease is -// handed away: its removal is the lease write's step, not a loss. +// isTheControllersNotices says an advisory is about the controller's consumer on DEAD_LETTER_NOTICES: its removal is the +// controller's end, not a loss. func (d *deadKeeper) isTheControllersNotices(id string) bool { - return d.away() && id == buscompose.DeadLetterNoticesStream+"."+buscompose.ControllerName + return id == buscompose.DeadLetterNoticesStream+"."+buscompose.ControllerName } // check takes the notices, or says they wait, as Gate has it. func (d *deadKeeper) check() { - away := d.away() - stands := true - if away { - var err error - if stands, err = deadletters.ControllerStands(d.ctx, d.js); err != nil { - d.logf("whether the controller's consumer on %s stands cannot be read (%v): it is taken to stand", buscompose.DeadLetterNoticesStream, err) - stands = true - } + stands, err := deadletters.ControllerStands(d.ctx, d.js) + if err != nil { + d.logf("whether the controller's consumer on %s stands cannot be read (%v): it is taken to stand", buscompose.DeadLetterNoticesStream, err) + stands = true } - take, waiting := deadletters.Gate(away, stands) + take, waiting := deadletters.Gate(stands) d.w.NoticesWaiting(waiting) d.mu.Lock() defer d.mu.Unlock() @@ -269,7 +250,7 @@ func (d *deadKeeper) check() { return } d.taking = cc - d.logf("the lease names no controller and its consumer on %s is gone: the watchman keeps the dead letters", buscompose.DeadLetterNoticesStream) + d.logf("the controller's consumer on %s is gone: the watchman keeps the dead letters", buscompose.DeadLetterNoticesStream) } // answer is the program's answer to the tools' ask on its socket. @@ -289,7 +270,7 @@ func answer(a socket.Ask, w *watch.Watchman, dead *deadKeeper, ask func([]byte) if strings.TrimSpace(a.Why) == "" { return nil, errors.New("a dead letter is delivered again or dropped only with why") } - return nil, deadletters.Refusal(dead.away(), a.Act) + return nil, deadletters.Refusal(a.Act) case a.Act != "": return nil, fmt.Errorf("%q is not deliver or drop", a.Act) case a.ID > 0: @@ -299,11 +280,7 @@ func answer(a socket.Ask, w *watch.Watchman, dead *deadKeeper, ask func([]byte) if err != nil { return nil, err } - keeper := "the controller (it holds its lease)" - if dead.away() { - keeper = "the watchman" - } - return map[string]any{"dead-letters": list, "total": total, "kept-by": keeper}, nil + return map[string]any{"dead-letters": list, "total": total, "kept-by": "the watchman"}, nil } return nil, fmt.Errorf("refused: the watchman's socket answers %q and %q", socket.AskDeadLetters, socket.AskResync) } diff --git a/modules/mesh-delivery-watchman/internal/deadletters/deadletters.go b/modules/mesh-delivery-watchman/internal/deadletters/deadletters.go index 4e4a228..4ad045a 100644 --- a/modules/mesh-delivery-watchman/internal/deadletters/deadletters.go +++ b/modules/mesh-delivery-watchman/internal/deadletters/deadletters.go @@ -305,27 +305,23 @@ func Named(ctx context.Context, js jetstream.JetStream, id uint64) (DeadLetter, return d, nil } -// Refusal is why the watchman delivers nothing again and drops nothing: before the lease write the controller keeps them -// and its verb acts; from it the act waits for the hand-act log's home (issue 1953), where a person's act is recorded. -// act is deliver or drop. -func Refusal(handedAway bool, act string) error { +// Refusal is why the watchman delivers nothing again and drops nothing: the act waits for the hand-act log's home (issue +// 1953), where a person's act is recorded. act is deliver or drop. +func Refusal(act string) error { done := map[string]string{"deliver": "delivered again", "drop": "dropped"}[act] if done == "" { return fmt.Errorf("%q is not deliver or drop", act) } - if !handedAway { - return fmt.Errorf("the controller still holds its lease and keeps the dead letters: %s it with "+ - "mesh-controller.dead-letters (novox/hq ADR 0564 §5); nothing is %s here", act, done) - } return fmt.Errorf("a dead letter is %s only as a person's act recorded in the hand-act log, whose home is not "+ "delivered yet (issue 1953): nothing is %s", done, done) } -// Gate is whether the watchman takes the notices itself: only when the lease names no controller (handedAway) and the -// controller's consumer is gone. waiting says the lease is handed away while `controller` still stands, which is the -// watchman's finding: each notice waits in the queue until the lease write's step removes it. -func Gate(handedAway, controllerStands bool) (take, waiting bool) { - return handedAway && !controllerStands, handedAway && controllerStands +// Gate is whether the watchman takes the notices itself: once the controller's consumer is gone. waiting says `controller` +// still stands, which is the watchman's finding: each notice waits in the queue until that consumer is removed. The lease +// is not read: the controller ended by its unassignment, never by the lease write (issue 1126), and mesh-sdk's +// buscompose.LeaseHandedAway answers false for the absent key, which would keep the notices the controller's for good. +func Gate(controllerStands bool) (take, waiting bool) { + return !controllerStands, controllerStands } // ControllerStands asks the bus whether the controller's consumer on DEAD_LETTER_NOTICES stands. diff --git a/modules/mesh-delivery-watchman/internal/deadletters/deadletters_test.go b/modules/mesh-delivery-watchman/internal/deadletters/deadletters_test.go index 2926eca..462849a 100644 --- a/modules/mesh-delivery-watchman/internal/deadletters/deadletters_test.go +++ b/modules/mesh-delivery-watchman/internal/deadletters/deadletters_test.go @@ -8,36 +8,32 @@ import ( "github.com/nats-io/nats.go" ) -// 1952/WM5, ADR 0564 §5: the watchman makes no consumer while `controller` exists or the lease names a controller, makes -// one once both are false, and says the wait meanwhile; it delivers and drops nothing, naming who does; and a kept message -// reads as the controller's copies do, a restricted one's age and work queue kept. -func TestTheWatchmanKeepsTheDeadLettersOnlyFromTheLeaseWrite(t *testing.T) { +// 1952/WM5, ADR 0564 §5, 1126/A2/AC2: with no lease read at all (the controller ended by its unassignment), the watchman +// makes no consumer while `controller` stands on DEAD_LETTER_NOTICES and says the wait, and makes its own once it is gone; +// it delivers and drops nothing, naming why; and a kept message reads as the controller's copies do, a restricted one's +// age and work queue kept. +func TestTheWatchmanKeepsTheDeadLetters(t *testing.T) { for _, c := range []struct { - handedAway, controllerStands, take, waiting bool + controllerStands, take, waiting bool }{ - {false, true, false, false}, - {false, false, false, false}, - {true, true, false, true}, - {true, false, true, false}, + {true, false, true}, + {false, true, false}, } { - take, waiting := Gate(c.handedAway, c.controllerStands) + take, waiting := Gate(c.controllerStands) if take != c.take || waiting != c.waiting { - t.Errorf("Gate(%v, %v) = %v, %v", c.handedAway, c.controllerStands, take, waiting) + t.Errorf("Gate(%v) = %v, %v", c.controllerStands, take, waiting) } } for _, c := range []struct { - away bool act string says string }{ - {false, "deliver", "mesh-controller.dead-letters"}, - {false, "drop", "nothing is dropped"}, - {true, "drop", "issue 1953"}, - {true, "deliver", "delivered again"}, - {true, "send", "not deliver or drop"}, + {"drop", "issue 1953"}, + {"deliver", "delivered again"}, + {"send", "not deliver or drop"}, } { - if err := Refusal(c.away, c.act); err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("Refusal(%v, %s) = %v, want it to say %q", c.away, c.act, err, c.says) + if err := Refusal(c.act); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("Refusal(%s) = %v, want it to say %q", c.act, err, c.says) } } at := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC) diff --git a/modules/mesh-delivery-watchman/manifest_test.go b/modules/mesh-delivery-watchman/manifest_test.go index afdf4bf..7f146ca 100644 --- a/modules/mesh-delivery-watchman/manifest_test.go +++ b/modules/mesh-delivery-watchman/manifest_test.go @@ -90,7 +90,7 @@ func TestManifestStateAndReads(t *testing.T) { // 1135/AC6 } reads := slices.Clone(m.Reads) slices.Sort(reads) - if !reflect.DeepEqual(reads, []string{"mesh-build-go.steps", "mesh-build-image.steps", "mesh-build-node.steps", "mesh-controller.lease", + if !reflect.DeepEqual(reads, []string{"mesh-build-go.steps", "mesh-build-image.steps", "mesh-build-node.steps", "mesh-delivery-assigner.wants", "mesh-delivery-indexer.modules", "mesh-host.health", "mesh-provisioner.provisions", "node-engine.have"}) { t.Errorf("reads = %v", reads) } diff --git a/modules/mesh-delivery-watchman/module.json b/modules/mesh-delivery-watchman/module.json index 9b1932d..f405cf7 100644 --- a/modules/mesh-delivery-watchman/module.json +++ b/modules/mesh-delivery-watchman/module.json @@ -19,7 +19,6 @@ "mesh-build-go.steps", "mesh-build-image.steps", "mesh-build-node.steps", - "mesh-controller.lease", "mesh-delivery-assigner.wants", "mesh-delivery-indexer.modules", "mesh-host.health", diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go b/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go index 47b763e..1523556 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/holder.go @@ -12,7 +12,6 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -159,11 +158,11 @@ func checkTunnelKey(k string) error { return nil } -// issuing says whether issue is on: only while the controller's lease is handed to a holder that is no controller (ADR 0587 -// §4), and why either way. +// issuing says whether issue is on, and why: always, since the controller ended by its unassignment, never by the lease +// write (issue 1126), so the seat is the only issuer (ADR 0587 §4). The lease is no longer read: mesh-sdk's +// buscompose.LeaseHandedAway answers false for the absent key, which would keep issuing off for good. func (h *Holder) issuing() (bool, string) { - raw, found, err := h.Read.Get("mesh-controller.lease", buscompose.ControllerLeaseKey) - return buscompose.LeaseHandedAway(raw, found, err) + return true, "the seat is the only issuer: the controller ended by its unassignment (issue 1126)" } // openEnrolments reads every node key of the bucket. diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/integration_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/integration_test.go index fe757eb..c544016 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/integration_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/integration_test.go @@ -220,11 +220,9 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) { return kv } enrolments := mk(delivery.EnrolmentsBucket, true) - logins, lease, placement := mk(delivery.NodeLoginsBucket, false), mk("mesh-controller_lease", false), mk("mesh-delivery-assigner_placement", false) + // No controller lease bucket at all: the controller ended by its unassignment (issue 1126). + logins, placement := mk(delivery.NodeLoginsBucket, false), mk("mesh-delivery-assigner_placement", false) have := mk("node-engine_have", false) - if _, err := lease.Put(ctx, "holder", []byte(`{"handed-to":"mesh-grants","handed-by":"operator"}`)); err != nil { - t.Fatal(err) - } if _, err := placement.Put(ctx, "hub.node", []byte(hubPlacement)); err != nil { t.Fatal(err) } diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go b/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go index 597f542..4cd5ea4 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/issue.go @@ -42,7 +42,7 @@ type IssueAnswer struct { } // Issue opens an enrolment (ADR 0587 §4): it refuses at once, naming every finding, a name that is no node name or that a -// node record, a placement key or an open enrolment holds, and every call before the lease write; chooses the lowest free +// node record, a placement key or an open enrolment holds; chooses the lowest free // address and reserves it by creating its key (the bus refuses a key that exists, so two issues never get one address); // writes the key at stage asked; and asks the operator, on a channel that proves who answered. The token is made only on // the operator's yes (Warranted). @@ -68,10 +68,6 @@ func (h *Holder) Issue(caller, node, why, overlayKey string, lasts time.Duration if lasts < 0 || lasts > delivery.MostTokenLife { findings = append(findings, fmt.Sprintf("the expiry %s is outside zero to %s", lasts, delivery.MostTokenLife)) } - if on, reason := h.issuing(); !on { - findings = append(findings, "issuing is off until the controller's lease is written to a holder that is no controller: "+reason+ - "; until then the controller's `token issue` is the only issuer (ADR 0587 §4)") - } placements, perr := h.placements() if perr != nil { findings = append(findings, "placement cannot be read: "+perr.Error()) diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go index 7c59eaf..7814259 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/issue_test.go @@ -14,8 +14,8 @@ import ( var yes = asks.Option{ID: "yes"} -// 774/S55/AC1: issue refuses, naming every finding, what it must: the lease with the controller, a bad name, a name a node -// record, a placement key or an open enrolment holds, and an expiry past a day. +// 774/S55/AC1: issue refuses, naming every finding, what it must: a bad name, a name a node record, a placement key or an +// open enrolment holds, and an expiry past a day; never the controller's lease, which no rig writes (issue 1126). func issueRefusesNamingEveryFinding(t *testing.T) { cases := []struct { name string @@ -24,8 +24,6 @@ func issueRefusesNamingEveryFinding(t *testing.T) { lasts time.Duration want []string }{ - {"the lease names the controller", func(r *rig) { r.rd["mesh-controller.lease"]["holder"] = `{"instance":"novox.mesh-controller"}` }, "ace", 0, - []string{"issuing is off until the controller's lease is written", "token issue"}}, {"a bad name and a long expiry", nil, "Ace.1", 48 * time.Hour, []string{"not a valid node name", "outside zero to 24h0m0s"}}, {"a placement key and a node record", func(r *rig) { r.rd["mesh-delivery-assigner.placement"]["ace.node"] = `{"v":1,"node":"ace","modules":[]}` diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go index 4dbad2b..1c04bff 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/kit_test.go @@ -201,7 +201,7 @@ type said struct { Body json.RawMessage } -// rig is a holder over fakes, with the lease handed to mesh-grants, one hub in placement and a clock the test moves. +// rig is a holder over fakes, with no controller lease at all (issue 1126), one hub in placement and a clock the test moves. type rig struct { mu sync.Mutex t *testing.T @@ -230,7 +230,6 @@ const hubPlacement = `{"v":1,"node":"hub","decided":{"hub":true,"endpoint":"192. func newRig(t *testing.T) *rig { r := &rig{t: t, st: newMem(), vault: &fakeVault{fail: map[string]bool{}}, asker: &fakeAsker{}, now: time.Date(2026, 11, 2, 10, 0, 0, 0, time.UTC), loaded: true} r.rd = memRead{ - "mesh-controller.lease": {"holder": `{"handed-to":"mesh-grants","handed-by":"operator"}`}, "mesh-delivery-assigner.placement": {"hub.node": hubPlacement}, "node-engine.have": {"hub.node": hubRecord}, } diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go index f7e9c01..9fd86e8 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go @@ -59,12 +59,11 @@ func tokensAnswersEachStageAndRelaysTheSealedTokenOfOneNode(t *testing.T) { } } } - // Before the lease write issuing is off, and says why. - r.rd["mesh-controller.lease"]["holder"] = `{"instance":"novox.mesh-controller"}` - off, _ := r.h.Tokens("", false) - mustContain(t, off.Issuing.Why, "controller holds the lease") - if off.Issuing.On { - t.Error("issuing is on while the controller holds the lease") + // 1126/A2/AC1: with no lease key issuing is on, and says why. + on, _ := r.h.Tokens("", false) + mustContain(t, on.Issuing.Why, "unassignment") + if !on.Issuing.On { + t.Errorf("issuing is off with no lease key: %s", on.Issuing.Why) } } @@ -83,6 +82,7 @@ func theManifestSaysWhatTheCodeDoes(t *testing.T) { Serves []string `json:"serves"` } `json:"claims"` Emits []string `json:"emits"` + Reads []string `json:"reads"` State []json.RawMessage `json:"state"` } if err := json.Unmarshal(raw, &m); err != nil { @@ -92,6 +92,9 @@ func theManifestSaysWhatTheCodeDoes(t *testing.T) { !slices.Equal(m.Claims[0].Serves, Verbs) || len(m.Emits) != 0 { t.Errorf("module %q runs as %q, claims %+v, emits %v", m.Module, m.Runs, m.Claims, m.Emits) } + if slices.Contains(m.Reads, "mesh-controller.lease") { + t.Error("the manifest reads the controller's lease, which the code no longer reads (issue 1126)") + } var heard []string for _, e := range EndEvents { heard = append(heard, Seat+"."+e) diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go index 3365c65..bc7a800 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tools.go @@ -48,8 +48,7 @@ func Tools(h *Holder) []stdio.Tool { {Name: IssueTool, Description: "Open an enrolment: let a new machine join the mesh (novox/hq ADR 0587 §4). Takes the node's name, a why and an " + "expiry (one hour unless given, at most 24 hours). Refused at once, naming every finding, for a name that is no node name " + - "or that a node record, a placement key or an open enrolment holds, and for every call before the controller's lease is " + - "written to a holder that is no controller. Otherwise it chooses the lowest free private-network address, reserves it, " + + "or that a node record, a placement key or an open enrolment holds. Otherwise it chooses the lowest free private-network address, reserves it, " + "writes the enrolment at stage asked and asks the operator on a channel that proves who answered; only their yes adds the " + "node to placement with the machine's tunnel key (overlay_key, as `nox-mesh-host key` prints it there), so the hub peers " + "it, and then makes the token, which carries the first tunnel (the vault makes it, this seat never holds it). The caller is the one the runtime proves.", @@ -107,8 +106,7 @@ func Tools(h *Holder) []stdio.Tool { Run: func(a map[string]any) (any, error) { return h.Again(arg(a, "node")) }}, {Name: TokensTool, Description: "What the seat holds (novox/hq ADR 0587 §8): every open enrolment, or one node's, with its stage (asked, live or " + - "redeeming), address, expiry, ask, revision and age; whether issuing is on and why (it is on only from the controller's " + - "lease write); the bucket's fill. With a node at stage live, the token sealed to the operator key, asked of the vault at " + + "redeeming), address, expiry, ask, revision and age; whether issuing is on and why; the bucket's fill. With a node at stage live, the token sealed to the operator key, asked of the vault at " + "this moment and relayed as the vault sealed it: the one route by which the operator gets a token, which this seat never " + "reads and keeps nowhere. With history=true the ended enrolments its events say. A machine stuck at redeeming shows here " + "with its age; revoke ends it. Read-only.", diff --git a/modules/mesh-enrolment/module.json b/modules/mesh-enrolment/module.json index 29c1455..4aed246 100644 --- a/modules/mesh-enrolment/module.json +++ b/modules/mesh-enrolment/module.json @@ -34,7 +34,6 @@ "seat:mesh-vault.sign" ], "reads": [ - "mesh-controller.lease", "mesh-delivery-assigner.placement", "mesh-grants.user-list", "mesh-vault.signers",