diff --git a/cmd/mesh-controller/agent_account.go b/cmd/mesh-controller/agent_account.go index 9436997e..e23d9b74 100644 --- a/cmd/mesh-controller/agent_account.go +++ b/cmd/mesh-controller/agent_account.go @@ -48,7 +48,8 @@ const agentAccountProbe = "DA" // // The one judgement: `node show`, the self-check, and ADR 0259's router honouring a verified sender read // it here. -func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) (named, confined bool, why string, err error) { +// now is the judging clock, threaded so a caller judging several things at one instant judges them all at it. +func agentConfined(ctx context.Context, inv *inventory.Inventory, node string, now time.Time) (named, confined bool, why string, err error) { n, err := inv.NodeByName(ctx, node) if err != nil { return false, false, "", err @@ -61,7 +62,7 @@ func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) ( if err != nil { return true, false, "", err } - confined, why = judgedConfined(n.AgentAccount, h, had, time.Now()) + confined, why = judgedConfined(n.AgentAccount, h, had, now) return true, confined, why, nil } @@ -228,7 +229,7 @@ func agentAccountLines(ctx context.Context, inv *inventory.Inventory, n inventor return []string{fmt.Sprintf(" agents run as the operator account (%s); no agent account is named", orNoneKnown(n.Account))} } - _, confined, why, err := agentConfined(ctx, inv, n.Name) + _, confined, why, err := agentConfined(ctx, inv, n.Name, time.Now()) if err != nil { return []string{fmt.Sprintf(" agents run as %s (home %s); whether it can become root could NOT be read: %v", n.AgentAccount, n.AgentHome(), err)} diff --git a/cmd/mesh-controller/agent_account_test.go b/cmd/mesh-controller/agent_account_test.go index 68143eca..f05790a1 100644 --- a/cmd/mesh-controller/agent_account_test.go +++ b/cmd/mesh-controller/agent_account_test.go @@ -106,10 +106,10 @@ func TestTheSelfCheckSaysAnAgentAccountThatCanBecomeRoot(t *testing.T) { if found, err = probeAgentAccounts(ctx, d); err != nil || len(onlyMachine(found, "anchor")) != 0 { t.Fatalf("a judged agent account still fails: %+v %v", found, err) } - if named, confined, why, err := agentConfined(ctx, inv, "anchor"); err != nil || !named || !confined { + if named, confined, why, err := agentConfined(ctx, inv, "anchor", time.Now()); err != nil || !named || !confined { t.Fatalf("agentConfined on anchor: %v %v %q %v", named, confined, why, err) } - if named, _, why, err := agentConfined(ctx, inv, "laptop"); err != nil || named || + if named, _, why, err := agentConfined(ctx, inv, "laptop", time.Now()); err != nil || named || !strings.Contains(why, "operator account") { t.Fatalf("agentConfined on a machine naming none: %v %q %v", named, why, err) } @@ -246,7 +246,7 @@ func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) { if found := say(link.StateUnknown, running); len(found) != 0 { t.Fatalf("a search first seen now was raised: %+v", found) } - if _, confined, why, _ := agentConfined(ctx, inv, "anchor"); confined || !strings.Contains(why, "not judged") { + if _, confined, why, _ := agentConfined(ctx, inv, "anchor", time.Now()); confined || !strings.Contains(why, "not judged") { t.Fatalf("an account whose search runs was read as confined: %q", why) } // The engine restarted again and again, each statement's own since fresh: the controller's clock runs on. diff --git a/cmd/mesh-controller/busobjects.go b/cmd/mesh-controller/busobjects.go index 71c99431..bb367a92 100644 --- a/cmd/mesh-controller/busobjects.go +++ b/cmd/mesh-controller/busobjects.go @@ -53,9 +53,26 @@ func assertBusObjects(ctx context.Context, inv *inventory.Inventory, r broker.Ra if err != nil { return nil, err } + // And the work queues of seats that name their caller or their kind, with each holder's worker + // (novox/hq ADR 0259 §3): an ask queues until the router takes it, a channel's work until that kind + // takes it. + trafficStreams, trafficWorkers, err := seatTrafficObjects(ctx, inv) + if err != nil { + return nil, err + } // Every one tried, and every failure named: one module's consumer the bus refuses is no reason // the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send). var failed []error + for _, s := range trafficStreams { + if err := r.EnsureStream(s); err != nil { + failed = append(failed, fmt.Errorf("the work queue %s: %w", s.Name, err)) + } + } + for _, c := range trafficWorkers { + if err := r.EnsureConsumer(c); err != nil { + failed = append(failed, fmt.Errorf("the worker %s on %s: %w", c.Name, c.Stream, err)) + } + } for _, c := range consumers { if err := r.EnsureConsumer(c.Consumer); err != nil { failed = append(failed, fmt.Errorf("how %s on %s hears what it consumes: %w", c.Module, c.Node, err)) @@ -130,6 +147,37 @@ func moduleConsumers(ctx context.Context, inv *inventory.Inventory) ([]broker.Mo return broker.ConsumersOf(users), nil } +// seatTrafficObjects is the work queues and workers of seats that name their caller or their kind, from +// the records the user list is composed from. +func seatTrafficObjects(ctx context.Context, inv *inventory.Inventory) ([]broker.Stream, []broker.Consumer, error) { + records, err := inv.BusRecords(ctx) + if err != nil { + return nil, nil, err + } + users, err := broker.Users(records) + if err != nil { + return nil, nil, err + } + streams, workers := broker.SeatTrafficObjects(users) + // And the queue of every such seat the catalogue declares, held or not: work queues from registration, + // so what is submitted before a holder is assigned waits for it (the correctness review of 2026-10-08). + declared, err := inv.DeclaredTrafficSeats(ctx) + if err != nil { + return nil, nil, err + } + have := map[string]bool{} + for _, s := range streams { + have[s.Name] = true + } + for _, s := range broker.TrafficQueues(declared) { + if !have[s.Name] { + streams = append(streams, s) + have[s.Name] = true + } + } + return streams, workers, nil +} + // moduleConsumerCount is how many modules hear what they consume, for the raise's one line. func moduleConsumerCount(ctx context.Context, inv *inventory.Inventory) (int, error) { consumers, err := moduleConsumers(ctx, inv) diff --git a/cmd/mesh-controller/doctor.go b/cmd/mesh-controller/doctor.go index b694dd43..3f78f57d 100644 --- a/cmd/mesh-controller/doctor.go +++ b/cmd/mesh-controller/doctor.go @@ -126,6 +126,11 @@ var probeRegistry = []probe{ {ID: agentAccountProbe, Asserts: "every machine that names an agent account has it judged, on its node-engine's " + "newest statement, unable to become root without a person", From: "ADR 0266, ADR 0259 §8", Kind: kindAgentCanBecomeRoot, Phase: 1, run: probeAgentAccounts}, + // Root where the trusted parties run (novox/hq ADR 0259 §8): while an agent can become root there without a + // person, an answer proven there proves nothing. + {ID: "D-root", Asserts: "no agent can become root without a person on a machine where the router or a channel " + + "proving its sender runs: not by its own account, and not through a tool that runs its command as an account " + + "that can", From: "ADR 0259 §8", Kind: kindRootNotFree, Phase: 2, run: probeAgentRoot}, {ID: "DW", Asserts: "the watchdogs of the signals table ran within three of their intervals", From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs}, // The core's health definitions (novox/hq to-be 45 §8, ADR 0236): what a core component's new build is diff --git a/cmd/mesh-controller/probe_agent_root.go b/cmd/mesh-controller/probe_agent_root.go new file mode 100644 index 00000000..de18aaf4 --- /dev/null +++ b/cmd/mesh-controller/probe_agent_root.go @@ -0,0 +1,376 @@ +package main + +import ( + "context" + "fmt" + "slices" + "sort" + "strings" + "time" + + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/conditions" + "github.com/novox/mesh-controller/internal/inventory" +) + +// Who can become root where the trusted parties run (novox/hq ADR 0259 §8, as reviewed on 2026-10-09). +// +// The router and every channel proving its sender run as accounts of their own, so that no agent reads what +// they hold or speaks as them. **Root on their machine undoes all of it**, and so does an agent running as the +// operator's account there. A machine is **root-free** — an answer proven there may authorise — only when all +// of these are measured, now, and hold: +// +// 1. the machine names an account agents run as (novox/hq ADR 0266), so no agent runs as the operator's +// account, which may become root; +// 2. its node-engine — running as root, which no agent controls — judged that account unable to become root +// without a person, in a statement heard within the last 15 minutes (agentConfined, judgedConfined); +// 3. the login shell's `execute` is not served there (novox/hq ADR 0268): its holder's setting withholds it +// **and** the bus was asked and heard no `execute` answered there. `execute` runs commands as the machine's +// runtime account, which the mesh's acting tools give passwordless sudo; that account is taken to become +// root, always, so no measure of it is asked. +// +// **Nothing else is a pass.** A machine that names no agent account, an unknown machine, a store or bus that +// could not be read, a verdict stale or absent — each is not root-free, and says why. The sudo module's own +// measure is no longer part of this judgement: it ran in the machine's runtime, as the very account an agent +// could become, so it could not be believed. +// +// The one judgement (judgeRoot) is read two ways: the self-check raises `root-not-free` on every machine where +// the router or a module of its own account runs and the judgement fails; and the `root-free` verb answers it +// live, to the router, which honours a verified sender only on its pass. A machine holding the operator's +// graphical session, where a messaging client's desktop app may run, is not judged here: the operator accepted +// that gap for now (hq issue 344). + +// kindRootNotFree is the condition a trusted party's machine that is not root-free raises. Its own key, apart +// from ADR 0266's agent-can-become-root (Token agent-root, from DA): the two judge different things — DA the +// agent account alone, this the whole of root-free — and one key from two probes flapped between them (the +// confirmation review of 2026-10-09). +const kindRootNotFree = "root-not-free" + +// routerSeat is the seat the router holds: where it runs counts as a trusted party's machine. +const routerSeat = "operator-channel" + +const ( + loginShellSeat = "node-login-shell" + // loginShellVerb is the seat's verb that runs a command, and the name of the setting its holder withholds + // it by (novox/hq ADR 0268). + loginShellVerb = "execute" + // executeServes is the one value of that setting that serves the verb; anything else withholds it. + executeServes = "serve" +) + +// loginShellServed judges whether the login shell's execute is served on a machine, failing closed (novox/hq +// ADR 0268): served while the holder's setting there is `serve` (or the holder has no such setting and claims +// the verb), or while the bus heard the verb answered there, or while the bus could not be asked. why says +// which, in words. +func loginShellServed(holder string, claims bool, setting *any, heard, asked bool) (bool, string) { + var why []string + switch { + case setting != nil: + if v, _ := (*setting).(string); v == executeServes { + why = append(why, holder+"'s execute setting there is "+executeServes) + } + case claims: + why = append(why, holder+" claims execute and has no setting that withholds it") + } + if heard { + why = append(why, "the bus hears execute answered there") + } else if !asked { + why = append(why, "the bus could not be asked whether execute is answered there") + } + return len(why) > 0, strings.Join(why, "; ") +} + +// rootFacts is what the judgement reads of one machine. +type rootFacts struct { + Machine string + // Unread is every read that failed, in words: any one is a fail. + Unread []string + // AgentNamed is whether the machine names an agent account; Confined whether its node-engine judged it + // unable to become root, freshly; ConfinedWhy the judgement's words either way. + AgentNamed bool + Confined bool + ConfinedWhy string + // Execute is whether the login shell's execute is served there; ExecuteWhy why, in words. + Execute bool + ExecuteWhy string + // SearchPending is the agent account unjudged only because the node-engine's first setuid search runs, + // within its bound (ADR 0266's quiet window). + SearchPending bool +} + +// rootVerdict is the judgement on one machine, as the root-free verb answers it. +type rootVerdict struct { + Machine string `json:"machine"` + Free bool `json:"free"` + Why string `json:"why"` + Judged time.Time `json:"judged"` + // Quiet is a machine not free only because its first setuid search still runs, within its bound: the + // self-check raises nothing for it then (ADR 0266's quiet window). It is never free for it. + Quiet bool `json:"quiet,omitempty"` +} + +// judgeRoot is the one judgement: free only when nothing failed to read, an agent account is named and judged +// confined, and execute is not served. +func judgeRoot(f rootFacts, now time.Time) rootVerdict { + v := rootVerdict{Machine: f.Machine, Judged: now.UTC()} + var not []string + if len(f.Unread) > 0 { + not = append(not, "not measured: "+strings.Join(f.Unread, "; ")) + } + switch { + case f.ConfinedWhy == "": + // Not read (said above), or nothing said of it: never a pass. + if len(f.Unread) == 0 { + not = append(not, "whether agents there can become root was not judged") + } + case !f.AgentNamed: + not = append(not, "agents run as the operator's account there, which may become root ("+f.ConfinedWhy+")") + case !f.Confined: + not = append(not, f.ConfinedWhy) + } + if f.Execute { + not = append(not, "the login shell runs any command an agent gives it as the machine's runtime account, "+ + "which can become root ("+orNoneKnown(f.ExecuteWhy)+")") + } + if len(not) > 0 { + v.Why = strings.Join(not, "; ") + // The one failure is the agent account not judged yet, because its first search runs. + v.Quiet = len(not) == 1 && f.SearchPending && f.AgentNamed && !f.Confined && len(f.Unread) == 0 && !f.Execute + return v + } + v.Free = true + v.Why = f.ConfinedWhy + "; the login shell's execute is not served there" + return v +} + +// rootReader reads the facts of machines live: the catalogue's placements, the node-engine's verdicts and the +// bus's discovery, each once per reader. +type rootReader struct { + entries []inventory.Entry + read error + heard map[string]map[string]map[string]bool + asked error + // confined is agentConfined; settings the login shell holder's settings on a machine. Replaceable in a test. + confined func(ctx context.Context, node string, now time.Time) (named, confined bool, why string, err error) + // quiet says the one thing keeping a machine's agent account unjudged is the node-engine's first setuid + // search, within its bound (ADR 0266, searchStillRunning). Read by the self-check alone, to raise nothing + // then; nil reads no quiet. It never makes a machine root-free. + quiet func(ctx context.Context, node string, now time.Time) bool + settings func(ctx context.Context, node, module string) ([]catalogue.Layer, error) +} + +func newRootReader(ctx context.Context, inv *inventory.Inventory, conn *nats.Conn) *rootReader { + r := &rootReader{} + r.entries, r.read = inv.Catalogued(ctx) + if conn == nil { + r.asked = fmt.Errorf("this process holds no connection to the bus") + } else { + r.heard, r.asked = discoverSeatVerbs(ctx, conn) + } + r.confined = func(ctx context.Context, node string, now time.Time) (bool, bool, string, error) { + return agentConfined(ctx, inv, node, now) + } + r.settings = inv.SettingsFor + return r +} + +// facts reads one machine, at now. +func (r *rootReader) facts(ctx context.Context, machine string, now time.Time) rootFacts { + f := rootFacts{Machine: machine} + if r.read != nil { + f.Unread = append(f.Unread, "the catalogue's placements could not be read: "+r.read.Error()) + } + named, confined, why, err := r.confined(ctx, machine, now) + if err != nil { + f.Unread = append(f.Unread, "the account agents run as could not be read: "+err.Error()) + } else { + f.AgentNamed, f.Confined, f.ConfinedWhy = named, confined, why + } + f.Execute, f.ExecuteWhy = r.executeServed(ctx, machine) + if r.quiet != nil && f.AgentNamed && !f.Confined { + f.SearchPending = r.quiet(ctx, machine, now) + } + return f +} + +// executeServed is whether the login shell's execute is served on a machine, failing closed: the bus not +// asked, the placements not read, or a holder's setting not read, is served. +func (r *rootReader) executeServed(ctx context.Context, machine string) (bool, string) { + heard := r.heard[loginShellSeat][loginShellVerb][machine] + asked := r.asked == nil + var whys []string + served := false + holders := 0 + for _, e := range r.entries { + if !e.Manifest.ClaimsSeat(loginShellSeat) || !slices.Contains(e.On, machine) { + continue + } + holders++ + var setting *any + if _, declared := e.Manifest.Settings[loginShellVerb]; declared { + layers, err := r.settings(ctx, machine, e.Manifest.Module) + if err != nil { + served = true + whys = append(whys, e.Manifest.Module+"'s setting there could not be read: "+err.Error()) + continue + } + for _, s := range catalogue.Effective(e.Manifest, layers) { + if s.Key == loginShellVerb { + v := s.Value + setting = &v + } + } + } + if s, why := loginShellServed(e.Manifest.Module, claimServes(e.Manifest, loginShellSeat, loginShellVerb), + setting, heard, asked); s { + served = true + whys = append(whys, why) + } + } + if holders == 0 { + // Nobody is assigned to serve it; the bus must still hear nobody answering it. + if s, why := loginShellServed("no holder", false, nil, heard, asked); s { + served = true + whys = append(whys, why) + } + } + if r.read != nil { + served = true + whys = append(whys, "who holds the login shell there could not be read") + } + return served, strings.Join(whys, "; ") +} + +// claimServes says whether a manifest's claim of a seat names a verb among those it serves. +func claimServes(m catalogue.Manifest, seat, verb string) bool { + for _, c := range m.Claims { + if c.Name == seat && slices.Contains(c.Serves, verb) { + return true + } + } + return false +} + +// judgeRootFree is the root-free verb's answer: each named machine judged now. It never fails: what could not +// be read is a machine not free, saying so. +func judgeRootFree(ctx context.Context, r *rootReader, machines []string, now time.Time) []rootVerdict { + out := make([]rootVerdict, 0, len(machines)) + for _, m := range machines { + out = append(out, judgeRoot(r.facts(ctx, m, now), now)) + } + return out +} + +// trustedMachines are the machines where the router or a module of its own account runs, each with those +// modules. +func trustedMachines(entries []inventory.Entry) map[string][]string { + trusted := map[string][]string{} + for _, e := range entries { + for _, node := range e.On { + if e.Manifest.RunsAs != "" || e.Manifest.ClaimsSeat(routerSeat) { + trusted[node] = append(trusted[node], e.Manifest.Module) + } + } + } + return trusted +} + +// agentRootObservation is the condition of a trusted party's machine that is not root-free. +func agentRootObservation(v rootVerdict, trusted []string) conditions.Observation { + trusted = append([]string(nil), trusted...) + sort.Strings(trusted) + return conditions.Observation{Scope: conditions.ScopeMachine, ID: v.Machine, Token: kindRootNotFree, + Machine: v.Machine, Kind: kindRootNotFree, Severity: conditions.Urgent, + Summary: fmt.Sprintf("%s is not root-free, where %s run: until it is, the router approves nothing proven "+ + "there (novox/hq ADR 0259 §8): %s", v.Machine, strings.Join(trusted, ", "), v.Why), + Headline: "Phone answers held on " + v.Machine, + Needs: "give the programs working for you on " + v.Machine + " an account that cannot become root.", + Explanation: "The modules that prove your answers from your phone run on " + v.Machine + ", and the mesh " + + "cannot show that a program working for you there is unable to become root or to act as you. Until " + + "it can, answers from your phone can only acknowledge.", + Resolved: "Answers from your phone can approve again on " + v.Machine} +} + +// probeAgentRoot is the probe: every trusted party's machine, judged by the one judgement. +func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, error) { + var conn *nats.Conn + if d.js != nil { + conn = d.js.Conn() + } + inv := d.open.inventory + r := newRootReader(ctx, inv, conn) + if r.read != nil { + return nil, r.read + } + // The self-check alone reads ADR 0266's quiet window: nothing raised while a machine's first setuid search + // runs, within its bound. The root-free verb never reads it, so the machine still answers not free. + r.quiet = func(ctx context.Context, node string, now time.Time) bool { + n, err := inv.NodeByName(ctx, node) + if err != nil || n.AgentAccount == "" { + return false + } + h, had, err := inv.HealthOf(ctx, node) + if err != nil { + return false + } + quiet, err := searchStillRunning(ctx, inv, node, n.AgentAccount, h, had, now) + return err == nil && quiet + } + return rootObservations(ctx, r, trustedMachines(r.entries), time.Now()), nil +} + +// rootObservations judges the machines and says each that fails. +func rootObservations(ctx context.Context, r *rootReader, trusted map[string][]string, now time.Time) []conditions.Observation { + var machines []string + for m := range trusted { + machines = append(machines, m) + } + sort.Strings(machines) + var out []conditions.Observation + for _, v := range judgeRootFree(ctx, r, machines, now) { + if !v.Free && !v.Quiet { + out = append(out, agentRootObservation(v, trusted[v.Machine])) + } + } + return out +} + +// rootClock is the clock the root-free verb judges by. +var rootClock = time.Now + +// rootFreeAnswer is the root-free verb: the named machines, each judged now by the serving controller. Only it +// answers: a process that is not serving says so, and a caller reads that as no machine free. +func rootFreeAnswer(ctx context.Context, machines string, now time.Time) (any, error) { + d := doctorFrom + if d == nil || d.open == nil || d.open.inventory == nil { + return nil, fmt.Errorf("this controller is not serving, so it judges no machine root-free: ask again, and " + + "the serving controller answers") + } + var names []string + for _, m := range strings.Split(machines, ",") { + if m = strings.TrimSpace(m); m != "" && !slices.Contains(names, m) { + names = append(names, m) + } + } + if len(names) == 0 { + return nil, fmt.Errorf("root-free judges the machines named, and none was") + } + var conn *nats.Conn + if d.js != nil { + conn = d.js.Conn() + } + return map[string]any{"machines": judgeRootFree(ctx, newRootReader(ctx, d.open.inventory, conn), names, now)}, nil +} + +// rootFreeNow is the machines judged root-free, for composing a push's memberships: only those that pass. +func rootFreeNow(ctx context.Context, r *rootReader, machines []string, now time.Time) map[string]bool { + free := map[string]bool{} + for _, v := range judgeRootFree(ctx, r, machines, now) { + if v.Free { + free[v.Machine] = true + } + } + return free +} diff --git a/cmd/mesh-controller/probe_agent_root_test.go b/cmd/mesh-controller/probe_agent_root_test.go new file mode 100644 index 00000000..52c56a93 --- /dev/null +++ b/cmd/mesh-controller/probe_agent_root_test.go @@ -0,0 +1,265 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/conditions" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +var rootNow = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) + +// A machine is root-free only on a positive measure of each thing (the review of 2026-10-09, H2/H3): every +// read succeeded, an agent account is named and judged confined by the node-engine, execute is not served. +func TestRootFreeIsAPositiveMeasureAndNothingElse(t *testing.T) { + pass := rootFacts{Machine: "anchor", AgentNamed: true, Confined: true, + ConfinedWhy: "the agent account agents cannot become root without a person (judged 2026-10-09 12:00)"} + if v := judgeRoot(pass, rootNow); !v.Free || v.Machine != "anchor" || !v.Judged.Equal(rootNow) { + t.Fatalf("the one pass: %+v", v) + } + fails := map[string]func(*rootFacts){ + "a read failed": func(f *rootFacts) { f.Unread = []string{"the store did not answer"} }, + "no agent account named": func(f *rootFacts) { f.AgentNamed, f.Confined = false, false }, + "not confined": func(f *rootFacts) { f.Confined = false }, + "nothing said of it": func(f *rootFacts) { f.ConfinedWhy = "" }, + "execute served": func(f *rootFacts) { f.Execute, f.ExecuteWhy = true, "the bus hears execute answered there" }, + "confined, but agent read": func(f *rootFacts) { f.Unread, f.ConfinedWhy = []string{"x"}, "" }, + } + for name, mutate := range fails { + f := pass + mutate(&f) + if v := judgeRoot(f, rootNow); v.Free || v.Why == "" { + t.Errorf("%s: judged %+v", name, v) + } + } +} + +// The reader fails closed on every case the review named: the agent account read only where the coding-agent +// module runs (old :225), no account to measure taken for a pass (old :246), and a measure that did not answer +// taken for "not root" (old :114, :123). +func TestTheRootReaderFailsClosed(t *testing.T) { + shell := catalogue.Manifest{Module: "zsh", Claims: []catalogue.Claim{{Name: loginShellSeat, Serves: []string{"execute"}}}, + Settings: map[string]catalogue.SettingDeclaration{"execute": {Default: "withhold"}}} + reader := func() *rootReader { + return &rootReader{ + entries: []inventory.Entry{{Manifest: shell, On: []string{"anchor"}}}, + heard: map[string]map[string]map[string]bool{}, + confined: func(context.Context, string, time.Time) (bool, bool, string, error) { + return true, true, "the agent account agents cannot become root without a person", nil + }, + settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }, + } + } + ctx := context.Background() + if v := judgeRootFree(ctx, reader(), []string{"anchor"}, rootNow)[0]; !v.Free { + t.Fatalf("the control: agents confined, execute withheld and unheard, everything read: %+v", v) + } + cases := map[string]func(*rootReader){ + "no agent account named, no coding-agent module there": func(r *rootReader) { + r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) { + return false, false, "anchor names no agent account: agents run as the operator account (ops)", nil + } + }, + "the node-engine's verdict not read": func(r *rootReader) { + r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) { + return false, false, "", errors.New("the store did not answer") + } + }, + "a stale verdict": func(r *rootReader) { + r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) { + return true, false, "the agent account agents is not judged: the machine's newest statement was heard at …", nil + } + }, + "the bus not asked": func(r *rootReader) { r.asked = errors.New("no bus") }, + "the placements not read": func(r *rootReader) { r.read = errors.New("no store") }, + "the holder's setting not read": func(r *rootReader) { + r.settings = func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, errors.New("no store") } + }, + "execute heard on the bus": func(r *rootReader) { + r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}} + }, + "a holder that serves execute": func(r *rootReader) { + r.entries[0].Manifest.Settings = nil + }, + } + for name, mutate := range cases { + r := reader() + mutate(r) + if v := judgeRootFree(ctx, r, []string{"anchor"}, rootNow)[0]; v.Free { + t.Errorf("%s: judged free: %+v", name, v) + } + } + // A machine with no login shell holder at all: still the bus must hear none. + r := reader() + r.entries = nil + r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}} + if v := judgeRootFree(ctx, r, []string{"anchor"}, rootNow)[0]; v.Free { + t.Errorf("execute answered by a module nobody assigned: %+v", v) + } +} + +// The probe says agent-root, by the same judgement, on each machine where the router or a module of its own +// account runs and that is not root-free; urgent and in plain words; nothing where every one is free. +func TestTheProbeSaysEachMachineThatIsNotRootFree(t *testing.T) { + entries := []inventory.Entry{ + {Manifest: catalogue.Manifest{Module: "telegram", RunsAs: "telegram"}, On: []string{"anchor"}}, + {Manifest: catalogue.Manifest{Module: "messenger", RunsAs: "messenger", + Claims: []catalogue.Claim{{Name: routerSeat}}}, On: []string{"anchor"}}, + {Manifest: catalogue.Manifest{Module: "xorg", Claims: []catalogue.Claim{{Name: catalogue.DisplayServerSeat}}}, + On: []string{"laptop"}}, + } + trusted := trustedMachines(entries) + if len(trusted["anchor"]) != 2 || len(trusted["laptop"]) != 0 { + t.Fatalf("trusted %v", trusted) + } + r := &rootReader{entries: entries, heard: map[string]map[string]map[string]bool{}, + confined: func(_ context.Context, node string, _ time.Time) (bool, bool, string, error) { + return false, false, node + " names no agent account: agents run as the operator account (ops)", nil + }, + settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }} + got := rootObservations(context.Background(), r, trusted, rootNow) + if len(got) != 1 || got[0].Machine != "anchor" || got[0].Kind != kindRootNotFree || got[0].Severity != conditions.Urgent || + !strings.Contains(got[0].Summary, "messenger, telegram") || !strings.Contains(got[0].Summary, "names no agent account") { + t.Fatalf("said %+v", got) + } + o := got[0] + if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation, + Needs: o.Needs, Resolved: o.Resolved}, o.Machine); !ok { + t.Errorf("not plain: %s", why) + } + r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) { + return true, true, "confined", nil + } + if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 0 { + t.Errorf("said of a free machine: %+v", got) + } +} + +// novox/hq ADR 0268: the login shell counts only where its execute is served, and fails closed. +func TestTheLoginShellCountsOnlyWhereExecuteIsServed(t *testing.T) { + val := func(v any) *any { return &v } + cases := []struct { + name string + claims bool + setting *any + heard, asked bool + served bool + saysInTheWhys string + }{ + {"withheld by the setting and silent on the bus", true, val("withhold"), false, true, false, ""}, + {"withheld by the setting, the machine not yet pushed", true, val("withhold"), true, true, true, "the bus hears"}, + {"the setting serves", true, val("serve"), false, true, true, "setting there is serve"}, + {"a wrong value withholds, as the holder does", true, val("Serve"), false, true, false, ""}, + {"the setting withholds, the bus could not be asked", true, val("withhold"), false, false, true, "could not be asked"}, + {"a holder with no such setting that claims execute", true, nil, false, true, true, "claims execute"}, + {"a holder with no such setting that does not claim it, heard all the same", false, nil, true, true, true, "the bus hears"}, + {"a holder with no such setting that does not claim it, silent", false, nil, false, true, false, ""}, + } + for _, c := range cases { + served, why := loginShellServed("zsh", c.claims, c.setting, c.heard, c.asked) + if served != c.served || (c.saysInTheWhys != "" && !strings.Contains(why, c.saysInTheWhys)) { + t.Errorf("%s: served %v (%q), want %v saying %q", c.name, served, why, c.served, c.saysInTheWhys) + } + } +} + +// The root-free verb is the serving controller's alone, answered in its process and never as a command; a +// controller not serving answers an error, which the router reads as no machine free. +func TestRootFreeIsAnsweredOnlyByTheServingController(t *testing.T) { + was := doctorFrom + doctorFrom = nil + t.Cleanup(func() { doctorFrom = was }) + if _, err := rootFreeAnswer(context.Background(), "anchor", rootNow); err == nil { + t.Error("a controller not serving judged a machine") + } + if !inProcess["root-free"] { + t.Error("root-free is not answered in the serving process") + } + if _, err := argvFor("root-free", map[string]any{"machines": "anchor"}); err == nil { + t.Error("root-free ran as a command") + } + // The router names its machines as a list (its contract with this verb); one text separated by commas is + // the same; anything else in the list is refused. + for _, given := range []any{[]any{"anchor", "relay"}, "anchor, relay"} { + a, err := readArguments("root-free", map[string]any{"machines": given}) + if err != nil || a.given["machines"] != "anchor,relay" && a.given["machines"] != "anchor, relay" { + t.Errorf("root-free given %v read %v (%v)", given, a, err) + } + } + if _, err := readArguments("root-free", map[string]any{"machines": []any{"anchor", 7}}); err == nil { + t.Error("root-free took a number for a machine") + } + if _, err := readArguments("status", map[string]any{"machines": []any{"anchor"}}); err == nil { + t.Error("a verb that takes no list took one") + } +} + +// The confirmation review of 2026-10-09: ADR 0266's quiet window (#175) keeps the self-check from raising +// agent-can-become-root while the node-engine's first setuid search runs. It must not make root-free answer free: +// root-free needs a complete, fresh verdict. A verdict still waiting for the search is "not judged" to +// agentConfined, so the machine is not root-free, whatever the quiet says — and the same statement, complete +// and healthy, is the control. +func TestAMachineWaitingForItsFirstSetuidSearchIsNotRootFree(t *testing.T) { + now := rootNow + statement := func(state, reason string) inventory.NodeHealth { + return inventory.NodeHealth{Node: "anchor", Contract: link.RootContract, SaidAt: now, HeardAt: now, + Resources: []inventory.ResourceHealth{{Module: "claude-code", Resource: "agent", Kind: link.KindAccount, + Target: "agents", State: state, Reason: reason, Root: link.RootNever}}} + } + judged := func(h inventory.NodeHealth) rootVerdict { + confined, why := judgedConfined("agents", h, true, now) + return judgeRoot(rootFacts{Machine: "anchor", AgentNamed: true, Confined: confined, ConfinedWhy: why}, now) + } + if v := judged(statement(link.StateHealthy, "")); !v.Free { + t.Fatalf("the control: a complete healthy verdict, fresh: %+v", v) + } + pending := statement(link.StateUnknown, link.ReasonRootPending+": the search runs") + if rootVerdictKind("agents", pending, true, now) != verdictPending { + t.Fatal("the statement is not one the quiet window counts as waiting for the search") + } + if v := judged(pending); v.Free { + t.Errorf("a machine whose first setuid search is pending was judged root-free: %+v", v) + } +} + +// The confirmation review of 2026-10-09, on #154 beside ADR 0266: D-root keeps ADR 0266's quiet window — nothing +// raised while the one thing unjudged is the first setuid search, within its bound — while the root-free verb +// still answers the machine not free; and D-root's condition has a key of its own, apart from DA's. +func TestRootNotFreeIsQuietWhileTheFirstSearchRunsAndKeyedApartFromDA(t *testing.T) { + entries := []inventory.Entry{{Manifest: catalogue.Manifest{Module: "telegram", RunsAs: "telegram"}, On: []string{"anchor"}}} + r := &rootReader{entries: entries, heard: map[string]map[string]map[string]bool{}, + confined: func(context.Context, string, time.Time) (bool, bool, string, error) { + return true, false, "the agent account agents is not judged: the search for setuid programs runs", nil + }, + settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }, + quiet: func(context.Context, string, time.Time) bool { return true }} + trusted := trustedMachines(entries) + if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 0 { + t.Errorf("raised while the first search runs: %+v", got) + } + if v := judgeRootFree(context.Background(), r, []string{"anchor"}, rootNow)[0]; v.Free || !v.Quiet { + t.Errorf("root-free while the first search runs: %+v", v) + } + // Quiet hides nothing else: the login shell served as well is said. + r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}} + if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 1 { + t.Errorf("a second failure was kept quiet: %+v", got) + } + // Past its bound, said. + r.heard, r.quiet = map[string]map[string]map[string]bool{}, func(context.Context, string, time.Time) bool { return false } + got := rootObservations(context.Background(), r, trusted, rootNow) + if len(got) != 1 { + t.Fatalf("a search past its bound was not said: %+v", got) + } + // One key per judgement: DA's is machine..agent-root, this one its own. + da := conditions.Observation{Scope: conditions.ScopeMachine, ID: "anchor", Token: "agent-root", Machine: "anchor"} + if got[0].Key() == da.Key() { + t.Errorf("D-root and DA share the key %s", da.Key()) + } +} diff --git a/cmd/mesh-controller/probes.go b/cmd/mesh-controller/probes.go index 2f8380ae..22ef45a3 100644 --- a/cmd/mesh-controller/probes.go +++ b/cmd/mesh-controller/probes.go @@ -641,31 +641,8 @@ const ( // discoverHolders asks the bus's discovery who serves what, and answers seat → machine for every // endpoint a seat's verb is served on. func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[string]bool, error) { - inbox := conn.NewRespInbox() - sub, err := conn.SubscribeSync(inbox) - if err != nil { - return nil, err - } - defer func() { _ = sub.Unsubscribe() }() - if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil { - return nil, fmt.Errorf("asking the bus who serves what: %w", err) - } out := map[string]map[string]bool{} - deadline := time.Now().Add(discoveryPatience) - for time.Now().Before(deadline) { - wait, cancel := context.WithTimeout(ctx, discoveryQuiet) - msg, err := sub.NextMsgWithContext(wait) - cancel() - if err != nil { - if ctx.Err() != nil { - return nil, ctx.Err() - } - break - } - var info micro.Info - if json.Unmarshal(msg.Data, &info) != nil { - continue - } + err := discoverServices(ctx, conn, func(info micro.Info) { for _, e := range info.Endpoints { seat, node := e.Metadata["seat"], e.Metadata["node"] if seat == "" { @@ -684,8 +661,69 @@ func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[strin out[info.Name] = map[string]bool{} } out[info.Name][info.ID] = true + }) + return out, err +} + +// discoverSeatVerbs asks the bus's discovery the same, one level finer: seat → verb → machine, for every +// seat verb answered (an endpoint's `tool` is its verb). A seat held where a verb is withheld (novox/hq ADR +// 0268) shows the seat and not that verb. +func discoverSeatVerbs(ctx context.Context, conn *nats.Conn) (map[string]map[string]map[string]bool, error) { + out := map[string]map[string]map[string]bool{} + err := discoverServices(ctx, conn, func(info micro.Info) { + for _, e := range info.Endpoints { + seat, verb, node := e.Metadata["seat"], e.Metadata["tool"], e.Metadata["node"] + if seat == "" || verb == "" { + continue + } + if node == "" { + node = info.ID + } + if out[seat] == nil { + out[seat] = map[string]map[string]bool{} + } + if out[seat][verb] == nil { + out[seat][verb] = map[string]bool{} + } + out[seat][verb][node] = true + } + }) + return out, err +} + +// discoverServices asks the bus's discovery once and hands every service's answer to visit, waiting +// discoveryQuiet after the last and discoveryPatience at the most. +func discoverServices(ctx context.Context, conn *nats.Conn, visit func(micro.Info)) error { + if conn == nil { + return errors.New("the controller holds no connection to the bus") } - return out, nil + inbox := conn.NewRespInbox() + sub, err := conn.SubscribeSync(inbox) + if err != nil { + return err + } + defer func() { _ = sub.Unsubscribe() }() + if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil { + return fmt.Errorf("asking the bus who serves what: %w", err) + } + deadline := time.Now().Add(discoveryPatience) + for time.Now().Before(deadline) { + wait, cancel := context.WithTimeout(ctx, discoveryQuiet) + msg, err := sub.NextMsgWithContext(wait) + cancel() + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + break + } + var info micro.Info + if json.Unmarshal(msg.Data, &info) != nil { + continue + } + visit(info) + } + return nil } // probeArchives is D4: every archive the mesh keeps is held by its manifest in the artifact store. diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index e1903da2..230f4611 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -1250,11 +1250,14 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se if err != nil { return err } - where := broker.PlacementsOf(records, records.Interchangeable) bus, ok := server.Bus().(link.OverNATS) if !ok { return nil } + // Which machines are root-free now (novox/hq ADR 0259 §8): a channel's verified sender is composed for the + // router only from one, beside a router on one. Judged once per push, by the root-free verb's judgement. + records.RootFree = rootFreeNow(ctx, newRootReader(ctx, open.inventory, bus.Conn), records.Nodes, time.Now()) + where := broker.PlacementsOf(records, records.Interchangeable) // **Every declared state's bucket, before the memberships that name it** (novox/hq ADR 0201). The // raise at start asserts them too, but a module registered and assigned since would otherwise have // its bucket only after the control plane next restarts — found the first time a module declared diff --git a/cmd/mesh-controller/seats.go b/cmd/mesh-controller/seats.go index 793897a6..07910266 100644 --- a/cmd/mesh-controller/seats.go +++ b/cmd/mesh-controller/seats.go @@ -135,6 +135,12 @@ func handOver(ctx context.Context, seatName, to string, adding bool) error { if !ok || nodeName == "" || module == "" { return fmt.Errorf("the new holder is named /, not %q", to) } + // A kinded bench is held once per kind, by the claims themselves (novox/hq ADR 0234 §2, ADR 0259): the + // record of who holds a seat has no kind, so a handover would name one holder for every kind. + if catalogue.KindedBenches[seatName] { + return fmt.Errorf("%s is a kinded bench: each kind is held by the module claiming it, and is not handed "+ + "over by `seat` — assign the module that claims the kind, or unassign the one that does", seatName) + } open, err := openStores(ctx) if err != nil { return err diff --git a/cmd/mesh-controller/seats_test.go b/cmd/mesh-controller/seats_test.go index 9fc61356..7db64bf8 100644 --- a/cmd/mesh-controller/seats_test.go +++ b/cmd/mesh-controller/seats_test.go @@ -1,6 +1,8 @@ package main import ( + "context" + "strings" "testing" "github.com/novox/mesh-controller/internal/catalogue" @@ -62,3 +64,13 @@ func TestAClaimOutsideTheSetIsShownNotHidden(t *testing.T) { t.Fatalf("a claim outside the set was not shown: %+v", outside) } } + +// A kinded bench is not handed over by `seat`: each kind is held by its claim (novox/hq ADR 0259). +func TestAKindedBenchIsNotHandedOver(t *testing.T) { + for _, bench := range []string{"channel", "intake"} { + err := handOver(context.Background(), bench, "anchor/telegram", false) + if err == nil || !strings.Contains(err.Error(), "is a kinded bench") { + t.Errorf("%s: %v", bench, err) + } + } +} diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 53478519..a9441024 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -114,6 +114,14 @@ func declaredArguments(v catalogue.Verb) (names []string, switches map[string]bo return names, switches } +// isList says a verb's argument is declared a list of text (catalogue's listed): given as a JSON array, it is +// read as its items joined by commas, as the same argument given as one text would be. +func isList(v catalogue.Verb, name string) bool { + props, _ := v.Input["properties"].(map[string]any) + p, _ := props[name].(map[string]any) + return p != nil && p["type"] == "array" +} + // readArguments refuses what the verb does not take, before anything is composed. func readArguments(verb string, args map[string]any) (*verbArguments, error) { v, known := controllerVerb(verb) @@ -150,6 +158,19 @@ func readArguments(verb string, args map[string]any) (*verbArguments, error) { return nil, fmt.Errorf("%s: %q is text, not true or false", verb, k) } value = fmt.Sprint(x) + case []any: + if !isList(v, k) { + return nil, fmt.Errorf("%s: %q is text, and was given a list", verb, k) + } + items := make([]string, 0, len(x)) + for _, item := range x { + text, ok := item.(string) + if !ok || strings.TrimSpace(text) == "" || strings.Contains(text, ",") { + return nil, fmt.Errorf("%s: %q is a list of names, and holds %v", verb, k, item) + } + items = append(items, strings.TrimSpace(text)) + } + value = strings.Join(items, ",") default: return nil, fmt.Errorf("%s: %q is text, and was given %T", verb, k, x) } @@ -282,6 +303,8 @@ func (a *verbArguments) commandLine() ([]string, error) { return nil, errors.New("tools is answered from the records, not by a command") case "dead-letters": return nil, errors.New("dead-letters is answered by the serving controller, on its own connection, not by a command") + case "root-free": + return nil, errors.New("root-free is judged by the serving controller, on its own connection, not by a command") case "status": return []string{"status", "--json"}, nil case "nodes": @@ -1092,6 +1115,9 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) { if verb == "dead-letters" { return deadLettersAnswer(ctx, a) } + if verb == "root-free" { + return rootFreeAnswer(ctx, a.given["machines"], rootClock()) + } if verb == "doctor" { // From the serving controller, which runs the self-check and hears the signals // (novox/hq to-be 45 §4): the last verdict at once, or a run now. @@ -1182,7 +1208,10 @@ func actsOnAPlan(args map[string]any) bool { var inProcess = map[string]bool{"tools": true, "calls": true, "doctor": true, // What a consumer gave up on, read and changed on the serving controller's own connection (novox/hq // issue 330). - "dead-letters": true} + "dead-letters": true, + // Whether a machine is root-free, judged live on the serving controller's store and connection (novox/hq ADR + // 0259 §8): the router asks it before an approval. + "root-free": true} // answersFirst is a command line whose caller is answered before it runs: a push, by its verb or // through `command`. A push sends the machine holding the bus first when its user list changed, the diff --git a/internal/broker/derived.go b/internal/broker/derived.go index 28831526..0a31ef41 100644 --- a/internal/broker/derived.go +++ b/internal/broker/derived.go @@ -126,7 +126,9 @@ func ConsumerFor(p Principal) (Consumer, bool) { // A module that reacts to anything — a module's events or a role's (novox/hq ADR 0121). Watching // a role was missing here, so the one module that does it got no consumer at all: it started, // connected, and its graph stayed empty with nothing anywhere reporting why. - if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0) { + // And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants. + hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0 + if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn) { return Consumer{}, false } perms, err := PermissionsFor(p) diff --git a/internal/broker/membership.go b/internal/broker/membership.go index ed4d3c51..b44aa6af 100644 --- a/internal/broker/membership.go +++ b/internal/broker/membership.go @@ -2,6 +2,7 @@ package broker import ( "encoding/json" + "slices" "sort" "strings" ) @@ -50,6 +51,12 @@ type Membership struct { // and refuses, with the reason, what is not on it — the bus enforces only the union over every // module on the machine. State []StateIssued `json:"state,omitempty"` + // SeatTraffic is what this module's code may submit, say, hear, take, ask, answer and read on seats + // that name their caller or their kind (novox/hq ADR 0259 §3). The runtime carrying the module + // publishes, takes and answers for it only what is listed here: the bus enforces only the union + // over every module on the machine, so one module's code reaching another's name or kind through + // the runtime is the runtime's to refuse. + SeatTraffic *SeatTraffic `json:"seat-traffic,omitempty"` } // Served is one address a tool is answered on. @@ -78,6 +85,8 @@ type Placements struct { // Interchangeable is each module whose definition says its instances are the same anywhere, // so the module's plain subject is issued to all of them in one queue. Interchangeable map[string]bool + // Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5). + Kinds []KindHeld } // AnswersForTheModule says whether an instance of a module on one machine is issued the module's @@ -104,11 +113,28 @@ func MembershipFor(node string, d Declared, where Placements) Membership { m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module}) } for _, s := range d.Holds { + if servedOnlyByTheController(s) { + continue // answered by the serving controller alone, never through a membership + } for _, verb := range s.Serves { m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)}) } } m.State = stateIssuedFor(d, node) + t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches) + for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) { + if !s.Kinded { + continue + } + for _, k := range where.Kinds { + if k.Seat == s.Name && !kindListed(t.Kinds, k) { + t.Kinds = append(t.Kinds, k) + } + } + } + if len(t.Publish)+len(t.Subscribe)+len(t.Answers)+len(t.Workers)+len(t.Records)+len(t.Kinds) > 0 { + m.SeatTraffic = &t + } if len(d.Invokes) > 0 { m.Reaches = map[string][]string{} for _, t := range d.Invokes { @@ -145,5 +171,67 @@ func PlacementsOf(r Records, interchangeable map[string]bool) Placements { for _, nodes := range p.Nodes { sort.Strings(nodes) } + // Where the router runs: a verified sender is believed only while its machine is root-free too. A router + // placed nowhere, or on more than one machine, frees nothing. + var routerNodes []string + for node, declared := range r.Assigned { + for _, d := range declared { + for _, s := range d.Holds { + if s.Name == routerSeat && !slices.Contains(routerNodes, node) { + routerNodes = append(routerNodes, node) + } + } + } + } + routerFree := len(routerNodes) == 1 && r.RootFree[routerNodes[0]] + for node, declared := range r.Assigned { + for _, d := range declared { + for _, s := range d.Holds { + if s.Kinded && s.Kind != "" { + p.Kinds = append(p.Kinds, KindHeld{Seat: s.Name, Kind: s.Kind, Module: d.Module, Node: node, + Capabilities: placedCapabilities(s.Capabilities, d.RunsAs, routerFree && r.RootFree[node])}) + } + } + } + } + sort.Slice(p.Kinds, func(i, j int) bool { + a, b := p.Kinds[i], p.Kinds[j] + if a.Seat != b.Seat { + return a.Seat < b.Seat + } + if a.Kind != b.Kind { + return a.Kind < b.Kind + } + return a.Node < b.Node + }) return p } + +// routerSeat is the seat the router of asks holds (novox/hq ADR 0259 §3). +const routerSeat = "operator-channel" + +// placedCapabilities is what a kind's claim promises, as far as its placement lets the router believe it +// (novox/hq ADR 0259 §8): `verified-sender` only from a holder that runs as an account of its own, on a bus +// account of its own — never one the machine's runtime carries as the operator's account — and only while +// its machine and the router's were root-free when composed (rootFree; the review of 2026-10-09, H3). The +// membership carrying it is composed at a push, so it can outlive a pass that later fails: the router asks +// the controller's root-free verb again before it honours an approval, and that is the check that holds. +func placedCapabilities(declared []string, runsAs string, rootFree bool) []string { + var out []string + for _, c := range declared { + if c == "verified-sender" && (runsAs == "" || !rootFree) { + continue + } + out = append(out, c) + } + return out +} + +func kindListed(list []KindHeld, k KindHeld) bool { + for _, x := range list { + if x.Seat == k.Seat && x.Kind == k.Kind && x.Module == k.Module && x.Node == k.Node { + return true + } + } + return false +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 3e765d71..52d1cfba 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -63,6 +63,23 @@ type Seat struct { Emits []string Serves []string Versions []string // protocol versions served beside the current one; empty for v1 only + + // Kinded says the seat is a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): each holder claims one + // kind, and its verbs' subjects carry it. Kind is the kind this principal's claim names, for a seat it + // holds. + Kinded bool + Kind string + // ByCaller are the accepts and emits whose last token names the calling module (ADR 0259 §3). + ByCaller []string + // Proofs are the seat's proof verbs: core request and reply, never on a stream (ADR 0259 §3). + Proofs []string + // Records are the holder's buckets, by their full name, each user reads under its own name. + Records []string + // Capabilities are what this principal's claim of a kinded bench promises (ADR 0234 §2). + Capabilities []string + // DeclaredBy is the module that declares the seat. On a kinded bench it alone submits work to a kind + // and answers its proofs (novox/hq ADR 0259 §8): the router, not any user or watcher of the bench. + DeclaredBy string } // A Principal is one user of the bus. Its permissions are derived from what it declares and @@ -530,6 +547,9 @@ func PermissionsFor(p Principal) (Permissions, error) { // 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a // role is hearing what it announced, not taking part in it. for _, w := range p.Watches { + if w.Kinded { + continue // composed by SeatTrafficOf below + } for _, e := range w.Emits { sub = append(sub, seatSubject(w, "event", e)) } @@ -546,8 +566,19 @@ func PermissionsFor(p Principal) (Permissions, error) { "$JS.API.CONSUMER.INFO."+consumerStream(p)+"."+consumerDurable(p), "$JS.API.CONSUMER.MSG.NEXT."+consumerStream(p)+"."+consumerDurable(p)) - // 3. Seats it holds: full participation. + // 3. Seats it holds: full participation — but the controller's own seat, whose verbs only the serving + // controller answers, on its own connection (servedOnlyByTheController). for _, s := range p.Holds { + if servedOnlyByTheController(s) { + continue + } + if s.isNewTraffic() { + // Composed by SeatTrafficOf below, worker and all; only its tools are served here. + for _, t := range s.Serves { + sub = append(sub, seatToolSubject(s, t, p.Node)) + } + continue + } // Taking work from the role's queue: the worker consumer every holder shares (asked // about, pulled from, acknowledged), on the seat's own stream (novox/hq ADR 0190). A // holder pulls — asks the consumer for its next message, answered on its own inbox — @@ -590,7 +621,7 @@ func PermissionsFor(p Principal) (Permissions, error) { // seat's inbound subject and watch other modules' traffic, nor publish its outbound // events and lie about outcomes (design 29 §2). for _, s := range p.Uses { - for _, a := range s.Accepts { + for _, a := range plainVerbs(s, s.Accepts) { pub = append(pub, seatSubject(s, "accept", a)) } for _, t := range s.Serves { @@ -603,6 +634,12 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, stateGrants(stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State, PerMachine: p.PerMachine, Reads: p.Reads, KeyedReads: p.KeyedReads})...) + // 6. Its traffic on seats that name their caller or their kind, ask proofs or keep records + // (novox/hq ADR 0259 §3). + tp, ts := SeatTrafficOf(p.Module, p.Holds, p.Uses, p.Watches).grants() + pub = append(pub, tp...) + sub = append(sub, ts...) + case KindNodeTools: // **One process serves what every module on the machine would have served for itself** // (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that @@ -628,6 +665,9 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, own+".event."+e) } for _, s := range d.Holds { + if servedOnlyByTheController(s) { + continue + } for _, t := range s.Serves { sub = append(sub, seatToolSubject(s, t, p.Node)) } @@ -680,6 +720,25 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, stateGrants(stateAccess{Module: d.Module, Node: p.Node, Keeps: stateNames(d.State), PerMachine: perMachineNames(d.State), Reads: d.Reads, KeyedReads: d.KeyedReads})...) } + // **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 + // let through. + for _, d := range p.Carries { + if why := trustedTraffic(d); why != "" { + return Permissions{}, fmt.Errorf("%s on %s is carried by the machine's runtime, and %s: it runs "+ + "as an account of its own, never the runtime's (novox/hq ADR 0259)", d.Module, p.Node, why) + } + } + // **And the seat traffic of the modules it carries** (novox/hq ADR 0259 §3): a bundle reaches the + // bus only through its runtime, so the runtime is granted the union. That one module's code does + // not publish under another's name or kind through it is the runtime's to keep, from the seat + // traffic each module's membership lists. + for _, d := range p.Carries { + tp, ts := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches).grants() + pub = append(pub, tp...) + sub = append(sub, ts...) + } sub = unique(sub) pub = unique(pub) } @@ -743,6 +802,13 @@ func seatSubject(s Seat, kind, verb string) string { // seat carries the node it is asked of, because a flat subject would reach every machine's holder // and the queue group would silently pick a winner (novox/hq ADR 0132, design 33 §4). A holder // subscribes its own node's; a user publishes any node's (`*`) and names the machine in the subject. +// servedOnlyByTheController says a seat's verbs are answered by the serving controller alone, on its own +// connection (the KindController grant), never by a module claiming the seat or a runtime carrying it: the +// controller's own seat. Its verbs decide what the mesh is — and `root-free` decides whether the router believes +// a verified sender (novox/hq ADR 0259 §8) — so a machine's runtime, whose credential an agent on that machine +// may hold, answering one would be an agent answering it (the confirmation review of 2026-10-09). +func servedOnlyByTheController(s Seat) bool { return s.Name == ControllerSeat } + func seatToolSubject(s Seat, verb, node string) string { base := seatSubject(s, "tool", verb) if s.Scope == "node" && node != "" { diff --git a/internal/broker/seattraffic.go b/internal/broker/seattraffic.go new file mode 100644 index 00000000..e8f69682 --- /dev/null +++ b/internal/broker/seattraffic.go @@ -0,0 +1,305 @@ +package broker + +import "sort" + +// What a module may say and take on the seats it holds, uses and watches, beyond tools (novox/hq ADR +// 0259 §3, to-be 46 §10). +// +// Three rules are added to the ones a seat always had, each a subject whose last token names who may +// publish it, granted to that publisher alone — the way a node seat's event about a machine carries the +// machine (ADR 0219): +// +// - **A verb named by its caller** (`by-caller`). A user of the seat submits that accept, and hears that +// event, under its own module's name and no other: `accept.ask.`, `event.decided.`. The +// holder takes every caller's accept and says the event to any caller. So an ask's asker is a fact the +// server enforces, and a warrant reaches only the asker it is for. +// - **A kinded bench** (ADR 0234 §2). A holder claims one kind and takes its own kind's accepts, says its +// own kind's events and asks its own kind's proofs, and nothing of another kind; a user submits to any +// kind. Each kind has its own worker on the seat's queue, so a holder that is away keeps its work and +// holds up no other kind. +// - **A proof** (`proofs`): core request and reply on `mesh.seat..proof..`. No stream's +// subjects cover it, so what travels there — a code typed by the operator — is never persisted. A kinded +// holder asks with its own kind; the modules that watch the seat answer. +// +// And one read: **a holder's records**, a bucket the seat names, read by each user under its own name only +// (`$KV...>`), so an asker reads the state of its own asks and no other asker's. + +// Worker is one durable consumer a holder pulls a seat's work from. +type Worker struct { + Stream string + Consumer string + Filter string +} + +// SeatTraffic is one module's seat traffic beyond tools. Publish and Subscribe are subject patterns, in the +// server's wildcards; the runtime that carries the module checks a bundle's request against them, since +// the runtime's own principal holds the union of every module it carries. +type SeatTraffic struct { + // Publish is what it submits (accepts of seats it uses), says (events of seats it holds) and asks + // (proofs of seats it holds a kind of). + Publish []string `json:"publish,omitempty"` + // Subscribe is what it hears: events of seats it uses that are named by caller, events of seats it + // watches, and accepts of seats it holds. + Subscribe []string `json:"subscribe,omitempty"` + // Answers is the proof subjects it answers, as a watcher of a kinded seat. + Answers []string `json:"answers,omitempty"` + // Workers are the work queues it takes from, as a holder. + Workers []Worker `json:"workers,omitempty"` + // Records is the direct-get subjects of the records it reads under its own name. + Records []string `json:"records,omitempty"` + // Kinds are the holders of every kinded bench it uses or watches, with what each promises: the one + // account of which channel is which, and what it can carry, that the router judges an answer by. The + // controller's, from the claims, never a channel's word (novox/hq ADR 0259 §5). + Kinds []KindHeld `json:"kinds,omitempty"` +} + +// KindHeld is one kind of a kinded bench and who holds it. +type KindHeld struct { + Seat string `json:"seat"` + Kind string `json:"kind"` + Module string `json:"module"` + Node string `json:"node"` + Capabilities []string `json:"capabilities,omitempty"` +} + +// KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded. +var KindedBenches = map[string]bool{"channel": true, "intake": true} + +// WorkerName is the worker a seat's holders pull from: one for the seat, or one per kind on a kinded bench. +func WorkerName(seat, kind string) string { + if kind == "" { + return "SEAT_" + upperSnake(seat) + "_worker" + } + return "SEAT_" + upperSnake(seat) + "_" + upperSnake(kind) + "_worker" +} + +func namesVerb(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +// SeatTrafficOf derives one module's seat traffic from the seats it holds, uses and watches. Only seats +// carrying one of the rules above are read: every other seat is composed as it always was. +func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic { + var t SeatTraffic + for _, s := range holds { + if !s.isNewTraffic() { + continue + } + kind := "" + if s.Kinded { + kind = s.Kind + if kind == "" || !safeSubject.MatchString(kind) { + // A kinded claim without a usable kind is refused at registration; here it is granted + // nothing, which is the same answer at the last place it could be asked. + continue + } + } + if len(s.Accepts) > 0 { + stream := seatStreamName(s.Name) + filter := "mesh.seat." + s.Name + ".accept.>" + if kind != "" { + filter = "mesh.seat." + s.Name + ".accept.*." + kind + } + t.Workers = append(t.Workers, Worker{Stream: stream, Consumer: WorkerName(s.Name, kind), Filter: filter}) + } + for _, a := range s.Accepts { + switch { + case kind != "": + t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+"."+kind)) + case namesVerb(s.ByCaller, a): + t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+".*")) + } + } + for _, e := range s.Emits { + switch { + case kind != "": + t.Publish = append(t.Publish, seatSubject(s, "event", e+"."+kind)) + case namesVerb(s.ByCaller, e): + t.Publish = append(t.Publish, seatSubject(s, "event", e+".*")) + } + } + if kind != "" { + for _, v := range s.Proofs { + t.Publish = append(t.Publish, seatSubject(s, "proof", v+"."+kind)) + } + } + } + for _, s := range uses { + if !s.isNewTraffic() { + continue + } + for _, a := range s.Accepts { + switch { + case namesVerb(s.ByCaller, a): + t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module)) + case s.Kinded && module == s.DeclaredBy: + // Work for a kind is put on its queue by the bench's own router, and by no other user. + t.Publish = append(t.Publish, seatSubject(s, "accept", a+".*")) + } + } + for _, e := range s.Emits { + if namesVerb(s.ByCaller, e) { + t.Subscribe = append(t.Subscribe, seatSubject(s, "event", e+"."+module)) + } + } + for _, b := range s.Records { + if !safeSubject.MatchString(b) { + continue + } + t.Records = append(t.Records, "$JS.API.DIRECT.GET.KV_"+b+".$KV."+b+"."+module+".>") + } + } + for _, w := range watches { + if w.Kinded { + for _, e := range w.Emits { + t.Subscribe = append(t.Subscribe, seatSubject(w, "event", e+".*")) + } + if module == w.DeclaredBy { + // A code is answered by the bench's own router, and by no other watcher. + for _, v := range w.Proofs { + t.Answers = append(t.Answers, seatSubject(w, "proof", v+".*")) + } + } + } + } + t.Publish = unique(t.Publish) + t.Subscribe = unique(t.Subscribe) + t.Answers = unique(t.Answers) + t.Records = unique(t.Records) + sort.Slice(t.Workers, func(i, j int) bool { return t.Workers[i].Consumer < t.Workers[j].Consumer }) + return t +} + +// grants is the bus permissions seat traffic needs: the subjects themselves, and the JetStream API a +// worker is pulled and acknowledged through and a record is read through. +func (t SeatTraffic) grants() (pub, sub []string) { + pub = append(pub, t.Publish...) + sub = append(sub, t.Subscribe...) + sub = append(sub, t.Answers...) + for _, w := range t.Workers { + pub = append(pub, + "$JS.API.CONSUMER.INFO."+w.Stream+"."+w.Consumer, + "$JS.API.CONSUMER.MSG.NEXT."+w.Stream+"."+w.Consumer, + "$JS.ACK."+w.Stream+"."+w.Consumer+".>") + } + pub = append(pub, t.Records...) + return pub, sub +} + +// isNewTraffic says whether a seat carries any of the rules above, so a seat that carries none is +// composed exactly as before them. +func (s Seat) isNewTraffic() bool { + return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0 +} + +// plainVerbs is a seat's accepts or emits with those the rules above compose taken out: a verb named by +// its caller and every verb of a kinded bench are composed by SeatTrafficOf and nowhere else. +func plainVerbs(s Seat, verbs []string) []string { + if s.Kinded { + return nil + } + var out []string + for _, v := range verbs { + if !namesVerb(s.ByCaller, v) { + out = append(out, v) + } + } + return out +} + +// SeatTrafficObjects is the work queues and workers the seat traffic of every composed user implies +// (novox/hq ADR 0259 §3): a queue for each seat a holder takes work from, and each holder's worker on it — +// one per kind on a kinded bench, filtered to that kind, so the kinds never take each other's work. Only +// seats carrying the rules above; the mesh's own seats' queues are RaiseSeats'. +func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) { + streams := map[string]Stream{} + consumers := map[string]Consumer{} + add := func(module string, holds []Seat) { + for _, w := range SeatTrafficOf(module, holds, nil, nil).Workers { + seat := "" + for _, s := range holds { + if seatStreamName(s.Name) == w.Stream { + seat = s.Name + } + } + streams[w.Stream] = Stream{ + Name: w.Stream, + Subjects: []string{"mesh.seat." + seat + ".accept.>"}, + Retention: RetentionWorkQueue, + MaxAge: 7 * 24 * 60 * 60, + Why: "work submitted to the " + seat + " seat; its holders take it, each kind its own, and it queues while nobody does", + } + consumers[w.Consumer] = Consumer{ + Name: w.Consumer, + Stream: w.Stream, + Filters: []string{w.Filter}, + AckWaitSeconds: 60, + // No bound on redelivery: a channel away for a day keeps its work, offered again later and + // later by its holder's runtime (novox/hq ADR 0259; the correctness review of 2026-10-08). + MaxDeliver: 0, + Why: module + " holds " + seat + "; it pulls one ask at a time and acknowledges once it has " + + "recorded it, so a crash redelivers rather than loses", + } + } + } + for _, p := range users { + switch p.Kind { + case KindModule: + add(p.Module, p.Holds) + case KindNodeTools: + for _, d := range p.Carries { + add(d.Module, d.Holds) + } + } + } + var ss []Stream + for _, s := range streams { + ss = append(ss, s) + } + sort.Slice(ss, func(i, j int) bool { return ss[i].Name < ss[j].Name }) + var cs []Consumer + for _, c := range consumers { + cs = append(cs, c) + } + sort.Slice(cs, func(i, j int) bool { return cs[i].Name < cs[j].Name }) + return ss, cs +} + +// trustedTraffic is why a module's seat traffic is the trusted holder's (novox/hq ADR 0259 §8), or "": it +// says a seat's event to one caller each (a warrant), or holds a kind of a kinded bench that proves its sender. +func trustedTraffic(d Declared) string { + for _, s := range d.Holds { + for _, e := range s.Emits { + if namesVerb(s.ByCaller, e) { + return "it says " + s.Name + "'s " + e + " to one caller each" + } + } + if s.Kinded && namesVerb(s.Capabilities, "verified-sender") { + return "it holds " + s.Name + " of kind " + s.Kind + ", which proves its sender" + } + } + return "" +} + +// TrafficQueues is the work queue of every seat naming its caller or its kind that accepts work, held or not +// (novox/hq ADR 0259 §3): what is submitted before a holder is assigned waits for it. +func TrafficQueues(seats []Seat) []Stream { + var out []Stream + seen := map[string]bool{} + for _, s := range seats { + if len(s.Accepts) == 0 || !s.isNewTraffic() || seen[s.Name] { + continue + } + seen[s.Name] = true + out = append(out, Stream{Name: seatStreamName(s.Name), Subjects: []string{"mesh.seat." + s.Name + ".accept.>"}, + Retention: RetentionWorkQueue, MaxAge: 7 * 24 * 60 * 60, + Why: "work submitted to the " + s.Name + " seat; its holders take it, each kind its own, and it queues while nobody does"}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} diff --git a/internal/broker/seattraffic_test.go b/internal/broker/seattraffic_test.go new file mode 100644 index 00000000..08110088 --- /dev/null +++ b/internal/broker/seattraffic_test.go @@ -0,0 +1,390 @@ +package broker + +import ( + "strings" + "testing" +) + +// The seats of novox/hq ADR 0259 §3, as the messenger declares them. +func operatorChannel() Seat { + return Seat{Name: "operator-channel", Scope: "mesh", Accepts: []string{"ask", "cancel"}, + Emits: []string{"decided"}, Serves: []string{"open", "history", "notify"}, + ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}} +} + +func channelSeat(kind string) Seat { + return Seat{Name: "channel", Scope: "mesh", Accepts: []string{"show", "edit", "send"}, Kinded: true, Kind: kind, + DeclaredBy: "messenger"} +} + +func intakeSeat(kind string) Seat { + return Seat{Name: "intake", Scope: "mesh", Emits: []string{"choice", "link"}, Proofs: []string{"code"}, + Kinded: true, Kind: kind, DeclaredBy: "messenger"} +} + +func allowed(patterns []string, subject string) bool { + for _, p := range patterns { + if subjectMatches(p, subject) { + return true + } + } + return false +} + +func perms(t *testing.T, p Principal) Permissions { + t.Helper() + got, err := PermissionsFor(p) + if err != nil { + t.Fatal(err) + } + return got +} + +func TestAnAskerAsksAndHearsUnderItsOwnNameOnly(t *testing.T) { + asker := Principal{Kind: KindModule, Node: "anchor", Module: "mesh-delivery", Uses: []Seat{operatorChannel()}} + got := perms(t, asker) + for _, s := range []string{ + "mesh.seat.operator-channel.accept.ask.mesh-delivery", + "mesh.seat.operator-channel.accept.cancel.mesh-delivery", + "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-delivery.a1", + } { + if !allowed(got.Publish, s) { + t.Errorf("an asker may not publish %s", s) + } + } + for _, s := range []string{ + "mesh.seat.operator-channel.accept.ask.mesh-controller", + "mesh.seat.operator-channel.accept.ask.*", + "mesh.seat.operator-channel.event.decided.mesh-delivery", + "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-controller.a1", + "$KV.messenger_asks.mesh-delivery.a1", + } { + if allowed(got.Publish, s) { + t.Errorf("an asker may publish %s, which is not its own to submit", s) + } + } + if !allowed(got.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-delivery") { + t.Error("an asker does not hear its own warrants") + } + if allowed(got.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-controller") { + t.Error("an asker hears another asker's warrants") + } + // And its own consumer carries its warrants, so a restart catches up. + c, ok := ConsumerFor(asker) + if !ok || !allowed(c.Filters, "mesh.seat.operator-channel.event.decided.mesh-delivery") { + t.Errorf("the asker's consumer does not carry its warrants: %v", c.Filters) + } +} + +func TestOnlyTheHolderPublishesAWarrant(t *testing.T) { + users, err := Users(Records{ + Nodes: []string{"anchor"}, + Assigned: map[string][]Declared{"anchor": { + {Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")}, + Watches: []Seat{{Name: "intake", Emits: []string{"choice", "link"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}}, + {Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}, + {Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}}, + }}, + }) + if err != nil { + t.Fatal(err) + } + for _, u := range users { + got := perms(t, u) + says := allowed(got.Publish, "mesh.seat.operator-channel.event.decided.mesh-delivery") + if says != (u.Module == "messenger") { + t.Errorf("%s %s publish a warrant", u.Username(), map[bool]string{true: "may", false: "may not"}[says]) + } + } +} + +func TestTheHolderTakesEveryCallersAskThroughItsWorker(t *testing.T) { + got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "messenger", Holds: []Seat{operatorChannel()}}) + if !allowed(got.Subscribe, "mesh.seat.operator-channel.accept.ask.mesh-delivery") { + t.Error("the router does not take an ask") + } + for _, s := range []string{ + "$JS.API.CONSUMER.MSG.NEXT.SEAT_OPERATOR_CHANNEL.SEAT_OPERATOR_CHANNEL_worker", + "$JS.ACK.SEAT_OPERATOR_CHANNEL.SEAT_OPERATOR_CHANNEL_worker.x", + "mesh.seat.operator-channel.event.decided.mesh-controller", + } { + if !allowed(got.Publish, s) { + t.Errorf("the router may not publish %s", s) + } + } + if allowed(got.Publish, "mesh.seat.operator-channel.accept.ask.messenger") { + t.Error("the holder may ask its own seat without using it") + } +} + +func TestAKindedHolderReachesItsOwnKindAndNoOther(t *testing.T) { + got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "telegram", + Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}}) + for _, s := range []string{ + "mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.event.link.telegram", + "mesh.seat.intake.proof.code.telegram", + "$JS.API.CONSUMER.MSG.NEXT.SEAT_CHANNEL.SEAT_CHANNEL_TELEGRAM_worker", + } { + if !allowed(got.Publish, s) { + t.Errorf("telegram may not publish %s", s) + } + } + for _, s := range []string{ + "mesh.seat.intake.event.choice.desktop", "mesh.seat.intake.proof.code.desktop", + "mesh.seat.channel.accept.show.telegram", + "$JS.API.CONSUMER.MSG.NEXT.SEAT_CHANNEL.SEAT_CHANNEL_DESKTOP_worker", + "mesh.seat.operator-channel.event.decided.mesh-delivery", + } { + if allowed(got.Publish, s) { + t.Errorf("telegram may publish %s", s) + } + } + if !allowed(got.Subscribe, "mesh.seat.channel.accept.show.telegram") || + allowed(got.Subscribe, "mesh.seat.channel.accept.show.desktop") { + t.Error("telegram does not take exactly its own kind's work") + } + if allowed(got.Subscribe, "mesh.seat.intake.proof.code.telegram") { + t.Error("a channel answers its own proofs") + } +} + +func TestTheWatcherAnswersProofsAndHearsEveryKind(t *testing.T) { + got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "messenger", + Uses: []Seat{channelSeat("")}, + Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}}) + for _, s := range []string{"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.proof.code.desktop"} { + if !allowed(got.Subscribe, s) { + t.Errorf("the router does not hear %s", s) + } + } + if !allowed(got.Publish, "mesh.seat.channel.accept.show.telegram") { + t.Error("the router cannot send a channel its work") + } + if allowed(got.Publish, "mesh.seat.intake.event.choice.telegram") || allowed(got.Publish, "mesh.seat.intake.proof.code.telegram") { + t.Error("the router may say a channel's answer or proof") + } +} + +func TestNoStreamKeepsAProof(t *testing.T) { + users, _ := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": { + {Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}}, + {Module: "messenger", Holds: []Seat{operatorChannel()}}, + }}}) + streams, _ := SeatTrafficObjects(users) + streams = append(streams, MeshStreams()...) + for _, s := range streams { + for _, subject := range s.Subjects { + if subjectMatches(subject, "mesh.seat.intake.proof.code.telegram") { + t.Errorf("%s keeps a proof (%s)", s.Name, subject) + } + } + } +} + +func TestEachKindHasAWorkerOfItsOwn(t *testing.T) { + users, _ := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": { + {Module: "telegram", Holds: []Seat{channelSeat("telegram")}}, + {Module: "desk-channel", Holds: []Seat{channelSeat("desktop")}}, + {Module: "messenger", Holds: []Seat{operatorChannel()}}, + }}}) + streams, workers := SeatTrafficObjects(users) + names := map[string]string{} + for _, w := range workers { + names[w.Name] = strings.Join(w.Filters, ",") + } + want := map[string]string{ + "SEAT_CHANNEL_TELEGRAM_worker": "mesh.seat.channel.accept.*.telegram", + "SEAT_CHANNEL_DESKTOP_worker": "mesh.seat.channel.accept.*.desktop", + "SEAT_OPERATOR_CHANNEL_worker": "mesh.seat.operator-channel.accept.>", + } + for n, f := range want { + if names[n] != f { + t.Errorf("worker %s filters %q, want %q", n, names[n], f) + } + } + if len(streams) != 2 { + t.Errorf("want the queues of channel and operator-channel, got %v", streams) + } +} + +func TestTheRuntimeIsGrantedTheUnionAndTheMembershipEachModulesShare(t *testing.T) { + telegram := Declared{Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}} + desk := Declared{Module: "desk-channel", Holds: []Seat{channelSeat("desktop"), intakeSeat("desktop")}} + got := perms(t, Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{telegram, desk}}) + for _, s := range []string{"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.event.choice.desktop"} { + if !allowed(got.Publish, s) { + t.Errorf("the runtime may not publish %s for a module it carries", s) + } + } + m := MembershipFor("anchor", telegram, Placements{}) + if m.SeatTraffic == nil || !allowed(m.SeatTraffic.Publish, "mesh.seat.intake.event.choice.telegram") || + allowed(m.SeatTraffic.Publish, "mesh.seat.intake.event.choice.desktop") { + t.Errorf("telegram's membership does not list exactly its own kind: %+v", m.SeatTraffic) + } + if plain := MembershipFor("anchor", Declared{Module: "plain"}, Placements{}); plain.SeatTraffic != nil { + t.Error("a module with no such seat is given seat traffic") + } +} + +func TestASeatWithoutTheNewRulesIsComposedAsBefore(t *testing.T) { + old := Seat{Name: "node-build-agent", Scope: "node", Accepts: []string{"build"}, Emits: []string{"built"}} + got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "builder", Holds: []Seat{old}}) + for _, s := range []string{"mesh.seat.node-build-agent.event.built", + "$JS.API.CONSUMER.MSG.NEXT.SEAT_NODE_BUILD_AGENT.SEAT_NODE_BUILD_AGENT_worker"} { + if !allowed(got.Publish, s) { + t.Errorf("an old seat's holder lost %s", s) + } + } + if !allowed(got.Subscribe, "mesh.seat.node-build-agent.accept.build") { + t.Error("an old seat's holder lost its accept") + } +} + +// The router learns which channel is which, and what each promises, from the controller's membership: +// the claims, never a channel's word (ADR 0259 §5). +func TestTheRoutersMembershipNamesEveryKindAndItsCapabilities(t *testing.T) { + tg := channelSeat("telegram") + tg.Capabilities = []string{"choice", "verified-sender"} + desk := channelSeat("desktop") + desk.Capabilities = []string{"choice"} + router := Declared{Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")}} + records := Records{Nodes: []string{"anchor", "laptop"}, Assigned: map[string][]Declared{ + "anchor": {router, {Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"}}, + "laptop": {{Module: "desk-channel", Holds: []Seat{desk}}}, + }, RootFree: map[string]bool{"anchor": true}} + where := PlacementsOf(records, nil) + m := MembershipFor("anchor", router, where) + if m.SeatTraffic == nil || len(m.SeatTraffic.Kinds) != 2 { + t.Fatalf("the router is not told the kinds: %+v", m.SeatTraffic) + } + byKind := map[string]KindHeld{} + for _, k := range m.SeatTraffic.Kinds { + byKind[k.Kind] = k + } + if k := byKind["telegram"]; k.Module != "telegram" || k.Node != "anchor" || !namesVerb(k.Capabilities, "verified-sender") { + t.Errorf("telegram is %+v", k) + } + if k := byKind["desktop"]; k.Module != "desk-channel" || namesVerb(k.Capabilities, "verified-sender") { + t.Errorf("the desk is %+v", k) + } + if other := MembershipFor("anchor", Declared{Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}, where); other.SeatTraffic != nil && len(other.SeatTraffic.Kinds) > 0 { + t.Error("an asker is told the channels") + } +} + +// novox/hq ADR 0259 §8: only the bench's own router answers its proofs and puts work on a kind's queue. +func TestOnlyTheBenchsRouterAnswersProofsAndSubmitsWork(t *testing.T) { + other := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "eavesdropper", + Uses: []Seat{channelSeat("")}, + Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}}) + if allowed(other.Subscribe, "mesh.seat.intake.proof.code.telegram") { + t.Error("a watcher that is not the router answers codes") + } + if allowed(other.Publish, "mesh.seat.channel.accept.show.telegram") { + t.Error("a user that is not the router puts work on a kind's queue") + } + if !allowed(other.Subscribe, "mesh.seat.intake.event.choice.telegram") { + t.Error("a watcher no longer hears the bench's events") + } +} + +// novox/hq ADR 0259 §8: the machine's runtime runs as the operator's account; it never carries a module +// that says warrants or speaks for a kind proving its sender, and such a module has its own account. +func TestTheMachinesRuntimeNeverCarriesATrustedHolder(t *testing.T) { + tg := channelSeat("telegram") + tg.Capabilities = []string{"choice", "verified-sender"} + for name, d := range map[string]Declared{ + "the router": {Module: "messenger", Holds: []Seat{operatorChannel()}}, + "a verified channel": {Module: "telegram", Holds: []Seat{tg}}, + } { + if _, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, + Carries: []Declared{d}}); err == nil || !strings.Contains(err.Error(), "an account of its own") { + t.Errorf("%s was composed into the machine's runtime: %v", name, err) + } + } + desk := channelSeat("desktop") + desk.Capabilities = []string{"choice"} + if _, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, + Carries: []Declared{{Module: "desk-channel", Holds: []Seat{desk}}}}); err != nil { + t.Errorf("a channel proving nothing was refused: %v", err) + } + users, err := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": { + {Module: RuntimeModule}, {Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"}, + {Module: "messenger", Holds: []Seat{operatorChannel()}, RunsAs: "messenger"}, + }}}) + if err != nil { + t.Fatal(err) + } + for _, u := range users { + if u.Kind == KindNodeTools { + for _, d := range u.Carries { + if d.RunsAs != "" { + t.Errorf("the machine's runtime carries %s", d.Module) + } + } + if _, err := PermissionsFor(u); err != nil { + t.Errorf("the runtime could not be composed: %v", err) + } + } + } +} + +// novox/hq ADR 0259 §8: verified-sender reaches the router only from a holder of its own account, on a machine +// root-free when composed, with the router's own machine root-free too (the review of 2026-10-09, H3). +func TestVerifiedSenderIsBelievedOnlyFromAHolderOfItsOwnAccount(t *testing.T) { + if got := placedCapabilities([]string{"choice", "verified-sender"}, "", true); namesVerb(got, "verified-sender") { + t.Errorf("a carried holder keeps verified-sender: %v", got) + } + if got := placedCapabilities([]string{"choice", "verified-sender"}, "telegram", true); !namesVerb(got, "verified-sender") { + t.Errorf("a holder of its own account on a root-free machine lost verified-sender: %v", got) + } + if got := placedCapabilities([]string{"choice", "verified-sender"}, "telegram", false); namesVerb(got, "verified-sender") || + !namesVerb(got, "choice") { + t.Errorf("a holder on a machine not root-free keeps verified-sender, or lost the rest: %v", got) + } +} + +// The kinds the router is told carry verified-sender only while the channel's machine and the router's are +// both root-free as composed; no record of a pass is no pass, and neither is a router placed nowhere. +func TestVerifiedSenderNeedsTheChannelsAndTheRoutersMachinesRootFree(t *testing.T) { + tg := channelSeat("telegram") + tg.Capabilities = []string{"choice", "verified-sender"} + router := Declared{Module: "messenger", Holds: []Seat{operatorChannel()}, RunsAs: "messenger"} + telegram := Declared{Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"} + verified := func(r Records) bool { + for _, k := range PlacementsOf(r, nil).Kinds { + if k.Kind == "telegram" { + return namesVerb(k.Capabilities, "verified-sender") + } + } + t.Fatal("telegram not placed") + return false + } + same := func(free map[string]bool) Records { + return Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {router, telegram}}, RootFree: free} + } + apart := func(free map[string]bool) Records { + return Records{Nodes: []string{"anchor", "relay"}, + Assigned: map[string][]Declared{"anchor": {router}, "relay": {telegram}}, RootFree: free} + } + if !verified(same(map[string]bool{"anchor": true})) { + t.Error("both on one root-free machine: verified-sender withheld") + } + if verified(same(nil)) { + t.Error("no record of a pass, and verified-sender kept") + } + if verified(apart(map[string]bool{"relay": true})) { + t.Error("the router's machine not root-free, and verified-sender kept") + } + if verified(apart(map[string]bool{"anchor": true})) { + t.Error("the channel's machine not root-free, and verified-sender kept") + } + if !verified(apart(map[string]bool{"anchor": true, "relay": true})) { + t.Error("both machines root-free: verified-sender withheld") + } + noRouter := Records{Nodes: []string{"relay"}, Assigned: map[string][]Declared{"relay": {telegram}}, + RootFree: map[string]bool{"relay": true}} + if verified(noRouter) { + t.Error("no router placed, and verified-sender kept") + } +} diff --git a/internal/broker/users.go b/internal/broker/users.go index e06ad472..adddd6e9 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -50,6 +50,9 @@ type Declared struct { // Checks are the module's own tools its health asks, each `.` (novox/hq ADR 0240, to-be // 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more. Checks []string + // RunsAs is the account the module runs as in a runtime of its own (novox/hq ADR 0259 §8): it is never + // carried by the machine's runtime, and reaches the bus on its own account. + RunsAs string } // Records is what composing a user list needs to know about the mesh, and nothing more. @@ -67,6 +70,10 @@ type Records struct { // Interchangeable is each module whose definition says its instances are the same anywhere // (ADR 0160), which decides whether the module's plain subject is issued to every instance. Interchangeable map[string]bool + // RootFree is each machine judged root-free when this was composed (novox/hq ADR 0259 §8): it names an + // account agents run as, judged unable to become root by its node-engine, and serves no login shell + // execute. A machine absent is not free: no record of a pass is no pass. + RootFree map[string]bool } // Users is every user the composed file should contain, in the order it will be written. @@ -120,10 +127,13 @@ func Users(r Records) ([]Principal, error) { }) } if runtimeHere { - out = append(out, Principal{ - Kind: KindNodeTools, Node: node, Module: RuntimeModule, - Carries: append([]Declared(nil), r.Assigned[node]...), - }) + var carried []Declared + for _, d := range r.Assigned[node] { + if d.RunsAs == "" { + carried = append(carried, d) + } + } + out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: RuntimeModule, Carries: carried}) } } for _, node := range sortedCopy(r.Enrolling) { diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index fadab8da..7d7f1e5e 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -1101,9 +1101,23 @@ func (r Resolution) compose(with Rendering, owner map[string]string, } owner[fmt.Sprint(process["id"])] = RuntimeModule out = append(out, process) - // What each module's bundles are given is read as the account the runtime runs as. + // And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8). + owns, err := r.ownRuntimes(with) + if err != nil { + return nil, err + } + for _, p := range owns { + id := fmt.Sprint(p["id"]) + owner[id] = strings.TrimSuffix(id, "."+OwnRuntimeID()) + out = append(out, p) + } + // What each module's bundles are given is read as the account the runtime runs as — not what a + // module of its own account is given, which its own account reads. words := map[string]map[string]string{} for _, m := range r.Modules { + if m.RunsAs != "" { + continue + } w, err := bundleWords(m, with) if err != nil { return nil, err diff --git a/internal/catalogue/kinded_test.go b/internal/catalogue/kinded_test.go new file mode 100644 index 00000000..896a87f3 --- /dev/null +++ b/internal/catalogue/kinded_test.go @@ -0,0 +1,168 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The router of novox/hq ADR 0259: it declares the operator's seat and the two kinded benches. +func router() Manifest { + return Manifest{Module: "messenger", Tools: []string{"open", "history", "notify"}, + State: []StateDeclaration{{Name: "asks"}}, RunsAs: "messenger", SecretsOwner: "messenger", + DefinesSeats: []SeatDeclaration{ + {Name: "operator-channel", Scope: ScopeMesh, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, + ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}, + Serves: []Verb{{Name: "open"}, {Name: "history"}, {Name: "notify"}}}, + {Name: "channel", Scope: ScopeMesh, Kinded: true, Accepts: []string{"show", "edit", "send"}}, + {Name: "intake", Scope: ScopeMesh, Kinded: true, Emits: []string{"choice", "link"}, Proofs: []string{"code"}}, + }, + Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh}}, + Uses: []string{"channel"}} +} + +func aChannel(module, kind string) Manifest { + return Manifest{Module: module, Claims: []Claim{ + {Name: "channel", Scope: ScopeMesh, Kind: kind}, {Name: "intake", Scope: ScopeMesh, Kind: kind}}} +} + +func TestTwoChannelsOfDifferentKindsHoldTheBenches(t *testing.T) { + shelf := Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"), + "desk-channel": aChannel("desk-channel", "desktop")} + if got := problemsFor(t, shelf); got != "" { + t.Fatalf("two kinds were refused: %s", got) + } + for _, m := range shelf { + if got := declaredSeatProblems(m); len(got) > 0 { + t.Fatalf("%s: %v", m.Module, got) + } + } +} + +func TestASecondClaimOfOneKindIsRefused(t *testing.T) { + got := problemsFor(t, Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"), + "telegram-two": aChannel("telegram-two", "telegram")}) + if !strings.Contains(got, `of kind "telegram", which telegram already claims`) { + t.Fatalf("a second holder of one kind stood: %s", got) + } +} + +func TestAKindedBenchNeedsAKindAndNoOtherSeatTakesOne(t *testing.T) { + got := problemsFor(t, Shelf{"messenger": router(), "nameless": aChannel("nameless", "")}) + if !strings.Contains(got, "claims the kinded bench channel and names no kind") { + t.Fatalf("a claim without a kind stood: %s", got) + } + odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Kind: "telegram"}}} + got = problemsFor(t, Shelf{"messenger": router(), "odd": odd}) + if !strings.Contains(got, "only a kinded bench takes a kind") { + t.Fatalf("a kind on a seat that is not kinded stood: %s", got) + } + dotted := problemsFor(t, Shelf{"messenger": router(), "dotted": aChannel("dotted", "a.b")}) + if !strings.Contains(dotted, "not a usable name") { + t.Fatalf("a kind that would widen a subject stood: %s", dotted) + } +} + +func TestOnlyChannelAndIntakeAreKinded(t *testing.T) { + m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "pager", Kinded: true, Accepts: []string{"page"}}}} + if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "only channel and intake are kinded") { + t.Fatalf("another kinded bench was declared: %s", got) + } +} + +func TestTheNewRulesAreHeldToWhatTheSeatSays(t *testing.T) { + m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "thing", Accepts: []string{"do"}, + ByCaller: []string{"undo"}, Proofs: []string{"code"}, Records: []string{"nothing"}}}} + got := strings.Join(declaredSeatProblems(m), "; ") + for _, want := range []string{"names thing.undo by its caller, which the seat neither accepts nor emits", + "declares proofs on thing, which is not kinded", `read its records "nothing", which it keeps no state of`} { + if !strings.Contains(got, want) { + t.Errorf("not refused: %q in %s", want, got) + } + } +} + +// Two kinds on one machine are two holders, and one kind on two machines is a second claimant. +func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) { + modules := []Manifest{aChannel("telegram", "telegram"), aChannel("desk-channel", "desktop")} + held, problems := checkClaims(modules, Node{Name: "anchor"}, nil, nil) + if len(problems) > 0 || len(held) != 4 { + t.Fatalf("two kinds on one machine: held %v, problems %v", held, problems) + } + _, problems = checkClaims([]Manifest{aChannel("telegram", "telegram")}, Node{Name: "home"}, held, nil) + if len(problems) == 0 { + t.Fatal("one kind held on two machines was not refused") + } +} + +// A channel's capabilities come from the fixed vocabulary, and only a kinded claim carries any. +func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) { + good := aChannel("telegram", "telegram") + good.Claims[0].Capabilities = []string{"deliver", "choice", "verified-sender", "max-length:4096"} + good.RunsAs = "telegram" + if got := problemsFor(t, Shelf{"messenger": router(), "telegram": good}); got != "" { + t.Fatalf("the vocabulary was refused: %s", got) + } + bad := aChannel("telegram", "telegram") + bad.Claims[0].Capabilities = []string{"trusted", "max-length:lots"} + got := problemsFor(t, Shelf{"messenger": router(), "telegram": bad}) + for _, w := range []string{`"trusted"`, `"max-length:lots"`} { + if !strings.Contains(got, w+", which channel-capabilities/1 does not have") { + t.Errorf("%s was not refused: %s", w, got) + } + } + odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Capabilities: []string{"deliver"}}}} + if got := problemsFor(t, Shelf{"messenger": router(), "odd": odd}); !strings.Contains(got, "only a kinded bench's claim carries them") { + t.Errorf("capabilities on a seat that is not kinded stood: %s", got) + } +} + +// novox/hq ADR 0259 §8: a module saying warrants, or speaking for a kind that proves its sender, runs as an +// account of its own — never carried by the machine's runtime, which runs as the operator's account. +func TestATrustedHolderMustRunAsAnAccountOfItsOwn(t *testing.T) { + r := router() + r.RunsAs = "" + if got := problemsFor(t, Shelf{"messenger": r}); !strings.Contains(got, "messenger must run as an account of its own") { + t.Errorf("a router on the machine's runtime stood: %s", got) + } + tg := aChannel("telegram", "telegram") + tg.Claims[0].Capabilities = []string{"choice", "verified-sender"} + if got := problemsFor(t, Shelf{"messenger": router(), "telegram": tg}); !strings.Contains(got, "telegram must run as an account of its own") { + t.Errorf("a verified channel on the machine's runtime stood: %s", got) + } + desk := aChannel("desk-channel", "desktop") + desk.Claims[0].Capabilities = []string{"choice"} + if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": desk}); got != "" { + t.Errorf("a channel proving nothing was held to it: %s", got) + } + // A kind that is `private` shows a link's code, which links an account as the operator: its holder is + // trusted with it, so it runs as its own account too (the confirmation review of 2026-10-09). + private := aChannel("desk-channel", "desktop") + private.Claims[0].Capabilities = []string{"choice", "private"} + if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": private}); !strings.Contains(got, "desk-channel must run as an account of its own") { + t.Errorf("a private channel on the machine's runtime stood: %s", got) + } +} + +func TestRunsAsIsAnAccountOfTheModulesOwn(t *testing.T) { + ok := Manifest{Module: "telegram", RunsAs: "telegram", SecretsOwner: "telegram", + OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}}, + Resources: []map[string]any{{"id": "account", "type": "user", "name": "telegram"}}} + if got := RunsAsProblems(ok); len(got) != 0 { + t.Fatalf("a sound runs-as was refused: %v", got) + } + for want, change := range map[string]func(*Manifest){ + "never root": func(m *Manifest) { m.RunsAs, m.SecretsOwner = "root", "root" }, + "not an account name": func(m *Manifest) { m.RunsAs = "${machine:account}" }, + "which it does not make": func(m *Manifest) { m.Resources = nil }, + "declares no own secret": func(m *Manifest) { m.OwnSecrets = nil }, + "they are the account's own": func(m *Manifest) { m.SecretsOwner = "" }, + } { + m := ok + m.Resources = append([]map[string]any(nil), ok.Resources...) + m.OwnSecrets = OwnSecrets{"broker": {Path: "/x"}} + change(&m) + if got := strings.Join(RunsAsProblems(m), "; "); !strings.Contains(got, want) { + t.Errorf("want %q, got %q", want, got) + } + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 8dcd7a51..842580cf 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -64,6 +64,13 @@ type Claim struct { // text/template over one piece — its fields and `module` — in the tool's own grammar (novox/hq ADR // 0255). The data is the mesh's, the format the holder's, as a module's facts template is. Renders map[string]string `json:"renders,omitempty"` + // Kind is the kind this module holds a kinded bench as (novox/hq ADR 0234 §2, ADR 0259): `telegram`, + // `desktop`. Refused on any other seat, and a second claim of one kind is refused. + Kind string `json:"kind,omitempty"` + // Capabilities are what a channel of this kind promises, from the fixed vocabulary + // channel-capabilities/1 (novox/hq ADR 0234 §2): the router judges an answer by these, read from the + // controller's record of this claim and never from the channel. + Capabilities []string `json:"capabilities,omitempty"` } // ServesFor is what this claim offers a seat's protocol: the verbs it names, else the module's @@ -640,6 +647,13 @@ type Manifest struct { // cannot use. SecretsOwner string `json:"secrets-owner,omitempty"` + // RunsAs is the account this module's tools bundle runs as, in a runtime of its own on a bus account of + // its own (novox/hq ADR 0259 §8): never the machine's runtime, which runs as the operator's account and + // carries every module on the machine. The account is one the module makes (a `user` resource of that + // name), owns its secrets (`secrets-owner`), and is neither root nor the operator's. Required of a module + // that says a warrant, or speaks for a channel kind that proves its sender. + RunsAs string `json:"runs-as,omitempty"` + // Keeps is where this module wants every operator-sealed secret in the mesh written — the // vault's field, and so far nobody else's (novox/hq ADR 0085, amended). // @@ -1620,6 +1634,7 @@ func ParseManifest(raw []byte) (Manifest, error) { // prefix. Whether a seat anybody names exists, and whether a holder answers for it, are // facts about the catalogue and are checked at registration (CatalogueProblems). problems = append(problems, declaredSeatProblems(m)...) + problems = append(problems, RunsAsProblems(m)...) if m.Computed != "" && len(m.Resources) > 0 { // One or the other. A module that both ships files and has them computed would leave // nobody able to say where a given file came from. diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index de3b5305..6ed02150 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -120,6 +120,16 @@ type Held struct { Node string Module string Site string + // Kind is the kind a kinded bench is held as (novox/hq ADR 0234 §2): each kind is its own holder. + Kind string +} + +// heldKey is what one holder holds: the seat, and its kind on a kinded bench. +func heldKey(claim, kind string) string { + if kind == "" { + return canonicalSeat(claim) + } + return canonicalSeat(claim) + "/" + kind } // Resolution is what a node should run, and why. @@ -874,7 +884,7 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel // **One seat under either of its names** (novox/hq ADR 0122): a manifest registered before // a rename claims the former name, and one written after it the current — two claimants of // one seat, compared by the seat they resolve to and not by how each spelled it. - seat := canonicalSeat(c.Name) + seat := heldKey(c.Name, c.Kind) if other, taken := byScope[scope][seat]; taken { problems = append(problems, fmt.Sprintf( "%s and %s both claim %q, and only one thing may hold it per %s", @@ -883,14 +893,14 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel } byScope[scope][seat] = m.Module held = append(held, Held{Claim: c.Name, Scope: scope, Node: node.Name, - Module: m.Module, Site: node.Site}) + Module: m.Module, Site: node.Site, Kind: c.Kind}) } } // And against the rest of the mesh, for the scopes that reach past this machine. for _, h := range held { for _, e := range elsewhere { - if e.Node == node.Name || canonicalSeat(e.Claim) != canonicalSeat(h.Claim) || e.Scope != h.Scope { + if e.Node == node.Name || heldKey(e.Claim, e.Kind) != heldKey(h.Claim, h.Kind) || e.Scope != h.Scope { continue } switch h.Scope { diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go index 71a5c87a..b7f3fbe4 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -170,6 +170,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) { if with.Adopted && m.Filtering != nil { continue } + if m.RunsAs != "" { + // Served by a runtime of its own, on its own account (ownRuntimes): never the machine's. + continue + } words, err := bundleWords(m, with) if err != nil { return nil, err @@ -232,6 +236,94 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) { return process, nil } +// OwnRuntimeID names the process a module of its own account is served by (novox/hq ADR 0259 §8). +func OwnRuntimeID() string { return "own-runtime" } + +// ownRuntimes are the processes the modules of their own account are served by (novox/hq ADR 0259 §8): each +// the machine's runtime program — the same build, run from the same source — serving that one module alone, +// as the module's own account, on the module's own bus credential. Never the machine's runtime, which runs +// as the operator's account and carries every module on the machine. +func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) { + var own []Manifest + for _, m := range r.Modules { + if m.RunsAs != "" && !(with.Adopted && m.Filtering != nil) { + own = append(own, m) + } + } + if len(own) == 0 { + return nil, nil + } + var runtime *Manifest + for i := range r.Modules { + if r.Modules[i].Module == RuntimeModule { + runtime = &r.Modules[i] + } + } + if runtime == nil || len(runtime.Bundles) != 1 || runtime.Bundles[0].Binary == "" { + return nil, fmt.Errorf("%s runs as its own account in a runtime of its own, and %s is not here to run it "+ + "from: assign %s to %s first (novox/hq ADR 0259)", own[0].Module, RuntimeModule, RuntimeModule, r.Node) + } + program := runtime.Bundles[0] + var out []map[string]any + for _, m := range own { + // Never the node's operator account, nor the account agents run as there (the review of 2026-10-09): + // either would hand what it holds back to the very accounts it is kept from. + switch { + case r.Account != "" && m.RunsAs == r.Account: + return nil, fmt.Errorf("%s runs as %s, the operator's account on %s: a module of its own account never "+ + "runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node) + case r.AgentAccount != "" && m.RunsAs == r.AgentAccount: + return nil, fmt.Errorf("%s runs as %s, the account agents run as on %s: a module of its own account "+ + "never runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node) + } + credential, declared := m.OwnSecrets["broker"] + if !declared { + return nil, fmt.Errorf("%s runs as its own account and declares no own secret broker", m.Module) + } + var served, restartOn []string + for _, b := range m.Bundles { + for _, load := range b.Loads { + if launcher, has := b.Launchers[load]; has { + load = launcher + } + served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load) + } + if len(b.Loads) > 0 { + restartOn = append(restartOn, m.Module+"."+BundleID(b.Name)) + } + } + if len(served) == 0 { + return nil, fmt.Errorf("%s runs as its own account and its build produced no bundle to serve", m.Module) + } + sort.Strings(served) + restartOn = append(restartOn, m.Module+"."+NeedID("broker")) + sort.Strings(restartOn) + env := map[string]string{RuntimeToolModules: strings.Join(served, ","), RuntimeBrokerFile: credential.Path} + words, err := bundleWords(m, with) + if err != nil { + return nil, err + } + if len(words) > 0 { + body, err := json.Marshal(map[string]map[string]string{m.Module: words}) + if err != nil { + return nil, err + } + env[RuntimeToolEnv] = string(body) + } + process := map[string]any{ + "id": m.Module + "." + OwnRuntimeID(), "type": "process", "name": m.Module + "-runtime", + "source": program.Source, "digest": program.Digest, + "run": []any{"./" + program.Binary}, "env": env, "restart-on": toAny(restartOn), + "user": m.RunsAs, + } + if err := artifactsInto(process, RuntimeModule, with); err != nil { + return nil, err + } + out = append(out, process) + } + return out, nil +} + func toAny(in []string) []any { out := make([]any, 0, len(in)) for _, s := range in { diff --git a/internal/catalogue/runtime_test.go b/internal/catalogue/runtime_test.go index 5d2b9f65..9c16bdb5 100644 --- a/internal/catalogue/runtime_test.go +++ b/internal/catalogue/runtime_test.go @@ -457,3 +457,75 @@ func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) { t.Error("a Go bundle loading a file it does not contain was admitted") } } + +// novox/hq ADR 0259 §8: a module of its own account is served by a runtime of its own — the machine's +// runtime program, as that account, on that module's own credential — and never by the machine's runtime, +// which runs as the operator's account and is given none of its words. +func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) { + with := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}} + goRuntime := Manifest{Module: RuntimeModule, Version: "1", + OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}}, + Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go", + System: "arch", From: "cmd/node-tools"}}}} + goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + telegram := aToolsModule(t, "telegram", "tools/index.js") + telegram.RunsAs, telegram.SecretsOwner = "telegram", "telegram" + telegram.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}} + out, err := Resolution{Node: "anchor", Account: "ops", + Modules: []Manifest{aToolsModule(t, "nftables", "tools/index.js"), telegram, goRuntime}}.Declaration(with) + if err != nil { + t.Fatal(err) + } + machine := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()) + if served := machine["env"].(map[string]string)[RuntimeToolModules]; strings.Contains(served, "telegram") || !strings.Contains(served, "nftables") { + t.Errorf("the machine's runtime serves %q", served) + } + own := fileNamed(out, "telegram."+OwnRuntimeID()) + if own == nil { + t.Fatalf("telegram has no runtime of its own: %v", ids(out)) + } + env := own["env"].(map[string]string) + if own["user"] != "telegram" || fmt.Sprint(own["run"]) != "[./node-tools]" || + env[RuntimeBrokerFile] != "/var/lib/telegram/broker" || + env[RuntimeToolModules] != "telegram="+BundleRoot+"/telegram/tools/tools/index.js" { + t.Errorf("its own runtime: user %v run %v env %v", own["user"], own["run"], env) + } + if _, told := env[RuntimeOperatorAccount]; told { + t.Error("a runtime of a module's own account is told the operator's account") + } + // Without the machine's runtime to run it from, it is refused in words. + if _, err := (Resolution{Node: "anchor", Modules: []Manifest{telegram}}).ownRuntimes(with); err == nil { + t.Error("a module of its own account composed without a runtime program") + } +} + +// The review of 2026-10-09 (L4): a module of its own account never runs as the node's operator account, nor +// as the account agents run as there — either would hand what it holds back to the accounts it is kept from. +func TestAModuleOfItsOwnAccountIsRefusedTheOperatorsAndTheAgentsAccount(t *testing.T) { + with := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}} + goRuntime := Manifest{Module: RuntimeModule, Version: "1", + OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}}, + Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go", + System: "arch", From: "cmd/node-tools"}}}} + goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + for _, account := range []string{"ops", "agent"} { + telegram := aToolsModule(t, "telegram", "tools/index.js") + telegram.RunsAs, telegram.SecretsOwner = account, account + telegram.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}} + _, err := Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent", + Modules: []Manifest{telegram, goRuntime}}.ownRuntimes(with) + if err == nil || !strings.Contains(err.Error(), account) { + t.Errorf("telegram running as %s was composed: %v", account, err) + } + } +} diff --git a/internal/catalogue/seats_declared.go b/internal/catalogue/seats_declared.go index 5fb89bdf..972eabf7 100644 --- a/internal/catalogue/seats_declared.go +++ b/internal/catalogue/seats_declared.go @@ -2,6 +2,7 @@ package catalogue import ( "fmt" + "regexp" "sort" "strings" ) @@ -51,6 +52,35 @@ type SeatDeclaration struct { // owns its own, which is why a seat is also the answer for a module that needs retention // its events cannot have. RetainSeconds int `json:"retain-seconds,omitempty"` + + // Kinded makes the seat a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): its holders are different + // modules, each claiming one kind, and each verb's subject carries the kind. Only the benches in + // KindedBenches may be kinded; making another is a decision, recorded. + Kinded bool `json:"kinded,omitempty"` + // ByCaller are accepts and emits whose subject's last token names the calling module (ADR 0259 §3): a + // user submits such an accept, and hears such an event, under its own name and no other. + ByCaller []string `json:"by-caller,omitempty"` + // Proofs are verbs carried as core request and reply, never on a stream: what travels on them (a code + // the operator typed) is never kept (ADR 0259 §3). On a kinded bench a holder asks with its own kind and + // the modules watching the seat answer. + Proofs []string `json:"proofs,omitempty"` + // Records are state buckets of the declaring module that each user reads under its own name — the + // keys `.…` and no other (ADR 0259 §3). + Records []string `json:"records,omitempty"` +} + +// KindedBenches are the seats that may be kinded (novox/hq ADR 0234 §2): `channel` sends to the operator, +// `intake` takes what the operator answers. Another is a decision, recorded, as ADR 0223 asks of a bench. +var KindedBenches = map[string]bool{"channel": true, "intake": true} + +// NamedByCaller says whether one of the seat's verbs is named by its caller. +func (s SeatDeclaration) NamedByCaller(verb string) bool { + for _, v := range s.ByCaller { + if v == verb { + return true + } + } + return false } // At is this declaration's scope, with the default applied. Mesh by default, because a seat @@ -132,6 +162,7 @@ func declaredSeatProblems(m Manifest) []string { "%s declares %s.%s, which is not a usable verb", m.Module, s.Name, v)) } } + problems = append(problems, trafficProblems(m, s)...) } for _, u := range m.Uses { @@ -142,6 +173,56 @@ func declaredSeatProblems(m Manifest) []string { return problems } +// trafficProblems is what one declaration of the rules of ADR 0259 §3 can be judged on alone. +func trafficProblems(m Manifest, s SeatDeclaration) []string { + var problems []string + if s.Kinded && !KindedBenches[s.Name] { + problems = append(problems, fmt.Sprintf( + "%s declares %s as a kinded bench; only channel and intake are kinded, and another is a "+ + "decision, recorded (novox/hq ADR 0234)", m.Module, s.Name)) + } + if s.Kinded && len(s.ByCaller) > 0 { + problems = append(problems, fmt.Sprintf( + "%s declares %s kinded and names verbs by their caller; a kinded bench's subjects carry the kind", + m.Module, s.Name)) + } + for _, v := range s.ByCaller { + inAccepts, inEmits := false, false + for _, a := range s.Accepts { + inAccepts = inAccepts || a == v + } + for _, e := range s.Emits { + inEmits = inEmits || e == v + } + if !inAccepts && !inEmits { + problems = append(problems, fmt.Sprintf( + "%s names %s.%s by its caller, which the seat neither accepts nor emits", m.Module, s.Name, v)) + } + } + for _, v := range s.Proofs { + if !name.MatchString(v) || strings.Contains(v, ".") { + problems = append(problems, fmt.Sprintf("%s declares the proof %s.%s, which is not a usable verb", + m.Module, s.Name, v)) + } + } + if len(s.Proofs) > 0 && !s.Kinded { + problems = append(problems, fmt.Sprintf( + "%s declares proofs on %s, which is not kinded; a proof is asked by a holder of a kind", + m.Module, s.Name)) + } + for _, r := range s.Records { + kept := false + for _, st := range m.State { + kept = kept || st.Name == r + } + if !kept { + problems = append(problems, fmt.Sprintf( + "%s says %s's users read its records %q, which it keeps no state of", m.Module, s.Name, r)) + } + } + return problems +} + // A Shelf is every manifest the mesh has registered, by module name. type Shelf map[string]Manifest @@ -182,8 +263,19 @@ func CatalogueProblems(shelf Shelf) []string { return ok } + // Who claims each kind of a kinded bench, so a second claim of one kind is refused (ADR 0234 §2). + kindsTaken := map[string]string{} + for _, module := range shelfOrder(shelf) { m := shelf[module] + for _, c := range m.Claims { + if c.Kind != "" { + if _, isModuleSeat := declared[c.Name]; !isModuleSeat { + problems = append(problems, fmt.Sprintf( + "%s claims %s of kind %q, and only a kinded bench takes a kind", module, c.Name, c.Kind)) + } + } + } // A `uses` naming nothing is where ADR 0110's guarantee lands under a derived set: the // same refusal, at the same moment, from a set nobody maintains by hand. @@ -214,6 +306,7 @@ func CatalogueProblems(shelf Shelf) []string { } continue } + problems = append(problems, kindProblems(module, c, s, kindsTaken)...) if c.At() != s.At() { problems = append(problems, fmt.Sprintf( "%s claims %s at scope %q, and %s declares it at %s", @@ -228,6 +321,16 @@ func CatalogueProblems(shelf Shelf) []string { } } } + // **A trusted holder runs as its own account** (novox/hq ADR 0259 §8): a module saying warrants, or + // speaking for a kind that proves its sender, is never carried by a machine's runtime. + for _, module := range shelfOrder(shelf) { + m := shelf[module] + if why := TrustedHolding(m, declared); why != "" && m.RunsAs == "" { + problems = append(problems, fmt.Sprintf( + "%s must run as an account of its own (runs-as): %s, and the machine's runtime runs as the "+ + "operator's account, which every agent runs as (novox/hq ADR 0259)", module, why)) + } + } // A read of a module's state that module does not keep (novox/hq ADR 0201) — said only where the // owner is on the shelf, as a consumer may be installed before its emitter. var manifests []Manifest @@ -239,6 +342,63 @@ func CatalogueProblems(shelf Shelf) []string { return problems } +// ChannelCapabilities is the fixed vocabulary `channel-capabilities/1` (novox/hq ADR 0234 §2): a word +// outside it is refused. `max-length:` takes a number. +var ChannelCapabilities = map[string]bool{ + "deliver": true, "reaches-away": true, "loud": true, "silent": true, "edit": true, + "reaches-when-mesh-down": true, "private": true, + "choice": true, "reply": true, "threads": true, "operator-first": true, + "verified-sender": true, "exact-render": true, "code-factor": true, "key-factor": true, +} + +var maxLength = regexp.MustCompile(`^max-length:[1-9][0-9]{0,6}$`) + +// capabilityProblems are the words of a claim outside the vocabulary, and capabilities on a claim of a +// seat that is not kinded. +func capabilityProblems(module string, c Claim, kinded bool) []string { + if len(c.Capabilities) == 0 { + return nil + } + if !kinded { + return []string{fmt.Sprintf("%s claims %s with capabilities, and only a kinded bench's claim carries them", + module, c.Name)} + } + var problems []string + for _, w := range c.Capabilities { + if !ChannelCapabilities[w] && !maxLength.MatchString(w) { + problems = append(problems, fmt.Sprintf( + "%s claims %s with the capability %q, which channel-capabilities/1 does not have", module, c.Name, w)) + } + } + return problems +} + +// kindProblems is a claim judged against a declared seat's kind: a kinded bench takes one claim per kind, +// a usable name; any other seat takes none. +func kindProblems(module string, c Claim, s SeatDeclaration, taken map[string]string) []string { + if problems := capabilityProblems(module, c, s.Kinded); len(problems) > 0 { + return problems + } + switch { + case !s.Kinded && c.Kind != "": + return []string{fmt.Sprintf("%s claims %s of kind %q, and only a kinded bench takes a kind", + module, c.Name, c.Kind)} + case !s.Kinded: + return nil + case c.Kind == "": + return []string{fmt.Sprintf("%s claims the kinded bench %s and names no kind", module, c.Name)} + case !name.MatchString(c.Kind) || strings.Contains(c.Kind, "."): + return []string{fmt.Sprintf("%s claims %s of kind %q, which is not a usable name", module, c.Name, c.Kind)} + } + key := c.Name + "/" + c.Kind + if first, ok := taken[key]; ok && first != module { + return []string{fmt.Sprintf("%s claims %s of kind %q, which %s already claims; a kind has one holder", + module, c.Name, c.Kind, first)} + } + taken[key] = module + return nil +} + // unserved is what a seat's protocol promises and the claimant does not answer. Only the tools // are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool // is code the module either has or has not written — under the claim's serves, or among its own. @@ -266,3 +426,70 @@ func shelfOrder(shelf Shelf) []string { sort.Strings(out) return out } + +var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,30}$`) + +// RunsAsProblems is what one manifest's `runs-as` is held to (novox/hq ADR 0259 §8): an account of the +// module's own making — a `user` resource of that name — that owns its secrets, with a bus account of its own, +// and that is neither root nor the operator's. +func RunsAsProblems(m Manifest) []string { + if m.RunsAs == "" { + return nil + } + var problems []string + say := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) } + switch { + case !accountName.MatchString(m.RunsAs): + say("%s runs as %q, which is not an account name of the module's own", m.Module, m.RunsAs) + return problems + case m.RunsAs == "root": + say("%s runs as root; a module of its own account runs as an account it makes, never root", m.Module) + } + made := false + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == m.RunsAs { + made = true + } + } + if !made { + say("%s runs as %s, which it does not make: a user resource named %s", m.Module, m.RunsAs, m.RunsAs) + } + if _, has := m.OwnSecrets["broker"]; !has { + say("%s runs as its own account and declares no own secret broker: its runtime reaches the bus on an "+ + "account of its own", m.Module) + } + if m.SecretsOwner != m.RunsAs { + say("%s runs as %s, and its secrets belong to %q: they are the account's own", m.Module, m.RunsAs, m.SecretsOwner) + } + return problems +} + +// TrustedHolding is why a module must run as its own account (novox/hq ADR 0259 §8), or "": it holds a seat +// whose events it says to one caller each (a warrant), or speaks for a kind of a kinded bench that proves +// its sender, or is private (a link's code is shown there). None may be carried by the machine's runtime, which +// runs as the operator's account. +func TrustedHolding(m Manifest, declared map[string]SeatDeclaration) string { + for _, c := range m.Claims { + s, ok := declared[c.Name] + if !ok { + continue + } + for _, e := range s.Emits { + if s.NamedByCaller(e) { + return fmt.Sprintf("it holds %s, whose %s it says to one caller each", c.Name, e) + } + } + if s.Kinded { + for _, capability := range c.Capabilities { + switch capability { + case "verified-sender": + return fmt.Sprintf("it holds %s of kind %s, which proves its sender", c.Name, c.Kind) + case "private": + // A private kind is shown a link's code, which makes an account the operator's. + return fmt.Sprintf("it holds %s of kind %s, which is private: a link's code is shown there", c.Name, c.Kind) + } + } + } + } + return "" +} diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 6f2525fa..f63b91df 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -429,6 +429,15 @@ var ControllerVerbs = []Verb{ "cause": "with consumer or older-than: the cause in a word (cleanup-waiting when absent)", }, nil, "confirm")}, // What a consumer gave up on (novox/hq issue 330): kept in DEAD_LETTERS until a person acts on it. + {Name: "root-free", Description: "Whether each machine named is root-free now (novox/hq ADR 0259 §8): no agent " + + "there can become root without a person. Judged when asked, never from a condition: free only when the machine " + + "names an account its agents run as, its node-engine judged that account unable to become root within the " + + "last 15 minutes, and the login shell's execute is not served there. Anything else, a read that failed " + + "included, is not free and says why. The router asks it before an answer from a channel proving its sender " + + "may approve. Only reads.", + Input: listed(schema(map[string]string{ + "machines": "the machines to judge, by name: a list, or one text separated by commas", + }, []string{"machines"}), "machines")}, {Name: "dead-letters", Description: "Every message a consumer on the bus gave up on after handing it over " + "as often as it may, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and " + "when it was given up, newest first. With id: that one whole, with what it said. With deliver: hand it " + @@ -545,6 +554,21 @@ func schema(properties map[string]string, required []string, switches ...string) return out } +// listed makes the named properties of a schema lists of text: a caller gives them as a JSON array (or, as +// any argument, one text separated by commas). +func listed(in map[string]any, names ...string) map[string]any { + props, _ := in["properties"].(map[string]any) + for _, n := range names { + p, _ := props[n].(map[string]any) + if p == nil { + panic("a list that is not a property: " + n) + } + props[n] = map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": p["description"]} + } + return in +} + // unpromised is what a claim says it serves and the seat's protocol never promised. func unpromised(serves []string, promised []Verb) []string { has := map[string]bool{} diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index 60935291..68ca4f54 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -34,9 +34,12 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) { // that seat promises. **Across the whole catalogue, not one manifest**: a seat is declared by // one module and held by another, which is the whole reason a seat exists (ADR 0118). seats := map[string]catalogue.SeatDeclaration{} + // And who declared each, so a seat's records are named as the declaring module's buckets (ADR 0259). + declarers := map[string]string{} for _, m := range declared { for _, s := range m.DefinesSeats { seats[s.Name] = s + declarers[s.Name] = m.Module } } // And the mesh's own, which carry protocol too (novox/hq ADR 0121). Added after the modules' @@ -78,7 +81,7 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) { "%s is assigned to %s and is not in the catalogue, so what it may say cannot "+ "be derived", module, n.Name) } - d := declaredFor(m, seats) + d := declaredFor(m, seats, declarers) // And the state offered to it as a seat's holder by the modules beside it (novox/hq ADR 0255). // For this machine's key alone (novox/hq ADR 0260): a bar shows its own machine's draw. for _, sr := range catalogue.ReadsGranted(m, onMachine(declared, modules), n.Name) { @@ -121,7 +124,7 @@ func onMachine(declared map[string]catalogue.Manifest, modules []string) []catal // declaredFor is one module's manifest as the composer needs it: what it says about itself, and the // protocol of every seat it holds or uses. -func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration) broker.Declared { +func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration, declarers map[string]string) broker.Declared { // A consumed name is a module's event unless it names a seat, and only somebody holding the seat // set can tell (novox/hq ADR 0121). Split here, because the composer cannot look at a name and // know — and a role's event read as a module's is a subscription to a namespace nobody owns. @@ -132,6 +135,16 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio if named { // A seat's event when the seat says it; else the event of the module of that name — a seat and // the module holding it may share a name (mesh-delivery, novox/hq ADR 0239). + if s, isASeat := seats[emitter]; isASeat && s.Kinded { + // A kinded bench's event is named `` or `.*` and heard from every kind; its + // proofs are answered by whoever watches it (ADR 0259 §3). + ev := strings.TrimSuffix(event, ".*") + if catalogue.SeatSays(s.Emits, ev) { + watches = append(watches, broker.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{ev}, + Kinded: true, Proofs: s.Proofs, DeclaredBy: declarers[s.Name]}) + continue + } + } if s, isASeat := seats[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) { watches = append(watches, broker.Seat{Name: s.Name, Emits: []string{event}}) continue @@ -155,6 +168,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio Reads: m.Reads, // And the tools its health asks (novox/hq ADR 0240): the machine's node-engine is granted them. Checks: catalogue.HealthChecks(m), + // And whether it runs as an account of its own (novox/hq ADR 0259 §8). + RunsAs: m.RunsAs, } // Whether it can be given an account at all: delivered as its own secret named broker, so one // that declares none has nowhere to read it (novox/hq issue 195). @@ -168,12 +183,15 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio // Every seat with a protocol, the mesh's own included. One that says only who does a job is // not here and grants nothing, which is most of them. if s, hasAProtocol := seats[c.Name]; hasAProtocol { - d.Holds = append(d.Holds, asSeat(s)) + held := asSeat(s, declarers[s.Name]) + held.Kind = c.Kind + held.Capabilities = c.Capabilities + d.Holds = append(d.Holds, held) } } for _, name := range m.Uses { if s, declaredSomewhere := seats[name]; declaredSomewhere { - d.Uses = append(d.Uses, asSeat(s)) + d.Uses = append(d.Uses, asSeat(s, declarers[s.Name])) } } return d @@ -204,9 +222,17 @@ func (i *Inventory) DeclaredBuckets(ctx context.Context) ([]broker.Bucket, error return out, nil } -func asSeat(s catalogue.SeatDeclaration) broker.Seat { - return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits, - Serves: catalogue.VerbNames(s.Serves)} +func asSeat(s catalogue.SeatDeclaration, declarer string) broker.Seat { + seat := broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits, + Serves: catalogue.VerbNames(s.Serves), Kinded: s.Kinded, ByCaller: s.ByCaller, Proofs: s.Proofs, + DeclaredBy: declarer} + // A seat's records are its declaring module's buckets, named as the bus holds them (ADR 0259 §3). + for _, r := range s.Records { + if declarer != "" { + seat.Records = append(seat.Records, broker.BucketName(declarer, r)) + } + } + return seat } // MeshSeats are the mesh's own seats that carry a protocol, as the bus needs them: what to make a work @@ -277,3 +303,22 @@ func heldHere(claimed []broker.Seat, holdings []catalogue.Held, node, module str } return out } + +// DeclaredTrafficSeats is every seat any registered module declares that names its caller or its kind +// (novox/hq ADR 0259 §3), as the bus needs it: assigned or not, so its work queue exists from registration. +func (i *Inventory) DeclaredTrafficSeats(ctx context.Context) ([]broker.Seat, error) { + declared, err := i.Catalogue(ctx) + if err != nil { + return nil, fmt.Errorf("cannot read the catalogue: %w", err) + } + var out []broker.Seat + for _, m := range declared { + for _, s := range m.DefinesSeats { + seat := asSeat(s, m.Module) + if seat.Kinded || len(seat.ByCaller) > 0 { + out = append(out, seat) + } + } + } + return out, nil +} diff --git a/internal/inventory/rootfree_grants_test.go b/internal/inventory/rootfree_grants_test.go new file mode 100644 index 00000000..119ecff2 --- /dev/null +++ b/internal/inventory/rootfree_grants_test.go @@ -0,0 +1,119 @@ +package inventory + +import ( + "os" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/catalogue" +) + +// grantMatches is a NATS subject pattern matching a subject: `*` one token, `>` the rest. +func grantMatches(pattern, subject string) bool { + p, s := strings.Split(pattern, "."), strings.Split(subject, ".") + for i, tok := range p { + if tok == ">" { + return len(s) > i + } + if i >= len(s) || (tok != "*" && tok != s[i]) { + return false + } + } + return len(p) == len(s) +} + +func grantsAny(patterns []string, subject string) bool { + for _, p := range patterns { + if grantMatches(p, subject) { + return true + } + } + return false +} + +// novox/hq ADR 0259 §8 (confirmation review of 2026-10-09): the router honours a verified sender only on the +// controller's `root-free` word, asked on the bus. So only the serving controller may answer that verb — be +// subscribed to its subject — and nobody may publish into the router's inbox but by answering a request it +// made (allow_responses). Composed here from the controller's own manifest, on a machine where the machine's +// runtime carries it beside an ordinary module, with the router, a channel, a person and an administrator: a +// runtime carrying the controller's module, a node-engine, a channel or anybody else answering `root-free` is +// an agent answering it. +func TestOnlyTheServingControllerMayAnswerRootFree(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + controller, err := catalogue.ParseManifest(raw) + if err != nil { + t.Fatal(err) + } + parse := func(s string) catalogue.Manifest { + m, err := catalogue.ParseManifest([]byte(s)) + if err != nil { + t.Fatal(err) + } + return m + } + dir := `"resources": [{"id": "state", "type": "directory", "mode": "0700", "place": "."}]` + 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"]}], + "invokes": ["seat:mesh-controller.root-free", "seat:mesh-controller.conditions"], + "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"}]}`) + ordinary := parse(`{"module": "lab-bystander", "version": "1", "own-secrets": {"broker": "${dir:state}/broker"}, ` + dir + `}`) + runtime := catalogue.Manifest{Module: broker.RuntimeModule} + + manifests := []catalogue.Manifest{controller, router, ordinary, 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{"anchor", "laptop"}, Assigned: map[string][]broker.Declared{}, + People: map[string][]string{"operator": {"*"}, "guest": {"mesh-controller.status"}}, + Interchangeable: map[string]bool{}} + for _, m := range manifests { + records.Assigned["anchor"] = append(records.Assigned["anchor"], declaredFor(m, seats, declarers)) + } + // And a second machine whose runtime carries an ordinary module: where agents run as the operator. + records.Assigned["laptop"] = []broker.Declared{declaredFor(ordinary, seats, declarers), declaredFor(runtime, seats, declarers)} + users, err := broker.Users(records) + if err != nil { + t.Fatal(err) + } + const verb = "mesh.seat.mesh-controller.tool.root-free" + answerers := 0 + for _, u := range users { + p, err := broker.PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + answers := grantsAny(p.Subscribe, verb) + if answers != (u.Kind == broker.KindController) { + t.Errorf("%s (%s) %s subscribe to %s", u.Username(), u.Kind, + map[bool]string{true: "may", false: "may not"}[answers], verb) + } + if answers { + answerers++ + } + // Nobody publishes into the router's inbox but as an answer to what it asked. + for _, inbox := range []string{"_INBOX.anchor.messenger.x1.y", "_INBOX.anchor.messenger.>"} { + if u.Username() != "anchor.messenger" && grantsAny(p.Publish, inbox) { + t.Errorf("%s may publish into the router's inbox (%s) without being asked", u.Username(), inbox) + } + } + } + if answerers != 1 { + t.Errorf("%d principals may answer root-free, want the controller alone", answerers) + } +} diff --git a/module.json b/module.json index ca6f2374..89f35a66 100644 --- a/module.json +++ b/module.json @@ -72,6 +72,7 @@ "retire", "cleanup", "dead-letters", + "root-free", "data", "build", "artifacts",