diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index dea1de5..4d23e3f 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -33,8 +33,16 @@ var createdAdvisory = regexp.MustCompile(`"\$JS\.EVENT\.ADVISORY\.STREAM\.CREATE // grants part's list is the one the bus loads; a hand-back loads the controller's, which lacks these reads, and the nodes // keep applying without composing their own until the next take-over. Each principal's hearing of its buckets' creation // (issue 1790) is the other row beyond, matched by createdAdvisory. +// +// And the watchman's dead letters (novox/hq ADR 0564 clause 5, issue 1952 S5): its own consumer on DEAD_LETTER_NOTICES and +// a read of DEAD_LETTERS, the controller's job until the lease write. func beyondTheControllers(user string) map[string]bool { out := map[string]bool{} + if strings.HasSuffix(user, "."+WatchmanModule) { + for _, s := range WatchmanDeadLetterGrants() { + out[s] = true + } + } node, ok := strings.CutPrefix(user, "node.") if !ok { return out @@ -548,6 +556,8 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers) } } + // 1952 S5 (novox/hq ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8): the health holders' credentials. + healthChecks(t) q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"}, QueueOwner{Process: "p", Stream: "M_WORK", MaxDeliver: 3})) if q.Durable != "a_m_p" || q.FilterSubject != "" || q.MaxDeliver != 3 || q.DeliverPolicy != jetstream.DeliverAllPolicy { @@ -555,3 +565,89 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { } }) } + +// healthChecks are the health holders' credential cases (ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8; issue 1952 S5). +func healthChecks(t *testing.T) { + t.Helper() + announced := map[string]announce.States{ControllerSeat: ControllerAnnouncement("novox"), + "mesh-healers": {Module: "mesh-healers", States: []announce.State{{Name: "attempts", Readable: true}, {Name: "would", Readable: true}}}} + for _, c := range []struct { + reader, read string + may bool + }{ + {"mesh-conditions", ControllerLease, true}, {"mesh-healers", ControllerLease, true}, {WatchmanModule, ControllerLease, true}, + {"mesh-self-check", ControllerLease, false}, {"messenger", ControllerLease, false}, + {"mesh-conditions", ControllerConditions, true}, {"mesh-healers", ControllerConditions, false}, {"messenger", ControllerConditions, false}, + {"mesh-healers", ControllerHandActs, true}, {"mesh-conditions", ControllerHandActs, false}, + {"mesh-conditions", "mesh-healers.attempts", true}, {"mesh-conditions", HealersWould, false}, {"mesh-delivery-view", HealersWould, false}, + } { + granted, _ := ReadsAnnouncedBy(c.reader, []string{c.read}, announced) + if (len(granted) == 1) != c.may { + t.Errorf("%s reads %s: %v, want %v", c.reader, c.read, granted, c.may) + } + } + bucket := func(m, n string) Bucket { return Bucket{Module: m, Name: n} } + users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": { + {Module: "mesh-conditions", State: []Bucket{bucket("mesh-conditions", "conditions"), bucket("mesh-conditions", "takeover")}, + Reads: []string{"mesh-self-check.findings", "mesh-healers.attempts", ControllerConditions}}, + {Module: "mesh-self-check", State: []Bucket{bucket("mesh-self-check", "findings")}}, + {Module: "mesh-healers", State: []Bucket{bucket("mesh-healers", "findings"), bucket("mesh-healers", "attempts"), bucket("mesh-healers", "would")}}, + {Module: WatchmanModule, State: []Bucket{bucket(WatchmanModule, "findings")}}, + {Module: "nats", SnapshotsTheBus: true, Processes: []ProcessPrincipal{{Process: BusToolsProcess}, {Process: "other"}}}, + {Module: "shop", Processes: []ProcessPrincipal{{Process: BusToolsProcess}}}, + }}}) + if err != nil { + t.Fatal(err) + } + may := map[string][]string{ + "a.mesh-conditions": {"$KV.mesh-conditions_conditions.machine.g14.silent"}, + WatchmanModule: {"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES." + WatchmanModule, "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES." + WatchmanModule, + "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS"}, + "a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", + "$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts"}, + } + mayNot := map[string][]string{ + // The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2). + "a.mesh-conditions": {"$KV.mesh-self-check_findings.x", "$KV.mesh-healers_attempts.x", "$KV.mesh-controller_conditions.x"}, + "a.mesh-self-check": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch"}, + "a.mesh-healers": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch"}, + // The watchman never deletes or changes the controller's consumer (clause 5). + WatchmanModule: {"$KV.mesh-conditions_conditions.x", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", + "$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES." + WatchmanModule}, + // The bus module's tools remove the one consumer and the acts' keys, nothing else (Limits 4); its other accounts + // remove nothing, and a process of that name in another module is no tools account. + "a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.other", "$JS.API.CONSUMER.DELETE.EVENTS.a_m", + "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.PURGE.KV_mesh-host_acts"}, + "a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"}, + "a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"}, + "a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"}, + } + seen := map[string]bool{} + for _, u := range users { + name := u.Username() + if u.Module == WatchmanModule && u.Kind == KindModule { + name = WatchmanModule + } + perms, err := PermissionsFor(u) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + for _, subject := range may[name] { + seen[name] = true + if !MayPublish(perms, subject) { + t.Errorf("%s may not publish %s", name, subject) + } + } + for _, subject := range mayNot[name] { + seen[name] = true + if MayPublish(perms, subject) { + t.Errorf("%s may publish %s", name, subject) + } + } + } + for name := range mayNot { + if !seen[name] { + t.Errorf("no user %s was composed", name) + } + } +} diff --git a/go/buscompose/health_grants.go b/go/buscompose/health_grants.go new file mode 100644 index 0000000..e6fc9de --- /dev/null +++ b/go/buscompose/health_grants.go @@ -0,0 +1,78 @@ +package buscompose + +import "git.novox.be/novox/mesh-sdk/go/health" + +// **The health holders' grants** (novox/hq ADR 0564 clauses 2, 5 and 8, Limits 2, 4 and 8; issue 1952 S5). +// +// - The condition keeper reads the controller's open conditions until the lease write, for what clauses 8 and 9 carry +// from them, and mesh-healers the controller's hand-act log, for S15 (Limits 2): both buckets the controller +// announces readable by nobody, so each is read by its named reader alone. The keeper, mesh-healers and the +// watchman read the controller's lease (Limits 8). Nobody reads mesh-healers' `would` (clause 8): what the healers +// would have done reaches no condition. Every observer's `findings`, the healers' `attempts`, the wants and the +// haves are read through `reads` as any announced state is; and no module writes another's state, so an observer +// can never write the keeper's buckets (clause 2). +// - The watchman makes its own consumer on DEAD_LETTER_NOTICES, by its own name, pulled, and reads whether the +// controller's consumer `controller` still stands; it may neither delete nor change that consumer (clause 5). It +// lists and shows DEAD_LETTERS, which is all it does before the lease write. +// - The bus module's tools run on a process account of their own, BusToolsProcess, which may remove only the +// consumer `controller` on DEAD_LETTER_NOTICES and keys of the node-engine's acts bucket (Limits 4), never the +// backup's account `broker`, which only reads (ADR 0235). + +// The states the composer names its readers for, beside the lease (NamedReaders). +var ( + // ControllerConditions is the controller's open conditions, read by the keeper alone (ADR 0564 clauses 8, 9). + ControllerConditions = ControllerSeat + ".conditions" + // ControllerHandActs is the controller's hand-act log, read by mesh-healers alone of the four health holders (ADR + // 0564 Limits 2). + ControllerHandActs = ControllerSeat + ".hand-acts" + // HealersWould is what mesh-healers would have done while the lease names a controller: read by nobody (clause 8). + HealersWould = health.HealersModule + "." + health.WouldState +) + +// BusToolsProcess is the process of the module holding mesh-broker whose account removes from the bus (ADR 0564 Limits +// 4): its secret is broker-nats-tools, as every process account's is broker- (ADR 0436 §2). +const BusToolsProcess = "nats-tools" + +// BusRemovalGrants is the whole authority of the bus module's tools account (ADR 0564 Limits 4, issue 1952 notes 6 and +// 7): read and delete the controller's consumer on DEAD_LETTER_NOTICES (the lease write's step, clause 5), and read and +// remove keys of the node-engine's acts bucket, which nothing else may empty when it fills. STREAM.INFO with a subject +// filter lists what a removal would take, so the tools read before they remove. A further consumer or bucket is a change +// of this grant. +func BusRemovalGrants() []string { + return []string{ + "$JS.API.CONSUMER.INFO." + DeadLetterNoticesStream + "." + ControllerName, + "$JS.API.CONSUMER.DELETE." + DeadLetterNoticesStream + "." + ControllerName, + "$JS.API.STREAM.INFO.KV_" + EngineActsBucket, + "$KV." + EngineActsBucket + ".>", + } +} + +// removesEngineActs is the bus module's tools account removing keys of the node-engine's acts bucket: the writers +// table's one exemption from a node's engine being the only writer of its keys, for removal (ADR 0564 Limits 4). +func removesEngineActs(p Principal, pattern string) bool { + return p.Kind == KindProcess && p.RemovesFromTheBus && pattern == "$KV."+EngineActsBucket+".>" +} + +// WatchmanDeadLetterConsumer is the watchman's own consumer on DEAD_LETTER_NOTICES, made only once the lease names no +// controller and `controller` is gone (ADR 0564 clause 5). +const WatchmanDeadLetterConsumer = WatchmanModule + +// WatchmanDeadLetterGrants are what the watchman may publish for the dead letters (ADR 0564 clause 5): its own pulled +// consumer on DEAD_LETTER_NOTICES (made, read, pulled, acknowledged), whether `controller` still stands (read only), and a +// read of DEAD_LETTERS: its list by subject, one kept message, and an ordered consumer of its own to page them. +func WatchmanDeadLetterGrants() []string { + own := DeadLetterNoticesStream + "." + WatchmanDeadLetterConsumer + return []string{ + "$JS.API.STREAM.INFO." + DeadLetterNoticesStream, + "$JS.API.CONSUMER.INFO." + DeadLetterNoticesStream + "." + ControllerName, + "$JS.API.CONSUMER.CREATE." + own, + "$JS.API.CONSUMER.INFO." + own, + "$JS.API.CONSUMER.MSG.NEXT." + own, + "$JS.ACK." + own + ".>", + "$JS.API.STREAM.INFO." + DeadLettersStream, + "$JS.API.STREAM.MSG.GET." + DeadLettersStream, + "$JS.API.CONSUMER.CREATE." + DeadLettersStream + ".>", + "$JS.API.CONSUMER.DELETE." + DeadLettersStream + ".*", + "$JS.FC." + DeadLettersStream + ".>", + } +} diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index 74981b0..26a5fbb 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -196,6 +196,9 @@ type Principal struct { // whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing // else — not its declarations, which the node's tool runtime serves for it. SnapshotsTheBus bool + // RemovesFromTheBus is the bus module's tools process (BusToolsProcess, novox/hq ADR 0564 Limits 4): its whole + // authority is BusRemovalGrants. + RemovesFromTheBus bool // WitnessesController is a machine principal whose node-engine witnesses the controller's upgrades: // the machine runs the controller (novox/hq ADR 0236, lease/witness.go). It may read the lease's one @@ -655,6 +658,16 @@ func PermissionsFor(p Principal) (Permissions, error) { return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil } + if p.Kind == KindProcess && p.RemovesFromTheBus { + pub := BusRemovalGrants() + sort.Strings(pub) + if err := CheckWriters(p, pub); err != nil { + return Permissions{}, err + } + // Its own inbox for the answers, and nothing else (Limits 4). + return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil + } + var pub, sub []string switch p.Kind { case KindController: @@ -988,6 +1001,8 @@ func PermissionsFor(p Principal) (Permissions, error) { if p.Module == WatchmanModule { sub = append(sub, BusConnectSubject, BusDisconnectSubject) pub = append(pub, BusConnzSubject) + // And the dead letters, from its own consumer, never the controller's (novox/hq ADR 0564 clause 5). + pub = append(pub, WatchmanDeadLetterGrants()...) } // The grants part alone publishes the modules' memberships into the assignments stream (novox/hq ADR 0547 §5; // the operator, 2026-10-25 22:31): one per subject, `mesh.assignment..`, reading the last one of each diff --git a/go/buscompose/userlist_switch.go b/go/buscompose/userlist_switch.go index 6e2cb07..266eaad 100644 --- a/go/buscompose/userlist_switch.go +++ b/go/buscompose/userlist_switch.go @@ -5,6 +5,7 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/health" ) // **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clause 7, issue 1945). The grants part writes @@ -81,8 +82,13 @@ const VaultBusAccounts = "mesh-vault.bus-accounts" // are read by the vault alone (novox/hq ADR 0561 §2). // The controller's lease is read by the parts that turn on at its write (ADR 0561 §3, §6), and by the assigner, which // reads the catalogue's placement alone from then (ADR 0549 §5, issue 774). +// +// The health holders (novox/hq ADR 0564 Limits 2 and 8, clauses 8 and 9): the condition keeper, mesh-healers and the +// watchman read the lease; the keeper alone the controller's conditions, mesh-healers alone its hand-act log; and nobody +// the healers' `would` (health_grants.go). var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat}, - ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule}} + ControllerLease: {VaultSeat, GrantsModule, delivery.AssignerModule, health.KeeperModule, health.HealersModule, WatchmanModule}, + ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule}, HealersWould: {}} // ReaderMay says whether module may read the state read (`.`) by the named readers: always, for a state // that names none. diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 5629510..10295ff 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -204,7 +204,7 @@ func Users(r Records) ([]Principal, error) { out = append(out, Principal{ Kind: KindProcess, Node: node, Module: d.Module, Process: pr.Process, Emits: pr.Emits, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks, - Invokes: pr.Invokes, + Invokes: pr.Invokes, RemovesFromTheBus: d.SnapshotsTheBus && pr.Process == BusToolsProcess, }) } if runtimeHere && d.Module == RuntimeModule { diff --git a/go/buscompose/writers.go b/go/buscompose/writers.go index 7649cb2..465d83d 100644 --- a/go/buscompose/writers.go +++ b/go/buscompose/writers.go @@ -249,7 +249,11 @@ var WritersTable = []WriterRow{ // node-engine's declared state, created by the grants part, written by each node's engine under its own keys. {State: "a machine's hand acts", Writer: "the node-engine of the machine, under its own keys `.>`, compare-and-set", KeptIn: "key-value " + EngineActsBucket + ", the node-engine's declared state acts", - Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket), Writes: ownNodeKeys}, + Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket), + Writes: func(p Principal, pattern string) bool { + return ownNodeKeys(p, pattern) || removesEngineActs(p, pattern) + }, + Shared: "the bus module's tools account removes keys of a full bucket, for removal only (novox/hq ADR 0564 Limits 4)"}, // The contents of a declaration's files over 64 KiB (novox/hq ADR 0486 §2): the controller alone writes the bucket, each // object under the digest of its content; a node-engine reads one by its name, with no list and no write. {State: "a declaration's large files", Writer: "controller, each object under the sha256 digest of its content; the " +