# Row 81 (#1575): Console shows the reply to a web send, round 1 Owner: Dewey. Reviewer: Darkwing, then Sage's gate. Brief: `docs/plans/2026-10-11_console-reply-view.md` ยง "Show replies to a message without receiving them", blob `dd42071d`. Lead decision 97 made the row; decision 111 started it. Base `ff11aa4b`. None of the candidate's paths changed between `ff11aa4b` and `refactor` HEAD (`1d4ba6bb`) when the packet was built. `build.patch` is `git diff --cached --binary ff11aa4b --` the twelve paths below. Apply it with `git apply --index` to a detached worktree of the base; `candidate-manifest.sha256` then checks 12/12 OK. ## What ships - **Bus read `replies`** (`packages/bus/src/broker.mjs`). `{id}`, nothing else (`keys` strict, `identifier` on the id). Returns the business's messages whose `in_reply_to` is that id, `ORDER BY seq`, `LIMIT REPLIES` (exported, 50). Human and reader sessions only; a role session gets `human-required`, the same gate as `decisions` and `credentials`. It is on the reader allowlist. An id naming no message in the session's business refuses `message-not-found` with the same query `message.send` uses for `in_reply_to` (`SELECT id FROM messages WHERE id=? AND business=?`), and the refusal is recorded like every other. It writes nothing: no `deliveries` row, no event on success. - **views** passes it through: `replies(id)` calls `('replies', { id })`. - **webui server**: `GET /api/bus/replies?id=`. `replies` joins `BUS_VERBS`; the id must pass `subjectOk` (the broker's identifier rule) or the server answers 400 `invalid-request` without calling the bus. `message-not-found` already maps to 400 (`busStatus`). GET only, like the other reads. - **Console page** (`src/public/bus.js`). A Sent receipt is followed by the replies to that message, read with `replies` on every render of the Inbox (the ten-second refresh and Read again). Each reply shows its role, time, class, a trail link and the body, through `txt` like every bus string. "No reply yet" when the list is empty; "could not be read" when the read failed, with `mosaic talk` named as the other place they print; a note at 50 that only the first 50 show. The receipt paragraph itself is unchanged, so `.receipt` text stays what row 76's tests check. A new send starts an empty list. The note under the box now says replies show under the receipt and that the Console never marks one delivered or read. - **CLI**: talk's timeout is `no reply from within s; it may still come, and the next mosaic talk shows it`. - **READMEs**: webui "Message a role" says where replies show, when they refresh, what each state means, and that the Console never receives or marks a message; the route paragraph lists `/api/bus/replies?id=`. bus README: the verb table row, `replies` in the reader capability list, a paragraph in "Human and reader paths", and `read.replies(messageId)` in the shared-reads snippet. ## Decisions and tradeoffs - **A new verb, not a `trail` field** (the brief left this to me). `trail` takes a task ref, decision id or message id and returns `[]` for an unknown subject; the brief says this read refuses an unknown id instead. Putting replies in `trail` would mean either changing trail's answer for unknown message ids or giving one field a different refusal rule from the rest of the call. Trail also returns linked records as a flat list of mixed tables; replies are a list of messages with their own cap. A separate verb keeps trail's shape and every existing trail caller as they are, and lets the Console read the replies without reading the whole trail on each refresh. - **Human and reader only.** The brief says "the same sessions as the other reads (human and reader)". `inbox`/`tasks`/`agents`/`trail` are also open to role sessions; `decisions` and `credentials` are not. I followed the brief's wording and the newer two: a role gets `human-required`. A test checks a role can't read replies even to a message it sent. - **The receipt id is not checked against the broker before the read.** The POST answer's id goes to `replies` as is; the server checks its form and the bus refuses an unknown one. The page shows that as "could not be read", not "No reply yet", so a bad id is visible. - **A late answer is dropped.** If a send finishes while a replies read is out, that read's rows belong to the earlier message. The composer keeps the receipt object it read for and assigns the rows only if it is still the current receipt (`receipt === r`). Tested with a held read. - **A failed replies read doesn't mark the Inbox stale.** It is caught in the composer, like the agents read for the role list, so the Inbox and its count stay fresh and only the receipt says the replies couldn't be read. ## Files - `packages/bus/src/broker.mjs`: `REPLIES`, the allowlist entry, the `replies` case. - `packages/bus/src/views.mjs`: one line. - `packages/bus/tests/replies.test.mjs`: new, 7 tests. - `packages/bus/README.md`: verb table row, reader caps, one paragraph, snippet line. - `packages/webui/src/serve.mjs`: `BUS_VERBS`, the id check. - `packages/webui/src/bus.mjs`: comment only. - `packages/webui/src/public/bus.js`: `fetchRead`'s query name, the replies state and renderer, the composer read, the reset on send, the note text. - `packages/webui/tests/bus.test.mjs`: 1 new test, 3 widened. - `packages/webui/tests/send-browser.test.mjs`: 1 new test, the note assertion updated. - `packages/webui/README.md`: "Message a role", the bus route paragraph. - `packages/cli/src/cli.mjs`: the talk timeout message. - `packages/cli/tests/verbs.test.mjs`: 1 new test, the timeout assertion. ## Tests - `packages/bus/tests/replies.test.mjs` (new, 7): - *a reply is listed for human and reader sessions, and its delivery rows are unchanged*: a reply to the send is listed; a message to the human with no `in_reply_to` and a reply to another message are not. Each row equals the `messages` row `trail` gives for the reply's id. The `deliveries` rows and the row counts of six tables are the same before and after; the reply's own deliveries are only `routed`. - *the reply still reaches the human message.receive (mosaic talk) after the Console showed it*: listed by reader and human, then received, read, and still listed. - *a message with no reply gives an empty list*. - *replies come oldest first and stop at REPLIES*: 51 replies from two roles; the first 50 in send order. - *an id that names no message in the business refuses message-not-found*: an unknown UUID, another business's message, a task ref and a decision id, for human and reader; each refusal recorded as an `action.refused` event. The other business's reader reads its own. - *strict arguments; a role session gets human-required*: seven bad argument shapes give `invalid-request`; a role gets `human-required`, including for a message it sent; the reader still can't `message.receive`. - *views passes replies through*. - `packages/webui/tests/bus.test.mjs`: new *replies route serves the replies to one message from the reader view* (200 with the row, an empty list, unknown id 400 `message-not-found`, no delivery row, and seven missing or malformed ids that never reach the bus, `?subject=` among them). Widened: the GET-only test (405 on `replies`), the no-bus refusals (`replies?id=%3C` 400, `replies?id=m-1` 503), the `busReader` passthrough. - `packages/webui/tests/send-browser.test.mjs`: new *a sent receipt lists its replies as the page refreshes, oldest first and capped, and marks none delivered or read*, in a browser against an in-process broker: "No reply yet" after the send; after Read again, two replies in order with role, class, trail link and a hostile body shown as text (no script or img element, `window.injected` unset), the unrelated messages absent, deliveries unchanged; a third reply shows on the page's own ten-second refresh with no click; the human's `message.receive` then gets all three, and after they are read they are still listed; 51 replies show 50 and the cap note; an unknown sent id shows "could not be read" with no stale banner on the Inbox; a new send starts an empty list; a send that finishes while a replies read is held keeps its own empty list, and the next read shows its own reply. The row 76 note assertion now checks the new note. - `packages/cli/tests/verbs.test.mjs`: the timeout assertion is now the exact new message. New *a late reply the Console has listed still reaches the next talk*: talk times out, the role replies, `replies` lists it, and the next talk prints it and marks only it read. New tests on base code (`new-tests-on-base.txt`): every new test fails (`replies.test.mjs` at import, `REPLIES` isn't exported). Base tests on candidate code (`base-tests-on-candidate.txt`): bus 95/95; webui 80/81 and cli 113/114, the two failures being the row 76 note text and the old timeout regex `/mosaic inbox/`, both text the brief changes. ## Mutants (`mutants.py`, `mutants.txt`) 17 single-site mutants, each run in a scratch copy of the candidate against the test files named in `mutants.py`. All 17 killed: | mutant | result | |---|---| | broker: no human-required | killed | | broker: no message-not-found | killed | | broker: newest first | killed | | broker: cap 51 | killed | | broker: reader not allowed | killed | | broker: no strict keys | killed | | views: subject arg | killed | | serve: no id check | killed | | serve: replies not a verb | killed | | page: query as subject | killed | | page: no replies read | killed | | page: no reset on send | killed | | page: no stale guard | killed | | page: cap note off by one | killed | | page: body unescaped | killed | | page: failure shown as none | killed | | cli: old message | killed | `mutants.txt` is the raw output. Its `BAD-ANCHOR (0) serve: no id check` line is my anchor's wrong indentation (the mutant did not run); the last line is the rerun with the anchor fixed. The base tests don't reach `replies` at all, so I didn't tabulate a base column: every new test fails on base, above. ## Gate (my run on the candidate) On the candidate in a worktree of `ff11aa4b`, run sequentially, 2026-10-12T18:29:24Z to 18:35:52Z (`gate-summary.txt`): - `node --test 'packages/

/tests/*.test.mjs'`: bus 102/102, webui 83/83, control-board 124/124, cli 115/115, each rc=0. - `scripts/test-auth.sh`, `test-conductor.sh`, `test-config.sh`, `test-discord.sh`, `test-extension-package.sh`, `test-foundation.sh`, `test-queue.sh`, `test-release.sh`, `test-task.sh`: all rc=0. The packet's tree is the one gated: `build.patch` applied to a fresh worktree of `ff11aa4b` checks 12/12 against `candidate-manifest.sha256`. ## For reviewers - **The bus README's verb table** sits in "Client protocol", not the reads section the brief names. `decisions` and `credentials` have rows there, so I added one for `replies`. One line; drop it if the brief's file boundary is strict. - **`packages/cli/README.md` lines 61-65** say "the reply still lands in the inbox". That is the human's message inbox (what `message.receive` returns), not `mosaic inbox`, so it isn't wrong, but it reads like the timeout wording this row removed. The file isn't owned; a follow-up could say "the next `mosaic talk` prints it". - **Equivalent mutant not run:** dropping `business=?` from the replies query. `message.send` refuses an `in_reply_to` outside the sender's business, so no reply can point across businesses and the filter changes no result. It stays as defence in depth, matching every other read.