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
+48
View File
@@ -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
// `<emitter>.<event>` 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 <emitter>.<event>, 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 (
+43
View File
@@ -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)
}
}
}