Console shows the reply to a web send: bus `replies` read (human and
reader, message-not-found on unknown ids, oldest first, cap 50, writes
nothing), /api/bus/replies, the Sent receipt's reply list, talk's timeout
names only mosaic talk. Base ff11aa4b, 12 paths, manifest 12/12 on a fresh
worktree. Gate green 18:29-18:35Z; 17/17 mutants killed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
216 lines
12 KiB
Markdown
216 lines
12 KiB
Markdown
# 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=<message 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 <role> within <n> 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/<p>/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.
|