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.
20 KiB
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) comparest.ownerto it, so either side reaching the other's thread getsthread-not-owned(chat-http maps it to 403).thread.create(:2295),thread.open(:2335),thread.send(:2342) andthread.messages(:2369) start withif (!s.tester) this.#human(s), asproject.createdoes since row 175 (:2271).thread.createstoresowner = #owner(s),thread.openstoresby = #owner(s). A tester may make threads in any project, including the human's.project.createalready lets it make its own.TESTER_VERBS(:97-98) gainsthread.create,thread.open,thread.send,thread.messagesandassistant.stop. That puts it at 25 verbs. The tester dispatch setsreader: trueon the view for non-message.verbs (:1290), and the!s.testerguards pass the tester before#humanreads that flag.thread.list(:2321) for a tester listsowner = 'tester'. That replaces row 175's "the business human's" (:2319-2320, ands.tester ? b.human : s.humanat :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
humanistester(invalid-business), next to the role check at :245.requireIdacceptstesteras 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)onthreads(3d-3e.sql:21) is per project, not per owner. A tester'sthread.createwith a name the human already used in that project refusesthread-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 bythis.#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'andfrom_run 'tester-cli', the same literals a tester'smessage.sendstores (:1882-1883). The insert becomess.tester ? 'tester' : 'human'ands.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-138pins "the tester is refused by default" for the assistant. - The rule: in a thread whose owner is
tester, the lists read the tester ashuman, both ways.thread.sendfrom 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.sendto the assistant outside a thread still refuses, somessaging.test.mjs:102-138stays 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
humanrefuses 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
toistester. The reply passestester-reply-required(:1829-1833), sincein_reply_tois a tester message, and it passes the lists by point 2's rule. #threadForputs the reply ins.thread.#recipient(b, 'tester', thread)finds norole_claimsholder fortester, so the message is routed with a null holder. That's the same state as a tester reply today.
- On a tester message
-
Never in the human's inbox:
- The human's
messagesview and count readto_role='human'only (:2197-2204). inboxreads decisionsroute_to='human'(:2122-2128).- The human's
message.receivereadsto_role='human'(:1902-1905).
The tester reads replies through
thread.messageson its thread, ormessage.receive(to_role='tester'). - The human's
-
New guard: a run of a tester-owned thread can't
message.sendtohuman. It refusestester-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
crossRoleauthority. The assistant definition hascrossRole: [](roles/assistant.json:10), so they're out of reach. If an assistant definition ever gainscrossRole, 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: …andassistant 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_roleistester. - The human's chat never lists the thread (point 1).
- No UI change is needed. One bus test pins the trail row (T7).
- The trail rows say
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.
#claimEndtakes an explicitbyof'tester'and reasonstopped by the tester. Its default,s.run ?? s.human, would befalsefor 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
assistantStopat 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.kindCHECK (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
chatfrom 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>/messagesand/api/chat/threads/<id>/open. These are the four chatHTTP write routes, exact-matched. - chat-http.mjs
chatHTTPgetstesterand the listener's writer.- A tester write needs
chat, Origin equal to the writer'shttp://127.0.0.1:<port>, andsec-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-requiredandtester-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 itsTESTER_CLIcall. 7332'schatstays the human caller's (cli.mjs:65). - public/bus.js:1095: passes
canSend || isTestertoMosaicChat.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/chattoo. - The test today (tester-listener.test.mjs:23-34) covers only
/api/boardand/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/threadsand POST/api/chat/threads/<uuid>/messages, each withx-mosaic-tester: '', answer 403tester 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.ownerisTEXT NOT NULL(3d-3e.sql:19).UNIQUE (project, name)is noted under point 1.thread_opens.byisTEXT NOT NULL(:42).messages.from_role,from_runandto_roleareTEXT NOT NULL(3b.sql:75-76).thread_claims.byisTEXT 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 whoseto_roleis the instance. The tester's message routes to the holder as the human's does, and a reply totesterisn'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.createdtrigger (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.listleaves the deep-equal READS (:15, :57-67).- The four thread verbs and
assistant.stopmove out of thetester-refusedlist (:136-140) into an assertion that the human's thread refuses the testerthread-not-owned, and the human's held runs refusenot-held.
packages/webui/tests/tester-listener.test.mjs: :75-85 loseschatand the four chat paths, plus new tests W1-W4.packages/webui/tests/tester-fixture.mjs: passeschatand setsplugins.channelto includewebui.packages/webui/tests/tester-cli.test.mjs: chat paths spawn onlyTESTER_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 --testernow lists the tester's own threads, so it asserts[]after the human made "one", then["t-one"]after a testerthread.create.
Docs
packages/bus/README.md:612: the tester paragraph names the new verbs, the owner rule, the list rule, the guard, andthread.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.listleaves it out, and the tester's lists only it. - The human gets
thread-not-ownedon itsthread.open,thread.sendandthread.messages. - The tester gets
thread-not-ownedon the same three verbs on the human's thread.
- The human's
-
T2: The tester's
thread.send:- stores
from_role 'tester'andfrom_run 'tester-cli'; - adds a
message_threadsrow; - adds no
human.input(the events count is unchanged); - returns
request: null.
thread.openstoresby 'tester'. - stores
-
T3: The tester's
thread.sendpasses the shipped assistant lists. Withhumanremoved fromreceiveFrom, it refusesmessage-sender-refused. A tester'smessage.sendto the assistant outside a thread still refuses. -
T4: A claimed run of the tester thread replies
to: 'tester'within_reply_to.- The reply has
to_role 'tester'and sits in the thread. - It's absent from the human's
messagesandmessage.receive. - It's present in the tester's
thread.messagesandmessage.receive. - The same run's
message.sendtohumanrefusestester-thread-human. - A human thread's run still sends to
human. - Without point 2's rule the reply refuses
message-recipient-refused(mutant M5).
- The reply has
-
T5: Runs hold both a tester thread and a human thread.
- The tester's
assistant.stoprevokes only the tester thread, withby 'tester'. - The human's stop revokes only the human's.
- A tester with no held thread gets
not-held.
- The tester's
-
T6: Boot refuses a business with
human: 'tester'(invalid-business). -
T7: The human's
trailon a tester-thread message returns it withfrom_role 'tester'andfrom_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.listis 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. Withbus.testeroff, the chat answers 403tester-off. - W4: 7332 refuses
x-mosaic-testeron 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 becausetester-reads.test.mjsand 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.mjspasses 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
- CLI tester threads.
mosaic thread create|open|read --testerandtalk|follow --thread --tester(cli.mjs:357 still refuses--threadwith--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, andtransport.mjsstays untouched either way. - A Console route for the tester's
assistant.stop. The bus verb comes with this row; the route doesn't. - Moving the
tester-thread-humanguard into#checkMessagingif an assistant definition ever getscrossRoleauthority.
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.