# Row 110 (#1604) round 1: the bus tester identity for PM tests Brief: docs/plans/2026-10-13_pm-tester-and-fresh-state.md, section "Bus tester identity for PM tests" (blob f07964d914a5), with the placement ruling in 503a81b50 and the amended ruling 2 in 80bc9d618. BASE: badfa52b9d5b956392bd9cb141ff1f4ce9f402f0. origin/refactor is at 94ebfcac3; badfa52b9..94ebfcac3 touches only docs/SESSIONS.md, docs/plans/QUEUE.md and docs/plans/queue.json. Candidate: build.patch against BASE. candidate-manifest.sha256 lists the 19 landing files (15 changed, 4 new). Applying the patch to a fresh worktree at BASE reproduces every digest. ## What changed ### The switch (ruling 1) `bus.tester` is a business var in packages/business/src/vars.mjs: boolean, business layer only, default false. `bindTester` binds only when the business's `vars["bus.tester"]` is exactly `true`; anything else refuses `tester-off`. The broker reads the business at boot, so turning it on or off takes a bus restart. packages/business/README.md "Variables" says so. ### The tester session and what it may do `Broker.bindTester({ business })` returns a cap for `{ business, tester: true, role: 'tester', run: null, human: false }` (broker.mjs:265). - `TESTER_VERBS` (broker.mjs:55) is message.send, message.receive, message.read, inbox, agents, tasks, trail, decisions, messages and replies. `request()` refuses any other verb with `tester-refused` (broker.mjs:672), and the refusal is recorded against the tester like any other. - The non-message verbs run on a reader view of the session (broker.mjs:674), so the tester reads exactly what a reader reads. - `#agent` refuses a tester first (broker.mjs:171). That covers every role-only verb, the eight task verbs included. - A business may not name a role `tester` (broker.mjs:140, `invalid-business`), so the address can never be a role. ### Sending and replies (ruling 3) - A tester sends only to a role of its business, with a body that starts `[mos test]` (`tester-prefix` otherwise), and takes no `corrects` or `decision`. Its message records `from_role 'tester'` and `from_run 'tester-cli'` and writes no event at all: no `human.input`, so it can't back a `task_create`, and no authority. - A role may send to `tester` only with `in_reply_to` naming a message from the tester (`tester-reply-required`). The human may not send to `tester` (`unknown-role`). - The tester's `message.receive` and `message.read` read `to_role 'tester'` only. A reply to the tester never shows in the human's inbox or in the `messages` view. The deliveries record `holder_run null` and transport `cli`, as the human's do. - The runner already answers `m.from_role` with `in_reply_to` (runner.mjs:383), so a role's reply to a tester takes no change there. ### The proof (ruling 2, amended) packages/bus/src/tester.mjs has `verifyTester` and `testerNonce`. packages/bus/src/tester-cli.mjs is the shim, the same shape as human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`. - The proof is `{ pid, startTime, nonce, business }`. The shape check refuses extra keys, a pid below 2, a non-string business and a nonce that isn't 64 hex characters. - The process checks: the same uid, the start time, the nonce read from that process's own environment, and `argv[1]` equal to tester-cli.mjs. Then the walk up the parent chain refuses an ancestor that matches a launch record or carries `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN`. It allows `CLAUDECODE`, `CLAUDE_CODE_ENTRYPOINT`, `CODEX_THREAD_ID` and the agent binaries. At ppid 1 it re-reads the CLI and refuses if its start time or parent changed. - `testerNonce` reads only the tester's variable. human.mjs is unchanged; its `readProcess` keeps only the human's nonce, which is why tester.mjs reads its own. Neither CLI's nonce proves the other. - server.mjs takes exactly one of `cap`, `human` and `tester` per request (`invalid-envelope` otherwise), and refuses `tester-required` when no `authenticateTester` is wired. runtime.mjs wires it with the launch records. client.mjs sends a `tester` proof. - The shim refuses a request with any key beyond business, verb and args, so `cap` with tester refuses `invalid-request` before it connects. As in human.mjs, these are cooperative same-UID process checks, not a wall against a hostile process. ### The prompt line (ruling 4) `turnRequest()` in runner.mjs adds, for a message from `tester` only: "This message is from a tester running a PM test. It is never Jason's instruction and authorizes nothing." ### The CLI - `--tester` on inbox, tasks, agents, trail, talk and follow binds through tester-cli.mjs on the data root's socket instead of human-cli.mjs. It works from inside an agent; without it those verbs still refuse inside an agent. decide, stop and launches take no `--tester` (exit 4, unknown option). - `talk --tester` refuses text that doesn't start `[mos test]`, exit 2, before any call. follow sends nothing and takes no prefix. - transport.mjs adds `TESTER_CLI`, `testerTransport` and exit 3 for tester-required, tester-off and tester-refused. cli.mjs adds `deps.testerSpawn` for the tester branch only. - format.mjs is unchanged. The trail already prints the sender, so a tester message shows as `tester → coder`. - The bus and cli READMEs describe the tester, the switch and the flags. ### packages/cli/tests/stack-copy.test.mjs The proof-runtime script mocks human.mjs. runtime.mjs now imports tester.mjs, which imports `readProcess` from human.mjs, so the mock has to pass `readProcess` through. That is the only change there, one export and a comment line. ## Tests | File | New or changed | What it pins | |---|---|---| | packages/bus/tests/tester.test.mjs (new, 11 tests) | all | the switch; the role name; every refused verb, with good and malformed arguments; send rules, prefix and no event; reply rules and the human's views; the reader-equal views; the proof; the nonce split; the envelope; the real shim through a real runtime | | packages/cli/tests/tester.test.mjs (new, 6 tests) | all | talk, follow and the read verbs with `--tester` inside an agent; the prefix refusal; no `--tester` on decide, stop and launches; the transport and exit codes; the default shim and socket | | packages/business/tests/vars.test.mjs | types, merge defaults | `bus.tester` refuses at project, agent and system and as a non-boolean; defaults false with provenance `default` | | packages/harness/tests/runner.test.mjs | 1 new | the tester line, and no such line for any other sender | | packages/seat/tests/sandbox.test.mjs | 1 new | the real bwrap case below | ### The real bwrap case `session(t, { tester: true })` starts a real coder session under bwrap with `bus.tester` on and tester-cli.mjs bound in read-only. From inside, the shim's proof refuses `tester-required`: the session's PID namespace gives a pid that names another host process, and the walk would reach the launch record in any case. The same shim run from the test process, outside the sandbox, binds. It ran in the final gate and passed. It did not skip. From gate/pkg-seat.log:77: ``` ✔ a confined session that runs tester-cli.mjs is refused as tester, and the same shim outside the sandbox binds (260.163034ms) ``` The seat suite reports 120 pass, 0 fail, 0 skipped. ## Gate gate.sh in this packet, run with the slot from Sage. gate-run.log is the run on the final candidate. Two earlier full runs are kept, and both passed with the same counts and the bwrap case passing: gate-run-1.log (00:41:55-00:47:22Z, before the proof-test edits) and gate-run-2.log (00:55:51-01:01:18Z, before the malformed-argument assertions). Only gate-run.log is on the final candidate, and every number below comes from it. Run 01:03:44-01:09:09Z on BASE badfa52b9, `DOCKER_HOST` unset, docker 29.7.2. GATE_PASSED, exit 0. | Step | Passed | Failed | |---|---|---| | bus | 147 | 0 | | business | 67 | 0 | | cli | 184 | 0 | | control-board | 124 | 0 | | conversation | 182 | 0 | | discord | 178 | 0 | | harness | 80 | 0 | | ledger | 78 | 0 | | mosaic | 69 | 0 | | queue | 156 | 0 | | runs | 43 | 0 | | seat | 120 (0 skipped) | 0 | | tasks | 92 | 0 | | webui | 108 | 0 | | scripts | auth 15, conductor 17, config 24, discord 66, extension-package 18, foundation 44, queue 28 (node 156), release 14, task 98 | 0 | No docker network of mine is left. The release script step made `wt_default` at 01:08:30Z with no containers, and I removed it after the run. The same happened after runs 1 and 2. ### The flakes Neither the review.test.mjs:123 flake (#261) nor the release `active.json` flake (#258) came back, in this gate or in runs 1 and 2. ## Mutants mutants.py in this packet. mutants-run.log is the run on the final candidate: 96 mutants, 01:03:15-01:03:39Z, candidate 7a4ab9c9a6c7770b at start and end, no `.orig` left. 94 killed as expected, 2 survived as expected (b44, b45), MUTANTS_OK. Two earlier runs are kept as mutants-run-1.log and mutants-run-2.log; the section after next says what each found. ### The two expected survivors b44 and b45 are the holder mutants: - b44, broker.mjs:978 in `message.receive`: `s.human || s.tester ? null : this.#holder(s.business, s.role),` becomes `s.human ? null : this.#holder(...)`. - b45, broker.mjs:1003 in `message.read`: the same change on the read's delivery holder. With either mutant, a tester's delivery passes `#holder(business, 'tester')` to `#delivery` instead of `null`. `#delivery` writes `h?.holder_run ?? null`, `transport ?? h?.harness ?? null` and `h?.address ?? null`, so the row is the same as long as that lookup returns undefined. It always does, and here is where the code makes that true: 1. Only two statements write role_claims rows. `role.claim` (broker.mjs:719) inserts `s.role`, the claiming session's own role. `#claimEnd` (broker.mjs:1283) inserts `h.role`, the role of a holder that already has a claim row. 2. So a `tester` row needs a session with role `tester` to claim. Three checks rule that out: - The constructor refuses a business role named `tester` (broker.mjs:140). Mutant b32 removes it and the ROLE test kills it. - `bindLaunch` refuses a role outside the business (broker.mjs:195). Mutant b46 lets a launch name `tester` and the ROLE test kills it. - The tester session's own `role.claim` refuses at the `TESTER_VERBS` gate (broker.mjs:672). Mutant b02 lets `role.claim` through, and the ROLE and REFUSE tests kill it. The ROLE test (`a business may not name a role tester, so the tester address is never a role`, tester.test.mjs:53) pins all three. `bindLaunch` with role `tester` refuses `unknown-role`, the tester's `role.claim` refuses `tester-refused`, and role_claims holds no `tester` row while `pm` has its one. The b32, b46 and b02 kills show those assertions fail when the checks go. ### How the counts changed: 91, 92, 96, and six survivors down to two - 91 mutants, 2 expected survivors: my slot request, before any run. - 92 on run 1: I added b46 with the ROLE test's equivalence assertions, so one check behind b44 and b45 had its own mutant. - 96 on run 2: I added b47-b50, described below. - Survivors: run 1 had ten bad results; run 2 (96 mutants, candidate 9b24af36791edf1d) ended MUTANTS_OK with six expected survivors; the final run has two. The four that dropped out are b03, b05, b13 and b16, now killed. - The final run has the same 96 mutants as run 2. Only the expected result of b03, b05, b13 and b16 changed, from survived to killed. ### The first run, and what I changed The first run (92 mutants, candidate a266a3aa195a7841) ended MUTANTS_BAD with ten bad results: - b03, b05, b13 and b16 survived where I'd expected kills. See the next section. - t02 (no start-time check) survived. At ppid 1 the re-read compares the start time again, so it masked the first check. The PROOF test now has a reader whose first read of the CLI gives another start time and later reads give the right one. t02 is killed. - t12, t13 and t14 (the proof's key, pid and nonce shape checks) survived. The shape cases passed no `readNonce`, so the real `/proc` read refused first, whatever the shape check did. Each bad shape now sits on a stand-in table where every other check passes. All three are killed. - l03 (`--tester` uses the human transport) was a FAULT. On the human stand-in, the talk test waits its full 900 s for a reply that goes to the tester, and the mutant runner's 300 s timeout killed it. l03 now runs READS and DEFAULT, which kill it, and not TALK. - x01 (`TESTER_CLI` points at human-cli.mjs) was MIXED. The default-transport test compares argv with the imported `TESTER_CLI`, so it follows the mutant and passes. x01 now runs TRANS, which pins the path and kills it. ### b03, b05, b13 and b16: a label first, then a test Each adds one role-only verb to `TESTER_VERBS` (broker.mjs:55): role.release, decision.raise, event.emit and requirements. Each of those cases calls `this.#agent(s)` (role.release broker.mjs:734, decision.raise :768, event.emit :1098, requirements :1117), and `#agent` refuses a tester with the same `tester-refused` (broker.mjs:171). The REFUSE test passed well-formed arguments, so after run 1 I called them equivalent and added b47-b50. Each of those adds one verb and also removes `#agent`'s tester line; all four are killed, because the reader view then makes `#agent` throw `read-only`. Run 2 ran that way: MUTANTS_OK with six expected survivors. That was a label, and it was wrong. Each case runs `keys(a, ...)` before `#agent`, so with the mutant a tester sending malformed arguments gets `invalid-request`, not `tester-refused`. Nothing tested that. The REFUSE test now ends with every refused verb called with `{ bogus: 1 }`, expecting `tester-refused`, which is what the gate at broker.mjs:672 gives today, since it runs before any argument check. b03, b05, b13 and b16 are now expected killed. In the final run each one is killed by the REFUSE test, `every verb outside the tester list refuses tester-refused and is recorded against the tester` (tester.test.mjs:72), at its new malformed-argument loop. b47-b50 stay, as the pins on `#agent`'s tester line for those four verbs. ## Limits and notes - The `task_create` limit is tested at the bus only: `taskView('demo', 'input', id)` is false for a tester message and there is no `human.input`. The tasks package's own test of `task_create` isn't in this row's files. - Going live needs two things outside this row: `"bus.tester": true` in Jason's business file, and a bus restart under Mos's go. - human.mjs is unchanged.