diff --git a/go/buscompose/events.go b/go/buscompose/events.go new file mode 100644 index 00000000..49302145 --- /dev/null +++ b/go/buscompose/events.go @@ -0,0 +1,106 @@ +package buscompose + +import ( + "fmt" + "regexp" + "strings" +) + +// The one place the mesh's event subjects are named (design 34 every-event §1, §25, §26), so neither the grants part +// nor the tool runner spells the event namespace itself. + +// The namespaces an event lands in, as `from` names them (design 34 every-event §13, §26). +const ( + fromSeat = "seat" + fromModule = "module" + fromStream = "stream" +) + +// eventNamespaces are the three, each with the prefix its subjects start with, in EveryEvent's order. +var eventNamespaces = []struct{ from, prefix string }{ + {fromSeat, "mesh.seat."}, + {fromModule, "mesh.mod."}, + {fromStream, moduleStreamPrefix}, +} + +// EveryEvent is the subjects the tool runner's account hears: every seat's events, every module's, and every module +// stream's, a restricted one included, in that order (novox/hq ADR 0670 §1, ADR 0671 §1). +func EveryEvent() []string { + out := make([]string, 0, len(eventNamespaces)) + for _, ns := range eventNamespaces { + out = append(out, ns.prefix+"*.event.>") + } + return out +} + +// eventToken is the form of each token of an event name. +var eventToken = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// EventSubjects is the subjects one event name lands on. A name is `.`, optionally followed by further +// tokens (`operator-channel.decided.claude-code`), each token of the form `[A-Za-z0-9_-]+`. With from empty it answers +// six subjects, per namespace (seat, module, stream) `mesh...event.` and the same followed by `.>`; +// with from `seat`, `module` or `stream` only that namespace's two. It refuses, naming the rule, a name of fewer than two +// tokens, an empty token or a token outside that form (so `*` and `>` are refused), and a from of any other word. +func EventSubjects(name, from string) ([]string, error) { + tokens := strings.Split(name, ".") + if len(tokens) < 2 { + return nil, fmt.Errorf("event name %q has fewer than two tokens: a name is .", name) + } + for _, t := range tokens { + if !eventToken.MatchString(t) { + return nil, fmt.Errorf("event name %q has the token %q: each token of a name matches [A-Za-z0-9_-]+, "+ + "so no token is empty and none is * or >", name, t) + } + } + emitter, rest := tokens[0], strings.Join(tokens[1:], ".") + var out []string + for _, ns := range eventNamespaces { + if from != "" && from != ns.from { + continue + } + subject := ns.prefix + emitter + ".event." + rest + out = append(out, subject, subject+".>") + } + if out == nil { + return nil, fmt.Errorf("from %q is none of %s, %s or %s", from, fromSeat, fromModule, fromStream) + } + return out, nil +} + +// EventName is the inverse of EventSubjects for a heard subject: for `mesh.seat..event.`, +// `mesh.mod..event.` or `mesh.stream..event.` it answers the name `.` and +// its namespace (`seat`, `module` or `stream`); any other subject is answered as not an event (ok false). +func EventName(subject string) (name, from string, ok bool) { + for _, ns := range eventNamespaces { + after, found := strings.CutPrefix(subject, ns.prefix) + if !found { + continue + } + emitter, rest, found := strings.Cut(after, ".event.") + if !found || emitter == "" || rest == "" || strings.Contains(emitter, ".") { + return "", "", false + } + return emitter + "." + rest, ns.from, true + } + return "", "", false +} + +// Overlaps says whether some subject matches both a and b, a wildcard on either side: `*` stands for one token and +// `>`, last, for one token or more (design 34 every-event §34). +func Overlaps(a, b string) bool { + at, bt := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; ; i++ { + switch { + case i == len(at) && i == len(bt): + return true + case i == len(at) || i == len(bt): + return false + case at[i] == ">" || bt[i] == ">": + return true + case at[i] == "*" || bt[i] == "*" || at[i] == bt[i]: + continue + default: + return false + } + } +} diff --git a/go/buscompose/events_test.go b/go/buscompose/events_test.go new file mode 100644 index 00000000..7c6c962e --- /dev/null +++ b/go/buscompose/events_test.go @@ -0,0 +1,98 @@ +package buscompose + +import ( + "reflect" + "strings" + "testing" +) + +// The mesh's event subjects are named in one place: EveryEvent, EventSubjects, EventName and Overlaps (design 34 +// every-event §1, §25, §26, §34, §37). +func TestTheEventSubjectsAreNamedInOnePlace(t *testing.T) { + // EE-S1/AC1 (E §1, §25): the three subjects, in order. + if got, want := EveryEvent(), []string{"mesh.seat.*.event.>", "mesh.mod.*.event.>", "mesh.stream.*.event.>"}; !reflect.DeepEqual(got, want) { + t.Fatalf("EveryEvent is %v, want %v", got, want) + } + + // EE-S1/AC2, AC3 (E §1, §26): a name's subjects, all six or one namespace's two, and the refusals naming the rule. + subjects := []struct { + name, from string + want []string + refused string + }{ + {name: "operator-channel.decided.claude-code", want: []string{ + "mesh.seat.operator-channel.event.decided.claude-code", "mesh.seat.operator-channel.event.decided.claude-code.>", + "mesh.mod.operator-channel.event.decided.claude-code", "mesh.mod.operator-channel.event.decided.claude-code.>", + "mesh.stream.operator-channel.event.decided.claude-code", "mesh.stream.operator-channel.event.decided.claude-code.>", + }}, + {name: "mesh-delivery-view.delivered", from: "seat", want: []string{ + "mesh.seat.mesh-delivery-view.event.delivered", "mesh.seat.mesh-delivery-view.event.delivered.>"}}, + {name: "mesh-delivery-view.delivered", from: "module", want: []string{ + "mesh.mod.mesh-delivery-view.event.delivered", "mesh.mod.mesh-delivery-view.event.delivered.>"}}, + {name: "mesh-mail.mail", from: "stream", want: []string{ + "mesh.stream.mesh-mail.event.mail", "mesh.stream.mesh-mail.event.mail.>"}}, + {name: "mesh-delivery-view", refused: "fewer than two tokens"}, + {name: "mesh-delivery-view..delivered", refused: "[A-Za-z0-9_-]+"}, + {name: "mesh-delivery-view.*", refused: "[A-Za-z0-9_-]+"}, + {name: "mesh-delivery-view.>", refused: "[A-Za-z0-9_-]+"}, + {name: "mesh-delivery-view.delivered", from: "bench", refused: "none of seat, module or stream"}, + } + for _, c := range subjects { + got, err := EventSubjects(c.name, c.from) + if c.refused != "" { + if err == nil || !strings.Contains(err.Error(), c.refused) { + t.Errorf("EventSubjects(%q, %q) answered %v, %v; want refused naming %q", c.name, c.from, got, err, c.refused) + } + continue + } + if err != nil || !reflect.DeepEqual(got, c.want) { + t.Errorf("EventSubjects(%q, %q) answered %v, %v; want %v", c.name, c.from, got, err, c.want) + } + // EE-S1/AC5 (E §1): every subject it answers is covered by one of EveryEvent's. + for _, s := range got { + covered := false + for _, e := range EveryEvent() { + covered = covered || Overlaps(e, s) + } + if !covered { + t.Errorf("%s is covered by none of EveryEvent", s) + } + } + } + + // EE-S1/AC4 (E §1, §26): a heard subject's name and namespace, and any other subject not an event. + names := []struct{ subject, name, from string }{ + {"mesh.seat.operator-channel.event.decided.claude-code", "operator-channel.decided.claude-code", "seat"}, + {"mesh.mod.mesh-delivery-view.event.delivered", "mesh-delivery-view.delivered", "module"}, + {"mesh.stream.mesh-delivery-checker.event.verdict", "mesh-delivery-checker.verdict", "stream"}, + {"mesh.seat.intake.call.accept.ace", "", ""}, + {"mesh.mod.netcheck.call.netcheck_dns", "", ""}, + {"$KV.views.x", "", ""}, + {"mesh.seat..event.x", "", ""}, + } + for _, c := range names { + name, from, ok := EventName(c.subject) + if name != c.name || from != c.from || ok != (c.name != "") { + t.Errorf("EventName(%q) answered %q, %q, %v; want %q, %q", c.subject, name, from, ok, c.name, c.from) + } + } + + // EE-S1/AC6 (E §34, §37): overlapping subjects, a wildcard on either side. + overlaps := []struct { + a, b string + want bool + }{ + {"mesh.stream.mail.event.mail.>", "mesh.stream.mail.event.mail.*.message", true}, + {"mesh.mod.mail.event.x", "mesh.stream.mail.event.x", false}, + {"mesh.mod.mail.event.x.>", "mesh.mod.mail.event.x", false}, + {"mesh.mod.mail.event.*", "mesh.mod.*.event.x", true}, + {"mesh.mod.mail.event.x", "mesh.mod.mail.event.x", true}, + {"mesh.mod.*.event.>", "mesh.mod.mail.event.x.ace", true}, + {"mesh.mod.mail.event.x.ace", "mesh.mod.mail.event.x", false}, + } + for _, c := range overlaps { + if Overlaps(c.a, c.b) != c.want || Overlaps(c.b, c.a) != c.want { + t.Errorf("Overlaps(%q, %q) is not %v both ways", c.a, c.b, c.want) + } + } +} diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 473b041d..46356436 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -22,9 +22,6 @@ 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/go/buscompose/waits.go b/go/buscompose/waits.go deleted file mode 100644 index b10e6c08..00000000 --- a/go/buscompose/waits.go +++ /dev/null @@ -1,18 +0,0 @@ -package buscompose - -// 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 -} diff --git a/go/catalogue/invokes_test.go b/go/catalogue/invokes_test.go index e7725b7d..dd0a1aa1 100644 --- a/go/catalogue/invokes_test.go +++ b/go/catalogue/invokes_test.go @@ -39,6 +39,11 @@ func TestAManifestMaySayWhatItInvokes(t *testing.T) { if back, _ := json.Marshal(runner); !strings.Contains(string(back), `"tool-runner":true`) { t.Errorf("a tool runner written back as %s", back) } + // EE-S2/AC1: the tool runner names no events to wait on any more (novox/hq ADR 0670 §2): a stored manifest still + // carrying `waits` is read without it, the word named unknown (design 34 every-event §2). + if old, err := decodeForTest([]byte(`{"module":"node-tools","version":"1","tool-runner":true,"waits":["a.b"]}`)); err != nil || !strings.Contains(old.UnknownField(), `"waits"`) { + t.Errorf("a manifest carrying waits: %v, unknown %q", err, old.UnknownField()) + } other, _ := decodeForTest([]byte(`{"module":"node-tools","version":"1"}`)) if got, ok := ToolRunnerOf([]Manifest{other, runner}); !ok || got.Module != "any-name" { t.Errorf("the tool runner found is %q, %v: by what it says, never by its name", got.Module, ok) diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index 38c08f1f..5a476c97 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -587,13 +587,6 @@ 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. @@ -812,7 +805,7 @@ type Manifest struct { OwnSecrets OwnSecrets `json:"own-secrets,omitempty"` // ToolRunner says this module is the node's tool runner (issue 774): the one program on each node that loads every - // assigned module's tools bundle, served by its runtime process and the only module that may declare `waits`. Said by + // assigned module's tools bundle, served by its runtime process. Said by // the module, never by a name in mesh-sdk. ToolRunner bool `json:"tool-runner,omitempty"` diff --git a/go/catalogue/own_manifest_test.go b/go/catalogue/own_manifest_test.go index 6b3a47ea..5d52469c 100644 --- a/go/catalogue/own_manifest_test.go +++ b/go/catalogue/own_manifest_test.go @@ -49,8 +49,9 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) { } } // The two seats the milestone renamed (ADR 0634 §9) are read by their new names; a node-engine built with the baseline - // reads the old ones, and a manifest of the new names is the milestone's to deliver after the engines. - renamed := []string{"seat:mesh-enrolment", "seat:mesh-placement"} + // reads the old ones, and a manifest of the new names is the milestone's to deliver after the engines. The word + // `waits` is no longer read: novox/hq ADR 0670 §2 retired it. + renamed := []string{"seat:mesh-enrolment", "seat:mesh-placement", "word:waits"} for _, b := range baseline { if !slices.Contains(reads, b) && !slices.Contains(renamed, b) { t.Errorf("the baseline's %s is no reader capability of this contract", b) diff --git a/go/catalogue/waits_test.go b/go/catalogue/waits_test.go deleted file mode 100644 index 8cf7f8d7..00000000 --- a/go/catalogue/waits_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package catalogue - -import ( - "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 := decodeForTest([]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) - } -} diff --git a/go/membership/membership.go b/go/membership/membership.go index 0e493de2..0321d436 100644 --- a/go/membership/membership.go +++ b/go/membership/membership.go @@ -61,8 +61,6 @@ type Membership struct { // Streams are this module's own streams, Hears the module streams it hears (novox/hq ADR 0363). 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 (novox/hq ADR 0367 §2). - Waits []WaitIssued `json:"waits,omitempty"` // Bindings are the module's bus state as the bus module created it, one per entry its manifest asks for in // `requires` (novox/hq ADR 0595 §1, §7): written by the grants part when it hears the bus module's `created`. Bindings []BusBinding `json:"bindings,omitempty"` @@ -175,12 +173,6 @@ type HeardIssued struct { Consumer string `json:"consumer"` } -// 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"` -} - // StateIssued is the state this membership issues its module's code by a local name, and whether it does: what every // runtime that serves a module's state calls reads, so no reader keeps a second copy of the rule (novox/hq issue 1966). // An entry of the old `state` list wins (ADR 0201); otherwise a binding of a key-value entry the module asks for in