# 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.