Files
jason.woltje 6bad02612a lead: commit dewey's untracked evidence (721 files: queue-124-review queue-143 queue-149 queue-165-review queue-170 queue-175 queue-177 queue-184 queue-191)
Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
2026-11-02 15:05:14 -06:00

34 KiB

Row 149 (#1643): security-reviewer and merge-gate roles, and proactive contracts

Builder: Dewey. Reviewer: Filbert. Gate owner: Sage. Brief: docs/plans/2026-10-15_q167-rows-r3.md, section "Security-reviewer and merge-gate roles, and proactive contracts", blob 8a3f0acca2 (rev 1632, commit c0af7f248). Assignment sage-0642, on blob 3fec535fd2 (rev 1627). c0af7f248 changed only the instances bullet (sage-0651), which is outside the build. The probe and organization.set questions are in sage-0650; the rulings are in sage-0651.

Base: 4b1795a8c4 (base.txt, origin/refactor, pushed in sage-0644). The candidate is build.patch on that base. candidate-manifest.sha256 lists every changed path with its sha256.

What ships

  • roles/security-reviewer.json and .md (new):
    • tools read, grep, find, ls; network api-only; withinRole review.verdict only; no crossRole grant; no credentials.
    • messaging sendTo and receiveFrom human, chief-of-staff, pm, cto, coder. coder and cto hold review.request, so the verdict can reach the role that asked.
    • The contract covers security review only and gives one verdict per request: approve, or changes with the reason.
  • roles/merge-gate.json and .md (new):
    • The same four read tools; withinRole empty; crossRole git.merge.protected only; no credentials.
    • messaging sendTo and receiveFrom human, chief-of-staff, pm, cto. cto, the technical arbiter, closes merge-gate's raise and must be able to reach it (row 133).
    • Contract: check the trail; raise git.merge.protected with domain technical and the task ref as target; land with land_work once the decision is resolved, the verdicts approve and the gate passes; otherwise don't land, and name the refusal code.
  • The proactive paragraph is in the Authority section of all 13 contracts, the 11 existing ones plus the two new ones: "Within your grants, act on what you see without waiting to be asked, and raise a decision or send a message for anything outside them."
  • roles/pm.md and roles/chief-of-staff.md get the REQ-DEC-2 routing paragraph after the proactive line, plus one-line edits to the old sentence that sent anything ungranted to the human. The paragraph lists the eight hard waits, and a test pins them to GATED_ONLY.
  • packages/harness/src/tools.mjs, raise_decision only:
    • an optional domain string, enum technical and delivery. Leaving it out means delivery.
    • a description saying where a raise goes: a cross-role raise, or a gated raise that cites the current spec commit, goes to the domain's arbiter; an uncited gated raise or a hard wait goes to the human.
    • end_run's text is unchanged; it belongs to row 155.
  • READMEs: packages/harness (domain) and packages/business (a new section, "Security-reviewer and merge-gate instances").

coder and reviewer are unchanged apart from the proactive paragraph.

Tests (new files only; no existing test file changes)

  • packages/business/tests/workflow-roles.test.mjs:
    • Each new file deep-equals its approved list, and its contract exists with the right header.
    • security-reviewer has no write, edit, bash or web tool; over ACTIONS, only review.verdict classifies within.
    • merge-gate's authority is exactly crossRole git.merge.protected; it has no write tool; over ACTIONS, only that action classifies cross.
    • Either role named as the technical or delivery arbiter refuses the business file with the row 133 message.
    • Every roles/*.md has the proactive line inside its Authority section.
    • pm and chief-of-staff carry the routing text. The hard waits they list equal GATED_ONLY.
  • packages/bus/tests/workflow-roles.test.mjs:
    • merge-gate's technical raise is cross-role and routes to cto. Before it's resolved, authorize fails decision-not-approved. After cto resolves it, cto can't use it (decision-mismatch); merge-gate uses it once, then gets decision-consumed.
    • merge-gate's message.send fails decision-required. A raise with no domain routes to pm. git.push.working is gated and routes to the human.
    • With cto removed from merge-gate's receiveFrom, the technical raise fails decision-route-refused and stores no decision.
    • security-reviewer answers a review.request with review.verdict, a REVIEW-RESULT to the coder. Its message.send fails decision-required, and it can't push or merge.
  • packages/harness/tests/raise-domain.test.mjs:
    • For coder, reviewer, pm and chief: domain is an optional string with enum exactly technical and delivery, and the descriptions match.
    • Through a fake tool socket into a real broker: technical, delivery and omitted route to cto, pm and pm. The forwarded args carry technical, delivery and no domain key. 'security' reaches the broker and fails invalid-domain. The harness doesn't check its own schema, so the broker is the refusal; three decisions are stored.

The brief allowed either outcome for a merge-gate arbiter: refuse it, or pin what happens. It's refused. The row 133 rule already refuses any arbiter whose definition declares messaging lists, and both new roles declare them. Making merge-gate an arbiter would also send its own merge raise to the human and give it every technical decision.

Calls made while building

  1. Neither role holds message.send. The brief gives security-reviewer message.send only if the verdict needs it, and it doesn't: review.verdict inserts the REVIEW-RESULT itself. For merge-gate the brief names one grant. See "Measured" for what this costs.
  2. The domain description stays neutral. My first draft gave domain examples ("technical for code, architecture and landing ..."). The PRD doesn't define the domains, and pm.md already routes a scope disagreement with the CTO to technical, so I removed them.
  3. A merge-gate raise with no domain goes to delivery (pm). The broker allows it, and changing the default is out of scope. The contract tells merge-gate to pass technical; the bus test pins the fallback.
  4. The routing paragraph is new text in pm.md and chief-of-staff.md, with two one-line edits elsewhere in pm.md and one in chief-of-staff.md. This keeps the row 143 overlap to separate paragraphs.

Measured (sage-0650 (a))

Question: when a merge-gate or security-reviewer turn ends and the runner's RESULT reply (runner.mjs:386-391) is refused, does anything reach Jason?

The probe is probe-149a.mjs; its output is probe-149a.out. It ran in a scratch store against the r149 tree. Four cases: each role, each with a message from cto and one from the human.

  • In all four cases the reply fails decision-required, and message.read doesn't run.
  • The store gains one row: events, kind action.refused, subject null, body {"code":"decision-required"}. It writes no decision and no decision_events row.
  • The human's inbox, decisions roles and decisions recent are 0 before and after.
  • message.receive returns nothing afterward, so the message is not redelivered.
  • Nothing shows that row. inbox and both decisions lists read only the decisions table (broker.mjs:1899-1934). trail matches events by subject, or by body.decision through a decision (broker.mjs:2353, 2368), so the Console never renders it.
  • The one other reader is tasks/live/run.mjs:162, the S3 live-run tally.

Ruling (sage-0651): keep the grants. Acceptance 314 never messages either role directly, so step 6 ("the first inbox is empty") holds.

Cost: neither role can answer a message. A human who messages merge-gate or security-reviewer gets no reply. All the store records is a null-subject action.refused event, and no inbox, decisions list or trail shows it. merge-gate reports a refusal through the task trail, where the bus records it with its code, and in its turn answer. It doesn't send a message.

Follow-ups

  1. No message.send for either role. Decide whether these roles should hold message.send within-role, or whether the runner should skip the reply when the role lacks it. Sage owns that brief change.
  2. A reviewer without bash can't read the candidate. fetch_review fetches the ref, but no typed tool shows a diff or a file at a ref, and security-reviewer has only read, grep, find and ls. The contract says never approve unread work and tells it to say so in a changes verdict. This blocks step 4 of acceptance 314, not this row; Sage files it as a P19 follow-up and a prerequisite for the 314 run (sage-0650).
  3. organization.set can't add an instance. The brief says instances come in through chief's organization change (row 132). In launch-settings.mjs:
    • instances is "instance model selections".
    • Lines 47-52 refuse invalid-request for any instance not already in roles.
    • The only thing it writes is roles[instance].vars.model. business.mjs:130 also refuses a launch.instances name that roles doesn't declare. So adding either role to a business is a business-file edit, a human step today. I reported this in dewey-0572. c0af7f248 fixed the brief and acceptance 314's "Before the run": Jason adds both by editing the business file, then validates it. The business README says the same. Sage is filing whether the chief of staff should be able to add an instance as a P19 policy follow-up for Jason (sage-0651). Not this row.
  4. Stale gated text in coder.md, cto.md and reviewer.md. They still say a gated action needs a decision "for the human", which isn't right under REQ-DEC-2 when the raise cites the spec. The row covers pm and chief-of-staff only.
  5. The shipped messaging lists name cto. A business whose technical arbiter is another instance needs a reviewed commit to merge-gate's lists; the business README says so.

Overlap with row 143

Row 143 (Researcher) edits roles/pm.md, roles/assistant.md, roles/cto.md, packages/harness/src/tools.mjs (BLOCK in MESSAGE_CLASSES) and packages/harness/tests/tools.test.mjs. This row:

  • adds a separate paragraph to pm.md, assistant.md and cto.md;
  • changes only raise_decision in tools.mjs;
  • doesn't touch tools.test.mjs. Whichever row lands second rebases and reruns every touched test file before going in-review again.

Single runs

These ran in my slot (sage-0653), in ~/dewey-scratch/r149 with the candidate applied, starting 2026-10-18T13:03:29Z. Each file ran on its own with node --test. All rc 0; logs are in single/.

  • New: business workflow-roles 6/6, bus workflow-roles 4/4, harness raise-domain 2/2.
  • Existing tests that read the touched files: business role 14/14, business assistant 11/11, business record-grants 1/1, bus csuite-messaging 16/16, bus decision-route 5/5, harness tools 19/19, harness spec-tools 2/2.

Mutants

mutants.py, 2026-10-18T13:03:52Z to 13:05:08Z. There is one mutant per new check, and each runs the three new test files. 19 of 19 were killed, the tree was restored (restored True), and the run ended rc 0. mutants/run.log names the failing tests for each mutant, and the per-mutant logs sit beside it.

  • T1-T4 (tools.mjs): drop the domain parameter; add "human" to its enum; cut its description down to "Optional"; replace raise_decision's routing description.
  • S1-S3 (security-reviewer.json): add bash; add message.send within-role; remove the messaging lists.
  • G1-G6 (merge-gate.json): move git.merge.protected within-role; add a second cross-role grant (task.reassign); add edit; drop cto from receiveFrom; remove the messaging lists; add message.send within-role.
  • H1: change merge-gate.md's contract header.
  • P1-P2: remove the proactive line from reviewer.md; move it outside Authority in coder.md.
  • R1-R3: remove chief-of-staff.md's routing paragraph; swap pm.md's deploy hard wait for git.merge.protected; restore pm.md's old "for the human" text.

After the restore, every changed file's mtime is 13:05:08Z or earlier, so the gate below ran on the candidate the manifest names.

Gate

gate.sh, 2026-10-18T13:06:02Z to 13:10:25Z, rc 0; the log is gate-run.log and per-step logs are in gate/.

  • Packages: bus 337, business 119, cli 230, harness 110, plugins 28, seat 150, tasks 97, webui 143. All pass.
  • Shell suites, all rc 0: test-auth, test-conductor, test-config, test-discord, test-extension-package, test-foundation, test-land-work, test-queue, test-release, test-task.

gate.sh left out six packages. gate-extra.sh ran them from 13:10:34Z to 13:11:49Z, rc 0 (gate-extra.log): control-board 124, conversation 182, ledger 86, mosaic 69, project 37, runs 43. discord and queue run inside test-discord.sh and test-queue.sh.

Docker: 50 containers and 15 networks before; 50 and 16 after. The extra network was r149_default (b3ff8181c4eb, compose project r149). It was created at 13:09:36Z, during test-release, and had 0 containers. I removed it by name at 13:12:24Z, which brought the count back to 50 and 15. No process of mine is left (checked by /proc cwd under ~/dewey-scratch).

Not run: Sage's integration gate at landing.

Candidate

build.patch and candidate-manifest.sha256 were regenerated after the gate. Both of the earlier copies came before the business README fix in sage-0651.

  • 21 paths: 14 modified and 7 new.
  • The patch applies cleanly to 4b1795a8c in a fresh worktree, and every manifest hash checks there. That worktree has been removed.
  • Manifest digest (sha256 of candidate-manifest.sha256): c43d0d7cf0221b463f8a42a9a48d962bc1e473b6771f020ac3191bbe9ec3faea.

Round 2: rebase onto ac55e84fe (sage-0673)

Row 143 landed as 0278b9e0b, and origin/refactor is now ac55e84fe4 (base.txt). Round 1's base.txt, build.patch and candidate-manifest.sha256 are kept in r1/.

Drift. git diff 4b1795a8c ac55e84fe on the 21 manifest paths changes 5 of them, all in 0278b9e0b:

  • packages/harness/src/tools.mjs: BLOCK added to MESSAGE_CLASSES.
  • roles/chief-of-staff.md, roles/pm.md, roles/cto.md and roles/assistant.md: one spec-review bullet at the end of Duties.

The rebase:

  • build.patch applies to ac55e84fe with no conflict and no change.
  • Its added and removed lines match round 1's, all 501 of them. Only context and line offsets moved.
  • The new hashes are on the 5 drifted paths only. The other 16 match round 1.
  • Read together, row 143's bullets end Duties and row 149's paragraphs open Authority, so the two rows don't contradict each other.
  • The patch applies to ac55e84fe in a fresh worktree and all 21 hashes check there. That worktree has been removed.

Single runs, r2/singles.sh (no slot). Each file was run alone with TMPDIR=~/dewey-scratch/tmp, 14:01:28Z to 14:02:25Z. Log: r2/singles.log, per-file output in r2/single/. The files:

  • row 149's three new tests;
  • row 143's spec-review and spec-review-contracts tests;
  • every test that imports harness tools.mjs;
  • every test that reads the repo's roles/;
  • every test that builds a business from them.

Results:

  • 37 files, 337 tests, 0 failures.
  • Manifest hashes check before and after the run, and nothing else in the tree changed.

Not run, because each needs the slot: package suites, scripts/test-*.sh, mutants and the landing gate.

New candidate:

  • build.patch sha256 9426ae8dda49055eb49b7b1d6fdd3a9a33352e49b4e85a98b70e4309babdd17b.
  • Manifest digest (sha256 of candidate-manifest.sha256): a325a32568a6a2635837455795b0965f1126f31fc25c4048e56e132263aeffcb.

Round 3: Filbert's 28878, Sage's ruling sage-0684

Base unchanged: ac55e84fe (base.txt). Round 2's base.txt, build.patch and candidate-manifest.sha256 are kept in r2/. The path list is the same 21 paths, and only 5 hashes change: roles/merge-gate.md, roles/cto.md, roles/chief-of-staff.md, roles/pm.md and packages/business/tests/workflow-roles.test.mjs.

Item 1: the landing takes two runs

The launcher reads landHeld once, at launch (launcher.mjs:262). Only a run launched while it is true gets land_work, the "Landing" prompt line and the git socket. A non-thread instance starts only through role.launch, the pm's launch tool or --pm. So the run that raises the merge decision can't land it. The contracts now say who does each step:

  • merge-gate.md: the raising run checks the trail, raises git.merge.protected with domain technical, the task reference as target and the spec citation (below), and ends its turn. If the approval reaches a run with no land_work, that run doesn't land and doesn't raise again. A later run of the same instance, launched after the resolution, has land_work and lands. That later run is the brief's acceptance step 7.
  • cto.md: on resolving a git.merge.protected decision with its approval option, send the chief of staff one message with send_message naming the raising instance (raised_by_role, which #decisionView returns) and the decision id. cto holds message.send (roles/cto.json).
  • chief-of-staff.md: on that message, launch the instance with launch (it holds role.launch). It ends the raising run first under the limits in "The chief of staff ends the raising run" below.

No new bus test (sage-0684). These row 137 tests already pin the states Filbert asked about, and they cover his item:

  • packages/bus/tests/land.test.mjs:333-351: landHeld is false before the resolution and true after ("landHeld is true only for an approved, unspent cross-role merge decision this role raised for a task in this thread").
  • packages/bus/tests/land.test.mjs:142: landHeld is false again once the landing spends the decision.
  • packages/cli/tests/launcher.test.mjs:391: land_work, its prompt line and the git socket are bound only at launch, and only when the broker says the role holds a merge decision.

r3/singles.sh runs both files with the others, so the run log records them.

The automatic relaunch, with no cto message and no chief-of-staff step, is a #314 follow-up row and isn't part of row 149.

Sage's ruling sage-0691 on the round 3 deviations

The first round 3 build differed from sage-0684 in two places. Sage ruled on both, and on where the tests go.

  1. The chief of staff ends the raising run: kept, with narrower wording. The runner stays in its message.receive loop after a turn (runner.mjs, loop: while (!stopping)). The thread idle stop at launcher.mjs:370-391 covers thread instances only. So the raising run is still running when the approval arrives, and launch refuses instance-running (launcher.mjs:202). The chief-of-staff Duties bullet now ends a run only when all of these hold: the cto's message named the instance and the decision, launch for that instance was refused instance-running, and the run belongs to that instance. It then ends it with end_run, with a reason naming the decision, launches once it has exited, and ends no other run. The chief of staff holds role.end. end_run refuses only the caller's own run and the assistant's (tools.mjs:165-169, chief-of-staff.test.mjs:176). The ended run's next broker call fails run-ended or not-holder, and the runner exits (SESSION_GONE, runner.mjs:48). The end doesn't touch the decision, so landHeld stays true for the next launch. launch-revoked is business-wide, not per instance (broker.mjs:698-702). chief-of-staff.md:69 now reads "Only the human stops a run, with mosaic stop, apart from the merge relaunch under Duties." #1651's row will make the host end and relaunch the run itself; this contract step stays as the fallback.
  2. The raise cites the spec: overruled, now cited. Routing doesn't need the citation (broker.mjs:1466-1467: a cross-role raise is arbitrated uncited), but acceptance 314 does. Step 5 passes only if merge-gate "raises the git.merge.protected decision citing spec/north-star.md at the commit from step 2". Step 8 checks that a raise citing the old commit is refused after a save (REQ-DEC-6). merge-gate.md now says: read the spec with read_spec and pass project, spec_path spec/north-star.md and spec_commit; if the raise is refused stale-spec, read the spec again and raise once more with the current commit; if the project has no spec the human saved (read_spec is refused, or confirmed is false), raise uncited and say so in the question. raise_decision has no reason field; its description says the reason goes in question. The refusals a citation can bring (stale-spec broker.mjs:1213, spec-unconfirmed broker.mjs:1212, invalid-spec-citation) are the behavior step 8 wants.
  3. Tests in workflow-roles.test.mjs, not role.test.mjs: agreed. sage-0684 named role.test.mjs, but that file asserts no contract text: it loads role files and checks their schema. The contract lines are asserted in packages/business/tests/workflow-roles.test.mjs, row 149's own contract test, in the style of its REQ-DEC-2 test.

Item 2: revoking the assistant is a hard wait

#hardWait (broker.mjs:516-519) is true for every GATED action, and for a role.revoke whose target's definition is in END_PROTECTED (['assistant'], broker.mjs:139). pm.md and chief-of-staff.md keep the eight-action sentence, which workflow-roles.test.mjs pins to GATED_ONLY. A new sentence follows it: "A role.revoke raise whose target is the assistant is a hard wait too: it goes to the human, cited or not." It describes today's behavior only. Row 155 rewrites this line.

Tests

The new and changed assertions, all in packages/business/tests/workflow-roles.test.mjs:

  • The REQ-DEC-2 test also matches the assistant hard-wait sentence in pm.md and chief-of-staff.md.
  • A new test, "the merge landing takes two runs: merge-gate, cto and chief-of-staff each say their part":
    • merge-gate: the citation line, the stale-spec line, the no-spec fallback, the raising run ending its turn, the no-land_work line and the later run's land_work line, plus doesNotMatch on round 2's one-run wording;
    • cto: the message sentence;
    • chief-of-staff: the launch sentence, the three-condition end sentence, the end_run / "End no other run." sentence and the amended :69 sentence.

Mutants: r3/mutants.sh, one per assertion, m1 to m16:

id file edit
m1 merge-gate.md drop "The run that raises the decision ends its turn there."
m2 merge-gate.md "don't raise again" becomes "raise it again"
m3 merge-gate.md the later run's land_work becomes the raising run's
m4 merge-gate.md restore round 2's one-run land_work bullet
m5 cto.md message the pm, not the chief of staff
m6 cto.md name the task, not the decision id
m7 chief-of-staff.md "tell the pm" instead of launch
m8 chief-of-staff.md "wait for it to end" instead of end_run and relaunch
m9 pm.md drop the assistant hard-wait sentence
m10 chief-of-staff.md drop the assistant hard-wait sentence
m11 chief-of-staff.md drop the "refused instance-running" condition
m12 chief-of-staff.md drop "End no other run."
m13 chief-of-staff.md drop the :69 amendment
m14 merge-gate.md cite project only
m15 merge-gate.md drop the stale-spec retry sentence
m16 merge-gate.md no-spec fallback becomes "don't raise"

Each mutant edits a single role file once, and the test file must then fail. The dry run applies and restores each edit, checks it matched exactly once and the file hash came back, and runs no test. Log: r3/dry-run.log (2026-10-18T14:34Z), all 16 apply once, status 0. The runs are under "Round 3 runs", below.

Verification without the slot:

  • The patch applies to ac55e84fe in a fresh worktree, and all 21 hashes check. The worktree has been removed.
  • The test file passes node --check. That is a parse only.

New candidate (supersedes the pre-sage-0691 build, patch cde78de6…, digest 46cba008…):

  • build.patch sha256 ab512071af8b693430c1e4561a89462a38d87907fb9232fb59913b5986927a24.
  • Manifest digest (sha256 of candidate-manifest.sha256): 6f10f2eaabe9ca2f67c568aff8aef3fa4578b172726280a8f1e1b7b29cedb0d7.

Round 3 runs (Sage's slot, sage-0695)

All runs used the worktree at ac55e84fe with the candidate applied, and the manifest checked before and after each script. No package suites and no Docker.

  • r3/singles.sh (r3/singles.log, 2026-10-18T14:35:55Z to 14:37:57Z): 38 of 39 files pass, land.test.mjs 11/11 among them. launcher.test.mjs under env -u TMPDIR failed 10 of 19, each with "runner for N did not start".
  • Control: the same file on a clean ac55e84fe worktree, under env -u TMPDIR, failed the same 10 (r3/control/launcher-base.log). So the failure is on base, not in the candidate.
  • Cause, from a kept runner.log: bwrap: Can't chdir to /tmp/mosaic-launch-…/data/launches/acme/<run>: No such file or directory. Since 57948a81f (#1586), binds() mounts the run directory at its own path and then <runDir>/tmp over /tmp (packages/seat/src/sandbox.mjs, the "a private /tmp" bind). With the test's temp root under /tmp, the second mount hides the run directory. Any data root under /tmp would break a launch the same way; the live data root isn't under /tmp. It's a follow-up for Sage, not part of row 149. The kept log came from a one-test probe on the control worktree (a --require preload that copied runner.log before the test's rmSync).
  • With TMPDIR in my scratch (~/dewey-scratch/tmp/lt), launcher.test.mjs passes 19/19, 0 skipped, on base (r3/control/launcher-base-scratchtmp.log) and on the candidate (r3/single/packages_cli_tests_launcher.test.mjs.scratchtmp.log, 14:41:29Z to 14:41:45Z). The cited test "a role the broker says holds a merge decision gets land_work, its prompt line and the git socket; without it, none" passes. The candidate run left 12 empty mosaic-launch-* roots in that directory; I removed them.
  • r3/mutants.sh (r3/mutants.log, 14:41:51Z to 14:41:54Z): the control passes, and all 16 mutants are caught. Each fails on its own assertion: m9 and m10 on the hard-wait regex (labeled pm and chief-of-staff), and the rest on the regex they target in the two-run test (r3/mutants/m*.log).
  • Afterwards there were no processes with a cwd in my scratch or in a launcher test root, and no mosaic-launch-* roots of mine in /tmp. The control worktree is removed.

Round 4: Filbert's 28913, Sage's sage-0707 and sage-0709

Base unchanged: ac55e84fe. Round 3's build.patch (ab512071…) and candidate-manifest.sha256 (digest 6f10f2ea…) are kept in r3/, rebuilt from the round 3 file backups; both hashes match the round 3 record. Same 21 paths. Four hashes change: roles/merge-gate.md, roles/cto.md, roles/chief-of-staff.md and packages/business/tests/workflow-roles.test.mjs. Contracts and tests only.

The gap

A relaunched merge-gate run gets land_work, but nothing gives it a turn. The runner takes a turn only on a message it receives (packages/harness/src/runner.mjs:346-400). message.receive skips anything already delivered to the role (broker.mjs:1700-1721), and the approval DECISION went to the raising run, which was still polling (broker.mjs:1584-1612).

The fix

  • cto.md: the message to the chief of staff also names the task reference, which is the decision's authorization.target (#decisionView returns authorization: { action, target, approvalChoice }).
  • chief-of-staff.md: the launch sentence names the task reference too. A new bullet: once launch for that instance succeeds, the first time or after an end, the chief of staff sends the instance one message with send_message naming the task reference and the decision id, with the reason (a run turns only on a received message; the approval went to the raising run).
  • merge-gate.md: the later run's turn comes on that message, and it calls land_work with the task reference and the decision id it names. A new bullet: a decision-consumed refusal, in the land_work reply or in the later RESULT, means the landing already happened. Report the task landed and don't land or raise again.

Messaging permissions: roles/merge-gate.json lists chief-of-staff in both sendTo and receiveFrom. roles/chief-of-staff.json holds message.send and has no messaging lists, so it doesn't restrict its recipients. The same goes for roles/cto.json. Merge-gate holds no send grant, so it reports the outcome in its answer, as its contract already says for refusals.

Why decision-consumed means landed

  • #spent (broker.mjs:737-743) finds an action.allowed event for the decision whose operation isn't decision.raise.
  • Only recordLand (broker.mjs:888) writes that event, after a successful land. landRefused writes action.refused, which #spent doesn't count. So a decision is spent only by a landing that happened.
  • #landFacts refuses decision-consumed when the decision is spent (broker.mjs:853). It runs in checkLand (:840) and again in recordLand (:896).
  • sage-0709, from Filbert's read of the row 156 brief: a second land started while the first is in flight passes checkLand, because nothing is spent yet. It's refused decision-consumed at recordLand, under the repository lock (packages/cli/src/git-socket.mjs:346-378). That refusal comes in the later RESULT, not in the land_work reply. Hence "in the land_work reply or in the later RESULT".
  • If the first landing fails instead, nothing is spent, and the second goes on through its own gate. Either way a decision-consumed refusal follows a successful landing.

Row 156 (brief docs/plans/2026-10-18_landing-run-launch.md at 9cc0ba0e1, item 7) adds a host landing turn that can spend the decision first. With it, the chief of staff's message path still works: the second land is refused decision-consumed in one of the two places above, and merge-gate reports done instead of raising again. I read the brief only for that contract line.

Tests

In the two-run test of packages/business/tests/workflow-roles.test.mjs:

  • merge-gate: the later run's assertion now ends at the "Landing" line sentence; new assertions for the turn sentence, the decision-consumed sentence (both places) and "Report the task landed…";
  • cto: the message sentence now ends with the task reference and authorization.target;
  • chief-of-staff: the launch sentence names the task reference; new assertions for the message sentence and its reason sentence.

Mutants: r4/mutants.sh, m1 to m25. m1-m16 are round 3's, with m6, m7, m8 and m11 re-anchored to round 4's wraps (the edits mean the same). m6 now drops the decision id from the cto message. New:

id file edit
m17 cto.md drop the task reference from the message (round 3 text)
m18 chief-of-staff.md launch sentence without the task reference (round 3 text)
m19 chief-of-staff.md "wait for its turn" instead of the message
m20 chief-of-staff.md drop the reason sentence
m21 chief-of-staff.md drop "the first time or after an end"
m22 merge-gate.md turn sentence back to round 3's bare land_work call
m23 merge-gate.md decision-consumed only in the land_work reply
m24 merge-gate.md "Raise the decision again." instead of reporting it landed
m25 merge-gate.md decision-consumed "means the decision is stale: raise it again"

Dry run: r4/dry-run.log (2026-10-18T15:06:27Z), all 25 apply once and restore, status 0. r4/singles.sh is round 3's list, with the launcher test under TMPDIR=~/dewey-scratch/tmp/lt instead of env -u TMPDIR (#1657). Runs wait for Sage's slot.

Verification without the slot:

  • The patch applies to ac55e84fe in a fresh worktree, and all 21 hashes check. The worktree has been removed.
  • The test file passes node --check. That is a parse only.

Follow-up (Filbert's 28913 note, not a change): launch.instances is an explicit list (packages/business/src/business.mjs:128-131), and the launch tool's enum comes from it (packages/harness/src/tools.mjs:116-117). A merge-gate instance the chief of staff should relaunch has to be in that list. One sentence in the README or a later brief would say so.

New candidate (supersedes round 3's):

  • build.patch sha256 df0fec543dba4627be3f92416da8bb7e96c10711ee063c563fc196790614c58c.
  • Manifest digest (sha256 of candidate-manifest.sha256): 6b4af9c2685be260b7d874a831fd869690bbd067f7f5be45cc5b62a4c2cfcc96.

Round 4 runs (Sage's slot, sage-0712)

Both scripts ran on the worktree at ac55e84fe with the candidate applied, and checked the manifest before and after. No package suites and no Docker.

  • r4/singles.sh (r4/singles.log, 2026-10-18T15:07:57Z to 15:09:09Z): all 39 files pass. workflow-roles.test.mjs (business) 7/7, land.test.mjs 11/11, and launcher.test.mjs 19/19 under TMPDIR=~/dewey-scratch/tmp/lt.
  • r4/mutants.sh (r4/mutants.log, 15:09:13Z to 15:09:19Z): the control passes and all 25 mutants are caught. The killing assertion for each, from r4/mutants/m*.log:
id killing assertion (workflow-roles.test.mjs, business)
m1 merge-gate "The run that raises the decision ends its turn there."
m2 merge-gate "If the approval arrives in a run whose tools have no land_work…"
m3 merge-gate "A run launched after the approval has land_work…"
m4 merge-gate doesNotMatch "Once the decision is resolved with its approval option, call land_work"
m5, m6, m17 cto "When you resolve a git.merge.protected decision…"
m7, m18 chief "When the cto's message names an instance…"
m8, m12 chief "Then end it with end_run… End no other run."
m9 REQ-DEC-2 test, hard-wait regex, labeled pm
m10 REQ-DEC-2 test, hard-wait regex, labeled chief-of-staff
m11 chief "End a run only when all of these hold…"
m13 chief "Only the human stops a run… apart from the merge relaunch under Duties."
m14 merge-gate "Cite the project's current spec commit on that raise…"
m15 merge-gate "If the raise is refused stale-spec…"
m16 merge-gate "If the project has no spec the human saved…"
m19, m21 chief "Once launch for that instance succeeds, the first time or after an end…"
m20 chief "A run takes a turn only on a message it receives…"
m22 merge-gate "Its turn comes on the chief of staff's message…"
m23, m25 merge-gate "A decision-consumed refusal, in the land_work reply or in the later RESULT…"
m24 merge-gate "Report the task landed in your answer, and don't land or raise again."
  • Afterwards no process had a cwd in my scratch or in a launcher test root. The launcher run left 6 empty mosaic-launch-* roots in ~/dewey-scratch/tmp/lt; I removed them. None of mine are in /tmp. The worktree has only the 21 staged paths.