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

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