From 7000bd5049ec9d7cefd8ec0225648670cb0ac49c Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 29 Oct 2026 21:52:52 +0100 Subject: [PATCH] Let a tools bundle declare its own bus account and what it may remove, so the bus tools remove the controller's dead-letter consumer and the node-engine's acts on a declared account with no idle service (issue 1952 S5, ADR 0564 Limits 4) --- go/buscompose/buscompose_test.go | 17 +++-- go/buscompose/health_grants.go | 49 ++++++++------ go/buscompose/nats.go | 13 ++-- go/buscompose/users.go | 5 +- go/catalogue/build.go | 2 +- go/catalogue/manifest.go | 6 ++ go/catalogue/process_bus.go | 106 ++++++++++++++++++++++++++++++- go/catalogue/process_bus_test.go | 43 +++++++++++++ 8 files changed, 208 insertions(+), 33 deletions(-) diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 4a10d97..e4f8212 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -13,6 +13,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go/jetstream" ) @@ -596,8 +597,9 @@ func healthChecks(t *testing.T) { {Module: WatchmanModule, State: []Bucket{bucket(WatchmanModule, "findings")}}, {Module: "telegram", Holds: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}}, Processes: []ProcessPrincipal{{Process: "w", Queues: []QueueOwner{{Process: "w", Stream: "M_WORK"}, {Process: "w", Stream: "KV_x"}}}}}, - {Module: "nats", SnapshotsTheBus: true, Processes: []ProcessPrincipal{{Process: BusToolsProcess}, {Process: "other"}}}, - {Module: "shop", Processes: []ProcessPrincipal{{Process: BusToolsProcess}}}, + {Module: "nats", SnapshotsTheBus: true, Processes: []ProcessPrincipal{{Process: "nats-tools", + Removes: []string{catalogue.RemovesControllerDeadLetterConsumer, catalogue.RemovesEngineActs}}, {Process: "other"}}}, + {Module: "shop", Processes: []ProcessPrincipal{{Process: "nats-tools", Removes: []string{catalogue.RemovesEngineActs}}}}, }}}) if err != nil { t.Fatal(err) @@ -627,12 +629,19 @@ func healthChecks(t *testing.T) { 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. + // remove nothing; an account is granted the removals its bus declares and no other (the catalogue refuses them + // outside the bus module). "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"}, + "a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-conditions_conditions.x"}, + } + // The tools account's secret is the module's own broker-, which its manifest's own-secrets names and the + // vault keeps by that name (AccountSecret). + if w := (WantedAccount{User: "a.nats.nats-tools", Kind: KindProcess, Node: "a", Module: "nats", Process: "nats-tools", + URL: "nats://a:4222"}); w.Valid() != nil || w.OwnSecret() != "broker-nats-tools" { + t.Errorf("the tools account is held in %q (%v)", w.OwnSecret(), w.Valid()) } seen := map[string]bool{} for _, u := range users { diff --git a/go/buscompose/health_grants.go b/go/buscompose/health_grants.go index 26f3c3b..e795a85 100644 --- a/go/buscompose/health_grants.go +++ b/go/buscompose/health_grants.go @@ -1,9 +1,12 @@ package buscompose import ( + "fmt" + "slices" "sort" "strings" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/health" ) @@ -19,9 +22,9 @@ import ( // - 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 bus module's tools run on an account of their own, declared by their bundle's `bus` with `removes` +// (catalogue.BusAccounts), 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 ( @@ -34,28 +37,32 @@ var ( 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 + ".>", +// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, issue 1952 +// notes 6 and 7), each removal its grant: the controller's consumer on DEAD_LETTER_NOTICES read and deleted (the lease +// write's step, clause 5); the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal +// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills. A +// removal outside the set is refused, as the catalogue refuses it (catalogue.BusRemovals). +func BusRemovalGrants(removes []string) ([]string, error) { + var out []string + for _, r := range removes { + switch r { + case catalogue.RemovesControllerDeadLetterConsumer: + out = append(out, "$JS.API.CONSUMER.INFO."+DeadLetterNoticesStream+"."+ControllerName, + "$JS.API.CONSUMER.DELETE."+DeadLetterNoticesStream+"."+ControllerName) + case catalogue.RemovesEngineActs: + out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>") + default: + return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4)", r, + strings.Join(catalogue.BusRemovals, ", ")) + } } + return out, nil } -// 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). +// removesEngineActs is an account declared to remove 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+".>" + return p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesEngineActs) && pattern == "$KV."+EngineActsBucket+".>" } // WatchmanDeadLetterConsumer is the watchman's own consumer on DEAD_LETTER_NOTICES, made only once the lease names no diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index 25edb1a..83378f1 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -196,9 +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 + // Removes are, for a process or tools bundle account whose bus declares them, what it may remove from the bus + // (novox/hq ADR 0564 Limits 4): its whole authority is then BusRemovalGrants of them. + Removes []string // DeadLetterSources are, for the watchman, the streams a dead letter it keeps may come from, by name // (deadLetterSourcesOf, novox/hq ADR 0564 clause 5): never a bucket's. DeadLetterSources []string @@ -661,8 +661,11 @@ func PermissionsFor(p Principal) (Permissions, error) { return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil } - if p.Kind == KindProcess && p.RemovesFromTheBus { - pub := BusRemovalGrants() + if p.Kind == KindProcess && len(p.Removes) > 0 { + pub, err := BusRemovalGrants(p.Removes) + if err != nil { + return Permissions{}, err + } sort.Strings(pub) if err := CheckWriters(p, pub); err != nil { return Permissions{}, err diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 2d1fc8c..ca23cf5 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -105,6 +105,9 @@ type ProcessPrincipal struct { Emits []string // Queues are the module's own work-queue streams this process is the one consumer of. Queues []QueueOwner + // Removes are what its account may remove from the bus, as its bus declares them (catalogue.BusRemovals; novox/hq + // ADR 0564 Limits 4): a tools bundle's account, whose whole authority is then BusRemovalGrants. + Removes []string // State is the module's own state, by local name, this process reads and writes. State []string // Asks says whether this process reads the records of its module's own asks (novox/hq ADR 0259 §3), on @@ -204,7 +207,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, RemovesFromTheBus: d.SnapshotsTheBus && pr.Process == BusToolsProcess, + Invokes: pr.Invokes, Removes: pr.Removes, }) } if runtimeHere && d.Module == RuntimeModule { diff --git a/go/catalogue/build.go b/go/catalogue/build.go index 0a2ff73..7c2a4b6 100644 --- a/go/catalogue/build.go +++ b/go/catalogue/build.go @@ -136,7 +136,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) { Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest, Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...), Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers), - Binary: BinaryOf(a), + Binary: BinaryOf(a), Bus: a.Bus, }) } sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name }) diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index b40dbad..ca3eba9 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -1011,6 +1011,8 @@ type Bundle struct { // Binary is the executable a bundle compiled to a binary is, at its root (novox/hq ADR 0193): // what runs it, where an interpreted bundle names an interpreter and an entrypoint. Binary string `json:"binary,omitempty"` + // Bus is the artifact's bus account, kept on the resolved manifest so the grant composition reads it (BusAccounts). + Bus *ProcessBus `json:"bus,omitempty"` } // Build says how to produce this module's artifacts from its source. @@ -1148,6 +1150,10 @@ type Artifact struct { // delivered version. A bundle that carried the package's name was delivered correctly, reported // success, and was invisible to the launcher (novox/hq 04-ISSUES/142). Binary string `json:"binary,omitempty"` + // Bus is a tools bundle's own bus account (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): read as a process's + // (ProcessBus), a principal named after the bundle's binary (BusAccounts), its secret broker-. A tools bundle + // is run by the node's tool runtime per call, so it declares its account here and runs no idle service for it. + Bus *ProcessBus `json:"bus,omitempty"` // URL is where an archive's upstream file is fetched from, for an archive that is a release // somebody else published rather than a directory of this repository (novox/hq ADR 0316 §2, §6). diff --git a/go/catalogue/process_bus.go b/go/catalogue/process_bus.go index 445fc68..c48f11c 100644 --- a/go/catalogue/process_bus.go +++ b/go/catalogue/process_bus.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "slices" "strings" ) @@ -29,6 +30,109 @@ type ProcessBus struct { // Invokes are the tools and seat verbs this process calls, each . or a role's // seat:. (as Manifest.Invokes), or * for every tool on the mesh. Invokes []string `json:"invokes,omitempty"` + // Removes are what a tools bundle's account may remove from the bus, from a closed set (BusRemovals; novox/hq ADR + // 0564 Limits 4): declared alone, by the module holding mesh-broker, never by a process. + Removes []string `json:"removes,omitempty"` +} + +// What a bus account may remove (novox/hq ADR 0564 Limits 4), each its own grant: the controller's consumer on +// DEAD_LETTER_NOTICES (the lease write's step, clause 5), and keys of the node-engine's acts bucket. A further consumer +// or bucket is a change of this set, through a decision. +const ( + RemovesControllerDeadLetterConsumer = "DEAD_LETTER_NOTICES.controller" + RemovesEngineActs = "mesh-host_acts" +) + +// BusRemovals is the closed set of what a bus account may remove. +var BusRemovals = []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs} + +// BusAccount is one account of a module's own beside its `broker`: a process's (ADR 0436) or a tools bundle's (ADR 0564 +// Limits 4), by its id (the process resource's, or the bundle's binary), with what its bus says. +type BusAccount struct { + ID string + Bus ProcessBus + // Tools says it is a tools bundle's, run by the node's tool runtime, not a process the node runs. + Tools bool +} + +// BusAccounts is every own bus account of the module, processes first in resource order, then tools bundles in artifact +// order: what the grant composition composes a principal for, each delivered in its own secret broker-. A tools +// bundle's is read from the repository's build, or from the resolved manifest's bundles. +func (m Manifest) BusAccounts() []BusAccount { + var out []BusAccount + buses := m.ProcessBuses() + for _, r := range m.Resources { + id := fmt.Sprint(r["id"]) + if b, ok := buses[id]; ok && fmt.Sprint(r["type"]) == "process" { + out = append(out, BusAccount{ID: id, Bus: b}) + } + } + seen := map[string]bool{} + if m.Build != nil { + for _, a := range m.Build.Artifacts { + if a.Bus != nil && a.Kind == ArtifactBundle && BinaryOf(a) != "" && !seen[BinaryOf(a)] { + seen[BinaryOf(a)] = true + out = append(out, BusAccount{ID: BinaryOf(a), Bus: *a.Bus, Tools: true}) + } + } + } + for _, b := range m.Bundles { + if b.Bus != nil && b.Binary != "" && !seen[b.Binary] { + seen[b.Binary] = true + out = append(out, BusAccount{ID: b.Binary, Bus: *b.Bus, Tools: true}) + } + } + return out +} + +// toolsBusProblems is what is wrong with a tools bundle's bus (ADR 0564 Limits 4): a bus on anything but a bundle +// compiled to a binary the runtime loads, a removal outside the closed set, or a removal beside any other grant or by a +// module that does not hold mesh-broker. Every other field is judged as a process's. +func toolsBusProblems(m Manifest) []string { + var problems []string + var artifacts []Artifact + if m.Build != nil { + artifacts = m.Build.Artifacts + } + claimsBroker := false + for _, c := range m.Claims { + claimsBroker = claimsBroker || c.Name == "mesh-broker" + } + for _, a := range artifacts { + if a.Bus == nil { + continue + } + if a.Kind != ArtifactBundle || BinaryOf(a) == "" { + problems = append(problems, fmt.Sprintf("%s's artifact %s declares bus, and only a tools bundle compiled to a "+ + "binary has an account of its own (novox/hq ADR 0564 Limits 4)", m.Module, a.Name)) + continue + } + b := *a.Bus + if len(b.Queues) > 0 || b.Asks { + problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus consumes a queue or reads asks; a tools "+ + "bundle runs per call and holds no consumer (novox/hq ADR 0564 Limits 4)", m.Module, a.Name)) + } + for _, r := range b.Removes { + if !slices.Contains(BusRemovals, r) { + problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes %q, which is not one of %s "+ + "(novox/hq ADR 0564 Limits 4)", m.Module, a.Name, r, strings.Join(BusRemovals, ", "))) + } + } + if len(b.Removes) > 0 && (!claimsBroker || len(b.Emits)+len(b.State)+len(b.Invokes) > 0) { + problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes from the bus; only the bus "+ + "module's (the holder of mesh-broker) may, and with nothing else (novox/hq ADR 0564 Limits 4)", m.Module, a.Name)) + } + } + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) != "process" { + continue + } + if b, present, err := processBusOf(r); present && err == nil && len(b.Removes) > 0 { + problems = append(problems, fmt.Sprintf("%s's process %v's bus removes from the bus; only a tools bundle's "+ + "may (novox/hq ADR 0564 Limits 4)", m.Module, r["id"])) + } + } + return problems } // UnmarshalJSON reads a process's bus strictly: an unknown key — serves, a seat to hold or claim, or @@ -87,7 +191,7 @@ func (m Manifest) ProcessBuses() map[string]ProcessBus { // §3): the account rule is modelled on RunsAsProblems (seats_declared.go:522-550), and an invoked tool or // seat verb's shape on invokeProblems. func ProcessBusProblems(m Manifest) []string { - var problems []string + problems := toolsBusProblems(m) streams := map[string]bool{} for _, s := range m.Streams { streams[s.Name] = true diff --git a/go/catalogue/process_bus_test.go b/go/catalogue/process_bus_test.go index 5551ec1..bd59506 100644 --- a/go/catalogue/process_bus_test.go +++ b/go/catalogue/process_bus_test.go @@ -47,6 +47,49 @@ func TestAProcessDeclaresItsBusAndIsAccepted(t *testing.T) { !b.Asks || !strSliceEqual(b.Invokes, []string{"seat:operator-channel.tell"}) { t.Fatalf("the bus read as %+v", b) } + + // 1952 S5 (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): a tools bundle declares its own bus account, read as a + // process's, named after its binary and kept through resolution; a removal is from the closed set, alone, and the + // bus module's (mesh-broker) alone. + tools := func(claims, bus string) string { + return `{"module":"nats","version":"1","claims":` + claims + `,"build":{"artifacts":[{"name":"tools","kind":"bundle",` + + `"language":"go","system":"arch","from":"cmd/nats-tools","binary":"nats-tools","loads":["nats-tools"],"bus":` + bus + `}]}}` + } + broker := `[{"name":"mesh-broker","scope":"mesh"}]` + for _, c := range []struct { + name, manifest, refused string + }{ + {name: "the bus module's tools removing", manifest: tools(broker, `{"removes":["DEAD_LETTER_NOTICES.controller","mesh-host_acts"]}`)}, + {name: "a removal outside the set", manifest: tools(broker, `{"removes":["EVENTS.a_m"]}`), refused: "not one of"}, + {name: "a removal beside a grant", manifest: tools(broker, `{"removes":["mesh-host_acts"],"invokes":["nats.nats_streams"]}`), refused: "with nothing else"}, + {name: "another module's tools removing", manifest: tools(`[]`, `{"removes":["mesh-host_acts"]}`), refused: "only the bus module"}, + {name: "a tools bundle consuming a queue", manifest: tools(broker, `{"queues":["x"]}`), refused: "holds no consumer"}, + {name: "a process removing", manifest: mailSenderProcessManifest(t, `{"removes":["mesh-host_acts"]}`), refused: "only a tools bundle"}, + } { + m, err := ParseManifest([]byte(c.manifest)) + if c.refused != "" { + if err == nil || !strings.Contains(err.Error(), c.refused) { + t.Errorf("%s: %v, want refused saying %q", c.name, err, c.refused) + } + continue + } + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + accounts := m.BusAccounts() + if len(accounts) != 1 || accounts[0].ID != "nats-tools" || !accounts[0].Tools || + !strSliceEqual(accounts[0].Bus.Removes, []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs}) { + t.Errorf("%s: the accounts are %+v", c.name, accounts) + } + // Resolved, the build goes and its bundles keep the bus (build.go). + resolved := Manifest{Module: "nats", Bundles: []Bundle{{Name: "tools", Binary: "nats-tools", Bus: m.Build.Artifacts[0].Bus}}} + if got := resolved.BusAccounts(); len(got) != 1 || got[0].ID != "nats-tools" { + t.Errorf("%s: resolved, the accounts are %+v", c.name, got) + } + } + if got := m.BusAccounts(); len(got) != 1 || got[0].ID != "send" || got[0].Tools { + t.Errorf("the process's account is %+v", got) + } } func strSliceEqual(a, b []string) bool {