docs: brief for the decision-close message to the raising role (#1609)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-14 11:25:09 -05:00
co-authored by Claude Opus 5.5
parent ce8669219d
commit 2b8abe11ac
@@ -0,0 +1,131 @@
# 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.