# The blur round-trip, and the migration that recreated the bug it fixed _2026-09-23 → 2026-09-24. Operator: "fix the blur." Commits `4cfbce5`, `c1f5543` (merged `6880ab3`), `8a78a9b`._ ## The defect design-dev's r2b bug-hunt found the `/blur` route stripping `f`, so the form for `" a.png"` blurred `"a.png"`. The route was only half of it: `.blurred` was one stripped rel per line, so NO writer could store a rel with edge whitespace or a newline. There were no live victims (6 legacy files, 42 rels, none with edge whitespace; 0 live filenames with edge whitespace), so it was latent. ## Round 1 (`4cfbce5`) - JSON array (the `.seen` shape) through a new stdlib-only `booth/blur.py`, so the CLI and the service share one reader and one writer. The CLI had its own grep/printf line writer, and after the format change it would have appended a line to a JSON array. - `Item.blurred_self` resolved in `booth_items` from the same read as `blurred`, replacing build_gallery's second `read_blurred`. That was a two-reads-of-one-file seam (invariant 3). - Built in a git worktree, because `scripts/booth` imports from the deployment root LIVE: a half-built blur.py would have broken `booth blur` for every session mid-TDD. ## Round 2: heid bug-hunt (hulda, regin, kimi; groa timed out) → `c1f5543` - **3/3: the migration recreated the bug.** JSON went into the OLD file name and the reader sniffed the format. A legacy file whose one line is an item named `["a.png"]` parses as JSON and blurs the neighbour. The docstring claimed that case was handled, and it wasn't. Fix: a NEW name, `.blurred.json`. The legacy `.blurred` is lines only, read only while `.blurred.json` is absent, and retired by the first write. - 2/3 + one: a planted directory 500'd the write path; the read path was hardened and the writer was not. Fix: the writer is judged by its reader (a postcondition), with BlurUnwritable answered as a 409. - hulda (execution-verified): a lone surrogate `"\ud800"` in planted JSON made every later write raise UnicodeEncodeError. Now dropped on read. - 2/3: the writer had no size cap, and the reader reads an oversized file as EMPTY. The writer now refuses first. - 2/3: the CLI's `*..*` refused `a..b.png`, which the route accepted. There's now one `check_rel` predicate for both, which also refuses an empty rel. - kimi: `booth blur` without its package printed a bare traceback. It now fails closed with exit 3, like `link`. - Declined: the Item positional-constructor break (booth_items is the only constructor, INV-1); the fdopen fd leak and the short read (not constructible on a local fs, the `.seen` shape); unreadable reads as revealed (blur is cosmetic, the `.seen` posture). ## Round 3: groa's late retry → `8a78a9b` Its four bugs were the same four, already fixed. Its 0600 note ("a cross-uid reader sees nothing and replaces it") exposed the real gap: `set_blurred` built on `read_blurred`, the renderer's LENIENT reader, so an unreadable, oversized or malformed file became an empty set and was overwritten. That is the `.marks.json` wipe of 2026-09-21 ([[2026-09-21-tolerant-writer-over-tolerant-reader]]), repeated in a new module and live for one night. Fix: `_load` is one parse with two postures (strict for the writer, lenient for the renderer). It refuses only for a REGULAR file it cannot read, since a link, a directory or a FIFO holds no set to lose. The file is 0644 again. ## Mutation notes - `blur_storage.toml` is 25/25. - One row was vacuous on its first run (`set() or X` is `X`). - Two open-flag rows went vacuous once `_load` lstat-checked for a regular file first. They're now proved by direct `_read_capped` tests, because they still close the lstat-to-open race.