# Row 116 (#1610) round 1: a resolved merge decision lands as a gated local commit Brief: docs/plans/2026-10-14_merge-path-for-approved-work.md, round 2 at 68ce7f354 (blob 7a610f379, queue rev 1148). Sage's rulings sage-0195 (the read path and the rules) and sage-0196 (the digest check) apply where they differ from the brief's text, and are cited below where they do. BASE: 1223a0f37. Nothing on refactor since then touches a candidate path. `git diff --stat 1223a0f37 02494c3ed` over scripts/, docs/TOOLS.md, docs/plans/CONDUCTOR.md and packages/bus/ shows one line: 7eb5ad0cb (PRD 1.2) changed the requirement count in packages/bus/tests/requirements.test.mjs from 30 to 38. packages/bus/src, which read.mjs imports, is unchanged. The candidate is build.patch against BASE, and candidate-manifest.sha256 lists the 6 files. `repro.sh` exports BASE with `git archive`, applies build.patch and checks every manifest digest: DIGESTS_OK. ## What changed All new except the two docs. Nothing under packages/bus/src, packages/harness or roles/ changes, and no broker verb was added. | File | What | |---|---| | scripts/land-work.sh | the tool | | scripts/land-work/read.mjs | every bus query, all SELECTs, and the schema check | | scripts/land-work/fixture.mjs | writes test rows through the real Store | | scripts/test-land-work.sh | the suite, 78 cases | | docs/TOOLS.md | a "Conductor (landing approved work)" entry and the Maintenance row | | docs/plans/CONDUCTOR.md | a "Landing approved work" section | I chose a new script over a `land` verb in an existing CLI. The brief allows either. `scripts/mosaic` verbs run as the human or a seat through the broker, and this tool is conductor-only and host-side, the same footing as `scripts/conductor-apply.sh`. A script also keeps the read path out of packages/bus. ### The read, scripts/land-work/read.mjs It opens `/bus/bus.sqlite` with `node:sqlite` `readOnly: true` after an lstat (a regular file owned by the user, not a symlink), and every query lives in its `queries` object. It imports `schemaDigest` and `SCHEMA_SHA256` from packages/bus/src/store.mjs. The schema check follows sage-0196, which overrides the brief's sentence that `schemaDigest(db)` should equal `SCHEMA_SHA256`. Those two are different hashes (one of schema.sql's text, one of sqlite_master), so that sentence could never hold. Sage: "This message is the lead ruling Filbert can cite against the brief's literal sentence." The check is the Store's own: 1. schema.sql is read and re-hashed against the imported `SCHEMA_SHA256`. 2. It is executed into a `:memory:` database, and `schemaDigest` of that is the expected digest. store.mjs doesn't export its expected digest, so this builds it the way the Store does. 3. `schemaDigest(db)` must equal it, and meta must say `schema_version` '3b' and `schema_digest` equal to it. The '3b' is a copy of the Store's literal (read.mjs header names it). A schema bump changes the Store's value, and until someone changes read.mjs too, every read refuses `bus-schema`. That fails closed, and the header says where to look. The rules follow sage-0195: - action `git.merge.protected`, on the decision and on its one raise event (`action.allowed`, operation `decision.raise`, subject the decision id); - exactly one close, and it is `resolved`; - resolved by the business file's `human`, `via` `cli`, with exactly one `human.input` answer whose actor role and run are null and whose choice matches; - the choice is the raise's `approvalChoice`; - the question names exactly one work ref, and it matches `work/vikunja-

-`. The task is `vikunja:

/`, and the decision's or the raise's `task_ref`, if set, must agree; - the raise's `target` is exactly `refactor`, and so is `--base`; - no later `git.merge.protected` decision in the business supersedes this one, has this task_ref, or names this work ref in its question; - the latest `review.verdict` for the task (by seq) is `approve`, its body's task_ref is the task, and its commit is a full object id. Each query is scoped to the business, so one business's rows can't answer for another's. I dropped the "into " text check from the question. The target comes from the raise (sage-0195). The subject is the merge option's text, first line (f71c73c9: "Squash-merge work/vikunja-19-265 into refactor"). The brief says "the decision title", and decisions have no title column. Sage agreed with the option text in sage-0196. ### The tool, scripts/land-work.sh The header lists every refusal in order. After the read: - `ref-moved`: `refs/heads/` in `/working//.git` must be C. git runs there with no hooks, no fsmonitor and file transport only. - The target fetches C's objects with no refspec destination and no FETCH_HEAD, so no ref moves. - The reviewed diff is `git diff $(merge-base refactor C) C` in a fixed format (`--no-ext-diff --no-textconv --binary --no-renames`, no prefix overrides). No merge base or an empty diff refuses `base-conflict`. - `root-file`: an added top-level file, an added file in a top-level directory the base doesn't have, or one under `src/` or `v1/`. The brief says "any added file at the repository root" and gives 010d30cbe's `src/parse-requirement-id.mjs` as the case, but `src/` already exists at the root with legacy files, so a top-level-only rule would pass it. I count the legacy `src/` and the archived `v1/` as the root. - `checkout-dirty`: if `refactor` is checked out somewhere, the reviewed paths there must have no changes, nothing staged, and no untracked file at a path the commit adds. The check runs before the worktree and again right before the commit, because the landing rewrites those paths. An unrelated local edit stays. - The squash is `git apply --index` in a detached worktree at the base (`base-conflict` if it doesn't apply). `patch-drift` compares `git patch-id --stable` of the worktree's cached diff with the reviewed one. - The gate, then one `git commit` in the worktree and a compare-and-swap `update-ref` of `refs/heads/refactor` from OLD. A base that moved during the gate refuses `base-moved` and writes nothing. Where refactor is checked out, the reviewed paths are reset and checked out from the new commit. - It prints `land-work: committed ... NOT pushed` and the PM line, and sends nothing. The gate is Sage's landing gate template: every `packages/*/tests` suite except queue, then every `scripts/test-*.sh`, each with pipefail and its own log in `$TMPDIR/land-work-logs.*`, plus gate.log. The first failure refuses `gate-failed` and names its log. There is no switch to skip or shorten it, and no flake case. I removed the MOSAIC_LAND_GATE override I had in an early draft, so the suite runs the real gate loop over its fixture repo's packages and scripts. Cleanup runs from an EXIT trap on every path: the `node_modules` link, `worktree remove --force` (or `rm -rf`) and `worktree prune`, `docker network rm _default` when it exists, and the scratch directory. The log directory stays unless it is empty. `--subject` and `--attribution` override the subject and the last line (`Applied-under: scripts/land-work.sh (conductor)`). `--business` and `--project` are needed only when the config has more than one business file or the business more than one project. ### Things a reviewer should know - A readOnly open of a WAL database whose writer is closed creates `bus.sqlite-shm` and `-wal` beside it. The fixture db has no writer after fixture.mjs closes, so the suite sees this. The database file itself is unchanged (the landing case checks its sha256). On the live bus the writer keeps those files open anyway. - The gate inherits the caller's environment, as Sage's template does. Every scripts/test-*.sh that uses MOSAIC_CONFIG sets its own. - `git patch-id --stable` ignores whitespace and line numbers, so a whitespace-only drift can't be made with `apply.whitespace=fix`. The patch-drift case uses a PATH git wrapper that appends a line to README.md and stages it right after `git -C apply`. - The gate inherits `TMPDIR`, and the cli suite builds unix socket paths under it (`/mosaic-launch-XXXXXX/data/launches//r<12 hex>/tools.sock`, refused over 107 bytes). My first replay used a 46-byte `TMPDIR`, and 8 cli tests failed with "data root path too long for a socket". land-work refused `gate-failed`, wrote no ref and cleaned up; replay-1/ has that run. It is deterministic, not a flake. The TOOLS.md entry says to keep `TMPDIR` short, and the replay now uses `~/darkwing-scratch/rt` (33 bytes). I didn't add a length check to land-work: the limit belongs to the cli suite and could change with it. - The working repository has the coder's history. A work ref whose history carries an unlanded commit and its revert (19/265 carries 010d30cbe and its revert) still squashes to the reviewed diff, which is the point of squashing. ## Tests scripts/test-land-work.sh, 78 cases, all passing. It runs in a sandbox under `$TMPDIR` with a fake `docker` on PATH that logs its calls, and builds a fixture project repository, a working repository holding the coder's commits, and a bus written through the real Store by fixture.mjs, in the live rows' shape. Each case gets its own copy of all three. It never opens `~/.mosaic-dev` or `~/.t3`. | Group | Cases | |---|---| | Arguments and config | `--base next` and `--base main` refuse `target-branch`, before the config is read; a bad id, an unknown flag and a two-line `--subject` exit 4; a missing system config and a missing bus exit 3 | | The bus database | a changed table and changed meta each refuse `bus-schema`; `openBus` opens read-only | | The decision | 20 variants: other business, no raise, not a merge, open, withdrawn, resolved by an agent, via webui, no human answer, chose no, two work refs, a bad ref, a disagreeing task_ref, target main, superseded by question, by task and by `supersedes`, no verdict, a later changes verdict, a short commit, a verdict body for another task; plus an unknown id | | The work ref and the diff | `ref-moved`; `root-file` for a top-level file, a new top-level directory and `src/`; `base-conflict` for unrelated history, an empty diff and an apply conflict; `checkout-dirty` for a changed, a staged and an untracked path, and for a path dirtied during the gate (no ref moves); `patch-drift` through the tamper wrapper | | The gate | a failing package suite and a failing `scripts/test-*.sh` each refuse `gate-failed` and name an existing log; no ref moves; a target without node_modules exits 4; `base-moved` keeps the base where it moved to | | A dry run | writes no ref, prints the commit with `--subject` and `--attribution`, runs the gate | | A landing | one commit on the old base; its diff is the reviewed diff by patch-id; its tree is the dry run's; the message has the option's subject, the citations and the attribution last; the PM line prints; the checked-out base gets the reviewed paths and keeps an unrelated edit; only `refs/heads/refactor` exists; the work ref and the bus file are unchanged; the gate ran every package suite but queue and every `scripts/test-*.sh` | | Cleanup | after a refusal past the worktree, patch-drift, gate-failed, no node_modules, a dry run and a landing: no worktree and no scratch; the Docker network is looked for and removed; the target's node_modules is still there | ## Mutants mutants.py applies 49 mutants, one at a time, to a copy of the file, runs the whole suite under `timeout 600` in its own process group, and restores the file from `.orig` with a sha256 check. The run ended `restored: True`. 48 of 49 are killed; mutants-run.log has each one's failing cases. | Mutant | Change | Result | |---|---|---| | r01-schema-digest | skip the schema digest compare | killed, 1 of 78 fail | | r02-schema-meta | skip the meta version and digest check | killed, 1 of 78 fail | | r03-read-only | open read-write | killed, 2 of 78 fail | | r04-business-scope | drop `business = ?` from every query | killed, 1 of 78 fail | | r05-one-raise | accept zero or several raises | killed, 1 of 78 fail | | r06-not-a-merge | skip the action check | killed, 1 of 78 fail | | r07-resolved-op | accept any single close op | killed, 1 of 78 fail | | r08-human-by | skip resolved-by == human | killed, 1 of 78 fail | | r09-human-via | skip via == cli | killed, 1 of 78 fail | | r10-human-answer | accept a missing human answer | killed, 1 of 78 fail | | r11-merge-option | skip choice == approvalChoice | killed, 1 of 78 fail | | r12-one-work-ref | accept several work refs | killed, 1 of 78 fail | | r13-work-ref-shape | loosen the work ref pattern | killed, 1 of 78 fail | | r14-task-ref-agrees | skip the task_ref agreement | killed, 1 of 78 fail | | r15-target | skip the target check | killed, 1 of 78 fail | | r16-later-supersedes | ignore `supersedes` | killed, 1 of 78 fail | | r17-later-task | ignore a later decision on the task | killed, 1 of 78 fail | | r18-later-question | ignore a later question naming the ref | killed, 1 of 78 fail | | r19-no-verdict | skip the no-verdict refusal | killed, 1 of 78 fail | | r20-latest-verdict | take the earliest verdict | killed, 30 of 78 fail | | r21-approve | skip verdict == approve | killed, 1 of 78 fail | | r22-verdict-task | skip the verdict body's task_ref | killed, 1 of 78 fail | | r23-full-oid | accept a short commit | killed, 1 of 78 fail | | s01-base-early | skip the early `--base` check | killed, 1 of 78 fail | | s02-ref-moved | skip ref-moved | killed, 1 of 78 fail | | s03-merge-base | ignore a missing merge base | killed, 1 of 78 fail | | s04-empty-diff | accept an empty diff | killed, 1 of 78 fail | | s05-root-top-file | allow a top-level file | killed, 1 of 78 fail | | s06-root-new-dir | allow a new top-level directory | killed, 1 of 78 fail | | s07-root-src-v1 | allow `src/` and `v1/` | killed, 1 of 78 fail | | s08-dirty-changed | skip the changed-path check | killed, 3 of 78 fail | | s09-dirty-staged | skip the staged check | killed, 1 of 78 fail | | s10-dirty-untracked | skip the untracked check | killed, 1 of 78 fail | | s11-dirty-recheck | skip the recheck before the commit | killed, 2 of 78 fail | | s12-apply-conflict | ignore an apply failure | killed, 1 of 78 fail | | s13-patch-drift | skip the patch-id compare | killed, 1 of 78 fail | | s14-gate-node-modules | skip the node_modules check | killed, 1 of 78 fail | | s15-gate-step-fails | ignore a failing gate step | killed, 5 of 78 fail | | s16-gate-skips-queue | run the queue suite too | killed, 15 of 78 fail | | s17-gate-scripts | run no scripts/test-*.sh | killed, 8 of 78 fail | | s18-base-moved-cas | update-ref without the old value | killed, 2 of 78 fail | | s19-dry-run | ignore `--dry-run` | killed, 3 of 78 fail | | s20-checkout-sync | skip the checkout sync | killed, 1 of 78 fail | | s21-subject | a fixed subject | killed, 1 of 78 fail | | s22-attribution | drop the attribution line | killed, 2 of 78 fail | | s23-cleanup-worktree | keep the worktree | killed, 6 of 78 fail | | s24-cleanup-network | keep the Docker network | killed, 1 of 78 fail | | s25-cleanup-scratch | keep the scratch directory | killed, 6 of 78 fail | | s26-cleanup-link | skip the explicit node_modules unlink | survived | s26 is an equivalent mutant. `git worktree remove --force` and the `rm -rf` fallback both delete the `node_modules` symlink without following it, so the target's node_modules survives with or without the explicit unlink, and the suite's "the target's node_modules is still there" case passes either way. I kept the unlink so cleanup never depends on how a git version treats a symlinked directory. r23 is killed by the wrong refusal: with a short commit, `ref-moved` fires in land-work.sh after read.mjs lets the verdict through, so the case gets exit 2 but not `not-approved`. ## Gate A targeted builder gate under #271 (pkt/gate.sh, logs in gate/). The candidate changes no package; read.mjs and fixture.mjs import packages/bus/src/store.mjs, so the bus package runs. test-conductor.sh is the conductor suite beside the new one. I searched every `scripts/test-*.sh` and `packages/*/tests` file for anything that reads `scripts/` as a whole, land-work, TOOLS.md or CONDUCTOR.md; only packages/webui/tests/pinned-copy.test.mjs walks a scripts directory, so it ran too. Sage's landing gate stays full. | Step | Result | |---|---| | packages/bus `node --test tests/*.test.mjs` | 156 pass, 0 fail | | scripts/test-land-work.sh | 78 passed, 0 failed | | scripts/test-conductor.sh | 17 passed, 0 failed | | packages/webui pinned-copy.test.mjs | 10 pass, 0 fail | GATE_PASSED. No flake in this gate. ## Replay replay/replay.sh runs land-work from the candidate worktree against scratch copies, with `bash replay.sh [BASE0]`. It clones the canonical repository into a target with `refactor` at BASE0 and copies the live working repository (`~/.mosaic-dev/working/mosaic-stack/stack.git`) once per case. It loads Sage's dump of the live rows (agents/sage/work/row116/live-rows.json) into a fresh bus through fixture.mjs, which uses the real Store. The live bus and the canonical repository are only read. Each run's sandbox config points at its own data root, and TMPDIR is a short scratch path. replay/rows.mjs leaves out the `task.created` rows. The schema's `events_task_created_body` trigger needs the `human.input` request each one cites, and the dump doesn't carry that request event (6870b3ca), so the Store refuses the insert. land-work never reads `task.created`, so the read sees the same decision, raise, close, answer and verdict rows the live bus has. ### The brief's base and tree can't both hold The acceptance line says base 1c9a4910d and a resulting tree equal to `f7c8537b3^{tree}`. f7c8537b3's parent is 4bc908991, not 1c9a4910d. Between them sit b3fca1f1a (my row 115 round 1 packet) and 4bc908991 (its SESSIONS line), 34 files under agents/darkwing/work/queue-115/r1/ and docs/SESSIONS.md. A squash on 1c9a4910d can't produce a tree that has those files, and nothing in the reviewed diff should add them. So I ran it twice: - at 1c9a4910d, the brief's base, where the check is that the landed diff equals `git diff f7c8537b3^ f7c8537b3`; - at 4bc908991, f7c8537b3's real parent, where the tree must match exactly. I'm asking Sage for a ruling Filbert can cite, the same kind as sage-0196. ### Run 1: TMPDIR too long (replay-1/) The first attempt at 1c9a4910d used TMPDIR=~/darkwing-scratch/r116/replay/tmp (46 bytes). The dry-run's gate refused `gate-failed: pkg-cli`, rc 2, and wrote no ref. 8 cli tests failed with `data root path too long for a socket`. That suite builds `/mosaic-launch-XXXXXX/data/launches//r<12 hex>/tools.sock`, and a unix socket path holds 107 bytes. The gate inherits TMPDIR, so this was the environment and not the candidate. I kept the logs, moved the replay TMPDIR to `~/darkwing-scratch/rt-`, and added one sentence to the TOOLS.md entry. That edit is in the candidate (TOOLS.md +25, was +23), and build.patch and the manifest were rebuilt after it. ### Run 2: base 1c9a4910d (replay-1c9a/) | Check | Result | |---|---| | dry-run rc | 0, gate GATE_PASSED (land-work-logs.2vWgxG) | | dry-run wrote no ref (refs, reflog, status, worktrees, working repo refs, bus sha256) | yes | | dry-run tree | 3ca4beda9, not f7c8537b3's, as explained above | | landing rc | 0, full gate GATE_PASSED (land-work-logs.QTBGn3) | | new commit | b295e3002, parent 1c9a4910d, tree equal to the dry-run tree | | landed diff equals f7c8537b3's own diff, as text | yes | | canonical repository lacks the commit | yes | | working repo refs unchanged | yes | | bus.sqlite sha256 unchanged | yes | | target status after | `?? node_modules` only (the replay's own link) | | worktrees after | 1 | | 19/229 (010d30cbe), fixture decision over verdict 1fbd1ed1 | rc 2, `refused: root-file: the reviewed diff adds src/parse-requirement-id.mjs src/parse-requirement-id.test.mjs` | | land-work scratch left in TMPDIR | 0 | The commit message is run 3's below with this base in its Base line and first paragraph. The PM line it printed and did not send: `work/vikunja-19-265 (vikunja:19/265) is merged to refactor as b295e3002; push pending.` Three corrections on this run: - replay-1c9a/replay.sh is the script as it ran. It had no diff check. I compared the diffs by hand after the run (`diff <(git -C target diff --binary b295e3002^ b295e3002) <(git -C canonical diff --binary f7c8537b3^ f7c8537b3)`, no output), and the current replay.sh does it as a check. - Its line `remote-tracking refs unchanged: NO` was a bug in the check, not a change. It grepped the saved snapshot for `' refs/remotes/'` with a leading space, but those lines begin with the ref name, so the before side was empty while the after side listed every remote ref. I compared the remote refs directly and they were identical, as was every target ref other than refactor. The current replay.sh checks "target refs other than refactor unchanged" instead. - My slip: while the landing's gate ran, I fetched 4bc908991 and f7c8537b3 into the replay's target clone by hand to look at the tree difference. That wrote FETCH_HEAD and objects into the target. No ref moved, and land-work had already fetched C and built its diff by then. The current replay.sh compares the diffs as text so the target never fetches from the canonical repository, and run 3 has no such fetch. ### Run 3: base 4bc908991 (replay-4bc9/) Same scripts, `bash replay.sh 4bc908991`, TMPDIR ~/darkwing-scratch/rt-4bc9. replay-4bc9/replay.sh is the script as it ran and equals replay/replay.sh. | Check | Result | |---|---| | dry-run rc | 0, full gate GATE_PASSED (land-work-logs.adiLIr) | | dry-run tree | cdd3a62c2, equal to `f7c8537b3^{tree}` | | dry-run wrote no ref | yes | | landing rc | 0, full gate GATE_PASSED (land-work-logs.ASEJp9, 13 package suites and 9 scripts) | | new commit | 1c0e5e723, parent 4bc908991 | | tree equals `f7c8537b3^{tree}` | yes | | landed diff equals f7c8537b3's own diff, as text | yes | | canonical repository lacks the commit | yes | | target refs other than refactor unchanged | yes | | working repo refs unchanged | yes | | bus.sqlite sha256 unchanged | yes | | target status after | `?? node_modules` only | | worktrees after | 1 | | 19/229 | rc 2, `refused: root-file: the reviewed diff adds src/parse-requirement-id.mjs src/parse-requirement-id.test.mjs` | | land-work scratch left in TMPDIR | 0 | The commit message: ``` Squash-merge work/vikunja-19-265 into refactor Squash of work/vikunja-19-265 (vikunja:19/265) at 8ae5a149152b35a6d8370924775725e5b36c7b92 onto refactor 4bc908991cacf77b04437cbab0a9c1c0d7eaa77c, the reviewed diff 0c3826910edbd0b76ef60362b49ac6f8b9bab369..8ae5a149152b35a6d8370924775725e5b36c7b92. Resolved yes by jason via the CLI at 2026-10-14T16:35:32.002Z. Decision: f71c73c9-c759-4aca-98d9-3e22e8c42b36 Verdict: d3f97f9d-f5c6-4f81-924d-d3c0698692f4 Work-ref: work/vikunja-19-265 Reviewed-commit: 8ae5a149152b35a6d8370924775725e5b36c7b92 Base: refactor 4bc908991cacf77b04437cbab0a9c1c0d7eaa77c Patch-id: 26100825e4128d64e9a84690dba008b9549fe6a5 Gate: full, every package suite and every scripts/test-*.sh Applied-under: scripts/land-work.sh (conductor) ``` The PM line: `work/vikunja-19-265 (vikunja:19/265) is merged to refactor as 1c0e5e723; push pending.` Across runs 2 and 3, four full gates ran (two dry-runs, two landings), and none had a nonzero step, so the flake rule had nothing to count. ### Leftovers outside land-work After runs 2 and 3, TMPDIR held 347 and 344 entries that aren't land-work's (`mosaic-business-*`, `mosaic-launch-*`). The base suites the gate runs leave them, and land-work's own scratch count was 0. I removed each replay TMPDIR after saving the evidence. Not this row's to fix; I note it for whoever owns those suites. ## Going live Nothing goes live. The tool is a conductor command run by hand; no service, role or session calls it, and I ran it only against scratch copies. Push, the merge to next or main, re-pins and restarts stay out of this row.