Files
stack/docs/plans/2026-10-14_decision-close-message.md
T

6.5 KiB

A closed decision reaches the role that raised it (2026-10-14)

Filed by Sage on Mos's report of 2026-10-14. Gitea #1609, Vikunja #205.

A closed decision sends the raising role a message

Problem

Jason resolved decision 03130d08-ddce-480d-a601-e59f64744fc3 (the PM's prd.approve for Q139/Q140, gated, routed to human) yes from the CLI at 2026-10-14T16:15:35.512Z. The bus db has the decision_events row (op resolved, by jason, via cli) and event 346, its human.input answer. No message cites the decision, and the PM had no turn after it: the newest message was still 75 (pm to human, 16:12:19Z) when Sage read the db at 16:23Z.

The cause is in packages/bus/src/broker.mjs (cc48b124f, live; refactor is the same):

  • decision.resolve and decision.withdraw write the decision_events row and, for a human, a human.input event {kind:'answer'}. They write no message.
  • message.receive reads only messages addressed to the role.
  • packages/harness/src/runner.mjs takes a turn only for a message that message.receive returns.

So a role learns an answer only when some unrelated message gives it a turn. raise_decision's tool text (packages/harness/src/tools.mjs:135) already says "The answer arrives as a message." This row makes the broker keep that promise, for every routed decision.

The fix goes in the broker, not the runner. A message is what every transport already receives, routes to the role's current holder, and strands visibly in agents when nobody reads it. A second wake path in the runner would need its own delivery record.

Owner and reviewer

  • Owner: darkwing, after row 113.
  • Reviewers: filbert (decision 130, not a sandbox row) and sage.

Files owned

  • packages/bus/src/broker.mjs
  • packages/bus/tests/ (a new decision-close.test.mjs, or changes to decisions.test.mjs and messages.test.mjs)
  • packages/bus/README.md, the decision.resolve, decision.withdraw and message.receive rows and the decision section
  • packages/harness/tests/, only for the runner case in item 4
  • agents/darkwing/work/queue-115/

What ships

  1. decision.resolve and decision.withdraw send the raising role a message when someone other than the raising run closes the decision:
    • It's written in the same transaction as the decision_events row, so a refused or rolled-back close writes no message.
    • to_role is the decision's raised_by_role. It's the role, not the run, because runs end. When no run holds the role, the message waits for the next claim like any other.
    • Class DECISION, decision set to the decision id, in_reply_to null.
    • Sender: a human close is stored from_role 'human', from_run 'human-cli'. A role close is stored as that role and run. The sender must be a real address, because the runner sends its answer to from_role and a refused send leaves the message unread.
    • Routed with a routed delivery like message.send's.
    • Body, one fixed form the broker writes: Decision <id> on <action> was resolved by <by>: <choice> (<option text>). or Decision <id> on <action> was withdrawn by <by>., then Note: <note> on its own line when the close had one. <by> is what decision_events.by holds. Nothing else from the request goes in.
  2. No message in these cases:
    • decision.seen. It's an acknowledgement, not a close.
    • A withdraw by the raising run itself, including the withdraw that supersedes writes.
    • A within-role decision, which decision.raise resolves itself.
  3. message.receive returns request for a human close's message: the id of that close's human.input answer event. That event keeps its subject (the decision id), so trail on the decision is unchanged. This adds no authority. The event is already a human input that task_create can cite; the role just learns its id. A role close's message has request null.
  4. The runner needs no change. turnRequest already prints citing decision <id> and request <id>. The role's answer to the DECISION message goes back to its sender as RESULT, so Jason sees what the role did with their answer, and a resolving role gets one RESULT and sends none back. One harness test shows a DECISION message from human gets a turn whose header cites the decision and the request.
  5. README: the decision.resolve and decision.withdraw rows say who gets the message and when none is sent. The message.receive row says where a DECISION message's request comes from.
  6. Tests, offline as today:
    • Human resolve: exactly one message to the raising role, class DECISION, the decision id, from human/human-cli, the choice and option text in the body; message.receive gives request equal to the answer event's id.
    • A cross-role decision resolved by its arbiter role: from that role and run, request null.
    • Human withdraw: one message, saying withdrawn, with the note.
    • Raising run's withdraw, a supersede, a within-role raise and decision.seen: no message.
    • Refused closes (invalid-choice, decision-closed, not-resolver): no message.
    • Raising role unheld at close: the message is routed with no holder and delivered to the next claim's message.receive.
    • Mutants, each recorded in the packet: drop the message insert; address it to route_to instead of raised_by_role; send one on the raising run's own withdraw. Each must fail a test.
  7. Suites: every package suite (node --test tests/*.test.mjs in each packages/*/) and every scripts/test-*.sh, on the packet gate.

Out of scope

  • Backfill. On 2026-10-14 decision 03130d08 was the only closed decision in the live db, and Mos unblocks it by hand. The broker writes messages only for closes after this lands.
  • The runner, the notifier, the Console Inbox and the Discord connector.
  • The raise_decision tool text. It's accurate once this lands.
  • A push wake for the runner. It polls message.receive every pollInterval (2 s by default), which is soon enough.
  • Decision 104, bus.tester and the C-suite rows, held for Q135.

Gate

Filbert and Sage approve on #1609 naming the candidate manifest. Sage reruns the full gate at the landing target. Mos gets notices before and after the landing (decision 109). It goes live only through stack-copy.sh copy, a re-pin and a bus restart on Mos's go. After the restart, the next human close of a PM decision should show the DECISION message in messages and a PM turn after it.