docs(queue-57): row 57 candidate packet, decisions read for roles and recent (filbert)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 15:00:47 -05:00
co-authored by Claude Opus 5.5
parent ab83bd2bbf
commit fa3cc6e1df
20 changed files with 1153 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
# 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(<decision id>)` 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`.
+1
View File
@@ -0,0 +1 @@
958668a8476250edab8025a48a5c553dfd75f99b
+440
View File
@@ -0,0 +1,440 @@
diff --git a/packages/bus/README.md b/packages/bus/README.md
index fff96910..5b57b7fc 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -196,8 +196,17 @@ must not invoke the human CLI or work around its proof.
Human resolutions and launch toggles append `human.input`. Agent capabilities
and read-only WebUI capabilities cannot reach these operations. Reader
-capabilities expose the human inbox plus tasks/agents/trail for one business;
-they do not confer mutation or resolution authority.
+capabilities expose the human inbox plus tasks/agents/trail/decisions for one
+business; they do not confer mutation or resolution authority.
+
+`decisions` (row 57, #1546) is for human and reader sessions only; a role
+session gets `human-required`, so it can't list another role's open
+decisions. It takes `{list}`, nothing else. `roles` lists the business's open
+decisions routed to a role, never to the human, oldest first. `recent` lists
+its last `RECENT` (50) closed decisions of every route, newest close first,
+each with `closed`, the closing `decision_events` row. Every row is the
+decision as `trail` gives the same session for its id, so the lists add no
+data a reader couldn't already read one id at a time; they add the listing.
## Shared reads and adapter boundary
@@ -207,6 +216,7 @@ import {views} from './src/views.mjs';
const read = views(new Client({path,cap}));
await read.inbox(); await read.tasks(); await read.agents();
await read.trail('vikunja:3/41');
+await read.decisions('roles'); await read.decisions('recent');
```
S4 and S5 must import this client-only module. Neither opens `bus.sqlite`.
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 0ebc8ff8..84db8799 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -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');
}
diff --git a/packages/bus/src/views.mjs b/packages/bus/src/views.mjs
index d161c2cd..2bffda6b 100644
--- a/packages/bus/src/views.mjs
+++ b/packages/bus/src/views.mjs
@@ -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 }),
});
}
diff --git a/packages/bus/tests/decisions.test.mjs b/packages/bus/tests/decisions.test.mjs
new file mode 100644
index 00000000..6626460b
--- /dev/null
+++ b/packages/bus/tests/decisions.test.mjs
@@ -0,0 +1,193 @@
+// Row 57 (#1546): `decisions`, the read behind the Inbox tabs "Routed to roles"
+// and "Recent". Human and reader sessions read it; a role session can't.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { Store } from '../src/store.mjs';
+import { Broker, RECENT } from '../src/broker.mjs';
+
+const options = [
+ { key: 'yes', text: 'Allow' },
+ { key: 'no', text: 'Decline' },
+];
+const roles = {
+ pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
+ cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
+ coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
+};
+const businesses = {
+ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles },
+ other: { id: 'other', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles },
+};
+function setup(t) {
+ const root = mkdtempSync(join(tmpdir(), 'bus-decisions-'));
+ const store = new Store(root);
+ const b = new Broker({ store, businesses });
+ t.after(() => {
+ store.close();
+ rmSync(root, { recursive: true, force: true });
+ });
+ const agent = (role, business = 'demo', run = `${business}-${role}-run`) => {
+ const cap = b.bindLaunch({ business, role, run, harness: 'pi', address: run });
+ b.request(cap, { verb: 'role.claim' });
+ return cap;
+ };
+ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+ const reader = b.bindReader({ business: 'demo' });
+ return { b, store, agent, human, reader };
+}
+const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
+const raise = (b, cap, action, extra = {}) =>
+ call(b, cap, 'decision.raise', { action, question: `${action}?`, options, recommendation: 'no', blocking: false, ...extra });
+const list = (b, cap, which) => call(b, cap, 'decisions', { list: which });
+
+// Open: one routed to cto, one to pm, one to the human. Closed: one within-role.
+function seed(t) {
+ const x = setup(t);
+ const { b, agent } = x;
+ const coder = agent('coder');
+ const cto = agent('cto');
+ const pm = agent('pm');
+ const toCto = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/1' });
+ const toPm = raise(b, coder, 'task.scope.change', { domain: 'delivery', target: 'vikunja:3/2' });
+ const toHuman = raise(b, coder, 'deploy', { target: 'release-1', task_ref: 'vikunja:3/3', blocking: true });
+ const within = raise(b, coder, 'message.send', { choice: 'yes' });
+ assert.deepEqual([toCto.route_to, toPm.route_to, toHuman.route_to, within.route_to], ['cto', 'pm', 'human', 'coder']);
+ return { ...x, coder, cto, pm, toCto, toPm, toHuman, within };
+}
+
+test('roles lists open decisions routed to a role, and never the human inbox', (t) => {
+ const { b, human, reader, toCto, toPm, toHuman } = seed(t);
+ for (const cap of [human, reader]) {
+ const rows = list(b, cap, 'roles');
+ assert.deepEqual(
+ rows.map((r) => [r.id, r.route_to]),
+ [
+ [toCto.id, 'cto'],
+ [toPm.id, 'pm'],
+ ],
+ );
+ // The same view as inbox: options parsed, blocking a boolean, the authorization context.
+ assert.deepEqual(rows[0].options, options);
+ assert.equal(rows[0].blocking, false);
+ assert.deepEqual(rows[0].authorization, { action: 'task.scope.change', target: 'vikunja:3/1', approvalChoice: 'yes' });
+ assert.deepEqual(
+ call(b, cap, 'inbox').map((r) => r.id),
+ [toHuman.id],
+ );
+ }
+});
+
+test('recent lists closed decisions of every route, newest close first, with how each closed', (t) => {
+ const { b, store, human, reader, coder, cto, toCto, toPm, toHuman, within } = seed(t);
+ call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'no', note: 'not today' });
+ call(b, cto, 'decision.resolve', { id: toCto.id, choice: 'yes' });
+ call(b, coder, 'decision.withdraw', { id: toPm.id });
+ const late = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/4' });
+ store.run(
+ "INSERT INTO decision_events(decision,at,op,by) VALUES(?,'2026-10-11T00:00:00.000Z','expired','broker')",
+ late.id,
+ );
+ for (const cap of [human, reader]) {
+ const rows = list(b, cap, 'recent');
+ assert.deepEqual(
+ rows.map((r) => [r.id, r.closed.op, r.closed.by, r.closed.choice, r.closed.via]),
+ [
+ [late.id, 'expired', 'broker', null, null],
+ [toPm.id, 'withdrawn', 'demo-coder-run', null, 'broker'],
+ [toCto.id, 'resolved', 'demo-cto-run', 'yes', 'broker'],
+ [toHuman.id, 'resolved', 'jason', 'no', 'cli'],
+ [within.id, 'resolved', 'demo-coder-run', 'yes', 'broker'],
+ ],
+ );
+ assert.equal(rows[3].closed.note, 'not today');
+ assert.deepEqual(rows[3].options, options);
+ assert.equal(rows[3].blocking, true);
+ assert.deepEqual(list(b, cap, 'roles'), []);
+ }
+});
+
+test('recent stops at RECENT rows', (t) => {
+ const { b, reader, coder } = seed(t);
+ const ids = [];
+ for (let i = 0; i < RECENT; i++) ids.push(raise(b, coder, 'message.send', { choice: 'yes' }).id);
+ const rows = list(b, reader, 'recent');
+ assert.equal(rows.length, RECENT);
+ assert.deepEqual(
+ rows.map((r) => r.id),
+ ids.reverse(),
+ );
+});
+
+test('every row is what trail already gives the same session for that id', (t) => {
+ const { b, human, reader, toHuman } = seed(t);
+ call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'yes' });
+ for (const cap of [human, reader])
+ for (const row of [...list(b, cap, 'roles'), ...list(b, cap, 'recent')]) {
+ const trail = call(b, cap, 'trail', { subject: row.id });
+ const d = trail.find((r) => r.table === 'decisions' && r.id === row.id);
+ assert.ok(d, row.id);
+ for (const k of ['route_to', 'class', 'action', 'question', 'recommendation', 'raised_by_role', 'raised_by_run'])
+ assert.equal(d[k], row[k], k);
+ assert.deepEqual(d.options, row.options);
+ if (row.closed) {
+ const { table, ...closed } = trail.find((r) => r.table === 'decision_events' && r.seq === row.closed.seq);
+ assert.equal(table, 'decision_events');
+ assert.deepEqual(closed, { ...row.closed });
+ }
+ }
+});
+
+test("a role session can't list decisions, so it can't read another role's open decisions", (t) => {
+ const { b, store, agent, coder, cto, pm, toCto, toPm, toHuman } = seed(t);
+ const idle = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-unclaimed', harness: 'pi', address: 'x' });
+ const refusals = () => store.get("SELECT count(*) AS n FROM events WHERE kind='action.refused'").n;
+ for (const cap of [cto, pm, coder, idle])
+ for (const which of ['roles', 'recent', 'bogus']) {
+ const before = refusals();
+ assert.throws(() => list(b, cap, which), /human-required/, which);
+ assert.equal(refusals(), before + 1, 'the refusal is recorded');
+ }
+ // Its own inbox stays its own: cto sees cto's, not pm's or the human's.
+ assert.deepEqual(
+ call(b, cto, 'inbox').map((r) => r.id),
+ [toCto.id],
+ );
+ assert.deepEqual(
+ call(b, pm, 'inbox').map((r) => r.id),
+ [toPm.id],
+ );
+ assert.ok(!call(b, coder, 'inbox').some((r) => r.id === toHuman.id));
+ // A role in another business reads none of demo's either.
+ assert.throws(() => list(b, agent('cto', 'other'), 'roles'), /human-required/);
+});
+
+test('decisions is a read: one business, strict arguments, nothing written', (t) => {
+ const { b, store, human, reader, agent } = seed(t);
+ const coder = agent('coder', 'other');
+ raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:9/1' });
+ raise(b, coder, 'message.send', { choice: 'yes' });
+ const otherReader = b.bindReader({ business: 'other' });
+ assert.equal(list(b, otherReader, 'roles').length, 1);
+ assert.equal(list(b, otherReader, 'recent').length, 1);
+ assert.ok(list(b, otherReader, 'roles').every((r) => r.business === 'other'));
+ assert.ok(list(b, reader, 'recent').every((r) => r.business === 'demo'));
+
+ const count = () =>
+ ['events', 'decisions', 'decision_events', 'messages', 'deliveries', 'role_claims'].map(
+ (table) => store.get(`SELECT count(*) AS n FROM ${table}`).n,
+ );
+ const before = count();
+ for (const cap of [human, reader]) {
+ list(b, cap, 'roles');
+ list(b, cap, 'recent');
+ }
+ assert.deepEqual(count(), before, 'a successful read writes nothing');
+ for (const cap of [human, reader])
+ for (const args of [{}, { list: 'all' }, { list: 'roles', limit: 5 }, { list: ['roles'] }, { list: 'Recent' }])
+ assert.throws(() => call(b, cap, 'decisions', args), /invalid-request/, JSON.stringify(args));
+ // The reader's other limits stand.
+ assert.throws(() => call(b, reader, 'decision.seen', { id: list(b, reader, 'roles')[0].id }), /read-only/);
+});
diff --git a/packages/webui/README.md b/packages/webui/README.md
index 20a9a884..e43118d5 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -193,17 +193,22 @@ configured board URL for the page's error message. No scanner, registration,
session reader or transport is implemented here. The session reader is
`packages/conversation`, served by the board.
-GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents` and
-`/api/bus/trail?subject=<id>` are the bus reads (`src/bus.mjs`). Any other
-method is 405. The subject must match the broker's identifier rule, else 400.
+GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`,
+`/api/bus/trail?subject=<id>` and `/api/bus/decisions?list=roles|recent` are
+the bus reads (`src/bus.mjs`). Any other method is 405. The subject must match
+the broker's identifier rule, and the list must be `roles` or `recent`, else
+400. `decisions` (row 57, #1546) is for the Inbox tabs "Routed to roles" and
+"Recent"; "Human and reader paths" in `packages/bus/README.md` has what each
+list holds.
Each read runs the S4 human transport (`packages/bus/src/human-cli.mjs`) as
a child process, so a slow read doesn't stall the server. The bus checks
that child's ancestry, which ends at the Console process; it never sees the
browser or any other client of the port. So while the Console runs,
anything that can connect to its port (7330 by default) reads what a reader
capability reads ("Human and reader paths" in `packages/bus/README.md`):
-the inbox, tasks, agents and trails. That includes a T3 seat using `curl`
-and a managed S6 session, since S6 doesn't confine the network. No route
+the inbox, tasks, agents, trails and the two decision lists. That includes
+a T3 seat using `curl` and a managed S6 session, since S6 doesn't confine
+the network. No route
writes (Q4), so the exposure is reads only. Slice 1 doesn't change this
(Filbert F1 on #1522). Answers are `{ rows, at }`; failures are `{ error, message }`
with 403 for a refusal, 503 for no bus or no answer, and 502 for an answer
diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs
index 290541ef..8fee21a6 100644
--- a/packages/webui/src/bus.mjs
+++ b/packages/webui/src/bus.mjs
@@ -1,8 +1,9 @@
// Slice 1 S5 (#1522): the Console's read of the bus. It runs the S4 human
// transport (packages/bus/src/human-cli.mjs, lead decision 70) as a child
// with spawn, not spawnSync, so one slow read never stalls the HTTP server.
-// Only the four verbs of the Q1 module (lead decision 56) are reachable:
-// the WebUI writes nothing, not even seen (Q4). The bus does the proof; a
+// Only the four verbs of the Q1 module (lead decision 56) and row 57's
+// `decisions` read are reachable: the WebUI writes nothing, not even seen
+// (Q4). The bus does the proof; a
// server started inside an agent run gets `human-required` from the broker.
import { spawn } from 'node:child_process';
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index 8e89e3ed..4d51942a 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -46,16 +46,22 @@ async function body(req) {
return bytes;
}
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
-const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail'];
+// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs.
+const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions'];
+const DECISION_LISTS = ['roles', 'recent'];
async function busRead(res, bus, verb, search) {
- let subject;
+ let arg;
if (verb === 'trail') {
- subject = new URLSearchParams(search).get('subject');
- if (!subjectOk(subject)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' });
+ arg = new URLSearchParams(search).get('subject');
+ if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' });
+ }
+ if (verb === 'decisions') {
+ arg = new URLSearchParams(search).get('list');
+ if (!DECISION_LISTS.includes(arg)) return json(res, 400, { error: 'invalid-request', message: 'list must be roles or recent' });
}
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
try {
- const rows = await bus[verb](subject);
+ const rows = await bus[verb](arg);
if (!Array.isArray(rows)) throw new BusReadError('invalid-response');
return json(res, 200, { rows, at: new Date().toISOString() });
} catch (err) {
diff --git a/packages/webui/tests/bus.test.mjs b/packages/webui/tests/bus.test.mjs
index b141b5cc..2d9ca6ba 100644
--- a/packages/webui/tests/bus.test.mjs
+++ b/packages/webui/tests/bus.test.mjs
@@ -87,13 +87,52 @@ test('bus routes serve the four reads from the reader view, unescaped JSON for t
assert.deepEqual([...new Set(calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
});
+// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists.
+test('decisions route serves the roles and recent lists from the reader view', async t => {
+ const store = new Store(tmp(t, 'webui-bus-decisions-'));
+ t.after(() => store.close());
+ const b = new Broker({ store, businesses });
+ const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
+ b.request(coder, { verb: 'role.claim' });
+ const raise = args => b.request(coder, { verb: 'decision.raise', args: { options: [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }], recommendation: 'no', blocking: false, ...args } });
+ const routed = raise({ action: 'task.scope.change', domain: 'technical', target: 'vikunja:32/8', question: `Split it? ${HOSTILE}` });
+ const human = raise({ action: 'deploy', target: 'release-1', question: 'Deploy?' });
+ const closed = raise({ action: 'message.send', question: 'Send?', choice: 'yes' });
+ const reader = b.bindReader({ business: 'demo' });
+ const calls = [];
+ const bus = views({ call: async (verb, args = {}) => { calls.push([verb, args]); return structuredClone(b.request(reader, { verb, args })); } });
+ const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
+ t.after(() => close(web));
+ const base = `http://127.0.0.1:${web.address().port}`;
+ const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
+
+ const roles = await read('/api/bus/decisions?list=roles');
+ assert.equal(roles.status, 200); assert.equal(roles.cache, 'no-store');
+ assert.match(roles.body.at, /^\d{4}-\d\d-\d\dT/);
+ assert.deepEqual(roles.body.rows.map(r => [r.id, r.route_to]), [[routed.id, 'cto']]);
+ assert.ok(roles.body.rows[0].question.endsWith(HOSTILE));
+ const recent = await read('/api/bus/decisions?list=recent');
+ assert.deepEqual(recent.body.rows.map(r => [r.id, r.closed.op, r.closed.choice]), [[closed.id, 'resolved', 'yes']]);
+ assert.deepEqual((await read('/api/bus/inbox')).body.rows.map(r => r.id), [human.id]);
+ assert.deepEqual(calls.filter(([v]) => v === 'decisions'), [['decisions', { list: 'roles' }], ['decisions', { list: 'recent' }]]);
+
+ // A missing or unknown list never reaches the bus.
+ const before = calls.length;
+ for (const q of ['', '?list=', '?list=all', '?list=Roles', '?list=roles%00', '?subject=roles'])
+ assert.deepEqual([q, (await read('/api/bus/decisions' + q)).body.error], [q, 'invalid-request']);
+ assert.equal(calls.length, before);
+});
+
test('bus routes are GET-only, have no write verb, and keep the same-origin checks', async t => {
const calls = [];
- const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail'].map(v => [v, async () => { calls.push(v); return []; }]));
+ const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => { calls.push(v); return []; }]));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
- for (const method of ['POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']) assert.equal((await fetch(base + '/api/bus/inbox', { method })).status, 405, method);
+ for (const method of ['POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']) {
+ assert.equal((await fetch(base + '/api/bus/inbox', { method })).status, 405, method);
+ assert.equal((await fetch(base + '/api/bus/decisions?list=roles', { method })).status, 405, method);
+ }
for (const path of ['/api/bus/decide', '/api/bus/seen', '/api/bus/', '/api/bus/inbox/x', '/api/bus/request', '/api/bus/raise']) {
assert.equal((await fetch(base + path)).status, 404, path);
assert.equal((await fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, 404, path);
@@ -176,6 +215,7 @@ test('busReader takes --business, else the live bus host, else refuses', async t
const dataRoot = join(root, 'data');
const pinned = busReader({ dataRoot, socket: 'sock', business: 'pinned', cli });
assert.equal((await pinned.inbox()).request.business, 'pinned');
+ assert.deepEqual((await pinned.decisions('recent')).request, { business: 'pinned', verb: 'decisions', args: { list: 'recent' } });
const reader = busReader({ dataRoot, socket: 'sock', cli });
const code = () => reader.agents().then(() => 'resolved', e => e.code);
@@ -0,0 +1,8 @@
690e9b583d229866f3f02cd23fc6b589a5444d92c19579e9fd631b9f41305e14 packages/bus/README.md
c5b7c6e5f74fd07b619693c6a85caca58ff049db76d6934e75ae22c3fa141053 packages/bus/src/broker.mjs
b7ae4ca62f98caa30fe1300811caef077e0b4758df1d0106fb1717d640c18760 packages/bus/src/views.mjs
052e8ec7e8c5903d6c689702ec0545094426093a2e36f5113ec16c9f9183d95e packages/bus/tests/decisions.test.mjs
df68ec0a53ead6b82636c2b86c86714306ecc3e848c3e3a44fd1e825b949f291 packages/webui/README.md
1b9d39dec208d1c987fe9b1fc921a61cdbbb2a2d2bcb3796a2bf422714be24b5 packages/webui/src/bus.mjs
d0d4be581cef0e25b7240457a757ed8bb41eaee21599e1f7b233ab2853f90601 packages/webui/src/serve.mjs
a449e0d9e69129ce3dabe479d842a7ac95d5311aad999dee96e46b1914c86140 packages/webui/tests/bus.test.mjs
+8
View File
@@ -0,0 +1,8 @@
packages/bus/README.md
packages/bus/src/broker.mjs
packages/bus/src/views.mjs
packages/bus/tests/decisions.test.mjs
packages/webui/README.md
packages/webui/src/bus.mjs
packages/webui/src/serve.mjs
packages/webui/tests/bus.test.mjs
@@ -0,0 +1,88 @@
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (233.094409ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (373.024636ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (1078.360956ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (2805.481771ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (720.907418ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (602.634427ms)
✔ task action subjects and linked decision trail are complete and ordered (430.211921ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (169.171298ms)
✔ business isolation includes inherited object names and cross-business message references (352.708355ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (444.315056ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (209.464843ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (324.820708ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (333.421489ms)
✔ both arbiters require human resolution when their cross-role route is themselves (368.354325ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (3.445468ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (10.247728ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (4.89508ms)
✔ expiry refuses use and env references never become client data (11.201998ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (2.643114ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.549213ms)
✔ roles lists open decisions routed to a role, and never the human inbox (331.252577ms)
✔ recent lists closed decisions of every route, newest close first, with how each closed (439.195ms)
✔ recent stops at RECENT rows (5202.300816ms)
✔ every row is what trail already gives the same session for that id (449.42969ms)
✔ a role session can't list decisions, so it can't read another role's open decisions (658.481846ms)
✔ decisions is a read: one business, strict arguments, nothing written (568.907077ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (195.475135ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (216.16421ms)
✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (223.815301ms)
✔ endLaunch leaves a claim another run took alone (266.348358ms)
✔ refuse records action.refused against the caller with the code only (2304.731396ms)
✔ launches off refuses role.launch with launch-revoked until launches on (1833.471203ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (784.153703ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (4.507649ms)
✔ process reader gets own kernel identity without exposing environment values (1.287823ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (3.315028ms)
✔ real pid 1 remains inspectable when its environment is protected (0.799191ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (319.822513ms)
✔ missing and foreign-business citations refuse and roll back message and grant (265.374166ms)
✔ cross-role sends still need a matching resolved decision and consume it once (964.86985ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (292.738892ms)
✔ startup token refusal returns safe code without value or partial listening broker (54.454834ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (263.557218ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (499.060254ms)
✔ trusted host registers later launches; socket clients never have a registration verb (2536.851756ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (356.20387ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (1117.970934ms)
✔ v3b prototype refusals, views and append-only mutations (5243.086738ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (386.916491ms)
✔ another run cannot consume an approval; a failed check leaves it usable (337.214319ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (751.934799ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (3616.852713ms)
✔ class drift gated to cross-role refuses before consumption (638.164117ms)
✔ class drift cross-role to gated refuses before consumption (524.076534ms)
✔ class drift gated to within-role refuses before consumption (384.467028ms)
✔ class drift cross-role to within-role refuses before consumption (380.410298ms)
✔ class drift within-role to gated refuses before consumption (320.484502ms)
✔ class drift within-role to cross-role refuses before consumption (301.809385ms)
✔ message.send consumes approval and prevents a later send or authorize (465.422414ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (385.859952ms)
✔ creates private WAL store and excludes a second writer until explicit close (197.610501ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (248.321479ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (314.800877ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (2159.568549ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (1346.65285ms)
✔ async transactions refuse before invoking their function (711.543159ms)
✔ recordTask keeps sync reads and a role write apart (265.085426ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (148.434209ms)
✔ a bad entry refuses the whole record (160.806142ms)
✔ taskView reads the projection for one business (209.017213ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (3479.055075ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (1176.287241ms)
✔ without an adapter the server refuses every task verb (658.484711ms)
✔ the runtime refuses an invalid adapter and closes a valid one (422.552128ms)
✔ the process loads the S3 adapter from plain-data trackers (531.329288ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (218.007537ms)
✔ two wire claims serialize; a lost reply never automatically retries (243.872356ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (181.884844ms)
✔ client preserves UTF-8 when a response divides a multibyte character (13.706377ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (548.0716ms)
ℹ tests 80
ℹ suites 0
ℹ pass 80
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 8653.757066
@@ -0,0 +1,49 @@
✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (10969.834809ms)
Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286}
✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (26960.907129ms)
✔ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes (22692.244104ms)
✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (540.466922ms)
✔ decisions route serves the roles and recent lists from the reader view (495.652478ms)
✔ bus routes are GET-only, have no write verb, and keep the same-origin checks (264.780998ms)
✔ bus refusals map to statuses and never carry a path or a stack (64.517399ms)
✔ humanCall speaks the human transport protocol without blocking the server (2475.443577ms)
✔ busReader takes --business, else the live bus host, else refuses (521.362418ms)
✔ humanCall kills a transport that runs past its timeout (558.840546ms)
✔ serve refuses a --business value that is not a business id (305.329249ms)
✔ conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers (18560.243478ms)
✔ conversation view: a fork keeps the open branch, says so, and opens the new one on request (5658.200446ms)
✔ conversation view: a newer session with no readable history keeps the marker (5085.577477ms)
✔ conversation view: seats without history say so and offer no reply (5597.344603ms)
✔ Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content (10572.359237ms)
✔ return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch (64391.819731ms)
✔ redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote (3.267785ms)
✔ credential state from the date alone, as the bus states it (0.477653ms)
✔ queue: rows, one row, ids and methods, notes; no path or id from a row (2293.473775ms)
✔ queue: a refused read fails closed with the store's text, redacted (2068.310913ms)
✔ queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack (5717.973112ms)
✔ business: names, vars on the allowlist, authority, credential metadata only (48.950144ms)
✔ business: missing or invalid file refuses with the module's text, redacted (74.880382ms)
✔ business without --business: the live bus host, else no-bus-host; no system config: not-configured (23.654078ms)
✔ settings: release, board, loopback address, notifier binding name only (1413.526676ms)
✔ both presentations replace old activity with relaunch notice, label retained history, then resume after new activity (11967.031582ms)
✔ reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh (32919.117251ms)
✔ loopback host and board origin fail closed (15.068546ms)
✔ real board fixture passes through WebUI; assets and isolated seen/reply work (162.240605ms)
✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (70.022971ms)
✔ unreachable board reports URL; CLI rejects unsupported options (1578.434888ms)
✔ tokens.css and icons.svg are byte copies of docs/design (7.201918ms)
✔ JetBrains Mono files match the digests in their sources file, with the OFL text (7.017662ms)
✔ shipped strings are unchanged, and the web keeps its write boundary (2.457525ms)
✔ shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette (18840.161593ms)
✔ five states on Board, Inbox, Tasks, Agents and Trail (7644.99647ms)
✔ Queue, Business and Settings in their five states, with no move button (14380.510749ms)
✔ seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module's words (7408.726728ms)
✔ the server log names no config path when the system config refuses (322.369609ms)
ℹ tests 40
ℹ suites 0
ℹ pass 40
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 64983.754866
@@ -0,0 +1,12 @@
node-bus exit 0 ℹ pass 80 ℹ fail 0
node-webui exit 0 ℹ pass 40 ℹ fail 0
test-auth exit 0 selftest: 15 passed, 0 failed
test-conductor exit 0 selftest: 17 passed, 0 failed
test-config exit 0 selftest: 24 passed, 0 failed
test-discord exit 0 discord suite: 66 passed, 0 failed
test-extension-package exit 0 extension package selftest: 18 passed, 0 failed
test-foundation exit 0 selftest: 44 passed, 0 failed
test-queue exit 0 queue suite: 27 passed, 0 failed
test-release exit 0 selftest: 4 passed, 0 failed
test-task exit 0 selftest: 26 passed, 0 failed
done
@@ -0,0 +1,17 @@
OK status with missing harness credential exits 3 and still lists accounts
OK status reports harness credential (read-only) + mosaic accounts
OK api key material never reaches output
OK oauth token material never reaches output
OK unparseable credential file exits 2
OK symlinked credential file exits 4
OK env-side credential names reported
OK env var values never reach output
OK accounts without an accounts dir reports none and creates nothing
OK accounts lists files and marks the active one
OK loose account perms flagged in listing
OK agent --auth with missing account file refuses (exit 4)
OK agent --auth with non-0600 account file refuses
OK agent --auth with invalid account name refuses
OK auth.sh without valid config refuses
selftest: 15 passed, 0 failed
@@ -0,0 +1,23 @@
On branch refactor
Your branch is up to date with 'origin/refactor'.
nothing to commit, working tree clean
OK dry-run: allowed change, exit 0, nothing committed (exit 0)
OK dry-run committed nothing
OK apply: allowed change exits 0 (exit 0)
OK apply: attribution in commit subject
OK apply: target tree clean after commit
OK disallowed path refused (exit 1)
OK disallowed path: target untouched
OK syntax gate refused broken .mjs (exit 1)
OK syntax gate: target untouched
OK suite failure refused (exit 1)
OK suite failure: target reverted to clean
OK disabled policy refused (exit 2)
OK disabled policy: target untouched
OK failed run refused (exit 1)
OK failed run: target untouched
OK missing run exits 4 (exit 4)
OK invalid policy exits 2 (exit 2)
selftest: 17 passed, 0 failed
@@ -0,0 +1,26 @@
OK absent adapter defaults to pi
OK adapter mock validates (exit 0)
OK unsupported adapter exits 2 (exit 2)
OK env exports adapter
OK bootstrap creates default when absent (exit 0)
OK bootstrap wrote config file
OK bootstrap is idempotent on existing config (exit 0)
OK bootstrap did not rewrite existing config
OK validate missing config exits 3 (exit 3)
OK malformed JSON exits 2 (exit 2)
OK unsupported configVersion exits 2 (exit 2)
OK unknown top-level key exits 2 (exit 2)
OK unknown execution key exits 2 (exit 2)
OK unsupported backend exits 2 (exit 2)
OK unsupported environment exits 2 (exit 2)
OK relative dataRoot exits 2 (exit 2)
OK non-canonical dataRoot exits 2 (exit 2)
OK filesystem root dataRoot exits 2 (exit 2)
OK home directory dataRoot exits 2 (exit 2)
OK dataRoot containing config dir exits 2 (exit 2)
OK control character in provider exits 2 (exit 2)
OK symlinked config file exits 2 (exit 2)
OK env exports resolve correctly
OK failed validation modified nothing
selftest: 24 passed, 0 failed
@@ -0,0 +1,70 @@
toolchain: node v26.8.1
OK syntax: packages/discord/src/approvals.mjs
OK syntax: packages/discord/src/authorize.mjs
OK syntax: packages/discord/src/binding.mjs
OK syntax: packages/discord/src/cli.mjs
OK syntax: packages/discord/src/connector.mjs
OK syntax: packages/discord/src/context.mjs
OK syntax: packages/discord/src/engine-pi.mjs
OK syntax: packages/discord/src/errors.mjs
OK syntax: packages/discord/src/gateway.mjs
OK syntax: packages/discord/src/git.mjs
OK syntax: packages/discord/src/journal.mjs
OK syntax: packages/discord/src/notify.mjs
OK syntax: packages/discord/src/rest.mjs
OK syntax: packages/discord/src/setspark.mjs
OK syntax: packages/discord/src/tools.mjs
OK syntax: packages/discord/src/web.mjs
OK syntax: packages/discord/bin/git-credential.mjs
OK syntax: packages/discord/extension/tools.mjs
OK syntax: packages/discord/tests/approvals.test.mjs
OK syntax: packages/discord/tests/authorize.test.mjs
OK syntax: packages/discord/tests/binding.test.mjs
OK syntax: packages/discord/tests/connector.test.mjs
OK syntax: packages/discord/tests/context.test.mjs
OK syntax: packages/discord/tests/engine.test.mjs
OK syntax: packages/discord/tests/fake-pi.mjs
OK syntax: packages/discord/tests/gateway.test.mjs
OK syntax: packages/discord/tests/git.test.mjs
OK syntax: packages/discord/tests/helpers.mjs
OK syntax: packages/discord/tests/journal.test.mjs
OK syntax: packages/discord/tests/notify.test.mjs
OK syntax: packages/discord/tests/recover.test.mjs
OK syntax: packages/discord/tests/rest.test.mjs
OK syntax: packages/discord/tests/setspark.test.mjs
OK syntax: packages/discord/tests/tools.test.mjs
OK syntax: packages/discord/tests/web.test.mjs
OK syntax: packages/discord/fixtures/claim-worker.mjs
OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs
OK syntax: scripts/discord.sh
OK syntax: scripts/discord-service.sh
OK packages/discord declares no dependencies
OK no bot-token-shaped string in packages/discord
OK fixture binding uses placeholder ids only
OK fixture binding validates
OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool
OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start
OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root
OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token
OK real pi with protocol vault adds reserve_id to the git verbs
OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key
OK real pi refuses a git key on a read-only root (fail closed)
OK real pi with the pilot flags (--no-tools) exposes no tool at all
OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed)
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
OK node --test packages/discord/tests/ (ℹ pass 178)
OK scripts/discord.sh --help exits 0
OK scripts/discord.sh check without a binding exits 4
OK scripts/discord.sh recover without a binding exits 4
OK scripts/discord.sh reload without a binding exits 4
OK scripts/discord-service.sh without a command exits 4
OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP
OK service install writes the rendered unit (0644) and leaves no temp file
OK service install a second time reports unchanged
OK systemd-analyze verify accepts the rendered unit
OK service uninstall removes the unit file
OK service install with an unknown flag exits 4
OK service install with USER unset finishes and names the account for lingering
discord suite: 66 passed, 0 failed
@@ -0,0 +1,21 @@
OK initial ordinary-file install
OK installed tree matches canonical source
OK installed tree has no symlinks
OK check detects installation drift
OK sync refuses to overwrite installation drift
OK check detects an extra destination file
OK check detects an extra destination directory
OK check rejects a destination symlink
OK sync accepts a canonical source update
OK updated installation matches canonical source
scripts/test-extension-package.sh: line 14: 2588080 Killed "$@" > /dev/null 2>&1
OK forced interruption kills the replacing process
OK next invocation recovers old consistent installation
OK interrupted replacement rolled back
OK sync succeeds after interruption recovery
OK unlocked stale lock file does not block
OK active lock refuses a concurrent sync
OK source symlink fails closed
OK nested second entrypoint fails closed
extension package selftest: 18 passed, 0 failed
@@ -0,0 +1,53 @@
toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0
OK syntax: scripts/foundation-inspect.mjs
OK syntax: scripts/foundation/strict-json.mjs
OK syntax: scripts/foundation/canonical.mjs
OK syntax: scripts/foundation/resolve.mjs
OK syntax: scripts/foundation/validate-record.mjs
OK syntax: scripts/foundation/fixtures/build-fixtures.mjs
OK syntax: scripts/foundation/canonical.test.mjs
OK syntax: scripts/foundation/cli.test.mjs
OK syntax: scripts/foundation/fixtures.test.mjs
OK syntax: scripts/foundation/resolve.test.mjs
OK syntax: scripts/foundation/strict-json.test.mjs
OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written)
OK fixture generator runs
OK checked-in fixtures/bundles equal a fresh generation
OK checked-in fixtures/raw equal a fresh generation
OK checked-in fixtures/index.json equal a fresh generation
OK checked-in demo bundles equal a fresh generation
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
OK node --test scripts/foundation/ (ℹ pass 80)
OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof)
platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999)
node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases)
schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10
profile column (schema-valid records only): profile-valid 510, profile-invalid 30
profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns
OK oracle: zero schema-column disagreements with the pinned checker
OK oracle: strict-only profile refusals are counted and asserted
OK demo: permitted read preview exits 0 (exit 0)
OK demo: permitted file.change preview exits 0 (exit 0)
OK demo: assignment.change proposal is unresolved (exit 3) (exit 3)
OK demo: revoked registration is refused (exit 3) (exit 3)
OK demo: message is not authority (exit 3) (exit 3)
OK usage: no arguments exits 2 (exit 2)
OK io: missing file exits 4 (exit 4)
OK io: directory exits 4 (exit 4)
OK io: symlink exits 4 (O_NOFOLLOW) (exit 4)
OK bound: oversize fixture exits 2 (exit 2)
OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2)
OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2)
OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0)
OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed
OK text output starts with the disclaimer
OK json output is valid JSON with result allowed and exactly the charter §7 fields
OK json golden matches byte-for-byte
OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal
OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs
OK canary never printed (bundle run and credential-file run)
OK a non-bundle JSON file is refused at the shape gate, not read into output
OK no field of the non-bundle file is echoed
selftest: 44 passed, 0 failed
@@ -0,0 +1,35 @@
toolchain: node v26.8.1, git version 2.55.0
OK syntax: packages/queue/src/cli.mjs
OK syntax: packages/queue/src/errors.mjs
OK syntax: packages/queue/src/io.mjs
OK syntax: packages/queue/src/lock.mjs
OK syntax: packages/queue/src/queue.mjs
OK syntax: packages/queue/src/review.mjs
OK syntax: packages/queue/src/store.mjs
OK syntax: packages/queue/tests/commit.test.mjs
OK syntax: packages/queue/tests/data.test.mjs
OK syntax: packages/queue/tests/dispatch.test.mjs
OK syntax: packages/queue/tests/helpers.mjs
OK syntax: packages/queue/tests/lock.test.mjs
OK syntax: packages/queue/tests/migration.test.mjs
OK syntax: packages/queue/tests/review.test.mjs
OK syntax: packages/queue/tests/store.test.mjs
OK syntax: packages/queue/tests/write.test.mjs
OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs
OK syntax: packages/queue/tests/fixtures/kill-at.mjs
OK syntax: packages/queue/tests/fixtures/lock-child.mjs
OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh
OK syntax: scripts/queue-commit.sh
OK syntax: scripts/git-hooks/pre-commit
OK syntax: scripts/mosaic
OK queue-commit.sh, the guard and scripts/mosaic are executable
OK packages/queue declares no dependencies
ℹ tests 149
ℹ pass 149
ℹ fail 0
OK node --test packages/queue/tests/
OK scripts/mosaic queue help
skip queue verify and render --check: this checkout (/home/jwoltje/filbert-scratch/r57) is not the queue's canonical root (/mnt/storage/src/mosaic-stack)
queue suite: 27 passed, 0 failed
@@ -0,0 +1,7 @@
OK valid RELEASE resolves (exit 0)
OK invalid RELEASE exits 1 (exit 1)
OK missing RELEASE exits 1 (exit 1)
OK valid RELEASE leaves image tag consistent with version
skip state-machine cases (docker daemon unavailable)
selftest: 4 passed, 0 failed
@@ -0,0 +1,32 @@
OK valid task validates (exit 0)
OK unknown task key exits 2 (exit 2)
OK unsupported taskVersion exits 2 (exit 2)
OK invalid task id exits 2 (exit 2)
OK empty prompt exits 2 (exit 2)
OK NUL in expectExact exits 2 (exit 2)
OK out-of-range timeout exits 2 (exit 2)
OK missing mission file exits 4 (exit 4)
OK task with valid mission validates (exit 0)
OK invalid mission exits 2 (exit 2)
OK validate missing task exits 4 (exit 4)
OK validation does not modify the task file
OK prune dry-run exits 0 (exit 0)
OK dry-run deleted nothing
OK prune --keep=2 --yes removes oldest (exit 0)
OK kept exactly 2 newest runs
OK newest run kept, oldest pruned
OK append-only receipt written (3 entries)
OK sessions/workspaces untouched by prune
OK prune with invalid keep exits 4 (exit 4)
skip adapter seam cases (docker daemon unavailable)
skip workspace/capability cases (docker daemon unavailable)
skip live task cases (docker unavailable)
OK onboard without name exits 4 (non-interactive) (exit 4)
OK onboard --name renders profile (exit 0)
OK profile written
OK canon structure: required filled, optional placeholdered
OK canon sections present
skip live recall pair: user recall run, recalled user name (docker daemon unavailable)
OK no agent identity on headless run
selftest: 26 passed, 0 failed
@@ -0,0 +1,3 @@
roles-with-human: bus rc=1 fail=1 roles lists open decisions routed to a role, and never the human inbox ; webui rc=1 fail=1 decisions route serves the roles and recent lists from the reader view
roles-other-business: bus rc=1 fail=1 decisions is a read: one business, strict arguments, nothing written ; webui rc=0 fail=0
done
@@ -0,0 +1,18 @@
role-allowed: bus rc=1 fail=1 a role session can't list decisions, so it can't read another role's open decisions ; webui rc=0 fail=0
roles-with-human: bus rc=1 fail=1 roles lists open decisions routed to a role, and never the human inbox ; webui rc=1 fail=undefined decisions route serves the roles and recent lists from the reader view | conversation view: a newer session with no readable history keeps the marker | return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch | reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh
roles-with-closed: bus rc=1 fail=3 roles lists open decisions routed to a role, and never the human inbox | recent lists closed decisions of every route, newest close first, with how each closed | decisions is a read: one business, strict arguments, nothing written ; webui rc=1 fail=1 decisions route serves the roles and recent lists from the reader view
roles-other-business: PATTERN NOT UNIQUE
recent-other-business: bus rc=1 fail=1 decisions is a read: one business, strict arguments, nothing written ; webui rc=0 fail=0
recent-oldest-first: bus rc=1 fail=2 recent lists closed decisions of every route, newest close first, with how each closed | recent stops at RECENT rows ; webui rc=0 fail=0
recent-by-raise: bus rc=1 fail=1 recent lists closed decisions of every route, newest close first, with how each closed ; webui rc=0 fail=0
recent-no-limit: bus rc=1 fail=1 recent stops at RECENT rows ; webui rc=0 fail=0
recent-limit-plus-one: bus rc=1 fail=1 recent stops at RECENT rows ; webui rc=0 fail=0
recent-resolved-only: bus rc=1 fail=1 recent lists closed decisions of every route, newest close first, with how each closed ; webui rc=0 fail=0
recent-no-closed: bus rc=1 fail=1 recent lists closed decisions of every route, newest close first, with how each closed ; webui rc=1 fail=1 decisions route serves the roles and recent lists from the reader view
loose-keys: bus rc=1 fail=1 decisions is a read: one business, strict arguments, nothing written ; webui rc=0 fail=0
unknown-list-empty: bus rc=1 fail=1 decisions is a read: one business, strict arguments, nothing written ; webui rc=0 fail=0
reader-refused: bus rc=1 fail=5 roles lists open decisions routed to a role, and never the human inbox | recent lists closed decisions of every route, newest close first, with how each closed | recent stops at RECENT rows | every row is what trail already gives the same session for that id ; webui rc=1 fail=1 decisions route serves the roles and recent lists from the reader view
web-no-list-check: bus rc=0 fail=0 ; webui rc=1 fail=1 decisions route serves the roles and recent lists from the reader view
web-no-verb: bus rc=0 fail=0 ; webui rc=1 fail=2 decisions route serves the roles and recent lists from the reader view | bus routes are GET-only, have no write verb, and keep the same-origin checks
views-fixed-list: bus rc=0 fail=0 ; webui rc=1 fail=2 decisions route serves the roles and recent lists from the reader view | busReader takes --business, else the live bus host, else refuses
done
@@ -0,0 +1,64 @@
// Filbert's row 57 mutants: each edits one file in the mutant worktree, runs
// the bus and webui suites, and restores the file. Usage: node run.mjs <worktree>
import { readFileSync, writeFileSync, appendFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
const W = process.argv[2];
const OUT = new URL('.', import.meta.url).pathname;
const B = 'packages/bus/src/broker.mjs';
const S = 'packages/webui/src/serve.mjs';
const V = 'packages/bus/src/views.mjs';
const mutants = [
['role-allowed', B, "if (!s.human && !s.reader) fail('human-required');", ''],
['roles-with-human', B, "AND route_to<>'human' AND", 'AND'],
['roles-with-closed', B, ` AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN \${CLOSED}) ORDER BY seq`, ' ORDER BY seq'],
['roles-other-business', B, "WHERE business=? AND route_to<>'human'", "WHERE ? IS NOT NULL AND route_to<>'human'"],
['recent-other-business', B, 'WHERE d.business=? ORDER BY x.seq DESC', 'WHERE ? IS NOT NULL ORDER BY x.seq DESC'],
['recent-oldest-first', B, 'ORDER BY x.seq DESC LIMIT', 'ORDER BY x.seq ASC LIMIT'],
['recent-by-raise', B, 'ORDER BY x.seq DESC LIMIT', 'ORDER BY d.seq DESC LIMIT'],
['recent-no-limit', B, ' LIMIT ${RECENT}`', '`'],
['recent-limit-plus-one', B, ' LIMIT ${RECENT}`', ' LIMIT ${RECENT + 1}`'],
['recent-resolved-only', B, `JOIN decision_events x ON x.decision=d.id AND x.op IN \${CLOSED}`, `JOIN decision_events x ON x.decision=d.id AND x.op IN ('resolved')`],
['recent-no-closed', B, '({ ...this.#decisionView(d), closed: this.#closed(d.id) })', '({ ...this.#decisionView(d) })'],
['loose-keys', B, "keys(a, ['list'], ['list']);\n if (a.list === 'roles')", "keys(a, ['list', 'limit'], ['list']);\n if (a.list === 'roles')"],
['unknown-list-empty', B, " .map((d) => ({ ...this.#decisionView(d), closed: this.#closed(d.id) }));\n fail('invalid-request');", " .map((d) => ({ ...this.#decisionView(d), closed: this.#closed(d.id) }));\n return [];"],
['reader-refused', B, "'trail', 'decisions'].includes(request.verb))", "'trail'].includes(request.verb))"],
['web-no-list-check', S, "if (!DECISION_LISTS.includes(arg)) return", 'if (false) return'],
['web-no-verb', S, "const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions'];", "const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail'];"],
['views-fixed-list', V, "decisions: (list) => client.call('decisions', { list }),", "decisions: () => client.call('decisions', { list: 'roles' }),"],
];
const suites = [
['bus', ['--test', 'packages/bus/tests/*.test.mjs'], W],
['webui', ['--test', 'tests/'], join(W, 'packages/webui')],
];
const env = { ...process.env, TMPDIR: process.env.HOME + '/filbert-scratch/tmp', DOCKER_HOST: 'unix:///nonexistent.sock' };
const log = join(OUT, process.argv[4] || 'result.txt');
writeFileSync(log, '');
const only = process.argv[3] ? process.argv[3].split(',') : null;
for (const [name, file, from, to] of mutants) {
if (only && !only.includes(name)) continue;
const path = join(W, file);
const orig = readFileSync(path, 'utf8');
if (orig.split(from).length !== 2) {
appendFileSync(log, `${name}: PATTERN NOT UNIQUE\n`);
continue;
}
writeFileSync(path, orig.replace(from, to));
const parts = [];
try {
for (const [label, args, cwd] of suites) {
const r = spawnSync('node', args, { cwd, env, encoding: 'utf8', timeout: 600000 });
const out = r.stdout + r.stderr;
writeFileSync(join(OUT, `${name}.${label}.log`), out);
const fail = (out.match(/^ℹ fail (\d+)/m) || [])[1];
const fails = [...out.matchAll(/^\s*✖ (.+?) \(\d/gm)].map((m) => m[1]).filter((t) => !/^tests?\//.test(t));
parts.push(`${label} rc=${r.status} fail=${fail} ${[...new Set(fails)].slice(0, 4).join(' | ')}`);
}
} finally {
writeFileSync(path, orig);
}
appendFileSync(log, `${name}: ${parts.join(' ; ')}\n`);
}
appendFileSync(log, 'done\n');