Let the tool runner's account hear the event families its manifest names (issue 745, ADR 0367) #342
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user