# Four independent paths to one fail-open delete _2026-09-22 · booth_ The U4 bug-hunt panel declared invariant was **"a deletion decision must never be made from a read that failed"**. The panel found **four independent paths through it, and no single arm found all four.** That is the strongest argument yet for running the panel rather than one arm. 1. **An entry-level hydration error lost its hold** (the round's best finding). `.marks.json` parses; one mark fails normalization; `_hydrate_safe` returns a `Mark` carrying `error`; `_is_open` returns False for an errored pick — **on purpose**, because a broken pick can never be answered. So the booth read as not-held and **swept**, while the panel beside it rendered the broken mark in full. The fail-safe had been built for FILE-level damage and missed ENTRY-level. A mark we cannot read is judgment we cannot see; deleting the booth it belongs to is the one thing we must not do with it. 2. **A present-but-blank `.marks.json` swept.** `_read_raw_strict` early-returns for whitespace-only content — correct for the WRITE path it was written for (a blank file is safe to overwrite), wrong for the DELETE path. Fixed with a `blank_is_corrupt=True` flag used only by `hold_read`. ⚠ The near-regression worth remembering: a **valid document with an empty `marks` list** is what deleting the last mark leaves behind, and holding on THAT would make every finished booth immortal. Blank bytes are damage; an empty list is an answer. 3. **`_newest_mtime` returned 0.0 when the booth's own stat failed**, which made it maximally ancient and therefore the FIRST thing the sweeper takes — a permissions problem resolving to a deletion. Now returns `now`: not knowing a booth's age is a reason to leave it alone. ⚠ Per-entry `FileNotFoundError` stays a skip, because a dangling symlink raises it and has no mtime worth counting; only OTHER stat errors mean "something is here we cannot read". 4. **`is_kept` collapsed a stat failure into not-kept.** `Path.exists()` maps ELOOP and EACCES to False. Now `lstat`, with any non-ENOENT error reading as KEPT, and a `.forever` symlink counting dangling or not. **`is_held` was replaced by `hold_reason`, which returns the REASON** — `"open"`, `"unreadable"`, or None — rather than a bool beside a separate error string. Two representations of one state drift; Regin independently flagged that the display could not tell the two holds apart. One value, read by the sweeper and by all four rendering surfaces. **Convergent 3-of-4, and the one with teeth beyond lifetime:** `record_view` used `Path.touch()`, which FOLLOWS an existing symlink. A booth carrying a planted `.viewed -> /anywhere` turned every page view into an mtime write at an arbitrary path under the service uid — and **any fleet session can write into a booth, because making a folder is the whole API.** Now `os.open(..., O_NOFOLLOW)` plus `os.utime(fd)`; a planted link raises ELOOP into the existing swallow. ⚠ **THE CAPTURE TOOLING FAILED SILENTLY AND THE PEER CAUGHT IT, NOT US.** The snapshot `files/` tree shipped to the arms was EMPTY. The loop was `for f in $IN` over a multi-line variable — and **zsh does not word-split unquoted parameter expansions the way bash does**, so it iterated once against a path that was the entire list. jekyll recovered by re-applying the bundled diff to HEAD and verified every file byte-identical, so the round was sound. **The failure mode is the dangerous one: an empty bundle reads exactly like a clean result.** Quote-and-split explicitly (`print -r -- $IN | while read f`) or build the list as a real array. Same family as `[[2026-09-22-vacuous-falsifiers]]` — an instrument that cannot fail loudly will fail quietly.