Files
stack/agents/dewey/work/queue-76/build.md
T
jason.woltjeandClaude Opus 5.5 1deb614344 review(dewey): row 76 round 1 packet, Console sends a human message to a role, on 3d298920 (#1570)
Built on bbd22f1b, rebased on row 60 (3d298920): the bus.mjs header
comment conflict merged, serve.mjs BUS_VERBS and the README credentials
paragraphs merged cleanly. 21 of 21 mutants killed, full gate green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 20:10:35 -05:00

20 KiB

Row 76 (#1570): Console sends a human message to a role, round 1

Owner: Dewey. Reviewers: Darkwing and Filbert. Brief: docs/plans/2026-10-11_dogfood-cutover.md § "Console sends a message to a role", blob f5d56554 (rev 552). Jason's Q95 answer is lead decision 95; the reply view question is lead decision 97, option (a). Base 3d298920 (row 60 landed). None of the candidate's paths changed between 3d298920 and refactor HEAD (3d298920) when the packet was built.

build.patch is git diff --binary 3d298920 -- packages/webui, the two new test files included. Apply it with git apply --index to a detached worktree of the base; candidate-manifest.sha256 then checks 9/9 OK.

What ships

serve --public-host NAME --auth-header HEADER --allow-user ID --send. --send is off by default; without it the Console behaves as after row 74 (preview.test.mjs and serve.test.mjs unchanged and green).

Start (cli.mjs, serve.mjs). --send is the one flag without a value. Without all three preview flags the CLI exits 2 with refused: --send needs the preview mode (...), and startServer throws the same for a direct caller, or for a send that isn't a function. With --send and no system config the start refuses (--send needs the bus configured (...), the reason redacted as before) instead of serving a box that can't send; a read-only Console still serves without the config. The Preview: line says reads and human messages to roles, one line per send below. busReader is split into busCaller (the human transport call, the business resolved per request as before) and views over it, so the reads and the send use the same transport.

The route. With --send, POST /api/bus/message is the one write preview mode lets past its read-only check; any other method on that path is 405, and every other write is still 403 read-only. Order, each on every request:

  1. The shared checks from row 74 run first: repeated Host 400, HEADER with a Host other than NAME 403 proxy Host refused, a NAME request without HEADER = ID once 401, a sent Origin other than https://NAME 403. These write no log line.
  2. busSend checks HEADER = ID once again itself (401), so the route never runs on a loopback Host: a loopback request without HEADER is 401, with it 403 proxy Host refused. There is no loopback send.
  3. Origin sent once and exactly https://NAME (a missing Origin passes the shared check and stops here), else 403 cross-origin request refused.
  4. Sec-Fetch-Site sent once and exactly same-origin, else 403 cross-site request refused.
  5. Content-Type: application/json, at most 16384 bytes, a JSON object with to and body, optionally in_reply_to, no other key; else 400 invalid-request.
  6. message.send with exactly those keys. The broker judges to and the body. Its limit, 32768 characters, is past the request limit, so 16 KiB binds first.

Every answer after step 2 carries sent: false for a refusal, "unknown" when the message may exist. Success is 200 {id, at}.

Outcomes (bus.mjs sendOutcome). The broker writes in one transaction, so any code it returns means nothing was written: {error: code, sent: false} at the read routes' statuses (busStatus), with unknown-role, message-not-found and request-too-large added as 400. outcome-unknown, invalid-response and response-too-large from the transport, any error that isn't a BusReadError, and an answer without a valid message id leave it open: 503 outcome-unknown with sent: "unknown". Nothing retries.

Log. One stdout line per request that gets past step 2: the time, send, user= (JSON-quoted), to= (JSON-quoted, cut to 64 characters, or none), then id=<message id> or error=<code>. Never the body. A test shows a to with a newline can't forge a second line.

/api/config gains send: true only with --send on a NAME request, so the operator's loopback page never shows a box that would get 401.

The page (public/bus.js). With send true the Inbox has "Message a role": a picker of held roles from the agents read, a text box and Send.

  • The draft (role and text) lives in page memory, so the ten-second refresh redraws the form without losing it; focus and the caret are kept. A reload loses it. A role no longer held stays selected, marked "(not held now)".
  • Send is disabled while a send is pending; a second press sends nothing. An empty role or text is refused in the page without a request.
  • One POST per Send, redirect: 'manual'. Sent: an id came back; the receipt shows it and links #/trail/message/<id> (a new trail kind: the message and its deliveries, back link to the Inbox, not-found for an id the trail doesn't hold). The text is cleared unless it was edited during the send.
  • Not sent: a 4xx, or the server's sent: false (a 503 no-bus-host too), or a redirect. Only the sign-in proxy redirects, when its session has expired, so the request never reached the Console; the receipt asks for a reload. The draft is kept.
  • Anything else, a lost answer included: outcome unknown. The receipt says the message may have been sent, that nothing was sent again, and that there is no trail to link without an id; the draft is kept.
  • The box says a reply comes back as a message the Console doesn't show yet, and the next mosaic talk prints it after sending its own message (decision 97).

Reply in the board (public/app.js). A refused Reply said "Delivery may be unknown; check the seat before sending again" for a 403. A 4xx now says "Not sent."; no answer, an unreadable one or a 5xx keeps the old text. Row 74 left this for this row.

README. A "Message a role" section: the command, what --send grants (anyone the proxy lets through as ID, and any local process that reaches the port and sets HEADER with Host: NAME; decisions 62 and 95), the request and each check, the broker's refusals and their statuses, the unknown outcome, the log line, the page's three receipts, and the reply path with mosaic talk's limits (decision 97). The routes list gains the message trail; the "nothing writes" lines name the exception. Folded in from row 74 per the row note: Filbert N1 ("its first signed-in request"), Darkwing's tunnel sentence (anything reaching a tunnel's local end can send a loopback Host without HEADER, so bind it to loopback), and a pointer to scripts/webui-service.sh, which takes no --send.

Decisions and tradeoffs

  1. No loopback send. The brief asks for the header on every request, loopback included. A loopback request can't carry it (row 74 refuses HEADER with a loopback Host), so the operator's browser on 127.0.0.1 reads but can't send. /api/config hides the box there.
  2. The route checks the header itself even though the shared check already did for NAME requests. It costs one line and keeps the route closed if the shared check changes.
  3. A missing Origin is refused on the send (the shared check allows a missing one for reads). The brief asks for the request's own origin, and browsers send Origin on a POST.
  4. Sec-Fetch-Site is compared exactly, so Same-Origin and a repeated header are refused. Browsers send the lowercase value once.
  5. Which errors mean "not sent". The brief maps broker codes like the reads'. I treat every coded broker answer as definite, since the broker writes in one transaction, and only the transport's three lost-answer codes, an uncoded error or a missing id as unknown. The page adds 4xx and the redirect as definite. A proxy 4xx means the request wasn't accepted for forwarding; a proxy 5xx (502, 504) can come after the Console got it, so it stays unknown.
  6. The draft is in page memory, not storage. The brief asks for it to survive the periodic refresh. Keeping it out of localStorage keeps a message body off disk on a shared browser profile.
  7. --send without the bus refuses to start. The brief doesn't say. Serving a box that can only answer not-configured seemed worse than a refusal at start.

Where the brief and the code differ (for Sage)

  • "Replies show in the inbox as today": they don't; the inbox shows decisions. Decision 97 settled it: the README and the box give mosaic talk's limits, and the reply view is row 81 (#1575).
  • "Points at the trail" on an unknown outcome: there's no id to link, so the receipt points at the role's side and the bus trail from a terminal.
  • The log's error= value is the code as answered, unquoted; for the 403s it has spaces (error=cross-origin request refused). It is always last on the line.

Rebase on row 60

I built this row on bbd22f1b. Row 60 landed first (1771bf98), so I applied my patch to 3d298920 with git apply --3way. Row 60's hunks in this row's files, and how each merged:

  • packages/webui/src/serve.mjs: row 60's BUS_VERBS line (adds credentials). It merged cleanly; my changes there are elsewhere in the file.

  • packages/webui/README.md: row 60's credentials read and its line in the exposure list. Merged cleanly, in different paragraphs from my "Message a role" section and the "nothing writes" lines.

  • packages/webui/src/bus.mjs: the header comment, the one conflict. Row 60 named its credentials read and I named the one write. The merged comment says both:

    // Only the four verbs of the Q1 module (lead decision 56), row 57's
    // `decisions` and row 60's `credentials` reads are reachable as reads: no
    // read writes, not even seen (Q4). The one write is row 76's human
    // `message.send` (lead decision 95), built by the CLI only with --send. The
    // bus does the proof; a server started inside an agent run gets
    // `human-required` from the broker.
    

Row 60's tests/bus.test.mjs change isn't in a file this row touches. The mutants, the suites and the demonstration below ran on the rebased tree.

Files

File Change
packages/webui/src/serve.mjs busSend, the route under send, body() limit parameter, send/log options, /api/config send
packages/webui/src/bus.mjs busCaller split out of busReader; sendOutcome; three codes added to the status map
packages/webui/src/cli.mjs --send, refused without the preview flags or the bus; the Preview: line
packages/webui/src/public/bus.js the "Message a role" box, the message trail kind, focus and caret kept across a refresh
packages/webui/src/public/app.js the Reply 4xx text
packages/webui/README.md the "Message a role" section and the folded-in row 74 notes
packages/webui/tests/send.test.mjs new, 13 tests
packages/webui/tests/send-browser.test.mjs new, 1 browser test
packages/webui/tests/shell.test.mjs the pinned write boundary: bus.js has exactly one write, this one

Tests

send.test.mjs (13): startServer refuses send without the preview mode or as a non-function; the CLI refuses --send without the flags and without a configured bus (exit 2, nothing on stdout, the config path not named); the CLI with --send and a configured bus but no bus host running prints the Preview: line, answers a send 503 no-bus-host with sent: false through the real human transport, and logs one line without the body; without --send the route is 403 read-only and config says no send; with --send every other write (POST, PUT, DELETE, PATCH on the board, bus and page routes) is still 403 read-only and nothing reaches the board or bus; config says send only on NAME; 401 for no header, another user, a suffix, two values, loopback without the header and localhost with no Origin, and 403 proxy Host refused for loopback with it, none sent or logged; each request check (no Origin, no or wrong or repeated Sec-Fetch-Site, no or wrong Content-Type, a form, 16385 bytes, extra keys, business, missing to or body, not JSON, null, an array, a string) refused with sent: false, logged without the body, and 16384 bytes accepted; wrong Origins refused by the shared check; GET and PUT 405; exactly to, body and in_reply_to passed on; a crafted to can't forge a log line; each bus refusal with its status and sent: false, each open outcome 503 unknown, a missing id unknown, and one call each (no retry); the human transport request is exactly the business, message.send and the keys; against a real broker the message is written once as a human send (from_role human, from_run human-cli, class INFO) and unknown-role, invalid-request and message-not-found map to 400.

send-browser.test.mjs drives the page in Chrome with an in-process broker for the roles and the trail and a stub for the POST (a send passes only with the proxy's https Origin, which a loopback page can't have): no box without send; the held roles; empty role and text refused without a request; the draft across a refresh; a 400 and a 503 sent: false refused with the draft kept; a redirect refused as sign-in; an unknown outcome, a network failure and a 200 without an id each unknown, with no trail link and no resend; one POST per Send, pinned as {method, headers, body, redirect: 'manual'}; Send disabled while pending and a second press ignored; text typed during a send kept; a plain success clears the text and keeps the role; the receipt's trail link opens the message's trail; an unknown message id is not found.

Mutants

mutants.py makes 21 single-site edits, one at a time, in a separate worktree of the rebased candidate (never the build tree). Each mutant runs against the whole webui suite, and the file is restored from the copy read before the edit. The baseline passed, the worktree was unchanged afterwards, and 21 of 21 were killed (2026-10-12T00:40:39Z to 01:02:24Z). mutants.txt lists each mutant with the tests that killed it.

  • Route (serve.mjs): the route's own header check removed; Origin unchecked; Sec-Fetch-Site unchecked, or same-site let through; the limit raised to 64 KiB; extra keys allowed; in_reply_to dropped; the id check removed; the log's to unquoted and uncut; /api/config send true on loopback; the route open without --send; every write opened by --send (send-opens-writes, killed by the new "every other write is still 403" test).
  • Outcomes (bus.mjs): only outcome-unknown left open; unknown-role dropped from the status map.
  • Page (bus.js): no pending guard; the text cleared though edited during the send; a 5xx sent: false treated as unknown; an unknown outcome resent; a redirect treated as unknown; redirects followed; the message trail shown for any id.

I ran 20 of these on the bbd22f1b build before the rebase. The 18 that finished there were killed too; the run was stopped for the rebase and its log is not in the packet.

Demonstration

demo.mjs runs the candidate's startServer with --send's options against a real in-process broker (pm, cto and coder; coder claimed), driven over node:http on loopback with the headers the proxy and the browser send (Host: NAME, x-authentik-uid: jason, Origin, Sec-Fetch-Site). Three servers: send, lost (the broker writes, then the call throws outcome-unknown, as when the transport loses the answer) and readonly (no --send). demo.txt is its output on the gate worktree:

== a send, and a reply to the coder
to coder                                       200  {"id":"8f66630d-cb38-4879-8119-fb3defd532c8","at":"2026-10-12T00:40:48.392Z"}
to coder, in_reply_to the coder's message      200  {"id":"3a35d7b4-2020-464e-9bc3-5ee870c8b53f","at":"2026-10-12T00:40:49.041Z"}
== refused before the bus
no sign-in header (401, no log line)           401  {"error":"sign-in required"}
loopback Host with the header                  403  {"error":"proxy Host refused","message":"the proxy must forward Host unchanged"}
another Origin (shared check, no log line)     403  {"error":"cross-origin request refused"}
no Origin                                      403  {"error":"cross-origin request refused","message":"a send needs the Console's own Origin","sent":false}
Sec-Fetch-Site: same-site                      403  {"error":"cross-site request refused","message":"a send needs Sec-Fetch-Site: same-origin","sent":false}
Content-Type: text/plain                       400  {"error":"invalid-request","message":"Content-Type must be application/json","sent":false}
16385 bytes                                    400  {"error":"invalid-request","message":"body larger than 16384 bytes","sent":false}
an extra key                                   400  {"error":"invalid-request","message":"a send takes to, body and an optional in_reply_to, nothing else","sent":false}
GET                                            405  {"error":"method not allowed"}
== refused by the broker
a role the business lacks                      400  {"error":"unknown-role","message":"not sent: the bus refused it (unknown-role)","sent":false}
an empty body                                  400  {"error":"invalid-request","message":"not sent: the bus refused it (invalid-request)","sent":false}
in_reply_to a missing message                  400  {"error":"message-not-found","message":"not sent: the bus refused it (message-not-found)","sent":false}
== the answer lost after the broker wrote
unknown, not retried                           503  {"error":"outcome-unknown","message":"the message may have been sent; nothing was retried. Check the trail before sending again","sent":"unknown"}
== without --send
POST is read-only                              403  {"error":"read-only","message":"the Console preview is read-only"}
== what the broker holds
8f66630d  from=human/human-cli to=coder class=INFO in_reply_to=- body="Please look at row 76 before the cutover."
3a35d7b4  from=human/human-cli to=coder class=INFO in_reply_to=0536126f-c54a-40a5-999d-c7fe1c01d9a2 body="refactor"
pm receives 1 message(s) from the lost-answer send: ["Lost answer test"]
== the Console log, one line per send past sign-in
2026-10-12T00:40:48.392Z send user="jason" to="coder" id=8f66630d-cb38-4879-8119-fb3defd532c8
2026-10-12T00:40:49.041Z send user="jason" to="coder" id=3a35d7b4-2020-464e-9bc3-5ee870c8b53f
2026-10-12T00:40:49.057Z send user="jason" to=none error=cross-origin request refused
2026-10-12T00:40:49.058Z send user="jason" to=none error=cross-site request refused
2026-10-12T00:40:49.061Z send user="jason" to=none error=invalid-request
2026-10-12T00:40:49.066Z send user="jason" to=none error=invalid-request
2026-10-12T00:40:49.072Z send user="jason" to=none error=invalid-request
2026-10-12T00:40:49.281Z send user="jason" to="auditor" error=unknown-role
2026-10-12T00:40:49.535Z send user="jason" to="coder" error=invalid-request
2026-10-12T00:40:49.802Z send user="jason" to="coder" error=message-not-found
2026-10-12T00:40:50.173Z send user="jason" to="pm" error=outcome-unknown
a body in the log: false

Gate (my run on the candidate)

Detached worktree of 3d298920, build.patch applied with git apply --index, the manifest 9/9 OK before and after. Suites one after another, TMPDIR in my scratch, Docker on, 2026-10-12T01:02:24Z to 01:09:40Z:

Suite Result
packages/webui 76 pass, 0 fail, 0 skipped, the browser tests included (14 new)
packages/bus 88 pass, 0 fail
packages/cli 114 pass, 0 fail
packages/control-board 124 pass, 0 fail
business, conversation, discord, harness, ledger, mosaic, queue, runs, seat, tasks all 0 fail (60, 182, 178, 59, 78, 69, 150, 41, 27, 71 pass)
build-tokens --check exit 0
test-auth, test-conductor, test-config, test-discord, test-extension-package, test-foundation exit 0 (15, 17, 24, 66, 18, 44 passed)
test-queue 27 passed, 0 failed; its verify/render --check case skips outside the canonical checkout
test-release 14 passed, 0 failed (Docker, nothing skipped)
test-task 98 passed, 0 failed (Docker, nothing skipped)

git status afterwards shows only the patch's 9 staged files and the node_modules link. core.hooksPath stayed unset.

Questions for reviewers

  1. Decision 5: is a proxy 4xx safe to show as "not sent"? I think so for the proxies we'd use, but a reviewer who knows one that answers 4xx after forwarding should say.
  2. Decision 7: refusing --send at start without the bus config, rather than serving reads and answering not-configured on each send.