Skip to content

refactor(ci): retire config-lint into the verb it already called, and fix the admission defect that forced it - #809

Merged
wenzowski merged 5 commits into
mainfrom
claude/cloud-843-bundle-b-dkfi06
Sep 1, 2026
Merged

wenzowski merged 5 commits into
mainfrom
claude/cloud-843-bundle-b-dkfi06

Conversation

@wenzowski

@wenzowski wenzowski commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Why

mise-tasks/config-lint.sh was a wrapper: it spawned batten config lint — the
successor — and then adjudicated the answer against a claim receipt and a commit trailer.
That adjudication carried a defect nothing could reach past it, and repairing it meant
editing authored shell, which V-SHELL-RULE-EDITED refuses with no override. So the fix
and the retirement are one change. That is the campaign working as designed, not a
coincidence — and it is why filing the defect as a ticket instead of retiring the program
was the wrong disposition.

The defect, in three states that were two

config-lint.sh's own header states the contract: "They AGREE. Where both are readable, a
trailer naming something the groom did not is refused, and the trailer alone admits
nothing."
It read the groom as grep -E '^weakens ' "$claim", so a receipt that exists
and names no weakening
produced the same empty string as no receipt at all, and the
admission arm keyed on that emptiness. The message it printed — "no claim receipt here to
check it against" — is CI's honest state, and it was being printed over a receipt sitting
in $GIT_DIR.

Groom::Unreadable  no receipt      -> the trailer alone admits (CI's half)
Groom::Read(named) names this pair -> admitted, both sources agree
Groom::Read(other) looked, did not -> REFUSED, whatever the trailer says

The third row is the one that was green and is now red. Filed as CLOUD-841 in 2026-08.

And it had a layer underneath that 841's own body assumes away. 841 notes "claim-check
must keep minting a receipt when the groom named nothing — it already does"
. It does not.
The port onto batten claim stopped writing the weakens line entirely, so every receipt
in every clone read as empty and the lenient arm was the only reachable one. Measured on
this branch's own predecessor: a receipt naming two groomed clauses, and config-lint
reporting "no claim receipt here" over it.

Either half alone still ships a gate that decides nothing, so both land here.

One more thing the shell's grammar could not have done. It anchored on
\*\*Weakens:\*\*[[:space:]]. The tracker normalises an author's **Weakens:** to
**Weakens: **, moving the space inside the emphasis — so the regex matched a body typed
into a local file and never one the tracker returned. Restoring it verbatim would have
re-landed a dead gate; both spellings are accepted, with a case pinning the tracker's.

What landed

  • fix(board) — claim::mint extracts the groomed clauses and writes
    weakens <ID> <smell> <key>. Five cases, three of them anti-vacuity.
  • fix(gate) — lint::groom/declared/admissions decide the three states in the
    verb. The trailer read goes through git's own trailer parse, so a line quoted mid-message
    cannot pose as one and this cannot disagree with attribution about the same commit. An
    empty Weakens: trailer is dropped rather than matched — V-WEAKENS-DECLARES-NOTHING's
    class. Seven cases.
  • refactor(ci) — the retirement. Two deleted paths, corpus 135 → 131 suites
    (three of those four are other bundles' retirements landing in parallel; this one is
    tests/config-lint.bats). Four compiled-binary cases.
  • fix(ci) — two defects verify caught in the retirement, both mine. Below.

What admits is unforgeable by the author at PR time

There is no flag, no environment variable and no config key. The two sources are written at
different moments, and the earlier one is minted by claim before the work starts —
anything settable inside the change under review would be a self-issued permit. House style
§8 loads policy out of band for exactly this reason.

Proven, not asserted

The centre case is the pair a with input as equivalent cannot reach: byte-identical
trailer, byte-identical config, a receipt that is absent versus one that is silent,
opposite verdicts. That shows the engine builds the two inputs — a receipt found under
the branch's own name, a trailer read out of a real commit — rather than a fixture
asserting a shape the reader may be unable to produce.

The ledger

Two file arms, both kind:mechanism — config lint already shipped, so no command surface
widens. Seventeen carried, six subsumed, and three arms that are not carried::

  • changed: — "with no claim receipt the trailer alone admits" still holds for an
    ABSENT receipt, which is what it names, but the shell reached that arm for a SILENT one
    too. The successor tells them apart, so the case is narrower than the behaviour it pinned.
  • withdrawn: ×3 — a grep over the program's own bytes for a BYPASS branch, a refusal
    text the shell composed, and a header claim reconciled against the workflow tree. A
    program that does not exist cannot carry a hatch or make a claim.

The task name survives, so verify, .github/workflows/ci.yml and hk.pkl are all
byte-identical and the four wiring cases still hold where they are.

Two defects this PR's own verify caught

Both mine, both in the retirement commit, and the first is the more interesting:

  1. A comment quoting the ratchet's pattern incremented the ratchet. The replacement task
    body is one line specifically to stay off inline-task-bodies-not-growing, and the
    comment saying so quoted the two spellings it counts. It is a substring ratchet over
    mise.toml that strips no comments, so the note tripped the rule it was describing.
    It was suppressed by the unnarrowed waiver from feat(facts): build the verdict-store producer, and retire three wrappers into the rows it wakes #797 — whose own reason says it is
    "expected to lapse unused". A second, unrelated increase riding it would have made that
    false while reading as true. The spellings are described rather than quoted now, the
    count is back to its floor, and the waiver lapses as written.
  2. One case carried two ledger arms, carried: and changed:. It is changed:.

Left undone, and stated plainly

  • board-diff-overlap (unit 16 of CLOUD-1162) is not in this PR, on the row's own
    instruction: its issue-body half extracts paths from prose, and input.tree.captured is a
    declared reduction — present, count or a bounded token, never a payload. That is a
    design boundary, not a gap to widen, and nothing owns the engine-side extractor it needs.
  • CLOUD-1281 duplicates CLOUD-841 and is marked as such; its two pieces of new evidence
    (the dropped weakens line, the tracker's normalisation) are folded in here.

Closes CLOUD-841
DO-NOT-CLOSE CLOUD-1162
DO-NOT-CLOSE CLOUD-1199
Refs: CLOUD-418

@linear-code

linear-code Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
CLOUD-841 `config-lint` reads a receipt that names no weakening as "no receipt", so a commit trailer alone admits a weakening the groom never saw

Why

CLOUD-789 gave config-lint its groomed-weakening hatch, and the design is stated in that file's own header:

  • The GROOMED BODY names it — a **Weakens:** … clause … claim-check copies it into the branch's claim receipt as a weakens line, at the one moment a gate holds the groomed body and the work has not started.
  • A COMMIT names it — a Weakens: <smell-id> <key> trailer …
  • They AGREE. Where both are readable, a trailer naming something the groom did not is refused, and the trailer alone admits nothing.

"The trailer alone admits nothing" is not what the code does. mise-tasks/config-lint reads the groom as:

groomed=$(grep -E '^weakens ' "$claim" || true)

so a receipt that exists and names no weakening yields the same empty string as no receipt at all. The admission arm then keys on that:

if [ -n "$trailers" ] && grep -qFx "$smell $key" <<<"$trailers"; then
    if [ -z "$groomed" ]; then
        echo "config-lint: admitted $smell $key (commit trailer; no claim receipt here to check it against)"

The message says "no claim receipt here" — CI's honest state, where none was ever written. On a developer branch the receipt is right there under $GIT_DIR and says, readably, this work was groomed and no weakening was admitted. That is not absence of evidence; it is evidence of absence, and it is read as the former.

Measured 2026-08-21 on claude/ratchet-retires-with-subjects-be87wn (CLOUD-807's branch). The receipt existed and named no weakening:

CLOUD-807
ready-lint pass
takeover 1 refusal(s) overridden (BATTEN_CLAIM_TAKEOVER): CLOUD-807 assigned
claimed-at 2026-08-21T07:50:43Z
...

Adding a Weakens: trailer to a commit, with the Ready block edited after the claim, produced:

batten.toml:rule[bats-tests-not-deleted].retires_with rule-predicate-changed
config-lint: 1 smell(s)
config-lint: admitted rule-predicate-changed rule[bats-tests-not-deleted].retires_with (commit trailer; no claim receipt here to check it against)
config-lint: 1 smell(s), every one admitted by a groomed decision

The last line is the defect in one sentence: it says groomed decision about a decision the groom never saw. The weakening in that instance was real and deliberate and is recorded on CLOUD-807 — the point here is that nothing forced it to be, and the local half that the header calls "the strong half" did not run.

Why this matters more than a wording fix. CLOUD-789's whole argument is that the strong check runs where the receipt is — verify, before a CI runner is spent — and CI confirms the committed half. As written, the strong half is unreachable on any branch whose groom recorded nothing, which is every branch that did not anticipate its own weakening. That is exactly the population the gate exists to catch: an author who knew would have groomed it.

The three states are two in the code. absent (no receipt — CI, or a branch with no claim), groomed-nothing (a receipt naming no weakening), and groomed-this (a receipt naming the pair) must be distinguished; today the first two are one. Only absent may fall back to the trailer.

Note claim-check must keep minting a receipt when the groom named nothing — it already does — so the distinguishing fact is receipt-exists, not weakens-line-exists.

Prior art on the same shape. This is CLOUD-615's class exactly: a fallback that reads "could not look" for "looked and found nothing", where the lenient branch is the one that fires. CLOUD-820 is removing the same shape from claim-check's own clock fallback, and its Ready block's reasoning ("delete the clock fallback rather than leaving it as dead code") applies here unchanged.


Refinement — Ready (three states, and only absence falls back)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Source of truth (§1). The claim receipt's existence is what separates "could not look" from "looked and found nothing"; its weakens lines are what separate the third state. No second record of either.
  • Computable predicate (§2). For each pointer batten.toml:<key> <smell>: admit iff a Weakens: <smell> <key> trailer exists in origin/main..HEAD and either (a) the claim receipt for this branch is unreadable/absent, or (b) it carries a matching weakens line. A readable receipt with no matching line refuses, whatever the trailer says. Decidable from the filesystem and git log, no network.
  • Effect (§3). read. No new verb, no new column.
  • Generated artifacts (§4). None.
  • Output & exit (§5). Pointer-only, unchanged: batten.toml:<key> <smell>. The refusal must say which of the two reasons applies — "no groom recorded for this branch" is a different remedy from "the groom named a different pair" — and must not quote the Ready block.
  • Commit / bump (§6). fix(gate) — a shell task, no crate API touched.
  • Test obligation (§7). tests/config-lint.bats. The discriminating case is the one that is green today and must go red: a receipt that exists, names no weakening, plus a matching commit trailer → refuses. Plus the three that must stay green: no receipt at all + trailer → admits (CI's path); receipt naming the pair + trailer → admits; receipt naming a different pair + trailer → refuses (already covered). A #MUTANT row reverting the arm to [ -z "$groomed" ] must turn the first case green again, or the fix is untested.
  • Blockers (§8). None. relatedTo CLOUD-789 (which built the hatch), CLOUD-807 (where this was measured), CLOUD-615 and CLOUD-820 (the same could-not-look-versus-found-nothing shape).

Acceptance

  • A branch whose receipt names no weakening cannot admit one by commit trailer.
  • CI, where no receipt exists, is unaffected — the committed half still confirms what the local half proved.
  • The admitting message no longer says "no claim receipt here" when a receipt is present and readable.

CLOUD-1162 Retire `config-lint` (23.5s) and `board-diff-overlap` (24.1s) — both fact preconditions have LANDED; `config-lint` is clear, `board-diff-overlap` needs a path extractor `captured` cannot give

THE TITLE SAID BLOCKED AFTER BOTH BLOCKERS LANDED — corrected 2026-08-31

The title read "each blocked on a different missing fact family" while the reopened
note directly below it already recorded both as Done. A correction block does not**
**correct a title, and the title is what a queue listing and every inline reference
render. Both blockedBy relations (CLOUD-1168, CLOUD-1154) have been removed.

Current seconds — both figures in this body are stale and LOW

bench/suites/RESULTS.md at origin/main 5b98174 (138 suites, 1097.1s):
config-lint = 23.5s (2.1%), board-diff-overlap = 24.1s (2.2%), 47.6s combined
rather than the 38.1s below. Read every figure from RESULTS.md, never from this body.

The per-program read this row demands, done — and the two units DIVERGE

config-lint IS clear. Both its inputs are answered: :130 spawns batten config lint, which is the successor, and :174's Weakens: trailer is
input.tree["commit-meta"].trailers (CLOUD-1187, Done, subsuming CLOUD-1168).
Dispatchable now. It is also a member of CLOUD-1199's wrapper family, whose
disposition — the successor is the verb the wrapper already calls — is settled and
proven by PR #780.

board-diff-overlap is NOT, and the reopened note above was too generous. Reading
it end to end rather than taking the class flip:

  • the diff half is fine — :96 is git diff --name-only origin/main...HEAD, carried by
    input.tree["base-delta"];
  • the tracked-paths half is fine — input.tree.tracked;
  • the issue-body half is still not expressible. The predicate extracts PATHS FROM
    PROSE, with basename resolution, out of a get_issue body. input.tree.captured
    (CLOUD-1188) is a declared REDUCTION — present, count, or a bounded
    token, never a payload, and a token carrying whitespace or over facts::TOKEN_MAX
    is REFUSED rather than truncated. That is non-negotiable rule 4 decided by the
    declaration's shape, so it is a design boundary and not a gap to widen.

So unit 16's successor needs an engine-side extractor whose RESULT is a bounded set
of tracked paths — the same shape CLOUD-1254 is deciding for the transcript family,
one domain over. Nothing owns that for the board family. Land config-lint alone;
do not batch unit 16 behind it on the strength of "1154 is Done".

That is the fourth instance of this row's own recorded failure — "clearing one input is*
*not clearing the program" — and the first where the over-clear was in the correction.

Nothing else in the body below is changed by this note.


REOPENED 2026-08-31 — THIS ROW WAS MARKED DONE WHILE BOTH ITS SUBJECTS ARE STILL IN THE TREE

Set Done at 04:36:54.197 directly from Todo — the state history records no In
Progress and no In Review, and no PR is attached. It was closed inside a ~4-second
bulk cluster (04:36:52–04:36:55) with roughly twenty other rows, so Done was not
set per-row from evidence.

Checkable rather than asserted, against origin/main 0683ce53 — all four paths**
**this row exists to delete are still tracked:

git cat-file -e origin/main:mise-tasks/config-lint.sh          # exists
git cat-file -e origin/main:tests/config-lint.bats             # exists
git cat-file -e origin/main:mise-tasks/board-diff-overlap.sh   # exists
git cat-file -e origin/main:tests/board-diff-overlap.bats      # exists

AGENTS.md is explicit that Done means released and is never the merge's to set;
nothing was released here because nothing landed. done-check did not catch it — the
class defect rather than this row's, and it has its own row now.

BOTH UNITS ARE NOW GENUINELY UNBLOCKED, which is the reason this is worth**
**reopening rather than merely correcting. Both blockers landed after this body was
last written:

  • unit 15 (config-lint) waited on CLOUD-1168 for its Weakens: trailer read — Done,
    and input.tree["commit-meta"] now carries trailers as a derived projection
    (CLOUD-1187 subsumed it).
  • unit 16 (board-diff-overlap) waited on CLOUD-1154 for a board payload — Done,
    and input.tree.forge plus input.tree.captured (CLOUD-1188) are on the surface.

Per-program reading is still owed before either is dispatched, because a class
precondition landing is not the same as the program being expressible — the gap that
produced three wrong verdicts on CLOUD-1163 the same day. The "CORRECTION
(2026-08-29)" section below is itself an instance: it records the cause as "the*
*shell-out at :130 was checked … and the second input — the trailer read at :174
— was never looked at." Read both programs end to end before writing either arm.

Nothing else in the body below is changed by this note.


Why

Units 15 and 16 of the 83-unit partition. Two separate deltas — this row carries both because each is small and neither glues to the other. 38.1s combined, 3.1% of the 1244.6s serial suite.

unit program lines suite s $MUTANT_GATES #MUTANT
15 mise-tasks/config-lint.sh 218 tests/config-lint.bats 21.2 yes 1
16 mise-tasks/board-diff-overlap.sh 160 tests/board-diff-overlap.bats 16.9 yes 3

Neither has an inbound reference from any governed file. Two deleted paths each, two ledger arms each, zero drag-in.

CORRECTION (2026-08-29): config-lint is NOT unblocked, and this row said it was

This row was filed asserting "unit 15 is not [blocked]" and "config-lint is unblocked and lands first". That is wrong. config-lint.sh:174 reads git log --format='%(trailers:key=Weakens,valueonly)' origin/main..HEAD — a commit trailer, which RangeCommit cannot carry: crates/batten/src/git.rs:3016 is sha-and-subject by non-negotiable rule 4. So unit 15 is blocked on CLOUD-1168, which owns the derived trailer projection.

The cause is the same one this campaign has now hit three times: the shell-out at :130 was checked (and correctly cleared), and the second input — the trailer read at :174 — was never looked at. Clearing one blocker is not clearing the program.

Neither unit lands today. Unit 15 waits on CLOUD-1168, unit 16 on CLOUD-1154. The "land config-lint alone if 1154 slips" escape below is void.

config-lint — the shell-out is to batten itself, and that half of the analysis stands

config-lint.sh:130 is output=$(cargo run --quiet -p batten -- "${args[@]}" 2>&1). The program is a wrapper that runs Batten's own config lint verb and adjudicates its output. A census that flags "executes another program" as a blocker mis-reads this: the executed program is the successor. Folding the shell-out away removes it entirely — the same shape as CLOUD-1145, where the port is also the performance fix, and the 21.2s is almost entirely cargo run start-up paid per case.

mise.toml:1984 and hk.pkl:387 invoke it by task name. Both ungoverned.

The successor's SPELLING moves (2026-08-30). CLOUD-1193 retires config lint out of the command surface entirely, into the rule id check config. That does not change this row's disposition — the wrapper still collapses into the thing it already calls, and the 21.2s is still cargo run start-up per case — but the successor is a [[rule]] row rather than a verb if CLOUD-1193 lands first. Write the arm against whichever exists at landing time; do not hard-code config lint.

board-diff-overlap — half expressible, and the split decides the scope

  • The diff half is expressible today. :96 is git diff --name-only origin/main...HEAD, which input.tree["base-delta"] carries (added/edited/deleted/code-changed).
  • The issue-body half is not. The gate reads get_issue payloads on stdin, and no tree-surface fact carries a board payload — that is CLOUD-1154.

So unit 16 is blocked on CLOUD-1154, and unit 15 is blocked on CLOUD-1168 — see the correction above. They are filed together for economy, and they are now blocked on two different fact families, so neither is a fallback for the other.

Its fixture tests/fixtures/board-diff-overlap/tracked-at-filing.txt is a frozen historical replay — tests/board-diff-overlap.bats:226 says "a historical replay that tracks the present measures nothing." It must be carried unchanged into the successor's fixtures, not regenerated.

The home each unit's decision lands in

A disposition is chosen before a successor is designed (CLOUD-1176). "Port it into crates/batten" is not a home: house style §2's surface is closed and §9 says consumer-specific behaviour is reconstructed through extension surfaces, never baked into the core.

unit home why
15 config-lint existing verb — config lint today, rule id check config after CLOUD-1193 measured against the emitted surface: config lint already ships, effect read. The shell is a wrapper that spawns it and adjudicates its output, so the successor is not a new verb at all — it is the wrapper collapsing into the verb it already calls, plus a consumer module for the Weakens:-trailer half once CLOUD-1168 lands. That also is the 21.2s fix: the cost is cargo run start-up paid once per case.
16 board-diff-overlap consumer module — policy/*.rego the whole predicate is written in a tracker's vocabulary — issue keys, issue bodies, a board payload — which non-negotiable rule 1 keeps out of crates/batten and out of a preset alike. The producer it waits on (CLOUD-1154) is substrate; this consumer is not.

Refinement — Ready (two deltas, two different missing fact families; neither lands today)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Authority boundary (§1). Four deleted paths across two PRs — config-lint.sh + its suite, board-diff-overlap.sh + its suite — and **four **// carried: ledger arms. Successors land at the homes named above — config lint's existing verb, and a policy/*.rego consumer module — never as a new crates/batten verb. Both $MUTANT_GATES entries and all 4 #MUTANT rows move with them. mise.toml and hk.pkl are ungoverned and repoint freely.
  • Computable predicate (§2) — the DECISION is conserved, never the defect (CLOUD-1176). Where either program's shell carries a tracked defect, the successor implements the corrected decision and that defect's own row records the change; a retirement that launders a known defect forward into Rust is the failure this campaign exists to avoid, not evidence of fidelity. config-lint: does batten config lint report a weakening the groom never saw, over the config at a named base ref. board-diff-overlap: does a row this branch spun off name code the branch was holding open — the intersection of the branch's changed set with the paths a filed issue names.
  • Deliberately not in scope (§2). Changing what config lint considers a weakening. Regenerating tracked-at-filing.txt — it is a frozen replay and tracking the present would make it measure nothing.
  • **Effect (§3). **read for both. Neither writes.
  • Output and exit (§5). Pointer-only: path:line and the rule id for config-lint; a count and a path for board-diff-overlap — never an issue body, which is a consumer's prose. Exit follows the 0/1/2/3 table; a could-not-look reading (no base ref, no payload) is 3, never a false 2.
  • **Commit / bump (§6). **refactor(ci) — no bump, per PR. Below 0.1.0 every release-worthy type collapses to a patch, but refactor is not one: it releases nothing at any version. CLOUD-595's correction.
  • Test obligation (§7). Over the compiled binary in crates/batten/tests/; **no **.bats file is added or edited (V-SHELL-RULE-ADDED refuses one at deny). **One **// carried: arm per deleted path — four paths, four arms (CLOUD-908). Shown able to fail per CLOUD-418: for config-lint, a config carrying an ungroomed weakening is reported and a clean one passes; for board-diff-overlap, the three-row REPLAY case must still report 1 crates/batten/src/git.rs for CLOUD-739 and CLOUD-740 and 1 mise-tasks/macos-link-check for CLOUD-737, and the control — the same bodies against a branch holding nothing open must report nothing, without which the first is satisfied by a gate that fires on everything. Mutated: 1 + 3 rows re-homed, mutant-census green.
  • **Blockers (§8). **blockedBy CLOUD-1154 — for board-diff-overlap only. blockedBy CLOUD-1168 — for config-lint only, per the correction above: its Weakens: trailer read has no fact behind it. relatedTo CLOUD-1151 (the wave owner), CLOUD-1140 (suite cost), CLOUD-908, CLOUD-418.

Acceptance

  • Four paths deleted across two PRs, four ledger arms, no governed file edited.
  • config-lint's successor makes **no **cargo run per case — the 21.2s is the point, and a port that keeps spawning has missed it.
  • config-lint's successor reads the Weakens: trailer from CLOUD-1168's projection, never by re-deriving a trailer scan.
  • board-diff-overlap's frozen fixture is carried byte-identical.
  • Both anti-vacuity controls observed.
  • mutant-census green; 4 mutations honoured at their new homes.

Units 15 and 16 of 83.

CLOUD-843 The retirement campaign has no row: eight capability rows cite "so the 79 gates have somewhere to migrate onto", and nothing owns the migration — measured, the bash grew today

Why

Eight rows in this campaign are justified by a migration nobody owns. CLOUD-833's title is literally "so none of the 79 gate-described mise-tasks has a surface to migrate onto"; CLOUD-832 exists so a bundle can carry 79 predicates; CLOUD-807 built the permit that lets a suite die with its subject. CLOUD-312 owns the 11 hook bodies and only those.

The 82 gate-described mise-tasks/ programs have no owning row. Searched 2026-08-21; the closest hits are all capability rows citing the campaign as their justification. This is DoR §2's own failure mode one level up: the backlog grew the engine, and the thing the engine was grown for was never filed.

Measured on main @ 12cca46 (v0.0.99), against the same census that morning

Surface Morning Now Δ
mise-tasks/ files 133 136 +3
mise-tasks/ lines 27,799 28,590 +791
gate-described tasks 79 82 +3
tests/*.bats lines 34,654 36,052 +1,398
bats cases 2,485 2,548 +63
gate tasks migrated — 0 —
kind = "policy" rows 0 1 — and it is trunk-based-preset, a vendored preset, not a migrated gate

One bash file was retired all day: .claude/hooks/batten-hook.sh, 65 lines (CLOUD-824). Against +791 lines added. The campaign to delete bash added bash, and nothing on the board was positioned to notice, because no row carries the number.

The classification, by what each task invokes

Not by substring. A string scan over these files put ci-local-parity and pipefail-grep-check in the forge bucket — the same instrument that counted 14 spawn sites where name resolution found 9 (CLOUD-743). Classified instead by the external programs each task actually invokes in command position:

bucket count can it migrate?
tree — no git, no forge, no build 22 yes, on CLOUD-833's tree surface, today
git facts 50 needs a git fact on the tree document; scope unproven
build/bench 3 probably never — they run cargo/hyperfine
forge 7 last, and some may legitimately stay

The 50 is the number that matters and it was not expected: this is a git policy engine, so most gates read git. The "79 gates become Rego rows" framing every capability row inherited is unproven for 60 of the 82. That is this row's first deliverable to settle, not assume.

The pilot

mise-pin-agreement — "every tool version named in .mcp.json agrees with mise.toml's pin — the second place a pin is written cannot drift from the first".

Chosen on data: 107 lines of bash, 10 cases, 108 lines of bats — the smallest of the structured-document gates. Both inputs are formats Fact::Document already parses (CLOUD-772: TOML/YAML/JSON/JSON5), and the predicate is agreement between two parsed trees, which is what Rego is for. No git, no spawn, no stdin.

It also carries no #MUTANT directive, so it is one of CLOUD-480's undeclared gates. Migrating it should add a declared mutation — coverage improves as a side effect rather than degrading.

Rejected as pilot: no-docs-tree, which an earlier plan named. It is not a mise-tasks/ program at all — it is a batten.toml rule in the hk gate. Recorded because the name was carried in prose across three documents before anyone checked the tree.

Sequencing, and the one trap

  1. Settle the 50. Determine what a git-fact gate needs on the tree surface. Until then "79 gates migrate" is an estimate, not a plan.
  2. CLOUD-835 lands — the destination for the 1,570 bats cases.
  3. The pilot, end to end, one gate: Rego module, test_ rules, delete the task and its suite, MUTANT declared. The pilot converts the estimate into a measured cost per gate. Do not batch before it.
  4. Waves by bucket, cheapest first.

The trap, and it is new as of today. CLOUD-807 landed retires_with, so a suite may now be deleted exactly when its declared subject dies. That is correct and it was the precondition for retiring anything — but it makes migrating without CLOUD-835 worse, not better. The ratchet will admit deleting a suite whose task died, with nothing asserting the Rego that replaced it. The permit made coverage evaporation quiet. 835 is a hard blocker, not a nicety.


Refinement — Ready

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Source of truth (§1). The tree. The census below is the authority on progress, not a burndown restated anywhere; batten.toml owns which gates are policy rows and mise-tasks/ owns which are still bash. No second list of what has migrated.
  • Computable predicate (§2). The census, re-run at every wave boundary:
ls mise-tasks/ | wc -l ; cat mise-tasks/* | wc -l
grep -l '#MISE description="Gate' mise-tasks/* | wc -l
ls tests/*.bats | wc -l ; cat tests/*.bats | wc -l ; grep -h '^@test' tests/*.bats | wc -l
grep -c 'kind = "policy"' batten.toml

A wave that does not move these down has retired nothing, whatever else it landed. That predicate is the whole point of this row existing: today's +791 was invisible because nothing computed it.

  • Effect (§3). read — migration moves predicates between surfaces; no verb is added and no rule kind's Authority changes.
  • Generated artifacts (§4). schema/batten.schema.json only if a row key changes; the per-wave batten.toml rows are authored. derived-check and schema-check gate both.
  • Output & exit (§5). Unchanged — a migrated gate keeps its exit contract, and a Rego predicate reports pointer-only by construction. A migrated gate's refusal text must still name its remedy (CLOUD-437); a msg that lost the remedy in translation is a regression the bats case would not catch.
  • Commit / bump (§6). refactor per wave — no bump. The answers are identical by construction; a wave that changes a verdict is not a migration.
  • Test obligation (§7). Per wave, and the pilot establishes the shape: (a) every case in the retired suite has a test_ rule that fails when the predicate is wrong; (b) the retired task and its suite are both gone, admitted by retires_with because the subject died; (c) the gate's declared mutation is caught by mise run mutant — for mise-pin-agreement that is a mutation it does not have today; (d) the census moves down by the retired count, asserted rather than eyeballed.
  • Blockers (§8). blockedBy CLOUD-835 — see the trap above; without a test destination the permit lets coverage disappear silently. relatedTo CLOUD-833 (the surface, landed), CLOUD-832 (predicate ids, landed), CLOUD-807 (the permit, landed), CLOUD-312 (the 11 hook bodies — the other half of the retirement, and not this row), CLOUD-480 (the undeclared gates a migration should shrink), CLOUD-772 (the document substrate the tree bucket consumes), CLOUD-839 (the capability dispatch that bought the machinery).

Acceptance

  • The 50 git-fact gates have a stated verdict: migratable on a named fact, or not, with the reason.
  • mise-pin-agreement is a policy row; its task and suite are deleted; mutant catches its declared mutation; the census is down by one gate and ~215 lines.
  • The measured cost per gate from the pilot is recorded here, and the waves are sized from it rather than from the count.
  • Every wave re-runs the census and records the delta.

Found while auditing what the CLOUD-839 fleet landed, by asking the question the capability rows never had to answer: how much bash actually went away.


PRESSURE-TESTED 2026-08-21 — wave 1 cannot dispatch yet, and the reason is a run rather than a reading

CLOUD-835 landed (62719ff, v0.0.100), so the blocker in the trap above is cleared and the 1,570 cases have a destination. Before dispatching a wave on that, the path was walked end to end against the release binary in a throwaway git fixture. It does not hold yet.

What the run showed

Two modules in one enabled bundle. One copied verbatim from policy.rs's own module doc; one written against what rules::tree_document actually builds. A stray.o tracked. Each with a test_ rule in the shape the vendored presets use.

$ batten policy test
policy test: 1 bundle(s), 2 passed, 0 failed        EXIT: 0

$ batten check
policy/ msrv-must-be-pinned                          EXIT: 2

The doc-shaped module passes its test and gates nothing. input.tree.tracked is documented at policy.rs:143-147 and never emitted — tree_document builds documents and missing, and nothing else. Rego makes the failure silent: iterating an undefined path yields no violations, so a dead gate and a clean tree are byte-identical. The test_ rule passes because with input as lets the author fabricate the very shape the engine cannot produce.

That is CLOUD-845, and it is a hard blocker on this row rather than tidy-up: every wave-1 agent starts from that doc, and combined with retires_with the failure mode is green tests, silent gate, deleted bash task that used to work. Ten gates migrated that way would show the census going down while enforcing nothing — the exact number this row exists to make honest.

And wave 1 is smaller than the bucket count suggests

The 20-odd tree gates were re-read by what they open, not by what they invoke. Fact::Document parses TOML, YAML, JSON, JSON5 — and Pkl, declarable-never-parsed.

reads gates migratable
structured config only 8 yes
markdown 4 no
.bats / .rs / .pkl text 5 no
no file literals 3 partly — needs the tracked list

That is CLOUD-846. Wave 1 is 8, not 22. The pilot mise-pin-agreement is in the 8 and is unaffected — both its inputs are parsed formats — so the pilot choice above stands.


BUNDLE W0 — the unblocker. Dispatch-ready now.

Both rows are unblocked, both are rules.rs / facts.rs / policy.rs / schema — one file domain, so one agent, one branch, one draft PR, per CLOUD-839's sizing. Nothing else in the campaign can start until it lands.

Superseded 2026-08-21 by the six-bundle dispatch at the foot of this row. W0's chain grew from two rows to five once the acquisition boundary was traced (CLOUD-849/850/851); it is now bundle A there. The prompt below is still accurate for the 845→846 half and is kept because bundle A's prompt builds on it.

# Chain File domain PR shape
W0 unblock-migration CLOUD-845 → CLOUD-846 rules.rs (tree_document), policy.rs (module doc + policy test), facts.rs, git.rs (list_tree), schema/* 1 PR

845 first: it fixes the input the doc promises and closes the false-green class. 846 then adds the lines fact on top of a tree_document that is already correct, and its §5 assertion (a finding may see a line, never carry one) is easier to state once 845's input-shape check exists.

W0 unlocks wave 1 at 8 gates. It does not unlock the other 12: those wait on 846's lines fact landing and being demonstrated, which is 846's own acceptance (d).

Dispatch prompt — one paste, self-contained

You are bundle W0 of the CLOUD-843 bash-retirement campaign in the Batten repo. Read
CLOUD-843 first: it carries the census, the bucket classification and the pilot choice.
Nothing else in the campaign can start until your PR lands.

YOUR CHAIN - one branch, one draft PR, landed in this order:
  CLOUD-845 -> CLOUD-846

CLOUD-845 first. There is a REPRODUCTION on that row - run it before you change anything,
and keep it as the test. Two modules in one bundle, one copied from policy.rs's module
doc and one written against what rules::tree_document actually builds: `batten policy
test` reports 2 passed, exit 0, while `batten check` reports only one predicate. The
doc-shaped gate is dead and its test is green.

Three parts, and the third is the one worth having:
- Emit `input.tree.tracked`. Do not just delete the doc example - a tracked-path list is
  what a whole class of these gates needs. `git::list_tree` already exists at git.rs:784
  and CLOUD-833 already uses it for bundle membership under --config-from. Bound it by
  declaration the way `documents` is bounded; an ambient walk would make the `read`
  classification a lie by degrees.
- Make policy.rs's module doc true, and assert it: every field its examples reference
  exists in what tree_document emits. Same shape spawn_census.rs:216 uses against
  clippy.toml. This defect is CLOUD-589's class recurring in the file that landed
  CLOUD-831, which was filed for exactly it - so an assertion, not a careful edit.
- `batten policy test` refuses a `with input as` naming a key the engine cannot produce,
  at exit 1 (config fault, not a policy verdict). This closes the CLASS. Without it every
  field added to the input document reopens the same hole. CLOUD-834 is making the
  document's keys the Fact variants asserted by exhaustive match - validate against that
  same table, do not build a second list.

Then CLOUD-846 on the same branch: a lines fact, `input.tree.lines[<path>]`, so a module
can decide over a .bats or .md file. Lines rather than raw text, and the reason is rule 4
rather than convenience - a module may SEE a line, a finding may never CARRY one. Assert
that; it is the clause with teeth and the one that keeps pointer-only structural. A
declared path the tree lacks is could-not-look, never an empty array. Acceptance (d) is a
demonstration, not a claim: migrate one of the four markdown gates as proof.

CROSS-BUNDLE: you are the only branch in flight on this campaign. CLOUD-834 is In Progress
in the CLOUD-839 fleet and also touches the policy input document - it projects the Fact
variants into it. Coordinate through that row rather than racing it: if 834 lands first,
rebase and validate against the table it built.

WORKFLOW CONTRACT (AGENTS.md is authoritative; this is the summary):
- Claim by hand BEFORE writing code: `mise run claim-check`, and assign yourself. The
  automation fires on the PR event, the end of the work, so waiting for it reserves nothing.
- `git fetch origin main`, short-lived branch, never author on main.
- Commit early and often. You are pre-authorized to commit and push without asking.
- Run the full `mise run verify` after EVERY commit. Local execution is free; a CI run is
  metered and the landing lease is fleet-wide.
- Open the PR as a DRAFT immediately (`gh pr create --draft`). CI does not run on drafts.
- When the chain is complete: `mise run linear-check`, then `mise run land` backgrounded.
  Do NOT ready by hand - land readies after its push. Do NOT wrap land in bespoke retry or
  pre-check logic; main advancing under you is that loop working.
- Background anything that can exceed ~2 minutes; a foreground command is killed at ~2 min.
- Move the Linear row as you move the work. Carry the lifecycle to landed-and-verified
  without stopping to report and wait.

Wave 1, after W0 lands

Eight structured-config gates, pilot first: mise-pin-agreement end to end — module, test_ rules, task and suite deleted, a #MUTANT directive declared where it has none today. The pilot converts the estimate into a measured cost per gate, and the waves are sized from that number rather than from the count. Do not batch before it.

A second thing the fixture run turned up, recorded here rather than filed because it is a one-line observation and its home is this campaign's tooling: the protected-path gate matches batten.toml by basename, so it refused writes to a fixture's batten.toml in a temp directory outside the repository — and the advertised BATTEN_GH_GUARD_BYPASS=1 did not take as an inline environment assignment. Every wave-1 agent will hand-build such a fixture. Whoever hits it should file it rather than work around it silently.


DISPATCH 2026-08-21 — six bundles, and the one lever that decides wall-clock

Gates per PR, not agents

The fleet-wide landing lease charges per land, not per gate — one branch spends CI at a time, ci p95 ≈ 701s.

batching lease acquisitions for 82 gates pure landing time
one gate per PR 82 ~20 h
one wave per PR 6 ~1.5 h

That 13× is the whole answer to "fastest", and every other choice is noise beside it. Migration is embarrassingly parallel per gate, which makes one-agent-per-gate the tempting and slowest schedule. Fan out the authoring, serialize the landing. Past ~8 concurrent PRs each extra worker adds landing time (every land forces every other branch to rebase) without removing work time, so do not dispatch 30.

Two tracks, run concurrently

The objective is 82 gate tasks and 11 hook bodies (CLOUD-312). They share almost nothing.

Track 2 has a free start: run-shape-guard is 630 lines, opens exactly one file (mise.toml), and is otherwise pure string analysis of command — which the envelope already carries. It needs no Document fact, so it is migratable now, before any capability lands. Earliest census movement available, one land. Its last two families need CLOUD-613, which is Backlog with no Ready block.

The bundles

# Bundle Rows, in order File domain Why together
A Acquisition — the long pole, gates all of track 1 CLOUD-849 → 845 → 850 → 846 → 851 rules.rs, facts.rs, policy.rs, schema/ A strict chain on one function; splitting means agents rebasing onto each other's edits to tree_document
B Ready-block gate family CLOUD-852 → 842 → 595 → 826 → 751 mise-tasks/ready-lint, tests/ready-lint.bats Five rows, one 35-line §6 block. Any split is a guaranteed conflict for zero parallelism
C Board-gate wiring CLOUD-825 the seven board gates + their invokers Disjoint from B; released is fed /dev/null and three gates have no invoker
D Hook surface CLOUD-461 → 525 hook.rs, lib.rs, doctor The two capabilities gating contract-drift and stop-guard retirement — the hook half
E Envelope content fact CLOUD-758 hook.rs, facts.rs Prospective Write/Edit content; the hook bodies reading tool_input need it. Folds into D if the fleet is cut to five
F Instruments + base ref CLOUD-844, CLOUD-720 .claude/rules/, resolve.rs Two small independents

CLOUD-852 has landed (PR #625, b405ca8) — bundle B starts at 842.

B is the highest-leverage non-obvious bundle. It is not on the objective's critical path, it is on the throughput path: every row of every later wave passes ready-lint, and that gate misread a negation, cannot check the claim it reports checking, refuses a corpus it was changed out from under, and accepts a §7 naming tests that do not exist. 82 migrations run through it is 82 chances to ship a Ready block nobody can trust.

Order

T+0 — five agents. A is 5 deep and lands last; the rest are shallow and clear the lease before A needs it. Agent 5 takes the run-shape-guard partial migration plus C.

T+1 — after A lands. One agent, one PR: the mise-pin-agreement pilot plus all 8 structured-config gates. Not the pilot alone — its purpose is the measured per-gate cost, measured just as well inside a batch of 9, for one lease instead of two.

T+2 — three parallel PRs: the 12 lines-fact gates, the git-fact gates this row's verdict clears, and the remaining hook bodies.

T+3 — the git remainder. ~10 lease acquisitions total.

What gates the schedule, and neither is on the bundle list

  1. A must land first and nothing parallelises it. Every hour it slips slips all of track 1.
  2. CLOUD-480 must land before wave 2, not after. Batching 8–24 gates per PR means one false-green module hides inside a large green diff; mutant at its current coverage cannot see it, and retires_with admits the suite deletion anyway. Batching raises the value of the anti-false-green instrument, so it comes first.
  3. Waves 1–3 have no owner. This row's Acceptance stops after the pilot. Either it grows to carry them or a sibling row does — dispatching a wave against a row that does not claim it is how work lands with nothing recording that it did.

Dispatch is BY HAND, and that is settled

create_session is refused upstream: the session-management tools carry a mandatory-approval flag — "requires explicit approval regardless of permission mode" — and bypassPermissions, an explicit permissions.allow entry and a PreToolUse allow hook are all recorded as tested and failing (#76264, #87548). mem:connector-allowlist-recovery's STOP section carries the mechanism and the tell. Do not spend a turn re-attempting it. A human opens the sessions and pastes the prompts; each bundle's rows carry full Ready blocks, so a prompt need only name the chain, the file domain and the workflow contract.

CLOUD-1199 Eleven gates wrap Batten's OWN verbs — SIX have now retired, and the acceptance clause this row owes (every second-input cell filled, including the "none"s) is answered below: 82.0s left, one member genuinely blocked

THE SECOND-INPUT COLUMN, FILLED (2026-08-31) — this is the artifact this row owes, and it is complete

The acceptance clause reads: "Every member's second-input cell is filled in with a quoted line, including the members whose answer is "none" — an empty cell and an unexamined cell are indistinguishable, and that is what let a bundle ship two blocked members in 2026-08-31's sizing." Every one of the eleven is below. Seconds are from bench/suites/RESULTS.md at origin/main, never from this body.

SIX OF ELEVEN ARE ALREADY GONE, tested with [ -f mise-tasks/<member>.sh ] at head rather than inferred from the PR list. PR #780 landed four; derived-check (CLOUD-1145, SUBSUMED) and man-pages went separately.

member s second input — QUOTED class verdict at head
hooks-wiring-check.sh 51.4 :366 merged_file="$HOME/$merged_rel" out-of-root UNBLOCKED — see below
config-lint.sh 23.5 :174 trailers=$(git log --format='%(trailers:key=Weakens,valueonly)' origin/main..HEAD ...) commit trailers UNBLOCKED — CLOUD-1187 Done
mcp-allow-check.sh 5.9 :327 for candidate in /tmp/mcp-config-cse_*.json; do DISCOVERED set BLOCKED — CLOUD-1251, genuinely open
render/cli.sh 0.9 none — read end to end, 90 lines; every input is argv or cargo run … generate markdown at :75 — UNBLOCKED, with a caveat below
ci-drift.sh 0.3 TWO, not one: :27 repo="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" and :29 if ! payload="$(gh api "repos/$repo/rules/branches/$branch")" forge × 2 UNBLOCKED — CLOUD-1154 Done; both reads are one record
~~derived-check.sh~~ — n/a — RETIRED (CLOUD-1145, SUBSUMED)
~~man-pages.sh~~ — n/a — RETIRED
~~schema-check.sh~~ — none — RETIRED (#780)
~~config-deprecations.sh~~ — none — RETIRED (#780)
~~reference-check.sh~~ — none — RETIRED (#780)
~~skill-check.sh~~ — none (.claude/skills/batten/SKILL.md is tracked and in-root) — RETIRED (#780)
82.0 4 land, 1 blocked

The title's 345.6s / 28.3% is spent. It was measured over a 1219.4s corpus; the corpus is 1097.1s at head and six members have left it. What remains is 82.0s, of which 76.1s is landable today and 5.9s is mcp-allow-check.

hooks-wiring-check and mcp-allow-check look identical by category and are OPPOSITE — which is the whole reason this column exists

Both "read a path under a directory outside the tree". One is declarable and one is not, and only reading the program tells them apart:

  • hooks-wiring-check.sh:125-128 **is a FOUR-ROW CONSTANT. **MERGED="${HOOKS_WIRING_MERGED-claude-code .claude/settings.json / claude-code .claude/settings.local.json / claude-code .claude/launcher-settings.json / gemini-cli .gemini/settings.json}" — a declared roster of (harness, relative path) pairs, each resolved against $HOME at :366. That is exactly [[rule.external]]'s shape: one path under one named root variable, per declared row. Four rows, four declarations, nothing discovered. CLOUD-1167 is Done and answers it completely. It is the largest single-program retirement available in the campaign and it is unblocked.
  • mcp-allow-check.sh:327 is a GLOB over a suffix minted per session. No consumer can enumerate the ids in advance, so no declared row reaches it. CLOUD-1251, real, open.

Declared-vs-discovered is the distinction, not in-root-vs-out-of-root — and this row's own 2026-08-31 correction filed mcp-allow-check under CLOUD-1167, which is the wrong owner for exactly that reason.

render/cli has no second INPUT and does have a second CONSUMER

The same failure shape one level over, and the column as specified does not catch it. render/cli.sh:42-45 states it: "One authority: release-assets-check asks --names rather than spelling it a second time." So retiring the program must carry the --names contract, or the release path breaks on an artifact name nothing emits. It is also not a gate — its #MISE description is Effect: render … the publish-time artifact, never committed — so its successor is the emit markdown verb it already calls at :75, and the retirement is a wrapper collapse with a name contract attached.

One sibling gap, recorded as a pointer rather than re-derived here

Not a member of this family, but the same class and it has no owner: nothing in the tree WRITES a tool-verdict **record. **crates/batten/src/tools.rs reads .git/batten-tools/<tool>‖<version>‖<digest> via record_key/record_path/verdicts; the only writer is crates/batten/tests/tool_verdict_facts.rs. So policy/validator-verdict-clean.rego resolves null, its own test_could_not_look_does_not_fault passes, and the module decides nothing on any real checkout — CLOUD-845's dead gate, shipped. CLOUD-1171 owns the channel and is Done while the condition persists, which is CLOUD-1253's class. Three retirements wait on the producer — renovate-config-validator (32.3s), pkl-check (5.4s), hook-profile-check (0.6s) — and a batten tools record verb is the whole of it: it must digest the input ITSELF, never take a caller-supplied digest, because that is what makes a record stale by construction.


Why

Deriving the blocked classes from the tree rather than from ticket prose returned a family nobody had filed, and it is the largest one: 11 programs, 345.6s of the 1219.4s bats corpus (28.3%).

Every member shells out to batten itself and adjudicates the output:

program line what it runs
derived-check.sh :106 generate completions / generate man, per artifact
man-pages.sh :33 spec --format json
config-lint.sh :130 config lint
schema-check.sh :46 generate schema
config-deprecations.sh :60 config deprecations
mcp-allow-check.sh :228 policy tools
hooks-wiring-check.sh :219 doctor hooks -J
reference-check.sh :80 spec --format json
render/cli.sh :75 the render path
skill-check.sh :94 batten
ci-drift.sh :34 batten

The spellings above are the CURRENT ones, and nine of eleven are scheduled to change (2026-08-30)

Pressure-tested against the imperative VERB OBJECT surface this campaign is moving onto — CLOUD-1184 declares the grammar, CLOUD-1190 moves 233 call sites onto it, CLOUD-1193 retires five singleton nouns into rule ids.

member calls today successor spelling
derived-check.sh generate completions / generate man emit completions / emit man
man-pages.sh, reference-check.sh spec --format json show spec
schema-check.sh generate schema emit schema
render/cli.sh the render path emit markdown
mcp-allow-check.sh policy tools ls tool
config-lint.sh config lint rule id check config (CLOUD-1193)
hooks-wiring-check.sh doctor hooks -J none recorded
config-deprecations.sh config deprecations none recorded
skill-check.sh, ci-drift.sh bare batten unchanged

The disposition is unaffected — no fact is missing, the successor is still the verb. What changes is the characters it is spelled with, so every per-member verdict this row records must be written in the target grammar, not in today's, or it is re-work the moment the wave lands.

**Two members name a leaf nothing has given a home. doctor hooks (surface.rs:1972) and config deprecations (surface.rs:1880) are noun-headed paths that grammar assertion #1 will refuse, and **CLOUD-1193's five-row table lists neither. CLOUD-1160 **names **doctor hooks as its successor too, so a withdrawal of the parent silently withdraws a successor two rows depend on. Naming those destinations is CLOUD-1193's work, not this row's — but this row cannot record a verdict for those two members until it happens.

This row is a PREREQUISITE for the imperative wave, which is why it is Urgent

CLOUD-1190 §1 names mise-tasks/** as its sharp edge: "Every moving call site under mise-tasks/ is checked against shell-retirement before the wave, and any that cannot be edited forces a retirement or a mise.toml task-name indirection — never a quiet edit."

Eight of this row's eleven members ARE those call sites, and the edit is refused with no override. Verified in policy/shell-retirement.rego: all three admitted edits — only_drops_a_retired_reference (:190-228), truncates_a_retired_reference (:269-275) and repoints_at_the_declared_successor (:255-261) — require some gone in delta.deleted with contains(line, gone), i.e. the line must spell a repo-relative path this same delta deletes. A line reading cargo run -p batten -- generate man contains no such path, so it matches none of the three, and V-SHELL-RULE-EDITED declares one route with no bypass_env.

So renaming generate → emit inside derived-check.sh is unlandable, and CLOUD-1190's own escape for that case is retirement — which is this row's disposition. Retiring the eleven removes eight of the ~20 mise-tasks/** call sites the wave is structurally unable to touch. That is a blocks edge, not a relatedTo.

The classification error, and it is the point of this row

A tree-scoped census reads these as blocked, on either of two premises. Both are wrong.

"The engine may not spawn." Refuted already by CLOUD-1171: batten perf ships, effect write, and perf.rs builds two binaries and runs hyperfine. §5's split is check = read and structurally incapable of spawning; enforce/exec are the spawning side. An execution is outside check, not outside the engine.

"The expected artifact is unreadable." The committed side is tracked and readable. The EMITTED side is produced under .gitignored target/, and Fact::Tracked's walk honours .gitignore (facts.rs:420-427, whose own doc warns this is how "a module author writes a predicate about the index and gets an answer about the checkout"). So a Rego module cannot see the emitted side — true, and irrelevant, because a Rego module is the wrong successor.

The successor is the verb these programs already call, plus a compiled-binary tier. That is CLOUD-1176's EXISTING §2 VERB home. Nothing is missing from the fact model. What is missing is the disposition being written down, which is why 345.6s has been sitting in the blocked column.

The proof this shape works, already landed as a decision

CLOUD-1145 is a member of this family and its disposition is SUBSUMED: crates/batten/tests/surface.rs:66 and :222 already assert derived-check's drift predicate over the compiled binary. No fact, no module — a Rust test over the emitted bytes, which can see target/ because it is not a .gitignore-honouring tree walk. The other ten members have the same shape and no row says so.

This row is not a fact family and must not be built as one. Filing it as substrate would add a Fact variant nothing needs — the dead-gate class CLOUD-845 records, one level up.

What each member still owes individually

The disposition is shared; the per-program verdict is not. Three sub-shapes:

  • Pure wrapper — the verb already decides and the shell only adjudicates its exit code (config-lint's :130, mcp-allow-check's :228, hooks-wiring-check's :219). The successor is the verb; the wrapper collapses.
  • Comparator — the shell diffs a committed artifact against emitted bytes (derived-check, schema-check, reference-check). The successor is a compiled-binary test, per CLOUD-1145.
  • Second input — the program ALSO reads something outside this family, and that read is a genuine blocker. config-lint.sh:174 reads a Weakens: commit trailer (CLOUD-1162 unit 15, blocked on the commit-metadata fact); hooks-wiring-check.sh:366 reads $HOME/$merged_rel (CLOUD-1160, blocked on the out-of-root fact).

Clearing one input is not clearing the program — the recorded failure this campaign has now committed three times, most recently on config-lint where :130 was cleared and :174 was never read.

MEASURED 2026-08-31: the second-input count is FOUR, not two — and this row's own table is what misled

A grooming session sized a retirement bundle straight off the "what it runs" table above, took skill-check and ci-drift as **bare **batten and mcp-allow-check as a pure wrapper, and put all three in a landable bundle. Two of the three are blocked, and this row's table is why: it records what each member runs and not what else each member reads, so a reader who trusts it gets exactly the failure the paragraph immediately above warns about — for the fourth and fifth time.

member second input owner
ci-drift.sh :29 — gh api "repos/$repo/rules/branches/$branch", the FORGE CLOUD-1154
mcp-allow-check.sh :327 — iterates /tmp/mcp-config-cse_*.json, OUTSIDE the repo root CLOUD-1167

ci-drift is listed above as running bare batten; it does, at :34, and then reads branch-protection rules off the forge. mcp-allow-check is listed as the archetypal pure wrapper; it is, at :228, and then globs a path no tree fact can project.

So the acceptance clause below is answered with 4, not 2 — config-lint, hooks-wiring-check, ci-drift, mcp-allow-check — and the members verified to have NO second input are config-deprecations, reference-check, schema-check and skill-check (skill-check reads .claude/skills/batten/SKILL.md, which is tracked and in-root, so it is not CLOUD-1167's class). Those four are the ones actually unblocked today, worth 65.7s against bench/suites/RESULTS.md at 144 suites / 1440.9s.

The fix this row owes is structural, not a correction to two cells. Every member needs its second-input column filled in with the same evidence a blocked one carries — the line, quoted — including the members where the answer is "none", because an empty cell and an unexamined cell are indistinguishable and that indistinguishability is what produced this instance.


Refinement — Ready (record the disposition; per-member verdicts, no new fact)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Authority boundary (§1). This row lands no code. It records a disposition and a per-member verdict onto CLOUD-1174's generated table, and amends each member's retirement row to name the EXISTING-VERB home rather than a missing fact. No mise-tasks/ program and no tests/**/*.bats is edited or added. No Fact variant is added — that is the whole finding.
  • Computable predicate (§2). For each of the 11 members: does it read anything other than Batten's own output? If no, its disposition is EXISTING §2 VERB and it is unblocked today. If yes, name the second input and the family that owns it. The verdict is per-program and recorded, never inferred from the family.
  • Deliberately not in scope (§2). Retiring any member — each is its own row. Adding a fact. Changing what any verb decides. Deciding whether target/ should be visible to a tree fact: it should not, and this row's finding is that no member needs it to be.
  • Effect (§3). None — this row is a recorded decision.
  • Output and exit (§5). The artifact is CLOUD-1174's table gaining a per-member disposition column value. Pointer-only, as that table already is.
  • Commit / bump (§6). docs — no bump. Nothing under crates/.
  • Test obligation (§7). No code, so no test tier. The check is that each of the 11 members' retirement rows names its home and its second input, and that no row in this family is left citing "the engine may not spawn" or "the artifact is unreadable" — both refuted above.
  • Blockers (§8). Nothing blocks this row. It blocks CLOUD-1190 — eight members are mise-tasks/** call sites that wave cannot legally edit, per the section above. The two members whose successor verb has no recorded destination wait on CLOUD-1193 naming it; the other nine do not. relatedTo CLOUD-1174 (whose table this fills a column of), CLOUD-1176 (the five homes), CLOUD-1145 (the landed proof of the comparator shape), CLOUD-1171 (which refuted the spawn premise), CLOUD-1162 (a member with a real second input).

Acceptance

  • All 11 members carry a recorded disposition, and the count of members blocked on a missing fact is stated — measured 2026-08-31 it is four: config-lint, hooks-wiring-check, ci-drift (forge) and mcp-allow-check (out-of-root). The difference between "wrapper" and "wrapper with a second input" is what this row exists to make legible.
  • ✅ ANSWERED 2026-08-31 — see the filled table at the top of this body. Every member's second-input cell is filled in with a quoted line, including the members whose answer is "none" — an empty cell and an unexamined cell are indistinguishable, and that is what let a bundle ship two blocked members in 2026-08-31's sizing. The count of members blocked on a missing fact is ONE (mcp-allow-check, CLOUD-1251), not four: config-lint's trailer read, hooks-wiring-check's $HOME roster and ci-drift's two forge reads all had their fact land (CLOUD-1187, CLOUD-1167, CLOUD-1154 — all Done).
  • **No **Fact variant is added by this row, and no member's row still cites a missing fact it does not need.
  • The seconds are restated per member from bench/suites/RESULTS.md rather than from this body.
  • At least one member other than CLOUD-1145 is shown to be unblocked today as a consequence.
  • Every recorded verdict names its successor in the TARGET grammar, not today's spelling — nine of eleven differ, and a verdict written in the current spelling is re-work the wave will have to redo.
  • The two members with no recorded destination (doctor hooks, config deprecations) are named as such, with CLOUD-1193 recorded as owning the gap rather than this row inventing a name.

Found by deriving the blocked classes from the tree instead of from ticket prose, while sizing a "build every missing fact family" bundle. Six families had no row at all; this is the largest, and it turned out not to be a fact family.

CLOUD-418 A new gate is never shown to fail, so a test that cannot discriminate ships as coverage

Why

This repository's most-repeated failure is a claim nothing exercises. land's refusal branch was dead code for months (CLOUD-235). timeout-check's budgets were placeholders that could not fire (CLOUD-352). A shape rule whose pattern was a program could never match and read as coverage (CLOUD-401). Each was caught after the fact.

It happened again, live, while building the landing lease (CLOUD-393). A concurrency test was written for a real race — observe() reading FETCH_HEAD, which is one file per clone while the heartbeat runs beside held/release in the same checkout. The test was green. Then the buggy version was restored to check the test could catch it, and it passed on the broken code too: every process fetches the same lease ref, so a crossed read yields a different generation of the same lease rather than an observably foreign one. The test asserted nothing.

That was found only because someone chose to mutate and re-run — a discipline nothing asks for and nothing checks. The green suite before that check and the green suite after it were indistinguishable.

Root cause. The obligation is stated as "a rule ships with a runnable gate" — a gate that exists. Nothing requires evidence the gate discriminates. A test that passes on both the fixed and the broken code satisfies every rule this repo currently has.

Scope, deliberately narrow. Not mutation testing over the workspace, which is a research project and a large CI bill. The claim here is about mise-tasks/*-check and the guards — the files whose entire purpose is to refuse — where the mutation is usually a one-line inversion and the suite is bats, so a run is seconds.

Refinement — Ready

  • Source of truth (§1). The gate's own suite, run against a deliberately broken copy of the gate. A pass there is the defect; the verdict is an exit code, not a judgement.
  • Mechanism (§3). Undecided between two, and choosing is what Ready needs:
    • Author-side, checked in. Each gate declares one or more mutant cases — a stated one-line corruption and the test name that must go red. A task runs them, and a mutant nothing catches fails. Costs the repo one small fixture per gate; runs locally, off the landing path.
    • Scheduled sweep. A weekly job applies mechanical mutations to mise-tasks/*-check and reports any whose suite stays green. No per-gate authoring, weaker coverage, and it belongs beside branch-age-check in the hygiene sweep, so no new CI minutes.
  • Deliberately not in scope (§2). Mutation coverage of crates/. Different tooling, different cost, different question.
  • Output (§7). Pointer-only: the gate, the mutant, and the test that failed to notice. Never a diff of the mutated source.

Test obligation

The mechanism must catch the case that motivated it: the FETCH_HEAD mutation of mise-tasks/land-lock against tests/land-lock.bats as it stood before the structural assertion replaced it. That pair is a known-good fixture — a real gate, a real mutant, and a real suite that missed it.

Commit / bump (§6): feat(gate) — patch until 0.1.0 regardless of type.

Blockers (§8): none.

Acceptance

  • Every *-check task has at least one mutation its suite is proven to catch.
  • A gate whose suite passes on a broken copy fails.
  • The land-lock/FETCH_HEAD pair is covered as a regression fixture, so the case that motivated this cannot recur silently.

Review in Linear

@coderabbitai

coderabbitai Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 42 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 50c341b3-3690-4eed-a5bf-c7d0f1132383

📥 Commits

Reviewing files that changed from the base of the PR and between 6063049 and c130eac.

📒 Files selected for processing (8)
  • bench/suites/RESULTS.md
  • crates/batten/src/claim.rs
  • crates/batten/src/lib.rs
  • crates/batten/src/lint.rs
  • crates/batten/tests/it/config_lint.rs
  • mise-tasks/config-lint.sh
  • mise.toml
  • tests/config-lint.bats

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

`config lint`'s admission arm reads two sources that must AGREE — a
`Weakens: <smell> <key>` commit trailer, and a groomed Ready block that named the
same pair BEFORE the work started. `mise-tasks/config-lint.sh`'s own header says
so: "They AGREE. Where both are readable, a trailer naming something the groom
did not is refused, and the trailer alone admits nothing."

THE SECOND SOURCE HAS NEVER BEEN WRITTEN. `mise-tasks/claim-check.sh` extracted
those clauses and emitted a `weakens` line per pair; the migration onto `batten
claim` did not carry them, and `claim::mint` writes six line kinds of which none
is that one. So every receipt in every clone is silent, `grep -E '^weakens '`
finds nothing, and the gate takes its `[ -z "$groomed" ]` arm — the one meant for
CI, where no receipt exists at all. A trailer alone admits, which is exactly the
"asserted in the change that performs it" shape house style section 8 refuses.

Measured on this branch's own predecessor: a receipt naming two groomed clauses,
and `config-lint` reporting "no claim receipt here to check it against" over a
receipt sitting in `$GIT_DIR`.

CLOUD-841 filed the lenient-fallback half of this in 2026-08 and its own note
reads "claim-check must keep minting a receipt when the groom named nothing — it
already does". That sentence is why the hole stayed invisible: the half it takes
for granted is the half that went missing.

THE TRACKER NORMALISES THE SPELLING, WHICH THE SHELL'S GRAMMAR COULD NOT SURVIVE.
An author types `**Weakens:** ` and the tracker stores `**Weakens: **`, moving
the trailing space inside the emphasis. The retired regex anchored on
`\*\*Weakens:\*\*[[:space:]]`, so it matched a body typed into a local file and
never one the tracker returned. Both spellings are accepted here, and the first
case pins it — restoring the old grammar verbatim would have re-landed a dead
gate, which is the whole risk of a port that trusts its predecessor.

Five cases, three of them anti-vacuity: a body naming no weakening extracts
nothing, a clause QUOTED mid-sentence is not a declaration (this repository's own
rules files describe the grammar), and two code spans with the wrong joiner are a
sentence rather than a pair.

Refs: CLOUD-1162
Refs: CLOUD-841
…s not two

`config lint` computes the base-ref smells and stopped there, so the whole
admission decision lived in `mise-tasks/config-lint.sh` — which is why retiring
that program needs this half first. It now runs in the verb, under exactly the
condition that produces a base-ref smell in the first place: a `--config-from`
base ref. An unarmed run is byte-identical, output and exit code, which matters
because the unarmed form is what a consumer without a trunk convention runs.

THREE STATES, AND TWO OF THEM WERE ONE (CLOUD-841). The shell read the groom with
`grep -E '^weakens ' "$claim"`, so a receipt that EXISTS and names no weakening
produced the same empty string as no receipt at all, and the admission arm keyed
on that emptiness. The message it printed — "no claim receipt here to check it
against" — is CI's honest state and was being printed over a receipt sitting in
`$GIT_DIR`. That is evidence of absence read as absence of evidence, and it is
the lenient direction: a trailer minted inside the change that performs the
weakening admitted it, which is the shape house style section 8 refuses.

  Groom::Unreadable  no receipt      -> the trailer alone admits (CI's half)
  Groom::Read(named) names this pair -> admitted, both sources agree
  Groom::Read(other) looked, did not -> REFUSED, whatever the trailer says

The third row is the one that is green today and goes red here.

WHAT ADMITS IS UNFORGEABLE BY THE AUTHOR AT PR TIME, which is the property the
whole design rests on. There is no flag, no environment variable and no config
key: the two sources are written at different moments, and the earlier one is
minted by `claim` before the work starts. Anything settable in the change under
review would be a self-issued permit.

The trailer read goes through git's own trailer parse rather than a scan of the
message body, so a line quoted mid-message cannot pose as a declaration and this
cannot disagree with what `attribution` reports about the same commit. An empty
`Weakens:` trailer is dropped rather than matched: it satisfies every "did the
author declare something" reading and names nothing, which is
V-WEAKENS-DECLARES-NOTHING's own class.

Pointer-only on both halves: the receipt carries the smell id and the config key,
the report carries those plus a verdict token. The clause's prose stays in the
groomed body, where a reviewer reads it in context.

Seven cases. Four decide the matrix; three are the mirrors that stop a
one-directional gate passing as coverage — a groom with no trailer admits
nothing, a groom naming a DIFFERENT pair refuses this one, and one unadmitted
smell leaves its neighbours admitted rather than collapsing the set.

Refs: CLOUD-1162
Refs: CLOUD-841
Unit 15 of CLOUD-1162's partition. `mise-tasks/config-lint.sh` spawned
`batten config lint` and adjudicated the answer, so the successor was never a new
verb — it is the wrapper collapsing into the thing it already called. Two deleted
paths, 23.5s of the bats corpus, and almost all of it was `cargo run` start-up
paid once per case, which is why the port IS the performance fix.

THE TASK NAME SURVIVES, and it has to. `verify`'s own body, `.github/workflows/ci.yml`
and `hk.pkl` all name it, and `ci-local-parity` holds the workflow and `verify` to
running the same task — so a rename would need a workflow edit to land a workflow
edit. Every call site is byte-identical, which is what keeps this retirement at
exactly one program.

The replacement body is ONE LINE, deliberately: `inline-task-bodies-not-growing`
counts `run = '''` and its `"""` twin, and a multi-line body is where a predicate
hides. A single-line dispatch carries no predicate — every decision is the
verb's, and the `${VAR:+...}` is argv assembly. No ratchet moves and no waiver is
owed, which is the disposition its own `no_fix_reason` asks for rather than the
one this campaign reached for last time.

THE LEDGER, AND THREE OF ITS ARMS ARE NOT `carried:`. Seventeen cases carried,
six subsumed into gates that already own them, and:

  changed:   "with no claim receipt the trailer alone admits" — the case still
             holds for an ABSENT receipt, which is what it names, but the shell
             reached that arm for a SILENT one too. The successor tells them
             apart, so the case is narrower than the behaviour it pinned.
  withdrawn: three cases whose subject this deletes — a grep over the program's
             own bytes for a BYPASS branch, a refusal text the shell composed,
             and a header claim reconciled against the workflow tree. A program
             that does not exist cannot carry a hatch or make a claim.

Four compiled-binary cases, and the centre one is the pair a `with input as`
equivalent cannot reach: byte-identical trailer, byte-identical config, a receipt
that is absent versus one that is silent, opposite verdicts. That is the engine
being shown to BUILD the two inputs — a receipt found under the branch's own
name, a trailer read out of a real commit — rather than a fixture asserting the
shape the reader may be unable to produce.

The gate leaves $MUTANT_GATES with its suite, per the two-shapes rule.

Refs: CLOUD-1162
Refs: CLOUD-841
Refs: CLOUD-1199
Two defects in the retirement directly before this one, both caught by `verify`
and both mine.

THE COMMENT TRIPPED THE RULE IT WAS EXPLAINING. The new `config-lint` task body
is one line specifically to stay off `inline-task-bodies-not-growing`, and the
comment saying so quoted the two spellings it counts. The rule is a substring
ratchet over `mise.toml` and strips no comments, so the note incremented the
count it was describing — 32 to 33, suppressed only because the previous change's
waiver is unnarrowed and happened to cover it.

That suppression is the part worth naming: the waiver's own reason says it is
"expected to lapse unused" once its base moves, and a second increase quietly
riding it would have made that false while reading as true. The spellings are
described rather than quoted now, the count is back to 32, and the waiver lapses
as written.

`tests/config-lint.bats`'s own retired case for the sibling rule had this exact
shape and said so — "prose may discuss a bypass; code may not have one" — with
comments stripped first. This ratchet has no such strip, which is a real
difference between the two and not one I am asserting should change here.

ONE CASE, TWO ARMS. The ledger recorded "with no claim receipt the trailer alone
admits" as both `carried:` and `changed:`. It is `changed:`: the case still holds
for a receipt that is ABSENT, which is what its name says, but the shell reached
that arm for a receipt that was merely SILENT too, and the successor tells those
apart. The duplicate is removed and `bats-tests-not-deleted` is clean.

Refs: CLOUD-1162
`bench/suites/RESULTS.md` is generated, and two bundles retiring suites in
parallel conflict on it every lap. Hand-merging two machine-written cost tables
would publish a number no measurement produced, so the conflict is resolved by
taking the trunk's and re-deriving from a fresh `test:bats` report over the
merged tree.

127 suite(s), 832.5s serial, 2289/2289 cases green.

Read the COUNT rather than the delta: most of the suites that have left since
this branch's last reading are other bundles' retirements landing alongside it,
and the serial total moves with whichever machine took the measurement.

Refs: CLOUD-1162
@wenzowski
wenzowski marked this pull request as ready for review September 1, 2026 16:01
@wenzowski
wenzowski force-pushed the claude/cloud-843-bundle-b-dkfi06 branch from 5185ad2 to c130eac Compare September 1, 2026 16:01
@sonarqubecloud

sonarqubecloud Bot commented Sep 1, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit c130eac into main Sep 1, 2026
10 of 11 checks passed
@wenzowski
wenzowski deleted the claude/cloud-843-bundle-b-dkfi06 branch September 1, 2026 16:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant