Files
booth/persistent-memory.d/2026-09-22-four-paths-to-one-fail-open-delete.md
T
vh c3a97c1b64 feat(u4): a booth's lifetime is derived from its state, not from a boolean
`.forever` was the only way to say three different things — "this is durable",
"I have not answered yet", "I am still looking" — and the census said it was
carrying all three: 17 of 24 live booths (70%, up from 54% the day before).
Three of the four booths in the fleet awaiting an answer had been pinned by
hand as well, and 10 of the 17 were younger than the TTL, so the sentinel had
bought them nothing and was pressed pre-emptively.

Only the first meaning is what `keep` means. The other two are facts the
service already held and did not consult.

    KEPT       `.forever` present                      never swept  (unchanged)
    HELD       an open pick, or marks we cannot read   never swept  (new)
    EPHEMERAL  everything else                         24h          (unchanged)

Viewing is activity: a deliberately-served response from a booth's own page
route writes `.viewed`, which is a dotfile and not a `.lock` dotfile, so
`_newest_mtime` already counts it. There is no new arithmetic — `booth_age_seconds`,
`is_expired` and `expires_in` are unchanged. Machine reads are excluded on
purpose: an agent must not be able to hold its own booth open by polling for
the answer it is waiting on.

The hold is unbounded, and what makes that safe is visibility plus two exits
that already existed. Every surface whose chrome the Booth owns says
`held until answered` where the countdown was, and `booth rm` / the UI x /
`DELETE /b/<n>` take a held booth exactly as they take a kept one. A hold is
protection from the timer, never from the operator.

Three cross-frontier panels ran and each found a class the others could not:

  * the paraphrase panel found that two reads of one file are not one read of
    one state — the contract's `is_held(marks_for(c), read_error(c))` could
    resolve to `([], None)`, the pair that deletes. `hold_read` is one read.
  * the code-review panel found, 4-of-4, that the booth header's board branch
    rendered no lifetime at all; and that five of seven invariant tests passed
    under the change that defeats them.
  * the bug-hunt panel found four more paths where a failed read still
    authorized a delete, and a `record_view` that followed a planted symlink.

`is_held` became `hold_reason`, which returns the reason rather than a bool
beside a string that can disagree with it.

Prediction, to re-count on or after 2026-10-06: the `.forever` rate falls to
the booths that are genuinely durable references. Only 4 booths carry marks at
all, so this rests on both halves of the unit; a null result cannot distinguish
a wrong diagnosis from a habit that outlived its need.

406 tests (341 before). Contract: docs/contracts/u4_derived_lifetime.contract.md
2026-09-22 09:44:25 -07:00

3.7 KiB

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.