# 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 8a3f0acca2ec (rev 1632, commit c0af7f248). Assignment sage-0642, on blob 3fec535fd2ff (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: 4b1795a8c43e7376df1318ef2826118fbc252cc3 (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 ac55e84fe46f (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/: No such file or directory`. Since 57948a81f (#1586), `binds()` mounts the run directory at its own path and then `/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.