From e094e362f416f6af40542bf7b6dae6e5c99cb5e1 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 10:33:14 +0100 Subject: [PATCH 1/3] Run mesh-grants' handed behaviour without the lease (issue 1126, A1) The controller ended by its unassignment and its lease key expired, so every gate on "the lease is handed away" stayed shut for good: no account consumer was created or matched, grant-missing was never raised, the enrolment users kept the controller's subject, memberships were still said on their subject and the sealed key never said LeaseHanded. The handed branch now always runs, and a hand-back is gone. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- modules/mesh-grants/cmd/mesh-grants/tools.go | 9 +- modules/mesh-grants/grants_test.go | 84 ++++++------------- .../mesh-grants/internal/grants/consumers.go | 28 +++---- modules/mesh-grants/internal/grants/grants.go | 8 +- modules/mesh-grants/internal/grants/inputs.go | 14 +--- modules/mesh-grants/internal/grants/part.go | 11 +-- .../mesh-grants/internal/grants/records.go | 2 +- modules/mesh-grants/internal/grants/sealed.go | 5 +- .../mesh-grants/internal/grants/takeover.go | 19 ++--- 9 files changed, 60 insertions(+), 120 deletions(-) diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index 0edd4c0..830c43c 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -118,8 +118,8 @@ func toolsOf(p *grants.Part) []stdio.Tool { Run: func(a map[string]any) (any, error) { return p.TakeOver(stdio.CallerOf(a), grants.Answer(a, "why")) }}, - {Name: grants.ToolHandBack, Description: "Ask the operator to switch the bus node back to the controller's list (ADR 0547 clause 8), " + - "by their yes on their channel; in shadow there is nothing to hand back, and it says so. Every act is recorded.", + {Name: grants.ToolHandBack, Description: "Gone: the controller ended by its unassignment (issue 1126), so nothing writes its user list and the grants " + + "part's list is the only one; it says so and asks nothing (ADR 0547 §11).", Input: schema(map[string]string{"why": "why, kept with the act"}, "why"), Run: func(a map[string]any) (any, error) { return p.HandBack(stdio.CallerOf(a), grants.Answer(a, "why")) @@ -129,15 +129,14 @@ func toolsOf(p *grants.Part) []stdio.Tool { "wrote it to its state accounts, which the vault alone reads: user, kind, node, module, process, the bus's address and " + "fingerprint, a module's claims with the verbs each seat promises, and when the key was written; one user's or all; " + "what the last pass wrote, deleted or kept; and the durable consumers the accounts need as the last pass found them " + - "(missing, differing and why, created; whether the lease is handed away). Never a password. Read-only: " + + "(missing, differing and why, created). Never a password. Read-only: " + "grants_compose_now writes them again.", Input: schema(map[string]string{"user": "one bus user, e.g. ace.mesh-build-go"}), Run: func(a map[string]any) (any, error) { return p.Accounts(grants.Answer(a, "user")) }}, {Name: grants.ToolConsumerMatch, Description: "Bring one durable consumer an account needs (a module's on EVENTS, a process's on " + "its work queue; novox/hq ADR 0561 §6) to the configuration the bus composer composes, by hand: the recovery when " + "the condition mesh-grants.consumer-differs names it. Asks the part's process, which alone may, and keeps where the " + - "consumer delivers from. Refused while the controller holds its lease (the consumers are the controller's then) and " + - "for a consumer the records do not compose. grants_user_list's last pass says what became of it.", + "consumer delivers from. Refused for a consumer the records do not compose. grants_user_list's last pass says what became of it.", Input: schema(map[string]string{"stream": "the stream, e.g. EVENTS", "consumer": "the consumer, e.g. ace_mesh-build-go"}, "stream", "consumer"), Run: func(a map[string]any) (any, error) { return p.MatchConsumer(grants.Answer(a, "stream"), grants.Answer(a, "consumer")) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 09dc244..0056be1 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -11,7 +11,6 @@ import ( "maps" "os" "path/filepath" - "reflect" "slices" "strings" "sync" @@ -540,7 +539,7 @@ func TestGrants(t *testing.T) { gap string }{ {"every record on the bus", func(*fakeBus) {}, - []string{"a.mod-x", "a.nats", "b.mod-y", "controller", "node.a", "node.b"}, "enrolment tokens"}, + []string{"a.mod-x", "a.nats", "b.mod-y", "controller", "node.a", "node.b"}, "people"}, {"a manifest missing is a gap and its user is left out", func(b *fakeBus) { delete(b.states[grants.ReadModules], "mod-y") }, []string{"a.mod-x", "a.nats", "controller", "node.a", "node.b"}, "manifest of mod-y"}, {"a want's built manifest is composed, not the trunk's; the list's records carry both (issue 774)", func(b *fakeBus) { @@ -551,7 +550,7 @@ func TestGrants(t *testing.T) { b.put(grants.ReadWants, "want.a.mod-x", delivery.Want{V: delivery.Version, FP: fp, Composed: "sha256:" + strings.Repeat("ef", 32)}) key, _ := delivery.BuilderBuildKey("mod-x", fp) b.put(grants.ReadBuilds, key, map[string]any{"v": delivery.Version, "state": "done", "manifest": digest}) - }, []string{"a.mod-x", "a.nats", "b.mod-y", "controller", "node.a", "node.b"}, "enrolment tokens"}, + }, []string{"a.mod-x", "a.nats", "b.mod-y", "controller", "node.a", "node.b"}, "people"}, {"placement refused is a gap; the machines come from the haves", func(b *fakeBus) { b.refused[grants.ReadPlacement] = true }, []string{"controller", "node.a", "node.b"}, grants.ReadPlacement}, } { @@ -1316,7 +1315,7 @@ func TestGrants(t *testing.T) { } // Written in shadow, the bus node told to follow the controller's list; every user left out while the vault holds // no hash; a tool's part (no object bucket) writes none. - if g, _, had := p.Sealed(); !had || g.Follow != delivery.FollowController || g.Revision != 1 || g.Node != "a" || + if g, _, had := p.Sealed(); !had || g.Follow != delivery.FollowController || !g.LeaseHanded || g.Revision != 1 || g.Node != "a" || g.Users != 0 || len(g.LeftOut) != 6 || len(objs.objects) != 1 || !strings.Contains(open(g), "accounts {") { t.Errorf("the sealed list in shadow = %+v, %d objects", g, len(objs.objects)) } @@ -1328,14 +1327,15 @@ func TestGrants(t *testing.T) { } else if _, _, had := tp.Sealed(); had { t.Error("a tool's part wrote the sealed list") } - if len(first.Published) != 3 || len(b.publishes) != 3 { - t.Errorf("the first composition published %v, want the three assignments' memberships", first.Published) + // Written to the state alone, never said on their subject, with no lease key (1126/A1). + if len(first.Published) != 3 || first.MembershipsWritten != 3 || len(b.publishes) != 0 { + t.Errorf("the first composition published %v (%d on the subject), want the three assignments' memberships in the state alone", first.Published, len(b.publishes)) } // The second pass says each membership unchanged (published at the first); the third writes nothing at all. if again := p.Compose(); again.Written || again.Revision != 1 || len(again.Published) != 0 || again.AssertionsWritten != 0 { t.Errorf("a composition with no input changed wrote or published: %+v", again) } - if third := p.Compose(); third.Written || third.MembershipsWritten != 0 || len(third.Published) != 0 || len(b.publishes) != 3 { + if third := p.Compose(); third.Written || third.MembershipsWritten != 0 || len(third.Published) != 0 || len(b.publishes) != 0 { t.Errorf("a membership equal to the one last published was published again, or a state written: %+v", third) } // mod-x placed on b too: b.mod-x is added and its first membership published; a.mod-x's loses the module's plain @@ -1402,8 +1402,6 @@ func TestGrants(t *testing.T) { var out any if act == "take-over" { out, err = p.TakeOver("shanks.node-tools", "a row") - } else { - out, err = p.HandBack("shanks.node-tools", "a row") } if err != nil { t.Fatalf("%s: %v", act, err) @@ -1452,7 +1450,6 @@ func TestGrants(t *testing.T) { }, []string{"a.mod-x: 1 read"}}, {"a yes with only added rights and every hash held records the switch, the ask naming them", "take-over", yes, settled(lacks), nil, nil, grants.TookOver, "recorded", nil, []string{"adds 1 right to 1 user and takes none away", "a.mod-x: 1 read"}}, - {"a yes to hand back records the switch back", "hand-back", yes, func() {}, nil, nil, grants.Shadow, "recorded", nil, nil}, } { t.Run(row.name, func(t *testing.T) { row.setup() @@ -1522,8 +1519,8 @@ func TestGrants(t *testing.T) { // The key the bus node follows turns with the switch at once, and the state says switched only once the bus // node loaded the grants part's written list. g, _, _ := p.Sealed() - if want := map[string]string{grants.TookOver: delivery.FollowGrants, grants.Shadow: delivery.FollowController}[p.SwitchState()]; g.Follow != want { - t.Errorf("the key says follow %s with the switch %s", g.Follow, p.SwitchState()) + if want := map[string]string{grants.TookOver: delivery.FollowGrants, grants.Shadow: delivery.FollowController}[p.SwitchState()]; g.Follow != want || !g.LeaseHanded { + t.Errorf("the key says follow %s, lease handed %v, with the switch %s and no lease key (1126/A1/AC2)", g.Follow, g.LeaseHanded, p.SwitchState()) } effect := func() string { st, _ := p.TakeOverState(); return fmt.Sprint(st.(map[string]any)["effect"]) } if row.outcome == "recorded" && row.act == "take-over" { @@ -1663,10 +1660,11 @@ func TestGrants(t *testing.T) { } }) } - t.Run("hand-back in shadow is refused at once, with nothing asked", func(t *testing.T) { + // 1126/A1: the controller ended by its unassignment, so nothing writes its list and a hand-back is gone. + t.Run("hand-back is gone at once, with nothing asked", func(t *testing.T) { n := len(fa.asks) - if _, err := p.HandBack("shanks.node-tools", "a row"); err == nil || !strings.Contains(err.Error(), "nothing to hand back") { - t.Errorf("hand-back in shadow = %v", err) + if _, err := p.HandBack("shanks.node-tools", "a row"); err == nil || !strings.HasPrefix(err.Error(), "gone") { + t.Errorf("hand-back = %v", err) } p.Compose() if len(fa.asks) != n { @@ -1674,6 +1672,8 @@ func TestGrants(t *testing.T) { } }) t.Run("an ask that can stay open is closed every way and can be asked again", func(t *testing.T) { + // The rows above end taken over and a hand-back is gone (1126/A1): asked again from a part in shadow. + delete(b.own[grants.StateTakeover], grants.KeySwitch) // A warrant the process missed is caught up from the router's record, whatever its outcome. id := asked("take-over") p.Compose() @@ -1843,11 +1843,10 @@ func TestGrants(t *testing.T) { if row.names == "" && row.last != nil && !strings.Contains(string(state), "10.0.0.1") { t.Errorf("the private network of the last membership is not carried: %s", state) } + // Written to the state alone, never said on its subject (1126/A1). got, err := membership.DecodeGrantsMembership(state) - var sent membership.Membership - _ = json.Unmarshal(b.stream["mesh.assignment."+key], &sent) - if err != nil || !reflect.DeepEqual(got, sent) || bytes.Contains(state, []byte(`"membership"`)) { - t.Errorf("the state is not the bare membership published (%v): %s", err, state) + if err != nil || got.Node != "a" || got.Module != "mod-x" || bytes.Contains(state, []byte(`"membership"`)) || len(b.stream) != 0 { + t.Errorf("the state is not the bare membership published, or its subject said (%v, %d said): %s", err, len(b.stream), state) } if !strings.Contains(pass, row.names) { t.Errorf("the pass does not name %q: %s", row.names, pass) @@ -1866,23 +1865,6 @@ func TestGrants(t *testing.T) { } }) } - // The assignments stream removed (nats_stream_remove at the lease write) and its subject refused: every membership - // is still written to the state, where each runtime reads it, and the refusal is said (issue 774). - t.Run("the stream gone and its subject refused: memberships still written, the refusal said", func(t *testing.T) { - b := fixture() - b.loaded = shadowOf(t, b) - b.refusePub = true - p := partOn(b) - refused := p.Compose() - if !slices.Equal(refused.Published, []string{"a.mod-x", "a.nats", "b.mod-y"}) || refused.MembershipsWritten != 3 || - !strings.Contains(strings.Join(refused.Errors, "\n"), "Permissions Violation") { - t.Errorf("with the stream gone = %+v: every membership written, the refusal said", refused) - } - b.refusePub = false - if next := p.Compose(); len(next.Published) != 0 || next.MembershipsWritten != 0 { - t.Errorf("the next event published %v again", next.Published) - } - }) t.Run("a tool answers while a pass waits on the bus", func(t *testing.T) { b := fixture() b.loaded = shadowOf(t, b) @@ -2251,7 +2233,7 @@ func TestGrants(t *testing.T) { } return nil } - b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"}) + // With no lease key (1126/A1). // The private network the mesh provides is placed with no fingerprint by construction: never a missing grant. b.assign("b", "mesh-wireguard") fp := "src2:d4afe7a1abab5ccdbef6d8629b2245006a2aec60290f430f7b4756e2930ed60c" @@ -2422,7 +2404,6 @@ func TestGrants(t *testing.T) { b := fixture() b.manifestOf("mesh-enrolment", enrolmentManifest) b.assign("a", "mesh-enrolment") - b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"}) issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC) for node, stage := range map[string]string{"lab": delivery.EnrolmentLive, "pi": delivery.EnrolmentAsked} { raw, err := delivery.EncodeEnrolment(delivery.Enrolment{Node: node, Address: "10.0.0.7", Why: "joins", @@ -2545,27 +2526,21 @@ func TestGrants(t *testing.T) { } }) - // 774/AC6 (novox/hq ADR 0561 §6): the durable consumers an account needs, with the controller's configuration, are - // compared in shadow while the controller holds the lease, and created, only where the bus lacks them, from the write - // that hands the lease away; one the bus holds is never changed. - t.Run("account consumers: compared while the controller holds the lease, created once it is handed away", func(t *testing.T) { + // 774/AC6 (novox/hq ADR 0561 §6), 1126/A1/AC1: the durable consumers an account needs, with the composed + // configuration, are created where the bus lacks them with no lease key at all (the controller ended by its + // unassignment, never by the lease write); one the bus holds is never changed. + t.Run("account consumers: created where the bus lacks them, with no lease key", func(t *testing.T) { const cManifest = `{"module":"mod-c","version":"1","own-secrets":{"broker":"/var/lib/c/broker"},"consumes":["mod-y.done"]}` b := fixture() b.manifestOf("mod-c", cManifest) b.assign("b", "mod-c") b.loaded = shadowOf(t, b) - b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"instance": "novox/1/2026-10-27", "epoch": 57}) p := partOn(b) p.Consumers = b - res := p.Compose() - if res.Consumers == nil || res.Consumers.On || !slices.Contains(res.Consumers.Missing, "EVENTS.b_mod-c") || len(b.consumerCreates) != 0 { - t.Fatalf("while the controller holds the lease: %+v, created %v", res.Consumers, b.consumerCreates) - } - b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"}) b.consumers = map[string][]map[string]any{"EVENTS": {{"name": "b_other", "filters": []string{"x"}, "max_deliver": 5}}} - res = p.Compose() - if !res.Consumers.On || !slices.Equal(res.Consumers.Created, []string{"EVENTS.b_mod-c"}) { - t.Fatalf("once handed away: %+v", res.Consumers) + res := p.Compose() + if res.Consumers == nil || !slices.Equal(res.Consumers.Created, []string{"EVENTS.b_mod-c"}) { + t.Fatalf("with no lease key: %+v", res.Consumers) } var made map[string]any for _, c := range b.consumers["EVENTS"] { @@ -2581,7 +2556,7 @@ func TestGrants(t *testing.T) { t.Errorf("a consumer the bus holds was asked again: %+v, %v", res.Consumers, b.consumerCreates) } // A held consumer that differs is never changed by a pass: it is raised as a condition naming it, and brought to - // the composed configuration only by hand (grants_consumer_match), never while the controller holds the lease. + // the composed configuration only by hand (grants_consumer_match), with no lease key too. var said []asks.Condition p.Say = func(c asks.Condition) error { if c.Key != grants.GrantMissingCondition && c.Key != grants.RefusedCondition { // their own rows @@ -2596,11 +2571,6 @@ func TestGrants(t *testing.T) { !strings.Contains(said[0].Explanation, "EVENTS.b_mod-c") { t.Fatalf("a differing consumer: %+v, said %+v", res.Consumers, said) } - b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"instance": "novox/2/2026-10-27", "epoch": 58}) - if _, err := p.MatchConsumer("EVENTS", "b_mod-c"); err == nil || !strings.Contains(err.Error(), "controller holds the lease") { - t.Errorf("matched by hand under the controller's lease: %v", err) - } - b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"}) if _, err := p.MatchConsumer("EVENTS", "b_nothing"); err == nil { t.Error("a consumer the records do not compose was asked to match") } diff --git a/modules/mesh-grants/internal/grants/consumers.go b/modules/mesh-grants/internal/grants/consumers.go index 90a5f62..53cd4f8 100644 --- a/modules/mesh-grants/internal/grants/consumers.go +++ b/modules/mesh-grants/internal/grants/consumers.go @@ -38,7 +38,8 @@ type ConsumerCreator interface { // ConsumersState is what one pass found and did of the account consumers. type ConsumersState struct { - // On says the lease is handed away, so the part creates what the bus lacks; Why says why it is on or off. + // On says the part creates what the bus lacks: always, since the controller ended by its unassignment (issue 1126); + // Why says so. On bool `json:"on"` Why string `json:"why"` // Wanted is how many the records compose; Missing those the bus lacks (`.`), Differ those it holds @@ -88,14 +89,13 @@ func heldConsumers(c Caller, stream string) (map[string]heldConsumer, error) { return out, nil } -// leaseHandedAway reads the lease as the part's reads give it. -func leaseHandedAway(r Reader) (bool, string) { - raw, found, err := r.Get(ReadLease, LeaseKey) - return buscompose.LeaseHandedAway(raw, found, err) -} +// ConsumersOn is why the part creates and matches the account consumers with no lease read: the controller ended by its +// unassignment, never by the lease write, so its lease names no holder for good and the part is their only maker +// (issue 1126; mesh-sdk's buscompose.LeaseHandedAway answers false for an absent key, so it is no longer asked here). +const ConsumersOn = "the grants part makes and matches the account consumers: the controller ended by its unassignment (issue 1126)" // consumers compares the account consumers the records compose with those the bus holds, and creates the missing ones -// once the lease is handed away and the part holds a creator. +// where the part holds a creator. func (p *Part) consumers(res *Result, recs buscompose.Records, match string) { users, err := buscompose.Users(recs) if err != nil { @@ -103,7 +103,7 @@ func (p *Part) consumers(res *Result, recs buscompose.Records, match string) { return } st := &ConsumersState{} - st.On, st.Why = leaseHandedAway(p.Read) + st.On, st.Why = true, ConsumersOn res.Consumers = st wanted := buscompose.AccountConsumers(users) st.Wanted = len(wanted) @@ -154,9 +154,8 @@ func (p *Part) consumers(res *Result, recs buscompose.Records, match string) { } // sayDiffer raises the warning condition mesh-grants.consumer-differs while an account consumer the part owns (from the -// lease write) differs from its composed configuration, naming each with the fields that differ, and clears it when they -// match. Said once per set of differences, again when it changes. Before the lease write the consumers are the -// controller's, and a difference is only said in the pass. +// controller's end) differs from its composed configuration, naming each with the fields that differ, and clears it when +// they match. Said once per set of differences, again when it changes. func (p *Part) sayDiffer(st *ConsumersState) { if p.Say == nil { return @@ -190,15 +189,12 @@ func (p *Part) sayDiffer(st *ConsumersState) { const ConsumerDiffersCondition = "mesh-grants.consumer-differs" // MatchConsumer is the tool consumer-match (ADR 0561 §6, §9): it asks the part's process to bring one account consumer -// the records compose to its composed configuration, keeping where it delivers from. Refused while the controller holds -// the lease (the consumers are the controller's then), and for a consumer the records do not compose. +// the records compose to its composed configuration, keeping where it delivers from. Refused for a +// consumer the records do not compose. func (p *Part) MatchConsumer(stream, name string) (any, error) { if stream == "" || name == "" { return nil, errors.New("consumer-match names a stream and a consumer") } - if on, why := leaseHandedAway(p.Read); !on { - return nil, fmt.Errorf("not asked: %s; the controller makes and matches the consumers while it holds the lease", why) - } in := ReadInputs(p.Read, &p.cache) recs, _ := Records(in) users, err := buscompose.Users(recs) diff --git a/modules/mesh-grants/internal/grants/grants.go b/modules/mesh-grants/internal/grants/grants.go index 99a25a4..7ae517d 100644 --- a/modules/mesh-grants/internal/grants/grants.go +++ b/modules/mesh-grants/internal/grants/grants.go @@ -314,15 +314,15 @@ func orSaid(s, none string) string { return s } -// sayMissing raises mesh-grants.grant-missing while a placed module has no grant, once the controller's lease is handed -// away (the part's grants are then the ones the assigner reads), and clears it when every one has one; in shadow the -// pass and the tool say them. Said once per set, again when it changes, and always at the first pass after a start. +// sayMissing raises mesh-grants.grant-missing while a placed module has no grant (the part's grants are the ones the +// assigner reads since the controller's end, issue 1126), and clears it when every one has one. Said once per set, again +// when it changes, and always at the first pass after a start. func (p *Part) sayMissing(missing []string) { if p.Say == nil { return } why := "" - if on, _ := leaseHandedAway(p.Read); on && len(missing) > 0 { + if len(missing) > 0 { why = strings.Join(missing, "; ") } p.holdMu.Lock() diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index ac8e8e1..433d489 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -81,8 +81,6 @@ type Gap struct { // Standing gaps: what the controller composes from its own store, which no bus value carries in this milestone. var standingGaps = []Gap{ - {Input: "enrolment tokens", Why: "while the controller holds the lease, its live enrolment tokens are in its store alone: " + - "an enrolment user is composed only from the seat mesh-enrolment's keys"}, {Input: "people", Why: "the people and the tools they may invoke are in the controller's store alone: no person user is composed"}, {Input: "seat holdings", Why: "who holds each seat held once for the mesh is in the controller's store alone: a mesh seat claimed on two machines is composed as held on both"}, {Input: "announced states", Why: "the controller does not announce its states-announced readable, so every read is composed from `reads`, as when announcements cannot be read"}, @@ -131,9 +129,6 @@ type Inputs struct { // NodeLogins is each node's key of the vault's node-logins (ReadNodeLogins, ADR 0583 §1); a node absent logs in as // node. alone. NodeLogins map[string]delivery.NodeLogin - // LeaseAway says the controller's lease is handed away, as ReadInputs read it: an enrolment user then enrols through - // the seat alone (ADR 0587 §6). - LeaseAway bool // Refused is every manifest the indexer holds that the part cannot read or mesh-sdk's reader refuses, assigned or // not, each with why: its module counts as placed nowhere, so it is said loudly, never passed over (issue 1945). Refused []Gap @@ -280,7 +275,6 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { in.Placed[node] = p } } - in.LeaseAway, _ = leaseHandedAway(r) for node, p := range in.Placed { nodes[node] = true for _, m := range p.Assigned() { @@ -448,12 +442,8 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { in.Gaps = append(in.Gaps, Gap{Input: "wanted manifests", Why: "no build record names the manifest at the wanted fingerprint, so the trunk's is used: " + strings.Join(fromTrunk, ", ")}) } in.Hashes = readHashes(r, &in.Gaps) - // The controller's tokens stand as a gap while it holds the lease; from the write the seat's keys are every token. - var enrolmentsRead bool - in.Enrolling, enrolmentsRead = readEnrolling(r, &in.Gaps) - if enrolmentsRead && in.LeaseAway { - in.Gaps = slices.DeleteFunc(in.Gaps, func(g Gap) bool { return g.Input == "enrolment tokens" }) - } + // The seat mesh-enrolment's keys are every token since the controller's end (issue 1126). + in.Enrolling, _ = readEnrolling(r, &in.Gaps) in.NodeLogins = readNodeLogins(r, &in.Gaps) for _, raw := range each(r, ReadResolved, &in.Gaps) { var v resolved diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index b7ef545..adce0f4 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -337,10 +337,8 @@ func (p *Part) Compose() Result { members = append(members, m.Membership) } seated := holdings.ByMemberships(members...) - // From the lease write the memberships are written to the state alone and no longer said on their subject, which - // nothing reads any more (novox/hq ADR 0571 clause 10, issue 774). - notice, _ := leaseHandedAway(p.Read) - notice = !notice + // The memberships are written to the state alone and never said on their subject, which nothing reads any more + // (novox/hq ADR 0571 clause 10, issue 774; unconditional since the controller's end, issue 1126). for _, key := range sortedKeys(ms) { // The last membership is the state's own (ADR 0571 clause 7), never the assignments stream's, which the bus // module's tools remove at the lease write (research 072 row 33): a last read there would freeze every membership @@ -360,11 +358,6 @@ func (p *Part) Compose() Result { m.Published, now = "not published (tried again at the next event): its own "+StateMemberships+" cannot be written", false } else { res.MembershipsWritten++ - if notice { - if err := p.Publish.PublishMembership(m.Node, m.Module, body); err != nil { - res.Errors = append(res.Errors, fmt.Sprintf("the membership of %s is written, its notice on its subject not said: %v", key, err)) - } - } } } // One unchanged is written bare once where the state still holds the wrapper written before 2026-10-30. diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index eaba280..ff8531d 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -39,7 +39,7 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { // The enrolment users, from the seat mesh-enrolment's keys, and from the lease write through its redeem alone (ADR 0587 // §4, §6). out := buscompose.Records{Assigned: map[string][]buscompose.Declared{}, People: map[string][]string{}, - Interchangeable: map[string]bool{}, Enrolling: slices.Clone(in.Enrolling), LeaseHandedAway: in.LeaseAway, + Interchangeable: map[string]bool{}, Enrolling: slices.Clone(in.Enrolling), LeaseHandedAway: true, // And each node's logins (ADR 0583 §1): both while it rotates. NodeLogins: in.NodeLogins} values := func(provider, setting string) []any { diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index dbe2c83..8bed292 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -95,8 +95,9 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut, dropped cur, _, had := p.Sealed() next := cur next.Follow = followOf(p.SwitchState()) - // The bus node follows this list alone once the lease is handed (ADR 0571 clause 8, ADR 0582 clause 3). - next.LeaseHanded = p.leaseHanded() + // The bus node follows this list alone (ADR 0571 clause 8): always since the controller ended by its unassignment, + // with no lease written (issue 1126). + next.LeaseHanded = true key := "" if in.Loaded != nil { key = in.Loaded.Key diff --git a/modules/mesh-grants/internal/grants/takeover.go b/modules/mesh-grants/internal/grants/takeover.go index 9d717b6..aa02d7e 100644 --- a/modules/mesh-grants/internal/grants/takeover.go +++ b/modules/mesh-grants/internal/grants/takeover.go @@ -132,14 +132,11 @@ func (p *Part) SwitchState() string { // the refusals of Decide. Asking again while an ask is open is refused. func (p *Part) TakeOver(caller, why string) (any, error) { return p.request(ActTakeOver, caller, why) } -// HandBack is the tool: it asks the operator to switch the bus node back to the controller's list. Once the lease is -// handed to the grants part it is answered as gone: the grants part's list is the only one (ADR 0547 §11). +// HandBack is the tool: it is answered as gone. The controller ended by its unassignment (issue 1126), so nothing +// writes its list and the grants part's list is the only one (ADR 0547 §11). func (p *Part) HandBack(caller, why string) (any, error) { - if p.leaseHanded() { - return nil, errors.New("gone: the controller's lease is handed to mesh-grants, so the grants part's list is the only " + - "one and the user list is not handed back (ADR 0547 §11); lease-hand-back gives the lease back first") - } - return p.request(ActHandBack, caller, why) + return nil, errors.New("gone: the controller ended by its unassignment (issue 1126), so nothing writes its user list; " + + "the grants part's list is the only one and the user list is not handed back (ADR 0547 §11)") } // nonce is four hex digits that keep two asks of one second apart. @@ -546,13 +543,7 @@ func (p *Part) recordWarrant(ask string) { if p.Call == nil { return } - rec := handacts.Recorder{Ask: p.Call.Call, LeaseAway: func() bool { - if p.Read == nil { - return false - } - away, _ := leaseHandedAway(p.Read) - return away - }} + rec := handacts.Recorder{Ask: p.Call.Call} taken, err := rec.RecordWarrant(Module, ask) if p.Logf == nil { return -- 2.54.0 From 1dae2de8354f1f7fe188379f0edf7674a6061e37 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 10:35:40 +0100 Subject: [PATCH 2/3] 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", -- 2.54.0 From a192052d82fe59a192394bc40a935d2f2a1fb1b2 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 10:38:22 +0100 Subject: [PATCH 3/3] Refuse any placement of the controller (issue 1126) The grants part, the enrolment seat and the watchman now act without reading the lease, so a controller placed again would write beside them with nothing saying so. The assigner refuses every proposal, seed or joining node's write that would place mesh-controller, naming why. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- .../cmd/mesh-delivery-assigner/seat.go | 1 + .../internal/assign/seat.go | 34 +++++++++++++++++++ .../internal/assign/seat_test.go | 17 ++++++---- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go index aba87c5..e43ccd8 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go @@ -559,6 +559,7 @@ func (p *passer) addNode(ctx context.Context, prop delivery.PlacementProposal, r return } laid, findings := delivery.LayChanges(current, prop.Changes) + findings = append(findings, assign.ControllerRefusals(prop.Changes, laid)...) node := prop.Changes[0].Node if prop.Changes[0].Op == delivery.OpRemoveNode { p.removeNode(ctx, prop, current, findings) diff --git a/modules/mesh-delivery-assigner/internal/assign/seat.go b/modules/mesh-delivery-assigner/internal/assign/seat.go index e9f9990..1ee398c 100644 --- a/modules/mesh-delivery-assigner/internal/assign/seat.go +++ b/modules/mesh-delivery-assigner/internal/assign/seat.go @@ -2,6 +2,8 @@ package assign import ( "fmt" + "maps" + "slices" "strings" "time" @@ -53,6 +55,7 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla } laid, lay := delivery.LayChanges(in.Current, in.Changes) findings = append(findings, lay...) + findings = append(findings, ControllerRefusals(in.Changes, laid)...) all := map[string]delivery.Placement{} for node, pl := range in.Current { all[node] = pl @@ -105,6 +108,37 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla return p, out, findings } +// ControllerModule is the controller's module, which nothing places again (issue 1126). +const ControllerModule = "mesh-controller" + +// ControllerEnded is why a placement of the controller is refused: the parts that acted only once its lease was handed +// (consumers, the user list, enrolment, dead letters) now act without reading the lease, so a controller placed again +// would write beside them, silently. +const ControllerEnded = "the controller ended by its unassignment on 2026-11-02; placing it again would make two writers (issue 1126)" + +// ControllerRefusals names every change, and every node as laid, that places the controller: by a place, by a seed's +// whole placement, or in what the changes lay. None when nothing does. +func ControllerRefusals(changes []delivery.PlacementProposalChange, laid map[string]delivery.Placement) []string { + var out []string + nodes := map[string]bool{} + for i, c := range changes { + places := c.Module == ControllerModule && c.Op != delivery.OpTakeOff + if c.Whole != nil && slices.Contains(c.Whole.Assigned(), ControllerModule) { + places = true + } + if places { + out = append(out, fmt.Sprintf("change %d places %s on %s: %s", i+1, ControllerModule, c.Node, ControllerEnded)) + nodes[c.Node] = true + } + } + for _, node := range slices.Sorted(maps.Keys(laid)) { + if !nodes[node] && slices.Contains(laid[node].Assigned(), ControllerModule) { + out = append(out, fmt.Sprintf("%s would run %s: %s", node, ControllerModule, ControllerEnded)) + } + } + return out +} + // Write is one node a warrant writes (§5): the node, its node key `.node` (the commit of the node's keys, // mesh-sdk delivery.PlacementKeys.Writes), the revision of the node's placement it is written at (0: it has none, so // it is created) and its value (nil: it is deleted). diff --git a/modules/mesh-delivery-assigner/internal/assign/seat_test.go b/modules/mesh-delivery-assigner/internal/assign/seat_test.go index 9dea2d4..cd5bcfa 100644 --- a/modules/mesh-delivery-assigner/internal/assign/seat_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/seat_test.go @@ -37,12 +37,17 @@ func TestThePlacementSeatDecides(t *testing.T) { in ProposeIn want string }{ - "a module not held": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "ghost"}), "the indexer holds no module ghost"}, - "a node with no record": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "stranger", Module: "web"}), "stranger is a node with no record"}, - "nothing changes": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "anchor", Module: "web"}), "leave every placement as it is"}, - "no why": {func() ProposeIn { i := in(move...); i.Why = " "; return i }(), "says why"}, - "no caller": {func() ProposeIn { i := in(move...); i.By = ""; return i }(), "names no caller"}, - "a seed by hand": {in(delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: "laptop", Whole: &laptop}), "gone with adopt"}, + "a module not held": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "ghost"}), "the indexer holds no module ghost"}, + "a node with no record": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "stranger", Module: "web"}), "stranger is a node with no record"}, + "nothing changes": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "anchor", Module: "web"}), "leave every placement as it is"}, + "no why": {func() ProposeIn { i := in(move...); i.Why = " "; return i }(), "says why"}, + "no caller": {func() ProposeIn { i := in(move...); i.By = ""; return i }(), "names no caller"}, + "a seed by hand": {in(delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: "laptop", Whole: &laptop}), "gone with adopt"}, + // 1126: the controller ended by its unassignment; placed again it would be a second writer beside the parts that + // act without its lease. + "the controller placed again": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "mesh-controller"}), "ended by its unassignment"}, + "the controller seeded whole": {in(delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: "anchor", Whole: &delivery.Placement{V: delivery.Version, Node: "anchor", + Decided: anchor.Decided, Modules: []delivery.PlacedModule{{Module: "mesh-controller"}}}}), "two writers (issue 1126)"}, "a pin of nobody placed": {in(delivery.PlacementProposalChange{Op: delivery.OpPin, Node: "laptop", Provision: "db", Provider: &delivery.Provider{Node: "laptop", Module: "store"}}), "not placed there"}, } { if _, _, f := Propose(c.in); !slices.ContainsFunc(f, func(s string) bool { return strings.Contains(s, c.want) }) { -- 2.54.0