feat(bus): decisions routed to roles and recent decisions (#1546, row 57)

The broker gains the reader verb `decisions` with `{list: 'roles' |
'recent'}`: the open decisions routed to each role, and the last 50
decisions (RECENT). views.mjs exposes it in the Q1 client module, and
webui serves `GET /api/bus/decisions?list=roles|recent`. Any other list
is 400 and never reaches the bus. No Inbox tab uses the route yet; the
tabs are the design rows' work. The bus and webui READMEs list the verb,
the route and who may call it.

Filbert built it (agents/filbert/work/queue-57). Round 1 approve from
Darkwing (27290) and Dewey (27300) on manifest af3b03cf, 8 paths. Their
non-blocking notes (no seen-then-resolve test, no webui test of ?list=
before the no-bus 503, test 4 compares fewer fields, roles has no LIMIT,
3 of 14 mutants survived) go to a follow-up row.

Sage's gate on 3c79809c, sequential and teed: node suites bus 80 and
webui 40, 0 failed; test-auth 15, test-conductor 17, test-config 24,
test-discord 66, test-extension-package 18, test-foundation 44,
test-queue 27, test-release 14, test-task 98, all 0 failed.
958668a8..3c79809c touches none of the 8 paths.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 15:36:52 -05:00
co-authored by Claude Opus 5.5
parent 3c79809ca0
commit f78ba57d70
8 changed files with 298 additions and 18 deletions
+25 -1
View File
@@ -38,6 +38,8 @@ const GATED = new Set([
'role.revoke',
]);
const CLOSED = "('resolved','withdrawn','expired')";
// How many closed decisions `decisions` with list 'recent' returns, newest close first.
export const RECENT = 50;
export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.')));
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([
@@ -466,7 +468,8 @@ export class Broker {
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
return this.#store.transaction(() => {
if (s.reader && !['inbox', 'agents', 'tasks', 'trail'].includes(request.verb)) fail('read-only');
if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions'].includes(request.verb))
fail('read-only');
const result = this.#dispatch(s, request.verb, args);
this.#secretCheck(result);
return result;
@@ -833,6 +836,27 @@ export class Broker {
.map((r) => ({ ...r, fields: JSON.parse(r.fields) }));
return this.#trail(s, identifier(a.subject));
}
// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and
// reader sessions only. Each row is what trail gives the same session for that id.
case 'decisions': {
if (!s.human && !s.reader) fail('human-required');
keys(a, ['list'], ['list']);
if (a.list === 'roles')
return this.#store
.all(
`SELECT d.* FROM decisions d WHERE business=? AND route_to<>'human' AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED}) ORDER BY seq`,
s.business,
)
.map((d) => this.#decisionView(d));
if (a.list === 'recent')
return this.#store
.all(
`SELECT d.* FROM decisions d JOIN decision_events x ON x.decision=d.id AND x.op IN ${CLOSED} WHERE d.business=? ORDER BY x.seq DESC LIMIT ${RECENT}`,
s.business,
)
.map((d) => ({ ...this.#decisionView(d), closed: this.#closed(d.id) }));
fail('invalid-request');
}
default:
fail('unknown-verb');
}
+1
View File
@@ -5,5 +5,6 @@ export function views(client) {
tasks: () => client.call('tasks'),
agents: () => client.call('agents'),
trail: (subject) => client.call('trail', { subject }),
decisions: (list) => client.call('decisions', { list }),
});
}