`.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
1.8 KiB
Two reads of one file are not one read of one state
2026-09-22 · booth
The one finding across both U4 panels that changed code rather than prose, and it came from Hulda (Codex) on the CONTRACT-paraphrase round — before any code existed.
The contract specified the hold check as:
is_held(marks_for(child), read_error(child))
Two reads of .marks.json, presented as one answer. They are not. A write or a
repair landing between them yields a pair that described the booth at no
instant, and the losing pair is ([], None) — no marks, no error — which is
exactly the pair that deletes. A lenient reader plus a strict reader, each
correct on its own, compose into a fail-open delete.
The fix is booth.marks.hold_read(booth) -> (marks, error): ONE strict read
answering both questions. sweep_once now does one read per booth per tick
instead of two. And because _read_raw_strict raises rather than dropping an
entry, a non-raising strict read returns exactly what the lenient read would —
so the index uses that same one read for its badge too and falls back to
marks_for only on the error path, where leniency is the point. Better than the
original in both correctness and cost.
The generalisable class, in heid's words: a two-read seam presented as one
answer is a TOCTOU race even when nothing on the page looks concurrent. Worth
looking for anywhere two reader functions with different strictness feed one
decision — especially when that decision ends in rmtree.
Related: [[2026-09-21-marks-write-wiped-judgment]] is the same
reads-lenient/writes-strict asymmetry; U4 extends it to the reaper with
"deletes strict", whose scope is the sweeper only — a hand delete is never
strict, which is what gives an unreadable-marks hold an exit at all.