diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index d634d5b5..1eede1df 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -1252,7 +1252,9 @@ func TestGrants(t *testing.T) { n := newFakeBus() // 1952/S5 (ADR 0564 Limits 4): the bus module's tools bundle declares its own account and what it may remove. n.manifestOf("nats", strings.Replace(strings.TrimSuffix(natsManifest, "}"), `"broker":"/var/lib/nats/broker"`, `"broker":"/var/lib/nats/broker","broker-nats-tools":"${dir:tools-secret}/broker-nats-tools"`, 1)+ - `,"resources":[{"id":"tools-secret","type":"directory","path":"/var/lib/mesh/nats-tools","mode":"0711"}],"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go",`+ + `,"resources":[{"id":"tools-secret","type":"directory","path":"/var/lib/mesh/nats-tools","mode":"0711"},`+ + // ADR 0626 §1: the keeper's process declares `keeps`, which its principal carries. + `{"id":"keeper","type":"process","name":"nats-keeper","artifact":"tools","run":["./nats-tools","keep"],"bus":{"state":["asked"],"keeps":true}}],"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go",`+ `"system":"arch","from":"cmd/nats-tools","binary":"nats-tools","loads":["nats-tools"],"bus":{"removes":["controller-leftovers","mesh-host_acts"]}}]}}`) n.manifestOf("claude-licence-manager", string(licence)) n.assign("novox", "nats") @@ -1272,8 +1274,9 @@ func TestGrants(t *testing.T) { tools = d.Processes } } - if len(tools) != 1 || tools[0].Process != "nats-tools" || !slices.Equal(tools[0].Removes, []string{"controller-leftovers", "mesh-host_acts"}) { - t.Errorf("the bus module's principals = %+v, want nats-tools carrying both removals", tools) + if len(tools) != 2 || tools[0].Process != "keeper" || !tools[0].Keeps || tools[1].Process != "nats-tools" || tools[1].Keeps || + !slices.Equal(tools[1].Removes, []string{"controller-leftovers", "mesh-host_acts"}) { + t.Errorf("the bus module's principals = %+v, want the keeper keeping and nats-tools carrying both removals", tools) } 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) diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index caffd5e4..a0a65dbc 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -1072,9 +1072,9 @@ func theBusModulesProcessKeepsBusState(t *testing.T) { {Module: "nats", SnapshotsTheBus: true, Holds: []Seat{{Name: catalogue.BrokerSeat, Serves: BrokerVerbs()}}, Consumes: []string{"mesh-delivery-indexer.manifest-accepted"}, Uses: []Seat{{Name: "operator-channel", Accepts: []string{"condition"}, ByCaller: []string{"condition"}}}, Streams: []ModuleStream{{Module: "nats", Name: "BROKER_EVENTS", Events: []string{"created", "refused"}, MaxAgeDays: 30, MaxMiB: 256}}, - Processes: []ProcessPrincipal{{Process: "keeper", State: []string{"asked"}}, {Process: "nats-tools", Tools: true, - Removes: []string{catalogue.RemovesEngineActs}}}}, - {Module: "mod-x"}, + Processes: []ProcessPrincipal{{Process: "keeper", State: []string{"asked"}, Keeps: true}, {Process: "other"}, + {Process: "nats-tools", Tools: true, Removes: []string{catalogue.RemovesEngineActs}}}}, + {Module: "mod-x", Processes: []ProcessPrincipal{{Process: "sneak", Keeps: true}}}, {Module: "admin-ish", Invokes: []string{"*"}}, }}, BusState: map[string][]catalogue.BusStateEntry{"mod-x": { @@ -1098,10 +1098,16 @@ func theBusModulesProcessKeepsBusState(t *testing.T) { keeper := perms["a.nats.keeper"] for _, s := range []string{"KV_mod-x_records", "mod-x_audit", "mod-x_audit_snapshot", "KV_mod-x_audit_state", "KV_mod-x_audit_index-by-status", "OBJ_mod-x_parts", "KV_gone_kept"} { - for _, op := range []string{"CREATE", "UPDATE", "DELETE"} { + for _, op := range []string{"CREATE", "UPDATE", "DELETE", "SNAPSHOT"} { if !MayPublish(keeper, "$JS.API.STREAM."+op+"."+s) { t.Errorf("the bus module's process may not %s %s", op, s) } + // Only the process whose bus declares `keeps`, and only on the module holding mesh-broker (ADR 0626 §1, §3). + for _, other := range []string{"a.nats.other", "a.mod-x.sneak"} { + if MayPublish(perms[other], "$JS.API.STREAM."+op+"."+s) { + t.Errorf("%s, which is no keeper of the bus, may %s %s", other, op, s) + } + } } } for _, s := range []string{"EVENTS", "KV_mesh-grants_memberships", "KV_mod-x_other"} { diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index 40ca5aa6..6dceb5d7 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -133,6 +133,9 @@ type ProcessPrincipal struct { Invokes []string // Tools says it is a tools bundle's account, run by the node's tool runtime, not a process the node runs. Tools bool + // Keeps says its bus declares `keeps`: the bus module's keeper (novox/hq ADR 0626 §1), composed the bus's create, + // update, snapshot and delete subjects of every module's bus state, and only on the module holding mesh-broker. + Keeps bool } // Records is what composing a user list needs to know about the mesh, and nothing more. @@ -239,9 +242,10 @@ func Users(r Records) ([]Principal, error) { Emits: pr.Emits, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks, Invokes: pr.Invokes, Removes: pr.Removes, }) - // The process of the module holding mesh-broker keeps every module's bus state (busstate.go, ADR 0595 §4), - // and its tools bundle reads its module's own stream back. - if d.SnapshotsTheBus && !pr.Tools && len(pr.Removes) == 0 { + // The process of the module holding mesh-broker whose bus declares `keeps` keeps every module's bus state + // (busstate.go, ADR 0595 §4, ADR 0626 §1); `keeps` on any other module composes nothing (the judge refuses it, + // ADR 0626 §3). Its tools bundle reads its module's own stream back. + if d.SnapshotsTheBus && pr.Keeps && !pr.Tools { k := &out[len(out)-1] k.KeepsBus, k.KeepsBusState = true, BusStateNames(r.BusState, r.Bound) k.Consumes, k.Streams = d.Consumes, d.Streams diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 5b7e64c0..a7447482 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -377,7 +377,7 @@ func processesOf(m catalogue.Manifest) []buscompose.ProcessPrincipal { // what it may remove from the bus (ADR 0564 Limits 4, issue 1952 S5). for _, a := range accounts { pr := buscompose.ProcessPrincipal{Process: a.ID, Emits: a.Bus.Emits, State: a.Bus.State, Asks: a.Bus.Asks, - Invokes: a.Bus.Invokes, Removes: a.Bus.Removes, Tools: a.Tools} + Invokes: a.Bus.Invokes, Removes: a.Bus.Removes, Tools: a.Tools, Keeps: a.Bus.Keeps} for _, q := range queues { if q.Process == a.ID { pr.Queues = append(pr.Queues, q)