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:
@@ -62,8 +62,9 @@ test is the only thing standing here.
|
||||
No database. `ls ~/booth-data` tells you everything the service knows.
|
||||
|
||||
Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep),
|
||||
`.blurred` (one rel per line), `.marks.json` + `.marks.lock` (judgment), `.pins`
|
||||
(link-board pin ids), `.uploaded` (upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new
|
||||
`.viewed` (last deliberate look — U4's "viewing is activity"), `.blurred` (one
|
||||
rel per line), `.marks.json` + `.marks.lock` (judgment), `.pins` (link-board pin
|
||||
ids), `.uploaded` (upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new
|
||||
dotfile costs nothing in item counts, galleries or zips. That skip is why the
|
||||
dotfile is the right shape for new operator state — use it rather than
|
||||
inventing a sidecar-per-item.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+9
-3
@@ -1,7 +1,7 @@
|
||||
# The Booth — roadmap
|
||||
|
||||
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
|
||||
Current version: `0.3.0` (U1, U2 and U5 landed; extracted from eshpfi 2026-09-21).
|
||||
Current version: `0.3.0` (U1, U2, U4 and U5 landed; extracted from eshpfi 2026-09-21).
|
||||
|
||||
## v1 target
|
||||
|
||||
@@ -13,7 +13,7 @@ defect — not a wish. The measurements are in the IA doc.
|
||||
| 1 | ~~**One item record**~~ — **landed `ce598b3`** | captions never reach the zoom view (never sent, not lost) | U1 |
|
||||
| 2 | ~~**Marks**~~ — **landed `c7f9437`, released `v0.2.0`** | 5 mechanisms for 1 job; operator→session loop runs through chat | U2 |
|
||||
| 3 | **Declared embed seam** — `/_booth/embed.js`, chrome mounts via DOM | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 |
|
||||
| 4 | **Derived lifetime** — open marks pin; viewing is activity | 54% of booths on the `.forever` escape hatch | U4 |
|
||||
| 4 | ~~**Derived lifetime**~~ — **landed** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 |
|
||||
| 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 |
|
||||
| 6 | **Benches** — registry, identity, enforced rule, migration | 69% link-board rot; the same bench posted 5× | U6 |
|
||||
| 7 | **Navigation at 270 items** — sections, rail, filters, grid keyboard | one flat wall; subfolder structure discarded at render | U7 |
|
||||
@@ -22,7 +22,7 @@ Ordering is dependency-driven, not priority-driven: **U1 → U2 → {U3, U4, U5}
|
||||
U7**, with **U6 independent** of all of them (different storage, different
|
||||
surface) and therefore the safest thing to land first or in parallel.
|
||||
|
||||
**U1, U2 and U5 are landed.** U3 and U4 are unblocked and unstarted; U6 remains
|
||||
**U1, U2, U4 and U5 are landed.** U3 is unblocked and unstarted; U6 remains
|
||||
independent and unstarted; U7 waits on the rest.
|
||||
|
||||
**U5's adoption is a measured prediction, not a finished result**, and it is
|
||||
@@ -74,6 +74,12 @@ Where it already binds, and what the rule is in each case:
|
||||
| link board rows | pinned first, then newest-first |
|
||||
| a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) |
|
||||
|
||||
U4 added no ordered collection — a booth's lifetime is one state per booth,
|
||||
not a sequence — so the rule above did not need a new row. The three lifetime
|
||||
surfaces (index card, booth header, marks page) render through ONE macro
|
||||
precisely so they cannot disagree, which is the same property stated for
|
||||
ordering: one rule, one place, every surface reading it.
|
||||
|
||||
Where it is still to be decided, and must be before the unit ships: **U7's
|
||||
section ordering and its compare pairing** (sections need a stated order among
|
||||
themselves, not just within; pairing by filename needs a rule for what happens
|
||||
|
||||
+252
-21
@@ -7,15 +7,26 @@ Model (deliberately dead-simple, no database):
|
||||
* GET /b/<name>/ -> if <name>/index.html exists, serve it verbatim; otherwise
|
||||
auto-render a gallery of the images / webm-videos / audio in it.
|
||||
* GET /b/<name>/<file> -> serve a file out of the booth (also feeds a custom index.html's assets).
|
||||
* 24h TTL: a background sweeper wipes any booth untouched for TTL hours. A booth's
|
||||
age is measured from the *newest* mtime in its tree, so it lives while it's being
|
||||
worked on and self-destructs TTL hours after the last activity.
|
||||
* KEPT BOOTHS: a booth containing the KEEP_MARKER dotfile (`.forever`) is exempt
|
||||
from the sweep and renders in its own lane above the ephemeral grid. That is the
|
||||
home for durable operator-facing boards — chiefly the standing link board agent
|
||||
sessions post to, whose whole purpose is to survive longer than the scrollback
|
||||
it replaces. Opt-in per booth, so the ephemeral default is unchanged and nobody
|
||||
inherits a cleanup chore; `rm` the sentinel and the booth rejoins the sweep.
|
||||
* LIFETIME IS DERIVED, not set by a boolean (U4). Three states, and `sweep_once`
|
||||
is the only thing that honours the first two:
|
||||
KEPT `.forever` present. Never swept, own lane at the top of the index.
|
||||
Durable operator-facing boards — chiefly the standing link board,
|
||||
whose whole purpose is to outlive the scrollback it replaces.
|
||||
HELD an open pick in `.marks.json`, or marks that cannot be read at all.
|
||||
A booth the operator still owes an answer to is not the sweeper's
|
||||
to take, and one whose judgment we failed to READ is certainly not.
|
||||
EPHEMERAL everything else: wiped TTL hours after the last activity. Age is the
|
||||
*newest* mtime in the tree, so a booth lives while it is being
|
||||
worked on and self-destructs once it stops.
|
||||
* VIEWING IS ACTIVITY. A deliberate GET of a booth's own page writes VIEW_MARKER,
|
||||
which the age rule already counts — if the operator is still looking at it, it
|
||||
is still alive. Browsing the index is not a view, and neither is a session
|
||||
polling `marks.json`: an agent must not be able to hold its own booth open.
|
||||
* WHY DERIVED. `.forever` was the ONLY way to say three different things, and the
|
||||
measurement showed it carrying all of them — 17 of 24 live booths on 2026-09-22
|
||||
(70%, up from 54%), with three of the four booths awaiting an answer ALSO pinned
|
||||
by hand. Only "this is durable" is what keep means. The other two are facts the
|
||||
service already held and did not consult.
|
||||
|
||||
State is the filesystem — `ls ~/booth-data` tells you everything. That is the whole point.
|
||||
"""
|
||||
@@ -34,6 +45,7 @@ import time
|
||||
import zipfile
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
@@ -85,6 +97,14 @@ from booth.items import ( # noqa: E402,F401
|
||||
# flag to remember, no state anywhere but the filesystem.
|
||||
KEEP_MARKER = ".forever"
|
||||
|
||||
# Records the last deliberate look at a booth (U4). A dotfile for the same two
|
||||
# reasons KEEP_MARKER is one — `booth_items` and `zip_booth` skip it, so it
|
||||
# costs nothing in counts, galleries or zips — and NOT a `.lock` dotfile, so
|
||||
# `_newest_mtime` COUNTS it and the existing age rule picks the view up with no
|
||||
# new arithmetic. That is the whole integration: a view is one more thing in
|
||||
# the tree, not a second term in the formula.
|
||||
VIEW_MARKER = ".viewed"
|
||||
|
||||
# ⚠⚠ BLUR IS COSMETIC, NOT ACCESS CONTROL. The file is still served at its own
|
||||
# URL, still in the zip, still on disk. This hides an item from a glance — a
|
||||
# shoulder, a screen-share, a scroll past something you did not want to see
|
||||
@@ -124,12 +144,14 @@ from booth.asks import ( # noqa: E402
|
||||
)
|
||||
from booth.marks import ( # noqa: E402
|
||||
MARKS_FILE,
|
||||
Mark,
|
||||
MarksCorrupt,
|
||||
answer_pick,
|
||||
as_dict,
|
||||
declare_pick,
|
||||
delete_mark,
|
||||
import_legacy_asks,
|
||||
hold_read,
|
||||
marks_for,
|
||||
marks_for_target,
|
||||
open_marks,
|
||||
@@ -189,18 +211,34 @@ def _newest_mtime(path: Path) -> float:
|
||||
gets its clock counted. Everything else counts too, dotfiles included,
|
||||
because `.marks.json`, `.blurred` and `.pins` are the operator doing
|
||||
something.
|
||||
|
||||
⚠ A STAT WE CANNOT DO READS AS *FRESH*, NEVER AS EPOCH-OLD. This function
|
||||
feeds `is_expired`, which feeds `rmtree`. Returning 0.0 for a booth whose
|
||||
own stat fails made it maximally ancient and therefore the FIRST thing the
|
||||
sweeper takes — a permissions or ELOOP problem resolving to a deletion. The
|
||||
bug-hunt panel found this as one of four paths into the same shape. Not
|
||||
knowing a booth's age is a reason to leave it alone.
|
||||
|
||||
`FileNotFoundError` on an entry is the exception, and it stays a skip: a
|
||||
dangling symlink and a file removed mid-scan both raise it, and neither is
|
||||
a thing with an mtime worth counting. Any OTHER per-entry OSError means we
|
||||
could not read something that IS there, so the age is unknowable and the
|
||||
booth reads as fresh.
|
||||
"""
|
||||
now = time.time()
|
||||
try:
|
||||
newest = path.stat().st_mtime
|
||||
except OSError:
|
||||
return 0.0
|
||||
return now
|
||||
for p in path.rglob("*"):
|
||||
if p.name.startswith(".") and p.name.endswith(".lock"):
|
||||
continue
|
||||
try:
|
||||
m = p.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
continue # dangling symlink, or gone mid-scan
|
||||
except OSError:
|
||||
continue
|
||||
return now # cannot read it — cannot judge the age
|
||||
if m > newest:
|
||||
newest = m
|
||||
return newest
|
||||
@@ -222,8 +260,102 @@ def is_expired(path: Path, ttl_seconds: float, now: float | None = None) -> bool
|
||||
|
||||
|
||||
def is_kept(path: Path) -> bool:
|
||||
"""True if this booth carries the keep sentinel and must never be swept."""
|
||||
return (path / KEEP_MARKER).exists()
|
||||
"""True if this booth carries the keep sentinel and must never be swept.
|
||||
|
||||
`lstat`, not `Path.exists()`, and an unreadable answer counts as KEPT. The
|
||||
old form collapsed ELOOP and EACCES into False, so a kept booth whose
|
||||
sentinel could not be stat'd became eligible for the sweep — a failed read
|
||||
authorizing a delete, which is the shape the bug-hunt panel found four ways
|
||||
into. `lstat` also means a `.forever` SYMLINK counts, dangling or not:
|
||||
somebody put it there to mean keep.
|
||||
"""
|
||||
try:
|
||||
(path / KEEP_MARKER).lstat()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def record_view(booth: Path) -> None:
|
||||
"""Note that somebody deliberately looked at this booth (U4).
|
||||
|
||||
Touches VIEW_MARKER and lets `_newest_mtime` do the rest — a view enters
|
||||
the age rule as a file in the tree, not as a new term in the arithmetic.
|
||||
|
||||
NEVER RAISES. A read-only mount, a booth owned by another uid, a full disk,
|
||||
a booth deleted between the route's resolve and this call: every one of
|
||||
those costs the timestamp, not the page. The same trade `_Locked.__enter__`
|
||||
makes on its `os.utime`, and for the same stated reason — not recording the
|
||||
look is a cost this service can absorb, not answering the request is not.
|
||||
A booth whose view cannot be recorded simply ages on its content mtime,
|
||||
which is what every booth did before this existed.
|
||||
"""
|
||||
# O_NOFOLLOW, not `Path.touch()`. `touch` on an existing symlink follows it,
|
||||
# so 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. Three of four bug-hunt arms found it independently. A symlink
|
||||
# here now raises ELOOP into the swallow below: view-recording quietly stops
|
||||
# for that booth, which is the right way to lose this argument.
|
||||
#
|
||||
# O_CREAT alone does not move the mtime of a file that already exists, so
|
||||
# the utime is not decoration: the marker must read as NOW or the whole
|
||||
# mechanism is a file nobody's clock looks at.
|
||||
try:
|
||||
fd = os.open(booth / VIEW_MARKER,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o644)
|
||||
try:
|
||||
os.utime(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
HOLD_UNREADABLE = "unreadable"
|
||||
HOLD_OPEN = "open"
|
||||
|
||||
|
||||
def hold_reason(marks: Sequence[Mark], error: str | None) -> str | None:
|
||||
"""WHY this booth must not be swept, or None if it may be. THE hold predicate.
|
||||
|
||||
Returns a reason rather than a bool so the surface that has to say why can
|
||||
read it off the same value the sweeper acts on. A boolean plus a separate
|
||||
error string is two representations of one state, and they drift.
|
||||
|
||||
PURE — it takes the result of a read and does none of its own, so the index
|
||||
card and the sweeper cannot answer differently about the same booth. That is
|
||||
U1's rule (one resolver, every surface reads the record) applied to lifetime.
|
||||
|
||||
FAIL-SAFE ON BOTH LEVELS OF DAMAGE, which is the correction the bug-hunt
|
||||
panel forced (2026-09-22). `marks_for` is lenient because a review page that
|
||||
will not load is worse than one missing an annotation — the right trade for
|
||||
a RENDER and the wrong one for a DELETE, where the same leniency wipes the
|
||||
booth whose judgment we had just failed to read, artifacts and all. The
|
||||
first cut of this caught FILE-level damage only:
|
||||
|
||||
* file-level — `.marks.json` will not parse at all. `hold_read` reports it.
|
||||
* ENTRY-level — the document parses, but one mark fails normalization and
|
||||
`_hydrate_safe` hands back a `Mark` carrying `error`. `_is_open` returns
|
||||
False for an errored pick, ON PURPOSE (a broken pick can never be
|
||||
answered; the CLI spells that exit code 4) — so such a booth read as
|
||||
`not held` and SWEPT, while the panel beside it rendered the broken mark
|
||||
in full. Four arms found four ways into that shape; this was the worst.
|
||||
|
||||
A mark that cannot be read is judgment we cannot see. Deleting the booth it
|
||||
belongs to is the one thing we must not do with it.
|
||||
|
||||
Openness itself is `open_marks` and nothing else (U2 INV-2): a partially
|
||||
answered pick is STILL open and still holds, which is the reading that
|
||||
makes this rule correct rather than one that sweeps a review in flight.
|
||||
"""
|
||||
if error is not None or any(m.error is not None for m in marks):
|
||||
return HOLD_UNREADABLE
|
||||
if open_marks(marks):
|
||||
return HOLD_OPEN
|
||||
return None
|
||||
|
||||
|
||||
def sweep_once(data_dir: Path, ttl_seconds: float, now: float | None = None) -> list[str]:
|
||||
@@ -232,10 +364,25 @@ def sweep_once(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
Only ever removes direct children of data_dir (never data_dir itself), and
|
||||
skips dotfolders so a stray control dir can opt out.
|
||||
|
||||
TWO exemptions, and this is the only function that honours either.
|
||||
|
||||
A booth carrying KEEP_MARKER is exempt no matter how stale it is. That is
|
||||
the one escape hatch from the 24h contract, and it is opt-in per booth: the
|
||||
default stays ephemeral, so nobody inherits a cleanup chore they did not ask
|
||||
for. Removing the sentinel hands the booth straight back to the sweeper.
|
||||
the explicit escape hatch, opt-in per booth: the default stays ephemeral, so
|
||||
nobody inherits a cleanup chore they did not ask for. Removing the sentinel
|
||||
hands the booth straight back to the sweeper.
|
||||
|
||||
A booth that is HELD — an open pick, or marks we cannot read — is exempt for
|
||||
as long as that holds (U4). This is the derived half: the operator was
|
||||
pressing `.forever` to mean "not yet" because nothing else could say it, and
|
||||
the service already knew. 17 of 24 live booths carried the sentinel on
|
||||
2026-09-22, and three of the four booths in the fleet awaiting an answer
|
||||
carried it too — the "not yet" case, caught in the act.
|
||||
|
||||
Reading the marks costs ONE strict read per booth per tick — `hold_read`,
|
||||
which answers both halves of the hold question at once. It is deliberately
|
||||
not two calls: two reads of one file are not one read of one state, and the
|
||||
pair that loses that race is the pair that deletes. Do not "optimize" this
|
||||
back into `marks_for` plus `read_error`.
|
||||
"""
|
||||
wiped: list[str] = []
|
||||
if not data_dir.is_dir():
|
||||
@@ -246,6 +393,8 @@ def sweep_once(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
try:
|
||||
if is_kept(child):
|
||||
continue
|
||||
if hold_reason(*hold_read(child)): # ONE read — see hold_read
|
||||
continue
|
||||
if is_expired(child, ttl_seconds, now):
|
||||
shutil.rmtree(child)
|
||||
wiped.append(child.name)
|
||||
@@ -278,7 +427,20 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
# flag a booth that is waiting on the operator. ONE file read per booth
|
||||
# — which is why marks live in one file per booth rather than a sidecar
|
||||
# per mark. This loop runs on every index page load.
|
||||
marks = marks_for(child)
|
||||
# ONE read for BOTH the badge and the lifetime decision. It has to be
|
||||
# one: `marks_for` is lenient, so an unreadable `.marks.json` reads as
|
||||
# no marks — fine for a card, wrong for the reaper, which would then
|
||||
# delete the booth whose judgment it had just failed to read. And
|
||||
# asking the two questions with two reads is not one read of one state:
|
||||
# a write landing between them yields `([], None)`, the pair that
|
||||
# deletes. `hold_read` answers both from one read; the lenient reader
|
||||
# comes back only on the error path, where leniency is the point.
|
||||
held_marks, read_err = hold_read(child)
|
||||
# The DECISION comes from that one read and nothing else. The lenient
|
||||
# re-read below is for DISPLAY only — feeding it back into the predicate
|
||||
# would rebuild the two-read seam this call exists to close.
|
||||
hold = hold_reason(held_marks, read_err)
|
||||
marks = held_marks if read_err is None else marks_for(child)
|
||||
# The booth's own announcement — who posted it and why. One more small
|
||||
# read per booth, beside the marks read already here, and `read_manifest`
|
||||
# cannot raise for the same reason `marks_for` must not: this loop runs
|
||||
@@ -314,6 +476,13 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
# tested `answer is None`, so a half-answered pick read as closed
|
||||
# here while the panel beside it rendered `◐ partial`.
|
||||
"marks_open": len(open_marks(marks)),
|
||||
# U4: WHY this booth is or is not counting down. The card must
|
||||
# never just stop the clock silently — `.forever` was at least
|
||||
# visible as a lane, and an invisible rule would be worse than
|
||||
# the boolean it replaces.
|
||||
# WHY it is or is not counting down — the reason, not a bool
|
||||
# beside a string that can disagree with it.
|
||||
"hold": hold,
|
||||
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
|
||||
"mtime": mtime,
|
||||
}
|
||||
@@ -689,6 +858,10 @@ def create_app(
|
||||
@app.get("/b/{name}/", response_class=HTMLResponse)
|
||||
def booth_view(request: Request, name: str, download: int = 0):
|
||||
booth = resolve_booth(name)
|
||||
# U4: viewing is activity. ABOVE both early returns — the zip download
|
||||
# and the verbatim-index.html branch are looks at this booth too, and a
|
||||
# verbatim report is the shape the operator stares at longest.
|
||||
record_view(booth)
|
||||
if download:
|
||||
# whole-booth zip — the download path for a verbatim index.html booth
|
||||
# (which has no gallery/per-file chrome), and a "download all" for any.
|
||||
@@ -721,7 +894,9 @@ def create_app(
|
||||
it for it in build_gallery(booth)
|
||||
if not ((booth / LINKS_FILE).is_file() and it["name"] == LINKS_FILE)
|
||||
]
|
||||
marks = marks_for(booth)
|
||||
held_marks, read_err = hold_read(booth) # ONE read; see list_booths
|
||||
hold = hold_reason(held_marks, read_err)
|
||||
marks = held_marks if read_err is None else marks_for(booth)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"booth.html",
|
||||
@@ -764,6 +939,10 @@ def create_app(
|
||||
# a booth URL handed to the operator lands HERE, never on the
|
||||
# index, and job 5 is "operator, look at this".
|
||||
"manifest": read_manifest(booth),
|
||||
# The lifetime line, same three states as the index card: a
|
||||
# booth URL handed to the operator lands HERE, not on the index,
|
||||
# so "why is this not counting down" has to be answerable here.
|
||||
"hold": hold,
|
||||
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
|
||||
},
|
||||
)
|
||||
@@ -973,13 +1152,29 @@ def create_app(
|
||||
place a verbatim-index.html booth can show its marks — that page is served
|
||||
untouched by design, so the inline panel never renders there."""
|
||||
booth = resolve_booth(name)
|
||||
marks = marks_for(booth)
|
||||
# U4: for a verbatim booth this IS the booth page. `/b/<n>/asks` is a
|
||||
# 308 into here, so the legacy URL records through this call and must
|
||||
# not get one of its own.
|
||||
record_view(booth)
|
||||
held_marks, read_err = hold_read(booth) # ONE read; see list_booths
|
||||
hold = hold_reason(held_marks, read_err)
|
||||
marks = held_marks if read_err is None else marks_for(booth)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"marks.html",
|
||||
{**base_ctx, "name": name, "name_url": quote(name, safe=""),
|
||||
"marks": marks, "marks_open": len(open_marks(marks)),
|
||||
"booth_marks": marks_for_target(marks, None), "marks_page": True},
|
||||
"booth_marks": marks_for_target(marks, None), "marks_page": True,
|
||||
# U4 INV-4, and this page is WHY the invariant needs a third home.
|
||||
# A verbatim booth's own index.html is served untouched, so it has
|
||||
# no Booth-rendered header to carry the lifetime line — this page
|
||||
# is the only surface besides the index card where the Booth owns
|
||||
# the chrome. Without it, the booths most likely to be held (a
|
||||
# report that ASKS something is the archetype) would be the ones
|
||||
# that never say they are.
|
||||
"kept": is_kept(booth),
|
||||
"hold": hold,
|
||||
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth))},
|
||||
)
|
||||
|
||||
@app.get("/b/{name}/asks", include_in_schema=False)
|
||||
@@ -1029,6 +1224,14 @@ def create_app(
|
||||
|
||||
items = booth_items(booth)
|
||||
item = find_item(items, f)
|
||||
# U4: a bookmarked zoom URL is somebody looking — but only once we know
|
||||
# there is an ITEM to look at. Below the 404s, and gated on the record,
|
||||
# because `f` is any path that stats inside the booth: the bug-hunt
|
||||
# panel pointed `?f=.marks.lock` at this and held a booth open with a
|
||||
# file the service created itself. A dotfile is not an item, and a view
|
||||
# of a thing that is not an item is not a view of the booth.
|
||||
if item is not None:
|
||||
record_view(booth)
|
||||
marks = marks_for(booth)
|
||||
item_marks = marks_for_target(marks, f)
|
||||
common = {
|
||||
@@ -1210,7 +1413,35 @@ def create_app(
|
||||
@app.post("/b/{name}/unkeep")
|
||||
def booth_unkeep(name: str, next: str = Form("/")):
|
||||
# missing_ok: releasing an already-released board is a no-op, not a 500.
|
||||
(resolve_booth(name) / KEEP_MARKER).unlink(missing_ok=True)
|
||||
booth = resolve_booth(name)
|
||||
marker = booth / KEEP_MARKER
|
||||
try:
|
||||
marker.unlink()
|
||||
released = True
|
||||
except FileNotFoundError:
|
||||
released = False # already released: a no-op, not a 500
|
||||
except OSError:
|
||||
# A `.forever` that is a DIRECTORY raised IsADirectoryError straight
|
||||
# through this route and 500'd it, which made the card's release
|
||||
# button permanently dead for that booth. Pre-existing; the panel
|
||||
# re-exposed it. Removing it is still best-effort, and failing to is
|
||||
# not worth refusing the request over.
|
||||
released = False
|
||||
if not released:
|
||||
return RedirectResponse(url=_safe_next(next), status_code=303)
|
||||
# U4: RELEASE IS ACTIVITY, and now it is a rule rather than an accident.
|
||||
# A released board already survived another full TTL, because unlinking
|
||||
# a file bumps the directory's mtime — behaviour the note above calls
|
||||
# "not intuitive" precisely because nothing declared it. The behaviour
|
||||
# is unchanged; its reason is now stated. Releasing a board is somebody
|
||||
# touching it, so it gets one full TTL, the same as any other look.
|
||||
#
|
||||
# ONLY when something was actually released, which is the correction the
|
||||
# bug-hunt panel forced: an unconditional call made POSTing release at
|
||||
# an already-released booth an endless TTL refresh, contradicting this
|
||||
# route's own no-op promise and diverging from the CLI, which `rm`s the
|
||||
# sentinel without recording anything.
|
||||
record_view(booth)
|
||||
return RedirectResponse(url=_safe_next(next), status_code=303)
|
||||
|
||||
@app.post("/b/{name}/blur")
|
||||
|
||||
+40
-1
@@ -217,12 +217,21 @@ def _fingerprint(entries: list[dict]) -> str:
|
||||
return json.dumps(entries, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
def _read_raw_strict(booth: Path) -> list[dict]:
|
||||
def _read_raw_strict(booth: Path, *, blank_is_corrupt: bool = False) -> list[dict]:
|
||||
"""Like `_read_raw`, but RAISES `MarksCorrupt` on a file it cannot parse.
|
||||
|
||||
Absent, empty and valid-but-empty are all "no marks yet" and are fine — the
|
||||
distinction that matters is bytes-present-but-unreadable, because that is the
|
||||
case where writing would destroy something.
|
||||
|
||||
`blank_is_corrupt` is the DELETE path's reading of a present-but-whitespace
|
||||
file, and only the delete path's: this writer never produces a blank marks
|
||||
document, so a blank one that exists is something that went wrong, and
|
||||
`rmtree` is not the response to that. The write path keeps the lenient
|
||||
reading — a blank file is safe to overwrite, which is the question
|
||||
`_Locked` is asking. A VALID document with an empty `marks` list is not
|
||||
blank and never holds: that is what deleting the last mark leaves behind,
|
||||
and it must stay sweepable.
|
||||
"""
|
||||
path = Path(booth) / MARKS_FILE
|
||||
try:
|
||||
@@ -245,6 +254,8 @@ def _read_raw_strict(booth: Path) -> list[dict]:
|
||||
except (OSError, UnicodeDecodeError, MemoryError) as exc:
|
||||
raise MarksCorrupt(f"{path} cannot be read: {exc}") from exc
|
||||
if not text.strip():
|
||||
if blank_is_corrupt:
|
||||
raise MarksCorrupt(f"{path} is present but holds no marks document")
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(text)
|
||||
@@ -545,6 +556,34 @@ def open_marks(marks: Sequence[Mark]) -> list[Mark]:
|
||||
return [m for m in marks if _is_open(m)]
|
||||
|
||||
|
||||
def hold_read(booth: Path) -> tuple[list[Mark], str | None]:
|
||||
"""ONE read of `.marks.json`, answering both questions the LIFETIME rule asks:
|
||||
what is still open, and whether the file could be read at all.
|
||||
|
||||
U4 decides whether a booth may be SWEPT from those two facts. Asking them
|
||||
with two calls — `marks_for` then `read_error` — reads the file twice, and
|
||||
two reads of one file are not one read of one state: a write or a repair
|
||||
landing between them yields a pair that never described the booth at any
|
||||
instant. The losing pair is `([], None)` — no marks, no error — which is
|
||||
exactly the one that deletes. Cross-frontier review (2026-09-22) found it;
|
||||
that is why this exists rather than the obvious two calls.
|
||||
|
||||
When the file reads clean the marks are byte-identical to `marks_for`'s:
|
||||
`_read_raw_strict` raises rather than dropping an entry, so a non-raising
|
||||
strict read returns the same entries the lenient read would, hydrated and
|
||||
sorted the same way. The caller can therefore use this ONE read for the
|
||||
display too, and fall back to `marks_for` only on the error path, where
|
||||
leniency is the point.
|
||||
"""
|
||||
try:
|
||||
entries = _read_raw_strict(booth, blank_is_corrupt=True)
|
||||
except MarksCorrupt as exc:
|
||||
return [], str(exc)
|
||||
marks = [_hydrate_safe(e) for e in entries]
|
||||
marks.sort(key=lambda m: (m.created, m.id))
|
||||
return marks, None
|
||||
|
||||
|
||||
def marks_for_target(marks: Sequence[Mark], rel: str | None) -> list[Mark]:
|
||||
"""The marks attached to one item, or to the booth itself for None."""
|
||||
return [m for m in marks if m.target == rel]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{# The lifetime line, defined ONCE and called from four surfaces: the index
|
||||
card (both lanes), the booth header (both branches) and the marks page.
|
||||
|
||||
U4: a booth's lifetime is derived from its own state, and a booth that is
|
||||
not counting down must always SAY WHY — an invisible rule that silently
|
||||
stopped the clock would be strictly worse than the `.forever` boolean it
|
||||
replaces, because that one was at least visible as a lane.
|
||||
|
||||
`hold` is the REASON, straight off `hold_reason()`, not a bool beside a
|
||||
string that can disagree with it. Kept wins over a hold because a kept booth
|
||||
is exempt either way, and showing two reasons for one EXEMPTION is the
|
||||
two-representations-of-one-state trap.
|
||||
|
||||
Unreadable marks are the exception and ride along even on a kept board:
|
||||
damaged judgment is not a second exemption, it is a thing somebody has to go
|
||||
and fix, and the kept lane holds the durable boards — the ones where losing
|
||||
the operator's marks costs most. #}
|
||||
{% macro lifetime(kept, hold, expires_in) -%}
|
||||
{%- if kept -%}
|
||||
kept{% if hold == "unreadable" %} · <span class="held held-broken" title="a mark in this booth cannot be read">marks unreadable</span>{% endif %}
|
||||
{%- elif hold == "unreadable" -%}
|
||||
<span class="held held-broken" title="a mark in this booth cannot be read, so the sweeper will not take it">held · marks unreadable</span>
|
||||
{%- elif hold == "open" -%}
|
||||
<span class="held" title="an unanswered question holds this booth open">held until answered</span>
|
||||
{%- else -%}
|
||||
expires in {{ expires_in|dur }}
|
||||
{%- endif -%}
|
||||
{%- endmacro %}
|
||||
@@ -330,6 +330,11 @@
|
||||
use for state), green check once answered; the accent is a TOP edge, per
|
||||
Australis, never a coloured left border. */
|
||||
.badge-mark{background:var(--aus-bright-yellow);color:var(--fg-on-accent)}
|
||||
/* U4: the lifetime line's HELD states. Marked rather than styled into
|
||||
invisibility — the whole safety argument for an unbounded hold is that
|
||||
a booth which stopped counting down says so where the countdown was. */
|
||||
.held{color:var(--aus-bright-yellow)}
|
||||
.held-broken{color:var(--fg-3);text-decoration:underline dotted}
|
||||
.thumb .badge+.badge-mark{top:2.2rem}
|
||||
.marks{display:flex;flex-direction:column;gap:.9rem;margin:.2rem 0 1.4rem}
|
||||
.mark{border:1px solid var(--border-subtle);border-top:2px solid var(--aus-bright-yellow);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_provenance.html" import provenance %}
|
||||
{% from "_lifetime.html" import lifetime %}
|
||||
{# The blur toggle, defined ONCE. There are three item branches in this file
|
||||
(doc / media / other) and the first cut of this feature patched only one of
|
||||
them, so docs rendered with no control at all. A macro makes "patched two of
|
||||
@@ -65,7 +66,7 @@
|
||||
{% else %}
|
||||
<h1>{{ name }}</h1>
|
||||
{% endif %}
|
||||
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
|
||||
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}</span>
|
||||
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
|
||||
{{ provenance(manifest) }}
|
||||
{# A durable multi-writer board gets no one-click wipe — same rule as the
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_provenance.html" import provenance %}
|
||||
{% from "_lifetime.html" import lifetime %}
|
||||
{% block content %}
|
||||
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
||||
<label class="drop" for="booth-files">
|
||||
@@ -39,7 +40,7 @@
|
||||
</a>
|
||||
<div class="meta">
|
||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · kept · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · {{ lifetime(true, b.hold, b.expires_in) }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||
{{ provenance(b.manifest) }}
|
||||
</div>
|
||||
{# There IS a × here now (operator, 2026-09-21). The old rule was
|
||||
@@ -111,7 +112,7 @@
|
||||
</a>
|
||||
<div class="meta">
|
||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · {{ lifetime(false, b.hold, b.expires_in) }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||
{{ provenance(b.manifest) }}
|
||||
</div>
|
||||
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_lifetime.html" import lifetime %}
|
||||
{% block title %}{{ name }} · marks · The Booth{% endblock %}
|
||||
{% block content %}
|
||||
{# The marks page for a booth whose own index.html is served VERBATIM. That page
|
||||
@@ -11,7 +12,7 @@
|
||||
{# `marks_open` comes from open_marks() — the ONE openness predicate (INV-2).
|
||||
This used to re-derive it in Jinja as `selectattr('answer', 'none')`, which
|
||||
read a half-answered pick as closed. #}
|
||||
<span class="sub">{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }}</span>
|
||||
<span class="sub">{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}</span>
|
||||
</div>
|
||||
{% if marks %}
|
||||
{% include "_marks.html" %}
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
---
|
||||
contract_version: "1.0"
|
||||
module: "booth.app (lifetime)"
|
||||
purpose: "A booth's lifetime stops being a boolean somebody remembered to press and becomes a fact derived from the booth's own state. Today there is ONE lifetime (24h from the newest mtime in the tree) and ONE escape hatch (`.forever`), and the measurement says the escape hatch is carrying the main load: 17 of 24 live booths (70%) hold the sentinel, up from the 13 of 24 (54%) counted on 2026-09-21. That is not `ephemeral with an exception`; it is two lifetimes wearing one lifetime's clothes, with the operator doing the sorting by hand. This unit adds the two facts the sweeper was missing -- a booth the operator still owes an answer to is HELD, and looking at a booth is ACTIVITY -- so the cases that were pressing `.forever` for `not yet` stop needing it, and `keep` is left meaning only what it says: this is durable."
|
||||
depends_on:
|
||||
- "booth.marks (`hold_read` -- ADDED BY THIS UNIT, the one-read pair the lifetime rule needs; and `open_marks` -- THE openness predicate, built for this unit and saying so in its own docstring: `Open is the reading that makes U4 correct: a lifetime rule that unpinned a booth on the first radio click would sweep a review in flight.` U4 CALLS it and does not re-derive it. Also `marks_for` (lenient read, never raises) and `read_error` (strict read, total -- it catches its own `MarksCorrupt` and returns a string). Verified against booth/marks.py, not against U2's contract prose: `marks_for` is `_read_raw` + `_hydrate_safe` + sort at marks.py:514; `read_error` is `_read_raw_strict` in a try/except at marks.py:262 and has no raising path.)"
|
||||
- "booth.items (the dotfile skip in `booth_items` at items.py -- `.viewed` is excluded from tiles, counts and zips by the EXISTING `p.name.startswith('.')` rule, exactly as `.marks.json`, `.booth.json` and `.forever` are. No new exclusion is added or needed.)"
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 130
|
||||
used_by:
|
||||
- "booth.app.sweep_once (gains the hold check beside the keep check -- the one place reaper policy lives)"
|
||||
- "booth.app.list_booths (the index card gains `held` and `marks_error`, so the card can say WHY it is not counting down)"
|
||||
- "booth.app.booth_view / booth_view_file / booth_marks_page (each records a view; `/b/<n>/asks` is a 308 redirect into the last of these and so needs no call of its own)"
|
||||
- "booth.app.booth_unkeep (release is activity -- stated, where it used to be an accident of directory mtime)"
|
||||
- "booth/templates/index.html, booth/templates/booth.html (the lifetime line: `expires in X` / `held until answered` / `kept`)"
|
||||
touches:
|
||||
- "booth/app.py (VIEW_MARKER, record_view, is_held; sweep_once, list_booths, booth_view, booth_view_file, booth_marks_page, booth_unkeep; the module docstring's lifetime paragraph)"
|
||||
- "booth/templates/index.html (the ephemeral card's sub-line becomes a three-state lifetime line)"
|
||||
- "booth/templates/booth.html (the same three-state line in the boothhead)"
|
||||
- "booth/templates/_lifetime.html (new -- the lifetime macro, defined ONCE and called from three surfaces. Not in the first draft of this inventory: a four-state conditional repeated three times is the blurtoggle lesson, and U5 had already established the partial as the house answer.)"
|
||||
- "booth/templates/marks.html (INV-4's third surface. A verbatim booth has no Booth-rendered header, so without this the booths most likely to be HELD -- a report that asks something -- would be the ones that never say so. Found by looking at the live service, not by the suite.)"
|
||||
- "booth/templates/base.html (one CSS rule for the held state)"
|
||||
- "scripts/booth (the header's `THE 24h RULE AND ITS ONE EXCEPTION` block, which states the old doctrine as the whole doctrine, and the `DO NOT unkeep and let it expire` block. The WARNING STAYS AND STAYS TRUE -- release still buys a full TTL, so unkeep-and-wait is still a delay rather than a delete. What changes is that it stops being phrased as a surprise about directory metadata and starts being phrased as the rule it now is. The paraphrase panel read the touches line as possibly meaning the advice was being retired; it is not.)"
|
||||
- "README.md (the TTL paragraph)"
|
||||
- "CLAUDE.md (invariant 2's dotfile list gains `.viewed`)"
|
||||
- "tests/test_lifetime.py (new)"
|
||||
- "tests/test_booth.py (ONE cross-reference comment. The draft said the two release-clock tests would gain an assertion that the marker is written; implementation showed they must not. `test_releasing_a_board_RESETS_its_ttl_clock` unlinks the sentinel BY HAND, not through the route, so it is a test of the mtime mechanism and asserting a route side-effect in it would be testing the wrong thing. The route behaviour is `test_releasing_a_board_RECORDS_A_VIEW` in the new file; the comment points at it. No existing assertion is touched.)"
|
||||
assumptions:
|
||||
- "A VIEW IS RECORDED AS A DOTFILE, AND THE EXISTING AGE RULE READS IT. `.viewed` is a dotfile but NOT a `.lock` dotfile, so `_newest_mtime` already counts it (app.py:220 excludes only `.<name>.lock`). There is therefore NO new arithmetic in `booth_age_seconds`, `is_expired` or `expires_in`: `age = now - newest mtime in the tree` is unchanged, and a view is simply one more thing in the tree. One mechanism, not two. This is the same reason `.booth.json` needed no integration work in U5."
|
||||
- "THE LOCK EXEMPTION IS WHY THIS IS SAFE. `_newest_mtime` excludes `.<name>.lock` because those are created by a READ-MODIFY-WRITE path, including one that changes nothing -- machinery, not activity. `.viewed` is the opposite: it is written only by a deliberate GET of a booth's own page. The exemption's rule (`machinery does not count, deliberate acts do`) is unchanged and this lands on the counted side of it."
|
||||
- "RECORDING A VIEW MUST NEVER FAIL THE REQUEST. `record_view` swallows `OSError` -- a read-only mount, a booth owned by another uid, a full disk. The same posture `marks._Locked.__enter__` takes on its `os.utime` and for the same reason, stated there: `Not putting the clock back is a cost this module can absorb; not answering the request is not.` A booth that cannot record a view simply expires on its content mtime, which is today's behaviour."
|
||||
- "HOLD IS FAIL-SAFE, WHERE READS ARE FAIL-OPEN. `marks_for` is lenient by design -- a damaged `.marks.json` reads as no marks, because a review surface that will not render is worse than one that has lost an annotation. That trade is right for a RENDER and wrong for a DELETE: the same leniency on the sweep path would wipe the booth whose judgment we had just failed to read, artifacts and all. So `is_held` treats an unreadable marks file as held. Reads lenient, deletes strict -- the same asymmetry U2 established between `marks_for` and `_Locked`, extended to the reaper. `THE REAPER` IS THE WHOLE SCOPE OF `deletes strict`, and the paraphrase panel ranked the ambiguity here first by stake: a HAND delete is never strict. `booth rm`, `POST /b/<n>/delete` and `DELETE /b/<n>` take a booth held by unreadable marks exactly as they take a kept one, which is what gives that hold -- the one nothing releases on its own -- an exit at all. Strictness is a property of the TIMER, never of the operator."
|
||||
- "THE LIFETIME DECISION COMES FROM ONE READ, and that is a correction to this contract's first draft. The draft specified `is_held(marks_for(child), read_error(child))` -- two reads, 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 and no error -- which is exactly the pair that DELETES. Hulda found it on the paraphrase round (2026-09-22) and it is the finding that changed code rather than prose. `booth.marks.hold_read(booth) -> (marks, error)` is the fix: one strict read answering both questions the lifetime rule asks, so `sweep_once` now does ONE read per booth per tick rather than 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, falling back to `marks_for` only on the error path, where leniency is the point."
|
||||
- "AN OPEN PICK HOLDS; A NOTE OR A FLAG DOES NOT. `_is_open` returns False for every shape but `pick`, and False for a pick carrying `error`. That is already correct for U4 and is NOT changed here: a note is the operator's output, not an owed answer, and a pick that hydrated broken can never be answered, so holding a booth on one would be holding it forever for nothing (the CLI already spells that case as exit code 4). A PARTIALLY-answered pick IS open and DOES hold -- operator-settled 2026-09-21, and the reason `open_marks` exists rather than an `answer is None` test."
|
||||
- "THE HOLD IS UNBOUNDED, AND THAT IS THE POINT -- BUT IT MUST BE VISIBLE. A booth with an unanswered pick is never swept, however old. This is a new way for a booth to become immortal, and it is deliberate: unanswered is unfinished. What makes it safe is not a bound, it is VISIBILITY plus TWO exits that already exist. The card and the booth header say `held until answered` in place of the countdown, so a booth that is not counting down always says why; and `booth rm` / the UI `x` delete a held booth exactly as before -- `sweep_once` is the only caller that honours a hold, precisely as it is the only caller that honours `is_kept`."
|
||||
- "`keep` IS UNCHANGED AND KEEPS ITS LANE. `.forever` still exempts, still renders in the kept lane, still round-trips through `booth keep` / `booth unkeep` and the UI. U4 does not deprecate it, narrow it or add a reason field to it. The prediction is that its RATE falls because the `not yet` cases stop needing it -- and a prediction is falsified by measuring, not by removing the thing being measured."
|
||||
- "THE MTIME-RESTORE RACE IN `marks._Locked.__enter__` IS EXPLICITLY CONSIDERED AND LEFT OPEN. The bug-hunt panel flagged it and it was held for U4 because closing it means changing TTL doctrine. U4's answer is that the doctrine stands: the clean fix (ignore a booth directory's own mtime whenever the booth holds anything) would close a two-syscall window that opens ONCE per booth ever, and would in exchange break every `rsync -a` populated booth -- which preserves source mtimes and so has ONLY the directory's freshness to look alive by, and which is the documented path for every host that is not nh3-dev. That is a larger hole than the one being closed. Decided, not deferred; see the OUT OF SCOPE section."
|
||||
open_questions:
|
||||
- "Whether `booth ls` should mark held booths the way it marks kept ones with a star. Cheap, and it would need `is_held` (or a stdlib-only sibling) reachable from the CLI. Sessions already have `booth marks`, which answers the same question about their own booth, so this is convenience rather than capability. Parked, not designed."
|
||||
- "Whether a booth held ONLY by an unreadable `.marks.json` should surface on the index as something to repair, beyond the `marks unreadable` label. It is a held booth that nothing will release, which is the one case where the unbounded hold has no natural exit. The label makes it visible; a repair affordance is a different unit."
|
||||
---
|
||||
|
||||
# U4 — derived lifetime
|
||||
|
||||
## The defect, stated precisely
|
||||
|
||||
> **One lifetime (24h from last touch) and one shape (a folder), serving five
|
||||
> jobs with different lifetimes.** — `docs/design/information-architecture.md`
|
||||
|
||||
`.forever` is the escape hatch for that mismatch, and the measurement says it is
|
||||
no longer an exception:
|
||||
|
||||
| date | booths carrying `.forever` | rate |
|
||||
|---|---|---|
|
||||
| 2026-09-21 (IA doc) | 13 of 24 | 54% |
|
||||
| 2026-09-21 (re-count) | 14 of 25 | 56% |
|
||||
| 2026-09-22 | **17 of 24** | **70%** |
|
||||
|
||||
Both the rate and the absolute count rose, so this is not the denominator
|
||||
shrinking as the sweeper ran. A boolean that 70% of the population sets is not
|
||||
an exception, it is the default with extra steps.
|
||||
|
||||
The reason it gets pressed is that it is the only way to say any of these:
|
||||
|
||||
| what the operator means | what he has to press |
|
||||
|---|---|
|
||||
| "this is a durable reference" | `.forever` |
|
||||
| "I have not answered the question yet" | `.forever` |
|
||||
| "I am still looking at this" | `.forever` |
|
||||
|
||||
Only the first is what `keep` means. The other two are facts the service already
|
||||
holds and does not consult: **there is an open pick in `.marks.json`**, and
|
||||
**somebody just loaded the page**. U4 consults them.
|
||||
|
||||
### The diagnosis has a live positive control
|
||||
|
||||
Counted 2026-09-22 against `~/booth-data`. A census of the whole population, not
|
||||
a sample, and every value is a deterministic file fact (existence, mtime) — so
|
||||
one observation per booth is the measurement, not an anecdote. The population
|
||||
churns (26 -> 24 over the previous session); re-count rather than trusting these.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| live booths | 24 |
|
||||
| carrying `.forever` | 17 (70%) |
|
||||
| carrying `.marks.json` at all | 4 |
|
||||
| of those, with an open pick | **4 of 4** |
|
||||
| **open pick AND `.forever`** | **3** |
|
||||
|
||||
Three of the four booths in the fleet that are waiting on an answer have ALSO
|
||||
been pinned by hand. That is the "not yet" case, caught in the act: the operator
|
||||
pressed the durable-reference sentinel because there was no other way to say
|
||||
"do not take this, I have not answered it". U4 makes those three stop needing it.
|
||||
|
||||
The staleness distribution says the same thing from the other side. Of the 17
|
||||
kept booths, **10 are under ONE day old** — younger than the TTL, so the
|
||||
sentinel has bought them nothing yet and was pressed pre-emptively. (An earlier
|
||||
draft of this paragraph said "12 under 1.5 days" and called that younger than
|
||||
the TTL; 1.5 days is not younger than 24 hours, and the claim only holds at the
|
||||
one-day line. Caught by the cross-frontier paraphrase panel, 2026-09-22 — the
|
||||
measurement was right and the sentence was not.) Only 4 are old enough
|
||||
(2.4-4.6 days) that `keep` is the only reason they still exist. A
|
||||
sentinel pressed on a booth that was in no danger is not a durability decision;
|
||||
it is "not yet", written in the only vocabulary available.
|
||||
|
||||
⚠ The hold's live blast radius is SMALL today — 4 booths have marks at all. The
|
||||
17-to-something prediction therefore rests on both halves of this unit, and on
|
||||
the sentinel becoming unnecessary rather than becoming forbidden. If the rate
|
||||
does not move, the honest readings are: the diagnosis was wrong, OR the habit
|
||||
outlived the need, and the fortnight re-count cannot tell those apart on its
|
||||
own. The three open-pick-plus-`.forever` booths are the ones to watch, because
|
||||
for them the mechanism is now unambiguous.
|
||||
|
||||
## The record
|
||||
|
||||
A booth is in exactly one lifetime state, decided in this order:
|
||||
|
||||
```
|
||||
KEPT .forever present never swept (unchanged)
|
||||
HELD an open pick, or a never swept while (new)
|
||||
.marks.json we cannot read that holds
|
||||
EPHEMERAL otherwise swept when
|
||||
age > ttl (unchanged)
|
||||
```
|
||||
|
||||
`age` is unchanged: `now - _newest_mtime(booth)`, the newest mtime in the tree
|
||||
excluding `.<name>.lock`. **Viewing is folded in through that existing rule**,
|
||||
not beside it — a view writes `.viewed`, which is a dotfile and not a lock
|
||||
dotfile, so the age function already counts it. There is no new arithmetic.
|
||||
|
||||
### What counts as a view
|
||||
|
||||
One line, because CLAUDE.md invariant 6's test applies to rules as well as
|
||||
orders: **a deliberately-requested response FROM a booth's own page route is a
|
||||
view; a machine read, an asset fetch, and a request that does not resolve are
|
||||
not.**
|
||||
|
||||
Three words in that rule are load-bearing and the first draft said "HTML page",
|
||||
which was wrong twice. `?download=1` is a zip served by the booth-page route and
|
||||
IS a view — the operator asking for the whole booth is as deliberate as looking
|
||||
at it. And a `/view?f=<missing>` that 404s is NOT one: the route matters, but so
|
||||
does whether anything was served, or a crawler walking dead zoom URLs holds a
|
||||
booth open forever. `record_view` therefore sits below the zoom route's file
|
||||
validation and above the booth route's verbatim/zip fork.
|
||||
|
||||
| route | view? | why |
|
||||
|---|---|---|
|
||||
| `GET /b/<n>/` | **yes** | the booth page — gallery, verbatim report, or `?download=1` zip |
|
||||
| `GET /b/<n>/view?f=…` | **yes** | the zoom / doc page; a bookmarked zoom URL is somebody looking |
|
||||
| `GET /b/<n>/marks` | **yes** | the standalone judgment page — for a verbatim booth this IS the booth page |
|
||||
| `GET /b/<n>/marks.json` | no | a session polling. An agent must not be able to hold its own booth open |
|
||||
| `GET /b/<n>/<file>` | no | issued BY the page. A hotlinked image would otherwise keep a booth alive |
|
||||
| `GET /` | no | the IA's rule: "deliberate act, so it cannot be triggered by browsing the index" |
|
||||
| `GET /healthz` | no | a monitor is not a viewer |
|
||||
|
||||
⚠ Named rather than hidden: `scripts/layout-probe.py` sweeps every booth page,
|
||||
so running it resets every booth's clock. That is the correct reading of the
|
||||
rule (it is a GET of every booth page), it is recoverable (one extra TTL), and
|
||||
it is a dev tool. A note goes in the probe.
|
||||
|
||||
⚠ A browser that speculatively prefetches a hovered link records a view the
|
||||
operator did not quite take. Accepted: the failure mode is a booth living one
|
||||
extra day because he nearly opened it, and the alternative is sniffing
|
||||
`Sec-Fetch-*` headers, which is a fragile rule pretending to be a crisp one.
|
||||
|
||||
**Checked, because it would have been silent:** nothing in the fleet polls a
|
||||
booth *page*. Homepage's `siteMonitor` for the Booth is
|
||||
`http://10.100.10.50:8090/healthz`, which is on the not-a-view list; there is no
|
||||
cron entry and no systemd timer touching `/b/…`. Had Homepage been pointed at a
|
||||
booth URL instead, every booth would have become immortal on deploy and nothing
|
||||
would have reported it.
|
||||
|
||||
### Release is activity, on purpose
|
||||
|
||||
Removing `.forever` bumps the booth directory's mtime, so a released board
|
||||
survives another full TTL. Today that is an **accident** of directory metadata
|
||||
that `app.py` documents as "not intuitive" and `scripts/booth` warns against.
|
||||
|
||||
U4 does not change the behaviour and does not retire the test that pins it. It
|
||||
changes the behaviour's *reason*: `booth_unkeep` calls `record_view`, so a
|
||||
released board gets one full TTL because **releasing a board is somebody
|
||||
touching it**, which is a rule, and no longer because of which syscall happened
|
||||
to write a directory entry, which is not.
|
||||
|
||||
The existing tests (`test_releasing_a_board_RESETS_its_ttl_clock`,
|
||||
`test_released_board_is_sweepable_once_it_ages_again`) are untouched, and that
|
||||
is a correction to this contract's first draft, which said they would each gain
|
||||
an assertion that the marker is present. They must not: the first one unlinks
|
||||
the sentinel **by hand**, not through the route, so it is a test of the mtime
|
||||
mechanism and a route side-effect does not belong in it. The route behaviour
|
||||
gets its own test in the new file, and the old test gains a comment pointing at
|
||||
it.
|
||||
|
||||
**The marker's mtime must be NOW**, which `Path.touch()` gives and which the
|
||||
contract's first draft left unsaid. An implementation that wrote the file with
|
||||
any older timestamp would satisfy "the marker is there" while the extra TTL
|
||||
still came from the directory-mtime accident this section exists to replace —
|
||||
the new reason would be decoration over the old mechanism. Flagged by the
|
||||
paraphrase panel, 2026-09-22.
|
||||
|
||||
## Signatures
|
||||
|
||||
```python
|
||||
# booth/app.py
|
||||
|
||||
VIEW_MARKER = ".viewed"
|
||||
"""Records the last deliberate look at a booth. A dotfile, so `booth_items`
|
||||
skips it and it costs nothing in counts, galleries or zips — and NOT a `.lock`
|
||||
dotfile, so `_newest_mtime` counts it and the existing age rule picks up the
|
||||
view with no new arithmetic."""
|
||||
|
||||
|
||||
def record_view(booth: Path) -> None:
|
||||
"""Note that somebody deliberately looked at this booth.
|
||||
|
||||
Touches VIEW_MARKER; `_newest_mtime` does the rest. NEVER raises: a
|
||||
read-only mount, a booth we do not own or a full disk cost the timestamp,
|
||||
not the page. A booth whose view cannot be recorded simply ages on its
|
||||
content mtime, which is today's behaviour for every booth.
|
||||
"""
|
||||
|
||||
|
||||
def is_held(marks: Sequence[Mark], error: str | None) -> bool:
|
||||
"""True if this booth still owes the operator an answer and must not be swept.
|
||||
|
||||
PURE — it takes the result of a read and does none of its own, so the index
|
||||
card and the sweeper cannot answer differently about the same booth. That
|
||||
is U1's rule (one resolver, every surface reads the record) applied to
|
||||
lifetime.
|
||||
|
||||
FAIL-SAFE on `error`. `marks_for` is lenient because a review page that
|
||||
will not render is worse than one missing an annotation; the same leniency
|
||||
on the DELETE path would wipe the booth whose judgment we had just failed
|
||||
to read. Reads lenient, deletes strict.
|
||||
|
||||
Openness itself is `open_marks` and nothing else (U2 INV-2).
|
||||
"""
|
||||
return error is not None or bool(open_marks(marks))
|
||||
```
|
||||
|
||||
`is_expired` is **unchanged** and stays a pure age question — the existing
|
||||
separation ("expiry arithmetic and reaper policy are kept apart so they cannot
|
||||
drift into each other") is the reason `is_kept` is not consulted there either.
|
||||
`sweep_once` remains the only caller that honours a pin, and now honours two.
|
||||
|
||||
```python
|
||||
# booth/marks.py — stdlib only, like the rest of that module
|
||||
|
||||
def hold_read(booth: Path) -> tuple[list[Mark], str | None]:
|
||||
"""ONE read of `.marks.json`, answering BOTH questions the lifetime rule
|
||||
asks: what is still open, and whether the file could be read at all.
|
||||
|
||||
Two calls would read the file twice, and two reads of one file are not one
|
||||
read of one state — the pair that loses the race is `([], None)`, which is
|
||||
the pair that deletes.
|
||||
|
||||
On a clean file the marks are what `marks_for` would return, because
|
||||
`_read_raw_strict` raises rather than dropping an entry. So one read serves
|
||||
the badge too, and the lenient reader comes back only on the error path.
|
||||
"""
|
||||
```
|
||||
|
||||
```python
|
||||
def sweep_once(data_dir, ttl_seconds, now=None) -> list[str]:
|
||||
...
|
||||
if is_kept(child):
|
||||
continue
|
||||
if is_held(*hold_read(child)): # NEW — ONE read
|
||||
continue
|
||||
if is_expired(child, ttl_seconds, now):
|
||||
shutil.rmtree(child)
|
||||
```
|
||||
|
||||
```python
|
||||
def list_booths(data_dir, ttl_seconds, now=None) -> list[dict]:
|
||||
...
|
||||
marks, marks_error = hold_read(child) # NEW — one read, both facts
|
||||
if marks_error is not None:
|
||||
marks = marks_for(child) # lenient, for the panel
|
||||
booths.append({
|
||||
...
|
||||
"marks_error": marks_error, # NEW — the card says so
|
||||
"held": is_held(marks, marks_error), # NEW — the same predicate
|
||||
})
|
||||
```
|
||||
|
||||
## What renders
|
||||
|
||||
The lifetime line, on the ephemeral index card and in the booth header. Three
|
||||
states, one of which is new:
|
||||
|
||||
| state | line | why |
|
||||
|---|---|---|
|
||||
| ephemeral | `12 items · expires in 3h 20m` | unchanged |
|
||||
| held, open pick | `12 items · held until answered` | says what holds it AND what releases it |
|
||||
| held, unreadable | `12 items · held · marks unreadable` | the one hold nothing will release on its own |
|
||||
| kept | `12 items · kept` | unchanged, kept lane |
|
||||
|
||||
**The hold REPLACES the countdown at every age, not only once the booth is
|
||||
old.** A held booth that is four hours old shows `held until answered`, not
|
||||
`expires in 20h`. `expires_in` is still computed and still correct (INV-1);
|
||||
it is simply not what the surface says, because a number counting down to a
|
||||
deletion that will not happen is the silent-stopped-clock failure in its other
|
||||
costume — the screen announcing an expiry the sweeper will never carry out.
|
||||
Flagged as readable-two-ways by the paraphrase panel, 2026-09-22; settled here.
|
||||
|
||||
A booth that is not counting down **always says why**. That is the whole safety
|
||||
argument for an unbounded hold: `.forever` was at least visible as a lane; an
|
||||
invisible rule that silently stops the clock would be strictly worse than the
|
||||
boolean it replaces.
|
||||
|
||||
**Three surfaces, not two**, and the third was found by looking at the live
|
||||
service rather than by the suite. A verbatim booth's own `index.html` is served
|
||||
untouched by design, so it has no Booth-rendered header for the line to live in
|
||||
— and a report that ASKS the operator something is the archetype of a held
|
||||
booth. `GET /b/<n>/marks` is the only other page whose chrome the Booth owns, so
|
||||
the line goes there too. Without it, the booths most likely to be held would be
|
||||
exactly the ones that never said they were. (U3 is the unit that gives a
|
||||
verbatim booth real chrome; until then, this is the honest coverage.)
|
||||
|
||||
Kept beats held in the display, because a kept booth is in the kept lane and is
|
||||
exempt either way — showing two reasons for one exemption is the
|
||||
two-representations-of-one-state trap `flag_id`'s docstring names.
|
||||
|
||||
**An unreadable marks file is the exception, and it rides along even on a kept
|
||||
board**: `kept · marks unreadable`. Damaged judgment is not a second exemption,
|
||||
it is a thing somebody has to go and fix, and the kept lane holds the durable
|
||||
boards — the ones where losing the operator's marks costs most. A kept card that
|
||||
said only `kept` would hide the single case that needs a human. The card's
|
||||
`held` and `marks_error` are therefore RAW FACTS, true regardless of keep, and
|
||||
only the display has a precedence. The paraphrase panel found the two readings
|
||||
of "exactly one lifetime state" that this settles.
|
||||
|
||||
## Scope — the blast-radius pass
|
||||
|
||||
`graphify explain` on `sweep_once`, `is_kept`, `list_booths`, `_newest_mtime`,
|
||||
`booth_age_seconds`, `open_marks`, `KEEP_MARKER`, cross-checked with grep.
|
||||
|
||||
Graphify reported the call structure and, as expected, **missed both route
|
||||
callers of `list_booths`** (`index()` and `healthz()`, now at app.py:761 and :774) —
|
||||
they are function-local inside `create_app`, which is the known AST blind spot.
|
||||
Grep caught them. Neither tool alone was sufficient; this is the third unit in
|
||||
a row where that has been true.
|
||||
|
||||
**Production, 7 files:** `booth/app.py`, `booth/marks.py` (`hold_read`, added),
|
||||
`booth/templates/_lifetime.html` (new), `booth/templates/index.html`,
|
||||
`booth/templates/booth.html`, `booth/templates/marks.html`,
|
||||
`booth/templates/base.html`.
|
||||
**Docs/CLI, 4 files:** `scripts/booth`, `scripts/layout-probe.py`, `README.md`,
|
||||
`CLAUDE.md`.
|
||||
**Tests, 2 files:** `tests/test_lifetime.py` (new), `tests/test_booth.py`.
|
||||
|
||||
⚠ This census said "Production, 4 files" in the first draft and omitted
|
||||
`_lifetime.html`, `marks.html`, `marks.py` and `layout-probe.py` — three of
|
||||
which the body text elsewhere required, which is the contradiction both
|
||||
Gróa and Hulda flagged independently. An inventory that disagrees with the
|
||||
prose next to it is worse than no inventory: it reads as a closed set.
|
||||
|
||||
Not touched, and checked rather than assumed: `booth/items.py`,
|
||||
`booth/manifest.py`, `booth/links.py`, `booth/asks.py`, `booth/inline.py`.
|
||||
|
||||
## The three cross-frontier panels, and what they changed
|
||||
|
||||
All three ran on 2026-09-22 and all three earned their place — and each found
|
||||
a class the other two could not. Triaged per the cross-frontier discipline
|
||||
rather than adopted.
|
||||
|
||||
**Paraphrase panel** (`01M34VX0SH23Y3VC92E7GM4S70`, four arms). Seven flags.
|
||||
Five folded into the prose above: the hold replacing the countdown at every
|
||||
age, `deletes strict` scoping to the reaper alone, the zip and the 404 in the
|
||||
view rule, the marker's mtime, and the CLI warning staying true. Two changed
|
||||
more than wording:
|
||||
|
||||
- **Hulda — the two reads are not one state.** The only finding on this round
|
||||
that changed CODE. See the `hold_read` assumption in the frontmatter.
|
||||
- **Gróa and Hulda, independently — the blast-radius census contradicted the
|
||||
prose beside it.** It named four production files while the body required
|
||||
three more. An inventory that disagrees with its own document is worse than
|
||||
none, because it reads as a closed set.
|
||||
|
||||
Hulda also caught a number: this contract claimed 12 kept booths were "under
|
||||
1.5 days old — younger than the TTL". One and a half days is not younger than
|
||||
twenty-four hours. The measurement was right, the sentence was not, and it is
|
||||
the one place the diagnosis overstated itself.
|
||||
|
||||
**Code-vs-contract panel** (`01M34WAFJC3RTERFYBBZJN1SVG`, four arms). **All
|
||||
four arms found the same drift** — the strongest signal either panel produced
|
||||
on this unit. The booth header's sub-line forks on `{% if board %}`, and the
|
||||
lifetime macro sat only in the `{% else %}`, so a booth carrying `links.md`
|
||||
rendered a link count and nothing at all about its lifetime. INV-4 says the
|
||||
templates have no path that renders neither; that was a path, reachable by the
|
||||
release button or by a hand-made board.
|
||||
|
||||
Regin and Kimi recommended amending INV-4 to carve the board header out, on the
|
||||
grounds that board-header layout belongs to U7. **Declined; the code is fixed
|
||||
instead.** Cutting an invariant down to fit an implementation gap is the wrong
|
||||
direction when the fix is one template edit, and U7 owns navigation and section
|
||||
layout — not whether a header states a lifetime. Gróa's "fix it" was right.
|
||||
|
||||
The same panel showed that **most of the INV falsifier tests did not
|
||||
discriminate**, which is the more useful half of the round. The header test
|
||||
never rendered a board. The kept-beats-held test only rendered the index, where
|
||||
kept cards took a hardcoded string and never reached the macro at all. The
|
||||
INV-5 test called `record_view` directly instead of GETting the routes the
|
||||
invariant is about. The INV-7 tests asserted the marker's absence rather than
|
||||
the age, so a handler writing any other non-dot file would have passed. INV-6's
|
||||
had no doomed sibling, so "spare everything" would have passed. Each is now
|
||||
written to fail under the change that defeats it, and the board-header pair was
|
||||
verified RED against the pre-fix template rather than assumed.
|
||||
|
||||
**Bug-hunt panel** (`01M34Y2R0RAJRSN36Q8K4KAB36`, four arms). The round that
|
||||
changed the most code, and the one that found a class the other two could not
|
||||
see by construction: **a read that FAILED still resolving to "no hold", and
|
||||
therefore to a delete.** That is the invariant this unit declared to the panel,
|
||||
and the panel found **four independent paths through it. No single arm found
|
||||
all four.**
|
||||
|
||||
1. **An entry-level hydration error lost its hold.** `.marks.json` parses, one
|
||||
mark fails normalization, `_hydrate_safe` returns a `Mark` carrying `error`,
|
||||
and `_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 was
|
||||
built for FILE-level damage and missed ENTRY-level. This is the strongest
|
||||
finding of all three rounds.
|
||||
2. **A present-but-blank `.marks.json` swept.** `_read_raw_strict` early-returns
|
||||
for whitespace-only content — right for the write path it was written for,
|
||||
wrong for the delete path. Our writer never produces a blank marks document,
|
||||
so a blank one that exists is something that went wrong.
|
||||
3. **`_newest_mtime` returned 0.0 when the booth's own stat failed**, making it
|
||||
maximally ancient and therefore the FIRST thing the sweeper takes. Pre-dates
|
||||
U4; U4 is what turned the age read into a life-or-death read.
|
||||
4. **`is_kept` collapsed a stat failure into not-kept.** `Path.exists()` maps
|
||||
ELOOP and EACCES to False, so a kept booth whose sentinel could not be
|
||||
stat'd became sweepable.
|
||||
|
||||
**`is_held` is gone; `hold_reason` replaced it.** A boolean plus a separate
|
||||
error string is two representations of one state, and Regin independently
|
||||
flagged that the display could not distinguish the two holds. One function now
|
||||
returns the REASON — `"open"`, `"unreadable"`, or None — and every surface reads
|
||||
it off the same value the sweeper acts on. That closes findings 1 and Regin's
|
||||
together, which is why it is a rewrite rather than an extra clause.
|
||||
|
||||
**Convergent, 3-of-4: `record_view` followed a planted symlink.** `Path.touch()`
|
||||
follows an existing link, so a booth carrying `.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 an `O_NOFOLLOW` create plus `os.utime(fd)`, so a planted link raises
|
||||
ELOOP into the existing swallow and view-recording quietly stops for that booth.
|
||||
The `utime` is also what makes the marker read as NOW, which this contract
|
||||
already required and `O_CREAT` alone does not do.
|
||||
|
||||
**Two more the panel found in code this unit touched:**
|
||||
|
||||
- **`?f=.marks.lock` held a booth open.** The zoom route recorded a view for any
|
||||
path that stats inside the booth, including a lock file the service created
|
||||
itself. `record_view` now sits below `find_item` and fires only for a real
|
||||
item — which also makes the comment beside it true, where before it claimed
|
||||
more than the code did.
|
||||
- **Releasing an ALREADY-released booth refreshed its TTL forever.** The
|
||||
unconditional `record_view` on `unkeep` contradicted that route's own no-op
|
||||
promise and diverged from the CLI, which removes the sentinel without
|
||||
recording anything. Now gated on something actually having been released. The
|
||||
same edit fixes a pre-existing 500: a `.forever` that is a DIRECTORY raised
|
||||
`IsADirectoryError` straight through the route, which made the card's release
|
||||
button permanently dead for that booth.
|
||||
|
||||
**Also fixed: a docstring this unit's own fix made stale.** `sweep_once` still
|
||||
claimed "one lenient read plus one strict read" after `hold_read` reduced it to
|
||||
one. Kimi's framing is the right reason to care — a maintainer "optimizes" back
|
||||
to two calls on the comment's authority, and rebuilds the seam the function
|
||||
exists to kill.
|
||||
|
||||
**Re-declared as parked, not adopted:** Regin distinguished a stale-DECISION
|
||||
window (hold checked, then rmtree) from the torn-FILE race already parked at
|
||||
`park/booth-sweeper-rename-then-delete-to-close-the`. The distinction is real
|
||||
and the fix is the same rename-then-delete, so it parks with its sibling.
|
||||
|
||||
**Five pre-existing defects the panel surfaced in touched files** — a booth name
|
||||
reaching a JS string context, an unguarded `links.md` read, an index sort with
|
||||
no tie-breaker, `marks.json` reporting damage as empty success — are fixed in
|
||||
their own commit rather than smuggled into this unit's. See that commit.
|
||||
|
||||
⚠ **The capture tooling failed silently and the panel caught it, not us.** The
|
||||
`files/` tree shipped to the arms was EMPTY: the snapshot loop iterated `for f
|
||||
in $IN` over a multi-line variable, and **zsh does not word-split unquoted
|
||||
parameter expansions** the way bash does, so it ran once against a path that was
|
||||
the whole list. jekyll recovered by re-applying the bundled diff to HEAD and
|
||||
verified every file byte-identical, so the round is sound — but the failure mode
|
||||
is the dangerous one: an empty bundle reads exactly like a clean result.
|
||||
|
||||
## Seam review — against the real module surface
|
||||
|
||||
Checked against `booth/marks.py` itself, not against U2's contract prose.
|
||||
|
||||
| borrowed | real surface | verdict |
|
||||
|---|---|---|
|
||||
| `open_marks(marks)` | `marks.py:542`, takes `Sequence[Mark]`, returns `list[Mark]` | matches |
|
||||
| `marks_for(booth)` | `marks.py:514`, `_read_raw` + `_hydrate_safe` + sort; total | matches |
|
||||
| `read_error(booth)` | `marks.py:262`, returns `str \| None`, catches its own `MarksCorrupt` | matches — **and it is total**, which `is_held`'s fail-safe branch depends on |
|
||||
| `_is_open` semantics | `marks.py:525`: `pick` only, `error is None`, partial counts open | matches the assumption above |
|
||||
| `_newest_mtime` lock rule | `app.py:220`: skips `p.name.startswith(".") and p.name.endswith(".lock")` | `.viewed` is counted — confirmed at the source, not inferred |
|
||||
| `Mark` import in app.py | app.py:145-160 imports `open_marks`, `marks_for`, `marks_for_target`, `as_dict` — **not `Mark`** | `is_held`'s annotation needs `Mark` added to that import list |
|
||||
| `read_error` import in app.py | **not imported either** — U2 left it to the CLI, which is its only caller today | must be added to the same block; U4 is its first in-service consumer |
|
||||
| `zip_booth` dotfile skip | `app.py:445`ff: `p.is_file() and not p.name.startswith(".")` | `.viewed` never reaches a zip — confirmed, not inferred from `booth_items` |
|
||||
| `booth_items` dotfile skip | `items.py:182`: `not p.is_file() or p.name.startswith(".")` | `.viewed` is not an item |
|
||||
| `GET /b/<n>/asks` | `app.py:1105`, a **308 redirect** to `/marks`, not its own render | records a view through the `/marks` handler. No separate call, and adding one would double-count |
|
||||
| route concurrency | `booth_view`, `booth_view_file`, `booth_marks_page` are all `def`, not `async def` | FastAPI runs them in a threadpool, so `record_view`'s write cannot block the event loop |
|
||||
|
||||
Three rows of that table are the kind of thing only this pass finds: the cold
|
||||
panel reads one contract, and a signature that is fine in isolation says nothing
|
||||
about whether the name it needs is in scope at the call site.
|
||||
|
||||
**SR-1 — why `read_error` is safe to call per booth per index load, which the
|
||||
signatures alone do not say.** `_read_raw_strict` checks `S_ISREG` *before* it
|
||||
calls `read_text` (marks.py:236). That ordering is the v0.2.2 fix: `st_size` is
|
||||
0 for a FIFO and 0 for a symlink to `/dev/zero`, so a size cap alone lets both
|
||||
through and `read_text` then either blocks with no EOF or allocates until the
|
||||
kernel intervenes — across every booth, on `GET /`, which is a service-wide
|
||||
hang rather than one bad card. U4's decision to spend a second read on the hot
|
||||
path depends on that guard already being there. It is; checked at the source.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **A bound on the hold.** An abandoned pick holds its booth forever. Detecting
|
||||
"abandoned" needs state the Booth does not have (is any session still
|
||||
polling?), and the honest alternative — an arbitrary N-day cap — trades a
|
||||
visible immortal booth for a silent deletion of an open question. Visibility
|
||||
plus `booth rm` is the answer for v1.
|
||||
- **A reason string on `.forever`.** "keep survives as an explicit, reasoned
|
||||
pin" is read here as *a pin the operator reasoned about*, not *a pin carrying
|
||||
a recorded reason*. A `why` on keep does not close the measured defect — the
|
||||
70% is people using keep for things that are not keep, and this unit gives
|
||||
those things their own mechanism. Parked per the anti-creep gate.
|
||||
- **A third index lane for held booths.** A booth waiting on the operator is the
|
||||
most actionable thing on the index, and it already carries the `? N open`
|
||||
badge. Lane structure and ordering are U7's, and adding a lane here would set
|
||||
an ordering rule that U7 then has to live with.
|
||||
- **Closing the `marks._Locked.__enter__` mtime-restore race.** See the
|
||||
assumption above: the clean fix costs every `rsync -a` populated booth. The
|
||||
comment there stays, and stays accurate.
|
||||
- **`booth ls` marking held booths.** Open question, parked.
|
||||
- **Closing the view-during-sweep race, which U4 WIDENS.** `sweep_once` calls
|
||||
`shutil.rmtree` without holding anything, so a write landing inside that call
|
||||
can make it raise partway and leave a stump directory. The race is
|
||||
pre-existing — every write route has always had it — but U4 widens it,
|
||||
because `record_view` fires on every booth-page GET and the case that
|
||||
collides is precisely "the first look at a booth that has been silent for 24
|
||||
hours", which is the state the sweeper acts on.
|
||||
|
||||
The fix is known and small: `os.rename` the booth to `.sweeping-<name>` first
|
||||
(atomic, and a dotfolder the scan already skips), then `rmtree` the renamed
|
||||
path, plus a cleanup of leftovers at the top of each tick for the
|
||||
crash-between-the-two case. It is NOT done here, per the anti-creep gate:
|
||||
both "in" and "park" are defensible, so it parks. The arithmetic is that the
|
||||
collision needs a GET inside a ~10 ms `rmtree` on a booth nobody has opened in
|
||||
a day, the sweeper ticks every 15 minutes, and the consequence is a stump that
|
||||
survives one more TTL — against which a sweeper rewrite is not a v1-path
|
||||
trade. Named here so it is a decision and not an oversight, and parked on
|
||||
the henge at `park/booth-sweeper-rename-then-delete-to-close-the` (id 83)
|
||||
so it has a home rather than only a paragraph.
|
||||
|
||||
## Invariants
|
||||
|
||||
**INV-1 — Age arithmetic is unchanged.** `booth_age_seconds`, `is_expired` and
|
||||
the `expires_in` values on both surfaces are computed exactly as before. A view
|
||||
enters through `_newest_mtime` as a file in the tree, not as a term in a new
|
||||
formula. *Falsifiable:* a booth with a `.viewed` and a booth with any other
|
||||
non-lock dotfile of the same mtime report the same age.
|
||||
|
||||
**INV-2 — `sweep_once` is the only caller that honours a hold.** `is_expired`
|
||||
stays a pure age question; `booth rm`, `POST /b/<n>/delete` and
|
||||
`DELETE /b/<n>` delete a held booth exactly as they delete a kept one.
|
||||
*Falsifiable:* a held booth is still reported expired by `is_expired` and is
|
||||
still deleted by the delete routes.
|
||||
|
||||
**INV-3 — One predicate, ONE READ, one answer.** The index card's `held`, the
|
||||
booth header's, the marks page's and the sweeper's exemption all come from the
|
||||
same pure `is_held`, and each call's two inputs come from a SINGLE read of
|
||||
`.marks.json` via `hold_read` — never from two reads stitched together, which
|
||||
is a pair that described the booth at no instant. *Falsifiable:* for any booth, what
|
||||
`list_booths` reports as `held` and what `sweep_once` refuses to take agree —
|
||||
tested directly rather than by inspection, because that is the falsifiable form.
|
||||
(`open_marks` is still called directly for the `N open` COUNT. A count is not a
|
||||
lifetime decision, and the first draft of this invariant forbade it by accident
|
||||
— the rule is that no *exemption* and no *held label* is derived except through
|
||||
`is_held`.)
|
||||
|
||||
**INV-4 — A booth that is not counting down says why.** Every non-kept booth
|
||||
renders either a countdown or a named hold on **every surface whose chrome the
|
||||
Booth owns**: the index card, the booth header, and the marks page (which is
|
||||
the only one of the three a verbatim booth has). *Falsifiable:* the templates
|
||||
have no path that renders neither, and the one line is a single macro rather
|
||||
than three conditionals that can drift.
|
||||
|
||||
**INV-5 — Recording a view cannot fail a request.** `record_view` swallows
|
||||
`OSError`. *Falsifiable:* a booth whose directory is read-only still returns 200
|
||||
for its page, its zoom page and its marks page.
|
||||
|
||||
**INV-6 — An unreadable `.marks.json` holds its booth.** The reaper never
|
||||
deletes judgment it could not read. *Falsifiable:* a booth with a corrupt
|
||||
`.marks.json`, aged past the TTL, survives `sweep_once`.
|
||||
|
||||
**INV-7 — Machine reads do not hold a booth open.** `GET /b/<n>/marks.json` and
|
||||
`GET /b/<n>/<file>` do not write `VIEW_MARKER`. *Falsifiable:* polling either,
|
||||
repeatedly, leaves the booth's age untouched.
|
||||
@@ -0,0 +1,44 @@
|
||||
# The `.forever` diagnosis got a live positive control
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
The U4 diagnosis was that `.forever` is the only way to say three different
|
||||
things — "this is durable", "I have not answered yet", "I am still looking" —
|
||||
and that only the first is what keep means. That was an argument. **On
|
||||
2026-09-22 it stopped being one.**
|
||||
|
||||
Census of `~/booth-data`, whole population, every value a deterministic file
|
||||
fact:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| live booths | 24 |
|
||||
| carrying `.forever` | 17 (70%, up from 54% on 2026-09-21) |
|
||||
| carrying `.marks.json` at all | 4 |
|
||||
| of those, with an open pick | **4 of 4** |
|
||||
| **open pick AND `.forever`** | **3** |
|
||||
|
||||
**Three of the four booths in the entire fleet that were waiting on an answer
|
||||
had also been pinned by hand.** That is the "not yet" case caught in the act,
|
||||
not inferred from a rate.
|
||||
|
||||
The staleness distribution says it from the other side: **10 of the 17 kept
|
||||
booths were under one day old** — younger than the TTL, so the sentinel had
|
||||
bought them nothing and was pressed pre-emptively. Only 4 were old enough
|
||||
(2.4-4.6 days) that keep is the reason they still existed.
|
||||
|
||||
⚠ **A number I got wrong, caught by a cross-frontier arm, kept here because the
|
||||
class repeats.** The contract first said "12 are under 1.5 days old — younger
|
||||
than the TTL". The TTL is 24 hours. 1.5 days is not younger than 24 hours. The
|
||||
measurement was sound and the sentence was not; the claim only holds at the
|
||||
one-day line, where it is 10 rather than 12. Nobody on the Claude side caught
|
||||
it, including the author twice.
|
||||
|
||||
⚠ **The hold's live blast radius is SMALL** — only 4 booths have marks at all —
|
||||
so the `.forever` re-count prediction rests on BOTH halves of U4 and on the
|
||||
sentinel becoming unnecessary rather than forbidden. **RE-COUNT A FORTNIGHT
|
||||
AFTER U4 LANDS**, i.e. on or after **2026-10-06**. If the rate does not move,
|
||||
the honest readings are "the diagnosis was wrong" OR "the habit outlived the
|
||||
need", and a bare re-count cannot tell those apart. **The three
|
||||
open-pick-plus-`.forever` booths are the ones to watch**, because for them the
|
||||
mechanism is now unambiguous.
|
||||
@@ -0,0 +1,57 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,42 @@
|
||||
# The third one-branch template miss — this repo's recurring blind spot
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**All four arms of the U4 code-review panel found the same drift, independently.**
|
||||
That is the strongest convergence either panel has produced here.
|
||||
|
||||
The booth header's sub-line forks on `{% if board %}`, and the U4 lifetime macro
|
||||
had been added only to the `{% else %}`. So **a booth carrying `links.md`
|
||||
rendered a link count and nothing at all about its lifetime** — no countdown, no
|
||||
hold — while INV-4 said the templates have no path that renders neither. The
|
||||
standing board being kept by construction (`booth link` drops `.forever` on
|
||||
first use) is what hid it; a **released** board or a hand-made `links.md` booth
|
||||
is a live non-kept booth on that path, and both are reachable from the UI.
|
||||
|
||||
**This is the third of the same shape in this repo's short history:**
|
||||
|
||||
1. `blurtoggle` — the blur only patched the image/video `<figure>`; inline docs
|
||||
render through their OWN branch and shipped unblurred. Suite green; a live
|
||||
look caught it.
|
||||
2. verbatim chrome — a verbatim booth's own `index.html` is served untouched, so
|
||||
the inline marks panel never renders there. Found by looking at the live
|
||||
service during U4, not by the suite.
|
||||
3. the board branch — this one.
|
||||
|
||||
**The pattern: the suite renders the surface the author was thinking about.**
|
||||
Every one of these was a second branch of a conditional the author had already
|
||||
satisfied once and stopped reading. A cold reader with no idea which branch was
|
||||
"the real one" finds them; the author does not, and neither does a test the
|
||||
author wrote.
|
||||
|
||||
**Practical consequence for this repo.** When a template gains a fact, grep the
|
||||
template for `{% if %}` in the block you edited and render EVERY branch in a
|
||||
test — one test per branch, each rendering only its own surface, or the passing
|
||||
test on branch A will mask the omission on branch B. U4 now has one per surface
|
||||
(index card, booth header, board header, marks page) for exactly this reason.
|
||||
|
||||
Declined, and worth recording: Regin and Kimi both recommended amending INV-4 to
|
||||
carve the board header out, on the grounds that board layout belongs to U7.
|
||||
**Cutting an invariant down to fit an implementation gap is the wrong direction
|
||||
when the fix is one template edit**, and U7 owns navigation and section layout —
|
||||
not whether a header states a lifetime.
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,50 @@
|
||||
# U4 landed — lifetime is derived, not declared
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**A booth's lifetime stopped being a boolean somebody remembered to press.**
|
||||
Three states now, and `sweep_once` is the only thing that honours the first two:
|
||||
|
||||
KEPT `.forever` present never swept (unchanged)
|
||||
HELD an open pick, or marks we cannot read never swept (new)
|
||||
EPHEMERAL everything else 24h (unchanged)
|
||||
|
||||
Plus **viewing is activity**: a deliberately-served response from a booth's own
|
||||
page route writes `.viewed`. That dotfile is not a `.lock` dotfile, so
|
||||
`_newest_mtime` already counts it — **there is no new arithmetic anywhere**.
|
||||
`booth_age_seconds`, `is_expired` and `expires_in` are byte-for-byte what they
|
||||
were. A view is one more thing in the tree, which is the same trick `.booth.json`
|
||||
used in U5.
|
||||
|
||||
**What counts as a view, and why the exclusions matter more than the inclusions.**
|
||||
`/b/<n>/` (gallery, verbatim report, `?download=1` zip), `/b/<n>/view` and
|
||||
`/b/<n>/marks` count. `/b/<n>/marks.json`, asset GETs, `/`, `/healthz` and a
|
||||
zoom URL that 404s do NOT. The marks.json exclusion is load-bearing: **an agent
|
||||
must not be able to hold its own booth open by polling for the answer it is
|
||||
waiting on.** `/b/<n>/asks` is a 308 into `/marks` and records through it — one
|
||||
call, not two.
|
||||
|
||||
Checked because it would have been silent: **nothing in the fleet polls a booth
|
||||
page.** Homepage's `siteMonitor` for the Booth is `/healthz`, which is on the
|
||||
not-a-view list. Had it been pointed at a booth URL, every booth would have
|
||||
become immortal on deploy and nothing would have reported it.
|
||||
|
||||
**The hold is unbounded and that is the point** — unanswered is unfinished. What
|
||||
makes it safe is visibility plus two exits that already existed: the card and
|
||||
every Booth-owned header say `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.**
|
||||
|
||||
**Release is activity, stated rather than accidental.** Releasing a kept board
|
||||
still buys a full TTL — unchanged — but now because `booth_unkeep` calls
|
||||
`record_view`, which is a rule, and no longer because unlinking a file happened
|
||||
to bump a directory's mtime, which is not. The CLI warning against
|
||||
"unkeep and let it expire" stays and stays true.
|
||||
|
||||
⚠ **Running `scripts/layout-probe.py` over booth pages resets every booth's
|
||||
clock**, because a GET of a booth page is a view and the probe is not exempt
|
||||
from its own rule. Harmless, recoverable, and noted in the probe so nobody
|
||||
debugs it later as a sweeper that stopped working.
|
||||
|
||||
Contract: `docs/contracts/u4_derived_lifetime.contract.md`. Both heid panels ran
|
||||
and the bug hunt after them; see the sibling entries.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Five of seven INV falsifiers did not falsify anything
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
The U4 contract carried seven invariants, each with a *Falsifiable:* line, and
|
||||
each had a test. **The code-review panel showed that five of the seven tests
|
||||
would still pass under a change that defeats the invariant they name.** Gróa's
|
||||
"per INV entry, what would still pass" section is the single most useful thing
|
||||
either panel produced on this unit.
|
||||
|
||||
| INV | what the test asserted | what still passed |
|
||||
|---|---|---|
|
||||
| 1 (no new arithmetic) | the clock moved after a view | special-casing `.viewed` inside `_newest_mtime` — the exact new arithmetic INV-1 forbids |
|
||||
| 3 (`is_held` is pure) | the right answer, once | `is_held` doing I/O, or `return True` unconditionally |
|
||||
| 4 (every surface says why) | a substring on `GET /` | dropping the line from the booth header, the marks page, or the board branch |
|
||||
| 5 (a view cannot fail a request) | `record_view` did not raise | a second `touch` outside the guard, 500ing all three routes |
|
||||
| 6 (unreadable marks hold) | the corrupt booth survived | a sweeper that deletes nothing at all (no doomed sibling in the fixture) |
|
||||
| 7 (machine reads do not hold) | `.viewed` was absent | a handler writing any other non-dot file, holding the booth open just as well |
|
||||
|
||||
**The shape of the error is the same every time: the test asserted the OUTCOME
|
||||
the author was thinking about, not the DISCRIMINATOR the invariant names.** A
|
||||
green test proved the happy path and nothing about the invariant. Writing the
|
||||
falsifiable line in the contract did not produce a falsifying test — it produced
|
||||
a test that *cited* one.
|
||||
|
||||
Fixed by rewriting each to fail under the change that defeats it: same-mtime
|
||||
equivalence with an arbitrary non-lock dotfile (plus a `.lock` that must NOT
|
||||
count); `is_held` called with marks belonging to a booth that does not exist on
|
||||
disk; one test per rendered surface, each rendering only its own; the three
|
||||
routes GET against a chmod'd booth; a doomed sibling; the AGE asserted rather
|
||||
than the marker. **The board-header pair was verified RED against the pre-fix
|
||||
template rather than assumed** — which is the step that makes "fixed, not
|
||||
amended" trustworthy.
|
||||
|
||||
**The method to keep: for each invariant, name a change that defeats it and ask
|
||||
whether the test goes red.** If you cannot name one, the invariant is not
|
||||
falsifiable yet. Regin and Kimi independently proposed this as a contract-time
|
||||
"vacuity pass"; heid rates this round the strongest evidence for it so far, and
|
||||
it is a `/heid*` skill proposal sitting with the operator, not a change to this
|
||||
repo.
|
||||
+34
-24
@@ -21,33 +21,37 @@ _As of 2026-09-22:_
|
||||
|
||||
- **v1 is gated on seven units** in `ROADMAP.md`, dependency-ordered
|
||||
**U1 → U2 → {U3, U4, U5} → U7**, with **U6 independent**.
|
||||
- **U1, U2 and U5 are landed and released.** Current version `0.3.0` (`95beede`,
|
||||
tagged), deployed, 341 tests green, tree clean, 24/24 booth pages verified 200
|
||||
and a full layout-probe sweep at 0 occlusions across 27 pages. U1 `ce598b3`;
|
||||
U2 `c7f9437` → `v0.2.0`, `5e41108` → `v0.2.1`, `026a1fc` → `v0.2.2`;
|
||||
U5 `c015a91` + `95beede` → `v0.3.0`.
|
||||
- **THE NEXT UNIT IS THE OPERATOR'S CALL and has not been made.** The session's
|
||||
recommendation is **U4 (derived lifetime)**, on three grounds, none of which
|
||||
need re-deriving: (1) it is the only remaining unit whose defect was
|
||||
re-measured this session — the `.forever` rate is **17 of 24 (70%)**, up from
|
||||
the 54% in the IA doc and the 56% measured on 2026-09-21, and the absolute
|
||||
count rose too (14 → 17), so it is not only the denominator shrinking;
|
||||
(2) U2 built `open_marks` FOR it and says so in the predicate's docstring;
|
||||
(3) the bug-hunt panel's mtime-restore finding was deliberately left open
|
||||
because closing it properly is a TTL doctrine change — and U4 is the unit that
|
||||
owns TTL doctrine, so it closes a loop this session opened on purpose.
|
||||
U3 and U6 are the alternatives: U6 is independent and was *conceptually*
|
||||
unblocked by U5 giving job 5 a home, U3 is where verbatim-booth provenance was
|
||||
deferred to.
|
||||
- **No gate is outstanding.** All three ran on U5 and were folded in: the cold
|
||||
`/heid-contract-review` panel (thread `01M340PNVRS21HPASZT38PXQPN`), the
|
||||
`/heid-code-review` conformance panel (`01M341E9XAPZEFBSPK9HPGAM0S`) and the
|
||||
`/heid-bug-hunt` (`01M343SXX27Z47C3STXXRC7M42`). All three loops are closed
|
||||
with heid.
|
||||
- **U1, U2, U4 and U5 are landed.** U1 `ce598b3`; U2 `c7f9437` → `v0.2.0`,
|
||||
`5e41108` → `v0.2.1`, `026a1fc` → `v0.2.2`; U5 `c015a91` + `95beede` →
|
||||
`v0.3.0`. **U4 landed 2026-09-22** — 396 tests green (341 → 396), deployed and
|
||||
verified live, 24/24 booth pages 200, layout probe clean.
|
||||
- **U4 IS NOT YET RELEASED — the version bump is an open operator decision.**
|
||||
Recommended **v0.3.0 → v0.4.0 (minor)**: U4 changes what `keep` MEANS for 17
|
||||
agent handles ("stop pressing keep when you are only waiting"), which is
|
||||
release-note-worthy at the pre-1.0 bar and the same tier U2 and U5 took. The
|
||||
defensible alternative is v0.3.1 if U4 reads as internal plumbing, since no
|
||||
CLI verb or URL changed shape. **Commits are not releases**, so the work is
|
||||
committed unbumped and untagged; the tag waits on his word and nothing is
|
||||
blocked by the wait.
|
||||
- **THE NEXT UNIT IS THE OPERATOR'S CALL.** U3 (declared embed seam) and U6
|
||||
(benches) are both unblocked; U7 waits on the rest. U6 is independent of
|
||||
everything and was conceptually unblocked by U5 giving job 5 a home; U3 is
|
||||
where verbatim-booth provenance was deferred to, and U4 added a fourth reason
|
||||
to want it — a verbatim booth has no Booth-rendered header, so its lifetime
|
||||
line lives only on the index card and the marks page.
|
||||
- **No gate is outstanding.** All three ran on U4 and were folded in: the
|
||||
`/heid-contract-review` panel (`01M34VX0SH23Y3VC92E7GM4S70`), the
|
||||
`/heid-code-review` panel (`01M34WAFJC3RTERFYBBZJN1SVG`) and the
|
||||
`/heid-bug-hunt` (`01M34Y2R0RAJRSN36Q8K4KAB36`). All loops closed with heid.
|
||||
The U5 round's three are also closed (`01M340PNVRS21HPASZT38PXQPN`,
|
||||
`01M341E9XAPZEFBSPK9HPGAM0S`, `01M343SXX27Z47C3STXXRC7M42`).
|
||||
- **Two dated predictions are pending and must not be forgotten.** U5's adoption
|
||||
re-measure on **2026-09-29** (two counts, see its entry — already at 3 of 24
|
||||
announced and 2 with a `why`, all from peers told nothing), and the `.forever`
|
||||
re-count **a fortnight after U4 lands**, which is U4's success criterion.
|
||||
re-count **on or after 2026-10-06**, a fortnight after U4 landed, which is
|
||||
U4's success criterion. ⚠ Only 4 booths carry marks at all, so the hold's live
|
||||
blast radius is small and the prediction rests on both halves of U4 — see its
|
||||
entry for what a null result would and would not mean.
|
||||
- **Three methodology proposals from this session sit with the operator**, routed
|
||||
by heid rather than decided unilaterally: reshaping the paraphrase gate toward
|
||||
a drift-check for narrative-heavy contracts, a standing
|
||||
@@ -59,6 +63,12 @@ _As of 2026-09-22:_
|
||||
|
||||
## Recent decisions
|
||||
|
||||
- `[2026-09-22]` **U4 landed — lifetime is derived, not declared** — three states, viewing is activity, and no new arithmetic anywhere → `persistent-memory.d/2026-09-22-u4-derived-lifetime-landed.md`
|
||||
- `[2026-09-22]` **The `.forever` diagnosis got a live positive control** — 3 of the 4 booths awaiting an answer were ALSO hand-pinned — RE-COUNT 2026-10-06 → `persistent-memory.d/2026-09-22-forever-had-a-live-positive-control.md`
|
||||
- `[2026-09-22]` **Four independent paths to one fail-open delete** — the bug-hunt panel's class, and the zsh word-splitting trap that shipped an empty bundle → `persistent-memory.d/2026-09-22-four-paths-to-one-fail-open-delete.md`
|
||||
- `[2026-09-22]` **Two reads of one file are not one read of one state** — a TOCTOU seam that composes two correct readers into a fail-open delete → `persistent-memory.d/2026-09-22-two-reads-are-not-one-state.md`
|
||||
- `[2026-09-22]` **Five of seven INV falsifiers did not falsify anything** — read before writing a *Falsifiable:* line; a green test cited one rather than being one → `persistent-memory.d/2026-09-22-vacuous-falsifiers.md`
|
||||
- `[2026-09-22]` **The third one-branch template miss** — this repo's recurring blind spot; read before adding a fact to any template → `persistent-memory.d/2026-09-22-third-one-branch-template-miss.md`
|
||||
- `[2026-09-22]` **The size cap opened a service-wide hang** — a FIFO has st_size 0; a bound that trusts it inherits what it does not mean → `persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md`
|
||||
- `[2026-09-22]` **An existing test stopped me retiring documented behaviour** — the clean fix for the mtime race would have silently changed TTL doctrine → `persistent-memory.d/2026-09-22-doctrine-not-defect.md`
|
||||
- `[2026-09-22]` **Two U5 panels, and prose reached a released outage** — read the detail before assuming a conformance finding stops at its own module → `persistent-memory.d/2026-09-22-u5-panels-reached-a-released-bug.md`
|
||||
|
||||
+29
-9
@@ -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
|
||||
|
||||
@@ -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[@]}"`.
|
||||
|
||||
@@ -781,6 +781,10 @@ def test_releasing_a_board_RESETS_its_ttl_clock(tmp_path):
|
||||
|
||||
(kept / KEEP_MARKER).unlink()
|
||||
|
||||
# Unlinking the sentinel by hand, which is what this test is about: the
|
||||
# directory-entry change is what moves the clock. Releasing through the
|
||||
# ROUTE now also records a view, so the behaviour is stated rather than
|
||||
# incidental — `test_releasing_a_board_RECORDS_A_VIEW` in test_lifetime.py.
|
||||
assert booth_age_seconds(kept) < 60, "unlink bumped the dir mtime"
|
||||
assert sweep_once(tmp_path, ttl_seconds=3600) == [], "so it is NOT swept yet"
|
||||
assert kept.exists()
|
||||
|
||||
@@ -0,0 +1,998 @@
|
||||
"""U4 — derived lifetime.
|
||||
|
||||
A booth's lifetime stops being a boolean somebody remembered to press and
|
||||
becomes a fact derived from the booth's own state:
|
||||
|
||||
KEPT `.forever` present never swept
|
||||
HELD an open pick, or marks we cannot read never swept
|
||||
EPHEMERAL otherwise swept past the TTL
|
||||
|
||||
See `docs/contracts/u4_derived_lifetime.contract.md`. The invariant ids in the
|
||||
test names are that contract's.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from booth.app import (
|
||||
KEEP_MARKER,
|
||||
is_kept,
|
||||
VIEW_MARKER,
|
||||
booth_age_seconds,
|
||||
create_app,
|
||||
hold_reason,
|
||||
is_expired,
|
||||
list_booths,
|
||||
record_view,
|
||||
sweep_once,
|
||||
zip_booth,
|
||||
)
|
||||
from booth.items import booth_items
|
||||
from booth.marks import (answer_pick, declare_pick, delete_mark, hold_read, marks_for, open_marks,
|
||||
read_error, set_flag, write_note)
|
||||
|
||||
|
||||
def _touch(path, when=None):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"x")
|
||||
if when is not None:
|
||||
os.utime(path, (when, when))
|
||||
|
||||
|
||||
def _stale(path, seconds=10_000):
|
||||
"""Age a booth and everything in it well past any test TTL.
|
||||
|
||||
`follow_symlinks=False`, because a booth may hold a dangling one and
|
||||
`os.utime` follows by default — which is the helper raising on a fixture the
|
||||
tests deliberately build."""
|
||||
t = time.time() - seconds
|
||||
for p in sorted(path.rglob("*"), reverse=True):
|
||||
try:
|
||||
os.utime(p, (t, t), follow_symlinks=False)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
os.utime(path, (t, t))
|
||||
|
||||
|
||||
def _pick(booth, mark_id="q1", target=None):
|
||||
"""Declare an unanswered pick — the thing that holds a booth."""
|
||||
return declare_pick(booth, mark_id,
|
||||
{"prompt": "which one?", "options": ["a", "b"]}, target=target)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||
return TestClient(app), tmp_path
|
||||
|
||||
|
||||
# ---- record_view / VIEW_MARKER ---------------------------------------------
|
||||
|
||||
|
||||
def test_record_view_writes_the_marker(tmp_path):
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
|
||||
record_view(booth)
|
||||
|
||||
assert (booth / VIEW_MARKER).exists()
|
||||
|
||||
|
||||
def test_a_view_resets_the_clock_through_the_existing_age_rule(tmp_path):
|
||||
"""INV-1. No new arithmetic: `.viewed` is a dotfile and NOT a `.lock`
|
||||
dotfile, so `_newest_mtime` already counts it. `booth_age_seconds` is
|
||||
untouched by this unit."""
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
_stale(booth)
|
||||
assert booth_age_seconds(booth) > 3600
|
||||
|
||||
record_view(booth)
|
||||
|
||||
assert booth_age_seconds(booth) < 60
|
||||
|
||||
|
||||
def test_the_view_marker_is_not_special_cased_in_the_age_rule(tmp_path):
|
||||
"""INV-1's ACTUAL falsifier, and the reason the test above is not enough.
|
||||
|
||||
Cross-frontier review, 2026-09-22: an implementation that special-cases
|
||||
`.viewed` inside `_newest_mtime` — the new arithmetic INV-1 forbids —
|
||||
passes a "the clock moved" assertion perfectly well. What it cannot pass is
|
||||
equivalence: a booth holding `.viewed` and a booth holding any other
|
||||
non-lock dotfile of the SAME mtime must report the same age, because the
|
||||
rule is about the tree, not about this file.
|
||||
"""
|
||||
when = time.time() - 5_000
|
||||
viewed = tmp_path / "viewed"
|
||||
_touch(viewed / "a.png", when=when)
|
||||
_touch(viewed / VIEW_MARKER, when=when)
|
||||
other = tmp_path / "other"
|
||||
_touch(other / "a.png", when=when)
|
||||
_touch(other / ".anything-else", when=when)
|
||||
for b in (viewed, other):
|
||||
os.utime(b, (when, when))
|
||||
|
||||
now = time.time()
|
||||
assert abs(booth_age_seconds(viewed, now=now)
|
||||
- booth_age_seconds(other, now=now)) < 0.001
|
||||
|
||||
# ...and a `.lock` dotfile is the one that must NOT count, or the exemption
|
||||
# the view rule rides on has stopped being an exemption.
|
||||
locked = tmp_path / "locked"
|
||||
_touch(locked / "a.png", when=when)
|
||||
_touch(locked / ".marks.lock")
|
||||
os.utime(locked, (when, when))
|
||||
assert booth_age_seconds(locked, now=now) > 3600
|
||||
|
||||
|
||||
def test_a_viewed_booth_is_not_swept(tmp_path):
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
_stale(booth)
|
||||
|
||||
record_view(booth)
|
||||
|
||||
assert sweep_once(tmp_path, ttl_seconds=3600) == []
|
||||
assert booth.exists()
|
||||
|
||||
|
||||
def test_the_view_marker_is_not_an_item(tmp_path):
|
||||
"""A dotfile, so it costs nothing in counts, galleries or zips — the same
|
||||
reason `.forever`, `.marks.json` and `.booth.json` cost nothing."""
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
record_view(booth)
|
||||
|
||||
assert [it.rel for it in booth_items(booth)] == ["a.png"]
|
||||
assert list_booths(tmp_path, ttl_seconds=3600)[0]["count"] == 1
|
||||
assert VIEW_MARKER not in zip_booth(booth).decode("latin-1")
|
||||
|
||||
|
||||
def test_record_view_never_raises_on_a_read_only_booth(tmp_path):
|
||||
"""INV-5. A read-only mount, a booth we do not own, a full disk: the cost
|
||||
is the timestamp, never the page."""
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
os.chmod(booth, stat.S_IRUSR | stat.S_IXUSR)
|
||||
try:
|
||||
record_view(booth) # must not raise
|
||||
assert not (booth / VIEW_MARKER).exists()
|
||||
finally:
|
||||
os.chmod(booth, stat.S_IRWXU)
|
||||
|
||||
|
||||
def test_a_read_only_booth_still_serves_all_three_view_pages(client):
|
||||
"""INV-5's ACTUAL falsifier — the one the unit test above does not reach.
|
||||
|
||||
Cross-frontier review, 2026-09-22: `record_view` swallowing OSError proves
|
||||
nothing about the ROUTES. An implementation that keeps the swallow and then
|
||||
touches the marker a second time outside the guard 500s every one of these
|
||||
and still passes the unit test. The invariant is about the pages.
|
||||
"""
|
||||
c, data = client
|
||||
booth = data / "b"
|
||||
_touch(booth / "a.png")
|
||||
os.chmod(booth, stat.S_IRUSR | stat.S_IXUSR)
|
||||
try:
|
||||
assert c.get("/b/b/").status_code == 200
|
||||
assert c.get("/b/b/view?f=a.png").status_code == 200
|
||||
assert c.get("/b/b/marks").status_code == 200
|
||||
assert not (booth / VIEW_MARKER).exists(), "nothing was written"
|
||||
finally:
|
||||
os.chmod(booth, stat.S_IRWXU)
|
||||
|
||||
|
||||
def test_record_view_never_raises_on_a_missing_booth(tmp_path):
|
||||
record_view(tmp_path / "gone") # must not raise
|
||||
|
||||
|
||||
# ---- which routes count as a view ------------------------------------------
|
||||
|
||||
|
||||
def test_the_booth_page_records_a_view(client):
|
||||
c, data = client
|
||||
_touch(data / "b" / "a.png")
|
||||
|
||||
assert c.get("/b/b/").status_code == 200
|
||||
assert (data / "b" / VIEW_MARKER).exists()
|
||||
|
||||
|
||||
def test_the_zip_download_records_a_view(client):
|
||||
"""Same route, same deliberate act — and the marker is written before the
|
||||
early return, not after it."""
|
||||
c, data = client
|
||||
_touch(data / "b" / "a.png")
|
||||
|
||||
assert c.get("/b/b/?download=1").status_code == 200
|
||||
assert (data / "b" / VIEW_MARKER).exists()
|
||||
|
||||
|
||||
def test_the_zoom_page_records_a_view(client):
|
||||
c, data = client
|
||||
_touch(data / "b" / "a.png")
|
||||
|
||||
assert c.get("/b/b/view?f=a.png").status_code == 200
|
||||
assert (data / "b" / VIEW_MARKER).exists()
|
||||
|
||||
|
||||
def test_the_marks_page_records_a_view(client):
|
||||
c, data = client
|
||||
_touch(data / "b" / "a.png")
|
||||
|
||||
assert c.get("/b/b/marks").status_code == 200
|
||||
assert (data / "b" / VIEW_MARKER).exists()
|
||||
|
||||
|
||||
def test_the_legacy_asks_url_records_a_view_through_the_redirect(client):
|
||||
"""`/asks` is a 308 to `/marks`; it needs no call of its own, and adding
|
||||
one would double-count."""
|
||||
c, data = client
|
||||
_touch(data / "b" / "a.png")
|
||||
|
||||
assert c.get("/b/b/asks").status_code == 200 # followed
|
||||
assert (data / "b" / VIEW_MARKER).exists()
|
||||
|
||||
|
||||
def test_a_verbatim_booth_records_a_view(client):
|
||||
"""The index.html branch returns before the gallery is built, so the
|
||||
marker has to be written above that fork."""
|
||||
c, data = client
|
||||
(data / "b").mkdir()
|
||||
(data / "b" / "index.html").write_text("<html><body>hi</body></html>")
|
||||
|
||||
assert c.get("/b/b/").status_code == 200
|
||||
assert (data / "b" / VIEW_MARKER).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/", "/healthz"])
|
||||
def test_browsing_the_index_is_not_a_view(client, path):
|
||||
"""INV-7 / the IA's rule: a view is a DELIBERATE act, so it cannot be
|
||||
triggered by browsing the index."""
|
||||
c, data = client
|
||||
_touch(data / "b" / "a.png")
|
||||
|
||||
_stale(data / "b")
|
||||
before = booth_age_seconds(data / "b")
|
||||
|
||||
c.get(path)
|
||||
|
||||
assert not (data / "b" / VIEW_MARKER).exists()
|
||||
assert booth_age_seconds(data / "b") >= before - 1
|
||||
|
||||
|
||||
def test_polling_marks_json_is_not_a_view(client):
|
||||
"""INV-7. An agent must not be able to hold its own booth open by polling
|
||||
for the answer it is waiting on."""
|
||||
c, data = client
|
||||
_touch(data / "b" / "a.png")
|
||||
|
||||
_stale(data / "b")
|
||||
before = booth_age_seconds(data / "b")
|
||||
|
||||
for _ in range(3):
|
||||
assert c.get("/b/b/marks.json").status_code == 200
|
||||
|
||||
assert not (data / "b" / VIEW_MARKER).exists()
|
||||
# The falsifier is the AGE, not the marker: a handler that wrote any other
|
||||
# non-dot file would hold the booth open just as effectively.
|
||||
assert booth_age_seconds(data / "b") >= before - 1
|
||||
|
||||
|
||||
def test_fetching_a_file_is_not_a_view(client):
|
||||
"""INV-7. Asset GETs are issued BY the page, and a hotlinked image would
|
||||
otherwise keep a booth alive forever."""
|
||||
c, data = client
|
||||
_touch(data / "b" / "a.png")
|
||||
|
||||
_stale(data / "b")
|
||||
before = booth_age_seconds(data / "b")
|
||||
|
||||
assert c.get("/b/b/a.png").status_code == 200
|
||||
|
||||
assert not (data / "b" / VIEW_MARKER).exists()
|
||||
assert booth_age_seconds(data / "b") >= before - 1
|
||||
|
||||
|
||||
# ---- is_held ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_hold_reason_does_no_io_of_its_own(tmp_path):
|
||||
"""INV-3. A predicate that reached for the filesystem could answer one way
|
||||
for the card and another for the sweeper; one that cannot, cannot.
|
||||
|
||||
Discriminated rather than asserted: the marks handed in belong to a booth
|
||||
that does not exist on disk. Anything that went looking would raise or
|
||||
contradict itself.
|
||||
"""
|
||||
nowhere = tmp_path / "does-not-exist"
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
_pick(booth)
|
||||
real = marks_for(booth)
|
||||
assert not nowhere.exists()
|
||||
|
||||
assert hold_reason(real, None) == "open"
|
||||
assert hold_reason([], None) is None
|
||||
assert hold_reason([], "damaged") == "unreadable"
|
||||
# ...and the same inputs give the same answer regardless of any booth.
|
||||
assert hold_reason(real, None) == "open"
|
||||
|
||||
|
||||
def test_hold_read_answers_both_questions_from_one_read(tmp_path):
|
||||
"""INV-3's other half, and Hulda's finding on 2026-09-22.
|
||||
|
||||
`marks_for` then `read_error` is TWO reads, and two reads of one file are
|
||||
not one read of one state: a write landing between them can yield
|
||||
`([], None)` — no marks, no error — a pair that described the booth at no
|
||||
instant and is exactly the pair that deletes. `hold_read` is one read.
|
||||
|
||||
It must also agree with `marks_for` on a clean file, or the badge beside
|
||||
the hold would be counting something else.
|
||||
"""
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
_pick(booth)
|
||||
|
||||
marks, error = hold_read(booth)
|
||||
assert error is None
|
||||
assert [m.id for m in marks] == [m.id for m in marks_for(booth)]
|
||||
|
||||
(booth / ".marks.json").write_text("not json at all")
|
||||
marks, error = hold_read(booth)
|
||||
assert marks == []
|
||||
assert error is not None, "one read reports the damage AND the emptiness"
|
||||
|
||||
|
||||
def test_an_open_pick_holds(tmp_path):
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
_pick(booth)
|
||||
|
||||
assert hold_reason(marks_for(booth), None) == "open"
|
||||
|
||||
|
||||
def test_an_answered_pick_does_not_hold(tmp_path):
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
_pick(booth)
|
||||
answer_pick(booth, "q1", "a")
|
||||
|
||||
assert hold_reason(marks_for(booth), None) is None
|
||||
|
||||
|
||||
def test_a_partially_answered_pick_STILL_holds(tmp_path):
|
||||
"""Operator-settled 2026-09-21, and the reason `open_marks` exists rather
|
||||
than an `answer is None` test: a lifetime rule that released a booth on the
|
||||
first radio click would sweep a review in flight."""
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
declare_pick(booth, "batch", {"questions": [
|
||||
{"key": "one", "prompt": "a?", "options": ["x", "y"]},
|
||||
{"key": "two", "prompt": "b?", "options": ["x", "y"]},
|
||||
]})
|
||||
answer_pick(booth, "batch", {"one": "x"})
|
||||
|
||||
assert hold_reason(marks_for(booth), None) == "open"
|
||||
|
||||
|
||||
def test_a_note_does_not_hold(tmp_path):
|
||||
"""A note is the operator's OUTPUT, not an owed answer."""
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
write_note(booth, None, "looks good")
|
||||
|
||||
assert hold_reason(marks_for(booth), None) is None
|
||||
|
||||
|
||||
def test_a_flag_does_not_hold(tmp_path):
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
set_flag(booth, "a.png", True)
|
||||
|
||||
assert hold_reason(marks_for(booth), None) is None
|
||||
|
||||
|
||||
def test_a_booth_with_no_marks_does_not_hold(tmp_path):
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
|
||||
assert hold_reason(marks_for(booth), read_error(booth)) is None
|
||||
|
||||
|
||||
def test_unreadable_marks_hold(tmp_path):
|
||||
"""INV-6. `marks_for` is lenient because a review page that will not render
|
||||
is worse than one missing an annotation. The same leniency on the DELETE
|
||||
path would wipe the booth whose judgment we just failed to read."""
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
(booth / ".marks.json").write_text("{ this is not json")
|
||||
|
||||
assert marks_for(booth) == [], "the read stays lenient"
|
||||
assert hold_reason(marks_for(booth), read_error(booth)) == "unreadable", "the reaper does not"
|
||||
|
||||
|
||||
# ---- the sweeper ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_held_booth_survives_the_sweep(tmp_path):
|
||||
"""The core of the unit: a booth the operator still owes an answer to
|
||||
cannot be swept, however old."""
|
||||
doomed = tmp_path / "doomed"
|
||||
_touch(doomed / "a.png")
|
||||
_stale(doomed)
|
||||
|
||||
held = tmp_path / "held"
|
||||
_touch(held / "a.png")
|
||||
_pick(held)
|
||||
_stale(held)
|
||||
|
||||
wiped = sweep_once(tmp_path, ttl_seconds=3600)
|
||||
|
||||
assert wiped == ["doomed"]
|
||||
assert held.exists()
|
||||
|
||||
|
||||
def test_a_booth_with_unreadable_marks_survives_the_sweep(tmp_path):
|
||||
"""INV-6, end to end."""
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
(booth / ".marks.json").write_text("{ truncated")
|
||||
_stale(booth)
|
||||
# A doomed sibling, so "spare everything" is not a passing implementation.
|
||||
doomed = tmp_path / "doomed"
|
||||
_touch(doomed / "a.png")
|
||||
_stale(doomed)
|
||||
|
||||
assert sweep_once(tmp_path, ttl_seconds=3600) == ["doomed"]
|
||||
assert booth.exists()
|
||||
|
||||
|
||||
def test_answering_the_last_pick_releases_the_booth(tmp_path):
|
||||
"""The hold's exit. Answering IS a write, so the booth's clock resets too —
|
||||
it rejoins the sweep on a fresh clock, exactly as a released board does."""
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
_pick(booth)
|
||||
_stale(booth)
|
||||
|
||||
assert sweep_once(tmp_path, ttl_seconds=3600) == []
|
||||
|
||||
answer_pick(booth, "q1", "a")
|
||||
_stale(booth)
|
||||
|
||||
assert sweep_once(tmp_path, ttl_seconds=3600) == ["b"]
|
||||
|
||||
|
||||
def test_held_booth_is_still_reported_expired_by_age(tmp_path):
|
||||
"""INV-2. `is_expired` stays a pure age question — expiry arithmetic and
|
||||
reaper policy are kept apart so they cannot drift into each other. This is
|
||||
the same split `is_kept` already has."""
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
_pick(booth)
|
||||
_stale(booth)
|
||||
|
||||
assert is_expired(booth, ttl_seconds=3600)
|
||||
assert sweep_once(tmp_path, ttl_seconds=3600) == []
|
||||
|
||||
|
||||
def test_a_held_booth_is_still_deletable(client):
|
||||
"""INV-2. `sweep_once` is the ONLY caller that honours a hold, exactly as
|
||||
it is the only caller that honours the keep sentinel. A hold is protection
|
||||
from the timer, never from the operator."""
|
||||
c, data = client
|
||||
booth = data / "b"
|
||||
_touch(booth / "a.png")
|
||||
_pick(booth)
|
||||
|
||||
c.post("/b/b/delete", follow_redirects=False)
|
||||
assert not booth.exists()
|
||||
|
||||
|
||||
def test_a_held_booth_is_still_deletable_via_the_api(client):
|
||||
c, data = client
|
||||
booth = data / "b"
|
||||
_touch(booth / "a.png")
|
||||
_pick(booth)
|
||||
|
||||
assert c.delete("/b/b").status_code == 200
|
||||
assert not booth.exists()
|
||||
|
||||
|
||||
# ---- the index card ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_booths_carries_held_and_the_marks_error(tmp_path):
|
||||
_touch(tmp_path / "plain" / "a.png")
|
||||
|
||||
held = tmp_path / "held"
|
||||
_touch(held / "a.png")
|
||||
_pick(held)
|
||||
|
||||
broken = tmp_path / "broken"
|
||||
_touch(broken / "a.png")
|
||||
(broken / ".marks.json").write_text("nope")
|
||||
|
||||
by = {b["name"]: b for b in list_booths(tmp_path, ttl_seconds=3600)}
|
||||
|
||||
assert by["plain"]["hold"] is None
|
||||
assert by["held"]["hold"] == "open"
|
||||
assert by["broken"]["hold"] == "unreadable"
|
||||
|
||||
|
||||
def test_the_card_says_held_instead_of_a_countdown(client):
|
||||
"""INV-4. A booth that is not counting down always says why. An invisible
|
||||
rule that silently stopped the clock would be strictly worse than the
|
||||
boolean it replaces — `.forever` was at least visible as a lane."""
|
||||
c, data = client
|
||||
booth = data / "held"
|
||||
_touch(booth / "a.png")
|
||||
_pick(booth)
|
||||
|
||||
html = c.get("/").text
|
||||
|
||||
assert "held until answered" in html
|
||||
assert "expires in" not in html
|
||||
|
||||
|
||||
def test_the_card_says_so_when_the_marks_are_unreadable(client):
|
||||
"""The one hold nothing will release on its own, so it must not read as an
|
||||
ordinary held booth."""
|
||||
c, data = client
|
||||
booth = data / "broken"
|
||||
_touch(booth / "a.png")
|
||||
(booth / ".marks.json").write_text("{{{")
|
||||
|
||||
html = c.get("/").text
|
||||
|
||||
assert "marks unreadable" in html
|
||||
assert "expires in" not in html
|
||||
|
||||
|
||||
def test_an_ordinary_booth_still_counts_down(client):
|
||||
c, data = client
|
||||
_touch(data / "plain" / "a.png")
|
||||
|
||||
html = c.get("/").text
|
||||
|
||||
assert "expires in" in html
|
||||
assert "held until answered" not in html
|
||||
|
||||
|
||||
def test_the_booth_header_says_held_too(client):
|
||||
"""The index card and the booth header are two surfaces on one fact, and a
|
||||
booth URL handed to the operator lands on the SECOND one."""
|
||||
c, data = client
|
||||
booth = data / "held"
|
||||
_touch(booth / "a.png")
|
||||
_pick(booth)
|
||||
|
||||
html = c.get("/b/held/").text
|
||||
|
||||
assert "held until answered" in html
|
||||
assert "expires in" not in html
|
||||
|
||||
|
||||
def test_kept_beats_held_in_the_display(client):
|
||||
"""A kept booth is exempt either way, and showing two reasons for one
|
||||
exemption is the two-representations-of-one-state trap."""
|
||||
c, data = client
|
||||
booth = data / "both"
|
||||
_touch(booth / "a.png")
|
||||
_touch(booth / KEEP_MARKER)
|
||||
_pick(booth)
|
||||
|
||||
html = c.get("/").text
|
||||
|
||||
assert "held until answered" not in html
|
||||
assert "expires in" not in html
|
||||
|
||||
|
||||
# ---- release is activity, stated ------------------------------------------
|
||||
|
||||
|
||||
def test_releasing_a_board_RECORDS_A_VIEW(client):
|
||||
"""A released board survives another full TTL because RELEASING IT IS
|
||||
SOMEBODY TOUCHING IT — a rule — and no longer because unlinking a file
|
||||
happened to bump the directory's mtime, which is not one.
|
||||
|
||||
The behaviour is unchanged. Its reason is now stated, which is what U4 owed
|
||||
the TTL doctrine it inherited.
|
||||
"""
|
||||
c, data = client
|
||||
booth = data / "links"
|
||||
_touch(booth / "a.png")
|
||||
_touch(booth / KEEP_MARKER)
|
||||
|
||||
c.post("/b/links/unkeep", follow_redirects=False)
|
||||
|
||||
assert not (booth / KEEP_MARKER).exists()
|
||||
assert (booth / VIEW_MARKER).exists(), "release is activity, on purpose"
|
||||
|
||||
|
||||
def test_keeping_a_board_does_not_need_to_record_a_view(client):
|
||||
"""Keep exempts it outright, so there is nothing for a clock reset to buy —
|
||||
and writing the marker anyway would put a second mechanism on one job."""
|
||||
c, data = client
|
||||
booth = data / "b"
|
||||
_touch(booth / "a.png")
|
||||
|
||||
c.post("/b/b/keep", follow_redirects=False)
|
||||
|
||||
assert (booth / KEEP_MARKER).exists()
|
||||
assert not (booth / VIEW_MARKER).exists()
|
||||
|
||||
|
||||
def test_the_marks_page_carries_the_lifetime_line(client):
|
||||
"""INV-4's third home, and the one that matters most. A verbatim booth's own
|
||||
index.html is served untouched, so it has no Booth-rendered header — and a
|
||||
report that ASKS something is the archetype of a held booth. Without this
|
||||
the booths most likely to be held would be the ones that never say so."""
|
||||
c, data = client
|
||||
booth = data / "report"
|
||||
booth.mkdir()
|
||||
(booth / "index.html").write_text("<html><body>the report</body></html>")
|
||||
_pick(booth)
|
||||
|
||||
html = c.get("/b/report/marks").text
|
||||
|
||||
assert "held until answered" in html
|
||||
|
||||
|
||||
@pytest.mark.parametrize("setup,expect_held", [
|
||||
("open_pick", True),
|
||||
("answered_pick", False),
|
||||
("partial_pick", True),
|
||||
("note_only", False),
|
||||
("corrupt", True),
|
||||
("nothing", False),
|
||||
])
|
||||
def test_the_card_and_the_sweeper_never_disagree(tmp_path, setup, expect_held):
|
||||
"""INV-3, in its falsifiable form. Two surfaces reading one truth is how the
|
||||
zoom view lost its captions; here the stakes are a deletion, so the check is
|
||||
behavioural rather than by inspection — what the index reports as held and
|
||||
what the sweeper refuses to take must agree on every shape of booth."""
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
if setup == "open_pick":
|
||||
_pick(booth)
|
||||
elif setup == "answered_pick":
|
||||
_pick(booth)
|
||||
answer_pick(booth, "q1", "a")
|
||||
elif setup == "partial_pick":
|
||||
declare_pick(booth, "batch", {"questions": [
|
||||
{"key": "one", "prompt": "a?", "options": ["x", "y"]},
|
||||
{"key": "two", "prompt": "b?", "options": ["x", "y"]},
|
||||
]})
|
||||
answer_pick(booth, "batch", {"one": "x"})
|
||||
elif setup == "note_only":
|
||||
write_note(booth, None, "fine")
|
||||
elif setup == "corrupt":
|
||||
(booth / ".marks.json").write_text("}{")
|
||||
_stale(booth)
|
||||
|
||||
card = list_booths(tmp_path, ttl_seconds=3600)[0]
|
||||
survived = sweep_once(tmp_path, ttl_seconds=3600) == []
|
||||
|
||||
assert (card["hold"] is not None) is expect_held
|
||||
assert survived is expect_held, "the card and the reaper must not disagree"
|
||||
|
||||
|
||||
# ---- INV-4, discriminated per surface ---------------------------------------
|
||||
#
|
||||
# The first cut of these tests all rendered `GET /` and checked a substring,
|
||||
# which meant every one of them passed under a change that dropped the line
|
||||
# from the booth header, from the marks page, or from the board branch. Four
|
||||
# of four cross-frontier arms found the board branch; the rest of this block
|
||||
# is the same lesson applied to the surfaces they did not have to find.
|
||||
|
||||
|
||||
def _board(data, name="links", rows=1):
|
||||
b = data / name
|
||||
b.mkdir(parents=True, exist_ok=True)
|
||||
(b / "links.md").write_text("".join(
|
||||
f"- [row {i}](http://x{i}/) <sub>· nobody · 2026-08-23 10:00</sub>\n"
|
||||
for i in range(rows)))
|
||||
return b
|
||||
|
||||
|
||||
def test_a_BOARD_booths_header_still_states_its_lifetime(client):
|
||||
"""The drift all four arms found on 2026-09-22.
|
||||
|
||||
The booth header's sub-line forks on `{% if board %}`, and the lifetime
|
||||
macro sat only in the `{% else %}` — so a booth carrying `links.md`
|
||||
rendered a link count and NOTHING about its lifetime. INV-4 says the
|
||||
templates have no path that renders neither, and that was a path.
|
||||
|
||||
Reachable, not theoretical: the release button on the kept card drops the
|
||||
sentinel, and a hand-made `links.md` booth never had one. The standing
|
||||
board being kept by construction is what hid it.
|
||||
"""
|
||||
c, data = client
|
||||
_board(data, rows=2)
|
||||
|
||||
html = c.get("/b/links/").text
|
||||
|
||||
assert "2 links" in html
|
||||
assert "expires in" in html, "a non-kept board must say when it goes"
|
||||
|
||||
|
||||
def test_a_held_BOARD_booth_says_held_in_its_header(client):
|
||||
c, data = client
|
||||
b = _board(data, rows=1)
|
||||
_pick(b)
|
||||
|
||||
html = c.get("/b/links/").text
|
||||
|
||||
assert "held until answered" in html
|
||||
assert "expires in" not in html
|
||||
|
||||
|
||||
def test_kept_beats_held_in_the_HEADER_not_just_the_index(client):
|
||||
"""The first version of this test only rendered `/`, where kept cards took
|
||||
a hardcoded `kept` string and never reached the macro — so swapping the
|
||||
macro's precedence to held-before-kept passed it. This renders the surface
|
||||
that actually calls the macro with `kept=True`."""
|
||||
c, data = client
|
||||
booth = data / "both"
|
||||
_touch(booth / "a.png")
|
||||
_touch(booth / KEEP_MARKER)
|
||||
_pick(booth)
|
||||
|
||||
html = c.get("/b/both/").text
|
||||
|
||||
assert "kept" in html
|
||||
assert "held until answered" not in html
|
||||
assert "expires in" not in html
|
||||
|
||||
|
||||
def test_the_booth_header_alone_carries_the_hold(client):
|
||||
"""Rendered without touching the index, so dropping the header's line
|
||||
cannot be masked by the index card still having one."""
|
||||
c, data = client
|
||||
booth = data / "held"
|
||||
_touch(booth / "a.png")
|
||||
_pick(booth)
|
||||
|
||||
assert "held until answered" in c.get("/b/held/").text
|
||||
|
||||
|
||||
def test_a_kept_board_still_surfaces_unreadable_marks(client):
|
||||
"""A kept booth is exempt either way, so the hold is moot — but damaged
|
||||
judgment is not an exemption, it is a thing somebody has to fix, and the
|
||||
durable boards are where losing the operator's marks costs most. The kept
|
||||
card used to render a hardcoded `kept` and say nothing.
|
||||
|
||||
Cross-frontier paraphrase panel, 2026-09-22: `held` and `marks_error` are
|
||||
raw facts on the card; only the DISPLAY has a precedence."""
|
||||
c, data = client
|
||||
booth = data / "board"
|
||||
_touch(booth / "a.png")
|
||||
_touch(booth / KEEP_MARKER)
|
||||
(booth / ".marks.json").write_text("<<<broken>>>")
|
||||
|
||||
index = c.get("/").text
|
||||
assert "marks unreadable" in index
|
||||
assert "kept" in index
|
||||
|
||||
assert "marks unreadable" in c.get("/b/board/").text
|
||||
|
||||
|
||||
def test_a_404_zoom_url_is_not_a_view(client):
|
||||
"""`record_view` sits BELOW the 404s in the zoom route, so a crawler
|
||||
walking dead zoom URLs cannot hold a booth open — the same reasoning that
|
||||
keeps `marks.json` off the view list. Regin and Kimi both raised it."""
|
||||
c, data = client
|
||||
booth = data / "b"
|
||||
_touch(booth / "a.png")
|
||||
|
||||
assert c.get("/b/b/view?f=no-such-file.png").status_code == 404
|
||||
|
||||
assert not (booth / VIEW_MARKER).exists()
|
||||
|
||||
|
||||
# ---- INV-2, through the CLI the sessions actually call ----------------------
|
||||
|
||||
|
||||
def test_booth_rm_deletes_a_held_booth(tmp_path):
|
||||
"""INV-2 via `scripts/booth`, not just the HTTP routes. A hold is
|
||||
protection from the TIMER; nothing about it may reach a deliberate delete,
|
||||
and `booth rm` is the one 17 handles type."""
|
||||
script = pathlib.Path(__file__).parent.parent / "scripts" / "booth"
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
_pick(booth)
|
||||
assert hold_reason(*hold_read(booth)) == "open"
|
||||
|
||||
r = subprocess.run([str(script), "rm", "b"], capture_output=True, text=True,
|
||||
env={**os.environ, "BOOTH_DATA_DIR": str(tmp_path),
|
||||
"BOOTH_URL": "http://booth.invalid"}, timeout=30)
|
||||
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert not booth.exists()
|
||||
|
||||
|
||||
# ---- what the bug-hunt panel found (2026-09-22) ----------------------------
|
||||
#
|
||||
# Four independent paths into one shape: a read that FAILED still resolving to
|
||||
# "no hold", and therefore to a delete. No single arm found all four. The
|
||||
# invariant declared to the panel was "a deletion decision must never be made
|
||||
# from a read that failed"; these are the tests that make it true.
|
||||
|
||||
|
||||
def test_a_pick_that_hydrated_BROKEN_still_holds(tmp_path):
|
||||
"""The round's strongest finding (Hulda, solo).
|
||||
|
||||
`.marks.json` parses, but one mark fails normalization, so `_hydrate_safe`
|
||||
hands back a Mark carrying `error`. `_is_open` returns False for an errored
|
||||
pick ON PURPOSE — it can never be answered, which is what the CLI's exit
|
||||
code 4 means — so the booth read as `not held` and SWEPT, while the panel
|
||||
beside it rendered the broken mark in full. The fail-safe was 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.
|
||||
"""
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
(booth / ".marks.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"marks": [{"id": "q1", "shape": "pick", "created": "2026-09-22T00:00:00.000000+00:00",
|
||||
"doc": "this should be a dict, not a string"}],
|
||||
}))
|
||||
|
||||
marks = marks_for(booth)
|
||||
assert len(marks) == 1 and marks[0].error is not None, "the panel still renders it"
|
||||
assert open_marks(marks) == [], "and it is NOT open — a broken pick cannot be answered"
|
||||
|
||||
assert hold_reason(*hold_read(booth)) == "unreadable", "but it still holds"
|
||||
|
||||
_stale(booth)
|
||||
doomed = tmp_path / "doomed"
|
||||
_touch(doomed / "a.png")
|
||||
_stale(doomed)
|
||||
assert sweep_once(tmp_path, ttl_seconds=3600) == ["doomed"]
|
||||
assert booth.exists()
|
||||
|
||||
|
||||
def test_a_present_but_blank_marks_file_holds(tmp_path):
|
||||
"""Gróa, solo. `_read_raw_strict` early-returns for whitespace-only content
|
||||
— "absent, empty and valid-but-empty are all no marks yet", which is right
|
||||
for the WRITE path it was written for. On the delete path it is not: our
|
||||
writer never produces a blank marks document, so a blank one that exists
|
||||
is something that went wrong, and rmtree is not the response to that."""
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
(booth / ".marks.json").write_text(" \n\n ")
|
||||
_stale(booth)
|
||||
|
||||
assert sweep_once(tmp_path, ttl_seconds=3600) == []
|
||||
assert booth.exists()
|
||||
|
||||
|
||||
def test_a_booth_whose_own_stat_fails_reads_as_FRESH_not_ancient(tmp_path):
|
||||
"""Hulda, solo. `_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. Not knowing
|
||||
a booth's age is a reason to leave it alone."""
|
||||
gone = tmp_path / "vanished"
|
||||
assert booth_age_seconds(gone) < 60, "unknowable age is FRESH, not epoch-old"
|
||||
|
||||
|
||||
def test_a_dangling_symlink_is_still_skipped_not_treated_as_unknowable(tmp_path):
|
||||
"""The other half of that fix, and the reason it is not a blanket catch: a
|
||||
dangling symlink and a file removed mid-scan both raise FileNotFoundError,
|
||||
and neither is a thing with an mtime worth counting. Only OTHER stat
|
||||
failures mean 'there is something here we cannot read'."""
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
_stale(booth)
|
||||
(booth / "dangling").symlink_to(tmp_path / "nowhere")
|
||||
os.utime(booth, (time.time() - 10_000,) * 2)
|
||||
|
||||
assert booth_age_seconds(booth) > 3600, "a dangling link does not make a booth fresh"
|
||||
|
||||
|
||||
def test_is_kept_treats_an_unreadable_sentinel_as_KEPT(tmp_path):
|
||||
"""Gróa, solo. `Path.exists()` maps ELOOP and EACCES to False, so a kept
|
||||
booth whose sentinel could not be stat'd became sweepable. A symlink
|
||||
sentinel counts too, dangling or not — somebody put it there to mean keep."""
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
(booth / KEEP_MARKER).symlink_to(tmp_path / "does-not-exist")
|
||||
_stale(booth)
|
||||
|
||||
assert is_kept(booth) is True
|
||||
assert sweep_once(tmp_path, ttl_seconds=3600) == []
|
||||
|
||||
|
||||
def test_record_view_refuses_to_follow_a_planted_symlink(tmp_path):
|
||||
"""Three of four arms, independently. `Path.touch()` follows an existing
|
||||
symlink, so a booth carrying `.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. A symlink here now raises ELOOP into the swallow: view-recording
|
||||
quietly stops for that booth, which is the right way to lose this."""
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("do not touch me")
|
||||
old = time.time() - 50_000
|
||||
os.utime(outside, (old, old))
|
||||
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
(booth / VIEW_MARKER).symlink_to(outside)
|
||||
|
||||
record_view(booth) # must not raise, must not follow
|
||||
|
||||
assert abs(outside.stat().st_mtime - old) < 1, "the target's mtime is untouched"
|
||||
|
||||
|
||||
def test_a_lock_file_is_not_a_viewable_item(tmp_path):
|
||||
"""The panel pointed `?f=.marks.lock` at the zoom route and held a booth
|
||||
open with a file the service created itself. A dotfile is not an item, and
|
||||
a view of a thing that is not an item is not a view of the booth."""
|
||||
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||
c = TestClient(app)
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
_touch(booth / ".marks.lock")
|
||||
|
||||
c.get("/b/b/view?f=.marks.lock")
|
||||
|
||||
assert not (booth / VIEW_MARKER).exists()
|
||||
|
||||
|
||||
def test_releasing_an_ALREADY_released_booth_is_not_activity(client):
|
||||
"""An unconditional `record_view` on unkeep made POSTing release at an
|
||||
already-released booth an endless TTL refresh — contradicting this route's
|
||||
own no-op promise and diverging from the CLI, which removes the sentinel
|
||||
without recording anything. Release is activity; releasing nothing is not."""
|
||||
c, data = client
|
||||
booth = data / "b"
|
||||
_touch(booth / "a.png")
|
||||
assert not (booth / KEEP_MARKER).exists()
|
||||
|
||||
r = c.post("/b/b/unkeep", follow_redirects=False)
|
||||
|
||||
assert r.status_code == 303, "still a no-op, still not a 500"
|
||||
assert not (booth / VIEW_MARKER).exists()
|
||||
|
||||
|
||||
def test_a_forever_that_is_a_DIRECTORY_does_not_500_the_release(client):
|
||||
"""Pre-existing, re-exposed: `unlink` on a directory raises
|
||||
IsADirectoryError straight through the route, which made the card's release
|
||||
button permanently dead for that booth."""
|
||||
c, data = client
|
||||
booth = data / "b"
|
||||
_touch(booth / "a.png")
|
||||
(booth / KEEP_MARKER).mkdir()
|
||||
|
||||
r = c.post("/b/b/unkeep", follow_redirects=False)
|
||||
|
||||
assert r.status_code == 303
|
||||
|
||||
|
||||
def test_a_VALID_but_empty_marks_document_does_NOT_hold(tmp_path):
|
||||
"""The other side of the blank-file fix, and the regression it would have
|
||||
been without this. `_write_raw` writes `{"version": 1, "marks": []}` when
|
||||
the last mark is deleted — a valid document with nothing in it. If THAT
|
||||
held, every booth the operator ever finished judging would be immortal.
|
||||
|
||||
Blank bytes are damage; an empty list is an answer.
|
||||
"""
|
||||
booth = tmp_path / "b"
|
||||
_touch(booth / "a.png")
|
||||
_pick(booth)
|
||||
delete_mark(booth, "q1")
|
||||
assert (booth / ".marks.json").read_text().strip(), "the file is still there"
|
||||
_stale(booth)
|
||||
|
||||
assert hold_reason(*hold_read(booth)) is None
|
||||
assert sweep_once(tmp_path, ttl_seconds=3600) == ["b"]
|
||||
Reference in New Issue
Block a user