Files
jason.woltje 6bad02612a lead: commit dewey's untracked evidence (721 files: queue-124-review queue-143 queue-149 queue-165-review queue-170 queue-175 queue-177 queue-184 queue-191)
Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
2026-11-02 15:05:14 -06:00

331 lines
20 KiB
Markdown

# Row 177 design: tester threads (Gitea #1675, Q173)
Dewey, for Sage's approval before any code. Brief blob `f146cd772eddf5aee0fd2501b42eb4b0f0fdbe27`.
Read at origin/refactor `c49de08e8`. Its code paths match `89af38a83` (`git diff --stat 89af38a83 c49de08e8 --
packages scripts roles` is empty), so the brief's line numbers hold. Filbert reviews; Sage owns the gate.
## Summary
A tester gets its own threads. Owner `tester`, `thread_opens.by` `tester`, messages `from_role 'tester'` and
`from_run 'tester-cli'`, no `human.input`. The human's thread list, chat and stop never see them. The tester's
never see the human's. Row 120's lists are met by one broker rule, scoped to tester-owned threads. The Console's
tester listener gets `/api/chat`, bound through `TESTER_CLI`, and the broker's owner check is what keeps it on
tester threads. No schema change. The CLI keeps its thread verbs human-only (a follow-up row, see the end).
## 1. Ownership
- `#owner(s)` = `s.tester ? 'tester' : s.human`. `#thread(s, id)` (broker.mjs:346-351) compares `t.owner` to
it, so either side reaching the other's thread gets `thread-not-owned` (chat-http maps it to 403).
- `thread.create` (:2295), `thread.open` (:2335), `thread.send` (:2342) and `thread.messages` (:2369) start
with `if (!s.tester) this.#human(s)`, as `project.create` does since row 175 (:2271). `thread.create`
stores `owner = #owner(s)`, `thread.open` stores `by = #owner(s)`. A tester may make threads in any project,
including the human's. `project.create` already lets it make its own.
- `TESTER_VERBS` (:97-98) gains `thread.create`, `thread.open`, `thread.send`, `thread.messages` and
`assistant.stop`. That puts it at 25 verbs. The tester dispatch sets `reader: true` on the view for
non-`message.` verbs (:1290), and the `!s.tester` guards pass the tester before `#human` reads that flag.
- `thread.list` (:2321) for a tester lists `owner = 'tester'`. That replaces row 175's "the business human's"
(:2319-2320, and `s.tester ? b.human : s.human` at :2331).
- **The human's list leaves tester threads out. It doesn't label them.** It needs no change: it already
filters `owner = s.human`. Reasons:
- REQ-USER-2 (:349): a user reaches only their own threads. A labelled row the human can't open or send to
is a dead entry.
- 7332's chat is the human's working view. Test traffic would sit in it on every test run.
- The human still sees the traffic, labelled, in the trail (point 3).
- **Boot refuses a business whose `human` is `tester` (`invalid-business`), next to the role check at :245.**
`requireId` accepts `tester` as a human id today. If it stood, the human and the tester would share an owner
and each would reach the other's threads. This is the one fail-closed tightening in the row. Any business
with that human id stops loading. I know of none, and I didn't read the live config to check.
- **Accepted leak:** `UNIQUE (project, name)` on `threads` (3d-3e.sql:21) is per project, not per owner. A
tester's `thread.create` with a name the human already used in that project refuses `thread-exists`, which
tells the tester the name exists. Nothing else about the thread is shown. Avoiding it needs a DDL change.
Tester tests should use their own projects.
- `#specThread` (:2605-2617) picks threads by `this.#business(...).human`, so a spec notice never lands in a
tester thread. `#threadHolders(b, role)` for a role end or revoke (:1599) still ends every thread, the
tester's included.
## 2. thread.send from a tester, and row 120's lists
- **Stored row:** `from_role 'tester'` and `from_run 'tester-cli'`, the same literals a tester's `message.send`
stores (:1882-1883). The insert becomes `s.tester ? 'tester' : 'human'` and
`s.tester ? 'tester-cli' : 'human-cli'`. Class, routing and `#route(..., t.id)` are unchanged.
- **The lists are met by a broker rule, not a business-file entry.** A file can't name the tester: the role
loader refuses it (`business/src/role.mjs:62`, "lists tester, which is never a role"), and the broker refuses
it at boot (`invalid-messaging`, broker.mjs:262). Lifting those bans would let every limited role list the
tester anywhere. `messaging.test.mjs:124-138` pins "the tester is refused by default" for the assistant.
- **The rule:** in a thread whose owner is `tester`, the lists read the tester as `human`, both ways.
- `thread.send` from a tester checks `#checkMessaging(b, 'human', t.instance)`.
- A run of a tester-owned thread sending `to: 'tester'` checks `#checkMessaging(b, s.role, 'human')`.
- Nowhere else changes. A tester's `message.send` to the assistant outside a thread still refuses, so
`messaging.test.mjs:102-138` stays as it is.
- **Effect:** in its own threads, the tester reaches the assistant exactly as far as the human does, never
further. A business whose assistant definition drops `human` refuses the tester too.
## 3. Replies
- **The reply's path.** The runner answers each turn with `message.send {to: m.from_role, class: 'RESULT',
in_reply_to: m.id}` (packages/harness/src/runner.mjs:450).
- On a tester message `to` is `tester`. The reply passes `tester-reply-required` (:1829-1833), since
`in_reply_to` is a tester message, and it passes the lists by point 2's rule.
- `#threadFor` puts the reply in `s.thread`.
- `#recipient(b, 'tester', thread)` finds no `role_claims` holder for `tester`, so the message is routed with
a null holder. That's the same state as a tester reply today.
- **Never in the human's inbox:**
- The human's `messages` view and count read `to_role='human'` only (:2197-2204).
- `inbox` reads decisions `route_to='human'` (:2122-2128).
- The human's `message.receive` reads `to_role='human'` (:1902-1905).
The tester reads replies through `thread.messages` on its thread, or `message.receive` (`to_role='tester'`).
- **New guard:** a run of a tester-owned thread can't `message.send` to `human`. It refuses
`tester-thread-human`.
- Without the guard, the assistant could write to the human's inbox mid-test on its own initiative.
- The refusal code is what the run gets back, so the name says what's wrong.
- Decisions and reviews, the other paths that store a message to the human, need `crossRole` authority. The
assistant definition has `crossRole: []` (roles/assistant.json:10), so they're out of reach. If an
assistant definition ever gains `crossRole`, the guard should move into `#checkMessaging`. I'll record that
as a note, not code it now.
- **How the human's view shows them as tester traffic:**
- The trail rows say `tester to assistant: …` and `assistant to tester: …`, with actor `{role: 'tester',
run: 'tester-cli'}` (webui public/bus.js:175-177).
- The assistant's turn text starts with the runner's tester line, "This message is from a tester running a
PM test…" (runner.mjs:76-77), because `from_role` is `tester`.
- The human's chat never lists the thread (point 1).
- No UI change is needed. One bus test pins the trail row (T7).
## 4. assistant.stop
**Yes, on the tester's own threads only.**
- **The tester's stop.** `assistant.stop` (:2249-2257) uses `#threadHolders(b, instance, #owner(s))`. For a
tester that is `'tester'`, so it revokes only tester-thread claims.
- **The record.** `#claimEnd` takes an explicit `by` of `'tester'` and reason `stopped by the tester`. Its
default, `s.run ?? s.human`, would be `false` for a tester session.
- **The human's stop** is unchanged (`owner = s.human`). It never ends a tester run, and the tester's never ends
the human's.
- **No held tester thread** refuses `not-held`, as for the human.
- **Why grant it:** Q173's tester starts assistant runs by sending. Without a stop it can't end them, and they
only idle out.
- **Bus verb only this row.** The tester listener still refuses `assistantStop` at startup (point 6), and no
CLI verb is added.
## 5. human.input
**A tester's `thread.send` writes no event and returns `request: null`.** That's what a tester's `message.send`
has done since row 110 (:1817-1821, :1888-1890).
What each consumer does with the event, checked first:
| Consumer | What it does |
|---|---|
| Runner | Uses `m.request` only for the `request <id>` head that `task_create` cites (runner.mjs:72-73, the head built at :66-73). It already handles a null request, as for every tester message. |
| Launcher | Reads `threadsState`: messages, opens and claims (:400-418), no events. A pending message starts the session either way. |
| 3b.sql:170-180 | The `task.created` trigger requires `body.request` to be a `human.input` id. So a turn of a tester thread can't create a task citing the tester. That's correct: a PM test authorizes nothing. |
| Others | Trail (:2691), landing.mjs:63 and webui bus.js:134-172 read existing `human.input` rows only. |
**Rejected: a new kind (`tester.input`).**
- The `events.kind` CHECK (3b.sql:10-31) lists every kind, so a new one needs a migration.
- No consumer would read it.
- It would put a schema change in front of the merged window's store.
## 6. Console chat on the tester listener
**What replaces the refusal: the broker.** The tester listener's `chat` is built from its own `TESTER_CLI`
caller, so every chat op is a tester op. The owner check (point 1) is the fence that keeps it on tester threads.
Changes:
- **serve.mjs:442** drops `chat` from the refused list. The list becomes `[send, settingsWrite, projectSave,
assistantStop]`, and the message changes to "tester listener permits only instance, route, name and chat
writes".
- **serve.mjs:480 fence:** a tester POST may also go to `/api/chat/projects`, `/api/chat/threads`,
`/api/chat/threads/<id>/messages` and `/api/chat/threads/<id>/open`. These are the four chatHTTP write
routes, exact-matched.
- **chat-http.mjs**
- `chatHTTP` gets `tester` and the listener's writer.
- A tester write needs `chat`, Origin equal to the writer's `http://127.0.0.1:<port>`, and
`sec-fetch-site: same-origin`, as row 176's tester writes (serve.mjs:309-311).
- The human path keeps `send && named && preview`, the header user, `https://` Origin and Fetch Metadata,
unchanged.
- STATUS gains `tester-off`, `tester-required` and `tester-refused`, each 403. Today they'd fall through to
502.
- The log line's user is `"tester"`.
- **webui cli.mjs:84-93:** the tester listener gets `chat: createChat({ call })` with its `TESTER_CLI` `call`.
7332's `chat` stays the human caller's (cli.mjs:65).
- **public/bus.js:1095:** passes `canSend || isTester` to `MosaicChat.render`, as the instance and route forms
do (:691, :745). The tester banner (:1292) already says changes are recorded as tester.
- **Unchanged:**
- The webui channel module (plugins/channel/webui/index.mjs) and chat.mjs.
- `src/bus.mjs`, which row 164 pins.
- `channel-proof.test.mjs:11-19`.
**7332 still refuses `x-mosaic-tester` on chat.**
- 7332 is the non-tester listener. :454 refuses the header, present even with an empty value, before any route,
so it covers `/api/chat` too.
- The test today (tester-listener.test.mjs:23-34) covers only `/api/board` and `/api/bus/agents`, on a server
with no chat.
- New test W4 covers chat: a signed-in send-mode human listener with a recording chat. GET `/api/chat/threads`
and POST `/api/chat/threads/<uuid>/messages`, each with `x-mosaic-tester: ''`, answer 403
`tester listener required`, and the chat is never called.
- **Mutant M9** deletes :454's refusal. W4 kills it.
## 7. DDL
**No schema change and no migration.** I read every migration, 3b through 3h-3i:
- **No CHECK on the columns this row writes:**
- `threads.owner` is `TEXT NOT NULL` (3d-3e.sql:19). `UNIQUE (project, name)` is noted under point 1.
- `thread_opens.by` is `TEXT NOT NULL` (:42).
- `messages.from_role`, `from_run` and `to_role` are `TEXT NOT NULL` (3b.sql:75-76).
- `thread_claims.by` is `TEXT NOT NULL` (3d-3e.sql:31).
- 3f-3g.sql:4 adds only `to_name`. No later migration touches these tables except usage_records.
- **Triggers:**
- `message_threads_match` (3d-3e.sql:68-71) needs the thread's instance in `{to_role, from_role}`. Both
directions qualify: tester to instance, and instance to tester.
- `delivery_same_thread` (:72-78) constrains only messages whose `to_role` is the instance. The tester's
message routes to the holder as the human's does, and a reply to `tester` isn't constrained.
- `threads_project_business`, the append-only triggers, and the claim triggers don't read the owner or
sender.
- The events kind CHECK (3b.sql:10-31) and the `task.created` trigger (3b.sql:170-180) are untouched because
no new kind is added (point 5).
## Files (expected manifest)
**Source**
| File | Change |
|---|---|
| `packages/bus/src/broker.mjs` | Points 1-5 |
| `packages/webui/src/serve.mjs` | :442 and :480 |
| `packages/webui/src/chat-http.mjs` | The tester write path and STATUS |
| `packages/webui/src/cli.mjs` | Tester chat |
| `packages/webui/src/public/bus.js` | :1095 |
| `packages/cli/src/thread.mjs:110` and `packages/cli/src/cli.mjs:357` | Comments only: the CLI's thread verbs stay the human's. Both comments become false otherwise. `transport.mjs` is untouched. |
**Tests**
- `packages/bus/tests/tester-threads.test.mjs`: new, T1-T7.
- `packages/bus/tests/tester-writes.test.mjs`:
- The TESTER_VERBS pin (:17-24) gains 5 verbs.
- `thread.list` leaves the deep-equal READS (:15, :57-67).
- The four thread verbs and `assistant.stop` move out of the `tester-refused` list (:136-140) into an
assertion that the human's thread refuses the tester `thread-not-owned`, and the human's held runs refuse
`not-held`.
- `packages/webui/tests/tester-listener.test.mjs`: :75-85 loses `chat` and the four chat paths, plus new tests
W1-W4.
- `packages/webui/tests/tester-fixture.mjs`: passes `chat` and sets `plugins.channel` to include `webui`.
- `packages/webui/tests/tester-cli.test.mjs`: chat paths spawn only `TESTER_CLI`.
- `packages/webui/tests/tester-browser.test.mjs`: the composer is enabled on the tester Console.
- `packages/cli/tests/tester-reads.test.mjs:81-84`: `threads --tester` now lists the tester's own threads, so
it asserts `[]` after the human made "one", then `["t-one"]` after a tester `thread.create`.
**Docs**
- `packages/bus/README.md:612`: the tester paragraph names the new verbs, the owner rule, the list rule, the
guard, and `thread.list`'s new meaning.
- The webui README's tester listener section, if it lists the refused writes. I'll check at build time.
## Tests
**Bus, `tester-threads.test.mjs`.** Real broker. The business has an assistant instance with the shipped
lists and `bus.tester: true`.
- **T1:** The tester makes a thread. Its owner is `tester`.
- The human's `thread.list` leaves it out, and the tester's lists only it.
- The human gets `thread-not-owned` on its `thread.open`, `thread.send` and `thread.messages`.
- The tester gets `thread-not-owned` on the same three verbs on the human's thread.
- **T2:** The tester's `thread.send`:
- stores `from_role 'tester'` and `from_run 'tester-cli'`;
- adds a `message_threads` row;
- adds no `human.input` (the events count is unchanged);
- returns `request: null`.
`thread.open` stores `by 'tester'`.
- **T3:** The tester's `thread.send` passes the shipped assistant lists. With `human` removed from
`receiveFrom`, it refuses `message-sender-refused`. A tester's `message.send` to the assistant outside a
thread still refuses.
- **T4:** A claimed run of the tester thread replies `to: 'tester'` with `in_reply_to`.
- The reply has `to_role 'tester'` and sits in the thread.
- It's absent from the human's `messages` and `message.receive`.
- It's present in the tester's `thread.messages` and `message.receive`.
- The same run's `message.send` to `human` refuses `tester-thread-human`.
- A human thread's run still sends to `human`.
- Without point 2's rule the reply refuses `message-recipient-refused` (mutant M5).
- **T5:** Runs hold both a tester thread and a human thread.
- The tester's `assistant.stop` revokes only the tester thread, with `by 'tester'`.
- The human's stop revokes only the human's.
- A tester with no held thread gets `not-held`.
- **T6:** Boot refuses a business with `human: 'tester'` (`invalid-business`).
- **T7:** The human's `trail` on a tester-thread message returns it with `from_role 'tester'` and
`from_run 'tester-cli'`.
**Webui.** The tester fixture runs a real broker, with chat through `createChat`.
- **W1:** Tester chat end to end.
- Create a project and a thread, send, open, read the messages. Each answers 200 with its receipt.
- The thread's owner is `tester`.
- The log lines say `user="tester"`.
- The human's `thread.list` is empty.
- **W2:** Tester chat write refusals. A wrong Origin, cross-site Fetch Metadata, `text/plain`, or the proxy
header (`x-user`) each refuse with no bus call.
- **W3:** The human's thread id through the tester chat answers 403 `thread-not-owned`. With `bus.tester` off,
the chat answers 403 `tester-off`.
- **W4:** 7332 refuses `x-mosaic-tester` on GET and POST chat routes, and the chat is never called (point 6).
**tester-cli.** GET `/api/chat/projects` and `/api/chat/threads`, and POST `/api/chat/threads` on the tester
port spawn `TESTER_CLI` for every call and `HUMAN_CLI` for none.
**Browser.** On the tester Console, the chat composer is enabled, a send shows the message, and the banner is
present.
## Mutants
Each mutant makes one exact edit, runs the named file, then restores the file and checks its sha256.
| Mutant | Edit | Killed by |
|---|---|---|
| M1 | `#thread` compares `s.human` again | T1 |
| M2 | Tester `thread.list` back to `b.human` | T1, tester-reads |
| M3 | Tester `thread.send` `from_run 'human-cli'` | T2 |
| M4 | Tester `thread.send` writes `human.input` | T2 |
| M5 | Point 2's substitution removed | T3, T4 |
| M6 | `tester-thread-human` guard removed | T4 |
| M7 | Tester `assistant.stop` owner `null` | T5 |
| M8 | Boot check for human `tester` removed | T6 |
| M9 | serve.mjs:454 refusal removed | W4 |
| M10 | chat-http tester Origin check removed | W2 |
| M11 | webui cli.mjs tester chat built from the human `call` | tester-cli |
| M12 | serve.mjs tester fence admits every POST path | tester-listener :82 (existing) |
| M13 | bus.js:1095 back to `canSend` | tester-browser |
| M14 | `thread.open` `by` back to `s.human` | T2 |
## Gate
Sage runs it on the candidate, on a fresh worktree at the then-current origin/refactor. Each item must be green:
- Full suites: bus (`node --test tests/*.test.mjs`), webui (`node --test tests/`, browser tests included),
conversation (`node --test tests/`) and cli (`node --test tests/*.test.mjs`). The cli suite is in because
`tester-reads.test.mjs` and two cli comments change.
- Every `scripts/test-*.sh`: auth, conductor, config, discord, extension-package, foundation, land-work, queue,
release, task. test-task uses Docker.
- `channel-proof.test.mjs` passes unchanged. The hashes of human.mjs, human-cli.mjs, cli transport.mjs and
webui bus.mjs are unchanged.
- My candidate packet carries each suite's log, my own mutant runs M1-M14, and an awk-summed count per suite.
All runs wait for Darkwing's clear on the test slot. Writing this design needed no slot.
## Not in this row
1. **CLI tester threads.** `mosaic thread create|open|read --tester` and `talk|follow --thread --tester`
(cli.mjs:357 still refuses `--thread` with `--tester`). It's a small follow-up if Q173's agents test from
the CLI rather than 7333. Should I add it here instead? It adds the cli thread tests to the gate, and
`transport.mjs` stays untouched either way.
2. **A Console route for the tester's `assistant.stop`.** The bus verb comes with this row; the route doesn't.
3. **Moving the `tester-thread-human` guard into `#checkMessaging`** if an assistant definition ever gets
`crossRole` authority.
## Behaviour change to flag
`thread.list` and `mosaic threads --tester` stop answering the human's threads to the tester. Row 175 read
parity (`tester-writes.test.mjs:57-67`, `tester-reads.test.mjs:81-84`) changes on purpose, per the brief's
point 1. The merged window plan (`repin-merged-bc.md`) doesn't use the tester's thread list. I checked W1-W10
and P3.