Candidate on base 94ece491f: 5 files, manifest 1910eb5a; only
decision-close.test.mjs changed since round 1. Mutants m1-m4 killed
(m4 is Filbert's f4); gate passed with no flakes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
12 KiB
Row 115 (#1609) round 2: a closed decision sends the raising role a message
Round 2
Filbert asked for changes in round 1 (comment 28422, rev 1131). Their C1: nothing tested the op filter in #request's fallback query, json_extract(body,'$.op') IN ('resolved','withdrawn'). A human decision.seen on a human-routed decision also writes a human.input answer with the decision as its subject, with op seen. Without the filter, a seen before the close becomes the close message's request, which breaks brief item 3. Their f4 dropped the filter and the suites still passed.
The fix is test-only, and broker.mjs is the same as in round 1. In packages/bus/tests/decision-close.test.mjs:
- A new test, "a human seen before the close leaves the request on the close's answer, not the seen". The human sees two human-routed decisions, then resolves one and withdraws the other. Each close message's
requestis that close's own answer, and not the seen's. - The
answer()helper takes an op,resolvedby default, so it can't return a seen's answer either. The human withdraw test now asks forwithdrawn.
f4 is m4 in mutants.py. Round 1 sections below are updated where the counts changed.
Brief: docs/plans/2026-10-14_decision-close-message.md. Mos found the gap. Jason resolved decision 03130d08, and the PM, which raised it, never got a turn, because a close wrote only decision_events and nothing reached the role.
BASE: 94ece491f0. I built the candidate on 54b7c7b3e and moved the worktree to 94ece491f before the mutants and the gate. The commits between them touch only packages/ledger, docs/SESSIONS.md and the queue files, none of the manifest paths.
Candidate: build.patch against BASE. candidate-manifest.sha256 lists the 5 files, of which only decision-close.test.mjs changed since round 1. I applied the patch to a fresh git archive 94ece491f export and every digest matched (DIGESTS_OK). HEAD has moved past 94ece491f since, with docs commits and the PRD v1.1 merge f7c8537b3. That merge changes docs/prd/mosaic-stack.md, packages/tasks/requirements.json and two requirements tests, packages/tasks/tests/requirements.test.mjs and packages/bus/tests/requirements.test.mjs (2 lines). None of them is a manifest path, but the bus one is in the bus suite, so the landing gate at the new target covers it and mine at 94ece491f doesn't.
What changed
The close message
packages/bus/src/broker.mjs, the decision.resolve/decision.seen/decision.withdraw case. After the decision_events insert and the human's human.input answer event, a close with op !== 'seen' by a human, or by a run other than raised_by_run, inserts one message:
| Column | Value |
|---|---|
from_role, from_run |
human, human-cli for a human close; the closing role and run for a role close |
to_role |
d.raised_by_role |
class |
DECISION |
decision |
the decision's id |
in_reply_to, corrects |
null |
body |
Decision <id> on <action> was resolved by <by>: <choice> (<option text>). or Decision <id> on <action> was withdrawn by <by>., then \nNote: <note> when the close had a note |
<by> is the same by the decision_events row gets: the human's name, or the closing run. The option text comes from the decision's own options, which invalid-choice has already checked. The message is routed like message.send's, to the raising role's current holder, or with no holder until the next claim routes it.
It runs inside request()'s transaction, after every refusal check, so a refused close (invalid-choice, decision-closed, not-resolver) writes no message.
The cases that send nothing:
decision.seen, by theopcheck.- The raising run's own withdraw, by the run check. An agent withdraw is only ever the raising run (
not-resolverotherwise), so a role's withdraw never sends one. A human withdraw does. - A supersede. Its withdrawal is a direct
decision_eventsinsert insidedecision.raise, not this case. - A within-role decision. It closes inside
decision.raise, sodecision.resolveon it refusesdecision-closed.
The request
message.receive mapped request with one query, the human.input whose subject is the message id. That moves into a private #request(business, m), unchanged for every message except one kind. When there's no event for the message itself and the message is a DECISION from human that cites a decision, it returns the human.input on that decision with kind answer and op resolved or withdrawn. A decision has at most one close, because both close paths refuse decision-closed and the broker never writes expired, so that event is unique. A human message.send with class DECISION keeps its own instruction event, because the first query finds it. A role close's message has no human.input, so its request is null.
The runner
No change, as the brief says. turnRequest already prints citing decision and request, and the runner replies to from_role, so the role's RESULT goes to the human's inbox.
README
packages/bus/README.md:
- The
decision.resolverow says it sends the raising role a DECISION message. Thedecision.withdrawrow says a human withdraw does and the raising run's own withdraw doesn't. Themessage.receiverow says a human close's message carries the close's answer asrequest, and a role close's has null. - A new section, "Decision close messages (row 115, #1609)", after the decision authorization text. It gives the columns, the routing, both body forms,
<by>, the note line, the four cases that send nothing, the request, and that the message shows in the decision'strail.
unslop-check is clean on the README.
Scope notes
- packages/bus/tests/single-use.test.mjs. "message.send consumes approval and prevents a later send or authorize" counted every message after its approval and expected 1. The approval's resolve is a cross-role close by the arbiter, so it now sends the raiser a DECISION message too, and the count was 2. The test now counts the DECISION message (1) and the other messages (1) separately. The brief lists packages/bus/tests/ as owned, and this was the only existing test that changed.
- The decision's
trailgains the new message, since#trailadds messages whosedecisionis the decision. The human.input answer keeps the decision as its subject. The human resolve test checks the trail. agents. A role whose decision the human closed now has a routed message. It readsworkingwhile its runner takes the turn, thenidleonce the runner marks the message read. That's the turn the brief asks for, so I didn't change the state rules.- Nothing reads the new message outside the bus's own verbs. The Console, the notifier and Discord are out of scope by the brief and unchanged.
Tests
packages/bus/tests/decision-close.test.mjs is new, with 8 tests. Its fixture raises one decision routed to cto (technical), one to pm (delivery) and one gated to the human, all from coder.
| Test | Checks |
|---|---|
| a human resolve sends the raising role one message from the human, and its request is the answer | one DECISION message. Every column, the body with the note line, decision_events.by is jason, one routed delivery to coder-run, and coder's message.receive returns it with request equal to the answer event id. The message is in the decision's trail |
| a cross-role decision resolved by its arbiter comes from that role and run, with no request | from cto/cto-run to coder, the body with resolved by cto-run: yes (Allow). and no note line, request null |
| a human withdraw sends one message with the note | from human to coder, withdrawn by jason., a two-line note kept as written, request the withdraw's answer |
| a human seen before the close leaves the request on the close's answer, not the seen | round 2. Two human-routed decisions, both seen by the human, one resolved and one withdrawn. Each message's request is its close's answer, not the seen's |
| the raising run's withdraw, a supersede, a within-role raise and seen send nothing | 3 closes and 2 seen rows in decision_events, no DECISION message, coder's receive is empty |
| refused closes send nothing: invalid-choice, decision-closed, not-resolver | a human bad choice, pm resolving cto's decision, cto withdrawing coder's: no message. Then one real close, and the second resolve and a human withdraw of it refuse decision-closed with still one message |
| a raising role nobody holds at the close gets the message on the next claim | coder releases, the human resolves: one routed delivery with no holder. coder-run-2 claims, the message is routed to it, and its message.receive returns it with the answer as request |
| a human DECISION send keeps its own instruction as the request, not a close's answer | after a human resolve, a human message.send with class DECISION citing the same decision. Coder receives both: the close's has the answer, the send's has its own request |
packages/harness/tests/runner.test.mjs gains "a decision the human resolves becomes a turn citing the decision and the answer". The runner holds coder, coder raises a gated deploy, and the human resolves it. The coder's RESULT reaches the human in reply to the close message, and its echoed turn ends with Message <id> from human, sent <at>, class DECISION, citing decision <d>, request <answer>: and the body. The close message is marked read.
Mutants
mutants.py runs each mutant against the full bus suite (156 tests) and restores broker.mjs after each. Log: mutants-run.log, per-mutant output under mutants/.
| Mutant | Change | Result |
|---|---|---|
| m1 | the send condition becomes if (false) |
killed, 8 fail: the seven close tests that expect a message, plus single-use's DECISION count |
| m2 | to_role becomes d.route_to |
killed, 7 fail: every close test that reads to_role or the coder's inbox |
| m3 | the condition drops the run check, if (op !== 'seen') |
killed, 1 fails: the raising run's withdraw test |
| m4 | Filbert's f4: #request's fallback loses AND json_extract(body,'$.op') IN ('resolved','withdrawn') |
killed, 1 fails: the round 2 seen-before-close test |
All four were restored, and git diff --stat matched the candidate afterwards. Round 1's m1 to m3 were killed too, with one fewer failing test each where the round 2 test applies.
Gate
gate.sh runs every package suite and every scripts/test-*.sh against the candidate worktree. GATE_PASSED at 17:00:04Z, 16:54:23 to 17:00:04. Log: gate-run.log, per-step logs under gate/. Round 1's gate also passed, at 16:43:58Z.
| Step | Pass |
|---|---|
| bus | 156 (148 at BASE, plus the 8 new) |
| business | 67 |
| cli | 184 |
| control-board | 124 |
| conversation | 182 |
| discord | 178 |
| harness | 81 (80 at BASE, plus the runner test) |
| ledger | 86 |
| mosaic | 69 |
| queue | 156 |
| runs | 43 |
| seat | 120 |
| tasks | 92 |
| webui | 108 |
| scripts | auth 15, conductor 17, config 24, discord 66, extension-package 18, foundation 44, queue 28 (node 156), release 14, task 98 |
No failures and no flakes. The bwrap tester case passed (gate/pkg-seat.log:77).
Managed sessions. Sage saw a managed coder session run tests in its workspace at about 16:30Z, during their row 113 gate. My busy check flags test runners started from any process whose cwd is under ~/.mosaic-dev/workspaces/. In round 2 a managed PM turn (claude -p, cwd ~/.mosaic-dev/workspaces/mosaic-stack/pm) was live at 16:53Z. The check first flagged it because the prompt text in its command line contains "test". Its processes were sh, claude and node, with no test runner, so I narrowed the check to test runner command lines and went ahead. The check was clear before the mutants and before the gate. A scan after the gate found no process with a cwd under the workspaces. I can't rule out a short run between checks, but nothing failed.
The release step created the wt_default Docker network at 16:59:12Z. It had 0 containers, and I removed it.
Going live
This is a bus landing, so decision 109 applies. Mos gets a notice before and after landing. It goes live only through a stack copy, a re-pin and a bus restart on Mos's go. I start, stop and restart nothing.