Files
booth/persistent-memory.d/2026-09-23-blur-round-trip-and-the-migration-that-recreated-it.md
T

3.7 KiB

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.