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…
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,9 @@ type Declared struct {
|
||||
Watches []Seat
|
||||
// Invokes are the tools it calls, `<module>.<tool>` 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).
|
||||
|
||||
@@ -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, `<emitter>.<event>`: 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user