# Row 137 (#1631): agents land approved merges into the business working repository Brief: `docs/plans/2026-10-15_q167-rows-r3.md`, section "Agents land approved merges into the business working repository". I designed against blob `f5ef29a2` (commit `c07c87fc2`, rev 1532). The row is now pinned to blob `6805904555dc` (rev 1564). The section differs from `f5ef29a2` in one paragraph, the read.mjs rule from sage-0505 and sage-0511, which this candidate already follows (see "Arbiter path and `gated` refusal" below). Owner Darkwing; reviewers Dewey and Filbert. Base `7a2987cf5` for rounds 1 and 2; round 3 is rebased onto `63c82f1c4`, row 139's landing. The design comes first, then "As built", the docs, the test map, and the checks with the candidate. ## What lands where A landing moves `refs/heads/main` in the business working repository, `/working//.git`. Nothing writes the project root, and canonical `refactor` is never a target. `scripts/land-work.sh` keeps working as it does today. ## The seed (working.mjs) Today `seed()` fetches the project root's HEAD as `+HEAD:refs/heads/main`, forced, on every push and review. A landed main would be thrown away on the next push. The new seed: 1. Fetch the root's HEAD as `+HEAD:refs/heads/root`, forced. A failure refuses `root-unreadable`, as now. 2. If main doesn't exist, create it at root's tip (`update-ref main ""`, which fails if another process created it first). 3. If main exists, isn't root's tip, and is an ancestor of root, fast-forward it with `update-ref main ` (compare and swap). 4. Otherwise leave main alone. It is never forced. push, review and land all seed under the repository lock. Working repositories that exist today need no migration: the first seed after the change creates `refs/heads/root`, and main is already an ancestor of it. ### The push and fetch behaviour change The brief asks for this to be named. After the first landing, main and root diverge until the owner brings main into the root. - push's unrelated-history check still compares against main. A push whose commit shares no history with main refuses `unrelated-history`. If the owner rewrites the root's history, main keeps the old one, and pushes built on the rewrite refuse until main is reconciled by hand. - Coders clone from the project root, so a later task's branch doesn't have earlier landings. Its merge base with main is then the root commit it started from, and the reviewed diff `MB..C` is the task's own change, as before. Landing it on a main that has moved on can refuse `base-conflict` where the two touch the same lines. - The reviewer's fetch now also has `refs/heads/root` in the repository, so its `have` negotiation knows the root's commits. What the reviewer gets is unchanged. - When the owner merges main into the root, the next seed fast-forwards main and the two agree again. ## Who may land (broker) The run that holds an approved `git.merge.protected` decision asks for the landing. Row 136 lets a later run of the same instance use it. **Target rule.** The raise's `context.target` must equal the task ref named by the question's work ref (`vikunja:/` for `work/vikunja--`). Then `#consumeAuthority` writes the record with that task ref as its subject, so the landing sits on the task's trail and `task-subject-required` can't fire. `refactor` never matches a task ref, so canonical refactor can't be a target, and land-work's decisions (target `refactor`) and agent landings (target a task ref) can't be used for each other. **Shared checks.** `readLanding` moves from `scripts/land-work/read.mjs` to `packages/bus/src/landing.mjs` and takes `{ into }`: - `into: 'refactor'` (land-work): the raise targets `refactor`, and read.mjs still checks its `--base` is `refactor`. - `into: 'main'` (agent landing): the raise targets the task ref. - Anything else refuses `target-branch`. It runs over a small query adapter (`get`, `all`), so read.mjs passes its read-only `DatabaseSync` and the broker passes its Store. read.mjs keeps its CLI and its own schema check. The human check (`res.by === business.human`, `via === 'cli'`, one human answer with no role or run) is in the shared `readLanding`. Under b2a2d741c (sage-0517) land-work's check never changes: canonical `refactor` stays the lead's landing. Only the host land op's call accepts a decision its arbiter resolved (REQ-DEC-2). Row 139 landed first, so round 3 adds that with the `arbiter` option only the broker passes, and the test that an arbiter-resolved merge decision raised by merge-gate lands through the host op. See "Round 3". **New broker methods** (trusted, through the launch socket ops): - `checkLand(cap, { task_ref, decision })`: the session's agent and task, `#checkAuthority(s, 'git.merge.protected', { decision, target: task_ref })`, decision not already consumed, then `readLanding` with `into: 'main'`. The project is the task's latest push; no push refuses `ref-moved`. A decision that names another project refuses `work-ref`. - `recordLand(cap, land)`: reruns the checks inside the transaction (the verdict is still the latest and approving, nothing supersedes the decision), then `#consumeAuthority` writes the `action.allowed` with the landed commit, the old main, the reviewed commit, the verdict and the patch-id. The decision is spent. - `recordEvent` reserves `action.allowed` for `git.merge.protected`, as it already does for `git.push.working`, so no run can write a fake landing. - `landHeld({ business, role, thread })`: whether that role and thread hold a resolved, unconsumed merge decision with the approval option. The launcher asks it at launch. ### REQ-LAND-1 and the gated path (sage-0505) PRD 1.5 round 2 (row 135) says only a role whose role file grants `git.merge.protected` as a cross-role action may land, and an approval of a gated-class merge decision never lands. Row 139 takes `git.merge.protected` out of GATED_ONLY. This row takes the current gated path. A test role file can't hold the grant: `role.mjs` refuses any GATED_ONLY action under `withinRole` or `crossRole`, and the broker's `GATED` set classifies `git.merge.protected` as `gated` before it reads the role file. So `checkLand` goes through `#checkAuthority` with class `gated`, and the tests raise gated decisions. The refusal of class `gated` belongs in `checkLand` and `recordLand`, right after the authority check returns. The brief (c07c87fc2) says whichever of rows 137 and 139 lands second carries it with its test, and Sage checks that at each landing. Row 139 landed first as `63c82f1c4`, so round 3 adds the refusal, the arbiter path and both tests, and `landHeld` asks for a cross-role decision so the launcher binds the op only for a role that could land. The paragraphs above describe the rounds 1 and 2 base; "Round 3" has what changed. ## The land op (git socket) The socket takes a third op, `land`, with `{ cap, task_ref, decision }`. The launcher binds the git socket for a run whose role and thread hold a merge decision (`landHeld`), as well as for the push and verdict roles it binds today. The bundle shows a `land_work` tool only then. The broker rechecks everything at request time, so binding grants nothing by itself. A decision resolved while a run is going can only be landed from the next run, because the bundle is fixed at launch. ### Asynchronous outcome (a choice, flagged for Sage) A full gate takes longer than every timeout between the agent and the host: the tool call's 60 s default (`callTool`), the runner's 900 s turn and the git client's 300 s socket timeout. The op can't wait for it. So `land` answers as soon as the gate has started: `{ landing: "started", base, reviewed, patch_id }`. Every check that can refuse before the gate answers synchronously as a refusal, as push does. The host finishes in the background. The outcome is two records: - the `action.allowed` (landed) or `action.refused` (`gate-failed`, `base-moved`, `host-closing`, ...) on the task's trail, and - a RESULT message from the bus (`_bus`, run `bus`, as `#notice` sends) to the role in the decision's thread, saying what landed or why not. RESULT makes the runner take a turn and send no automatic reply. That message is a broker change beyond "the authority check for the new op only". The alternative is a run that polls the trail, which needs a read op agents don't have. I took the message. If Sage wants the narrower scope, the fallback is the records alone and the role learns the outcome from its next launch. The git socket runs one landing at a time. A second request while one is running refuses `land-busy`. Two full gates at once would also fight over Docker. If the host closes during a gate, the gate's process group is killed and the landing is refused `host-closing`; nothing moves. If the host dies outright, nothing is recorded and the decision is still unspent, so the run can ask again. ### The steps In order, matching land-work's refusal order and codes: 1. Broker `checkLand`: `decision-not-found`, `not-a-merge`, `not-resolved`, `not-human`, `not-merge-option`, `work-ref`, `target-branch`, `superseded`, `no-verdict`, `not-approved`, plus `decision-consumed`. 2. Under the repository lock: seed; the work ref's tip must be the verdict's commit (`ref-moved`); OLD is main; `MB = merge-base(OLD, C)`, none refuses `base-conflict`; the reviewed patch is `MB..C` in land-work's fixed diff format, empty refuses `base-conflict`; its patch-id is taken. 3. Root file: an added top-level file, an added top-level directory OLD doesn't have, or anything under `src/` or `v1/` refuses `root-file`. 4. The squash, in a temporary index on OLD's tree: the patch is applied with `apply --cached`; failure refuses `base-conflict`; the patch-id of `OLD..tree` must equal the reviewed one (`patch-drift`). The commit is made with `commit-tree`, no ref, author and committer "Mosaic bus host" ``, and land-work's message with `Applied-under: git socket land (bus host)`. The commit is fixed before the gate, so nothing the gate does can change what lands. 5. Gate check: the project root is a mosaic-stack checkout (below) with a `node_modules`; otherwise `gate-missing`. This is the last synchronous refusal; the op answers "started" here. 6. The gate, outside the lock, in a detached worktree of the new commit with the root's `node_modules` linked. A failing step refuses `gate-failed` with the step and its log. 7. Under the lock: `update-ref main NEW OLD`; failure refuses `base-moved`. Then `recordLand`. If recording throws, main goes back with `update-ref main OLD NEW`; if that fails too, the refusal is `rollback-failed` and names both commits. The worktree, its `node_modules` link and the Docker network `_default` are removed on every path. Logs stay under `/bus-host/land//`. ### The exact mosaic-stack check `git -C merge-base --is-ancestor c2365ae5190aee751bfbd0a3d47105a706adb5e7 HEAD`. c2365ae5 is the new foundation's root commit ("chore: baseline container POC and atomic foundation plan", 2026-09-02, tag `poc-container-hello-v0`). The repository has three root commits (c2365ae5, e7f338e3, ecde7443); a project without c2365ae5 in its history isn't this one. The root must also have a `node_modules` directory, which the gate links. ### The gate One definition, in `packages/seat/src/landing.mjs`, used by the host and by land-work.sh: every `packages/

/tests/*.test.mjs` suite except queue's (`scripts/test-queue.sh` runs those), then every `scripts/test-*.sh`, each step logged to `.log` and summarised in `gate.log` with land-work's line format, ending `GATE_PASSED`. land-work.sh calls it through node and keeps its own worktree, refusal messages and Docker cleanup. The root-file rule moves to the same module. The gate runs reviewed, agent-authored code on the host, outside a sandbox, with the host's environment. land-work.sh does the same today under the conductor. Here a human-resolved decision and an approving review are what authorize it. ## Harness - `land_work` tool, verb `working.land`, in `tools.mjs`, shown when the launcher says the session may land. - `landWork()` client in `git-tools.mjs`. - runner local handler for `working.land` over the session's git socket. ## Out of scope Routing and GATED_ONLY (row 139), the merge-gate role (row 149), any push to a remote, force-push or history rewrite. ## As built: where the candidate differs from the design above - **The squash is made in a worktree, not a temporary index.** prepareLand adds a detached worktree of the bare repository at main under `bus-host/land//` and applies the reviewed patch there with `git apply --index`. The applied diff (`--cached` against OLD) must have the reviewed patch-id. `write-tree`, `commit-tree` with OLD as the one parent, then `reset --soft` to the squash, so the gate runs in the tree that lands. The worktree goes on every refusal and at the end (`dropLand`, which removes the `node_modules` symlink first). - **Patches are written by git.** Both diffs go through `git diff --output=` with fixed prefixes, no textconv and no external diff, and patch-id reads the file. A file that isn't UTF-8 keeps its patch-id. - **Order.** The root-file check runs before the worktree is added, so a root-file refusal never touches the object store beyond reading. gate-missing comes after prepareLand, so the squash exists when the host answers `started`. - **The `started` answer carries `commit`**, the squash, besides `base`, `reviewed` and `patch_id`. An agent can say what will land. - **Tickets.** checkLand returns a ticket the host passes to recordLand and landRefused. A ticket outlives the run that asked, since the gate may end after the run did. An unknown ticket refuses `land-unknown`. recordLand checks authority with `#authorityFor` (the holder check is left out for that reason) and reruns the landing checks in the transaction; it writes the `action.allowed` itself rather than through `#consumeAuthority`, which would ask for the claim again. - **`no-subject`.** land-work refuses a landing with no subject and no `--subject`. An agent has no `--subject`, so checkLand refuses a merge option with no first line `no-subject`. - **`decision-consumed`** is checked in checkLand and again in recordLand. - **The gate's environment.** The host runs the gate with `gateEnv`: PATH, HOME, USER, LOGNAME, SHELL, LANG, LC_ALL, TZ, TERM, TMPDIR, XDG_RUNTIME_DIR and DOCKER_HOST only. This replaces "with the host's environment" in the gate section. land-work.sh keeps the conductor's. - **isMosaicStack is async** and runs git through `runGit` with only the environment's PATH, hooks at /dev/null and fsmonitor off. - **The gate is injectable.** `createGitSocket({gate})` takes `LAND_GATE`'s shape, `{classify, run, dropNetwork}`, so the socket tests run a fake gate. A test checks that `LAND_GATE` holds the real `isMosaicStack` and `runGate`, and the refusals test runs the real classifier on a fixture project for gate-missing. - **Harness.** `land_work` has no clone requirement and sits between `push_working` and `request_review`. The prompt line (round 3 wording): "Landing: once your merge decision is approved, land_work it with the task. The reply only says the landing started; the gate's outcome comes later as a RESULT from the bus. Don't land again while one is running." The launcher asks `landHeld` with the run's role instance and thread and binds the git socket when it answers true. - **The runner's `working.land` route has no test of its own.** Neither do `working.push` and `working.fetch`. `landWork` is tested in git-tools.test.mjs against a fake host socket, and the launcher test checks the bundle and `session.json`. - **Arbiter path and `gated` refusal** (sage-0511, sage-0517, sage-0565): rounds 1 and 2 left both out, since row 139 hadn't landed, and both callers of `readLanding` required a human/cli resolution. Row 139 landed first, so round 3 adds them (see "Round 3"). land-work's check in `scripts/land-work/read.mjs` is unchanged in effect in every round. - **The raise is found by its body, not its subject.** read.mjs looked for the decision.raise `action.allowed` with `subject` equal to the decision id. The broker files a raise under its task ref when it has one (`a.task_ref ?? target-if-task-ref ?? id`), so every decision raised for a task, which is every one the host op lands, came back decision-not-found. The first gate run caught it (six land.test failures). `readLanding` now matches the business, the kind, the operation and `body.decision`, as the broker's own raise lookups do. For land-work this changes one thing: a refactor decision raised with a `task_ref` used to refuse decision-not-found and now reaches the checks after it, where a `task_ref` that disagrees with the question refuses work-ref. That branch existed in read.mjs but no real raise could reach it; the fixture in test-land-work.sh files every raise under the decision id, so its cases are unchanged. The human-only check is untouched. `recordEvent` refuses an `action.allowed` whose action is `git.merge.protected`, so an adapter can't add a second raise that passes. - **landing.mjs is a runner file.** The broker imports it, so it joins `RUNNER_FILES` in packages/seat/src/sandbox.mjs, and the bus import closure test (project-runtime.test.mjs, row 138) lists the new import and checks that landing.mjs imports nothing. - **thread-launch.test.mjs's host answers landHeld.** Its in-process host lists the launch ops it serves, and every launch now asks `landHeld`, so five of its tests failed with "invalid-host-request" until the op was added beside `threadIdle`. ## Docs Only the landing paragraphs the brief names: - `packages/seat/README.md`: push step 2 and the review paragraph now describe the root seed; "No other ref moves" names the seed's refs; a new "Landing on main (#1631)" subsection covers prepareLand, finishLand, dropLand and `src/landing.mjs`; the sandbox bind list adds `landHeld`. - `packages/cli/README.md`, "Git socket": the bind rule, a `land` row in the op table, `checkLand` in the check order, a landing paragraph (steps, land-busy, landRefused, the RESULT outcome, logs, `LAND_GATE`), and close() stopping a gate. - `packages/bus/README.md`: the four row 137 ops and the ticket. - `packages/harness/README.md`: the `land_work` paragraph, runner step 3, the git socket bind rule. - `docs/TOOLS.md`: one paragraph after land-work's on the shared modules. Each passes `tools/unslop-hook/unslop-check.js`. ## Tests, against the brief's list | Brief | Test | |---|---| | A reseed moves root and leaves a landed main | working.test "a reseed moves root but leaves a landed main, and main forwards once root contains it" | | main fast-forwards to root when it's an ancestor, stays put when it isn't | the same test, and "after a landing, a push built on the root still lands and main stays where the landing put it" | | push's unrelated-history check against main | working.test "a branch with no history in common with main refuses unrelated-history", where main and root are the same commit, and (round 2) "once the root shares no history with a landed main, a push is checked against main, not the root", where they share nothing: a push built on main lands and one built on the root refuses | | A mosaic-stack checkout runs the gate | landing.test "isMosaicStack: ... node_modules; nothing else" (true on a child of the root commit) and the runGate tests; git-socket.test "land: the squash runs the gate in its own worktree, then main moves and the broker records it" with a classifier that says yes | | Another project refuses gate-missing | git-socket.test "land: refusals before the gate ..." with the real `isMosaicStack` on the fixture project | | Each land-work refusal code | On the shared module: `scripts/test-land-work.sh` runs every `readLanding` code through land-work (`into: 'refactor'`), unchanged from row 116. On the host side: land.test "checkLand refuses on the decision and the review" (the broker's decision-not-found, decision-not-approved, decision-mismatch, no-verdict, ref-moved), "checkLand refuses with land-work's codes past the authority check..." (work-ref twice, target-branch, superseded, not-approved, and a `refactor` decision refused decision-mismatch), "no-subject", and decision-consumed in the first test; working.test ref-moved, base-conflict, root-file, base-moved, rollback-failed, and (round 2) patch-drift, with a git first on the PATH that adds a line to what `apply --index` staged, as test-land-work.sh does; git-socket.test gate-missing, gate-failed, land-busy, host-closing, unknown-project and a broker refusal at record | On the host side, `#checkAuthority` runs first, so `not-a-merge`, `not-resolved` and `not-merge-option` reach the role as the broker's `decision-mismatch` or `decision-not-approved`. In rounds 1 and 2, `not-human` couldn't happen there, since only the human resolved a gated decision. In round 3 it still can't reach checkLand: a gated decision refuses `not-authorized` first, and the broker resolves a cross-role one only through its route, the arbiter or the human. The round 3 test checks `not-human` on `readLanding` directly instead (see "Round 3"). Also new: landing.test (rootHits, gateEnv, gateSteps, runGate pass, fail and abort, the CLI), land.test (ticket, landRefused, landHeld, the reserved event), the launcher test for `landHeld` binding, and the harness tests for the tool, the prompt line and `landWork`. `scripts/test-land-work.sh` is unchanged and runs land-work through the shared gate and root-file CLI. ## Candidate `r1/build.patch` against BASE `7a2987cf5` (sha256 `49d5048f6bb53e1b546a6b2dbdec14ef4a9bfe5087f20e218018a62481266611`). `r1/candidate-manifest.sha256` lists the 31 files: 27 changed and 4 new. The new ones are packages/bus/src/landing.mjs, packages/bus/tests/land.test.mjs, packages/seat/src/landing.mjs and packages/seat/tests/landing.test.mjs. I applied the patch to a fresh worktree at BASE, `sha256sum -c` passed on every line, and I removed that worktree. Canonical HEAD was `b71268b8e` at 18:56Z. `git diff 7a2987cf5 b71268b8e` on the 27 changed paths is empty, and none of the 4 new paths exists at HEAD. ## Checks The slot was mine from 18:37Z (sage-0519) until 18:55:30Z. `r1/gate.sh` runs `node --test tests/*.test.mjs` in every package with a tests/ directory except queue (test-queue.sh runs that suite), then every `scripts/test-*.sh`. The suites run one after another, each teed to its own log, and the script stops at the first failure. TMPDIR was `~/darkwing-scratch/r137/tmp`. cli's launcher.test ran on its own under the session's inherited TMPDIR, because the bwrap runner fails "did not start" under an override. DOCKER_HOST was unset, Docker 29.7.2 was up, and COMPOSE_PROJECT_NAME was `darkwing-r137`. An EXIT trap removes `darkwing-r137_default` only when it has 0 containers. Round 5, the candidate, ran from 18:49:43 to 18:55:25 UTC and ended GATE_PASSED (`r1/gate-run.log`, step logs in `r1/gate/`): | Step | Pass | Fail | |---|---|---| | bus | 302 | 0 | | business | 109 | 0 | | cli (all but launcher) | 202 | 0 | | cli launcher.test, inherited TMPDIR | 19 | 0 | | control-board | 124 | 0 | | conversation | 182 | 0 | | discord | 183 | 0 | | harness | 108 | 0 | | ledger | 86 | 0 | | mosaic | 69 | 0 | | plugins | 28 | 0 | | project | 30 | 0 | | runs | 43 | 0 | | seat | 148 | 0 | | tasks | 97 | 0 | | webui | 113 | 0 | | test-auth | 15 | 0 | | test-conductor | 17 | 0 | | test-config | 24 | 0 | | test-discord | 78 | 0 | | test-extension-package | 18 | 0 | | test-foundation | 44 | 0 | | test-land-work | 84 | 0 | | test-queue | 156 node, 28 suite | 0 | | test-release | 14 | 0 | | test-task | 98 | 0 | The trap printed "network darkwing-r137_default removed". Afterwards no Docker network created today remained, no container carried the darkwing-r137 project label, and no node or docker process of mine had a cwd under my r137 scratch. Rounds 1 to 4 each stopped on a fault in the candidate or its tests. None was load or timing (sage-0527). Each fix was rerun singly before the next round. The round summaries, the failing step logs and the reruns I logged are in `r1/earlier/`. The git-socket rerun in round 2 has no saved log; round 5 covers it. 1. Round 1, bus, 295 pass, 7 fail. Six land.test tests refused decision-not-found, the raise lookup described in "As built". The project-runtime import closure test didn't list the broker's new import. After both fixes, land.test and project-runtime.test passed 11 of 11. 2. Round 2, cli, 196 pass, 6 fail. git-socket.test expected a newline after the Applied-under line, which `w.git` trims, so the test now anchors that line at the end of the message. Five thread-launch tests failed because the test host lacked `landHeld`. A rerun under the inherited TMPDIR failed the same five (`r2-thread-launch-inherited-tmpdir.log`), which ruled out TMPDIR. With the op added, the file passed 8 of 8. This round also changed gate.sh: its launcher line used `env -u TMPDIR`, and it now passes the inherited TMPDIR. 3. Round 3, cli launcher.test, 18 pass, 1 fail. The `landHeld` test's wrapper used `Object.assign` on a host whose `op` is a read-only inherited property and threw a TypeError. It now uses `Object.create`. 19 of 19 passed, 0 skipped. 4. Round 4, seat, 146 pass, 2 fail. Two runGate tests named their "must not run" script test-three.sh, which sorts before test-two.sh, so it ran first. It is now test-z.sh, with a comment saying why. landing.test passed 8 of 8. Researcher's row 148 gate ran from /mnt/storage/scratch/researcher/q148 during my slot (sage-0527). I didn't look inside that directory. Each failure above traces to a line in the candidate or its tests, so I changed code rather than rerunning for a flake. Not run: mutants. Builder rounds may be targeted (decision 136), and the reviewers have the gate above. ## Round 2 (Filbert's comment 28782, sage-0541) Filbert asked for two tests the brief requires. Both are in packages/seat/tests/working.test.mjs, the only file that changed. - B1, "a landing whose applied diff isn't the reviewed one refuses patch-drift and leaves no worktree". A `git` first on the PATH runs the real git, then adds a line to the README that `apply --index` staged, as scripts/test-land-work.sh does for land-work. prepareLand refuses `patch-drift`, main stays put and no worktree is left. The same landing with the real git then prepares. - B2, "once the root shares no history with a landed main, a push is checked against main, not the root". After a landing, the owner starts the root over on an orphan branch. The seed moves root to it and leaves main. A push built on main lands, and a push built on the new root refuses `unrelated-history`. Also fixed: the stale "Row 139 isn't built yet" in "Arbiter path and `gated` refusal left out". The test table above names both tests. ### Candidate, round 2 `r2/build.patch` against BASE `7a2987cf5` (sha256 `383fc8329ef340f804de68f981146593bf06e597481991fa9889649e29cb8691`). `r2/candidate-manifest.sha256` (sha256 `3311b46a61e525a1897190bbce05de15a850843d00fce4497ef3d4828434c09f`) lists the same 31 paths as r1. Only the working.test.mjs line differs. I applied the patch to a fresh worktree at BASE: `sha256sum -c` passed on every line and `git diff --cached --check` was clean (31 files, +2043 -255). Canonical HEAD was `96fd8593e`, and `git diff 7a2987cf5 96fd8593e` on the 31 paths is empty. ### Checks, round 2 The slot was mine from 19:18Z (sage-0547). This round was targeted (decision 136). `r2/round.sh` ran each file singly with TMPDIR `~/darkwing-scratch/r137/tmp` and no Docker, then probed each check the way Filbert did. A probe edits working.mjs in place, runs working.test, and restores the file. The script then checks working.mjs's sha256 against the one it started with, and it must fail on exactly the new test. Output is in `r2/gate-run.log`, step logs in `r2/gate/`. | Step | Pass | Fail | |---|---|---| | seat working.test, candidate | 26 | 0 | | cli git-socket.test, candidate | 23 | 0 | | probe B1: line 348 `if (false) refuse("patch-drift", ...)` | 25 | 1 | | probe B2: line 224 merge-base against `refs/heads/root` | 25 | 1 | | seat, whole suite, restored candidate | 150 | 0 | - Probe B1 failed only "a landing whose applied diff isn't the reviewed one refuses patch-drift and leaves no worktree", with "Missing expected rejection". - Probe B2 failed only "once the root shares no history with a landed main, a push is checked against main, not the root". The push built on main refused `unrelated-history`. After each probe working.mjs was back at `0d332d73596e94d2114751c1924c4f8463c387aba9ffdb0924d61f6228e1c463`, its manifest hash. Afterwards `sha256sum -c` on the r2 manifest passed in my worktree, no `.orig` file was left, and no node process of mine was running. Not run in this round: the other package suites, the scripts/test-*.sh and mutants. Round 2 changes one test file. The r1 gate above covers the other 30 paths, which are unchanged. ## Round 3 (sage-0565): rebased onto row 139, with both carry items Row 139 landed first as `63c82f1c4`, so this row carries the two items the brief gives the second lander (brief blob `6805904555dc`). The r2 patch applied to `63c82f1c4` without a conflict. B1 and B2 are unchanged. ### What row 139 changed under this row In broker.mjs, `GATED` is now the hard-wait set, and vocabulary.mjs's `GATED_ONLY` matches it. `git.merge.protected` isn't in it. A role file may grant a merge cross-role, and a business that grants a hard wait at either class refuses `invalid-authority` (REQ-WAIT-2). So a hard wait is never a cross-role decision. decision.raise routes: - cross-role to `arbiters[domain]`, or to the human when the arbiter is the raiser's own role; - gated and cited to the arbiter; - gated and uncited, or any hard wait, to the human. An agent's resolution writes `via: 'broker'` with its run as `by`. A human-routed decision resolved by an agent refuses `human-required`. ### (a) REQ-LAND-1: a gated merge decision never lands `#landFacts` starts with `if (cls === 'gated') fail('not-authorized')`. checkLand passes the class `#checkAuthority` returned, and recordLand passes the one from `#authorityFor`, so both run the same line. The class can't change between the two: `#authorityFor` requires the decision's class to be the one the role holds. The refusal comes before `decision-consumed` and `readLanding`, so who resolved a gated decision doesn't matter. ### (b) REQ-DEC-2: the host op accepts an arbiter-resolved merge `readLanding` takes `{ into, arbiter = false }`. It accepts a resolution when one of these holds: - the human resolved it through the CLI with one human answer, the check rounds 1 and 2 already had; or - `arbiter` is true, the decision is `cross-role`, its `route_to` isn't `human`, the resolution came `via: 'broker'`, and there is no human answer. Only the broker's `#landFacts` passes `arbiter: true`. read.mjs is unchanged, so land-work passes nothing and stays human-only (item 4). The route check holds because the broker lets only a run of the `route_to` role resolve an agent-routed decision. ### Item 5: no hard wait through the arbiter path A hard wait can't reach the arbiter path, for three reasons: 1. `readLanding` refuses `not-a-merge` for any action but `git.merge.protected`, which isn't a hard wait. 2. The arbiter path takes only `cross-role`, and REQ-WAIT-2 keeps a hard wait out of that class. 3. A gated decision refuses `not-authorized` before `readLanding` runs. ### landHeld and the prompt `landHeld` now skips any decision that isn't `cross-role`, so the launcher binds the land op only for a role that could land. The bundle's prompt line no longer says the human approved the merge. It reads "Landing: once your merge decision is approved, land_work it with the task." The harness bundle test and the cli launcher test assert it. ### Tests (packages/bus/tests/land.test.mjs) The world gains a `merge-gate` role granted `git.merge.protected` cross-role, with pm as the technical and delivery arbiter. The landing tests now use it instead of a coder raising a gated decision. - **(b)**, "REQ-DEC-2: a merge-gate raise its arbiter resolved lands through the host op: ...". The raise routes `cross-role` to pm, and pm resolves it (`by: 'pm-run'`, `via: 'broker'`). `readLanding` without `arbiter` refuses `not-human` on it. checkLand answers the facts with `by: 'pm-run'`, and recordLand writes the merge event with class `cross-role` and spends the decision once. - **(a)**, "REQ-LAND-1: a gated merge decision refuses not-authorized, cited and approved by the arbiter or uncited and approved by the human". On row 139's spec fixture, the coder pushes, gets an approving review and raises a merge citing `spec/north-star.md` at the fixture commit. The raise routes `gated` to pm, and pm approves. checkLand refuses `not-authorized`, `readLanding` with `arbiter: true` refuses `not-human`, and landHeld is false. Then, uncited: the raise routes `gated` to the human, the human approves, and checkLand still refuses `not-authorized`. Nothing merges. - New: "a cross-role merge that routes to the human, because merge-gate is its own arbiter, lands on the human's CLI answer". pm's resolve refuses `human-required`; the human's lands with `by` set to the human. - landHeld's test adds a coder's gated decision approved by the human, for which landHeld is false. `not-human` still can't reach checkLand (see the test table above). A comment above the land-work codes test says so. ### Docs, round 3 - `packages/bus/README.md`: land-work accepts only a human CLI resolution; the broker's `arbiter: true` also accepts a cross-role decision its arbiter resolved; `not-authorized` for gated (REQ-LAND-1); checkLand and landHeld name `cross-role`. - `packages/cli/README.md`: a role granted the merge cross-role lands once its arbiter or the human approved the decision. - `packages/cli/tests/git-socket.test.mjs`: one comment. Its broker is a stand-in, and no logic changed. ### Candidate, round 3 `r3/build.patch` against BASE `63c82f1c4` (sha256 `52c1f1d6564d9e5e12b5708cbf2f12675dee0582b5fc42ece2fcc203fb5b2a7a`). `r3/candidate-manifest.sha256` (sha256 `0fae2b6fa41f1cbcf2117b637a2f7a89cb2af209bf32647f0afc1612a9a34e16`) lists the same 31 paths as r2. Round 3 changed 9 of them: the bus and cli READMEs, broker.mjs, landing.mjs, land.test.mjs, git-socket.test.mjs, launcher.test.mjs, bundle.mjs and bundle.test.mjs. I applied the patch to a fresh worktree at BASE. `sha256sum -c` passed on every line, and `git diff --cached --check` was clean (31 files, +2115 -255). I removed that worktree. Canonical HEAD was `c492f4aaa`, and `git diff 63c82f1c4 c492f4aaa` on the 31 paths is empty. ### Checks, round 3 The slot was mine from 19:43:51Z to 19:46:02Z (sage-0569). Before the start, /proc showed no `node --test` process outside my own cwd. This round was targeted (decision 136). `r3/round.sh` ran every test file the candidate touches, plus row 139's hard-waits.test, each on its own with TMPDIR `~/darkwing-scratch/r137/tmp` and no Docker. cli launcher.test and thread-launch.test ran under the inherited TMPDIR. Then came scripts/test-land-work.sh and one kill per check. A kill edits one source file in place, runs one test file, which must fail on the named test, and restores the file. The script checks each restored file against its starting sha256. Last came the whole bus and seat suites. Output is in `r3/gate-run.log`, step logs in `r3/gate/`. It ran from 19:43:51 to 19:45:35 UTC and ended ROUND_PASSED. | Step | Pass | Fail | |---|---|---| | bus land.test | 10 | 0 | | bus hard-waits.test (row 139) | 12 | 0 | | bus project-runtime.test | 3 | 0 | | seat landing.test | 8 | 0 | | seat working.test | 26 | 0 | | harness bundle.test | 22 | 0 | | harness git-tools.test | 5 | 0 | | harness tools.test | 19 | 0 | | cli git-socket.test | 23 | 0 | | cli launcher.test, inherited TMPDIR | 19 | 0 | | cli thread-launch.test, inherited TMPDIR | 8 | 0 | | scripts/test-land-work.sh | 86 | 0 | | bus, whole suite, restored candidate | 316 | 0 | | seat, whole suite, restored candidate | 150 | 0 | 0 skipped in each. | Kill | Edit | Must fail | Failing | |---|---|---|---| | B1 | working.mjs:348 `if (false) refuse("patch-drift", ...)` | the patch-drift test, exactly 1 | 1 | | B2 | working.mjs:224 merge-base against `refs/heads/root` | the root-history test, exactly 1 | 1 | | (a) gated refusal | broker.mjs:815 `if (false) fail('not-authorized')` | REQ-LAND-1, exactly 1 | 1 | | (b) arbiter path | landing.mjs:112 `arbitrated = false && ...` | REQ-DEC-2, one or more | 6 | | item 5, cross-role only | landing.mjs:112 drops `d.class === 'cross-role'` | REQ-LAND-1, exactly 1 | 1 | | land-work stays human-only | landing.mjs:91 `arbiter = true` | REQ-DEC-2, exactly 1 | 1 | | landHeld cross-role | broker.mjs:924 drops `d.class !== 'cross-role'` | the landHeld test, one or more | 2 | - Kill (b) fails all 6 land.test tests that land or check an arbiter-resolved merge. - The item 5 kill fails only REQ-LAND-1. With the gated refusal in place, the `readLanding` call with `arbiter: true` on the arbiter-resolved gated decision is the only check that sees it. - The arbiter-default kill fails REQ-DEC-2 on its `readLanding` call without `arbiter`, which must refuse `not-human`. - The landHeld kill fails the landHeld test and REQ-LAND-1, since both assert landHeld is false for a gated decision. Afterwards no node or chromium process of mine had a cwd under `~/darkwing-scratch`. No `.orig` file was left, and every killed file was back at its starting sha256. The r137 TMPDIR held 847 entries from round 1's full gate (newest 18:53Z) and none from this run. I removed them. Not run in this round: the other package suites, the other scripts/test-*.sh, Docker and mutants. Some untouched test files also read a source round 3 changed, and they didn't run on this base: - broker.mjs: packages/cli/tests/helpers.mjs (and so the cli tests that build a broker through it), cli tester.test and harness runner.test; - launcher.mjs, which asks landHeld: cli launch-limits.test; - bundle.mjs: harness claude-gate, claude-session, mcp-server and pi-session tests. All of them passed in the r1 full gate on `7a2987cf5`, before the gated refusal, the arbiter path and landHeld's filter. The reviewers' gates and Sage's gate at the landing target run them on `63c82f1c4`. ## Round 4 (sage-0585): B1, the class refusal and the landing.mjs comment Dewey asked for changes on round 3 (#1631 comment 28801, rev 1595), and Filbert approved it (comment 28804, manifest `0fae2b6f`). sage-0585 fixed round 4 as three items. Everything else in round 3 is unchanged. Base is still `63c82f1c4`. ### Item 1, Dewey B1: the squash names the real channel Round 3's `landMessage` wrote "Resolved by via the CLI" for every landing. For an arbiter's landing that put "by pm-run via the CLI" into main's history, which is false. Now: - `readLanding` returns `via: res.via`, and `#landFacts` passes it into checkLand's facts. The broker's ticket answer gains `via`. - git-socket.mjs maps it: `cli` writes "via the CLI", and `broker` writes "as its arbiter, via the broker". readLanding accepts no other value. If one ever got through, the line would print `via `. It never falls back to the CLI. - land-work's read.mjs prints every key, so it now prints a `via` line too. land-work.sh reads keys by name and still writes "via the CLI" itself, which holds, since it accepts only a human CLI resolution. ### Item 2, Filbert note 1 and Dewey N1: every class but cross-role refuses `#landFacts` now starts with `if (cls !== 'cross-role') fail('not-authorized')`, ahead of the spent check and the human check in readLanding. Round 3 refused only `gated`. A role granted `git.merge.protected` within-role resolves its own raise (`via: 'broker'`), so its landing got past that line and refused `not-human` in readLanding instead. It now refuses `not-authorized`, like a gated one. role.mjs is unchanged: whether it should refuse a within-role merge grant at load is a follow-up for row 149 (sage-0585). The comment above land.test's land-work codes test now matches the code. checkLand never gives `not-human`: #landFacts refuses every class but cross-role first, and the broker resolves a cross-role decision only through the human's CLI answer or its arbiter's decision.resolve. ### Item 3, Dewey N2: the comment at landing.mjs:112 The arbiter path's line now has a comment above it. A cross-role decision routed to an agent closes only through decision.resolve, which takes it from the routed role alone (`s.role === route_to`, else `not-resolver`), so `res.by` there is the arbiter's run. ### Tests, round 4 - land.test, REQ-DEC-2: checkLand's facts gain `via: 'broker'`. - land.test, the human-routed cross-role landing: checkLand answers `by: 'jason'`, `via: 'cli'`. - land.test, new: "REQ-LAND-1: a within-role merge grant refuses not-authorized at the land". The world's merge-gate holds the grant within-role, so its raise with `choice: 'yes'` closes in the call (`within-role`, routed to itself, `via: 'broker'`). checkLand refuses `not-authorized`, landHeld is false, and nothing merges or refuses on the trail. `setup` takes a `roles` override for it. - git-socket.test, the first land test: the stand-in's facts gain `via: "cli"`, and the squash's message must hold "Resolved yes by jason via the CLI at 2026-10-16T00:00:00.000Z." - git-socket.test, new: "land: an arbiter-resolved landing's squash says the arbiter resolved it through the broker". The stand-in answers `by: "pm-run", via: "broker"`. The message must hold "Resolved yes by pm-run as its arbiter, via the broker at ..." and no "via the CLI". ### Docs, round 4 - `packages/bus/README.md`: not-authorized for every class but `cross-role`, gated and within-role named; checkLand's answer carries the resolution's `via`. - `packages/cli/README.md`: the squash's Resolved line names the channel from the ticket's `via`. ### Interdiff, round 3 to round 4 `r4/interdiff.patch` (sha256 `dcc4672cf6f6ad01cf068725969bfb724d52460528df1adeb405c75a13856af3`), 7 files, +65 -20: | File | Change | |---|---| | packages/bus/src/broker.mjs | the class refusal and its comment; `via` in the facts | | packages/bus/src/landing.mjs | `via` in readLanding's return; the N2 comment | | packages/cli/src/git-socket.mjs | `RESOLVED_VIA` and the Resolved line | | packages/bus/tests/land.test.mjs | `via` in two tests, the within-role test, the comment, `setup`'s `roles` | | packages/cli/tests/git-socket.test.mjs | `via: "cli"` in the stand-in, the CLI assertion, the arbiter test | | packages/bus/README.md | the refusal's classes, `via` in the answer | | packages/cli/README.md | the Resolved line | ### Candidate, round 4 `r4/build.patch` against BASE `63c82f1c4` (sha256 `0cabe175b095dd632e6ee6e166efad19089a1c87ffba6198a32c6849a1a80965`). `r4/candidate-manifest.sha256` (sha256 `548ee477baca2a172d2585dd3074979f299b83db4484d1ec89674bb3142a9928`) lists the same 31 paths as r3, and differs from it on the 7 above. I applied the patch to a fresh worktree at BASE. `sha256sum -c` passed on every line, and `git diff --cached --check` was clean (31 files, +2160 -255). I removed that worktree. Canonical HEAD was `4ebef2284`, and `git diff 63c82f1c4 4ebef2284` on the 31 paths is empty. ### Checks, round 4 `r4/round.sh` is round 3's script with three kills added and kill (a) moved to the new line: | Kill | Edit | Must fail | |---|---|---| | (a) class refusal | broker.mjs:815 `if (false) fail('not-authorized')` | the within-role test, one or more | | item 2, gated only | broker.mjs:815 back to round 3's `cls === 'gated'` | the within-role test, exactly 1 | | B1, CLI text | git-socket.mjs:259 hard-codes "via the CLI" | the arbiter squash test, exactly 1 | The other six kills are round 3's. I ran it on Sage's go (sage-0592) from 2026-10-16T20:38:29Z to 20:40:26Z, in `~/darkwing-scratch/r137/wt4` at BASE with the patch applied. Log: `r4/gate-run.log`, per-step logs under `r4/gate/`. ROUND_PASSED. | Step | Result | |---|---| | bus land, hard-waits, project-runtime | 11, 12, 3 pass, 0 fail | | seat landing, working | 8, 26 pass, 0 fail | | harness bundle, git-tools, tools | 22, 5, 19 pass, 0 fail | | cli git-socket | 24 pass, 0 fail | | cli launcher, thread-launch (inherited TMPDIR) | 19, 8 pass, 0 fail | | scripts/test-land-work.sh | 86 passed, 0 failed | | whole bus suite | 317 pass, 0 fail | | whole seat suite | 150 pass, 0 fail | All nine kills killed: | Kill | Failing | |---|---| | b1-patch-drift | 1 | | b2-against-root | 1 | | (a) class refusal | 2 | | item 2, gated only | 1 | | B1, CLI text | 1 | | b-arbiter-path | 6 | | 5-cross-role-only | 1 | | arbiter-default | 1 | | held-cross-role | 3 | Each named test was among the failures. After the run the sweep found none of my processes left, no `.orig` file remained, and `sha256sum -c` on the manifest still passed. This is a targeted builder round (decisions 136/137). The whole cli and harness suites and the other `scripts/test-*.sh` are the landing gate's.