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
+48 -7
View File
@@ -15,7 +15,9 @@ filesystem *is* the state.
- **Live:** http://10.100.10.50:8090/ (nh3-dev) · linked from Homepage → *Apps → The Booth*
- **Data dir:** `~/booth-data/` on nh3-dev (one subfolder per booth)
- **TTL:** 24h, measured from the newest mtime in a booth's tree (it lives while
you're touching it, self-destructs 24h after you stop)
you're touching it, self-destructs 24h after you stop). Two things hold a booth
open past that: the `.forever` sentinel, and **an unanswered question** — see
*Lifetime* below. **Opening a booth page is activity**; polling it is not.
## How a session posts
@@ -159,7 +161,46 @@ is exactly why the direct `×` was worth adding.
an ephemeral booth could only be kept from a shell. The `/keep` route and the
CLI verb both already existed; only the button was missing.
## Kept boards — the one exception to the 24h rule
## Lifetime — derived, not declared
A booth is in exactly one of three states, and only the first is a button you
press:
| state | what puts it there | swept? |
|---|---|---|
| **kept** | you pressed `keep` / dropped `.forever` | never |
| **held** | an **unanswered pick**, or a `.marks.json` the service cannot read | not while that holds |
| **ephemeral** | everything else | 24h after the last activity |
**An open question holds its own booth.** A session that runs `booth ask` does
not also need to `keep` the booth — the booth cannot be swept while the operator
still owes it an answer, and it is released automatically when he answers. A
*partially* answered multi-question pick still counts as open, so a review in
flight is never swept out from under him. The index card and the booth header
say `held until answered` where the countdown would be, so a booth that has
stopped counting down always tells you why.
**Viewing is activity.** A deliberate GET of a booth's own page — the gallery, a
verbatim report, the zoom view, the marks page, a zip download — resets the
clock. If the operator is still looking at it, it is still alive. Browsing the
index does **not** count, and neither does a session polling `marks.json` or
`booth marks --wait`: machine reads are deliberately excluded, so an agent
cannot hold its own booth open by waiting on it.
**A held booth is still yours to delete.** The hold is protection from the
timer, never from you: `booth rm`, the UI ×, and `DELETE /b/<name>` all work
exactly as before. `sweep_once` is the only thing that honours a hold, exactly
as it is the only thing that honours `.forever`.
**Why this exists:** `.forever` used to be the only way to say three different
things — "this is durable", "I haven't answered yet", and "I'm still looking at
it" — and the measurement showed it carrying all three. On 2026-09-22, 17 of 24
live booths (70%) held the sentinel, up from 54% the day before; three of the
four booths in the fleet awaiting an answer had been pinned by hand as well.
Only the first meaning is what `keep` means. The other two the service already
knew and did not consult.
### Kept boards — the explicit pin
A booth containing a **`.forever`** dotfile is **never swept**, and renders in
its own **Kept** lane at the top of the index (blue top edge, `★ kept` badge, no
@@ -168,6 +209,7 @@ still ephemeral, so nobody inherits a cleanup chore they didn't ask for.
```bash
booth keep my-board # drop the sentinel — exempt from the sweep, forever
# (NOT for "waiting on an answer" — the pick holds it)
booth unkeep my-board # release the pin — the board rejoins the sweep
booth rm my-board # delete it NOW (works on kept boards; says so when it was kept)
@@ -438,11 +480,10 @@ wipe it from there. Release is reversible — press keep again and nothing was
lost. From the CLI, `booth rm <name>` deletes a kept board immediately and
tells you it was kept.
**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 TTL.
Unkeep-and-wait is a 24-hour delay, not a delete. Use the × or `booth rm` 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 TTL. Unkeep-and-wait is a 24-hour delay, not a delete. Use the × or
`booth rm` when you mean now.
## Ops