# Row 175 (#1673, Q173, mos-0870 N2, mos-0872): tester reads and writes for the test system Builder: Dewey. Reviewer: Filbert. Gate owner: Sage. Brief: docs/plans/2026-10-19_tester-read-checks.md, blob 41b6033da224 (commit 3dfaea286, queue rev 2048), which adds the scripts/mosaic section, test 12 and its mutant to blob 8973499247a2 (1c90c68c5). Assignment sage-1074, plus sage-1081 for the wrapper. Round 2: sage-1094 (the rebase past row 169, and Filbert's round 1 notes N1-N3 from comment 29264). Slot: Darkwing's clear after the row 169 mutants (22:53Z). Round 2 base: 9e74925c2 (base.txt), the refactor tip with row 169's landing 264756a30 and row 179's 9e74925c2. Both changed docs/TOOLS.md, one of this row's 25 paths, so each moved the candidate off base (the candidate-drift rule). - Row 169 broke Filbert's round 1 approval of TOOLS.md's sha256. Round 2 first rebased onto 1bfc9833b, and that rebase applied cleanly. Row 169's TOOLS.md edit is in stack-release activate, pm-on and the two-phase PM start; this row's is the one-sentence agent-check exception at :277. At round 2's start, origin/refactor was e7d47c346, which lacks 264756a30. Sage's 1bfc9833b, pushed after, is the base that carries it. - Row 179 landed during the round 2 request (sage-1100). `git diff --stat 1bfc9833b 9e74925c2` lists BUILD-LOG.md, docs/SESSIONS.md and docs/TOOLS.md; only TOOLS.md is a manifest path. The rebase onto 9e74925c2 applied cleanly. `git range-diff` shows all three commits as `=`, and `git patch-id --stable` on the whole diff is c1cf0ed66006 both before and after. Only TOOLS.md's hash changed in the manifest. Candidate: 989699f7c (candidate.txt), three commits on the base in Dewey's worktree, not on refactor: 1. c0b06ed89, the row (round 1's 84e350f99, rebased). 2. 3067ae93e, sage-1081's wrapper line, test 12 and the bad-key asserts (round 1's 7b27ab3f9, rebased). 3. 989699f7c, N1-N3. build.patch is `git diff 9e74925c2 989699f7c`. candidate-manifest.sha256 (digest 2e30f2a1e667) lists the 25 changed or added paths with their sha256. That is the same path list as round 1. Six hashes differ from round 1's manifest: docs/TOOLS.md (the rebases), and broker.mjs, cli README, tester-reads.test, paths.mjs and tester-init.test (N1-N3). The first round 2 request (comment 29275 on #1673, manifest bdc4e74f4aa6, f9e41b918 on 1bfc9833b) is superseded. Its packet files are kept in r2a/. Follow-up, not in this row: row 179's TOOLS.md step 2 says "Until row 175 lands, `scripts/mosaic` doesn't dispatch `plugins`". After this row lands, the condition that matters is the window's target copy. A copy from before the landing still needs the `node /packages/cli/src/cli.mjs` form, and a later one takes `mosaic plugins set`. The 179 brief says the second row to land rebases. This row changes no wording there, so the sentence should be reworded once it lands. Round 1 (approved by Filbert, 29264): base d63cfca22, candidate 7b27ab3f9, manifest dc5c0361a310. Its packet files are kept in r1/. ## Row 164's pinned files `packages/bus/src/human.mjs`, `packages/bus/src/human-cli.mjs`, `packages/cli/src/transport.mjs` and `packages/webui/src/bus.mjs` are untouched (not in the diff). channel-proof.test.mjs passes on the candidate. A grep of every `packages/*/tests` file and `scripts/test-*.sh` for a 64-hex value finds pins on those four files only. A grep for each changed path finds one test that reads a changed doc: `packages/cli/tests/host.test.mjs:826` reads the cli README for the unit file's name, which this row doesn't touch; it passes. ## What ships ### Bus (`packages/bus/src/broker.mjs`) - `TESTER_VERBS` is exported and frozen. It gains `usage`, `names`, `limits`, `plugins`, `instances`, `project.list`, `thread.list`, `name.set` and `project.create` (19 entries). - New exported, frozen `TESTER_ORGANIZATION_VERBS`: `limits`, `plugins`, `instances`, `plugins.set`, `instances.set`, `routes.set`. In the organization branch the tester gate runs first, before any argument check, as the store's gate does: a tester sending any other organization verb (`limits.set`, `organization.set`, `organization.revision`, `routes`) refuses `tester-refused`. Then the branch passes a tester where it used to refuse `human-required`, and `actor = s.tester ? "tester" : s.human`, so W1-W3 receipts name `tester`. Every check the human's write runs still runs (revision, key lists, plugin and route checks, the instance writer's refusals). - `name.set` (W4): a tester skips `#classification` and `#consumeAuthority`, runs every name check, and the insert writes `by = 'tester'`. - `project.create` (W6) and `project.list`: a tester skips `#human`; `created_by` is `tester`. - `thread.list`: a tester skips `#human` and the query binds `owner` to the business human (`b.human`), so it lists the human's threads. Row 177 gives the tester its own. - `message.send` (W5): the `[mos test]` check, `tester-prefix` and `TESTER_PREFIX` are gone. The keys, role-only target, no decision, no authority, no `human.input` and `tester-reply-required` stay. - `#requestProjectFile` (`project.files`, `project.file`, `project.file.save`): these dispatch before the store's tester gate, so a tester used to get `human-required` from `#human`. It now refuses `tester-refused` first, so the brief's item 9 code holds for `project.file.save`. A behavior change for a tester only; the human's path is unchanged. ### CLI (`packages/cli/src/`) - `--tester` on the seven reads: `usage` (usage.mjs), `names` (cli.mjs), `limits` (the read only), `plugins`, `threads` (thread.mjs), `project list` (project.mjs) and `business instances` (instances.mjs). The flag stays in `parsed` until `callFor`/`channelCallFor`, which pick the tester transport. - `--tester` on the write forms: `plugins set` (W1), `business instance add|set` (W2), `name` (W4), `talk --tester` (W5, any text), `project create` (W6) and `project init` (W7). W3 has no CLI form. - `limits` refuses `--tester` on `set`, `reset` and `migrate` (exit 4, before any call). - `talk` loses the early prefix check and `TESTER_PREFIX`. - `project init --tester` (`projectCommand`), in order, before any disk write: 1. `testerProjectArea(context)`: refuses `tester-project-area` (ProjectError, exit 2) for a repo outside `/projects//`. 2. One `agents` read through the tester transport, so `tester-off` and `tester-required` exit 3. 3. `initProject(context, { author: 'tester' })`. `project check` takes no `--tester`, and a duplicate `--tester` on init is exit 4. - Usage strings gain ` [--tester]` on each form that takes it. ### Wrapper (`scripts/mosaic`, sage-1081) - The case line that sends verbs to `packages/cli/src/cli.mjs` gains `plugins`, `name`, `names`, `follow`, `threads` and `thread`. Before, they fell through to the seat CLI, which refused with `usage: mosaic launch ...`, so `mosaic plugins set` (W1), `mosaic name` (W4) and `mosaic names` failed through the wrapper. `queue` and `business` keep their own branches. scripts/mosaic is not a row 164 pin, and no test pins its hash. ### Round 2 (sage-1094, Filbert's notes) - N1: `testerProjectArea` also refuses the area root itself, `/projects/`, with `tester-project-area`. A repository there would hold every other project's default repository. `contains` is unchanged, since projectRepo's overlap check still needs it to count equal paths. The check is `relative(area, repo) === '' || !contains(area, repo)`. The cli README's `project init --tester` item says so. - N2: test 10's list gains `bus start acme --tester` and `bus stop --tester`. Both already refused with exit 4 in parse, so this is a test-only change. The cli README's no-`--tester` list names them. - N3: a comment at the reader view in `request` (broker.mjs, after the TESTER_VERBS gate). It says the view now also carries name.set and project.create, and that neither branch checks `reader`: each passes a tester by `s.tester` and records `tester`. ### Project (`packages/project/src/`) - `initProject(context, { author })`: the layout commit's author and committer are `author`, checked with `checkedId` before anything is made; without it, the business human as before. - `testerProjectArea(context)` in paths.mjs: `projectRepo(context)` (its own checks first), then the same `contains` test projectRepo uses (hoisted to module scope), against the area. It writes nothing. ### Docs bus README (verb table rows for organization.set, organization.revision and name.set; the instances, routes, tester and project-file paragraphs), cli README (usage block, the `--tester` paragraph, a new `project init --tester` item, limits, instances and usage sections), plugins README (step 5) and docs/TOOLS.md (one sentence: the `--tester` forms are the exception to the agent check). `grep -rn "tester-prefix\|mos test\]" packages scripts docs` now finds only sample message bodies in tests, which are valid bodies, not prefix rules. ## Design choices and tradeoffs - Both tester lists are exported so the item 13 test can pin them. - The organization gate sits before the argument checks, so a tester's refused verb never reveals argument errors, as at the store gate. - `contains` counts the area root itself as inside (`rel === ''`), as projectRepo's own use does. In round 1 that let a tester init a repo at `/projects/` itself. Filbert's N1 flagged it, and round 2 refuses it in `testerProjectArea` alone. - The project file verbs' tester code changes from `human-required` to `tester-refused`, so the brief's item 9 holds for `project.file.save` and the tester's codes stay uniform. - The W7 CLI tests spawn `scripts/mosaic` as its own process. The tester transport is spawnSync, so a tester-cli.mjs spawned from the test process couldn't reach a broker in that process; as a child of a child it can. The child's env drops `MOSAIC_BUS_(TESTER|CLI)_NONCE`. ## Tests (the brief's thirteen) New files: `packages/bus/tests/tester-writes.test.mjs` (8 tests), `packages/cli/tests/tester-reads.test.mjs` (6), `packages/project/tests/tester-init.test.mjs` (3). 1. bus tester-writes "reads": the human sets a name, a project and a thread; each of the seven reads deep-equals between tester and human, and thread.list, names and project.list are non-empty. 2. cli tester-reads "each read with --tester": for the seven forms, the output inside an agent with `--tester` equals the human form's; the tester transport is called and the human's isn't; without `--tester` exit 3 before any call. 3. bus tester-writes "W1-W3": plugins.set, instances.set and routes.set as the tester each log actor `tester` with a matching transaction; the human's routes.set logs `jason`. Also bus instances-verb.test and routes-verb.test pass the tester's actor through. 4. bus tester-writes "W2 refusals": a credential-declaring definition (`organization-credentials-out-of-scope`), a protected definition (`organization-definition-refused`), the names `human` and `tester` (`invalid-request`), and a `bus.tester` var on add and on change (`invalid-request`); the file bytes, revision and log are unchanged after each. Control: an add with a `model` var lands with actor `tester`. 5. bus tester-writes "W4" and names.test: `by = 'tester'`, no new events, the human's `jason`; `name-taken`, `name-is-instance` (instance key, `human`, `tester`) and `name-is-role` still refuse. 6. bus tester.test.mjs "a tester message is from tester, needs no prefix, and writes no human.input or authority" (updated): stored body without the prefix, `unknown-role` for the targets `human` and `tester`, `invalid-request` for `corrects` and `decision`, no event. cli tester.test "talk --tester sends text with no prefix" checks the stored body. 7. bus tester-writes "W6": `created_by` `tester` and `jason`, `project-exists` still refuses, the tester's project.list equals the human's. 8. cli tester-reads, three W7 tests through `scripts/mosaic`: init makes the repo under the area, committed `tester|tester`, and plain init inside an agent still exits 3; a repo outside the area (bus.tester off) exits 2 `tester-project-area` with the target missing and the data root listing unchanged; off exits 3 `tester-off` and each managed-run marker exits 3 `tester-required`, with no projects directory. project tester-init covers `initProject`'s author option and `testerProjectArea` directly; since round 2 its refused list includes the area root `/projects/acme` (N1). 9. bus tester-writes "human-only": the 13 sync verbs refuse `tester-refused`, and so do organization.set and organization.revision with a key neither takes (the gate runs before the argument checks), `project.file.save` and `project.files` reject `tester-refused`; the organization file bytes, the limits revision, the human's views and thread.messages are unchanged. 10. cli tester-reads: `--tester` exits 4 with no calls and no projects directory on limits set/reset/migrate, decide, inbox --read/--read-all, thread create/open/read, talk/follow --thread, stop, launches off/on/list, bus start and stop (round 2, N2), project check and a duplicate `--tester` on init. 11. bus tester-writes: `vars` null, `{}`, `false`, `'true'` and `1` each refuse `tester-off`. 12. cli tester-reads "scripts/mosaic sends plugins, name, names, follow, threads and thread to packages/cli": as instances.test.mjs:146, it spawns the wrapper with no config. Each verb, in a form packages/cli refuses, exits 4 with packages/cli's own message, and stderr has no seat usage (`mosaic launch <`). app.test.mjs:832, which pins the seat usage, passes unchanged. 13. bus tester-writes pins `TESTER_VERBS` (19) and `TESTER_ORGANIZATION_VERBS` (6) and checks both are frozen. ## Existing tests changed - bus tester.test: the message test drops the prefix and asserts the stored body. - bus names.test: the tester's name.set lands with `by = 'tester'` instead of refusing. - bus instances-verb.test: the tester leaves the refused set; its forged actor refuses `invalid-request`, its `instances` and `instances.set` pass with actor `tester`. - bus routes-verb.test: the tester's `routes` refuses `tester-refused`; its routes.set passes. - bus limits.test: the refusal loop covers role and reader; the tester's limits.set refuses `tester-refused` and its `limits` read passes. - cli names.test: the usage pins gain ` [--tester]`. - cli tester.test: the prefix test becomes "talk --tester sends text with no prefix, and follow --tester reads"; the `tester-prefix` exit map line goes. ## Runs All runs are Dewey's, in Dewey's worktrees with TMPDIR under Dewey's scratch, on the heavy-run slot (Darkwing's clear at 22:53Z, released to Sage at 23:10:15Z). Every count below is an awk sum over the `ℹ` lines of the run's logs. ### Round 1a, candidate 84e350f99 (manifest b9f438788558, 24 paths), 22:55:08Z-23:00:17Z - The 21 touched test files, each run alone: 169 tests, 169 pass. - Suites: bus 436/436, cli 369/369, plugins 111/111, project 40/40, business 149/149, webui 239 pass and 1 skip of 240. The skip is the existing editor-rebuild check, which needs esbuild 0.28.2; 0.28.1 is installed. Total: 1345 tests, 1344 pass, 0 fail, 1 skip. - Mutants: 25 run in a second worktree that matches the manifest, after a control pass of every named test file. 24 killed. m2b (organization.set let through for the tester) survived: it is a role verb, so `#checkAuthority` → `#agent` refuses a tester `tester-refused` anyway, and the test sent valid arguments. Round 1b adds bad-key asserts that kill it. ### Round 1b, candidate 7b27ab3f9 (manifest dc5c0361a310, 25 paths), 23:04:15Z-23:10:04Z - The 21 touched files, plus app.test.mjs, stack-copy.test.mjs and stack-release.test.mjs (the other tests that spawn or copy scripts/mosaic), each run alone: 285 tests, 285 pass. - The cli suite: 370/370 (369 plus test 12). - Mutants after a control pass: m2b, now killed by tester-writes. m10a (drop `plugins` from the wrapper's case line) and m10b (drop `name`) are both killed by tester-reads test 12. Each file was restored and its hash checked, and the wrapper kept its mode. - Both worktrees matched the manifest at the start and at the end. A sweep after the run found no process with a working directory in Dewey's run dirs. After the slot release, a grep for `scripts/mosaic` in every `packages/*/tests` file and `scripts/test-*.sh` found more readers than the cli files above. The queue suite's dispatch.test.mjs runs the real wrapper against fake CLIs: run alone, 4/4. None of its cases uses the six verbs. `bash -n scripts/mosaic` passes and the file is still executable, which is what test-queue.sh checks. The webui, runs and other hits mention the path only as text: unit file lines, a usage string, or `scripts/mosaic-task.mjs`/`mosaic-config.mjs`. The bus, plugins, project, business and webui suites weren't rerun in 1b. Their source is unchanged between 84e350f99 and 7b27ab3f9; the one bus file 1b changes is tester-writes.test.mjs, which ran alone (8/8). ### Round 2, candidate f9e41b918 (manifest bdc4e74f4aa6, 25 paths), 23:33:12Z-23:41:13Z The slot came from sage-1094. The base is 1bfc9833b. At the start origin/refactor was e7d47c346, which didn't have row 169's landing 264756a30, so I based the round on the local commit Sage said they would push. When the runs ended, origin/refactor was ce5807989, which contains 1bfc9833b and changes only BUILD-LOG.md and docs/SESSIONS.md. Neither is a manifest path. - Singles: the 21 touched files, plus app, stack-copy, stack-release and queue dispatch.test, each run alone. 309 tests, 309 pass. stack-release.test.mjs (93/93) covers row 169's change, which came in with the base. - Suites: cli 390/390 (370 plus row 169's stack-release tests) and project 40/40. - Mutants: all 28 rerun after a control pass of every named file, and all 28 were killed. m11 restores `rel === ''` in `testerProjectArea` (`if (!contains(area, repo))`), and tester-init.test.mjs kills it. m2b is still killed by the bad-key asserts. Each file was restored and its hash checked, and the wrapper kept its mode. - Both worktrees matched the manifest at the start and at the end. A sweep after the run found no process with a working directory in Dewey's run dirs. The bus suite wasn't rerun. Round 2 changes broker.mjs by two comment lines only (N3), and the base move changed no bus file. tester-writes, tester and the other touched bus tests ran alone. After the rebase onto 9e74925c2 (candidate 989699f7c, manifest 2e30f2a1e667), I ran stack-release.test.mjs alone at 23:44:37Z-23:47:40Z, as sage-1100 asks: 93/93. The runs above stand for this candidate. Row 179 changed only docs/TOOLS.md among the manifest paths, and no test reads TOOLS.md. Both the busy check and the manifest check passed before and after, and a sweep found no process left. Greps, as sage-1094 asks: - I grepped every `packages/*/tests` file and `scripts/test-*.sh` for each of the 25 manifest paths. No test names paths.mjs, which the tests reach through `packages/project/src/index.mjs`. The only test that reads a changed doc is host.test.mjs:826, on the cli README, and it ran alone (41/41). The other README hits are text. No test reads docs/TOOLS.md; its one mention, stack-release.test.mjs:428, is a refusal string. - The 64-hex pins sit in 11 files. Six are bus migration tests that pin DDL, and four are discord, queue and webui fixtures. The last is channel-proof, which pins the four row 164 files. None of them is a manifest path. channel-proof.test.mjs passed alone (2/2), so those four files are unchanged.