diff --git a/internal/broker/delivery_parts_grant_test.go b/internal/broker/delivery_parts_grant_test.go index 947e69c1..b4e622ca 100644 --- a/internal/broker/delivery_parts_grant_test.go +++ b/internal/broker/delivery_parts_grant_test.go @@ -636,3 +636,58 @@ func TestTheToolRunnerStillCarriesAModuleWithToolsAndItsOwnCredential(t *testing } } } + +// routeProxyExcerpt is the part of mesh-control's modules/route-proxy/module.json at 70cb217601f9 (read 2026-10-20) +// that decides its grants: its state, what it reads, its own broker secret, and the bundle `provider` that the machine's +// runtime loads (`loads`) though the module declares no tools. +const routeProxyExcerpt = `{ + "module": "route-proxy", + "state": ["bound"], + "reads": ["mesh-controller.assignments"], + "own-secrets": { "broker": "${dir:mesh-state}/broker" }, + "build": { "artifacts": [ + { "name": "provider", "kind": "bundle", "language": "go", "from": "cmd/route-provider", "binary": "route-provider", "loads": ["route-provider"] } + ] } +}` + +// 1329 (review): a module with no tools whose bundle the runtime loads stays carried, because its helper writes its state +// and reads on the runner's account; the delivery's five parts are still dropped. +func TestTheToolRunnerStillCarriesAModuleWhoseBundleItLoads(t *testing.T) { + var m struct { + Module string `json:"module"` + State []string `json:"state"` + Reads []string `json:"reads"` + Build struct { + Artifacts []struct { + Loads []string `json:"loads"` + } `json:"artifacts"` + } `json:"build"` + } + if err := json.Unmarshal([]byte(routeProxyExcerpt), &m); err != nil { + t.Fatal(err) + } + proxy := Declared{Module: m.Module, Reads: m.Reads} + for _, s := range m.State { + proxy.State = append(proxy.State, Bucket{Module: m.Module, Name: s, History: 1}) + } + for _, a := range m.Build.Artifacts { + proxy.Loads = proxy.Loads || len(a.Loads) > 0 + } + streams := map[string]ModuleStream{"mesh-delivery-forge-bridge": {Module: "mesh-delivery-forge-bridge", Name: "FORGE"}} + all := composed(t, Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": { + {Module: RuntimeModule}, proxy, declaredOf(t, bridgeManifest, streams), declaredOf(t, indexerManifest, streams), + declaredOf(t, tellerManifest, streams), declaredOf(t, watchmanManifest, streams), + }}}) + runner := all["runner@novox"] + if !canWrite(runner, "route-proxy_bound", "k") { + t.Error("novox's tool runner does not carry route-proxy, whose bundle it loads") + } + if !canWrite(all["route-proxy@novox"], "route-proxy_bound", "k") { + t.Error("route-proxy lost its own credential") + } + for _, b := range []string{"mesh-delivery-teller_answers", "mesh-delivery-forge-bridge_heads", "mesh-delivery-indexer_modules", "mesh-delivery-watchman_stuck"} { + if canWrite(runner, b, "k") { + t.Errorf("novox's tool runner may write %s", b) + } + } +} diff --git a/internal/broker/users.go b/internal/broker/users.go index 3612dc36..c33814db 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -69,6 +69,10 @@ 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 + // Loads says the module has a compiled bundle whose entrypoints the machine's tool runtime loads and runs + // (novox/hq ADR 0175; issue 1329): a helper that reaches the bus on the runner's account, so the runner + // must carry the module although it declares no tools. + Loads bool // 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 @@ -394,11 +398,11 @@ func boundBucketsOf(r Records) []string { // runnerServes says the machine's tool runner has something of this module to serve (novox/hq ADR 0436 §1, // ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, event families a session waits on, -// or a check its health asks. A module with none of these is a process of its own on its own credential, and +// a bundle the runtime loads (Loads), or a check its health asks. A module with none of these is a process of its own on its own credential, and // carrying it would hand every session on the machine the runner's account with that module's state, seat // traffic and streams. A module with no account of its own (NoAccount) is spoken for by the runner, so it // stays carried, and so does the runtime module itself. Whether the module has its own broker secret is not // the test: a module with tools and its own credential is still carried (ADR 0175). func runnerServes(d Declared) bool { - return d.Module == RuntimeModule || d.NoAccount || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0 + return d.Module == RuntimeModule || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0 } diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index 09fd6215..c68fddda 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -201,6 +201,12 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio // name (novox/hq ADR 0305). Issued: issuedTools(m), } + // Whether the node's runtime loads a bundle of it: a helper that runs on the runner's account (issue 1329). + for _, b := range m.Bundles { + if len(b.Loads) > 0 { + d.Loads = true + } + } // 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). if _, reads := m.OwnSecrets["broker"]; !reads {