From c3a97c1b645526600a08ad0d0af66888a6ce272c Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Tue, 22 Sep 2026 09:44:25 -0700 Subject: [PATCH] feat(u4): a booth's lifetime is derived from its state, not from a boolean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.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/` 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 --- CLAUDE.md | 5 +- README.md | 55 +- ROADMAP.md | 12 +- booth/app.py | 273 ++++- booth/marks.py | 41 +- booth/templates/_lifetime.html | 28 + booth/templates/base.html | 5 + booth/templates/booth.html | 3 +- booth/templates/index.html | 5 +- booth/templates/marks.html | 3 +- .../contracts/u4_derived_lifetime.contract.md | 606 +++++++++++ ...-22-forever-had-a-live-positive-control.md | 44 + ...9-22-four-paths-to-one-fail-open-delete.md | 57 + ...26-09-22-third-one-branch-template-miss.md | 42 + .../2026-09-22-two-reads-are-not-one-state.md | 35 + .../2026-09-22-u4-derived-lifetime-landed.md | 50 + .../2026-09-22-vacuous-falsifiers.md | 40 + persistent-memory.md | 58 +- scripts/booth | 38 +- scripts/layout-probe.py | 8 + tests/test_booth.py | 4 + tests/test_lifetime.py | 998 ++++++++++++++++++ 22 files changed, 2339 insertions(+), 71 deletions(-) create mode 100644 booth/templates/_lifetime.html create mode 100644 docs/contracts/u4_derived_lifetime.contract.md create mode 100644 persistent-memory.d/2026-09-22-forever-had-a-live-positive-control.md create mode 100644 persistent-memory.d/2026-09-22-four-paths-to-one-fail-open-delete.md create mode 100644 persistent-memory.d/2026-09-22-third-one-branch-template-miss.md create mode 100644 persistent-memory.d/2026-09-22-two-reads-are-not-one-state.md create mode 100644 persistent-memory.d/2026-09-22-u4-derived-lifetime-landed.md create mode 100644 persistent-memory.d/2026-09-22-vacuous-falsifiers.md create mode 100644 tests/test_lifetime.py diff --git a/CLAUDE.md b/CLAUDE.md index 4c44623..97bc351 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index b8b22b6..39f80c9 100644 --- a/README.md +++ b/README.md @@ -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/` 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 ` 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 diff --git a/ROADMAP.md b/ROADMAP.md index 5670516..36c79fc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/booth/app.py b/booth/app.py index 1d44e2c..e8a20bb 100644 --- a/booth/app.py +++ b/booth/app.py @@ -7,15 +7,26 @@ Model (deliberately dead-simple, no database): * GET /b// -> if /index.html exists, serve it verbatim; otherwise auto-render a gallery of the images / webm-videos / audio in it. * GET /b// -> 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//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") diff --git a/booth/marks.py b/booth/marks.py index 282a025..c9b0807 100644 --- a/booth/marks.py +++ b/booth/marks.py @@ -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] diff --git a/booth/templates/_lifetime.html b/booth/templates/_lifetime.html new file mode 100644 index 0000000..d981d9e --- /dev/null +++ b/booth/templates/_lifetime.html @@ -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" %} · marks unreadable{% endif %} + {%- elif hold == "unreadable" -%} + held · marks unreadable + {%- elif hold == "open" -%} + held until answered + {%- else -%} + expires in {{ expires_in|dur }} + {%- endif -%} +{%- endmacro %} diff --git a/booth/templates/base.html b/booth/templates/base.html index 8f81f1b..300f22e 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -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); diff --git a/booth/templates/booth.html b/booth/templates/booth.html index b742e9d..d432d92 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -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 %}

{{ name }}

{% endif %} - {% if uploaded %}⬆ pickup {% 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 %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %} + {% if uploaded %}⬆ pickup {% 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 %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %} {% if items %}⬇ zip{% endif %} {{ provenance(manifest) }} {# A durable multi-writer board gets no one-click wipe — same rule as the diff --git a/booth/templates/index.html b/booth/templates/index.html index 56482e2..1443505 100644 --- a/booth/templates/index.html +++ b/booth/templates/index.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% from "_provenance.html" import provenance %} +{% from "_lifetime.html" import lifetime %} {% block content %}