> For the complete documentation index, see [llms.txt](https://docs.thecolliery.org/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.thecolliery.org/tools/coalwash/changelog.md).

# Changelog

All notable changes to CoalWash are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning: [SemVer](https://semver.org/) (the version lives in `.claude-plugin/plugin.json`).

## \[1.9.1] - 2026-09-25

A link, or another file, swapped in at the name of the lock, the bin death log or a config after CoalWash checked the path is no longer opened without being vetted on Windows, and every platform now proves the open handle is the file it judged.

### Security

* **A link, or another file, swapped in at the name of the lock, the bin death log or a config between CoalWash's path check and its open was opened without being vetted (CodeQL #43/#44, `js/file-system-race`); the open now comes first and the handle is proved to be the vetted file.** The 1.9.0 Security entry below says the stale-lock takeover, the death-log append and the `configure.mjs` project write "refuse a link". That held for a link COMMITTED at those names on every platform, and for one swapped in later on Linux and macOS (`O_NOFOLLOW`), but not on Windows: Windows has no `O_NOFOLLOW`, so a link swapped in after the `lstat` was followed, and the `fstat` that came next vouched for its target (a plain single-link file passes), which was then truncated and overwritten (the takeover), appended to (the death log) or written in place (`configure.mjs`'s fallback when a rename is refused). It needs a concurrent local writer, a stale lock or a held file, and on Windows a symlink privilege (a file symlink is refused with `EPERM` on a box without developer mode). The three sites now open first and then prove three things: the path is a plain single-link regular file, the handle is one too, and the two are the same file by dev and ino (compared as BigInt, since a Windows file id can exceed 2^53). The takeover also binds its stale verdict to the inode it judged, so a different plain file renamed to the lock's name in the window is left alone on every platform, and on a filesystem that reports no file identity number the takeover defers with a message, the death-log append is skipped and the `configure.mjs` fallback fails with its original rename error, rather than any of them guessing. **Affected: Windows, 1.9.0 only** for a swapped-in link (through 1.8.0 a link at these names was followed unconditionally, which the 1.9.0 entry covers). Named, not closed: a directory component swapped for a junction after `ownSandboxDir`'s check, the read side's link-following window, which reads and never writes, and the class-A engine (`explode`, `detonate`), which has the same check-then-open shape but is not shipped in the plugin. — test: `scripts/lib/repo-fs.test.mjs`

## \[1.9.0] - 2026-09-25

A cloned repository can no longer aim CoalWash's own reads, writes and deletes outside the project, a config that exists but cannot be used is now reported on SessionStart instead of skipped in silence, and a project config can no longer choose where your transcripts are archived.

### Added

* **A config that EXISTS where the walk reads but cannot be used is now REPORTED, once, on SessionStart, instead of skipped in silence (UMB-174 (b), CWK-135 (a)).** The line is `[CoalWash] UNREADABLE: <path> exists but is not a readable config (<reason>); it was skipped — canonical = <canonical>`, and `<reason>` is one of `malformed JSON` (an empty file is not JSON), `a directory`, `not a JSON object` (`[]`, `"x"`, `42`, `null`, `0`, `false`, `""`) or `unreadable`. The file is still skipped and the walk's selection is unchanged. The project tier's canonical is `.claude/coal/coalwash.json`; the global tier names its own path there, since a global config has nowhere to move to. An unreadable GLOBAL config makes `coalwashMode` fail safe to `off`, and its line is printed even so — otherwise the skill would go quiet with no reason shown; a readable `off` stays fully silent, and so does an unreadable project config beneath it. A project config over 1 MiB, a FIFO or device, or a link that dangles or leaves the project reads as `unreadable`. `config-status` does not carry this line. — test: `scripts/lib/config-load.test.mjs`, `scripts/lib/conductor.test.mjs` (`2f375f5`)

### Changed

* **`estate.archiveDir` is now read from the GLOBAL config only; a value set in a PROJECT config is ignored (CWK-137).** A cloned repo has no business choosing where your session transcripts are archived. With no global value the archive lands under `~/.claude/coal/coalwash/estate-archive`. `node scripts/configure.mjs --estate.archiveDir <path>` (a project write) still writes the value but now warns that the key is read from the global config only and points at `--global --estate.archiveDir`; `--global` prints no warning, and `configure.mjs --help` says so. **If you archived with a project-level `archiveDir`,** `estate-search` and `estate-restore` now look at the global or default directory: the files in the old one stay on disk and are no longer found until you move the value with `--global --estate.archiveDir`. So it is no longer silent: on every run, `estate-search` and `estate-restore` print one line on stderr when a project config carries such a value (stdout and the exit code are unchanged): `[CoalWash] estate.archiveDir: the project config (<project config path>) asks for <ignored value>, and that was ignored. A cloned repo ships a project config, and it must not be able to choose where your own session transcripts are archived, so this key is read from the GLOBAL config only. This run read <directory actually read>. If you set that path yourself and want it used, set estate.archiveDir in <global config path>, or move the archives that path holds into <directory actually read>.` The line states why the value was ignored and offers a remedy only if you set that path yourself, never the repo's path as yours. A project value that only restates your own global value draws no line, and neither does a relative one (it never directed archives anywhere). — test: `scripts/lib/config-load.test.mjs`, `scripts/configure.test.mjs`, `scripts/lib/config-schema.test.mjs` (`2812d15`, `8f6bb73`)
* **`exercisePerBand` is stated honestly as read-tolerated with NO runtime effect (CWK-120).** The conductor's OBESE and FULL branches are fixed (OBESE runs the free mechanical Quick pass automatically, no ask; a FULL with certain fat force-runs it and only then asks, once, to open the Full wizard) and never read the key, so the README and the shipped config comment that described it as choosing an exercise were wrong. The hookless "best-effort agent-driven offer" is likewise stated as designed and not shipped on a file-copy install, and the `/coalface` hand-off offer is now conditional on CoalFace being installed. No behaviour change — ship-text only.
* **Contributor-facing.** The git hooks' suite can be run serial and memory-capped (`COALWASH_TEST_CONCURRENCY=1 NODE_OPTIONS=--max-old-space-size=2048`); every git child in a test or gate takes one shared environment helper that strips the whole `GIT_*` family (`scripts/git-env.mjs`, CWK-133) and `verify.mjs` fails a git spawn that does not (CWK-136); `verify.mjs`'s dist line is built from `DIST_ITEMS` and its SessionStart FAIL names the hooks path it checks; the config-key gate's Configure section ends at any Markdown heading; PR checks always report and no workflow leaves a token in `.git/config` (CWK-120). Not shipped in the plugin.

### Security

* **A cloned repo could aim CoalWash's own reads, writes and deletes outside the project, and one path did it at SessionStart with no user action (CWK-137).** `sweepWriteguard` runs at every SessionStart, on by default, and recursively deleted the entries of `<repo>/.claude/coalwash/writeguard` — following a symlink or junction a repo had committed there, so opening a malicious clone emptied the link's target. Reproduced red, then green: before the fix a junction at that path left the directory it pointed at EMPTY after one SessionStart; after it, nothing is deleted through the link. The class was closed at the source: every directory CoalWash writes or deletes in under `.claude/coalwash` is now reached only through a chain with no link between the project root and it; the write-guard snapshot, the sweep, the transaction, bin and keeps directories, the stale-lock takeover, the death-log append and the `configure.mjs` project write refuse a link, a special file or a multi-linked file; and the reads of repo-derived files are bounded and kind-gated (config, `@import` closure, the snapshot sweep's journal, the roster, the dead-link scan, each plan target, whose snapshot copy is a kernel-side copy verified bounded right after and before any mutation, and snapshot verification; one named residual, the crash-recovery replay of a journal inside the trusted roots, was left as it was, anchor-gated and containment-checked) — a governance or memory file over 4 MiB is no longer snapshotted or content-measured (its `@import` closure is flagged instead), and a rewrite or delete target over 4 MiB is refused by name and left untouched. The CoalHearth handoff-journal guard now fails CLOSED (an unreadable or linked-out journal protects the newest session; it used to fail open). CR and LF in a death-log entry's `id` and `original` are neutralized (log injection: both come from a repo-plantable index). A project whose `.claude` or `.claude/coalwash` is itself a link now gets no snapshot, no sweep, and a loud `apply` refusal naming the link. **Affected versions, derived from the commit that introduced each primitive, never from tag names, each through 1.8.0: the transaction directory, the `@import` closure read and the project-config read (the last two also run at SessionStart) from 0.1.0-beta.1; the lock takeover from 0.1.0-beta.2; the keeps store directory from 0.1.0-beta.6; the bin directory and the death-log append from 0.1.0-beta.12; the write-guard snapshot and its SessionStart sweep, the destructive vector that needs no user action, from 0.1.0-beta.19; the `configure.mjs` project write from 1.6.1** — every one uncured in the last release, `v1.8.0`. A sister channel, a project-layer `estate.archiveDir`, is closed under Changed above. — test: `scripts/lib/repo-fs.test.mjs`, `scripts/lib/writeguard.test.mjs`, `scripts/lib/apply.test.mjs`, `scripts/lib/config-load.test.mjs`, `scripts/configure.test.mjs` (`9ff6c8e`, `d3d0404`, `54dcda9`)

### Fixed

* **`configure.mjs` no longer accepts a config whose body is `null`, `0`, `false` or an empty string as an empty config** — it refuses it as not a JSON object and leaves the file byte-identical (the truthy non-objects were already refused). — test: `scripts/configure.test.mjs` (`2f375f5`)
* **A corrupt or hand-edited `fullCleanAt` no longer makes the conductor speak the advisory instead of asking for the Full-tier consent.** A Full clean is now a positive timestamp, judged on the value, rather than whatever `Number()` coerces to a finite number (`null`, `''` and `false` all coerced to `0`). — test: `scripts/lib/conductor.test.mjs` (`f760fab`)
* **`retier` config values are integers only:** a decimal now falls back to the default in the hook, the CLI and `runRetier` alike, where the hook and the CLI used to disagree. — test: `scripts/lib/config-schema.test.mjs` (`bf8935b`)
* **An unreadable project config no longer contributes a false-valued safety key** to the merge: the presence check read the raw project layer instead of the effective one. — test: `scripts/lib/config-load.test.mjs` (`d4c9923`)
* **The wizard's project root and slug now agree with the rest of the engine on a Windows 8.3 short name**, through `realpathSync.native`. — test: `scripts/lib/wizard.test.mjs` (`86f0c98`)
* **A bin sweep no longer races a concurrent record:** the sweep now takes the bin lock and re-reads the index inside it, and the bin lock judges staleness by a live clock, so an item banked days in the past no longer leaves an orphaned lock that never goes stale. — test: `scripts/lib/tailings.test.mjs` (`44b214a`)
* **RE-TIER rollback survives a malformed manifest** as failed restores instead of throwing mid-rollback and leaving the run half-restored. — test: `scripts/lib/retier.test.mjs` (`45e325c`)

## \[1.8.0] - 2026-09-22

### Added

* **A `.coalwash.json` at CoalTipple's/CoalBoard's legacy shape (`.claude/.coalwash.json`) is now honoured here too, and a `.coalwash.json` at a path this room's walk will never read is reported instead of silently skipped.** The candidate walk already read the three canonical `<agent-dir>/coal/coalwash.json` paths, then ONE legacy dotfile at the project root — a second, nested legacy shape some users bring from a sibling room's own convention sat outside that list and was read past in silence. The walk now honours BOTH legacy shapes, nested before root, canonical still winning over either. Two small reports ride alongside it: `node scripts/lib/cli.mjs config-status [--json]` names a resolved-legacy read with the canonical path, and the conductor's SessionStart line flags a `.coalwash.json` planted under an agent dir this walk does not carry a nested-legacy candidate for (`.agents`/`.gemini`), which would otherwise be silently dead. The migration notice is deliberately NOT on SessionStart — this room's own hermetic conductor tests default every project to the root legacy shape, and plenty of real installs do too, so an unconditional per-session notice for it would be a nag on the ordinary case rather than a rare finding; the rare, genuinely-a-mistake shape (hole 1) stays on the ambient channel. Considered and declined: adding the nested shape to the project-root marker list — `<home>/.claude/.coalwash.json` is byte-identical to `globalConfigPath`'s own path, so a real user's global config would make the walk misread their HOME directory as a project root the moment it reaches it. — test: `scripts/lib/config-load.test.mjs`, `scripts/lib/conductor.test.mjs`, `scripts/lib/cli.test.mjs`, `scripts/configure.test.mjs` (`UMB-133`)
* **`/coalwash:stats` now renders both config-path reports.** A legacy read (naming the canonical path) and any ignored stray config surface in the stats output the same way `gauge`/`estate` already do, reading `config-status --json`; nothing prints when the config is canonical and nothing is stray. — test: none (a command-prose contract, not code) (`UMB-133`)

### Changed

* **If you have EVER written a `.claude/.coalwash.json` — even the CoalTipple/CoalBoard habit, months ago and forgotten — it now applies here, on upgrade, with no action from you.** That shape sat outside this room's candidate walk before this release, so any config living there was silently inert; the walk now reads it as CoalWash's second legacy path (README `## 🔧 Configure`). Every key you set there now takes effect, retention edges included — measured exhibit: a project carrying `estate.purgeAfterDays: 0` under that path had it ignored at v1.7.0 and honoured here, tightening the 180-day schema default to 0. The consent-bearing keys (`coalwashMode`, `updateMode`, `writeGuard`, `localOnly`, `scanEverything`, `estate.deleteCold`) are unaffected in the direction that matters — the safer-value-wins clamp against the global layer still applies to a value read from either legacy path exactly as it does to the canonical one; proven by mutation and by a before/after resolution matrix, not merely read (two clamp-reverting mutants both reddened, and all six consent keys resolve identically with the nested legacy live, INSPECT round). If a stale `.claude/.coalwash.json` is not what you want honoured, move or delete it — see the deprecation note below. — test: `scripts/lib/config-load.test.mjs` (`UMB-133`)

### Deprecated

* **Both project-level legacy config shapes: `<project>/.claude/.coalwash.json` and `<project>/.coalwash.json` at the project root.** The canonical path is `.claude/coal/coalwash.json` (README `## 🔧 Configure`). Both are still read today, nested-before-root, and a legacy read now surfaces via `node scripts/lib/cli.mjs config-status` / `/coalwash:stats` — this is a notice, not a countdown; nothing stops working before removal. **Deprecated at this release (MINOR), removable at CoalWash's next MAJOR.** Migration owner: this room. The global `~/.claude/.coalwash.json` is a different path and is unaffected. (`UMB-133`)

## \[1.7.0] - 2026-09-21

### Added

* **CoalWash can now learn your real context ceiling from a file another tool writes, when the platform's own stats leave it blank.** On the machine this was measured on (2026-09-10), Claude Code reported every model's context window as `0` in `~/.claude/stats-cache.json`, so the gauge there fell back to the conservative 167,000-token ceiling — and a store sized for a 1,000,000-token session read FULL on every gauge. Where the stats cache is blank that way, the capacity adapter now checks a second place: `~/.claude/coal/coalwash/capacity.json`, `{ stateSchema: 1, rawWindowTokens, capacityTokens }`, written by whatever can actually see a session's window (a `claude -p --output-format json` receipt carries it; a hook never can). The gauge line names it: `(discovered: capacity-file)`. **CoalWash only reads this file — it never writes it.** **The file must hold the SMALLEST raw window of any model the machine runs, never the last session's.** It is one number for every session on that machine, and CoalWash cannot tell which model a session runs or check what the writer recorded — so a file holding a larger window than some model on the machine makes that model's sessions gauge against a ceiling they do not have. **A stats cache that reports a real window (any value above `0`) always decides instead:** its own smallest in-range window when that is usable, or the conservative default when it reports a window but none it can use — the file is not read, so it can never override a smaller window the platform itself reported in a cache the adapter can read. The usable ceiling is re-derived from `rawWindowTokens` (raw minus the 33,000-token auto-compact reserve: 1,000,000 → 967,000), and the file's own `capacityTokens` must agree with that, so a writer using a different reserve cannot quietly install its own arithmetic. A missing or non-numeric field, a number out of range, or a `stateSchema` that is older or newer than 1 makes CoalWash ignore the file and use the conservative default, labelled as such; a leading byte-order mark does not. — test: `scripts/lib/caliper.test.mjs` (`CWK-099`)

### Changed

* **A keep's `anchor` is now measured for real content before it is stored, and the store tells you when one was dropped.** A re-affirm passing a whitespace-only, single-character, or invisible-character anchor no longer silently overwrites a real adjudicated one, and `recordKeep`/`recordGlobalKeep` return `{ ok, anchorDropped, anchorStored }` instead of a bare boolean — so "the write succeeded" and "the anchor you asked for did not survive the floor" stop being the same answer. **This is a return-shape change for any caller reading that value: `{ ok: false }` is TRUTHY, so a truthiness check that used to detect failure now cannot.** Nothing in the shipped engine calls these functions, so no shipped behaviour depends on it today.
* **The gauge line names a partially-recovered dangling run instead of reporting it as a clean recovery.** A run where some item failed or was refused — journal and snapshot deliberately kept for a human — used to render exactly like a full success; it now says so, and points at `--json` for the detail.
* `tailings.mjs` gains a test-only `__testHooks.binLockAttempts` counter (the `caliper.mjs`/`fidelity-gate.mjs` precedent — a wall-clock bound replaced by a load-independent count), incremented once per `acquireLock` attempt inside `recordBinItem`. It replaces the suite's tightest clock — "an orphaned bin lock is reclaimed quickly" asserted `ms < 500` against a \~600 ms retry budget — with "reclaimed on the FIRST attempt". Zero non-test consumers and zero behaviour change, but the export ships in a `DIST_ITEMS` member, so it is recorded here. — test: `scripts/lib/tailings.test.mjs` (`grad6 F1`)

### Removed

* **The anchor-based STRUCTURAL position check is gone, not merely retired.** Seven functions in `apply.mjs` (`needleIndentShape`, `locateStructural`, `ancestorChain`, `chainPreserved`, `indentRelativeSurvives`, `flattenSurvives`, `survivesOwnFile`) checked whether a keep's multi-line anchor still sat inside the same enclosing structure after a rewrite. They were **never reachable in a shipped run** — nothing in the engine writes a keep's `anchor` field, and every real `keeps.json` record carries only `{target, reason, date}` — and they were declared retired by their own author before this. **The honest consequence, stated rather than buried: for a HAND-WRITTEN multi-line anchor, the KEEPS-GATE now asks only whether the anchor's text still appears somewhere in the plan's post-edit content, exact or whitespace-normalized.** Content that escapes its enclosing block while keeping its bytes therefore counts as surviving. `pinned: true`'s file-level protection is a different, wired mechanism and is unaffected.

### Fixed

* **The shipped CLI did nothing when it was run through a symlink or a directory junction.** `scripts/lib/cli.mjs` decided whether it was the entry point by comparing the path it was invoked by with the path Node resolved for it. Through a link those two paths differ, so the check never matched: whatever command it was given (`gauge`, `restore`, the `writeguard` and `estate` commands), it exited `0` with no output and did nothing. Measured through a directory junction: run with no subcommand it should print its usage and exit `1`; it printed nothing and exited `0`. Both paths are now resolved to their real location before they are compared. — test: `scripts/lib/cli.test.mjs` (`r34c`)

## \[1.6.1] - 2026-09-10

**Supersedes \[1.1.0]'s "CoalWash has no project-config WRITER anywhere in this codebase (no `configure.mjs`…)" note — true when written, no longer true.** A config CLI now exists at `scripts/configure.mjs`. **It does not reach the plugin you install**: `plugin/scripts` carries `lib/` only, so this release delivers no new capability to an installed user, which is why it is a PATCH. The CLI is documented in the README for people working from a repo checkout; it earns no `### Added` line here, because this file covers what a user *receives*.

### Fixed

* **The one permission case 1.6.0 explicitly did NOT cover is now closed: a governance file you can still SEE but are not allowed to READ.** 1.6.0 closed every case where a path could not be resolved at all, and said so in its own entry — it also said, in the same breath, that a narrower block (the path resolves and stats fine, only the CONTENT is denied) still dropped that file's entire `@import` closure in silence. That was accurate for 1.6.0 and its entry below is left exactly as written. It is no longer true here. Measured on this box with a restore control: a project `CLAUDE.md` importing one file, with a read-only-denied parent, dropped the imported file from the reading entirely while nothing at all was flagged. **The file's own bytes were always counted and still are — what vanished was everything it imports**, and the new flag says precisely that: `unreadable governance file: <path> [<code>] — its own bytes ARE counted, its @import closure is NOT`. Pre-existing since `beta.1`. — test: `scripts/lib/class-b.test.mjs` (`r32 (a)`)
* **A dangling link in your governance tree was reported as `refused` — a permission word for a case where nothing denied anything.** A junction or symlink whose target has been deleted still passes `lstat` (which does not follow it) and then fails to resolve; CoalWash carved that same "nothing is behind this path, stay silent" case out on the first check and not on the second, so the flag read `refused path (governance): DANGLING.md [ENOENT]` on every gauge and sent the reader hunting an ACL problem that does not exist. **A dangling link loses nothing — there is nothing behind it to count — so it now produces no flag at all**, the same silence a plainly absent file already got. Reached rather than argued, and the choice between the two available fixes was made from a measurement: `ENOENT` is the only code observed reaching that second check on this platform (a junction loop and an overlong name are both stopped by the first one), so the carve-out is repeated rather than a code-to-noun map invented for codes nothing could be shown to reach. — test: `scripts/lib/class-b.test.mjs` (`F-T3`, `R3-F3 INVARIANT`)

## \[1.6.0] - 2026-09-10

### Added

* **CoalWash now works out what your machine's context ceiling actually is, instead of using one fixed number for every install (the capacity adapter).** The FULL band has always had a "capacity wall" — the point where your always-loaded memory is crowding the session itself, and the honest advice stops being *wash* and becomes *move something out*. Until now that wall was a single placeholder shared by every user on every model. It now looks for a real per-machine figure first (`~/.claude/stats-cache.json`, the platform's own model stats), and when it finds one it uses it — and says so.
* **The gauge line tells you WHERE the ceiling came from.** A ceiling that was actually discovered on your machine reads `capacity ~N tok (discovered: ...)`; a fallback reads `capacity ~N tok (CONSERVATIVE DEFAULT — no platform figure discovered)`, and only where it matters (a FULL store). A LEAN store is not told about a ceiling it is nowhere near. **On today's Claude Code the platform reports the field but leaves it empty, so most installs will see the conservative default — that is the honest answer, not a failure, and the day the platform fills the field CoalWash picks it up with no update needed.**

### Changed

* **A store between roughly 167,000 and 600,000 tokens of always-loaded memory will now read FULL where it used to read LEAN.** This is the one user-visible consequence of the release and it is deliberate. The old placeholder assumed \~600k of usable room; the new conservative default assumes the smallest window a supported session actually runs on, minus the reserve the platform holds back for auto-compaction (200,000 − 33,000 = 167,000). If your governance and memory files add up to 167k tokens on a 200k-token session, there is essentially no room left for the conversation — the old number said that was fine and it was not. **Nothing is deleted and nothing runs automatically because of this**: a FULL crossing runs the free mechanical pass and, at most, asks you one question.
* **CoalWash no longer tells you your memory is "muscle, not bloat" until something has actually looked at it.** The FULL(externalize) advisory used to say a store was muscle on the strength of the MECHANICAL scan, which only ever proves exact duplicates and spacing — it never reads meaning. That advisory now appears only after a Full (semantic) pass has genuinely removed something in the same episode. Before that, the same crossing offers you the Full pass instead of advising you to go relocate files. Both messages now say what was actually measured.
* **The capacity message appears at most once per session** instead of on every turn while a store sits over the ceiling.

### Fixed

* **A memory file moved into a SUBFOLDER stopped being counted at all — so the band could read LEAN on a store that never shrank.** CoalWash reads your memory store to work out how much of every session your always-loaded files are eating. That read was one level deep: `memory/notes.md` was counted, `memory/topics/notes.md` was invisible. Measured on identical content: the same 49,515 bytes read **12,384 tokens** as a flat file and **631 tokens** one folder down — and nothing anywhere said the difference had gone. The walk now goes all the way down, with a cap (500 files or folders) that ANNOUNCES itself if it ever trips, so a partial read can never look like a complete one. **Two things this deliberately does NOT change:** only the top-level `MEMORY.md` is the always-loaded index, so nothing found in a subfolder can inflate the per-session number you are judged on (pinned by its own test); and content moved OUT of the store entirely, or parked beside it rather than inside it, is still not visible — that is stated rather than papered over. — test: `scripts/lib/class-b.test.mjs` (`CWK-082 L1`)
* **The "move something out" advice now tells you WHERE the weight actually is, and what it cannot see.** When your always-loaded memory is crowding the session, CoalWash can only advise — it never moves your files, by design. It used to advise without naming anything, so you were left to guess which file to open. It now lists the largest always-loaded entries with their token cost, and says plainly that a file moved WITHIN the store still counts while a file moved OUT of the store leaves the gauge’s sight completely, with no line anywhere reporting it. **Nothing was given permission to move anything** — this is bookkeeping, not a new action. — test: `scripts/lib/ask.test.mjs` (`CWK-082 L2`)
* **The FULL(externalize) advisory told you a semantic pass had "judged the rest" of your memory — a claim the record behind it cannot support.** That record is per-TRANSACTION, not per-store: a Full pass that rewrites one file of three and removes one line from it is recorded, while the other two files were judged by nothing. The advisory now states only what the episode actually established — a Full (semantic) pass ran and REMOVED content under the fidelity gate, which refuses any drop the plan did not name — and says outright that it establishes nothing about files that pass never touched. **When it can no longer support a claim, it under-claims:** the eligibility rule is unchanged, only the sentence narrowed, so at worst you re-run a pass you did not need. — test: `scripts/lib/ask.test.mjs` (`CWK-081 F1`)
* **A Full-pass run that removed nothing could still mark your store as "adjudicated".** A transaction that only creates files, rewrites a file to identical content, or appends, committed successfully and was recorded as a completed semantic pass — after which CoalWash would tell you a pass had judged your memory and kept it. The record now requires that something was actually removed. — test: `scripts/lib/apply.test.mjs` (`CWK-081 H1`)
* The capacity probe now refuses a malformed `modelUsage` array instead of reading values out of it. — test: `scripts/lib/caliper.test.mjs` (`CWK-081 L1`)
* **The write-path airbag's undo net was described as covering any hand-move — it does not, and the shipped text now says so.** README and the skill body both said, with no condition, that the airbag was "the only undo net" for your gitignored `MEMORY.md`/`CLAUDE.md`. It only ever snapshots a write made through the file-edit tools (Edit/Write/MultiEdit); a move made through the shell (`mv`, `sed`, a heredoc, a script) has never taken a snapshot and has no undo net. Both the externalize advisory and the two doc surfaces now state the same condition: covered on Edit/Write/MultiEdit, not covered on the shell. **If you were relying on the wider claim, you were not protected on that channel.** — test: `scripts/lib/ask.test.mjs` (`CWK-082 L3`)
* **A file or directory whose path could not be resolved at all could vanish from your memory reading with no warning — that specific case is now closed.** Three related sites let this happen in silence: an unreadable subdirectory partway through the memory-store walk; a permission-denied governance file or role-memory store whose path could not even be resolved; and a perfectly readable file sitting behind a parent directory that itself cannot be traversed, which used to read as simply absent rather than refused. Measured: a single permission-denied `CLAUDE.md` took its entire `@import` closure from 3 entries / 1,516 tokens to 0 entries / 0 tokens, with nothing flagged. All three are now flagged by name (a path relative to your project, never an absolute one, reading `refused path (...)`) instead of being silently subtracted from what CoalWash thinks it read. **What this does NOT cover, stated so the claim is not read wider than it is:** a file that resolves and stats fine but whose CONTENT you are denied — a narrower permission block than a resolve failure — still drops its entire `@import` closure in silence today; that gap is open and tracked for a future release, not closed by this fix. — test: `scripts/lib/class-b.test.mjs` (`CWK-082 F2`, `CWK-082 F2-R2`)
* **A file behind a directory you can't traverse used to read as simply absent — it now reads as refused, and CoalWash tells you which.** A candidate whose own path is fine but whose PARENT directory denies traversal used to be silently treated the same as a file that never existed; the always-loaded count and the wash both quietly shrank with nothing said. It is now flagged the same way as the sibling cases above. The flag's own wording also changed for every resolve-failure case: it used to read `unresolvable path (...)`, and now reads `refused path (...)` — if you have anything matching on that string, update it. — test: `scripts/lib/class-b.test.mjs` (`R3-F1`, `R3-F3`)
* **When CoalWash named a file for you to move, two files sharing a name could print as one indistinguishable label.** The "move something out" advisory and the "what did the last pass touch" line both showed only a file's own basename, so a project's own `MEMORY.md` and the platform's own `memory/MEMORY.md` could both print as `MEMORY.md` on the one screen whose job is telling you which one to open. Both lines now show the shortest path suffix that is unique among what's on screen — one word in the common case, more only where two files actually collide. — test: `scripts/lib/ask.test.mjs` (`CWK-082 F3`)
* **The FULL(externalize) advisory now also names exactly which files a Full pass removed content from.** It already told you what it could not vouch for (files a pass never touched); it now also states what it can: "THAT PASS TOUCHED, exactly: ... — anything not in that list it did not read." An un-covered file is now visibly outside the claim rather than silently inside it. — test: `scripts/lib/apply.test.mjs`, `scripts/lib/ask.test.mjs` (`CWK-081 (b)`)

## \[1.5.1] - 2026-09-10

### Fixed

* **The conductor's stdin read used a TOTAL deadline (`STDIN_BUDGET_MS = 30`, armed at process start) and dropped a well-formed payload whose first byte simply arrived late (CWK-072).** The timer fired on an empty buffer, `JSON.parse('')` threw, the read resolved `{}`, `main()` matched no event branch, and the hook exited 0 having done nothing — no gauge, no `lastVerdict`, no directive, indistinguishable from a hook that correctly found nothing. This was **delivery latency, not corruption or absence**: measured at 40 concurrent spawners (4000 invocations, a timing probe carrying no timer at all, so nothing is dropped and every invocation reports a real number), the first byte lands after 30 ms on **25% of invocations** (1007/4000, the BUILDER's own timing probe), p50 19.5 ms, p95 56.5 ms, max 141.9 ms — a deadline measured from process start is a function of host contention, and the loss rate rises with contention (0.850% MISS at K=40 → 2.400% at K=80 on the pre-fix engine). **An independent REVIEWER instrument — same box, same day, same K, its own timer-free probe (N=2000) — measured the same DIRECTION at a different magnitude: 1.3% (26/2000), p50 10 ms, p95 20 ms, max 78 ms.** Two probes measuring from different origins under different self-load; neither is necessarily wrong, and neither bare number is the fact worth carrying forward — the direction (contention pushes the first byte past 30 ms, and the rate rises with K) is what both agree on.

  Fixed by replacing the total deadline with an **IDLE timeout re-armed on every chunk, arming from the data handler only** (never from process start) plus a generous **anti-hang ceiling covering the pre-first-byte window**. The two cannot share one arm-point: an idle timer armed at t=0 IS the retired total deadline — measured and rejected (31/4000 MISS at K=40, statistically indistinguishable from the retired timer's 34/4000). Idle silence is only meaningful relative to *progress*, and there is none before the first byte, so that window belongs to the ceiling instead.

  **`STDIN_HANG_CEILING_MS = 1500`** — a ceiling on pathology, never a budget for lateness, and the comment says so explicitly with an instruction never to tune it downward (doing so rebuilds the retired deadline in a larger costume). **Argued from what two independent instruments AGREE on, never from a safety factor they do not:** the BUILDER's probe (N=4000) read a worst end-of-payload of 180.8 ms; an independent REVIEWER rebuild (its own origin, its own self-load, N=2000) read 1189 ms, p99 760 ms — worst cases \~6× apart, and no multiple derived from either instrument survives the other. What both agree on is the only property this number needs: nothing crossed 1500 ms in any cell either instrument ran.

  **What this costs, named rather than buried:** the happy path — a closed pipe (stdin ends at once) or a TTY (a new explicit branch, since a human hand-running the file carries no hook payload and would otherwise wait out the ceiling for nothing) — resolves immediately, unaffected by either timer; Phoenix #3's own *added-work* budget is untouched there. One shape gets slower: a pipe that is opened, held in total silence, and never closed now costs the ceiling (measured 1586 ms) where it once cost 30 ms. That shape has never been observed from Claude Code — the 30 ms version of it was the one dropping real payloads under contention, which is the point of the fix.

  Proven red-first with a probe that extracts the shipped `readStdinJson` from the real conductor file by line anchor rather than re-implementing it, so every cell measures shipped code: `4000/4000` clean at both K=40 and K=80 post-fix against `34/4000` and `96/4000` MISS pre-fix, with a control (an echo child running none of our code) confirming `spawnSync` input delivery itself is sound at the same concurrency. The regression test (`conductor.test.mjs`) spawns the real hook, writes the `SessionStart` payload 200 ms after spawn, and asserts the positive state effect (`lastVerdict` written, band `LEAN`, one stamp) — proven RED against the pre-fix engine first (`late payload dropped: the hook exited 0 having done nothing (raw: {})`); exit 0 proves nothing on its own, since Phoenix #4 guarantees it on the dropped-payload path too. No test anywhere in this fix was made tolerant — no retry, sleep, widened window, `t.skip`, or catch-and-continue.

  **Three limits stay named, not resolved by this fix:**

  1. **Real-session exposure.** As an input to the release decision this is moot — the fixed condition ("1500 ms of silence before the first byte") no longer contains a term for concurrency, and an ordinary session spawning one hook at a time is strictly less contended than the K=40/K=80 cells that could not make the fixed engine fail. As a historical question it is **not answered**: nobody has measured how Claude Code actually delivers a hook payload in the field, and this fix does not either. Whether real sessions were losing payloads before 1.5.1 stays unproven in either direction; what changed is that the answer no longer gates whether the fix is correct.
  2. **The path breakdown behind the pre-fix loss was n=3** (2 `end`-path losses at 5 ms with 0 bytes, 1 `timeout`-path loss at 56 ms) and this fix's own probe reports HIT/MISS only — it neither confirms nor widens that sample.
  3. **Why removing the total timer also removed the `end`-path losses is not explained.** This fix does not establish a causal chain for it, and none is invented here — it stays an open gap.
  4. **A payload split by a silence longer than `STDIN_IDLE_MS` (30 ms) mid-flight still truncates.** The idle timer fires 30 ms after the last byte SEEN, so a >30 ms inter-chunk gap resolves on a partial buffer — `JSON.parse` throws, `{}`, no branch matched, exit 0 having done nothing: the identical CWK-072 outcome, and the 1500 ms ceiling never engages because the idle timer wins first. This is a named residual, not a live exposure — measured reachability is **zero**: a 256 KB payload arrives in 5 chunks with a max inter-chunk gap of 3 ms (K=20), 3 ms (K=40), 5 ms (K=80) — **0 of 1200 gaps over 30 ms** across all three. Contention delays the START of a write, never its continuation, which is the mechanistic reason arming the idle timer on the first byte — rather than at process start — is the right design.
* **`commands/update.md:9`'s self-update Cadence step named `updateMode`/`updateCheckDays` as living "in `.coalwash.json`" with no tier — the one ship-text surface in the room that omitted the global/project cascade every other mention of these keys states (`README.md`'s Configure table, `references/platform-cc.md`).** Both keys are also two of the six safety keys a project config may only quieten, never escalate past a deliberate or unreadable global (`hooks-safety.md` §9) — a fact the old wording gave no room to state. Corrected to name the cascade and the clamp, matching the other two surfaces' own phrasing rather than inventing a third. `commands/*.md` is a tracked `plugin/` dist item (`scripts/verify.mjs`'s `descTargets` walk); the dist twin is rebuilt in this same release, not left to drift.

### Declined

* **Shape (c) — distinguishing an EMPTY stdin read from a MALFORMED one — is not built, deliberately.** In a hook bound by Phoenix #13 (zero noise) and #12 (silent skip on unexpected state), both cases resolve `{}`, match no `main()` branch, and exit 0 silently — the distinction has no observable consumer anywhere outside the process, so it is untestable and would ship as dead code. Building it properly means `hooks-safety.md` §4.1's diagnostic escape hatch (env-gated, file-only, its own named collector under Phoenix #1) — its own unit, with its own obligations, not folded into this fix.

## \[1.5.0] - 2026-09-03

### Added

* **`scanEverything` — one key bypasses every SCAN-scope cut for the run (CWK-057, owner law; flock-canonical, live in CoalMine `ee15ade` and CoalLedger `12b4e12`).** Default `false` = today's behaviour byte for byte. ON lifts the two cuts this room actually has: **(1) the always-loaded READ BUDGET** (`measureEntries`' 262144 B) — past it an entry was never read, so its certain fat was never measured and it counted as MUSCLE ("fail-toward-silence, deliberately", per that function's own comment); ON no longer skips an entry FOR BUDGET (an entry whose read FAILS still contributes nothing — that path is unchanged). **(2) the 200-path cap** (`ALWAYS_LOADED_PATHS_CAP`) on the Stop hook's cheap re-stat baseline — a path past #200 was never re-stat'd, so a size change there was invisible to the cheap gate (the next SessionStart's full gauge still caught it); ON keeps the whole list. — test: scripts/lib/caliper.test.mjs
* **It widens SEEING, never DESTROYING — in a room that deletes, that is the whole feature.** `keeps.json`, the KEEPS-GATE, every other delete gate, `localOnly` and every consent gate are untouched: with the key ON, nothing is deleted, merged or mutated that would not have been with it OFF. Deliberately NOT bypassed, each for a stated reason: **keeps** has no scope cut to lift at all (`loadKeepsAt` reads every well-formed entry — no cap, no budget, no truncation anywhere in `keeps.mjs`), and its two non-test readers are `apply.mjs`'s KEEPS-GATE (a delete gate) and `estate-archive.mjs`'s tombstone registry (an advisory, never a block) · **`managedPaths`** scopes the knife, not the scan ("MEASURED like anything else (BMI must never undercount the parcel)") · **`localOnly`** is a consent switch, already clamped the other way · **`fileMaxSizeKb`** is a flagging threshold · **band/hysteresis/`exercisePerBand`** suppress the ask and the force, not the gauge, which runs every SessionStart regardless · **`estate.*`/`retier.*`** are destroy-lane and reorg economics · **`armDigGauge`'s session dedup** is declined: it dedups a SURFACED line within one session and narrows nothing about what was scanned, and making it re-surface would either write its state flag at a different time or not at all — both leak past the single run the mode is for.
* **Disclosure while ON, on a channel Phoenix #13 already sanctions** (the SessionStart context injection — the sanctioned list does not grow), and gated on the gauge HAVING RUN, never on the flag alone: with the flag on but no scan (coalwashMode `manual`, or an empty class-B store) it stays silent rather than reporting an event that did not occur. It names both lifted cuts explicitly, states plainly that keeps, the delete gates, `localOnly` and the consent gates are untouched, and bounds the claim rather than saying "everything": recall-tier entries are still sized from stat bytes and never read, and a file the platform never surfaced as class-B is still not there. It also claims no more than the bypass delivers — no always-loaded entry is skipped FOR BUDGET, but an entry whose read throws still contributes nothing (that fail-toward-silence path is unchanged). — test: scripts/lib/conductor.test.mjs

### Changed

* **`scanEverything` merges safer-value-wins, in a new `SAFER_FALSE` list — the MIRROR of `localOnly`'s `SAFER_TRUE`, and the polarity is the point.** `SAFER_TRUE`'s rule is "a global `true` wins"; here `true` is the ESCALATED value, because a project file setting it makes us read MORE of the user's memory than their global stance allowed. `hooks-safety.md` §9's blast test reads the DIRECTION OF ESCALATION, never the key's name. Four properties, each a hole this file has shipped before: a project's silence never clamps a global `true` away · an ABSENT global reads as the schema default (`false`), never a `continue` (the factory-default hole, W2-3/R2 — and the common case is a user with no global config at all) · a project may still QUIETEN `true`→`false` · an unreadable global file assumes the safe value unconditionally. Junk on either side gets no say and the stored value is a real boolean (K1). §9's SCOPE test passes with **no residue**: every consumer reads through `loadMergedConfig`, there is no second read path, and `neutralScan` resolves the key IN CODE rather than relying on the SKILL snippet to pass it. Its one test seam — `neutralScan`'s optional `scanEverything` opt — is itself fed through `mergeSafety` as a project-side layer (INSPECT F1), so an explicit `true` can never beat a global `false`: exactly ONE clamped path by which this key reaches the decision. — test: scripts/lib/config-load.test.mjs, scripts/lib/wizard.test.mjs

## \[1.4.2] - 2026-08-31

### Security

* **class-B arbitrary write through an alias planted at a write temp (CB board U7, HIGH) — the class-B twin of the class-A blob-symlink bug closed at `5ba5254`, missed when that fix never crossed lanes.** `writeDurable` opened its DESTINATION with a plain `'w'`, which follows a symlink sitting there, and `atomicWrite` handed it a fully derivable temp (`<target>.coalwash-tmp`). Anyone able to write the directory holding a class-B memory file could pre-place an alias at that path and have the wash push the file's bytes through it, outside every approved root, with the run still reporting `ok:true`. Reproduced live before the fix (an unprivileged hardlink stands in for the file symlink, which is EPERM on Windows without Developer Mode): a file outside every root came back holding the memory content. Now every durable write opens an O\_EXCL fresh inode at an unpredictable (12-byte CSPRNG) temp and `renameSync`s it into place — rename REPLACES a directory entry instead of writing through whatever sits there, so the destination is never opened for write at all. The two guards are cross-nature on purpose: the random name removes the precondition, O\_EXCL defeats a race that guesses right anyway. `atomicWrite` is gone, folded into `writeDurable`; its one caller calls that directly. — test: scripts/lib/apply.test.mjs
* **The same class swept at the four other derivable write temps the board's sweep clause points at, not just the one it named** — `caliper.mjs` (state save + the legacy-root migration), `keeps.mjs` (the keeps store), `tailings.mjs` (the recovery-bin index). All four already renamed into place, so their destination was never exposed; the defect was the derivable temp name plus a plain `writeFileSync`, which follows an alias planted there. Each now uses a CSPRNG suffix and `flag: 'wx'`. Deliberately NOT routed through `writeDurable`: that would add an fsync pair to the conductor's own state path (Phoenix #3 latency), and `caliper.mjs` cannot import `apply.mjs` at all — the module cycle runs the other way. — test: scripts/lib/keeps.test.mjs, scripts/lib/apply.test.mjs (class guard)
* **A standing class guard, so the seventh site cannot reintroduce this silently.** The board's own pattern finding was that the belt closes make→gate but has no propagate→twins step; this is that step made mechanical. It scans every engine module for a temp path derivable from its destination (literal glue, a single interpolation, or a `process.pid` segment) and fails naming the file and line. Its class-A exclusion is read from `build-plugin.mjs`'s own `UNWIRED_ENGINE` list rather than hardcoded, so the day class-A is wired the guard starts covering it with no edit. — test: scripts/lib/apply.test.mjs

### Changed

* **A stranded write temp no longer counts toward `rollback-failed`.** The per-action `<phys>.coalwash-tmp` sweep in the rollback path is removed: the temp name is now unpredictable, so a name-derived sweep at a distance cannot find it, and `writeDurable` reaps its own temp in its own catch — at the site that created it, reaching every in-process failure the old sweep reached. A leftover scratch sibling was never the class the remaining counters exist for (those count a mixed STATE of the user's data: a snapshot restore that failed, a plan-created file still present); it sits beside an untouched target, and a process crash skipped the old sweep just as completely.

## \[1.4.1] - 2026-08-29

### Changed

* `caliper.mjs` gains a test-only `__testHooks.strayPruneCalls` counter (the fidelity-gate.mjs `__testHooks` precedent — a wall-clock bound replaced by a load-independent count), incremented on every `pruneStrayStateDirs()` invocation. Closes a vacuous regression test: `R2/TP-6`'s one-shot-sweep pin used to assert `existsSync(strayFile)`, which can never fail for that fixture's planted stray (it is never a delete candidate under `pruneStrayStateDirs`' own containment guard, proven by mutation). The counter is test-only in effect — zero non-test consumers, zero shipped-behaviour change — but the export itself ships in `caliper.mjs`, a real `DIST_ITEMS` member, so the artifact changed and earns the version.

## \[1.4.0] - 2026-08-23

### Added

* **`keeps.json` gains a `pendingUser` field (+ `pendingSince`), and `keeps.mjs` gains `pendingUserKeeps()` (board #129, AGENTS.md's THE USER-OWNED CLASS).** The room's own store had 7 of 26 keep entries whose own `reason` text NAMES THE USER as the decision-holder ("USER own tradeoff", "deliberate user instructions", "USER-HELD", two "defer to a user-routed pass") — and every one recorded an agent-written *permanent* exemption instead of routing that call up, standing violations written before the rule existed. Fixed structurally, not by rewording: an insider recording a keep whose reason names the user now passes `pendingUser: true`, which still fully protects the target (no enforcement change — nothing is deleted, no keep is reversed) but stays a RETURNED decision rather than a settled one. Clearing semantics are the deliberate MIRROR of the existing anchor/anchorFile merge, not a copy: `undefined` (an ordinary re-affirm that has never heard of this mechanism) PRESERVES the flag — the whole point is that silence must not re-settle a standing violation — and only an EXPLICIT `pendingUser: false` clears it, the one signal that the user's decision actually landed.
* **The receipt surfaces the count.** `buildReceipt` renders `N keep(s) await YOUR decision` when `pendingUserKeeps > 0` — metrics only, per this room's own §9b data-leak rule (the targets live in `keeps.json` for the human to open, never inline in a receipt). `method.md` §5 now instructs filling it from `pendingUserKeeps(loadKeeps(projectRoot))` on every run this store carries any, not only a wizard run. §3's keep-recording rule gets the same exception named at the point of recording, so the next insider does not repeat the 7.
* **The 7 standing entries in `TheColliery/.claude/coalwash/keeps.json` marked `pendingUser: true` this release** (re-derived at source before marking, not accepted from the board's own citation — `grep`-equivalent: `data.keeps.filter(k => /user/i.test(k.reason))`, 7 of 26, 10 raw occurrences when not deduplicated per entry — both numbers agree with board #129's own derivation). No separate ratification was found for any of the 7 (checked `MEMORY.md` for a later user response to each — none exists); all 7 land in the RETURNED state, none is annotated as already-ratified.
* **Adjacency to board #128 (0ac): deliberately NOT the same mechanism.** 0ac's `owner: user|agent` field (not yet built) is the PERMANENT end state once a decision is actually made; this release's `pendingUser` is the transitional bridge that gets a standing violation IN FRONT OF the user once. A future `owner`-field build converts a cleared `pendingUser` entry, it does not replace this release's mechanism.

## \[1.3.1] - 2026-08-22

### Changed

* **SKILL.md body lean — the CLASSIFY-BLOCK denial cells' MECHANISM moves to `references/method.md` §13; every rail stays in the body (`skill-authoring.md` §5, campaign #6 unit 3).** The Grants & denials table grew +4,145 characters across three findings-back rounds in one day, each round appending its own war story *into* the cell — textbook §5 REGROWTH RATCHET, whose cure is that a dogfood fix adds its explanation to the REFERENCE and the body keeps only the rail the incident proved. What stayed: all three rows, all three grants, and every denial branch — engine reads fail closed and never a clean bill · `applyPlan`/estate moves fail closed while `keeps.json` appends fail SILENT and the airbag is more permissive still (the edit lands with no undo net) · a spawn denial is reported as distinct from a `localOnly` no-spawn. What moved: the per-path source citations and the provenance of each branch. **Body 28,102 → 27,000 LF chars, frontmatter excluded (−1,102, −3.9%).**
* **HONEST FRAME, stated because the number is the finding and not a failure to cut harder.** Measured with the platform's own projection on an install verified byte-identical to source first (`git hash-object`, src == installed — a stale install makes this reading plausible and wrong): **on-invoke \~11.2k against the \~5k guide, ≈2.24×.** Residue accounting says the remainder is overwhelmingly RAIL, not explanation: the two ledgers ARE the enumerability mechanism §3b's own measurement identifies (their COUNT and MEMBERSHIP are the pass condition, so cutting rows is the failure mode, not the fix) · the wizard section is the four-choice menu an agent must actually render · the run pipeline is step ORDER · Hard rules, the Asks branches and the Activation ladder rungs are all behaviour. **A body brought under budget by removing what the agent needs is a §5 violation, not a §3b win** (minimal ≠ short, vendor-verbatim), so the residual ≈2.2× is reported as this skill's honest size rather than chased. **Declared bound, BODY-ONLY, frontmatter excluded, LF-normalized** — re-derive rather than trust: `node -e "const l=require('fs').readFileSync('skills/coalwash/SKILL.md','utf8').replace(/\r\n/g,'\n');console.log(l.slice(l.match(/^---\n[\s\S]*?\n---\n/)[0].length).length)"`

## \[1.3.0] - 2026-08-22

### Added

* **`/coalwash:stats` now reports the ADJUDICATION RATE — the one direction the apparatus never measured (board #120).** False DELETES have two independent guards (the fidelity gate on dropped tokens, the claim-strength outsider on softened meaning); false KEEPS had none, and no surface anywhere reported how often the tool actually cuts, so a 100%-reject run was as quiet as a healthy one. The report is built entirely from data that already persists — `keeps.json` (one durable record per adjudicated keep) against the two bin manifests (one entry per banked cut, with `bytes` and `origin`) — so it needs no new state and no engine change; `apply.mjs` deliberately persists no run outcome, and `receipt.mjs`'s removed/trimmed/kept counters are transient render-time values, which makes the bins plus `keeps.json` the only honest durable record. Shows keeps count + oldest keep's date, per-bin cut count and bytes split by origin, and the resulting `N kept / M cut`. Counts and bytes are deterministic and are NOT `~est`-labelled. **The bin count is stated as a LOWER BOUND, not lifetime truth:** a bin is retention-bounded, so items swept past their horizon have left the index and live only in that bin's `death.log`.

  **CORRECTION TO THE RECORD, because this unit was justified on a number that was wrong.** Board #120 was dispatched on the premise that this store had made ZERO cuts against 26 keeps — an "applies \~0" / 0%-cut-rate framing. **That measurement was false.** Verified at source: `fat-bin` holds 2 rows and `store.old` holds 33, i.e. **≥35 banked cuts against 26 keeps — this store has cut MORE than it has kept**, 33 of the 35 with `origin: wizard-cut` (the paid semantic tier), spanning 2026-07-29 to 2026-08-21. It was the department head's own measurement error, not the builder's: a bad root from shell-escaping made `listBin` miss the directory, `loadIndex` swallowed the ENOENT by design (fail-silent), and an ERROR was read as a MEASUREMENT. **The lower-bound property above is what makes the correction decisive rather than merely a different number:** sweeping only ever REMOVES rows, so 35 surviving rows is a FLOOR — no amount of retention sweeping can push a true count UP to 35 from below. **None of the three edits rests on that number.** They rest on board findings 1/2/3/5/6, which are arguments from STRUCTURE — the flag format cannot carry corroboration, the adjudication burden is inverted, and nothing measures a keep — and every one of those holds at any cut ratio. Finding 2 in particular (accept rests on an unverifiable claim while reject needs one always-writable sentence) is what justifies the burden flip, not the ratio. Recorded here rather than quietly fixed, because a unit justified on a false premise should say so even when its conclusions survive.

### Changed

* **The outsider's flag format now carries its own class's evidence (board #120, `references/method.md` §2).** The required return was `file · line-range · class · one-line reason` — four fields, none corroborating — while `superseded` and `duplicate` are *defined* by a second location and `done-point-in-time` by a date. The outsider could therefore assert duplication without ever naming what it duplicated, turning three mechanically checkable claims into three opinions. The template already knew how to demand two locations (its contradiction-candidates clause asks for exactly that) and simply never asked it of these classes. The format gains an `EVIDENCE` field: a `file:line` pointer for `superseded`/`duplicate`, the date for `done-point-in-time`, `n/a` for the two judgement classes. **An evidence-less flag of the three evidence-bearing classes is demoted to `class=unsure` BY THE CONTRACT, mechanically — never by the outsider's choice**, and it may not be upgraded back by asserting the reason more strongly. The `unsure`/`unread` escape hatch and the every-listed-file-gets-a-line invariant are untouched; both are load-bearing and were added deliberately.
* **THE OWNER ADJUDICATION LAW — the adjudication burden flips to the DEFENSE (owner ruling 2026-08-22, `references/method.md` §3).** Verbatim: *"โดนทุก flags ก็หาหลักฐานมาหักล้างให้ได้ ถ้าทำไม่ได้ สิ่งนั้นคือขยะ"*. Previously ACCEPT (delete) rested on an unverifiable claim while REJECT (keep) needed only "a concrete reason, not a feeling" — one always-writable sentence, so rejecting was free and checking was expensive, in a tool whose purpose is to cut. Now a flagged target survives only with source-verified REFUTING evidence; unrefutable means garbage and the delete rides the plan. **Scoped narrowly and deliberately: the law reaches EXACTLY THREE NAMED CLASSES — `superseded`, `duplicate`, `done-point-in-time` — and nothing else.** Each asserts a checkable fact about a second location or a date, which is why failing to refute one is meaningful. The scope is an explicit class list rather than the property "carries its class's evidence": that property phrasing shipped first and was found VACUOUSLY SATISFIABLE by INSPECT on `ea27054` — §2 hands `over-verbose` and `trivially-obvious` the literal value `EVIDENCE=n/a`, which technically IS their class's evidence, so both passed the test and the law reached them. That authorised a delete on an unfalsifiable claim: `trivially-obvious` has no source to check against, so it can never be refuted, and unlike `over-verbose` (which §3 routes to *shrink*) it routes to DELETE. Both judgement classes are now excluded by name. `unsure` likewise never rides this law and never authorises a delete. **No safety rail moves:** the fidelity gate, the snapshot/whole-run-rollback undo net, the KEEPS-GATE and `pinned: true` are all unchanged. This moves who carries the argument, never what the machine refuses.
* `references/method.md` §3's `anchor`/`anchorFile` status corrected from "lab-only, declared 2026-08-04" (implying a someday-wire) to **RETIRED** (board #7, 2026-08-22) — the structural re-check they were built to drive has zero production callers anywhere in the shipped tree (every `anchor`-passing call site is a test fixture), so the proposed positional-provenance fix (`docket-0v`) would have hardened a mechanism nothing ever reaches. The shipped fidelity-diff gate does not independently close the escape class the mechanism targeted (relocation into a fenced code block) — named as a real, still-open gap rather than smoothed over. No code/API change; `recordKeepAt` still accepts both fields unchanged, `pinned: true` is unaffected.

## \[1.2.0] - 2026-08-21

### Added

* **SKILL.md now declares a CLASSIFY-BLOCK — what happens on a denied read/write/spawn grant, per `skill-authoring.md` §5b (board #93, gold-standard finding F22, retrofit strength `prefer`).** A new "Grants & denials" ledger, placed beside the existing Consent and Prohibitions ledgers, states the grant each step class needs and the branch a denial takes — `network` is dropped with a one-line reason (CoalWash is offline/zero-dependency by design; no step fetches). Two genuinely new rails land alongside the mostly-descriptive read/write rows: (1) a spawned outsider that cannot read an assigned file must name it as unread, never fold a missed read into a silent "nothing to flag"; (2) a spawn-tool DENIAL must be reported as distinct from `localOnly`'s deliberate no-spawn — both degrade to the same manual-flag fallback, but the reason is now stated, where the two previously collapsed into the identical silent "no sub ran" output. The write row separately names the write-path guard's airbag (§8b) as the ONE write path that does **not** fail closed the way `applyPlan` does — a fail-silent PreToolUse hook (Phoenix #4) never blocks the edit it snapshots, so a denied/failed airbag write leaves the real mutation landing with no undo net for that edit; `applyPlan` itself is unaffected (its whole body is one try/catch, verified at source — a write failure anywhere, including the snapshot, returns `{ok:false}` before any mutation lands). No engine code changed; this is a declaration, not a new mechanism.

  **INSPECT (independent, same day) returned FIX-NEEDED — 2 MED, 1 LOW-MED, 1 LOW — all closed in this same entry, no code risk (the commit changes no engine code):** the READ row's "outsider names an unreadable file" clause bound a reader (the outsider) that never sees SKILL.md at all — its real contract is `method.md` §2's verbatim template, which had no slot for it; fixed at the template (extends the existing `class=unsure` escape hatch: *"this covers a listed file you cannot read: flag it `class=unsure, reason=unread`"*), and the SKILL.md row now points at that mechanism instead of asserting behavior nobody instructs. The WRITE row understated its own scope on two counts: `keeps.json` appends are a THIRD shape, neither `applyPlan`'s fail-closed abort nor the airbag's fail-silent-and-passes-through — `recordKeepAt` swallows a write failure and returns `false` **silently, with no throw**, so an unrecorded adjudicated *stand* is lost with nothing surfacing it unless the agent checks the return value itself (the item re-flags next run, the exact decision-fatigue failure keeps exist to prevent); and the airbag clause claimed a PreToolUse hook "only gates by explicitly emitting `{decision:'block'}`" when `hooks-safety.md` §1.0 names a SECOND channel (`exit 2`) — corrected to name both channels and state CoalWash's conductor uses neither for PreToolUse. **The §3b variance-walk skip was reversed, not just noted:** the original build judged a full walk disproportionate for a two-rail addition and declared that call rather than hiding it — INSPECT attacked the call as instructed and found it did not survive §3b's own text on three independent grounds (a proportionality argument is the named carve-out the rule forecloses by name; a ledger is the rule's own example of a re-walked shared section; a grants/denials table is a fail-path enumeration, one of the five things the walk exists to measure) — and F1 was itself exactly the kind of multi-reader disagreement the walk's disagreement-detection is for. **A real walk then ran**, scoped to the FULL TIER lane per INSPECT's own narrowing argument (both new rails bind there; the write row is mostly descriptive of pre-existing behavior, per skill-authoring.md §5's rail/explanation test) — 3 rounds each at a weak and a strong model tier, 6 independent cold reads of `SKILL.md` + `method.md` §§2/4/6/9b, each asked to enumerate every distinct fail-path branch in the lane. **Result: the new CLASSIFY-BLOCK table's own three core rows (read/write/spawn) were captured with ZERO variance — every one of the 6 reads, both tiers, named all three.** ~~The strongest possible outcome for the section the walk exists to test.~~ **RETRACTED at RE-INSPECT — see below; this measured only the SKELETON, which §3b names as "the easy half."** The residual variance concentrates almost entirely in PRE-EXISTING, unedited Full-tier content this unit never touched (the `flagged[]` per-file-refusal note, the `deferred:true` lock line, the before-vs-after claim-strength second-outsider spawn, the wizard handshake refusal) — a real, useful finding, named here and left for a future SKILL.md consolidation pass rather than fixed inside this unit's own scope. ~~One softer signal from the new content itself — the write row's airbag clause was named in all 3 strong-tier reads and 1 of 3 weak-tier reads — sits within a plausible noise band absent a measured null-control floor for this instrument, and is recorded as a declared, unchased residual rather than a defect: the room's own doctrine (`skill-authoring.md` §3b) permits shipping a non-zero read once a real attempt has been made and the bound is stated, which this is.~~ **RETRACTED at RE-INSPECT — §3b permits a declared bound only after a real carve was attempted, across consecutive waves, against a measured floor; none of the three held here. See the next paragraph for what actually satisfies that bar.**

  **RE-INSPECT (independent, same day) returned FIX-NEEDED, far narrower — confirmed F1/F3/F4 all genuinely closed (F1 better than asked: the outsider template now also gained a completeness invariant — "every listed file gets a line, one way or another" — making a gap detectable by counting, not by an outsider volunteering it) — and attacked the walk itself on 4 grounds, all upheld against me:** (1) §3b's unit is FAIL PATHS, not table ROWS — "skeleton stable, rails not" is §3b's own named failure shape, and "all three rows present" measured only the skeleton; (2) the "pre-existing, not mine" framing was **unsupported** — no pre-edit baseline had run, and §3b requires one before attributing instability to prior content; (3) declaring the airbag signal an "unchased residual" with no measured null-control floor was **the identical move as the original walk-skip**, which the rule's own text forbids ("a bound declared against an unmeasured floor is the same unscored shrug... the EXISTENCE of a measured floor before trusting a declared bound is MUST"); (4) only 2 of the required 3 tiers ran (medium was absent) and 3 of 5 required stamps were missing, leaving nothing to ratchet against. Two new LOW findings, both fixed in the same pass: the write row's own numbering read THIRD/FOURTH with no SECOND (a hole introduced while closing F3/F4); and "ULTRA/estate archive moves" was still listed with **zero** stated denial shape — concrete proof of point (1) above (`estate-archive.mjs`'s `archiveSession` verified at source: it fails closed the same way `applyPlan` does — a write failure rolls back its own partial copy and returns `{ok:false, reason: "...— original kept"}`, source untouched until the archive copy verifies — now stated as its own shape in the row, folded under the same `applyPlan`-style FAIL-CLOSED heading since the mechanism matches).

  **A second, better-instrumented walk then ran, addressing RE-INSPECT's own critique directly rather than declaring around it:** re-scoped to CATEGORICAL yes/no questions (§3b's own named escape from count-based noise) about TOOL-GRANT denials specifically (excluding lock contention / content-policy refusal / handshake mismatch as out-of-scope for CLASSIFY-BLOCK, which is about permission grants) — 8 questions, **all THREE tiers this time** (weak/medium/strong), 3 rounds each = 9 independent cold reads. **Q1–Q7 (read-row, write-row × 3 shapes, spawn-row, localOnly-distinctness): 9/9 unanimous Y — zero variance.** Q8 (does the spawn row's single denial branch explicitly cover the wizard clone, not just the outsider) split 4Y/5N, isolated entirely across tiers (weak 3/3 Y, medium 0/3 Y, strong 1/3 Y) — a real, precisely-characterized gap: the row named three spawn sites but its prose read as written for the outsider alone. **Carved:** one bolded lead clause added — "The SAME branch binds all three named spawn sites — outsider, clone, and ③ block alike, none is a special case." **Re-walked Q8 alone, 2 rounds × 3 tiers = 6 reads: 5/6 Y (83%), up from 4/9 (44%) — a measured improvement across consecutive waves, satisfying the precondition RE-INSPECT named as missing.** The one remaining miss is isolated to weak tier (medium 2/2, strong 2/2, both 100%) and, read in full, the miss quoted the exact new sentence back verbatim and still answered N — a weak-tier judgment-call miss on an explicit bold declarative statement, close to the model-variable ceiling `skill-authoring.md` §3b names as a legitimate place to stop rather than a text clarity defect chase-able further. **Declared as the bound, ratcheted:** a future edit to the spawn row that widens this miss rate past today's measured 1/6 is a regression; today's 1/6 is not.

### Fixed

* **The capacity wall's index-LINES leg no longer dies silently when the memory-index entry falls past the read budget.** `measureEntries`' over-budget branch set `index.bytes` but never `index.lines`, so `bandVerdict`'s `indexLines >= CC_INDEX_CAP_LINES` leg read 0 and could not fire. The direction is what makes it serious: the memory-index entry is LAST in discovery order, so it is the FIRST pushed out of the budget as the corpus ahead of it grows — **the leg died precisely as the corpus grew toward the wall it guards**, and the sibling bytes leg is already at 79.6% of its cap on this repo's own tree. The line count is now recovered with a BOUNDED read (an index above `CC_INDEX_CAP_BYTES` already trips the bytes leg unconditionally, so the lines leg only decides anything below that cap — which bounds the read at 25 KB, once, for the single index entry). The global read budget is unchanged and the recovered text feeds newline counting only, never `mechFatFromText`. Same fix at the read-ERROR path, which previously zeroed BOTH index legs: `bytes` is a stat fact knowable without reading, so it now survives a failed read; `lines` genuinely needs the read and stays 0, the safe direction.

### Changed

* **The gauge line no longer reads as a clean bill of health it cannot support.** `certain fat ~0 tok` was rendered as a finding when it is only the FLOOR of what `mechFatFromText` can prove (exact-duplicate substance lines + excess blank runs); unread and unprovable content counts as muscle by design, so a store carrying real semantic bloat measures \~0 and looks clean — measured live on this repo's own tree, where 675 independently-flagged cuttable items coexist with `~0 tok`. The figure now carries its bound in either shape: `certain fat ~N tok (lower bound)`, or `no provable fat (lower bound — unread/unprovable counts as muscle)` when nothing was proven. Wording lifted from `commands/stats.md`, this room's own already-approved phrasing for the identical fact, rather than newly authored. **And BMI is suppressed at fat=0, where it is a tautology:** `muscle = footprint − fat` and `bmi = footprint / muscle`, so fat=0 forces exactly 1.00 by arithmetic — it carried zero independent information while sitting beside the fat figure looking like a second, corroborating measurement. It is still shown wherever it can actually vary. `bandVerdict`'s own source comment, which glossed the ratio as *"1.0 = provably-pure muscle"*, carried the same overreach at the point where the next engineer forms the belief and is corrected in the same pass. **No measurement and no band logic changed** — `mechFatFromText`'s definition, the arm/disarm thresholds, and every trigger are untouched.
* **Ship-text now states plainly that a keep's `anchor`/`anchorFile`-based structural re-check is lab-only (board #18, owner-ruled 2026-08-04; this sweep landed it 12 days late).** The mechanism (`needleIndentShape`/`indentRelativeSurvives`/`flattenSurvives`/`survivesOwnFile` in `apply.mjs`) verifies an old keep's anchor text still sits where it was recorded before honoring the keep — it is not wired into any shipped write path, since `recordKeep` (the only function capable of writing an anchor-bearing record) has zero production callers anywhere in `scripts/`/`hooks/` non-test code, confirmed again this session. Every live keep on this machine and in the shipped engine is the plain `{target, reason, date}` form; the declaration closes a theoretical gap in a dormant record type, not a live one. **`pinned: true`'s file-level gate (`isPinned`/`pinVerdict`) is a separate, wired, fully-exercised mechanism, untouched by this note.** Landed in `README.md` and `skills/coalwash/references/method.md`; full detail, including four measured escapes in the anchor-check mechanism itself (three CRITICAL content-loss, one HIGH structural-mismatch — live-executed, reproduced, or constructed per-finding, all currently zero-blast for the same dormant-record reason above) — see `MEMORY.md`'s "2026-08-04 — DECLARED LAB-ONLY: the anchor-based structural keep path" entry.

## \[1.1.0] - 2026-08-08

### Added

* **Per-project config now lives under an agent dir — the namespace campaign (#69+#39, owner-designated 2026-08-08).** The read order is a RAIL, identical wording across the whole series: (1) `<project>/.<the running agent's own dir>/coal/coalwash.json` — for CoalWash, always `.claude`, since this room activates only through Claude Code's own hook system; (2) other known agent dirs, fixed order `.claude` → `.agents` → `.gemini` (first found wins); (3) LEGACY: `<project>/.coalwash.json` at the project root (the pre-2026-08-08 shape) — still read normally, no breakage for an existing config. `findProjectRoot`'s `ROOT_MARKERS` gained the three new-shape paths alongside the legacy dotfile, so a project configured ONLY through the new shape (no `.git`, no `CLAUDE.md`) still anchors correctly rather than falling through to the raw cwd — the same per-subdir-scatter class this file's history already names for the legacy marker. The safer-value-wins clamp semantics (`mergeSafety`) are untouched — only the file's ADDRESS moved. **Backward-compatible: MINOR, not breaking** — an existing `.coalwash.json` at the project root keeps working unchanged, forever, via the legacy fallback; nothing currently reading a config stops working. This is the READ side only: CoalWash has no project-config WRITER anywhere in this codebase (no `configure.mjs`, no consent-persistence call — both `.coalwash.json` files are hand-edited), so there is no move-on-write to build here, and the machine-global scratch/state relocation (`~/.claude/coal/coalwash/`, the sibling #39 half) was already shipped in an earlier release and is unaffected by this change.

### Fixed

* **`caliper.mjs`: `FAT_MULTIPLE_DEFAULT` removed — a zero-consumer export (ponytail-audit board #72 finding #1).** Grouped in one "LEGACY (task #4)" comment with `CEILING_BMI`/`CEILING_REARM_BMI` under the claim "state migration comments + history cite them" — true for its two siblings (both still cited by name elsewhere and exercised by `caliper.test.mjs`) but never actually true for this one: nothing anywhere, code or prose, referenced it. Not part of the `.coalwash.json` schema (a separate, still-retained `fatMultiple` config key exists and is unaffected) — a pure internal JS constant with no consumer to break. `CEILING_BMI`/`CEILING_REARM_BMI` are unchanged.
* **`estate.mjs`: the P1 estate report's `~est reclaimable` figure was structurally incapable of ever reporting more than zero on a real machine (board #55).** `reclaimableEstimate` defaulted to `RECLAIM_HORIZON_MS`, a hardcoded 30 days — set deliberately EQUAL to Claude Code's own first-party `cleanupPeriodDays` default (verified live against `code.claude.com/docs/en/settings`, 2026-08-05: key `cleanupPeriodDays`, default 30, minimum 1, applied at CC startup). An equal threshold is a race CoalWash cannot win: nothing survives to the 30-day mark on this project's own clock without CC's own sweep having already claimed it first at the exact same mark, so `reclaim.bytes`/`reclaim.files` sat at effectively zero on every real install regardless of how much reclaimable estate actually existed.

  Fixed by BINDING the horizon to the platform's REAL `cleanupPeriodDays`, tracked automatically in both directions — a `resolveEstateHorizon` ladder (new export, `estate.mjs`) evaluated FRESH on every call, never cached (a cached horizon carries stale semantics the instant the platform value changes, silently, in the data-loss direction). One formula, no floor: `horizonDays = floor(cleanupPeriodDays / 2)`. `cleanupPeriodDays` itself comes from a new 4-tier settings cascade (`readCleanupPeriodDays`, `config-load.mjs` — managed > local > project > user > the documented default; a present-but-unreadable/invalid tier is skipped to the next rather than guessed as "key absent") tightened to accept only a SANE integer (`1`-`3650`; an out-of-range or non-integer value is treated as absent, never trusted). A one-way empirical cross-check (`observedRetentionFloorDays`, gated by `observedMachineAgeDays` — the age of the oldest slug directory, a signal the platform's own file sweep cannot erase) measures the oldest surviving transcript machine-wide and can only ever LOWER the bound, never raise it, and only once the machine itself is old enough to have genuinely tested the assumed boundary (a fresh-install false trigger, and the dead-band gap an earlier version of this gate left open, are both closed by asking about the MACHINE's age rather than a ratio of the observation itself). If the key resolved on a prior run and stops resolving — a rename/removal signal — the ladder falls back to a small conservative constant rather than trusting a documented default that may itself be stale — **and, if the same one-way empirical cross-check above ALSO measured a genuine floor at that moment, the fallback takes the SMALLER of the constant and the evidence-derived value, never the constant alone (RE-INSPECT 2026-08-06, Finding A) — the constant's own claimed safety depended on the cross-check's old lower bound, and the cross-check no longer has one.** Every unbound key matching a retention-shaped name is reported by name, never guessed into the binding. `estateReport()` states the full derivation — the value, its source, the resulting horizon, any override — in its output every run, never a silent number.

  `RECLAIM_HORIZON_MS` remains exported as an explicit-override fixture constant for a caller that wants the raw platform-mirror value; it is no longer `estateReport`'s own default.

### Security

* **`estate-archive.mjs`: ULTRA's WARM archiving (copy-verify-then-delete) had no guard against archiving a LIVE resident's own transcript (board #55).** A room following the department-head-contract convention (`TheColliery/.claude/DEPARTMENT-HEAD-CONTRACT.md`) records live sid-resident sessions in a project-root `.claude/agent-roster.md`; under the 2026-08-05 NO-HANDOFF LAW a sid-resident's warm transcript IS its accumulated experience, so a WARM sweep destroying one out from under a live seat is real, unrecoverable-in-kind damage regardless of the transcript's age. `classifySessions` now hard-skips (bands `active`) any session whose sid appears anywhere in `<projectRoot>/.claude/agent-roster.md` — deliberately PROJECT-RELATIVE, never a hardcoded path to today's own dev environment, so a project that never adopted the convention has no such file and the guard is a silent no-op with every existing WARM/COLD/ACTIVE behavior unchanged. A roster file that EXISTS but cannot be read or is empty fails toward protecting EVERY session this run (`roster.unreachable`), matching this module's own stated law — "uncertainty fails toward ACTIVE, never compress on doubt" — rather than the looser absent-or-corrupt-is-the-same treatment `chJournalGuard` uses for a lower-stakes signal.

### Changed

* **`fidelity-gate.mjs`: three of four `GATE COST`/`KEY-LINE COST` perf-regression tests converted from a wall-clock bound to a COUNT bound** (press 2 — this class of test had produced four cross-platform CI blocks in one day). `roundedSurvivor`'s candidate re-parse, the evidence-anchor `next.includes` short-circuit, and `FILE_REF_RE`'s run-length cap each guard a real, machine-independent invariant (a call count or a max slice length) that the old ms-based assertions could only approximate; each is now instrumented via a new test-only `__testHooks` export (`parseNumTokenCalls`, `evidenceIncludesCalls`, `fileRefMaxSliceLen`) and mutation-proven to redden on the exact regression it replaces. The fourth (`KEY-LINE COST`) has no genuine count behind it — the retired quadratic lived entirely inside a single regex `.exec()` call's own backtracking, not in a loop our code controls — so its wall-clock bound stays, with the reason declared inline. Zero behavior change; the `ms` assertions remain as secondary sanity checks on all four.

## \[1.0.0] - 2026-08-03

> **BREAKING.** `fullPercent` and `fatMultiple` — documented, user-settable config keys (README's Configure table: *"raise it to consciously carry more muscle before the wall forces a run"*) — are now read-tolerated and ignored. Task #4 replaced the floor-driven wall they scaled with a measured-fat model that takes no config input; a project that set either key to change the capacity wall's behavior will see that setting silently stop doing anything. No error, no warning — the same read-tolerated-and-ignored shape as the earlier `forceMode` retirement, but that one shipped mid-beta, before this project's stable line existed. This one breaks a promise the shipped README made in a stable release, which is the SemVer MAJOR case by this room's own rule (`scripts-quality.md` §3: *"MAJOR = a BREAKING change \[to a] ... config key"*) — sized here rather than left for a future release to under-bump, per the standing correct-forward instruction after the CoalBoard v1.0.13 / CoalTipple v1.0.23 precedent.
>
> WAVE-16 finding 1 (inspect findings-back): the grad6 3-commit unit (`7d57d4c`, `247a768`, `9e9ddde`) plus its own findings-back round changed six shipped libs with no `[Unreleased]` entry — opened per the room's own post-stable flow (`d68c12b`).

### Security

* **`config-load.mjs`: `pathWithin` — the containment primitive behind the config-territory trust boundary — keyed its case-folding on `process.platform === 'win32'` instead of measuring the volume (#36 class-B half; re-landed from `af17017` after the `174f330` revert, and lands as a PAIR with the class-A half `ebc8f60`/`3168259`).** Case-insensitivity is a property of the VOLUME, not the platform: macOS APFS is POSIX *and* case-insensitive by default, and an NTFS directory can be flipped case-SENSITIVE per-directory since Windows 10 1803 with no admin rights. The rule was therefore wrong in BOTH directions. Replaced with a read-only capability probe (`volumeCaseFolds`): flip the case of the basename, stat the flipped spelling, compare device+inode; cached per resolved root path, never per `st.dev`, because two directories on one device can legitimately disagree.
* **`apply.mjs`: the KEEPS-GATE decided whether a user's pinned keep BINDS the action about to delete or rewrite its file using that same platform-name rule (#36 demand 10).** On a case-INSENSITIVE non-Windows volume — macOS APFS, the default there — a keep recorded as `Memory.md` did not bind an action on `memory.md`, the same file, so the pinned file was rewritten or deleted anyway and the keep protected nothing. The compare is now pairwise (`samePathForKeep`) and folds when EITHER side's directory folds: Windows sets case sensitivity per-directory, so a unary fold applied to each side independently would itself turn a genuine match into a miss. Direction is deliberate — a match makes the keep bind and EXCLUDE the action, so over-folding refuses more (REFUSE-polarity), which is what the probe's fallback is defaulted for.

  **THE TWIN IS NOT A COPY.** class-A carries BOTH containment polarities on one primitive, so its half takes the miss direction as a REQUIRED per-call-site argument rather than inheriting this one's default; at a PERMIT-polarity gate the fallback used here would invert a fail-closed default into a fail-open one. `twin-pin.test.mjs` gained a row over a genuinely case-SENSITIVE directory (built by capability probe, verified by distinct inodes, never by platform name) — the row the ordinary-tmpdir table structurally could not express, which is why CI and not the gate caught the last drift.

  **A BOUND, NOT A COMPLETENESS CLAIM.** The probe's `flipCase` is guarded per-character by a round-trip check because JS's Unicode case mapping and a volume's on-disk case table are different functions that disagree by at least two known mechanisms (an expansion; a singleton/compatibility remap). No claim is made that those two are all of them — an absolute about an OS case table this file does not own can only ever be falsified by the next codepoint, never proven right.

  **RETRACTION, carried here because its host cannot be corrected.** The revert commit `174f330` is pushed and public and states a mechanism that measurement does not support: that the probe MISSes on a case-sensitive volume and falls back to `folds:true`. It does not. There are two stats in the probe and they fail in OPPOSITE directions — the OUTER stat failing is a genuine miss and folds; the INNER stat failing is the probe SUCCEEDING, and sets `folds = false`, the correct answer, which is the ordinary result on any case-sensitive volume. The revert itself was RIGHT — one twin had moved and the pair must land together — but its stated reason was not. The root of that error was a code comment in this file that enumerated both stats as one clause; it has been rewritten to name them separately.

  **FINDINGS-BACK (station-3 WAVE-1 on `1d191b4`) — the reach guard's own coverage gap, closed the same way it closes gaps in the code it guards.** `DEMAND-2/polarity-reach` counted READERS per FILE, not per CALL SITE, so a second `volumeCaseFolds` call site planted inside `apply.mjs` — already an allowlisted file — passed silently; it now counts and pins call sites per allowlisted file, so a new one must be explicitly re-affirmed. The same test's staleness check read the RAW file while the consumer scan stripped comments first, so a real call site removed while its own polarity COMMENT survived (the comment literally names the function) stayed "not stale" forever; both scans now share one comment-stripping definition. The test's own name and assertion message claimed the probe has "exactly ONE reader" — false the moment `apply.mjs` was added as a second, intentional one — reworded to scope the claim to config-load.mjs's internal caller, which is what was actually true. And the cache's staleness polarity, documented on class-A's copy of this probe (`3168259`) but silent on this one, is now recorded here too: a cached `folds:false` that goes stale under-folds, the fail-OPEN direction at this file's REFUSE-polarity callers; a stale cached `folds:true` only ever over-refuses. Unreached in practice (every engine here is a short-lived CLI process) — recorded as a bound, matching the sibling file's own treatment, not fixed with cache invalidation.

  **ASSEMBLY FINDING (main, merged pair on `main`, 2026-08-01) — `DEMAND-2/polarity-reach` false-alarmed on `explode.mjs`, and the scanner's OWN scope was the bug.** The reach guard matched on bare NAME presence, and class-A's `explode.mjs` independently DEFINES its own local `volumeCaseFolds` under the same conventional name — it cannot import `config-load.mjs` at all (that engine loads in isolation and is excluded from the shipped dist) — so the two functions share nothing but a name. Narrowed the scan to require a GENUINE `import { sym } from './config-load.mjs'` clause before counting a file as a consumer at all, applied to both the new-consumer scan and the staleness check. This makes a cross-lane false alarm through this mechanism structurally impossible going forward — class-A is architecturally barred from importing this file, so no file in that engine can ever trip a real entry through it. Verified against the real merged pair (class-A's actual converted `explode.mjs`, not a stand-in): reproduced the exact failure the un-narrowed scanner produced, confirmed the narrowed scanner passes for the right reason, and confirmed `twin-pin`/`classa-no-auto` are unaffected.

  **COALBOARD RULING (2026-08-01, `CW-POLARITY-REACH-BOARD-2026-08-01.md`) — `DEMAND-2/polarity-reach` was the wrong instrument, replaced rather than fixed a fifth time.** Four blind lenses each found a shape the deleted text-scanning guard could not see: a namespace import, a second import line, a dynamic import, a re-export hop, a CJS `require`, and — the sharpest — a count-preserving polarity flip (`apply.mjs`'s KEEPS-GATE rewritten from REFUSE to an unconditional PERMIT while `callSites` stayed 2 and the allowlist still matched, `consumers = []`, guard silent). `volumeCaseFolds`'s `foldOnMiss` parameter is now REQUIRED with no default — exactly class-A's own shape (`explode.mjs`'s `volumeCaseFolds`/`containment`) — so a caller that omits it THROWS a `TypeError`, checked by the language at every call site regardless of how the caller obtained the function reference. `DEMAND-2/polarity-reach` and its three helpers are deleted.

  **A SECOND DEFECT surfaced building this: the required-parameter design would have let a MISS answer leak across callers through the shared cache.** `CASE_FOLD_CACHE` keys on `dirPhys` alone; a miss branch's answer now IS the caller's own declared `foldOnMiss`, which can legitimately differ per caller for the identical path — caching it would let whichever caller probed a path FIRST silently overwrite every later caller's own declared polarity for that same path. Both miss branches now return directly, bypassing the cache, matching class-A's own `volumeCaseFolds`, which already avoided this for the identical reason. Real answers (the volume's actual case-sensitivity, independent of who is asking) are still cached exactly as before.

  **Two false claims corrected in the same pass, both named by the board:** `pathWithin`'s header no longer says "BOTH HALVES ARE NOW CONVERTED" (false the moment it is read from `class-b`'s own checkout, which never received class-A's commits — a caretaker must re-derive the other lane's state, never trust a comment here); and the claim that a test "pins the consumer SET so a PERMIT-polarity reader cannot arrive unnoticed" is removed along with the test it described.

  **`pathWithin` itself is NOT threaded a `foldOnMiss` parameter** (unlike class-A's `isUnder`, which threads `containment`'s) — its only 2 callers (`touchesClaudeBase`, `findProjectRoot`'s `isBase`) are both REFUSE-polarity, confirmed at source, and adding API surface for a polarity nobody uses would be the over-hardening this room's own skill-authoring discipline forbids. `pathWithin`'s own internal call to `volumeCaseFolds` now passes `true` explicitly.

  **RE-INSPECT findings-back on `9e01a54` (station-3): 2 doc-rot phrases, 1 false universal, 1 real coverage hole, all closed.** Two comments in `config-load.mjs`/`apply.mjs` still spoke of `volumeCaseFolds` owning one baked-in "default" or "fallback" it no longer has (`foldOnMiss` is a required argument, not a default) — reworded to describe the direction each caller declares. `findProjectRoot`'s comment claimed folding more "narrows a project-root anchor, never widens one" — false: `isBase(dir)=true` skips `dir`'s own marker check and keeps climbing, which can select a WIDER root when a marker exists further up; reworded to attribute the real safety to `applyPlan`'s `touchesClaudeBase` trust-boundary check, which is where it actually lives, rather than to `isBase`. And the ordinary KEEPS-GATE fixtures all compare identical path strings, so the negative test added for demand 10 was the only one reaching `samePathForKeep`'s fold clause at all — nothing pinned the POSITIVE behaviour (a keep recorded with a different-case spelling than the file's real on-disk name must still bind). A new test closes it, verified empirically before writing: a case-variant of an EXISTING file does NOT reach the fold clause on a folding volume (`canonicalOrNull` itself resolves both spellings identically first); the fold clause is reached only when the keep's recorded spelling does not resolve at all, which happens on a genuinely case-sensitive directory — there the miss branch answers the caller's own declared direction, and that is what makes the keep still bind.

### Changed

* **The band axis is measured fat, and the floor family is retired as an INPUT (task #4).** Hysteresis is the same Schmitt trigger re-axed onto measured fat tokens (arm 500 / disarm 200) instead of BMI-vs-floor. `leanFloorTokens` on file is inert history — never read, never clobbered (a poisoned floor can no longer poison anything). The `fullPercent` and `fatMultiple` config keys are RETIRED: read-tolerated and ignored, the `forceMode` precedent, because both scaled a wall off a stamp the gauge no longer consults. The capacity wall is the TRUE `capacityTokens` plus the CC index caps only, and a cap hit routes by measured fat: armed → `absolute-cap` (force), un-armed → `externalize` (advisory — all-muscle is MEASURED now, not inferred from a day-one stamp, so the externalize advice is honest immediately). BMI survives as display only (footprint / measured muscle). The cached verdict carries the new pair (`muscleTokens`, `demotableTokens`, `reorgPerDay`, `reorgBreakEvenDays`) and drops `floorUnmeasured`.
* **FULL/economic requires BOTH break-evens before the wizard ask can arm (task #4 condition 2).** The "Fat + reorganize muscle" tier has two halves and only one was ever costed. Now (a) the measured certain fat must pay for a run, AND (b) the reorganize half must pay on its own numbers — the demotable always-loaded index mass over the RE-TIER envelope (`envelopeFor`, resolved via `config-schema.mjs` so the conductor never imports `retier.mjs` — the "RE-TIER is wizard-only" grep-rail stays true), with its own break-even. The wizard ask renders both proofs; a store with certain fat but nothing to reorganize stays OBESE (auto-Quick, silent) instead of asking for a tier half of which has no work to do.
* **A case-variant `.MD` filename in a class-B store now classifies as a topic (an archive candidate), where it used to be silently immune.** `retier.mjs`'s `collectStores` matched the `.md` extension byte-exact, so a file saved as `NOTES.MD` fell into `others[]` (text-less, never demoted) instead of `topics[]`; `classifyRetier` already answered `class-b-topic` for such a file on both engines, so the old immunity was accidental, not a deliberate exemption — but it is an observable behaviour change in the destructive direction and is declared here on its own terms.
* **The one-line receipt for a store that GREW (a negative token delta) now says so explicitly (`grew ~Xk tok (+Y%)`), instead of printing the identical `cut ~0 tok (−0%)` a genuine no-op clean prints.** A caller reading the old line could not tell a healthy quiet run from one that quietly lost ground.

### Fixed

* **Three shipped surfaces claimed the mechanical Quick tier auto-cuts exact-duplicate lines, dead links, whitespace, and rebuilds the memory index — it never has.** `ask.mjs`'s `wizardEscalation` template asserted a Quick pass "survived... something blocked or exceeded mechanical cutting"; no such attempt has ever been possible — `broom.mjs`'s two real text-mutators (an exact-residue sweep, an empty-table strip) were retired to flag-only 2026-07-24, safety-over-yield, and no replacement cutter was ever built. `references/method.md` §1's op-list table presented exact-dedup/whitespace/index-rebuild as an automated "deterministic op list" ("Compute the new text per file, then gate + apply") when every row has always been agent-hand-run (`broom.mjs`'s own header: "promoted FROM agent-executed procedure... which has ALWAYS been hand-run, never coded"). Measured before writing anything: `mechFatFromText` over this project's own class-B always-loaded set (root `AGENTS.md`/`CLAUDE.md`/`MEMORY.md` + `.claude/rules/ecc/common/**` + `domain/**`) reads 13 tok total (one duplicate line, zero excess blank runs) — far under `FAT_ARM_TOKENS` (500); a mechanical cutter for this class would remove single-digit tokens on real governance text, which is why building one was ruled moot rather than pursued. `ask.mjs`'s ask now states plainly that a Quick pass ran but has no cutter for this fat class, and points at the wizard/a human instead of implying a script attempted and failed. `method.md` §1 gained a "Run by" column (every row: agent — none are code-run) and an intro paragraph naming the one real code presence in this tier (`mechFatFromText`, measurement only, never an edit). `README.md`'s Quick-tier row carried the same claim ("Exact-dedup, dead-link fix, whitespace, index rebuild — free, deterministic") and is corrected too, but `README.md` does not reach the shipped `plugin/` dist (confirmed: no `README` reference anywhere in `build-plugin.mjs`/`verify.mjs`, no `plugin/README.md` on disk) — per this file's own PATCH-vs-no-bump rule, that edit is NOT part of this entry, recorded in the commit and room `MEMORY.md` only. `SKILL.md` carries a softer instance of the same framing ("Deterministic edits only (method §1)") — reported, not edited (§3b variance-walk-gated).
* **The FULL band was a FALSE POSITIVE on all-muscle stores — the DEFINITION is replaced, not the symptom patched (task #4).** The live incident: the umbrella project's own store, floor frozen at 28,833 tok by the 2026-07-25 wizard clean, grew by legitimate muscle to \~58.8k tok, crossed the retired `fatMultiple × leanFloor` wall (57,666), and fired the force/ask loop on "fat" that was muscle no wash could ever cut (two zero-cut force receipts were the tell). Root cause: fat was DEFINED as footprint minus a stamped floor, so every token of real growth since the last clean counted as fat — and any stamp-based repair (re-stamp at clean, re-stamp on refusal) just moves which growth step fires falsely. Fat is now MEASURED from content at every gauge (`mechFatFromText`: exact-duplicate substance lines beyond their first occurrence, plus blank runs — a deliberate LOWER bound: what cannot be proven fat counts as muscle, failing toward silence), muscle = footprint − measured fat, and the FULL line RIDES the muscle 1:1 (threshold = muscle + arm margin), per the ruling "กล้ามโต 10 หน่วย นิยาม FULL โต 10 หน่วย" — a store that is all muscle cannot cross it at any size, which is the acceptance test, pinned both as a unit sweep and as a round trip through the real hook replaying the incident's own four readings against the frozen floor (red on the pre-fix engine at the first reading; silent end-to-end on this one).
* **Ship-text still described the retired BMI-vs-floor model after task #4 replaced it — `SKILL.md`'s band table and post-clean step, `references/method.md`'s band-math writeup and gauge-line shape, and `references/platform-cc.md`'s per-project state description all named a Memory-BMI/`CEILING_BMI` hysteresis and a `fatMultiple × leanFloor` wall that no gauge computes any more.** A reader trusting the shipped skill text over the code would reason about the wrong trigger for OBESE, the wrong requirement for FULL (one break-even instead of the two task #4 now requires), and a wall that was scaled off a stamp instead of the real capacity ceiling. Reworded throughout to the measured-fat/muscle-1:1 model: `FAT_ARM_TOKENS`/`FAT_REARM_TOKENS` hysteresis, both break-evens gating FULL/economic, BMI as an informational footprint/muscle ratio only, and the capacity wall as `capacityTokens` plus the CC index caps with no floor input. `setLeanFloor`'s documented reasoning is corrected from "uncleaned fat contaminates the floor" (no longer true — no gauge reads the stamp for the band) to "kept for the receipt's history line only." Two comments in `caliper.mjs`'s `recordVerdict` also still listed the removed `floorUnmeasured` field among the cached payload; corrected to match the object literal, which has never carried that field since task #4 landed.
* **`config-load.test.mjs`: `RE-INSPECT/R3-1` asserted its own sanity precondition with no capability probe and no `t.skip`, unlike its sibling `RE-INSPECT/R2` twenty lines above.** The precondition (that the flipped spelling is the OS's own variant of the same directory) is false on any genuinely case-sensitive volume, so the test failed on both Ubuntu CI legs for a reason unrelated to the Unicode codepoint it exists to test. Now capability-probed the same way its sibling is — never on `process.platform`, which is the defect the surrounding unit retires.
* **`tailings.test.mjs`: the cross-process bin test pinned `onDisk.length === 40`, the LOSSLESS item count at 4×10 concurrency — a property of the machine, not of the code** (40 on a developer box, 37 measured on a 2-core runner). The room's own grad6 F2 had already ruled that number machine-dependent and pinned the INVARIANT instead in the test it wrote the same day; that ruling is now carried back here. The invariant — the catalogue and the blobs on disk agree, whatever survives contention — always held, including at 37, and is kept along with bounds that stop it passing vacuously.
* **`fidelity-gate.mjs`: an unverifiable frontmatter census (a fence CoalWash's own reader cannot fully parse) used to inventory as zero keys, so `checkFidelity` could report `pass:true, 0 drops` on a real multi-key deletion.** Now abstains (`frontmatter-inventory-incomplete`) via the existing per-drop approval channel — no new block-tier, and `pinVerdict`/`isPinned` are untouched (deliberately opposite polarity, unchanged).
* **`retier.mjs`: the reference census and the store collector were byte-exact case-sensitive**, so a wikilink differing only in case (`[[API]]` vs `api.md`) or a referrer file saved uppercase (`NOTES.MD`) could make a still-referenced topic read as unreferenced and get archived out from under a live link. Both folded to match `classifyRetier`'s own existing case convention.
* **`keeps.mjs`: re-affirming an already-enforced keep (bumping just `reason`/`date`, the ordinary re-review shape) without re-supplying `anchor`/`anchorFile` silently downgraded it from mechanically enforced to advisory, with no flag.** `recordKeepAt` now preserves the prior entry's `anchor`/`anchorFile` whenever a call doesn't supply its own; a real new value still overrides.
* **`tailings.mjs`: concurrent `recordBinItem` calls (a real burst, not merely simulated) raced on the shared `index.json` and a fixed temp filename, undercounting the bin catalogue against the actual blobs on disk** — reproduced with real OS processes (4×10 workers: 16 catalogued vs 31 actual blobs against an expected 40 of each). Fixed with a per-bin lock plus a short bounded retry (a single attempt alone dropped 34 of 40 items under this call frequency).
  * **Follow-up (findings-back): the lock's own orphan-reclaim window was too long.** It inherited `apply.mjs`'s 30-minute tx-lock default, sized for a big rare transaction, though this lock's own critical section is sub-millisecond — a crashed holder stayed unreclaimable for up to 30 minutes, during which every call burned its retry budget and returned `null`, which the caller discarded outright. Now uses its own honestly-short 5-second window (measured happy-path hold: 12–25 ms, a \~200× margin on local NTFS — network/cloud-synced mounts are a named, unquantified exception per this file's own `#57 FILESYSTEM-SEMANTICS` admission), and a bin-stash failure is a visible `flagged[]` entry on BOTH the main `applyPlan` path and RE-TIER's wizard choice-4 path (`runRetier`/`runRetierReport` — the latter had silently dropped `applyPlan`'s own `flagged[]` on its success return, making the visibility fix invisible on the higher-stakes muscle-reorg path).
  * **The fallback wait (no `SharedArrayBuffer`/`Atomics.wait` on the runtime) used wall-clock `Date.now()` although documented as a "monotonic clock" — corrected to `process.hrtime.bigint()`, the primitive `apply.mjs` itself already uses and documents as monotonic.** Dead path on any supported Node version today; the old wall-clock version could hang indefinitely under a backward clock step (NTP correction, a manual clock change).
* **`fidelity-gate.mjs`: `FILE_REF_RE` was unbounded over the whole rewritten text inside `checkFidelity`'s evidence-anchor pass** (since `82c239a`'s own fix for a different quadratic), costing seconds on a crafted unbroken run with no valid file extension. Bounded via a non-backtracking run-split plus a per-run length cap; ordinary content is unaffected.
* **`apply.mjs`: a merge (`delete(src)` + `rewrite(dst, dst+src)`, two independent plan actions) could apply only its rewrite half when the delete half was refused for INCAPACITY (a per-file refusal, not a whole-plan abort) — leaving two copies of the same content on disk while reporting `ok:true`.** The paired rewrite is now detected (content-containment against the excluded delete's original bytes) and excluded in the same pass, scoped to just the matched pair.
* **`apply.mjs` / this file: a narrowly-true claim about the pin gate read as broader than measured.** *"A pin was actually READ (via the floor or a parsed entry) → whole-plan abort"* is true for those two code paths, but a 63-cell spelling×junk-shape battery shows 42 of those cells actually land on the gentler per-file INCAPACITY path instead (it runs first and intercepts most real malformed content) — reworded at both sites to state the measured shape rather than imply the marker path is the common case.

## \[0.2.1] - 2026-07-30

### Security

* **`mergeSafety` did not clamp OBJECT-typed config keys — a clone-borne project `.coalwash.json` could flip `estate.deleteCold` false→true (task #22, W2-1 HIGH, blind-wave W2 batch).** The safer-value-wins clamp only ever looked at TOP-LEVEL enum/bool keys (`coalwashMode`, `updateMode`, `writeGuard`, `localOnly`); an object-typed key like `estate` was replaced WHOLESALE by `{...global, ...project}`, so a project setting `estate.deleteCold: true` sailed through untouched — no clamp existed to catch it one level deeper. Fixed: object-typed schema keys (`estate`, `retier`) now merge PER-SUB-KEY (never a wholesale replace), and `estate.deleteCold` gets the same safer-value-wins clamp as a top-level key (a boolean gating an outward action is an enum of two) — a project may quieten it (true→false) but never escalate past the effective global (false, whether explicit or the schema default).
* **`estate.deleteCold` is NOT the sole gate on file removal in the estate tier — corrected (re-inspect 2026-07-30) after the original wording overstated it.** It gates exactly one transition: whether a COLD session (older than `purgeAfterDays`) is archived-then-DELETED automatically, versus staying report-only for a human to run the first-party purge command. The WARM band (older than `compressAfterDays`) already archives-then-removes the ORIGINAL file **unconditionally** — no consent gate at all, by design, even under the factory default, because it is copy-VERIFY-then-delete (byte-compared before the original goes) and fully restorable. **`estate.deleteCold` changes the SHAPE of consent** (unlocks a class of automatic, not-easily-undone action that otherwise needs a human at the keyboard); **`purgeAfterDays`/`compressAfterDays` only move the EDGE of a mechanism that was already running, already safe, and already consent-free before this fix existed** — that is the line, and it is why only the boolean is clamped.
* **`estate.purgeAfterDays` is deliberately NOT clamped — not because "deleteCold already gates it" (that claim was false and is retracted), but for two different reasons.** (1) The action it paces was never consent-gated to begin with (above), so a project shifting its boundary isn't unlocking a new capability the way a `deleteCold` escalation would. (2) **A SENTINEL HAZARD:** `0` means "never becomes cold" (`estate-archive.mjs`'s own `resolveEstateCfg` comment) — the WIDEST possible WARM window, since nothing ever graduates into COLD's report-only rest state — yet `0` sits at the schema's numeric FLOOR, where an ordinary safer-index clamp would read it as the *safest* value. Safety here is not monotone in the raw number (the narrowest, safest WARM window sits NEAR `compressAfterDays`; it widens again toward either extreme), so the SAFER\_ENUM ordered-list mechanism cannot be reused as-is — clamping it would need new, non-trivial infrastructure this unit does not build. Left as a named, flagged decline.
* **Fixing W2-1 required fixing a correctness bug first (W2-2): the wholesale object-replace also clobbered every OTHER global sub-key to schema defaults the instant a project touched even ONE sub-key** (e.g. a project setting only `estate.indexEnabled` silently reset the user's own `purgeAfterDays`/`compressAfterDays`/`archiveDir` back to factory values). The per-sub-key merge above closes both at once — one level of recursion, matching what `clampedRead` already does on the read side.
* **A malformed GLOBAL config failed OPEN: the kill switch was silently lost (W2-3).** `readJsonc` returned an identical bare `{}` whether the global file was genuinely absent (the schema default IS the user's stance) or PRESENT but unreadable/corrupt (an UNKNOWN stance) — a user's explicit `coalwashMode: "off"` silently reverted to the schema default `"auto"` if the file later got mangled (a botched edit, a crash mid-write). Fixed: `readJsonc` now distinguishes the two (`pathExists` at the read site); an unreadable-but-present global forces the SAFEST value on every safety-clamped key (never the schema default, which can be weaker than what the user actually had), unconditionally — a project value gets no more say than usual.
* **A RELATIVE `CLAUDE_CONFIG_DIR` could collapse `global == project` onto the same physical file, making the whole safer-value-wins clamp a self-compare no-op (W2-5).** `claudeBaseDirs` resolved a relative override against `process.cwd()` at READ time (not set time); if cwd happened to equal the project root (an ordinary hook invocation), `globalConfigPath` and `projectConfigPath` pointed at the identical file. Fixed at the input (the shape-refusal-belongs-on-the-input rule already used elsewhere in this file): a non-absolute `CLAUDE_CONFIG_DIR` entry is rejected and falls through to the fixed `~/.claude` default, same as an absent override. **Widened (re-inspect 2026-07-30):** a bare `path.isAbsolute()` check was not enough on win32 — `/repo` reads `isAbsolute()===true` with no drive/UNC root, so `path.resolve()` still depends on whatever the CURRENT drive happens to be (the same instability class, narrower). Now requires an actual drive letter or UNC share (`path.parse(p).root.length > 1`), win32-only (POSIX's single-character root already IS the stable form).
* **README's "project wins" claim was false for the clamped keys (W2-4)** — corrected to name the safer-value-wins exception (`coalwashMode`, `updateMode`, `writeGuard`, `localOnly`, `estate.deleteCold`); the same exception is now noted in `references/platform-cc.md`'s config line, which made the same unqualified claim.
* **Not fixed, named decline: W2-6 (`fullPercent` near-inert post-0j).** A day-one store's PROVISIONAL floor (0j) stamps immediately, so `fullPercent`'s raw pre-floor heuristic is rarely the live path in practice — this is an inherent consequence of the 0j design (BMI live from day one), not a merge-safety bug; nothing to clamp or fix here.

### Changed

* **A GLOBAL config that exists but is unreadable now silences the WHOLE skill for that session, not just the safety keys — a behavior widening beyond the security fix itself (re-inspect 2026-07-30, measured HEAD vs the pre-fix commit).** Forcing every `SAFER_ENUM` key to its safest index on an unreadable global includes `coalwashMode` → `"off"`, and the conductor's own `if (mode === 'off') return` then skips the gauge, the state write, AND the self-update check entirely — where the pre-fix code still ran (schema-default `"auto"`, at least the self-update nudge fired). A single bad edit or a crash mid-save on `~/.claude/.coalwash.json` now reads as "user turned CoalWash off," silently, with zero signal that anything broke. Correct and intentional (the same "assume the safest, unconditionally" rule this whole batch applies), but declared here on its own terms per this room's standing discipline for behavior WIDENING (the wall-moves-down precedent, docket 0r station-3, MED-D) rather than folded silently into the `### Security` narrative above.

### Fixed

* **Removed `numberTokens`, a dead function in `fidelity-gate.mjs` (CodeQL `js/unused-local-variable`, alerts #29/#30, NOTE, pre-existing 2026-07-27) — an orphan left behind by the SET→MULTISET refactor (`1578eca`).** It wrapped `numberScanList` in a `Set` for the old set-only number inventory; the multiset refactor moved that projection inline (`inventory()`'s `new Set(lists.numbers)`) and never removed the now-unreachable wrapper. Zero call sites anywhere in source, dist, or tests (verified by grep across the repo before deleting — not a specimen: no comment or test names it as a deliberate oracle/reference implementation, unlike this file's genuinely-kept contrast fixtures). The stale comment above it, which had claimed the set layer projects "via `numberTokens`", is corrected to name the real path. Source-only change; `plugin/` dist rebuilt to match.

## \[0.2.0] - 2026-07-30

> The 0.2.0 STABLE release — folds the 0.2.0-rc.1..rc.9 line plus the growable-wall caliper batch (docket 0r, labtest-PASSED) below. First stable of the 0.2.0 series; the rc HOLD was lifted by the USER 2026-07-30 (labtest = the ship gate, passed).

### Added

* **`fatMultiple` config key (docket 0r) — the growable absolute-cap wall.** Once a lean floor is measured, the FULL band's absolute-cap threshold is now `fatMultiple x leanFloor` (clamped at the true capacity ceiling), default `2.0`, min `1.6` (must stay above `caliper.mjs`'s `CEILING_BMI` 1.5 or the wall fires before OBESE arms — enforced by the schema clamp, not a runtime check). `fullPercent` demotes to the legacy pre-floor heuristic only (used while no floor has been stamped yet). **This is a user-visible new capability and does NOT ship silently inside an rc patch (main's ruling, station-3 batch): it lands as the NEXT MINOR once the rc.10 HOLD lifts — an `### Added` entry never ships as a PATCH (scripts-quality.md precedent: CoalBoard v1.0.13, CoalTipple v1.0.23, both named under-bumps).** The final version number at HOLD-lift is the USER's call, not this batch's.

### Changed

* **A SMALL lean floor moves the wall DOWN too, not only up — FULL now fires EARLIER than the old static wall on a fresh or small-floor project (the non-headline half of docket 0r).** The growable wall is `fatMultiple x leanFloor`; at the default `fatMultiple` (2.0) any floor under 18,000 tok produces a wall BELOW the old fixed \~36,000-tok wall. Example, pinned in `caliper.test.mjs`: floor 3,000 tok, footprint 7,000 tok (bmi 2.33, armed) — the OLD static wall (36,000) never fired here, so this store stayed OBESE (auto-Quick-silent); the NEW wall (2.0 x 3,000 = 6,000) is already cleared, so the SAME store now routes `FULL/absolute-cap` (force-runs Quick, then the wizard ask if still over). Correct and intended: a small-floor store growing fast is exactly the case that should escalate sooner, not later — but it is a real behavior change on real stores, not merely the headline muscle-store fix, and is declared here on its own terms per this room's own discipline for behaviour WIDENING (see the rc.9 pin-gate precedent above).
* **Doc sweep for the growable wall (docket 0r): README.md and `skills/coalwash/references/method.md` conformed, `plugin/` rebuilt.** Both previously described the capacity wall as `fullPercent`-of-capacity, "always-fixed, no BMI needed" — false since the fix above: the wall is now `fatMultiple x leanFloor` (clamped at true capacity) once a floor is measured, `fullPercent` demoting to the legacy pre-floor-only heuristic. README's Configure table gains `fatMultiple`/`fullPercent` rows; its "How it works" FULL paragraph and method.md's band-math + externalize-lever paragraphs now describe the growable mechanic and match `ask.mjs`'s already-corrected "raise `fatMultiple`" wording (method.md previously still said "raise `fullPercent`"). `platform-configs/.coalwash.json`, `config-schema.mjs` and `COALWASH_BLUEPRINT.md` were already conformed by the docket's own commits — this closes the remaining doc surfaces. SECURITY.md/PRIVACY.md checked, do not state wall mechanics, untouched. This doc content ships in the same commit stream as `fatMultiple` itself, so it lands at the same next-MINOR HOLD-lift, not before.

### Fixed

* **The absolute-cap WALL was floor-BLIND, contradicting the blueprint's own locked design ("`full` TRACKS the lean MEAT floor — growable, clamped at the hard ceiling", §5/§16) — a legitimately-grown muscle store force-ran FULL forever with nothing to cut (live: a \~51.6k tok muscle store vs a fixed \~36k tok wall, two zero-cut force receipts on the umbrella project, 2026-07-30).** `bandVerdict`'s `capHit` compared raw footprint against `fullPercent x capacityTokens` — a static % of the context window, never the floor — so once a project's real, legitimate memory outgrew that small fixed slice, every session re-triggered the unconditional FULL force-run (0m) with the fidelity gate correctly finding nothing to wash (fat ≈ 0 relative to the true floor). Fixed at the root: once a lean floor is measurable (`>= FLOOR_MIN_TOKENS`), the wall is `min(fatMultiple x leanFloor, capacityTokens)` — it rides the floor like every other boundary in `bandVerdict` already does (LEAN↔OBESE, OBESE↔FULL/economic), clamped at the TRUE capacity ceiling (the raw context window), never the small `fullPercent`-scaled number. A store with no measured floor yet keeps the exact pre-fix `fullPercent x capacity` heuristic (the growth needs something real to track against). A day-one store's PROVISIONAL floor (0j, stamped = the store's own first-gauge footprint) also counts as measurable, so a fresh project that merely happens to be bigger than the old static wall no longer force-loops on pure muscle either — only a genuinely near-capacity store still routes FULL/absolute-cap via the floor leg. The index-byte/line caps are a SEPARATE, floor-independent `capHit` route that can still fire at any floor size (`capHit` is a three-term OR — see the CORRECTED station-3 note in room MEMORY.md) — but it routes `absolute-cap` only when armed (`bmi >= CEILING_BMI`) or the floor is provisional, and `externalize` otherwise, the exact same over/floorProvisional branching every other `capHit` route already uses; it is NOT a blanket "always absolute-cap at any floor size" case. `~all-muscle at the true capacity clamp` still correctly routes to the pre-existing `externalize` advisory, never force+wizard. No `stateSchema` bump: the only persisted field this touches (`lastVerdict.hardCeilingTokens`) is a pure per-gauge display NUMBER, never a decision GATE itself (unlike its sibling `lastVerdict.reason`, a live Stop-path branch input) — but it is NOT guaranteed fresh on every Stop call: the Stop handler's re-gauge only runs when nothing is already pending (`if (!crossing)`), so a Stop with an already-armed crossing renders a cached value verbatim. Worst cross-version case: one stale informational wall-number in a force/externalize message for a single turn, self-healing at the next unconditional SessionStart re-gauge — never a decision made on stale data (labtest G1, 2026-07-30). **The blueprint's own §5/§11 contradiction this fix exposed (the "hard ceiling" defined two incompatible ways) is amended in place in `COALWASH_BLUEPRINT.md` at the L125 region, dated 2026-07-30 — the clamp is the raw `capacityTokens`, never `fullPercent x capacity`.**

### Security

* **The round-7 residual is CLOSED: a bare CR can no longer hide a top-level pin (rc.9 station-3 MED — pre-existing on every engine back to the first pin).** "Every line accounted for" inherits the correctness of the LINE SPLIT it counts over: YAML 1.2 breaks on a lone CR (`b-break ::= CRLF | CR | LF`) while the reader split on `/\r?\n/`, so a MIXED-ending block joined `title: x<CR> pinned: true` — a top-level pin by YAML — into ONE accounted-for line, and the file was deleted `ok: true` through the shipped door (its G4-3 twin: a rewrite stripping the hidden `pinned` reported 0 drops). **Fixed AT the split's owner, never in a caller:** `frontmatterBlockParse` refuses a block containing a bare CR as unreadable — the same discipline, and the same reason string family, as `readFrontmatter`'s `cr` fence branch — so a wrong line basis now lands on the refusing branch, never on "accounted for". The delete door refuses per-file (the two-tier gate above), the rewrite is sniff-flagged with the reason. Controls pinned: CRLF and pure-LF files are untouched in both directions, a lone CR in the BODY (past the closing fence) never refuses, and the inventory (`frontmatterKeys`) keeps exactly its pre-fix joined-basis reading — the fix adds the refusal, it does not change the entries. Mutation-proven both ways: clause removed → the four door/primitive tests red; refusal forced always-on → the CRLF/LF controls and the ordinary-frontmatter yield test red. Line-splitter census across the engine: **14 functional sites, 15 split calls** (`removedLines` holds two calls in one function — the counting rule: a FUNCTION with its own line basis is one site; station 3 caught the first total, 13, as an unreconciled sum over correct buckets); this is the ONLY verdict-bearing basis (the gate's other two splits compare orig and next through the SAME splitter, the broom's two are flag-only surfaces, apply's `removedLines` feeds the bins under a byte-exact snapshot, discovery and caliper are measurement, retier's three sit above these gates, estate's three are class-A JSONL) — each named so the next reader does not re-derive the census.

### Changed — behaviour WIDENING, stated on its own terms

* **A non-create action refused for INCAPACITY (unverifiable head · unreadable block · read error) no longer aborts the plan — it is excluded per-file and the REST of the plan executes.** Same input plan, different on-disk outcome: before, one such file meant NOTHING was applied; now every other action lands and the refusal is named in `flagged[]` with the way out. The refused file's own treatment is unchanged (untouched, both before and after), and a MARKER pin — `pinned: true` actually read, via the floor or a parsed entry — still aborts the whole plan. The honest narrowing, named per station 3: a genuinely-pinned-but-UNREADABLE file (e.g. a UTF-16-encoded pin) no longer shields the REST of a plan by collateral whole-abort — the rest is plan-authorized and UNDO-backed, and the pinned file itself still survives. Mechanism, tiers and proofs under `### Fixed` (the two-tier pin gate).

### Fixed

* **`checkFidelity` is no longer quadratic on the drops path (#36 — and gate time is EXPOSURE, not just latency: every second sits inside `applyPlan`'s window between the staging read and the external-writer compare, and this fix shrinks that window \~132× at 1 MB — both legs measured on ONE fixture through both engines, the 28,187 → 213.8 ms pair below).** Two terms, both closed byte-identically, one residual named:
  1. **`roundedSurvivor` parsed every surviving numeric candidate once per dropped number** — O(drops × candidates) regex parses (the docketed mechanism; the reviewer measured 128 KB 0.28 s → 1.04 MB 15.4 s, \~4× per doubling). The candidates are now parsed ONCE per gate call and split by kind; iteration order within a kind is the inventory's insertion order and cross-kind candidates never matched, so the FIRST qualifying survivor — the receipt-visible tie-break — is unchanged, pinned by its own control test and by an order-reversal mutation that reddens it. Measured on one controlled fixture through both engines: **1 MB drop-heavy 28,187 ms → 213.8 ms (×132)**; 128/256/512 KB: 440→20.3 · 1,672→27.8 · 6,916→79.3 ms.
  2. **The evidence-anchor loop ran `next.includes(tok)` — a full-text scan — once per surviving evidence token** (found while closing #36; controlled-pair proven: swapping `verified` for a non-marker word collapsed 721 ms → 48 ms at 512 KB — and the marker-heavy shape is exactly our own MEMORY.md house style). `next`'s own evidence tokens are extracted once; an extraction match is a substring by construction, so set membership short-circuits with the identical branch outcome, and `markerAlive` — which depends only on the marker string — is memoized per distinct marker. Typical wash shape (1 MB, 94% of tokens surviving): **3,459 ms → 591 ms**. **THE NAMED RESIDUAL:** a genuinely ABSENT token still pays one full `includes` scan (exact-substring semantics — a token surviving inside a longer word is kept today and must stay kept), so a plan dropping thousands of evidence anchors at once still scales with drops × text (1 MB with half of all tokens dropped: 2,670 → 1,832 ms) — that plan is refused wholesale anyway, and the bound is stated rather than papered over.
  * **Byte-identity proven at scale, not asserted:** an old-vs-new differential of the FULL `checkFidelity` result over the real flock corpus — 1,567 `.md` files × 3 deterministic transforms + synthetics = **4,706 pairs: 0 diffs**. The perf regression guards (drop-heavy 512 KB, marker-heavy low-drop 1 MB, both red on the old engine by AssertionError) and the tie-break control ship in the suite.
* **The key-line parse is a linear SCAN — the retired `KEY_STRICT`/`KEY_LOOSE` regex pair's quadratic backtracking is gone (rc.9 station-3 LOW: one line of `a` + 60 KiB of spaces cost 5.37 s, and round 7 had doubled the exposure by parsing in `sniffUnrewritable` as well as `isPinned`).** The retired forms re-scanned the whitespace run once per lazy start position — measured on this box: 5,560 ms (no colon) / 2,866 ms (a colon failing the strict lookahead) per 60 KiB line, now 0.3 ms / 0.2 ms. **The language is UNCHANGED, proven two ways:** (1) the retired regexes stay in the suite as the equivalence ORACLE — 38 enumerated boundary shapes (embedded colons, whitespace runs before the separator, lone CR, NBSP in and around keys, Thai keys, URL lines, empty values) must answer identically on match/no-match, key, value, and strict tier, and each of three scan mutations (separator loosened · trim removed · loose tier removed) reddens it; (2) a differential of the OLD engine against the NEW over the real flock corpus — **10,968 `.md` files, 573 closed frontmatter blocks: 0 parse diffs, 0 `frontmatterKeys` diffs.** The separator is the FIRST `:` followed by whitespace or end (the lazy quantifier's leftmost preference, so `a:b c: d` still keys as `a:b c`), else the first `:` anywhere (the loose tier); the key trims trailing whitespace via `trimEnd`, which strips the identical set `\s` matched (ECMA WhiteSpace + LineTerminator).
* **The pin gate now has TWO TIERS, and only a MARKER pin aborts the plan (rc.9 station-3 MED: one ordinary `---`-rule document in a DELETE plan aborted the ENTIRE plan, with an error claiming `pinned: true` about a file that carries no pin).** `pinVerdict` reports WHY a file refuses: a pin actually READ (`pinned: true` via the floor or a parsed entry) is a **marker** — a plan naming such a file violated an explicit user marker, so the plan is malformed and is still refused WHOLE, unchanged. A refusal from **incapacity** (unverifiable head, unreadable block, read error — including the `---`-as-horizontal-RULE shape every measured rc.9 refusal was) is CoalWash's own limitation, not the plan's fault: that file is now refused **PER-FILE** on the flag channel — untouched, named in `flagged[]` with the real reason and the way out — and the rest of the plan proceeds, exactly like the rewrite path's sniff channel. The refused file itself is equally safe on both tiers; the tier only decides whether the REST of the plan is trusted. Covers the >64 KiB edge too (a block closing past the pin window is incapacity, not a marker). `isPinned`'s boolean is a projection of the verdict — every existing caller's decisions are byte-identical. Mutation-proven in both directions: removing the per-file channel reddens the tier-2 tests; demoting either marker site (floor / parsed entry) reddens the tier-1 whole-abort control. This supersedes rc.9's *"a DELETE of such a file is refused by the pin gate, which aborts the plan by design"* — the abort was never the design's point; the FILE surviving was. **CORRECTED (grad6 §1c, round-6 CoalBoard verdict): "via the floor or a parsed entry" reads as though those two are the common way anything pin-shaped resolves, with incapacity as the exception — measured the opposite. A 63-cell battery of spelling variants x junk-shapes landed 42 of 63 on incapacity, not the marker path, because the incapacity check runs before the parsed-entry check and intercepts anything the block-reader cannot fully account for (most real malformed content). The marker path is a narrow, exact-conforming trigger — the whole block must parse cleanly; messy or malformed pin-like content overwhelmingly lands on the gentler per-file incapacity refusal instead.**

## \[0.2.0-rc.9] - 2026-07-28

### Security

* **HIGH (round-7 lab, G4-2) - the SEVENTH breach of the pin promise, and it is the slot BEFORE the key: an indented `pinned: true` was read as UNPINNED and the file was DELETED.** Every earlier repair swept the three slots *inside* `pinned : true $`; nobody had looked at the one in front of it. Both key regexes anchor on `^([^\s:#-]...)`, so ONE leading space stops the line being an entry at all, and the round-6 floor `/^pinned\s*:\s*true\s*$/m` does not fire either because its `^` is a line anchor. **Both readers agreed, and both disagreed with the author** - an independent YAML parser reads `" pinned: true"` as a top-level `{pinned: true}`, because a block mapping may sit at any consistent indentation. Measured through the shipped `applyPlan` door: one space, two spaces or a tab before the marker, file gone, `ok: true`. **No shipped document of ours places a column constraint on the marker** (README, SECURITY.md and SKILL.md all say a file marked `pinned: true` is never touched); that promise is now true.
* **HIGH (round-7 lab, G4-3) - the same root, wider blast: the fidelity gate was blind to the WHOLE indented-key class.** `frontmatterKeys` filters on the same anchored shape, so a rewrite that stripped every key of a uniformly-indented frontmatter block reported **0 drops** and passed. That is the flagship *"blocks any STRUCTURED-token drop"* claim failing silently on any file whose frontmatter is indented.
* **THE FIX IS THE QUESTION, NOT THE REGEX: `pinned` is no longer something we look for, it is something a file must be PROVED not to carry.** Six rounds asked *"is there a pin?"* and answered it more cleverly each time; a seventh clever answer buys nothing. `frontmatterBlockParse` now returns the entries **and** whether the block is provably readable, and the destroying consumer requires the proof: every line accounted for, or the file is untouchable.
  * **THE CLAIM, WITH THE EDGE STATED RATHER THAN IMPLIED (station 3 found it).** *"No marker found is what a wrong parse always produces"* is proven against every way a line can be **MIS-CLASSIFIED** — a wrong verdict about a line we HAVE cannot manufacture "accounted for", only break it, which is the safe side. **It is BOUNDED BY THE LINE-SPLITTING it counts over, and that bound is a real residual, not a caveat.** The reader splits on `/\r?\n/`; YAML 1.2 also breaks on a LONE CR (`b-break ::= CRLF | CR | LF`), so a MIXED-ending block is joined into fewer lines than the author wrote, each joined line still matches the key regex, and a WRONG BASIS is faithfully accounted for. Measured through the shipped delete door: `title: x<CR> pinned: true` is a top-level pin by YAML and is deleted — **identically on rc.8, so it is PRE-EXISTING and rc.9 does not close it.** MED, not HIGH: a CR-only file is already refused at the fence, so it takes a mixed-ending file, which ordinary editors do not produce. DOCKETED; the fix belongs at the split, not in a caller.
  * **Renamed with the shape change: `frontmatterBlockEntries` -> `frontmatterBlockParse`**, because a function that also reports whether the block is readable no longer only returns entries. Earlier CHANGELOG entries naming the old identifier are HISTORY and stay as written.
  * **"Top level" is the block's OWN root column** - set by the first non-blank, non-comment line - **not column 0.** The discriminator that keeps this from degenerating into "refuse anything indented" is the nested control: a key indented BELOW a root key is still excluded and still washable, and so is a decoy `pinned: true` inside a block scalar. YAML requires nested content to be MORE indented than its parent, so nothing at a deeper column can be a root key - which is why this needs no YAML parser, only a column.
  * **The inventory takes the UNION of both readings** (root column and column 0). Round 5 shipped a regression by ASSUMING a merge was a widening; measured here in both directions against the shipped rc.8 engine over 468 shapes: **0 protections lost, 0 inventoried keys lost.**
  * **`frontmatterKeys` deliberately IGNORES the unreadable flag while `isPinned` refuses on it** - one primitive, two consumers, OPPOSITE safe directions. A permissive answer at the pin gate deletes a file; a permissive answer in an inventory merely reports fewer drops, so the inventory keeps every key it did read. Do not "make these consistent".
* **THE PRICE, MEASURED NOT ESTIMATED, and it is a yield loss that never touches safety: frontmatter CoalWash cannot read line-by-line is FLAGGED, not washed.** In order of what a real file actually hits:
  1. **A LINE AT THE ROOT COLUMN WITH NO COLON — the one an ordinary document reaches, and the one every measured refusal was.** The commonest shape by far: a file that opens with `---` used as a **horizontal RULE** rather than as frontmatter, with any colon-less prose line before the next `---`. Measured 4/4 on that shape (`true -> false`), with controls (`title: x`, a bare URL, a wrapped scalar) staying washable; on an adversarial basis this bucket is **5x the next** (156 against 32/21/9).
  2. tab indentation (illegal in YAML) · 3. a non-space character used as indentation (NBSP, IDEOGRAPHIC SPACE) · 4. an invisible character glued to a top-level key · 5. a key that opens with one of YAML's own indicator characters (`{pinned: true}` is a flow mapping whose real key is `pinned`) · 6. a key containing anything outside printable ASCII · 7. mixed indentation.
  * **Scanned across the whole flock: of 550 real frontmatter blocks, 4 are refused, and 0 carry a non-ASCII top-level key.** ~~All four are lab padding fixtures.~~ **CORRECTED — that sentence gave the PROVENANCE of the hits and hid their SHAPE, and shape is what a user meets.** All four are item 1 above, which is the only class an ordinary file reaches; "they were lab fixtures" reads as "nobody will hit this" and is not what the measurement showed. A REWRITE takes the per-file flag channel (the rest of the plan proceeds, so one odd file cannot make CoalWash unusable on a store) and the reason names the user's problem plus the way out: **put the block on one indentation as plain `key: value` lines, or remove the frontmatter, and CoalWash washes it normally.** A DELETE of such a file is refused by the pin gate, which aborts the plan by design.
* **THE ORACLE THAT REPLACED THE TWO-READER DISAGREEMENT.** G3-1's oracle was two readers contradicting each other; collapsing them to one removed it, and round 6 then proved a hand-written list cannot stand in for it. The replacement is a **constructive generator**: it WRITES 400 blocks and therefore KNOWS which keys it placed at the root, so the expectation never comes from a parser and cannot drift into agreement with the one under test. **Its bound, stated rather than implied:** it proves the reader agrees with the CONSTRUCTION, not that either conforms to YAML - an external YAML parser was used as an oracle in the lab and cannot ship, because zero-dependency is binding. YAML conformance is owned by that lab run and by refusing everything we do not recognise.

## \[0.2.0-rc.8] - 2026-07-28

### Security

* **MEDIUM (round-6 station 3, G3-1 follow-through) - the lower-bound ORACLE was only ever a LIST, and it missed a live REGRESSION: a shape that was PIN-protected before G3-1 was DELETED after it.** The retired predicate `/^pinned\s*:\s*true\s*$/m` was kept in the SUITE carrying the universal *"whatever it protected must still be protected"* - over a body that enumerated SEVEN strings. **`\s` spans LINE TERMINATORS and a one-parse-per-LINE reader cannot**, so `pinned` + a newline + `: true` silently lost its protection. Measured with the pre-fix engine as the control: **1458 of the 19683 shapes the retired regex admits were unprotected, against 0 before**, and at the shipped `applyPlan` door that shape went from *PIN-protected, refuse to touch* to a completed delete.
  * **The fix is the FLOOR IN THE CODE, not a narrower claim in the comment - a test cannot protect a file.** The retired predicate's verdict is now OR'd into `isPinned` over the same block, so its protected set is a floor BY CONSTRUCTION; and because "by construction" is how the last four claims about this one line were wrong, the suite sweeps the PARAMETER - every member of JS `\s`, in every slot - instead of seven hand-picked members. **A monotone widening cannot re-open the two-reader defect**: it only ever ADDS pins, and the answer that loses there is the one that authorises a delete.
  * **Fifth false universal on this line, identical shape every time: a test NAME that quantifies over a BODY that enumerates.** The rule it settles - generate the space the OLD thing admitted, demand the NEW thing still covers it, and run the identical sweep against the pre-fix engine as the control. A named bound costs nothing; the search costs under a second.
* **MEDIUM (round-6 station 3) - G3-2 RE-OPENED: completeness was inferred from the READ COUNT, and `read(2)` may legally return short.** `full = n < PIN_READ_BYTES` reads "fewer bytes than asked for" as "reached EOF", so on any mount where a short read happens - the network/cloud case `apply.mjs`'s own `#57 FILESYSTEM-SEMANTICS` note already names - the truncation flag went off, the `$` alternative fabricated a close exactly as in G3-2, and the pin past the cut went unseen again. **Reproduced by patching `node:fs` before the import: a 40,000-byte short read on a 40,025-byte file flips `isPinned` from `true` to `false`.** `fs.fstatSync(fd).size > n` asks the FILE instead of the read, is exact, and is immune to a short read; it is taken after the read so a concurrent append shows up as truncated, which is the safe direction.
  * **It closes the declared 65536-exact residual in the same line.** A file of EXACTLY the window was treated as truncated, which refused the one complete shape that closes only via `$` - a file whose last bytes are its closing fence. That residual was declared in an inline comment with no CHANGELOG line and no test; it now has both, and the over-refusal direction is mutation-proven RED.
* **HIGH (round-5 lab, G3-1) - the frontmatter block had TWO readers and they answered the same question differently; the one that loses ends in a DELETE.** Four previous repairs all attacked PARSING (what may precede the fence, what may trail it); nobody had looked at the PREDICATE reading the block the parser returns. `isPinned` ran a private `/^pinned\s*:\s*true\s*$/m` while `fidelity-gate`'s `frontmatterKeys` parsed the SAME block with a different regex - so **six spellings the gate itself inventoried as a `pinned` key were deleted through the shipped `applyPlan` door**: `pinned: True`, `pinned: TRUE`, `"pinned": true`, `pinned: true # do not delete`, `pinned: yes` (YAML 1.1), and `pinned: "true"`.
  * **The fix is to REMOVE the second reader, not to make the regex cleverer** - that would have been the sixth patch on one line. `frontmatterBlockEntries` is now the ONE parse of a block; `frontmatterKeys` and `isPinned` are projections of it. Same one-extraction-two-views shape as `tokenLists` -> `inventory`, and for the same reason: a second extraction site is the twin-drift this room has paid for three times.
  * **MERGING TWO READERS CAN BE LOOSER THAN EITHER ONE, and it nearly was here.** Measured before building: `pinned:true` (no space after the colon) is protected by the retired regex and is **not** a key to `frontmatterKeys`, because a YAML mapping needs whitespace after the colon. Handing the pin question straight to the gate's parser would have UNPROTECTED a file that is protected today while fixing the six spellings. So an entry carries a `strict` flag - the gate keeps exactly the keys it always had (byte-identical, characterization-tested), and the pin gate reads the loose entries too. ~~The retired regex is kept in the suite as a lower-bound ORACLE: whatever it protected must still be protected.~~ **RETRACTED - that was a universal over seven hand-written strings, and it was false: a loose ENTRY covers what one LINE can hold, and the retired regex also spanned line terminators. The floor now lives in the code, not in the suite. See the round-6 entry above.**
  * **`not pinned` is now EARNED, the same polarity discipline as rc.7's fence fix one layer up.** A `pinned` key clears only through an explicit `false` / `no` / `off` (case-folded, quote- and comment-tolerant). Every other value - `maybe`, `0`, `[]`, empty, or a spelling nobody has thought of - refuses. **That is a statement about a THREE-MEMBER list you can read, not about YAML.** THE PRICE, pinned by its own test: `pinned: n` (YAML 1.1 false) is refused too - a yield loss, never a safety loss.
* **HIGH (round-5 lab, G3-2) - the 64 KiB pin read window FABRICATED a closing fence, and the code contradicted its own written invariant.** `readFrontmatter`'s closing regex ended in `(?:\r?\n|$)` with no `/m`, so `$` meant end-of-STRING - which is end-of-FILE for whole text but **end-of-the-WINDOW** for the prefix `isPinned` hands it. A `\n---` landing on that cut closed the block early, every key past it (including the pin) went unseen, and the file was deleted. `apply.mjs`'s `PIN_READ_BYTES` comment has always read *"a block that does not close within this = unverifiable"*; the code did not do it. A truncated read now requires a REAL line terminator after the fence - end-of-string proves nothing about a string somebody else cut. The caller declares which it handed over, the same discipline the tri-state itself is built on.
* **HIGH (round-5 lab, G3-3) - the recovery bin was a STRING channel, so the undo net corrupted the only copy it held.** `applyPlan` banked `baseBuf.toString('utf8')` and `tailings` wrote it back with `writeFileSync(..., 'utf8')`, so any byte sequence that is not valid UTF-8 (a CP1252 `MEMORY.md`, a Notepad "ANSI" save) decoded to U+FFFD going in and was re-encoded as the replacement character coming out. Measured: one U+FFFD, byte-identical false, while an ASCII sibling deleted in the SAME `applyPlan` call round-tripped exactly - which is why nobody saw it. **A net that alters what it catches is worse than no net, because the restore LOOKS successful.** The bin is now Buffer end-to-end with no string hop: `recordBinItem` writes raw bytes and weighs the real byte length, `restoreFromBin` returns a Buffer, a delete banks `baseBuf` itself (byte-equal to disk BY CONSTRUCTION - the external-writer guard aborts the transaction otherwise), and the create-undo reads bytes.
  * **A string view was deliberately NOT shipped alongside it.** Two doors onto one artifact is the exact defect class being fixed one file over, and the next caller picks the wrong one. A caller that wants text decodes at its own call site: `anchor-diff` does (it scans for structured tokens), the CLI pipes bytes.
  * **The same one-line defect was found at the airbag's door by grepping the callers, and is fixed in this commit under the twin-drift law.** `readWriteguardSnapshot` read its snapshot through `'utf8'` - the snapshot on disk was always byte-exact (a `copyFileSync`), but the RECOVERY DOOR transcoded it, while the CLI told the human *"byte-exact original on stdout"*. That claim is now true. This is the only undo net for a gitignored `MEMORY.md`.
  * **The rule this settles for the next such site:** a RECOVERY path moves BYTES; an ANALYSIS path may decode to text and must say so at the call.
* **HIGH (round-5 lab, G3-4) - a REWRITE transcoded the LIVE file, and the fidelity gate is structurally blind to it.** G3-3's twin, and the higher blast of the two: G3-3 corrupted the backup, this corrupted the file. A rewrite reads its target as TEXT, so a byte that is not valid UTF-8 came back as U+FFFD and was written over the original - **measured through the shipped door: the sniff returned `null`, `applyPlan` reported `ok`, and the live file lost the byte permanently.** `sniffUnrewritable` exists to refuse what it cannot parse, but it routed through `readFrontmatter`, which scans only the first 64 characters; a legacy-encoded byte deeper in the file was invisible to it. **The gate cannot cover this by construction** - it compares structured tokens of before and after, and BOTH sides were decoded the same lossy way, so the inventories match and nothing drops.
  * **The instrument is a ROUND TRIP, not an encoding detector: `Buffer.from(buf.toString('utf8'), 'utf8').equals(buf)`.** A detector guesses, and a wrong guess is a NEW risk on a tool that overwrites memory. The round trip asks the only question that matters - can this file survive the trip we are about to put it through - and answers it exactly, deterministically, with no table and no heuristic. It sits after the NUL check (cheaper, and it already catches UTF-16/binary) and before any parse, because there is no point parsing a decode already known to be lossy.
  * **DECLARED TRADE, and it is a yield loss, never a safety loss: a non-UTF-8 file is FLAGGED, not washed.** The flag names the real cause - that YOUR FILE is not UTF-8, not that CoalWash is confused - and states the way out: **convert the file to UTF-8 and CoalWash washes it normally.** Deletes do not pass through this guard - nothing is rewritten and the bins bank the original bytes. ~~(they never decode)~~ **CORRECTED: they are not decode-free. `isPinned` decodes a 64 KiB head on every delete to read the pin; that decode fails CLOSED (an undecodable head, or a block that does not close inside the window, is `unverifiable` = pinned = refuse), so the delete path is safe by its own gate rather than by never decoding. An absolute inside a scope paragraph is what stops the next reader looking.**
  * **Claimed narrowly, with its bound: this refuses files that are not valid UTF-8. It is NOT an ASCII rule.** Valid UTF-8 round-trips byte-identically whatever the script, so Thai, CJK, emoji (astral) and BOM'd files pass by construction - **pinned by a control test that a mutation proves goes RED when the refusal over-fires**, because a bug here would silently stop CoalWash washing an entire language's corpus.
  * **Scope, swept rather than assumed:** `applyPlan` is the single write chokepoint for class-B - `retier` mutates only through it, and its one direct write gzips a Buffer - so this one guard covers every producer. Every other `utf8` decode in the engine is an ANALYSIS path (the gate's own comparison, discovery, measurement, JSON state), where both sides take the same transform. Cost: 0.150 ms median on a 128 KB file, at plan time, off the Phoenix #3 hook path.
  * **NOT attempted here, docketed instead:** rewriting by byte splice so a non-UTF-8 file could be washed rather than skipped. Re-plumbing the write path mid-campaign is the same class of move that let one line rot through five rounds.

## \[0.2.0-rc.7] - 2026-07-27

### Security

* **MEDIUM (station 3, round 4) - the fence bypass survived the THIRD fix as well, and the defect was never the LIST: it is the POLARITY.** Ten more characters still read `state: 'none'` - U+034F CGJ - U+3164 HANGUL FILLER - U+115F / U+1160 Choseong & Jungseong Filler - U+FE0F VS-16 - U+17B4 KHMER VOWEL INHERENT AQ - U+180B FVS1 - U+FFA0 HALFWIDTH HANGUL FILLER - U+2800 BRAILLE PATTERN BLANK - U+0303 COMBINING TILDE - and **not one of them is White\_Space, Cf or Cc**, so a `pinned: true` file carrying one measured deletable AND rewritable end-to-end, all three protections off exactly as in N1.
  * **`'none'` authorises a DELETE, and it was the FALLTHROUGH.** Every codepoint nobody had classified therefore landed on the destroying side automatically - which is why one line took four repairs, and why a fifth list would have leaked too. `'none'` is no longer reachable by falling into it: a fence tail EARNS it only by containing a printable-ASCII glyph (`[\x21-\x7E]`); every other tail refuses.
  * **What is claimed, and how to check it - stated narrowly on purpose.** `'none'` is reachable only through that 94-member allowlist, which is verifiable by READING it; this is not a statement about Unicode coverage. **Measured, with its bound:** a sweep of U+00A0-U+FFFF finds no codepoint reaching `'none'`, and an astral codepoint arrives as two surrogate code units, neither of which is printable ASCII, so it refuses by the same rule. Both are pinned as tests.
  * **REJECTED, recorded so nobody re-derives it:** `\p{Default_Ignorable_Code_Point}` is the closest real property and covers only 8 of the 10 (U+2800 and combining marks are not default-ignorable) - a fifth list, with a residual. `\p{Mn}` / `\p{Lo}` swallow real letters (U+3164 is `Lo`, exactly like ordinary Hangul, and renders as nothing).
  * **THE PRICE, NAMED:** a tail of legitimately VISIBLE non-ASCII prose (Thai, CJK, accented Latin, emoji) is now refused too - the file is not washed, and not deleted either. A YIELD loss, never a SAFETY loss, pinned by its own test so it stays a decision rather than a surprise.
* **MEDIUM (station 3) - `marketplace.json` described CoalWash as a class-B-only tool: FALSE BY OMISSION on the surface the marketplace reads.** The vocabulary sweep corrected the claim's STRENGTH in all three copies and its SCOPE in only two - `.plugins[0].description` still read "for agent class-B memory" while the repo About and `plugin.json` already named both lanes. It now states the two lanes in the same words `plugin.json` uses (611 chars, cap 1024). **The checklist that drove the sweep listed FILE NAMES, and so inherited the blindness of checking one sentence at a time: a claim has a STRENGTH and a SCOPE, and a sweep must ask both of every copy.**
* **MEDIUM (station-3 parameter-space sweep, run-confirmed) — the fence bypass SURVIVED the N1 fix through every whitespace character that fix did not list.** `[ \t]` was still an enumeration: NBSP U+00A0, IDEOGRAPHIC SPACE U+3000, VT, FF and ZWSP U+200B each still read `state: 'none'`, so a `pinned: true` file carrying one of them stayed deletable AND rewritable on the unattended path — the third repair of one line. **The evidence needed no external standard, which is what makes it decisive: the SAME byte on the CLOSING fence already answered `unverifiable`.** A primitive that says "cannot tell" at one fence and "no frontmatter" at the other is wrong at one of them, and `'none'` is the answer that ends in a delete.
  * **The AXIS changed, not the byte list.** The opening fence line's tail is now classified against the COMPLEMENT of visible content: empty or `[ \t]` = a fence (the ordinary editor artifact) · no visible glyph (Unicode `White_Space` | `Cf` format | `Cc` control) = `unverifiable` · anything a reader can SEE (`--- a/file.txt`, `----`) = `'none'`, genuinely not frontmatter and still washable. ~~An unlisted invisible byte therefore cannot exist.~~ **RETRACTED - that was a NEW false universal, written three lines after flagging the previous one, and station 3 falsified it the same day: a union of general categories is not a property. See the round-4 entry above.** Widening to `s` was considered and REJECTED — it misses U+200B (ZWSP is `Cf`, not `White_Space`) and would swallow line terminators.
  * Deliberate over-refusal in the safe direction, bounded to the one file. Controls are pinned end-to-end against a real `applyPlan` delete: a pasted diff header, a 4-dash thematic break, a leading blank line, and an invisible tail FOLLOWED by visible content all stay washable.
  * **The closing fence deliberately keeps `[ \t]*` and is NOT given the tri-state** — named in the source so the symmetry is not "finished" into a regression. Its accept-set is already identical and its miss is conservative by construction: an unmatched close either over-includes the block (more keys inventoried, and `isPinned` matches `pinned: true` anywhere in it, so a pin can only gain) or returns `unverifiable`. No reading of a bad closing fence is permissive.
* **MEDIUM (station-3 parameter-space sweep) — the EVENT identity reached only ONE of its two bank sites, so a crash recovery shredded its own undo material.** `201dae9` made a shared `at` the event unit for retention thinning and swept `applyPlan`'s bank; `recoverDangling`'s create-undo bank passed no `now`, so `recordBinItem` fell back to a per-call `Date.now()` — one recovery undoing N creates banked N distinct stamps = N single-item events, and past the 48h floor last-per-day kept ONE and destroyed the other N-1 pieces of the SAME transaction. Measured before the fix: 8 creates banked 8 stamps spread over 32 ms. `recoverDangling` now takes one clock reading for the whole run (`opts.now || Date.now()` — the shape `applyPlan` has always used) and banks every undone create under it. **A source-grep test pins the contract at both bank sites**, because this defect was precisely one site swept out of two and a sync comment is not a guard.
* **HIGH — one trailing space or tab after the opening `---` fence defeated the `pinned: true` refusal (graduation-lab round 2, N1).** The primitive contradicted itself: `readFrontmatter`'s closing fence has accepted trailing `[ \t]*` since it was written, and its opening fence did not — so `--- \n` read as `state: 'none'` ("genuinely no frontmatter"), and ONE invisible byte (which Markdown tooling deliberately preserves: two trailing spaces = a hard break, so trim-on-save is commonly off for `.md`) switched off THREE protections at once: the pin refusal on delete, the pin refusal on the unattended Quick/force rewrite (no human in the loop at any point), and the unclosed-fence "cannot faithfully parse" refusal. Same class as the encoding-preamble CRITICAL below — a lexical NO on decoded text read as a confident claim about the file — at a byte nobody had enumerated.
  * **Fixed at the same ONE primitive, never a call site:** the opening fence now tolerates `[ \t]*` exactly as the closing fence does, so a trailing-whitespace fence PARSES and the pin promise becomes TRUE for those files (the BOM-strip precedent: parse what is unambiguous; refuse only what cannot be read). `isPinned`, `sniffUnrewritable` and `frontmatterKeys` inherit through the shared primitive.
  * **The parameter space, as far as THIS fix saw it:** space · tab · runs · mixed · space+CRLF, each proven end-to-end against a real `applyPlan` delete AND rewrite; controls pin what must NOT become frontmatter (a pasted diff header `--- a/file.txt`, a 4-dash thematic break, a leading blank line — a position-0-convention DECISION, pinned in a test so it is visible, flagged to the room as the lab's named judgment call); and the shape that still cannot be parsed — a bare-CR (classic-Mac) fence head — is now `unverifiable` (refuse to touch), never `'none'`. **⚠ That sub-claim was WRONG and is corrected by the station-3 entry below:** the list enumerates the TOLERATED set and not one member of the REFUSED set, which is exactly where the hole still was.
* **HIGH — an INVALID project config value ESCALATED a globally-off skill (graduation-lab round 2, K1).** `mergeSafety`'s unknown-value branch was `continue` — "leave the shallow-merge result (schema clamps it downstream)" — but the shallow merge is project-wins and the downstream clamp lands on the SCHEMA DEFAULT, not on the global's stance: a global `coalwashMode: 'off'` was defeated by **every** junk project value (`'nope'`, `null`, `0`, `{}`, `[]`, `' auto '`, `true`) and resolved ACTIVE (`'auto'`); same hole re-enabled `updateMode` standing-consent checks. An invalid project value now gets NO say — the effective global (real, else the schema default, per the WAVE-2 R2 rule extended from *absent* to *unreadable*) stands. **The `'Off'` half of the lab finding, honestly scoped:** a case variant of a valid value folded correctly in the compare but the RAW string was stored (`'Off'` in the merged object); every shipped consumer reads through `clampedRead`, which lowercases enums, so the end-to-end blast was merge-layer-only — the resolved value is now stored CANONICAL (lowercase, from the order table) so no future raw-compare consumer inherits the trap (check one spelling, act on that same spelling). A raw-cased GLOBAL stance (`'OFF'`) is now also read as its folded self instead of silently degrading to the default.
* **The fidelity gate is now MULTISET-grade (board disposition 2, USER-accepted): occurrence collapse is a reported drop.** The old set semantics let a value stated on three different lines survive on one with 0 drops reported (`878`×3 → ×1 — a token DROPPED, inside the gate's existing promise). Occurrences are now counted once per DISTINCT line, so: an exact-duplicate-line cut (the broom's own charter — an identical line survives, information-free by spec) stays green BY CONSTRUCTION; a value surviving on fewer distinct lines than it occupied is reported with honest mention counts (`3 mention(s) -> 1; the value itself survives`) under the SAME `${type}:${value}` approval key — the wizard and RE-TIER channels need no new grammar, and RE-TIER's self-approval paths cover the new entries by construction. One extraction now feeds both layers (`tokenLists` — the set inventory is a projection of it), with overlapping extractors masked-chained so a `[text](url)` → bare-url restyle counts ONE occurrence on both sides, never a false drop. The gate's header now states the split claim per board disposition 1: what it PROVES (every structured token that went in came out, value grain AND mention grain) vs what it CANNOT SEE (surviving values trading places — re-pairing is the semantic layer's charter, `references/method.md` §4). The `fidelity-gate.mjs:2` "zero-fact-loss guarantee" header — disposition 1's third surface, deliberately left to this commit — is retired with it. Two superseded tests updated by name (the "set semantics" compaction test now asserts the flag; in-line repeats collapse under the same rule — the gate cannot tell an information-free repeat from an information-bearing one, so it reports and the adjudication channel decides). Measured cost: the gate's matched-path run \~2× (8.3 → 16.2 ms median on a 119 KB file, this box); the write-guard's happy path (the prefilter, Phoenix #3's budget) is untouched.
* **HIGH — one wash cutting N files kept ONE recovery record: the density axis destroyed N−1 of them at 49h (graduation-lab round 2, N2).** `applyPlan` banks every cut of a transaction with the transaction's single clock reading, and `retentionPlan`'s density axis slotted per ITEM (`floor(at/DAY)`, one survivor per slot) — so all N items of one wash shared one slot and an ordinary sweep past the 48h floor discarded all but one (`restoreFromBin → null`), no crash, no attacker, byte cap off, and it scaled the wrong way: the bigger the wash, the more of it was discarded. **The density UNIT is now the EVENT** — items sharing one `at` are one transaction and survive or die together; the newest event per slot survives WHOLE. This is the true Time-Machine port: a snapshot is a point-in-time SET, and "one per day" always meant one snapshot, never one file of it. A DIFFERENT axis of the module `3ded5ec` fixed (byte-pressure-vs-floor holds and was re-verified by the lab; the density axis was never that fix's subject) — both axes of the module are now closed in one pass. The old "same-`at` tie keeps the later-listed" rule is superseded (a tie was precisely a wash's own files fighting each other — the old test asserting it encoded the defect and is updated, named here); two genuinely separate transactions colliding on one millisecond would merge and BOTH survive, the keep-more direction. Byte pressure stays per-item by design (bytes are bytes; partial recovery of an event under a binding cap beats none — the floor + `capConflict` own the young case). `recoverDangling` — 160 lines, 7 filesystem-mutation sites — contained ZERO `isPinned` call sites while the module header promises "refuses delete AND rewrite" unconditionally: a forged journal (or an honest crash plus a post-crash user pin) could overwrite a pinned file through the restore replay and delete one through the create-undo. Both mutation sites now run the same pin gate `applyPlan` runs, per-item: a pinned target is REFUSED (`refusedPinned` on the return), the rest of the replay proceeds, the journal + snapshot are kept for a human (`recovered: 'partial'`). A legitimate journal never names a pinned target (applyPlan refuses them at plan time), so the refusal costs nothing legitimate — the one rare loss (a crashed transaction whose own rewrite ADDED `pinned: true` cannot be auto-rolled-back) lands as partial-plus-journal, the safe direction. The pin check runs on EXISTING targets only: `isPinned` fail-closes on a read error, so probing a nonexistent path would have killed the R4/TP-3 deleted-file restore — pinned control test in place.
* **CRITICAL — an encoding preamble defeated the `pinned: true` refusal, and `applyPlan` deleted the file.** A 3-byte UTF-8 BOM in front of the frontmatter fence (Notepad, `Set-Content`, any editor with BOM-on-save) made `isPinned` answer `false`, so the pin gate passed and both the delete and the rewrite went through on a file the README promises three times is never touched. A UTF-16LE file (PowerShell 5.1's `>` default) failed the same way; its NUL bytes already blocked the *rewrite* path via the binary sniff, but deletes are not sniffed, so the delete still ran. Reported by the graduation lab against `8fab00d`, whose engine blobs are byte-identical to the released rc.6 — **the defect is live in rc.6**.
  * **Fixed at ONE primitive, not three call sites.** `readFrontmatter` (`fidelity-gate.mjs`) is now the single answer to "does this text open with a frontmatter block", used by `isPinned`, `sniffUnrewritable` (both in `apply.mjs`, both gating destruction) and `frontmatterKeys`. Three private copies of `/^---\r?\n/` was the twin-drift shape this engine has already paid for twice.
  * **The fix is a DIRECTION change, not a wider regex.** The primitive is tri-state — `none` / `closed` / `unverifiable` — and each caller declares its own safe direction, because "I could not tell" is not "no". Previously the unknown case fell into the permissive branch by default; `isPinned`'s own docstring had promised fail-closed since it was written.
  * A leading U+FEFF is now **stripped and the file parsed** (a UTF-8 BOM is a legal signature and the file is fully decodable), so a BOM'd `pinned: true` file is correctly protected rather than merely refused.
* **HIGH (same root cause) — a BOM emptied the frontmatter inventory,** so every frontmatter key in a BOM'd original was silently droppable and the fidelity gate passed a rewrite that erased all of them. Closed by the same primitive.
* **MEDIUM (station-3 residual, run-confirmed) — a DOUBLE UTF-8 BOM still defeated the pin.** The primitive stripped exactly one U+FEFF; a second one is decodable and was not in the refuse set, so the state fell to `none` and `applyPlan` deleted a double-BOM'd `pinned: true` file. Closed at the same primitive: one strip is the legal signature; a U+FEFF **still at the head after it** is an encoding preamble (a re-encode artifact whose reading is ambiguous) and returns `unverifiable` — position 0 only, so a ZWNBSP inside real content is never dragged into the refuse set. Narrower trigger than the original CRITICAL (stacked/re-encoded BOMs), same blast.

### Changed — behaviour WIDENING, stated on its own terms

* `isPinned` now returns `true` (refuse to touch) for a file whose head is **not decodable UTF-8 text** — a UTF-16/32 preamble, NUL-interleaved content, or binary. Previously such a file read as unpinned and was deletable. This is deliberate over-refusal in the safe direction and it is the contract the function already documented; a file CoalWash cannot read is a file CoalWash does not destroy.
* `frontmatterKeys` now returns real keys for BOM-prefixed text, so the gate detects drops it previously could not see. An apply that used to pass may now be blocked — that is the gate working.
* **`sniffUnrewritable` widened, previously undeclared (named here per station 3; verified against the rc.6 blob):** the rc.6 sniff refused only a NUL byte or an unclosed fence; routing it through the shared primitive additionally flags any head that does not decode as UTF-8 (U+FFFD in the first 64 chars) — so a legacy-codepage file (TIS-620/CP874, CP125x) is now **flagged, not rewritten**, where rc.6 would have rewritten its mojibake decode back over the original. Safe direction: refusal, file untouched. The double-BOM fix above widens the same way: a double-BOM'd file — pinned or not — is now unverifiable and therefore untouchable, the same deliberate over-refusal already declared for UTF-16 heads.
* **NOT changed, and named so it is not read as covered:** an `unverifiable` frontmatter still yields an empty key set rather than making the gate refuse. Whether an un-inventoriable frontmatter should block an apply outright is a separate open question and was not decided here.

### Fixed

* **HIGH — the recovery bins' 48h keep-all window was not honoured, and the budget behind it was sized off the wrong quantity (graduation lab P5 + P8, one defect).** Measured on rc.6: a routine sweep destroyed a 25-hour-old pre-surgery whole-store image — the deepest restore point the undo net has — while the documented rule says nothing under 48 hours is ever touched, and the destruction log recorded only an opaque id whose id→file mapping died in the same operation. Two mechanisms, fixed together:
  * **The size cap now has a TIME FLOOR it cannot cross (snapper's 2-pass cleanup, ported as an age).** Time layers run first (horizon + density thinning, as before); byte pressure then evicts oldest-first **only among items already past the 48h keep-all floor**. A bin whose under-floor items alone exceed the cap **grows past the cap** and the conflict is reported — in `applyPlan`'s receipt (`binConflicts`, one advisory line naming the bin, the floor and both byte figures), in the sweep's return (`capConflict`, present only when live), and as a `cap-conflict` line in the bin's death log. No senior retention system (journald · logrotate · snapper · Prometheus · restic · Time Machine) says this out loud — they all resolve the contradiction by silently dropping data; the silence is the one part not ported. The floor also nets any future budget-sizing error: a wrong base can no longer reach young items.
  * **The budget base is now the MEASURED STORE, as the prose always said.** `applyPlan`'s preflight read `lastVerdict.alwaysLoadedBytes` while `retention.mjs`/`tailings.mjs` both document the budget as a multiple of "the MEASURED STORE"; the bins shadow what washes actually cut — the recall-tier-dominated whole store (the lab measured the gap at \~62x). The session gauge now caches `storeTotalBytes` (the whole measured class-B store, `measureEntries` total) in the verdict and the sweep budgets off that. Deliberately **no fallback** to `alwaysLoadedBytes`: a state written before the field existed sweeps horizon-only (keep-on-doubt) and self-heals at the next SessionStart gauge — no `stateSchema` bump, since no existing field changed meaning and the absence is the documented never-gauged fail direction.
  * **The death certificate now carries name/age/RULE — the full line the module's own header always promised.** `destroyed <id> (age Nd, rule horizon|density|size-cap) original <source-path>`: the axis that fired and the source filename survive inside the certificate itself, so destroying the index entry no longer destroys the only record of what was destroyed or why (the P8 audit-trail finding; the Prometheus "whichever triggers first" resolver, made auditable per kill).
  * **Checked, already correct, recorded so nobody re-derives it:** the two bins do NOT pool a budget — each bin's own mass is measured against its own `2 x store` cap independently (the journald System/Runtime separation, per the USER's inverted-fill-rate rule). And no pinned/refused content can sit in a bin (the pin gate refuses before anything is banked); the bytes the cap counts but cannot delete are the floor-protected young, the newest-item retrievability anchor, and doubt/weightless items — exactly the mass the new `capConflict` report names instead of silently missing.
  * `retentionPlan` returns two additive fields (`reasons`: destroyed-item → axis; `capConflict`); `applyPlan`'s result gains `binConflicts` (always present, `[]` when clean). Proof: red→green on own byte-level fixtures (never the lab harness's repro, per its own defect disclosure), plus five reverted mutations — floor-off, conflict-off, certificate-stripped, base-reverted, conductor-wiring-off — each proven to go RED on AssertionErrors with the sources restored byte-exact.
  * **Ship-text synced to the mechanism (docs ride-along):** `references/method.md` §8, README's Recovery-bins row, and `SKILL.md`'s retention line no longer state the two axes as an unqualified "whichever binds first" — the 48h keep-all floor beats byte pressure, and an unsatisfiable cap is described as it behaves: reported by name, never silently resolved. The death-certificate description now names the full line (id · age · rule-axis · original source path), and the bin budget's base is stated as the whole measured store (`storeTotalBytes`).
* **Ship-text claim correction — the fidelity gate's promise is split into what it PROVES and what it CANNOT SEE (CoalBoard permutation verdict 2026-07-27, labtest finding P4).** The gate diffs structured tokens as a positionless set: it proves every token that went in came out, and it structurally cannot see surviving values trading places — `pass 878 / fail 0` rewritten as `pass 0 / fail 878` drops nothing and passes. README (headline, the what-it-is bullet, the caution box), `SKILL.md` (frontmatter description + the fidelity-scope note, net-negative on the body budget), and `references/method.md` §4 now state both halves with that example inline and tell the reader the consequence — read the diff yourself; the before-vs-after claim-strength outsider's contract now names value re-pairing as formally in-scope. Docs only — no gate behaviour changed.
* **Ship-text: the retired "zero fact loss" vocabulary swept off the two FRONT-MOST metadata surfaces and the write-guard header (station 3).** `1578eca` retired the phrase in `fidelity-gate.mjs` and `17870c3` swept README / `SKILL.md` / `references/method.md`, but `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` still described the gate as "proving zero STRUCTURED-token loss" while `SKILL.md` already said "blocking any STRUCTURED-token drop" — two claim strengths for one product inside one package, on the surface a marketplace visitor reads and in the \~100-token metadata loaded every session. `writeguard.mjs`'s header carried "zero-fact-loss guarantee" twice (source and dist). All now carry the board-locked vocabulary; the plugin description is byte-length-unchanged at 622 chars (cap 1024), the marketplace entry 519.
* **LOW (station 3) - the new call-site grep test asserted too weakly.** `/\bnow\b/` is satisfied by `Date.now()` - the exact fallback the test exists to forbid - and by the word appearing in a trailing comment. It now strips line comments and matches the shorthand PROPERTY (`[,{]\s*now\s*[,}]`), the defect's own vocabulary; a mutation planting `now: Date.now()` at the recovery bank turns it red. Net coverage was never lost (the behavioural test catches both cases), but an assertion that cannot fail on the thing it names is not a guard.

## \[0.2.0-rc.6] - 2026-07-27

> Step-release (USER decision 2026-07-27, "ไปทีละสเต็ป"): shipped with two backend systems **not yet lab-tested on this build** — the recovery layer (bins / restore / estate-restore round-trips) and the in-flight write-path guard (airbag/seatbelt). The destruction lanes below carry their own per-fix reproduction evidence; the full graduation labtest runs separately and gates the NEXT step, not this one.

### Added

* **Class-A ULTRA at-rest reduce engine landed in source — `scripts/lib/explode.mjs` (main destruction engine) + `scripts/lib/detonate.mjs` (input-verify gate + survey census).** Byte-exact streaming reduce of a `.jsonl` transcript larger than memory: explode on a DISCOVERED boundary, hard-cut exactly the caller's `cutTypes`, reproduce every survivor's source bytes verbatim, snapshot-backed, source never mutated (the reduction writes a separate slim copy). Graduation record: **A2** dry-run parity · **A3** real-corpus run — byte-exact output, census correct, cost-bounded, source verified untouched · **A4** deterministic across runs, and the factory `CLAUDE_DEFAULT_CUT_TYPES` re-scoped to the proven-lossless pair `{custom-title, mode}` (a census measured `last-prompt` as the SOLE copy of a user prompt 64% of the time and `queue-operation` as only conditionally content-free, \~4% carrying payload — neither is safe to cut sight-unseen; cutting more is done deliberately per file via an explicit `cutTypes` derived from the survey's `freeFormCount`). **Honest yield:** the engine ALONE reduces \~4.76% at the type level — the 94.8% figure from the original prototype required a future agent-side semantic layer that is not built. **Not user-facing yet:** nothing in the SKILL flow invokes it and it is deliberately excluded from the shipped `plugin/` dist (named `UNWIRED_ENGINE` exclusion in `scripts/build-plugin.mjs`); it ships when the class-A SKILL surface wires it.

### Changed

* **The automatic sweep now re-runs on every new wave of fat, instead of once when the store first got heavy.** Previously a store that crossed into the middle band was swept a single time and then went quiet — it only spoke again if it grew all the way into the top band or was cleaned back down to lean. Anything that piled up in between simply rode along, even though cutting it costs nothing. Now each genuinely new lump of mechanical clutter is swept as it arrives, and it is the fat that survives those sweeps which eventually carries the store into the top band, where the one question CoalWash ever asks lives. Only the free mechanical pass was made automatic — nothing that costs anything changed, and the middle band still never asks. An unchanged store stays silent: re-running is gated on real growth, measured against the level of the last sweep, with a small cushion so that a reworded sentence or an edited timestamp is not mistaken for new clutter.
* **Every run now says what it did, including a run that found nothing.** The one-line result was previously suppressed when a pass cut nothing, on the grounds that an empty receipt is clutter. That reverses here, because the sweep above now fires on its own far more often: a silent run is indistinguishable from a run that never happened, and there was no way to tell a working autopilot from a dead one. The line is the same for every path — the automatic sweep, the forced pass at the top band, and the full wizard pass all print one line and differ only in the numbers. It now carries exactly two numbers, which is what the documentation had always described while the code printed three; the fuller breakdown remains available on request.
* **Themed engine filenames — `scripts/lib/quick.mjs` → `broom.mjs`, `scripts/lib/bins.mjs` → `tailings.mjs`** (with their test files, `quick.test.mjs` → `broom.test.mjs` and `bins.test.mjs` → `tailings.test.mjs`). The two modules were the last engine files still named for a tier (`quick`) or a generic container (`bins`) instead of the mine vocabulary the rest of the codebase already speaks — the mechanical pass has always been "the broom", and the recovery bins hold what the wash separated out. Filenames + every reference to them only: imports, the `verify.mjs` lib roster, the `test.mjs` suite roster, engine comments, `CONTRIBUTING.md`'s module table, and `references/method.md`. **No behaviour, export, config key, or function name changed** — `quickVsFull`, the Quick tier's name, `FAT_BIN_NAME`/`recordBinItem`/`listBin`, and every bin concept are untouched, so no user-visible surface moves.

### Security

* **\[HIGH] The crash-recovery step ran without the guards the apply step already had — and it is reached through the ordinary front door.** Every run, and every `/coalwash:stats` call, opens with a recovery preflight that finishes or discards an interrupted previous run by reading a transaction journal out of the project it is pointed at. That step built the same set of trusted roots as the apply step, from the same project anchor — but neither of the apply step's two guards stood in front of it: nothing refused an anchor that swallows your home directory whole, and nothing refused an anchor inside the Claude configuration directory. A journal shipped inside a cloned repository was therefore enough. Measured on the pre-fix code, one of them overwrote `~/.claude/settings.json` with a `SessionStart` command hook, deleted `~/.ssh/authorized_keys`, reported a clean rollback, and then deleted the journal, so the evidence went with it.

  **The fix is one shared primitive, not a second copy of the two guards.** Copying them above the second derivation would have been correct on the day and is the exact shape this engine has already paid for three times: two peer functions with duplicated guards diverge the moment somebody hardens one side, and the divergence ships silently because both still *look* guarded. The two functions that mutate the memory store — the apply and the recovery replay — now call one anchor gate that owns the home-swallow check, the configuration-territory check and the root derivation together. The recovery door takes the fail-closed reading of both legs, because its anchor is whatever its caller derived from the working directory rather than one a caller vouched for; the apply door skips only the home-swallow leg, and only when the caller supplied its own anchor. The gate runs above the first filesystem touch, so a refused anchor reads nothing and writes nothing. Two related corrections ride along: the removal inside the recovery loop now acts on the spelling that was validated rather than the raw one from the journal (checking one spelling and acting on another lets the operating system resolve the path a second time), and the gauge now passes the home directory through to the preflight instead of letting it resolve one independently — harmless in production, where both land in the same place, but a false-green trap in any test that sandboxes it, where the new guard could never fire.
* **\[HIGH, code that has never shipped] A symlink planted at a snapshot's blob path turned the class-A snapshot into an arbitrary write.** The at-rest reduce engine wrote its snapshot blob with a plain file copy, and a plain file copy *follows a symlink at the destination* — so a link planted at the store's content-addressed path pushed the source file's bytes through it to anywhere on disk and reported success. The deduplication check in front of it did not save it; it routed into it. That check asks whether a blob with this hash already exists: the existence test follows the link, the hash test then hashes the *victim's* content, which cannot match, so the condition comes out false — and false is defined to mean *write*. A guard whose negative branch is the dangerous action is not a guard, and reading it as one is how it survives review. The write now goes to a temporary name created exclusively and renames into place, because a rename replaces a directory entry instead of writing through it. The exclusive create belongs on the temporary name and not on the blob path itself: the blob path's write branch legitimately serves a second case, replacing a wrong-hash blob, which an exclusive create would refuse.
* **\[HIGH, code that has never shipped] A backup that was an alias of the file it was backing up passed as a real snapshot.** The class-A snapshot store is content-addressed and deduplicated on content alone — if a blob already exists at the hash, the copy is skipped. A hardlink or symlink to the source, planted at that path, hashes *exactly* equal, because it is the source; so the check passed it and the store recorded a "snapshot" that is the same bytes on disk as the thing it exists to protect. Measured before the fix: plant the link, take the snapshot, get a success reporting a deduplicated hit, then rewrite the source — and the backup reads the new content. The undo net was a lie, silently, with a green result. Surviving the source changing is a snapshot's entire job, so independence is part of the definition of a backup rather than an extra check bolted on: the deduplication path now reuses the engine's existing alias test (real paths plus device and inode on both sides) instead of re-deriving one, and an alias falls through to the write path, which replaces the entry with a real independent blob. The temporary file used by that write is now named from 12 random bytes rather than the process id — that removes the precondition rather than relying on the exclusive create to refuse a planted name, and the exclusive create stays as the second belt. **Honest limit:** the report that a dangling symlink defeats an exclusive create on Windows could not be reproduced here, because creating a file symlink is not permitted on the machine that did this work (the symlink-gated test skips with a visible reason rather than passing vacuously), so the random name is deliberately the guard that does not depend on those semantics being what we hope. A sibling temporary file on the restore path still carries the predictable-name shape and is tracked separately.
* **\[HIGH, code that has never shipped] "Cannot tell" was being read as "allowed" at five containment guards.** The class-A containment helper folded an unresolvable path into a plain `false`, under a comment calling that fail-closed. It is fail-closed only where *contained* means *permitted*. At a guard whose polarity is the opposite — *contained means refuse*, which is how the source-inside-the-store and output-inside-the-store checks are written — `false` means allow, so a path the engine could not resolve walked straight through. Windows UNC and device (`\\?\`) spellings return exactly that unresolvable answer by design, and a broken junction anywhere in the parent chain produces one on any platform, so this was reachable through Node's own documented `path.toNamespacedPath()`. A sweep of every containment consumer in both class-A modules found five inverted sites — three of them not named by the report that started the work — and one of the two in the input-verify module was saved at the frozen commit only because its copy of the resolver happens to lack the other's device rejection, which is drift, not a defence. The fix is at the primitive rather than at each call site: the helper now answers `inside`, `outside` or `unknown`, and every caller must state what unknown means in its own direction, so the lesson cannot be re-learned per site — null is not a safe default, it is a value whose meaning the caller's polarity decides. The five refuse-polarity guards now require a *proven* outside. The old boolean helper is re-expressed as "is `inside`", so its behaviour is unchanged and the cross-engine containment gate stays green. **Behaviour change:** a call passing a namespaced path the engine cannot resolve is now refused rather than quietly proceeding, consistent with the engine's existing deliberate rejection of device spellings on the input side.
* **\[HIGH] An unresolvable configuration directory switched the whole guard off.** The check that refuses a project anchor inside the Claude configuration directory treated "the configured directory could not be resolved" the same as "there is no configured directory" — a free pass. A `CLAUDE_CONFIG_DIR` spelled as a UNC or `\\?\` path therefore made the guard answer "does not touch config territory" for *every* anchor, and it became a no-op: `settings.json` took a `SessionStart` command hook, and the `\\?\` form also rewrote a cached plugin's hook script. A two-sided containment comparison has two attack inputs, and only one of them had been hardened. The two meanings are now distinguished — **absent** is not a constraint (a fresh install with no configuration directory must still work), **present but unresolvable** is refused.
* **\[HIGH] A shape check on the wrong side of canonicalization refused legitimate paths and, through one caller, opened a write escape.** The previous release added a Windows 8.3 short-name check against the *output* of canonicalization — but canonicalization always expands a genuine short name, so the check could never fire on one, while directories whose perfectly legal long names merely resemble the pattern (`backup~1`, `notes~2`) were refused. Two consequences shipped: a project stored under such a directory could not be washed at all, and the write-guard silently stopped snapshotting files with such names. Worse, the path-resolver for not-yet-created destinations treats "unresolvable" as "climb higher and reattach the rest of the path textually" — so a link inside a snapshot store pointing at such a directory was never resolved, and a write could land outside the store the check exists to protect. **The principle now applied: a shape refusal belongs on the input, where the ambiguity is, never on the output, where canonicalization has already resolved it — and a fail-closed primitive stays fail-closed only if no caller reinterprets its refusal as permission to guess.** The 8.3 check is removed rather than moved (there is no residual ambiguity to refuse), and the destination resolver now refuses outright rather than reattaching across a segment it could not resolve.
* **\[HIGH] Windows path spellings could walk through every containment guard — fixed in the canonicalization primitive.** `fs.realpathSync` does not canonicalize three Windows path forms: an 8.3 short name (`CW-HOM~1\CLAUDE~1`) comes back unexpanded, a UNC path (`\\localhost\C$\…`) stays UNC, and a `\\?\`-prefixed path throws, after which the old helper fell back to a lexical resolve. A short-name or UNC spelling of a directory therefore compared *unequal* to its ordinary spelling, and the configuration-directory guard, the home-directory guard and the class-A snapshot-store guard all missed: a short-name working directory rewrote `settings.json` and a cached plugin hook script, a UNC working directory rewrote `settings.json`, and a short-name working directory under a home holding `.git` added a key to `~/.ssh/authorized_keys`.

  The fix is one function, not per-guard patches: canonicalization now uses `fs.realpathSync.native` (which does expand 8.3), and any form that still cannot be reduced to a comparable path — UNC, `\\?\`, or a surviving 8.3 component — is **refused by shape** rather than passed through. An unresolvable path returns nothing and every caller fails closed. The three separate copies of this helper (shipped engine, class-B discovery, class-A engine) now share the identical rule.

  **The limit, stated plainly rather than claimed away — and corrected once more:** the guarantee is that an anchor is *either canonicalized and compared, or refused* — not that every possible alias is detected. That sentence was still too strong when first written: it described only the anchor side, while an unresolvable configuration directory on the other side of the comparison quietly disabled the check (see the entry above). Both sides now refuse rather than pass. A new path form must be added to the refusal list, never waved through. The previous entry's assertion that the guard was unconditional has been corrected in both the code comment and below.
* **\[HIGH] The Claude configuration directory could become a trusted apply root — now refused at the trust boundary.** `~/.claude/CLAUDE.md` (the user's own global instruction file, present on most installs) matched the newly added `CLAUDE.md` project-root marker, so a session working under `~/.claude` resolved its project root to the configuration directory. That directory then entered `applyPlan`'s trusted roots, and the home-swallow guard did not fire because `~/.claude` sits *below* home: a forged `PLAN.json` could write a `SessionStart` command hook into `settings.json`, or rewrite a cached plugin hook script — either one is code execution on the next session.

  **An earlier entry in this same unreleased section claimed "The base directory is now never a project root". That claim was wrong.** It described a check placed inside the path-resolving walk, and testing showed three ways straight past it: the walk's two fallback exits returned the raw working directory untested (so a working directory *at* the configuration directory still produced it as the anchor, with no precondition at all), the comparison was case-sensitive (so a `CLAUDE_CONFIG_DIR` differing only in drive-letter case slipped through), and only the first entry of a comma-separated `CLAUDE_CONFIG_DIR` was ever considered.

  What holds now is a single check at the consumer: **`applyPlan` refuses any project anchor that touches a configured Claude base directory in either direction** — inside it, or containing it — case-folded, across every `CLAUDE_CONFIG_DIR` entry, and binding trusted callers too. (That description was itself too absolute when first written: the comparison is only as strong as the canonical form beneath it, and the path forms that cannot be canonicalized are refused by shape — see the entry above.) Nothing in that tree is ever a project to wash, and the one global store CoalWash does wash enters the trusted set separately, derived from the project anchor rather than being it. The resolver keeps a narrower awareness of the configuration directory purely so per-project *state* is not filed under a non-project path; it no longer carries the security decision, and its comment now says so. Introduced and caught within this unreleased cycle; no shipped version was affected.

### Fixed

* **A project nested under a shared governance umbrella was told to clean up cost that was never its own.** The gauge counted every governance file the platform's up-tree walk loads — including the ones living *above* the project, in a parent it does not own — as part of that project's own always-loaded footprint. Measured across one set of sibling projects at one moment: the shared umbrella alone came to 30,487 estimated tokens, 85% of the hard ceiling, before any project held a single byte of its own, and it pushed three of them over the wall. None was anywhere near the ceiling on its own content. The advice that follows a capacity wall is "externalize or split", and a project can do neither to a parent's file — washing it from there would be reaching into somebody else's room.

  The band arithmetic was never wrong; its input was. Inherited-ancestor files are now a tier of their own — measured the same way, reported separately, and kept out of the number the band verdict and the wash act on — exactly as per-role subagent memory stores already were. **Consequence:** a project that reported the top band purely because of its umbrella now reports lean, and the umbrella's per-session cost appears as its own figure: real context cost, correctly not that project's to wash. The split is decided per file by location, inside the one discovery step that can reach above the project, so a global store that legitimately sits outside it — the one CoalWash actually does wash — is not swept into the inherited tier along with it.
* **Recovery's one unbacked delete now banks the bytes before removing them.** Undoing an interrupted run means removing the files that run created, and it removes *every* create in a dangling transaction regardless of whether the journal recorded that step as finished — deliberately, because a crash between writing a file and stamping the journal leaves the stamp missing on a file that exists. That reasoning cannot tell our file with a lost stamp from somebody else's file written at the same path after the crash, and it needs no attacker to lose data. A rewrite or delete on the apply path is backed twice — a verified snapshot before the first mutation, a recovery-bin record after the commit — while this removal had no handle at all, and the journal is deleted on the success path, so nothing survived to say what went. The removal now records the file into the recovery bin first. If it cannot, it does not destroy: it counts the refusal, reports a partial recovery, and keeps both the journal and the snapshot for you. An un-undone create is a mixed state a person can still fix; an unrecoverable delete is not. A directory sitting at a create path lands in the same refusal, where it used to be a silently swallowed failure reported as a clean rollback.
* **A refused crash-recovery was invisible at the front door.** The gauge's one-line output appended its recovery clause for every recovery outcome except "none" — and every *refusal* returns "none" plus an error, so a user with a poisoned journal sitting in their checkout saw output byte-identical to a user with no journal at all: the guard fired, correctly, and left no trace, and a guard whose firing leaves no trace cannot be trusted to have fired. The two "none" cases now read differently — nothing to do stays silent, unchanged, but a journal that was found and REFUSED says so, tersely, with a pointer at the JSON detail the skill text now tells the agent to read. Two sibling text corrections ride along because a caller branches on them: the shared refusal message no longer offers a remedy only one of its two doors can actually perform, and the recovery function's written contract now names all five outcomes it returns — including the one that leaves a store mixed, the one a caller most needs to branch on — with the refusal-versus-empty distinction stated where the next caller will read it.
* **A hook payload the conductor could not read took the loudest branch instead of staying silent.** The session hook's dispatcher fell through to the session-start handler whenever the event name matched none of the ones it knows — including when reading the payload timed out and left an empty object. A real end-of-turn invocation that lost that race therefore ran the session-start measurement instead, and could print the update-available line in the middle of a turn: a hook must skip an unexpected state silently, never take its noisiest path. The dispatcher now requires an explicit session-start match, and any other or unreadable event is a silent no-op, matching the four events the hook file actually registers. Observed once on Windows continuous integration and not reproducible on the identical commit, operating system and Node version — a genuine timing race, which is why the regression test forces the empty-payload condition directly instead of racing a real pipe.
* **\[code that has never shipped] The primary undo was refused by a message naming the very flag the caller had passed.** Restoring a snapshot back over the file it was taken from is the whole reason the class-A recovery store exists. That restore refused unconditionally whenever the caller declared which file was the protected source — telling them to pass a force flag while ignoring the force flag they had passed — and it refused at the moment somebody was mid-recovery, leaving the file corrupted by the bad run. The perverse incentive is the tell: *omitting* the declaration made the same restore succeed, so the engine punished the caller for being explicit about what it was protecting. The branch is now gated on the absence of force, matching the clobber guard ten lines below it that was already gated that way — one honouring force and one ignoring it is the defect. Without force the precise alias refusal still fires, unchanged, and a refused restore still leaves the destination untouched.
* **\[code that has never shipped] The survey and the reducer disagreed about where a line starts, and the honesty counter read zero for the disagreement.** The class-A survey trimmed each line before parsing it; the reducer strips only the line terminator. JavaScript's trim treats a byte-order mark as whitespace, so a unit prefixed with one parsed for the survey and failed for the reducer. Measured on a single fixture: the survey advertised a type as cuttable, the reducer refused to parse that line and cut nothing, and the count of unparsed units reported **zero** — the counter whose whole job is honesty reading zero for the very unit the survey had silently mis-typed. An agent reads that survey to choose what to cut, so this was a wrong instrument sitting on a destructive decision path, offering a cut that cannot happen while asserting nothing was unparseable. The survey now calls the reducer's own line reader, so there is one definition of "the text of a line" with two callers instead of two readings — which removes the divergence class, of which the byte-order mark was only the instance that surfaced it. The blank-line check still trims, because a whitespace-only line is structural, and that distinction is now written at the line. **Consequence:** a unit prefixed with a byte-order mark or leading whitespace is counted as unparsed and no longer offered as cuttable, which is what the reducer will actually do with it.
* **\[code that has never shipped] The class-A source-protection belt could be handed a confidently wrong answer on a UNC spelling.** The reduce engine's final belt — the source must not resolve inside the snapshot store — resolved the source with a bare resolver that returns a UNC spelling verbatim, then compared it against a drive-letter base, so a source handed in as `\\localhost\C$\…` *inside* the store compared as outside and the belt did not fire; a redirected Windows profile makes UNC paths ordinary, not exotic. The "cannot tell" polarity fix above is armour against unknown; it is no armour against a wrong answer. Measured on the frozen commit: the belt stayed silent and the engine's deeper floor did the refusing — a belt carried by the guard behind it, the same shape found on the output side one commit earlier. The belt now uses the same shape-refusing resolver as the floor it mirrors, so an unresolvable spelling is refused at the gate that owns it, with the reason that explains it, instead of being answered wrongly. Nothing that worked before is lost: a UNC source was already unusable end-to-end, just refused at the wrong layer.
* **The stop-of-turn channel ignored a configured language.** Both other channels that speak to the user append the configured-language instruction; this one never did. It did not matter while its messages were purely instructions to the agent, but each of them now ends in a user-facing receipt, so a user who had pinned a language could still be handed an English line. Numbers and units are explicitly excluded from translation.
* **Behaviour widening: paths that were refused for the last few days now resolve again.** The short-name refusal removed above had been rejecting real directories whose legal names merely resemble the Windows 8.3 pattern. Nothing is known to have depended on those refusals and the suite is green either way, but a widening belongs in the record on its own terms rather than only as the tail of a bug fix.
* **The two copies of the containment primitive are now held together by a test, not a comment.** The class-A engine keeps its own copy on purpose (it is loaded in isolation and must not pull in the configuration chain), and for four review rounds that copy was kept in step by a note asking the next person to remember. It twice fell behind in ways that shipped. A gate now drives the same table of inputs through both and requires identical verdicts — and it found a live divergence on its first run: the false-positive short-name check had been removed from one side only.
* **Two containment guards that were correct only by accident are now pinned.** The rule that both sides of the internal containment comparison must already be in canonical form shipped with no test of its own: it had replaced a silent path-resolution fallback, so a later refactor could have quietly restored that fallback with nothing going red. Every rejected shape — relative, unnormalized, trailing-separator, empty, non-string, absent — is now asserted, on *both* arguments, because a guard that checks one side is half a guard. Separately, a third containment helper carries no case handling of its own and is case-insensitive on Windows only because Node's own relative-path computation folds case internally. That reliance is now asserted as a declared dependency rather than left unstated, so if it ever changes it surfaces in our own gate instead of in a user's containment check.
* **The plugin-build gate was blind to test artifacts inside a dist item.** Files matching the test pattern are filtered out of the build and skipped by both directions of the sync check, but — unlike the other two exclusions — nothing asserted their absence, so anything named like a test file placed inside a shipped directory was invisible, including a *directory* so named, which hid an unlimited subtree. The rule is now stated rather than patched case by case: every exclusion sharing those walks owns an absence check over exactly what it removes.
* **A recovery run could not restore a deleted file.** Deletes are performed last, so a file already removed is the characteristic damage of an interrupted run — yet recovery resolved each target through a helper that by definition cannot resolve a path which no longer exists, and refused it as though it lay outside the permitted roots. Recovery now uses the destination-side resolver (safe to rely on only after the resolver fix above), with containment unchanged.
* **The one-time state migration cost is re-recorded honestly.** The figure published last release was measured against empty sibling directories, so the per-directory read it is supposed to account for never happened. With realistic contents: 9.4 ms at 21 directories, 14.5 ms at 100, 107.6 ms at 1000 — paid once per project. Steady state is unchanged and flat at roughly 3 ms regardless of count.
* **The plugin-build gate was blind to anything beside a nested dist item.** `scripts/lib` is a directory item two levels down, so `plugin/scripts/` itself was never walked and a top-level allowlist admitted it: a stray file, a whole subtree, or another tool's state directory placed directly in `plugin/scripts/` all passed. The gate now states the invariant generally — every entry in the built plugin is either covered by a dist item or an ancestor directory on the way to one, at any depth — instead of enumerating the cases found so far.
* **A recovery call could throw instead of refusing.** Restoring from a snapshot with a non-string reference raised a type error rather than returning the fail-closed result every other bad reference gets; a recovery primitive is reached exactly when something has already gone wrong and must never crash its caller.
* **`CLAUDE_CONFIG_DIR` with a leading empty entry disagreed with itself.** The single-value accessor fell through to the default directory while the list accessor reported the configured one, so the directory actually written to sat outside the guarded set.
* **The plugin-build gate asserted absence over a narrower set than it excluded.** Excluding stray dot-named entries from the dist walk removed *files* as well as directories, but the compensating check enumerated directories only — so a dist-only dot-file orphan, which the gate caught before the exclusion existed, became invisible. The check now covers exactly the set the exclusion removes.
* **A file-shaped dist item left its directory unchecked.** `.claude-plugin/plugin.json` is enumerated as a single file, so nothing else placed in that directory was ever examined; the gate now enumerates the parent and reports anything unaccounted for.
* **The stray-state cleanup ran on every state write.** It is a one-time migration for a condition the never-create guard makes unrepeatable, but it walked `~/.claude/projects/` on each save — measurable overhead on a hook path that fires on every subagent spawn, and enough to exceed the hook time budget on a machine with a normal number of project directories. It now runs once and records that it has.
* **Sibling projects could lose their measured baseline to the stray-state cleanup.** The cleanup decided ownership with a slug-string prefix test, but the project slug maps every non-alphanumeric character to `-`, which makes a sibling project (`work/proj-notes`) indistinguishable from a subdirectory (`work/proj/notes`). A single ordinary write in one project could therefore delete the other's gate-passed lean floor, resetting it to provisional and silencing CoalWash on that project. Ownership is now decided by the root recorded inside the state file, with the directory name required to match that root's own slug so a planted file cannot nominate someone else's directory.
* **The stray-state cleanup now realpath-contains every removal.** A junction planted under `~/.claude/projects/` could redirect a delete outside `~/.claude`; the cleanup now applies the same realpath-both-sides containment every other CoalWash write and delete already used.
* **A stale state copy could resurrect an old lean floor.** State is read from the per-project location and then the global fallback, but a write that moved between the two left the old copy behind — so when the per-project directory later disappeared (project removal, or Claude Code's own cleanup), the next read silently reverted to the pre-move floor. A write that moves home now reaps its own previous copy, leaving exactly one live state.
* **Windows: a rounded inode could refuse a legitimate class-A reduce.** An NTFS 64-bit file ID routinely exceeds 2^53, so the default numeric `ino` is a rounded double — measured here, 4000 distinct files produced 5 identical `(dev, ino)` pairs. The hardlink guard read that as "the output aliases the source" and refused, at random, on unrelated files. The guard now compares the identifier exactly (BigInt stats): the false positive is gone and real hardlink detection is unchanged.
* **The plugin build no longer copies stray dot-directories.** The dist walk was a denylist, so an unrelated tool's state directory written inside `scripts/lib/` was copied into `plugin/` while the dist check cleared it as "has a source". Such files are gitignored, so a git install was never affected, but the ZIP and `--plugin-dir` surfaces shipped the directory as-is. Dot-directories below a dist item are now excluded and their absence asserted; `verify.mjs` also gained a both-direction drift check on its library roster, so a new engine module can no longer go silently unchecked.
* **The repo's own gate could die on a sparse checkout instead of reporting one.** `scripts/verify.mjs` statically imported two of the very libraries it checks, and a static import resolves while the module graph links — before the first `try/catch` in the file exists — so a checkout missing one of them (a sparse caretaker bench) killed the gate with a raw module-not-found error and **zero `FAIL` lines**. A gate that dies is indistinguishable from a gate that was never run: reporting nothing reads exactly like nothing to report. Both imports are now dynamic inside the one check that consumes them, leaving the top level Node builtins only, so a missing library surfaces as the enumerated `FAIL scripts/lib/<lib> missing` line the file's header already promised — still exit 1, reporting instead of crashing. The description-target scan, the file's last unguarded filesystem walk, is wrapped the same way. A spawned test pins the shape against an empty tree — exit non-zero, one `FAIL` per missing library, the summary reached, no stack trace — and was watched red against the pre-fix file first.
* **The class-A-never-auto gate no longer scans a nested checkout as if it were this tree.** The caretaker benches are nested worktrees inside the repo (machine-local, gitignored), and `.gitignore` fences git, not a filesystem walk: the gate's own walk read the benches' copies of files it had already scanned as ten unrecognised invocation artifacts, failing the local suite while CI — which has no benches — stayed green. Not a silenced finding: those are the same files the walk already covers in the real tree, and counting one twice is the defect. The walk now skips any directory carrying its own `.git` entry (covers nested clones too, and cannot rot on a rename), with the check at the descent rather than the entry so the anchor repo can never skip itself — the first draft checked at the entry and returned an empty walk, every assertion vacuously green. The test now asserts both ways: the real tree still walks, and an unrecognised artifact planted in it still goes red.
* **State scatter: a session whose cwd sat in a SUBDIR of the project minted a spurious `~/.claude/projects/<slug>/` per subdir** — the rc.3 OS-citizen state relocation in a new form, plus wrong semantics (a per-subdir state = a separate BMI floor and crossing history for the SAME project). Field-caught 2026-07-25: three orphan slug dirs, each holding only `coalwash/state.json` and zero transcripts. Two independent layers now close it. **(1) Root-anchored derivation** — `findProjectRoot` gained `CLAUDE.md` (the governance root) alongside `.git`/`.coalwash.json`, so a project that declares itself by governance and NOT by git no longer matches nothing, runs the walk to home, and falls back to the raw cwd; every subdir of a project now resolves to the same root, so the state path, the config cascade, and the write-guard snapshot dir all follow the project rather than the shell's location. (`AGENTS.md` is deliberately *not* a marker: Codex reads a chain of per-directory `AGENTS.md` files, so honouring one as a root would re-create the same scatter.) **(2) Never-create guard** — CoalWash will no longer `mkdir` a slug dir under `~/.claude/projects/` at all; it writes its `coalwash/` state only into a slug dir Claude Code already created, and otherwise falls back to the existing `coal/coalwash/` path. Even a future derivation bug therefore cannot manufacture a phantom project dir. A state file now records the root it was written for, and a write self-cleans CoalWash's own stray dirs from before the fix — identified by that recorded root, never by directory contents (a real project's dir can legitimately hold only `coalwash/` once Claude Code sweeps its transcripts), with a file that records no root left untouched.
* **The one-shot gauge measured role-memory stores and then discarded them before they reached anyone.** `discoverClassB` has reported each subagent role's memory store separately since #22, and the CLI's own reference documentation has described the JSON output as reporting them per-store since the same release — but the gauge's return object never included the field, so every caller of `gauge --json` saw a store that was silently measured and silently invisible: the CLI itself, `/coalwash:stats`, and now the design for the upcoming role-aware dig assistant, which needs exactly this signal to judge which role knows a topic more deeply. The field is now surfaced unchanged from discovery; nothing about how a role store is found or reported was touched.
* **The break-even numbers shown at the OBESE and FULL ceilings were labelled in the wrong unit.** The figures underneath are correct — a token cost per day and a payback measured in days — but the two surfaces that speak them, the ceiling directive and the force directive, rendered them as `tok/session` and `session(s)` instead, silently contradicting `/coalwash:stats`'s own correct `fat/day` and `break-even days` labels on the identical numbers. At a realistic session rate the two doors could show figures five times too high or five times too low depending on which was read, and the wrong one is the one read to decide whether declining a wash is the economically correct call. Both directives now say `tok/day` and `day(s)`, matching the numbers underneath them.
* **A project config with no accompanying global config could disable the undo net for gitignored governance files, or turn on standing-consent self-update checks, with no protection at all.** The safety merge that is supposed to stop an untrusted cloned repo from escalating a consent-bearing setting only compared the project's value against an *explicitly written* global one — and skipped the comparison entirely whenever no global config existed, on the reasoning that an absent global meant "nothing to protect." That reasoning was backwards: the schema's declared default **is** the user's position until they say otherwise, and a user who has never written a global config — the ordinary case — is exactly the one with nothing standing between a cloned repository and the setting. This implementation was the one three other rooms in the series copied as their own reference shape today, so the same gap was present everywhere it was copied to. A missing global is now treated as the schema's own default when deciding whether a project's value is safe to honour: `writeGuard` (whose default is already the strictest setting) can no longer be weakened by a project alone under any circumstance, and `updateMode` can no longer be escalated to unattended checks the same way. Reaching either now requires an explicit global choice permitting it, which is what "configured system-wide" is supposed to mean.

## \[0.2.0-rc.5] - 2026-07-24

The class-B blind-IC field-lab batch — the shipped output of the 8-wave setter≠solver campaign (9-piece function lab + blind IC-pin surgery on the engine legs; every fix reproduced by a fresh blind verifier before landing, hermetic test + gate-liveness per fix).

### Security

* **\[CRITICAL] Forged-PLAN.json containment (apply RCE class).** `applyPlan` derived its containment trust anchor from the untrusted PLAN file — a forged plan could both name a target and supply the "containing" root that blessed it. The anchor now derives from the caller/cwd, never the plan, with a home-swallow guard (a root that swallows `~/.claude` whole is refused); rollback honesty preserved (a refused apply reports exactly what it did not do).

### Fixed

* **fidelity-gate: the version-superversion collapse** closed one-flock across all 4 version regexes (`VERSION_RE`, `V_SHORT`, `CLAIM`, `TA`) — a superstring version (`v1.2.30` over `v1.2.3`) no longer satisfies a shorter token's survival check — plus the frontmatter-key separator fix.
* **retier: anchor-survival rewritten** to re-extract survivors through the same `topAnchors` tokenizer that nominated them — compound/version/NBSP/astral/multi-word anchors can no longer strand silently by tokenization asymmetry (killed by construction, not per-case patching); the dig-row anchor cap raised to `TOP_ANCHOR_N`.

### Changed

* **quick: empty-table + emptied-heading auto-cut RETIRED to flag-only** (safety-over-yield, USER decision — the own-residue distinction stayed unstable across 6 blind IC waves; no auto-cut = no false-cut). `flagEmptyTables`/`flagEmptyHeadings` remain the surfaces; a human/wizard adjudicates. EOL (CRLF) preservation hardened on the rewrite path.

## \[0.2.0-rc.4] - 2026-07-17

Security + fidelity hardening from a nasa-L3 CoalBoard audit + a CoalMine Heavy scan (each finding reproduced before fixing, hermetic test + gate-liveness per fix).

### Security

* **\[CRITICAL] Poisoned-journal arbitrary file overwrite/delete (C1 + H1).** `recoverDangling` (apply.mjs) and `rollbackFromSnapshot` (retier.mjs) anchored their containment on the untrusted on-disk journal/manifest's OWN `roots` — circular: a malicious repo's journal supplied both the target and the "containing" roots, so the check always passed. Every restore/delete target now must sit inside a caller-TRUSTED root (`projectRoot ∪ ccMemoryDir`) that a poisoned journal cannot widen — closing the arbitrary-path escape AND its narrower `~/.claude` reach (a `settings.json` overwrite = hook/permission injection).
* **Fidelity gate is now sign-aware (H2).** The structured-number inventory dropped a leading `-`/`+`, so a sign-inverted fact (`-43%` ↔ `43%`, `-44,192` ↔ `44,192`) passed the "zero structured-token loss" gate. Polarity is now part of the token key.
* **Config kill-switch hardened (H5 + H6).** The safe-merge compare is case-folded (`"AUTO"`/`"Off"` can no longer re-enable a globally-`off` skill) and `writeGuard` joins the safe set; `readJsonc` now BOM-sniffs UTF-16 (a PowerShell-written UTF-16LE `.coalwash.json` no longer mojibakes a kill switch back to defaults).
* **Encoding tripwire now catches Trojan-Source bidi** — RLO/LRO/PDF/isolates + ZWJ + mid-string BOM (introduced-only).

### Added

* **Delete/merge fidelity union-gate (H3).** The mutation-boundary interlock ran on rewrites only; a delete (or merge = delete-src + rewrite-dst) silently dropped the removed file's structured tokens. A deleted file's tokens must now survive in the transaction or be in `approvedDrops`, else the apply blocks.
* **Durable ULTRA estate archive (H4).** The snapshot-less estate tier wrote its `.gz` non-durably then deleted the sole original; the `.gz` content + directory entry are now fsync'd before the delete (and estate-restore matches).

### Changed

* **SKILL.md + references/method.md translated to English** — the agent reads them at runtime and is English-native; the type-treatment operators are now discard/compress/shrink/skip (rail-preserving 1:1; the code already used English enum values).
* Docs: rc.3 state paths corrected (PRIVACY, stats), stale "P2 not built yet" line fixed, remaining Thai → English in public English surfaces.

## \[0.2.0-rc.3] - 2026-07-16

Estate-hardening batch on the 0.2.0 line — the class-A ULTRA/RE-TIER machinery gains two incident-mined loss-class guards (#57d, #58), a bounded per-run work-limit, a knee-grounded dig-gauge recalibration, and role-memory discovery. Still `-rc` (internally hardened, field-unproven). Every item ships with hermetic tests (564 → 576).

### Added

* **runBudget — a per-run work-limit on the ULTRA session loop (the one unbounded axis)** (`estate.runBudget` = `maxSessionsPerRun` def 25 · `maxBytesPerRun` def 500 MB). The loop STOPS at a completed session-unit boundary once either limit is reached — never mid-unit (each unit is an independent copy-verify-delete tx, so a stop leaves ZERO partial); the report says "archived N/M — run again for the rest" and a second run continues. RE-TIER carries no runBudget by construction (ONE atomic tx, not an incremental loop; its work is bounded by the wizard-gated store roster — the named divergence lives in `retier.mjs`). Senior: SQLite `incremental_vacuum` / an SSD's bounded-burst GC.
* **#57(d) cloud-placeholder read-poison guard** (MASTER-LOSS-TAXONOMY #57, 4th member) — before trusting a source read as ground truth for a copy-verify-then-delete (estate WARM path) or a rewrite (`applyPlan`), a shared `isCloudPlaceholder` metadata sniff (never a content read) fail-closes on a dehydrated OneDrive/iCloud stub (a `size>0`, `blocks===0` file that returns self-consistent stub bytes, blinding the external-writer guard). **Platform-calibrated:** on win32 the blocks sniff is a documented no-op — a legit NTFS sparse file shares the `size>0`/`blocks===0` stub fingerprint and the reparse attribute that distinguishes them is unexposed by Node `fs`, so a blocks-only sniff would over-refuse real sparse files (the earlier "`blocks===0` for every win32 file" rationale was version drift, corrected from field data — thanks @mehvetero, #8); win32 is a NAMED residual, upgrade path = a native reparse-attribute read at the two injectable call sites, CORRECT on POSIX where `blocks` is real.
* **#58 deletion-unaware time-travel-restore advisory** (MASTER-LOSS-TAXONOMY #58) — `estate-search`/`estate-restore` now cross-check recovered content against the tombstone registry (keeps.json anchors + the bins' death-log) and LABEL a result whose wording overlaps a gate-adjudicated cut ("⚠ later-removed? verify against the current live store"). Advisory only — recovery still works, the signal never blocks; opt-in (the CLI builds the registry, a direct caller passes it).
* **MANUAL-tier wizard rework** (backfilled 2026-07-24; shipped in this release, commit `941bf52`) — 2×2 entry + choice-4 agent tier + CoalFace hand-off + background handshake on the `/coalwash` MANUAL wizard.
* **Role-memory discovery (#22)** — `discoverClassB` now also returns `roleMemories`: the native-subagent `agent-memory/<role>/` stores (index + siblings), reported PER-STORE so gauge/wash/stats SEE them. Nested-habitat: a role store loads into a SUB when the role spawns, not the main every session, so it is a SEPARATE tier — never folded into the main's always-loaded footprint (the main gauge/BMI/force/break-even stay byte-identical with or without role dirs). Promotes RE-TIER's store enumeration up to the central discovery layer; unlocks (does not build) the docketed #55 cross-store detector.

### Changed

* **dig-gauge recalibration — knee-grounded, not %-of-window** (`estate.digCrush` defaults: singleFileTok 100000 → **35000** · pileTok 150000 → **58000** · fileCount 8 → **6**; all within the existing clamps, no schema change). The byte/4 `~est` under-counts a real Read \~1.7× (CC #20223 line-number overhead), and long-context degradation has an ABSOLUTE knee \~32-100k tok (NoLiMa/Chroma) a 1M-window model does not move — and CT can delegate a dig to a 200k worker, so the gate targets the SMALLEST fleet window. method §10a gains the free relative layer (weigh the verdict against the platform's own `<system_warning>` remaining-budget signal) + a Thai under-count flag.
* **dig-index record-weighting (#6)** — the searchable index (topEntities + firstUserLine) is now explicitly built from `text`/answer content-parts ONLY; a `thinking` part is de-weighted to zero (search noise), read by the content-part `type` stamp. Index/search quality only — treatment stays whole-file byte-identity.
* **externalize advisory → a hand-move TEMPLATE (#21)** — the FULL(externalize) advisory (`ask.mjs`) now renders the cluster → destination → pointer steps (CoalWash NEVER auto-moves muscle; the write-path airbag snapshots the hand-move; the CoalPortal memory→durable-file precedent). SKILL body gains the estate type-map rail (classify by structural stamp, unknown → skip+REPORT); method §10 gains the full stamp table + cross-platform richness note.

Tests 564 → 576 (+12: #57d ×4, #58 ×2, runBudget ×2, role-memory ×2, dig-index-weighting ×1, externalize-template ×1; dig-gauge threshold tests re-pinned to the new priors). SKILL body 19,409 → 19,613 ch (+204, the type-map rail; the ratchet holds under \~19,600). verify PASS, `plugin/` dist rebuilt. AUTO-tier semantics (quick/caliper/conductor/ask asks) unchanged except the dig-gauge config defaults.

## \[0.2.0-rc.2] - 2026-07-16

Continued internal wear-hardening on the 0.2.0 line (feature-frozen; still `-rc` = internally hardened, field-unproven). A standing mutation-test layer over every safety gate + a forward-looking platform gate on the estate/retier engine. No user-facing capability change.

### Added

* **Gate-liveness harness (`scripts/lib/gate-liveness.test.mjs`)** — mutation-tests all 8 safety gates. Per gate: the exact violation it guards is constructed and the REAL engine must BLOCK it (the DEAD-GATE SENTINEL — a gate whose disabling leaves the suite green is a finding, not papered over), THEN the gate's decisive check is neutralized test-side (a scoped `fs`/`Buffer`/`path`/`zlib` patch, or the gate's own sanctioned bypass) and the SAME violation must now land the loss (proves the gate — not something downstream — is load-bearing). Ports mehvetero/move-test-gen's L3 mutation idea back, and makes the GitLab-2017 / Pixar-1998 "the safety net was never exercised" lesson a STANDING test; automates the MED-1 dead-guard class by construction. Gates covered: fidelity · MOVE-VERIFY · verifySnapshot · copy-verify-then-delete · the delete-boundary TOCTOU re-verify (the `8b7fc71` fix) · containment · chJournalGuard · external-writer guard · #56 empty-only prune. **All 8 proved load-bearing — 0 dead gates.** Self-verified: sabotaging fidelity / the TOCTOU re-verify / chJournalGuard in the engine each flipped exactly its own harness test RED (the sentinels bite; no false-pass). Every neutralization is TEST-SIDE only — the production engine ships ZERO test-hooks; MOVE-VERIFY (pure in-function logic, no cleanly patchable primitive) is proven by a REAL-violation on the exported function (both arms), standing alone.
* **Platform-layout gate on the estate/retier engine (armor #2)** — `estateUltraScan` / `runEstate` / `retierScan` / `runRetier` now gate on `detectPlatform()` at entry (one-flock with `discoverClassB`, sharing a new `UNKNOWN_PLATFORM_FLAG` single-source-of-truth constant): a non-Claude-Code home is an explicit conservative no-op (nothing scanned, the verbatim conservative flag surfaced), never a CC-layout-assumed scan. Keyed on the real signal — creating `~/.claude` flips it back to scanning — not a hardcode.

### Fixed

* **Removed the `tamperForTests` fault-injection seam from production `retier.mjs`** (parameter + 3 call sites): a corruption-injection hook — even inert (null-default, CLI-unreachable) — must not ship in an engine that moves/deletes real bytes, and it was inconsistent with the other 7 gates (neutralized purely test-side). The seam's three tests were re-proven test-side: MOVE-VERIFY on the exported function (both arms), the external-writer rollback via the blessed `gzip` benign-injectable, and a `rollbackFromSnapshot` unit test; the two seam-ONLY integration paths (a corrupted `indexNew` / a post-apply anchor loss — un-producible with real data, since demotion moves whole lines verbatim and the strand-guard protects sole-home anchors) were dropped, their detection already unit-covered. Sweep confirmed: production `scripts/lib/*.mjs` now carry zero test-hooks.
* Two CodeQL alerts in `scripts/lib/retier.test.mjs` cleared: a `js/file-system-race` (a check-then-use TOCTOU — the wizard-only hooks grep now reads the file-type from one `readdirSync({ withFileTypes: true })`, no separate `statSync`) and an unused `collectStores` import.

Tests 544 → 554 (+9 gate-liveness, +2 platform-gate, +1 `rollbackFromSnapshot` unit; −2 seam-only integration tests retired with the `tamperForTests` seam). The **dry-run pre-apply armor was RECONSIDERED and SKIPPED**: the snapshot + whole-run rollback + the interleaved external-writer guard already guarantee no partial-survivor across all three destruction campaigns, and every validation gate (containment · sniff · pinned · keeps · fidelity) already fails BEFORE the snapshot (zero mutation, zero snapshot I/O). A dry-run would only make a doomed run — which the campaigns show essentially never happens — fail marginally cheaper, and cannot pre-check the external-writer RACE or an execute-time IO failure anyway. It buys efficiency, not safety (rollback already owns the safety outcome) → over-harden, deliberately not built (skill-authoring §2/§3). Wear round over the new code: DRY (the harness bites precisely, the platform gate keys on `detectPlatform` not a hardcode, no primitive patch leaks between tests). verify PASS, `plugin/` dist rebuilt.

## \[0.2.0-rc.1] - 2026-07-16

**MINOR** — three new class-A / class-B memory systems. The version leaves the 0.1.0 rc cycle (new capability = MINOR); it stays `-rc` because the new code is internally wear-hardened but field-unproven — a forward move to the 0.2 line, never a beta regression.

### Added

* **RE-TIER (wizard 4th door)** — merge every class-B store → redistribute by a ± envelope; overflow DEMOTES down the ladder (index line → `retier-overflow.md` → estate archive), lossless byte-identical, and NEVER summarizes or deletes under pressure (ทิ้ง/discard exists in no treatment-table cell). Two mechanisms separated by construction: the envelope (config `retier`, target 4125 tok = the cross-AI Tier-1 always-loaded median AND \~2% of the 200k binding envelope; an arm/disarm/headroom band, no flap) decides TIER PLACEMENT only; a CODE treatment table (governance/machine-parsed/unknown = skip-only, case-insensitive name identity) owns TREATMENTS. Gates reused: `applyPlan` tx (snapshot/rollback) + MOVE-VERIFY + #54 anchor-diff + #55 report-only cross-store reconcile + an N=20 top-anchor survival probe. Wizard-only; `retier-run` refuses under the arm line ("dead zone, no action"). CLI `retier-scan`/`retier-run`.
* **dig-gauge (ULTRA trigger #2)** — a pre-READ tollgate (`cli.mjs dig-gauge <paths>`): pure `fs.stat`, zero content into context, fired between a search's hit-list and the first Read. CRUSHING when a single candidate is >= 100k tok, or the pile >= 150k tok, or >= 8 files (config `estate.digCrush`, clamped priors from the 200k-envelope minimax) -> offer ULTRA once, never blocks (declining proceeds to the raw dig). Pre-read because context-token burn is multiplicative: re-carry every turn, per-prefix fan-out, compaction spiral.
* **Class-A / class-B fidelity-note split** — SKILL note 1 (structured-token fidelity) is now scoped CLASS-B only; a new note 3 states the class-A byte-identity contract (never semantic-edit a transcript; copy-verify-then-delete; the one seam where the class-B gate re-enters = RE-TIER's hot-index rewrite).
* **Loss class #56 (VERIFY-SCOPE / DELETE-SCOPE MISMATCH)** added to the master taxonomy — found + closed by the wear campaign.

### Fixed (wear campaign, 2 rounds, loop-until-dry)

* estate over-delete (#56, HIGH): `estate-archive.mjs` used `rm -rf <sid>/` gated only on enumerated-deletes-succeeded; now `pruneEmptyDirs` rmdir-if-empty (delete\_scope == verified\_set), un-enumerated survivors kept + surfaced.
* `classifyRetier` check-ordering so a governance/program `.md` sitting in a memory dir is skip-only, never demoted off the live tree — case-insensitive (Windows/macOS ship platforms) incl. cross-agent `gemini.md`.
* survival-probe false-pass under `indexEnabled:false` (a sole-home top-anchor is kept in the live tree) · pin no longer vetoes the whole multi-store plan (filtered pre-plan) · `dig-gauge intOr` re-clamps the schema bounds. Suite 490 -> 530.

## \[0.1.0-rc.3] - 2026-07-12

The well-behaved-OS-citizen relocation (series law: one namespace, no scatter). CoalWash's per-session state stops littering `~/.claude/`'s root as a scattered dotfile and moves into a namespaced, memory-anchored home — with a transparent one-time migration that deletes the old files. rc-line, PATCH-class: a defect fix (the OS-scatter mess), no new capability, migration is transparent (nothing a user relied on breaks). rc → stable resumes after this proves in the field with no further code change.

### Changed

* **State relocated to a memory-anchored, namespaced home.** Per-project session state moved from the scattered `~/.claude/.coalwash-state.json` to `~/.claude/projects/<slug>/coalwash/state.json` (riding CC's own project directory — a project dir cleaned by CC free-prunes the state with it), and the global update-check stamp to `~/.claude/coal/coalwash/`. The path DERIVES from the platform's real memory dir (class-b's `ccMemoryDir`/`ccProjectSlug`), never a fresh hardcode — an unknown platform leaves the gauge inert, writing no state. Every derived per-project path is realpath-contained to `~/.claude` and fails closed to `~/.claude/coal/coalwash/`.
* **Transparent one-time migration (the no-old-version-leftover standard, extended to the state layer).** On first read state loads from the new path, else the old-root file (LOCATION fallback); on first write the new file is written AND this project's old-root entry is deleted, the legacy file drained + removed once empty. Touches ONLY CoalWash's own prior state files, never a wildcard sweep. An rc.2-era store is preserved across the pure move (no spurious schema reset). The user config `~/.claude/.coalwash.json` is never touched (different class). A reinstall-then-reinstall no longer strands stale state.

### Fixed

* SECURITY.md path-containment wording no longer over-generalizes — the per-project path is realpath-contained + fail-closed; the fixed global stamp path is a construct-under-base with no untrusted input in it (two different, both-correct guarantees stated as one before). Corrected a `pruneDeadEntries` comment: keep-on-doubt is narrow (only a *throwing* stat keeps an entry; `existsSync` reads an offline/UNC path as absent, so its LEGACY entry can drain — safe, it is the old file's own recomputable bookkeeping, never memory content, and a wrongly-drained project re-stamps a provisional floor next session).

Tests 448 → 452 (+10 OS-citizen: memory-anchor derivation, containment escape → fail-closed fallback, migration read/write/delete, config-untouched, cross-version un-strand; −6 obsolete shared-map orphan-prune). Review lane: SHIP (0 CRITICAL/HIGH/MEDIUM — every delete is a fixed-path, own-file, lazy-on-write removal inside the sandbox; the schema reset preserves the lean-floor baseline; containment fails closed). verify PASS, `plugin/` dist byte-identical.

## \[0.1.0-rc.2] - 2026-07-12

The first dogfood FIELD bugfix (rc = internal-proven; real use surfaced it). A chronically-FULL store that consumed its crossing — especially one carried across the pre-0m→0m upgrade — went permanently silent: the conductor could never re-arm, so the skill looked dead on a still-fat store. Fixed, plus the state layer joined the no-old-version-leftover guarantee. Bugfix only, no new capability.

### Fixed

* **The stranded-crossing un-strand (session-id + growth re-arm).** A consumed FULL crossing now re-arms when either (a) a NEW session opens on a still-FULL store, or (b) fat grows past the last-flagged level MID-session (the Stop-hook warp-gate already re-gauges every turn) — so a dragged single session re-offers on real growth, not only on a fresh session. The re-arm was gated to FULL only (an earlier cut over-reached to OBESE, which re-fired every session on a flat plateau — caught in review, fixed).
* **Force-then-ask on every growth (USER decision).** Each fat-growth past the flagged level runs the FREE mechanical Quick force FIRST (code, \~0 token — sweeps the certainty), THEN the wizard ask only if judgment-fat remains — never ask-only, never a throttle. Frequency mirrors the fat-growth rate: rapid growth → rapid force+ask, plateau → silent (the no-nag law: silence is "fat didn't move," never a timer).
* **State schema-version guard (no version-stale state).** The state file stamps a `stateSchema`; reading state written by an older schema migrates it — resetting only the version-SENSITIVE fields whose semantics a ruling changed (the crossing family), PRESERVING the version-STABLE baseline (the lean floor). This is the series' "self-update: no-old-version-leftover" standard extended to the state layer, so a reinstall/upgrade never carries stale crossing state (it un-strands the live pre-0m store on first read). The lean floor is the skill's "electricity" — never reset, or every upgrade would false-FULL until the next clean.

Tests 427 → 448 (session-id re-arm, schema migration, the FORCE→ASK→FORCE→ASK dictator alternation through the real hook, OBESE-plateau silence, force-never-starved / ask-never-starved). Review: SHIP (sequencer traced to a total, mutually-exclusive, non-starving state machine; one LOW = a recurring externalize reminder, safe-by-construction, comment corrected).

## \[0.1.0-rc.1] - 2026-07-11

**Feature freeze.** The 0f–0p ruling wave is complete and internally proven — 53/53 lab loss-classes covered, 0/33 traps leaked, 427 hermetic tests, an adversarial review SHIP on every wave. That earns **rc**: the structure is durable (the safety floor is code-held, model-independent). It is NOT yet "live" — rc is internal-proven awaiting **field** proof (real global Claude Code use surfaces the operator-drift / last-hop-visibility class the lab can't simulate). rc → stable = a real dogfood stretch with no code change; a field bug → rc.2 (bugfix only, no features).

This release carries the two no-feature cleanups that close the beta line:

### Changed

* **SKILL.md leaned −46% (26.4K → 14.1K chars body)** per the new series law `skill-authoring.md §5` ("lean cuts TOKENS never the RAILS"): all 17 behavior-forcing rails stay in the always-resident body; the explanation (band-math derivation, analogies, worked examples, install onboarding) moved to `references/method.md` (on-demand) or the README. Verified rail-identical by an independent 5-step scenario trace, not by eye — the proof gate §5 mandates.

### Fixed

* Removed two CodeQL-flagged unused imports (`path` in `cli.mjs`, `readSnapshot` in `writeguard.test.mjs` — `js/unused-local-variable`, note-level); clears the repo's open code-scanning alerts.

## \[0.1.0-beta.19] - 2026-07-11

The write-path guard (ruling 0p — the last feature before rc): the fidelity discipline stops being CoalWash-only. Every hand that writes a class-B file — the main, a subagent, any tool — is now watched, because "zero fact loss" enforced only on CoalWash's own knife is half a constraint (the store is edited by other knives daily; four header-clobbers this session were live proof).

### Added

* **Airbag — snapshot-on-first-write (`scripts/lib/writeguard.mjs`, PreToolUse on `Edit|Write|MultiEdit`):** the first write to a class-B file each session copies it once into the `.claude/coalwash/` sandbox (self-ignored, out of VCS) — the only possible undo net for gitignored governance (MEMORY.md/CLAUDE.md have none). Write-only, idempotent, fail-silent; session-event-gated cleanup (keep current, drop prior — never a clock, 0h-GUARD).
* **Seatbelt — advisory drop-detector (PostToolUse on `Edit|Write|MultiEdit`):** on a structured-token drop (link/number/quote/frontmatter — the fidelity-gate classes) it injects ONE plain advisory line naming the file + the dropped class + the snapshot path. **Advisory ONLY — never blocks, never a nonzero exit** (a deliberate deletion is legitimate; an ambient gate has no approved-drops channel, so blocking would sabotage real work). Reaches subagents natively (tool hooks fire in subs). FP scoping is honest option (ii): it fires on ANY drop with no deliberate-vs-careless classifier — an FP costs one ignorable FYI line, and the snapshot pointer turns every fire into a usable undo hint.
* **Recovery by reference (`cli.mjs writeguard-list` / `writeguard-restore`):** an agent points at which snapshot by metadata (name/session/bytes/path — never the content); CODE copies the byte-exact original to stdout (`isBareId`-contained). NO path where an AI re-authors lost content — an AI-retyped "recovery" is a hallucination-twin (lab-caught: ADD-01), a fake that looks like the original. Undo is trustworthy precisely because the bytes are the real bytes, code-moved, model-untouched.
* **Config `writeGuard`:** `on` (default) / `snapshot-only` (keep the airbag undo net, silence the advisory) / `off`.

### Changed

* Series law added — `skill-authoring.md §5` "lean cuts TOKENS never the RAILS": a SKILL.md line is a rail (forces behavior) or an explanation (moves to references); a lean pass proves rail-identical behavior by re-running the scenario, not by eye. (Our production standard for Coal\* skills, not a yardstick for others'.)

Tests 397 → 427 (writeguard unit 17 · conductor/cli/ask/config spawn tests 13 — incl. the FP-lab pins, the containment/traversal rejections, and the structural no-copy-on-non-guarded perf proof). Review: SHIP (3 INFO, all non-blocking: a named import divergence, the accepted 0o-class hook cost, the gitignored blueprint). CI-verified under a simulated tracked-files-only checkout.

## \[0.1.0-beta.18] - 2026-07-11

The parcel-audit layer (ruling 0l — the immortal-bird definition): CoalWash keeps NO hand-maintained list of what counts as always-loaded memory; its list is a MIRROR of the real load list, whoever writes to it (the company auto-loads it, the user wires it, or a future platform update adds a surface — it enters by definition, no code change; a hand-kept list rots, a mirror can't).

### Added

* **L2 parcel audit (`scripts/lib/parcel.mjs`, read-only):** `verifyParcelCandidates` — an agent lists the files it can SEE auto-loaded in its own context (path + the head it observed); CODE certifies each one — realpath-resolves + contained in the home/project trees (fail-closed on symlink escape, both sides) + the on-disk head matches the observed sample (whitespace-normalized, a 24-char substance floor + a tiny-file whole-content carve). A hallucinated or never-loaded candidate can't quote a matching head, so it's rejected; fail direction is undercount (unseen = unmeasured = uncut). `compareParcelToAdapter` cross-checks the parcel against the L1 adapter's every-session set as a **drift canary** — a mismatch flags a new platform surface or adapter rot the day it happens.
* **L1 stays the every-session path (0-token); L2 is a wizard-entry / on-demand agent tool** — the module boundary is the two-layer architecture. On an unknown platform the conservative path upgrades from "verify scope manually" to propose → code-verify → human-confirm, still never auto-delete.
* **Capture-all → filter → wash order preserved:** L2 feeds MEASUREMENT only — it never feeds the knife (wash jurisdiction unchanged).

### Changed

* Durability-campaign benchmark records published (org `.github/benchmarks/CoalWash/`): the campaign-close summary (53/53 loss classes covered · 0/33 traps leaked · 33/33 Thai tripwires · 10/10 workability parity · the three standing cautions) and the model-independent framing. README Notes 1+2 reframed to the measured protocol (stop at two dry rounds + one varied-angle sweep) and the honest per-tier-matrix-pending scope; the safety-floor-is-code-held claim is the published takeaway. Trap corpus + per-trap identities withheld by design.

Tests 387 → 397 (parcel verifier: legit round-trip, head-mismatch/mid-quote/traversal/junction-escape rejected fail-closed, read-only full-tree proof, compare partitions, recall-excluded). Review: SHIP (one INFO = the already-recorded evidence-of-existence-not-proof-of-load limit; blast radius measurement-only, never the knife).

## \[0.1.0-beta.17] - 2026-07-11

The true-bill spawn meter (ruling 0o-b — the user-found fleet-economics blind spot): every subagent spawned from a room re-pays that room's always-loaded parcel in full (per-prefix cache — a sub cannot share main's warm cache), so the real cost of fat is footprint × (main + every spawn). Measured live: one fat room × \~6 sub-rounds ≈ 460k tok of pure parcel in a single dev day, invisible to every meter before this.

### Added

* **`PostToolUse` spawn meter (`hooks/hooks.json` + the conductor):** matcher `Agent|Task|Workflow` (CC exact-list semantics — `Task` does not match `TaskCreate`; grounded against the live hooks docs + CoalMine's shipped matcher shape) with a pre-import first-line belt for matcher-less platforms. On each completed spawn: silently add the room's **cached** parcel figure (no re-gauge, no content I/O; missing cache = count at cost 0) to session-scoped counters in the existing state entry. **Write-only — no per-spawn output, ever** (the NOISE RULE: N spawns = N silent increments, one louder number in the same one voice). Auto mode only (manual/off = meter off — no session boundary exists there to keep the figure honest); counters reset at the once-per-session gauge heartbeat.
* **The bill surfaces through existing voices only:** `/coalwash:stats` gains "subs this session: N spawns ≈ X tok parcel" (omitted at zero) and the FULL force/escalation directives gain one clause — "This fat also rode N sub spawn(s) ≈ X tok of parcel (\~est) this session" — only when N > 0.
* Nested spawns count by construction (tool-level hooks follow a sub's own tool calls; a flattened deep spawn becomes a fresh session where the gauge itself boots). Cross-room spawns bill the current room's cached parcel — a named conservative approximation.

Tests 377 → 387 (noise pins assert empty stdout AND stderr; non-spawn tools proven to create no state; manual-mode-off; the full gauge→2-spawns→directive-clause→session-reset round trip). Review: SHIP (matcher semantics grounded; contention analysis: the only losable write is one counter increment — cosmetic; class-B content untouched by construction).

## \[0.1.0-beta.16] - 2026-07-11

Force restored as the free dictator tier (ruling 0m — user-caught live on the day-one store: the heavier band did LESS than the lighter one). The misapplied economic-proof gate is gone from the force leg; the proof requirement was always the PAID wizard's, never the free Quick's.

### Changed

* **Every FULL crossing (economic AND absolute-cap) force-runs the free mechanical Quick UNCONDITIONALLY** (`hooks/coalwash-conductor.js`): the day-one over-wall store now gets the ruled sequence — silent forced Quick (receipt numbers after) → shrunk below FULL/LEAN = silence → still over = the ONE wizard-escalation ask. `externalize` stays pure advisory (washing cannot shrink muscle). OBESE auto-Quick unchanged.
* **First-ask exemption (`caliper.mjs` recordCrossing):** the first wizard escalation of an episode arms on `quickTried` alone — required because a provisional-floor store has measured fat ≡ 0; the no-nag law still guards every RE-ask (re-arm only on fat growth past the last flagged level; a plateau never re-asks; a LEAN reset opens a new episode).
* **`forceAuto` directive headline** now renders the absolute-cap case honestly — "over the capacity wall (store \~X tok vs the \~Y tok wall)" — never a misleading "fat \~0" on a day-one store; the economic break-even headline is unchanged; force text states it is non-optional at FULL (the OS-maintenance model).

### Removed

* **The `forceMode` knob — force has NO off switch (user ruling: "วินโดว์ไม่เคยมีให้ปิด force ได้ และ force นี้ต้องเผด็จการเท่ากัน").** Key retired from the schema (`RETIRED_KEYS` tombstone: a legacy config carrying it validates clean and reads as nothing); factory template + all docs swept (README gained the "No force off switch — by design" callout). Consent lives in UNDO (verified snapshot · whole-run rollback · bins + `restore <id>`), and the receipt is FULL's surfacing — `coalwashMode: off` remains the skill's whole power switch.
* `ceilingAsk` (its last caller died with the knob) and `sanitizeVerdict`/`VERDICT_MAX_AGE_MS` (consumer-less; verdict numbers are re-recorded every SessionStart, so the force directive can never render stale figures) — deleted with their tests, no leftovers.

Tests 380 → 377 (14 deleted with dead code, 11 added — incl. the six-invocation day-one round-trip reproducing the live scenario end-to-end, the legacy `forceMode:"off"`-still-forces proof, and the plateau/no-re-nag pins). Review: FIX-FIRST (2 stale doc lines) → closed.

## \[0.1.0-beta.15] - 2026-07-11

### Fixed

* **CI determinism — the warp-hole perf test rewritten structural (`caliper.test.mjs`):** the old "PERF GATE" gauged the LIVE repo as its fixture and asserted a wall-clock ≥3× ratio — green on the dev box (fat gitignored store), deterministically red on every CI checkout (those files don't exist there; failed all 12 matrix legs across beta.13/beta.14, unnoticed at beta.13). Now the **STRUCTURAL GATE**: a hermetic sandbox fixture + an instrumented `fs.readFileSync` proving `statOnlyFootprintBytes` opens ZERO file content (byte-correct from stats alone) while the full re-gauge on the same fixture does read content — the design claim, machine-independent, no clock. The measured dev-box numbers stay recorded as engineering data in the section comment. Verified green under a simulated CI checkout (tracked-files-only copy). No shipped-behavior change; test count 380 unchanged.

## \[0.1.0-beta.14] - 2026-07-11

The authoritative 3-flow + the economics: the wizard ask moves to its ONE ruled site (FULL, after the forced Quick proves insufficient — OBESE never asks), FULL becomes the economic cut-point on top of the armed ceiling, the bins finally get fed and kept on a dual limit, and BMI is live from the moment of install. Reviewed FIX-FIRST → all findings closed. Engine tests 337 → 380.

### Added

* **Economic FULL (0g):** FULL = `breakEven.economical` on floor-relative fat, on top of the armed OBESE ceiling — `FULL ⊂ OBESE` (a tiny-fat store can never jump LEAN→FULL), latched per episode (LEAN reset clears), and the **force authorization always demands a FRESH economical proof** (numbers shown every fire — the economic-dominance clause is the band now). The fixed capacity line is demoted to the outer WALL: bootstrap `absolute-cap` (no floor yet) / `externalize` (\~all muscle).
* **Bin population (0h):** every landed cut is recorded post-COMMIT into a bin, routed by the plan's `origin` (`program-cut` default → fat bin · `wizard-cut` → `store.old`). Bin sweeps run ONLY from inside `applyPlan` — never a hook, cron, or session-event age-sweep (0h-GUARD: idle days destroy nothing).
* **Dual-limit retention (0i):** SIZE-CAP (`BIN_BUDGET_STORE_MULTIPLE` 2× the store's own measured bytes — never the disk) ∧ TIME-HORIZON, whichever binds first (the journald model); era-preserving thinning first, hard cap second; the newest item always survives; doubt/weightless items are never size-evicted.
* **BMI on at install — provisional floor (0j):** the first gauge of a never-seen store stamps a provisional floor = the current footprint → BMI = 1.00 live from day one, no switch command (Single Power Button); the provisional floor never self-ratchets; the first gate-passed Full clean overwrites it (flag cleared); `capHit` + provisional → `absolute-cap`, never `externalize` (a provisional baseline cannot certify all-muscle).
* **`restore <id>` CLI subcommand (`scripts/lib/cli.mjs`):** the promised 0-token recovery door — stdout = the recovered content (redirect `> file` to keep the bytes out of any context window), stderr = ONE summary line; unknown id → exit 1 naming both bins searched; read-only, never writes to the store.

### Changed

* **Wizard escalation relocated OBESE → FULL-after-force (0f, the authoritative 3-flow):** the ONE wizard ask now lives at FULL after the forced Quick already ran this episode and the store is still over; it is checked BEFORE the force crossing (kills the silent force-loop). OBESE is auto-Quick-silent, full stop.
* **`exercisePerBand.obese` clamped to `quick`:** the schema is per-band now (`obese: ['quick']`); a legacy `obese: "full"` config reads as `quick` silently (safer-value-wins) without clobbering a valid `full:` customization. `ceilingAsk` remains solely the FULL forceMode-`ask`/`off` leg.
* Docs resynced: the stale "no dedicated restore CLI yet" claim removed from SKILL/method (the CLI is real now); blueprint §18 gains the 0j clause.

### Security

* **Bin id containment (`isBareId`, `bins.mjs`):** `restoreFromBin` rejects any non-bare id (`../x`, `..\x`, absolute, `.`, `..` → not-found) and `loadIndex` filters the same shape — so a traversal id can no longer read outside the bin dir, and a poisoned `index.json` can no longer surface or sweep files outside it (the recovery-path class: same family as beta.2's `recoverDangling` fix). Regression-tested with a planted outside-the-bin victim proven untouched.

## \[0.1.0-beta.13] - 2026-07-11

The lifecycle autopilot: the code tier now sweeps structural fat on its own (Storage-Sense shape — act + one-line report, never a per-run ask), the wizard ask survives only for semantic judgment and re-arms only on fat GROWTH, and a within-session spike is caught at `Stop` through a measured perf gate. Engine tests 302 → 337.

### Added

* **OBESE auto-Quick, no ask (`ask.obeseAutoQuick`):** an OBESE crossing whose configured exercise is `quick` (the factory default) skips the blocking ask and fires a standing-consent auto-run directive — `oneLineResult`-only output, snapshot-backed, revertible. `exercisePerBand.obese: "full"` routes back through the real ask. Consent is standing via config (the `forceMode: auto` / rot-canary `autoFixMode` precedent).
* **The OBESE loop (`ask.wizardEscalation` + `caliper.markQuickTried`/`lastEscalationFat`):** once Quick can no longer reduce fat and OBESE persists, ONE wizard-escalation ask arms — and re-arms ONLY when fat grows past the level last flagged, never on a plateau, never on a timer (ask frequency tracks the fat-growth rate; the BMI edge is the sole gate). The FULL force-run backstop needs no user at all.
* **Warp-hole Stop gate (`caliper.statOnlyFootprintBytes` + `REGAUGE_DELTA_TOKENS`):** every `Stop` runs a stat-only footprint delta (measured \~0.2ms — no directory walk, no content read); only real drift past the threshold triggers the full re-gauge (measured \~7-18ms — over the ≤5ms happy-path budget, hence gated, decided by measurement). A within-session spike is now caught same-turn.
* **Shrink as a first-class wizard outcome (docs + tests):** the outsider runs ONE question — "how much of this is enough to keep?" — with three outcomes: delete / shrink / stand. A shrink (right-sizing an over-verbose muscle: wording down, fact verbatim) is mechanically a `rewrite` under the existing 0-fact-loss gate (proven by regression tests; no new gate class). The merge before/after claim-strength diff instruction now covers shrink.

### Changed

* README/SKILL/method resynced to the autopilot flow (band table, Stop-gate paragraph, wizard 3-outcome). SKILL description trimmed back under the 1024-char cross-platform cap (1019).

### Lab receipts (this release's ship conditions)

Auto-Quick trap-regression (unsupervised, code-only, seeded structural fat among 33 engineered traps): 5/5 seeds cut · pre-existing empty heading correctly survived (flag-only) · **33/33 traps intact · 9/9 semantic decoys untouched**. Stop-gate perf pinned by measurement (0.13-0.32ms stat-only vs 6.6-17.9ms full).

Tests 302 → 337.

## \[0.1.0-beta.12] - 2026-07-11

The durability build (phases 1+2 of the 1e-16 ladder), verified by a full lab campaign: all 53 loss classes tested (26 measured in the wear campaign, 27 adversarial-verified through this pipeline) — 0/33 engineered traps flagged-or-cut, 10/10 washed-vs-pristine workability parity. The claim is a STRUCTURE that refuses load-bearing loss even against an adversarial corpus, not model-infallibility. Engine tests 195 → 302.

### Added

* **Band-collapse (`caliper.mjs`):** the 4-band PLUMP/OBESE/FULL ladder + time-snooze collapse to ONE hysteresis-gated ceiling — `CEILING_BMI` 1.5 arms, `CEILING_REARM_BMI` 1.2 re-arms (a Schmitt trigger replaces the clock) — plus a SEPARATE stateless machine-capacity FULL line (`absolute-cap`, person-independent, needs no floor). Growable-full invariant preserved (BMI = ratio; floor ratchets only on a gate-passed clean; capacity gate person-independent, remedy = externalize). `FLOOR_MIN_TOKENS` floor-sanity.
* **Template asks (`ask.mjs`, program-side, zero agent composition):** `ceilingAsk` / `forceAuto` / `externalizeAdvisory`. Answer-first — SessionStart is silent for band matters, Stop is the sole ask surface; every template embeds the answer-first reminder; break-even payback numbers on BOTH OBESE and FULL asks.
* **Bins (`bins.mjs` + `retention.mjs` policy):** two bins (fat / wizard-muscle) + `store.old` pull-only + breadcrumbs + Time-Machine density-thinning + death-certificate destruction, wired into the apply preflight. (Population at cut sites is not wired yet — the restore surface is complete; documented, not overclaimed.)
* **Quick-ceiling (`quick.mjs`):** `sweepResidue` (own-knife blast-zone only — kills the class-23 residue a prior cut leaves) + `stripEmptyTables` + `flagEmptyHeadings` (flag-only). Mechanical share measured 0% historically (Quick never shipped executable before).
* **Fidelity-gate classes 9 (number-precision) + 10 (evidence-anchor)** and the **keeps-gate** (pre-mutation exclusion) from phase 1.
* **Wizard primitives (`wizard.mjs`):** `neutralScan` (measurement-only, no BMI at entry) + `estimateBill` (banded, placeholder rates labeled). Managed-artifact tagging (`class-b.mjs`): byte-identical-across-roots + `managedPaths` config.

### Changed

* Docs (README/SKILL/stats) resynced to the collapsed band model. The kernel-scope note (README CAUTION + SKILL hard-rule) — high stakes, capped blast radius.

Tests 195 → 302.

## \[0.1.0-beta.11] - 2026-07-10

The knife move: removes the last human pre-approval step from delete/merge authorization. Safety was never resting on that flag alone — it now rests entirely on UNDO. Engine tests 194 → 195.

### Changed

* **Delete/merge authorization is plan-sourced, not human-approved.** A delete or merge action reaching `apply.mjs` is authorized by its presence in the adjudicated plan (the insider-adjudication step already decided it) — there is no separate approval flag to set or check. Safety relocates to UNDO: every apply still snapshots (verified at creation) before the first mutation, and a whole-run rollback (kept 3 snapshots) restores everything on any failure — unchanged since beta.2, now the ONLY safety net for a cut instead of one of two. **The ruling behind the move:** per-name OK-pressing over a list of unread filenames is ceremony, not judgment — a human cannot meaningfully vet memory content by filename alone, so the old "approval" was really the human rubber-stamping the machine's own adjudication. The Windows maintenance model this series ports (Disk Cleanup, Storage Sense, defrag) never asks per-file either — it shows a number ("clean 4.2GB?") and the system is trusted; CoalWash now matches that shape exactly instead of a looser approximation of it.
* **`hooks/coalwash-conductor.js`'s Stop-hook strings and SessionStart advisories, `skills/coalwash/SKILL.md`'s Hard Rules, and `references/method.md`** reworded throughout from "human-gated"/"deletes require approval" to "every cut is snapshot-backed and revertible" — no gate behavior changed by this pass, only the language describing where the gate lives.

### Removed

* **The `deletesApproved` plan flag and its refusal check in `apply.mjs`.** There is no field left to set; a delete/merge present in `actions[]` is self-authorizing by construction, since it could only arrive there via the adjudicated plan.

### Still in force (unchanged by this release)

The fidelity gate's no-silent-drop interlock (any structured-token drop still blocks the apply unless the plan names that exact drop in `approvedDrops` — a different, still-live mechanism from the removed `deletesApproved`) · `pinned: true` refusal · realpath-and-contain containment on both sides · the external-writer (R1) abort-and-rollback · the snapshot + WAL journal + whole-run rollback. The human's job stays exactly 2 presses: run consent, and ทำ/later at a band-ceiling crossing — never a per-item review.

Tests 194 → 195.

## \[0.1.0-beta.10] - 2026-07-10

Moves CoalWash off the advisory request channel entirely and onto the Stop hook's blocking enforcement channel — the same mechanism `rot-canary` already proves daily on this machine. Engine tests 171 → 193.

### Changed

* **ROUND 4 POSTMORTEM: the advisory channel itself was the last-hop failure.** A live transcript showed the SessionStart directive AND the beta.9 per-turn bar BOTH delivered to a sonnet-tier main session — delivery proven twice — yet the agent served a greeting and ignored both. Root cause: `UserPromptSubmit` context is a REQUEST channel — advisory, and an agent (especially a weaker tier on a no-tool turn) is free to ignore it. `rot-canary`'s `Stop` hook lands every time on this same machine because Stop has BLOCKING semantics (the harness holds the stop until the reason is addressed) plus question-box form (a human presses a button — no model-discipline dependence). CoalWash now rides that exact mechanism: SessionStart stays the silent measurement chokepoint; Stop is the one and only place anything gets surfaced or authorized.

### Removed

* **The beta.8/9 per-turn `UserPromptSubmit` bar** — the `hooks.json` registration and its conductor branch. Superseded same-day by the round-4 live-test evidence above; retired outright, not throttled further.

### Added

* **`Stop` hook — the enforcement branch** in `hooks/coalwash-conductor.js`: a structured `{decision: 'block', reason}` JSON write (the CoalMine `rot-canary` exemplar), not plain `console.log` — that structure is what makes Claude Code hold the stop and hand `reason` back to the agent as something it must address, instead of a passive context line it was always free to ignore. `stop_hook_active` is checked first, same as `rot-canary`, so CC re-invoking Stop after the agent responds can never loop.
* **Once-per-crossing edge semantics** (`caliper.mjs`: `recordCrossing` / `sanitizeCrossing` / `consumeCrossing`, `BAND_RANK`). A band RISE (new rank above the previous one) arms exactly one pending crossing at SessionStart; a fall to LEAN clears it outright; a same-or-falling band leaves an existing pending crossing untouched (two SessionStarts at the same band are one crossing, not two). The Stop hook consumes a crossing the instant it surfaces it — ask or force — never on a later "the user picked X" signal, since no CLI exists for the agent to report that back. An ask fires once per crossing; picking "later" dismisses it until the next rise. There is no snooze in the Stop path — SessionStart's existing `setSnooze` remains its own separate self-throttle for the PLUMP/OBESE gauge nudges, unchanged by this release.
* **`ทำ`/`later` two-button Stop-hook ask** for a PLUMP/OBESE crossing, or a FULL crossing whose auto-run authorization is suppressed: names the crossing band, the fat estimate, and the `exercisePerBand`-configured exercise for that ceiling; "later" defers to the next crossing — never silently forever.
* **`forceMode` config key** (`auto` | `ask` | `off`, default `auto`) — governs only a FULL+economical crossing at Stop. `auto` = standing-consent auto-run of the free mechanical Quick pass (the `rot-canary` `autoFixMode` model): numbers still shown every fire, every DELETE/MERGE still waits at the human gate. `ask`/`off` both degrade to the same ทำ/later ask as any other ceiling — FULL awareness is never suppressed, only the auto-run authorization.
* **`exercisePerBand` config key** (`{plump, obese, full}`, each `quick` | `full`, default `{plump: quick, obese: full, full: full}`) — the exercise the Stop-hook ask offers per ceiling.
* Tests 171 → 193: `caliper.test.mjs` gains the edge-crossing coverage; `conductor.test.mjs` gains hermetic spawn tests for the new Stop branch (asserting the structured block-decision output, the `stop_hook_active` loop guard, and the once-per-crossing consume behavior).

## \[0.1.0-beta.9] - 2026-07-10

Hotfix to beta.8's per-turn FULL bar — one directive string in `hooks/coalwash-conductor.js`, no engine changes.

### Fixed

* **The bar's blanket sibling-yield clause was a structural mute, not a graceful defer.** A live round-3 test proved delivery in-transcript (the SessionStart directive and the per-turn bar both fired as designed), but CoalTipple fires every turn by design and CoalBoard fires on every Thai-script prompt — so the clause's "yield when a sibling advisory fires" condition was true on every turn for a Thai-typing user, permanently muting the bar even though the agent obeyed the shipped contract exactly. Fixed: maintenance now yields to the user's actual ACTIVITY, never to a sibling advisory's mere presence — the background spawn IS the complete yield; the one carve is CoalBoard actually **convening** this turn (its consent question-box going up), which defers the spawn one turn at zero cost (the bar repeats next turn regardless).

## \[0.1.0-beta.8] - 2026-07-10

Reverses beta.7's `Notification`-event OS announce on its own lab measurement (a 142-transcript sweep of this machine found the event never fires here), replacing it with the blueprint's original answer: a persistent per-turn FULL directive that re-injects on `UserPromptSubmit` until the store is cleaned. Engine tests 164 → 171.

### Removed

* **The beta.7 `Notification`-event OS announce.** Lab-measured dead on this machine: a fresh dogfood session confirmed the FULL branch's session-scoped marker was written, but a 142-transcript sweep found **zero `Notification` hookEvents ever fired here** — the CC mechanism is real per its docs, it simply has no surface on the desktop app this machine runs (docs-true, fires-never — the platform-churns lesson in a new coat, not a bug in the mechanism itself). Removed: the `hooks.json` `Notification` registration, the marker write/consume path, the `handleNotification` handler, and the OSC-777 `terminalSequence` emission.

### Added

* **Persistent per-turn FULL bar on `UserPromptSubmit`.** SessionStart now unconditionally caches its computed verdict (`recordVerdict` — runs on every band, so a LEAN result immediately overwrites a stale cached FULL) instead of relying on the removed one-shot side-channel. A new `UserPromptSubmit` branch reads that cache (`sanitizeVerdict` — hot path: no discovery, no `measureEntries`, a single state read) and re-injects the FULL standing directive every turn while the store is FULL + economical + the cached verdict is fresher than 24h (`VERDICT_MAX_AGE_MS`). Same plain-stdout context-injection channel the shipped CoalBoard/CoalTipple conductors already use on this event — CoalWash joins it, not a new delivery mechanism. The directive tells the agent to SPAWN the free mechanical Quick pass as a background subagent (never inline-before-the-task) and to yield silently — no surfaced "conflict" — whenever a CoalBoard or CoalTipple conductor directive also fires the same turn (CB > CT > CW, the bottom rung of the shipped arbitration frame); yielding costs nothing because the bar repeats next turn for free. Honest ceiling: flipping `coalwashMode` off/manual mid-session can leave one stale nag firing for up to 24h until the next SessionStart re-stamps and corrects it — silence is the fail-safe side of the guard, never a stuck-on nag (`sanitizeVerdict` collapses a malformed, stale, or future-clock cached verdict to null).
* **`caliper.mjs`: `recordVerdict` / `sanitizeVerdict` / `VERDICT_MAX_AGE_MS`** — the cache-write and cache-read halves of the per-turn bar above, plus its 24h staleness bound. Tests 164 → 171.

### Changed

* **Doc sweep: every README/reference claim naming the removed `Notification`/OS-announce channel realigned** to the per-turn bar (README's Compatibility section, `platform-cc.md`'s conductor wiring line, and the SECURITY.md/CONTRIBUTING.md hook descriptions — `hooks/coalwash-conductor.js` now branches on two registered events, SessionStart and UserPromptSubmit, not one).

## \[0.1.0-beta.7] - 2026-07-09

Fifth same-day hardening pass: the growable-full band fix the beta.6 live dogfood run surfaced within the hour (a freshly-cleaned, all-muscle store landed FULL on the old flat absolute-cap instead of LEAN), a user-visible channel for the FULL force-run announce (closing the last-hop visibility gap the same live test exposed — the conductor injected the announce correctly, but the receiving agent never surfaced it), the engine primitives for a global-scope lock/keeps pair on shared governance files (contract wiring follows), and the outer-only human-gate + headroom-quiet reconcile from the same-day design pass. Engine tests 148 → 164.

### Added

* **Growable-full band verdict.** Once a lean floor is stamped, FULL's soft trigger is `leanFloor + fatBudget` (a fixed allowance above the measured floor) instead of a flat capacity percentage — the ceiling now rises WITH legitimate muscle growth, so a gate-passed clean never leaves an all-muscle store stuck FULL. Before any floor is stamped (bootstrap — a store's first run), FULL keeps the absolute-cap heuristic as an upper-bound guess. The true, floor-independent **hard machine-capacity ceiling** stays as a separate, rarer trigger — firing on it now means muscle outgrew the machine, not fat to wash, and the advice is externalize/split, never wash-harder.
* **User-visible FULL announce**, where the platform exposes a notification channel the agent doesn't have to relay itself (Claude Code: the `Notification` hook event → a terminal OS-notification sequence) — additive to the existing agent-context injection, never a replacement. Platforms without such a channel keep agent-context-only delivery (documented degrade, not parity).
* **Global-scope lock + global keeps store — engine primitives** for shared governance files (e.g. `~/.claude/CLAUDE.md`) that every project's class-B discovery pulls in: a global lock beside the per-project one and a machine-wide keeps store, both landed and hermetically tested. HONEST STAGING: the contract wiring that marks global-scope actions and consults/records the global keeps is a FOLLOWING pass — until it ships, cross-project safety on shared global files rests on the external-writer guard (which already aborts + rolls back any concurrent foreign write).
* **Receipt "unknown" degrade:** a gate-FAIL receipt with no drop count now reads `unknown` instead of a bare `?`.

### Changed

* **Human gate is outer-only.** The Full-tier consent ask (step 2 — already naming the target store) IS the delete/merge gate; `deletesApproved` is set on the strength of that one consent, never a second mid-run y/n. The terse flagged list is now a programmer-opt-in surface — available on request, never a mandatory blocking gate. `apply.mjs`'s code-enforced refusal of ungated deletes is unchanged; what moves earlier is only WHEN the flag gets set.
* **Receipt-only reporting.** The receipt is now stated as the only pushed post-run output; the itemized record (WAL journal, `keeps.json`, snapshot) is a disk pull-surface a programmer can inspect, never narrated into the run — pushing item-level detail was inviting the same keep-fat meddling the outer gates exist to avoid.
* **method.md: spawn contracts are template-only.** Every sub contract this skill spawns (the outsider flag-pass, the post-merge claim-strength diff, any future reconcile pass) must be lifted verbatim from its template with only the placeholders filled — composing a fresh prompt is now named a contract violation, closing the gap that let both benchmark-day outsider prompts get hand-composed despite the template already existing. Plus: the outsider deliverable is now an incremental file (appended per file-group, final message = path + totals) instead of one long emission; a stalled outsider is resumed for a compact re-emit rather than re-spawned; stores above \~150 files/\~500KB now partition across multiple outsiders by directory.
* **SKILL.md/README band-semantics text** rewritten to the growable-full model + the two-pillar trigger doctrine (Memory-BMI and the machine-capacity ceiling are the only triggers; time/age is never one).

## \[0.1.0-beta.6] - 2026-07-09

Fourth same-day hardening pass: three new fidelity-gate classes, a keep-verdict store that ends repeat-adjudication fatigue, state-store self-maintenance, a merge/fold discipline for the one thing the mechanical gate cannot see — claim-strength drift — plus five transactional-apply guards, each porting a classic storage-tool disaster. Engine tests 102 → 148.

### Added

* **Fidelity gate: 3 new structured-token classes** — `quote-drop` (a quoted span dropped), `number-drop` (a numeral dropped), `codespan-drop` (an inline code span dropped) — joining the existing wikilink/date/version/link/frontmatter-key classes; ANY drop still blocks the apply until restored or explicitly human-approved.
* **Keep-verdict store** (`.claude/coalwash/keeps.json`, `[{target, reason, date}]`): an insider-adjudicated keep is recorded once and the outsider's contract is handed the list on every later run — a target already kept is not re-flagged without new evidence. The house metaphor: the outsider is a stranger who may challenge a hoarded item, never delete it; the resident answers with a reason, not a feeling, and a settled answer sticks.
* **State-file orphan prune**: the caliper state (`~/.claude/.coalwash-state.json`) drops a tracked project's entry once its path no longer exists, on the next state read — closes the item queued 2026-07-09 (beta.2 era); fail-silent, no new config key.
* **BMI floor read-sanity**: the stored lean-floor value is range/type-checked at state read, degrading to "no floor yet" on a corrupt or out-of-range stamp rather than feeding a bad number into the band verdict.
* **External-writer guard** (`applyPlan`): every rewrite/delete/create target is re-read immediately before its mutation and byte-compared against the plan's recorded baseline — pass the scan-time `expectedOrig` and the guard covers the whole scan→consent→apply window; ANY foreign change (cloud-sync client, external editor, another agent) aborts the transaction via rollback. Ports the WHS KB946676 / dedup co-writer class.
* **Snapshot verified at creation**: every snapshot copy is read back and byte-compared against a fresh source read BEFORE the destructive phase — a bad snapshot aborts while nothing has changed. Ports the GitLab all-backups-dead class.
* **Own-artifact retention**: apply-preflight sweeps completed-transaction snapshots beyond the newest 3; a dangling/incomplete transaction's snapshot is NEVER swept; an unreadable or newer-schema journal freezes the sweep entirely. Ports the ReFS thin-pool leak class.
* **Flag-not-rewrite for unparseable targets**: a NUL-bearing or unclosable-frontmatter file is FLAGGED and excluded from rewrites (the run continues on the rest); deletes keep the stricter pinned refusal. Ports the e2defrag rewrite-what-you-can't-parse class.
* **Artifact schema-version gates**: the WAL journal (field `version`) and `keeps.json` (field `v`) are version-stamped — an artifact written by a NEWER CoalWash is read-only to an older one, and recovery refuses fail-closed. Ports the XP-deletes-Vista-restore-points class.

### Changed

* **Merge/fold discipline.** An absorbed block must carry its source facts near-verbatim — compression must never change a claim's strength (an "all fixed except 2 deferred" folding into "all fixed" is a regression, not a tidy-up). Every accepted merge now gets a second, retasked before-vs-after outsider check for claim-strength drift before it applies (same zero-context pattern as the Full-tier outsider); `localOnly` or a no-spawn platform flags the merge for manual review instead of skipping the check.
* **Consent asks name their target.** The Full-tier consent and the human delete-gate now NAME the store being washed (path + measured size) — consent is always to a named target, never an ambient yes (the wrong-target incident class).
* **Plain-format invariant stated.** README now states what was true by construction: plain markdown in, plain markdown out — every artifact CoalWash writes (snapshots, WAL journal, `keeps.json`) is a plain file readable without the tool. PRIVACY.md's local-files inventory gains the `keeps.json` keep-verdicts entry.
* **Doc accuracy sweep:** the fidelity-gate class enumeration in SKILL.md/README extended to match the code (the 3 new classes, plus the previously-unlisted `link-drop` class); the SKILL.md Honest-frame callout now points to the canonical list (step 3) instead of repeating it, so the two never drift apart again.
* **Scope boundary made explicit — the four washability tests.** A wash target must be a local file · user-owned · PROSE · ACCRETED; failing any one = never-wash even though it rides the session payload (skills/commands/hooks/agent-definitions = programs · configs/state/locks/journals = machine-parsed · other tools' artifacts · vendor-installed products). Discovery already excludes all of these by construction; the SKILL.md Hard Rules now state the boundary so scope can never drift onto them (lint/health of the excluded classes belongs to CoalMine/CoalLedger).

Also verified this round — pinned by new tests, no code gaps found: symlink-skip discovery (G1) · corrupt-state conservative path (G2) · never-wash-own-artifacts (G4).

## \[0.1.0-beta.5] - 2026-07-09

Third **CoalBoard dogfood** (nasa), same day — two honesty findings on top of beta.3's own fix: beta.3 corrected the claims at their PRIMARY location (SKILL.md + the README frame) but left the identical phrasing stale everywhere it was repeated — a "say it once" miss, not a new bug class.

### Changed

* **\[MED honesty] "zero fact-loss proven by code" — remaining unscoped copies matched to the SKILL.md/README-established wording.** The mechanical gate proves zero **structured-token** loss only (wikilinks, dates, versions, link/URL destinations, frontmatter); a load-bearing **prose** fact is out of its scope and rests on the paid semantic reviewers + the human gate. Corrected wherever the bare "proven by diff, not hoped" phrasing — no structured-token scope named — still stood.
* **\[MED honesty] `localOnly`'s "no spawned sub EVER receives memory content" absolute reworded to its real enforcement level.** SKILL.md's Hard Rules already carry the honest version (beta.3): a MODE the run contract honors, not an OS/code guarantee — the flag's own integrity is code-enforced (the merge-protection in `config-load.mjs`: a project cannot weaken a global `localOnly:true`), but the no-spawn *behavior* is contract-enforced by the agent honoring SKILL.md, not by a sandbox or hook. The same unhedged "ever" absolute stood wherever it was repeated outside SKILL.md; reworded to the same honest framing.

Removes overclaim, adds no new guarantee: the fidelity gate, the human delete-gate, and `localOnly`'s merge-protection are unchanged — only the wording now matches what the code actually proves, everywhere the claim is repeated, not just at its first mention. Credit: the user's CoalBoard nasa audit, 2026-07-09.

### Fixed

* **\[LOW process] beta.4 shipped with no CHANGELOG entry** — backfilled below, reconstructed from git (the same class CoalLedger backfilled today).

## \[0.1.0-beta.4] - 2026-07-09

*(Backfilled 2026-07-09 — shipped with no CHANGELOG entry; reconstructed from git, `v0.1.0-beta.3..v0.1.0-beta.4`.)*

### Fixed

* **\[HIGH CodeQL] `js/file-system-race` (TOCTOU) in `ensureSelfIgnore`** (`scripts/lib/apply.mjs`): the self-ignore `.gitignore` write was exists-then-write; now an exclusive create (`{ flag: 'wx' }`, `EEXIST` swallowed — two racing writers produce identical content, both harmless). The idempotent write made the race harmless in practice; the fix closes the check-then-use window and silences the HIGH. Config-only safety fix, no behavior change.

### Changed

* **CI:** `github/codeql-action` init/analyze/upload-sarif 4.36.3 → 4.37.0 · `DavidAnson/markdownlint-cli2-action` 23.2.0 → 24.0.0 (Dependabot, SHA-pinned).
* **Dependabot config:** `github/codeql-action*` grouped into ONE PR (no init/analyze version skew — the skew that reds CodeQL, seen live) + `assignees: [HetCreep]` so bot PRs notify the maintainer at any watch level. Human still reviews + merges (no auto-merge).

## \[0.1.0-beta.3] - 2026-07-09

Second **CoalBoard dogfood** (full-mirror, nasa) — the config trust-boundary + two honesty over-claims.

### Fixed

* **\[MED] an untrusted project config could weaken a global safety/privacy choice.** The two-level cascade merged `{...global, ...project}` (project wins every key), so a cloned repo's `.coalwash.json` could flip a user's global `localOnly: true` → `false` (defeating the privacy opt-out) or a global `coalwashMode/updateMode: off` back on. Safety-shaping keys now merge **monotonically — safer-value-wins**: `localOnly` is OR'd (a project may make it more private, never less), and `coalwashMode`/`updateMode` let a project move only toward the *safer* end (off/quiet). This **preserves "shut off per project"** (off is the safe end, always allowed) while closing the hole. Every other key still project-wins. +4 regression tests (98 → 102).
* **\[MED honesty] "zero fact-loss proven by code" over-claimed.** The mechanical gate proves zero **structured-token** loss (wikilinks, dates, versions, link/URL destinations, frontmatter) — a load-bearing **prose** fact is out of its scope and rests on the paid semantic reviewers + the human (exactly what the module comment already says). README / SKILL / honest-frame wording corrected to match the code.
* **\[MED honesty] `localOnly` was advertised as an absolute code guarantee** ("no spawned sub EVER receives memory content") with no executable enforcing it. Reworded to what it is: a **mode the skill contract runs** (Quick-only, no content-bearing sub) — with the FLAG now merge-protected (a project cannot disable a global `localOnly:true`), and the no-sub behavior honestly attributed to the contract, not an OS sandbox.

## \[0.1.0-beta.2] - 2026-07-09

Launch-day **CoalBoard dogfood** (nasa rigor, 3 opus blind lenses + judge) found real defects a green suite missed — the three lenses returned DISJOINT sets (the sampler + correlated-blind-spot doctrine working). All fixed here; +6 regression tests (92 → 98).

### Fixed

* **\[HIGH] `recoverDangling` bypassed containment + the delete gate.** Cold-start recovery replayed `manifest.json` / journal `steps` verbatim — a poisoned `.claude/coalwash/journal.json` shipped inside a repo could overwrite/delete arbitrary absolute paths outside the memory sandbox, unattended. The journal now records the transaction's resolved `roots`; recovery realpath-and-contains every restore/delete target against them (fail-closed) and refuses + keeps the journal for a human on any out-of-root or unverifiable target.
* **\[HIGH] the lock's stale-takeover could admit two holders, and `release` deleted any lock.** Takeover was `rm`-then-`create` (a missing-file window) and `release` was an unconditional `rmSync` with no owner check — a slow/suspended holder whose lock was stolen deleted the new holder's lock. Now: a per-acquire owner token, steal-in-place (no rm window; a race collapses to one-holder-or-both-defer), and `release`/takeover verify the token.
* **\[HIGH] a create orphaned on a crash between write and journal.** Recovery only removed creates stamped `done`; a power-loss after the file landed but before the step persisted left an orphan that then entered class-B. Recovery now removes every create in a dangling transaction (a no-op if it was never written).
* **\[MED] the fidelity gate was not interlocked at the mutation boundary.** `applyPlan` enforced the delete/pin/containment gates in code but ran the flagship fidelity check only as a pipeline step a caller could skip — contradicting "proven by code, not promised by a prompt". `applyPlan` now diffs every rewrite original-vs-new and ABORTS on an unapproved structured-token drop (`plan.approvedDrops` carries the human's explicit approvals).
* **\[MED] the fidelity floor missed link destinations and false-blocked reformats.** It never inventoried markdown-link / autolink / bare-URL destinations (a dropped `[t](url)` passed); it keyed wikilinks by the whole `Target|Display` span (a display-text edit failed the gate); and it treated `2026-07-09` and `9-Jul-2026` as distinct (an endorsed reformat failed). Now: URL destinations are inventoried, wikilinks key on the TARGET, and dates canonicalize to `YYYY-MM-DD`.
* **\[MED] `isPinned` was fail-OPEN.** A read error, or `pinned: true` beyond the 4 KB read window, returned not-pinned → an "untouchable" file became rewritable/deletable. Now fail-CLOSED (65 KB window; a read error or an unclosable frontmatter counts as pinned).
* **\[MED] the rules-tree walk was unbounded by directory count.** `RULES_FILE_CAP` counted only `.md` files, so a deep/wide tree with few `.md` files traversed uncapped every SessionStart (Phoenix #3). The directory traversal is now capped too.
* **\[LOW] a partial rollback reported as clean.** A restore failure inside `rollback()` was swallowed and the transaction still marked `rolled-back`; a cold-start recovery then cleared the journal over a mixed on-disk state. A partial rollback now reports `rolledBack: 'partial'` and marks the journal `rollback-failed` (not auto-cleared).
* **\[LOW] discovery was fail-OPEN on an unresolvable root** (parity with the write path's fail-closed containment): an unresolvable home/project root now drops out instead of falling back to a lexical path.
* **\[LOW] doc:** the README `coalwashMode` row noted `manual` as fully silent; clarified that the self-update nudge is orthogonal (its own `updateMode: off`).

## \[0.1.0-beta.1] - 2026-07-09

First public beta — the code-core engine plus the orchestration skill.

### Added

* **Engine (code-core, zero-dependency ESM):** `class-b.mjs` per-platform class-B discovery (Claude Code adapter; read-only, realpath-and-contained) · `caliper.mjs` footprint measurement, 4-band Memory-BMI verdict (LEAN/PLUMP/OBESE/FULL), deterministic economic break-even, lean-floor/stamp/snooze state · `fidelity-gate.mjs` mechanical zero-fact-loss gate (wikilinks/dates/versions/frontmatter inventory diff + encoding-corruption tripwires) · `apply.mjs` transactional apply (exclusive lock, marked snapshot, fsync'd WAL, atomic writes, deletes last, wholesale rollback, code-enforced human gate on deletes, `pinned: true` refusal) · `receipt.mjs` plain terse numbers block.
* **SessionStart conductor** (`hooks/coalwash-conductor.js`, Phoenix-13): the chokepoint gauge — silent on LEAN, band nudges with snooze, FULL force-run armed only by the shown break-even numbers; kind-1 self-update scheduling.
* **Skill** `skills/coalwash/SKILL.md` — the lean orchestration contract over the engine (Quick mechanical → consent-gated semantic Full with a zero-context outsider → fidelity gate → human gate → apply → receipt), with `references/method.md` (snippets, rubric, garbage taxonomy) and `references/platform-cc.md`.
* **Commands:** `/coalwash:stats` (measurement standard-system, read-only) · `/coalwash:update` (consent-gated self-update procedure).
* **Config system:** `.coalwash.json` global + per-project cascade, schema SSoT with clamped reads, commented factory template.
* **Docs:** README, SECURITY, PRIVACY (localOnly zero-transmission mode; receipts = metrics never content), CONTRIBUTING, Apache-2.0 LICENSE + NOTICE.
* **CI:** the flock's four SHA-pinned workflows (ci · codeql · markdownlint · scorecard), dependabot, issue templates.
* **Benchmark scaffold** (org `.github/benchmarks/CoalWash/`): protocol + planted fat/muscle fixtures + mechanical `score.mjs` for the sawtooth-vs-bloat and infinity-loop fact-loss measurements.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.thecolliery.org/tools/coalwash/changelog.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
