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
This commit is contained in:
vh
2026-09-22 09:44:25 -07:00
parent d37b81ab9f
commit c3a97c1b64
22 changed files with 2339 additions and 71 deletions
+29 -9
View File
@@ -12,6 +12,8 @@
# booth rm <name> wipe a booth now (TTL would eventually anyway)
#
# booth keep <name> exempt a booth from the 24h sweep, forever
# (NOT for "waiting on an answer" — an open
# pick holds its own booth, see below)
# booth unkeep <name> hand it back to the sweeper
# booth link <url> [description] append a link to the standing link board
# booth links list the board, numbered, with entry ids
@@ -59,12 +61,28 @@
# access, so they poll the HTTP mirror instead:
# http://10.100.10.50:8090/b/<name>/marks.json
#
# THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last
# THE 24h RULE AND ITS THREE STATES. Every booth is wiped 24h after its last
# activity — that is the contract, and it is why nobody has to clean up after
# themselves. `keep` drops a `.forever` sentinel that exempts one booth from the
# sweep and moves it into its own lane at the top of the index. Use it for
# durable operator-facing boards, not for run output. `unkeep` is just `rm` of
# the sentinel, so putting a board back under the sweeper costs nothing.
# themselves. Two things exempt a booth, and only the first is a button:
#
# KEPT `keep` drops a `.forever` sentinel that exempts one booth from the
# sweep and moves it into its own lane at the top of the index. Use it
# for durable operator-facing boards, not for run output. `unkeep` is
# just `rm` of the sentinel, so putting a board back costs nothing.
# HELD a booth with an UNANSWERED pick is never swept, automatically, for as
# long as the question is open. You do not press anything: `booth ask`
# is what holds it, and the operator answering is what releases it. A
# partially-answered pick still counts as open, so a review in flight
# cannot be swept out from under him.
#
# So: DO NOT `keep` a booth just because you are waiting on an answer. That was
# the old workaround, it is what made 70% of live booths "durable", and it is
# no longer needed. `keep` means durable. The question holds its own booth.
#
# VIEWING IS ACTIVITY TOO. The operator opening a booth page resets its clock —
# if he is still looking at it, it is still alive. Your polling does NOT: `booth
# marks --wait` and the `marks.json` endpoint are machine reads and deliberately
# do not count, so a session cannot hold its own booth open by waiting on it.
#
# DELETING A KEPT BOARD: `booth rm <name>` works on kept boards too and deletes
# NOW — it announces that the board was kept, so wiping something durable is
@@ -72,10 +90,12 @@
# card drops the sentinel, the card moves to the ephemeral lane, and the × wipes
# it from there.
#
# DO NOT "unkeep and let it expire". Removing the sentinel BUMPS the booth
# directory's mtime, and a booth's age is the newest mtime in its tree — so a
# released board's clock RESETS and it survives another full 24h. Unkeep-and-wait
# is a delay, not a delete. Use `rm` (or the UI ×) when you mean now.
# DO NOT "unkeep and let it expire". RELEASING A BOARD IS ACTIVITY — you just
# touched it — so a released board's clock resets and it survives another full
# 24h. Unkeep-and-wait is a delay, not a delete. Use `rm` (or the UI ×) when you
# mean now. (This was true before U4 as an accident of directory mtime; it is
# now the stated rule, which is why it no longer needs a warning shaped like a
# surprise.)
#
# `link` is the reason the exception exists: agent sessions hand the operator
# URLs that then drown in terminal scrollback. They go on a standing kept board
+8
View File
@@ -24,6 +24,14 @@ claimed it did until 2026-09-22. Pass booth URLs explicitly to cover them:
scripts/layout-probe.py http://10.100.10.50:8090/{,b/my-run/}
⚠ PROBING A BOOTH PAGE RESETS THAT BOOTH'S TTL CLOCK (U4). A GET of `/b/<n>/`
is a view, and a view is activity — that is the rule, and this script is not
exempt from it just because it is ours. Sweeping every booth page therefore
buys every booth another full TTL. Harmless and recoverable (nothing is
deleted, things merely live longer), named here so nobody debugs it later as a
sweeper that stopped working. The index-only default does NOT do this: browsing
the index is deliberately not a view.
⚠ In zsh an unquoted `$URLS` does NOT word-split, so a variable holding
several URLs arrives as ONE argument and the probe silently reports
"2 page(s)" while covering two. Use an array and `"${URLS[@]}"`.