# Row 57 (#1546): the bus reads decisions routed to roles and recent decisions, candidate packet Author: Filbert. Reviewers: Darkwing and Dewey. Brief: "Bus read: decisions routed to roles and recent decisions" in `docs/plans/2026-10-10_design-implementation.md`. Base: `958668a8476250edab8025a48a5c553dfd75f99b` (`base.txt`). The feature is uncommitted; only this packet is committed. I pushed nothing. ## Files `files.txt` lists the 8 repository paths. `candidate-manifest.sha256` has their sha256. `build.patch` is `git diff --cached --binary 958668a8` over them: 8 files, +297/−17. | File | Change | |---|---| | `packages/bus/src/broker.mjs` | new verb `decisions`, `{list: 'roles' \| 'recent'}`; `RECENT = 50`; the reader allowlist adds it | | `packages/bus/src/views.mjs` | `decisions(list)` in the Q1 client module | | `packages/bus/tests/decisions.test.mjs` | new: 6 tests, listed below | | `packages/bus/README.md` | "Human and reader paths" says what the verb returns and who may call it; the "Shared reads" example | | `packages/webui/src/serve.mjs` | `GET /api/bus/decisions?list=roles\|recent`; any other list is 400 and never reaches the bus | | `packages/webui/src/bus.mjs` | the header comment only; the reader already passes any `views()` verb through | | `packages/webui/tests/bus.test.mjs` | one new test for the route; the 405 test and the `busReader` test cover it too | | `packages/webui/README.md` | the bus read list and the port exposure note | ## Scope The brief owns `packages/bus/`, `packages/webui/src/bus.mjs` and its tests. Two files outside that list changed: - `packages/webui/src/serve.mjs`. The brief's "its webui endpoint" is a route, and the routes live there, not in `bus.mjs`. The change adds the verb to `BUS_VERBS` and checks `?list=` before the bus is called. - `packages/webui/README.md`. It lists every `/api/bus/` route and what the port exposes, so a new route without it would leave the README wrong. No Inbox tab uses the endpoint yet. The brief ships the read and the endpoint; the tabs are the design rows' work. ## How it works `decisions` is a broker verb like `inbox`, in the same read transaction. - Who: human and reader sessions. A role session gets `human-required`, claimed or not, and the refusal is recorded as `action.refused` like every refusal. A reader still gets `read-only` for every write. - Arguments: `{list}` and nothing else (`keys(a, ['list'], ['list'])`). Any other list is `invalid-request`. - `roles`: the business's open decisions with `route_to <> 'human'`, in `seq` order. Open means no `resolved`, `withdrawn` or `expired` event, the same rule as `inbox` and the schema's `inbox` view. The human's own open decisions stay in `inbox`. Unlike `inbox`, gated decisions are not put first: the list spans roles, and `route_to` is on each row. - `recent`: the business's closed decisions of every route, newest close first (`decision_events.seq DESC`), at most `RECENT` (50). Each row has `closed`, the closing `decision_events` row, from the same `#closed()` that the authority check and `decision.resolve` use. The schema trigger `decisions_resolve_once` allows one close per decision, so the join can't repeat a row. - Each row is `#decisionView(d)`, the shape `inbox` returns: options parsed, `blocking` a boolean, and the authorization context. ### What a reader may read The brief says the reader's authority doesn't widen. Per row, it doesn't: `trail()` already gives a reader or human session the same decision row and its closing event, and test 4 checks every listed row against it. What is new is the listing. Before this row a reader needed the id. Decision ids are random UUIDs. A decision with a `task_ref` could already be found through `tasks` and then the task's trail, but one without (a within-role decision, or a scope change with only a target) could not. The WebUI port is where that matters. F1 on #1522 recorded that anything that can connect to the Console's port reads what a reader capability reads, including a T3 seat with `curl` and a managed S6 session, and slice 1 accepted that. With this row that includes both lists, so a role session that can reach a running Console's port can list other roles' open decisions there, though not through its own bus capability. The README's exposure note now says so. The brief's test is on the bus capability (test 5); closing the port is outside this row, as it was for S5. ## Tests `packages/bus/tests/decisions.test.mjs`, each for both a human and a reader session where it applies: 1. `roles` lists the open decisions routed to cto and pm, never the human's, with the `inbox` shape; `inbox` still lists only the human's. 2. `recent` lists closed decisions of every route, newest close first, with how each closed: resolved by the human (via `cli`, with its note), resolved by a role, withdrawn, expired, and within-role. `roles` is then empty. 3. `recent` stops at `RECENT` rows, newest first. 4. Every row of both lists equals the decision row, and its `closed` the closing event, that `trail` gives the same session for that id. 5. A role session (cto, pm, coder, and an unclaimed launch) gets `human-required` for `roles`, `recent` and an unknown list, and each refusal is recorded. Each role's `inbox` stays its own. A role in another business is refused too. 6. One business only (a reader of `other` sees only `other`'s rows), and strict arguments (`{}`, `all`, `limit`, an array, `Recent`). A successful read writes no row in `events`, `decisions`, `decision_events`, `messages`, `deliveries` or `role_claims`. A reader still can't send `decision.seen`. `packages/webui/tests/bus.test.mjs`: - New: the route serves both lists from a real broker through the reader view, with `no-store`, `{rows, at}`, and a hostile question unescaped in the JSON. A missing, empty, unknown, wrong-case or NUL-suffixed list, or `?subject=` instead, is 400 and never calls the bus. - The 405 test sends every non-GET method to the new route too. - The `busReader` test checks `decisions('recent')` sends `{business, verb: 'decisions', args: {list: 'recent'}}` to the transport. ### Mutants `out/mutants/run.mjs` edits one source file in a second worktree, runs the bus and webui suites, and restores the file (`out/mutants/result.txt`): | Mutant | Change | Killed by | |---|---|---| | `role-allowed` | a role session may call `decisions` | bus test 5 | | `roles-with-human` | `roles` includes the human's decisions | bus test 1; webui route test | | `roles-with-closed` | `roles` includes closed decisions | bus tests 1, 2, 6; webui route test | | `roles-other-business` | `roles` ignores the business | bus test 6 | | `recent-other-business` | `recent` ignores the business | bus test 6 | | `recent-oldest-first` | `recent` oldest close first | bus tests 2, 3 | | `recent-by-raise` | `recent` ordered by raise, not close | bus test 2 | | `recent-no-limit` | no `LIMIT` | bus test 3 | | `recent-limit-plus-one` | `LIMIT RECENT + 1` | bus test 3 | | `recent-resolved-only` | `recent` drops withdrawn and expired | bus test 2 | | `recent-no-closed` | rows lose `closed` | bus test 2; webui route test | | `loose-keys` | `limit` accepted | bus test 6 | | `unknown-list-empty` | an unknown list returns `[]` | bus test 6 | | `reader-refused` | the reader allowlist drops `decisions` | bus tests 1-4 and 6; webui route test | | `web-no-list-check` | the route passes any `?list=` to the bus | webui route test | | `web-no-verb` | `decisions` not in `BUS_VERBS` | webui route test and 405 test | | `views-fixed-list` | `views().decisions` ignores its argument | webui route test and `busReader` test | All 17 are killed. `roles-other-business` first matched the `inbox` query too; I narrowed the pattern and reran it with `roles-with-human` (`result-rerun.txt`). In the first run, `roles-with-human`'s webui run also hit the 600-second timeout in unrelated conversation-view tests while the machine was loaded; the rerun shows the route test failing on its own. ## Gate Run on `958668a8` with the candidate applied, sequentially, `TMPDIR` in my scratch and `DOCKER_HOST` pointed at a missing socket (`out/gate/`): | Suite | Result | |---|---| | `node --test packages/bus/tests/*.test.mjs` | rc 0, 80 pass, 0 fail | | `node --test tests/` in `packages/webui` | rc 0, 40 pass, 0 fail | | `test-auth.sh` | rc 0, 15 passed | | `test-conductor.sh` | rc 0, 17 passed | | `test-config.sh` | rc 0, 24 passed | | `test-discord.sh` | rc 0, 66 passed | | `test-extension-package.sh` | rc 0, 18 passed | | `test-foundation.sh` | rc 0, 44 passed | | `test-queue.sh` | rc 0, 27 passed | | `test-release.sh` | rc 0, 4 passed; the Docker cases skip without a daemon | | `test-task.sh` | rc 0, 26 passed | The manifest checks OK after the gate. Sage's rerun with Docker is the gate of record. ## Things a reviewer should know - `RECENT` is fixed at 50 and the verb takes no limit. The design's Recent tab shows one page; a limit argument would be a new input to validate for no current caller. - The `recent` query reads `decision_events` once per row through `#closed()`, as `inbox` does. At 50 rows that is 51 indexed reads in one transaction. - A decision raised and closed in the same call (within-role with a `choice`) appears only in `recent`, never in `roles`.