diff --git a/modules/assignments-readers b/modules/assignments-readers deleted file mode 100644 index 436863a..0000000 --- a/modules/assignments-readers +++ /dev/null @@ -1,7 +0,0 @@ -# The modules of this repository that still read the controller's assignments (mesh-controller.assignments), each with -# why. mesh-grants' test (TestGrants, "assignments readers") refuses any other module.json that reads them, and an entry -# here whose module no longer does: the list only shrinks, and it is empty in the first change after the controller's -# lease is written to a holder that is no controller (novox/hq ADR 0549 §3, §6; issue 774). Each module named here -# uses the assignments only while the controller holds the lease (buscompose.LeaseHandedAway), placement alone after it. -mesh-delivery-assigner unioned with the placement until the lease write (assign.Placed with handedAway); placement alone after -mesh-grants unioned with the placement until the lease write (ReadInputs); placement alone after diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 14fa410..9ae4a82 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -319,8 +319,38 @@ func (b *fakeBus) refuse(module, raw, problem string) { Problems: []string{problem}}) } +// assign places module on node in the seat's placement, the one place the part reads it (ADR 0549 §5; issue 774). func (b *fakeBus) assign(node, module string) { - b.put(grants.ReadAssignments, node+"."+module, map[string]any{"node": node, "module": module, "assigned": "2026-10-25T20:00:00Z"}) + var pl delivery.Placement + if raw, ok := b.states[grants.ReadPlacement]["placement."+node]; ok { + pl, _ = delivery.DecodePlacement(raw) + } + pl.Node = node + for _, m := range pl.Modules { + if m.Module == module { + return + } + } + pl.Modules = append(pl.Modules, delivery.PlacedModule{Module: module}) + raw, err := delivery.EncodePlacement(pl) + if err != nil { + panic(err) + } + if b.states[grants.ReadPlacement] == nil { + b.states[grants.ReadPlacement] = map[string][]byte{} + } + b.states[grants.ReadPlacement]["placement."+node] = raw +} + +// unassign takes module off node in the seat's placement. +func (b *fakeBus) unassign(node, module string) { + pl, err := delivery.DecodePlacement(b.states[grants.ReadPlacement]["placement."+node]) + if err != nil { + return + } + pl.Modules = slices.DeleteFunc(pl.Modules, func(m delivery.PlacedModule) bool { return m.Module == module }) + raw, _ := delivery.EncodePlacement(pl) + b.states[grants.ReadPlacement]["placement."+node] = raw } const ( @@ -350,7 +380,6 @@ func fixture() *fakeBus { b.assign("a", "nats") b.assign("a", "mod-x") b.assign("b", "mod-y") - b.put(grants.ReadAssignments, "b.gone", map[string]any{"node": "b", "module": "gone", "assigned": "2026-10-01T00:00:00Z", "ended": "2026-10-02T00:00:00Z"}) b.put(grants.ReadHave, "a.node", map[string]any{"v": 1}) b.put(grants.ReadHave, "b.node", map[string]any{"v": 1}) b.put(grants.ReadHave, "a.nats", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK, @@ -512,8 +541,8 @@ func TestGrants(t *testing.T) { 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"}, - {"placement refused is a gap; the machines come from the haves", func(b *fakeBus) { b.refused[grants.ReadAssignments] = true }, - []string{"controller", "node.a", "node.b"}, grants.ReadAssignments}, + {"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}, } { t.Run(row.name, func(t *testing.T) { b := fixture() @@ -1101,8 +1130,10 @@ func TestGrants(t *testing.T) { t.Errorf("read of the keys per module: placed %v, gaps %v", got.Placed, got.Gaps) } b.refused[grants.ReadPlacement] = true - 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) + // The placement alone places (issue 774, 774/S29): not read, nothing is placed, and it is a failed read. + if read := grants.ReadInputs(b, &grants.Manifests{}); !slices.ContainsFunc(read.Gaps, func(g grants.Gap) bool { return g.Input == grants.ReadPlacement && g.Failed }) || + len(read.Assigned) != 0 { + t.Errorf("placement not on the bus is no failed read, or something is placed: %v, %v", read.Gaps, read.Assigned) } // 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. @@ -1374,7 +1405,7 @@ func TestGrants(t *testing.T) { // settled is the placement of the lists agreeing, every hash held, and the loaded list as edit leaves it. settled := func(edit func(modX *grants.User)) func() { return func() { - b.states[grants.ReadAssignments] = nil + b.states[grants.ReadPlacement] = nil b.assign("a", "nats") b.assign("a", "mod-x") b.assign("b", "mod-y") @@ -1585,7 +1616,7 @@ func TestGrants(t *testing.T) { 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.assign("a", "mod-y") 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() @@ -1607,7 +1638,7 @@ func TestGrants(t *testing.T) { b.refuseUsers = true b.mu.Unlock() b.mu.Lock() - delete(b.states[grants.ReadAssignments], "a.mod-y") + b.unassign("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) @@ -2045,11 +2076,11 @@ func TestGrants(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { f.Run(ctx); close(done) }() - waitOpened(t, w, grants.ReadAssignments) + waitOpened(t, w, grants.ReadPlacement) if r := f.Refused(); r[grants.ReadModules] == "" { waitFor(t, func() bool { return f.Refused()[grants.ReadModules] != "" }) } - w.change(grants.ReadAssignments, "a.mod-x") + w.change(grants.ReadPlacement, "placement.a") waitOpened(t, w, grants.ReadModules) waitFor(t, func() bool { return len(f.Refused()) == 0 }) select { @@ -2242,83 +2273,64 @@ func TestGrants(t *testing.T) { } }) - // The guard of ADR 0549 §6 for this repository, as mesh-catalog's checks/placement is for the catalogue: no module.json - // here reads the controller's assignments unless ../assignments-readers names it with why, and that list only shrinks. - t.Run("assignments readers: only the named modules read the controller's assignments, and the list only shrinks", func(t *testing.T) { - raw, err := os.ReadFile("../assignments-readers") - if err != nil { - t.Fatal(err) - } - allowed := map[string]bool{} - for _, line := range strings.Split(string(raw), "\n") { - if f := strings.Fields(line); len(f) > 1 && !strings.HasPrefix(f[0], "#") { - allowed[f[0]] = true - } else if len(f) == 1 { - t.Errorf("../assignments-readers names %s with no why", f[0]) - } - } + // ADR 0571 §13's test for this repository (issue 774, 774/S24/AC1), replacing the guard of ADR 0549 §6 and its list + // ../assignments-readers: at phase write no module.json here depends on mesh-controller beyond the lease and the three + // kept to the write (mesh-sdk catalogue.ManifestRefusals, the judge grants_lease_check runs). + t.Run("no module of this repository depends on the controller beyond what the lease write keeps", func(t *testing.T) { paths, _ := filepath.Glob("../*/module.json") - reads := map[string]bool{} + manifests := map[string]catalogue.Manifest{} for _, path := range paths { raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } - var m struct { - Module string `json:"module"` - Reads []string `json:"reads"` - } - if err := json.Unmarshal(raw, &m); err != nil { + m, err := catalogue.Decode(raw) + if err != nil { t.Fatalf("%s: %v", path, err) } - if slices.Contains(m.Reads, grants.ReadAssignments) { - reads[m.Module] = true - if !allowed[m.Module] { - t.Errorf("%s reads %s: read where modules run from the seat mesh-placement's placement (ADR 0549 §3); the lease write "+ - "is refused while a module reads the assignments (ADR 0549 §6)", m.Module, grants.ReadAssignments) - } - } + manifests[m.Module] = m } - for m := range allowed { - if !reads[m] { - t.Errorf("../assignments-readers names %s, which no longer reads %s: take it off the list", m, grants.ReadAssignments) - } + if len(manifests) < 8 { + t.Fatalf("read %d manifests, want every module of the repository", len(manifests)) + } + for _, why := range catalogue.ManifestRefusals(manifests, catalogue.PhaseWrite) { + t.Error(why) } }) - // 774 (the L3 review, ADR 0549 §3, §6): once the controller's lease is handed away the assignments are frozen and place - // nothing; the seat's placement alone does. A module left only in the stale assignments loses its account and its - // grant names no account of it; before the lease write the two are still unioned. - t.Run("placement alone once the lease is handed away: a module only in the stale assignments has no account or grant of it", func(t *testing.T) { + // 774/S29/AC1 (ADR 0549 §5, ADR 0571 §10): the memberships composed from the placement alone, with no read of the + // controller, are the ones the union of placement and assignments gave on the same input: the fixture's (two nodes, + // the bus and mod-x on a, mod-y on b, as placed on the live mesh's own shape of nats beside its modules), whose + // assignments named the same modules. Neither the controller's assignments nor its condition events are read. + t.Run("placement alone places, with the memberships the union gave", func(t *testing.T) { b := fixture() - b.loaded = shadowOf(t, b) p := partOn(b) - p.Bus = grants.BusAddress{URL: "nats://a.mesh:4222", Fingerprint: "ab"} - pl, _ := delivery.EncodePlacement(delivery.Placement{Node: "a", Modules: []delivery.PlacedModule{{Module: "nats"}, {Module: "mod-x"}}}) - b.states[grants.ReadPlacement] = map[string][]byte{"placement.a": pl} - for _, u := range []string{"a.mod-x", "a.nats", "b.mod-y"} { - b.put(grants.ReadAccounts, u, map[string]any{"user": u, "hash": "$2a$10$x", "generation": 1}) - } - fp := "src2:" + strings.Repeat("ab", 32) - key, _ := delivery.PartGrantKey("mod-y", fp) - grantOf := func() []string { - g, _ := delivery.DecodeControllerGrant(b.own[grants.StateGrants][key]) - return g.Accounts - } - b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"instance": "novox/1/2026-10-28", "epoch": 60}) p.Compose() - if b.own[grants.StateAccounts]["b.mod-y"] == nil || !slices.Equal(grantOf(), []string{"b.mod-y"}) { - t.Fatalf("under the controller's lease the assignments no longer count: account %v, grant %v", - b.own[grants.StateAccounts]["b.mod-y"] != nil, grantOf()) + want := map[string]string{"a.nats": "", "a.mod-x": "", "b.mod-y": ""} + for key := range want { + raw, _, found, _ := b.ownGet(grants.StateMemberships, key) + if !found { + t.Fatalf("no membership of %s from the placement", key) + } + want[key] = string(raw) } - b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"}) - in := grants.ReadInputs(b, &grants.Manifests{}) - if len(in.Assigned["b"]) != 0 || !slices.Equal(in.Assigned["a"], []string{"mod-x", "nats"}) || in.BusNode != "a" { - t.Errorf("after the lease write the stale assignments still place: %v, bus %q", in.Assigned, in.BusNode) + if got := slices.Sorted(maps.Keys(b.own[grants.StateMemberships])); !slices.Equal(got, []string{"a.mod-x", "a.nats", "b.mod-y"}) { + t.Errorf("memberships %v", got) } - res := p.Compose() - if res.Accounts == nil || !slices.Equal(res.Accounts.Deleted, []string{"b.mod-y"}) || slices.Contains(grantOf(), "b.mod-y") { - t.Fatalf("a module only in the stale assignments kept its account or grant: %+v, grant %v", res.Accounts, grantOf()) + for _, r := range grants.Reads { + if strings.HasPrefix(r, "mesh-controller.") && r != grants.ReadLease { + t.Errorf("the part reads %s", r) + } + } + for _, c := range grants.Consumes { + if strings.HasPrefix(c, "mesh-controller.") { + t.Errorf("the part consumes %s", c) + } + } + for _, c := range b.calls { + if strings.Contains(c, "mesh-controller") { + t.Errorf("the part called %s", c) + } } }) @@ -2360,7 +2372,7 @@ func TestGrants(t *testing.T) { t.Errorf("the wanted accounts are read by %v", readers) } // A read that fails retires nothing; once read, the user placement dropped is retired. - delete(b.states[grants.ReadAssignments], "b.mod-y") + b.unassign("b", "mod-y") b.refused[grants.ReadResolved] = true if res := p.Compose(); res.Accounts == nil || len(res.Accounts.Deleted) != 0 || b.own[grants.StateAccounts]["b.mod-y"] == nil { t.Fatalf("a pass whose read failed deleted %+v", res.Accounts) diff --git a/modules/mesh-grants/internal/grants/follow.go b/modules/mesh-grants/internal/grants/follow.go index 4ba35f2..3831219 100644 --- a/modules/mesh-grants/internal/grants/follow.go +++ b/modules/mesh-grants/internal/grants/follow.go @@ -22,7 +22,7 @@ type Watcher interface { // wants (the manifest each machine is wanted to run), and the vault's hashes of the bus accounts. // // And its own state lease, whose key removal-due's expiry marker starts the day-later removal (ADR 0582 clause 6). -var Followed = []string{ReadAssignments, ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement, ReadLease, ReadTakeover, +var Followed = []string{ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement, ReadLease, ReadTakeover, OwnLease} // OwnLease is the part's own state lease as the follower watches it, `.`. diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 9c2728d..afe87f7 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -24,13 +24,12 @@ import ( // The states of other parts the grants part reads, as its manifest's reads name them (`.`). const ( - ReadAssignments = "mesh-controller.assignments" - ReadModules = "mesh-delivery-indexer.modules" - ReadManifests = "mesh-delivery-indexer.manifests" - ReadHave = "node-engine.have" - ReadResolved = "mesh-config.resolved" - ReadWants = "mesh-delivery-assigner.wants" - ReadBuilds = "mesh-delivery-builder.builds" + ReadModules = "mesh-delivery-indexer.modules" + ReadManifests = "mesh-delivery-indexer.manifests" + ReadHave = "node-engine.have" + ReadResolved = "mesh-config.resolved" + ReadWants = "mesh-delivery-assigner.wants" + ReadBuilds = "mesh-delivery-builder.builds" // ReadAccounts is the vault's bcrypt hashes of the bus accounts it holds, by bus user (ADR 0547 clause 9). ReadAccounts = "mesh-vault.bus-accounts" // ReadPlacement is the seat mesh-placement's placement (ADR 0567 §1, §9): each node's modules and the seats @@ -38,10 +37,9 @@ const ( ReadPlacement = delivery.PlacementRead ) -// Reads is every state the part reads, in the manifest's order. ReadAssignments stays (and in Followed) only while the -// controller holds the lease, when the assignments are unioned with the placement (ReadInputs); after the lease write -// they place nothing, and the first change after it takes them out (../assignments-readers names the part until then). -var Reads = []string{ReadAssignments, ReadModules, ReadManifests, ReadResolved, ReadHave, ReadWants, ReadBuilds, +// Reads is every state the part reads, in the manifest's order. Not the controller's assignments: the seat's placement +// alone places modules (novox/hq ADR 0549 §5, ADR 0571 §10; issue 774). +var Reads = []string{ReadModules, ReadManifests, ReadResolved, ReadHave, ReadWants, ReadBuilds, ReadAccounts, ReadPlacement, ReadLease, ReadTakeover} // The bus module's tools the part calls, as its manifest's invokes name them: read-only, each. @@ -83,18 +81,6 @@ var standingGaps = []Gap{ {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"}, } -// assignment is a key of the controller's assignments as composing needs it (mesh-controller internal/chain Assignment): -// a value that names no time assigned (a module pulled in), has ended, or waits in a state, is no assignment. -type assignment struct { - Node string `json:"node"` - Module string `json:"module"` - Assigned time.Time `json:"assigned,omitzero"` - Ended time.Time `json:"ended,omitzero"` - State string `json:"state,omitempty"` -} - -func (a assignment) stands() bool { return !a.Assigned.IsZero() && a.Ended.IsZero() && a.State == "" } - // resolved is a key of mesh-config's resolved values, `..`. type resolved struct { Node string `json:"node"` @@ -105,10 +91,9 @@ type resolved struct { // Inputs is what the shadow composes from, read from the bus. type Inputs struct { - // Nodes is every machine seen: in a node agent's have or a standing assignment. + // Nodes is every machine seen: in a node agent's have or the placement. Nodes []string - // Assigned is each machine's standing assignments and what the seat's placement puts there, sorted: while both - // stand (ADR 0549 §5) a module either names keeps its membership. + // Assigned is what the seat's placement puts on each machine, sorted (issue 774: never the controller's assignments). Assigned map[string][]string // Placed is each node's placement as the seat mesh-placement holds it (ADR 0567 §1); empty before any is read. // Where it is, a seat held once for the mesh is held where placement says, not wherever a module claiming it runs. @@ -248,26 +233,11 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { NoFingerprint: map[string]string{}, Running: map[string]catalogue.Manifest{}, Values: map[string]any{}, Gaps: append([]Gap(nil), standingGaps...)} nodes := map[string]bool{} - // **After the lease write the assignments place nothing** (novox/hq ADR 0549 §3, §6): they are frozen then, so a - // module the catalogue takes off a node would keep its account, grant, membership and user-list entry. Once the lease - // is handed away and the placement is read, the placement alone places modules, as the assigner's Placed does with - // the same lease reading (buscompose.LeaseHandedAway); before, or with no placement on the bus, the two are unioned. - byAssignment, busByAssignment := map[string][]string{}, "" - for _, raw := range each(r, ReadAssignments, &in.Gaps) { - var a assignment - if json.Unmarshal(raw, &a) != nil || !a.stands() || a.Node == "" || a.Module == "" { - continue - } - byAssignment[a.Node] = append(byAssignment[a.Node], a.Module) - if a.Module == BusModule { - busByAssignment = a.Node - } - } - // The seat's placement (ADR 0567 §9): what it places is composed beside the assignments, and the seats held once - // for the mesh are held where it says. Not on the bus yet is a standing gap, never a failed read, so the list is still - // composed and sealed from the assignments alone. + // **The seat's placement alone places modules** (novox/hq ADR 0549 §5, ADR 0567 §9; issue 774): the controller's + // assignments are read no more, and the seats held once for the mesh are held where it says. Not read is a failed + // read: nothing is placed, so after take-over the list composed past it is not sealed. if keys, err := r.Keys(ReadPlacement); err != nil { - in.Gaps = append(in.Gaps, Gap{Input: ReadPlacement, Why: "not read yet: the assignments alone place modules (" + err.Error() + ")"}) + in.Gaps = append(in.Gaps, Gap{Input: ReadPlacement, Why: "refused or absent: nothing is placed (" + err.Error() + ")", Failed: true}) } else { // Each node's keys in either form, assembled by the seat's one reader (delivery.PlacementsOf; research 072 // row 37): its node key and a key per module, or its legacy key `placement.`. @@ -294,17 +264,6 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { in.Placed[node] = p } } - away, _ := leaseHandedAway(r) - if away && len(in.Placed) > 0 { - in.Gaps = append(in.Gaps, Gap{Input: ReadAssignments, Why: "not used: the controller's lease is handed away, " + - "so the seat's placement alone places modules (ADR 0549 §3, §6)"}) - } else { - for node, ms := range byAssignment { - nodes[node] = true - in.Assigned[node] = append(in.Assigned[node], ms...) - } - in.BusNode = busByAssignment - } for node, p := range in.Placed { nodes[node] = true for _, m := range p.Assigned() { @@ -328,7 +287,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { nodes[node] = true } } - in.Gaps = append(in.Gaps, Gap{Input: "machines", Why: "read from the node agents' haves and the assignments: a machine with neither is not seen"}) + in.Gaps = append(in.Gaps, Gap{Input: "machines", Why: "read from the node agents' haves and the placement: a machine with neither is not seen"}) for n := range nodes { in.Nodes = append(in.Nodes, n) } diff --git a/modules/mesh-grants/internal/grants/rootfree.go b/modules/mesh-grants/internal/grants/rootfree.go index 046c122..b282723 100644 --- a/modules/mesh-grants/internal/grants/rootfree.go +++ b/modules/mesh-grants/internal/grants/rootfree.go @@ -49,8 +49,9 @@ func HeardFrom(event string) string { const EventAccountIssued = "mesh-vault." + buscompose.AccountIssuedEvent // Consumes is every event the part hears, as its manifest's consumes name them. -var Consumes = []string{EventConditionRaised, EventConditionCleared, EventKeeperRaised, EventKeeperCleared, EventAccountIssued, - EventBucketExpirySet} +// +// Not the controller's condition events: the keeper's alone (novox/hq ADR 0564 §9, issue 774). +var Consumes = []string{EventKeeperRaised, EventKeeperCleared, EventAccountIssued, EventBucketExpirySet} // IsAccountIssued says an event heard is the vault's account-issued, by its name or its bus subject. func IsAccountIssued(event string) bool { diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 62e70cf..ba6515d 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -14,7 +14,6 @@ ], "reads": [ "mesh-config.resolved", - "mesh-controller.assignments", "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-indexer.manifests", @@ -26,8 +25,6 @@ "mesh-conditions.takeover" ], "consumes": [ - "mesh-controller.condition-raised", - "mesh-controller.condition-cleared", "mesh-conditions.condition-raised", "mesh-conditions.condition-cleared", "mesh-vault.account-issued",