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

885 lines
45 KiB
Markdown

# 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 <choice> by <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.