18 KiB
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)
role.end: a new action. A role whose file grants it (only the chief of staff) ends another role's run withrole.revoke {role, reason}: a non-emptyreasonof at most 500 characters and nodecision. The reason is written torole_claims.reasonand to theaction.allowedevent body (class: within-role,reason).role.revokeitself stays gated for every role (decision 46), so any other role still needs a resolved decision.organization.change: a new action. A role whose file grants it calls the new broker verborganization.set, which takes exactly the keys of row 122's write path and goes through the samelaunchSettings.setwriter the human'slimits.setuses (same validation, same history receipt).- Schema 3c → 3d: the
role_revoke_needs_decisiontrigger accepts a revoke with a reason and no decision. - 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 isEND_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}getdecision-required; sending{role}getsinvalid-request; thehumangetsagent-required;readerandtesterare refused; therole_claimsrow is unchanged and norole.endevent 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 areasontogether with adecisionis 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-refusedfor both; nothing inserted, norole.endallowed 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}ororganization.setthrough 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:92still tells every role "Only the human stops a run, withmosaic stop" and to raise arole.revokedecision. 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
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)