fix(prune): the escalation could not see, could not open, could not forget, and reached for the wrong lever - #785
Conversation
CLOUD-1240 The prune escalation cannot reach a NESTED cargo build tree, so every cross-target and nested-`CARGO_TARGET_DIR` root is unreclaimable — measured 2.1 GB, and the floor ratchets up when a lap exceeds it
Why Measured, lapping PR #775 on 2026-08-31One
The feedback loop, which is the part that makes this urgent rather than tidyThe observed warm floor is derived from the worst lap on record. Measured this session, verbatim:
So a lap that ran short of disk — and therefore reclaimed by hand, or ran cold — raises the bar for every subsequent lap. Before: 9970 MB. After: 10997 MB. The next lap now needs Three laps this session closed below the floor at 7896 / 8777 / 9064 MB and were refused. Each refusal was correct and each named the environment — "That is the environment rather than this branch: there is nothing in the diff to reproduce." The floor is a working sensor. What is missing is that the escalation cannot reclaim the roots that consumed the headroom, so the only thing that clears a lap is a human deleting directories by hand. That was done twice in one session; the second time moved free space 7.7 GB → 12 GB and let the lap close. Why this is the ENGINE's and not a
|
| free at refusal | declared | in force | escalation ran? | outcome |
|---|---|---|---|---|
| 7896 MB | 7264 | 9970 | no | refused; reclaimed by hand |
| 8777 MB | 7264 | 9970 | no | refused; reclaimed by hand |
| 9064 MB | 7264 | 9970 | no | refused; reclaimed by hand |
| 10931 MB | 7264 | 17357 (cold) | no | refused; reclaimed by hand |
| 6600 MB | 7264 | 10997 | yes | 6061 MB reclaimed, basis unmoved |
The last row is the controlled arm: the same tree and the same binary, ballasted with a 4200 MB file so free space fell below the declaration. The escalation fired immediately and freed 6061 MB, reporting "none of those roots is the cargo build's basis, so the next build is still warm".
So on all four refusals the reclaim had gigabytes available to it and was never invoked. Each of those laps was then unblocked by a human deleting directories by hand — which is the behaviour the escalation exists to make unnecessary.
The refusal text makes this hard to see, because it reads as though the reclaim had been tried and come up empty:
below the measured warm disk floor, and nothing left to reclaim that would change the basis — the regrowable roots
[prune]declares are already gone, were never there, or none of them is the cargo build's own
Every clause of that is about what a reclaim FOUND. None of it ran.
Why it goes unnoticed
The gap only opens once an observation stands above the declaration, so a fresh clone never sees it — the two numbers are equal and the gate is exactly right. It appears the first time a lap ratchets the floor, and from then on it is permanent and grows with the ratchet: the worse the disk pressure, the wider the band in which the reclaim refuses to engage.
Refinement — Ready
Mechanism (§1) — a computable predicate: the number the escalation opens against equals the number the refusal is judged against. Today config.warm.mb versus declared.max(observed); they must be one value.
Scope (§2) — in: the entry condition of both escalation tiers. Out, deliberately: the floor VALUES in batten.toml, which are protected and are CLOUD-1158's; whether the observation should have been recorded at all, which is CLOUD-1241's; what the escalation may reclaim once it runs, which is CLOUD-1240's; and the conditional nature of the escalation itself — it stays conditional, because dropping a cache costs the work that wrote it and paying that every lap trades a rare stall for a permanent tax.
Change (§3) — a named warm_floor_in_force(config, journal) read by both tiers. The warm standing is the right one even where the cold floor is what will ultimately apply: this gate opens the cheap tier, whose rows cost only their own next run, and reading the cold standing would make a basis-moving drop the entry condition for a pass whose contract is that it does not move the basis. The costly tier re-reads free space behind its own guard and must use the same number, or it inherits the identical gap one step later.
Output (§7) — counts and megabytes, unchanged. What changes is that the "nothing left to reclaim" line becomes true when it is printed.
Commit / bump (§6) — fix(prune); bump: patch, which is what fix releases and what below 0.1.0 every releasing type collapses to anyway. Non-breaking for the consumer surface and for the library surface alike: no config key moves and no public item changes shape.
Blockers (§8) — none. relatedTo CLOUD-1240 (what the escalation may reclaim once it is asked), CLOUD-1241 (why the observation was inflated in the first place), CLOUD-1158 (the declared floors), CLOUD-766 (the exhaustion), CLOUD-1155 (the retirement unit that owns land, where the per-lap arithmetic is recorded), CLOUD-418 (shown able to fail).
Acceptance
- With an observation of 10997 standing above a declaration of 7264, a tree at 7896 / 8777 / 9064 / 10931 MB free is below the floor the escalation opens against — the four readings measured, asserted directly.
- Shown able to fail (CLOUD-418): under the declaration alone all four are at or above the gate, so none escalates. Kept as its own case, so the two numbers cannot quietly become one again.
- An absent observation leaves the gate exactly as it was, so the change is inert on a fresh clone.
- An observation below the declaration does not lower the gate — the declaration is a lower bound, which the reporting half already states and the gate must agree with.
- Both tiers open against the same number.
CLOUD-1246 A ratchet observation taken by an engine whose reading has since been CORRECTED stays in force forever — fixing `basis_of` does not retire the number `basis_of` got wrong
Why
The fix landed and the container is still refused by the number the bug wrote
CLOUD-1241 is fixed at 2b7f57b2 — a cold lap beside a surviving target/release/deps is no longer recorded as warm. The lap it misrecorded is still in the journal:
"ratchet":{"warm":{"mb":10997,"head":"45601adc","measured":"2026-08-31"}}[prune.warm] declares 7264. The 10997 was written by a lap that was cold, by an engine that has since been corrected — and Ratchet::raise only ever climbs, so the corrected engine reads a number the corrected reading would never have produced, and treats it as measured fact.
Measured consequence on this container after the fix landed: mise run land refused at verify's precondition with free 8356MB against floor 10997MB (warm basis, observed on 45601adc). 8356 clears the declaration by 1092MB. The lap was refused entirely by the artifact of the bug it had just fixed.
The class, stated so it is not read as this one journal
A ratchet is a memory of a MEASUREMENT, and a measurement is only as good as the instrument that took it. Nothing in the journal records which instrument that was, so nothing can distinguish an observation that is still true from one whose reading has been retired. Every consumer that ever ran a pre-fix binary carries the poisoned floor for the life of the clone.
The only remedy today is deleting $GIT_DIR/batten-prune/laps.json by hand. No gate names it, no output mentions it, and the refusal's own remedy line says "Free space outside ./target, or start a fresh session" — which is advice to work around a number that should have been discarded. A refusal naming a remedy that is not the real one is CLOUD-1050's class arriving on the disk surface.
Why this is not CLOUD-1241 and not its candidate 3
CLOUD-1241 §3 lists "let the ratchet decay" as a rejected candidate — "does not fix the misclassification, but stops any single lap being permanent." This row is a different mechanism and a narrower claim:
- Decay is a forgetting policy over TIME or laps. It weakens every observation, including the correct ones, and it needs a half-life nobody has measured. That candidate stays rejected.
- Invalidation is a statement about the ENGINE. An observation taken by a reading that has since been corrected is not a weaker fact, it is not a fact at all, and discarding it is exactly as principled as
journal_unreadablediscarding bytes that will not parse — a precedent this file already carries.
The two are not substitutes and adopting this one does not reopen that one.
Refinement — Ready
Mechanism (§1) — a computable predicate: an observation whose recording engine's generation differs from the running engine's is discarded at read, and the run says so. LapJournal::read already has the shape — an unparseable journal reads as empty and Outcome::report prints why. This is the same discard on a different precondition.
Scope (§2) — in: the journal's own record of what took its observations, and what a read does when that record does not match. Out, deliberately: the floor values in batten.toml, which are protected; time- or lap-based decay, which is CLOUD-1241's rejected candidate 3 and stays rejected; the spent.min(free_mb) cap, which bounds magnitude and is working; and the crate version, which is the wrong key — a patch release that changes nothing about the reading must not discard a correct history.
Change (§3) — LapJournal gains a generation stamp, #[serde(default)] so an absent one is the empty string and therefore never matches. On read, a journal whose stamp differs from the compiled-in constant keeps nothing: the ratchet is dropped and the open lap with it, because a lap opened by the old reading closes into the new one and its spent arithmetic would be filed under a basis the two engines disagree about.
The constant is bumped by hand, only when a fix changes what a reading MEANS — which is the whole point of not keying it on the crate version. Its doc comment is the log of which fix each generation is for, so the next author bumping it has to write down why.
Output (§7) — one line, in journal_unreadable's shape and pointer-only: that the standing observations were taken by a superseded reading and were discarded. No megabytes from the discarded record — a number a reader could act on is exactly what this row says was never a fact.
Commit / bump (§6) — fix(prune); bump: patch. No config key moves and no public item changes shape; LapJournal is private.
Blockers (§8) — none. relatedTo CLOUD-1241 (the reading this retires the output of), CLOUD-1240 (the reclaim half), CLOUD-1244 (the escalation reading the standing observation, which is what makes a poisoned one expensive rather than merely wrong), CLOUD-1155 (the retirement unit that owns land), CLOUD-418 (shown able to fail).
Acceptance
- A journal carrying a stamp other than the compiled-in one reads with no standing observation and no open lap, and the run reports the discard.
- A journal carrying the current stamp reads unchanged — this must be inert on every clone whose history the current reading did produce.
- A journal with no stamp at all (every journal written before this row) is discarded, which is the case this container is.
- Shown able to fail (CLOUD-418): with the stamps compared as equal-or-absent, the pre-fix journal survives the read and the 10997 warm observation is still in force — the case reds on exactly the number that produced this row.
- The declared floor is what binds after a discard, so a run following one is judged by
batten.tomlalone.
CLOUD-1249 The escalation cannot reach the `keep = 2` undo hedge, which is now ~11 GB — the largest reclaimable thing in the tree, and the one a breached floor never asks for
Why
The module already states the formula and the risk. The world moved and nothing re-read it
prune.rs's header:
keepis 2 rather than 1, and it is a hedge with a stated cost. The newest artifact per stem is what the current build reads; the one before it is what a rebase that reverts would otherwise rebuild from scratch. Keeping two costs one copy per stem and buys back the common undo.
and, on the floor's basis:
Retained bytes after a perfectly successful prune are
keep × stems × size.keepis 2 andsizeis stable;stemsis not.
**Both are correct and together they are the defect. **stems was ~41 when CLOUD-766 measured it (2026-08-20). It is now ~130 — one cargo test target per crates/batten/tests/*.rs, and CLOUD-843's campaign adds one per retired gate with ~147 shell suites still standing. size is 85–106 MB per test binary under [profile.dev] debug = 1.
So the hedge is 1 × 130 × ~85 MB ≈ 11 GB, on a container whose usable allowance for target plus one lap is ~26 GB. The hedge now costs more than a lap.
Measured, this container, 2026-08-31
After a session of land laps, with the superseded pass having run and reported success:
| MB | |
|---|---|
target/debug/deps |
18326 |
everything else under target |
234 |
deps held 3177 files: test binaries 85–106 MB each, libbatten-*.rlib at 172 MB × 2. cargo clean -p batten --profile dev took target from 18560 MB to 1423 MB and free space from 7799 MB to 25563 MB — 17 GB, with the basis still WARM, because the dependency closure survives and only this crate is relinked.
Neither existing tier can reach it, and that is structural rather than an oversight
- The retention cannot: two copies is exactly what
keep = 2asks it to leave. It is succeeding. - The escalation cannot: the hedge copy is not a
[[prune.regrowable]]root. It is live build output by policy.
So a run that is short of disk drops incremental — making the next cargo build fully cold, the most expensive thing this module can do — while ~11 GB of artifacts nothing will read sits untouched beside it. The escalation reaches for the costly lever because the cheap one is not wired up.
Why this is not its neighbours
- CLOUD-1240 widened what the escalation can see — the cross-target trees, 2.1 GB. Correct, landed, and about 11% of this. I read it as the pole and it is not.
- CLOUD-1244 fixed when the escalation opens. Also correct; it cannot help when the thing that would clear the floor is not a candidate at all.
- CLOUD-766 is the exhaustion this is one cause of.
- CLOUD-1211 owns
size: it adopted arm C ([profile.dev.package."*"] debug = 0), which strips the dependency closure and deliberately leaves workspace debuginfo for readable backtraces. Its own census measured arm A at 1.60 GB linked against 15.11 GB. That term is real and is not in scope here — this row is about the multiplier, which needs no profile change and no protected edit.
Refinement — Ready
Mechanism (§1) — a computable predicate: when the floor is still breached after the cheap regrowable tier, the retention is re-applied at keep = 1 and the second generation goes. reclaim_superseded(root, 1) already exists and already does exactly this; nothing calls it.
Scope (§2) — in: escalate in crates/batten/src/prune.rs, and the report line that says what it did. Out, deliberately: [prune] keep in batten.toml, which is protected and must keep its declared value — this changes what an ESCALATION may do under pressure, never the standing policy; [[prune.regrowable]], untouched; [profile.dev], which is CLOUD-1211's size term; and the (stem, kind) grouping, which is CLOUD-1157's and is what makes a per-kind retention safe to tighten at all.
Change (§3) — a third tier, placed between the two that exist, and the placement is the argument rather than a detail. Order is cheapest-cost-first: a regrowable root costs its own next run; the hedge copy costs a rebuild only on a rebase that reverts; a basis-moving root costs a full cold rebuild of everything. So the hedge goes **before **incremental, never after — after is too late, because the basis has already moved and the lap is already judged against the cold floor.
It is repo-agnostic by construction: 1 is the schema's own declared minimum for keep, so no new config key, no consumer name, and a consumer that declares keep = 1 already sees this tier do nothing.
Output (§7) — one line, pointer-only: the count of artifacts dropped and that the retention was tightened. Reported separately from the regrowable-cache line, because the two cost different things and a reader deciding whether to worry needs to know which happened. No paths.
Commit / bump (§6) — fix(prune); bump: patch. Outcome is already #[non_exhaustive] as of CLOUD-1246, so the new report field is additive and not a break.
Blockers (§8) — none. relatedTo CLOUD-1155 (the retirement unit that owns land, where the lap-cost measurement lives), CLOUD-766 (the exhaustion), CLOUD-1240 and CLOUD-1244 (the two escalation fixes this completes), CLOUD-1211 (the size term, deliberately out of scope), CLOUD-418 (shown able to fail).
Acceptance
- A tree below the floor after the cheap tier, holding two generations per stem, keeps one and the run says so.
- The basis stays warm: dropping a superseded copy is not a basis-moving reclaim, and a lap that only tightened retention is still judged against the warm floor.
- Shown able to fail (CLOUD-418): with the tier removed, the same fixture keeps both generations and the run reports no tightening — and the case reds on the retained count, not merely on a message.
- The tier is inert above the floor: a tree with headroom keeps its hedge, so the undo the header bought is only spent under pressure.
- A consumer declaring
keep = 1is unaffected, because there is no second generation to take. - The costly tier still runs when tightening was not enough, so nothing that used to be reclaimable stops being reclaimable.
|
Warning Review limit reachedNext included review available in 23 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (2)
Note 🎁 Summarized by CodeRabbit FreeYour 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 |
…name The escalation knew names and not shapes, so every cross-compilation target tree was outside it entirely. Measured on this container while lapping #775, from a completely cleared `target/`: debug 6429 MB partly reachable (`incremental`, `build`) semver-checks 1548 MB declared by name aarch64-apple-darwin 1378 MB UNREACHABLE release 872 MB UNREACHABLE x86_64-pc-windows-gnu 721 MB UNREACHABLE perf 562 MB declared by name Nothing supersedes those trees, so the retention rule cannot reach them either. They are caches of `incremental`'s exact kind: regrowable, unbounded, and superseded by nothing. WHY THIS IS THE ENGINE'S AND NOT TWO MORE `[[prune.regrowable]]` ROWS. The module header argued that which directories a build tree grows is a fact about the consumer's project, so the list belongs in config. That is true of a NAME and not of a SHAPE, and the distinction is the whole of this change: `semver-checks`, `perf` and `flycheck*` are names somebody chose, so they stay declared. `target/<triple>/` is not — it is what cargo lays down for every `--target`, in every project, and a nested `CARGO_TARGET_DIR` has the identical shape. Recognising that shape compiles in no consumer identifier and matches nothing at all in a project that never cross-compiles, which is what non-negotiable rule 1 is actually about. The header now says so rather than arguing the opposite. The derived roots join the WARM tier and never move the basis, on the file's own existing test: dropping a nested tree makes only *that* build full and leaves the host cargo build warm — the identical reasoning already recorded for `semver-checks` and `perf`. They are taken after the declared pass, and a directory a declared row already removed is skipped, because `remove_dir_all` on an absent path would count a reclaim that freed nothing. SHOWN ABLE TO FAIL (CLOUD-418), because this function ends in `remove_dir_all` and the one case that matters is `target/debug`. A predicate matching it would delete the host build every time the floor was breached and report it as a reclaim. It is excluded twice — structurally (a real `target/debug` holds `deps/`, `build/`, `incremental/`, never a nested profile directory) and by name — and the redundancy is deliberate. Proved rather than asserted: removing the name guard reds `the_host_profile_directories_are_never_taken_as_nested_trees` on the second assertion, and the guard was restored from a backup taken before the mutation. Seven cases: the cross-target recognition, the host exclusion above, a directory with no profile directory inside it, one-level-only so a tree's own `deps/` is not itself a tree, the warm-tier reclaim leaving the basis warm, the cold tier never taking a derived root, and a tree a declared row already took not being counted twice. WHAT THIS DOES NOT FIX, stated rather than implied. The lap still costs 11684 MB and the floor still ratchets up off the worst lap on record — measured this session, a cold lap raised the observed warm floor from 9970 MB to 10997 MB, so the next lap needs ~22.7 GB free at open. This closes 2.1 GB of that gap. `target/release` is deliberately left alone: `perf-gate` rebuilds it partway through the same lap, so reclaiming it would cause a rebuild inside the lap rather than a reclaim from it. Refs: CLOUD-1240, CLOUD-1157, CLOUD-1155, CLOUD-766, CLOUD-418
…ration `prune` gated its reclaim on `config.warm.mb` while the refusal that follows is judged against `declared.max(observed)`. Those are one number only until a ratchet observation stands above the declaration. From then on there is a BAND between the two, and inside it a run refuses without ever attempting the reclaim that would have cleared it. MEASURED, ACROSS ONE SESSION, AND EVERY REFUSAL IN IT FELL IN THE BAND. Declared 7264MB; in force 9970MB and then 10997MB. Four refusals at 7896 / 8777 / 9064 / 10931MB free — all four above the declaration, all four below the floor they were judged by — and zero escalations. Each one ended with a human deleting directories by hand. The reclaim was never unable, it was never asked: ballasting the same tree to 6600MB, below the DECLARATION, escalated immediately and reclaimed 6061MB without moving the basis. Those gigabytes were reachable at every one of the four refusals. Both tiers now open on `warm_floor_in_force`. The costly tier matters as much as the cheap one: it re-reads free space to decide whether the cheap pass was enough, so comparing that reading against the declaration inherits the identical gap one step later. The WARM standing is what opens both, and never the cold one, even where the cold floor is what will judge the lap. This gate admits a pass whose rows cost only their own next run; the costly tier sits behind its own re-read. Opening on the cold standing would make a basis-moving drop the entry condition for a pass whose whole contract is that it does not move the basis. SHOWN ABLE TO FAIL (CLOUD-418): `the_declaration_alone_would_have_refused_every_one_of_them_without_trying` asserts the predecessor's reading over the same four numbers — against `config.warm.mb` alone every one of them is ABOVE the gate and none escalates, which is the observed behaviour, kept as an assertion so the two numbers cannot quietly become one again. Two cases hold the direction: an absent observation must not invent a floor (the fresh clone, where this is inert), and an observation BELOW the declaration must not lower it — the declaration is a lower bound, which the reporting half already states and the gate now agrees with. Refs: CLOUD-1244, CLOUD-1241, CLOUD-1240, CLOUD-766, CLOUD-418
The reading that recorded a cold lap against the warm basis was corrected. The number it wrote was not, and could not be: `Ratchet::raise` only ever climbs, so a corrected engine reads a figure the corrected reading would never have produced and treats it as measured fact. MEASURED ON THIS CONTAINER, AFTER THE FIX HAD LANDED. `[prune.warm]` declares 7264MB. The journal held a warm observation of 10997MB, written by a lap that was cold. `land` was refused at `verify`'s precondition with 8356MB free — 1092MB ABOVE the declaration — by the artifact of the bug it had just fixed. The only remedy was deleting a file under `$GIT_DIR` by hand. No gate named it, no output mentioned it, and the refusal's own remedy line said "free space outside ./target, or start a fresh session" — advice to work around a number that should have been discarded rather than worked around. A ratchet is a memory of a MEASUREMENT, and a measurement is only as good as the instrument that took it. The journal recorded no instrument, so nothing could tell an observation that is still true from one whose reading has been retired, and every clone that ever ran a pre-fix binary carries the poisoned floor for life. The journal now carries `taken_by`, and a journal some other reading took is discarded at read — the ratchet AND the open lap, because a lap opened by the old reading closes into the new one and its `spent` arithmetic would land in whichever basis the two engines disagree about, which is the misfiling the discard exists to undo. COMPARED FOR EQUALITY, never equal-or-absent. Every journal written before this key existed carries no stamp, and those are exactly the ones the corrected reading did not take. The stamp is bumped BY HAND, only when a fix changes what a reading MEANS — which is why it is not the crate version: a patch release that changes nothing about how a lap is measured must not throw away a history that is still true. Its doc comment is the log of which fix each generation is for, so bumping it costs writing down why. NOT DECAY, which stays rejected. Decay is a forgetting policy over time; it weakens correct observations too and needs a half-life nobody has measured. This is a statement about the engine: an observation a retired reading took is not a weaker fact, it is not a fact, and discarding it is exactly as principled as the existing discard of bytes that will not parse. SHOWN ABLE TO FAIL (CLOUD-418), over the real file rather than a hand-written equivalent: `the_journal_that_refused_this_container_does_not_survive_the_read` carries this container's exact journal bytes, and relaxing the comparison to equal-or-absent — the one plausible weakening — reds it on precisely the 10997MB observation that produced the row. Five cases: those bytes discarded, a journal this reading took read unchanged (a clone whose history is still its own must be untouched, or the ratchet resets every run and no floor is ever observed), a stamp from some other reading also discarded so the mechanism works forwards and the next bump is not inert, the writer re-stamping so a discard happens once rather than every run, and the report saying so while carrying no number out of the record it threw away. Refs: CLOUD-1246, CLOUD-1241, CLOUD-1244, CLOUD-1240, CLOUD-418
`semver` refused the branch: `constructible_struct_adds_field`. Adding `journal_superseded` to `prune::Outcome` is a break, because `pub mod prune` exports a struct anyone could build with a literal. THE ROW SAID OTHERWISE AND THE ROW WAS WRONG. CLOUD-1246 §6 read "no config key moves and no public item changes shape; `LapJournal` is private" — the second half true, the first false, and the true half is what made the false one look right. Corrected on the row rather than only here. The break is declared rather than dodged. Nothing outside this crate builds an `Outcome`: `prune` builds it and callers read it, so a struct literal was never the contract, only an accident of it being available. Declaring it makes this the LAST break of its kind rather than the second. `journal_superseded` is the second discard flag the struct has grown, and it arrived because fixing a reading turned out not to retire what that reading had written — the next class of observation will do the same. Without `#[non_exhaustive]` each one is priced at a version bump, which teaches the next author to fold two claims into one boolean. That is exactly what this struct must not do: unreadable says the store is damaged, superseded says the store is intact and its numbers stopped being facts, and a reader who cannot tell those apart cannot tell a disk problem from an upgrade. BREAKING CHANGE: `prune::Outcome` is `#[non_exhaustive]` and carries a new `journal_superseded` field. A struct literal outside this crate no longer compiles; reading the fields is unchanged. The struct is built by `prune` and read by its callers, so no supported use is affected. Refs: CLOUD-1246, CLOUD-1241, CLOUD-1244, CLOUD-1240
CLOUD-1246's five cases all called `LapJournal::read` directly. That tier pins the predicate and proves nothing about whether the ENGINE routes its answer anywhere — `.claude/rules/policy-modules.md`'s argument, arriving on a lap journal instead of a policy module. The new case runs `batten target prune` over the verbatim bytes that refused this container: the journal as it stood when CLOUD-1241's fix had landed and `land` was then refused at 8356MB free against a floor of 10997MB, 1092MB ABOVE the declaration, by the artifact of the bug it had just fixed. No `taken_by`, because the key did not exist when those bytes were written. TWO ASSERTIONS, AND NEITHER ALONE DISCRIMINATES. Without "the run says so", the case passes over an engine that silently starts a fresh history — the failure `journal_unreadable` already exists to prevent. Without "10997 binds nothing", it passes over one that prints the line and obeys the observation anyway. SHOWN ABLE TO FAIL (CLOUD-418): relaxing the stamp compare to equal-or-absent — the one plausible weakening, and the reading every pre-key journal would slip through — reds it on the 10997 assertion. AND IT REPAIRS A CASE OF TRUNK'S THAT THIS CHANGE BROKE. `an_observed_floor_names_the_file_that_holds_it` seeds a journal by hand to prove CLOUD-1218's last acceptance bullet, that a learned floor names the file holding it. Unstamped, that fixture is now discarded before the assertion can see it, and the case went red for the right reason: its own comment says the case "would prove nothing over a number the repair discards", written before a second repair existed. Stamping the fixture restores what it was asserting. The literal is duplicated from `prune::JOURNAL_GENERATION` because an integration test cannot see a private constant. That is a drift hazard with a loud failure rather than a silent one: the next bump reds both cases with the reason in the message. Refs: CLOUD-1246, CLOUD-1218, CLOUD-1244, CLOUD-418
01b71f7 to
8149058
Compare
…basis `prune.rs`'s header states the formula and the risk in the same breath: `keep` is 2 rather than 1, and it is a hedge with a stated cost. The newest artifact per stem is what the current build reads; the one before it is what a rebase that reverts would otherwise rebuild from scratch. Retained bytes after a perfectly successful prune are `keep x stems x size`. `keep` is 2 and `size` is stable; `stems` is not. BOTH ARE CORRECT AND TOGETHER THEY ARE THE DEFECT. `stems` was ~41 when CLOUD-766 censused it on 2026-08-20. It is ~130 now — one cargo test target per `crates/batten/tests/*.rs`, with CLOUD-843's campaign adding one per retired gate — at 85-106MB each. So the hedge is ~11GB on a container with ~26GB for `target` plus a lap: it costs more than the lap it is meant to survive. MEASURED, and neither pass could reach it while both reported success: target/debug/deps 18326MB over 3177 files everything else 234MB The retention leaves two copies because that is what `keep = 2` asks. The escalation skips them because they are not a `[[prune.regrowable]]` root. So a run short of disk took `incremental` — a full cold rebuild, the most expensive thing this module can do — with gigabytes nothing will ever read beside it. The cheap lever was not wired up, so it reached for the expensive one. THE FIX IS A THIRD TIER AND ITS PLACEMENT IS THE ARGUMENT. Cost order: 1. a regrowable root that is not the basis -> that tool's next run 2. the undo hedge -> a rebuild only on a revert 3. a basis-moving root -> a full cold rebuild Tier 2 goes BEFORE tier 3, never after. After is too late: tier 3 has already moved the basis, the lap is already judged against the cold floor, and the space tier 2 would have found arrives after the decision it would have changed. `1` is the SCHEMA'S OWN MINIMUM for `keep`, so this tightens to the strictest bound a config could legally have declared rather than inventing a number. No new key, no consumer name, nothing in `batten.toml` moves — the standing policy is untouched and this is only what an escalation may do under pressure. A consumer already at `keep = 1` has no second generation and the tier is a no-op. NOT BASIS-MOVING, which is why it belongs on this side of tier 3: the copy taken is by definition not the one the next build reads, so `deps` stays populated and the lap is still judged warm. The tightened artifacts are counted as SUPERSEDED rather than as cache, because that is what they are — the same pass re-run at a stricter bound. A reader seeing them under "regrowable cache dropped" would think a cache had gone that had not, and the two lines are exactly the distinction a reader deciding whether to worry is deciding between. `Outcome::tightened` is a count; the bytes are already in `reclaimed_mb`. SHOWN ABLE TO FAIL (CLOUD-418): disabling the tier reds `a_breached_floor_spends_the_undo_hedge_before_it_takes_the_basis` on the surviving count AND on `incremental` still existing, and `spending_the_hedge_leaves_the_basis_warm` on the basis. The inertness case `a_tree_above_the_floor_keeps_its_undo_hedge` stays GREEN under the same mutation, which is what makes the pair discriminate rather than merely fire. Four cases: the tier fires and stops before the basis; the basis stays warm; a tree with headroom keeps its hedge; and the count lands in the superseded column rather than the cache one. Refs: CLOUD-1249, CLOUD-1155, CLOUD-766, CLOUD-1240, CLOUD-1244, CLOUD-418
|
❌ The last analysis has failed. |
|
/fast-forward |
Closes CLOUD-1240. Closes CLOUD-1244. Closes CLOUD-1246. Closes CLOUD-1249.
Four defects in
prune.rs, found bylandrefusing lap after lap on one container. Every refusal was correct and every one named the environment rather than the diff — the floor is a working sensor. What was wrong sat behind it.Read CLOUD-1249 first. The other three are about the floor; that one is about the ~11 GB the floor was refusing over, which nothing could reach.
CLOUD-1249 — the cheap lever was never wired up, so a short run took the expensive one
prune.rs's header states the formula and the risk in the same breath:>
keepis 2 rather than 1, and it is a hedge with a stated cost. The newest artifact per stem is what the current build reads; the one before it is what a rebase that reverts would otherwise rebuild from scratch.>
> Retained bytes after a perfectly successful prune are
keep × stems × size.keepis 2 andsizeis stable;stemsis not.Both correct, and together they are the defect.
stemswas ~41 at CLOUD-766's census on 2026-08-20. It is ~130 now — one cargo test target percrates/batten/tests/*.rs, with CLOUD-843's campaign adding one per retired gate — at 85–106 MB each. The hedge is therefore ~11 GB on a container with ~26 GB fortargetplus a lap. It costs more than the lap it exists to survive.Measured here, with both passes reporting success:
target/debug/deps(3177 files)targetNeither pass could touch it, and neither was malfunctioning:
keep = 2is what it was told;[[prune.regrowable]]root.So a run short of disk dropped
incremental— a full cold rebuild, the most expensive thing this module can do — with gigabytes nothing will ever read sitting beside it.The fix is a third tier, and its placement is the argument. Cost order:
Tier 2 goes before tier 3, never after: after is too late, because tier 3 has already moved the basis, the lap is already judged against the cold floor, and the space tier 2 would have found arrives after the decision it would have changed.
1is the schema's own minimum forkeep, so this tightens to the strictest bound a config could legally have declared rather than inventing a number. No new key, nothing inbatten.tomlmoves, and a consumer already atkeep = 1sees a no-op. The standing policy is untouched — this is only what an escalation may do under pressure. It is not basis-moving: the copy taken is by definition not the one the next build reads.The tightened artifacts are counted as superseded, not as cache, because that is what they are — the same pass re-run at a stricter bound. A reader seeing them under "regrowable cache dropped" would think a cache had gone that had not, and that distinction is exactly what a reader deciding whether to worry is deciding between.
CLOUD-1240 — the escalation knew names, not shapes
One lap from a cleared tree builds across six trees, and four were unreachable:
debugincremental/buildlive inside itsemver-checksaarch64-apple-darwinreleasex86_64-pc-windows-gnuperfThe header argued the list belongs in config because "which directories a build tree grows is a fact about THIS project". True of a NAME, not of a SHAPE.
semver-checksandperfare names somebody chose.target/<triple>/is what cargo lays down for every--targetin every project, and a nestedCARGO_TARGET_DIRis identical. Recognising the shape compiles in no consumer identifier.Derived roots join the warm tier and never move the basis — dropping a nested tree makes only that build full.
CLOUD-1244 — the reclaim was never unable, it was never asked
The escalation gated on
config.warm.mbwhile the refusal is judged againstdeclared.max(observed). Those are one number only until a ratchet observation stands above the declaration — after which there is a band where a run refuses without attempting the reclaim that would clear it.Every refusal that day fell in the band. Declared 7264; in force 9970 then 10997:
Ballasting the same tree to 6600 MB — below the declaration — escalated immediately and reclaimed 6061 MB. Those gigabytes were reachable at all four refusals.
All tiers now open on
warm_floor_in_force. The re-read between tiers matters as much: comparing it against the declaration inherits the identical gap one step later.CLOUD-1246 — a corrected reading could not retire what the broken one wrote
CLOUD-1218 fixed how a lap's basis is read. The observations the old reading took stayed in the journal, and
Ratchet::raiseonly climbs:Measured after that fix landed:
landrefused at 8356 MB free against a floor of 10997 MB — 1092 MB above the declaration — by the artifact of the bug it had just fixed.A ratchet is a memory of a measurement, and a measurement is only as good as the instrument that took it.
LapJournalnow records which reading took its observations; a journal whose stamp is not the running engine's keeps nothing, the open lap included, because a lap opened by the old reading closes into the new one and itsspentarithmetic would land in whichever basis the two disagree about.The stamp moves by hand, only when a fix changes what a reading means — the point of not keying it on the crate version. This is not CLOUD-1218's rejected "let the ratchet decay": decay is a forgetting policy over time and weakens correct observations too; invalidation is a statement about the engine. It is the near neighbour of CLOUD-1218's still-open bullet — that one is a floor being unsatisfiable, this is its provenance.
Outcomeis#[non_exhaustive], and that is the declared breaksemverrefused this branch withconstructible_struct_adds_field; the row's §6 had claimed "no public item changes shape", which was false. The break is declared, and the attribute makes it the last of its kind — this is the second discard flag the struct has grown, and without it every future observation is priced at a version bump, which teaches the next author to fold two claims into one boolean. Bump is stillpatch: below 0.1.0 every releasing type collapses to it.Shown able to fail
incrementalstill existing; the basis case reds on the basis. The inertness case stays green under the same mutation, which is what makes the pair discriminate rather than merely fire.escalate's guard toconfig.warm.mb→ the four measured readings all sit above the gate with nothing escalating.Plus the inertness clauses: an absent observation must not invent a floor, an observation below the declaration must not lower it, a journal this reading took must be read unchanged, and a tree with headroom keeps its hedge.
Two commits dropped, and this is the part worth reading
This branch also carried a fix for CLOUD-1241 —
basis_of's.any()narrowed to.all(). Implemented, tested, green. CLOUD-1218 landed onmainmid-branch and refutes that mechanism by name:>
directories_namedonly yields directories that EXIST, and the reclaim an agent actually performs REMOVEStarget/debug/deps, which drops that profile out of the walk and leaves a populatedtarget/release/depssatisfying either quantifier..all()only helps for adepsthat survives but is empty, which is not what any of the three instances did.Correct, and fatal.
.all()cannot see the case that actually happens; the passing suite proved the predicate, not the class. CLOUD-1218's.fingerprintmarker closes it.The second dropped commit is stranger: this branch found that reading every
depsunder the root letsprune.rs's own fixtures decide the answer — one emptytarget/tmp/<case>/target/debug/depstook the floor in force from 7264 to 17357 MB on a tree holding 2088 + 710 real artifacts. CLOUD-1218 hit the same defect within the hour, from the opposite direction — its fixtures masked warm — and fixed it withcargo_owned. Two sessions, one hour, the same litter, both found by running the binary rather than reading the diff.CLOUD-1241 is Canceled as a duplicate of CLOUD-1218, Ready block kept rather than gutted: deleting one on withdrawal makes a row read as filed-and-never-groomed, which is a different and false claim.
What this does not fix
The ratchet is still unbounded. A floor learned from a roomier lap can exceed what a completed lap now leaves free, and then only ever refuses — measured here at 11519 MB in force against 9412 MB left by a lap that finished. That is CLOUD-1218's open bullet, recorded there with the numbers.
sizeis untouched. ~85 MB per test binary is[profile.dev] debug = 1on workspace code. CLOUD-1211 adopted arm C deliberately, for readable backtraces; its own census measured arm A at 1.60 GB against 15.11 GB. This PR is about the multiplier, not that term.target/releaseis deliberately left alone —perf-gaterebuilds it inside the same lap.The declared floors are untouched.
batten.tomlisprotected, and whether 7264 MB is the right basis is CLOUD-1158's.Not in scope
No predicate weakens.
Regrowableand[[prune.regrowable]]are unchanged, no declared row is removed or narrowed,[prune] keepkeeps its declared value, andbasis_ofis entirely trunk's.