From 755457cc379e6eea647826512aaa14b1d745b2d8 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Wed, 14 Oct 2026 23:05:14 +0000 Subject: [PATCH] Let the tool runner's account hear the event families its manifest names (issue 745, ADR 0367) A session on the mesh MCP server polls for every verdict, answer and delivery because the tool runner's account hears no event. ADR 0367 lets the tool runner's module name, one by one, the families a session may wait on: the manifest's `waits`, refused for any other module and for a wildcard. The controller grants the runtime's account a plain subscription on those subjects alone, makes no durable consumer for them, and issues the same list in the tool runner's membership, so the grant and what `mesh_wait` offers come from one composition and no call or setting widens it. A family on a seat whose events end in a kind or an asker must name it, and is refused at composition without it, so it is never granted a subject nothing publishes on. Co-Authored-By: Claude Opus 5.5 --- internal/broker/membership.go | 5 + internal/broker/nats.go | 8 ++ internal/broker/users.go | 3 + internal/broker/waits.go | 100 ++++++++++++++ internal/broker/waits_test.go | 135 +++++++++++++++++++ internal/catalogue/manifest.go | 48 +++++++ internal/catalogue/waits_test.go | 43 ++++++ internal/inventory/busrecords.go | 28 ++++ internal/inventory/waits_grants_test.go | 167 ++++++++++++++++++++++++ 9 files changed, 537 insertions(+) create mode 100644 internal/broker/waits.go create mode 100644 internal/broker/waits_test.go create mode 100644 internal/catalogue/waits_test.go create mode 100644 internal/inventory/waits_grants_test.go diff --git a/internal/broker/membership.go b/internal/broker/membership.go index fc3d6ed7..66138254 100644 --- a/internal/broker/membership.go +++ b/internal/broker/membership.go @@ -66,6 +66,10 @@ type Membership struct { // consumer on that stream the runtime binds beside the one on EVENTS (§8). Streams []StreamIssued `json:"streams,omitempty"` Hears []HeardIssued `json:"hears,omitempty"` + // Waits are the event families a session may wait on through the mesh MCP server, the tool runner's + // alone (novox/hq ADR 0367 §2): the subjects its account is granted, under the names a session asks for. + // The tool runner refuses any family not listed here. + Waits []WaitIssued `json:"waits,omitempty"` } // Served is one address a tool is answered on. @@ -133,6 +137,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership { m.Logs = logsIssuedFor(d) m.Streams = streamsIssuedFor(d) m.Hears = HeardIssuedFor(node, d) + m.Waits = waitsIssuedFor(d) t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches) for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) { if !s.Kinded { diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 9323c6fd..6554a0b0 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -883,6 +883,14 @@ func PermissionsFor(p Principal) (Permissions, error) { for _, d := range p.Carries { pub = append(pub, logGrants(d.Module, logNames(d.Logs))...) } + // **And the event families a session may wait on** (novox/hq ADR 0367 §2): the tool runner's module's own, + // named one by one in its manifest, as a plain subscription — no consumer is made for them, and no + // call or setting adds one. + waits, err := waitGrants(p.Carries) + if err != nil { + return Permissions{}, err + } + sub = append(sub, waits...) // **Never the traffic of a trusted holder** (novox/hq ADR 0259 §8): the machine's runtime runs as the // operator's account, which every agent runs as, so a module saying warrants or speaking for a kind // that proves its sender is never composed into it — refused here, naming it, whatever registration diff --git a/internal/broker/users.go b/internal/broker/users.go index 2c0ea4c5..7ec4beb3 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -36,6 +36,9 @@ type Declared struct { Watches []Seat // Invokes are the tools it calls, `.` or `*` (novox/hq ADR 0152). Invokes []string + // Waits are the event families a session may wait on through the tool runner, the tool runner's module's + // alone (novox/hq ADR 0367 §2): its account subscribes them for a call, through no consumer. + Waits []Wait // State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201). State []Bucket // Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297). diff --git a/internal/broker/waits.go b/internal/broker/waits.go new file mode 100644 index 00000000..a751d947 --- /dev/null +++ b/internal/broker/waits.go @@ -0,0 +1,100 @@ +package broker + +import ( + "fmt" + "strings" +) + +// What a session may wait on through the mesh MCP server (novox/hq ADR 0367). +// +// **A grant apart from `consumes`, and the tool runner's alone.** The tool runner's module names in its +// manifest the event families a session may wait on, one by one; the runtime's account is granted a plain +// subscription on those subjects and nothing wider — no durable consumer, no queue of its own — and its +// membership names the same list, from which `mesh_wait` refuses any other. Both are read from this one +// composition, so the grant and what the tool runner offers cannot disagree, and nothing but the manifest +// adds a family (§6). + +// Wait is one event family the tool runner's module declares: its name as the manifest gives it, and the +// emitter — a seat or a module — and the event, as the inventory resolved them. +type Wait struct { + // Name is the family as the manifest names it, `.`: what a session asks for. + Name string + Emitter string + // Seat says the emitter is a seat, whose events live under the seat's namespace (novox/hq ADR 0121). + Seat bool + // Event is the event, with any further token the subject carries: `decided.claude-code`, the asker of a + // seat that names it last (ADR 0259 §3). + Event string + // Last names what the seat's event subject ends in — "kind" for a kinded bench, "asker" for a seat naming + // its caller (ADR 0259 §3) — and is empty for an event that ends at its name. A family needing it is waited + // on with it given, and refused without. + Last string +} + +// WaitIssued is one family as a membership issues it: the name a session asks for, and its subject. +type WaitIssued struct { + Event string `json:"event"` + Subject string `json:"subject"` +} + +// WaitSubject is where a family's events land. Each token is checked before it becomes a subject: a +// wildcard, a dot inside a name or an empty token would widen the grant. +func WaitSubject(w Wait) (string, error) { + if !safeSubject.MatchString(w.Emitter) { + return "", fmt.Errorf("%q cannot name an emitter of a waited event: it would widen the subject", w.Emitter) + } + for _, t := range strings.Split(w.Event, ".") { + if !safeSubject.MatchString(t) { + return "", fmt.Errorf("%q cannot name a waited event: each of its names is plain, never a wildcard "+ + "(novox/hq ADR 0367 §2)", w.Event) + } + } + if w.Last != "" && !strings.Contains(w.Event, ".") { + return "", fmt.Errorf("%s names no %s: %s's events are said for each %s, so a family is %s.%s.<%s>; "+ + "without it no event would ever be heard (novox/hq ADR 0367 §5)", + w.Name, w.Last, w.Emitter, w.Last, w.Emitter, w.Event, w.Last) + } + if w.Seat { + return "mesh.seat." + w.Emitter + ".event." + w.Event, nil + } + return "mesh.mod." + w.Emitter + ".event." + w.Event, nil +} + +// waitGrants is the subscription the tool runner's account is granted for its module's families, and an +// error for a carried module other than the tool runner's that declares any (ADR 0367 §2). +func waitGrants(carries []Declared) ([]string, error) { + var sub []string + for _, d := range carries { + if len(d.Waits) == 0 { + continue + } + if d.Module != RuntimeModule { + return nil, fmt.Errorf("%s declares what a session waits on; only %s, the tool runner's module, "+ + "does (novox/hq ADR 0367 §2)", d.Module, RuntimeModule) + } + for _, w := range d.Waits { + s, err := WaitSubject(w) + if err != nil { + return nil, err + } + sub = append(sub, s) + } + } + return sub, nil +} + +// waitsIssuedFor is the families a membership names: the tool runner's module's, and nobody else's. +func waitsIssuedFor(d Declared) []WaitIssued { + if d.Module != RuntimeModule { + return nil + } + var out []WaitIssued + for _, w := range d.Waits { + s, err := WaitSubject(w) + if err != nil { + continue // refused where the account is composed, which names it + } + out = append(out, WaitIssued{Event: w.Name, Subject: s}) + } + return out +} diff --git a/internal/broker/waits_test.go b/internal/broker/waits_test.go new file mode 100644 index 00000000..cfc0b1a2 --- /dev/null +++ b/internal/broker/waits_test.go @@ -0,0 +1,135 @@ +package broker + +import ( + "reflect" + "sort" + "strings" + "testing" +) + +// The families ADR 0367 §5 starts the grant with, resolved as the inventory resolves a manifest: a seat's +// event under the seat's namespace, a module's under the module's. +var firstFamilies = []Wait{ + {Name: "mesh-knowledge.reviewed", Emitter: "mesh-knowledge", Event: "reviewed"}, + {Name: "mesh-delivery.transition", Emitter: "mesh-delivery", Event: "transition"}, + {Name: "gitea.forge.status", Emitter: "gitea", Event: "forge.status"}, + {Name: "mesh-controller.checked", Emitter: "mesh-controller", Seat: true, Event: "checked"}, + {Name: "operator-channel.decided.claude-code", Emitter: "operator-channel", Seat: true, Event: "decided.claude-code"}, +} + +var firstSubjects = []string{ + "mesh.mod.gitea.event.forge.status", + "mesh.mod.mesh-delivery.event.transition", + "mesh.mod.mesh-knowledge.event.reviewed", + "mesh.seat.mesh-controller.event.checked", + "mesh.seat.operator-channel.event.decided.claude-code", +} + +func runtimeCarrying(waits []Wait) Principal { + return Principal{Kind: KindNodeTools, Node: "desk", Module: RuntimeModule, PasswordHash: "x", Carries: []Declared{ + {Module: "shop", Emits: []string{"order.placed"}, Consumes: []string{"billing.paid"}, Serves: []string{"price"}}, + {Module: RuntimeModule, Invokes: []string{"*"}, Waits: waits}, + }} +} + +func without(all, some []string) []string { + drop := map[string]bool{} + for _, s := range some { + drop[s] = true + } + var out []string + for _, s := range all { + if !drop[s] { + out = append(out, s) + } + } + return out +} + +// novox/hq ADR 0367 "How it is checked": the account's wait grant equals the manifest's families. The tool +// runner's account subscribes exactly the families' subjects beyond what it had without them — nothing +// published, nothing wider — and the membership it is issued names the same subjects. +func TestTheToolRunnersWaitGrantIsTheManifestsFamiliesAlone(t *testing.T) { + before, err := PermissionsFor(runtimeCarrying(nil)) + if err != nil { + t.Fatal(err) + } + after, err := PermissionsFor(runtimeCarrying(firstFamilies)) + if err != nil { + t.Fatal(err) + } + added := without(after.Subscribe, before.Subscribe) + sort.Strings(added) + if !reflect.DeepEqual(added, firstSubjects) { + t.Errorf("the wait grant is %v, want the families' subjects %v", added, firstSubjects) + } + if extra := without(after.Publish, before.Publish); len(extra) > 0 { + t.Errorf("waiting granted a publish: %v", extra) + } + for _, s := range after.Subscribe { + if strings.HasPrefix(s, "mesh.mod.*.event") || s == "mesh.>" || s == ">" || strings.HasSuffix(s, ".event.>") { + t.Errorf("the runtime may subscribe every event: %s", s) + } + } + for _, other := range []string{"mesh.seat.operator-channel.event.decided.shop", + "mesh.mod.mesh-knowledge.event.proposed", "mesh.mod.gitea.event.forge.push"} { + if MaySubscribe(after, other) { + t.Errorf("the runtime may subscribe %s, which no family names", other) + } + } + + issued := MembershipFor("desk", runtimeCarrying(firstFamilies).Carries[1], Placements{}) + var subjects []string + for _, w := range issued.Waits { + subjects = append(subjects, w.Subject) + } + sort.Strings(subjects) + if !reflect.DeepEqual(subjects, firstSubjects) { + t.Errorf("the membership names %v, want the grant %v", subjects, firstSubjects) + } + if issued.Waits[0].Event != "mesh-knowledge.reviewed" { + t.Errorf("a family is issued under its manifest name: %+v", issued.Waits[0]) + } + if shop := MembershipFor("desk", runtimeCarrying(firstFamilies).Carries[0], Placements{}); len(shop.Waits) > 0 { + t.Errorf("a module beside the runtime is issued waits: %+v", shop.Waits) + } +} + +// **A subscription for the call, never a durable consumer** (ADR 0367 §2): composing the users makes no +// consumer that filters a family, and none for the runtime's module at all. +func TestAWaitMakesNoDurableConsumer(t *testing.T) { + for _, c := range ConsumersOf([]Principal{runtimeCarrying(firstFamilies)}) { + if c.Module == RuntimeModule { + t.Errorf("the runtime's module has a durable consumer: %+v", c.Consumer) + } + for _, f := range c.Consumer.Filters { + for _, s := range firstSubjects { + if f == s { + t.Errorf("%s's consumer filters the family %s", c.Module, s) + } + } + } + } +} + +// Only the tool runner's module waits (ADR 0367 §2): a carried module declaring waits is refused at +// composition, naming it, and no name that would widen a subject is composed. +func TestAWaitIsComposedOnlyForTheToolRunnerAndOnlyWhole(t *testing.T) { + p := runtimeCarrying(nil) + p.Carries[0].Waits = []Wait{{Name: "mesh-knowledge.reviewed", Emitter: "mesh-knowledge", Event: "reviewed"}} + if _, err := PermissionsFor(p); err == nil || !strings.Contains(err.Error(), "shop") { + t.Errorf("a carried module's waits were composed: %v", err) + } + for _, w := range []Wait{ + {Name: "x", Emitter: "mesh-knowledge", Event: "*"}, + {Name: "x", Emitter: "mesh-knowledge", Event: "a.>"}, + {Name: "x", Emitter: "*", Event: "reviewed"}, + {Name: "x", Emitter: "mesh.knowledge", Event: "reviewed"}, + {Name: "x", Emitter: "mesh-knowledge", Event: ""}, + {Name: "x", Emitter: "mesh-knowledge", Event: "a..b"}, + } { + if _, err := PermissionsFor(runtimeCarrying([]Wait{w})); err == nil { + t.Errorf("%+v was composed", w) + } + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index fa9d65e6..845b9122 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -513,6 +513,13 @@ type Manifest struct { // person's account (design 25 §7) already had the same shape. Invokes []string `json:"invokes,omitempty"` + // Waits are the event families a session may wait on through the mesh MCP server's `mesh_wait`, each + // `.` as `consumes` names one, a seat's or a module's (novox/hq ADR 0367 §2). Only the tool + // runner's module declares them, one by one and never by a wildcard. **A grant apart from `consumes`**: the + // controller grants the tool runner's account a plain subscription on those subjects alone, with no + // durable consumer, and issues the same list in its membership; no call or setting adds one (§6). + Waits []string `json:"waits,omitempty"` + // State is the current state this module keeps on the bus, by local name: each a key-value // bucket the controller creates, which every instance of the module writes and reads // (novox/hq ADR 0201). Not history — that is an event — and never a secret, sealed or not. @@ -1971,6 +1978,7 @@ func ParseManifest(raw []byte) (Manifest, error) { } } problems = append(problems, invokeProblems(m)...) + problems = append(problems, waitProblems(m)...) problems = append(problems, replacesProblems(m)...) problems = append(problems, UnitSettingProblems(m)...) problems = append(problems, OwnerSettingProblems(m)...) @@ -2694,6 +2702,46 @@ func invokeProblems(m Manifest) []string { return problems } +// waitToken is one name of an event family's subject: never a wildcard, never a dot, never empty. +var waitToken = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +// waitProblems judges the event families a module says a session may wait on (novox/hq ADR 0367 §2, §6): +// the tool runner's alone, each named whole — emitter and event, with no wildcard — and each once. Refused +// here, in the manifest's words, rather than at the composition of the bus's users. +func waitProblems(m Manifest) []string { + if len(m.Waits) == 0 { + return nil + } + if m.Module != RuntimeModule { + return []string{fmt.Sprintf("%s says what a session waits on; only %s, the tool runner's module, "+ + "declares waits (novox/hq ADR 0367 §2)", m.Module, RuntimeModule)} + } + var problems []string + seen := map[string]bool{} + for _, w := range m.Waits { + if strings.ContainsAny(w, "*>") { + problems = append(problems, fmt.Sprintf("%s waits on %q: a family is named one by one, never a "+ + "wildcard (novox/hq ADR 0367 §2)", m.Module, w)) + continue + } + tokens := strings.Split(w, ".") + whole := len(tokens) >= 2 && name.MatchString(tokens[0]) + for _, t := range tokens[1:] { + whole = whole && waitToken.MatchString(t) + } + if !whole { + problems = append(problems, fmt.Sprintf("%s waits on %q, which does not name an emitter and an "+ + "event: a family is ., as consumes names one", m.Module, w)) + continue + } + if seen[w] { + problems = append(problems, fmt.Sprintf("%s waits on %q twice", m.Module, w)) + } + seen[w] = true + } + return problems +} + // How a module takes one of its own secrets (ADR 0114): read from the file when it starts, or // applied by its own code to a backend that takes it once. const ( diff --git a/internal/catalogue/waits_test.go b/internal/catalogue/waits_test.go new file mode 100644 index 00000000..adf2080f --- /dev/null +++ b/internal/catalogue/waits_test.go @@ -0,0 +1,43 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// novox/hq ADR 0367 §2: the tool runner's module names, one by one, the event families a session may wait on. +func TestTheToolRunnerMayNameTheEventsASessionWaitsOn(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"node-tools","version":"1",` + + `"waits":["mesh-knowledge.reviewed","operator-channel.decided.claude-code"]}`)) + if err != nil { + t.Fatal(err) + } + if len(m.Waits) != 2 || m.Waits[1] != "operator-channel.decided.claude-code" { + t.Fatalf("waits not read: %v", m.Waits) + } +} + +// A family is named one by one, never by a wildcard, and only by the tool runner's module (ADR 0367 §2, §6): +// each refusal is said in the manifest's words, at parse, rather than at the composition of the bus's users. +func TestAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner(t *testing.T) { + for manifest, want := range map[string]string{ + `{"module":"shop","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only node-tools", + `{"module":"node-tools","version":"1","waits":["mesh-knowledge.*"]}`: "never a wildcard", + `{"module":"node-tools","version":"1","waits":["**"]}`: "never a wildcard", + `{"module":"node-tools","version":"1","waits":["mesh-knowledge.>"]}`: "never a wildcard", + `{"module":"node-tools","version":"1","waits":["*.reviewed"]}`: "never a wildcard", + `{"module":"node-tools","version":"1","waits":["reviewed"]}`: "does not name an emitter and an event", + `{"module":"node-tools","version":"1","waits":["mesh-knowledge..reviewed"]}`: "does not name an emitter and an event", + `{"module":"node-tools","version":"1","waits":["Mesh-Knowledge.reviewed x"]}`: "does not name an emitter and an event", + `{"module":"node-tools","version":"1","waits":["a.b","a.b"]}`: "twice", + } { + _, err := ParseManifest([]byte(manifest)) + if err == nil { + t.Errorf("accepted: %s", manifest) + continue + } + if !strings.Contains(err.Error(), want) { + t.Errorf("%s refused for the wrong reason (want %q): %v", manifest, want, err) + } + } +} diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index 8ce9c330..e8cf32f5 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -178,6 +178,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio Serves: m.Tools, // And what it calls (novox/hq ADR 0152) — the console's `*`, nothing else's. Invokes: m.Invokes, + // And the event families a session may wait on, the tool runner's alone (novox/hq ADR 0367 §2). + Waits: waitsOf(m, seats), // And the state it keeps and reads (novox/hq ADR 0201). State: bucketsOf(m), Reads: m.Reads, @@ -219,6 +221,32 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio return d } +// waitsOf is the event families a manifest says a session may wait on, each resolved as a consumed name is +// (novox/hq ADR 0121, ADR 0239): a seat's event when the seat says it, else the event of the module of that +// name. A seat whose event subjects end in a kind (a kinded bench) or in the asker (ADR 0259 §3) is waited on +// with that last name given — `decided.claude-code` — and the family says which it needs, so one given without +// it is refused where it is composed rather than granted a subject nothing publishes on. +func waitsOf(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration) []broker.Wait { + var out []broker.Wait + for _, w := range m.Waits { + emitter, event, _ := strings.Cut(w, ".") + first, _, _ := strings.Cut(event, ".") + wait := broker.Wait{Name: w, Emitter: emitter, Event: event} + if s, isASeat := seats[emitter]; isASeat { + switch { + case s.Kinded && catalogue.SeatSays(s.Emits, first): + wait.Seat, wait.Last = true, "kind" + case s.NamedByCaller(first) && catalogue.SeatSays(s.Emits, first): + wait.Seat, wait.Last = true, "asker" + case catalogue.SeatSays(s.Emits, event): + wait.Seat = true + } + } + out = append(out, wait) + } + return out +} + // issuedTools are the tools through which a module answers the credentials its software issues (novox/hq ADR 0305). func issuedTools(m catalogue.Manifest) []string { var out []string diff --git a/internal/inventory/waits_grants_test.go b/internal/inventory/waits_grants_test.go new file mode 100644 index 00000000..c3841b4e --- /dev/null +++ b/internal/inventory/waits_grants_test.go @@ -0,0 +1,167 @@ +package inventory + +import ( + "reflect" + "sort" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/catalogue" +) + +// The tool runner's manifest with the families novox/hq ADR 0367 §5 starts the grant with, and one more. +const runtimeWaiting = `{"module": "node-tools", "version": "1", "invokes": ["*"], "waits": [%s]}` + +var adr0367Families = []string{`"mesh-knowledge.reviewed"`, `"mesh-delivery.transition"`, `"gitea.forge.status"`, + `"mesh-controller.checked"`, `"operator-channel.decided.claude-code"`} + +// composeWaits composes the bus's users from manifests as BusRecords does — the router declaring the operator +// channel, the mesh's own seats, a person granted every tool — and answers the tool runner's account's +// subscriptions on events, and the subjects its membership names. +func composeWaits(t *testing.T, families []string, people map[string][]string) (granted, issued []string) { + t.Helper() + parse := func(s string) catalogue.Manifest { + m, err := catalogue.ParseManifest([]byte(s)) + if err != nil { + t.Fatal(err) + } + return m + } + router := parse(`{"module": "messenger", "version": "1", "runs-as": "messenger", + "seats": [{"name": "operator-channel", "scope": "mesh", "serves": ["open"], "accepts": ["ask"], + "emits": ["decided"], "by-caller": ["ask", "decided"]}], + "claims": [{"name": "operator-channel", "scope": "mesh", "serves": ["open"]}], + "own-secrets": {"broker": "${dir:state}/broker"}, "secrets-owner": "messenger", + "resources": [{"id": "account", "type": "user", "name": "messenger", "shell": "/usr/bin/nologin", "home": "/var/lib/messenger"}, + {"id": "state", "type": "directory", "mode": "0700", "place": ".", "owner": "messenger"}]}`) + asker := parse(`{"module": "claude-code", "version": "1", "consumes": ["mesh-knowledge.reviewed"], + "own-secrets": {"broker": "${dir:state}/broker"}, "resources": [{"id": "state", "type": "directory", "mode": "0700", "place": "."}]}`) + runtime := parse(strings.Replace(runtimeWaiting, "%s", strings.Join(families, ", "), 1)) + manifests := []catalogue.Manifest{router, asker, runtime} + seats := map[string]catalogue.SeatDeclaration{} + declarers := map[string]string{} + for _, m := range manifests { + for _, s := range m.DefinesSeats { + seats[s.Name], declarers[s.Name] = s, m.Module + } + } + for _, own := range catalogue.SeatsWithAProtocol() { + seats[own.Name] = catalogue.SeatDeclaration{Name: own.Name, Scope: own.Scope, Accepts: own.Accepts, + Emits: own.Emits, Serves: own.Serves} + } + records := broker.Records{Nodes: []string{"laptop"}, Assigned: map[string][]broker.Declared{}, + People: people, Interchangeable: map[string]bool{}} + for _, m := range manifests { + records.Assigned["laptop"] = append(records.Assigned["laptop"], declaredFor(m, seats, declarers)) + } + users, err := broker.Users(records) + if err != nil { + t.Fatal(err) + } + for _, u := range users { + if u.Kind != broker.KindNodeTools { + continue + } + p, err := broker.PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + for _, s := range p.Subscribe { + if strings.Contains(s, ".event.") { + granted = append(granted, s) + } + } + } + for _, d := range records.Assigned["laptop"] { + for _, w := range broker.MembershipFor("laptop", d, broker.PlacementsOf(records, nil)).Waits { + issued = append(issued, w.Subject) + } + } + sort.Strings(granted) + sort.Strings(issued) + return granted, issued +} + +// novox/hq ADR 0367 "How it is checked": the account's wait grant equals the manifest's families — a seat's +// event under the seat, the asker's own `decided` alone, a module's under the module — and the membership +// names the same; no person's grant, however wide, widens it; a family is granted only once the manifest +// that adds it is composed. +func TestTheWaitGrantIsTheToolRunnersManifestAndNothingElse(t *testing.T) { + want := []string{ + "mesh.mod.gitea.event.forge.status", + "mesh.mod.mesh-delivery.event.transition", + "mesh.mod.mesh-knowledge.event.reviewed", + "mesh.seat.mesh-controller.event.checked", + "mesh.seat.operator-channel.event.decided.claude-code", + } + granted, issued := composeWaits(t, adr0367Families, map[string][]string{}) + if !reflect.DeepEqual(granted, want) { + t.Errorf("the tool runner hears %v, want the manifest's families %v", granted, want) + } + if !reflect.DeepEqual(issued, want) { + t.Errorf("its membership names %v, want the grant %v", issued, want) + } + // No call or setting widens it: a person granted every tool, an operator's account, changes nothing. + if wide, _ := composeWaits(t, adr0367Families, map[string][]string{"operator": {"*"}}); !reflect.DeepEqual(wide, want) { + t.Errorf("a person's grant widened the wait grant: %v", wide) + } + // And without the families it hears no event at all (ADR 0043 §3): a module beside it consuming one + // lends it nothing. + if none, issuedNone := composeWaits(t, nil, map[string][]string{}); len(none)+len(issuedNone) > 0 { + t.Errorf("a tool runner declaring no waits hears %v, issued %v", none, issuedNone) + } + // A family is granted only after the manifest change that adds it is composed. + more := append(append([]string{}, adr0367Families...), `"mesh-knowledge.accepted"`) + grown, _ := composeWaits(t, more, map[string][]string{}) + if added := without(grown, want); !reflect.DeepEqual(added, []string{"mesh.mod.mesh-knowledge.event.accepted"}) { + t.Errorf("adding one family to the manifest granted %v", added) + } +} + +func without(all, some []string) []string { + drop := map[string]bool{} + for _, s := range some { + drop[s] = true + } + var out []string + for _, s := range all { + if !drop[s] { + out = append(out, s) + } + } + return out +} + +// A family of a seat whose event subjects end in a kind (a kinded bench) or in the asker (a seat naming its +// caller) is composed with that last token, and refused without it, naming the family: a subject without it +// is one nothing publishes on, and a wait on it would never hear (the review of issue 745). +func TestAWaitOnASeatNamingAKindOrAnAskerNeedsThatName(t *testing.T) { + seats := map[string]catalogue.SeatDeclaration{ + "intake": {Name: "intake", Kinded: true, Emits: []string{"choice", "failed"}}, + "operator-channel": {Name: "operator-channel", Emits: []string{"decided"}, ByCaller: []string{"ask", "decided"}}, + } + compose := func(family string) (string, error) { + m := catalogue.Manifest{Module: broker.RuntimeModule, Waits: []string{family}} + waits := waitsOf(m, seats) + return broker.WaitSubject(waits[0]) + } + if s, err := compose("intake.choice.telegram"); err != nil || s != "mesh.seat.intake.event.choice.telegram" { + t.Errorf("a kind's event: %q, %v", s, err) + } + if s, err := compose("operator-channel.decided.claude-code"); err != nil || s != "mesh.seat.operator-channel.event.decided.claude-code" { + t.Errorf("an asker's event: %q, %v", s, err) + } + for family, want := range map[string]string{"intake.choice": "kind", "operator-channel.decided": "asker"} { + s, err := compose(family) + if err == nil || !strings.Contains(err.Error(), family) || !strings.Contains(err.Error(), want) { + t.Errorf("%s composed as %q (%v), want it refused for want of its %s", family, s, err, want) + } + } + // Refused where the bus's users are composed, naming it, so the merge gate is red before it lands. + p := broker.Principal{Kind: broker.KindNodeTools, Node: "laptop", Module: broker.RuntimeModule, Carries: []broker.Declared{ + {Module: broker.RuntimeModule, Waits: waitsOf(catalogue.Manifest{Module: broker.RuntimeModule, Waits: []string{"intake.choice"}}, seats)}}} + if _, err := broker.PermissionsFor(p); err == nil || !strings.Contains(err.Error(), "intake.choice") { + t.Errorf("the runtime was composed with a family no subject carries: %v", err) + } +} -- 2.54.0