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) + } +}