docs: Researcher row 123 round 2 packet (#1617, F1 fix, candidate on eaeb5ebf2 + row 49 r1)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
2026-10-15 12:05:43 -05:00
co-authored by Claude Sonnet 5.5
parent be2ea5f7cc
commit b959160f9a
91 changed files with 4669 additions and 0 deletions
@@ -0,0 +1,244 @@
# Row 123 (#1617), round 2: the chief of staff ends runs and changes the organization file
Seat: Researcher. Reviewer: Filbert (broker authority). Gate owner: Sage.
Requirements: REQ-ROLE-2, REQ-ROLE-3, REQ-LIMIT-1, REQ-COS-1.
Brief: `docs/plans/2026-10-14_prd-1.2-build.md` build step 6.
Round 1 (candidate c8bf62fb, packet commit 2209ac4c1, `../`) is left as committed. This directory is round 2.
**Base: eaeb5ebf2 plus row 49 r1 candidate 87ef1321a1a08be64a20499889fc24b5549b47d6d33abfd86db95f62fb3cb6d0**
(Rocko's packet `agents/rocko/work/queue-49/r1/`, `build.patch` applied uncommitted).
Round 1 was built on 0bdd573ff; row 131 (plugins) landed as eaeb5ebf2 since, so I rebased. Both patches apply cleanly.
`row123.patch` is a diff of my changes only, against base + row 49 r1 (13 files, +721 −34).
It applies with `git apply --cached --check` on that tree, and the result round-trips to the candidate tree
(`7a8092d2974b6d8046c8326784cc494b037c5491`; base + row 49 r1 is `73a4929ca7d5bbfe5dbea988acbeb8bfdd33ef79`).
Candidate manifest `candidate-manifest.sha256` (13 files): file sha256 `255a960582f1018b8d400e8d193e8e95713a078930277652d59ef4153197d9f6`.
Nothing is committed in the worktree, nothing is pushed, and no live store, unit or `~/.mosaic` file was touched.
No candidate path changed between eaeb5ebf2 and canonical HEAD be2ea5f7c (those commits touch docs and the queue only).
## Delta round 1 → round 2 (for Filbert)
`delta-r1-to-r2.patch` is round 1's `row123.patch` applied to the new base, diffed against round 2: **3 files, +39 −3**.
Four candidate files differ from the round 1 manifest by content (these three plus `packages/business/README.md`, which
only differs because row 131 changed the neighbouring text in the base).
| File | Change |
|---|---|
| `packages/bus/src/broker.mjs` | **F1.** `organization.set` from a role checks the grant (`#checkAuthority`) before the writer and consumes it (`#consumeAuthority`, which logs `action.allowed`) only after the writer returns |
| `packages/bus/tests/chief-of-staff.test.mjs` | `setup()` gains a failing-writer switch and a `refused()` reader; one new test (13 tests now, was 12) |
| `packages/bus/README.md` | the stray blank line after the `organization.set` row is gone, so the table is one table again |
### F1: a refused change must not leave `action.allowed`
Round 1 consumed `organization.change` before calling the writer. The `limits`/`limits.set`/`organization.set` branch runs
outside the store transaction that wraps most verbs, so when the writer refused (stale revision, invalid value, writer
failure) the consume had already committed: the log showed `action.allowed` followed by `action.refused`. A refused human
`limits.set` leaves `action.refused` alone. Now the chief's refused change leaves the same trace.
If the grant check fails, nothing is consumed and the writer is not reached. If the writer throws, the error is mapped as
before (`revision-conflict`, `organization-*`, `invalid-request`, `outcome-unknown`) and the consume line is never reached.
Only a committed change logs `action.allowed`.
Test (`packages/bus/tests/chief-of-staff.test.mjs:195`): **`REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set`**.
It makes the stub writer throw a revision conflict, a validation error and a plain failure, and sends each as `chief organization.set`
and as `human limits.set`. It asserts the same code for both, `action.allowed` for `organization.change` is empty, and the
`action.refused` events are exactly the expected `(actor_role, code)` pairs (a human has `actor_role` null), plus a refusal
before the writer (a key outside the write path). Then it lets the writer succeed and asserts exactly one `action.allowed`
and no further refusal. Mutant **m22** (consume before the writer, the round 1 behaviour) fails it. Mutants m9, m14 and m17
also fail it now.
Known edge, not handled: the consume is a second transaction, so if it fails after the writer committed (disk error), the change
stands and the receipt names the chief, but no `action.allowed` was logged. The receipt in `changes.jsonl` is the evidence in that
case. Making both atomic would mean the writer joins the store transaction, which is a different design. I did not do it.
### Control characters in `reason`: not refused, my call, and the tradeoff
Filbert's note on control characters in the `role.revoke` reason. I did not add a rule. The bus `text()` helper refuses only NUL
for every free-text field (messages, decision text, notes). The event log stores the body as escaped JSON, so a newline or an
escape byte in `reason` cannot forge a log line or break a record. A rule for `reason` alone would make one field stricter than
all the others for no log-integrity gain. **Tradeoff:** a chief's reason may contain a newline or a terminal escape sequence, and a
reader that prints the stored text raw (a terminal tail of `role_claims.reason`, say) shows it raw. Anything rendering free text
has to escape it, as it must for message and note text today. If the owner wants a stricter shared rule, it belongs in `text()`
for every free-text field, in its own row. No test and no mutant for this.
### role-end-refused after refusals
Filbert asked about no `action.allowed` after a `role-end-refused`. That assertion is already there:
`chief-of-staff.test.mjs:171` (`allowed('role.end').length === 0` after the self and assistant refusals). Nothing added.
## What the row does (unchanged from round 1)
1. **`role.end`**: a new action. A role whose file grants it (only the chief of staff) ends another role's run with
`role.revoke {role, reason}`: a non-empty `reason` of at most 500 characters and **no `decision`**. The reason is
written to `role_claims.reason` and to the `action.allowed` event body (`class: within-role`, `reason`).
`role.revoke` itself stays gated for every role (decision 46), so any other role still needs a resolved decision.
2. **`organization.change`**: a new action. A role whose file grants it calls the new broker verb
`organization.set`, which takes exactly the keys of row 122's write path and goes through the same
`launchSettings.set` writer the human's `limits.set` uses (same validation, same history receipt).
3. Schema 3c → 3d: the `role_revoke_needs_decision` trigger accepts a revoke with a reason and no decision.
4. The broker has no role-name checks for the grants. The grant lives in `roles/chief-of-staff.json`.
The only name the broker knows is `END_PROTECTED = ['assistant']` (a definition name) in the refusal below.
## Files
| File | Change |
|---|---|
| `packages/business/src/vocabulary.mjs` | `role.end`, `organization.change` added to the action vocabulary |
| `roles/chief-of-staff.json` | `withinRole`: `message.send`, `role.launch`, `role.end`, `organization.change`. `role.revoke` is not granted |
| `packages/bus/src/broker.mjs` | `ACTIONS` gets both. `ORGANIZATION_KEYS` (one constant, line 99). `organization.set` beside `limits`/`limits.set`, check-then-write-then-consume (F1). The `role.revoke` reason form. `#claimEnd` stores the reason |
| `packages/bus/schema.sql` | trigger `WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL`; sha256 `e4f1de9e…39b5` |
| `packages/bus/src/migrations/3c-3d.sql` (new) | `DROP TRIGGER` + `CREATE TRIGGER` with the new text, nothing else |
| `packages/bus/src/store.mjs` | `SCHEMA_SHA256`, `SCHEMA_VERSION = '3d'`, one more entry in Rocko's `migrations` array |
| `packages/bus/tests/fixtures/schema-3c.sql` (new) | Rocko's schema.sql as it stands in row 49 r1 |
| `packages/bus/tests/comment-migration.test.mjs` | **Eight lines in a row 49 file, with the reason: see below** |
| `packages/bus/tests/chief-of-staff.test.mjs` (new) | 13 tests |
| `packages/business/tests/chief-of-staff.test.mjs` | approved list gains the two actions; `role.revoke` asserted gated |
| `packages/harness/tests/tools.test.mjs` | the chief's `untypedActions` is now `["role.end", "organization.change"]` (Sage accepted, sage-0317; see Follow-ups) |
| `packages/bus/README.md`, `packages/business/README.md` | verb rows, chief-of-staff paragraph, 3c→3d sentence |
### Eight lines in a row 49 file, and why
`packages/bus/tests/comment-migration.test.mjs` is Rocko's. Three of its tests pin the current version as `'3c'`
(the "ready" string, the pinned version, the future-version fixture). Adding the 3c→3d step makes `3d` current,
so those three fail with no change to Rocko's step. I changed 8 lines: `'3c'` → `'3d'`, the future fixture `'3e'`,
the copy list gains `3c-3d.sql`, the expected line is `bus schema 3d ready`, titles updated.
Sage accepted this for row 123. If row 49 goes to round 2 and Rocko makes that test independent of the latest version,
I rebase and drop my edit. If row 49 lands as is, the edit rides with row 123. Row 49 has no verdicts yet. No other row 49 file is changed.
## Every field the chief of staff can change through `organization.set`
Condition 1. This is the whole list, and it is the same list `limits.set` takes from the human (`ORGANIZATION_KEYS`,
`broker.mjs:99`, used by both verbs):
| Key | Rule |
|---|---|
| `revision` | **required** concurrency token; a stale one is refused by the writer |
| `limits` | counts such as `limits.launch` (row 122's write path); the writer validates |
| `models` | the model table |
| `instances` | per-instance model selections |
Nothing else. Fields outside the write path are refused with `invalid-request` before the writer is called, for the
chief exactly as for the human: `launch` (so `launch.by`), `human`, `arbiters`, `roles`, `actor`, and the literal key
`'launch.by'`. Credential references and plugins are not in the write path either. Arbiters are #1627 and not started.
Test: **`REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys`**
(`chief-of-staff.test.mjs:239`). It runs each outside key as `chief organization.set` and `human limits.set`, expects
`invalid-request` in every case, and checks that the writer received no call. Mutants m10 and m11 fail it.
The writer is told the actor `chief-of-staff.chief` (definition + instance, matching `ID_PATTERN`), so the receipt in
`businesses/.history/<id>/changes.jsonl` names the chief, not a human.
## Storage now accepts a revoke with a reason and no decision
Stated plainly, as required: **after 3d, SQLite accepts an `op='revoke'` row with a reason and no decision.** The trigger
aborts only a revoke with neither. Storage no longer enforces "a decision is required" for `role_claims`. **Only the broker
decides who may revoke that way.** The broker refuses the reason form unless the caller's role file grants `role.end`,
and it checks before any insert.
Tests at the broker, for Condition 2 and 3 (`packages/bus/tests/chief-of-staff.test.mjs`):
- `REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke` (line 102): pm, coder and assistant sending
`{role, reason}` get `decision-required`; sending `{role}` gets `invalid-request`; the `human` gets `agent-required`;
`reader` and `tester` are refused; the `role_claims` row is unchanged and no `role.end` event is logged. Mutants m1 and m16 fail it.
- `REQ-ROLE-2: a reason is required, bounded, and never sent with a decision` (line 156): empty, non-string and over-500
reasons and an extra key are refused (the 500-character bound is inclusive), and a `reason` together with a `decision` is
refused for the chief too (`invalid-request`), with the claim row unchanged. Mutants m2, m3.
- `REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end` (line 171):
`role-end-refused` for both; nothing inserted, no `role.end` allowed event. Mutants m4, m5.
- `REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither` (line 116):
the schema rule alone. Mutants m19, m20.
## Acceptance checks against the brief
| Brief check | Test (file `packages/bus/tests/chief-of-staff.test.mjs` unless stated) |
|---|---|
| chief ends a run without a decision, logged with its reason | line 84 (`role_claims.reason` and the `action.allowed` body) |
| another role still needs a decision | line 102 (refused), line 136 (a resolved decision still ends the run, reason null) |
| chief change validated, written, logged like a human's | line 267, runtime test with the real writer: same receipt shape as `limits.set` |
| invalid change refused, and logged as refused only | line 267 (negative, non-numeric and unknown limits, unknown model, unknown instance, empty model table, stale `revision` → `revision-conflict`; file bytes and receipts unchanged), line 239 (outside keys), line 195 (event trace, new) |
| `role.revoke` gated for every role, the grant is the chief's file | `packages/business/tests/chief-of-staff.test.mjs:86` |
| upgrade of a real 3c store | line 376, below |
## Migration 3c → 3d (unchanged)
Built on Rocko's engine and version scheme, no second scheme: a `{from:'3c', to:'3d', sql}` entry in `migrations`.
The engine validates digest, version, WAL, timestamps and integrity per step, runs the step in a transaction, and
verifies the target digest. The step is two statements (drop and recreate one trigger) and touches no row.
Test `REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused`
(line 376): the 3c database starts from Rocko's pinned 3b fixture (`schema-3b.sql`, with an event and a claim row)
and goes through Rocko's real `3b.sql` and `3b-3c.sql` steps, run by a copy of `store.mjs` with only the 3c→3d entry removed and
`schema-3c.sql` as its schema. The test shows the old trigger refuses a reason-only revoke, a 3d `Store` refuses the 3c
database (`schema-mismatch`) until the explicit step runs, and after the step the version is `3d`, the event and claim rows
are preserved, the chief ends the held role with a reason (stored, decision null), and a revoke with neither is still aborted.
Cutover: no seat touches a live store. Mos's single re-pin window takes the live store 3b → 3c → 3d with
`node <new-copy>/packages/bus/src/migrate-schema.mjs <absolute dataRoot>` (Rocko's `MIGRATION.md` steps, unchanged).
The bus schema changed, so this row needs the re-pin; it rides in the same window as row 49.
## Mutants (one per check, each fails a test)
22 mutants, run one at a time in their own process group with a whole-group kill on timeout, on the round 2 tree.
Controls: bus 13/13, business 10/10. **Every mutant exits 1; 22/22 killed.** Log `mutants-run.txt`; per-mutant `.patch`,
`.out`, `.rc` in `mutants/` (the `.patch` header paths name the runner's temp backup directory, which is gone). Runner `mutants.sh`.
Started 2026-10-15T16:59:59Z (`mutants-started.txt`). Busy check by exit status before each run.
| # | Mutant | Fails |
|---|---|---|
| m1 | reason form skips the `role.end` check | 3 bus tests |
| m2 | reason and decision together accepted | reason/decision test |
| m3 | reason unbounded | reason test |
| m4 | chief may end its own run | self/assistant test |
| m5 | chief may end the assistant's run | self/assistant test |
| m6 | reason not stored | 4 tests incl. migration |
| m7 | reason not logged | chief-ends-run test |
| m8 | decision path checks `role.end` instead of `role.revoke` | decision-path test (line 136) |
| m9 | `organization.set` skips the check | 2 tests (incl. the new one) |
| m10 | key list widened with `launch` | outside-keys test |
| m11 | `revision` optional | outside-keys test |
| m12 | wrong actor passed to the writer | actor test, real-writer test |
| m13 | `limits` open to agents | non-grant refusal test |
| m14 | writer failure no longer maps to `invalid-request` | real-writer test, new refusal-trace test |
| m15 | vocabulary drops `role.end` | 8 business tests |
| m16 | chief file drops `role.end` | 4 bus tests |
| m17 | chief file drops `organization.change` | 4 bus tests (incl. the new one) |
| m18 | chief file grants `role.revoke` | 9 business tests |
| m19 | trigger back to decision-only | 5 bus tests |
| m20 | trigger never fires | storage and migration tests |
| m21 | 3c→3d step file empty | file fails at import (`migration-target-mismatch`) |
| **m22** | **consume before the writer (round 1 behaviour)** | **the new refusal-trace test (line 195)** |
## Targeted gate (decision 136), run in the q123 worktree, finished 2026-10-15T17:01:57Z
Round 2 adds `plugins` (row 131 is in the base). No flake of #258, #261, #262 or #263 was seen in this gate.
| Suite | Result |
|---|---|
| business | 92/92 |
| bus | 191/191 |
| plugins | 21/21 |
| tasks | 97/97 |
| cli | 195/195 |
| webui | 112/112 |
| harness | 84/84 |
| seat | 132/132 |
| `scripts/test-land-work.sh` | 84 passed, 0 failed |
Logs in `suites/`, summary `suites-run.txt`; runner `suites.sh`. The gate created no Docker networks (before and after lists
identical) and left no process under the worktree. Sage's full gate at the landing target still applies.
## Follow-ups (not in this row)
- **No harness tool yet.** The chief's session cannot call `role.revoke {role, reason}` or `organization.set` through a typed
tool; the manifest lists both as untyped (`tools.test.mjs`). Sage makes a harness tools row. **Ordering rule (Sage, #1617 comment
28565): that row lands before the 3d re-pin goes live.**
- `packages/harness/src/bundle.mjs:92` still tells every role "Only the human stops a run, with `mosaic stop`" and to raise a
`role.revoke` decision. That is wrong for the chief once it has a tool. Change it with the tool row.
- Row 49 round 2: rebase, drop the eight-line edit if Rocko makes his test version-independent.
- A shared rule for control characters in free text, if the owner wants one, goes in `text()` in its own row.
- #1627 (arbiters) not started.
## Verify
```sh
cd <packet dir>
sha256sum -c packet-manifest.sha256
git apply --check row123.patch # on eaeb5ebf2 + row 49 r1 (agents/rocko/work/queue-49/r1/build.patch)
sha256sum -c candidate-manifest.sha256 # from the repo root of the candidate tree (13 files)
```
@@ -0,0 +1,13 @@
d7dc974f4fd2817c15bbf051230d8b222265e03008c7a773cbc8a96ac38bf16e packages/bus/README.md
e4f1de9e16d2fab5e051fd3810189b28cc76a670cfe573b752d9f53bf9ca39b5 packages/bus/schema.sql
b94703a3d77a41ba93d058129ba512b3e4677dccfc0a402ca920658e40cee12b packages/bus/src/broker.mjs
0423832d05aa68f9191d7362827bd19310001165b895fb2de40b962fe68f9d56 packages/bus/src/migrations/3c-3d.sql
22487c0a9205f1cf93b19a47037a2db8c21443728bc904acf079e34d6e86b82a packages/bus/src/store.mjs
08c4ecd2d4740a29fd7d5aee4d8bd0a25d20718922a639e7dc621737ba594207 packages/bus/tests/chief-of-staff.test.mjs
c89cb508ca2cdb98e48341e3f5ff0ff2cb646511cfb6a8bc5962bd278c388095 packages/bus/tests/comment-migration.test.mjs
7587ac1df813e5148fdb73dc4733ec8064e6a142e4647f0da71be009a73eebdb packages/bus/tests/fixtures/schema-3c.sql
681b5a7fe3f8cd54b6421b07559231e91569061addd4318c75af5fd5c27560de packages/business/README.md
0c387e95a17d6576eca42b9bcdf207dbccdbaed34f80a630cd3f8a175032a6bc packages/business/src/vocabulary.mjs
28a02144e8ae774bd006b10ad5d07b984e07e0293df86102f82576c7670c6291 packages/business/tests/chief-of-staff.test.mjs
f7a7247ed4a88a84d9ff41ec40013c795c4a2eff70534e59141a0c3462b3625e packages/harness/tests/tools.test.mjs
a9b4b30b9a37c937b0f0418bc03476aae5ea447ea66494eb5e2c335da13bf4fe roles/chief-of-staff.json
@@ -0,0 +1,13 @@
M packages/bus/README.md
M packages/bus/schema.sql
M packages/bus/src/broker.mjs
A packages/bus/src/migrations/3c-3d.sql
M packages/bus/src/store.mjs
A packages/bus/tests/chief-of-staff.test.mjs
M packages/bus/tests/comment-migration.test.mjs
A packages/bus/tests/fixtures/schema-3c.sql
M packages/business/README.md
M packages/business/src/vocabulary.mjs
M packages/business/tests/chief-of-staff.test.mjs
M packages/harness/tests/tools.test.mjs
M roles/chief-of-staff.json
@@ -0,0 +1,102 @@
diff --git a/packages/bus/README.md b/packages/bus/README.md
index 015b8a7fc..3bfd35a55 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -140,7 +140,6 @@ an operator inspect content and delivery evidence after an uncertain result.
| `role.release` | No args; only its current holder releases. |
| `role.revoke` | `{role,decision}`; needs human-approved gated decision for this exact target and holder incarnation. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run and the assistant's are `role-end-refused`; any other role sending `reason` gets `decision-required`. |
| `organization.set` | `{revision,limits?,models?,instances?}`; the keys `limits.set` takes, through the same writer and receipt. Needs `organization.change` in the caller's role file (the chief of staff); the receipt's actor is `<definition>.<instance>`, e.g. `chief-of-staff.chief`. Every other key is `invalid-request`. `limits` and `limits.set` stay human-only. |
-
| `decision.raise` | `{action,question,options:[{key,text}],recommendation,blocking,domain?,target?,project?,task_ref?,requirement_ref?,supersedes?,choice?,approvalChoice?}`. Returns decision with decoded options and authorization context. A blocking decision holds a task: `blocking` true with no `task_ref` refuses `task-required`. A non-blocking one needs no task, and a gated action such as `prd.approve` or `role.revoke` still routes to the human's inbox. |
| `decision.resolve` | `{id,choice,note?}`; option must exist, resolver must be routed role or outside-agent human CLI. Sends the raising role a DECISION message (Decision close messages, below). |
| `decision.seen` | `{id,note?}`; acknowledgement by resolver, not a resolution. |
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index ddff2cc54..ecb18f8b8 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -731,8 +731,11 @@ export class Broker {
if (request.verb === "limits") keys(args, []);
else keys(args, ORGANIZATION_KEYS, ["revision"]);
let actor = s.human;
+ // This branch runs outside the store transaction below, so the grant is checked before the writer
+ // and consumed only once the writer has committed: a refused change logs action.refused alone,
+ // as a refused human limits.set does.
if (role) {
- this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));
+ this.#checkAuthority(s, "organization.change");
actor = this.#business(s.business).roles[s.role].definition + "." + s.role;
}
let result;
@@ -746,6 +749,7 @@ export class Broker {
}
throw new BusError("outcome-unknown");
}
+ if (role) this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));
this.#secretCheck(result);
return result;
}
diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs
index 645b41dec..a224c7546 100644
--- a/packages/bus/tests/chief-of-staff.test.mjs
+++ b/packages/bus/tests/chief-of-staff.test.mjs
@@ -49,11 +49,13 @@ function setup(t) {
rmSync(root, { recursive: true, force: true });
});
const organization = [];
+ const writer = { error: null };
const b = new Broker({
store,
businesses: demo(),
organization: (...args) => {
organization.push(args);
+ if (writer.error) throw writer.error;
return { revision: 'b'.repeat(64) };
},
});
@@ -74,7 +76,9 @@ function setup(t) {
.all("SELECT actor_role, subject, body FROM events WHERE kind='action.allowed' ORDER BY seq")
.map((e) => ({ ...e, body: JSON.parse(e.body) }))
.filter((e) => e.body.action === action);
- return { b, store, caps, call, lastClaim, allowed, organization };
+ const refused = () =>
+ store.all("SELECT actor_role, body FROM events WHERE kind='action.refused' ORDER BY seq").map((e) => ({ ...e, body: JSON.parse(e.body) }));
+ return { b, store, caps, call, lastClaim, allowed, refused, organization, writer };
}
test('REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged', (t) => {
@@ -188,6 +192,35 @@ test('REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches t
assert.deepEqual(organization.at(-1), ['demo', 'limits.set', PATCH, 'jason']);
});
+test('REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set', (t) => {
+ const { call, allowed, refused, organization, writer } = setup(t);
+ // The grant is consumed only after the writer commits, so a refusal leaves no allowed event behind.
+ const codes = [];
+ for (const [error, code] of [
+ [Object.assign(new Error('stale'), { code: 'revision-conflict' }), 'revision-conflict'],
+ [Object.assign(new Error('bad'), { name: 'BusinessError' }), 'invalid-request'],
+ [new Error('disk'), 'outcome-unknown'],
+ ]) {
+ writer.error = error;
+ assert.throws(() => call('chief', 'organization.set', PATCH), new RegExp(code));
+ assert.throws(() => call('human', 'limits.set', PATCH), new RegExp(code));
+ codes.push(code, code);
+ }
+ // A refusal before the writer (a key outside the write path) leaves the same trace.
+ assert.throws(() => call('chief', 'organization.set', { ...PATCH, human: 'mallory' }), /invalid-request/);
+ assert.equal(organization.length, 6);
+ assert.equal(allowed('organization.change').length, 0);
+ assert.deepEqual(refused().map((e) => [e.actor_role, e.body.code]), [
+ ...codes.map((code, i) => [i % 2 ? null : 'chief', code]), // a human has no role
+ ['chief', 'invalid-request'],
+ ]);
+ // A change that commits leaves exactly one allowed event, and no further refusal.
+ writer.error = null;
+ call('chief', 'organization.set', PATCH);
+ assert.equal(allowed('organization.change').length, 1);
+ assert.equal(refused().length, 7);
+});
+
test('REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change', (t) => {
const { call, organization, allowed } = setup(t);
for (const who of ['pm', 'coder', 'assistant'])
@@ -0,0 +1,102 @@
control-bus: exit 0; ℹ tests 13 ℹ pass 13 ℹ fail 0
control-business: exit 0; ℹ tests 10 ℹ pass 10 ℹ fail 0
m1-role-end-unchecked: exit 1; ℹ tests 13 ℹ pass 10 ℹ fail 3
failed: ✖ failing tests:
failed: ✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged
failed: ✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end
failed: ✖ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke
m2-reason-and-decision-accepted: exit 1; ℹ tests 13 ℹ pass 12 ℹ fail 1
failed: ✖ failing tests:
failed: ✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision
m3-reason-unbounded: exit 1; ℹ tests 13 ℹ pass 12 ℹ fail 1
failed: ✖ failing tests:
failed: ✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision
m4-self-end-allowed: exit 1; ℹ tests 13 ℹ pass 12 ℹ fail 1
failed: ✖ failing tests:
failed: ✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end
m5-assistant-end-allowed: exit 1; ℹ tests 13 ℹ pass 12 ℹ fail 1
failed: ✖ failing tests:
failed: ✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end
m6-reason-not-stored: exit 1; ℹ tests 13 ℹ pass 9 ℹ fail 4
failed: ✖ failing tests:
failed: ✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged
failed: ✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end
failed: ✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision
failed: ✖ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused
m7-reason-not-logged: exit 1; ℹ tests 13 ℹ pass 12 ℹ fail 1
failed: ✖ failing tests:
failed: ✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged
m8-decision-path-wrong-action: exit 1; ℹ tests 13 ℹ pass 12 ℹ fail 1
failed: ✖ failing tests:
failed: ✖ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason
m9-organization-set-unchecked: exit 1; ℹ tests 13 ℹ pass 11 ℹ fail 2
failed: ✖ failing tests:
failed: ✖ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set
failed: ✖ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys
m10-key-list-widened: exit 1; ℹ tests 13 ℹ pass 12 ℹ fail 1
failed: ✖ failing tests:
failed: ✖ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys
m11-revision-optional: exit 1; ℹ tests 13 ℹ pass 12 ℹ fail 1
failed: ✖ failing tests:
failed: ✖ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys
m12-wrong-actor: exit 1; ℹ tests 13 ℹ pass 11 ℹ fail 2
failed: ✖ failing tests:
failed: ✖ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor
failed: ✖ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused
m13-limits-open-to-agents: exit 1; ℹ tests 13 ℹ pass 12 ℹ fail 1
failed: ✖ failing tests:
failed: ✖ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change
m14-invalid-change-not-refused: exit 1; ℹ tests 13 ℹ pass 11 ℹ fail 2
failed: ✖ failing tests:
failed: ✖ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set
failed: ✖ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused
m22-consume-before-writer: exit 1; ℹ tests 13 ℹ pass 12 ℹ fail 1
failed: ✖ failing tests:
failed: ✖ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set
m15-vocabulary-drops-role-end: exit 1; ℹ tests 10 ℹ pass 2 ℹ fail 8
failed: ✖ a business grant of a gated action through limits does not widen the role
failed: ✖ a launch block whose by is the pm instance is refused
failed: ✖ a limit that drops role.launch leaves the chief of staff with no launch block
failed: ✖ failing tests:
failed: ✖ the chief of staff holds role.launch, message.send, role.end and organization.change; every other action is gated
failed: ✖ the instance takes no credential references and no tracker
failed: ✖ the pm does not hold role.launch
failed: ✖ the resolved record matches the approved list, so a widened file fails
failed: ✖ the role file loads and resolves through loadBusiness
m16-chief-file-drops-role-end: exit 1; ℹ tests 13 ℹ pass 9 ℹ fail 4
failed: ✖ failing tests:
failed: ✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged
failed: ✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end
failed: ✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision
failed: ✖ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused
m17-chief-file-drops-organization-change: exit 1; ℹ tests 13 ℹ pass 9 ℹ fail 4
failed: ✖ failing tests:
failed: ✖ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set
failed: ✖ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor
failed: ✖ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys
failed: ✖ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused
m18-chief-file-grants-role-revoke: exit 1; ℹ tests 10 ℹ pass 1 ℹ fail 9
failed: ✖ a business grant of a gated action through limits does not widen the role
failed: ✖ a launch block whose by is the pm instance is refused
failed: ✖ a limit that drops role.launch leaves the chief of staff with no launch block
failed: ✖ failing tests:
failed: ✖ the chief of staff holds role.launch, message.send, role.end and organization.change; every other action is gated
failed: ✖ the instance takes no credential references and no tracker
failed: ✖ the pm does not hold role.launch
failed: ✖ the resolved record matches the approved list, so a widened file fails
failed: ✖ the role file loads and resolves through loadBusiness
failed: ✖ the shipped file is exactly the approved list
m19-trigger-decision-only: exit 1; ℹ tests 13 ℹ pass 8 ℹ fail 5
failed: ✖ failing tests:
failed: ✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged
failed: ✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end
failed: ✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision
failed: ✖ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused
failed: ✖ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither
m20-trigger-never-fires: exit 1; ℹ tests 13 ℹ pass 11 ℹ fail 2
failed: ✖ failing tests:
failed: ✖ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused
failed: ✖ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither
m21-step-empty: exit 1; ℹ tests 1 ℹ pass 0 ℹ fail 1
failed: ✖ failing tests:
failed: ✖ packages/bus/tests/chief-of-staff.test.mjs
@@ -0,0 +1 @@
2026-10-15T16:59:59Z
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Row 123 (#1617) round 2: the chief of staff ends runs and changes the organization file.
# Same 21 mutants as r1 plus m22 (F1: the grant consumed before the writer). Mutants m1 to m21 run again against the r2 tree.
# Each check must fail on a mutant. For each mutant this runs one named test file against the
# real source (control) and against the source with one exact-once text edit, in the given worktree,
# one test file at a time, and restores every source file on exit.
# Usage: mutants.sh <worktree> <outdir> (MUTANT_DRY=1 only proves every edit matches exactly once)
set -u
WT=$(cd "$1" && pwd); OUT=$(mkdir -p "$2" && cd "$2" && pwd)
cd "$WT" || exit 2
BAK=$(mktemp -d "$OUT/.bak.XXXXXX")
declare -A SAVED
save() { [ -n "${SAVED[$1]:-}" ] || { mkdir -p "$BAK/$(dirname "$1")"; cp "$1" "$BAK/$1"; SAVED[$1]=1; }; }
restore() { for f in "${!SAVED[@]}"; do cp "$BAK/$f" "$f"; done; rm -rf "$BAK"; }
trap restore EXIT
edit() { # file old new: exact-once replace
save "$1"
node -e '
const fs = require("fs"); const [f, a, b] = process.argv.slice(1);
const s = fs.readFileSync(f, "utf8"); const n = s.split(a).length - 1;
if (n !== 1) { console.error("expected one match, found " + n); process.exit(3); }
fs.writeFileSync(f, s.replace(a, () => b));
' "$1" "$2" "$3"
}
run() { # name testfile
if [ -n "${MUTANT_DRY:-}" ]; then echo "$1: edit ok (dry)"; return; fi
setsid -w timeout -k 10 400 node --test "$2" > "$OUT/$1.out" 2>&1; echo "$?" > "$OUT/$1.rc"
echo "$1: exit $(cat "$OUT/$1.rc"); $(grep -E '^ℹ (tests|pass|fail)' "$OUT/$1.out" | tr '\n' ' ')"
grep -E '^✖' "$OUT/$1.out" | sort -u | sed 's/ ([0-9.]*ms)$//' | sed "s/^/ failed: /"
}
mutant() { # name testfile file old new
edit "$3" "$4" "$5" || { echo "$1: edit failed"; return; }
diff -u "$BAK/$3" "$3" > "$OUT/$1.patch"
run "$1" "$2"
cp "$BAK/$3" "$3"
}
CS=packages/bus/tests/chief-of-staff.test.mjs; BCS=packages/business/tests/chief-of-staff.test.mjs
B=packages/bus/src/broker.mjs; V=packages/business/src/vocabulary.mjs; C=roles/chief-of-staff.json
for t in "$CS" "$BCS"; do run "control-$(basename "$(dirname "$(dirname "$t")")")" "$t"; done
# role.end (REQ-ROLE-2, REQ-COS-1)
# m1: the reason form skips the authority check, so any caller may end a run with a reason
mutant m1-role-end-unchecked "$CS" "$B" "this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason });" "void 0;"
# m2: a reason and a decision together are accepted
mutant m2-reason-and-decision-accepted "$CS" "$B" "if ('decision' in a) fail('invalid-request');" "void 0;"
# m3: the reason has no length bound
mutant m3-reason-unbounded "$CS" "$B" "string(a.reason, 500);" "string(a.reason, 100000);"
# m4: the chief may end its own run
mutant m4-self-end-allowed "$CS" "$B" "if (a.role === s.role || END_PROTECTED" "if (END_PROTECTED"
# m5: the chief may end the assistant's run
mutant m5-assistant-end-allowed "$CS" "$B" "END_PROTECTED.includes(b.roles[a.role]?.definition)" "false"
# m6: the reason is not written to role_claims.reason
mutant m6-reason-not-stored "$CS" "$B" "this.#claimEnd(s, h, 'revoke', a.decision ?? null, a.reason ?? null);" "this.#claimEnd(s, h, 'revoke', a.decision ?? null, null);"
# m7: the reason is not in the action.allowed body
mutant m7-reason-not-logged "$CS" "$B" "{ target: a.role }, { reason: a.reason });" "{ target: a.role }, {});"
# m8: the decision path checks role.end instead of role.revoke, so another role's decision no longer ends a run
mutant m8-decision-path-wrong-action "$CS" "$B" "this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });" "this.#consumeAuthority(s, 'role.end', { decision: a.decision, target: a.role });"
# organization.set (REQ-LIMIT-1, REQ-ROLE-3, REQ-COS-1)
# m9: organization.set skips the authority check, so any agent may change the file
mutant m9-organization-set-unchecked "$CS" "$B" 'this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));' "void 0;"
# m10: organization.set accepts a key beyond the write path (launch)
mutant m10-key-list-widened "$CS" "$B" "const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances'];" "const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances', 'launch'];"
# m11: the revision is optional
mutant m11-revision-optional "$CS" "$B" 'else keys(args, ORGANIZATION_KEYS, ["revision"]);' "else keys(args, ORGANIZATION_KEYS, []);"
# m12: the writer is told the wrong actor
mutant m12-wrong-actor "$CS" "$B" 'actor = this.#business(s.business).roles[s.role].definition + "." + s.role;' "actor = s.role;"
# m13: limits and limits.set stop being human-only
mutant m13-limits-open-to-agents "$CS" "$B" 'if (!role && !s.human) fail("human-required");' "void 0;"
# m14: a validation failure from the shared writer no longer maps to invalid-request
mutant m14-invalid-change-not-refused "$CS" "$B" 'if (e.name === "BusinessError" || e.code === "invalid-request"' 'if (e.name === "NoSuchError" || e.code === "invalid-request"'
# m22 (round 2, F1): the grant is consumed before the writer, as in r1, so a refused change leaves an action.allowed behind
edit "$B" 'this.#checkAuthority(s, "organization.change");' 'this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));' &&
edit "$B" 'if (role) this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));
this.#secretCheck(result);' 'this.#secretCheck(result);' || echo "m22: edit failed"
diff -u "$BAK/$B" "$B" > "$OUT/m22-consume-before-writer.patch"
run m22-consume-before-writer "$CS"
cp "$BAK/$B" "$B"
# vocabulary and the shipped role file (REQ-COS-1: role.revoke stays gated for every role)
# m15: role.end leaves the action vocabulary
mutant m15-vocabulary-drops-role-end "$BCS" "$V" ' "role.end",
' ''
# m16: the shipped chief file drops its role.end grant
mutant m16-chief-file-drops-role-end "$CS" "$C" '"role.launch", "role.end", "organization.change"' '"role.launch", "organization.change"'
# m17: the shipped chief file drops its organization.change grant
mutant m17-chief-file-drops-organization-change "$CS" "$C" '"role.end", "organization.change"' '"role.end"'
# m18: the shipped chief file lists role.revoke as within-role
mutant m18-chief-file-grants-role-revoke "$BCS" "$C" '"role.launch", "role.end"' '"role.launch", "role.revoke", "role.end"'
# schema (REQ-ROLE-2): the 3c→3d trigger. A schema edit must keep the pinned digest honest, so each of these
# edits schema.sql and the 3c-3d step the same way and re-seals SCHEMA_SHA256 from the edited schema.sql.
SQ=packages/bus/schema.sql; ST=packages/bus/src/store.mjs; MG=packages/bus/src/migrations/3c-3d.sql
reseal() {
save "$ST"
local sha; sha=$(sha256sum "$SQ" | cut -d' ' -f1)
node -e '
const fs = require("fs"); const [f, sha] = process.argv.slice(1);
const s = fs.readFileSync(f, "utf8");
fs.writeFileSync(f, s.replace(/export const SCHEMA_SHA256 = \x27[a-f0-9]+\x27;/, "export const SCHEMA_SHA256 = \x27" + sha + "\x27;"));
' "$ST" "$sha"
}
schema_mutant() { # name testfile old new
edit "$SQ" "$3" "$4" && edit "$MG" "$3" "$4" || { echo "$1: edit failed"; return; }
reseal
{ diff -u "$BAK/$SQ" "$SQ"; diff -u "$BAK/$MG" "$MG"; diff -u "$BAK/$ST" "$ST"; } > "$OUT/$1.patch"
run "$1" "$2"
for f in "$SQ" "$MG" "$ST"; do cp "$BAK/$f" "$f"; done
}
# m19: the trigger goes back to the 3c rule (a revoke needs a decision), so a reason alone is refused by storage
schema_mutant m19-trigger-decision-only "$CS" "WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL" "WHEN NEW.op = 'revoke' AND NEW.decision IS NULL"
# m20: the trigger never fires, so a revoke with neither a decision nor a reason is accepted
schema_mutant m20-trigger-never-fires "$CS" "WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL" "WHEN 0"
# m21: the 3c→3d step is a no-op on the trigger: the shipped schema has it, a migrated 3c database keeps the old rule.
# (Run against the migration test only; the step file keeps its text but the DROP/CREATE are removed, so the engine
# refuses at import with migration-target-mismatch. That refusal is the check: a step that does not reach the
# pinned schema cannot ship.)
save "$MG"; : > "$MG"
{ diff -u "$BAK/$MG" "$MG"; } > "$OUT/m21-step-empty.patch"
run m21-step-empty "$CS"
cp "$BAK/$MG" "$MG"
@@ -0,0 +1,21 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (17.599872ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (16.499379ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (11.433733ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (11.919354ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (13.555276ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (16.493773ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (15.21153ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (11.455873ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (16.018647ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (14.874716ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (22.565852ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (40.258926ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (40.754775ms)
ℹ tests 13
ℹ suites 0
ℹ pass 13
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 310.32856
@@ -0,0 +1 @@
0
@@ -0,0 +1,18 @@
✔ the role file loads and resolves through loadBusiness (4.771359ms)
✔ the shipped file is exactly the approved list (0.180405ms)
✔ the resolved record matches the approved list, so a widened file fails (1.215488ms)
✔ the chief of staff holds role.launch, message.send, role.end and organization.change; every other action is gated (1.048882ms)
✔ the pm does not hold role.launch (1.199589ms)
✔ a business grant of a gated action through limits does not widen the role (1.419807ms)
✔ a limit that drops role.launch leaves the chief of staff with no launch block (0.823168ms)
✔ a launch block whose by is the pm instance is refused (1.226778ms)
✔ the contract names no duty to talk to the human and the pm contract no launch (0.266804ms)
✔ the instance takes no credential references and no tracker (1.643655ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 68.69798
@@ -0,0 +1 @@
0
@@ -0,0 +1,71 @@
✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (16.720423ms)
✖ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (12.119747ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (11.365672ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (11.930418ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (13.59432ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (13.270607ms)
✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (12.805327ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (12.573453ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (15.284614ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (15.818214ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (20.145718ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (40.723261ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (41.053588ms)
ℹ tests 13
ℹ suites 0
ℹ pass 10
ℹ fail 3
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 301.447439
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:84:1
✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (16.720423ms)
TypeError: Cannot read properties of undefined (reading 'actor_role')
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:92:22)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.start (node:internal/test_runner/test:1262:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17)
test at packages/bus/tests/chief-of-staff.test.mjs:102:1
✖ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (12.119747ms)
AssertionError [ERR_ASSERTION]: Missing expected exception: pm
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:105:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: /decision-required/,
operator: 'throws',
diff: 'simple'
}
test at packages/bus/tests/chief-of-staff.test.mjs:171:1
✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (12.805327ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
0 !== 1
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:182:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 0,
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:00.354977497 -0500
@@ -831,7 +831,7 @@
if ('reason' in a) {
if ('decision' in a) fail('invalid-request');
string(a.reason, 500);
- this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason });
+ void 0;
const b = this.#business(s.business);
if (a.role === s.role || END_PROTECTED.includes(b.roles[a.role]?.definition)) fail('role-end-refused');
} else {
@@ -0,0 +1,41 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (18.070205ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (16.916802ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (11.266898ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (10.969737ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (13.102543ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (14.325904ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (13.106349ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (10.296578ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (14.520224ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (14.45189ms)
✖ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (12.467441ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (39.0404ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (40.936516ms)
ℹ tests 13
ℹ suites 0
ℹ pass 12
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 289.777494
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:239:1
✖ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (12.467441ms)
AssertionError [ERR_ASSERTION]: Missing expected exception: chief launch
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:258:14)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: /invalid-request/,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:03.496026105 -0500
@@ -96,7 +96,7 @@
// A git commit id, SHA-1 or SHA-256.
const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x);
// Row 123 (#1617): the one list of organization-file keys limits.set and organization.set accept.
-const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances'];
+const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances', 'launch'];
// Row 123 (#1617): role.end never reaches the assistant (the user's seat) or the caller's own run.
const END_PROTECTED = ['assistant'];
const VERDICTS = ['approve', 'changes'];
@@ -0,0 +1,41 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (16.855272ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (15.370259ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (10.646869ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (10.341655ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (12.878577ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (13.452931ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (12.610886ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (10.135886ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (14.563403ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (14.344626ms)
✖ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (18.58806ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (39.045666ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (40.351692ms)
ℹ tests 13
ℹ suites 0
ℹ pass 12
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 288.264313
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:239:1
✖ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (18.58806ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:263:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: /invalid-request/,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:03.834031333 -0500
@@ -729,7 +729,7 @@
if (!role && !s.human) fail("human-required");
if (!this.#organization) fail("not-configured");
if (request.verb === "limits") keys(args, []);
- else keys(args, ORGANIZATION_KEYS, ["revision"]);
+ else keys(args, ORGANIZATION_KEYS, []);
let actor = s.human;
// This branch runs outside the store transaction below, so the grant is checked before the writer
// and consumed only once the writer has committed: a refused change logs action.refused alone,
@@ -0,0 +1,77 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (17.679874ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (17.654085ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (11.17048ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (11.093444ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (14.906631ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (14.043148ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (14.43196ms)
✖ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (12.665598ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (15.925824ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (15.015369ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (19.179538ms)
✖ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (30.487117ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (41.412439ms)
ℹ tests 13
ℹ suites 0
ℹ pass 11
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 301.115506
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:185:1
✖ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (12.665598ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
... Skipped lines
[
[
'demo',
'limits.set',
{
...
},
+ 'chief'
- 'chief-of-staff.chief'
]
]
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:190:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: [ [ 'demo', 'limits.set', [Object], 'chief' ] ],
expected: [ [ 'demo', 'limits.set', [Object], 'chief-of-staff.chief' ] ],
operator: 'deepStrictEqual',
diff: 'simple'
}
test at packages/bus/tests/chief-of-staff.test.mjs:267:1
✖ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (30.487117ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'chief'
- 'chief-of-staff.chief'
^
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:304:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'chief',
expected: 'chief-of-staff.chief',
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:04.170036529 -0500
@@ -736,7 +736,7 @@
// as a refused human limits.set does.
if (role) {
this.#checkAuthority(s, "organization.change");
- actor = this.#business(s.business).roles[s.role].definition + "." + s.role;
+ actor = s.role;
}
let result;
try {
@@ -0,0 +1 @@
1
@@ -0,0 +1,41 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (18.611418ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (16.951691ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (11.133691ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (10.466369ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (12.710486ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (13.859597ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (13.319571ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (10.660022ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (14.394258ms)
✖ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (13.25928ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (17.273755ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (39.110325ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (40.8944ms)
ℹ tests 13
ℹ suites 0
ℹ pass 12
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 293.031166
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:224:1
✖ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (13.25928ms)
AssertionError [ERR_ASSERTION]: Missing expected exception: limits
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:235:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: /human-required/,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:04.520041940 -0500
@@ -726,7 +726,7 @@
// file lists organization.change. It takes the same keys, runs the same write path and
// logs the same receipt; only the actor differs: the role definition and instance.
const role = request.verb === "organization.set";
- if (!role && !s.human) fail("human-required");
+ void 0;
if (!this.#organization) fail("not-configured");
if (request.verb === "limits") keys(args, []);
else keys(args, ORGANIZATION_KEYS, ["revision"]);
@@ -0,0 +1,77 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (16.489636ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (18.096088ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (12.473725ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (10.868164ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (13.130288ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (15.093797ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (13.586422ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (10.407087ms)
✖ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (12.194274ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (14.709876ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (18.407814ms)
✖ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (32.287025ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (40.835113ms)
ℹ tests 13
ℹ suites 0
ℹ pass 11
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 290.623394
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:195:1
✖ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (12.194274ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /invalid-request/. Input:
'Error: outcome-unknown'
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:205:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: Error: outcome-unknown
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:750:17)
at call (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:70:44)
at file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:205:25
at getActual (node:assert:580:5)
at strict.throws (node:assert:728:24)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:205:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19),
expected: /invalid-request/,
operator: 'throws',
diff: 'simple'
}
test at packages/bus/tests/chief-of-staff.test.mjs:267:1
✖ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (32.287025ms)
AssertionError [ERR_ASSERTION]: {"revision":"a8913f94c7189f9d7f3fcea64ca3cdf93a1d4f3bf15c28cacf7aff42cbe6b512","limits":{"total":-1}}
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:321:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: Error: outcome-unknown
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:750:17)
at file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:322:20
at getActual (node:assert:580:5)
at strict.throws (node:assert:728:24)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:321:12)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7),
expected: /invalid-request|unknown-model/,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:04.865047274 -0500
@@ -744,7 +744,7 @@
} catch (e) {
if (e.code === "revision-conflict") fail("revision-conflict");
if (e.code?.startsWith("organization-")) fail(e.code);
- if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
+ if (e.name === "NoSuchError" || e.code === "invalid-request" || e.code === "unknown-model") {
fail("invalid-request");
}
throw new BusError("outcome-unknown");
@@ -0,0 +1,164 @@
✖ the role file loads and resolves through loadBusiness (3.178612ms)
✔ the shipped file is exactly the approved list (0.539971ms)
✖ the resolved record matches the approved list, so a widened file fails (0.879013ms)
✖ the chief of staff holds role.launch, message.send, role.end and organization.change; every other action is gated (0.797163ms)
✖ the pm does not hold role.launch (0.817087ms)
✖ a business grant of a gated action through limits does not widen the role (0.736095ms)
✖ a limit that drops role.launch leaves the chief of staff with no launch block (0.700889ms)
✖ a launch block whose by is the pm instance is refused (1.625346ms)
✔ the contract names no duty to talk to the human and the pm contract no launch (0.241044ms)
✖ the instance takes no credential references and no tracker (0.890907ms)
ℹ tests 10
ℹ suites 0
ℹ pass 2
ℹ fail 8
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 62.717116
✖ failing tests:
test at packages/business/tests/chief-of-staff.test.mjs:49:1
✖ the role file loads and resolves through loadBusiness (3.178612ms)
Error [BusinessError]: unknown action in authority.withinRole: "role.end"
at refuse (file:///mnt/storage/scratch/researcher/q123/packages/business/src/errors.mjs:12:9)
at file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:40:38
at requireDistinctList (file:///mnt/storage/scratch/researcher/q123/packages/business/src/util.mjs:46:5)
at checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:39:18)
at validateRoleDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:153:16)
at loadRoleFile (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:160:10)
at loadRole (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:165:10)
at checkRoles (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:88:103)
at validateBusinessDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:150:34)
at loadBusiness (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:202:10) {
exitCode: 2
}
test at packages/business/tests/chief-of-staff.test.mjs:76:1
✖ the resolved record matches the approved list, so a widened file fails (0.879013ms)
Error [BusinessError]: unknown action in authority.withinRole: "role.end"
at refuse (file:///mnt/storage/scratch/researcher/q123/packages/business/src/errors.mjs:12:9)
at file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:40:38
at requireDistinctList (file:///mnt/storage/scratch/researcher/q123/packages/business/src/util.mjs:46:5)
at checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:39:18)
at validateRoleDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:153:16)
at loadRoleFile (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:160:10)
at loadRole (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:165:10)
at checkRoles (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:88:103)
at validateBusinessDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:150:34)
at loadBusiness (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:202:10) {
exitCode: 2
}
test at packages/business/tests/chief-of-staff.test.mjs:86:1
✖ the chief of staff holds role.launch, message.send, role.end and organization.change; every other action is gated (0.797163ms)
Error [BusinessError]: unknown action in authority.withinRole: "role.end"
at refuse (file:///mnt/storage/scratch/researcher/q123/packages/business/src/errors.mjs:12:9)
at file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:40:38
at requireDistinctList (file:///mnt/storage/scratch/researcher/q123/packages/business/src/util.mjs:46:5)
at checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:39:18)
at validateRoleDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:153:16)
at loadRoleFile (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:160:10)
at loadRole (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:165:10)
at checkRoles (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:88:103)
at validateBusinessDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:150:34)
at loadBusiness (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:202:10) {
exitCode: 2
}
test at packages/business/tests/chief-of-staff.test.mjs:97:1
✖ the pm does not hold role.launch (0.817087ms)
Error [BusinessError]: unknown action in authority.withinRole: "role.end"
at refuse (file:///mnt/storage/scratch/researcher/q123/packages/business/src/errors.mjs:12:9)
at file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:40:38
at requireDistinctList (file:///mnt/storage/scratch/researcher/q123/packages/business/src/util.mjs:46:5)
at checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:39:18)
at validateRoleDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:153:16)
at loadRoleFile (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:160:10)
at loadRole (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:165:10)
at checkRoles (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:88:103)
at validateBusinessDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:150:34)
at loadBusiness (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:202:10) {
exitCode: 2
}
test at packages/business/tests/chief-of-staff.test.mjs:106:1
✖ a business grant of a gated action through limits does not widen the role (0.736095ms)
Error [BusinessError]: unknown action in authority.withinRole: "role.end"
at refuse (file:///mnt/storage/scratch/researcher/q123/packages/business/src/errors.mjs:12:9)
at file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:40:38
at requireDistinctList (file:///mnt/storage/scratch/researcher/q123/packages/business/src/util.mjs:46:5)
at checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:39:18)
at validateRoleDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:153:16)
at loadRoleFile (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:160:10)
at loadRole (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:165:10)
at checkRoles (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:88:103)
at validateBusinessDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:150:34)
at loadBusiness (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:202:10) {
exitCode: 2
}
test at packages/business/tests/chief-of-staff.test.mjs:118:1
✖ a limit that drops role.launch leaves the chief of staff with no launch block (0.700889ms)
Error [BusinessError]: unknown action in authority.withinRole: "role.end"
at refuse (file:///mnt/storage/scratch/researcher/q123/packages/business/src/errors.mjs:12:9)
at file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:40:38
at requireDistinctList (file:///mnt/storage/scratch/researcher/q123/packages/business/src/util.mjs:46:5)
at checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:39:18)
at validateRoleDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:153:16)
at loadRoleFile (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:160:10)
at loadRole (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:165:10)
at checkRoles (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:88:103)
at validateBusinessDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:150:34)
at loadBusiness (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:202:10) {
exitCode: 2
}
test at packages/business/tests/chief-of-staff.test.mjs:126:1
✖ a launch block whose by is the pm instance is refused (1.625346ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /launch\.by names pm, whose role pm doesn't hold role\.launch within-role/. Input:
'unknown action in authority.withinRole: "role.end"'
at Object.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/business/tests/chief-of-staff.test.mjs:30:12)
at expectedException (node:assert:547:17)
at expectsError (node:assert:672:21)
at strict.throws (node:assert:728:3)
at refuses (file:///mnt/storage/scratch/researcher/q123/packages/business/tests/chief-of-staff.test.mjs:27:10)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/business/tests/chief-of-staff.test.mjs:127:3)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'unknown action in authority.withinRole: "role.end"',
expected: /launch\.by names pm, whose role pm doesn't hold role\.launch within-role/,
operator: 'match',
diff: 'simple'
}
test at packages/business/tests/chief-of-staff.test.mjs:145:1
✖ the instance takes no credential references and no tracker (0.890907ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /must reference exactly the services its role definition needs \(none; got gitea\)/. Input:
'unknown action in authority.withinRole: "role.end"'
at Object.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/business/tests/chief-of-staff.test.mjs:30:12)
at expectedException (node:assert:547:17)
at expectsError (node:assert:672:21)
at strict.throws (node:assert:728:3)
at refuses (file:///mnt/storage/scratch/researcher/q123/packages/business/tests/chief-of-staff.test.mjs:27:10)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/business/tests/chief-of-staff.test.mjs:146:3)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'unknown action in authority.withinRole: "role.end"',
expected: /must reference exactly the services its role definition needs \(none; got gitea\)/,
operator: 'match',
diff: 'simple'
}
@@ -0,0 +1,10 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/business/src/vocabulary.mjs 2026-10-15 12:00:05.567274227 -0500
+++ packages/business/src/vocabulary.mjs 2026-10-15 12:00:05.584058388 -0500
@@ -29,7 +29,6 @@
// Row 123 (#1617, REQ-COS-1): the chief of staff ends a run with a reason and no decision,
// and changes the organization file through the shared write path. Neither is gated-only,
// so the grant lives in a role file; role.revoke itself stays gated for every role.
- "role.end",
"organization.change",
"credential.mint",
"spend",
@@ -0,0 +1,99 @@
✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (15.055545ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (17.721434ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (10.715978ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (10.583615ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (13.020796ms)
✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (14.447756ms)
✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (11.35312ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (10.347495ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (14.526949ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (14.634392ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (17.483568ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (39.303261ms)
✖ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (41.408468ms)
ℹ tests 13
ℹ suites 0
ℹ pass 9
ℹ fail 4
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 293.272573
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:84:1
✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (15.055545ms)
Error: decision-required
at fail (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:78:9)
at #checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:458:20)
at #consumeAuthority (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:484:40)
at #dispatch (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:834:33)
at file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:762:38
at Store.transaction (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:217:22)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:756:26)
at call (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:70:44)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:86:20)
at Test.runInAsyncScope (node:async_hooks:226:14) {
code: 'decision-required'
}
test at packages/bus/tests/chief-of-staff.test.mjs:156:1
✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (14.447756ms)
Error: decision-required
at fail (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:78:9)
at #checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:458:20)
at #consumeAuthority (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:484:40)
at #dispatch (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:834:33)
at file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:762:38
at Store.transaction (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:217:22)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:756:26)
at call (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:70:44)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:168:20)
at Test.runInAsyncScope (node:async_hooks:226:14) {
code: 'decision-required'
}
test at packages/bus/tests/chief-of-staff.test.mjs:171:1
✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (11.35312ms)
AssertionError [ERR_ASSERTION]: chief
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:174:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: Error: decision-required
at fail (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:78:9)
at #checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:458:20)
at #consumeAuthority (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:484:40)
at #dispatch (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:834:33)
at file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:762:38
at Store.transaction (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:217:22)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:756:26)
at call (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:70:44)
at file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:174:25
at getActual (node:assert:580:5),
expected: /role-end-refused/,
operator: 'throws',
diff: 'simple'
}
test at packages/bus/tests/chief-of-staff.test.mjs:376:1
✖ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (41.408468ms)
Error: decision-required
at fail (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:78:9)
at #checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:458:20)
at #consumeAuthority (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:484:40)
at #dispatch (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:834:33)
at file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:762:38
at Store.transaction (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:217:22)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:756:26)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:393:22)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7) {
code: 'decision-required'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/roles/chief-of-staff.json 2026-10-15 12:00:05.687013662 -0500
+++ roles/chief-of-staff.json 2026-10-15 12:00:05.707060289 -0500
@@ -6,7 +6,7 @@
"tools": ["read", "grep", "find", "ls"],
"network": "api-only",
"authority": {
- "withinRole": ["message.send", "role.launch", "role.end", "organization.change"],
+ "withinRole": ["message.send", "role.launch", "organization.change"],
"crossRole": []
},
"credentials": []
@@ -0,0 +1,99 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (17.809455ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (16.939574ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (10.946884ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (10.847147ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (13.46099ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (14.036987ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (13.304999ms)
✖ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (10.527913ms)
✖ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (11.017493ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (14.705925ms)
✖ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (10.954894ms)
✖ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (24.550964ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (40.819067ms)
ℹ tests 13
ℹ suites 0
ℹ pass 9
ℹ fail 4
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 270.558568
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:185:1
✖ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (10.527913ms)
Error: decision-required
at fail (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:78:9)
at #checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:458:20)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:738:31)
at call (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:70:44)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:187:16)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12) {
code: 'decision-required'
}
test at packages/bus/tests/chief-of-staff.test.mjs:195:1
✖ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (11.017493ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /revision-conflict/. Input:
'Error: decision-required'
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:205:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: Error: decision-required
at fail (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:78:9)
at #checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:458:20)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:738:31)
at call (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:70:44)
at file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:205:25
at getActual (node:assert:580:5)
at strict.throws (node:assert:728:24)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:205:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25),
expected: /revision-conflict/,
operator: 'throws',
diff: 'simple'
}
test at packages/bus/tests/chief-of-staff.test.mjs:239:1
✖ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (10.954894ms)
Error: decision-required
at fail (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:78:9)
at #checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:458:20)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:738:31)
at call (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:70:44)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:241:3)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12) {
code: 'decision-required'
}
test at packages/bus/tests/chief-of-staff.test.mjs:267:1
✖ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (24.550964ms)
Error: decision-required
at fail (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:78:9)
at #checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:458:20)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:738:31)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:296:26)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
code: 'decision-required'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/roles/chief-of-staff.json 2026-10-15 12:00:05.687013662 -0500
+++ roles/chief-of-staff.json 2026-10-15 12:00:06.051065606 -0500
@@ -6,7 +6,7 @@
"tools": ["read", "grep", "find", "ls"],
"network": "api-only",
"authority": {
- "withinRole": ["message.send", "role.launch", "role.end", "organization.change"],
+ "withinRole": ["message.send", "role.launch", "role.end"],
"crossRole": []
},
"credentials": []
@@ -0,0 +1,197 @@
✖ the role file loads and resolves through loadBusiness (3.324615ms)
✖ the shipped file is exactly the approved list (1.2475ms)
✖ the resolved record matches the approved list, so a widened file fails (0.947396ms)
✖ the chief of staff holds role.launch, message.send, role.end and organization.change; every other action is gated (0.845485ms)
✖ the pm does not hold role.launch (0.83896ms)
✖ a business grant of a gated action through limits does not widen the role (0.749386ms)
✖ a limit that drops role.launch leaves the chief of staff with no launch block (0.745226ms)
✖ a launch block whose by is the pm instance is refused (1.141259ms)
✔ the contract names no duty to talk to the human and the pm contract no launch (0.549949ms)
✖ the instance takes no credential references and no tracker (0.93912ms)
ℹ tests 10
ℹ suites 0
ℹ pass 1
ℹ fail 9
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 62.317057
✖ failing tests:
test at packages/business/tests/chief-of-staff.test.mjs:49:1
✖ the role file loads and resolves through loadBusiness (3.324615ms)
Error [BusinessError]: authority.withinRole lists role.revoke, which is always gated
at refuse (file:///mnt/storage/scratch/researcher/q123/packages/business/src/errors.mjs:12:9)
at file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:41:40
at requireDistinctList (file:///mnt/storage/scratch/researcher/q123/packages/business/src/util.mjs:46:5)
at checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:39:18)
at validateRoleDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:153:16)
at loadRoleFile (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:160:10)
at loadRole (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:165:10)
at checkRoles (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:88:103)
at validateBusinessDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:150:34)
at loadBusiness (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:202:10) {
exitCode: 2
}
test at packages/business/tests/chief-of-staff.test.mjs:64:1
✖ the shipped file is exactly the approved list (1.2475ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
{
authority: {
crossRole: [],
withinRole: [
'message.send',
'role.launch',
+ 'role.revoke',
'role.end',
'organization.change'
]
},
contract: 'chief-of-staff.md',
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/business/tests/chief-of-staff.test.mjs:68:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: { roleVersion: 2, name: 'chief-of-staff', contract: 'chief-of-staff.md', tools: [ 'read', 'grep', 'find', 'ls' ], network: 'api-only', authority: { withinRole: [Array], crossRole: [] }, credentials: [] },
expected: { roleVersion: 2, name: 'chief-of-staff', contract: 'chief-of-staff.md', tools: [ 'read', 'grep', 'find', 'ls' ], network: 'api-only', authority: { withinRole: [Array], crossRole: [] }, credentials: [] },
operator: 'deepStrictEqual',
diff: 'simple'
}
test at packages/business/tests/chief-of-staff.test.mjs:76:1
✖ the resolved record matches the approved list, so a widened file fails (0.947396ms)
Error [BusinessError]: authority.withinRole lists role.revoke, which is always gated
at refuse (file:///mnt/storage/scratch/researcher/q123/packages/business/src/errors.mjs:12:9)
at file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:41:40
at requireDistinctList (file:///mnt/storage/scratch/researcher/q123/packages/business/src/util.mjs:46:5)
at checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:39:18)
at validateRoleDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:153:16)
at loadRoleFile (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:160:10)
at loadRole (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:165:10)
at checkRoles (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:88:103)
at validateBusinessDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:150:34)
at loadBusiness (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:202:10) {
exitCode: 2
}
test at packages/business/tests/chief-of-staff.test.mjs:86:1
✖ the chief of staff holds role.launch, message.send, role.end and organization.change; every other action is gated (0.845485ms)
Error [BusinessError]: authority.withinRole lists role.revoke, which is always gated
at refuse (file:///mnt/storage/scratch/researcher/q123/packages/business/src/errors.mjs:12:9)
at file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:41:40
at requireDistinctList (file:///mnt/storage/scratch/researcher/q123/packages/business/src/util.mjs:46:5)
at checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:39:18)
at validateRoleDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:153:16)
at loadRoleFile (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:160:10)
at loadRole (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:165:10)
at checkRoles (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:88:103)
at validateBusinessDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:150:34)
at loadBusiness (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:202:10) {
exitCode: 2
}
test at packages/business/tests/chief-of-staff.test.mjs:97:1
✖ the pm does not hold role.launch (0.83896ms)
Error [BusinessError]: authority.withinRole lists role.revoke, which is always gated
at refuse (file:///mnt/storage/scratch/researcher/q123/packages/business/src/errors.mjs:12:9)
at file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:41:40
at requireDistinctList (file:///mnt/storage/scratch/researcher/q123/packages/business/src/util.mjs:46:5)
at checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:39:18)
at validateRoleDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:153:16)
at loadRoleFile (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:160:10)
at loadRole (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:165:10)
at checkRoles (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:88:103)
at validateBusinessDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:150:34)
at loadBusiness (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:202:10) {
exitCode: 2
}
test at packages/business/tests/chief-of-staff.test.mjs:106:1
✖ a business grant of a gated action through limits does not widen the role (0.749386ms)
Error [BusinessError]: authority.withinRole lists role.revoke, which is always gated
at refuse (file:///mnt/storage/scratch/researcher/q123/packages/business/src/errors.mjs:12:9)
at file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:41:40
at requireDistinctList (file:///mnt/storage/scratch/researcher/q123/packages/business/src/util.mjs:46:5)
at checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:39:18)
at validateRoleDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:153:16)
at loadRoleFile (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:160:10)
at loadRole (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:165:10)
at checkRoles (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:88:103)
at validateBusinessDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:150:34)
at loadBusiness (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:202:10) {
exitCode: 2
}
test at packages/business/tests/chief-of-staff.test.mjs:118:1
✖ a limit that drops role.launch leaves the chief of staff with no launch block (0.745226ms)
Error [BusinessError]: authority.withinRole lists role.revoke, which is always gated
at refuse (file:///mnt/storage/scratch/researcher/q123/packages/business/src/errors.mjs:12:9)
at file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:41:40
at requireDistinctList (file:///mnt/storage/scratch/researcher/q123/packages/business/src/util.mjs:46:5)
at checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:39:18)
at validateRoleDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:153:16)
at loadRoleFile (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:160:10)
at loadRole (file:///mnt/storage/scratch/researcher/q123/packages/business/src/role.mjs:165:10)
at checkRoles (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:88:103)
at validateBusinessDocument (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:150:34)
at loadBusiness (file:///mnt/storage/scratch/researcher/q123/packages/business/src/business.mjs:202:10) {
exitCode: 2
}
test at packages/business/tests/chief-of-staff.test.mjs:126:1
✖ a launch block whose by is the pm instance is refused (1.141259ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /launch\.by names pm, whose role pm doesn't hold role\.launch within-role/. Input:
'authority.withinRole lists role.revoke, which is always gated'
at Object.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/business/tests/chief-of-staff.test.mjs:30:12)
at expectedException (node:assert:547:17)
at expectsError (node:assert:672:21)
at strict.throws (node:assert:728:3)
at refuses (file:///mnt/storage/scratch/researcher/q123/packages/business/tests/chief-of-staff.test.mjs:27:10)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/business/tests/chief-of-staff.test.mjs:127:3)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'authority.withinRole lists role.revoke, which is always gated',
expected: /launch\.by names pm, whose role pm doesn't hold role\.launch within-role/,
operator: 'match',
diff: 'simple'
}
test at packages/business/tests/chief-of-staff.test.mjs:145:1
✖ the instance takes no credential references and no tracker (0.93912ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /must reference exactly the services its role definition needs \(none; got gitea\)/. Input:
'authority.withinRole lists role.revoke, which is always gated'
at Object.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/business/tests/chief-of-staff.test.mjs:30:12)
at expectedException (node:assert:547:17)
at expectsError (node:assert:672:21)
at strict.throws (node:assert:728:3)
at refuses (file:///mnt/storage/scratch/researcher/q123/packages/business/tests/chief-of-staff.test.mjs:27:10)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/business/tests/chief-of-staff.test.mjs:146:3)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'authority.withinRole lists role.revoke, which is always gated',
expected: /must reference exactly the services its role definition needs \(none; got gitea\)/,
operator: 'match',
diff: 'simple'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/roles/chief-of-staff.json 2026-10-15 12:00:05.687013662 -0500
+++ roles/chief-of-staff.json 2026-10-15 12:00:06.374070596 -0500
@@ -6,7 +6,7 @@
"tools": ["read", "grep", "find", "ls"],
"network": "api-only",
"authority": {
- "withinRole": ["message.send", "role.launch", "role.end", "organization.change"],
+ "withinRole": ["message.send", "role.launch", "role.revoke", "role.end", "organization.change"],
"crossRole": []
},
"credentials": []
@@ -0,0 +1,99 @@
✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (17.872529ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (16.875803ms)
✖ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (10.150954ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (10.517412ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (12.45374ms)
✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (13.492681ms)
✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (12.100495ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (10.658562ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (14.923389ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (14.226542ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (18.390428ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (47.924215ms)
✖ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (43.19302ms)
ℹ tests 13
ℹ suites 0
ℹ pass 8
ℹ fail 5
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 305.178306
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:84:1
✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (17.872529ms)
Error: storage-refused
at #refused (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:696:47)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:767:26)
at call (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:70:44)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:86:20)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.start (node:internal/test_runner/test:1262:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) {
code: 'storage-refused'
}
test at packages/bus/tests/chief-of-staff.test.mjs:116:1
✖ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (10.150954ms)
Error: revoke needs a resolved decision or a reason
at Store.run (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:196:34)
at file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:195:66
at Store.transaction (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:217:22)
at Store.run (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:195:43)
at insert (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:121:11)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:127:3)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19) {
code: 'ERR_SQLITE_ERROR',
errcode: 1811,
errstr: 'constraint failed'
}
test at packages/bus/tests/chief-of-staff.test.mjs:156:1
✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (13.492681ms)
Error: storage-refused
at #refused (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:696:47)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:767:26)
at call (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:70:44)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:168:20)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
code: 'storage-refused'
}
test at packages/bus/tests/chief-of-staff.test.mjs:171:1
✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (12.100495ms)
Error: storage-refused
at #refused (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:696:47)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:767:26)
at call (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:70:44)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:180:3)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
code: 'storage-refused'
}
test at packages/bus/tests/chief-of-staff.test.mjs:376:1
✖ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (43.19302ms)
Error: storage-refused
at #refused (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:696:47)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:767:26)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:393:22)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
code: 'storage-refused'
}
@@ -0,0 +1,31 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/schema.sql 2026-10-15 12:00:06.478273870 -0500
+++ packages/bus/schema.sql 2026-10-15 12:00:06.495072466 -0500
@@ -124,7 +124,7 @@
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
- WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
+ WHEN NEW.op = 'revoke' AND NEW.decision IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/migrations/3c-3d.sql 2026-10-15 12:00:06.499244177 -0500
+++ packages/bus/src/migrations/3c-3d.sql 2026-10-15 12:00:06.516072790 -0500
@@ -1,5 +1,5 @@
-- Row 123 (#1617): a revoke needs a resolved decision or a reason (the chief of staff's role.end).
DROP TRIGGER role_revoke_needs_decision;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
- WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
+ WHEN NEW.op = 'revoke' AND NEW.decision IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/store.mjs 2026-10-15 12:00:06.519669829 -0500
+++ packages/bus/src/store.mjs 2026-10-15 12:00:06.538073130 -0500
@@ -13,7 +13,7 @@
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
-export const SCHEMA_SHA256 = 'e4f1de9e16d2fab5e051fd3810189b28cc76a670cfe573b752d9f53bf9ca39b5';
+export const SCHEMA_SHA256 = 'f60a271cb339141a8bddeb98a926fefda2864a12e1f5bc78291c476984ad7cc7';
export const SCHEMA_VERSION = '3d';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
@@ -0,0 +1,41 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (17.006746ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (16.261494ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (11.750918ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (11.200223ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (13.528957ms)
✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (13.937134ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (15.633073ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (11.461747ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (16.34046ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (15.226758ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (18.856332ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (42.464143ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (41.962979ms)
ℹ tests 13
ℹ suites 0
ℹ pass 12
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 307.25561
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:156:1
✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (13.937134ms)
AssertionError [ERR_ASSERTION]: Missing expected exception: {"role":"coder","reason":"why","decision":"some-decision"}
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:165:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: /invalid-request/,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:00.709982993 -0500
@@ -829,7 +829,7 @@
// reason and no decision; the reason rides in the action.allowed body. Every other caller
// still cites a resolved decision for this exact target and holder incarnation.
if ('reason' in a) {
- if ('decision' in a) fail('invalid-request');
+ void 0;
string(a.reason, 500);
this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason });
const b = this.#business(s.business);
@@ -0,0 +1,69 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (18.846632ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (17.945369ms)
✖ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (11.03369ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (10.979554ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (12.918601ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (13.882406ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (13.23966ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (10.107865ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (14.890599ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (15.223091ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (18.179345ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (38.356063ms)
✖ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (41.019779ms)
ℹ tests 13
ℹ suites 0
ℹ pass 11
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 296.810565
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:116:1
✖ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (11.03369ms)
AssertionError [ERR_ASSERTION]: Missing expected exception.
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:126:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: /revoke needs a resolved decision or a reason/,
operator: 'throws',
diff: 'simple'
}
test at packages/bus/tests/chief-of-staff.test.mjs:376:1
✖ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (41.019779ms)
AssertionError [ERR_ASSERTION]: The input did not match the regular expression /revoke needs a resolved decision or a reason/. Input:
'Error: role is not held'
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:400:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: Error: role is not held
at Store.run (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:196:34)
at file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:195:66
at Store.transaction (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:217:22)
at Store.run (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:195:43)
at file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:400:29
at getActual (node:assert:580:5)
at strict.throws (node:assert:728:24)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:400:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7),
expected: /revoke needs a resolved decision or a reason/,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,31 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/schema.sql 2026-10-15 12:00:06.478273870 -0500
+++ packages/bus/schema.sql 2026-10-15 12:00:06.898078692 -0500
@@ -124,7 +124,7 @@
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
- WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
+ WHEN 0
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/migrations/3c-3d.sql 2026-10-15 12:00:06.499244177 -0500
+++ packages/bus/src/migrations/3c-3d.sql 2026-10-15 12:00:06.918079002 -0500
@@ -1,5 +1,5 @@
-- Row 123 (#1617): a revoke needs a resolved decision or a reason (the chief of staff's role.end).
DROP TRIGGER role_revoke_needs_decision;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
- WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
+ WHEN 0
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/store.mjs 2026-10-15 12:00:06.519669829 -0500
+++ packages/bus/src/store.mjs 2026-10-15 12:00:06.941079357 -0500
@@ -13,7 +13,7 @@
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
-export const SCHEMA_SHA256 = 'e4f1de9e16d2fab5e051fd3810189b28cc76a670cfe573b752d9f53bf9ca39b5';
+export const SCHEMA_SHA256 = '93eba5f30b6e76c6c6aa2f76242cf08ffc8fd48789e8b315c8bcdb8cbccda699';
export const SCHEMA_VERSION = '3d';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
@@ -0,0 +1,26 @@
file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:67
throw Error('migration-target-mismatch');
^
Error: migration-target-mismatch
at file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:67:9
at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
at async node:internal/modules/esm/loader:650:26
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Node.js v26.8.1
✖ packages/bus/tests/chief-of-staff.test.mjs (45.593483ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 51.048326
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:1:1
✖ packages/bus/tests/chief-of-staff.test.mjs (45.593483ms)
'test failed'
@@ -0,0 +1,8 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/migrations/3c-3d.sql 2026-10-15 12:00:06.499244177 -0500
+++ packages/bus/src/migrations/3c-3d.sql 2026-10-15 12:00:07.273411946 -0500
@@ -1,5 +0,0 @@
--- Row 123 (#1617): a revoke needs a resolved decision or a reason (the chief of staff's role.end).
-DROP TRIGGER role_revoke_needs_decision;
-CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
- WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
- BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
@@ -0,0 +1 @@
1
@@ -0,0 +1,44 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (17.48694ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (16.954384ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (12.599573ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (11.407078ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (12.884822ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (14.037554ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (13.601103ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (11.0359ms)
✖ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (16.637592ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (14.549388ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (17.834602ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (43.711751ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (40.33232ms)
ℹ tests 13
ℹ suites 0
ℹ pass 12
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 308.210729
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:195:1
✖ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (16.637592ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
3 !== 0
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:212:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 3,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,19 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:05.225052839 -0500
@@ -735,7 +735,7 @@
// and consumed only once the writer has committed: a refused change logs action.refused alone,
// as a refused human limits.set does.
if (role) {
- this.#checkAuthority(s, "organization.change");
+ this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));
actor = this.#business(s.business).roles[s.role].definition + "." + s.role;
}
let result;
@@ -749,7 +749,6 @@
}
throw new BusError("outcome-unknown");
}
- if (role) this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));
this.#secretCheck(result);
return result;
}
@@ -0,0 +1,41 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (18.545658ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (18.35868ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (11.060723ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (12.353229ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (13.010306ms)
✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (11.668532ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (13.392685ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (10.611945ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (13.855071ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (13.505006ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (17.270022ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (39.808878ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (42.600272ms)
ℹ tests 13
ℹ suites 0
ℹ pass 12
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 292.27179
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:156:1
✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (11.668532ms)
AssertionError [ERR_ASSERTION]: Missing expected exception: {"role":"coder","reason":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:165:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: /invalid-request/,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:01.066988520 -0500
@@ -830,7 +830,7 @@
// still cites a resolved decision for this exact target and holder incarnation.
if ('reason' in a) {
if ('decision' in a) fail('invalid-request');
- string(a.reason, 500);
+ string(a.reason, 100000);
this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason });
const b = this.#business(s.business);
if (a.role === s.role || END_PROTECTED.includes(b.roles[a.role]?.definition)) fail('role-end-refused');
@@ -0,0 +1,41 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (18.372761ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (18.720862ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (11.878283ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (10.866388ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (12.540988ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (13.980104ms)
✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (11.001711ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (10.108914ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (14.376315ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (14.109381ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (16.854347ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (38.723177ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (41.154833ms)
ℹ tests 13
ℹ suites 0
ℹ pass 12
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 294.338611
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:171:1
✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (11.001711ms)
AssertionError [ERR_ASSERTION]: Missing expected exception: chief
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:174:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: /role-end-refused/,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:01.413993891 -0500
@@ -833,7 +833,7 @@
string(a.reason, 500);
this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason });
const b = this.#business(s.business);
- if (a.role === s.role || END_PROTECTED.includes(b.roles[a.role]?.definition)) fail('role-end-refused');
+ if (END_PROTECTED.includes(b.roles[a.role]?.definition)) fail('role-end-refused');
} else {
identifier(a.decision);
this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
@@ -0,0 +1,41 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (18.251015ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (16.329821ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (10.992459ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (11.128452ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (12.934863ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (14.079579ms)
✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (11.764329ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (10.395192ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (14.522322ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (13.890308ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (18.300655ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (44.594065ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (45.053154ms)
ℹ tests 13
ℹ suites 0
ℹ pass 12
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 305.305039
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:171:1
✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (11.764329ms)
AssertionError [ERR_ASSERTION]: Missing expected exception: assistant
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:174:12)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
expected: /role-end-refused/,
operator: 'throws',
diff: 'simple'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:01.758999231 -0500
@@ -833,7 +833,7 @@
string(a.reason, 500);
this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason });
const b = this.#business(s.business);
- if (a.role === s.role || END_PROTECTED.includes(b.roles[a.role]?.definition)) fail('role-end-refused');
+ if (a.role === s.role || false) fail('role-end-refused');
} else {
identifier(a.decision);
this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
@@ -0,0 +1,81 @@
✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (16.313238ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (17.519195ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (11.205752ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (11.658741ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (13.353967ms)
✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (14.606021ms)
✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (12.822169ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (11.475779ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (14.133784ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (14.109682ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (18.56275ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (42.144352ms)
✖ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (43.597007ms)
ℹ tests 13
ℹ suites 0
ℹ pass 9
ℹ fail 4
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 309.971891
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:84:1
✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (16.313238ms)
Error: storage-refused
at #refused (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:696:47)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:767:26)
at call (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:70:44)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:86:20)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.start (node:internal/test_runner/test:1262:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) {
code: 'storage-refused'
}
test at packages/bus/tests/chief-of-staff.test.mjs:156:1
✖ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (14.606021ms)
Error: storage-refused
at #refused (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:696:47)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:767:26)
at call (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:70:44)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:168:20)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
code: 'storage-refused'
}
test at packages/bus/tests/chief-of-staff.test.mjs:171:1
✖ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (12.822169ms)
Error: storage-refused
at #refused (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:696:47)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:767:26)
at call (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:70:44)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:180:3)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
code: 'storage-refused'
}
test at packages/bus/tests/chief-of-staff.test.mjs:376:1
✖ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (43.597007ms)
Error: storage-refused
at #refused (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:696:47)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:767:26)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:393:22)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1409:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
code: 'storage-refused'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:02.115004740 -0500
@@ -840,7 +840,7 @@
}
const h = this.#holder(s.business, a.role);
if (h?.op !== 'claim') fail('not-held');
- this.#claimEnd(s, h, 'revoke', a.decision ?? null, a.reason ?? null);
+ this.#claimEnd(s, h, 'revoke', a.decision ?? null, null);
return { revoked: true };
}
case 'decision.raise': {
@@ -0,0 +1,44 @@
✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (17.457352ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (16.658359ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (11.37142ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (10.933543ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (12.808619ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (13.711565ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (12.566058ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (9.914221ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (14.641505ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (14.141564ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (17.367796ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (41.679452ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (39.451142ms)
ℹ tests 13
ℹ suites 0
ℹ pass 12
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 289.179007
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:84:1
✖ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (17.457352ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ undefined
- 'stuck on the same test for an hour'
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:94:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.start (node:internal/test_runner/test:1262:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: undefined,
expected: 'stuck on the same test for an hour',
operator: 'strictEqual',
diff: 'simple'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:02.479010372 -0500
@@ -831,7 +831,7 @@
if ('reason' in a) {
if ('decision' in a) fail('invalid-request');
string(a.reason, 500);
- this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason });
+ this.#consumeAuthority(s, 'role.end', { target: a.role }, {});
const b = this.#business(s.business);
if (a.role === s.role || END_PROTECTED.includes(b.roles[a.role]?.definition)) fail('role-end-refused');
} else {
@@ -0,0 +1,39 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (16.857787ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (16.373235ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (11.439287ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (10.595349ms)
✖ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (12.342167ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (13.508687ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (12.68155ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (10.169111ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (15.116546ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (15.752065ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (17.65354ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (38.904404ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (40.465377ms)
ℹ tests 13
ℹ suites 0
ℹ pass 12
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 294.069102
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:136:1
✖ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (12.342167ms)
Error: decision-mismatch
at fail (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:78:9)
at #checkAuthority (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:475:7)
at #consumeAuthority (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:484:40)
at #dispatch (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:839:33)
at file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:762:38
at Store.transaction (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/store.mjs:217:22)
at Broker.request (file:///mnt/storage/scratch/researcher/q123/packages/bus/src/broker.mjs:756:26)
at call (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:70:44)
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:150:20)
at Test.runInAsyncScope (node:async_hooks:226:14) {
code: 'decision-mismatch'
}
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:02.820015648 -0500
@@ -836,7 +836,7 @@
if (a.role === s.role || END_PROTECTED.includes(b.roles[a.role]?.definition)) fail('role-end-refused');
} else {
identifier(a.decision);
- this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
+ this.#consumeAuthority(s, 'role.end', { decision: a.decision, target: a.role });
}
const h = this.#holder(s.business, a.role);
if (h?.op !== 'claim') fail('not-held');
@@ -0,0 +1,55 @@
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (16.010382ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (15.730426ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (11.530674ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (10.961111ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (13.132015ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (14.044986ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (13.496126ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (10.113187ms)
✖ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (14.500927ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (13.857639ms)
✖ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (9.550959ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (37.452543ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (39.246445ms)
ℹ tests 13
ℹ suites 0
ℹ pass 11
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 279.75667
✖ failing tests:
test at packages/bus/tests/chief-of-staff.test.mjs:195:1
✖ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (14.500927ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
0 !== 1
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:220:10)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 0,
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
test at packages/bus/tests/chief-of-staff.test.mjs:239:1
✖ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (9.550959ms)
TypeError: Cannot read properties of undefined (reading 'actor_role')
at TestContext.<anonymous> (file:///mnt/storage/scratch/researcher/q123/packages/bus/tests/chief-of-staff.test.mjs:243:22)
at Test.runInAsyncScope (node:async_hooks:226:14)
at Test.run (node:internal/test_runner/test:1402:25)
at Test.processPendingSubtests (node:internal/test_runner/test:974:18)
at Test.postRun (node:internal/test_runner/test:1542:19)
at Test.run (node:internal/test_runner/test:1467:12)
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
@@ -0,0 +1,11 @@
--- /mnt/storage/src/mosaic-stack/agents/researcher/work/queue-123/r2/mutants/.bak.hq5f18/packages/bus/src/broker.mjs 2026-10-15 12:00:00.334855540 -0500
+++ packages/bus/src/broker.mjs 2026-10-15 12:00:03.163020955 -0500
@@ -749,7 +749,7 @@
}
throw new BusError("outcome-unknown");
}
- if (role) this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));
+ if (role) void 0;
this.#secretCheck(result);
return result;
}
@@ -0,0 +1,90 @@
d3e9a2f6c4ed19b70148c063dc91f55ef8081421a7d09067acde210d7d9deb2e build.md
255a960582f1018b8d400e8d193e8e95713a078930277652d59ef4153197d9f6 candidate-manifest.sha256
570118925e5818d5dc8c8a113c9cfe4c2ee983f6d4cb670c4ced6ba092179f1d changed-paths.txt
3482c5d7e5036c56573ac3675ab596d523c224d6ba578530d494e03c89b8d375 delta-r1-to-r2.patch
b2c13b33d0076f9ed0f25bdc2d599b1024eb360a8d413f91b23821d1a78622da mutants-run.txt
b0582d394a4b4e7504588dd5451dd36ad698fc84d88759301988c7d3f3cebfea mutants-started.txt
38896cdeead792c7ce5b183d1acfde1047b12c6ec28e7ffee31b6e24c6f29bfd mutants.sh
7908d57a3c471cb14fe02ab35a3d140ed33ac759145f5319b3e04b4a60036104 mutants/control-bus.out
9a271f2a916b0b6ee6cecb2426f0b3206ef074578be55d9bc94f6f3fe3ab86aa mutants/control-bus.rc
185d05d4c5bcf073369e1ddca7bdb7c26a10b710c46ba661cbd613cf97b4fb4a mutants/control-business.out
9a271f2a916b0b6ee6cecb2426f0b3206ef074578be55d9bc94f6f3fe3ab86aa mutants/control-business.rc
3074d434c1ef6bfbc11c6f8185fd0af7cffd1f620a6a4d175e849019672b8747 mutants/m1-role-end-unchecked.out
c248ce2b3da08046a6548eba12e9b0401deac3425ca4a69559b32d1946b898d8 mutants/m1-role-end-unchecked.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m1-role-end-unchecked.rc
4f91f53082e1dcc02ebf31916249424ede331350d85720017e3420a7274e5a5a mutants/m10-key-list-widened.out
b7b525afe2465d574988f8c0a53142dfb970c3d000291807b464921221ad6fd4 mutants/m10-key-list-widened.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m10-key-list-widened.rc
dcd5ed2c9f0e4c45fa37e23161246a189171857b5f9e2d71f33d63e3f128e8af mutants/m11-revision-optional.out
aaf583722b05254b3b5742eefca4660b9add7a2576876516be79633cc421d9fa mutants/m11-revision-optional.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m11-revision-optional.rc
149f61a3efb928830f34ee18e134dad0dfa474c2cf6c1d46ec3d2fa2c04b94fc mutants/m12-wrong-actor.out
1aeaf1cd1737fa5f99a842322e6fb2ff380cd3c0b3561f659799e586d4c2574e mutants/m12-wrong-actor.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m12-wrong-actor.rc
9f0ae698709691a2c93a65137eed4e2210b64c4f038eb0f4e1a3089676d13722 mutants/m13-limits-open-to-agents.out
26b85e4f739c5c3359354a474f0eab90ede6b5a4e884070ddaec35fe7066923b mutants/m13-limits-open-to-agents.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m13-limits-open-to-agents.rc
06ac2d634a4369b80afa8a7f22dd79afbe55913a51879934c2dcba6f7fd37eef mutants/m14-invalid-change-not-refused.out
3d5f5a5a856bf1dd67ed0cff54ebb6807fa0f853716f19c5f7a07ab8c02165ca mutants/m14-invalid-change-not-refused.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m14-invalid-change-not-refused.rc
ebbbdf76b1610505f0ab7f2d460d11c375f9736df793baf2f145eab720424d1a mutants/m15-vocabulary-drops-role-end.out
478c16107c2c5ee974d6decf570aab72a28ca91b9a6a3fba6e2068ecd3245df5 mutants/m15-vocabulary-drops-role-end.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m15-vocabulary-drops-role-end.rc
36d034e158c418b20989927c99c6f1d4dfb3fefa24be23dbdecd0aa0b1f4a0d6 mutants/m16-chief-file-drops-role-end.out
a1d2f28e0d168b8b364c109e19415087616deffc5b17261e5c246241ebfd6c9c mutants/m16-chief-file-drops-role-end.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m16-chief-file-drops-role-end.rc
3c9cce995931207039ac6037cd35c4ee3e1c06816c54ec0c24331d4a4de5c3a5 mutants/m17-chief-file-drops-organization-change.out
73f070152ba971c697cffb0e7af76f83336cdd651fc70147e3d5faf52b83b039 mutants/m17-chief-file-drops-organization-change.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m17-chief-file-drops-organization-change.rc
3a8f7a6cc7cad747a08bba81646f4a5faf03ee87f6f96a54f3eb1c7a1de7d78b mutants/m18-chief-file-grants-role-revoke.out
74703e61a235efa8246ebe2ecee50c6013cab5c08d97a1bc7aa567619dd72a0d mutants/m18-chief-file-grants-role-revoke.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m18-chief-file-grants-role-revoke.rc
5abcd1944abf384406e0c3260a0acb5e16cd661e0177e9a024d45598310071a9 mutants/m19-trigger-decision-only.out
90ff61a6818f48c80ee1b7db35e78c48459e9dbd5d97fb19ce5c57d2ecb1a991 mutants/m19-trigger-decision-only.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m19-trigger-decision-only.rc
e71e853b533e6d823ce896ced46e6ff82ea150f5bc8a275f2c7d98a3b99df0a6 mutants/m2-reason-and-decision-accepted.out
901baee47d76cf1503b3c957965e952be74fe8d361f9a93ba070fdf401ea0fe4 mutants/m2-reason-and-decision-accepted.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m2-reason-and-decision-accepted.rc
4a03434538dc32fbf5b3cf58baaea67a1ea70f981ebd0ddfffc292ac14ae6716 mutants/m20-trigger-never-fires.out
4e763f46c2caf10d6476226752e2a3351cdf90cdce82dd803d1bbb8a0995ee3c mutants/m20-trigger-never-fires.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m20-trigger-never-fires.rc
e58ad64933f51c8284f6370b9dec09afb94e57d0f35bf78e0bd96bc9ee531d55 mutants/m21-step-empty.out
f11597d741d34070051dee25af4be3bfbb44bf61b13981519fb033e30b1f76fd mutants/m21-step-empty.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m21-step-empty.rc
ae1cfc84b6f4e1985963a3ff6ee0bad97dc8d3dd8dc46a70e39109c830d49a10 mutants/m22-consume-before-writer.out
46b4cd76e78b16d719ee083f65dc3926353954a1e2ac4516eb14c5181d242ab9 mutants/m22-consume-before-writer.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m22-consume-before-writer.rc
ee62288b1950e6d1702f03fbeee6e818463864db1af315a078707e3294f903d7 mutants/m3-reason-unbounded.out
c57a1e05f85770b3ec4060885575b2099ddf81d08628e93c0029176469b7e9b9 mutants/m3-reason-unbounded.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m3-reason-unbounded.rc
7a18c404b8491396eef673fc1e3660ecd78a808f7ae888ad86d5f1e450eb23b9 mutants/m4-self-end-allowed.out
53044381433cb203572be2df1516fcfe455baca2e6fe7be36f8684ad1248b15d mutants/m4-self-end-allowed.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m4-self-end-allowed.rc
52e4408785ff26374d08b8adfcdbc45f66ccc9e6daa78cd453e004e1deb5d217 mutants/m5-assistant-end-allowed.out
4213c3cb93935c9382df9d6b44a7b34ace804b6c5cc9f7d86536246088732f3b mutants/m5-assistant-end-allowed.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m5-assistant-end-allowed.rc
6530b40173fa3c3fe0cda112f933f2b73071e32042867cc01d252391c909b960 mutants/m6-reason-not-stored.out
60e7ea5bc44b9bbd520f36ca4f500e1ec8108dad3c935ee4bd9be0a1bab90464 mutants/m6-reason-not-stored.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m6-reason-not-stored.rc
66c3c51416f51a86bfbc34ec250c2c2e23c56af60b6274ca673c7fe047309962 mutants/m7-reason-not-logged.out
6cf09fcb05eea93b59d2974a7bea692465675b21381830dd4be7eb628b3705d1 mutants/m7-reason-not-logged.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m7-reason-not-logged.rc
3904aabaebc7a0aa4140c21a8be7994dd131ea48d9f971ca5273af326beaf08d mutants/m8-decision-path-wrong-action.out
d6ce05fa5436d494a9d6dfcad7d8bf992d3084fcd3f597702819f3b4997e823e mutants/m8-decision-path-wrong-action.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m8-decision-path-wrong-action.rc
07eb3ce0b53afc4ffc8f99b3fcd0c68a2941f06c9ad6b0a0677d98a725653c9e mutants/m9-organization-set-unchecked.out
3d6122297a4c1182d38f927f6c5b8f71d9e4e987b70f8faf3c6a1f8da21f6ece mutants/m9-organization-set-unchecked.patch
4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865 mutants/m9-organization-set-unchecked.rc
6fe838e6ae0eb70f0311c2440ae6e80d06d20e0802fe68cb8f6d45f6d7b0bb11 row123.patch
fd8f661918281cb57b9c37fee962d522e157bd26c22855da96b9719c4cd8e008 suites-run.txt
522398109cbae8f9637c2e66e21c523244c761c36eea672ca8fceb2322925f65 suites-started.txt
4cc52a46664e56a74832566d9038f03c14df18fe8686953d0e975a3d9249caf7 suites.sh
f74b084560f0cbddf6819f6e6a55dff6403c6b3f40007ce27319c148baabedf7 suites/bus.txt
a7d8125fe335b028386b33072909e63105b515a001162ec0971fa2452bde642d suites/business.txt
47823e3632a54703200370342aa271f84bc14908995e42fcbe3ba33d6f479cdb suites/cli.txt
e987484584512f9f395541f532149d23018ec6431b5ea86758854b0d26eca2d1 suites/harness.txt
b95fe3a422adc56b3e71234772f3a8596cf00909a4e3314dac628c68f4df72fb suites/plugins.txt
d8ba97792c5c84c26e53af2ead995978d3f3a754a9a240db75f612c01dc07d86 suites/seat.txt
aa577163f9eda27f06e860d3623df3e0988ab5d7e16ff5e8b796170325474732 suites/tasks.txt
8e2254aa3e1bb9b205ab72c68273d447c0328c9f156ccba2a46f3a0476468b58 suites/test-land-work.txt
005f92ff93bd1714078066aebc26d0eb0c94d79ec0d9aa4b1eb69bca4e52eafa suites/webui.txt
@@ -0,0 +1,997 @@
diff --git a/packages/bus/README.md b/packages/bus/README.md
index 2973590c5..3bfd35a55 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -138,7 +138,8 @@ an operator inspect content and delivery evidence after an uncertain result.
|---|---|
| `role.claim` | No args; claims capability's role/run. One current holder per business/instance; a revoked run cannot reclaim that role, including after restart. |
| `role.release` | No args; only its current holder releases. |
-| `role.revoke` | `{role,decision}`; needs human-approved gated decision for this exact target and holder incarnation. |
+| `role.revoke` | `{role,decision}`; needs human-approved gated decision for this exact target and holder incarnation. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run and the assistant's are `role-end-refused`; any other role sending `reason` gets `decision-required`. |
+| `organization.set` | `{revision,limits?,models?,instances?}`; the keys `limits.set` takes, through the same writer and receipt. Needs `organization.change` in the caller's role file (the chief of staff); the receipt's actor is `<definition>.<instance>`, e.g. `chief-of-staff.chief`. Every other key is `invalid-request`. `limits` and `limits.set` stay human-only. |
| `decision.raise` | `{action,question,options:[{key,text}],recommendation,blocking,domain?,target?,project?,task_ref?,requirement_ref?,supersedes?,choice?,approvalChoice?}`. Returns decision with decoded options and authorization context. A blocking decision holds a task: `blocking` true with no `task_ref` refuses `task-required`. A non-blocking one needs no task, and a gated action such as `prd.approve` or `role.revoke` still routes to the human's inbox. |
| `decision.resolve` | `{id,choice,note?}`; option must exist, resolver must be routed role or outside-agent human CLI. Sends the raising role a DECISION message (Decision close messages, below). |
| `decision.seen` | `{id,note?}`; acknowledgement by resolver, not a resolution. |
@@ -475,7 +476,10 @@ fixtures. It takes the same exclusive writer lock, checks the pinned source
schema and metadata, WAL, timestamps and integrity, then runs named migration
steps in order. Each step atomically adds its DDL, changes only schema version
and digest metadata, restores the metadata update guard, and verifies its target
-digest. The 3b→3c step also records an immutable `comment_cursor_epoch`. Prior
+digest. The 3b→3c step also records an immutable `comment_cursor_epoch`. The 3c→3d
+step (row 123) replaces the `role_revoke_needs_decision` trigger: a revoke needs a resolved
+decision or a reason. Storage accepts a revoke with a reason and no decision, so only the
+broker decides who may revoke that way (`role.end`, in the `role.revoke` row above). Prior
steps stay pinned under `src/migrations/`; later versions append a step. A completed
step remains valid if a later step refuses; rerunning resumes from that version.
Already-current migration is a validated no-op. Missing stores refuse.
diff --git a/packages/bus/schema.sql b/packages/bus/schema.sql
index e68424252..337ae80b9 100644
--- a/packages/bus/schema.sql
+++ b/packages/bus/schema.sql
@@ -124,8 +124,8 @@ CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
- WHEN NEW.op = 'revoke' AND NEW.decision IS NULL
- BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision'); END;
+ WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
+ BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 7c281d635..ecb18f8b8 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -20,6 +20,8 @@ export const ACTIONS = Object.freeze([
'message.external',
'role.launch',
'role.revoke',
+ 'role.end',
+ 'organization.change',
'credential.mint',
'spend',
'deploy',
@@ -93,6 +95,10 @@ function identifier(x) {
const taskRef = (x) => typeof x === 'string' && /^vikunja:[1-9][0-9]*\/[1-9][0-9]*$/.test(x);
// A git commit id, SHA-1 or SHA-256.
const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x);
+// Row 123 (#1617): the one list of organization-file keys limits.set and organization.set accept.
+const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances'];
+// Row 123 (#1617): role.end never reaches the assistant (the user's seat) or the caller's own run.
+const END_PROTECTED = ['assistant'];
const VERDICTS = ['approve', 'changes'];
// Row 93 (#1587): the review loop's evidence, written only by the verbs below. A push is an
// action.allowed for git.push.working that names its commit, written only by recordPush.
@@ -715,14 +721,26 @@ export class Broker {
const args = request.args ?? {};
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
- if (["limits", "limits.set"].includes(request.verb)) {
- if (!s.human) fail("human-required");
+ if (["limits", "limits.set", "organization.set"].includes(request.verb)) {
+ // Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
+ // file lists organization.change. It takes the same keys, runs the same write path and
+ // logs the same receipt; only the actor differs: the role definition and instance.
+ const role = request.verb === "organization.set";
+ if (!role && !s.human) fail("human-required");
if (!this.#organization) fail("not-configured");
if (request.verb === "limits") keys(args, []);
- else keys(args, ["revision", "limits", "models", "instances"], ["revision"]);
+ else keys(args, ORGANIZATION_KEYS, ["revision"]);
+ let actor = s.human;
+ // This branch runs outside the store transaction below, so the grant is checked before the writer
+ // and consumed only once the writer has committed: a refused change logs action.refused alone,
+ // as a refused human limits.set does.
+ if (role) {
+ this.#checkAuthority(s, "organization.change");
+ actor = this.#business(s.business).roles[s.role].definition + "." + s.role;
+ }
let result;
try {
- result = this.#organization(s.business, request.verb, args, s.human);
+ result = this.#organization(s.business, role ? "limits.set" : request.verb, args, actor);
} catch (e) {
if (e.code === "revision-conflict") fail("revision-conflict");
if (e.code?.startsWith("organization-")) fail(e.code);
@@ -731,6 +749,7 @@ export class Broker {
}
throw new BusError("outcome-unknown");
}
+ if (role) this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));
this.#secretCheck(result);
return result;
}
@@ -804,13 +823,24 @@ export class Broker {
return { released: true };
}
case 'role.revoke': {
- keys(a, ['role', 'decision'], ['role', 'decision']);
+ keys(a, ['role', 'decision', 'reason'], ['role']);
identifier(a.role);
- identifier(a.decision);
- this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
+ // Row 123 (#1617, REQ-ROLE-2, REQ-COS-1): a role whose file lists role.end ends a run with a
+ // reason and no decision; the reason rides in the action.allowed body. Every other caller
+ // still cites a resolved decision for this exact target and holder incarnation.
+ if ('reason' in a) {
+ if ('decision' in a) fail('invalid-request');
+ string(a.reason, 500);
+ this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason });
+ const b = this.#business(s.business);
+ if (a.role === s.role || END_PROTECTED.includes(b.roles[a.role]?.definition)) fail('role-end-refused');
+ } else {
+ identifier(a.decision);
+ this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
+ }
const h = this.#holder(s.business, a.role);
if (h?.op !== 'claim') fail('not-held');
- this.#claimEnd(s, h, 'revoke', a.decision);
+ this.#claimEnd(s, h, 'revoke', a.decision ?? null, a.reason ?? null);
return { revoked: true };
}
case 'decision.raise': {
@@ -1367,9 +1397,10 @@ export class Broker {
).map((m) => ({ id: m.id, from: m.from, run: m.run, at: m.at }));
return { ...c, state, working, inputNeeded, stranded };
}
- #claimEnd(s, h, op, decision) {
+ // Row 123 (#1617): a role.end revoke carries its reason in role_claims.reason and no decision.
+ #claimEnd(s, h, op, decision, reason = null) {
this.#store.run(
- 'INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by,decision) VALUES(?,?,?,?,?,?,?,?,?)',
+ 'INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by,decision,reason) VALUES(?,?,?,?,?,?,?,?,?,?)',
this.#now(),
s.business,
h.role,
@@ -1379,6 +1410,7 @@ export class Broker {
h.address,
s.run ?? s.human,
decision,
+ reason,
);
}
// A message's originating human.input: a human instruction's own, or for the
diff --git a/packages/bus/src/migrations/3c-3d.sql b/packages/bus/src/migrations/3c-3d.sql
new file mode 100644
index 000000000..af600bae5
--- /dev/null
+++ b/packages/bus/src/migrations/3c-3d.sql
@@ -0,0 +1,5 @@
+-- Row 123 (#1617): a revoke needs a resolved decision or a reason (the chief of staff's role.end).
+DROP TRIGGER role_revoke_needs_decision;
+CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
+ WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
+ BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
diff --git a/packages/bus/src/store.mjs b/packages/bus/src/store.mjs
index 0c4d14f2d..99e1f1ac0 100644
--- a/packages/bus/src/store.mjs
+++ b/packages/bus/src/store.mjs
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
-export const SCHEMA_SHA256 = '7587ac1df813e5148fdb73dc4733ec8064e6a142e4647f0da71be009a73eebdb';
-export const SCHEMA_VERSION = '3c';
+export const SCHEMA_SHA256 = 'e4f1de9e16d2fab5e051fd3810189b28cc76a670cfe573b752d9f53bf9ca39b5';
+export const SCHEMA_VERSION = '3d';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -25,6 +25,10 @@ const migrations = [{
initialize: (store) => store.run(
'INSERT INTO meta(key,value) VALUES (?,?)', 'comment_cursor_epoch', new Date().toISOString(),
),
+}, {
+ from: '3c',
+ to: '3d',
+ sql: readFileSync(new URL('./migrations/3c-3d.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs
new file mode 100644
index 000000000..a224c7546
--- /dev/null
+++ b/packages/bus/tests/chief-of-staff.test.mjs
@@ -0,0 +1,401 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { chmodSync, copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { createHash } from 'node:crypto';
+import { DatabaseSync } from 'node:sqlite';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { Store, schemaDigest } from '../src/store.mjs';
+import { Broker } from '../src/broker.mjs';
+import { startBroker } from '../src/runtime.mjs';
+import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
+
+// Row 123 (#1617): the chief of staff ends runs without a decision and changes the organization
+// file through the shared write path. REQ-ROLE-2 (one holder; revoke), REQ-ROLE-3 (the
+// organization file), REQ-LIMIT-1 (limits are settings), REQ-COS-1 (the chief of staff runs the
+// organization). The grant is read from the shipped roles/chief-of-staff.json, so the test pins the
+// file the stack runs, not a copy of it.
+const shipped = (name) =>
+ JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
+const chiefAuthority = shipped('chief-of-staff').authority;
+const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
+
+const roles = {
+ chief: { definition: 'chief-of-staff', authority: chiefAuthority },
+ assistant: { definition: 'assistant', ...none },
+ pm: { definition: 'pm', ...none },
+ coder: { definition: 'coder', ...none },
+};
+const demo = () => ({
+ demo: {
+ id: 'demo',
+ human: 'jason',
+ arbiters: { technical: 'pm', delivery: 'chief' },
+ roles,
+ definitions: {},
+ projects: { stack: { root: '/nonexistent' } },
+ vars: { 'bus.tester': true },
+ },
+});
+
+const PATCH = { revision: 'a'.repeat(64), limits: { total: 3 } };
+
+function setup(t) {
+ const root = mkdtempSync(join(tmpdir(), 'bus-chief-'));
+ const store = new Store(root);
+ t.after(() => {
+ store.close();
+ rmSync(root, { recursive: true, force: true });
+ });
+ const organization = [];
+ const writer = { error: null };
+ const b = new Broker({
+ store,
+ businesses: demo(),
+ organization: (...args) => {
+ organization.push(args);
+ if (writer.error) throw writer.error;
+ return { revision: 'b'.repeat(64) };
+ },
+ });
+ const caps = {};
+ for (const role of Object.keys(roles)) {
+ caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
+ b.request(caps[role], { verb: 'role.claim' });
+ }
+ caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+ caps.reader = b.bindReader({ business: 'demo' });
+ caps.tester = b.bindTester({ business: 'demo' });
+ const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
+ const lastClaim = (role) => ({
+ ...store.get('SELECT op, decision, reason, by FROM role_claims WHERE role=? ORDER BY rowid DESC LIMIT 1', role),
+ });
+ const allowed = (action) =>
+ store
+ .all("SELECT actor_role, subject, body FROM events WHERE kind='action.allowed' ORDER BY seq")
+ .map((e) => ({ ...e, body: JSON.parse(e.body) }))
+ .filter((e) => e.body.action === action);
+ const refused = () =>
+ store.all("SELECT actor_role, body FROM events WHERE kind='action.refused' ORDER BY seq").map((e) => ({ ...e, body: JSON.parse(e.body) }));
+ return { b, store, caps, call, lastClaim, allowed, refused, organization, writer };
+}
+
+test('REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged', (t) => {
+ const { call, lastClaim, allowed } = setup(t);
+ assert.deepEqual(call('chief', 'role.revoke', { role: 'coder', reason: 'stuck on the same test for an hour' }), {
+ revoked: true,
+ });
+ assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: null, reason: 'stuck on the same test for an hour', by: 'chief-run' });
+ const [event, ...rest] = allowed('role.end');
+ assert.equal(rest.length, 0);
+ assert.equal(event.actor_role, 'chief');
+ assert.equal(event.subject, 'coder');
+ assert.equal(event.body.reason, 'stuck on the same test for an hour');
+ assert.equal(event.body.target, 'coder');
+ assert.equal(event.body.class, 'within-role');
+ assert.ok(!('decision' in event.body));
+ // The coder's run is over: it no longer holds the role.
+ assert.throws(() => call('coder', 'message.send', { to: 'pm', body: 'hi' }), /not-holder/);
+});
+
+test('REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke', (t) => {
+ const { call, lastClaim, allowed } = setup(t);
+ for (const who of ['pm', 'coder', 'assistant']) {
+ assert.throws(() => call(who, 'role.revoke', { role: 'coder', reason: 'no decision' }), /decision-required/, who);
+ assert.throws(() => call(who, 'role.revoke', { role: 'coder' }), /invalid-request/, who);
+ }
+ assert.deepEqual(lastClaim('coder'), { op: 'claim', decision: null, reason: null, by: 'coder-run' });
+ assert.equal(allowed('role.end').length, 0);
+ // The human cannot use the reason path either: it has no role to hold role.end.
+ assert.throws(() => call('human', 'role.revoke', { role: 'coder', reason: 'no' }), /agent-required/);
+ assert.throws(() => call('reader', 'role.revoke', { role: 'coder', reason: 'no' }), /read-only/);
+ assert.throws(() => call('tester', 'role.revoke', { role: 'coder', reason: 'no' }), /tester-refused/);
+});
+
+test('REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither', (t) => {
+ // Storage does not decide who may revoke by reason; the broker does (tests above). It only
+ // refuses a revoke that carries nothing.
+ const { store } = setup(t);
+ const insert = (decision, reason) =>
+ store.run(
+ "INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by,decision,reason) VALUES('2026-10-15T00:00:00.000Z','demo','pm','revoke','pm-run','pi',NULL,'chief-run',?,?)",
+ decision,
+ reason,
+ );
+ assert.throws(() => insert(null, null), /revoke needs a resolved decision or a reason/);
+ insert(null, 'a reason');
+});
+
+test('REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path', (t) => {
+ const { call, lastClaim } = setup(t);
+ assert.throws(() => call('chief', 'role.revoke', { role: 'coder', decision: 'missing' }), /decision-not-found/);
+ assert.deepEqual(lastClaim('coder'), { op: 'claim', decision: null, reason: null, by: 'coder-run' });
+});
+
+test('REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason', (t) => {
+ const { call, lastClaim, allowed } = setup(t);
+ const d = call('pm', 'decision.raise', {
+ action: 'role.revoke',
+ target: 'coder',
+ question: 'Stop the coder?',
+ options: [
+ { key: 'yes', text: 'Yes' },
+ { key: 'no', text: 'No' },
+ ],
+ recommendation: 'yes',
+ blocking: false,
+ });
+ call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
+ assert.deepEqual(call('pm', 'role.revoke', { role: 'coder', decision: d.id }), { revoked: true });
+ assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: d.id, reason: null, by: 'pm-run' });
+ assert.equal(allowed('role.revoke').filter((e) => e.body.class === 'gated' && e.body.decision === d.id && e.body.target === 'coder').length, 1);
+ assert.equal(allowed('role.end').length, 0);
+});
+
+test('REQ-ROLE-2: a reason is required, bounded, and never sent with a decision', (t) => {
+ const { call, lastClaim } = setup(t);
+ for (const args of [
+ { role: 'coder', reason: '' },
+ { role: 'coder', reason: 'x'.repeat(501) },
+ { role: 'coder', reason: 7 },
+ { role: 'coder', reason: 'why', decision: 'some-decision' },
+ { role: 'coder', reason: 'why', extra: true },
+ ])
+ assert.throws(() => call('chief', 'role.revoke', args), /invalid-request/, JSON.stringify(args).slice(0, 60));
+ assert.deepEqual(lastClaim('coder'), { op: 'claim', decision: null, reason: null, by: 'coder-run' });
+ // The bound is inclusive.
+ assert.deepEqual(call('chief', 'role.revoke', { role: 'coder', reason: 'x'.repeat(500) }), { revoked: true });
+});
+
+test('REQ-COS-1: the chief of staff cannot end its own run or the assistant\'s through role.end', (t) => {
+ const { call, lastClaim, allowed } = setup(t);
+ for (const role of ['chief', 'assistant'])
+ assert.throws(() => call('chief', 'role.revoke', { role, reason: 'no' }), /role-end-refused/, role);
+ assert.deepEqual(lastClaim('chief'), { op: 'claim', decision: null, reason: null, by: 'chief-run' });
+ assert.deepEqual(lastClaim('assistant'), { op: 'claim', decision: null, reason: null, by: 'assistant-run' });
+ // The refusal rolls its action.allowed back with the transaction.
+ assert.equal(allowed('role.end').length, 0);
+ // A role that is not held is refused as before, and logs nothing.
+ call('chief', 'role.revoke', { role: 'pm', reason: 'first' });
+ assert.throws(() => call('chief', 'role.revoke', { role: 'pm', reason: 'again' }), /not-held/);
+ assert.equal(allowed('role.end').length, 1);
+});
+
+test('REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor', (t) => {
+ const { call, organization } = setup(t);
+ const done = call('chief', 'organization.set', PATCH);
+ assert.deepEqual(done, { revision: 'b'.repeat(64) });
+ // Same callback verb as a human's limits.set; only the actor differs.
+ assert.deepEqual(organization, [['demo', 'limits.set', PATCH, 'chief-of-staff.chief']]);
+ call('human', 'limits.set', PATCH);
+ assert.deepEqual(organization.at(-1), ['demo', 'limits.set', PATCH, 'jason']);
+});
+
+test('REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set', (t) => {
+ const { call, allowed, refused, organization, writer } = setup(t);
+ // The grant is consumed only after the writer commits, so a refusal leaves no allowed event behind.
+ const codes = [];
+ for (const [error, code] of [
+ [Object.assign(new Error('stale'), { code: 'revision-conflict' }), 'revision-conflict'],
+ [Object.assign(new Error('bad'), { name: 'BusinessError' }), 'invalid-request'],
+ [new Error('disk'), 'outcome-unknown'],
+ ]) {
+ writer.error = error;
+ assert.throws(() => call('chief', 'organization.set', PATCH), new RegExp(code));
+ assert.throws(() => call('human', 'limits.set', PATCH), new RegExp(code));
+ codes.push(code, code);
+ }
+ // A refusal before the writer (a key outside the write path) leaves the same trace.
+ assert.throws(() => call('chief', 'organization.set', { ...PATCH, human: 'mallory' }), /invalid-request/);
+ assert.equal(organization.length, 6);
+ assert.equal(allowed('organization.change').length, 0);
+ assert.deepEqual(refused().map((e) => [e.actor_role, e.body.code]), [
+ ...codes.map((code, i) => [i % 2 ? null : 'chief', code]), // a human has no role
+ ['chief', 'invalid-request'],
+ ]);
+ // A change that commits leaves exactly one allowed event, and no further refusal.
+ writer.error = null;
+ call('chief', 'organization.set', PATCH);
+ assert.equal(allowed('organization.change').length, 1);
+ assert.equal(refused().length, 7);
+});
+
+test('REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change', (t) => {
+ const { call, organization, allowed } = setup(t);
+ for (const who of ['pm', 'coder', 'assistant'])
+ assert.throws(() => call(who, 'organization.set', PATCH), /decision-required/, who);
+ assert.throws(() => call('human', 'organization.set', PATCH), /agent-required/);
+ assert.throws(() => call('reader', 'organization.set', PATCH), /read-only/);
+ assert.throws(() => call('tester', 'organization.set', PATCH), /tester-refused/);
+ assert.equal(organization.length, 0);
+ assert.equal(allowed('organization.change').length, 0);
+ // limits.set and limits stay with the human.
+ for (const verb of ['limits', 'limits.set'])
+ assert.throws(() => call('chief', verb, verb === 'limits' ? {} : PATCH), /human-required/, verb);
+ assert.equal(organization.length, 0);
+});
+
+test('REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human\'s keys', (t) => {
+ const { call, organization, allowed } = setup(t);
+ call('chief', 'organization.set', PATCH);
+ const [event] = allowed('organization.change');
+ assert.equal(event.actor_role, 'chief');
+ assert.equal(event.body.class, 'within-role');
+ // Fields outside the write path are refused for the chief of staff exactly as for a human, and
+ // nothing reaches the writer.
+ const outside = [
+ { launch: { by: 'coder' } },
+ { human: 'mallory' },
+ { arbiters: { technical: 'coder', delivery: 'coder' } },
+ { roles: {} },
+ { actor: 'jason' },
+ { 'launch.by': 'coder' },
+ ];
+ const before = organization.length;
+ for (const extra of outside)
+ for (const [who, verb] of [['chief', 'organization.set'], ['human', 'limits.set']])
+ assert.throws(
+ () => call(who, verb, { ...PATCH, ...extra }),
+ /invalid-request/,
+ `${who} ${Object.keys(extra)[0]}`,
+ );
+ assert.throws(() => call('chief', 'organization.set', { limits: { total: 1 } }), /invalid-request/);
+ assert.equal(organization.length, before);
+});
+
+test('REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human\'s; an invalid one is refused', async (t) => {
+ const root = mkdtempSync(join(tmpdir(), 'bus-chief-runtime-'));
+ const rolesDir = rolesCopy(root);
+ const file = join(root, 'businesses/demo.json');
+ writeJson(file, businessDoc(root, 'demo'));
+ mkdirSync(join(root, 'data'), { mode: 0o700 });
+ const bus = await startBroker({
+ dataRoot: join(root, 'data'),
+ businesses: demo(),
+ organization: { dir: root, rolesDir, system: systemFor(root) },
+ });
+ t.after(async () => {
+ await bus.close();
+ rmSync(root, { recursive: true, force: true });
+ });
+ const { broker } = bus;
+ const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+ const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
+ broker.request(chief, { verb: 'role.claim' });
+ const receipts = () =>
+ readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
+ const document = () => JSON.parse(readFileSync(file, 'utf8'));
+
+ // A human's change, for comparison.
+ const first = broker.request(human, { verb: 'limits' });
+ const byHuman = broker.request(human, { verb: 'limits.set', args: { revision: first.revision, limits: { total: 5 } } });
+ assert.equal(document().launch.limits.total, 5);
+
+ // The chief of staff's change: validated, written, logged, with the same shape of receipt.
+ const byChief = broker.request(chief, {
+ verb: 'organization.set',
+ args: { revision: byHuman.revision, limits: { total: 9 }, models: document().launch.models },
+ });
+ assert.equal(byChief.limits.total, 9);
+ assert.equal(document().launch.limits.total, 9);
+ const [h, c] = receipts().filter((r) => r.state === 'committed');
+ assert.equal(h.actor, 'jason');
+ assert.equal(c.actor, 'chief-of-staff.chief');
+ assert.deepEqual(Object.keys(c).sort(), Object.keys(h).sort());
+ assert.equal(c.after, byChief.revision);
+ assert.equal(c.business, 'demo');
+ assert.ok(receipts().some((r) => r.state === 'prepared' && r.transaction === c.transaction));
+
+ // An invalid change is refused, leaves the file and the receipts alone, and names no state change.
+ const bytes = readFileSync(file, 'utf8');
+ const count = receipts().length;
+ for (const args of [
+ { revision: byChief.revision, limits: { total: -1 } },
+ { revision: byChief.revision, limits: { total: 'many' } },
+ { revision: byChief.revision, limits: { surprise: 1 } },
+ { revision: byChief.revision, instances: { chief: 'no-such-model' } },
+ { revision: byChief.revision, instances: { nobody: 'claude-opus-5-5' } },
+ { revision: byChief.revision, models: {} },
+ ])
+ assert.throws(
+ () => broker.request(chief, { verb: 'organization.set', args }),
+ /invalid-request|unknown-model/,
+ JSON.stringify(args),
+ );
+ assert.throws(
+ () => broker.request(chief, { verb: 'organization.set', args: { revision: byHuman.revision, limits: { total: 1 } } }),
+ /revision-conflict/,
+ );
+ assert.equal(readFileSync(file, 'utf8'), bytes);
+ assert.equal(receipts().length, count);
+});
+
+// REQ-ROLE-2, REQ-COS-1: the 3c→3d step. The 3c database comes out of row 49's real 3b→3c step, run by a
+// copy of the engine pinned at 3c (its schema.sql is tests/fixtures/schema-3c.sql), not hand-built DDL.
+const here = (path) => new URL(path, import.meta.url);
+const REVOKE_NEITHER =
+ "INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by,decision,reason) VALUES('2026-10-15T00:00:00.000Z','demo','coder','revoke','coder-run','pi',NULL,'chief-run',NULL,NULL)";
+async function database3c(t) {
+ const root = mkdtempSync(join(tmpdir(), 'bus-chief-migrate-'));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const ddl3b = readFileSync(here('./fixtures/schema-3b.sql'), 'utf8');
+ mkdirSync(join(root, 'bus'), { mode: 0o700 });
+ const path = join(root, 'bus/bus.sqlite');
+ const db = new DatabaseSync(path);
+ db.exec(ddl3b);
+ db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3b');
+ db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
+ db.prepare('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)')
+ .run('preserved', '2026-10-15T00:00:00.000Z', 'demo', 'action.allowed', '{"fixture":true}');
+ db.prepare(
+ "INSERT INTO role_claims(at,business,role,op,holder_run,harness,address,by) VALUES('2026-10-15T00:00:00.000Z','demo','coder','claim','coder-run','pi',NULL,'coder-run')",
+ ).run();
+ db.close();
+ chmodSync(path, 0o600);
+ // The engine pinned at 3c: current store.mjs with the 3c→3d step removed and the 3c schema.
+ const ddl3c = readFileSync(here('./fixtures/schema-3c.sql'), 'utf8');
+ const moduleRoot = join(root, 'engine-3c');
+ mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
+ let source = readFileSync(here('../src/store.mjs'), 'utf8');
+ const pinned = source
+ .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
+ .replace("export const SCHEMA_VERSION = '3d';", "export const SCHEMA_VERSION = '3c';")
+ .replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
+ assert.notEqual(pinned, source);
+ assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
+ writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
+ writeFileSync(join(moduleRoot, 'schema.sql'), ddl3c);
+ for (const name of ['3b.sql', '3b-3c.sql'])
+ copyFileSync(here('../src/migrations/' + name), join(moduleRoot, 'src/migrations', name));
+ const { Store: Store3c } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
+ new Store3c(root, { migrateSchema: true }).close();
+ return { root, path };
+}
+
+test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused', async (t) => {
+ const { root, path } = await database3c(t);
+ // The fixture really is 3c: the old trigger refuses a revoke that carries only a reason.
+ const old = new DatabaseSync(path);
+ assert.equal(old.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value, '3c');
+ assert.throws(() => old.exec(REVOKE_NEITHER.replace('NULL,NULL)', "NULL,'a reason')")), /revoke needs a resolved decision$/);
+ old.close();
+ // A 3c database does not open as 3d until the explicit step runs.
+ assert.throws(() => new Store(root), /schema-mismatch/);
+ const store = new Store(root, { migrateSchema: true });
+ t.after(() => store.close());
+ assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3d');
+ assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
+ assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
+ const b = new Broker({ store, businesses: demo(), organization: null });
+ const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
+ b.request(chief, { verb: 'role.claim' });
+ assert.deepEqual(b.request(chief, { verb: 'role.revoke', args: { role: 'coder', reason: 'migrated and ended' } }), {
+ revoked: true,
+ });
+ assert.deepEqual(
+ { ...store.get("SELECT op, decision, reason FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1") },
+ { op: 'revoke', decision: null, reason: 'migrated and ended' },
+ );
+ assert.throws(() => store.run(REVOKE_NEITHER), /revoke needs a resolved decision or a reason/);
+});
diff --git a/packages/bus/tests/comment-migration.test.mjs b/packages/bus/tests/comment-migration.test.mjs
index 8d730e1eb..d2ac6928a 100644
--- a/packages/bus/tests/comment-migration.test.mjs
+++ b/packages/bus/tests/comment-migration.test.mjs
@@ -28,12 +28,12 @@ function legacy(t) {
return { root, source, path };
}
-test('explicit 3b copy migration preserves evidence and opens as 3c; normal startup never migrates', t => {
+test('explicit 3b copy migration preserves evidence and opens as 3d; normal startup never migrates', t => {
const { root, source } = legacy(t);
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
- assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3c');
+ assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3d');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -62,7 +62,7 @@ test('migration refuses active writers and drifted legacy schemas, with no parti
assert.throws(() => new Store(other.root, { migrateSchema: true }), /writer-locked/);
});
-test('named migration registry chains 3b through 3c to a fixture-only future version in order', async t => {
+test('named migration registry chains 3b through 3d to a fixture-only future version in order', async t => {
const { root } = legacy(t);
const { writeFileSync } = await import('node:fs');
const { pathToFileURL } = await import('node:url');
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3c to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
- .replace("export const SCHEMA_VERSION = '3c';", "export const SCHEMA_VERSION = '3d';")
- .replace('}];\nconst timedTables', `}, { from: '3c', to: '3d', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
+ .replace("export const SCHEMA_VERSION = '3d';", "export const SCHEMA_VERSION = '3e';")
+ .replace('}];\nconst timedTables', `}, { from: '3d', to: '3e', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
- for (const name of ['3b.sql', '3b-3c.sql'])
+ for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
- assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3d');
+ assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3e');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
- assert.match(migrated.stdout, /bus schema 3c ready/);
+ assert.match(migrated.stdout, /bus schema 3d ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
diff --git a/packages/bus/tests/fixtures/schema-3c.sql b/packages/bus/tests/fixtures/schema-3c.sql
new file mode 100644
index 000000000..e68424252
--- /dev/null
+++ b/packages/bus/tests/fixtures/schema-3c.sql
@@ -0,0 +1,229 @@
+PRAGMA journal_mode = WAL;
+PRAGMA foreign_keys = ON;
+CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
+CREATE TABLE events (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ id TEXT NOT NULL UNIQUE,
+ at TEXT NOT NULL,
+ business TEXT NOT NULL,
+ kind TEXT NOT NULL CHECK (kind IN (
+ 'session.launched',
+ 'session.ended',
+ 'action.allowed',
+ 'action.refused',
+ 'task.created',
+ 'task.assigned',
+ 'task.state',
+ 'task.closed',
+ 'task.changed.external',
+ 'task.conflict',
+ 'task.missing',
+ 'review.requested',
+ 'review.verdict',
+ 'human.input',
+ 'config.refused',
+ 'credential.expiring',
+ 'credential.expired',
+ 'credential.changed',
+ 'launch.revoked',
+ 'launch.restored',
+ 'digest.sent')),
+ actor_role TEXT, actor_run TEXT,
+ subject TEXT,
+ corrects TEXT REFERENCES events(id),
+ body TEXT NOT NULL CHECK (json_valid(body))
+) STRICT;
+CREATE TABLE role_claims (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ at TEXT NOT NULL,
+ business TEXT NOT NULL, role TEXT NOT NULL,
+ op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
+ holder_run TEXT NOT NULL,
+ harness TEXT NOT NULL, address TEXT,
+ by TEXT NOT NULL, reason TEXT,
+ decision TEXT
+) STRICT;
+CREATE TABLE decisions (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ id TEXT NOT NULL UNIQUE,
+ at TEXT NOT NULL,
+ business TEXT NOT NULL, project TEXT,
+ raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
+ class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
+ action TEXT NOT NULL,
+ route_to TEXT NOT NULL,
+ question TEXT NOT NULL,
+ options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
+ recommendation TEXT NOT NULL,
+ task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
+ blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
+ supersedes TEXT REFERENCES decisions(id)
+) STRICT;
+CREATE TABLE decision_events (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ decision TEXT NOT NULL REFERENCES decisions(id),
+ at TEXT NOT NULL,
+ op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
+ by TEXT NOT NULL,
+ choice TEXT, note TEXT, via TEXT
+) STRICT;
+CREATE TABLE messages (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ id TEXT NOT NULL UNIQUE,
+ at TEXT NOT NULL,
+ business TEXT NOT NULL,
+ from_role TEXT NOT NULL, from_run TEXT NOT NULL,
+ to_role TEXT NOT NULL,
+ class TEXT NOT NULL,
+ in_reply_to TEXT REFERENCES messages(id),
+ decision TEXT REFERENCES decisions(id),
+ corrects TEXT REFERENCES messages(id),
+ body TEXT NOT NULL
+) STRICT;
+CREATE TABLE deliveries (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ message TEXT NOT NULL REFERENCES messages(id),
+ at TEXT NOT NULL,
+ op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
+ holder_run TEXT, transport TEXT, address TEXT, detail TEXT
+) STRICT;
+CREATE TABLE task_snapshots (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ at TEXT NOT NULL,
+ business TEXT NOT NULL,
+ task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
+ updated TEXT NOT NULL,
+ etag TEXT,
+ digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
+ fields TEXT NOT NULL CHECK (json_valid(fields)),
+ source TEXT NOT NULL CHECK (source IN ('self','poll')),
+ via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
+ read_at TEXT,
+ role TEXT, run TEXT,
+ CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
+ CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
+ CHECK (json_type(fields, '$.bucket') IS 'integer'
+ OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
+) STRICT;
+CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
+CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
+ WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
+ SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
+ BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
+CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
+ WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
+ SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
+ BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
+CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
+ WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
+ BEGIN SELECT RAISE(ABORT, 'role already held'); END;
+CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
+ WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
+ BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
+CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
+ WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
+ BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
+CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
+ WHEN NEW.op = 'revoke' AND NEW.decision IS NULL
+ BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision'); END;
+CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
+CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
+CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
+CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
+CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
+CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
+CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
+CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
+CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
+CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
+CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
+CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
+CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
+CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
+CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
+CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
+CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
+CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
+CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
+CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
+CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
+CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
+CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
+CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
+CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
+ WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
+ BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
+CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
+ WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
+ BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
+CREATE TRIGGER events_credential_body BEFORE INSERT ON events
+ WHEN NEW.kind GLOB 'credential.*' AND (
+ json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
+ OR json_extract(NEW.body, '$.instance') IS NULL)
+ BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
+CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
+ WHEN NEW.kind = 'task.missing' AND (
+ json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
+ OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
+ BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
+CREATE TRIGGER events_task_subject BEFORE INSERT ON events
+ WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
+ BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
+CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
+ WHEN NEW.kind = 'task.created' AND (
+ json_type(NEW.body, '$.request') IS NOT 'text'
+ OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
+ AND kind = 'human.input' AND business = NEW.business)
+ OR json_type(NEW.body, '$.requirement') IS NOT 'text'
+ OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
+ OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
+ BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
+CREATE VIEW launch_state AS
+ SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
+ FROM events e WHERE kind IN ('launch.revoked','launch.restored')
+ AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
+CREATE VIEW task_external_changes AS
+ SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
+ FROM task_snapshots p
+ LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
+ WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
+ WHERE p.source = 'poll'
+ AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
+ AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
+CREATE VIEW task_current AS
+ SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
+ FROM task_snapshots s
+ WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
+ WHERE c.business = s.business AND c.task_ref = s.task_ref
+ AND (c.source = 'self'
+ OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
+ WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
+ ORDER BY ls.seq DESC LIMIT 1), '')));
+CREATE VIEW tasks_open AS
+ SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
+ FROM task_current s
+ WHERE json_type(s.fields, '$.gone') IS NULL
+ AND json_extract(s.fields, '$.done') = 0;
+CREATE VIEW urgent_inbox AS
+ SELECT d.id, d.business, d.task_ref, d.question, d.at
+ FROM decisions d
+ WHERE d.class = 'gated' AND d.blocking = 1
+ AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
+CREATE TABLE task_comment_cursors (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ business TEXT NOT NULL,
+ task_id INTEGER NOT NULL CHECK (task_id > 0),
+ last_comment INTEGER NOT NULL CHECK (last_comment > 0)
+) STRICT;
+CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
+CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
+ BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
+CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
+ BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
+CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
+ WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
+ BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
+CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
+ WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
+ WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
+ BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
diff --git a/packages/business/README.md b/packages/business/README.md
index d2967cb33..1d3802b87 100644
--- a/packages/business/README.md
+++ b/packages/business/README.md
@@ -123,8 +123,11 @@ The `chief-of-staff` role (`roles/chief-of-staff.json`, contract
`roles/chief-of-staff.md`, REQ-COS-1) runs the organization: it hires and
terminates role instances, owns the structure, takes work from the
assistant and never talks to the user. It holds `message.send` and
-`role.launch` within role (decision 139), with no credentials and the same
-read-only tools. It is the only shipped role that holds `role.launch`, so
+`role.launch` within role (decision 139), plus `role.end` and
+`organization.change` (row 123): it ends a run with a reason and no decision
+(`role.revoke` stays gated for every role), and it changes the organization
+file through the broker's `organization.set`, limited to the keys the human's
+`limits.set` takes. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
it is the only role `launch.by` can name. The PM holds no `role.launch`
(REQ-LAUNCH-1); it asks the chief of staff for a session.
diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs
index 592a476aa..c24993250 100644
--- a/packages/business/src/vocabulary.mjs
+++ b/packages/business/src/vocabulary.mjs
@@ -26,6 +26,11 @@ export const ACTIONS = Object.freeze([
"message.external",
"role.launch",
"role.revoke",
+ // Row 123 (#1617, REQ-COS-1): the chief of staff ends a run with a reason and no decision,
+ // and changes the organization file through the shared write path. Neither is gated-only,
+ // so the grant lives in a role file; role.revoke itself stays gated for every role.
+ "role.end",
+ "organization.change",
"credential.mint",
"spend",
"deploy",
diff --git a/packages/business/tests/chief-of-staff.test.mjs b/packages/business/tests/chief-of-staff.test.mjs
index b993cf5c6..c92c4cc6e 100644
--- a/packages/business/tests/chief-of-staff.test.mjs
+++ b/packages/business/tests/chief-of-staff.test.mjs
@@ -11,12 +11,12 @@ import { join } from "node:path";
import { ACTIONS, BusinessError, classify, loadBusiness, loadRole, resolveInstance } from "../src/index.mjs";
import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
-// Decision 104's list plus role.launch (decision 139). A role file that
-// differs from it fails.
+// Decision 104's list plus role.launch (decision 139) plus role.end and
+// organization.change (row 123, REQ-COS-1). A role file that differs from it fails.
const APPROVED = Object.freeze({
tools: ["read", "grep", "find", "ls"],
network: "api-only",
- authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch"]), crossRole: Object.freeze([]) }),
+ authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change"]), crossRole: Object.freeze([]) }),
credentials: Object.freeze([]),
});
@@ -83,11 +83,14 @@ test("the resolved record matches the approved list, so a widened file fails", (
assert.deepEqual(record.credentials, {});
});
-test("the chief of staff holds role.launch and message.send; every other action is gated", () => {
+test("the chief of staff holds role.launch, message.send, role.end and organization.change; every other action is gated", () => {
const record = resolved(loadFixture());
+ const held = ["message.send", "role.launch", "role.end", "organization.change"];
for (const action of ACTIONS) {
- assert.equal(classify(record, action), action === "message.send" || action === "role.launch" ? "within" : "gated", action);
+ assert.equal(classify(record, action), held.includes(action) ? "within" : "gated", action);
}
+ // role.revoke itself stays gated for the chief of staff: role.end is the grant, not a loophole.
+ assert.equal(classify(record, "role.revoke"), "gated");
assert.equal(classify(record, "task.create"), "gated");
});
diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs
index 6ab36b534..582fd6385 100644
--- a/packages/harness/tests/tools.test.mjs
+++ b/packages/harness/tests/tools.test.mjs
@@ -29,7 +29,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => {
}
});
-// Row 119 (#1613): the chief of staff holds launch and message.send, and nothing else.
+// Row 119 (#1613): the chief of staff holds launch and message.send; row 123 adds role.end and organization.change (no typed tools).
test("the chief of staff gets launch and send_message, and no task verbs", (t) => {
const chief = resolvedFor(t, "chief");
const tools = typedTools({ resolved: chief, tracker: true });
@@ -38,7 +38,8 @@ test("the chief of staff gets launch and send_message, and no task verbs", (t) =
assert.equal(launch.verb, "launch");
assert.equal(launch.action, "role.launch");
assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]);
- assert.deepEqual(untypedActions(chief, tools), []);
+ // Row 123 (#1617, REQ-COS-1): the chief also holds role.end and organization.change on the bus; no typed tool yet.
+ assert.deepEqual(untypedActions(chief, tools), ["role.end", "organization.change"]);
});
// Row 119 (#1613): the assistant holds message.send alone.
diff --git a/roles/chief-of-staff.json b/roles/chief-of-staff.json
index aafbd2a6e..8613710a4 100644
--- a/roles/chief-of-staff.json
+++ b/roles/chief-of-staff.json
@@ -6,7 +6,7 @@
"tools": ["read", "grep", "find", "ls"],
"network": "api-only",
"authority": {
- "withinRole": ["message.send", "role.launch"],
+ "withinRole": ["message.send", "role.launch", "role.end", "organization.change"],
"crossRole": []
},
"credentials": []
@@ -0,0 +1,10 @@
business: rc=0 ℹ tests 92 ℹ pass 92 ℹ fail 0
bus: rc=0 ℹ tests 191 ℹ pass 191 ℹ fail 0
plugins: rc=0 ℹ tests 21 ℹ pass 21 ℹ fail 0
tasks: rc=0 ℹ tests 97 ℹ pass 97 ℹ fail 0
cli: rc=0 ℹ tests 195 ℹ pass 195 ℹ fail 0
webui: rc=0 ℹ tests 112 ℹ pass 112 ℹ fail 0
harness: rc=0 ℹ tests 84 ℹ pass 84 ℹ fail 0
seat: rc=0 ℹ tests 132 ℹ pass 132 ℹ fail 0
test-land-work: rc=0 land-work selftests: 84 passed, 0 failed
done
@@ -0,0 +1 @@
2026-10-15T17:00:15Z
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Row 123 (#1617) round 2: the targeted gate (plugins added after row 131) (decision 136), run in the given worktree, every output teed to <outdir>.
# Usage: suites.sh <worktree> <outdir>
set -u -o pipefail
WT=$(cd "$1" && pwd); OUT=$(mkdir -p "$2" && cd "$2" && pwd)
cd "$WT" || exit 2
node_suite() { # name dir
setsid -w timeout -k 10 900 node --test "$2"/tests/*.test.mjs 2>&1 | tee "$OUT/$1.txt" >/dev/null
echo "$1: rc=${PIPESTATUS[0]} $(grep -E '^ℹ (tests|pass|fail) ' "$OUT/$1.txt" | tr '\n' ' ')"
}
for d in business bus plugins tasks cli webui harness seat; do node_suite $d packages/$d; done
setsid -w timeout -k 10 1200 bash scripts/test-land-work.sh 2>&1 | tee "$OUT/test-land-work.txt" >/dev/null
echo "test-land-work: rc=${PIPESTATUS[0]} $(grep -iE 'passed' "$OUT/test-land-work.txt" | tail -1)"
echo done
@@ -0,0 +1,199 @@
✔ agents: a held role with nothing on the bus is idle; a role session gets the claim rows unchanged (25.578503ms)
✔ agents: working counts what the holder received and has not read; routed alone is not working (23.388837ms)
✔ agents: input needed is the role's open decisions routed to the human, gated first, then oldest (42.628907ms)
✔ agents: working wins over waiting, and Input needed still shows (18.703786ms)
✔ agents: a new holder doesn't inherit what the last one left unread; decisions stay with the role (24.063556ms)
✔ agents: state reads only its own business, even for a run name both use (21.979338ms)
✔ agents: a message the holder released unread is stranded; the next holder gets nothing and cannot read it (28.477831ms)
✔ agents: a message the holder read is not stranded; one the current holder has not read is not listed (25.483032ms)
✔ agents: stranded is oldest first and capped at 50 for a role (120.899162ms)
✔ agents: stranded is per role and per business (38.979045ms)
✔ agents: a message routed to a held role and not yet received is not stranded (17.236218ms)
✔ launch identity is stamped, payload identity is refused and stale holder cannot send (26.941985ms)
✔ decision classes route from policy; gated resolution is human-only, choice and target must match (31.779783ms)
✔ claim exclusion, holder release, gated revoke and rerouting to a new holder are atomic (33.006443ms)
✔ launch events require a human CLI capability; generic emit cannot forge authority events (18.827581ms)
✔ within-role decisions close atomically and invalid options or blocking omissions refuse (20.233314ms)
✔ a recommendation that is not exactly one option key refuses invalid-recommendation after the options (34.476886ms)
✔ observer capabilities read human inbox but cannot mutate or forge launch identity (21.685969ms)
✔ task action subjects and linked decision trail are complete and ordered (33.106061ms)
✔ launch binding is durable and reconnecting requires the identical trusted record (15.657506ms)
✔ business isolation includes inherited object names and cross-business message references (25.454131ms)
✔ authority never transfers between action, run, target, unresolved or replaced role holder (38.479205ms)
✔ task projection uses schema current view, skipping earlier and equal-start polls (19.494793ms)
✔ revocation permanently bars the old run from reclaiming first, including after broker restart (27.594749ms)
✔ empty message references refuse before storage; refusal-evidence failure stays a typed error (19.332062ms)
✔ both arbiters require human resolution when their cross-role route is themselves (37.277406ms)
✔ the shipped coder role raises task.reassign as gated to the human; task.scope.change stays cross-role (32.528243ms)
✔ S1 adapter takes resolved limits and refs, rejects mismatched instance, never mutates input (2.144851ms)
✔ REQ-COS-1, REQ-ROLE-2: the chief of staff ends a run with a reason and no decision, and the reason is logged (31.035017ms)
✔ REQ-ROLE-2: a role without role.end still needs a resolved decision to revoke (30.851981ms)
✔ REQ-ROLE-2: storage accepts a revoke with a reason or a decision and aborts one with neither (23.688118ms)
✔ REQ-ROLE-2: the chief of staff may still cite a decision, and a decision alone is the old path (23.873994ms)
✔ REQ-ROLE-2: a role without role.end still ends a run through a resolved decision, which is recorded and has no reason (27.81874ms)
✔ REQ-ROLE-2: a reason is required, bounded, and never sent with a decision (32.058334ms)
✔ REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end (31.93706ms)
✔ REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor (26.085001ms)
✔ REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set (36.900254ms)
✔ REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change (33.360592ms)
✔ REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set takes only the human's keys (43.200799ms)
✔ REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and logged like a human's; an invalid one is refused (91.619431ms)
✔ REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a held role with a reason; neither is still refused (81.699257ms)
✔ comment cursors isolate businesses, move only forward and remain append-only (18.909736ms)
✔ only trusted sync writes validated cursors; events and cursor share rollback (14.796135ms)
✔ explicit 3b copy migration preserves evidence and opens as 3d; normal startup never migrates (69.615868ms)
✔ migration refuses active writers and drifted legacy schemas, with no partial upgrade (130.940414ms)
✔ named migration registry chains 3b through 3d to a fixture-only future version in order (87.142403ms)
✔ migration refuses mismatched source metadata without changing schema or evidence (66.042257ms)
✔ offline command migrates only the supplied fixture root and reports usage errors (186.163036ms)
✔ credentials gives human and reader sessions service, account, role, date and state, and nothing else (20.422342ms)
✔ credentials refuses a role session and any argument (18.040028ms)
✔ credentials reads the status on each request, and a broker without one lists none (23.874757ms)
✔ the broker process answers credentials without a token, path or variable in its output, log or store (93.442491ms)
✔ only validated broker references load; returned data and exceptions cannot expose a known token (3.961576ms)
✔ bad file modes, symlinks, repository/data paths, malformed tokens and missing dates refuse (2.171697ms)
✔ expiry refuses use and env references never become client data (0.80386ms)
✔ S1 parsed service refs work, service mismatch refuses, Gitea rotation due is a warning state (1.572831ms)
✔ opaque tokens shorter than 16 characters refuse before use (0.305569ms)
✔ a human resolve sends the raising role one message from the human, and its request is the answer (41.081762ms)
✔ a cross-role decision resolved by its arbiter comes from that role and run, with no request (25.946603ms)
✔ a human withdraw sends one message with the note (26.57486ms)
✔ a human seen before the close leaves the request on the close's answer, not the seen (35.123252ms)
✔ the raising run's withdraw, a supersede, a within-role raise and seen send nothing (35.252267ms)
✔ refused closes send nothing: invalid-choice, decision-closed, not-resolver (38.277691ms)
✔ a raising role nobody holds at the close gets the message on the next claim (33.149687ms)
✔ a human DECISION send keeps its own instruction as the request, not a close's answer (34.858391ms)
✔ roles lists open decisions routed to a role, and never the human inbox (33.234272ms)
✔ recent lists closed decisions of every route, newest close first, with how each closed (33.240242ms)
✔ recent gives the closing event as closed when the decision was seen first (34.966124ms)
✔ recent stops at RECENT rows (117.194055ms)
✔ every row is what trail already gives the same session for that id (55.180271ms)
✔ a role session can't list decisions, so it can't read another role's open decisions (49.654688ms)
✔ decisions is a read: one business, strict arguments, nothing written (53.840096ms)
✔ endLaunch writes session.ended, releases the run claim and kills its capabilities (21.043404ms)
✔ endLaunch refuses an unknown run, a second end and a rebind of the ended run (15.449843ms)
✔ a restarted broker refuses to rebind an ended run; a refused rebind leaves the run unbound (15.339919ms)
✔ endLaunch leaves a claim another run took alone (19.407439ms)
✔ refuse records action.refused against the caller with the code only (16.107301ms)
✔ launches off refuses role.launch with launch-revoked until launches on (26.173029ms)
✔ broker process: launch ops authorize role.launch, record refusals and end runs (116.418592ms)
✔ human proof binds CLI entry, process start and nonce; agents and incomplete ancestry refuse (2.483255ms)
✔ process reader gets own kernel identity without exposing environment values (0.697264ms)
✔ EACCES ancestor environments skip only markers; commands and registered launches still refuse (0.766713ms)
✔ real pid 1 remains inspectable when its environment is protected (0.294323ms)
✔ limits binds actor from human proof and rejects role, reader, tester and forged actor (24.651199ms)
✔ runtime wires verified human settings to the actual shared file writer and actor receipt (52.820042ms)
✔ uncertain organization receipts leave bus startup and reads alone but refuse writes until reconciled (53.335184ms)
✔ within-role sends cite an open gated launch decision without spending it or naming it in grants (29.700314ms)
✔ missing and foreign-business citations refuse and roll back message and grant (22.991627ms)
✔ cross-role sends still need a matching resolved decision and consume it once (29.766247ms)
✔ messages pages all human messages newest first without writes; read state is history (410.428637ms)
✔ messages refuses role callers, invalid paging and cursors outside this human feed (34.171016ms)
✔ views messages passes only its paging arguments through (0.287091ms)
✔ messages isolates businesses even with a valid foreign cursor (21.096131ms)
✔ the assistant sends to the user and to the chief of staff only (38.44306ms)
✔ an assistant send to any other role, or to the tester, is refused and logged (40.219226ms)
✔ only the user and the chief of staff may send to the assistant; the tester is refused by default (39.896916ms)
✔ a sender-side list alone refuses a send to a role that has no list (38.887246ms)
✔ a target-side list alone refuses a send from a role that has no list (39.107411ms)
✔ the sender list is checked before the target list (33.385093ms)
✔ roles without the field behave exactly as before (46.727725ms)
✔ a business with no role definitions at all is unlimited (43.62866ms)
✔ a malformed messaging field refuses at boot (18.269547ms)
✔ the assistant's review.request to a reviewer is refused and logged, even with an approved decision (44.373783ms)
✔ a review.verdict to the assistant from a role outside its receiveFrom is refused and logged (43.049145ms)
✔ review requests and verdicts between roles without the field are unchanged (30.854225ms)
✔ broker process binds trusted launches, offers reader capabilities, refuses human mutation, closes cleanly (92.818921ms)
✔ startup token refusal returns safe code without value or partial listening broker (68.43042ms)
✔ loaded fixture token is absent from socket replies and SQLite, including refusal evidence (94.915036ms)
✔ killed broker leaves an explicit stale lock; another process cannot silently reclaim it (146.969129ms)
✔ trusted host registers later launches; socket clients never have a registration verb (81.05628ms)
✔ runtime excludes declared project roots even when host supplies no repoRoots (46.849678ms)
✔ a refused launch binding leaves the broker and existing capabilities alive; bad protocol stops it (64.618516ms)
✔ a reply is listed for human and reader sessions, and its delivery rows are unchanged (40.786256ms)
✔ the reply still reaches the human message.receive (mosaic talk) after the Console showed it (25.029921ms)
✔ a message with no reply gives an empty list (19.664532ms)
✔ replies come oldest first and stop at REPLIES (99.008574ms)
✔ an id that names no message in the business refuses message-not-found, and the refusal is recorded (41.580952ms)
✔ strict arguments; a role session gets human-required (42.977345ms)
✔ views passes replies through as one call with the id (0.297022ms)
✔ requirements returns the committed list to a holding role session, and no one else (31.356539ms)
✔ the PM's prd.approve with no task, when no id fits, reaches the human's Inbox (19.085787ms)
✔ the PM's non-blocking role.revoke with no task reaches the human's Inbox (22.505345ms)
✔ a push is recorded as the action.allowed for git.push.working on the task, and only the host records one (34.834638ms)
✔ review.request names the latest pushed commit and sends the reviewer a REVIEW-REQUEST (29.629424ms)
✔ review.request says which run holds the reviewer role, or null, and a null request waits for a claim (37.821088ms)
✔ review.request refuses a commit that isn't the latest push, a non-reviewer and a role without the action (46.988422ms)
✔ reviewFetch answers only the addressed reviewer, for the request it cites (29.00738ms)
✔ review.verdict answers one request once, to the role that asked (40.895006ms)
✔ task.close on a pushed task needs an approve verdict for the latest push (53.036953ms)
✔ a refusal the host decided is filed on the task it names, and only a task ref counts (31.778978ms)
✔ v3b prototype refusals, views and append-only mutations (107.664693ms)
✔ gated approval authorizes once, survives store reopen, and fresh approval works (40.707666ms)
✔ another run cannot consume an approval; a failed check leaves it usable (32.321745ms)
✔ two scheduled callers have exactly one grant and one consumed refusal (19.893803ms)
✔ failed commit rolls consumption back; cross-role consumes and within-role stays reusable (42.349908ms)
✔ class drift gated to cross-role refuses before consumption (31.391335ms)
✔ class drift cross-role to gated refuses before consumption (35.951582ms)
✔ class drift gated to within-role refuses before consumption (33.724708ms)
✔ class drift cross-role to within-role refuses before consumption (29.936425ms)
✔ class drift within-role to gated refuses before consumption (23.785584ms)
✔ class drift within-role to cross-role refuses before consumption (22.216088ms)
✔ message.send consumes approval and prevents a later send or authorize (21.580415ms)
✔ role.revoke consumes approval and prevents a later revoke or authorize (20.018232ms)
✔ a business that never switched launches reads allowed with no time and no event (23.42386ms)
✔ launches revoked and then restored read allowed, with the restore time and event id (22.623067ms)
✔ a task closed on the tracker leaves the open list, and total counts the open ones (20.25188ms)
✔ a tombstone is not open (17.970873ms)
✔ the open list stops at the cap and says so; total still counts every open task (21.166223ms)
✔ a role and a reader session read state; arguments, and an unbound cap, refuse (19.19386ms)
✔ a human session reads the same state, with the same cap (23.337588ms)
✔ reading state writes nothing: every table keeps its row count (20.645998ms)
✔ creates private WAL store and excludes a second writer until explicit close (18.52899ms)
✔ rollback is atomic and schema metadata is checked against trusted DDL, not just itself (21.924079ms)
✔ existing empty database and symlink runtime directory refuse, never initialize over damage (21.413137ms)
✔ crash during a transaction recovers no partial event after explicit fixture-only lock removal (72.761332ms)
✔ writer refuses mixed at/read_at forms atomically, even through trusted SQL helpers (15.738643ms)
✔ async transactions refuse before invoking their function (12.685621ms)
✔ the session default fits under the reply cap and pages to the end with next (195.566652ms)
✔ the short form: no description, assignees by role, every listed key (80.800641ms)
✔ each filter, alone and together, and total counts every match (154.547115ms)
✔ task_ref reads one task in full, as the whole board has it (90.071383ms)
✔ no arguments is the read the Console and mosaic make, unchanged (71.152743ms)
✔ an unknown argument or a bad value refuses invalid-request (348.620787ms)
✔ tasks rows carry priority and requirement for every session that reads tasks (51.383441ms)
✔ the read model rebuilds from the ledger: a reopened bus and each trail give the same values (58.283892ms)
✔ an empty business and one with snapshots but no task.created read cleanly (13.418383ms)
✔ recordTask keeps sync reads and a role write apart (30.128193ms)
✔ read_at must be one canonical UTC format, so the projection compares strings safely (13.921857ms)
✔ a bad entry refuses the whole record (12.842559ms)
✔ taskView reads the projection for one business (15.712668ms)
✔ requestTask hands only a holder and a task verb to the handler, and records refusals (27.603972ms)
✔ the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous (273.138079ms)
✔ without an adapter the server refuses every task verb (16.29688ms)
✔ the runtime refuses an invalid adapter and closes a valid one (16.571155ms)
✔ the process loads the S3 adapter from plain-data trackers (117.493475ms)
✔ the tester binds only when the business sets bus.tester to exactly true (88.004326ms)
✔ a business may not name a role tester, so the tester address is never a role (20.022289ms)
✔ every verb outside the tester list refuses tester-refused and is recorded against the tester (34.002079ms)
✔ a tester message is from tester, starts [mos test], and writes no human.input or authority (13.368214ms)
✔ a role answers a tester only in reply to a tester message, and the answer never reaches the human (19.460499ms)
✔ the tester reads the human views a reader reads (20.809476ms)
✔ tester proof binds its own CLI, start time and nonce, refuses a managed run and allows an agent (1.2294ms)
✔ neither CLI's nonce proves the other: each proof is bound to its own CLI and nonce variable (0.370636ms)
✔ testerNonce reads only the tester's variable and gives null on any read error (0.226916ms)
✔ an envelope carries exactly one of cap, human and tester; no tester authenticator refuses (17.771392ms)
✔ tester-cli.mjs binds through the runtime when the switch is on, and refuses off or under a run marker (664.392514ms)
✔ socket capability stamps launch identity; shared views use wire, no SQL client (33.568384ms)
✔ two wire claims serialize; a lost reply never automatically retries (23.970038ms)
✔ malformed, oversized and identity-forging envelopes refuse without echoing input (14.562818ms)
✔ client preserves UTF-8 when a response divides a multibyte character (12.020527ms)
✔ committed mutation followed by dropped reply reports unknown and is never retried (15.180547ms)
ℹ tests 191
ℹ suites 0
ℹ pass 191
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1426.522917
@@ -0,0 +1,100 @@
✔ the role file loads and resolves through loadBusiness (7.789735ms)
✔ the shipped file is exactly the approved list (1.010614ms)
✔ the assistant's authority is message.send within role and nothing else (2.606629ms)
✔ the assistant talks to the user and the chief of staff only, and no other shipped role is limited (0.896788ms)
✔ a business grant through limits does not widen the role (2.404013ms)
✔ a launch block whose by is the assistant is refused (1.923749ms)
✔ the instance takes no credential references and no tracker (3.637378ms)
✔ the contract passes work to the chief of staff and directs no one (0.810589ms)
✔ config directory and file path follow MOSAIC_CONFIG (1.376543ms)
✔ the fixture business validates and comes back frozen (5.528553ms)
✔ two instances may share a definition (2.404309ms)
✔ top-level refusals (5.466705ms)
✔ arbiters and projects (8.475247ms)
✔ role instances (5.015159ms)
✔ Vikunja bots (8.321229ms)
✔ a role without Vikunja takes no tracker block (2.674738ms)
✔ credential references match the definition's services (3.834921ms)
✔ launch (10.898213ms)
✔ loadBusiness: file checks (1.986603ms)
✔ loadBusiness: not a regular file (44.663331ms)
✔ loading writes nothing (1.612377ms)
✔ names that are Object.prototype properties don't count as declared (3.32128ms)
✔ the shipped example refuses as written and validates once filled in (1.161375ms)
✔ the role file loads and resolves through loadBusiness (8.937896ms)
✔ the shipped file is exactly the approved list (0.332306ms)
✔ the resolved record matches the approved list, so a widened file fails (2.352654ms)
✔ the chief of staff holds role.launch, message.send, role.end and organization.change; every other action is gated (2.003787ms)
✔ the pm does not hold role.launch (2.040342ms)
✔ a business grant of a gated action through limits does not widen the role (2.730758ms)
✔ a limit that drops role.launch leaves the chief of staff with no launch block (1.569037ms)
✔ a launch block whose by is the pm instance is refused (2.223948ms)
✔ the contract names no duty to talk to the human and the pm contract no launch (0.450326ms)
✔ the instance takes no credential references and no tracker (3.312749ms)
✔ usage errors exit 4 (298.937403ms)
✔ validate: a good business exits 0 and prints instance digests (64.907263ms)
✔ validate: project files (302.97894ms)
✔ validate: missing files and a broken system config (237.546921ms)
✔ validate: credential reference problems exit 2 and name each one (63.854228ms)
✔ validate: a token file inside the repository is refused (59.738486ms)
✔ validate: role definitions come from MOSAIC_ROLES_DIR (177.174476ms)
✔ resolve: prints one instance's record (254.341173ms)
✔ resolve: refusals (362.006449ms)
✔ parse: exactly one of file or env, plus the service's date (3.271512ms)
✔ check: a good file has no problems (1.046037ms)
✔ check never opens the file: a write-only token passes (0.438583ms)
✔ check: file problems (1.143927ms)
✔ check: token files can't live in the repository or dataRoot, even through a linked directory (1.499187ms)
✔ check: dates and environment references (0.532974ms)
✔ REQ-LIMIT-1 schema: every scope unset or zero, adjustable above four, bad values and unknown models refuse (20.190428ms)
✔ REQ-LIMIT-1 counts each exact scope at/over limit, unlimited and old tuple after model reassignment (1.055917ms)
✔ REQ-ROLE-3 writer: atomic validated revision, immutable previous bytes and authenticated actor/old/new receipt (26.367974ms)
✔ REQ-ROLE-3 writer: pre-rename fault leaves old file, post-rename recovery records commit, conflicting recovery refuses (14.372404ms)
✔ REQ-LIMIT-1 / decision145 migration prints only, preserves values, rejects ambiguity, and is idempotent (2.908539ms)
✔ REQ-LIMIT-1 shared settings validates the complete proposed selection and logs exact actor (21.862083ms)
✔ REQ-LIMIT-1 provider is table data: another provider routes separately and aliases share exact counts (2.027261ms)
✔ REQ-ROLE-3 refuses live writer locks, symlink targets and uncertain recovery without changing bytes (11.355316ms)
✔ REQ-PLUG-1 schema: plugins is optional, names known kinds and checked module names (14.819485ms)
✔ REQ-PLUG-1 REQ-ROLE-3 the setter writes plugins through the organization writer and logs old and new names (37.217264ms)
✔ REQ-PLUG-1 REQ-ROLE-3 the writer checks the bytes it writes: a Date in plugins is refused there too (4.737518ms)
✔ REQ-PLUG-1 REQ-ROLE-3 a refused setter write throws a code, changes nothing and logs nothing (1.67011ms)
✔ path and load (2.958193ms)
✔ refusals (1.654248ms)
✔ systemVars flattens the validated config (2.514785ms)
✔ precedence: system, business, project, project role, agent (5.281174ms)
✔ limits narrow the definition and never widen it (2.80785ms)
✔ role.launch stays within-role only for the instance the launch block names (8.878825ms)
✔ limits.authority without role.launch leaves the launcher with no launch block (4.262562ms)
✔ limits.authority narrows cross-role actions too (1.500213ms)
✔ classify (1.546821ms)
✔ the record carries what the broker and launcher need (1.477034ms)
✔ digest: key order doesn't matter, any value change does (7.0311ms)
✔ refusals (2.180314ms)
✔ the four shipped version 2 roles load (5.020656ms)
✔ shipped role scopes match addendum B section 2 and the SR runbook (1.515809ms)
✔ shipped authority follows the note's table (1.462214ms)
✔ every role file in roles/ loads through the validator, and a v2 contract exists (1.075023ms)
✔ version 1 files keep loading with no authority (1.236948ms)
✔ the conductor policy isn't a role (0.276326ms)
✔ a missing role file is exit 4, a symbolic link too (0.67357ms)
✔ version 2 refusals (1.729544ms)
✔ authority: closed vocabulary, no gated-only action, no overlap (3.304955ms)
✔ messaging: optional, role definitions and human, never the tester (2.272802ms)
✔ credentials: Gitea scopes (1.359058ms)
✔ credentials: Vikunja scopes are a group-to-verbs map from the grantable list (1.008686ms)
✔ credentials: services (0.612416ms)
✔ contract: a non-empty regular Markdown file beside the role file (0.81672ms)
✔ every key names known layers and a merge rule (1.275784ms)
✔ unknown keys and wrong layers refuse (0.948922ms)
✔ types (2.897403ms)
✔ merge: defaults, then the most specific layer wins (0.434177ms)
✔ merge: limits only narrow, and provenance lists each source (0.517546ms)
✔ merge doesn't change its inputs (0.629634ms)
ℹ tests 92
ℹ suites 0
ℹ pass 92
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1901.561197
@@ -0,0 +1,205 @@
✔ every screen and overlay fits 120x36, 80x24 and the narrow layout, row for row (1441.467369ms)
✔ width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0 (81.538081ms)
✔ broker text is shown, never interpreted: no raw control character or bidi override reaches the terminal (157.147271ms)
✔ NO_COLOR: no escape codes, except reverse video on the selected row (247.307745ms)
✔ 1-9 shows the decide flow's own lines and question; only y resolves (55.018139ms)
✔ the app shows the same decide text as mosaic decide (23.560729ms)
✔ any key but y or Y at the question answers No; nothing is sent (208.574837ms)
✔ below 40x10 the question is hidden, so every key answers it No, y included (Filbert R3) (66.611093ms)
✔ under 40 columns a tall terminal shows the too-small screen even where the decide box would fit; y sends nothing (Filbert N10) (273.011414ms)
✔ a hidden decide box names the way out, and the status line under it does not promise y (Filbert N12) (37.061379ms)
✔ a throw while answering the question goes to crash, sends nothing and leaves no job pending (22.966079ms)
✔ type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs (411.35367ms)
✔ quitting at the question answers No and sends nothing, armed or not (41.61611ms)
✔ a timer tick reads nothing under an overlay, never answers the question, and keeps a resolved status (39.032811ms)
✔ a long question is cut inside the question; the action, target, choice and prompt stay (Dewey B1) (55.961178ms)
✔ a decide box too big for the terminal says so, and every key answers it No, y included (Dewey B1, round 3) (461.737785ms)
✔ at every size the decide box shows each line y depends on, or says it is hidden (8432.399036ms)
✔ outcome unknown: the CLI's sentence, no retry, then the trail or a re-read (65.298268ms)
✔ refused and failed reads keep the last list, marked stale; a refused decide says refused (30.081428ms)
✔ help or a detail box cut to fit keeps its last line, Esc included (Dewey) (14.943089ms)
✔ keys: views, moving, trail of a selection, the command line, help and quit (75.726602ms)
✔ one option: the status line says Press 1, not 1-1 (17.6928ms)
✔ no business: the app says so and :business picks one (8.542377ms)
✔ mosaic with no argument: exit 3 in an agent run, exit 4 with the usage off a terminal, the app on one (1.703266ms)
✔ scripts/mosaic with no argument reaches the app entry; the seat usage it printed before has not drifted (170.756276ms)
✔ strings to keep exactly live once, in the decide flow and the transport; the app does not restate them (0.416464ms)
✔ decide with --note still asks; a No sends nothing (9.301045ms)
✔ the app's business comes from a live bus host only (0.956849ms)
✔ runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error, the first frame's included (6.785331ms)
✔ draw is pure: same state, same frame (0.737795ms)
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (34.910427ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (24.533356ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (21.418979ms)
✔ decide prints a declining choice as declining (29.825758ms)
✔ an unknown outcome is reported once and never resent (18.58138ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (19.194614ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (19.682591ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (16.518183ms)
✔ every human command refuses inside an agent run before it touches the bus (18.481159ms)
✔ usage errors exit 4; no business and no host is a usage error (18.829099ms)
✔ agents and tasks print through the broker (13.894834ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (3.600305ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (76.774062ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (111.275283ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (48.839222ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (48.132486ms)
✔ a business without tracker.baseUrl gets no trackers entry (51.025449ms)
✔ an unknown business and a broken system config refuse with exit 3 (76.809324ms)
✔ follow prints an unsolicited message after plain talk returned (44.779154ms)
✔ talk --follow continues after reply and prints a later unsolicited message (39.932226ms)
✔ inbox lists all unread human messages under decisions, does not deliver or read them (364.024937ms)
✔ follow refuses agent context and bad arguments (8.695053ms)
✔ follow Ctrl-C wakes the poll sleep and removes its handler; transport errors also clean up (10.215659ms)
✔ plain talk leaves the process SIGINT handlers unchanged (11.041138ms)
✔ empty views say so (1.052182ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.482052ms)
✔ tasks print the tracker fields the snapshot carries (0.333555ms)
✔ the review loop: the coder pushes and asks, the reviewer fetches and approves, and the task's trail holds each step (514.333431ms)
✔ refusals: no action, non-fast-forward, an unknown commit or request, and a push for another task moves only that task's ref (330.059886ms)
✔ the size cap refuses a push before any byte (149.032053ms)
✔ a fetch over the cap refuses before any byte reaches the reviewer (314.465261ms)
✔ the socket refuses a request with no capability, an unknown one, and a bad request line (163.19369ms)
✔ a rigged clone runs nothing: hooks, fsmonitor, a filter, a gitlink and a symlinked .git/config leave no marker (264.185233ms)
✔ an identity with no role, or of another business, refuses not-a-role and moves no ref (204.536972ms)
✔ a review of a project the business no longer has refuses unknown-project (202.280999ms)
✔ past maxActive connections the socket refuses git-busy, and a hung-up connection frees its slot (142.662751ms)
✔ close() answers host-closing to a connection still sending its request line (130.588407ms)
✔ a bundle that trickles in refuses body-timeout at its deadline and frees its slot (548.24908ms)
✔ a reviewer gone before its bundle was sent is filed fetch-aborted, not as the host's fault (446.071201ms)
✔ a reviewer that hangs up the moment it has the whole bundle was served, not aborted (685.235911ms)
✔ send: a close after every byte left is served; one that fails a queued write is fetch-aborted (41.785803ms)
✔ a bundle with no bytes for bodyIdle refuses body-timeout long before its deadline (467.062158ms)
✔ send: a close once the loop is past its last chunk is served, and a failed write there is fetch-aborted (24.55142ms)
✔ letGo waits for an answer to leave for at most its cap, then destroys the socket (300.757404ms)
✔ a record that fails and can't be undone refuses rollback-failed, which the host logs (346.699387ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (959.210648ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (156.656008ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (83.683945ms)
✔ two bus starts racing for one data root leave one host running; the other refuses that a host already runs (478.724196ms)
✔ a host takes over a host.lock its dead host left, and puts back a live claim that replaced it meanwhile (1.37734ms)
✔ a host.lock that goes between the check and the read is tried again; one that can't be parsed still refuses as unreadable (0.739053ms)
✔ a live host's lock stays, however old the file is (0.504995ms)
✔ a host.lock left by a host killed with SIGKILL doesn't stop the next bus start (771.855752ms)
✔ while a host boots, a reader sees no host: the human commands still ask for --business, stop and status find none (216.602659ms)
✔ a host started before host.lock existed, holding only host.json, still refuses a second start, which leaves no lock (30.601827ms)
✔ a broker fork that throws leaves no host.lock and no host.json (39.573308ms)
✔ a broker send that throws leaves no host.lock and no host.json (32.02317ms)
✔ a broker that misses its boot wait leaves no host.lock and no host.json (33.474007ms)
✔ a notifier fork that throws stops the broker and leaves no host.lock and no host.json (94.141862ms)
✔ a notifier send that throws stops the broker and leaves no host.lock and no host.json (101.763979ms)
✔ a boot reply with no reader for the business refuses with exit 3 naming it, before the notifier starts, and leaves no host.lock and no live broker (97.013325ms)
✔ a boot reply with no reader for the business refuses with exit 3 naming it, with no notifier, and leaves no host.lock and no live broker (96.92904ms)
✔ a host.json that can't be written stops both children and leaves no host.lock (207.218842ms)
✔ an error event on the broker before it replies fails the start at once, and leaves no host.lock and no live child (92.454961ms)
✔ an error event on the notifier before it replies fails the start at once, and leaves no host.lock and no live child (152.82228ms)
✔ an error event on the broker while the notifier starts fails the start once the notifier replies (266.667135ms)
✔ an error event on the broker once the host is up stops the host with exit 1, and leaves no host.lock and no host.json (211.917687ms)
✔ an error event on the notifier once the host is up stops the host with exit 1, and leaves no host.lock and no host.json (195.385678ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1089.57928ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (189.886761ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (93.770546ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (152.821995ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (141.681556ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (86.9077ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (140.458798ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (93.680669ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (168.158495ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (28.306748ms)
✔ bus stop refuses to signal a live pid that is not a bus host (202.298384ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (277.181991ms)
✔ bus start refuses with exit 3 without a notifier config (118.516282ms)
✔ bus start runs until bus stop; status reports it while it runs (722.642531ms)
✔ bus-service.sh renders the unit and installs it into a given directory (54.304474ms)
✔ bus-service.sh pins the claude binary, refuses one the unit can't carry, and renders without one (62.637745ms)
✔ bus-service.sh refuses a directory pin and keeps a symlink default unresolved (12.533478ms)
✔ bus-service.sh refuses a default claude without the execute bit, or in a PATH dir with a space (8.066786ms)
✔ each launch rereads limits and model selections; lowered limits keep sessions and log exact hit (51.231981ms)
✔ bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest (1306.190286ms)
✔ the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code (528.849205ms)
✔ each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged (3585.17238ms)
✔ a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left (129.846817ms)
✔ a runner that stops at once ends its launch with the runner's reason (215.48375ms)
✔ a Claude Code launch with no session credential refuses before it starts (110.21756ms)
✔ a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables (181.263081ms)
✔ a runner killed from the host ends its launch as killed, not failed (251.379059ms)
✔ a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback (110.783031ms)
✔ a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold (1218.591944ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (626.099572ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3570.651695ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (89.807687ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (93.737954ms)
✔ a launch client that never closes its side doesn't hold the host's close (92.118422ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1211.545787ms)
✔ claudeVersion asks MOSAIC_CLAUDE_BIN when set, else claude on PATH (4.248334ms)
✔ migration needs no bus/config, prints whole converted document, is idempotent and never writes (17.626632ms)
✔ limits view/set use the human bus transport and pass only the explicit patch (2.099069ms)
✔ post-119 example and Jason fixture migrate with checked inherited models; wrong or absent config refuses (299.777968ms)
✔ zoned uses the IANA zone across DST (24.129137ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (34.970589ms)
✔ two blocking decisions get two DMs with different nonces (28.529497ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.357527ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (25.953138ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (30.944661ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (27.991172ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (22.535598ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (133.397194ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (50.102045ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (13.529563ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (10.507222ms)
✔ an inbox read failure is logged and the next poll retries (0.759151ms)
✔ no Discord id reaches the journal or the log (11.542125ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (2.481071ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (2.908404ms)
✔ the journal: a whole file that is one torn line truncates to empty (1.63057ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (0.504103ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (2.439423ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (2.599354ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.371551ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.489656ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.387045ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.45388ms)
✔ digest content stays within Discord's 2000 characters (0.315004ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.708167ms)
✔ a human command refused with human-required prints the other-tree line and exits 3 (3.464728ms)
✔ a --tester command refused with tester-required prints the other-tree line and exits 3 (1.459768ms)
✔ other exit-3 refusals print no other-tree line (2.368433ms)
✔ the line names no path (0.18329ms)
✔ shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation (582.156024ms)
✔ wrapper preserves Q81 lease and marker, rejects foreign/symlink; check uses effective roots (428.100659ms)
✔ bus unit copy/pm pins, install refuses all busy states and failed reads, drop-in refusal (434.091801ms)
✔ missing package manifest refuses before npm; npm failure removes the unpublished temp copy (293.322969ms)
✔ real pinned install loads bus and Console; same-copy mosaic talk passes proof, other trees refuse (3550.281634ms)
✔ bus status reads effective copy/pm/drop-ins without writes; check refuses an overridden ExecStart (498.870008ms)
✔ drop-ins in either override field and malformed metadata refuse; uninstall also checks --no-reload (194.745712ms)
✔ install never shows a template name on systemd 261; template drop-ins still refuse (163.826816ms)
✔ unlisted instance drop-in is found in another unit lookup directory; path discovery errors refuse (163.78909ms)
✔ inherited mosaic-.service.d override refuses without changing the unit (195.117471ms)
✔ inherited service.d override refuses without changing the unit (189.749972ms)
✔ inherited drop-in unreadable directory or file refuses before writing (165.591312ms)
✔ talk --tester sends a [mos test] message as tester from inside an agent and prints the role's reply (39.873223ms)
✔ talk --tester refuses text without the [mos test] prefix before any call (21.406982ms)
✔ the read verbs take --tester inside an agent, and the trail shows the tester as the sender (26.94186ms)
✔ decide, stop and launches take no --tester (19.977975ms)
✔ the tester transport runs tester-cli.mjs, and the tester's refusals exit 3 (0.441629ms)
✔ with no stand-in transport, --tester runs tester-cli.mjs on the data root's socket (0.792132ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (352.89883ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (47.224346ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2236.872648ms)
✔ busExit and refuseInsideAgent (0.472514ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (65.877724ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1026.24375ms)
✔ a late reply the Console has listed still reaches the next talk (2033.328162ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (13.209854ms)
✔ launches off and on go to the broker and change the business's launch state (13.145754ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (11.534588ms)
ℹ tests 195
ℹ suites 0
ℹ pass 195
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 13491.102048
@@ -0,0 +1,92 @@
✔ sessionModel: agent vars win, then the system's execution settings (13.399692ms)
✔ a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory (4.584534ms)
✔ a chief-of-staff bundle lists launch among the bus tools and the manifest's typed tools (2.296631ms)
✔ a claude-code bundle adds the wrapped gate hook and the MCP config (3.053174ms)
✔ the prompt's built-in tools line is the harness's own names, as the manifest lists them (9.249709ms)
✔ the prompt names the project clones in the workspace, and none when there are none (7.272513ms)
✔ the prompt explains the review loop to a role with push_working, and reviews to one with fetch_review (7.638611ms)
✔ a bundle is written once: an existing file refuses (1.460462ms)
✔ a path with a single quote can't go into the hook command (2.389599ms)
✔ allow exits 0, a deny exits 2 with the reason on stderr (125.530748ms)
✔ a missing or wrong policy, or a bad event, exits 2 (74.968745ms)
✔ the bundle's wrapped command: a missing gate or node still blocks (1084.255248ms)
✔ claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist (706.733004ms)
✔ claude: the hook alone blocks a path outside the workspace (457.849325ms)
✔ claude: a second turn resumes the first turn's session (679.61127ms)
✔ claude adapter: --restricted is always passed (5.13109ms)
✔ claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable (16.156421ms)
✔ claude adapter: a directory pin exits 2 before it runs; a pin with a space runs (5.548467ms)
✔ claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do (785.931942ms)
✔ claude: a missing hook or MCP file refuses before claude starts (6.918007ms)
✔ pi: policy tools and typed tools pass, anything else is blocked (3.349528ms)
✔ claude: builtins map from pi names, typed tools need the mcp prefix (0.737366ms)
✔ file tool paths must resolve inside the workspace (1.213461ms)
✔ pi's own path normalisation can't be used to step out (1.111981ms)
✔ a symlink inside the workspace that points out is outside (0.969621ms)
✔ a dangling symlink is refused at any depth, in both harnesses (2.411056ms)
✔ read is checked under every spelling pi's read would open, in both harnesses (15.05931ms)
✔ other spellings cover directories, dangling links and pi's cwd (1.761293ms)
✔ a relative path climbs from the workspace's real path, in both harnesses (4.545519ms)
✔ claude path fields per tool (1.123275ms)
✔ glob patterns stay inside the workspace (0.76606ms)
✔ a path that can't be checked is blocked (0.364174ms)
✔ fetch_review's have lists the working refs before origin's, and no more than HAVE_MAX (198.382116ms)
✔ fetch_review's have orders the working refs newest first within each clone, one clone after another (133.129597ms)
✔ a runner git past its time: SIGTERM reaches its process group, and SIGKILL follows after the grace period (1810.286267ms)
✔ once a signalled runner git has closed, its group gets SIGKILL, so a process that ignored SIGTERM dies too (304.639152ms)
✔ initialize, ping and tools/list (47.853177ms)
✔ tools/call goes through the tool socket; a refusal is an isError result (32.118526ms)
✔ replies are compact JSON; over the cap a tool error names the size and the cap (33.501923ms)
✔ unknown tools and methods are JSON-RPC errors and never reach the socket (29.955629ms)
✔ a missing argument is a usage error (26.151906ms)
✔ pi: typed tools reach the socket, the gate blocks, agent_end writes the marker (364.983523ms)
✔ pi: a reply over the cap is a tool error naming the size and the cap; list_tasks asks for open tasks (310.922905ms)
✔ pi: a write through a dangling symlink is blocked, and nothing appears outside (356.448684ms)
✔ pi: a read is refused when pi would open another spelling outside (313.987297ms)
✔ pi: a relative path climbs from the real path of a workspace behind a symlink (309.279237ms)
✔ pi: a relative path climbs from the real path of a dataRoot behind a symlink (299.482402ms)
✔ pi: a missing extension refuses before any model call (6.408932ms)
✔ pi: an extension without its configuration fails pi's start (242.65042ms)
✔ founderCheck: founder variables, then a needed service without a usable token (1.387414ms)
✔ turnRequest names the sender, class, reply and decision (0.231666ms)
✔ turnRequest says a tester's message is a test and never Jason's instruction (0.191222ms)
✔ turnRequest shows the message's own sent time, a day old as well as new (0.140888ms)
✔ stateBlock names the launch state with its time and event, and the open tasks with the cap (0.231872ms)
✔ a message becomes a turn, the answer goes back as a RESULT, SIGTERM releases and exits 0 (211.037409ms)
✔ launches revoked and then restored: the turn's block reads allowed and gives the restore time (168.641165ms)
✔ a task closed on the tracker is missing from the next turn's open list (168.874887ms)
✔ a message read a day after it was sent shows the time it was sent (88.493867ms)
✔ a decision the human resolves becomes a turn citing the decision and the answer (167.958612ms)
✔ a failed state read fails the turn: no adapter runs, the sender hears why, the message is read (64.332042ms)
✔ a SIGTERM before the claim stops the runner with exit 0 and no claim (48.971564ms)
✔ typed tools carry the runner's capability; launch goes to the host's launch socket (272.7423ms)
✔ a RESULT gets no automatic reply; failed turns reply with the reason (1197.197203ms)
✔ SIGTERM during a turn kills the turn's process group and still exits 0 (92.595515ms)
✔ founder credentials stop before the claim (20) (96.308192ms)
✔ a refused claim exits 21; an ended run's capability exits 22 (95.162912ms)
✔ the launch ending under a running session exits 22 (99.199269ms)
✔ a broker that stays unreachable exits 23 after brokerRetries polls (204.764604ms)
✔ a broker that is down at the claim exits 23, not 21 (50.652923ms)
✔ no capability, or a malformed one, on stdin exits 2 (142.013049ms)
✔ a missing or malformed policy exits 2 before the claim (85.249607ms)
✔ the PM gets its task verbs and the reads, and no launch (7.157617ms)
✔ the chief of staff gets launch and send_message, and no task verbs (2.836501ms)
✔ the assistant gets send_message and the reads, no launch (2.833563ms)
✔ list_requirements reads the requirement ids, for a task.create holder with a tracker (3.577265ms)
✔ a coder gets no launch, no resolve_decision, and no task tools without a tracker (1.237907ms)
✔ a coder with a tracker gets its own task tools and no task_reassign (1.569331ms)
✔ launch only when the business's launch block names the instance as launcher (2.600909ms)
✔ an action outside the instance's authority has no tool (1.452998ms)
✔ the review loop: push_working and request_review for the coder, fetch_review and review_verdict for the reviewer, none for the PM (4.485505ms)
✔ callTool: one JSON line out, the result back, a refusal rejects (11.338377ms)
✔ list_tasks takes the broker's filters and pages; get_task takes a task_ref (3.681892ms)
✔ callTool sends list_tasks done false unless the session names done; other tools as given (3.583033ms)
✔ replyText: compact JSON up to the cap, an error naming the size and the cap over it (1.246407ms)
ℹ tests 84
ℹ suites 0
ℹ pass 84
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 10606.422363
@@ -0,0 +1,29 @@
✔ REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source and limit-check at interface version 1, each a list (1.027331ms)
✔ REQ-PLUG-1 checkPlugins accepts one key per kind, each a list of names, empty included (0.235072ms)
✔ REQ-PLUG-1 checkPlugins refuses bad shapes, unknown kinds, bad names and duplicates (4.09361ms)
✔ REQ-PLUG-1 a kind that switches on one module takes one name, not a list (0.157184ms)
✔ REQ-PLUG-1 checkPlugins reports through the caller's fail callback (0.140848ms)
✔ REQ-PLUG-1 fixture modules of each kind switch on from a test organization file, no core file changed (7.622662ms)
✔ REQ-PLUG-1 a name not matching ^[a-z][a-z0-9-]*$ refuses and keeps the old setting (3.500062ms)
✔ REQ-PLUG-1 a missing module directory or index.mjs refuses and keeps the old setting (3.36445ms)
✔ REQ-PLUG-1 a symlinked module directory or index.mjs refuses and keeps the old setting (2.495043ms)
✔ REQ-PLUG-1 a symlinked kind directory refuses and keeps the old setting (2.172335ms)
✔ REQ-PLUG-1 a symlinked packages/plugins refuses (run with --preserve-symlinks so the path keeps it) (25.146233ms)
✔ REQ-PLUG-1 index.mjs swapped for a symlink after the lstat checks refuses at the realpath check (2.774086ms)
✔ REQ-PLUG-1 a path that can't be read refuses as unreadable, is logged and keeps the old setting (2.218967ms)
✔ REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses at either realpath (2.439158ms)
✔ REQ-PLUG-1 a module that fails to load refuses and keeps the old setting (3.153953ms)
✔ REQ-PLUG-1 a missing export refuses and keeps the old setting (3.317677ms)
✔ REQ-PLUG-1 a different interface version refuses and keeps the old setting (2.454003ms)
✔ REQ-PLUG-1 a kind refused on its first load has nothing switched on, and the log names kind and name (1.779205ms)
✔ REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole setting keeps everything (3.050519ms)
✔ REQ-PLUG-1 loads run one at a time, so the last requested setting wins (102.642943ms)
✔ REQ-PLUG-1 the loader needs a log callback (1.114576ms)
ℹ tests 21
ℹ suites 0
ℹ pass 21
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 223.916143
@@ -0,0 +1,145 @@
✔ a first launch clones the root at its branch, with the root's path as origin (34.875337ms)
✔ the clone is not at its place until git clone has finished (27.741804ms)
✔ a later launch fast-forwards a clean clone after a commit to the root, then finds it current (76.322452ms)
✔ a clone ahead of the root keeps its commits and counts as current (39.242032ms)
▶ a dirty clone and one on another branch are fetched, and nothing but refs/remotes moves
✔ a modified file (45.106476ms)
✔ an untracked file (45.994302ms)
✔ a staged file (49.952007ms)
✔ another branch (50.146522ms)
✔ a detached HEAD (46.226905ms)
✔ a dirty clone and one on another branch are fetched, and nothing but refs/remotes moves (238.621303ms)
✔ a diverged clone gets the fetch into refs/remotes and is kept, not-fast-forward (43.414766ms)
▶ a clone kept before the branch check, or for a failed status, gets no fetch
✔ not-a-clone (27.70706ms)
✔ hooks-present (32.667349ms)
✔ submodule (34.698309ms)
✔ config-changed (31.666155ms)
✔ root-unreadable (39.061326ms)
✔ root-detached (45.828785ms)
✔ status-failed, on the root's branch and clean (51.78043ms)
✔ status-failed, dirty (46.495272ms)
✔ a clone kept before the branch check, or for a failed status, gets no fetch (311.499633ms)
✔ a dirty clone whose fetch fails is kept, fetch-failed, exactly as it was (45.555542ms)
✔ every git call the host makes, for a new clone and an existing one, carries maintenance.auto=false and gc.auto=0 (49.594313ms)
✔ a clone with loose objects enough for gc --auto gets no maintenance or gc from the fetch and the merge (80.481666ms)
▶ a clone with user.name or user.email in its config is kept, and git runs nothing in it
✔ user.name (26.005414ms)
✔ user.email (26.319705ms)
✔ a clone with user.name or user.email in its config is kept, and git runs nothing in it (52.763437ms)
✔ a commit git can't read after the fast-forward is null (48.00762ms)
✔ a new clone whose commit can't be read refuses the launch and leaves nothing behind (19.411803ms)
▶ a clone the session rigged is kept, and git runs nothing and writes nothing outside it
✔ core.fsmonitor (34.520589ms)
✔ core.hooksPath (34.518041ms)
✔ a hook file (33.407408ms)
✔ include.path (38.8982ms)
✔ includeIf (38.007121ms)
✔ a filter driver (34.356934ms)
✔ origin moved (36.896178ms)
✔ a second origin url (34.13191ms)
✔ the origin url twice, both the root (41.588321ms)
✔ an ext:: origin (34.870104ms)
✔ a branch tracking another remote (35.48466ms)
✔ a branch merging a tag (38.941866ms)
✔ a fetch line onto local branches (37.049265ms)
✔ no origin (38.557693ms)
✔ a config file over the size cap (30.827345ms)
✔ config a symlink to /dev/zero (30.446881ms)
✔ the clone a symlink to the bait (31.123812ms)
✔ the clone a file (30.558879ms)
✔ the clone a plain directory (31.6311ms)
✔ .git a gitdir: file (32.071133ms)
✔ .git a symlink (35.219107ms)
✔ .git/logs a symlink (31.582809ms)
✔ .git/refs/heads a symlink (31.411062ms)
✔ .git/objects a symlink (30.599472ms)
✔ a reflog hard-linked to a file outside (30.858439ms)
✔ HEAD a fifo (31.459736ms)
✔ a commondir file (32.186796ms)
✔ an alternates file (31.4754ms)
✔ a gitlink staged, no .gitmodules (44.891473ms)
✔ a gitlink and .gitmodules staged (45.188944ms)
✔ a gitlink to a repository with its own gitlink (53.221316ms)
✔ a gitlink and .gitmodules committed (48.483679ms)
✔ a gitlink and submodule.active in config (45.46646ms)
✔ a .git/modules with no gitlink (34.940179ms)
✔ a clone the session rigged is kept, and git runs nothing and writes nothing outside it (1229.250678ms)
▶ a root that can't be cloned refuses with the project id and leaves nothing behind
✔ missing (10.669759ms)
✔ not a repository (10.253699ms)
✔ a plain directory inside another repository (14.74689ms)
✔ unreadable (10.261765ms)
✔ on no branch (12.822276ms)
✔ missing an object (15.388173ms)
✔ a root that can't be cloned refuses with the project id and leaves nothing behind (75.072395ms)
✔ a clone whose root is on no branch, or gone, is kept and named (29.039185ms)
✔ no credential or forge remote of the root reaches a clone (48.605035ms)
✔ every project in the business file gets its own clone at its own branch (37.202988ms)
✔ a clone past its time gets SIGTERM and refuses the launch, with nothing left in the workspace (514.276414ms)
✔ a clone that ignores SIGTERM gets SIGKILL once the grace period is over (1015.978128ms)
✔ a timeout signals git's process group, so a process git started dies with it (514.085016ms)
✔ once a signalled git has closed, its group gets SIGKILL, so a process that ignored SIGTERM dies too (515.219777ms)
✔ RUNNER_FILES is exactly what the runner and the harness sources it starts import (1.63634ms)
✔ sandboxProblem names a missing bwrap and a bwrap that fails (8.641367ms)
✔ every bind says why; the bundle and session file stay read-only over the run directory (1.369955ms)
✔ the host's git socket is bound only when given, read-only and before the writable binds (1.039055ms)
✔ the git identity is the instance's, at <instance>@<business>.mosaic.invalid, and sandboxArgv refuses without it (1.084317ms)
✔ a confined session's shell reaches its own workspace and run directory and nothing of the founder's or another session's (1726.142975ms)
✔ a bind that exposes a stand-in, or a missing workspace bind, fails the matching check (2670.175895ms)
✔ a confined session that runs tester-cli.mjs is refused as tester, and the same shim outside the sandbox binds (206.95412ms)
✔ resolveSeat: by name under --repo resolves the repo layout (1.232023ms)
✔ resolveSeat: by path resolves the fleet layout (0.333451ms)
✔ resolveSeat: refusals for missing dir, missing launch.sh, non-executable launch.sh, invalid name, and unknown layout (0.705028ms)
✔ tmuxContext: outside tmux, default socket, custom socket, and exec failure (0.655851ms)
✔ makeRegistration produces a record that validates; each shape violation throws SeatError (0.802278ms)
✔ writeRegistration/readRegistration: round trip, permissions, absence, and malformed records (1.259719ms)
✔ updateTask: changes task, taskSetBy and updatedAt only, and refuses appropriately (1.644774ms)
✔ CLI launch: registers, execs the fake launch script, and passes args through (28.172346ms)
✔ CLI launch: --harness lands in the record (34.878383ms)
✔ CLI launch: the launch script's own exit code passes through (35.815063ms)
✔ CLI launch: relaunching a seat rewrites the one registration record (62.985662ms)
✔ CLI launch: omitting --task records an empty string, not null (27.95698ms)
✔ CLI seat task: updates only the task after a launch, and refuses on an unlaunched seat (85.340796ms)
✔ CLI refusals: no args, unknown flag, missing config, already-registered env, and exec failure (149.392727ms)
✔ samePath: equal paths, symlinked dirs, distinct dirs, and non-strings (0.379119ms)
✔ resolveSetBy: explicit --by wins over the environment; absent or empty environment gives unknown; invalid explicit or environment values refuse with exit 4 (0.596011ms)
✔ validateRegistration/readRegistration: taskSetBy is optional; a record without it (written before #1511) still loads unchanged; an invalid one is refused; the version does not change (0.744001ms)
✔ updateTask: records setBy, preserves startedAt and every unrelated field, upgrades an old record in place only when the task is set, and replaces a previous attribution (1.287917ms)
✔ CLI seat task: --by beats MOSAIC_AGENT_NAME, the environment beats nothing, empty environment is unknown, invalid --by or environment refuses with exit 4 and leaves the record byte for byte (303.007657ms)
✔ REQ-LIMIT-1: family substring matching is no longer exported (0.736279ms)
✔ sessionEnv passes only the allowlist, the repo's bin on PATH, and the run id (0.696161ms)
✔ sessionExit gives a runner's signal death back as the signal; a session credential is one whole set of nonempty variables (0.251991ms)
✔ newRun: short ids, 0700 directories, and a refusal when the socket path won't fit (1.458291ms)
✔ session file and launch log: 0600, the session file written once (1.171606ms)
✔ endReason maps the runner's exit codes; a signal is killed (0.124648ms)
✔ Registry mirrors to sessions.json; readSessions marks live entries; bad files refuse (1.216918ms)
✔ stopSession refuses an unknown run, reports a stale one, and won't signal a pid that isn't a runner (0.98007ms)
✔ a session runs in its sandbox as pid 1 of its namespace, claims, answers, and stops on mosaic stop (244.532619ms)
✔ workRef and validBranch take only what a ref name can hold (1.131054ms)
✔ a push lands as the task's work ref, a fast-forward lands after it, and main is the root's (120.790467ms)
✔ a rewritten branch refuses non-fast-forward and the work ref stays (91.91966ms)
✔ a branch with no history in common with main refuses unrelated-history (47.113844ms)
✔ a bundle that needs commits the working repository lacks refuses missing-prerequisite (44.132169ms)
✔ main is fetched from the root again before each push, so a clone of a newer root pushes (105.090196ms)
✔ a root moved back after the clone leaves the push's base unknown: missing-prerequisite (65.677917ms)
✔ a commit with a broken object refuses invalid-bundle, and only because fsck is on (72.126398ms)
✔ only a v2 bundle with the named branch is read (55.714504ms)
✔ a branch named main, or named for another task, moves only this task's work ref (105.571456ms)
✔ a record that throws moves the work ref back, or removes a new one (124.808195ms)
✔ a work ref that can't go back refuses rollback-failed, naming the ref and the commit (349.918665ms)
✔ a root that can't be read refuses root-unreadable (32.774783ms)
✔ a git past its time gets SIGTERM, leaves no ref lock, and the next push lands (2118.910365ms)
✔ a git that ignores SIGTERM gets SIGKILL once the grace period is over (1028.062689ms)
✔ a working repository path that isn't a directory refuses (25.57549ms)
✔ review bundles a pushed commit as refs/mosaic/review, leaving out what the reviewer has (97.911924ms)
✔ review of a project with no working repository refuses unknown-commit (13.466123ms)
✔ the runner's git tools hold the host's limits and branch rule (1.150774ms)
ℹ tests 132
ℹ suites 0
ℹ pass 132
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 5111.544504
@@ -0,0 +1,105 @@
✔ the boot config is checked before anything starts (31.121752ms)
✔ a business with no tracker entry refuses task verbs (15.160232ms)
✔ credential.expiring and .expired are recorded once per instance (34.944956ms)
✔ a token file that changes on disk records credential.changed (14.915956ms)
✔ autostart polls, reconciles and retries a startup the tracker was down for (38.330682ms)
✔ a refusal a restart must clear is not retried by the poll (10.856907ms)
✔ a poll that fires while two are queued is dropped (12.858225ms)
✔ close waits for a running verb and refuses one that has not started (43.438836ms)
✔ stopped bus resumes an empty task cursor after a long outage, exactly once (54.98856ms)
✔ persisted IDs avoid replay, exclude bots and bound rows to observed new comments (38.375555ms)
✔ commit followed by crash before cache advance neither loses nor duplicates a comment (22.311189ms)
✔ failed event insert rolls back cursor and a retry still emits the comment once (19.771093ms)
✔ initial cursor accepts a comment within the stated one-minute clock skew margin (16.864747ms)
✔ the bundled Vikunja is the pinned upstream image the runbook names (1.026315ms)
✔ every published port is on 127.0.0.1, and no secret is in the file (0.474044ms)
✔ the committed table loads frozen; its verbs are the broker task verbs (2.223615ms)
✔ a write names a verb the table has as its own key, not one it inherits (0.576169ms)
✔ the schema refuses any other shape (2.607215ms)
✔ the Field table in the slice 1 plan names the verbs each field is written by (0.741696ms)
✔ each verb refuses, asks for a decision and writes as the table and the role files say (185.630593ms)
✔ the list holds the PRD's requirement ids, in its order, at its version (2.093712ms)
✔ the committed list loads frozen (0.985866ms)
✔ the schema refuses any other shape (3.771499ms)
✔ the fake answers each route with the statuses and shapes Vikunja v2.7.0 sent (102.057253ms)
✔ the project list holds recorded project objects in the recorded list envelope (11.670136ms)
✔ the recorded task bodies pass the checks S3 applies to every read (0.514495ms)
✔ the client works against the fake over real HTTP with the platform fetch (98.766162ms)
✔ a correct install starts, and the first reconcile records tasks that already exist (30.699595ms)
✔ verbs refuse while a business is starting and after startup refused it (19.621606ms)
✔ startup refuses a token that can do more than its role needs (47.988299ms)
✔ startup refuses an unsupported version and flags an untested one (32.044155ms)
✔ startup refuses a board that the runbook did not install (39.759946ms)
✔ startup refuses a project the sync bot cannot read (8.670932ms)
✔ startup refuses a configured label the pm bot cannot see (8.796369ms)
✔ startup refuses an expired credential and a missing sync credential (15.173294ms)
✔ an unreachable tracker refuses with tracker-unavailable (6.2927ms)
✔ an edit in the UI is recorded once, with the fields that changed (84.724161ms)
✔ a move between open buckets is seen on the board, though updated does not change (41.612684ms)
✔ a person's comment is counted and a bot's is not (69.690991ms)
✔ the hourly reconcile catches a comment through comment_count (78.846912ms)
✔ a task closed in the UI leaves the open view with its done bucket (114.699406ms)
✔ a task that leaves the board is recorded as deleted, moved or out of reach (65.810782ms)
✔ a poll that read before a verb wrote does not overwrite the verb (34.127077ms)
✔ a tracker fault during a tick is reported and the next tick catches up (36.748651ms)
✔ a malformed answer refuses the tick with tracker-shape (15.076083ms)
✔ no token value reaches the database, the log or a refusal (46.78952ms)
✔ the first look at a task counts only comments inside the window (34.521549ms)
✔ with descendants off the adapter reads no project list and no child, as in v0 (51.841049ms)
✔ a three-level tree is read per project, and a project outside it is not (24.753682ms)
✔ a watch-only child keeps the business ready, and a failed child read refuses as transient (27.600618ms)
✔ a root without the installed buckets refuses with descendants on (9.870645ms)
✔ the project list refuses without read_all, and skips archived projects and saved filters (24.568675ms)
✔ descendants must be true or false (10.699061ms)
✔ a move inside the tree is a moved tombstone plus a snapshot under the new ref (61.025297ms)
✔ a child made after startup joins the tree at the next reconcile (54.261479ms)
✔ a child that leaves the tree has its tasks recorded missing, and the ledger keeps their history (59.459914ms)
✔ a child deleted or hidden between reconciles drops out at the next tick, and the root still refuses (85.068419ms)
✔ a reconcile that fails keeps the previous tree (33.707474ms)
✔ a closed task the cursor missed takes its own project's done bucket (31.992503ms)
✔ closed tasks read at startup take their own project's done bucket (9.978982ms)
✔ a closed task the cursor returns takes its own project's done bucket (37.2709ms)
✔ a task moved out of a child that leaves the tree at the same time is moved, not no-access (35.31806ms)
✔ a task in a bucket outside the five keeps its raw bucket id (30.57272ms)
✔ bucket 0 on a watch-only task never becomes a bucket or a state (78.816935ms)
✔ a child whose kanban view is deleted still refuses the tick, and the reconcile reads it watch-only (56.388459ms)
✔ a child dropped at a tick comes back at the next reconcile once the bot reads it again (35.584486ms)
✔ task.create needs a recorded human request and a requirement id (44.384267ms)
✔ task.create takes only an approved requirement id (27.379205ms)
✔ task.create cites the request a received message carries, not the message's id (21.048112ms)
✔ only labels named in the business file can be written (29.68317ms)
✔ task.schedule sets and clears a due date and relations (35.101646ms)
✔ assign and reassign move the role bots and record task.assigned (54.290108ms)
✔ task.update.assigned is for the assignee and records task.state (48.12765ms)
✔ a wrong expected digest records task.conflict and writes nothing (18.295048ms)
✔ a cross-role verb needs a resolved decision, used once (22.068299ms)
✔ task.close needs a verdict; after it every verb refuses with task-done (21.028465ms)
✔ a task with pushed work closes only citing an approve verdict for its latest push, and a refusal writes nothing (29.690654ms)
✔ a lost answer is settled by a re-read and never retried (23.890832ms)
✔ a create whose answer is lost is reported uncertain, and the poll finds the task (36.778463ms)
✔ a task the sync bot cannot read refuses and records nothing (15.878304ms)
✔ verbs and polls for one business run one at a time (86.397159ms)
✔ a due date with milliseconds is written to the second (65.765733ms)
✔ every write landed and the final read failed: the verb succeeds and records what it wrote (39.029463ms)
✔ some writes landed and the final read failed: write-uncertain, and nothing is recorded (13.696109ms)
✔ a create whose final read fails succeeds and records task.created (32.079502ms)
✔ an edit between the last write and the final read shows as external on the next poll (37.250343ms)
✔ task.created is recorded when a later label write fails (12.29171ms)
✔ every verb acts in an installed child, on the child's own board (43.725725ms)
✔ task.create refuses a project outside the tree, a watch-only one and one that is not an id (25.316542ms)
✔ with descendants off, task.create takes only the root project (12.917187ms)
✔ a verb on a watch-only task, or a relation to one, refuses with project-not-installed (17.088778ms)
✔ a related task is read before authority: in another project than its ref names, task-moved; missing, task-not-found (22.925977ms)
✔ a task moved to another project of the tree refuses its old ref with task-moved and takes its new one (14.450606ms)
✔ a verb and a create in the child record the final read of the child's board (13.687874ms)
✔ a create in the child whose final read fails records the child's todo bucket (33.437577ms)
✔ a lost answer in the child is settled by a re-read of the child's board (21.127354ms)
✔ every related task is read before authority, not only the first (15.19141ms)
ℹ tests 97
ℹ suites 0
ℹ pass 97
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1051.072883
@@ -0,0 +1,86 @@
OK args: exit 2, target-branch
OK args: exit 2, target-branch
OK args: exit 4
OK args: exit 4
OK args: exit 4
OK --base next refuses before the config is read (got 2)
OK a missing system config exits 3 (got 3)
OK nobus: exit 3
OK schema-table: exit 2, bus-schema
OK schema-meta: exit 2, bus-schema
OK openBus opens the database read-only
OK other-business: exit 2, decision-not-found
OK no-raise: exit 2, decision-not-found
OK not-a-merge: exit 2, not-a-merge
OK open: exit 2, not-resolved
OK withdrawn: exit 2, not-resolved
OK by-agent: exit 2, not-human
OK via-webui: exit 2, not-human
OK no-answer: exit 2, not-human
OK answer-role: exit 2, not-human
OK answer-run: exit 2, not-human
OK answer-choice: exit 2, not-human
OK two-answers: exit 2, not-human
OK chose-no: exit 2, not-merge-option
OK two-refs: exit 2, work-ref
OK bad-ref: exit 2, work-ref
OK task-ref-disagrees: exit 2, work-ref
OK raise-task-disagrees: exit 2, work-ref
OK target-main: exit 2, target-branch
OK superseded-question: exit 2, superseded
OK superseded-task: exit 2, superseded
OK superseded-by: exit 2, superseded
OK no-verdict: exit 2, no-verdict
OK changes-later: exit 2, not-approved
OK short-commit: exit 2, not-approved
OK wrong-task-body: exit 2, not-approved
OK args: exit 2, decision-not-found
OK earlier-merge: exit 0
OK ref-moved: exit 2, ref-moved
OK root-top: exit 2, root-file
OK root-newdir: exit 2, root-file
OK root-src: exit 2, root-file
OK unrelated: exit 2, base-conflict
OK empty: exit 2, base-conflict
OK an empty reviewed diff says so
OK dirty-changed: exit 2, checkout-dirty
OK dirty-staged: exit 2, checkout-dirty
OK dirty-untracked: exit 2, checkout-dirty
OK conflict: exit 2, base-conflict
OK a refusal after the worktree is added leaves no worktree or scratch
OK cleanup looks for the worktree's Docker network
OK dirty-during: exit 2, checkout-dirty
OK a reviewed path dirtied during the gate moves no ref
OK drift: exit 2, patch-drift
OK patch-drift leaves no worktree or scratch
OK gate-pkg: exit 2, gate-failed
OK gate-failed names the failing step's log, and it exists
OK gate-failed leaves no worktree or scratch
OK gate-script: exit 2, gate-failed
OK a failing scripts/test-*.sh fails the gate
OK a failed gate moves no ref
OK nomods: exit 4
OK a target without node_modules leaves no worktree or scratch
OK moved: exit 2, base-moved
OK base-moved keeps the base where it moved to
OK dry: exit 0
OK --dry-run writes no ref
OK --dry-run prints the commit with --subject and --attribution
OK --dry-run runs the gate
OK --dry-run leaves no worktree or scratch
OK land: exit 0
OK the landing is one commit on the old base
OK its diff is the reviewed diff by patch-id
OK its tree is the dry run's
OK the message has the option's subject, the citations and the attribution last
OK it prints the line for the PM
OK the checked-out base has the reviewed paths and keeps the unrelated edit
OK only refs/heads/refactor exists in the target
OK the work ref is unchanged
OK the bus database is unchanged
OK the gate ran each package suite but queue, and each scripts/test-*.sh
OK the landing leaves no worktree or scratch
OK the target's node_modules is still there, with its contents
OK the worktree's Docker network is removed
---
land-work selftests: 84 passed, 0 failed
@@ -0,0 +1,122 @@
✔ the Board: held roles with no machine sessions, a claim with no address, the toggle kept across a reload, and Message addressed to the role (5264.904615ms)
✔ a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads (6849.113241ms)
✔ browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback (3394.947468ms)
Rendered contrast: {"failures":[],"count":330,"lowest":4.504658476260286}
✔ served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures (4239.0484ms)
✔ the README says a refused field table names the file or the key at fault, never a path (1.211982ms)
✔ S5 pages in a browser: real broker rows, inert text, Copy, routes, failures, layout and no writes (6003.239026ms)
✔ bus routes serve the four reads from the reader view, unescaped JSON for the page to escape (132.500516ms)
✔ decisions route serves the roles and recent lists from the reader view (252.746133ms)
✔ credentials route serves the broker credential status from the reader view (275.277045ms)
✔ replies route serves the replies to one message from the reader view (861.609933ms)
✔ bus routes are GET-only, have no write verb, and keep the same-origin checks (279.465846ms)
✔ bus refusals map to statuses and never carry a path or a stack (118.017692ms)
✔ humanCall speaks the human transport protocol without blocking the server (2555.035688ms)
✔ busReader takes --business, else the live bus host, else refuses (513.203873ms)
✔ humanCall kills a transport that runs past its timeout (566.878299ms)
✔ serve refuses a --business value that is not a business id (988.274243ms)
✔ conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers (3445.429948ms)
✔ conversation view: a fork keeps the open branch, says so, and opens the new one on request (2524.339588ms)
✔ conversation view: a newer session with no readable history keeps the marker (2221.82666ms)
✔ conversation view: seats without history say so and offer no reply (1221.624116ms)
✔ Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content (3011.06364ms)
✔ return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch (54576.693789ms)
✔ messages HTTP read pages and preserves metadata; bad arguments refuse before a read; other reads unchanged (172.675499ms)
✔ Console Messages lists late unsolicited messages on polling with sender class time read state and trail; nav count includes beyond the page; reads never deliver (4537.16862ms)
✔ Messages nav count polls on Tasks and Board and clears on a Board refusal; messages 403 refuses local pages (3557.092683ms)
✔ copy writes the Console paths at the full id, read-only, and leaves an existing copy alone (4189.993712ms)
✔ copy refuses a commit off refactor, an unknown one, a worktree and a bad copies directory (2532.347539ms)
✔ copy writes nothing when an archived path is missing at the commit (827.334825ms)
✔ a copy that loses the rename race leaves no read-only temp directory behind (605.686286ms)
✔ a copy whose rename fails with no target refuses, writes no copy and leaves no temp directory (1019.382073ms)
✔ a copy that meets a target holding files says exists: and moves nothing into it (573.649196ms)
✔ a copy that meets an empty target directory replaces it by rename, not by moving into it (501.401979ms)
✔ serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout (770.228875ms)
✔ serve refuses a --checkout that is not an absolute directory path (221.912175ms)
✔ consoleReads runs the queue read under queueRoot and reads RELEASE under root (51.612716ms)
✔ preview options are all three or none, and each is checked (11.234171ms)
✔ the CLI refuses a partial or invalid preview flag set at start (3021.734431ms)
✔ the CLI with all three flags serves the preview mode (474.888324ms)
✔ a request for the public name needs the header equal to the user id, else 401 and nothing read (57.757028ms)
✔ the header name matches without regard to case, and the value compare is exact (10.566872ms)
✔ the public name with the header reads the bus views, and the Host and Origin are checked first (142.521147ms)
✔ in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing (27.050603ms)
✔ loopback requests are unchanged in preview mode, and the bus views work with the board down (10.908607ms)
✔ a proxy that rewrites Host to the loopback port gets 403 on its first request, whatever the user (61.716427ms)
✔ a repeated Host is 400 before any other check, in preview mode and without it (20.159937ms)
✔ without the preview flags the public name is refused and writes still reach the board (11.240967ms)
✔ redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote (2.124145ms)
✔ credential state from the date alone, as the bus states it (0.278232ms)
✔ queue: rows, one row, ids and methods, notes; no path or id from a row (2863.869902ms)
✔ queue: a refused read fails closed with the store's text, redacted (855.965177ms)
✔ queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack (2023.482178ms)
✔ business: names, vars on the allowlist, authority, credential metadata only (80.339588ms)
✔ business: missing or invalid file refuses with the module's text, redacted (89.877986ms)
✔ business without --business: the live bus host, else no-bus-host; no system config: not-configured (41.689242ms)
✔ settings: release, board, loopback address, notifier binding name only (721.778158ms)
✔ task fields: the committed table with no reads configured, GET only; a bad table refuses with no path (55.292394ms)
✔ both presentations replace old activity with relaunch notice, label retained history, then resume after new activity (2985.585747ms)
✔ reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh (22755.620617ms)
✔ the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx (5096.920643ms)
✔ a sent receipt lists its replies as the page refreshes, oldest first and capped, and marks none delivered or read (12812.974139ms)
✔ startServer takes send only as a function and only with the preview mode (14.908626ms)
✔ the CLI refuses --send without the preview flags, and without a configured bus (1210.78239ms)
✔ the CLI with --send and a configured bus sends through the human transport and logs one line (339.133654ms)
✔ without --send the route is read-only like every other write, and config says no send (25.854396ms)
✔ with --send every other write is still 403 read-only, and nothing reaches the board or bus (33.808182ms)
✔ config says send only on the public name with --send on (7.510429ms)
✔ a send without the signed-in user is 401 and is neither sent nor logged, loopback included (32.437576ms)
✔ each request check refuses before anything is sent, and logs the refusal without the body (53.318748ms)
✔ a send passes exactly to, body and in_reply_to, answers the id, and logs it (21.659186ms)
✔ a crafted role name cannot forge a log line (6.432655ms)
✔ a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried (23.901346ms)
✔ through the human transport the request is exactly the business, message.send and the keys (307.501702ms)
✔ against a real broker: the message is written once as a human send, and its refusals map (153.785088ms)
✔ loopback host and board origin fail closed (25.222855ms)
✔ real board fixture passes through WebUI; assets and isolated seen/reply work (196.567313ms)
✔ proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect (151.70734ms)
✔ unreachable board reports URL; CLI rejects unsupported options (959.88192ms)
✔ render prints the unit without the preview flags (52.452634ms)
✔ render carries every serve flag, the preview set included (79.492078ms)
✔ a partial preview set is refused (113.346915ms)
✔ a value the unit cannot carry is refused, for every flag (1322.95227ms)
✔ the rendered unit names none of the human proof agent markers and sets no agent variable (67.624502ms)
✔ install into --dir with --no-reload writes the file whole, then reports unchanged (368.686618ms)
✔ install without --no-reload runs daemon-reload and nothing that starts the unit (167.691565ms)
✔ uninstall refuses while the unit is running, with or without --no-reload, and removes it once it is not (1127.068415ms)
✔ status prints the unit state and GET /healthz on the installed port (654.328807ms)
✔ usage: an unknown verb or flag, and start or enable, exit 4 (95.794372ms)
✔ without --send, render is the template with its placeholders filled, byte for byte (50.297628ms)
✔ render --send with the preview set ends ExecStart with --send and changes nothing else (64.213703ms)
✔ --send without the whole preview set is refused and writes nothing (196.748384ms)
✔ install --send into --dir with --no-reload writes the sending unit, and status says whether it sends (473.059788ms)
✔ uninstall and status take no --send (84.682541ms)
✔ install refuses while the unit is running, in any state but inactive or failed, and changes nothing (903.985629ms)
✔ when the state read fails, install and uninstall refuse with one line and change nothing (106.680834ms)
✔ install needs --copy naming a copy that exists, and refuses outside the canonical checkout without --allow-worktree (71.449652ms)
✔ install refuses unless the bus unit runs from the same copy, naming both paths, and changes nothing (275.612378ms)
✔ render --copy runs the copy and reads the queue in the checkout; without --copy the unit is unchanged (19.256655ms)
✔ the uid header takes only an Authentik uid; the username header still takes a username (385.001654ms)
✔ a preview value cannot start with - or . (29.311419ms)
✔ status names the copy and whether refactor has moved past it, and says when the unit runs from no copy (418.755982ms)
✔ Settings form shows every scope and held role; gated save, exact payload, retained draft and no automatic retry (3064.951244ms)
2026-10-15T17:00:38.927Z settings user="jason" changed
✔ REQ-WEB-1 signed-in same-origin Settings write reaches exactly one human operation (66.409188ms)
✔ REQ-WEB-1 Settings writes require enabled send, named Host, identity, exact Origin and same-origin Fetch Metadata (14.548245ms)
✔ REQ-WEB-1 Settings validates JSON, size, revision and strict writable keys before broker call (11.685139ms)
✔ tokens.css and icons.svg are byte copies of docs/design (2.792135ms)
✔ JetBrains Mono files match the digests in their sources file, with the OFL text (3.444631ms)
✔ shipped strings are unchanged, and the web keeps its write boundary (4.374134ms)
✔ shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette (7966.727862ms)
✔ five states on Board, Inbox, Tasks, Agents and Trail (2232.220164ms)
✔ Queue, Business and Settings in their five states, with no move button (5297.612022ms)
✔ seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module's words (1043.215719ms)
✔ the server log names no config path when the system config refuses (419.452662ms)
ℹ tests 112
ℹ suites 0
ℹ pass 112
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 55118.019432