Skip to content

fix(prune): widen the reclaim, close the lap, and record the floor's basis - #756

Merged
wenzowski merged 11 commits into
mainfrom
claude/prune-bundle-w3ndkx
Aug 30, 2026
Merged

fix(prune): widen the reclaim, close the lap, and record the floor's basis#756
wenzowski merged 11 commits into
mainfrom
claude/prune-bundle-w3ndkx

Conversation

@wenzowski

@wenzowski wenzowski commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Closes CLOUD-1157.
Closes CLOUD-861.
Closes CLOUD-1158.

Three rows, in this order because each makes the next satisfiable: the reclaim has to reach the classes that actually grow before a floor can ratchet against them, and the ratchet has to exist before its basis is worth watching.

The branch is keyless on purposeclosing-key-check passes on the first key it finds and branch-name precedence beats the body, so a branch named for one issue would move that issue and strand the other two. All three keys close from this body.

Deliverable 0 — the built-tree split, measured first

Every prior measurement on these rows was taken on a tree holding zero of the 110 integration-test binaries. cargo nextest run --workspace, then target/debug/deps — 15458 MB, 2492 files:

class bytes files share reachable before this PR
extension-less executables 13411.9 MB 114 86.8% yes
.rlib 1235.3 MB 381 8.0% no
.rmeta 472.3 MB 813 3.1% no
.so 336.9 MB 39 2.2% no
.d 3.9 MB 966 0.03% no (and stays out)

This reverses CLOUD-1157's headline, and the correction is recorded on the row: the unreachable share is 13.2%, not 80.6%. The 80.6% figure was an artifact of a tree the suite had never been run on.

CLOUD-1157 — the group key is (stem, kind)

stem_of split on the last - and required the tail to be all hex, so libbatten-<hash>.rlib produced a "hash" of <hash>.rlib, failed on the ., and became its own stem. Every extensioned artifact was a group of one, and nothing in a group of one is ever past keep.

Retention is now per kind, and the kind cannot fold into the stem: libbatten carries 2 .rlib and 6 .rmeta, and one group of eight under keep = 2 can retain two .rmeta and delete the live .rlib — the keep = 0 failure Prune::validate already refuses, arriving through the grouping. The executable bit now gates only the extension-less class.

The escalation's roots move out of the crate into [prune.regrowable], each declaring whether dropping it makes the next cargo build full.

CLOUD-861 — the floor is an invariant, not a precondition

The same verb now runs at both boundaries. verify:gated calls it again as its last content step, closing the lap the head call opened: consumed = start − end + reclaimed_in_between, the reclaim's own megabytes included.

The basis ratchets. A journal under $GIT_DIR — one file, rewritten by rename — records what a lap actually consumed and raises the floor to it, per basis, from the lap after the one that observed it. A lap is judged against the floor it was admitted under, so a machine's first lap is not refused for being the thing that measured it.

It has now run for real, repeatedly, and that is where four of the defects below came from. Four consecutive verify laps went green through the closing reading on this container, measuring 20602, 22163, 22451 and 22196 MB consumed and ratcheting the observed warm floor to each in turn. None of those numbers existed before this PR.

CLOUD-1153 — not folded in, and this is the finding

The first draft carried it: verify reports any non-zero from target-prune as "not enough disk", so a config refusal (1) or a could-not-look (3) is reported as a full volume. Three codes, three messages.

It cannot be written in that body. tests/verify.bats's "a volume that cannot be recovered is a STOP, not a lap" drives the mapper with target-prune exiting 1 and asserts the output carries "not enough disk" — so the classification turns a standing case red, and the only fix inside that shape is an edit to a bats suite. V-SHELL-RULE-EDITED refuses that with no override route and no bypass_env.

That is the gate working: CLOUD-1153 is a row whose §1 is written in the wrong shape. The guard is back to its landed one-way form byte for byte, the comment above it carries the finding, and the row stays on the board.

And the row was then observed live, twice, which is worth more than folding it in would have been. A rebase pulled a new authority config variant onto the branch; the installed binary predated it, refused batten.toml as a parse error, and verify reported "not enough disk" with 24 GB free — the wrong remedy, exactly as the row describes.

CLOUD-1158 — a floor records the basis, not only the date

measured is a pointer to a basis nobody wrote down. Floor now carries a glob, the count it held when the floor was measured, and a tolerance.

The comparison is not at config load, and that is the row's §2. config.rs calls Prune::validate on the path every batten hook invocation pays. Shape validation stays at load; the count runs in batten target prune on Surface::VerifyOnly.

And neither is the absent-basis refusal, which is a correction to the row. basis began as a required field, and a required new config key makes its own PR unlandable: config-lint loads origin/main:batten.toml with the working tree's binary — house style §8's out-of-band load — and the base ref has no such key, so the load fails with missing field and the gate reports could-not-look rather than a verdict. Measured with the whole of the rest of verify green. The type takes Option and the refusal moves to the surface §2 already put the comparison on; it is still exit 2, still names the floor and the date that points at nothing.

Six defects found by running it, not by reading it

Three walk-up defects. git::git_dir and git::open both walk up, so a fixture under target/tmp/ reached the enclosing repository's state — one journal shared across every case (a fabricated 99999 MB reading wrote a ratchet the next case judged itself against), and a basis count of 189 for a fixture declaring 1. The authority is ./.git, for the reason ./batten.toml is the config's.

A fourth was a surviving mutation: OpenLap carried the floor it was admitted under, a second authority on a number the ratchet already answers. Blanking it changed no verdict, which is the only way a redundant conjunct announces itself.

The last three are the live ones, each found by a lap and each fixed with a case shown able to fail:

  • The basis was read from this run's escalation alone, so a tree emptied by anything else was invisible. A hand deletion of target/debug to satisfy the floor made the next lap build 110 test binaries from nothing, and the journal recorded that 21226 MB cold lap as the worst warm one on record — the ratchet learning the wrong number, not merely printing the wrong word. An empty or absent deps is now the signal, OR'd with the escalation's flag. Seven fixtures went red on the change: each created target/debug/deps and stopped, declaring a cold tree while asserting a warm verdict.
  • The escalation line reported the wrong basis. The basis-of-record rule redefined Outcome::basis as the basis the closed lap ran under, which is right for the floor lines and wrong for a claim about the next build. A close that dropped incremental printed "none of those roots is the cargo build's basis, so the next build is still warm" while the journal recorded the next lap as cold; the run after it was refused against 14914 MB with nothing in the output connecting the two. Outcome now carries next_basis beside basis.
  • The escalation took every root at once, so a run needing 2 GB dropped incremental too — and that is what makes the next cargo build full, raising the floor it must clear from 6242 MB to 14914 MB on a tree a full lap leaves at ~8.4 GB. The escalation created the demand that refused it. Now the rows costing only their own next run go first, free space is re-read, and the basis-moving rows are taken only if the floor is still breached. The first form of this fix keyed on journal.open.is_some() and the suite refused it: every run after the first closes a lap and opens another, so it would have disabled the basis escalation permanently.

Verification

crates/batten/tests/target_prune.rs, over the compiled binary — 51 cases, up from 35. Every new case is shown able to fail (CLOUD-418) by mutating the conjunct it pins:

mutation reds
empty kind list the extensioned retention, the collision case
stem-only key the collision case
every root basis-moving the warm-basis escalation
no closing reading all five lap cases
the standing ratchet never binds the ratchet case
one ratchet for both bases the basis-not-shared case
consumption ignores the reclaim the arithmetic case
the basis is never compared the drift case, the placement precondition
the tolerance is ignored the tolerated case
an empty glob loads clean the absent-basis case
an ancestor's index the no-index case
basis_of pinned to warm the emptied-tree case
the undeclared arm skips the undeclared-basis case
the escalation keyed on the judged basis the closing-escalation case
the cheap tier takes the basis roots four cases

Two mutations survived their first form and are recorded as such: one named a conjunct another already excluded, and one named a redundant field — the second is the OpenLap defect above, and the fix is the removal.

@linear-code

linear-code Bot commented Aug 30, 2026

Copy link
Copy Markdown
CLOUD-1157 `stem_of` cannot group an extensioned artifact, so `.rlib`, `.rmeta` and `.so` are never superseded however many copies accumulate — and three regrowable roots are outside the walk entirely

Why

CLOUD-766's acceptance §1 — "target/ does not grow monotonically across laps" — does not hold, and this is the residue. Filed as its own row rather than as a comment on CLOUD-766 because CLOUD-475 settled that a finding against a Done row is stranded there, and because this is a different defect from CLOUD-861's (a floor checked at the wrong moment) — this one is the reclaim's coverage.

Corrected 2026-08-29, same day, before dispatch. The row shipped titled around a 78% figure and both halves of that were wrong. Arithmetically the unreachable share of the table below is 80.6%, not 78% — the original divided by rlib+rmeta+noext and silently dropped .so and .d. More seriously the fraction was measured on an unrepresentative tree and should never have been the headline: see the caveat under the table. The title now names stem_of, which is a property of the code rather than of whichever tree you measure.

The measurement

target/debug/deps on a Claude Code web container, 2026-08-29, after two SHAs' worth of building:

class bytes files reachable by reclaim_superseded
.rlib 794.7 MB 270 no
.rmeta 345.2 MB 644 no
extension-less executables 315.7 MB 2 yes
.so 171.1 MB 20 no

Read the caveat before the table. target/debug/deps on this container held zero of the 110 integration-test binaries — the only two extension-less executables are both batten-<hash>, the lib test target, because the suite had not been run here. So the reachable class is almost entirely ABSENT from this reading, and the 19.4% / 80.6% split below is an artifact of that rather than a property of the pass. CLOUD-766's measurement on a built tree is the counter-reading and it points the other way: 283 files over 40 MB in deps totalling 16.6 GB, ~41 test stems at 6 copies each, 13.2 GB reclaimable — the pass reaches that class fine. A representative split is unmeasured and is this row's first deliverable (cargo nextest run --workspace, then re-read deps).

What does not depend on which tree you measure, and is the whole of this row:

  1. stem_of cannot group any name carrying an extension, so .rlib, .rmeta and .so are never superseded no matter how many copies accumulate. Structural, provable from the code, and visible above.
  2. .rmeta is the fastest-accumulating class — 644 files against 270 .rlib and 2 executables — because cargo check and clippy write metadata without producing a binary or a test executable, and verify runs both every lap. libbatten alone carries 6 .rmeta against 2 .rlib. That is the per-lap growth term, and it is exactly the class the grouping cannot touch.
  3. Three regrowable roots are outside the walk entirely (§3 below), on any tree.

1. The executable filter, and its stated ground is refuted

reclaim_superseded (crates/batten/src/prune.rs) takes only executable regular files directly under a deps directory. The module header gives the reason: ".d, .rlib and .rmeta are left alone: they are small, and a dangling one only makes cargo rebuild."

They are not small, and the per-lap component is real rather than a static tail. Both copies of the workspace's own library were resident in the reading above:

libbatten-42061777d57a0311.rlib   131 MB   Aug 29 20:42
libbatten-f8edd060dc0f6832.rlib   112 MB   Aug 28 02:32

A lap rebases, which mints a new SHA, which changes the workspace crates' metadata hash — exactly the mechanism CLOUD-766 documented for test binaries. It applies unchanged to libbatten.rlib and its .rmeta, and nothing bounds either.

2. stem_of cannot group an extensioned artifact — a latent bug, not a policy

let Some((head, hash)) = name.rsplit_once('-') else { return name };
if hash.len() >= 8 && hash.chars().all(|c| c.is_ascii_hexdigit()) { head } else { name }

libbatten-42061777d57a0311.rlib splits to hash = "42061777d57a0311.rlib". The . fails is_ascii_hexdigit, so the whole filename becomes the stem, every extensioned artifact is a group of one, and nothing in a group of one is ever past keep.

This is the ordering constraint for any fix. Widening the is_executable filter on its own reclaims zero bytes, because the grouping that retention depends on cannot form. It also silently exempts the 171 MB of .so today, which does pass the executable filter and is nonetheless never superseded.

3. Three regrowable roots are outside the walk's reach entirely

Not superseded artifacts — caches of incremental's kind, which the escalation is the right home for and which it does not currently name:

path measured
target/semver-checks 2.6 GB (CLOUD-861, second session; 1.3 GB on its first)
target/perf 401 MB (CLOUD-861)
target/debug/build 156 MB (this reading)
target/flycheck0 small here, unbounded in principle

CLOUD-861's own follow-up already names semver-checks as "a third category the reclaim currently does not consider". It is recorded there against a row about the floor's timing, where it does not belong; it belongs here.

Why it is worth a row

The reclaim is the thing that makes CLOUD-861's floor satisfiable, and the ground is the regrowable roots rather than a percentage (corrected 2026-08-29). A ratcheting floor needs reclaimable headroom to ratchet against, and semver-checks at 2.6 GB plus perf and build are unreachable by any pass today — so a floor that ratchets before this row lands climbs toward a number nothing can satisfy — which batten.toml's own [prune] comment already names as the failure that gets a gate switched off. So this row blocks CLOUD-861 rather than merely relating to it.


Refinement — Ready (widen the reclaim to the classes that actually grow)

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

  • **Source of truth (§1). **crates/batten/src/prune.rsstem_of, reclaim_superseded, drop_incremental — and the [prune] table in batten.toml for the roots, which are the consumer's. mise-tasks/target-prune.sh no longer exists: it was retired whole under CLOUD-1059 and the predicate is the crate's. A change touching mise-tasks/** or tests/**/*.bats to close this is refused by V-SHELL-RULE-EDITED with no override and no bypass_env.
  • **Computable predicate (§2). **batten target prune → exit code and a byte count over the tree, no network, no build. Three parts, in this order because the first is load-bearing for the second:
    1. **The group key becomes **(stem, extension), and retention is per-kind. stem_of strips a known artifact extension before the hash test so libbatten-<hash>.rlib can group with its siblings, and the extension is then carried as part of the key. The existing guard against eating a suffix that merely contains a dash stays.

      Grouping on the stem ALONE is unsafe and would delete a live artifact — corrected 2026-08-29 before dispatch, because the row shipped specifying exactly that. Measured in deps: libbatten has 2 .rlib and 6 .rmeta. Collapse the extension and that is one group of 8, where keep = 2 can retain two .rmeta and delete the **live **.rlib. That is the keep = 0 failure Prune::validate already refuses, arriving through the grouping instead of through the count.

    2. The superseded pass covers .rlib, .rmeta and .so under the same keep retention, per kind. .d files stay out — 2.8 MB across 666 files here, so the whole class is below the noise.

    3. The escalation gains a declared list of regrowable roots beside incrementalsemver-checks, perf, flycheck*, build. Declared in [prune], never a constant in the crate, per non-negotiable rule 1.

      Each root declares whether dropping it moves the basis, because they do not all cost the same thing — corrected 2026-08-29; the row shipped saying every root moves the basis to Cold "exactly as incremental does", and that over-refuses. Basis::Cold means the next cargo build is full, and the cold floor (14914 MB) is budgeted for precisely that. Dropping incremental or build creates that demand; dropping semver-checks, perf or flycheck* makes only their own next run cold and leaves the cargo build warm, so marking the basis Cold there judges the lap against a demand that will not arrive.

  • Effect (§3). No new verb and no new surface. batten target prune already carries Cost::Effect on Surface::VerifyOnly and Effect::Destructive, and stays unreachable from the mediated call.
  • Output & exit (§5). Pointer-only per non-negotiable rule 4, unchanged in shape: a file count, bytes reclaimed, bytes free, the floor and its basis. Never a path listing — that is unbounded, and a caller who wants one runs du.
  • **Commit / bump (§6). **fix(prune)patch until 0.1.0. The workspace is 0.0.130, so release-plz bumps the patch whatever the type says. Crate source, so a release does move — unlike the task-runner era this predicate came from.
  • **Test obligation (§7). **crates/batten/tests/target_prune.rs, the compiled-binary tier, since a with input as-style fixture cannot prove the walk reaches a real directory. Shown able to fail (CLOUD-418):
    • an extensioned stem with keep + 1 copies of ONE kind loses exactly one, newest-first — red today, because the grouping cannot form;
    • the collision case, and it is the one that would ship a data-loss bug: a stem carrying 2 .rlib and 6 .rmeta retains 2 of EACH, and the newest .rlib survives. Red under a stem-only key, which is what the first draft of this row specified;
    • a declared regrowable root is dropped only below the warm floor, and moves the basis to Cold only if it is declared as basis-moving; a root declared otherwise leaves the basis Warm;
    • anti-vacuity twins: a .d file is untouched; a stem with a single .rlib copy is untouched; a declared root above the floor keeps its bytes. Without these the fix is just "delete more", which costs a full rebuild every lap.
    • a_non_executable_file_beside_the_artifacts_is_left_alone (target_prune.rs) currently pins the narrow scope as intended behaviour and changes with this row. That it exists is the evidence the scope was a decision rather than an oversight, which is why this row argues against the reason rather than calling it a bug.
  • Blockers (§8). None. blocks CLOUD-861 (whose ratcheting floor is unsatisfiable over a reclaim this narrow). relatedTo CLOUD-766 (the row whose acceptance §1 this residue is), CLOUD-1030 (the two-floor repair this builds on), CLOUD-1031 (see below).

Deliberately out of scope, decided rather than deferred. /tmp/bats-run-* held ~470 MB of stale scratch in CLOUD-861's measurement, and it is outside [prune].root and outside the repository. A repo tool that unlinks paths outside the repo root is a blast-radius change, not a coverage one, and the disk-saturation posture it belongs to is CLOUD-1031. Recorded here so the next reader does not re-find it and assume it was missed.

Acceptance

  • A stem whose copies carry an extension is subject to keep retention, and a fixture with keep + 1 copies of an .rlib loses exactly one.
  • The escalation names its regrowable roots in [prune], each declaring whether dropping it moves the basis.
  • Only artifacts that will not be rebuilt as a consequence are removed by the superseded pass; the escalation's roots are all regrowable and are dropped only below the floor.
  • A stem carrying two artifact kinds retains keep of each, and no live artifact is removed.
  • A declared regrowable root moves the basis only where it is declared basis-moving; the others leave it Warm.
  • The built-tree split is measured and recorded on this rowcargo nextest run --workspace, then re-read target/debug/deps — so the reachable share is a number rather than an inference from a tree the suite had never been run on.

Provenance. Found while diagnosing why mise run land still exhausts the disk when it laps, with batten target prune in place and released in v0.0.129. Measured on a Claude Code web container, 2026-08-29: allowance ~38 GB (df reports a 258 GB device; used + available = 37939 MB), target/ at 3.5 GB after two SHAs.

CLOUD-861 `target-prune` checks the disk floor once per lap, before the phase that exhausts it — and its own budget comment is falsified by 3x

Why

CLOUD-766 shipped target-prune against exactly this class and is Done. Measured 2026-08-21, the class recurs. Both halves of that row's title were true again — the disk fills mid-lap, and the exhaustion was reported as a test failure.

Groomed 2026-08-29 — the Refinement block below is rewritten; the Why and both session reports are untouched.

**Its §1 named a file that no longer exists. **mise-tasks/target-prune.sh and tests/target-prune.bats were retired whole under CLOUD-1059; the predicate is crates/batten/src/prune.rs and its second tier is crates/batten/tests/target_prune.rs. A plan written from the old §1/§3/§6/§7 sends an implementer at mise-tasks/**, which V-SHELL-RULE-EDITED refuses with no override and no bypass_env — so it read as a blocked row when it was a row written in the wrong shape.

Two findings were carried here that are not this row's, and both now have their own. The reclaim's coverage is CLOUD-1157 — whose claim is that stem_of cannot group any name carrying an extension, so .rlib, .rmeta and .so are never superseded however many copies accumulate. That row shipped with a 78% headline and it has been corrected: the figure was both mis-divided and measured on a tree holding zero of the 110 integration-test binaries, so it said more about the tree than about the pass. The floors' basiskeep × stems × size, where stems went from ~41 to 110 and CLOUD-843 adds one per retired gate — is CLOUD-1158. This row keeps the one defect it was filed for: the floor is checked at the wrong moment.

§2 acceptance is now met and §3 is not. The escalation reaches incremental (observed twice, second session below). The budget comment moved but became x1, so the floor is exactly the worst observed lap and a lap equalling it breaches by construction — which the rewritten §2 replaces with an observed, ratcheting number rather than another constant.

Re-measured 2026-08-29 on a Claude Code web container: the writable allowance is ~38 GB, not the 258 GB df prints (used + available = 37939 MB). The declared [prune.cold] floor of 14914 MB is 39% of the whole allowance.

Moved to Todo and unassigned, 2026-08-29, so the bundle can claim it. The In Progress + assigned state was an artifact rather than a pull: this row, CLOUD-766 and CLOUD-778 all flipped out of Done at the same timestamp (2026-08-29T00:57) — a sweep. Left as it was, batten claim check raises not-todo and assigned (both Kind::Competitor) and a bundle child skips the middle ticket of its own chain. --takeover is the wrong instrument for that; it exists for a stale competitor, not for the person steering the campaign. The blockedBy edge still keeps this row off the frontier until CLOUD-1157 lands.

Scope narrowed 2026-08-21. The second half is fixed and landing in PR #626 (ci(land): a full disk is the environment, not a verdict on this tree): land now classifies ENOSPC and names the reclaim instead of saying "reproduce and fix locally". Two bats cases, the discriminating one red without it. **What remains on this row is the floor, and it lives entirely in **mise-tasks/target-prune. The diagnosis half is kept below as the evidence that produced the measurement, not as open work.

Filed as its own row rather than as a comment on CLOUD-766 because CLOUD-475 settled that a finding against a Done row is stranded there.

The measurement

One mise run land lap, this container:

target-prune: 0 superseded artifact(s) removed, 0MB reclaimed, 6242MB free (floor 4096MB)
   ... verify → cargo test ...
rustc-LLVM ERROR: IO failure on output stream: No space left on device
::error:: land: verify failed on 9a3e3ef (exit 1). Reproduce and fix locally;
          CI is not where you discover this.

df afterwards: 96M available, 100% used. The prune passed with 6242MB against a 4096MB floor, and the cargo test build inside the same lap consumed all 6GB.

Defect 1 — the floor is a precondition, never an invariant

target-prune runs once, at lap start, and answers "is there enough room to begin?" Nothing re-checks during the phase that actually consumes the disk. So the floor is structurally unable to catch a build whose growth exceeds the headroom the check just certified — which is not an edge case here, it is the normal shape of a cargo test link step after a rebase onto 14 commits.

The check is not wrong, it is in the wrong place: a precondition where an invariant is needed. Either the floor has to account for the build about to run (headroom, not free space), or the prune has to be reachable from inside the phase that exhausts.

Where the space actually was, after the failure, all of it rebuildable:

path size
target/debug/incremental 3.8G
target/semver-checks 1.3G
target/perf 401M
stale /tmp/bats-run-* ~470M

Deleting those returned 5.9GB. target-prune's own predicate — superseded artifacts — reclaimed 0MB while 3.8G of incremental cache sat there, because incremental state is not superseded by anything; it is simply unbounded. That is the gap in one line.

Defect 2 — the diagnosis was wrong (FIXED, PR #626)

CLOUD-766's title already names this: "the exhaustion is reported as a test failure." It still is. land said "verify failed … Reproduce and fix locally; CI is not where you discover this" — advice that is correct for a real failure and actively misleading for this one. There is nothing to reproduce and nothing in the diff to fix.

Same shape as CLOUD-811 (*"reports every failure as a linearity verdict, so a stale toolchain stopped a landing under a message about *main"): a task attributing every non-zero exit to the thing it is about. ENOSPC is distinguishable — rustc-LLVM ERROR: IO failure on output stream: No space left on device is a literal, and df is one call — so this is a classification a gate can make, not a judgement.

The floor's own budget comment is falsified, and by 3x

mise-tasks/target-prune carries its justification inline, in the form the repo requires:

FLOOR_MB=4096 # budget: worst-lap=2048mb x2 measured=2026-08-20

So the floor is twice the worst lap anyone had measured — a day before this. This lap consumed 6242MB, three times that worst-lap figure and half again the floor derived from it. The doubling was not too small a safety factor; the number it doubled was wrong.

That matters more than picking a bigger constant: the comment is the mechanism that makes the floor auditable, and a stale measurement inside it reads exactly like a fresh one. Whatever this row lands, worst-lap has to be re-measured rather than scaled.

Why it is worth a row

The container exhausted its disk three times in one session. Each cost a full lap: a rebase, a partial verify, and a wrong diagnosis to read past. At the fleet's measured cadence a lost lap is ~15-20 minutes of a fleet-wide lease that only one branch can hold.


Refinement — Ready (the floor accounts for the build about to run, and ratchets from what a lap actually consumed)

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

  • **Source of truth (§1). **crates/batten/src/prune.rs and the [prune] table in batten.toml, plus verify's call site in mise.toml. **Not **mise-tasks/target-prune — retired whole under CLOUD-1059, and mise-tasks/** and tests/**/*.bats are refused by V-SHELL-RULE-EDITED with no override and no bypass_env. mise-tasks/land.sh's mise run target-prune call stays byte-identical, which is what keeps this change out of the governed set. One place decides "is there room", as before; only the file moved.
  • **Computable predicate (§2). Headroom, not free space. Two df readings at phase boundaries: batten target prune records its post-prune reading, and a second reading at the end of verify:gated closes the lap. consumed = start − end + reclaimed_in_between is that lap's real demand, and the maximum observed ratchets the floor, journalled under $GIT_DIR beside the existing receipts. [prune.warm].mb becomes the seed for the first lap and a lower bound, not the whole answer. Predicate: *after a lap that ran a full *verify, free space never fell below the floor — two df readings and a comparison, no judgement and no model verdict. The refusal names the lap that set the number and the date it was taken, so the basis cannot go stale the way a hand-declared worst_mb does.
  • Effect (§3). No new verb and no new surface. batten target prune already carries Cost::Effect on Surface::VerifyOnly and Effect::Destructive; the journal write rides the same effect class. Unreachable from the mediated call, per RuleKind::scopes.
  • Output & exit (§5). Pointer-only per non-negotiable rule 4: the phase name, bytes free, the floor, its basis (warm/cold) and the date the ratchet was set. Never a path listing from the build tree.
  • **Commit / bump (§6). **fix(prune)patch until 0.1.0. The workspace is 0.0.130, so release-plz bumps the patch whatever the type says. This clause is corrected: it said ci(target-prune) — no bump — on the ground that "nothing here is crate source". That ground died with the retirement; prune.rs is crate source and a release does move.
  • **Test obligation (§7). **crates/batten/tests/target_prune.rs, the compiled-binary tier — **not **tests/target-prune.bats, which is deleted. Shown able to fail (CLOUD-418):
    • a lap whose start reading clears the floor and whose end reading does not is refused at the boundary, naming the phase — red today, because there is no second reading to compare;
    • the ratchet raises the floor when an observed lap exceeds the seed, and the refusal names that lap;
    • anti-vacuity twins: a lap that stays above the floor throughout is not refused and does not ratchet; a lap below the seed is refused on the seed alone. Without these the fix is "refuse more", which is a gate someone switches off.
    • TARGET_PRUNE_FREE_MB already takes a comma-separated sequence of readings in order (prune.rs), which is exactly the seam a two-reading fixture needs. Reuse it; a second seam would be a second authority on what a run sees.
  • **Blockers (§8). **blockedBy CLOUD-1157. Not decoration, and the ground is the regrowable roots rather than a percentage (corrected 2026-08-29 — this clause quoted a share of deps bytes that was itself an artifact of an unbuilt tree). A ratchet needs reclaimable headroom to ratchet against, and target/semver-checks at 2.6 GB plus target/perf and target/debug/build are unreachable by any pass today. So a floor that ratchets before CLOUD-1157 lands climbs toward a number nothing can satisfy — which is the failure batten.toml's own [prune] comment names as the one that gets a gate switched off. relatedTo CLOUD-766 (the origin row), CLOUD-1030 (the two-floor repair this builds on), CLOUD-1158 (the basis sensor), CLOUD-778 (the suite hermeticity seam this reuses), CLOUD-811 (the same misattribution shape in linear-check).

**Folded in: **CLOUD-1153. verify's guard reports any non-zero from target-prune as "not enough disk", including a batten that refused for a config reason and an exit 2 that means "could not look". That is the same mise.toml line this row edits, so it is repaired here rather than churning the line twice: exit 1 (below the floor), exit 2 (could not look) and a config refusal get three different messages.

Acceptance

  • A lap whose build would breach the floor is refused before or during the build with a named phase, rather than certifying headroom it then consumes.
  • The floor in force is derived from an observed lap's measured consumption, and the refusal names that lap and its date — no hand-declared worst_mb is the sole authority.
  • A lap that stays above the floor throughout is not refused and does not ratchet, so the mechanism discriminates.
  • verify tells a below-the-floor refusal apart from a batten that could not look or refused for a config reason (CLOUD-1153).

Filed from bundle B's session, which lost a lap to this. Search receipt minted (20 rows seen).

Generated by Claude Code


2026-08-22, second session — the floor moved as acceptance §3 asked, and is breached again; §2 looks satisfied

Two more exhaustions in one session, on a container running the bash-retirement sweep. Taken against the three acceptance bullets rather than as a fresh report.

§2 (incremental inside the reclaim) — appears SATISFIED, observed twice

The row's evidence was superseded reclaiming 0MB while 3.8G of incremental sat there. The escalation now reaches it, both times:

target-prune: 61 superseded artifact(s) removed, 5364MB reclaimed, 5303MB free (floor 6242MB)
target-prune: escalated below the floor — 5354MB of incremental cache dropped, 10525MB free

target-prune: 58 superseded artifact(s) removed, 5011MB reclaimed, 4952MB free (floor 6242MB)
target-prune: escalated below the floor — 2439MB of incremental cache dropped, 7335MB free

So the gap this row named in one line is closed. Recorded as observation, not as a claim about which change closed it — that was not traced.

§3 (the budget comment) — the figure MOVED, and the safety factor was removed with it

FLOOR_MB=4096 # budget: worst-lap=2048mb x2 measured=2026-08-20   <- as this row recorded it
FLOOR_MB=6242 # budget: worst-lap=6242mb x1 measured=2026-08-22   <- as it stands now

The row asked that the figure move rather than be scaled, and it did. But x2 became x1: the floor is now exactly the worst observed lap, so a lap merely equalling the worst breaches it by construction. The original 4096 at least carried a doubling; this does not. Whether that is deliberate is not recorded in the comment, which is the surface this row says must stay auditable.

§1 (precondition, not invariant) — still fails, twice

Both exhaustions have the row's exact shape: prune certified headroom at lap start, the build inside the same window consumed it, and the exhaustion surfaced as compile and bats failures rather than as a disk fault:

tee: /tmp/tmp.T75SMAAJXn: No space left on device
error: could not compile `batten` (test "cli") due to 1 previous error
mkdir: cannot create directory '/tmp/bats-run-fmKIFX/test/635': No space left on device

What is measured and what is not. The prune left 10,525MB free at 04:13, and df read 64K available, 100% used at 06:35. That interval is NOT one lap — it spans a full mise run fmt (which runs the whole gate, CLOUD-681), a verify, and two land invocations. So it is not evidence that a single lap consumes 10.5GB, and this row should not be read as claiming that. What it does establish is narrower and still decisive for §1: a floor certified at more than 1.6x its own value was exhausted before the next lap completed.

One datapoint the row's table does not have: target/semver-checks was 2.6G here against the 1.3G recorded, and target/debug alone was 14G. Dropping semver-checks by hand returned 2.4GB — rebuildable, outside superseded, and outside the incremental escalation, so it is a third category the reclaim currently does not consider.

Cost, in this session's own terms

Two laps lost. The second failed with land: still not linear after 2 laps, each of which bought a CI matrix — so the disk fault also consumed the lap budget that message is written against, and the branch was linear the whole time. The two failures compound: the exhaustion costs a lap, and the lost lap is then attributed to main moving.

CLOUD-1153 `target-prune` reports a refusing `batten` as "not enough disk", so a speculative lap's real blocker is invisible and the remedy it prints is wrong

mise-tasks/target-prune.sh shells the installed batten (command -v batten) to compute the reclaim, and verify turns any non-zero from that step into one message:

::error:: verify: not enough disk to run the gate, and pruning did not recover it —
          the refusal above names free space and the floor. No receipt written.

That message is a claim about free space. It is emitted whether or not free space was the problem, because the step's exit status is the only thing read.

Measured 2026-08-29, five consecutive mise run land invocations

land lost every lap on claude/config-authority-bundle-fvqapl (PR #748) with the text above. Free space at the time was 26 GB against a 14.9 GB cold floor — the floor was never in question. The actual output of the step was:

batten: invalid config ./batten.toml: TOML parse error at line 125, column 1
    |
125 | protected_readers = [
    | ^^^^^^^^^^^^^^^^^
unknown field `protected_readers`, expected one of `version`, `min_batten_version`, ...

The mechanism is the speculation. land speculatively linearizes onto the branch holding the lease — here claude/retirement-gate-integrity-3mfqc9@b706d53 — and that branch adds a [protected_readers] config key together with the Rust field that reads it. The batten on PATH was built from a tree that predates the key, so it refuses the borrowed batten.toml under deny_unknown_fields, which is the loader doing exactly its job.

So on a speculative tree the installed binary and the working-tree config are, by construction, allowed to disagree — and the whole point of speculation is to run against a tree the current binary has never seen.

Why it costs more than a confusing line

  • The remedy printed is wrong. "Free space outside ./target, or start a fresh session" was followed four times, reclaiming ~19 GB each time, and changed nothing. A reader with 26 GB free and a disk refusal has no next step that works.
  • It is invisible in the log. The batten: line appears once, unprefixed, above a [target-prune] ERROR task failed; the ::error:: annotation that CI and the reader actually read names disk.
  • It is not rare under contention. Any sibling branch that lands a config column plus its field produces it for every other branch that speculates onto it, for as long as it holds the lease.

Class

This is CLOUD-811's defect one task over — a step reporting every failure as its own headline verdict — and the second half of CLOUD-766's "the exhaustion is reported as a test failure", inverted: there a real disk problem wore a test failure's clothes, here a config refusal wears a disk problem's.

The narrow repair is that the disk claim should be made only when the floor comparison actually decided it, and a batten that refuses should surface as could-not-look with its own pointer. Note the shape constraint: mise-tasks/target-prune.sh is a governed shell path, so under .claude/rules/toolchain.md it is retired whole or left alone — this is a port, not an edit.

A second question worth deciding rather than assuming: whether target-prune should consult the working tree's batten.toml at all on a speculative lap, given the binary is known to predate it.

Found while landing CLOUD-341; not fixed there because it is outside that row's scope and the file cannot be edited in place.


Refinement — Ready (the disk claim is made only when the floor comparison decided it)

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

  • **Authority boundary (§1). **mise-tasks/target-prune.sh is the one changed authority. It is governed shell, so under the two-landable-shapes rule this is a retirement — the program is ported whole to the engine, together with tests/target-prune.bats, and never edited in place. The task runner's own verify body only restates the verdict this program returns and is a reader rather than an authority; nothing in the policy authority moves, and no consumer surface changes.

    §1 deliberately names only the two paths this row would change, and names the retirement rule rather than the file that states it. The paths cited as evidence elsewhere in this body — the config the reclaim reads, the workflow that runs it — are cited and not claimed, which is the distinction filed-over-own-diff's own cites_only arm exists to draw.

  • Computable predicate (§2). The reclaim step resolves to exactly three outcomes rather than two: recovered (free ≥ the applicable floor), could-not-look (the reclaim could not be computed at all — the binary refused, is absent, or the root does not resolve), and short (the floor comparison ran and free is below it). Only the third may be reported as a disk refusal. The second carries the underlying program's own exit status and its pointer, and it is decidable rather than inferred: the floor comparison either produced two numbers or it did not.

  • **Effect (§3). **write — pruning removes build artifacts, exactly as today. The classification change adds no new class of write and the port moves no verb into the derived allowlist.

  • Generated artifacts (§4). None beyond what a retirement always regenerates. If the port lands the predicate as a verb, mise run fix regenerates the completions, man pages and batten spec, and derived-check proves them byte-for-byte.

  • Output & exit (§5). Pointer-only, and this is the substance: a refusal names free space and the floor only when it read both, and a could-not-look names the program that refused and its own pointer — never a config body, never the borrowed tree's contents. Exit follows the one table: 2 for the floor genuinely being short, 1 for an invalid invocation, and 3 for could-not-look, which is the code the current collapse spends on a 2-shaped message.

  • **Commit / bump (§6). **fix(prune)patch until 0.1.0, since below that release-plz bumps the patch whatever the type says. Not ! for the consumer surface: no verb, output shape or existing exit code moves for a run where the floor really is short, which is every run the current message is honest about.

  • Test obligation (§7). Over the compiled binary, shown able to fail per CLOUD-418. The discriminator is the measured case, which must be red against the current program: a tree with free space far above the cold floor, where the reclaim's own program refuses, is reported today as a disk refusal and must become a could-not-look naming that program. The direction a careless fix breaks: a genuinely short floor must still refuse with both numbers in the pointer. And the case that separates this from a blanket downgrade: a could-not-look must not be reported as recovered, or the gate stops defending the floor at all.

  • Blockers (§8). None. relatedTo CLOUD-811 (the same collapse one task over — every failure reported as that task's own headline verdict), CLOUD-766 (the inverse: a real exhaustion wearing a test failure's clothes), CLOUD-1030 (the warm/cold floor basis this reads), CLOUD-778 (whose suite reads the host's real free space, so the case above needs a hermetic fixture to be written at all) and CLOUD-418.

Acceptance

  • A reclaim that could not run is never reported as a disk refusal, and names the program that refused.
  • A genuinely short floor still refuses, with free space and the floor both in the pointer.
  • No message tells a reader with 26 GB free to free more space.

Not in scope. Whether the reclaim should consult the working tree's batten.toml on a speculative lap at all — that is a question about what land borrows, and deciding it here would put a second authority over the speculation. Recorded above as an open question for whoever owns that.

CLOUD-1158 `[prune]`'s floors are measured against a test-target count that grows one per retired gate, and nothing reds when that basis moves

Why

Floor requires a measured date and validates it as a calendar date — deliberately, because "a stale measurement reads exactly like a fresh one". That is CLOUD-266's rule, discharged. But a date is not the basis; it is a pointer to a basis nobody wrote down, so nothing can tell whether the world under the number has moved.

It has moved, and it moves on a schedule this repository sets for itself.

Corrected 2026-08-29, same day, before dispatch. Three claims in the first draft were loose. §2 put the comparison in Floor::validate, which is on the path every mediated tool call pays for — see §2 for the correction and the precedent. The "131 MB" figure below was the rlib, not the linked binary. And the 41 → 110 series compares two counts taken different ways; both are now labelled.

The arithmetic nobody watches

Retained bytes after a perfectly successful prune are keep × stems × size. keep = 2 and size are stable. stems is not:

distinct integration-test stems, CLOUD-766, 2026-08-20 ~41
crates/batten/tests/*.rs, 2026-08-29 110
tests/*.bats suites still standing 147

Every one of those .rs files is an independent cargo test target — crates/batten/Cargo.toml sets no autotests = false and declares no [[test]], so cargo discovers all 110 — and each links against the workspace library. Sizes, corrected: 131 MB is the libbatten rlib, not the linked binary. CLOUD-766 measured integration test binaries at 36–100 MB (cli-<hash> at 63 MB and 36 MB); the lib test target on this container is 144–172 MB.

The two counts in the table were taken differently, and the row should not imply one series. ~41 is *stems resident in *deps on a tree where the suite had been built (CLOUD-766, 2026-08-20); 110 is tracked test files (git ls-files, 2026-08-29). Both are test targets in substance, but one counts what was built and the other counts what exists. And the count is not drifting by accident: CLOUD-843's bash retirement adds one per retired gate.claude/rules/policy-modules.md makes the compiled-binary tier the second, non-optional tier of every retirement, so each of the 147 standing suites owes a crates/batten/tests/*.rs when it goes.

So the floor's basis is being multiplied by the campaign the repository is mid-way through, and the mechanism that would notice is the one thing Floor does not record.

Limit of this reading, stated rather than smoothed over: the container's clone is shallow (50 commits, back to 2026-08-26), so the intermediate points between 41 and 110 were not measurable from it. 41 is CLOUD-766's recorded figure and 110 is git ls-files; the trend between them is inferred from CLOUD-843's mechanism, not sampled.

What that costs today

[prune.warm].measured = "2026-08-22". [prune.cold].measured = "2026-08-29". Both carry multiplier = 1, so each floor is exactly the worst lap observed on the day it was taken — CLOUD-861's follow-up already recorded that the x2 became x1 when the number moved, and that whether that was deliberate is written down nowhere.

A floor taken against a smaller stem count is not merely conservative, it is wrong in the direction that fails silently: the check passes, the build then writes more than the basis anticipated, and the exhaustion arrives as a rustc IO error inside a test run. That is the presentation prune.rs's own header says the refusal exists to prevent.

Why it is worth a row

CLOUD-861 makes the floor ratchet from observation, which handles the number. It does not handle the basis: a ratcheting floor over a growing base climbs until it exceeds the container's allowance, and a floor nothing can satisfy is the failure batten.toml's [prune] comment already names as the one that gets a gate switched off. This is the sensor that makes that visible before it happens, and it is a different predicate from either CLOUD-861's or CLOUD-1157's.


Refinement — Ready (a floor records the basis it was measured against, not only the date)

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

  • **Source of truth (§1). **Floor and Floor::validate in crates/batten/src/prune.rs, and the [prune.warm] / [prune.cold] tables in batten.toml. The basis is the consumer's — which glob and which count — and the comparison is the engine's, which is what keeps non-negotiable rule 1. mise-tasks/target-prune.sh no longer exists (retired whole under CLOUD-1059); mise-tasks/** and tests/**/*.bats are refused by V-SHELL-RULE-EDITED with no override and no bypass_env.

  • **Computable predicate (§2). **Floor gains the basis beside the date: a declared glob and the file count it held when the lap was measured, plus a tolerance. The comparison reds when the live count for that glob has moved past the tolerance since measured — a count against a count, an exit code over the tree, no judgement and no model verdict.

    The comparison must NOT live in Floor::validate — corrected 2026-08-29 before dispatch, because the first draft put it exactly there. crates/batten/src/config.rs:1038 calls prune.validate() from the shared config-load path, which **every **batten invocation runs, batten hook on every mediated tool call included. A glob walk there is taxed onto the PreToolUse path against perf-assert's ceiling, and batten check over 654 tracked files already costs ~3.6 ms above the process floor. The repository has ruled on this exact shape once: claim-race-check was moved off the mediated call because a round trip on every tool call is disqualifying, and RuleKind::scopes pairs every spawning kind with RuleScope::Tree alone.

    So: Floor carries the declared basis (it is config, and validating its shape at load is free), and the comparison against the live tree runs in batten target prune — already Cost::Effect on Surface::VerifyOnly, which perf-assert deliberately budgets no ceiling for — or as a tree-scoped rule row under verify. Either is acceptable; Floor::validate is not.

  • Effect (§3). No new verb and no new surface. Shape validation of the declared basis stays at config load; the tree comparison is read-only and runs on Surface::VerifyOnly, never on the mediated call.

  • Output & exit (§5). Pointer-only per non-negotiable rule 4: the glob, the declared count, the live count, the tolerance and the measured date. Never a file listing — the count is the finding, and the paths are unbounded.

  • **Commit / bump (§6). **fix(prune)patch until 0.1.0. The workspace is 0.0.130, so release-plz bumps the patch whatever the type says.

  • **Test obligation (§7). **crates/batten/tests/target_prune.rs, the compiled-binary tier, since the live count has to come from a real tree rather than from a fabricated one. Shown able to fail (CLOUD-418):

    • a floor whose declared basis count no longer matches the fixture tree is refused by the verify-surface comparison, naming both numbers;
    • the placement case: a mediated call (batten hook) over a tree whose basis count has moved is not refused and does not walk the tree — red if the comparison is put back on the config-load path, which is what the first draft specified;
    • anti-vacuity twin: a count inside the tolerance loads clean, and a tree at exactly the declared count loads clean — without these the check is "always red", which is a gate nobody keeps;
    • a floor declaring no basis at all is refused, on the same ground measured already is: an absent basis reads exactly like a satisfied one.
  • Blockers (§8). None. It composes with CLOUD-861 and CLOUD-1157 and depends on neither: the number, the reclaim and the basis are three separate mechanisms, and this one is landable alone. relatedTo CLOUD-861 (the ratcheting floor this keeps satisfiable), CLOUD-1157 (the reclaim that sets what size × stems actually retains), CLOUD-266 (the budget-carries-its-measurement rule this extends), CLOUD-843 (the campaign moving the term).

Acceptance

  • Each floor in [prune] declares the basis it was measured against — a glob and a count — beside its date.
  • A tree whose live count for that glob has moved past the declared tolerance is refused on the verify surface, naming the declared count, the live count and the date.
  • batten hook does not walk the tree for this, and perf-assert is unmoved — the comparison is off the mediated call.
  • A tree inside the tolerance loads clean, so the check discriminates rather than always refusing.
  • The declared basis is the consumer's, in batten.toml; no count and no glob appears in crates/batten.

Provenance. Found while diagnosing why mise run land still exhausts the disk when it laps, with batten target prune in place and released in v0.0.129. The reading that produced it: keep = 2 bounds copies per stem and nothing bounds stems, and the repository is actively multiplying stems as a matter of policy.

Review in Linear

@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 52 minutes.

View limit details

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

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: b6b2bcfd-eaef-40cb-ab8b-e1bda86de217

📥 Commits

Reviewing files that changed from the base of the PR and between 3a17c40 and 0b4cc7d.

📒 Files selected for processing (3)
  • batten.toml
  • crates/batten/src/lib.rs
  • mise.toml
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 32234ceb-611e-485b-b89c-eec2a4c5a5ea

📥 Commits

Reviewing files that changed from the base of the PR and between c36bbeb and 3a17c40.

📒 Files selected for processing (6)
  • batten.toml
  • crates/batten/src/lib.rs
  • crates/batten/src/prune.rs
  • crates/batten/tests/target_prune.rs
  • mise.toml
  • schema/batten.schema.json

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


📝 Walkthrough

Walkthrough

target prune now supports measured warm and cold floors, configurable regrowable cache roots, lap journals, and local Git basis checks. Pruning distinguishes warm and cold build states, records lap consumption, ratchets observed floors, and retains supported artifacts by kind. The schema and project configuration define the new metadata. Verification opens and closes laps around its content steps, while integration tests cover validation, escalation, retention, reporting, and failure handling.

Merge Risk: ⚪ Minimal · up to 3a17c

The PR updates pruning coverage and disk-floor tracking; no actionable merge-blocking risk remains after 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.

@wenzowski
wenzowski force-pushed the claude/prune-bundle-w3ndkx branch 2 times, most recently from 3a17c40 to 29901f9 Compare August 30, 2026 04:48
…n's roots

`stem_of` split a filename on its last `-` and required the tail to be all
hex, so `libbatten-<hash>.rlib` produced a "hash" of `<hash>.rlib`, failed on
the `.`, and became its own stem. Every extensioned artifact was a group of
one, and nothing in a group of one is ever past `keep`: `.rlib`, `.rmeta` and
`.so` were never superseded however many copies accumulated.

The key is now `(stem, kind)` with retention per kind. The kind cannot fold
into the stem — `libbatten` carries 2 `.rlib` and 6 `.rmeta`, and one group of
eight under `keep = 2` can retain two `.rmeta` and delete the live `.rlib`,
which is the `keep = 0` failure arriving through the grouping. The executable
bit now gates only the extension-less class, where it is the sole signal that
a name is an artifact. `.d` and every unrecognised kind stay out of scope.

The escalation's roots move out of the crate into `[prune.regrowable]`, in
declared order, each saying whether dropping it makes the next cargo build
full. `incremental` and `build` do; `semver-checks`, `perf` and `flycheck*`
make only their own next run cold, so marking them basis-moving would judge a
lap against a full rebuild that never arrives. A single trailing `*` is the
whole wildcard language, and a name that is not a name is refused at load.

Three mutations show the new cases able to fail: an empty kind list reds the
extensioned retention and the collision case, a stem-only key reds the
collision case alone, and an unconditional basis move reds the warm-basis
escalation.

Refs: CLOUD-1157
…chet it

The floor was a PRECONDITION: read once at the head of `verify`, answering "is
there room to begin", with nothing re-reading during the phase that actually
consumes the disk. A build whose growth exceeded the headroom that check had
just certified was structurally invisible. Measured three times in one session
— the prune passed at 6242MB free, the `cargo test` link step inside the same
lap took all of it, and the exhaustion arrived as a rustc IO error under a
`land` line telling the author to fix their own diff.

So the same verb now runs at both boundaries. `verify:gated` calls it again as
its last content step, and that run closes the lap the head call opened:
`consumed = start - end + reclaimed_in_between`, the reclaim's own megabytes
included, because space handed back is space the lap had spent. A lap that
ended below its floor is refused there, before the receipt.

The basis ratchets. `[prune.warm].worst_mb` was hand-declared at x1, so the
floor was exactly the worst lap somebody wrote down and a measurement taken
once read like a fresh one forever. The journal — one file under `$GIT_DIR`,
rewritten by rename so CLOUD-1032's half-record class is unwritable — records
what a lap actually consumed and raises the floor to it, per basis, from the
lap after the one that observed it. The declaration becomes a lower bound.

Folded in CLOUD-1153: `verify` reported any non-zero from `target-prune` as
"not enough disk", including a config refusal (1) and a could-not-look (3).
Three codes, three messages, at both call sites.

Two defects the suite caught rather than review. `git::git_dir` walks up, so
every non-git fixture resolved the enclosing repository's git dir and shared
one journal — the same class #734 records for the config root; the authority is
`./.git`, as `./batten.toml` is the config's. And `OpenLap` carried the floor
it was admitted under, which was a second authority on a number the ratchet
already answers: blanking it changed no verdict, which is the only way a
redundant conjunct announces itself.

Four mutations show the new cases able to fail: no closing reading reds all
five, a ratchet that never binds reds the ratchet case, one ratchet for both
bases reds the basis case, and a consumption that ignores the reclaim reds the
arithmetic case.

Refs: CLOUD-861, CLOUD-1153
…y the date

`measured` discharges CLOUD-266 — a limit carries its measurement — and stops
there. A date is a POINTER to a basis nobody wrote down, so nothing could say
whether the world under the number had moved. It had, and it moves on a
schedule this repository sets for itself.

Retained bytes after a perfectly successful prune are `keep x stems x size`.
`keep` is 2 and `size` is stable; `stems` is not. CLOUD-766 recorded ~41 test
stems resident in `deps` on 2026-08-20; `crates/batten/tests/*.rs` is 110
tracked files today, each an independent cargo test target, and CLOUD-843's
retirement owes one per retired gate with 147 shell suites still standing.
Measured on this container the same day: 114 extension-less test binaries
holding 13411.9 MB, 86.8% of `deps`.

So `Floor` gains `basis` — a glob, the count it held when the floor was
measured, and a tolerance. A floor taken against a smaller count fails in the
direction that fails silently: the check passes, the build writes more than the
basis anticipated, and the exhaustion arrives as a rustc IO error inside a test
run, which is the presentation the refusal exists to prevent.

THE COMPARISON IS NOT AT CONFIG LOAD, and that is the whole of §2. `config.rs`
calls `Prune::validate` on the shared path every `batten` invocation runs —
`batten hook` on every mediated tool call included — so a tracked-path
enumeration there is a tree read taxed onto the PreToolUse budget, the exact
shape `claim-race-check` was moved off that path for. `Floor::validate` checks
the basis's SHAPE, which is free; the count runs in `batten target prune` on
`Surface::VerifyOnly`, which `perf-assert` budgets no ceiling for.

Reuses `git::tracked_paths` (the index, so an untracked file cannot move the
count) and one `rules::Selector` per floor rather than a `glob_match` per path.
Could-not-look allows, per that function's own stated posture, and the count is
taken from the cwd's own `.git` — an ancestor's index made a fixture declaring
1 file be told the tree tracks 189, the third instance of that walk-up class on
this branch.

Pointer-only: two counts, a tolerance and a date, never a file listing.

Four mutations show the new cases able to fail: no comparison reds the drift
case and the placement case's own precondition, an ignored tolerance reds the
tolerated case, an accepted empty glob reds the absent-basis case, and an
ancestor's index reds the no-index case.

Refs: CLOUD-1158

BREAKING CHANGE: `prune::Floor` gains a required `basis` field and
`prune::prune` takes the lap store as a fourth argument, so both are
constructed differently by any caller outside this crate. Declared here rather
than worked around: the basis cannot be optional without reading exactly like a
satisfied one, which is the defect this change is about.
… own close created

Found on this row's first live lap rather than in a fixture. The closing run's
reclaim escalated — free space was below the warm floor — which dropped
`incremental`, which moved the basis to Cold, which became the floor the lap
being closed was then judged against. Measured here: a close at 9790MB free
refused against the 14914MB cold floor, and every full lap would have failed at
its own closing reading.

The escalation is not the error: it frees space, and the next build really is
cold afterwards. The error was letting it speak for the lap already finished. A
closing run now judges against the basis recorded when the lap OPENED, and the
basis its own reclaim created is what admits the NEXT lap — which is where that
consequence belongs.

The first attempt at this guard was worse and is recorded because it nearly
shipped: suppressing the escalation whenever a lap was open. Every run after the
first closes one, so that would have disabled the escalation permanently.

Refs: CLOUD-861
…not land here

The lap-open call site had grown a three-way classification of `target-prune`'s
exit code — 2 the verdict, 3 could-not-look, 1 the invocation — folding
CLOUD-1153 into this bundle. Measured, that row cannot be written in this body
at all: `tests/verify.bats`'s "a volume that cannot be recovered is a STOP, not
a lap" drives the mapper with `target-prune` exiting 1 and asserts the output
carries "not enough disk", so the classification turns a standing case red and
the only fix inside this shape is an edit to a bats suite.

`V-SHELL-RULE-EDITED` refuses that with no override route and no `bypass_env`,
which is the refusal working rather than one to argue with: CLOUD-1153 is a row
whose §1 is written in the wrong shape, and the finding goes on the row instead
of through a governed file.

So the guard returns to its landed one-way form byte for byte, and the comment
above it carries the finding — the lap-open pairing CLOUD-861 needs is stated
there, and the closing call site at the tail of `verify:gated` is untouched.

Refs: CLOUD-861
…calation

`basis` was `Cold` iff THIS invocation dropped a basis-moving regrowable root,
so a tree emptied by anything else was invisible to the ratchet.

Measured on this branch's own landing lap. A hand deletion of `target/debug`
freed the room the floor asked for; the lap that followed built 110 test
binaries from nothing and consumed 21226MB; and the journal recorded that cold
lap as the worst WARM one on record. Every warm lap after it is then admitted
against a full rebuild's demand — 21226MB on a 37939MB allowance — which is the
floor nothing can satisfy that CLOUD-861's own §8 names as the failure that gets
a gate switched off. The ratchet was learning the wrong number, not merely
printing the wrong word.

An empty or absent `deps` is the signal, and it is the artifacts themselves
rather than a cache: with nothing there the next build writes all of it, whoever
removed it. Not the regrowable roots, which cannot answer this — a `cold` row
says *dropping this makes the next build full*, a claim about a removal rather
than about a state, and `incremental` is simply absent on a tree that has never
built incrementally. So the escalation's flag stays as the other half, OR'd
rather than replaced: a dropped `incremental` leaves `deps` full and still makes
the next build cold.

Both cold-basis messages are reworded to say what is true under either cause
rather than attributing it to the escalation.

Seven fixtures went red on the change, and that is the sensor discriminating
rather than the fixtures having been wrong: each created `target/debug/deps` and
stopped, declaring a tree whose next build writes everything while asserting a
warm verdict. `built()` gives them a resident file with no hash suffix — not a
name any pass groups, not a file `survivors` counts — so no retention arithmetic
moves. The new case is shown able to fail: with `basis_of` pinned to `Warm` it is
the one red of fifty.

Refs: CLOUD-861
`basis_of` 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. Measured on this branch's own landing lap, where `install:local` had
just written the release tree.

Scoping it would mean knowing which profile the caller is about to build, which
the reclaim is not told and must not guess. The bound is written where the
function is rather than left for the next reader to rediscover, with the reason
it is tolerable: this reading can turn a warm answer cold and never the reverse,
so it is strictly stronger than reading the escalation alone.

Refs: CLOUD-861
… load

`Floor::basis` was a required field, so a floor without one failed to PARSE.
That makes the key's own pull request unlandable, and the mechanism is
structural rather than incidental: `config-lint` loads `origin/main:batten.toml`
with the WORKING TREE's binary — house style §8's out-of-band load, so a branch
cannot lower the bar it is judged by — and the base ref does not carry a key this
branch is adding. The load fails with `missing field `basis``, and the gate
reports could-not-look instead of a verdict.

Measured on this branch's own landing lap, with the whole of the rest of `verify`
green: 2654 bats cases, the workspace suite, `batten-check` and `commit-lint` all
passed, and `config-lint` refused a tree it could not read.

So the type takes `Option` and the refusal moves one surface later, to
`basis_drift` on `Surface::VerifyOnly` — which is where CLOUD-1158 §2 already puts
the live comparison, and which `perf-assert` budgets no ceiling for. Nothing is
weakened: a floor declaring no basis is still exit 2, still names the floor and
still names the date that points at nothing. Shape validation of a basis that IS
declared stays at load, where it is free.

`BasisFinding` splits the two answers because they have different remedies —
write the row, versus re-measure the floor — and one refusal wearing two shapes
would leave a reader to work out which.

Shown able to fail (CLOUD-418): with the undeclared arm skipping instead of
refusing, `an_undeclared_basis_is_refused_on_the_verify_surface` is the one red
of fifty-one.

BREAKING CHANGE: `Floor::basis` is now `Option<Measured>` and `basis_drift`
returns `Option<BasisFinding>` rather than `Option<BasisDrift>`.

Refs: CLOUD-1158
… one judged

The basis-of-record rule redefined `Outcome::basis` as the basis the CLOSED lap
ran under, which is right for the floor lines and wrong for the escalation line —
and the two diverge at exactly the run where the escalation matters.

Measured on this branch's own landing lap, and it cost a diagnosis rather than
merely reading oddly. A closing reclaim dropped `incremental` and printed "none
of those roots is the cargo build's basis, so the next build is still warm"
while the journal recorded the next lap as cold. The run after it was then
refused against a 14914MB cold floor at 8793MB free, with no line anywhere in the
output connecting the refusal to the escalation that caused it. That is the same
misattribution this bundle is about, in a message this bundle added.

`Outcome` now carries `next_basis` beside `basis`: what the tree and this run's
own escalation leave behind, against the basis of record the floor belongs to.
Only the escalation line reads it, because it is the only line that is a claim
about the next build rather than about the lap being closed.

Shown able to fail (CLOUD-418): keyed back on the closed lap's basis,
`a_closing_escalation_does_not_make_the_lap_it_closes_a_cold_one` is the one red
of fifty-one — the case that already pinned the judging half now pins the
reporting half too, and they are the same divergence read from both sides.

BREAKING CHANGE: `Outcome` gains a `next_basis` field.

Refs: CLOUD-861
…ill short

The escalation took every declared root at once, so a run needing 2GB dropped
`incremental` along with the rest — and dropping `incremental` is what makes the
next cargo build a full one, which raises the floor that build has to clear from
the warm number to the cold one.

Measured twice on this branch's own landing lap, and it is why no lap after the
first could ever open. A reclaim freed 5711MB, most of it from roots that cost
only their own next run, and thereby moved the floor the next lap faced from
6242MB to 14914MB on a tree a full lap leaves at ~8.7GB. Every second `land` lap
was then refused for a full rebuild nothing had asked for. The escalation created
the demand that refused it, and `verify` was green in both laps that died this
way.

So the cheap rows go first, free space is re-read, and the basis-moving rows are
taken only if the floor is still breached. On the measured lap the cheap tier
alone cleared 6242MB, so `incremental` would never have been touched.

NOT A PHASE RULE, and the first form of this fix was one — `journal.open.is_some()`,
which the suite refused: every run after the first closes a lap and opens
another, so that guard would have disabled the basis escalation permanently. A
run genuinely cannot tell `verify`'s head from `verify:gated`'s tail, because
both are the same invocation. It can always tell whether it still needs the
space, and that is the question this asks instead.

Shown able to fail (CLOUD-418): with the first tier taking the basis-moving roots
instead, four cases red — the closing escalation, the two per-row basis cases and
the prefix row.

Refs: CLOUD-861
…ps' evidence

`incremental` carried `cold = true` on the reading that dropping rustc's
incremental state guarantees a full rebuild. Measured over four live laps on a
Claude Code web container, it does not: cargo still reuses every `deps` artifact
whose fingerprint matches, so what is lost is rustc's within-crate state rather
than the artifact set. The laps immediately following an escalation that took
`incremental` consumed 228MB and 2852MB, against the 14914MB full-rebuild floor
the flag was asserting.

What the flag cost while it was wrong is the whole reason this is a fix rather
than a tidy-up: the closing reclaim took `incremental` to clear its own floor,
the next lap was then admitted under the cold floor with ~8.1GB free, and `land`
was refused on every second lap — for a full rebuild nothing had asked for,
having freed the space that created the demand. Four consecutive `verify` runs
went green through the closing reading and lost the landing to exactly that.

`build` goes the same way and for the same reason: losing build-script output
reruns those scripts and rebuilds what depended on them, which is a fraction of
the artifact set rather than all of it.

The mechanism is untouched and the flag stays in the schema — a consumer whose
reclaim really does destroy the artifact set needs it, and
`a_declared_root_that_is_not_the_cargo_basis_is_dropped_without_moving_the_floor`
still pins both directions. What changes is this consumer's declaration, which is
where a fact about this tree belongs.

The real cold signal is read directly now rather than inferred from a removal:
`basis_of` reports `Cold` when `deps` is empty or absent, which is the state a
full rebuild actually follows from, and it is true whoever emptied it.

Refs: CLOUD-861
@wenzowski
wenzowski marked this pull request as ready for review August 30, 2026 06:11
@wenzowski
wenzowski force-pushed the claude/prune-bundle-w3ndkx branch from 29901f9 to 0b4cc7d Compare August 30, 2026 06:11
@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 0b4cc7d into main Aug 30, 2026
9 of 10 checks passed
@wenzowski
wenzowski deleted the claude/prune-bundle-w3ndkx branch August 30, 2026 06:45
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