# 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 ` 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//messages` and `/api/chat/threads//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:`, 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//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.