Files
jason.woltje 158205c5cc lead: commit darkwing's untracked evidence (348 files: queue-137 queue-141 queue-150 queue-162 queue-165 queue-166 queue-167 queue-169 queue-179 queue-180 queue-181 queue-182 queue-185 queue-186 queue-187 queue-188 queue-190 queue-93)
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

45 KiB

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, <dataRoot>/working/<business>/<project>.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 <tip> "", 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 <tip> <old> (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:<project>/<task> for work/vikunja-<project>-<task>). 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" <[email protected]>, 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 <name>_default are removed on every path. Logs stay under <dataRoot>/bus-host/land/<id>/.

The exact mosaic-stack check

git -C <root> 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/<p>/tests/*.test.mjs suite except queue's (scripts/test-queue.sh runs those), then every scripts/test-*.sh, each step logged to <step>.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/<id>/<id> 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=<file> 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 <value>. 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.