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.
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:
- Fetch the root's HEAD as
+HEAD:refs/heads/root, forced. A failure refusesroot-unreadable, as now. - If main doesn't exist, create it at root's tip (
update-ref main <tip> "", which fails if another process created it first). - 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). - 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..Cis the task's own change, as before. Landing it on a main that has moved on can refusebase-conflictwhere the two touch the same lines. - The reviewer's fetch now also has
refs/heads/rootin the repository, so itshavenegotiation 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 targetsrefactor, and read.mjs still checks its--baseisrefactor.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, thenreadLandingwithinto: 'main'. The project is the task's latest push; no push refusesref-moved. A decision that names another project refuseswork-ref.recordLand(cap, land): reruns the checks inside the transaction (the verdict is still the latest and approving, nothing supersedes the decision), then#consumeAuthoritywrites theaction.allowedwith the landed commit, the old main, the reviewed commit, the verdict and the patch-id. The decision is spent.recordEventreservesaction.allowedforgit.merge.protected, as it already does forgit.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) oraction.refused(gate-failed,base-moved,host-closing, ...) on the task's trail, and - a RESULT message from the bus (
_bus, runbus, as#noticesends) 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:
- Broker
checkLand:decision-not-found,not-a-merge,not-resolved,not-human,not-merge-option,work-ref,target-branch,superseded,no-verdict,not-approved, plusdecision-consumed. - 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 refusesbase-conflict; the reviewed patch isMB..Cin land-work's fixed diff format, empty refusesbase-conflict; its patch-id is taken. - Root file: an added top-level file, an added top-level directory OLD
doesn't have, or anything under
src/orv1/refusesroot-file. - The squash, in a temporary index on OLD's tree: the patch is applied
with
apply --cached; failure refusesbase-conflict; the patch-id ofOLD..treemust equal the reviewed one (patch-drift). The commit is made withcommit-tree, no ref, author and committer "Mosaic bus host"<[email protected]>, and land-work's message withApplied-under: git socket land (bus host). The commit is fixed before the gate, so nothing the gate does can change what lands. - Gate check: the project root is a mosaic-stack checkout (below) with a
node_modules; otherwisegate-missing. This is the last synchronous refusal; the op answers "started" here. - The gate, outside the lock, in a detached worktree of the new commit
with the root's
node_moduleslinked. A failing step refusesgate-failedwith the step and its log. - Under the lock:
update-ref main NEW OLD; failure refusesbase-moved. ThenrecordLand. If recording throws, main goes back withupdate-ref main OLD NEW; if that fails too, the refusal isrollback-failedand 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_worktool, verbworking.land, intools.mjs, shown when the launcher says the session may land.landWork()client ingit-tools.mjs.- runner local handler for
working.landover 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 withgit apply --index. The applied diff (--cachedagainst OLD) must have the reviewed patch-id.write-tree,commit-treewith OLD as the one parent, thenreset --softto the squash, so the gate runs in the tree that lands. The worktree goes on every refusal and at the end (dropLand, which removes thenode_modulessymlink 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
startedanswer carriescommit, the squash, besidesbase,reviewedandpatch_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 theaction.alloweditself 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 lineno-subject.decision-consumedis 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
runGitwith only the environment's PATH, hooks at /dev/null and fsmonitor off. - The gate is injectable.
createGitSocket({gate})takesLAND_GATE's shape,{classify, run, dropNetwork}, so the socket tests run a fake gate. A test checks thatLAND_GATEholds the realisMosaicStackandrunGate, and the refusals test runs the real classifier on a fixture project for gate-missing. - Harness.
land_workhas no clone requirement and sits betweenpush_workingandrequest_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 askslandHeldwith the run's role instance and thread and binds the git socket when it answers true. - The runner's
working.landroute has no test of its own. Neither doworking.pushandworking.fetch.landWorkis tested in git-tools.test.mjs against a fake host socket, and the launcher test checks the bundle andsession.json. - Arbiter path and
gatedrefusal (sage-0511, sage-0517, sage-0565): rounds 1 and 2 left both out, since row 139 hadn't landed, and both callers ofreadLandingrequired a human/cli resolution. Row 139 landed first, so round 3 adds them (see "Round 3"). land-work's check inscripts/land-work/read.mjsis unchanged in effect in every round. - The raise is found by its body, not its subject. read.mjs looked
for the decision.raise
action.allowedwithsubjectequal 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).readLandingnow matches the business, the kind, the operation andbody.decision, as the broker's own raise lookups do. For land-work this changes one thing: a refactor decision raised with atask_refused to refuse decision-not-found and now reaches the checks after it, where atask_refthat 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.recordEventrefuses anaction.allowedwhose action isgit.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_FILESin 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 besidethreadIdle.
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 andsrc/landing.mjs; the sandbox bind list addslandHeld.packages/cli/README.md, "Git socket": the bind rule, alandrow in the op table,checkLandin 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: theland_workparagraph, 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.
- 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.
- Round 2, cli, 196 pass, 6 fail. git-socket.test expected a newline
after the Applied-under line, which
w.gittrims, so the test now anchors that line at the end of the message. Five thread-launch tests failed because the test host lackedlandHeld. 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 usedenv -u TMPDIR, and it now passes the inherited TMPDIR. - Round 3, cli launcher.test, 18 pass, 1 fail. The
landHeldtest's wrapper usedObject.assignon a host whoseopis a read-only inherited property and threw a TypeError. It now usesObject.create. 19 of 19 passed, 0 skipped. - 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
gitfirst on the PATH runs the real git, then adds a line to the README thatapply --indexstaged, as scripts/test-land-work.sh does for land-work. prepareLand refusespatch-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
arbiteris true, the decision iscross-role, itsroute_toisn'thuman, the resolution camevia: '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:
readLandingrefusesnot-a-mergefor any action butgit.merge.protected, which isn't a hard wait.- The arbiter path takes only
cross-role, and REQ-WAIT-2 keeps a hard wait out of that class. - A gated decision refuses
not-authorizedbeforereadLandingruns.
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-roleto pm, and pm resolves it (by: 'pm-run',via: 'broker').readLandingwithoutarbiterrefusesnot-humanon it. checkLand answers the facts withby: 'pm-run', and recordLand writes the merge event with classcross-roleand 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.mdat the fixture commit. The raise routesgatedto pm, and pm approves. checkLand refusesnot-authorized,readLandingwitharbiter: truerefusesnot-human, and landHeld is false. Then, uncited: the raise routesgatedto the human, the human approves, and checkLand still refusesnot-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 withbyset 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'sarbiter: truealso accepts a cross-role decision its arbiter resolved;not-authorizedfor gated (REQ-LAND-1); checkLand and landHeld namecross-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
readLandingcall witharbiter: trueon the arbiter-resolved gated decision is the only check that sees it. - The arbiter-default kill fails REQ-DEC-2 on its
readLandingcall withoutarbiter, which must refusenot-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:
readLandingreturnsvia: res.via, and#landFactspasses it into checkLand's facts. The broker's ticket answer gainsvia.- git-socket.mjs maps it:
cliwrites "via the CLI", andbrokerwrites "as its arbiter, via the broker". readLanding accepts no other value. If one ever got through, the line would printvia <value>. It never falls back to the CLI. - land-work's read.mjs prints every key, so it now prints a
vialine 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 refusesnot-authorized, landHeld is false, and nothing merges or refuses on the trail.setuptakes arolesoverride 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 butcross-role, gated and within-role named; checkLand's answer carries the resolution'svia.packages/cli/README.md: the squash's Resolved line names the channel from the ticket'svia.
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.