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:
+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" %}
|
||||
|
||||
Reference in New Issue
Block a user