Files
mesh-controller/internal/inventory/waits_grants_test.go
T
mesh-toolsandClaude Opus 5.5 755457cc37
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…
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 <noreply@anthropic.com>
2026-10-14 23:05:14 +00:00

168 lines
7.6 KiB
Go

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