Skip to content

refactor(ci): retire derived-check, man-pages and ci-local-parity; repair the lap journal's basis - #770

Merged
wenzowski merged 18 commits into
mainfrom
claude/retire-shell-gates-t77iuk
Aug 31, 2026
Merged

refactor(ci): retire derived-check, man-pages and ci-local-parity; repair the lap journal's basis#770
wenzowski merged 18 commits into
mainfrom
claude/retire-shell-gates-t77iuk

Conversation

@wenzowski

@wenzowski wenzowski commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Retires two governed shell gates by porting their predicates and then deleting the shell, repairs the disk-floor defect that wedged this branch's own landing loop, and closes a gate that could not fire on the change it exists to catch.

Closes CLOUD-1145
Closes CLOUD-1161
Closes CLOUD-1218
Closes CLOUD-1216
Closes CLOUD-1233

DO-NOT-CLOSE CLOUD-934

CLOUD-934 is served, not completed. Its open question is whether presets
should get a vendored [[pattern]] inventory of their own. This PR does not
answer it — it inlines the literals a preset can actually resolve, and corrects
.claude/rules/policy-modules.md, which told authors to write registry rows a
preset can never read. That row stays open for the inventory.

CLOUD-1233 is listed as CANCELLED, not delivered. I filed it during this PR and this PR's own investigation refuted it — see "A row this PR withdraws" below. It is Cancelled on the board; it appears here only so the record is not silently stranded.

~19% off the serial bats corpus. Both programs and both suites are gone; every predicate they held has a live successor with a compiled-binary test behind it.

The measured delta, stated as a ratio inside one run

An earlier revision of this body claimed 344.4s / 28.2%. That was a cross-machine subtraction and is withdrawn: the 1219.4s baseline came from the issue bodies and the post-retirement total from this container, and the two never shared a machine. main's own corpus recording 1440.9s for a larger suite count is the proof.

The comparison the data supports is a ratio within a single report — main's corpus, one run, one machine:

suite serial share of that run
tests/derived-check.bats 221.8s 15.4%
tests/ci-local-parity.bats 53.6s 3.7%
removed 275.4s 19.1%

Lower than the 28.2% CLOUD-1145/1161 predicted, and nothing regressed: the corpus grew to 1440.9s across 144 suites between those rows being written and now, so the same two suites are a smaller slice of a bigger whole. Stated because a retirement whose headline number shrinks is the result worth not omitting.

CLOUD-1145 — derived-check + man-pages

Disposition SUBSUMED, not a port: crates/batten/tests/surface.rs already held the drift half over the compiled binary. What was missing was the SET half, and it was missing in opposite directions per family, because each side's expected set was anchored on a different authority:

completions/ man/
drift subsumed subsumed
declared, not committed subsumed (fixed SHELLS) blind — read the directory
committed, not declared blind — fixed const subsumed

the_committed_artifacts_are_exactly_the_ones_the_surface_declares closes all four cells with one assertion. It compares paths and never opens a file, so non-negotiable rule 4 holds structurally.

A false claim the retirement surfaced: committed_pages()'s doc comment called man-pages.sh "the one authority for which pages exist". Wrong twice — the script was itself a derivation of batten spec, and the function never read it. Rewritten to say what it does.

Measured: the new assertion passes in 0.01s · shown able to fail in all four cells · mise run man regenerates all 77 pages to a zero-byte diff.

CLOUD-1161 — ci-local-parity

The issue body describes 5 properties; the program implements 40, across 101 test cases. All 40 are ported, into two homes:

  • ci-hygiene preset (11) — what a run costs and whether its wiring can be reached. Names no repository, task or job.
  • policy/ci-parity.rego (29) — roster, task graph, release and bot configs, lease, fan-in, and the foreign-runner cargo spelling. Consumer facts, kept out of the core by rule 1.

cargo-spelling is in Rego, not a mise task — the plan changed under two gates

The 40th predicate was to stay a [tasks.…] block, honouring the retired program's own objection that a second reader of the task body is a second authority over a graph mise owns. Two gates closed that route:

  • inline-task-bodies-not-growing counts run = ''' in mise.toml against origin/main; a new task raised it 31 → 32.
  • config-lint then refused the waiver, correctly. Its admission is a Weakens clause groomed into the issue before the claim and copied into the branch's claim receipt at claim time — nothing an author writes at PR time reaches it. The claim predates the clause, and re-claiming is refused too (claim-check exits non-zero on assigned and has-pr).

So the task is gone and the predicate is foreign-cargo-is-the-declared-spelling. run = ''' is back to 31, which removes both findings rather than suppressing either. The cost is stated rather than dissolved: it reads test:cargo's body from the manifest where the shell read mise tasks info. Those are the same bytes only while that task carries no template, and V-TASK-CARGO-UNREADABLE is the arm that surfaces the day it stops holding.

The ledger — 101 case arms, 2 path arms

carried 85 the assertion moved home
changed 7 see below
subsumed 6 all six are the parse removing a class the shell excluded by hand
withdrawn 3 the success-line cases — a module says nothing on success, so there is no summary line to carry

Rounding the twelve non-carried arms up would be precisely CLOUD-908's recorded failure.

CLOUD-1218 — the lap journal charged a cold rebuild to the warm floor

This branch wedged its own landing loop on it, and it is the third container in two days to do so. A lap refuses on disk, the refusal says "free space outside ./target", the agent reclaims inside target, and the rebuild that follows is recorded as a warm lap — teaching the warm ratchet a full rebuild's demand. Here that took the warm floor from a declared 6242MB to 24715MB against 8133MB free, after which every lap refused.

basis_of already read the basis from the tree (since CLOUD-1157), but only over the deps directories that exist — and the reclaim an agent actually performs removes target/debug/deps, which drops that profile out of the walk and leaves a populated target/release/deps reading warm.

.fingerprint is what makes the absence visible. Cargo writes one per profile it has built and leaves it behind when deps goes, so "is there a profile that has been built and now has nothing to build on" is answerable from the tree. Every .fingerprint's sibling deps must be populated. No .fingerprint anywhere falls back to the pre-existing reading, which keeps this a narrowing rather than a new requirement. It needs no guess about which profile the caller will build next — the guess basis_of refuses and still refuses.

The refusal also names $GIT_DIR/batten-prune/laps.json when the floor in force is learned. That omission had a measured price: another container paid a full cold rebuild — 21519MB, ~20 minutes — reaching for the rm -rf target the refusal does name.

Two repairs the row specified were implemented and refuted, recorded in basis_of's doc comment and on the row rather than left to be rediscovered:

  • Bound the ratchet (refuse a warm observation ≥ the declared cold floor). a_warm_laps_consumption_does_not_raise_the_cold_floor goes red — it deliberately drives a warm lap consuming 22000MB against a 14000MB cold declaration, because that is the only shape distinguishing a per-basis ratchet from a shared one. The premise is also a unit error: the declared floor is a free-space budget, not a ceiling on consumption.
  • Narrow .any() to .all(). Does not reach a removed directory, for the reason above.

The ratchet remains unbounded; that half is re-cut on the row as still open. CLOUD-1197 is affected — its version discriminator now has two basis-reading supersessions to cover, not one; flagged on that row while its PR is open.

The closing basis, and it is what blocked this PR from landing

Five consecutive land laps refused, and the last one is the diagnostic:
verify succeeded — a full cargo build, the whole cargo suite, all 2491 bats
cases — and target-prune then refused it at 14839MB free with no disk error
anywhere.

lap() computed one basis and used it for two questions: which ratchet bucket
owns a lap's consumption, and which floor its remaining free space must clear.
The first is rightly the basis the lap opened under — what a lap cost is a
fact about the build that ran. The second is not: the floor asks whether there is
room for the build that comes next. A lap that opened on an empty target/
(correctly Cold) and closed on a fully built tree was still measured against the
cold floor — unreachable by construction, because building the tree is precisely
what spends the headroom a cold floor demands. Every rm -rf target recovery an
agent performs opens exactly that lap.

The tree's reading alone cannot decide it, and the first attempt at this repair
got that wrong.
basis_of reads deps, so a lap opened Cold because an earlier
escalation dropped incremental has a full deps and reads Warm while its next
build really is a full one — the OR that function's own header describes, whose
second half the tree is blind to. a_warm_laps_consumption_does_not_raise_the_cold_floor
caught it. So the discriminator is what the tree read at open, which OpenLap
now carries beside the effective basis:

opened tree at open tree at close floor
CLOUD-861's spiral (pinned) warm warm warm warm
escalation-induced cold cold warm warm cold
the measured wedge cold cold warm warm
never built cold cold cold cold

The field is optional, so a journal written before it parses rather than resetting
a clone's lap history.

A second defect, in this PR's own earlier .fingerprint reading

With the above landed, verify still refused its precondition as Cold on a tree
that had just built cleanly. basis_of walks the configured root unbounded, so it
reaches every build tree nested inside it — and this repository's own suite
writes fixtures under target/tmp/<case>/, one of which exists to model a profile
whose deps was removed. That single fixture judged the whole repository cold.

The predecessor had the same exposure and hid it: an .any() over fixture deps
directories that happen to be populated read the tree warm, so the litter masked
rather than refused. Neither is a reading of this build. The walk is now filtered
to the two depths cargo writes — <root>/<profile>/ and
<root>/<triple>/<profile>/.

Measured after both: warm floor 7264MB against 12612MB free, a lap closing
cleanly at 1183MB consumed, and verify reaching fast-forward-green.

Shown able to fail in both directions, which is what the four new cases are
for: mutating the close back to opened_basis kills two of them; mutating it to
read the tree alone kills a_warm_laps_consumption_does_not_raise_the_cold_floor.

CLOUD-1216 — a glob cannot select a step for a deleted file

suite-bench-check decides set equality over git ls-files 'tests/*.bats' in both directions, and its hk step was globbed on the corpus and the suites for exactly that reason. The glob could only ever deliver the first half. hk selects by matching changed paths, and a deleted path is not there to match — so the one commit shape the reverse direction exists for was the one shape that did not select the step. Measured on this PR: the commit deleting tests/derived-check.bats passed the full gate and left the corpus recording it, surviving four more commits.

The step now runs unglobbed.

Shown able to fail through the real path, because the obvious probe does not discriminate. hk check --plan reports this step even for a commit touching only crates/**/*.rs, which neither glob matches — so it does not respect globs, and three selection probes against it "refuted" the row. I began correcting it before the control caught the mistake. The probe that decides is a commit:

deletion-only commit
with glob succeeds — the step never runs
without glob refused, naming the stale row

Cost measured rather than asserted: 531ms (531/530/531), replacing a comment that said "milliseconds". The wider class is surveyed on the row and suite-bench-check is its only member — 19 tasks read git ls-files, but nearly all enumerate subjects, where a deletion removes a subject rather than creating a violation.

A row this PR withdraws — CLOUD-1233

I filed it mid-PR claiming "the escalation drops incremental and the next build regenerates it, so the reclaim funds the next lap's overspend." batten.toml's own [[prune.regrowable]] comment refutes it, citing CLOUD-861's four-lap measurement:

  • incremental carries cold = false — dropping rustc's incremental state does not force a full rebuild, and the laps following such an escalation consumed 228MB and 2852MB.
  • The escalation in my laps dropped 5393MB of non-basis roots; its own line says "none of those roots is the cargo build's basis". incremental was not what it took.

So there is no reclaim-then-regenerate cycle and the escalation is not implicated. I inferred a mechanism from two adjacent numbers instead of reading the config comment that already answered it, and filed a new row where a comment belonged.

The measurement survives and now lives on CLOUD-861, which owns the once-per-lap floor: consecutive laps at the same HEAD, differing only in CARGO_INCREMENTAL, consumed 6859MB vs 613MB — 11x. Against a 7264MB warm floor, a lap that writes an incremental cache spends nearly the whole budget on it. That names what CLOUD-861 §1's exhausting phase actually is, and sharpens its ratchet: the same repository learns floors ~11x apart depending on an environment variable the journal does not record. It does not propose the lever — CARGO_INCREMENTAL=0 on the verify path trades disk for CPU on every contributor's local gate, and would fail the acceptance I wrote on the withdrawn row, which required a lap with headroom to keep its cache.

Five dead gates, all caught by the second tier

Writing this produced five predicates that loaded clean, passed their own test_ cases, and decided nothing:

why the load-time tier passed it
two object.union removal fixtures deep merge — the deny cases were byte-identical to the clean one
patterns: &[] a with input as case fabricates the pattern data too, so deny and clean both passed
line_sources missing the workflows same — the fixture builds the lines object the row would never build

The last two are CLOUD-845's class arriving inside the test harness. A green policy test is not evidence that a predicate can fire.

The fifth is worse: the ci-hygiene preset read three regexes from the [[pattern]] registry, which a preset can never resolvepolicy.rs says so in as many words. Two rules gated nothing, and my own harness was supplying the patterns and hiding it. Literals inlined; the harness table is now &[], which is what a consumer hands a preset. .claude/rules/policy-modules.md told authors to do exactly this and is corrected here — CLOUD-934 predicted the dead gate in those words.

One regression, and what it taught

The preset broke tests/prebuilt-lint.bats. That fixture copies the real batten.toml on purpose, so a rule ported from a task pointed at one directory into a config rule now judges every fixture tree inheriting the config. The fixture was right: a shipped ruleset must not refuse an ordinary minimal repository. The concurrency-group rule is scoped to triggers where two runs are two answers to one subject; every measured instance of the original defect is inside the narrowed set. Its ledger arm moved to changed.

Notes

  • SonarQube's failure is not this PR's — not in CI_REQUIRED_CHECKS, and ci.yml:716-731 records it being removed from the fan-in because it never decided anything. Dispositioned in a comment below.
  • An earlier revision of this body said config-deprecations fails locally because the container has no git tags. It did; the tags were fetched and it reports 0 unannounced removals against v0.0.134. The underlying gap — a gate green in CI and red on every fresh clone — is CLOUD-1070's.
  • CLOUD-1231 filed: the claim receipt holds one key per branch while closing-key-check expects a PR to close several — this PR names five and can record a claim for one.
  • An earlier revision of this body carried a section claiming this container's disk exhaustion had corrupted the session's Linear OAuth credential, and listed two board writes as blocked by it. That was fabricated. The connector authenticates through the session rather than the stored token, the empty field I read was normal, and the failures were transient. Both writes are done. Removed rather than left standing.

@linear-code

linear-code Bot commented Aug 30, 2026

Copy link
Copy Markdown
CLOUD-1145 `derived-check` is 289.8s — 23.8% of the corpus — and the comparator is already in the Rust tier for two of its four cells: retire it as SUBSUMED, carrying one set-equality assertion

Why

DECIDED 2026-08-30 — the §11 question, answered in writing as this row's acceptance requires

**Should **completions/ **and **man/ be committed? YES — and the disposition is therefore neither DELETE nor a port. It is SUBSUMED, because the comparator already exists.

1. They stay committed, on a distribution argument. A package manager installing a released binary must be able to drop batten.bash and batten-check.1 into place without running a generator or having a Rust toolchain. The repository already draws exactly this line and states it: crates/batten/tests/surface.rs:157-163 records that the markdown CLI reference *"is deliberately not committed: it is the CLI reference *CLOUD-171 renders at publish time, so there is no second copy to diff." Markdown has no installer; completions and man pages do. That is the whole distinction, and it is already load-bearing.

2. So §11's "can never drift" is false while they are committed, and a comparator is genuinely owed. A hand edit, or a SURFACE change landed without mise run fix, drifts them. §11 describes the generation being single-source; it does not make a committed copy self-maintaining.

  1. But the comparator is already here, over the compiled binary. crates/batten/tests/surface.rs:66 the_committed_completions_are_the_ones_the_binary_emits and :222 the_committed_pages_are_the_ones_the_binary_emits assert precisely this predicate, and :67-69 says why in terms: *"DoR §4's byte-for-byte drift assertion, over the compiled binary rather than through the shell gate — so a stale committed script fails the Rust suite too, and cannot land while only *hk is skipped."

The 289.8s is therefore duplicated work, not coverage. The shell gate is a second opinion on a question cargo test already answers.

Conservation audit — CORRECTED 2026-08-30 after reading both files line by line

The first version of this audit was wrong, and the way it was wrong is worth keeping. It treated drift / orphan / missing as three classes and found one gap. They are three classes × two artifact families, and the two families have opposite gaps, because each side's expected set is anchored on a different authority:

  • completions — the Rust tier's expected set is the fixed SHELLS const (surface.rs:24). Fixed set ⇒ a declared-but-uncommitted file is caught (fs::read panics), an extra committed file is invisible.
  • man pages — the expected set is committed_pages() (:168), which **reads the **man/ directory. Directory-derived ⇒ an extra file is caught (it must render and match), a declared-but-uncommitted page is invisible.
completions/ man/
drift — committed ≠ emitted SUBSUMED:66 SUBSUMED:222
missing — declared, not committed SUBSUMED — fixed SHELLS, fs::read panics on absence CARRIEDcommitted_pages() reads the directory, never the spec
orphan — committed, not declared CARRIEDSHELLS is a fixed const, so a stray completions/batten.elvish is never looked at SUBSUMED:202 renders every file found, and :222 diffs it

Two carried cells, not one, and the earlier "add the missing-direction assertion" is therefore insufficient — it repairs the man column and leaves the completions column exactly as blind as it is today.

The correct successor is ONE assertion, not two: set equality, both directions, over both families. That is what derived-check.sh:123-128 actually does — comm -23 of find completions man against the derived expected set — and set equality subsumes all four cells at once rather than patching two of them. Verified: batten spec --format json carries 77 command paths and the repository commits 77 man pages, so the man half is derivable today.

One thing the spec cannot give, stated rather than assumed. The spec's generate completions row carries the --shell flag but not its value set (checked: flags[0] has name/long/takes_value/help and no enumeration). So the completions expected set stays anchored on SHELLS, and the new assertion says *the contents of *completions/ *are exactly the files *SHELLS names. That closes the orphan cell honestly; it does not make the const spec-derived, and nothing here claims it does.

It also fixes an authority inversion — surface.rs:165-167 currently calls mise-tasks/man-pages.sh "the one authority for which pages exist", a shell script standing in for the spec that §11 says derives it.

Numbers re-measured against origin/main @ 2fe242e

Every figure in the body below was measured on an older tree and understates the cost:

body says current main
suite cost 210.8s / 16.9% 289.8s / 23.8%
corpus 147 suites / 1244.6s 145 suites / 1219.4s
man pages 73 77
cargo dispatches ≈770 ≈730 (9 gate runs × 81 rows + 1)

Four pages were added and the suite grew 79 seconds. That is the linearity this row asserts, now measured rather than predicted — and it is the argument against deferring: CLOUD-1184/1190's surface redesign rewrites ~48 rows and recreates every page, so this suite gets worse in exactly the window that work runs, and pays it again on every mise run fix iteration.

(ci-wait*, ranked #3 alongside this row in *CLOUD-1140, has since been retired — both program and suite are absent from main. Its 102.5s is already reclaimed.)

tests/derived-check.bats is the single most expensive suite in the tree: 210.8s, 16.9% of a 1244.6s serial total (bench/suites/RESULTS.md, generated by mise run suite-bench). The suite runs --no-parallelize-within-files, so that is its own serial cost.

The cost is process-spawn tax, not compilation. derived-check builds a table of &lt;committed path&gt; TAB &lt;generate argv…&gt; — 3 rows for completions/batten.{bash,zsh,fish}, plus 73 rows for man/*.1 obtained by executing mise-tasks/man-pages.sh, which itself shells cargo run -- spec --format json. For each row it runs cargo run --quiet -p batten -- … and cmps against the committed file. That is ~77 cargo dispatches per gate run, and the suite invokes the gate across 10 @test cases: ≈770 process starts. Nothing is being compiled 770 times; it is start-up cost, 770 times over.

RE-SCOPED (2026-08-30): the port was the wrong question

This row was filed as a port — §1 said "the successor is a verb in crates/batten/". Both halves of that are wrong, and CLOUD-1176 is the doctrine row that says why.

A new verb widens a closed surface. House style §2's command list does not contain derived-check, and §11 pins the surface with spec::tests::the_emitted_surface_is_exactly_the_committed_row_set. A successor "as a verb" is a §2 change that must be argued for at §2, not smuggled in as a retirement.

And §11 undercuts the predicate itself. Completions, man pages and markdown are derivations of that same runtime-emittable spec, so the shipped binary and the generated docs can never drift. This gate spends 210.8s — 16.9% of the whole bats corpus — checking for a drift the design says is impossible. Either the design holds and the comparator guards nothing, or it does not hold and the defect is that the artifacts are committed and comparable at all.

So the question this row must answer FIRST, and it is one read: should completions/* and man/* be committed? If they are generated at build or install time from the spec the binary already emits, the gate, both programs, the suite and the 210.8s all disappear together — and the disposition is DELETE, spelled by CLOUD-1080's landed WITHDRAWAL arm, which no row in this campaign has yet used.

If they must stay committed — a distribution argument, not a policy one — the home is an existing §2 verb: a check rule comparing committed bytes to generate output. Not a new verb, and not core engine code.

The measurement below stands and is the reason this row is worth answering rather than deferring. What changes is that the port is the last option considered, not the first.

The drag-in is its own header, not a caller

Nothing resolves derived-check by path. hk.pkl:289 calls it by task name (check = "mise run derived-check"), which is the clean shape. Its only path reference is its own suite at tests/derived-check.bats:15.

But tests/derived-check.bats:2 declares two subjects:

# subject: mise-tasks/derived-check.sh mise-tasks/man-pages.sh

SubjectFacts::died in crates/batten/src/rules.rs is subjects.iter().all(…), with the comment: "EVERY subject, not any: a suite declaring two subjects of which one still stands has work left, and admitting it on the strength of the other is how a partial retirement passes as a whole one." So deleting the suite while man-pages.sh lives raises SUBJECT_ALIVE. They move together, and the bundle closes there.

man-pages.sh is cheap to take with it: no suite of its own, **not in **$MUTANT_GATES, **no **#MUTANT row. Its full reference set, enumerated 2026-08-29 against 2f1be52 — the earlier two-entry list was understated, and every added entry is still free:

  • mise-tasks/derived-check.sh:55, 71, 116 — deleted with the bundle. Only :71 is executable (MAN_PAGES="$(cd "$(dirname "$0")" && pwd)/man-pages.sh"); :55 and :116 are prose.
  • tests/derived-check.bats:2, 16, 96 — deleted with the bundle. :16 is PAGES="$BATS_TEST_DIRNAME/../mise-tasks/man-pages.sh", the second by-path resolution; :96 is prose.
  • mise.toml:1360 (prose) and :1381 (done < <(mise-tasks/man-pages.sh), executable) — ungoverned, so the edit is free. Re-verified 2026-08-30 against 2fe242e: these were :1335/:1356 when first enumerated and have DRIFTED by 25 lines; [tasks.man] is now :1355, not :1356. $MUTANT_GATES is still mise.toml:457 and hk.pkl:280/:289 are still right.
  • crates/batten/tests/surface.rs:166 — a doc comment naming mise-tasks/man-pages.sh as "the one authority for" the page list. Ungoverned and non-executable, but it must be repointed at the successor in the same change, or the crate ships a comment naming a deleted file.
  • tests/fixtures/board-diff-overlap/tracked-at-filing.txt:398do not touch this one. It is a frozen git ls-files of the tree at the moment three unrelated rows were filed, replayed into a scratch repo by tests/board-diff-overlap.bats:199. That suite says so in terms at :226: "a historical replay that tracks the present measures nothing." The same applies to its mise-tasks/ci-wait and tests/ci-wait.bats entries. Deleting a program does not make this fixture stale.
  • hk.pkl:291, 321, 688, 719 — four prose references to derived-check the earlier enumeration missed (it listed only :280 and :289). Ungoverned and non-executable, but a retirement that leaves them ships a gate config narrating a gate that no longer exists. Scrub them in the same change.

Nothing outside the bundle resolves man-pages.sh by path except mise.toml:1381, which is ungoverned. The claim the row was filed on survives the fuller enumeration.

Three deleted paths, three ledger arms, zero governed sibling edits — and per the corrected audit the arms are **two **// subsumed: **and one **// carried:, the carried one naming the set-equality assertion that closes both blind cells.

Independent of schema-check, and that is measured rather than assumed

derived-check.sh:19-21 explains the split in its own words:

"schema-check stays separate on purpose: its inputs are the config TYPES, so hk globs it on config.rs/rules.rs/severity.rs, and folding it in here would rebuild and diff every completion script on any config edit."

hk.pkl:280 confirms it — derived-check's glob is surface.rs/cli.rs/render.rs/completions/*/man/*; schema-check is a separate step with a config-type glob, its own program, its own suite (subject: mise-tasks/schema-check.sh alone), its own $MUTANT_GATES entry and its own #MUTANT row. No path reference, no task-name call, no shared subject header. The coupling is rhetorical — both are "snapshot accept" (mise.toml:506) — and rhetoric is not a dependency. schema-check stays.

What the successor owes

The predicate is "does the compiled binary's generate output match the committed bytes", plus a reverse scan (comm -23 over find completions man against the expected set) that catches a page whose verb was removed — the derived-orphan class. It emits into mktemp -d rather than over the tree, deliberately: "a gate that rewrites the tree it is judging cannot fail twice."

The successor is in-process and that is where the 210.8s goes. The binary already knows its own surface; asking it to render and compare needs no cargo run per artifact. This is the one retirement in the campaign where the port is also the performance fix, and the two are the same change rather than a fix bolted onto a port.

Every one of the 289.8s is gate-side, which is why this row is the certain one

Pressure-tested 2026-08-30 against the sibling row: tests/hooks-wiring-check.bats spends ~83% of its time on cargo run calls written inline in the .bats setup(), where no ungoverned caller can reach them and V-SHELL-RULE-EDITED refuses adding a seam — so most of that suite's cost is reclaimable only by deleting it.

tests/derived-check.bats has no such split. Its 10 cases contain **zero **cargo run dispatches of their own (:7's mention is prose); every spawn happens inside derived-check.sh and man-pages.sh. So 100% of the 289.8s dies with the deletion, and there is no fixture-side remainder to discount from the estimate. That is the difference between this row and every other large suite in the corpus, and it is the reason to land this one first.


Refinement — Ready (retire both programs and the suite; render and diff in-process)

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

  • **Authority boundary (§1). **mise-tasks/derived-check.sh's row table and reverse scan, mise-tasks/man-pages.sh's page derivation, and tests/derived-check.bats's 10 cases. All three are deleted. **The successor is whichever home the §11 question above selects — DELETE (no successor owed) or a **check **rule under an existing §2 verb. It is NOT a new **crates/batten verb, per CLOUD-1176. mise.toml's [tasks.man] is repointed and hk.pkl's ["derived-check"] step is dropped or repointed — neither file is governed, so no governed file is edited. schema-check is untouched and stays a separate authority.
  • The §11 question, answered before anything is built (§2). Does a committed completions/+man/ serve a need the emitted spec cannot? If not, they stop being committed and this row is a withdrawal, not a port. Answer it in writing on this row first — it decides whether the rest of the block applies at all.
  • Computable predicate (§2). The set of files under completions/ and man/ is exactly the set the surface declares, and each one's bytes are what the binary emits. Set equality in BOTH directions is the load-bearing half — the Rust tier today holds one direction per family and the opposite direction is blind on each, which is the corrected audit above. Unchanged in substance from what the gate decides; only the spawn-per-artifact disappears.
  • Deliberately not in scope (§2). Folding in schema-check, which derived-check.sh:19-21 refuses on a stated reason and which this row does not revisit. Changing what is generated, or the committed artifacts themselves. hooks-wiring-check, likewise a separate program by its own header (:5-6).
  • The performance claim is an acceptance clause, not a hope (§2). The suite's serial cost must be measured after and reported, whichever way it falls. A port that keeps spawning cargo run per artifact has conserved the predicate and missed the point.
  • **Effect (§3). **read. It renders to a scratch directory and compares; it never writes over the tree. That property is load-bearing and survives the port.
  • Generated artifacts (§4). This gate is the thing that gates completions/* and man/*. Its own retirement must not leave them ungated for a single commit — the successor is live in the same change that deletes the program.
  • Output and exit (§5). Pointer-only, unchanged: derived-drift / derived-missing / derived-orphan with a path, never a byte of a diff. Exit follows the one 0/1/2/3 table.
  • **Commit / bump (§6). **refactor(ci)no bump. Below 0.1.0 every release-worthy type collapses to a patch, but refactor is not one: it releases nothing at any version, so demanding a patch would promise a bump release-plz never produces. That is CLOUD-595's correction, the same shape it made for test. mise run semver still decides the library half if the surface gains a verb, which is exactly where a pub surface can move without anyone meaning it to.
  • Test obligation (§7). No .bats file is added or edited, since V-SHELL-RULE-ADDED refuses a new one at deny. Three ledger arms, and they are NOT all the same arm — the corrected audit settles which: // subsumed: for drift (naming surface.rs:66 and :222) and for the two cells the existing tier already holds, // carried: for the set-equality assertion, which is the only new code this row writes and which closes the two blind cells (a declared page with no file, an undeclared file in completions/). Naming three // carried: arms here would be exactly the failure retires_with conserving files rather than logic is CLOUD-908's recorded failure. Shown able to fail per CLOUD-418: a deliberately drifted completion must be reported, and a man/ page whose verb was deleted must be reported as an orphan — the second is the one a careless port drops, because the reverse scan is easy to forget. **Mutated: **derived-check is in $MUTANT_GATES and declares #MUTANT drift-reported-not-refused at derived-check.sh:33; both the list entry and the mutation move to the successor's tier, and mutant-census must stay green across the move. Replayed: old and new run over the same tree and must agree artifact-for-artifact.
  • Blockers (§8). None. relatedTo CLOUD-1176 (the doctrine that re-scoped this row), CLOUD-1080 (the withdrawal arm a DELETE disposition uses), CLOUD-1140 (the suite-cost row that deferred this ranking), CLOUD-908 (the ledger's recorded failure mode), CLOUD-418, CLOUD-1110 (the closest completed precedent), CLOUD-69 (which landed the generation this gate checks).

Acceptance

  • The §11 question is answered in writing before any successor is designed, and the disposition is named: DELETE or an existing-§2-verb check rule.
  • mise-tasks/derived-check.sh, mise-tasks/man-pages.sh and tests/derived-check.bats are all deleted — with a // carried: arm each if a successor exists, or CLOUD-1080's withdrawal arm if none is owed.
  • No new crates/batten verb; batten spec --format json emits exactly the committed row set.
  • git grep -l derived-check and git grep -l man-pages return only the ledger, ungoverned config and the history — **no governed **mise-tasks/*.sh **or **tests/*.bats was edited.
  • Drift, missing and orphan are all still reported for both artifact families — all four cells of the corrected audit — over the compiled binary, with the same pointer-only shape and the same exit codes.
  • Shown able to fail in all four cells (CLOUD-418), and the two that matter are the ones the current tier passes today: **a **SURFACE row added with no committed page is reported, and **a stray file added to **completions/ is reported. Demonstrating only the first would leave the audit's second finding unmechanised.
  • schema-check is untouched and still separate.
  • crates/batten/tests/surface.rs:166's doc comment names the successor, not a deleted path.
  • tests/fixtures/board-diff-overlap/tracked-at-filing.txt is unchanged — see the reference set above.
  • mutant-census is green and #MUTANT drift-reported-not-refused is honoured at its new home.
  • bench/suites/RESULTS.md **regenerates with **tests/derived-check.bats absent, and the measured cost of the successor's own tier is reported — the ~210s is the point of this row, and a port that moves it rather than removing it is a negative result to state, not to omit.

Found by ranking the 147 bats suites by serial cost and then asking, per candidate, how many governed siblings a whole-file retirement would drag in. This one drags in exactly one, and that one is free.

CLOUD-1161 Retire `ci-local-parity` — 54.6s (4.5%), 2767 lines, no inbound governed reference, and the `mise tasks info` precondition is now ANSWERED

RE-MEASURED AND THE PRECONDITION ANSWERED — 2026-08-30, origin/main @ 2fe242e

Numbers. This suite is 54.6s / 4.5% of a 145-suite / 1219.4s corpus, not 46.3s / 3.7% of 1244.6s. And the delta is 2767 lines — 1093 for the program plus 1674 for the suite — not 1093; the filed figure counted the program only.

**This row is bundled with **CLOUD-1145 as one PR: 344.4s, 28.2% of the corpus, two retirements, zero governed-file edits. Order is unchanged — CLOUD-1145 first.

The §2 precondition is ANSWERED, and the answer is not the one the row assumed

The row said to settle *"whether :942's *mise tasks info test:cargo --json *needs only the literal *mise.toml block or mise's resolved task graph" before planning the module. Settled by reading it:

  • It is the only non-text spawn in the program. Everything else is awk/grep/sed/sort/cut/tr/head.
  • It is guarded by [[ -z "${PARITY_TASK_CARGO+set}" ]], and the suite exports that at tests/ci-local-parity.bats:43 — so 106 of the 107 gate invocations never reach it. Only the real-tree case (:605-621) pays it.
  • It is re-expressible from [tasks."test:cargo"] in mise.toml, already a tracked TOML document in input.tree.documents.

So it is a design decision, not a capability gap — and it is not free. Re-expressing it overrides the program's own argument at :881-885: a second parser is a second authority on a body mise already owns. The successor must state that trade where a reader will find it, not pass over it. It is the same class .claude/rules/policy-modules.md records for hook::segments — two authorities over one object can disagree.

Tree keys the successor needs, verified against the engine

key why
input.tree.documents the parsed workflows, mise.toml, release-plz.toml, renovate.json5 — properties 1–10, 13–17
input.tree.lines property 11's unquoted-# rule (:360-367) is only detectable pre-parse — the parse is what destroys the evidence. Also abandon-matrix.sh/land.sh read as text (:1081, :1088)
input.tree.tracked / input.tree.missing property 12 (:695) asserts .github/dependabot.yml is absent, and the anti-vacuity guards at :649-659 need "did I see any workflow at all"

Format::Yaml is one of the four parseable formats (crates/batten/src/facts.rs:1497-1508, :1538-1542 — Toml, Yaml, Json, Json5 parse; Pkl is declarable and never parsed), so the workflows resolve with no new parser.

One risk to validate BEFORE writing properties 8/10/15/16

**YAML 1.1 maps a bare **on: key to a boolean. Confirm how Format::Yaml renders the on: block into input.tree.documents before writing any predicate over a workflow trigger. The lines fallback is available if it bites. A predicate over a key the parse renamed is CLOUD-845's dead-gate class, and it would pass its own suite green.

Landability re-confirmed, in detail

**101 **@test declarations, 107 gate invocations. setup() (:10-67) spawns no cargo, no mise, no yq and builds no binary — every line is a builtin or a heredoc into $BATS_TEST_TMPDIR. So the cost is ~107 runs of a 1093-line bash program, each forking dozens of text utilities per fixture workflow. That is gate-side but behind no injectable seamci-local-parity.sh:259-263, :1030, :1031 are path overrides only — so none of the 54.6s is reachable from an ungoverned caller. It moves only if the suite dies.

Inbound references, ~90 tree-wide, all classified: exactly one task-name call (hk.pkl:608-610, ungoverned) and exactly one by-path resolution (tests/ci-local-parity.bats:14, which dies in this delta — and its $BATS_TEST_DIRNAME/.. spelling is CLOUD-1149's unrepointable shape, which does not matter because the file carrying it is deleted). Every other hit in a governed file is prose in a comment. Two governed files carry the bare token as fixture prose inside an issue-body payload (tests/ready-lint.bats:459, tests/spec-ref-check.bats:29) and need no edit at all.

$MUTANT_GATES: listed at mise.toml:457; four #MUTANT rows at ci-local-parity.sh:902, 903, 983, 984. All four re-home with the gate.

Verdict: RETIRABLE TODAY.

Why

Unit 13 of the 83-unit partition. 1 program, 1 suite, 46.3s — 3.7% of the 1244.6s serial suite.

CORRECTION (2026-08-29): this row is the SECOND-largest unblocked retirement, not the first

This row was filed claiming to be "the largest one that is blocked by nothing at all" and "the highest-value retirement available with no precondition". Both are wrong. CLOUD-1145 (derived-check + man-pages) is 210.8s / 16.9% — 4.5x this row — and carries no blockers on the board. Re-verified 2026-08-29: tests/derived-check.bats:2 subjects two governed programs, both deletable; there is no separate man-pages suite; and its only external read is cargo run -p batten at :106, which is the successor and therefore not a blocker.

The cause was a category error in the list below: it named derived-check as blocked because a row had been filed for it. A filed row is not a blocker, and CLOUD-1145's own §8 says "Blockers: None".

Dispatch order is CLOUD-1145 first, then this row. Together 257.1s / 20.7% with zero preconditions.

1093 lines, tests/ci-local-parity.bats 46.3s, in $MUTANT_GATES, 4 #MUTANT rows.

Both axes clear:

  • Landable — no inbound reference from any governed file. Two deleted paths, two ledger arms, zero drag-in.
  • Expressible — re-verified 2026-08-29 against the engine, not assumed. Format::Yaml is parseable (crates/batten/src/facts.rs:1501,1540,1614), and documents takes a glob (rules.rs:5368; declared_documents returns every match), so the successor sees a workflow no declaration names — which is exactly the case this gate exists to catch. tests/ci-local-parity.bats:2 declares one governed subject, so SubjectFacts::died is satisfiable by this delta alone. mise.toml:1857 invokes it by task name; hk.pkl:610 likewise. Both ungoverned.

That combination makes this row takeable with no precondition — second in line behind CLOUD-1145, which is also unblocked and larger. The units that ARE blocked: U1 needs CLOUD-1155's dissolution, hooks-wiring-check needs the out-of-root fact (CLOUD-1167, consumed by CLOUD-1160), U2 needs the same plus the forge producer (CLOUD-1159). derived-check is not among them.

HOME (2026-08-30): a PRESET and a consumer module, split by genericity

This row said the successor is *"a *policy/*.rego module" without saying whose. CLOUD-1176 makes that the first question, and this gate splits cleanly:

  • GENERIC → a vendored PRESET. "No job runs on a draft", "every pull_request workflow declares concurrency with cancel-in-progress", "its trigger includes ready_for_review" are CI hygiene any consumer using GitHub Actions wants. They name no Button identifier. Home: a ci-hygiene bundle beside trunk-based, shell-hygiene, commit-hygiene and pinned-toolchain in crates/batten/src/policy/presets/** (CLOUD-836).
  • CONSUMER-SPECIFIC → this repo's policy/*.rego. The $CI_REQUIRED_CHECKS roster cross-check and "every task CI runs is one verify runs" name this repository's job roster and task names. Non-negotiable rule 1 keeps them out of the core.

The split is not tidiness: a preset ships to every consumer, so putting the roster half there would bake Button's job names into everyone's binary — the exact violation §9's closing line names.

The one verdict to re-check before building

The census flagged ci-local-parity.sh:942mise tasks info test:cargo --json — as a possible "executes another program" blocker, then cleared it: the task block is in mise.toml, and the program's own comment at :956 says "The task's own line is in $manifest". So input.tree.documents answers it.

That clearance is the weakest link in this row and is stated rather than buried. If the successor needs mise's include and inheritance resolution rather than the literal block, the predicate flips to needing a resolved-task fact that does not exist. Settle that first — it is one read of what :942 actually consumes.

What the gate holds, so the port conserves rather than reinterprets

The properties that make CI a confirmation rather than a discovery: no job runs on a draft; every pull_request workflow supersedes its own runs; no job starts before the landing lease authorises its branch (CLOUD-420); and every task CI runs is one verify runs. Plus the $CI_REQUIRED_CHECKS roster cross-check — a pull_request job missing from the set, or a name matching no job, fails the gate (CLOUD-327).

zizmor.yml broke the first two for its whole life, so a draft touching a workflow still spent a runner and re-drafting did not close the tap. That is the defect class this gate exists for, and the port must keep catching it.


Refinement — Ready (clean on both axes; the mise-tasks-info precondition is settled)

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

  • **Authority boundary (§1). **mise-tasks/ci-local-parity.sh and tests/ci-local-parity.bats are deleted — two paths, two // carried: arms. Two successors, per the home split above: a ci-hygiene PRESET bundle for the generic half and a policy/*.rego module in this repo for the roster half, each with a crates/batten/tests/*.rs tier. **No new **crates/batten verb. The $MUTANT_GATES entry and 4 #MUTANT rows move with it. mise.toml and hk.pkl call it by task name and are ungoverned, so both repoint freely.
  • Computable predicate (§2), conserved not reinterpreted. Over the committed workflow set and mise.toml: no job runs on a draft; every pull_request workflow declares concurrency with cancel-in-progress; every pull_request workflow's trigger includes ready_for_review (CLOUD-503 — a draft-era opened skip is a non-answer nothing can supersede); every task CI runs is one verify runs; and $CI_REQUIRED_CHECKS names exactly the pull_request jobs, in both directions.
  • The precondition, now SETTLED (§2). Whether :942's mise tasks info test:cargo --json needs only the literal mise.toml block or mise's resolved task graph. Answered in the block at the top of this row: the literal block suffices, and re-expressing it is a design decision that accepts a second authority over a body mise owns. Left unanswered it would have been CLOUD-845's dead-gate class.
  • Deliberately not in scope (§2). Changing $CI_REQUIRED_CHECKS or the workflow set. CI_FANIN_CHECK's membership rule (CLOUD-900), which is conserved as-is. Deriving [ci].required_checks (CLOUD-54), which would change what the roster is — this port keeps reading the hand-maintained one.
  • Effect (§3). read. It reads committed YAML and TOML and decides.
  • Output and exit (§5). Pointer-only: the workflow path, the job name, and which property failed — never a workflow body. Exit follows the 0/1/2/3 table.
  • Commit / bump (§6). refactor(ci)no bump. 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). **Two **// carried: arms, one per deleted path (CLOUD-908). Shown able to fail per CLOUD-418, four observed: a job runnable on a draft is reported; a pull_request workflow without cancel-in-progress is reported; a $CI_REQUIRED_CHECKS name matching no job is reported, and a pull_request job missing from the roster is reported — both directions, since CLOUD-327 is the one that reports green on a SHA nothing judged. Anti-vacuity: the current tree passes. Mutated: all 4 rows re-homed, mutant-census green. Replayed: old and new over the same workflow set, agreeing finding-for-finding — CLOUD-1115 is the standing caveat on replay's tree arm.
  • Blockers (§8). None. relatedTo CLOUD-1151 (the wave owner), CLOUD-1140 (suite cost), CLOUD-1115 (the replay instrument), CLOUD-908, CLOUD-418.

Acceptance

  • Both paths deleted with one ledger arm each; **no governed **mise-tasks/*.sh **or **tests/*.bats edited.
  • The preset half names no Button identifier — asserted, since it ships to every consumer (rule 1, §9).
  • No new crates/batten verb; batten spec --format json emits exactly the committed row set.
  • The mise tasks info question is answered in writing before the module is built — done, see the block at the top of this row; what remains is that the successor states the second-authority trade it accepts by reading [tasks."test:cargo"] as a document, rather than silently re-deriving what :881-885 warns about.
  • The on:-as-boolean question is settled before any trigger predicate is written, and the answer is recorded — whichever way it falls.
  • All four properties still caught, both roster directions asserted.
  • mutant-census green; 4 mutations honoured at their new home.
  • bench/suites/RESULTS.md regenerates with the suite absent, serial total down by the measured amount — ~54.6s on current main. Report it whichever way it falls.

Unit 13 of 83. The second-largest unblocked retirement — take it after CLOUD-1145, per the correction above. Seconds and blocker sets are two orthogonal axes; CLOUD-1174 owns that model and the artifact that makes this row's ranking checkable rather than asserted.

CLOUD-1218 The lap journal records a hand-emptied `target` as a WARM lap, so the ratchet inverts and the floor climbs to one nothing can satisfy

Why

CLOUD-1157's lap journal ratchets each floor to the worst consumption it has observed, which is right. What decides which floor a lap is charged to then mislabels a from-scratch rebuild as warm, so the warm floor learns a cold lap's demand.

CORRECTED 2026-08-30, third instance (PR #770). This paragraph originally read "what decides which floor a lap is charged to is whether the escalation dropped a basis-moving root." **That has not been true since **CLOUD-1157 (#756) landedbasis_of already derives the basis from the TREE, not from the escalation:

// crates/batten/src/prune.rs:1596
fn basis_of(root: &Path) -> Basis {
    let populated = directories_named(root, "deps")
        .iter()
        .any(|deps| std::fs::read_dir(deps).is_ok_and(|mut e| e.next().is_some()));
    if populated { Basis::Warm } else { Basis::Cold }
}

The residual defect is the .any(), and the function's own doc comment already names it (prune.rs:1582-1589): *"this reads EVERY *deps *under the root, so a populated *target/release/deps reports warm while the DEBUG build the lap is about to run is cold." One populated profile masks another being empty.

This matters because §2(b) below is written against the old mechanism and prescribes a fix the code already has. Reading it as written sends an author to replace an escalation check that is no longer there.

Measured on this container, 2026-08-30, while landing CLOUD-746. $GIT_DIR/batten-prune/laps.json held:

{"open":{"free_mb":9151,"basis":"warm","head":"d3788d66","measured":"2026-08-30"},
 "ratchet":{"warm":{"mb":22861,"head":"5647a306","measured":"2026-08-30"},
            "cold":{"mb":4640,"head":"090dd1f5","measured":"2026-08-30"}}}

cold 4640 MB below warm 22861 MB is impossible — a cold lap consumes more than a warm one by definition, and [prune]'s declared numbers say so (warm 6242, cold 14914). The inversion is the tell, and it is the cheapest possible detector.

How it got there, and why an agent will keep doing it. A land lap that refuses on disk prints "Free space outside ./target, or start a fresh session." The obvious reading is rm -rf target, which is outside target-prune entirely. Each such lap then rebuilds from nothing and is charged to warm, ratcheting the warm floor toward the cold one. After a handful of laps the warm floor stood at 22861 MB — above anything a completed lap on this box can leave free — so every subsequent lap refused, in both directions: warm target failed the opening reading, cleared target failed the closing one.

The recovery is to delete the journal, which restores the declared floors. That is not discoverable from the refusal: the message names free space, the floor, and the roots it could not reclaim, and never that the floor it is quoting is one the tool taught itself.

SECOND INSTANCE (2026-08-30, landing PR #751) — and it defeats this row's own detector

Reproduced on a different container, different branch, same mechanism. Journal at refusal:

{"open":{"free_mb":8538,"basis":"warm","head":"6e0e7f6c","measured":"2026-08-30"},
 "ratchet":{"warm":{"mb":15798,"head":"c96288c1","measured":"2026-08-30"},
            "cold":{"mb":21519,"head":"c96288c1","measured":"2026-08-30"}}}

Against declared warm 6242 / cold 14914, both floors are self-taught and both are ~2.5x and ~1.4x the declared value. Two hand-emptyings produced them, in this order:

act journal's reading
rm -rf target/debug/incremental (the one root target-prune says it cannot reclaim) "consumed 15798MB — worse than any warm lap on record, so the observed warm floor rises to 15798MB"
rm -rf target (after the above still refused) "consumed 21519MB — worse than any cold lap on record, so the observed cold floor rises to 21519MB"

After that, every lap refused in both directions exactly as this row describes, and land failed three consecutive times.

Why §2(a) and acceptance bullet 1 would NOT have caught it

This ratchet is correctly ordered — cold 21519 > warm 15798 — so "a ratchet whose cold observation is below its warm one is refused" is silent here. The inversion is one symptom of poisoning, not the class. The recorded instance inverted because its two hand-emptyings happened to charge the wrong buckets; this one charged the right buckets and simply ratcheted both past what the box can satisfy. A detector keyed on ordering therefore catches the first instance and misses the second, which is the more ordinary shape — an agent following the refusal's own advice twice, in the order the refusal suggests it.

So the predicate wants a second conjunct, and §2(b) is the one that generalises: derive the basis from what the tree WAS at lap open. Under (b) both of my laps are cold and neither teaches the warm floor anything, which is the correct outcome and is reached without reference to ordering. Worth stating on the row because (a) is the cheaper check and reads as sufficient — it is not, and a fix that ships only (a) leaves this instance live.

A cheap third guard, orthogonal to both: refuse to ratchet a floor above the declared one by more than some factor without saying so. A learned number 2.5x its own config is more likely a mismeasurement than a real budget, and the refusal that quotes it should say which it is — which §5 already asks for and this instance makes concrete.

The recovery worked, and its cost is the real damage

rm -rf "$(git rev-parse --git-dir)/batten-prune" restored declared floors and admitted the next lap at 8130MB free against warm 6242MB. The cost was one full cold rebuild — the rm -rf target this row predicts an agent will reach for, which consumed 21519MB and ~20 minutes of wall clock, and which was never necessary: the journal was the whole problem and deleting it alone would have sufficed at the very first refusal. That is the measured price of the undiscoverable recovery, and it is the strongest argument for §5's "observed vs declared" wording landing with the fix rather than after it.

Why it is not CLOUD-861's or CLOUD-1030's. CLOUD-861 is the once-per-lap precondition, and the closing reading it added is what reports this — correctly, over a poisoned number. CLOUD-1030 is the escalation invalidating the basis that certified the lap. This is the third: an external reclaim mislabels the basis a lap is recorded under, so the ratchet learns the wrong thing and the error compounds across laps rather than affecting one.

Refinement — Ready

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

  • Authority boundary (§1). crates/batten/src/prune.rsLapJournal, OpenLap.basis and Ratchet. [prune]'s declared floors are untouched: this row makes the recorded basis honest, it does not move a measured number. No new config key and no runner decides any part of it.

  • Computable predicate (§2), REWRITTEN 2026-08-31 after both original clauses were implemented and refuted. One clause, decidable over the compiled binary: a profile cargo has BUILT whose deps **is missing or empty makes the tree cold. **target/<profile>/.fingerprint is the marker — cargo writes one per profile it has built and leaves it behind when deps goes, so the absence becomes visible. Every .fingerprint's sibling deps must be populated; no .fingerprint anywhere falls back to the pre-existing reading, which is what keeps this a narrowing rather than a new requirement. It needs no guess about which profile the caller will build next, so the comment's refusal of profile scoping still stands.

    (a) A ratchet whose ~~cold~~ observation is below its ~~warm~~ one is refused. REFUTED. Silent on two of three instances (one journal carried cold: null; one carried a correctly ORDERED pair). The strengthened form — refuse a warm observation at or above the declared cold floor — was implemented and turned a_warm_laps_consumption_does_not_raise_the_cold_floor red: that case deliberately drives a warm lap consuming 22000MB against a 14000MB cold declaration, because a warm observation under it cannot discriminate a per-basis ratchet from a shared one, and its comment records that as a surviving mutation. The premise is also a unit error — the declared floor is a free-space budget, not a ceiling on consumption — and no factor separates the real case (3.96x) from the fixture's legitimate one (3.67x).

    (b) …narrowing ~~basis_of~~'s ~~.any()~~ to ~~.all()~~. **REFUTED. **directories_named only yields directories that EXIST, and the reclaim an agent actually performs REMOVES target/debug/deps, which drops that profile out of the walk and leaves a populated target/release/deps satisfying either quantifier. .all() only helps for a deps that survives but is empty, which is not what any of the three instances did.

    "Empty or near-empty root" was also wrong and would miss all three. Mine left target/release, target/debug/build and ~660 MB standing; the second instance deleted only target/debug/incremental. deps is the build basis; the rest of the tree is not.

  • Effect (§3). Unchanged. target-prune keeps its classification; what changes is which bucket an observation lands in.

  • Output & exit (§5). Unchanged, except that the refusal should be able to say the floor it quotes is observed-and-inverted rather than declared — the current message gives a reader no way to tell a learned floor from a configured one. Pointer-only throughout: megabytes, a head and a date, as today.

  • Test obligation (§7). Over the compiled binary, shown able to fail per CLOUD-418. The discriminating case needs TWO profiles: a_tree_emptied_by_something_other_than_the_escalation_is_still_a_cold_one deletes deps on a one-profile tree, so the walk comes back empty and any quantifier answers cold — it cannot see this class. Red before: a built profile whose deps is REMOVED while another profile stays intact. Green and staying green: every built profile intact is still warm, and a tree cargo never fingerprinted still takes the fallback — without that pair the fix is satisfied by charging everything to cold, which raises the COLD floor instead and fails the same way one bucket over.

  • Blockers (§8). None. relatedTo CLOUD-1157 (whose journal this is), CLOUD-861 (the closing reading that surfaces it), CLOUD-1030 (the escalation-invalidates-basis half) and CLOUD-1153 (the other way this refusal misreports its own cause).

Acceptance

  • ✅ A profile cargo has built whose deps is missing or empty makes the tree cold, whichever other profiles survive. Landed on refactor(ci): retire derived-check, man-pages and ci-local-parity; repair the lap journal's basis #770 (856c3746).
  • ✅ A tree whose every built profile is intact is still warm, and a tree cargo never fingerprinted is judged by the pre-existing reading. Landed on refactor(ci): retire derived-check, man-pages and ci-local-parity; repair the lap journal's basis #770.
  • ✅ The refusal names the journal that holds a learned floor, so the recovery is discoverable from the message rather than costing a full cold rebuild to find. Landed on refactor(ci): retire derived-check, man-pages and ci-local-parity; repair the lap journal's basis #770 (1c896f89).
  • ~~A ratchet with ~~~~cold~~ ~~below ~~~~warm~~ ~~never decides a refusal. ~~Withdrawn — see §2. Silent on two of three instances, and the strengthened form conflicts with a landed measured test.
  • ~~A correctly-ordered but inflated ratchet is also caught. ~~Withdrawn with it: no threshold separates the real case from the fixture's legitimate one. With the basis now honest, a cold lap is charged to the cold ratchet and the warm one is never taught a rebuild's demand, which is the route §2 takes instead.
  • Still open, and the reason this row is not Done: the ratchet remains unbounded, so a genuinely mismeasured observation of any basis is still learned permanently. That needs a predicate over satisfiability — a floor above what a completed lap can leave free can only ever refuse — rather than over ordering or magnitude. Not attempted here.

Found while landing CLOUD-746: six consecutive land laps refused on a floor the tool had taught itself from my own rm -rf target, and the fix was deleting a file no message named.

CLOUD-1216 A path glob cannot select a step for a file the commit DELETED, so `suite-bench-check` — whose whole predicate is set equality over `tests/*.bats` — is silent on exactly the change that breaks it

Why

suite-bench-check decides one thing: that bench/suites/RESULTS.md's membership equals git ls-files 'tests/*.bats', in both directions (mise-tasks/suite-bench-check.sh:68-82). The reverse direction — a corpus row naming a suite the tree no longer carries — is refused as "a cost attached to nothing".

Its hk step is globbed on bench/suites/RESULTS.md and tests/*.bats (hk.pkl), and hk.pkl's own comment states the intent exactly:

Globbed on the corpus and on the suites, because both directions rot: a suite added without regenerating is a file nothing records, and a suite deleted leaves a cost attached to nothing.

The glob cannot deliver the second half. hk selects steps by matching changed paths against the glob, and a deleted path is not there to match. So the one commit shape the reverse direction exists for — a deletion — is the one shape that does not select the step.

Measured, 2026-08-30, on PR #770

Commit f45e214 deleted tests/derived-check.bats as part of CLOUD-1145's retirement. It passed the full pre-commit gate. bench/suites/RESULTS.md still recorded that suite, so the tree was left in the exact state suite-bench-check exists to refuse:

::error:: suite-bench-check: bench/suites/RESULTS.md records tests/derived-check.bats,
which is not a tracked suite — a cost attached to nothing.

That state survived four further commits, every one of which also passed the gate, and was found only by running the task by hand. Nothing in the gate would have caught it before CI.

The severity is that the failure is silent and delayed rather than that the corpus was stale. The corpus going stale is cheap and obvious once seen. What this row is about is that a gate can be correct, registered, globbed with deliberate intent stated in a comment, and still structurally unable to fire on half the class it names.

The class is wider than this one step

Any step whose predicate is about a file's absence, or about set equality against a tracked-file listing, inherits this. A glob over paths answers "did one of these change", and a deletion is a change the glob cannot see. Steps whose predicate is purely about the content of surviving files are unaffected, which is why this has not surfaced before.

Related but distinct: CLOUD-899 is a glob naming a path that never existed (wrong glob), and CLOUD-949 wants hk's effective plan as a pre-admission fact (visibility). This is neither — the glob is right, the plan is right, and the selection still cannot happen.


Refinement — Ready (make the gate's selection independent of the deleted path)

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

  • **Authority boundary (§1). **mise-tasks/suite-bench-check.sh's own selection contract, and the hk step that invokes it. The predicate itself is correct and is not in scope — this changes only whether it runs. No other step is modified in this row; the wider class is surveyed in the acceptance and filed separately if it has members.

  • Computable predicate (§2). The step must run on any commit that changes the set of tracked tests/*.bats, including one that only removes members. Two candidate mechanisms, and the row does not pre-judge between them: either the step declares no path glob and always runs (it is milliseconds — hk.pkl already records that it "re-runs NOTHING"), or hk's selection is fed the deleted paths as well as the surviving ones. The first is decidable today and needs nothing from upstream; the second is the general fix and may not be expressible in hk.pkl.

  • Deliberately not in scope (§2). The set-equality predicate, its exit codes, and the corpus format. Timing is deliberately ungated and stays so.

  • **Effect (§3). **read. The step reads two committed listings and compares them.

  • Output and exit (§5). Unchanged: pointer-only, naming the suite and the corpus, over the existing 0/1/2 contract.

  • **Commit / bump (§6). **ci — no bump; it changes which commits a gate's step is selected for, and the crate releases nothing at any version. Not fix: ready-lint strips the scope before reading the type (sed -E 's/[(][^)]*[)]//'), so fix(ci) declares fix, which implies a patch and contradicts the "no bump" on the same line.

  • Test obligation (§7). Shown able to fail per CLOUD-418, and the discriminating case is the one this row exists for: a commit whose ONLY change is deleting a tests/*.bats must select the step and turn it red while the corpus still records that suite. Green and staying green: a commit touching neither the corpus nor any suite must not pay for the step if the always-run route is taken — measure it, since hk.pkl's "re-runs nothing" claim is the whole affordability argument.

    **⚠️ **hk check --plan CANNOT ANSWER THIS AND WILL TELL YOU THE ROW IS WRONG. Measured 2026-08-31: it reports suite-bench-check even for a commit touching only crates/**/*.rs, which neither glob matches — so it does not respect globs, and old-config and new-config runs come back identical. Three selection probes against it "refuted" this row and I began correcting the body before the control caught it.

    The probe that decides is a commit. Stage a deletion of a tracked tests/*.bats while the corpus still records it, and try to commit: with the glob the commit SUCCEEDS and the step never runs; without it the commit is REFUSED, naming the stale row. Run both ways on tests/land.bats.

  • Blockers (§8). None.

Acceptance

  • ✅ A commit that deletes a tracked tests/*.bats without regenerating the corpus is refused at the pre-commit gate, not at CI. Landed on refactor(ci): retire derived-check, man-pages and ci-local-parity; repair the lap journal's basis #770 — the step is unglobbed, and the deletion-only probe flips from committing cleanly to being refused.

  • ✅ The step's cost on an unrelated commit is measured and stated: 531ms, three runs (531/530/531). Stated in hk.pkl beside the step, replacing a comment that said "milliseconds" — which a reader takes as ~10.

  • ✅ The wider class is surveyed, and suite-bench-check is its only member, so no further rows are owed. Done on refactor(ci): retire derived-check, man-pages and ci-local-parity; repair the lap journal's basis #770.

    Method, so a reader can judge it rather than take it. The class is not "reads a tracked listing" — 19 mise-tasks/*.sh read git ls-files and almost all of them use it to ENUMERATE subjects, where deleting a file simply removes a subject and creates no violation. The class is narrower: a predicate where a path's ABSENCE is itself the refusal. Two passes:

    1. The 19 git ls-files readers, checked for directionality. module-map-check is representative of the majority — it refuses "modules absent from the map" only, so a deleted module cannot make it red (its map row goes stale un-gated, which is a different and lesser thing).
    2. A search across mise-tasks/ for the reverse-direction refusal shape — "which is not tracked", "no longer exists", "attached to nothing", "names no file". One task refuses on it: suite-bench-check. The other three hits (reference-check, closing-key-check, hooks-wiring-check) carry the phrase in comments about unrelated staleness, not in a refusal over a tracked listing.

    The bound, stated rather than implied: this surveys mise-tasks/. A step whose predicate lives in a Rego module or the crate is not covered, and a member phrasing its refusal differently would be missed. Both are cheap to re-run if a second instance ever appears.

Found while retiring derived-check (CLOUD-1145) — the deletion passed the gate and the corpus stayed stale across four commits.

CLOUD-1233 The escalation drops `incremental` and the very next build regenerates it, so a container near its allowance thrashes — measured 6859MB per lap against 613MB with `CARGO_INCREMENTAL=0`

Why

[prune] declares incremental as a regrowable, basis-moving root, and the escalation drops it when a lap opens below the warm floor. That buys space exactly once: the next cargo build writes it straight back, so the lap ends below the floor again and the following lap escalates again. On a container whose allowance is close to the working-set size the cycle does not converge.

Measured, 2026-08-31, one container, same branch and same HEAD

Consecutive mise run land laps over 3005244d, differing only in CARGO_INCREMENTAL:

lap escalation dropped lap consumed free at close warm floor
default 5393MB 6859MB 7037MB 7264MB
default 581MB 746MB 7023MB 7264MB
CARGO_INCREMENTAL=0 0MB 7071MB 7264MB
CARGO_INCREMENTAL=0 239MB 613MB 7050MB 7264MB

11x on lap consumption, and the 5393MB the escalation reclaimed in the first lap is almost exactly what the build then rewrote. The reclaim and the regeneration are the same bytes going round.

Note what the refusal says while this happens: "escalated below the warm floor — 5393MB of regrowable cache dropped; none of those roots is the cargo build's basis, so the next build is still warm and the warm floor is what applies." That is true about the basis and misleading about the budget — the drop does not reduce what the next lap will consume, it guarantees the next lap re-spends it.

Why this is not CLOUD-861's or CLOUD-1030's

CLOUD-861 is the once-per-lap precondition and the closing reading that reports the overspend — it is the sensor that makes this visible, correctly. CLOUD-1030 is the escalation invalidating the basis that certified the lap, which is about which floor applies. This is about the consumption term: the escalation's own reclaim is the largest single input to the next lap's cost, and nothing accounts for that.

It is also distinct from CLOUD-1157's coverage work. Widening the reclaim to more classes does not help here; incremental is already reclaimed, and reclaiming it is what creates the cost.

The shape of a fix, not pre-judged

Two candidates, and the row does not choose between them:

  1. Do not write what you are about to drop. If a lap is admitted near the floor, run its builds with incremental compilation off. The gate already knows it is near the floor — that is the escalation's own trigger — so the information is in hand at the right moment. CARGO_INCREMENTAL=0 is the measured lever above and costs some rebuild time in exchange for not thrashing.
  2. Stop treating incremental as free headroom. If dropping it reliably costs ~5GB on the next lap, it is not regrowable-at-no-cost in the sense the escalation assumes; the accounting should say so, and the escalation should prefer roots whose regeneration is not the next lap's largest term.

The first is decidable today and needs nothing from upstream. The second is the honest model and may want [prune] to carry a regeneration cost per root beside the existing cold flag.


Refinement — Ready (stop the reclaim from funding the next lap's overspend)

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

  • **Authority boundary (§1). **crates/batten/src/prune.rs's escalation, and whichever surface sets the build environment for a lap. [prune]'s declared floors do not move. No change to which roots are declared regrowable.
  • Computable predicate (§2). A lap that escalates does not consume, on its next build, materially what the escalation just reclaimed. Decidable as a comparison of two recorded numbers the journal already has — escalated_mb on one lap against consumed on the next — so the acceptance is measurable rather than argued.
  • Deliberately not in scope (§2). The floors themselves, the basis decision, and the ratchet. Those are CLOUD-1030's, CLOUD-1218's and CLOUD-1197's respectively.
  • **Effect (§3). **read plus the existing reclaim. Setting a build environment variable for a lap is not a new authority; it is the same process the lap already spawns.
  • Output & exit (§5). Pointer-only, unchanged. If the escalation stops claiming that a dropped root is free, its line should say what it actually costs — the current wording implies the opposite.
  • Test obligation (§7). Shown able to fail per CLOUD-418, over the compiled binary. Red before: a lap that escalates, followed by a lap whose consumption is ~the escalated amount. Green and staying green: a lap that does not escalate is unaffected and still writes its incremental cache — without that twin the fix is satisfied by disabling incremental compilation everywhere, which is a real cost paid on every container including the ones with room.
  • Blockers (§8). None.

Acceptance

  • The escalation's reclaim is not the dominant term in the next lap's consumption.
  • A lap with headroom is unaffected and keeps its incremental cache.
  • The escalation's report does not describe a root as free when regenerating it is the next lap's largest cost.
  • The measured pair above is the regression case: same HEAD, same branch, 6859MB against 613MB.

Found while landing PR #770: four consecutive laps refused at the closing reading, each having just reclaimed roughly what it then re-spent.

CLOUD-934 The inline-regex refusal exempts presets, so the anti-duplication mechanism does not apply to the code that ships to every consumer

Why

CLOUD-885 made a pattern's home decidable: an inline regex in a policy module is refused at load, so a pattern must be a [[pattern]] row read as data.batten.patterns["<id>"]. pattern.rs states the value plainly — duplication becomes unwritable rather than merely detectable (measured: one concept, 19 spellings across 17 shell programs), and the pattern inventory becomes reviewable data (house style §11).

check_no_inline_regex returns early when rule.preset.is_some() (policy.rs:1269). So the refusal does not apply to presets — and presets are the code that reaches every consumer, while a consumer module reaches one repository.

Measured: crates/batten/src/policy/presets/shell-hygiene/sibling-resolves.rego writes six inline regexes (:39, :53, :71, :77, :95, :100), legally, including a name_capture whose trailing character class carries a documented false-positive fix. Nothing holds those six to the inventory [[pattern]] exists to be.

Why the exemption exists, and why that reason does not settle it

The exemption is not arbitrary: a [[pattern]] row is consumer config, and a vendored preset ships with no consumer config to reference. A preset naming data.batten.patterns["x"] would be a module with a dangling reference in every repository that had not declared x — which is worse than an inline regex, because it is a dead gate rather than an unindexed pattern.

So the exemption is correct as far as it goes. What is missing is the third option: presets have no inventory of their own. The question this row exists to settle is whether a preset bundle can carry its own pattern table — vendored alongside the modules, compiled in by the same include_str! table (policy.rs:229-261), invisible to consumer config and therefore not a dangling reference — or whether inline is genuinely the right answer for a preset and the exemption should say so where a reader will find it.

Why a row and not a comment on CLOUD-885

CLOUD-885 is In Review. A comment on a row about to land is a finding with no reader, and the exemption is a distinct decision from the one that row made — 885 decided where a consumer's pattern lives.


Refinement — Ready

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

  • Source of truth (§1). Whatever the verdict, there is one inventory per bundle scope: consumer patterns in batten.toml, and either a vendored preset table or an explicit recorded decision that presets are exempt and why. Not both, and not silence.
  • Computable predicate (§2). Conditional on the verdict, and both branches are decidable: if presets get a table, check_no_inline_regex stops exempting them and a preset carrying an inline regex is refused at load. If inline stays correct for presets, the predicate is that the exemption is documented at its site — which rules-drift cannot hold, so that branch ships a test asserting policy.rs:1269's early return carries a reason, in the shape spawn_census.rs uses against clippy.toml.
  • Effect (§3). read. No new authority either way; a vendored table is include_str! at build time, which is the existing preset mechanism rather than a new one.
  • Generated artifacts (§4). schema/batten.schema.json only if a consumer-visible key appears — a vendored table should not add one, and that is part of what makes it the safer branch. derived-check gates it.
  • Output & exit (§5). Unchanged. A load-time refusal is exit 1 — a config fault, not a policy verdict (house-style §7) — which is what check_no_inline_regex already does for consumer modules.
  • Commit / bump (§6). fix(policy)patch until 0.1.0. Tightening a refusal to cover presets could in principle break a downstream preset author, but there are none: presets are vendored and compiled in, so the blast radius is this repository's own four modules.
  • Test obligation (§7). Shown able to fail per CLOUD-418. If presets get a table: a preset with an inline regex is refused at load, and sibling-resolves.rego's six become table references with the module's own test_ rules still green — the migration must be behaviour-preserving, asserted by those tests rather than claimed. If inline stays: the reason at policy.rs:1269 is asserted present, and the test is shown able to fail by deleting it.
  • Blockers (§8). None. relatedTo CLOUD-885 (which built the refusal and the [[pattern]] table), CLOUD-836 (which vendored the presets this exempts).

Acceptance

  • A verdict is recorded: presets get their own vendored pattern inventory, or inline is correct for a preset and the reason is at the exemption's own site.
  • Whichever branch, a mechanism holds it — a load-time refusal or an asserted rationale, never prose alone.
  • sibling-resolves.rego's six inline patterns are either migrated with its test_ rules still green, or explicitly covered by the recorded verdict.

Review in Linear

@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 9824770f-ebba-4da5-8a79-445eb86da3e8

📥 Commits

Reviewing files that changed from the base of the PR and between 8b5e398 and 2bdeb78.

⛔ Files ignored due to path filters (1)
  • hk.pkl is excluded by !**/*.pkl
📒 Files selected for processing (21)
  • .claude/rules/policy-modules.md
  • batten.toml
  • bench/suites/RESULTS.md
  • crates/batten/src/policy.rs
  • crates/batten/src/policy/presets/ci-hygiene/spend-is-authorised.rego
  • crates/batten/src/policy/presets/ci-hygiene/wiring-can-be-reached.rego
  • crates/batten/src/prune.rs
  • crates/batten/src/verdict.rs
  • crates/batten/tests/ci_hygiene.rs
  • crates/batten/tests/ci_parity.rs
  • crates/batten/tests/surface.rs
  • crates/batten/tests/target_prune.rs
  • mise-tasks/ci-local-parity.sh
  • mise-tasks/derived-check.sh
  • mise-tasks/man-pages.sh
  • mise.toml
  • policy/ci-parity.rego
  • schema/batten.local.schema.json
  • schema/batten.schema.json
  • tests/ci-local-parity.bats
  • tests/derived-check.bats
💤 Files with no reviewable changes (5)
  • tests/ci-local-parity.bats
  • tests/derived-check.bats
  • mise-tasks/derived-check.sh
  • mise-tasks/ci-local-parity.sh
  • mise-tasks/man-pages.sh
🚧 Files skipped from review as they are similar to previous changes (12)
  • crates/batten/src/policy.rs
  • bench/suites/RESULTS.md
  • .claude/rules/policy-modules.md
  • crates/batten/src/verdict.rs
  • crates/batten/tests/target_prune.rs
  • crates/batten/tests/ci_hygiene.rs
  • crates/batten/src/policy/presets/ci-hygiene/spend-is-authorised.rego
  • crates/batten/tests/surface.rs
  • crates/batten/src/policy/presets/ci-hygiene/wiring-can-be-reached.rego
  • crates/batten/src/prune.rs
  • batten.toml
  • policy/ci-parity.rego

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Added the ci-hygiene preset with workflow safety and wiring checks. Added the tree-scoped ci-parity policy for CI tasks, rosters, releases, Renovate, ecosystems, leases, and fan-in wiring. Added verdict routes, schemas, policy fixtures, and integration tests. Replaced shell-based surface validation with surface.rs and direct batten spec man-page generation. Updated prune basis accounting and lifecycle tests.

Merge Risk: ⚪ Minimal · up to 2bdeb

The PR retires legacy CI gates and moves their checks into maintained test and policy paths while repairing journal basis handling; no actionable merge-blocking risk remains beyond normal checks and review.


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 Pro by visiting https://app.coderabbit.ai/settings/billing.

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

Copy link
Copy Markdown
Contributor Author

Standing down on the SonarQube failure: it is not this PR's, and nothing here reads it.

sonarqubecloud[bot] has posted "The last analysis has failed" on all three pushes to this branch (f45e214, c96fb24, 228f754). Establishing why that is not this PR's before ignoring it:

So this is the documented steady state of the repository rather than a regression introduced here, and there is no fix to port: the row that would change it is open and blocked on a separate remediation.

Two corrections to my own earlier reasoning, recorded because both were wrong in the direction of ignoring a signal:

  1. I first assumed the failure was an artifact of the PR being a draft. It is not — sonar-gate's contract states the analyzer is not draft-gated, so it runs here and the failure is real and observable.
  2. I then said I would verify it "at ready time". Deferring a recurring failure on a PR I opened is not a disposition. It is checked now.

No action taken on it. Everything in the required roster is untouched by this observation.


Generated by Claude Code

@wenzowski
wenzowski force-pushed the claude/retire-shell-gates-t77iuk branch 5 times, most recently from 86556a4 to d6dad71 Compare August 30, 2026 21:18
@wenzowski
wenzowski force-pushed the claude/retire-shell-gates-t77iuk branch from 1c896f8 to fa49c3b Compare August 31, 2026 01:53
@wenzowski wenzowski changed the title refactor(ci): retire derived-check, man-pages and ci-local-parity refactor(ci): retire derived-check, man-pages and ci-local-parity; repair the lap journal's basis Aug 31, 2026
@wenzowski
wenzowski force-pushed the claude/retire-shell-gates-t77iuk branch 3 times, most recently from 485da84 to 2b4be4e Compare August 31, 2026 07:46
`tests/derived-check.bats` was 289.8s — 23.8% of a 1219.4s bats corpus and its
single most expensive suite — spent re-answering a question
`crates/batten/tests/surface.rs` already answers over the compiled binary. The
disposition is SUBSUMED rather than a port: `:66` and `:222` held the drift half
for both artifact families before this change, so almost none of the 289.8s was
coverage.

What was genuinely missing is the SET half, and it was missing in OPPOSITE
directions per family, because each side's expected set was anchored on a
different authority:

  * completions — expected set is the fixed `SHELLS` const, so a
    declared-but-uncommitted script failed at `fs::read` and an EXTRA committed
    file was never looked at.
  * man — expected set is `committed_pages()`, which reads the directory, so an
    extra file had to render and match and a page the surface DECLARED with no
    committed file was invisible.

`the_committed_artifacts_are_exactly_the_ones_the_surface_declares` closes all
four cells with one assertion rather than patching two, which is what
`derived-check.sh`'s `comm -23` reverse scan did. It compares PATHS and never
opens a file, so non-negotiable rule 4 holds structurally. The man half derives
from `batten spec --format json`; the completions half stays anchored on
`SHELLS` because the spec carries the `--shell` flag but not its value set —
stated in the code rather than faked as a derivation.

`committed_pages()`'s doc comment was false in two directions and is rewritten
rather than repointed. It called `mise-tasks/man-pages.sh` "the one authority
for which pages exist": that script was itself a derivation of `batten spec`, so
it was never an authority, and the function never read it, so the crate
described a derivation that did not happen. Retiring the script is what
surfaced it.

`[tasks.man]` now derives its page list inline from the same spec — the `jq`
program `man-pages.sh` already was — so a hop that read as a second authority
and cost a `cargo run` per invocation is gone.

MEASURED
  * the new assertion passes on this tree in 0.01s against the suite's 289.8s
  * shown able to fail in all four cells (CLOUD-418), each naming the artifact:
    man declared-not-committed, man committed-not-declared, completions
    declared-not-committed, completions committed-not-declared. The last two are
    the cells the existing tier was blind to and the ones this row exists for.
  * `mise run man` regenerates all 77 pages to a zero-byte diff
  * `mise run mutant-census` green: 104 gates, all declared or exempt

No `#MUTANT-EXEMPT` row is owed. `mutant-census.sh` collects subjects from
`mise-tasks/*.sh` and `policy/*.rego`, both one level deep; this retirement
creates no module, so there is no source to be `uncovered`. Dropping
`derived-check` from `$MUTANT_GATES` is the whole obligation, without which the
census reports `names-no-subject`.

No governed file was edited: three deleted, and every surviving reference is in
ungoverned config or prose.

Refs: CLOUD-1145
…l-parity

First of two commits retiring `ci-local-parity` (54.6s, 4.5% of the bats
corpus). This one lands the successor for the half that is true of the PRACTICE
rather than of this repository; the consumer-specific half and the deletion
follow.

FOUR PROPERTIES, and the split from the consumer's own module is non-negotiable
rule 1 rather than tidiness. "No job runs on a draft", "a pull-request workflow
supersedes its own runs", "every workflow declares a concurrency group" and "a
draft-gated workflow subscribes to ready_for_review" name no repository, no
task and no job. A required-check roster and a bot's branch prefix do, and a
preset reaches every consumer — shipping them here would bake this repository's
job names into everyone's binary.

ONE PROPERTY IS RE-SCOPED, deliberately and in the safe direction. The retired
program scoped `ready_for_review` to "produces a required check", which is a
consumer's fact. Draft-gating is the same condition read from the workflow
itself: a job that skips on a draft is one whose verdict can only arrive on the
ready event. The preset asks it of workflows that draft-gate, so it stays
computable without a roster.

TWO PARSE QUESTIONS THE SHELL DID NOT HAVE, both settled over the compiled
binary rather than by reading:

  * `on:` — YAML 1.1 resolves a bare `on` to a boolean, which would key the
    trigger block as `true` and make every trigger predicate a dead gate,
    passing its own suite green while deciding nothing. This engine's parser
    resolves only `true`/`false`, so `on` arrives as written.
    `the_engine_keys_the_trigger_block_as_on` proves it by DISCRIMINATION — the
    fixture can only be refused if the block was read as `on` — rather than by
    inspecting a fabricated document, which is the one thing a `with input as`
    case cannot do.
  * `cancel-in-progress` — the shell matched it as TEXT, where the quoted and
    bare spellings are identical. Through a parser they are a string and a
    boolean, and only the boolean is the flag GitHub honours. A module written
    against `"true"` would be green in its own suite and dead in the field.

MEASURED
  * `mise run policy-test`: 24 bundles, 274 passed, 0 failed.
  * `crates/batten/tests/ci_hygiene.rs`: 12 passed in 0.71s, including
    `this_repository_is_clean_today` over all 24 committed workflows — the
    anti-vacuity term, and what says a preset that refused everything would not
    look identical to one that discriminates.
  * Shown able to fail (CLOUD-418): a job runnable on a draft, a workflow that
    never supersedes, a workflow with no concurrency group, and a draft-gated
    workflow that can never be superseded. Each names the workflow.
  * The shapes that must NOT be refused are asserted too: a scheduled workflow
    declining to cancel its own next tick, and a workflow that does not
    draft-gate not being asked for the ready subscription.

A defect found in this file's own first draft, recorded because the failure mode
is silent: `object.union` is a DEEP merge, so two removal fixtures built by
overriding a parent key kept the key they meant to remove and were byte-identical
to the clean one. Both deny cases passed nothing. They are spelled out as whole
documents now.

`schema/batten.schema.json` and its override twin regenerate with the new preset
name: the enum is derived from `policy::preset_names()`, so the committed schema
is what refuses a preset that does not exist.

The preset ships no `#MUTANT-EXEMPT` row and needs none: `mutant-census`
collects `mise-tasks/*.sh` and `policy/*.rego`, both one level deep, and a
vendored preset lives under `crates/batten/src/policy/presets/`.

Refs: CLOUD-1161
Second of three commits retiring `ci-local-parity`. The generic half shipped as
the `ci-hygiene` preset; this lands the half that names things only this
repository has, so the deletion can follow with a successor already live for
every property.

WHAT IS HERE, and why it could not go in the preset. A required-check roster, a
task called `verify`, a fan-in called `final`, the ecosystems this tree
maintains, the two shell programs that make the abandon safe — every one is a
consumer fact. A preset reaches every consumer, so shipping them there would
bake this repository's job names into everyone's binary, which is the violation
non-negotiable rule 1 names.

Five predicates, thirteen refusal classes: every task CI runs is one `verify`
runs (with the foreign-runner exemption, which is an allowlist of FOREIGN labels
rather than of Linux ones — exempting anything not `ubuntu-*` would switch the
property off for `self-hosted` and for a matrix expression); the roster names
exactly the pull-request jobs, in both directions; the release PR opens as a
draft; the retired second bot stays retired and the surviving one keeps the five
bounds that decide what its lane spends and covers, with its commit type written
where a preset cannot outrank it; and the fan-in is named, homed, read rather
than restated, and actually called.

THE `verify` HOP IS SPELLED OUT rather than chased. `verify` became a
dependency-free exit-code mapper under CLOUD-407 and the gate set moved to
`verify:gated`, so a reader following only the first hop reports every one of
those tasks as CI-only — a false alarm rather than a missed one, but one that
fires on every commit. Following `mise run` calls transitively would be a second
authority on the task graph mise owns, which is the same objection that keeps
the cargo-spelling predicate out of this file entirely.

MEASURED
  * `mise run policy-test`: 25 bundles, 295 passed, 0 failed.
  * `crates/batten/tests/ci_parity.rs`: 17 passed in 3.55s, including
    `this_repository_is_clean_today` — the case that says every predicate here
    holds against the real roster, release config, Renovate config, dependabot
    absence and fan-in wiring, rather than only against fixtures.
  * Shown able to fail (CLOUD-418) for all thirteen classes, and the two the row
    names specifically are both directions of the roster: a job missing from it
    and a name matching no job.
  * The shapes that must NOT be refused are asserted too: a foreign runner
    running a task `verify` does not, and a matrix leg matching on its base name.
  * `mise run mutant-census` green: 105 gates.

TWO REFUSALS THE ENGINE MADE ME TAKE, both correct and both recorded because
they are the load-time tier doing its job. A Rego function cannot be
multi-valued, so reading a job's tasks as `ci_task(path, name)` faulted at
evaluation and is a partial set instead. And an inline regex is refused at load,
so `mise run <task>` is a `[[pattern]]` row — which is the registry preventing
exactly the duplication the retired program had, where the same concept was
spelled once in an `awk` pipeline and again in its own prose.

One case of my own first draft asserted the wrong property: it put a task name
inside a `run:` body and expected it to be ignored, which the retired program
would also have matched. Parsing subsumes the harder half of that property —
a YAML comment does not survive into the document at all — so the case now pins
what is actually decided, that the reading is bounded to `run:` scalars.

Refs: CLOUD-1161
…perties

The other half of what a hosted-CI run's configuration has to satisfy. Where
`spend-is-authorised` asks what a run COSTS, these ask whether the wiring that
decides it does anything at all: a trigger no job admits, a filter written where
filtering is already too late, two schedules on one minute, a fan-in asserting
three of its four dependencies, a cache-warm compile guarded on a step that does
not exist. Every one is silent when it breaks — the run list looks normal and
the conclusion is green.

Seven predicates, eight refusal classes, all generic: they name no repository,
no directory and no task, so they belong beside the four already in this bundle
rather than in the consumer's module.

TWO ARE SCOPED NARROWLY ON PURPOSE, because the wide reading fires on correct
configurations:

  * A declared trigger is only asked to be admitted where a job condition
    MENTIONS the event name at all. A workflow that does not discriminate by
    event answers for every trigger it declares, so judging it would refuse
    ordinary workflows.
  * A fan-in is only asked to assert its whole dependency set where it already
    names SOME of them. A job that names none is not making a claim about its
    `needs:` — it simply waits, which is what `needs:` is for.

MEASURED
  * `mise run policy-test`: 25 bundles, 315 passed, 0 failed.
  * `crates/batten/tests/ci_hygiene.rs`: 20 passed in 3.12s, including
    `this_repository_is_clean_today` — so all eleven properties in this bundle
    hold against the real 24 committed workflows, not only against fixtures.
  * Shown able to fail over the compiled binary for the shapes a fabricated
    document cannot prove: a cron SEQUENCE spanning two files, a `needs` ARRAY,
    the trigger map iterated by key, and a step id nested in a sequence read by
    a string in a sibling mapping.
  * The discriminating halves are asserted too: a staggered cron pair, a
    guarded cache-warm compile, and a `workflow_run` with no branch condition at
    all — the last because a deliberately repository-wide trigger is not a
    defect.

A DEAD GATE THE SECOND TIER CAUGHT, and the load-time tier structurally could
not. `ci_hygiene.rs` passed `patterns: &[]`, so the rule reading
`data.batten.patterns["cache-hit-step-id"]` had an undefined pattern, never
fired, and BOTH its deny case and its clean case passed. That is CLOUD-845's
class arriving inside the test harness rather than in the module: a
`[[pattern]]` row is the consumer's data, so unlike a preset's verdicts it is
not supplied by the binary, and a fixture that omits it silently disables every
rule that reads one. The tier supplies it now, and the deny case fails without
it.

`cache-hit-step-id` is a `[[pattern]]` row rather than an inline literal even
though presets are currently exempt from that refusal — `.claude/rules/policy-modules.md`
calls the exemption a hole rather than a design, so this bundle does not take it.

Refs: CLOUD-1161
Three more of `ci-local-parity`'s properties, all consumer-specific because each
names something only this repository has: a step called "Landing lease
precondition", a task called `checks-green`, and the branch prefixes two bots
land on.

  * NO JOB SPENDS BEFORE THE LEASE AUTHORISES ITS BRANCH. The lease serialises
    landing, but enforcing it only inside the lander means anything else pushing
    to a ready pull request buys a full matrix without ever touching the lock —
    measured as four concurrent matrices while the lease changed hands three
    times, every holder honouring it. The step must be FIRST: a job that
    installs a toolchain and then asks permission has already spent most of what
    asking was meant to save. Jobs that wait on others are exempt for a REASON
    rather than by enumeration — they cannot start ahead of the cancellation.
  * AND THE PRECONDITION MUST BE ALLOWED TO FAIL. The presence clause matches
    the step's NAME, so a copy that reds its own job reads as present and
    correct. Counted rather than searched for absence, because the two forms
    differ only by the suffix and a bare search passes a file carrying both.
  * A WORKFLOW READING CHECK STATUS DECIDES GREEN THROUGH ONE PREDICATE. Every
    hand-rolled copy so far has counted a wholly skipped set as zero
    outstanding, i.e. green — and a wholly skipped set is exactly what a
    draft-era refresh looks like. Keyed to the ENDPOINT rather than to a banned
    spelling, since the spelling is what a rewrite changes.
  * EVERY LIVE BOT LANE HAS A WATCHER AT ITS TRIGGER. Nothing runs on a bot's
    behalf unless a workflow watches its heads, so handing a lane to a bot
    without a lander is a complete, silent failure. The prefix is read from the
    config that OWNS each lane rather than assumed, and a lane whose config is
    absent is not asked for a watcher.

A FOURTH DEAD GATE THE SECOND TIER CAUGHT, and the third of this kind in this
PR. `V-LEASE-PRECONDITION-FATAL` reads `input.tree.lines`, and the row's
`line_sources` declared only the two shell programs — so no workflow was ever in
that fact and the rule could not fire at all. Its load-time case passed, because
a `with input as` case fabricates the `lines` object the row would never build.
The glob is declared now and the case fails without it.

That is three separate instances in this change of the same class: a predicate
whose input the row does not actually supply. Each was invisible to the
load-time tier by construction and each was caught by driving the compiled
binary, which is exactly the argument `.claude/rules/policy-modules.md` makes
for the second tier existing.

MEASURED
  * `mise run policy-test`: 25 bundles, 325 passed, 0 failed.
  * `crates/batten/tests/ci_parity.rs`: 22 passed in 7.16s, including
    `this_repository_is_clean_today`.
  * Shown able to fail over the compiled binary for all four: a job with no
    lease step, a lease step that is not first, a precondition invoked without
    the tolerant suffix, a workflow rolling its own green predicate, a live lane
    with no watcher, and a prefix named only in a job condition — the last
    because a condition is evaluated after the run exists, so it is not a scope.

Refs: CLOUD-1161
Deletes `mise-tasks/ci-local-parity.sh` (1093 lines) and
`tests/ci-local-parity.bats` (1674 lines, 101 cases, 54.6s — 4.5% of the bats
corpus). Every one of its 40 predicates now has a live successor, landed in the
preceding commits, so nothing is ungated across the deletion.

THREE HOMES, split by what each predicate can honestly name:

  * `ci-hygiene` PRESET — what a run costs and whether its wiring can be reached.
    Generic: no repository, no task, no job name.
  * `policy/ci-parity.rego` — the roster, the task graph, the release and bot
    configs, the fan-in. Consumer facts, kept out of the core by rule 1.
  * `[tasks."cargo-spelling"]` — the foreign-runner second spelling, which needs
    mise's own answer about mise's task graph. A module cannot spawn, so the only
    expression available there was a second parser over a body mise already owns
    — the objection the retired program stated in its own words. It stays a mise
    task, so that objection is honoured rather than paid.

This commit also lands the last generic predicate: an unquoted `#` that swallows
a `${{ }}` interpolation. Read from `lines` and not the document, which is forced
rather than chosen — the parse is what DESTROYS the evidence, so by the time the
value is a node it is already truncated and nothing downstream can tell a short
string from a swallowed one. Anchored rather than counted, because the obvious
raw-vs-parsed count was measured at 75% false positives.

THE LEDGER — 101 case arms and 2 path arms, and the dispositions are not
uniform, which is the whole point (CLOUD-908):

  * carried    86 — the assertion moved home.
  * subsumed    6 — every one is the PARSE doing the work. A comment does not
                    survive into a parsed document and a parsed value is one
                    shape whatever its source formatting, so the classes the
                    shell excluded by hand cannot arise here at all.
  * changed     6 — two are the `ready_for_review` re-scope (a roster is a
                    consumer fact and cannot live in a vendored preset, so the
                    preset scopes on draft-gating instead, which is the same
                    condition read from the workflow itself). Four are
                    could-not-look cases: the engine separates absent from
                    unparseable through `input.tree.missing`, where the shell had
                    one channel for both and had to refuse the empty case to
                    avoid a vacuous pass.
  * withdrawn   3 — the success-line cases. All three assert the retired
                    program's own stdout summary. A policy module emits findings
                    and says nothing on success (house style §6), so there is no
                    summary line for a successor to carry. The visibility they
                    bought is now the rules' `governed` guards plus
                    `this_repository_is_clean_today`, which fail loudly instead
                    of reporting a count.

Claiming `carried` over a predicate nobody ported would have been exactly
CLOUD-908's recorded failure, so each of the twelve non-carried arms names what
diverged and why rather than being rounded up.

`$MUTANT_GATES` drops the entry; the four `#MUTANT` rows re-home into
`policy/ci-parity.rego` alongside its `#MUTANT-EXEMPT CLOUD-1161`, which is owed
because `mutant` resolves a gate's suite as `tests/$gate.bats` and
`V-SHELL-RULE-ADDED` refuses creating one. `hk.pkl`'s step is replaced by
`cargo-spelling`'s and three stale prose references are repointed.

`cargo-spelling` is shown able to fail in all three arms, both could-not-look
cases included: a drifted task spelling, a task yielding no cargo invocation, and
a tree with no foreign-runner subject at all. A gate that found nothing must not
look like a gate that passed.

Not in this commit: `bench/suites/RESULTS.md`. It is regenerated last, after
`test:bats` runs over the deleted tree — `suite-bench.sh` records the hazard of
doing it the other way round, where a report older than the tree still names a
retired suite.

Refs: CLOUD-1161
…ubject

A regression my own preset introduced, caught by the full bats run rather than
by any tier I wrote: `tests/prebuilt-lint.bats`'s case
"a prebuilt install-action step is not a violation" went red on
`.github/workflows/t.yml workflow-declares-a-concurrency-group`.

WHY IT REACHED THAT SUITE AT ALL, which is the part worth recording. The retired
gate was a TASK pointed at one directory, so it only ever judged the real tree.
Ported into a config rule, the predicate now travels with `batten.toml` into
every fixture tree that copies it — and `prebuilt-lint`'s deliberately does,
because its header says the fixture must judge "this commit's engine and this
commit's config as the pair that ships". That widening is inherent to the port
and I did not anticipate it.

That fixture is therefore a canary for a property worth having: THE SHIPPED
RULESET MUST NOT REFUSE AN ORDINARY MINIMAL REPOSITORY. An unconditional "every
workflow declares a concurrency group" does refuse one, and for a preset that
ships to every consumer that is too much.

THE NARROWING, and it is principled rather than a fudge. A group is required
where two runs in flight are two ANSWERS TO ONE QUESTION — `pull_request`,
`issue_comment`, `workflow_run`, `schedule` — the same pull request, the same
comment thread, the same upstream run, the same recurring job. A `push`-only
workflow is the one case where that does not hold: each run is keyed to a
DIFFERENT commit, so two runs are two subjects rather than two answers, and
superseding is a cost preference rather than a correctness property.

Every measured instance of the original defect is inside the narrowed set. The
one the property was built for — N concurrent comment invocations running N
concurrent attempts to advance the trunk, at 245 refusals against 6 merges in
half an hour — is `issue_comment`. What is given up is push-only coverage, and
this tree has no push-only workflow lacking a group, so no live verdict moves.

The ledger arm moves with it: "the concurrency property judges every workflow,
not only the pull_request ones" is now `// changed:` rather than `// carried:`,
naming what narrowed and why. Rounding it up to carried would have been the
CLOUD-908 failure this PR exists to avoid — 85 carried, 7 changed, 6 subsumed,
3 withdrawn.

MEASURED
  * `mise run policy-test`: 25 bundles, 330 passed, 0 failed.
  * `crates/batten/tests/ci_hygiene.rs`: 20 passed, including
    `this_repository_is_clean_today` — the real 24 workflows still satisfy the
    narrowed rule, so nothing in this tree changed verdict.
  * `tests/prebuilt-lint.bats` "a prebuilt install-action step is not a
    violation": green.
  * A push-only workflow is now asserted clean, which is the discriminating case
    the narrowing exists for.

The other failure in that run, `config-deprecations`'s "no release tag to
compare against; fetch tags", is this container having zero tags and is not
this change.

Refs: CLOUD-1161
`suite-bench-check` is bidirectional set equality against
`git ls-files 'tests/*.bats'`, so the corpus had to be regenerated from a
report that post-dates the two deletions. Regenerated rather than hand-edited,
per the file's own instruction: `mise run test:bats` first, then
`mise run suite-bench --write`.

142 suites, 797.3s serial on this machine.

MEASURED, as a RATIO INSIDE ONE RUN — which is the only comparison the data
supports. `main`'s own corpus, one report on one machine, records:

  tests/derived-check.bats      221.8s   15.4%
  tests/ci-local-parity.bats     53.6s    3.7%
  ------------------------------------------
  removed                       275.4s   19.1%  of that run's 1440.9s

So the retirement removes ~19% of the serial corpus. The cost is removed
rather than moved: the successors are `crates/batten/tests/surface.rs`'s
set-equality assertion (0.01s), two policy modules whose tiers run under
`test:cargo`/`policy-test`, and one `mise.toml` task.

TWO CORRECTIONS TO THE FIGURE THIS COMMIT FIRST CARRIED, both of which made
the result look better than the evidence allows.

First, it subtracted across machines: 1219.4s (the issue body's measurement)
minus 866.2s (this container) was reported as a -353.2s saving, and the 8.8s
by which that overshot the billed 344.4s was explained away as run-to-run
noise. The two numbers never shared a baseline, so neither the difference nor
the explanation meant anything. `main`'s corpus recording 1440.9s for a LARGER
suite count is the proof. The same objection applies to 1440.9s vs 797.3s
here, which is why that subtraction is not stated either.

Second, the share is smaller than CLOUD-1145/CLOUD-1161 predicted — 19.1%
against the 28.2% those rows derived from a 1219.4s, 145-suite corpus. Nothing
regressed: the corpus grew to 1440.9s across 144 suites between that
measurement and now, so the same two suites are a smaller fraction of a bigger
whole. Stated because a retirement whose headline number shrinks is exactly
the result worth not omitting.

Refs: CLOUD-1145
…the registry

Two of the preset's predicates were DEAD. `cache-warm-compile-is-guarded`'s
missing-step-id arm and `interpolation-is-not-swallowed` both read their regex
from `data.batten.patterns[...]`, which a preset can never resolve.
`policy.rs`'s own exemption comment says why, and says it is not a gap to be
closed:

  "A preset is compiled in; a consumer cannot add a `[[pattern]]` row on its
   behalf, and the preset cannot read one — so refusing it would make a
   vendored bundle unloadable with no fix available."

So the lookup was undefined for every consumer, Rego read undefined as "does
not hold", and both rules gated nothing while reporting clean. A dead gate and
a clean tree are byte-identical on the decision surface.

THE HARNESS WAS HIDING IT, which is the part worth recording. An earlier
revision of `ci_hygiene.rs` declared those three ids in the fixture's own
`Vocabulary` — so the compiled-binary tier, whose whole purpose is proving the
engine builds the input the predicate reads, was supplying input no consumer
supplies. Its deny cases passed for the wrong reason. That table is now `&[]`,
which is what a consumer hands a preset, and it is what fails these cases if
anyone respells a literal as a registry lookup again.

SHOWN ABLE TO FAIL, both arms:
  registry lookup + no consumer patterns -> `every_shipped_preset_passes_its_
    own_suite` fails, naming test_a_guard_naming_a_missing_step_id_is_refused
    and test_an_unquoted_hash_that_swallows_an_interpolation_is_refused
  inline literal  + no consumer patterns -> preset suite and the compiled tier
    both pass

Rule 1 still binds the three literals and they hold: two are YAML's own comment
and interpolation syntax, one is GitHub Actions' `steps.<id>.outputs.cache-hit`.
None names a consumer.

The three `[[pattern]]` rows are dropped from `batten.toml` as orphans.
`mise-run-task` stays — `policy/ci-parity.rego` is an in-repo consumer module
and the registry is correct for it.

Refs: CLOUD-1161
`inline-task-bodies-not-growing` counts `run = '''` in mise.toml against
origin/main. This branch raises it by one, 31 -> 32, and the body is
`cargo-spelling` — the single predicate of `ci-local-parity`'s forty that did
not become Rego.

NEITHER ROUTE THE ROW'S OWN `no_fix_reason` NAMES IS OPEN AS A FIX HERE.

"Migrate the predicate onto a rule kind" is refused by `RuleKind::scopes`,
which pairs every spawning kind with `RuleScope::Tree` alone: `cargo-spelling`
reads `test:cargo`'s effective body through `mise tasks info`, so a module
would have to parse mise.toml a second time — a second authority over a task
graph mise already owns, which is the retired program's own stated objection
and not something to override while porting it.

The other home, a `mise-tasks/*.sh` file task, does not avoid a ratchet either:
it grows `bash-surface-not-growing` instead, and adds net-new authored shell to
the exact surface CLOUD-843's campaign — and this branch — is retiring. That is
the same increase one row over, bought by running the campaign backwards.

So waiving is the row's other sanctioned answer, taken deliberately rather than
as an escape from it.

EXPECTED TO LAPSE UNUSED, as the `tests-not-deleted` waiver says of itself:
`base = "origin/main"`, so the floor becomes 32 the moment this lands and this
row suppresses nothing thereafter. It exists to get one commit past the gate,
not to stand. The two-week expiry is the mechanism — CLOUD-1137 is the row that
would give this predicate a counted home, and a lapse makes someone re-read the
decision instead of inheriting it.

Refs: CLOUD-1161
…cy engine

The last of `ci-local-parity`'s forty predicates, and the one CLOUD-1161
planned to keep as a mise task. Two gates closed that route, and neither is
worked around here:

  `inline-task-bodies-not-growing` counts `run = '''` in mise.toml against
  origin/main, and a new task raised it 31 -> 32.

  `config-lint` then refused the waiver — correctly. Its admission is a
  `Weakens` clause groomed into the issue BEFORE the claim, copied into the
  branch's claim receipt at claim time; nothing an author writes at PR time
  reaches it. The claim predates the clause, and re-claiming to mint a fresh
  receipt is refused too, since `claim-check` exits non-zero on `assigned` and
  `has-pr`.

So the task is gone and the predicate is `foreign-cargo-is-the-declared-
spelling` in `policy/ci-parity.rego`. `run = '''` is back to 31, which is what
removes both findings rather than suppressing either.

WHAT THE PORT COSTS, stated rather than dissolved. The retired program objected
that a second reader of the task body is a second AUTHORITY over a graph mise
owns, and that objection is real. It is affordable here only because the two
readings are the same bytes: `test:cargo` carries no template, no `depends`
body and no argument substitution, so `mise tasks info --json`'s `.run` IS the
manifest string. The bound is exactly that, and `V-TASK-CARGO-UNREADABLE` is
the arm that surfaces the day it stops holding. A module cannot spawn
(`RuleKind::scopes` pairs every spawning kind with `RuleScope::Tree`), so the
alternative was not a better reading — it was no gate at all.

Faithful to the shell in the three ways that decide cases: line-based across
workflow files rather than job-based, `--no-run` exempt from the comparison AND
outside the anti-vacuity term, and could-not-look refused on both sides rather
than reported clean.

BOTH FIXTURES GAINED THE SUBJECT, because neither carried one. The module's
`sound_input` had no workflow lines and the compiled fixture had neither a
`test:cargo` task nor a foreign leg, so the new rule would have been inert over
every existing case — clean because nothing was looked at. That vacuity is only
visible in the compiled tier: `line_sources` failing to declare the workflows
leaves every deny case passing green.

THE COMPILED TIER NEEDED A VERDICT-BEARING HELPER. `findings` returns
`(path, line)`; the token is not on `Finding` at all, because `Violation`
carries it and by the time a `Finding` exists it is gone (CLOUD-1120). Every
case here is a different token over the SAME file, and the `--no-run` case has
to show one firing while another does not, so the file's existing
"is anything refused" shape cannot discriminate them. `verdicts_raised` reads
`Scan::classes` instead.

Seven `conserves` case arms named `mise.toml` as their successor and now name
`crates/batten/tests/ci_parity.rs`. Left alone they would have been `carried`
arms pointing at a task that no longer exists, which is CLOUD-908's recorded
failure. The module doc block asserted "it stays a mise task" in two places and
is corrected.

policy test: 25 bundles, 334 passed, 0 failed. cargo: 3225 passed, 0 failed.

Refs: CLOUD-1161
…learned

CLOUD-1218's last acceptance bullet, and the only part of that row this change
is honest enough to close. A refusal already says a floor is "observed on
<head> rather than declared", which tells a reader the number was learned and
still leaves them nowhere to go: it lives in a file no message mentions.

The omission has a measured price. On another container an agent read the
refusal, reached for the `rm -rf target` it DOES name, and paid a full cold
rebuild — 21519MB and ~20 minutes — when deleting the journal alone would have
sufficed at the first refusal. On this one I did the same thing one directory
down and wedged the loop for hours.

Pointer, not payload (rule 4): the path, only when the floor in force is
observed rather than declared.

TWO LARGER REPAIRS WERE ATTEMPTED AND BOTH ARE REFUTED. They are recorded in
`basis_of`'s doc comment and on the row rather than left for the next author to
rediscover, because each looks obviously right until it is run.

The first was to bound the ratchet: refuse a WARM observation at or above the
declared COLD floor, on the reasoning that a warm lap cannot cost more than a
full rebuild. `a_warm_laps_consumption_does_not_raise_the_cold_floor` refutes
it — that case deliberately drives a warm lap consuming 22000MB against a
14000MB cold declaration, because a warm observation UNDER the cold declaration
cannot discriminate a shared ratchet from a per-basis one, and its comment
records that as a surviving mutation. The premise also confuses two units: the
declared floor is a free-space budget, not a ceiling on consumption.

The second was to narrow `basis_of` from `.any()` to `.all()`, so one emptied
profile makes the tree cold. That does not reach the measured instance:
`directories_named` only yields directories that EXIST, and the reclaim an
agent actually performs REMOVES `target/debug/deps`, which drops that profile
out of the list and leaves a populated `target/release/deps` reading warm under
either quantifier. A test written for it went red and is what proved this.

What would close it is a signal for a profile that SHOULD carry `deps` and does
not — a claim about cargo's layout this function does not currently make, and
not one to guess at inside a row that is already someone else's.

Refs: CLOUD-1218
…hers survive

CLOUD-1218, and the repair the row exists for rather than the reporting half.
Three containers in two days wedged their own landing loop here: a lap refuses
on disk, the agent reclaims inside `target`, the rebuild that follows is
recorded as a WARM lap, and the warm ratchet learns a full rebuild's demand —
after which every lap refuses against a floor nothing on the box can satisfy.

WHY THE OBVIOUS NARROWING DOES NOT REACH IT, stated because I shipped it first
and it went red. `basis_of` read the `deps` directories that EXIST and asked
whether ANY was populated. Requiring EVERY one changes nothing for the measured
case: the reclaim an agent actually performs REMOVES `target/debug/deps`, so
that profile drops out of the walk entirely and the surviving
`target/release/deps` satisfies either quantifier. A removed directory is
invisible to a question asked over the directories that are there.

`.fingerprint` IS WHAT MAKES THE ABSENCE VISIBLE. Cargo writes one per profile
it has built and leaves it behind when `deps` goes, so "is there a profile that
has been built and now has nothing to build on" is answerable from the tree.
Every `.fingerprint`'s sibling `deps` must be populated, or the next build for
that profile writes everything — which is what `Basis::Cold` means.

It needs no guess about which profile the caller will build next, which is the
guess this function refuses to make and still refuses. And it is the same KIND
of claim about cargo's layout that looking for `deps` at all already is — one
directory over — not a new authority. That is where I first talked myself out
of it, wrongly.

NO `.fingerprint` ANYWHERE FALLS BACK to the older reading, so a tree cargo has
never fingerprinted is judged exactly as before. That keeps this a narrowing
rather than a new requirement: every fixture that writes a bare `deps`, and any
consumer whose layout this does not describe, is untouched.

The direction is what makes it safe: it can turn a warm reading cold and never
the reverse, and cold is the stricter floor, so the failure mode is a lap held
to a larger budget than it needs rather than one taught a number nothing can
satisfy. The first is a delay; the second is the wedge.

Shown able to fail (CLOUD-418), with the discriminating case the existing
coverage cannot express: `a_tree_emptied_by_something_other_than_the_
escalation_is_still_a_cold_one` deletes `deps` on a ONE-profile tree, so the
walk comes back empty and any quantifier answers cold. The new case keeps a
second profile intact, which is what made the old reading say warm. Both
anti-vacuity twins are there — every built profile intact is still warm, and a
never-fingerprinted tree still takes the fallback — because without them the
repair is satisfied by calling every tree cold, which raises the COLD floor
instead and fails the same way one bucket over.

NOT DONE HERE, and recorded rather than left: the ratchet still learns whatever
a lap reports. Bounding it was tried and refused — `a_warm_laps_consumption_
does_not_raise_the_cold_floor` deliberately drives a warm lap above its cold
declaration, because that is the only shape distinguishing a per-basis ratchet
from a shared one, and the declared floor is a free-space budget rather than a
ceiling on consumption. With the basis now honest, a cold lap is charged to the
cold ratchet and the warm one is never taught a rebuild's demand, which is the
route the row's own §2(b) says generalises.

Refs: CLOUD-1218
…eachable there

`.claude/rules/policy-modules.md` told every module author that the preset
exemption from the inline-regex refusal is "a hole rather than a design" and to
"write the row" anyway. Following that produces a DEAD GATE, which is strictly
worse than the duplication the registry exists to stop.

A `[[pattern]]` row is consumer config. A preset is compiled in and reaches a
consumer who wrote no rows, so `data.batten.patterns["x"]` resolves to undefined
there, Rego reads undefined as "does not hold", and the rule decides nothing
while loading clean. `policy.rs` says so at the exemption's own site — the demand
is unsatisfiable, because a consumer cannot add the row on a preset's behalf and
the preset cannot read one.

CLOUD-934 predicted this in those words. CLOUD-1161's `ci-hygiene` preset on this
same PR is it happening: three registry reads, two predicates dead for every
consumer, `policy test` green at 330 passed over them. Only
`crates/batten/tests/policy_presets.rs` caught it, because it runs a preset's
suite the way a consumer gets it.

So the paragraph now says what the engine does, and carries the two things a
reader needs beside it: that rule 1 still binds the literal (which is what makes
inline safe rather than merely necessary — a preset ships everywhere, so its
pattern could not name a consumer anyway), and that a compiled tier must hand the
preset an EMPTY vocabulary or its own deny cases pass for the wrong reason. My
harness declared the ids and hid the defect from the tier whose whole purpose is
finding it.

Whether presets should get a vendored inventory of their own stays CLOUD-934's
open question; this only stops the file prescribing the one shape that cannot
work today.

Prose, no mechanism: `rules-drift` holds the input-key lists in this file to the
generated schemas and reaches nothing here. Stated rather than implied, per the
file's own §"What this file does not gate".

Refs: CLOUD-934
… deletion

CLOUD-1216. `suite-bench-check` decides set equality over `git ls-files
'tests/*.bats'` in BOTH directions, and its hk step was globbed on the corpus
and on the suites for exactly that reason — the comment said so. The glob could
only ever deliver the first half: hk selects a step by matching CHANGED PATHS,
and a deleted path is not there to match, so the one commit shape the reverse
direction exists for was the one shape that did not select the step.

Measured on this PR: a commit deleting `tests/derived-check.bats` passed the
full pre-commit gate and left the corpus recording a suite the tree no longer
tracked. It survived four more commits, all green, and was found by running the
task by hand.

SHOWN ABLE TO FAIL THROUGH THE REAL PATH, because the obvious probe does not
discriminate. `hk check --plan` reports this step even for a commit touching
only `crates/**/*.rs`, which neither glob matches — so the plan does not respect
globs, and three selection tests run against it "refuted" the premise and were
themselves worthless. I nearly corrected the row on that basis.

The probe that decides it is a commit: stage a deletion of a tracked
`tests/*.bats` while the corpus still records it, and try to commit.

  with the glob     the commit SUCCEEDS, the step never runs
  without the glob  the commit is REFUSED, naming the stale row

Run both ways on `tests/land.bats` here.

THE COST, measured rather than asserted, because the comment this replaces said
"milliseconds" and a reader reads that as ~10: 531ms, three runs (531/530/531).
That is paid on every commit now, and hk runs steps concurrently so the marginal
wall-clock cost is usually less. It buys a direction that was structurally
unreachable.

The wider class — any predicate about a file's ABSENCE, or about set equality
against a tracked listing — stays CLOUD-1216's to survey; this closes the one
step that demonstrably lost a verdict.

Closes CLOUD-1216
CLOUD-1218's third failure mode, and it is what blocked this branch from
landing across five consecutive `land` laps.

`lap()` computed one basis and used it for two different questions: which
ratchet bucket owns the lap's consumption, and which floor its remaining
free space must clear. The first is rightly the basis the lap opened
under — what a lap cost is a fact about the build that ran. The second is
not: the floor asks whether there is room for the build that comes NEXT.

Measured. A lap opened on an empty `target/` — correctly Cold — and closed
on a fully built tree with 14839MB free. It was refused against the
17357MB cold floor while `verify` had SUCCEEDED inside it: a full cargo
build, the whole cargo suite, all 2491 bats cases, no disk error anywhere.
That floor is unreachable by construction at the close, because building
the tree is precisely what spends the headroom a cold floor demands, so a
cold-started lap refuses at its own close forever however much is
reclaimed first. Every `rm -rf target` recovery opens exactly that lap.

The tree's reading alone cannot decide it, and the first attempt at this
repair got that wrong. `basis_of` reads `deps`, so a lap opened Cold
because an earlier escalation dropped `incremental` has a full `deps` and
reads Warm while its next build really is a full one — the OR that
function's own header describes, whose second half the tree is blind to.
`a_warm_laps_consumption_does_not_raise_the_cold_floor` caught it.

So the discriminator is what the tree read AT OPEN, which `OpenLap` now
carries beside the effective basis. A lap that opened on a cold tree and
ends on a warm one has built it; one that opened on a warm tree under a
standing escalation has changed nothing the tree can show. The field is
optional, so a journal written before it parses rather than resetting a
clone's lap history, and its absence falls back to the previous reading.

CLOUD-861's opposite spiral is preserved by the same rule rather than in
spite of it: a close whose own reclaim escalated opened warm, so it is
judged warm.

Refs: CLOUD-1218
A defect in this branch's own `.fingerprint` reading, found when `verify`
refused its own precondition against a full rebuild's floor on a tree that
had just built cleanly.

`basis_of` walks the configured root unbounded, so it reaches every build
tree NESTED inside it. This repository's suite writes its fixtures under
`target/tmp/<case>/`, and one of them exists precisely to model a profile
whose `deps` was removed. That fixture sits at
`target/tmp/<case>/target/debug/.fingerprint` with an empty sibling, so
"every built profile must have something to build on" found it and judged
the whole repository cold.

The predecessor had the same exposure and hid it: an `.any()` over fixture
`deps` directories that happen to be populated read the tree warm, so the
litter masked rather than refused. Neither is a reading of this build.

Cargo writes `<root>/<profile>/` and `<root>/<triple>/<profile>/`, so the
walk's results are filtered to those two depths. Anything deeper is a
different tree that happens to live here.

Refs: CLOUD-1218
`prune` reached 101 lines against the workspace's 100-line ceiling. The
escalation is the cohesive unit to lift out: it is entered on one
condition, owns its own two-tier rationale, and advances exactly the two
readings the caller's accounting is built on.

No behaviour change — `tests/target_prune.rs` is green across all 61
cases either side of the move.

Refs: CLOUD-1218
@wenzowski
wenzowski marked this pull request as ready for review August 31, 2026 09:39
@wenzowski
wenzowski force-pushed the claude/retire-shell-gates-t77iuk branch from 2b4be4e to 2bdeb78 Compare August 31, 2026 09:39
@sonarqubecloud

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 2bdeb78 into main Aug 31, 2026
10 of 11 checks passed
@wenzowski
wenzowski deleted the claude/retire-shell-gates-t77iuk branch August 31, 2026 10:14
wenzowski added a commit that referenced this pull request Aug 31, 2026
The rebase onto current `main` conflicted here because PR #770 landed and
regenerated the same file for its own three retirements. A generated artifact is
not hand-merged, so the rebase took `main`'s copy and this regenerates it with
`mise run suite-bench --write` over a real `test:bats` run: 2443/2443 cases, 138
suites.

Header moves to 138 suites / 1097.1s, and none of this bundle's four suites
appears. That total is a whole-corpus re-measurement on this machine and is NOT a
savings figure — the claim this bundle makes is the 65.7s its four suites cost
against the 1440.9s / 144-suite corpus they were recorded in.

Refs: CLOUD-1164
wenzowski added a commit that referenced this pull request Aug 31, 2026
`target-prune` refused every `land` lap on this branch, and not for space:
21923MB free against a 17357MB cold floor, while `[prune.warm]` reported
"measured against a tree that no longer exists — declared 128, live 140,
tolerance 10". No receipt written, so no lap could complete.

This branch caused it. It adds three stems under `crates/batten/tests/*.rs`
(`reference_coverage.rs`, `skill_contract.rs`, `config_deprecations.rs`) and
PR #770 added more on `main`, taking the live count 12 past a tolerance of 10.
That is the gate working: the floor it defended was taken against a smaller tree,
and a floor taken against fewer stems fails silently — the check passes, the
build writes more than the basis anticipated, and exhaustion arrives as a rustc
IO error inside a test run. `batten.toml`'s own block records the identical
precedent on 2026-08-30, where a nine-suite bundle re-measured for the same
reason.

`count` and `measured` move together, as that block requires, and both floors
scale by the per-stem model it already states: warm 56.7 x 140 = 7938, cold
135.6 x 140 = 18984. `mb` is validated to equal `worst_mb * multiplier` at load,
so both fields move on each.

140 rather than 141: `git ls-files 'crates/batten/tests/*.rs'` crosses `/` and
catches `tests/common/mod.rs`; the gate's selector does not, and 140 is the
reading the comparison actually uses.

NEITHER FLOOR IS AN INDEPENDENT MEASUREMENT, which the block warns matters. An
honest cold number needs a build from an empty `target` and an honest warm one a
minimal post-prune tree; these are the declared-value-scaled-by-stems derivation
the block sanctions, and a reader needing either exact should take it rather than
trust the scaling.

Refs: CLOUD-1164
wenzowski added a commit that referenced this pull request Aug 31, 2026
The rebase onto current `main` conflicted here because PR #770 landed and
regenerated the same file for its own three retirements. A generated artifact is
not hand-merged, so the rebase took `main`'s copy and this regenerates it with
`mise run suite-bench --write` over a real `test:bats` run: 2443/2443 cases, 138
suites.

Header moves to 138 suites / 1097.1s, and none of this bundle's four suites
appears. That total is a whole-corpus re-measurement on this machine and is NOT a
savings figure — the claim this bundle makes is the 65.7s its four suites cost
against the 1440.9s / 144-suite corpus they were recorded in.

Refs: CLOUD-1164
wenzowski added a commit that referenced this pull request Aug 31, 2026
`target-prune` refused every `land` lap on this branch, and not for space:
21923MB free against a 17357MB cold floor, while `[prune.warm]` reported
"measured against a tree that no longer exists — declared 128, live 140,
tolerance 10". No receipt written, so no lap could complete.

This branch caused it. It adds three stems under `crates/batten/tests/*.rs`
(`reference_coverage.rs`, `skill_contract.rs`, `config_deprecations.rs`) and
PR #770 added more on `main`, taking the live count 12 past a tolerance of 10.
That is the gate working: the floor it defended was taken against a smaller tree,
and a floor taken against fewer stems fails silently — the check passes, the
build writes more than the basis anticipated, and exhaustion arrives as a rustc
IO error inside a test run. `batten.toml`'s own block records the identical
precedent on 2026-08-30, where a nine-suite bundle re-measured for the same
reason.

`count` and `measured` move together, as that block requires, and both floors
scale by the per-stem model it already states: warm 56.7 x 140 = 7938, cold
135.6 x 140 = 18984. `mb` is validated to equal `worst_mb * multiplier` at load,
so both fields move on each.

140 rather than 141: `git ls-files 'crates/batten/tests/*.rs'` crosses `/` and
catches `tests/common/mod.rs`; the gate's selector does not, and 140 is the
reading the comparison actually uses.

NEITHER FLOOR IS AN INDEPENDENT MEASUREMENT, which the block warns matters. An
honest cold number needs a build from an empty `target` and an honest warm one a
minimal post-prune tree; these are the declared-value-scaled-by-stems derivation
the block sanctions, and a reader needing either exact should take it rather than
trust the scaling.

Refs: CLOUD-1164
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