Let the tool runner's account hear the event families its manifest names (issue 745, ADR 0367)
mesh/merge-gate head 755457cc379e merged into trunk 4cc6b6c566a5 — pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus…
mesh/repo-check head 755457cc379e merged into trunk 4cc6b6c566a5 — pass: its merge-check.sh passed
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792019685173332639 (novox/mesh-controller at 0b7e740b into ma…

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 23:05:14 +00:00
co-authored by Claude Opus 5.5
parent 4cc6b6c566
commit 755457cc37
9 changed files with 537 additions and 0 deletions
+28
View File
@@ -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
+167
View File
@@ -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)
}
}