From bc1978ec67e8518d84beb52f33559e367f31f2bc Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 27 Oct 2026 20:42:52 +0100 Subject: [PATCH 1/3] Keep every seat's holder under a placement, and hold a user list that would lose a grant (issue 1945) The user list of 2026-10-27 09:21 granted no holder for nine seats: the seeded placements hold only the seats the controller had on record, and the grants part read those holds as every holder (records.go heldWherePlaced). A placement's holds are now the holders on record, as catalogue.World's Holdings are: a mesh seat no placement holds is held by derivation, wherever its claimant is placed. The membership loss guard (Losses) did not cover the user list: after take-over the list that lost those seats went out. UserLosses applies the same guard per user against the loaded list, and holdsBack holds a list that would lose a grant, or whose loaded grants cannot be read, raising the held warning. Quick fix chosen over moving the on-record rule into mesh-sdk: mesh-grants is the only part reading placement holds itself; the others pass them to the catalogue. --- modules/mesh-grants/grants_test.go | 104 ++++++ .../internal/grants/memberships.go | 35 ++ modules/mesh-grants/internal/grants/part.go | 5 +- .../mesh-grants/internal/grants/records.go | 38 +- modules/mesh-grants/internal/grants/sealed.go | 29 +- .../testdata/claude-licence-manager.json | 184 ++++++++++ .../node-tools-novox-licence-subjects.json | 35 ++ .../testdata/placement-novox-0c61bad.json | 326 ++++++++++++++++++ 8 files changed, 734 insertions(+), 22 deletions(-) create mode 100644 modules/mesh-grants/testdata/claude-licence-manager.json create mode 100644 modules/mesh-grants/testdata/node-tools-novox-licence-subjects.json create mode 100644 modules/mesh-grants/testdata/placement-novox-0c61bad.json diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 4a0aaa6..ff324b5 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -761,6 +761,62 @@ func TestGrants(t *testing.T) { if read := grants.ReadInputs(b, &grants.Manifests{}); slices.ContainsFunc(read.Gaps, func(g grants.Gap) bool { return g.Input == grants.ReadPlacement && g.Failed }) { t.Errorf("placement not on the bus is a failed read: %v", read.Gaps) } + // Issue 1945, the user list of 2026-10-27 09:21: a placement's holds are the holders on record, not every holder. + // A mesh seat no placement holds keeps the module claiming it as its holder, wherever placement puts it. + in.Manifests["other"] = catalogue.Manifest{Module: "other", Version: "1", Claims: []catalogue.Claim{{Name: "other-seat", Scope: catalogue.ScopeMesh}}, + DefinesSeats: []catalogue.SeatDeclaration{{Name: "other-seat", Scope: catalogue.ScopeMesh, Serves: []catalogue.Verb{{Name: "v"}}}}} + in.Assigned["b"] = append(in.Assigned["b"], "other") + in.Placed["b"] = delivery.Placement{Node: "b", Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "other"}}} + if recs, _ := grants.Records(in); holds(recs, "a") != 1 || holds(recs, "b") != 1 { + t.Errorf("a seat no placement holds is held on b %d times, want once (and store-seat on a alone: a %d)", holds(recs, "b"), holds(recs, "a")) + } + in.Placed["b"] = delivery.Placement{Node: "b", Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "other"}}, + Holds: []delivery.Holding{{Claim: "other-seat", Scope: "mesh", Module: "someone-else"}}} + if recs, _ := grants.Records(in); holds(recs, "b") != 0 { + t.Errorf("a seat placement holds by another module is still held by other on b") + } + // The live shapes: the seed's novox placement (mesh-catalog 0c61bad, its twelve holdings, without + // anthropic-licence-manager), the licence manager's manifest (mesh-catalog c05aa5e6), and the subjects the bus + // grants novox.node-tools for its seat (nats_users, 2026-10-27 19:40Z). + file := func(name string, v any) { + raw, err := os.ReadFile(filepath.Join("testdata", name)) + if err == nil { + err = json.Unmarshal(raw, v) + } + if err != nil { + t.Fatal(err) + } + } + var seed delivery.Placement + var live struct{ Users []grants.User } + file("placement-novox-0c61bad.json", &seed) + file("node-tools-novox-licence-subjects.json", &live) + licence, _ := os.ReadFile(filepath.Join("testdata", "claude-licence-manager.json")) + n := newFakeBus() + n.manifestOf("nats", natsManifest) + n.manifestOf("claude-licence-manager", string(licence)) + n.assign("novox", "nats") + n.assign("novox", "claude-licence-manager") + n.manifestOf("node-tools", `{"module":"node-tools","version":"1"}`) // the machine's tool runner, which carries the seat + n.assign("novox", "node-tools") + n.put(grants.ReadHave, "novox.node", map[string]any{"v": 1}) + pl, _ = delivery.EncodePlacement(seed) + n.states[grants.ReadPlacement] = map[string][]byte{"placement.novox": pl} + recs, _ := grants.Records(grants.ReadInputs(n, &grants.Manifests{})) + if m := grants.Memberships(recs)["novox.claude-licence-manager"]; len(m.Membership.Seats) == 0 { + t.Errorf("the licence manager's membership under the seeded placement carries no seats: %+v", m.Membership) + } + var runtime grants.User + for _, u := range shadowOf(t, n) { + if u.User == "novox.node-tools" { + runtime = u + } + } + for _, s := range live.Users[0].Subscribe { + if strings.Contains(s, ".call.") && !slices.Contains(runtime.Subscribe, s) { + t.Errorf("under the seeded placement novox.node-tools is not granted %s, which the bus grants it today", s) + } + } }) t.Run("compare excuses a retired verb and a multiply claimed mesh seat's added subscriptions on its machines, nothing else", func(t *testing.T) { @@ -787,6 +843,27 @@ func TestGrants(t *testing.T) { t.Errorf("%s: equal %v, excused %v", row.name, c.Equal(), c.Excused) } } + // The user list's loss guard (issue 1945): the membership's Losses, per user of the list in force. + u := func(user string, responses bool, sub ...string) grants.User { + return grants.User{User: user, Subscribe: sub, Publish: []string{}, AllowResponses: responses} + } + for _, row := range []struct { + name string + inForce, now []grants.User + gone []string + lost int + }{ + {"a seat grant dropped", []grants.User{u("a.node-tools", false, "x", store)}, []grants.User{u("a.node-tools", false, "x")}, nil, 1}, + {"only added", []grants.User{u("a.node-tools", false, "x")}, []grants.User{u("a.node-tools", true, "x", store)}, nil, 0}, + {"a retired verb dropped", []grants.User{u("a.node-tools", false, "x", retired)}, []grants.User{u("a.node-tools", false, "x")}, nil, 0}, + {"responses taken away", []grants.User{u("a.node-tools", true, "x")}, []grants.User{u("a.node-tools", false, "x")}, nil, 1}, + {"a user dropped", []grants.User{u("a.node-tools", false), u("a.mod-x", false)}, []grants.User{u("a.node-tools", false)}, nil, 1}, + {"a user gone from placement", []grants.User{u("a.node-tools", false), u("a.mod-x", false)}, []grants.User{u("a.node-tools", false)}, []string{"a.mod-x"}, 0}, + } { + if lost := grants.UserLosses(row.inForce, row.now, row.gone); len(lost) != row.lost { + t.Errorf("%s: lost %v, want %d", row.name, lost, row.lost) + } + } }) t.Run("tools", func(t *testing.T) { @@ -1068,6 +1145,33 @@ func TestGrants(t *testing.T) { if len(fresh) == 0 || fresh[0].Key != grants.HeldCondition || fresh[0].State != asks.StateCleared { t.Errorf("a fresh part's first pass does not say the held warning is cleared: %+v", fresh) } + // Issue 1945, 2026-10-27 09:21: a list that would lose a seat grant of the list in force is held, as a + // membership that would lose one is: not written, the warning raised naming what it would lose; and a + // list whose loaded grants cannot be read is held too. + inForce := slices.Clone(b.loaded) + const seatGrant = "mesh.seat.anthropic-licence-manager.call.licences.*" + for i := range b.loaded { + if b.loaded[i].User == "a.mod-x" { + b.loaded[i].Subscribe = append(slices.Clone(b.loaded[i].Subscribe), seatGrant) + } + } + b.put(grants.ReadAssignments, "a.mod-y", map[string]any{"node": "a", "module": "mod-y", "assigned": "2026-10-27T08:20:00Z"}) + b.put(grants.ReadAccounts, "a.mod-y", map[string]any{"user": "a.mod-y", "hash": "$2a$10$x", "generation": 1}) + said = nil + res := p.Compose() + if res.Sealed == nil || !strings.Contains(res.Sealed.Why, "publishing would lose a.mod-x: subscribe") || !strings.Contains(res.Sealed.Why, seatGrant) { + t.Errorf("a list losing a seat grant = %+v", res.Sealed) + } + if now, _, _ := p.Sealed(); now.Digest != g.Digest { + t.Errorf("a list losing a seat grant was written: %s, was %s", now.Digest, g.Digest) + } + if len(said) != 1 || said[0].Key != grants.HeldCondition || said[0].State != asks.StateRaised || !strings.Contains(said[0].Summary, seatGrant) { + t.Errorf("the held list did not raise the warning naming the lost grant: %+v", said) + } + b.loaded = inForce + if p.Compose(); func() bool { now, _, _ := p.Sealed(); return now.Digest == g.Digest }() { + t.Errorf("a list that only adds a.mod-y was not written") + } } }) } diff --git a/modules/mesh-grants/internal/grants/memberships.go b/modules/mesh-grants/internal/grants/memberships.go index e53b452..e14952a 100644 --- a/modules/mesh-grants/internal/grants/memberships.go +++ b/modules/mesh-grants/internal/grants/memberships.go @@ -184,6 +184,41 @@ func sameMembership(was, now []byte) bool { return reflect.DeepEqual(a, b) } +// UserLosses is the same guard as a membership's (Losses) over the user list (issue 1945): everything a user of the list +// in force holds that the list composed now drops, `: `. A user the composed list lacks is lost whole unless +// gone (placement no longer names it); a subscription to a verb the seat catalogue retired is no loss, as in a +// membership (WithoutRetired); and a user allowed responses now where it was not is a gain, not a change. Empty means +// the composed list holds all the list in force did, and maybe more. Pure. +func UserLosses(inForce, composed []User, gone []string) []string { + now := map[string]User{} + for _, u := range composed { + now[u.User] = u + } + var out []string + for _, l := range inForce { + c, ok := now[l.User] + if !ok { + if !slices.Contains(gone, l.User) { + out = append(out, l.User+": the user, with every grant it holds") + } + continue + } + l.Subscribe = slices.DeleteFunc(slices.Clone(l.Subscribe), func(s string) bool { + m := seatSubject.FindStringSubmatch(s) + return m != nil && retiredVerb(m[1], m[2]) + }) + if c.AllowResponses { + l.AllowResponses = true + } + was, _ := json.Marshal(l) + is, _ := json.Marshal(c) + for _, lost := range Losses(was, is) { + out = append(out, l.User+": "+lost) + } + } + return out +} + // ShadowDifference opens what a membership's pass says when it was not published because it would lose something. const ShadowDifference = "not published, a shadow difference: publishing would lose " diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 38457d7..b70f859 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -236,11 +236,12 @@ func (p *Part) Compose() Result { res.Errors = append(res.Errors, "the user list could not be composed whole: "+err.Error()) } else { // The users the bus loaded: what a first list after take-over must keep where the part has not yet written one. - var bus []string + var bus, lost []string if st.Compare.Unread == "" { bus = names(loaded) + lost = UserLosses(loaded, users, st.Compare.Gone) } - p.writeSealed(&res, list, n, leftOut, in, bus) + p.writeSealed(&res, list, n, leftOut, in, bus, lost) } ms := Memberships(recs) res.Memberships = len(ms) diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index e92e11c..cf2af39 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -61,7 +61,7 @@ func Records(in Inputs) (buscompose.Records, []Gap) { } d := declaredFor(m, seats, declarers) if len(in.Placed) > 0 { - d.Holds = heldWherePlaced(d.Holds, in.Placed[node], m.Module) + d.Holds = heldWherePlaced(d.Holds, in.Placed, node, m.Module) } for _, sr := range catalogue.ReadsGranted(m, here, node) { d.KeyedReads = append(d.KeyedReads, buscompose.KeyedRead{Read: sr.Read, Key: sr.Key}) @@ -304,21 +304,43 @@ func hearsOf(m catalogue.Manifest, declared map[string]catalogue.Manifest, node return out, touches, onEvents } -// heldWherePlaced is a module's holds on a node as the catalogue's placement says them (ADR 0549 §1, §3): a seat held once -// for the mesh is held there only when the node's placement names this module holding it, so a module claiming such a seat -// on two machines holds it on the one placement names, not on both; a seat of every node is held wherever the module runs. -func heldWherePlaced(holds []buscompose.Seat, p delivery.Placement, module string) []buscompose.Seat { +// heldWherePlaced is a module's holds on a node as the catalogue's placement says them (ADR 0549 §1, §3). A placement's +// holds are the holders on record, as catalogue.World's Holdings are: a seat held once for the mesh that some node's +// placement holds is held only where a placement names it (this node's naming this module), so a module claiming it on +// two machines holds it on the one placement names, not on both. A seat no placement holds is held by derivation, by the +// module claiming it wherever it is placed, as it was before placement: the seeded placements hold only the seats the +// controller had on record (twelve on the control-node), and treating their holds as every holder left nine seats with +// no holder in the user list of 2026-10-27 09:21 (issue 1945). A seat of every node is held wherever the module runs. +func heldWherePlaced(holds []buscompose.Seat, placed map[string]delivery.Placement, node, module string) []buscompose.Seat { + same := func(x delivery.Holding, h buscompose.Seat) bool { + return (x.Scope == "" || x.Scope == catalogue.ScopeMesh) && sameSeat(x.Claim, h.Name) && x.Kind == h.Kind + } var out []buscompose.Seat for _, h := range holds { if h.Scope != "" && h.Scope != catalogue.ScopeMesh { out = append(out, h) continue } - if slices.ContainsFunc(p.Holds, func(x delivery.Holding) bool { - return x.Claim == h.Name && x.Module == module && x.Kind == h.Kind - }) { + onRecord := false + for _, p := range placed { + if slices.ContainsFunc(p.Holds, func(x delivery.Holding) bool { return same(x, h) }) { + onRecord = true + break + } + } + if !onRecord || slices.ContainsFunc(placed[node].Holds, func(x delivery.Holding) bool { return same(x, h) && x.Module == module }) { out = append(out, h) } } return out } + +// sameSeat says two claims name one seat, under either of its names (catalogue.SeatNamed, ADR 0122). +func sameSeat(a, b string) bool { + if a == b { + return true + } + sa, oka := catalogue.SeatNamed(a) + sb, okb := catalogue.SeatNamed(b) + return oka && okb && sa.Name == sb.Name +} diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index 6a584cc..5cd2237 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -72,7 +72,7 @@ func followOf(state string) string { // writeSealed puts the list sealed to the bus node and writes the key, each only when what it says changed. It says in // res what it wrote, or why it wrote nothing. -func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string, in Inputs, bus []string) { +func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string, in Inputs, bus []string, lost []string) { cur, _, had := p.Sealed() next := cur next.Follow = followOf(p.SwitchState()) @@ -91,10 +91,10 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string why = "no node runs the bus's module, so the list is sealed to nobody" case key == "": why = "the bus node's have of the bus module names no key yet (its node-engine says it from this release)" - case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut, bus) != "": + case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut, bus, lost) != "": // After take-over the list in force is never replaced by one composed past a failed read, or one that drops a user // of it for want of a hash: that user's module would be locked out of the bus (review of the switch). - why = p.holdsBack(in, leftOut, bus) + why = p.holdsBack(in, leftOut, bus, lost) held = why case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode: object, err := delivery.SealUserList(list, key) @@ -297,14 +297,21 @@ func listUsers(list []byte) []string { return out } -// holdsBack says why a list composed now may not replace the one in force after take-over: a read failed this pass, or -// a user of the list in force is left out for want of a hash. The list in force is the one the part wrote, by the users +// holdsBack says why a list composed now may not replace the one in force after take-over: a read failed this pass, a +// user of the list in force is left out for want of a hash, or the list would lose a grant of the list in force (lost, +// UserLosses: the membership's guard, issue 1945; nil when the loaded list could not be read, which holds it too). The list in force is the one the part wrote, by the users // it kept of it; where it kept none (a list written before this release), the users the bus loaded (bus, nil when // they could not be read). Empty when it may. -func (p *Part) holdsBack(in Inputs, leftOut, bus []string) string { +func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string) string { if f := in.ReadFailures(); len(f) > 0 { return "held: the list in force is kept while a read of this pass failed (" + strings.Join(f, "; ") + ")" } + if bus == nil { + return "held: the grants of the list in force cannot be read, so a loss of seats cannot be ruled out" + } + if len(lost) > 0 { + return "held, " + ShadowDifference + strings.Join(lost, "; ") + } raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenUsers) if err != nil { return "held: the users of the list in force cannot be read: " + err.Error() @@ -313,19 +320,17 @@ func (p *Part) holdsBack(in Inputs, leftOut, bus []string) string { switch { case found: _ = json.Unmarshal(raw, &had) - case bus == nil: - return "held: the users of the list in force are not known: the part kept none, and the loaded list could not be read" default: had = bus } - var lost []string + var unhashed []string for _, u := range leftOut { if slices.Contains(had, u) { - lost = append(lost, u) + unhashed = append(unhashed, u) } } - if len(lost) > 0 { - return "held: the list in force is kept, since " + strings.Join(lost, ", ") + " would be left out for want of a hash in the vault" + if len(unhashed) > 0 { + return "held: the list in force is kept, since " + strings.Join(unhashed, ", ") + " would be left out for want of a hash in the vault" } return "" } diff --git a/modules/mesh-grants/testdata/claude-licence-manager.json b/modules/mesh-grants/testdata/claude-licence-manager.json new file mode 100644 index 0000000..a55da31 --- /dev/null +++ b/modules/mesh-grants/testdata/claude-licence-manager.json @@ -0,0 +1,184 @@ +{ + "module": "claude-licence-manager", + "version": "1", + "slug": "licmgr", + "requires": [ + "postgres-database", + "secret" + ], + "contributes": { + "postgres-database": { + "name": "claude_licences", + "readable": [ + "audit", + "binding" + ] + } + }, + "binds": { + "postgres-database": "${dir:state}/database.json" + }, + "secrets": { + "postgres-database": "${dir:state}/database.secret", + "secret": { + "grant-key": "${dir:state}/grant.key" + } + }, + "seats": [ + { + "name": "anthropic-licence-manager", + "scope": "mesh", + "serves": [ + "licences", + "bindings", + "bind", + "switch", + "release", + "refresh", + "usage", + "adopt", + "current", + "public-key" + ] + } + ], + "claims": [ + { + "name": "anthropic-licence-manager", + "scope": "mesh", + "serves": [ + "licences", + "bindings", + "bind", + "switch", + "release", + "refresh", + "usage", + "adopt", + "current", + "public-key" + ] + } + ], + "emits": [ + "licence.adopted", + "licence.refused", + "licence.bound", + "licence.switched", + "licence.released", + "licence.rotated", + "licence.failing", + "usage.read" + ], + "state": [ + "bindings" + ], + "reads": [ + "claude-code.holdings" + ], + "config": { + "cadence_minutes": { + "type": "number", + "description": "How old a licence's grant may grow before it is rotated, in minutes; 0 keeps 240.", + "minimum": 0, + "trusted": true, + "default": 240 + }, + "floor_minutes": { + "type": "number", + "description": "How little time a grant may have left before it is refreshed in any case, in minutes; 0 keeps 60.", + "minimum": 0, + "trusted": true, + "default": 60 + }, + "failures_to_notify": { + "type": "number", + "description": "How many refreshes of a licence fail in a row before the operator is told, a count; 0 keeps 3.", + "minimum": 0, + "trusted": true, + "default": 3 + }, + "cooldown_hours": { + "type": "number", + "description": "How long after telling the operator about a licence nothing more is said about it, in hours; 0 keeps 24.", + "minimum": 0, + "trusted": true, + "default": 24 + }, + "refresh_warn_days": { + "type": "number", + "description": "How long before a licence's refresh token itself expires the operator is warned, in days; 0 keeps 3.", + "minimum": 0, + "trusted": true, + "default": 3 + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "database-url", + "type": "file", + "path": "${dir:state}/database.url", + "mode": "0600", + "template": "files/database.url" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "takes-config": ["cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours", "refresh_warn_days"], + "merge": "json", + "template": "files/settings.json" + }, + { + "id": "prepare", + "type": "process", + "name": "claude-licence-manager-prepare", + "artifact": "code", + "run": [ + "./claude-licence-manager", + "prepare" + ], + "run-once": true, + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + }, + "restart-on": [ + "database-url" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "code", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/claude-licence-manager", + "binary": "claude-licence-manager", + "loads": [ + "claude-licence-manager" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url", + "MESH_LICENCE_STATE": "${dir:state}", + "MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key", + "MESH_LICENCE_SETTINGS": "${dir:state}/settings.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/modules/mesh-grants/testdata/node-tools-novox-licence-subjects.json b/modules/mesh-grants/testdata/node-tools-novox-licence-subjects.json new file mode 100644 index 0000000..ad0f790 --- /dev/null +++ b/modules/mesh-grants/testdata/node-tools-novox-licence-subjects.json @@ -0,0 +1,35 @@ +{ + "users": [ + { + "user": "novox.node-tools", + "subscribe": [ + "$SRV.INFO.anthropic-licence-manager", + "$SRV.INFO.anthropic-licence-manager.>", + "$SRV.PING.anthropic-licence-manager", + "$SRV.PING.anthropic-licence-manager.>", + "$SRV.STATS.anthropic-licence-manager", + "$SRV.STATS.anthropic-licence-manager.>", + "mesh.seat.anthropic-licence-manager.call.adopt.*", + "mesh.seat.anthropic-licence-manager.call.bind.*", + "mesh.seat.anthropic-licence-manager.call.bindings.*", + "mesh.seat.anthropic-licence-manager.call.current.*", + "mesh.seat.anthropic-licence-manager.call.licences.*", + "mesh.seat.anthropic-licence-manager.call.public-key.*", + "mesh.seat.anthropic-licence-manager.call.refresh.*", + "mesh.seat.anthropic-licence-manager.call.release.*", + "mesh.seat.anthropic-licence-manager.call.switch.*", + "mesh.seat.anthropic-licence-manager.call.usage.*", + "mesh.seat.anthropic-licence-manager.tool.adopt", + "mesh.seat.anthropic-licence-manager.tool.bind", + "mesh.seat.anthropic-licence-manager.tool.bindings", + "mesh.seat.anthropic-licence-manager.tool.current", + "mesh.seat.anthropic-licence-manager.tool.licences", + "mesh.seat.anthropic-licence-manager.tool.public-key", + "mesh.seat.anthropic-licence-manager.tool.refresh", + "mesh.seat.anthropic-licence-manager.tool.release", + "mesh.seat.anthropic-licence-manager.tool.switch", + "mesh.seat.anthropic-licence-manager.tool.usage" + ] + } + ] +} diff --git a/modules/mesh-grants/testdata/placement-novox-0c61bad.json b/modules/mesh-grants/testdata/placement-novox-0c61bad.json new file mode 100644 index 0000000..20e8e7e --- /dev/null +++ b/modules/mesh-grants/testdata/placement-novox-0c61bad.json @@ -0,0 +1,326 @@ +{ + "v": 1, + "node": "novox", + "modules": [ + { + "module": "artifact-store-tools" + }, + { + "module": "avahi" + }, + { + "module": "ca-trust" + }, + { + "module": "claude-code" + }, + { + "module": "claude-licence-manager" + }, + { + "module": "dbus" + }, + { + "module": "de-spiegel" + }, + { + "module": "desk-channel" + }, + { + "module": "dev-tools" + }, + { + "module": "disk-load" + }, + { + "module": "distribution" + }, + { + "module": "dnsmasq" + }, + { + "module": "docker" + }, + { + "module": "fail2ban" + }, + { + "module": "gitea" + }, + { + "module": "go" + }, + { + "module": "hostname" + }, + { + "module": "invoicing" + }, + { + "module": "kernel" + }, + { + "module": "keycloak" + }, + { + "module": "link2pay" + }, + { + "module": "localization" + }, + { + "module": "logrotate" + }, + { + "module": "mailu" + }, + { + "module": "mesh-build-go" + }, + { + "module": "mesh-build-node" + }, + { + "module": "mesh-catalog" + }, + { + "module": "mesh-cli" + }, + { + "module": "mesh-config" + }, + { + "module": "mesh-controller" + }, + { + "module": "mesh-delivery-checker" + }, + { + "module": "mesh-delivery-forge-bridge" + }, + { + "module": "mesh-delivery-gate" + }, + { + "module": "mesh-delivery-indexer" + }, + { + "module": "mesh-delivery-teller" + }, + { + "module": "mesh-delivery-watchman" + }, + { + "module": "mesh-files" + }, + { + "module": "mesh-grants" + }, + { + "module": "mesh-host" + }, + { + "module": "mesh-issues" + }, + { + "module": "mesh-knowledge" + }, + { + "module": "mesh-mail" + }, + { + "module": "mesh-mail-reader" + }, + { + "module": "mesh-mail-web" + }, + { + "module": "mesh-provisioner" + }, + { + "module": "mesh-provisioner-client" + }, + { + "module": "mesh-reads" + }, + { + "module": "mesh-vault" + }, + { + "module": "mesh-wireguard" + }, + { + "module": "messenger" + }, + { + "module": "minio" + }, + { + "module": "mongodb" + }, + { + "module": "mssql" + }, + { + "module": "nats" + }, + { + "module": "netcheck" + }, + { + "module": "nextcloud" + }, + { + "module": "nftables" + }, + { + "module": "node-env" + }, + { + "module": "node-tools" + }, + { + "module": "nodejs" + }, + { + "module": "only-office" + }, + { + "module": "pacman" + }, + { + "module": "photos" + }, + { + "module": "postgres" + }, + { + "module": "power" + }, + { + "module": "powerlevel10k" + }, + { + "module": "records" + }, + { + "module": "restic" + }, + { + "module": "route-proxy" + }, + { + "module": "ssh-client" + }, + { + "module": "sshd" + }, + { + "module": "step-ca" + }, + { + "module": "sudo" + }, + { + "module": "systemd" + }, + { + "module": "systemd-networkd" + }, + { + "module": "telegram" + }, + { + "module": "time-sync" + }, + { + "module": "umami" + }, + { + "module": "website" + }, + { + "module": "zsh" + }, + { + "module": "zsh-autosuggestions" + }, + { + "module": "zsh-syntax-highlighting" + } + ], + "holds": [ + { + "claim": "git", + "scope": "mesh", + "module": "gitea", + "site": "hosting" + }, + { + "claim": "mesh-artifact-store", + "scope": "mesh", + "module": "distribution", + "site": "hosting" + }, + { + "claim": "mesh-broker", + "scope": "mesh", + "module": "nats", + "site": "hosting" + }, + { + "claim": "mesh-catalog", + "scope": "mesh", + "module": "mesh-catalog", + "site": "hosting" + }, + { + "claim": "mesh-config", + "scope": "mesh", + "module": "mesh-config", + "site": "hosting" + }, + { + "claim": "mesh-controller", + "scope": "mesh", + "module": "mesh-controller", + "site": "hosting" + }, + { + "claim": "mesh-delivery", + "scope": "mesh", + "module": "mesh-delivery-gate", + "site": "hosting" + }, + { + "claim": "mesh-dns-resolver", + "scope": "mesh", + "module": "dnsmasq", + "site": "hosting" + }, + { + "claim": "mesh-provisioner", + "scope": "mesh", + "module": "mesh-provisioner", + "site": "hosting" + }, + { + "claim": "mesh-store", + "scope": "mesh", + "module": "postgres", + "site": "hosting" + }, + { + "claim": "mesh-vault", + "scope": "mesh", + "module": "mesh-vault", + "site": "hosting" + }, + { + "claim": "npm-package-registry", + "scope": "mesh", + "module": "gitea", + "site": "hosting" + } + ] +} -- 2.54.0 From feb32600bbd6be4960bd6c1b714bd0c593823892 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 27 Oct 2026 20:53:49 +0100 Subject: [PATCH 2/3] Hold a list or membership only when a seat would lose its last holder (issue 1945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holding the user list on any loss would freeze it after take-over at the first release that drops a tool or moves a seat (ADR 0547 §7), with nothing to accept a deliberate loss; memberships had the same gap. The lead's decision in review round 1: one pure guard, Holdings.LastHolders, holds the list and each membership only when a seat held in force would have no holder in what is composed now while an assigned module still claims it, the 09:21 kind. A dropped tool, verb or field, or a seat handed to another node, is written. --- modules/mesh-grants/cmd/mesh-grants/tools.go | 4 +- modules/mesh-grants/grants_test.go | 114 +++++++--- .../internal/grants/memberships.go | 208 +++++++++--------- modules/mesh-grants/internal/grants/part.go | 13 +- modules/mesh-grants/internal/grants/sealed.go | 7 +- .../mesh-grants/internal/grants/userlist.go | 3 +- 6 files changed, 208 insertions(+), 141 deletions(-) diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index bb351b4..afa8482 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -85,8 +85,8 @@ func toolsOf(p *grants.Part) []stdio.Tool { "the part's process (which alone holds the grant to publish a membership) to compose the memberships, the user list and " + "the assertions, publish each membership that differs from the one last published, and keep what changed in its own " + "state (one revision when an input changed, none when not), saying user-list-changed and membership-changed. Answers " + - "the ask and the last pass; grants_user_list shows the pass that answers it. A membership is published only when it is " + - "the first or only adds to the last one; any loss stays a shadow difference, named. The user list, assertions and " + + "the ask and the last pass; grants_user_list shows the pass that answers it. A membership is published unless it would " + + "leave a seat a placed module claims with no holder, which stays a shadow difference, named. The user list, assertions and " + "grants stay in shadow. It cannot ask the controller to write a module's grant again: the controller serves no such " + "verb, and the answer says so.", Run: func(a map[string]any) (any, error) { return p.AskCompose(stdio.CallerOf(a)) }}, diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index ff324b5..726ac69 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -52,6 +52,8 @@ type fakeBus struct { // creates are the streams the part asked to create, refused when refuseCreate. creates []string refuseCreate bool + // refuseUsers refuses the bus module's nats_users: the loaded list cannot be read. + refuseUsers bool // rootFree answers the controller's root-free verb: nil is no answerer at all (no responders). rootFree func(machines []string) (any, error) } @@ -155,6 +157,9 @@ func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) { args, _ := body.(map[string]any) switch key { case grants.CallUsers: + if b.refuseUsers { + return nil, errors.New("nats: no responders available for request") + } part, _ := args["user"].(string) if part == "" { var names []map[string]any @@ -248,6 +253,17 @@ const ( yManifest = `{"module":"mod-y","version":"1","own-secrets":{"broker":"/var/lib/y/broker"},"reads":["mod-x.kept"]}` ) +// brokerSeat is one verb of the bus's seat as a membership serves it: nats claims the seat in fixture and no membership +// composed there serves it, so a membership or list that drops it leaves the seat with no holder (issue 1945). +func brokerSeat() (seat, verb string) { + s, _ := catalogue.SeatNamed(catalogue.BrokerSeat) + verb = "v" + if len(s.Serves) > 0 { + verb = s.Serves[0].Name + } + return s.Name, verb +} + // fixture is a mesh of two machines: a runs the bus and mod-x, b runs mod-y; the bus node's agent loaded a list. func fixture() *fakeBus { b := newFakeBus() @@ -843,25 +859,32 @@ func TestGrants(t *testing.T) { t.Errorf("%s: equal %v, excused %v", row.name, c.Equal(), c.Excused) } } - // The user list's loss guard (issue 1945): the membership's Losses, per user of the list in force. - u := func(user string, responses bool, sub ...string) grants.User { - return grants.User{User: user, Subscribe: sub, Publish: []string{}, AllowResponses: responses} + // The guard of the user list and the memberships (issue 1945, the lead's decision in review round 1 of its fix): a + // seat the list in force holds left with no holder while a placed module still claims it, nothing else. + sm, _ := catalogue.SeatNamed(catalogue.ServiceManagerSeat) + nodeSeat := func(node string) string { + return "mesh.seat." + sm.Name + ".tool." + sm.Serves[0].Name + "." + node } + h := grants.HoldingsOf(grants.Inputs{Assigned: map[string][]string{"novox": {"postgres", "systemd"}, "ace": {"postgres", "systemd"}}, + Manifests: map[string]catalogue.Manifest{ + "postgres": {Module: "postgres", Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}}, + "systemd": {Module: "systemd", Claims: []catalogue.Claim{{Name: sm.Name, Scope: catalogue.ScopeNode}}}}}) + u := func(user string, sub ...string) grants.User { return grants.User{User: user, Subscribe: sub} } for _, row := range []struct { name string inForce, now []grants.User - gone []string - lost int + lost []string }{ - {"a seat grant dropped", []grants.User{u("a.node-tools", false, "x", store)}, []grants.User{u("a.node-tools", false, "x")}, nil, 1}, - {"only added", []grants.User{u("a.node-tools", false, "x")}, []grants.User{u("a.node-tools", true, "x", store)}, nil, 0}, - {"a retired verb dropped", []grants.User{u("a.node-tools", false, "x", retired)}, []grants.User{u("a.node-tools", false, "x")}, nil, 0}, - {"responses taken away", []grants.User{u("a.node-tools", true, "x")}, []grants.User{u("a.node-tools", false, "x")}, nil, 1}, - {"a user dropped", []grants.User{u("a.node-tools", false), u("a.mod-x", false)}, []grants.User{u("a.node-tools", false)}, nil, 1}, - {"a user gone from placement", []grants.User{u("a.node-tools", false), u("a.mod-x", false)}, []grants.User{u("a.node-tools", false)}, []string{"a.mod-x"}, 0}, + {"09:21: the seat's holder lost, its claimant still placed", []grants.User{u("novox.node-tools", "x", store)}, []grants.User{u("novox.node-tools", "x")}, []string{"mesh-store"}}, + {"a dropped tool", []grants.User{u("novox.node-tools", "mesh.mod.postgres.tool.gone", store)}, []grants.User{u("novox.node-tools", store)}, nil}, + {"a seat moved from novox to ace", []grants.User{u("novox.node-tools", store)}, []grants.User{u("novox.node-tools"), u("ace.node-tools", store)}, nil}, + {"a seat no placed module claims any more", []grants.User{u("novox.node-tools", "mesh.seat.unclaimed.tool.v")}, []grants.User{u("novox.node-tools")}, nil}, + {"a retired verb dropped", []grants.User{u("novox.node-tools", retired)}, []grants.User{u("novox.node-tools")}, nil}, + {"a node seat lost on novox, held on ace", []grants.User{u("novox.node-tools", nodeSeat("novox")), u("ace.node-tools", nodeSeat("ace"))}, + []grants.User{u("novox.node-tools"), u("ace.node-tools", nodeSeat("ace"))}, []string{sm.Name + "@novox"}}, } { - if lost := grants.UserLosses(row.inForce, row.now, row.gone); len(lost) != row.lost { - t.Errorf("%s: lost %v, want %d", row.name, lost, row.lost) + if lost := h.LastHolders(h.ByUsers(row.inForce), h.ByUsers(row.now)); !slices.Equal(lost, row.lost) { + t.Errorf("%s: lost %v, want %v", row.name, lost, row.lost) } } }) @@ -928,11 +951,12 @@ func TestGrants(t *testing.T) { if third := p.Compose(); third.Written || third.MembershipsWritten != 0 || len(third.Published) != 0 || len(b.publishes) != 3 { 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 would lose the module's - // plain subject (mod-x now runs on two machines), a loss, so it stays a shadow difference. + // 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 + // subject (mod-x now runs on two machines), which leaves no seat without its holder, so it is published too (issue + // 1945, the lead's decision in review round 1 of its fix). b.assign("b", "mod-x") moved := p.Compose() - if !moved.Written || moved.Revision != 2 || !slices.Equal(moved.Published, []string{"b.mod-x"}) || len(moved.NotPublished) != 1 || !strings.HasPrefix(moved.NotPublished[0], "a.mod-x: "+grants.ShadowDifference) || moved.UserList.Compare.Equal() || + if !moved.Written || moved.Revision != 2 || !slices.Equal(moved.Published, []string{"a.mod-x", "b.mod-x"}) || len(moved.NotPublished) != 0 || moved.UserList.Compare.Equal() || !slices.Equal(moved.UserList.Compare.Added, []string{"b.mod-x"}) { t.Errorf("a composition after placement moved = %+v", moved) } @@ -1145,13 +1169,14 @@ func TestGrants(t *testing.T) { if len(fresh) == 0 || fresh[0].Key != grants.HeldCondition || fresh[0].State != asks.StateCleared { t.Errorf("a fresh part's first pass does not say the held warning is cleared: %+v", fresh) } - // Issue 1945, 2026-10-27 09:21: a list that would lose a seat grant of the list in force is held, as a - // membership that would lose one is: not written, the warning raised naming what it would lose; and a - // list whose loaded grants cannot be read is held too. + // Issue 1945, 2026-10-27 09:21: a list that would leave a seat the list in force holds with no holder, + // while a placed module still claims it, is held, as such a membership is: not written, the warning + // raised naming the seat. A list that only adds is written; one whose loaded grants cannot be read is held. inForce := slices.Clone(b.loaded) - const seatGrant = "mesh.seat.anthropic-licence-manager.call.licences.*" + seat, verb := brokerSeat() + seatGrant := "mesh.seat." + seat + ".call." + verb + ".*" for i := range b.loaded { - if b.loaded[i].User == "a.mod-x" { + if b.loaded[i].User == "a.nats" { b.loaded[i].Subscribe = append(slices.Clone(b.loaded[i].Subscribe), seatGrant) } } @@ -1159,19 +1184,35 @@ func TestGrants(t *testing.T) { b.put(grants.ReadAccounts, "a.mod-y", map[string]any{"user": "a.mod-y", "hash": "$2a$10$x", "generation": 1}) said = nil res := p.Compose() - if res.Sealed == nil || !strings.Contains(res.Sealed.Why, "publishing would lose a.mod-x: subscribe") || !strings.Contains(res.Sealed.Why, seatGrant) { + if res.Sealed == nil || !strings.Contains(res.Sealed.Why, "publishing would lose the last holder of "+seat) { t.Errorf("a list losing a seat grant = %+v", res.Sealed) } if now, _, _ := p.Sealed(); now.Digest != g.Digest { t.Errorf("a list losing a seat grant was written: %s, was %s", now.Digest, g.Digest) } - if len(said) != 1 || said[0].Key != grants.HeldCondition || said[0].State != asks.StateRaised || !strings.Contains(said[0].Summary, seatGrant) { + if len(said) != 1 || said[0].Key != grants.HeldCondition || said[0].State != asks.StateRaised || !strings.Contains(said[0].Summary, "the last holder of "+seat) { t.Errorf("the held list did not raise the warning naming the lost grant: %+v", said) } b.loaded = inForce if p.Compose(); func() bool { now, _, _ := p.Sealed(); return now.Digest == g.Digest }() { t.Errorf("a list that only adds a.mod-y was not written") } + written, _, _ := p.Sealed() + b.mu.Lock() + b.refuseUsers = true + b.mu.Unlock() + b.mu.Lock() + delete(b.states[grants.ReadAssignments], "a.mod-y") + b.mu.Unlock() + if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "the grants of the list in force cannot be read") { + t.Errorf("a pass whose loaded list cannot be read = %+v", res.Sealed) + } + if now, _, _ := p.Sealed(); now.Digest != written.Digest { + t.Errorf("a list was written while the loaded list could not be read") + } + b.mu.Lock() + b.refuseUsers = false + b.mu.Unlock() } }) } @@ -1273,8 +1314,9 @@ func TestGrants(t *testing.T) { }) }) - t.Run("memberships: a first or an additive one published, a loss never", func(t *testing.T) { + t.Run("memberships: published unless a seat would lose its last holder", func(t *testing.T) { const key = "a.mod-x" + brokerName, brokerVerb := brokerSeat() composed := func(b *fakeBus) map[string]any { recs, _ := grants.Records(grants.ReadInputs(b, &grants.Manifests{})) raw, _ := json.Marshal(grants.Memberships(recs)[key].Membership) @@ -1294,25 +1336,28 @@ func TestGrants(t *testing.T) { m["mesh"] = []any{"10.0.0.1"} return m }, true, ""}, - {"a change that drops verified-sender is not published, and named", func(m map[string]any) map[string]any { - m["seat-traffic"] = map[string]any{"kinds": []any{map[string]any{"seat": "channel", "kind": "telegram", "capabilities": []any{"verified-sender"}}}} - return m - }, false, "verified-sender"}, - {"a change that drops any other field is not published", func(m map[string]any) map[string]any { + // The lead's decision in review round 1 of the fix of issue 1945: only a seat left with no holder holds a + // membership back; a dropped tool, field or capability is published. + {"a change that drops a tool is published", func(m map[string]any) map[string]any { m["serves"] = append(m["serves"].([]any), map[string]any{"subject": "mesh.mod.mod-x.tool.gone"}) + m["mesh"] = []any{"10.0.0.1"} return m - }, false, "tool.gone"}, + }, true, ""}, + {"a change that takes the last holder from a seat a placed module claims is not published, the seat named", func(m map[string]any) map[string]any { + m["seats"] = []any{map[string]any{"seat": brokerName, "verb": brokerVerb, "subject": "mesh.seat." + brokerName + ".tool." + brokerVerb}} + return m + }, false, "the last holder of " + brokerName}, // A verb the seat catalogue retired on purpose (mark-ready, mesh-sdk d506f21, ADR 0421) is dropped and named; a // verb of a seat the catalogue does not define is still a loss (issue 1945). {"a change that drops only a retired seat verb is published, the verb named", func(m map[string]any) map[string]any { m["seats"] = []any{map[string]any{"seat": "mesh-delivery", "verb": "mark-ready", "subject": "mesh.seat.mesh-delivery.tool.mark-ready"}} return m }, true, "mesh-delivery mark-ready"}, - {"a retired verb and a module seat's verb dropped: not published, the module seat's named", func(m map[string]any) map[string]any { + {"a seat no placed module claims dropped is published", func(m map[string]any) map[string]any { m["seats"] = []any{map[string]any{"seat": "mesh-delivery", "verb": "mark-ready", "subject": "mesh.seat.mesh-delivery.tool.mark-ready"}, map[string]any{"seat": "telegram-sender", "verb": "send", "subject": "mesh.seat.telegram-sender.tool.send"}} return m - }, false, "telegram-sender"}, + }, true, "mesh-delivery mark-ready"}, } { t.Run(row.name, func(t *testing.T) { b := fixture() @@ -1482,7 +1527,8 @@ func TestGrants(t *testing.T) { raw, _ := json.Marshal(grants.Memberships(recs)[key].Membership) var m map[string]any _ = json.Unmarshal(raw, &m) - m["serves"] = append(m["serves"].([]any), map[string]any{"subject": "mesh.mod.lost.tool.gone"}) + seat, verb := brokerSeat() + m["seats"] = []any{map[string]any{"seat": seat, "verb": verb, "subject": "mesh.seat." + seat + ".tool." + verb}} raw, _ = json.Marshal(m) subject := "mesh.assignment." + key b.stream[subject], b.seqs[subject] = raw, uint64(i+1) @@ -1508,7 +1554,7 @@ func TestGrants(t *testing.T) { <-done held, err := partOn(b).MembershipOf("", "") h, _ := held.(map[string]any) - if err != nil || h["count"] != 2 || !strings.Contains(fmt.Sprint(h["held"]), "tool.gone") { + if seat, _ := brokerSeat(); err != nil || h["count"] != 2 || !strings.Contains(fmt.Sprint(h["held"]), "the last holder of "+seat) { t.Errorf("grants_membership with neither node nor module = %v, %v", held, err) } if _, err := partOn(b).MembershipOf("a", ""); err == nil { diff --git a/modules/mesh-grants/internal/grants/memberships.go b/modules/mesh-grants/internal/grants/memberships.go index e14952a..fbbcb24 100644 --- a/modules/mesh-grants/internal/grants/memberships.go +++ b/modules/mesh-grants/internal/grants/memberships.go @@ -74,8 +74,9 @@ const ( ) // publish publishes m when it differs from the membership last published for its assignment, carrying that one's private -// network and received contributions; it answers m as published (or why not) and whether it was published now. -func publish(p Publisher, m Membership) (Membership, bool) { +// network and received contributions; it answers m as published (or why not) and whether it was published now. held is +// every holding the memberships composed now carry (Holdings.ByMemberships). +func publish(p Publisher, m Membership, h Holdings, held map[string]bool) (Membership, bool) { if p == nil { m.Published = "not published: no publisher here (only the part's process publishes)" return m, false @@ -101,15 +102,19 @@ func publish(p Publisher, m Membership) (Membership, bool) { m.Published = MembershipUnchanged return m, false } - // Only a first membership, or one that only adds to the last, is published (the lead's decision, review of 1ff5049): - // composed with no root-free judgement, a kind's verified-sender would be dropped, and the operator's approvals by - // a channel would no longer be honoured. Any loss stays a shadow difference, named. + // A membership is held back only when it would take the last holder from a seat some placed module still claims (the + // 09:21 kind of issue 1945; the lead's decision in review round 1 of its fix, replacing the hold on any loss of review + // 1ff5049): a dropped tool or verb, or a seat handed to another node, is published. if found { kept, retired := WithoutRetired(was) m.Retired = retired - if lost := Losses(kept, body); len(lost) > 0 { - m.Published = ShadowDifference + strings.Join(lost, "; ") - return m, false + var last membership.Membership + if json.Unmarshal(kept, &last) == nil { + last.Node = m.Node + if lost := h.LastHolders(h.ByMemberships(last), held); len(lost) > 0 { + m.Published = ShadowDifference + "the last holder of " + strings.Join(lost, ", ") + return m, false + } } } if err := p.PublishMembership(m.Node, m.Module, body, seq); err != nil { @@ -184,108 +189,115 @@ func sameMembership(was, now []byte) bool { return reflect.DeepEqual(a, b) } -// UserLosses is the same guard as a membership's (Losses) over the user list (issue 1945): everything a user of the list -// in force holds that the list composed now drops, `: `. A user the composed list lacks is lost whole unless -// gone (placement no longer names it); a subscription to a verb the seat catalogue retired is no loss, as in a -// membership (WithoutRetired); and a user allowed responses now where it was not is a gain, not a change. Empty means -// the composed list holds all the list in force did, and maybe more. Pure. -func UserLosses(inForce, composed []User, gone []string) []string { - now := map[string]User{} - for _, u := range composed { - now[u.User] = u - } - var out []string - for _, l := range inForce { - c, ok := now[l.User] - if !ok { - if !slices.Contains(gone, l.User) { - out = append(out, l.User+": the user, with every grant it holds") +// ShadowDifference opens what a membership's or the user list's pass says when it was not published because it would +// leave a seat with no holder (LastHolders). +const ShadowDifference = "not published, a shadow difference: publishing would lose " + +// Holdings is how the part tells whether a list or a membership would leave a seat with no holder (issue 1945): every +// holding some assigned module claims now, and each seat's scope. A holding is named by its seat for a seat held once +// for the mesh or a site, and `@` for a seat of every node, so a seat handed to another node keeps its +// holding and one dropped on a node does not. +type Holdings struct { + Claimed map[string]bool + Scopes map[string]string +} + +// HoldingsOf is the holdings the assigned modules claim now, by their running manifest or the trunk's, each scope as the +// seat's declaration says (the seat catalogue, or the manifest defining it), else the claim's. Pure. +func HoldingsOf(in Inputs) Holdings { + h := Holdings{Claimed: map[string]bool{}, Scopes: map[string]string{}} + for _, m := range in.Manifests { + for _, d := range m.DefinesSeats { + if d.Scope != "" { + h.Scopes[canonicalSeat(d.Name)] = d.Scope } - continue } - l.Subscribe = slices.DeleteFunc(slices.Clone(l.Subscribe), func(s string) bool { - m := seatSubject.FindStringSubmatch(s) - return m != nil && retiredVerb(m[1], m[2]) - }) - if c.AllowResponses { - l.AllowResponses = true + } + for node, modules := range in.Assigned { + for _, module := range modules { + m, ok := in.Running[node+"."+module] + if !ok { + m, ok = in.Manifests[module] + } + if !ok { + continue + } + for _, c := range m.Claims { + seat := canonicalSeat(c.Name) + if _, known := h.scope(seat); !known { + h.Scopes[seat] = c.At() + } + h.Claimed[h.key(seat, node)] = true + } } - was, _ := json.Marshal(l) - is, _ := json.Marshal(c) - for _, lost := range Losses(was, is) { - out = append(out, l.User+": "+lost) + } + return h +} + +func canonicalSeat(name string) string { + if s, ok := catalogue.SeatNamed(name); ok { + return s.Name + } + return name +} + +func (h Holdings) scope(seat string) (string, bool) { + if s, ok := catalogue.SeatNamed(seat); ok && s.Scope != "" { + return s.Scope, true + } + sc, ok := h.Scopes[seat] + return sc, ok +} + +func (h Holdings) key(seat, node string) string { + seat = canonicalSeat(seat) + if sc, _ := h.scope(seat); sc == catalogue.ScopeNode { + return seat + "@" + node + } + return seat +} + +// ByUsers is every holding a user list carries: a user subscribed to a seat's verb (seatSubject) holds it on its +// machine; a verb the seat catalogue retired holds nothing. Pure. +func (h Holdings) ByUsers(users []User) map[string]bool { + out := map[string]bool{} + for _, u := range users { + node, _, _ := strings.Cut(u.User, ".") + if node == "node" || node == "provisioner" { + _, node, _ = strings.Cut(u.User, ".") + } + for _, s := range u.Subscribe { + if m := seatSubject.FindStringSubmatch(s); m != nil && !retiredVerb(m[1], m[2]) { + out[h.key(m[1], node)] = true + } } } return out } -// ShadowDifference opens what a membership's pass says when it was not published because it would lose something. -const ShadowDifference = "not published, a shadow difference: publishing would lose " - -// Losses names everything the last membership holds that now drops or weakens: a field gone, a value changed, a list -// element (a capability, a kind, a served address, a seat, a state, a stream) with no element in now that holds all of it. -// Empty means now holds all the last one did, and maybe more. Pure. -func Losses(last, now []byte) []string { - var a, b any - if json.Unmarshal(last, &a) != nil { - return nil - } - if json.Unmarshal(now, &b) != nil { - return []string{"the new membership is not JSON"} - } - return losses("", a, b) -} - -func losses(path string, last, now any) []string { - switch l := last.(type) { - case map[string]any: - n, ok := now.(map[string]any) - if !ok { - return []string{fmt.Sprintf("%s: %s", name(path), brief(last))} - } - keys := make([]string, 0, len(l)) - for k := range l { - keys = append(keys, k) - } - sort.Strings(keys) - var out []string - for _, k := range keys { - nv, ok := n[k] - if !ok { - out = append(out, fmt.Sprintf("%s: %s", name(path+"."+k), brief(l[k]))) - continue - } - out = append(out, losses(path+"."+k, l[k], nv)...) - } - return out - case []any: - n, _ := now.([]any) - var out []string - for i, e := range l { - if !slices.ContainsFunc(n, func(c any) bool { return len(losses("", e, c)) == 0 }) { - out = append(out, fmt.Sprintf("%s[%d]: %s", name(path), i, brief(e))) +// ByMemberships is every holding memberships carry: a seat verb served holds the seat on the membership's node. Pure. +func (h Holdings) ByMemberships(ms ...membership.Membership) map[string]bool { + out := map[string]bool{} + for _, m := range ms { + for _, s := range m.Seats { + if !retiredVerb(s.Seat, s.Verb) { + out[h.key(s.Seat, m.Node)] = true } } - return out } - if !reflect.DeepEqual(last, now) { - return []string{fmt.Sprintf("%s: %s, now %s", name(path), brief(last), brief(now))} - } - return nil + return out } -func name(path string) string { - if path == "" { - return "the membership" +// LastHolders is every holding before carries that now does not while an assigned module still claims it: the seats a +// list or a membership would leave with no holder, sorted. Empty means none would; a seat no longer claimed, a verb +// dropped, or a seat held elsewhere now, is no loss here. Pure. +func (h Holdings) LastHolders(before, now map[string]bool) []string { + var out []string + for k := range before { + if !now[k] && h.Claimed[k] { + out = append(out, k) + } } - return strings.TrimPrefix(path, ".") -} - -// brief is a value in JSON, cut at 200 bytes. -func brief(v any) string { - raw, _ := json.Marshal(v) - if len(raw) > 200 { - return string(raw[:200]) + "…" - } - return string(raw) + sort.Strings(out) + return out } diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index b70f859..371f873 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -14,6 +14,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/membership" ) // The part's own states, as its manifest declares them, and the keys of the two that hold one value. @@ -109,7 +110,7 @@ type Result struct { } // NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships. -const NothingTakesEffect = "memberships (on the assignments stream, a first one or one that only adds to the last published or drops only a verb the seat catalogue retired; any other loss stays a shadow difference), and each bucket, log or stream a manifest declares that the bus lacks, created once with the manifest's limits (the operator, 2026-10-26 08:54); the user list, the grants and any change to a stream the bus holds stay in shadow until take-over" +const NothingTakesEffect = "memberships (on the assignments stream, unless one would leave a seat a placed module claims with no holder, which stays a shadow difference), and each bucket, log or stream a manifest declares that the bus lacks, created once with the manifest's limits (the operator, 2026-10-26 08:54); the user list, the grants and any change to a stream the bus holds stay in shadow until take-over" // Part is the grants part: it reads, calls and writes through these, and nothing else. type Part struct { @@ -195,6 +196,7 @@ func (p *Part) Compose() Result { res.Refused = append(res.Refused, g.Input+": "+g.Why) } recs, left := Records(in) + holdings := HoldingsOf(in) res.RootNotFree = WithRootFree(&recs, p.Call) st := UserListState{V: 1, Mode: p.SwitchState(), LeftOut: left, Loaded: in.Loaded, BusNode: in.BusNode, Gaps: append(append([]Gap(nil), in.Gaps...), MembershipGaps...), At: p.now()} @@ -239,14 +241,19 @@ func (p *Part) Compose() Result { var bus, lost []string if st.Compare.Unread == "" { bus = names(loaded) - lost = UserLosses(loaded, users, st.Compare.Gone) + lost = holdings.LastHolders(holdings.ByUsers(loaded), holdings.ByUsers(users)) } p.writeSealed(&res, list, n, leftOut, in, bus, lost) } ms := Memberships(recs) res.Memberships = len(ms) + composed := make([]membership.Membership, 0, len(ms)) + for _, m := range ms { + composed = append(composed, m.Membership) + } + seated := holdings.ByMemberships(composed...) for _, key := range sortedKeys(ms) { - m, now := publish(p.Publish, ms[key]) + m, now := publish(p.Publish, ms[key], holdings, seated) switch { case now: res.Published = append(res.Published, key) diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index 5cd2237..c8d8eb8 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -298,8 +298,9 @@ func listUsers(list []byte) []string { } // holdsBack says why a list composed now may not replace the one in force after take-over: a read failed this pass, a -// user of the list in force is left out for want of a hash, or the list would lose a grant of the list in force (lost, -// UserLosses: the membership's guard, issue 1945; nil when the loaded list could not be read, which holds it too). The list in force is the one the part wrote, by the users +// user of the list in force is left out for want of a hash, or the list would leave a seat the list in force holds with +// no holder while a placed module still claims it (lost, Holdings.LastHolders: the membership's guard, issue 1945; the +// loaded list not read holds it too, since that cannot be ruled out). The list in force is the one the part wrote, by the users // it kept of it; where it kept none (a list written before this release), the users the bus loaded (bus, nil when // they could not be read). Empty when it may. func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string) string { @@ -310,7 +311,7 @@ func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string) string { return "held: the grants of the list in force cannot be read, so a loss of seats cannot be ruled out" } if len(lost) > 0 { - return "held, " + ShadowDifference + strings.Join(lost, "; ") + return "held, " + ShadowDifference + "the last holder of " + strings.Join(lost, ", ") } raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenUsers) if err != nil { diff --git a/modules/mesh-grants/internal/grants/userlist.go b/modules/mesh-grants/internal/grants/userlist.go index edb44fe..bffade3 100644 --- a/modules/mesh-grants/internal/grants/userlist.go +++ b/modules/mesh-grants/internal/grants/userlist.go @@ -368,7 +368,8 @@ func (c Comparison) Excusing(f func(user, subject string, added, subscribe bool) return c } -var seatSubject = regexp.MustCompile(`^mesh\.seat\.([^.]+)\.(?:call|tool)\.([^.]+)(?:\.\*)?$`) +// seatSubject is a seat verb's subject, for any caller (`.*`) or one machine's (`.`, a seat of every node). +var seatSubject = regexp.MustCompile(`^mesh\.seat\.([^.]+)\.(?:call|tool)\.([^.]+)(?:\.[^.]+)?$`) var seatService = regexp.MustCompile(`^\$SRV\.(?:INFO|PING|STATS)\.([^.]+)(?:\.>)?$`) // ExcusedBy is the excuse of a comparison until placement lives in the catalogue (the operator, 2026-10-26): a verb the -- 2.54.0 From ec0b7e2dcc067a7d9c29740d0d78b3ed13672cf7 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 27 Oct 2026 20:56:09 +0100 Subject: [PATCH 3/3] Keep holding a membership that would drop a capability its holder still asks for (issue 1945) A dropped verified-sender comes from a root-free word that failed for one pass, not from a release, and losing it cut the operator's approvals on 2026-10-27. Holdings.CapabilitiesLost holds such a membership with its own reason while the holder is assigned and its manifest still asks for it; a dropped tool or field and a moved seat stay published. --- modules/mesh-grants/cmd/mesh-grants/tools.go | 3 +- modules/mesh-grants/grants_test.go | 33 ++++++++---- .../internal/grants/memberships.go | 51 ++++++++++++++++++- modules/mesh-grants/internal/grants/part.go | 2 +- 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index afa8482..822fb2b 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -86,7 +86,8 @@ func toolsOf(p *grants.Part) []stdio.Tool { "the assertions, publish each membership that differs from the one last published, and keep what changed in its own " + "state (one revision when an input changed, none when not), saying user-list-changed and membership-changed. Answers " + "the ask and the last pass; grants_user_list shows the pass that answers it. A membership is published unless it would " + - "leave a seat a placed module claims with no holder, which stays a shadow difference, named. The user list, assertions and " + + "leave a seat a placed module claims with no holder, or drop a capability its holder still asks for (verified-sender), " + + "which stays a shadow difference, named. The user list, assertions and " + "grants stay in shadow. It cannot ask the controller to write a module's grant again: the controller serves no such " + "verb, and the answer says so.", Run: func(a map[string]any) (any, error) { return p.AskCompose(stdio.CallerOf(a)) }}, diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 726ac69..f039e24 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -1314,9 +1314,16 @@ func TestGrants(t *testing.T) { }) }) - t.Run("memberships: published unless a seat would lose its last holder", func(t *testing.T) { + t.Run("memberships: published unless a seat would lose its last holder or a holder a capability it asks for", func(t *testing.T) { const key = "a.mod-x" brokerName, brokerVerb := brokerSeat() + // The telegram module's claim of its channel, as mesh-catalog's telegram manifest has it (c05aa5e6), on mod-x. + const telegramClaim = `[{"name":"channel","scope":"mesh","kind":"telegram","capabilities":["deliver","verified-sender"]}]` + verified := func(m map[string]any) map[string]any { + m["seat-traffic"] = map[string]any{"kinds": []any{map[string]any{"seat": "channel", "kind": "telegram", "module": "mod-x", "node": "a", + "capabilities": []any{"deliver", "verified-sender"}}}} + return m + } composed := func(b *fakeBus) map[string]any { recs, _ := grants.Records(grants.ReadInputs(b, &grants.Manifests{})) raw, _ := json.Marshal(grants.Memberships(recs)[key].Membership) @@ -1329,38 +1336,46 @@ func TestGrants(t *testing.T) { last func(map[string]any) map[string]any published bool names string + // claims, when set, are mod-x's claims in its manifest for the row. + claims string }{ - {"a first membership is published", nil, true, ""}, + {"a first membership is published", nil, true, "", ""}, {"an additive change is published, the private network carried", func(m map[string]any) map[string]any { delete(m, "state") m["mesh"] = []any{"10.0.0.1"} return m - }, true, ""}, - // The lead's decision in review round 1 of the fix of issue 1945: only a seat left with no holder holds a - // membership back; a dropped tool, field or capability is published. + }, true, "", ""}, + // The lead's decision in review round 1 of the fix of issue 1945: only a seat left with no holder, or a + // capability its holder still asks for (verified-sender, dropped by a root-free word that failed), holds a + // membership back; a dropped tool or field is published. {"a change that drops a tool is published", func(m map[string]any) map[string]any { m["serves"] = append(m["serves"].([]any), map[string]any{"subject": "mesh.mod.mod-x.tool.gone"}) m["mesh"] = []any{"10.0.0.1"} return m - }, true, ""}, + }, true, "", ""}, + {"a change that drops verified-sender its holder still asks for is not published, and named", verified, false, "channel telegram verified-sender of a/mod-x", telegramClaim}, + {"a change that drops verified-sender its holder no longer asks for is published", verified, true, `"published":"published"`, `[{"name":"channel","scope":"mesh","kind":"telegram"}]`}, {"a change that takes the last holder from a seat a placed module claims is not published, the seat named", func(m map[string]any) map[string]any { m["seats"] = []any{map[string]any{"seat": brokerName, "verb": brokerVerb, "subject": "mesh.seat." + brokerName + ".tool." + brokerVerb}} return m - }, false, "the last holder of " + brokerName}, + }, false, "the last holder of " + brokerName, ""}, // A verb the seat catalogue retired on purpose (mark-ready, mesh-sdk d506f21, ADR 0421) is dropped and named; a // verb of a seat the catalogue does not define is still a loss (issue 1945). {"a change that drops only a retired seat verb is published, the verb named", func(m map[string]any) map[string]any { m["seats"] = []any{map[string]any{"seat": "mesh-delivery", "verb": "mark-ready", "subject": "mesh.seat.mesh-delivery.tool.mark-ready"}} return m - }, true, "mesh-delivery mark-ready"}, + }, true, "mesh-delivery mark-ready", ""}, {"a seat no placed module claims dropped is published", func(m map[string]any) map[string]any { m["seats"] = []any{map[string]any{"seat": "mesh-delivery", "verb": "mark-ready", "subject": "mesh.seat.mesh-delivery.tool.mark-ready"}, map[string]any{"seat": "telegram-sender", "verb": "send", "subject": "mesh.seat.telegram-sender.tool.send"}} return m - }, true, "mesh-delivery mark-ready"}, + }, true, "mesh-delivery mark-ready", ""}, } { t.Run(row.name, func(t *testing.T) { b := fixture() + if row.claims != "" { + b.manifestOf("mod-x", strings.Replace(xManifest, `"own-secrets"`, `"claims":`+row.claims+`,"own-secrets"`, 1)) + } b.loaded = shadowOf(t, b) if row.last != nil { raw, _ := json.Marshal(row.last(composed(b))) diff --git a/modules/mesh-grants/internal/grants/memberships.go b/modules/mesh-grants/internal/grants/memberships.go index fbbcb24..af81735 100644 --- a/modules/mesh-grants/internal/grants/memberships.go +++ b/modules/mesh-grants/internal/grants/memberships.go @@ -104,7 +104,8 @@ func publish(p Publisher, m Membership, h Holdings, held map[string]bool) (Membe } // A membership is held back only when it would take the last holder from a seat some placed module still claims (the // 09:21 kind of issue 1945; the lead's decision in review round 1 of its fix, replacing the hold on any loss of review - // 1ff5049): a dropped tool or verb, or a seat handed to another node, is published. + // 1ff5049), or drop a capability a kind's holder still asks for (verified-sender, dropped by a root-free word that + // failed): a dropped tool or verb, or a seat handed to another node, is published. if found { kept, retired := WithoutRetired(was) m.Retired = retired @@ -115,6 +116,10 @@ func publish(p Publisher, m Membership, h Holdings, held map[string]bool) (Membe m.Published = ShadowDifference + "the last holder of " + strings.Join(lost, ", ") return m, false } + if lost := h.CapabilitiesLost(last, m.Membership); len(lost) > 0 { + m.Published = CapabilityDifference + strings.Join(lost, "; ") + return m, false + } } } if err := p.PublishMembership(m.Node, m.Module, body, seq); err != nil { @@ -200,12 +205,14 @@ const ShadowDifference = "not published, a shadow difference: publishing would l type Holdings struct { Claimed map[string]bool Scopes map[string]string + // Asked is every capability an assigned module's claim of a kind asks for, `////`. + Asked map[string]bool } // HoldingsOf is the holdings the assigned modules claim now, by their running manifest or the trunk's, each scope as the // seat's declaration says (the seat catalogue, or the manifest defining it), else the claim's. Pure. func HoldingsOf(in Inputs) Holdings { - h := Holdings{Claimed: map[string]bool{}, Scopes: map[string]string{}} + h := Holdings{Claimed: map[string]bool{}, Scopes: map[string]string{}, Asked: map[string]bool{}} for _, m := range in.Manifests { for _, d := range m.DefinesSeats { if d.Scope != "" { @@ -228,6 +235,9 @@ func HoldingsOf(in Inputs) Holdings { h.Scopes[seat] = c.At() } h.Claimed[h.key(seat, node)] = true + for _, capability := range c.Capabilities { + h.Asked[askedKey(node, module, seat, c.Kind, capability)] = true + } } } } @@ -301,3 +311,40 @@ func (h Holdings) LastHolders(before, now map[string]bool) []string { sort.Strings(out) return out } + +func askedKey(node, module, seat, kind, capability string) string { + return node + "/" + module + "/" + canonicalSeat(seat) + "/" + kind + "/" + capability +} + +// CapabilitiesLost is every capability a kind's holder carries in the membership in force (its seat traffic's kinds) that +// the composed one lacks while that holder is still assigned and its manifest's claim still asks for it, ` +// of /`, sorted. Such a drop comes from a word that failed for one pass (the controller's +// root-free verb drops verified-sender), not from a release, and losing verified-sender stops the operator's approvals by +// a channel (issue 1945; review 1ff5049's protection, kept by the lead in review round 1 of its fix). Pure. +func (h Holdings) CapabilitiesLost(last, now membership.Membership) []string { + if last.SeatTraffic == nil { + return nil + } + has := map[string]bool{} + if now.SeatTraffic != nil { + for _, k := range now.SeatTraffic.Kinds { + for _, c := range k.Capabilities { + has[askedKey(k.Node, k.Module, k.Seat, k.Kind, c)] = true + } + } + } + var out []string + for _, k := range last.SeatTraffic.Kinds { + for _, c := range k.Capabilities { + if key := askedKey(k.Node, k.Module, k.Seat, k.Kind, c); !has[key] && h.Asked[key] { + out = append(out, k.Seat+" "+k.Kind+" "+c+" of "+k.Node+"/"+k.Module) + } + } + } + sort.Strings(out) + return slices.Compact(out) +} + +// CapabilityDifference opens what a membership's pass says when it was not published because it would drop a capability +// its holder still asks for. +const CapabilityDifference = ShadowDifference + "a capability its holder still asks for: " diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 371f873..7756720 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -110,7 +110,7 @@ type Result struct { } // NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships. -const NothingTakesEffect = "memberships (on the assignments stream, unless one would leave a seat a placed module claims with no holder, which stays a shadow difference), and each bucket, log or stream a manifest declares that the bus lacks, created once with the manifest's limits (the operator, 2026-10-26 08:54); the user list, the grants and any change to a stream the bus holds stay in shadow until take-over" +const NothingTakesEffect = "memberships (on the assignments stream, unless one would leave a seat a placed module claims with no holder, or drop a capability its holder still asks for, which stays a shadow difference), and each bucket, log or stream a manifest declares that the bus lacks, created once with the manifest's limits (the operator, 2026-10-26 08:54); the user list, the grants and any change to a stream the bus holds stay in shadow until take-over" // Part is the grants part: it reads, calls and writes through these, and nothing else. type Part struct { -- 2.54.0