# Row 177 (#1675, Q173): tester threads Builder: Dewey. Reviewer: Filbert. Gate owner: Sage. Brief: docs/plans/2026-10-19_tester-threads.md, blob f146cd772edd (dfd3e5823, queue revs 2093-2094). Design: DESIGN.md in this packet, sha256 99ce578091bb…2660f0, approved in sage-1146 (#1675 comment 29333) with calls (a)-(c) and notes N1 and N2 folded into the build. Base: c49de08e8 (base.txt), origin/refactor when the build started. Round 2 candidate: e097915d954f (candidate.txt), a commit in my worktree on top of round 1's 1971683e14a6. Neither is on refactor. build.patch is `git diff --binary c49de08e8 e097915d9` (sha256 046c6e114209…6c62c6, `git patch-id --stable` 15f9c7bba807…29f1a2e). candidate-manifest.sha256 lists the same 16 paths as round 1 (sha256 of the manifest 3251f94d9e7e…185854). Round 2 changes four of them: packages/bus/README.md, broker.mjs, tester-threads.test.mjs and tester-writes.test.mjs. Round 1's patch and manifest (manifest sha256 9aaabae85984…845971) are kept in r1/. Base drift, checked at 02:51Z and again before the in-review move: refactor has moved past the base (refactor and origin/refactor are c0d6bc7d5 at 03:03Z). `git diff --name-only c49de08e8 -- ` is empty. The new commits touch only BUILD-LOG.md, docs/SESSIONS.md, the queue files, docs/plans/2026-10-20_stack-release-after-window.md, and the agents/darkwing and agents/sage packets. ## What changed - **Bus (broker.mjs).** - TESTER_VERBS adds thread.create, thread.open, thread.send, thread.messages and assistant.stop. - `#owner(s)` is `tester` for a tester session and the human otherwise. It is used for the `thread-not-owned` check, the owner `thread.create` stores, the `thread.list` filter, the `thread.open` `by` and the `assistant.stop` holder scope. - A tester `thread.send` stores `from_role 'tester'` and `from_run 'tester-cli'`, writes no `human.input`, and returns `request: null`. Its lists check reads the sender as the human. - In a tester-owned thread, a run's `message.send` to `tester` is checked against the lists as if it were to `human`. Its `message.send` to `human` refuses `tester-thread-human`. - A tester `assistant.stop` revokes only the runs of tester threads. The claim row records `by 'tester'` and the reason `stopped by the tester`. - The boot check refuses a business whose human is `tester` (`invalid-business`), call (a). - **Webui.** - The tester listener (7333) accepts `chat`. Its fence admits exactly the four chat write routes (TESTER_CHAT_WRITE). - chatHTTP gets a tester branch for writes: no sign-in, the Origin must be the listener's own, Fetch Metadata still same-origin. STATUS maps tester-off, tester-required and tester-refused to 403. - The webui CLI builds the tester listener's chat from the TESTER_CLI caller. 7332's chat is unchanged. - bus.js renders the chat with `canSend || isTester`. - **CLI.** Comments only: the CLI's thread verbs stay the human's, and a tester `talk --thread` is the follow-up row from call (b). - **READMEs.** - bus: the row 177 paragraph covers the verbs, owner, list rule, boot refusal, from_run, the lists rule, `tester-thread-human`, N1 (the chief reach is not new reach), the name leak, call (c), and where a tester reads replies (sage-1148, deviation 6). - webui: the tester section allows chat, keeps 7332's chat the human's, and states N2 (a loopback caller can start assistant turns; `bus.tester` is the switch and off answers 403 `tester-off`). ## Deviations from the design 1. **W1-W3 and the browser test use their own business, not the shared tester fixture.** The shared fixture has no assistant role, and other tests count its roles and instances. tester-fixture.mjs gains a `testerChat` export: a plain Broker with an assistant and a chief, `bus.tester` on or off, and `createChat` over a caller that binds the tester at each call. Only `plugins` is stubbed (channel on), after the bind, so `bus.tester` off still refuses first. 2. **tester-cli turns the channel on first.** The fixture business has no `plugins.channel`, so the test calls the tester's `plugins.set` to add `["cli","webui"]`. The fixture also has no assistant instance, so POST `/api/chat/threads` ends in the bus's `thread-instance-missing`. That refusal shows the call went through TESTER_CLI to `thread.create`. 3. **M5 is split.** M5a puts the tester `thread.send` lists check back on the sender. M5b removes the reply substitution. 4. **chat.mjs is unchanged, as designed, but two of its strings are now stale.** The header says "the current human transport", and the TypeError says "chat needs a human transport". The tester listener now passes the tester transport. Wording follow-up, no behavior change. 5. **The browser test clicks Refresh before it looks for the sent text.** After a send the client shows "Accepted message …", and the log picks the message up on the next read, the same as on the human Console (chat-browser.test.mjs waits only for the receipt). The test waits for the receipt, clicks `#refresh`, then waits for the text in `.chat-log`. My first draft waited for the text without a read, and its control failed on that wait. The rerun is under Runs. 6. **The bus README says where a tester reads replies (sage-1148, #1673 comment 29334).** That is `message.receive` (what `talk --tester` and `follow --tester` use) or `thread.messages` in its own thread, never `inbox`: `inbox --tester --json` prints decisions only (cli.mjs:298-300), and the `messages` view reads `to_role 'human'` for every session (broker.mjs `case 'messages'`). T4 reads the reply both ways (tester-threads.test.mjs:134, :137), and W1 reads through the chat's `thread.messages`. No code change. ## Runs All on my worktree (`~/dewey-scratch/b177`), after Darkwing's "clear" at 02:18Z. Every run took the slot after a passing busy check, chained with `&&`. DOCKER_HOST pointed at a missing socket and MOSAIC_CONFIG at a missing file, so **test-task skipped its Docker cases** (adapter seam, workspace/capability, live task, live recall). Sage's gate runs them. Scripts and logs are in `r1/`. **Mutants** (`r1/b177-mutants.sh`, 02:18-02:19Z, `r1/mut/`). Each mutant is one exact-once edit with the file's sha256 checked on restore, and every mutant exited 1: | Mutant | Edit | Killed by | |---|---|---| | M1 | `#thread` compares `s.human` | T1, T2, T3 (both), T4, T7 | | M2 | thread.list owner `s.tester ? b.human : s.human` | T1 and the row 175 tester-reads list test | | M3 | `from_run 'human-cli'` | T2, T7 | | M4 | tester send writes `human.input` | T2 | | M5a | send lists check on the sender | T2, T3, T4, T7 | | M5b | no reply substitution | T4 | | M6 | no `tester-thread-human` guard | T4 | | M7 | stop holder scope `s.human` | T5 | | M8 | no boot check | T6 | | M9 | main listener's x-mosaic-tester refusal removed | main-listener test, W4 | | M10 | chat-http tester Origin check removed | W2 | | M11 | tester chat on the human busCaller | CLI tester listener (tester-cli.test.mjs:71) | | M12 | tester write fence off | "refuses every human-only write" | | M13 | composer `canSend` only | the browser test, at the composer wait (tester-browser.test.mjs:35) | | M14 | thread.open `by s.human` | T2 | Controls on the real source: tester-threads 9/9, tester-reads 6/6, tester-listener 10/10, tester-cli 2/2, tester-writes 8/8. tester-browser was 1/2: the first draft of the row 177 browser test failed its wait for the sent text (deviation 5). I fixed the test, and the rerun's control passed 2/2. **M13 was rerun against the fixed test** (`r1/rerun-mut/`): it exits 1 at the composer wait, line 35. The first M13 result in `r1/mut/` was taken against the broken test, so it doesn't count. **Suites, first run** (`r1/b177-gate.sh`, ended 02:26:14Z, `r1/suites/`): - bus 445/445, webui 257/258 with 1 skipped, conversation 182/182. - The webui skip is the editor rebuild check. It needs esbuild 0.28.2, and this checkout has 0.28.1. It isn't one of my files. - **cli failed 24/396.** Every failure traces to my TMPDIR (`~/dewey-scratch/tmp/b177-suites`). It pushed launch socket paths past session.mjs:119's 107-byte limit ("data root path too long for a socket"). The timeouts, `launch-failed` and the T42 rollback-set refusal follow from it. - All 10 scripts passed, 340 checks in total: auth 15, conductor 17, config 24, discord 78, extension-package 18, foundation 44, land-work 86, queue 28 (plus its 156 node tests), release 4, task 26. **Rerun** (`r1/b177-rerun.sh`, TMPDIR `~/dewey-scratch/t7`, ended 02:30:28Z, `r1/rerun/`). This ran on the final tree, with the sage-1148 README sentence: - cli 396/396, T42 included. - tester-browser control 2/2. - M13 killed, as above. The other suites ran before I added that README sentence. That is a one-sentence change to packages/bus/README.md, and no test reads that file (grep below). Node totals by awk sum: bus, webui, conversation and the cli rerun give 1281 tests, 1280 pass, 0 fail, 1 skipped. channel-proof: the test file is unchanged, and it passed in the 445/445 bus run. It pins four files: bus human-cli.mjs and human.mjs, cli transport.mjs and webui src/bus.mjs. None is in the manifest. **Hash-pin and path-read grep** over packages/*/tests and scripts/test-*.sh, for every path in the manifest: - No 64-hex pin names any of them. - project-runtime reads the broker's imports, which are unchanged. - bus-browser reads the webui README phrase "the loader's", which is still present. - stack-release T39 reads `--tester-port` in webui cli.mjs, which is still present. - shell.test counts fetch and method calls in bus.js, and the counts are unchanged. - No test reads packages/bus/README.md. After every run, a /proc cwd sweep of `~/dewey-scratch` found no processes left. ## Round 2 (sage-1160, after Filbert's changes verdict, #1675 comment 29347) Line numbers below are the round 2 candidate's packages/bus/src/broker.mjs. ### F1: a tester-thread run's decision.raise to the human - `#testerThread(s)` (after `#sender`) is the one test: not the human, not the tester, a thread claim, and that thread's owner is `tester`. message.send uses it now (it was inline in round 1). - decision.raise (:1676) refuses `tester-thread-human` when `route` comes out `human` and `#testerThread(s)` holds. The check comes right after `route` is computed, so it runs before the messaging check, the supersede withdrawal (:1686, a `decision_events` insert) and the decision insert (:1696). Nothing is stored. - `route` is `human` for an uncited gated raise, a hard wait, and an arbitrated raise whose arbiter is the raising role (:1673). All three refuse. A raise routed to an arbiter that is another role stays allowed: that is the chief's reach to the human that N1 already names, not new reach. - Tests, tester-threads.test.mjs: - T8a. From the tester-thread run, a gated `task.create` (outside the assistant's grants) refuses `tester-thread-human`. The human's `inbox` stays `[]` and the `decisions` table has no row. The same raise from a human-thread run stores `route_to 'human'` and shows in the human's `inbox`. - T8b. With `task.create` in the assistant's crossRole, the tester-thread run's raise is stored with `route_to 'chief'`, and the human's `inbox` stays `[]`. - M15 removes the :1676 guard (results under the round 2 runs). - bus README (:614): the run's `decision.raise` refuses the same way when the route comes out `human`, so neither its replies nor its decisions reach the human's inbox. A raise routed to the chief as arbiter stays allowed. ### Every broker path that stores a message or decision with a human target The broker has one decision insert and eight message inserts, counting `#reviewMessage` once (the three verbs that call it are listed separately). The guards rest on three facts: - No role is named `human` or `tester` (:248, `invalid-business`). - Both arbiters must be roles (:256). - `launch.by` must be a declared role (packages/business/src/business.mjs:123). | Line | Path | Target | Can a tester-thread run store a human target here? | |---|---|---|---| | :1696 | decision.raise | `route_to = route` | No, since this round: :1676 refuses `route === 'human'` first. | | :1894 | message.send | `to_role = to` | No: :1859 refuses `to === 'human'` (round 1). Its `to 'tester'` is checked as `human` on the lists, but it is stored as `tester`. | | :999 | `#landMessage` (landing outcome, from the trusted host) | `s.role`, the landing run's role | No: the target is a role, and no role is named `human`. | | :1806 | decision.resolve / withdraw, message to the raiser | `d.raised_by_role` | No: only an agent raises (`#agent(s)` in decision.raise), so the raiser is a role. | | :2377 | thread.send | `t.instance` | No: only the human or the tester calls it (`if (!s.tester) this.#human(s)`), and the target is the thread's instance, a role. | | :2518 via :1987 | staff.request | `b.launch.by` | No: `launch.by` is a declared role (business.mjs:123). | | :2518 via :2032 | review.request | `a.to` | No. Sage's check: `a.to` must be a key of `b.roles` (:2005, `unknown-role`; Sage cited :1985 on the base). Its gated form needs an approved decision first, and with F1 a tester-thread run can't raise one to the human. | | :2518 via :2071 | review.verdict | `r.from`, the `review.requested` event's `actor_role` | No: the requester ran review.request, so `r.from` is a role. | | :2565 | `#notice` to launch.by | `by` | No: a role (business.mjs:123). | | :2596 | `#specNotice` (spec save) | pm and cto roles | No: called only from `project.file.save` (:1450), which is human-only (:1427-1428). | `messages` reads `to_role 'human'` and `inbox` reads `route_to 'human'`, so with these guards nothing from a tester-thread run shows in either human view. ### Deviation 7: DESIGN.md:82-84 was wrong The approved design said a tester-thread run reaches the human's decisions only through a crossRole raise to the chief, and needed no guard. That is false. An uncited gated raise routes straight to `human` (:1672), and the assistant's grants make any action outside them gated. Filbert's probe stored one (F1). The fix is the :1676 guard. DESIGN.md stays as approved (sha256 99ce5780…2660f0); this entry records the correction. ### F2 and F3 - F2: the dispatch comment above the tester request view now names the row 175 writes (`name.set`, `project.create`) and the five row 177 verbs. It says that none of their branches checks `reader`, that each passes a tester by `s.tester`, and that each write records `tester`. `thread.messages` writes nothing. - F3: tester-writes.test.mjs:2 adds "Row 177 (#1675) adds the thread verbs, tested in tester-threads.test.mjs", and the :60 test name reads "each of the six reads". ### Follow-ups (Sage files them after landing) - N1: `#threadFor` follows the `in_reply_to` chain with no owner check. - N2: the stale chat.mjs strings (deviation 4). - The tester `talk --thread` row from call (b). ### Round 2 runs Slot granted in sage-1162 at 02:53:22Z. My busy check said CLEAR at 02:53:32Z, chained with `&&`, and the run ended at 03:01:13Z. It ran from the worktree at 1971683e14a6 with the four round 2 files uncommitted (`dirty=4` in r2/summary.txt). I committed exactly that tree as e097915d9 afterwards: `git status` showed no other change under packages/. TMPDIR was `~/dewey-scratch/t7` (30 bytes) for the suites and `~/dewey-scratch/t8` for the mutants. Docker was off, so test-task skipped its Docker cases. Scripts and logs are in r2/ (`b177-r2.sh`, `b177-mutants.sh` with M15 added, `mut/`). - Controls on the real source: tester-threads 11/11 (both T8 tests), tester-reads 6/6, tester-listener 10/10, tester-cli 2/2, tester-browser 2/2, tester-writes 8/8. - Mutants M1-M15: every one exits 1 with the same killers as round 1. **M15** (the :1676 guard removed) is killed by T8a, "a tester-thread run never raises a decision to the human; a human-thread run still does". T8b passes under M15, as it should: that raise routes to the chief. - Suites: bus 447/447 (round 1's 445 plus T8a and T8b), webui 257/258 with 1 skipped (the esbuild 0.28.2 editor check, as in round 1), conversation 182/182, cli 396/396. - Node totals by awk sum over the four suite logs: 1283 tests, 1282 pass, 0 fail, 1 skipped. - The ten scripts: 340 passed, 0 failed, as in round 1. Queue's 156 node tests pass too. - channel-proof's four pinned files are still outside the manifest. - Grep of packages/*/tests and scripts/test-*.sh for the four changed files: - No 64-hex pin names any of them. - No test reads packages/bus/README.md. - project-runtime reads broker.mjs's imports, and round 2 adds none. - tester-writes.test.mjs names tester-threads.test.mjs only in comments. - A /proc cwd sweep of `~/dewey-scratch` after the run found only my own shell.