Files

18 KiB
Raw Permalink Blame History

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

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)