Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c75d7a2797 | ||
|
|
c3a97c1b64 | ||
|
|
d37b81ab9f |
@@ -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.4.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 `c3a97c1`, released `v0.4.0`** | 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
|
||||
|
||||
+300
-32
@@ -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,11 +476,23 @@ 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,
|
||||
}
|
||||
)
|
||||
booths.sort(key=lambda b: b["mtime"], reverse=True)
|
||||
# Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime
|
||||
# booths ordered by whatever `iterdir()` yielded, which is not a rule — and
|
||||
# invariant 6 is not "usually stable", it is a sentence you can write down.
|
||||
# Two booths created by one `rsync` batch share an mtime exactly, and the
|
||||
# operator refers to cards positionally.
|
||||
booths.sort(key=lambda b: (b["mtime"], b["name"]), reverse=True)
|
||||
return booths
|
||||
|
||||
|
||||
@@ -689,6 +863,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 +899,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",
|
||||
@@ -739,13 +919,13 @@ def create_app(
|
||||
# Ordered pinned-first then newest-first, each row stamped with a
|
||||
# `pinned` flag. Empty list for every other booth, so the template
|
||||
# branch simply does not fire.
|
||||
"board": (
|
||||
order_for_display(
|
||||
parse_link_entries((booth / LINKS_FILE).read_text()),
|
||||
read_pins(booth),
|
||||
)
|
||||
if (booth / LINKS_FILE).is_file() else []
|
||||
),
|
||||
# `is_file()` then an UNGUARDED read was a 500 waiting on a
|
||||
# mode change or an EIO: the board is one tile on this page, and
|
||||
# a page that will not load is worse than one missing a tile —
|
||||
# the same posture `read_blurred`, `marks_for` and
|
||||
# `read_manifest` already take. A booth whose `links.md` cannot
|
||||
# be read renders as a booth with no board.
|
||||
"board": _board_rows(booth),
|
||||
# Marks: operator judgment attached to this booth or to one of
|
||||
# its items — a session's question (`pick`), the operator's own
|
||||
# remark (`note`), the operator's selection (`flag`). Rendered
|
||||
@@ -764,10 +944,29 @@ 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)),
|
||||
},
|
||||
)
|
||||
|
||||
def _board_rows(booth: Path) -> list[dict]:
|
||||
"""The link board's rows, or [] for a board that cannot be read.
|
||||
|
||||
NEVER RAISES, for the reason every other read on this page does not:
|
||||
one damaged file must cost its own tile, not the booth page."""
|
||||
try:
|
||||
if not (booth / LINKS_FILE).is_file():
|
||||
return []
|
||||
return order_for_display(
|
||||
parse_link_entries((booth / LINKS_FILE).read_text()),
|
||||
read_pins(booth),
|
||||
)
|
||||
except (OSError, ValueError, UnicodeDecodeError):
|
||||
return []
|
||||
|
||||
def _mark_redirect(name: str, form, anchor: str) -> RedirectResponse:
|
||||
"""Land where the form was: the standalone marks page for a verbatim
|
||||
booth (its own index.html cannot show the recorded judgment), else the
|
||||
@@ -973,13 +1172,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)
|
||||
@@ -1002,12 +1217,29 @@ def create_app(
|
||||
the whole booth instead of one question at a time.
|
||||
"""
|
||||
booth = resolve_booth(name)
|
||||
marks = marks_for(booth)
|
||||
return JSONResponse({
|
||||
marks, read_err = hold_read(booth)
|
||||
body = {
|
||||
"booth": name,
|
||||
"marks": [as_dict(m) for m in marks],
|
||||
"open": [m.id for m in open_marks(marks)],
|
||||
})
|
||||
}
|
||||
# A DAMAGED file used to come back as an empty list and nothing else,
|
||||
# which is indistinguishable from "you were never asked anything" — and
|
||||
# this endpoint is the ONLY reader a remote session has. Its filesystem
|
||||
# sibling has told the truth since U2: `booth marks` exits 3 on an
|
||||
# unreadable file precisely so a caller can tell "not yet" from
|
||||
# "broken". One question, two surfaces, two answers.
|
||||
#
|
||||
# The STATUS stays 200 and that is deliberate. Reads are lenient here —
|
||||
# the same rule that keeps a poisoned booth from 500ing the index — and
|
||||
# a pinned status code is a promise to remote clients this fix has no
|
||||
# business breaking. The information goes in the body instead: a client
|
||||
# that wants the CLI's exit-3 parity reads `error`, and one that does
|
||||
# not behaves exactly as it does today.
|
||||
if read_err is not None:
|
||||
body["error"] = "this booth's .marks.json cannot be read"
|
||||
body["detail"] = read_err
|
||||
return JSONResponse(body)
|
||||
|
||||
@app.get("/b/{name}/view", response_class=HTMLResponse)
|
||||
def booth_view_file(request: Request, name: str, f: str):
|
||||
@@ -1029,6 +1261,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 +1450,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
|
||||
@@ -69,11 +70,11 @@
|
||||
a label changes width. #}
|
||||
<div class="kept-actions">
|
||||
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
||||
onsubmit="return confirm('Release \u201c{{ b.name }}\u201d?\n\nIt moves to the ephemeral lane so you can wipe it from there. Nothing is deleted by this step.')">
|
||||
data-booth="{{ b.name }}" data-confirm="release">
|
||||
<button title="release this board so it can be wiped">release</button>
|
||||
</form>
|
||||
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
|
||||
onsubmit="return confirm('WIPE the KEPT booth \u201c{{ b.name }}\u201d?\n\nThis deletes it and its files immediately. Kept booths are the ones nothing else will clean up, so nobody else is going to do this for you — and nothing brings it back.')">
|
||||
data-booth="{{ b.name }}" data-confirm="wipe-kept">
|
||||
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -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
|
||||
@@ -123,7 +124,7 @@
|
||||
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
|
||||
</form>
|
||||
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
||||
onsubmit="return confirm('Wipe booth “{{ b.name }}”?')">
|
||||
data-booth="{{ b.name }}" data-confirm="wipe">
|
||||
<button title="wipe now" aria-label="wipe booth">×</button>
|
||||
</form>
|
||||
</article>
|
||||
@@ -160,5 +161,39 @@
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
/* Destructive-action confirmation, delegated and DATA-DRIVEN.
|
||||
These were an inline onsubmit calling confirm() with the booth NAME
|
||||
interpolated straight into the JS string literal. Jinja's autoescape is
|
||||
HTML-attribute escaping, not JS-string escaping: the browser decodes the
|
||||
entity back to a quote before the JS parser ever sees it, so a booth name
|
||||
crafted to close that string executed on submit. Booth names are
|
||||
agent-authored — making a folder under the data dir is the whole API — so
|
||||
that is a live path, not a theoretical one.
|
||||
|
||||
The name now travels as a DATA ATTRIBUTE, where escaping is escaping, and
|
||||
never reaches a JS string literal. Same pattern the board controls already
|
||||
use. With JS off the form submits without a prompt, which is what every
|
||||
no-JS browser here already did. */
|
||||
(function () {
|
||||
var WORDS = {
|
||||
release: function (n) {
|
||||
return 'Release \u201c' + n + '\u201d?\n\nIt moves to the ephemeral lane so you '
|
||||
+ 'can wipe it from there. Nothing is deleted by this step.';
|
||||
},
|
||||
'wipe-kept': function (n) {
|
||||
return 'WIPE the KEPT booth \u201c' + n + '\u201d?\n\nThis deletes it and its files '
|
||||
+ 'immediately. Kept booths are the ones nothing else will clean up, so nobody '
|
||||
+ 'else is going to do this for you \u2014 and nothing brings it back.';
|
||||
},
|
||||
wipe: function (n) { return 'Wipe booth \u201c' + n + '\u201d?'; }
|
||||
};
|
||||
document.addEventListener('submit', function (ev) {
|
||||
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
|
||||
if (!form) return;
|
||||
var word = WORDS[form.getAttribute('data-confirm')];
|
||||
if (word && !confirm(word(form.getAttribute('data-booth') || ''))) ev.preventDefault();
|
||||
}, true);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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,11 @@
|
||||
# Every code-changing finding came from the AMBIGUITY pass
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Every one of the panel's code-changing findings came from the
|
||||
AMBIGUITY pass, none from a paraphrase divergence** — and two arms independently
|
||||
proposed cutting the paraphrase to a drift-check for narrative-heavy contracts,
|
||||
because this contract's own frontmatter carries a plain-language narrative and the
|
||||
paraphrase was partly reading my framing back to me. That is a finding about the
|
||||
`/heid-contract-review` **skill**, not about this repo, and it was reported back
|
||||
to heid. Recorded here only so a future session does not rediscover it.
|
||||
@@ -0,0 +1,11 @@
|
||||
# A boolean escape hatch as the lifetime mechanism
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**A boolean escape hatch as the lifetime mechanism.**
|
||||
`.forever` was added because a 24h TTL genuinely did not fit some booths —
|
||||
and then 56% of live booths ended up on it, which means it is not "ephemeral
|
||||
with an exception", it is two lifetimes wearing one lifetime's clothes, with
|
||||
the operator doing the sorting by hand. Replaced at U4 by lifetime derived
|
||||
from state (an open mark pins; viewing is activity; `keep` survives as an
|
||||
explicit reasoned pin rather than the only way to say "not yet").
|
||||
@@ -0,0 +1,16 @@
|
||||
# Deterministic order is a cross-cutting v1 invariant
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Deterministic order is a cross-cutting v1 invariant** —
|
||||
operator directive, mid-implementation. Every ordered collection the Booth
|
||||
renders must have a *stated* rule producing the same sequence on every render
|
||||
of the same state; the rule can be anything defensible (byte order, time, an
|
||||
explicit number, an arbitrary-but-recorded sequence), but no rule at all is
|
||||
forbidden. It binds harder here than elsewhere because the Booth's job is
|
||||
**comparison** — the operator judges tile 47 against tile 47 and refers to
|
||||
artifacts positionally, so an order that moves between renders misfiles a flag
|
||||
or a note rather than crashing. Recorded as `ROADMAP.md` § "Cross-cutting
|
||||
invariant" (with the per-collection table) and `CLAUDE.md` invariant 6, and
|
||||
tested. Still undecided and must be settled before those units ship: **U7's
|
||||
section ordering and compare pairing**, and **U6's bench listing**.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Extracted from `eshpfi` into its own repo
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Extracted from `eshpfi` into its own repo.** The accreted
|
||||
service came over whole, tests included, so `tests/test_booth.py` (1581 lines)
|
||||
is the regression net the v1 rewrite is checked against.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Five mechanisms to get one question beside one artifact
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Five separate mechanisms to get one question next to one
|
||||
artifact** — `.forever`, the link board, `inline.py`'s placeholder DSL,
|
||||
`wrap_verbatim_html`'s six regexes, and the floating amber asks chip plus
|
||||
`/b/<n>/asks`. Every one is a *correct local fix* to the same global
|
||||
mismatch, which is exactly why they accumulated without anyone making a bad
|
||||
call. **The foot-gun is the sixth one:** the next "just add a small thing for
|
||||
this case" reads as reasonable and is the pattern. The git log carries the
|
||||
signature — every feature ships, then takes 2–5 patches for cases the single
|
||||
shape did not anticipate. Check the ROADMAP gate before adding a mechanism.
|
||||
@@ -0,0 +1,10 @@
|
||||
# The `.forever` diagnosis is a falsifiable prediction
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**The `.forever` diagnosis is a stated, falsifiable
|
||||
prediction.** U4 (derived lifetime) predicts the kept-rate falls to the
|
||||
genuinely-durable booths. Re-measured today: **14 of 25 booths kept (56%)**,
|
||||
against the 54% the IA doc recorded. **Re-count a fortnight after U4 lands.**
|
||||
If it does not move, the diagnosis was wrong and the boolean was doing
|
||||
something else. Tracked in the IA doc's Booth section and by this entry.
|
||||
@@ -0,0 +1,11 @@
|
||||
# The information architecture and the v1 gate landed
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**The information architecture and the v1 gate landed**
|
||||
(`726822b`): `docs/design/information-architecture.md` names the single
|
||||
defect — *one lifetime (24h from last touch) and one shape (a folder),
|
||||
serving five jobs with different lifetimes and different shapes* — and
|
||||
`ROADMAP.md` gates v1 on seven units, each closing a **measured** defect
|
||||
rather than a wish. Both were written after a measurement pass over the live
|
||||
service, and the measurements are the load-bearing part.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Letting Jinja hot-reload templates in the deployment root
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Letting Jinja hot-reload templates while the repo is the
|
||||
deployment root** — the cause of a live outage the same day U2 landed, and the
|
||||
sharpest foot-gun in the repo. `booth.service` sets `WorkingDirectory` to this
|
||||
repo, so the running service imports these files with no build step and no
|
||||
staging copy. Python is read once at process start; Jinja's `FileSystemLoader`
|
||||
re-reads a template **on every render**. Editing `booth.html` therefore
|
||||
deployed it instantly against Python from 22:03 that knew nothing about
|
||||
`item_marks`, and **19 of 25 live booths returned 500** with
|
||||
`UndefinedError: 'item_marks' is undefined`. Neither the old code nor the new
|
||||
code was broken — the service was running both at once.
|
||||
**The lesson that generalises:** a skew between a process and the disk under it
|
||||
is invisible to the test suite by construction, so no amount of green tests
|
||||
would have caught it; the operator found it. Fixed at the source rather than
|
||||
with a reminder — the `Environment` is hand-built with `auto_reload=False`, so
|
||||
there is now ONE staleness rule (nothing takes effect until you restart) and
|
||||
the running process is always a coherent snapshot of one commit. Asserted by
|
||||
`test_templates_do_not_hot_reload_from_disk`. Watch the second-order risk the
|
||||
fix introduces: a hand-built `Environment` does not inherit `autoescape` from
|
||||
the `Jinja2Templates` constructor, and booth names, item names and mark text
|
||||
are all agent-authored strings landing in HTML.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Letting the link board absorb the announce job
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Letting the link board absorb the announce job.** `booth
|
||||
link` is an `O_APPEND` write with no identity and no stated rule, so
|
||||
re-announcing a bench appends a row instead of updating one, and a booth URL
|
||||
rots the moment its booth is swept — **145 of 211 rows (69%) pointed at
|
||||
nothing**, and 22 were the same target re-posted (talk 5×, peedlar 4×). The
|
||||
rot is **structural, not drift**. The lesson that cost the most: enforcing
|
||||
the link rule without first giving the announce job a home (`.booth.json`
|
||||
provenance on the index, U5) just makes it homeless.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Marks are one `.marks.json` per booth
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Marks are stored as one `.marks.json` per booth**, atomic
|
||||
temp-file + `os.replace`, `fcntl` lock on the read-modify-write — operator
|
||||
decision, this session. Two alternatives were weighed and lost: a sidecar
|
||||
per item (`<rel>.marks.json`) and extending the existing `<stem>.ask.json`
|
||||
shape. Rationale, and the reason it is not `links.md`-shaped: **(a)** U4
|
||||
makes *"does this booth owe an answer?"* a hot question — the sweep asks it
|
||||
per booth per tick and the index asks it per card per page load, so per-item
|
||||
sidecars turn it into a full walk of all 25 booths, one of which holds 270
|
||||
files; **(b)** `links.md` is an `O_APPEND` content-hash log because **17
|
||||
agent handles write it concurrently**, whereas marks have exactly one writer
|
||||
(the operator, in one browser) and many readers — a different problem that
|
||||
must not inherit the append-log design; **(c)** `.blurred` / `.pins` /
|
||||
`.forever` already establish the per-booth dotfile as the house shape for
|
||||
operator state, and `booth_items()`'s dotfile skip means it costs nothing in
|
||||
counts, galleries or zips. Accepted cost: a corrupt `.marks.json` loses that
|
||||
booth's marks rather than one item's. Implementation deferred to U2 —
|
||||
tracked at `ROADMAP.md` U2 and by this entry.
|
||||
@@ -0,0 +1,15 @@
|
||||
# A write over a damaged `.marks.json` wiped the booth
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**A write over a damaged `.marks.json` was wiping every mark in
|
||||
the booth.** Shipped in `v0.2.0`, found by the panel (Kimi, converged with
|
||||
Hulda), fixed in `v0.2.1`. `marks_for` is deliberately lenient — unparseable
|
||||
reads as `[]` so a review page still loads — and the write path inherited that
|
||||
leniency through the same reader, so one flag click appended to an empty list and
|
||||
atomically replaced the file. The fix is an **asymmetry**, which is the reusable
|
||||
part: reads stay lenient, writes go strict (`MarksCorrupt`), damaged bytes stay
|
||||
on disk, routes answer 409 not 500. A page that renders without an annotation is
|
||||
recoverable; a file that overwrote the operator's judgment is not. Kimi also
|
||||
named the class correctly — "an author steeped in the design conversation would
|
||||
likely read past" it — and that was accurate.
|
||||
@@ -0,0 +1,10 @@
|
||||
# A partially-answered pick counts as OPEN
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**A partially-answered pick now counts as OPEN** — declared, not
|
||||
smuggled. The old index badge tested `answer is None`, so a half-answered
|
||||
four-question ask read as closed on the index while the panel beside it
|
||||
rendered `◐ partial`: the two disagreed about the same booth. 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.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Regex-injecting chrome into arbitrary author HTML
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Regex-injecting chrome into arbitrary author HTML**
|
||||
(`wrap_verbatim_html` + `_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`,
|
||||
`_BODY_CLOSE_RE`, `_HTML_CLOSE_RE`, `_ICON_RE`, and the doctype/charset
|
||||
ordering constraints they thread). It works today and is **still live** —
|
||||
but it is the single most fragile thing in the service and it is load-bearing
|
||||
for the operator's most important workflow. Slated for deletion at U3 in
|
||||
favour of a declared seam (`/_booth/embed.js`, mounted through a real DOM
|
||||
API), which costs an author one line and removes the whole class. Do not
|
||||
extend the regex set in the meantime; if a verbatim page breaks, that is an
|
||||
argument for U3, not for a seventh pattern.
|
||||
@@ -0,0 +1,8 @@
|
||||
# `sindra-finalists` is U2's flag motivation, caught live
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**`sindra-finalists` is U2's `flag` motivation caught in the
|
||||
act** — 86 items, every one captioned, and the booth's entire name is "the
|
||||
ones the operator picked." That loop currently runs through chat, which is
|
||||
the defect `flag` closes. Evidence, not argument.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Tagging a release while a review gate was in flight
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Tagging a release while a review gate was still in flight.**
|
||||
`v0.2.0` was cut and announced to 15 consuming handles; the
|
||||
`/heid-contract-review` panel — dispatched BEFORE implementation, as the
|
||||
discipline says — replied afterwards with three defects in the code that had just
|
||||
shipped, one of them silent data loss. Nothing about the tier decision was wrong;
|
||||
the *timing* was. **If a gate is outstanding on the work being released, the tag
|
||||
waits for it.** The cost was a same-hour `v0.2.1` and a correction note to peers
|
||||
who had already verified against the broken version.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Letting the write path share the read path's leniency
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**Letting the write path share the read path's leniency.** See the
|
||||
`MarksCorrupt` decision above. The general shape, worth carrying beyond marks:
|
||||
a tolerant reader and a tolerant writer over the same state are not the same
|
||||
decision, and pointing both at one function silently makes them one. Tolerate on
|
||||
read so the surface still renders; refuse on write so nothing is destroyed.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Seam review and cold panel had zero overlap, twice
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**The two review gates are complementary, measured on one unit.**
|
||||
The caller-side **seam review** (nine findings, against the real sibling module
|
||||
surfaces) and the cold **`/heid-contract-review` panel** (four arms,
|
||||
artifact-only) had **zero overlap in both directions** on U2. The seam review
|
||||
found a scope miss the panel structurally could not see: the contract omitted
|
||||
`inline.py`, whose `place()` indexes by subscript, which a frozen dataclass
|
||||
refuses. The panel found three code defects and a missing test the seam review
|
||||
had no lens for. Matches heid's kvasir zero-overlap result on the
|
||||
conformance-versus-hunt axis. **Run both; neither substitutes.**
|
||||
@@ -0,0 +1,16 @@
|
||||
# U2 (marks) landed — one primitive for three mechanisms
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**U2 (marks) landed.** One primitive replacing three
|
||||
mechanisms. `pick` / `note` / `flag` in one `.marks.json` per booth, one read
|
||||
path (`marks_for`), one openness predicate (`open_marks`), rendered beside the
|
||||
artifact on the tile, at full size in the zoom, and in the panel. `flag` and
|
||||
`note` had no write path at all before this — the selection loop
|
||||
(`golden-candidates`, `sindra-finalists`, the `pancake-*` ladders) was running
|
||||
through chat. 242 tests. Details worth carrying: `asks.py` kept `normalize_ask`
|
||||
and gained `build_answer` (the 2026-09-09 partial-answer semantics preserved by
|
||||
moving, not rewriting) and LOST its five sidecar-storage functions;
|
||||
`GET /b/<n>/marks.json` was added because remote sessions polled
|
||||
`<stem>.answer.json` over HTTP and the sidecar's removal would have taken that
|
||||
capability with it; `/b/<n>/asks` 308s to `/marks`.
|
||||
@@ -0,0 +1,17 @@
|
||||
# The U2 seam review earned its place, and how
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**The U2 seam review earned its place, and the record should
|
||||
say how.** Nine findings against the real `booth.asks` / `booth.items` /
|
||||
`booth.inline` surfaces, two of which changed scope or behaviour: `inline.py`
|
||||
was missing from `touches` entirely (its `place()` indexes asks by
|
||||
**subscript**, which a frozen dataclass refuses — nothing else in the service
|
||||
does that), and the partial-answer inconsistency above. The cold
|
||||
`/heid-contract-review` pass is artifact-only by design and structurally
|
||||
cannot see a sibling module, so neither it nor a same-model self-review would
|
||||
have found either. Two more surfaced later and are worth the same note: a
|
||||
SECOND subscript in `inline.place` the seam review undercounted, and a
|
||||
regression in my own legacy importer that a retargeted test caught — a
|
||||
malformed sidecar that renders `⚠ broken` today would have silently vanished
|
||||
on migration.
|
||||
@@ -0,0 +1,19 @@
|
||||
# U7's section premise is half wrong
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**U7's section premise is half wrong, and it is the half that
|
||||
matters** — found by re-measuring `~/booth-data` rather than trusting the IA
|
||||
doc. The IA says sections come from subfolders that already exist on disk;
|
||||
true, but **every booth that actually needs navigation is flat**:
|
||||
`pancake-v3-full` (270 items, 0 subfolders), `pancake-v4-full` (270, 0),
|
||||
`sindra20-engines` (98 items + 99 caption sidecars, 0), `sindra-finalists`
|
||||
(86 + 87, 0). Subfolders exist on exactly two booths — `pewpew-ui-brief` (7,
|
||||
nested to `_ds/powerpellet-design-system-<uuid>/preview`) and `dfa-concepts`
|
||||
(1) — and **both are reports**, the job where grid navigation matters least.
|
||||
So sections stay worth shipping and `Item.section` stays right, but they are
|
||||
**not** "most of the navigation fix": the rail, the filters and grid keyboard
|
||||
are all of it. Worth noting for whoever writes U7: `sindra20-engines` encodes
|
||||
its structure in the **filename prefix** (`b2-s1-<subject>-<seed>`), which is
|
||||
where a grouping heuristic would actually pay. The IA doc's claim about what
|
||||
sections buy needs a line struck — not yet edited.
|
||||
@@ -0,0 +1,14 @@
|
||||
# v0.2.0 was tagged while a gate was in flight
|
||||
|
||||
_2026-09-21 · booth_
|
||||
|
||||
**v0.2.0 cut and announced; v0.2.1 fixed what the announcement
|
||||
was already wrong about.** Operator approved the minor (a v1 unit closed plus a
|
||||
CLI surface change for 17 consuming handles clears the release-note bar). The
|
||||
note went to 15 handles — the 17 link-board posters minus `nh3-dev`, a host
|
||||
label, and `heid`, an oracle that does not script these verbs. Then the
|
||||
cross-frontier contract panel landed and found **three defects in the code I had
|
||||
just released**, so `v0.2.1` shipped within the hour. Sequence worth remembering:
|
||||
the release was correct by the tier bar and still premature by the discipline —
|
||||
the panel had been dispatched BEFORE implementation and its reply arrived AFTER
|
||||
the tag. **If a gate is in flight, the tag can wait for it.**
|
||||
@@ -0,0 +1,14 @@
|
||||
# `booth marks` / `booth answer` got real exit codes
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**`booth marks` / `booth answer` got real exit codes**, because
|
||||
a read that CRASHED was indistinguishable from a read that said no. `marks`
|
||||
printed a traceback and exited 0 (a caller's `jq` saw success and got
|
||||
nothing); `answer --wait` read a damaged file as "not yet" and spun for the
|
||||
full hour before blaming the operator. Now `0 ok · 1 unanswered/timed-out ·
|
||||
2 no such pick · 3 unreadable`, and `read_error()` was added to `marks.py` so
|
||||
the CLI can ask the question the browser must not: the page stays lenient, the
|
||||
machine consumer gets the truth. Also `--wait` now prints ONCE — it was
|
||||
emitting a whole JSON document per poll, so a captured `--wait` held several
|
||||
concatenated values and parsed as none of them.
|
||||
@@ -0,0 +1,13 @@
|
||||
# An existing test stopped me retiring documented behaviour
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**An existing test stopped me retiring documented behaviour
|
||||
while fixing a race.** The mtime-restore race is real, and the clean fix —
|
||||
ignoring a booth directory's own mtime whenever the booth holds anything —
|
||||
would also have silently retired the rule that RELEASING a kept board resets
|
||||
its clock, which the CLI header, the README and a deliberately-written test
|
||||
all pin. That is a TTL doctrine change, not a bug fix. Fixed the concrete half
|
||||
(a failing `os.utime` used to escape and 500 the route), left the race stated
|
||||
in the code. **A fix that changes a documented rule is a proposal, not a
|
||||
patch.**
|
||||
@@ -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,15 @@
|
||||
# The lenient reader's blast radius was the whole service
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**The lenient reader's blast radius was the whole service, not
|
||||
one booth.** `_clean_text` did `(text or "").replace(...)` and `marks_for`
|
||||
sorts on `(created, id)`, so a stored `text` that was a dict or a `created`
|
||||
that was a number raised out of the READ path — and `list_booths` reads every
|
||||
booth's marks on every index load. One hand-edited file 500'd `/` and
|
||||
`/healthz` for all 25 booths. Fixed in two layers, matching the house posture:
|
||||
a named type check (`_entry_type_error`) plus a `_hydrate_safe` backstop that
|
||||
cannot raise, and the panel now RENDERS an unreadable mark as ⚠ broken instead
|
||||
of as an empty note. **The general shape: a lenient reader is only lenient if
|
||||
the leniency is bounded by where it runs.** `marks_for` was written for one
|
||||
booth's page and is called in a loop over every booth.
|
||||
@@ -0,0 +1,11 @@
|
||||
# `scripts/booth` went from zero tests to five
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**`scripts/booth` had zero tests and now has five**
|
||||
(`tests/test_cli.py`). The panel's guard-strength tables returned UNVERIFIED
|
||||
for every CLI claim because nothing in the suite executed the script — two of
|
||||
the round's findings lived in exactly that gap. The new tests run the real
|
||||
script under the system `python3`, which makes them a live check on INV-1
|
||||
(stdlib-only) as a side effect: a third-party import in `marks.py` now fails
|
||||
in the suite the same way it would fail on a fleet host.
|
||||
@@ -0,0 +1,23 @@
|
||||
# The size cap opened a service-wide hang
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**The U5 bug-hunt panel found a service-wide hang that the
|
||||
SIZE CAP ITSELF opened — two hours after I added the cap.** `stat` reports
|
||||
size 0 for a FIFO and 0 for a symlink to `/dev/zero`, so both sail under a
|
||||
byte cap and then `read_text` blocks with no EOF or allocates until the kernel
|
||||
intervenes. `list_booths` reads every booth on every `GET /`, so ONE such file
|
||||
stalls the front page for the whole service with no error and no recovery
|
||||
short of a restart. Reproduced (`timeout` returned 124), fixed with an
|
||||
`S_ISREG` check BEFORE the size check in both modules, verified live: the
|
||||
index answered 200 in 36 ms with two FIFOs planted. **The reusable shape:
|
||||
`st_size` answers a different question than "can this be read", and a bound
|
||||
that trusts it inherits everything it does not mean — a hardening fix opened
|
||||
a worse hole than the one it closed.** Also adopted: the upload path wrote the
|
||||
manifest ABOVE its own cleanup guard (4/4), so a failure orphaned a half-booth
|
||||
whose uniquely-named leaked temp then kept it alive forever; replace-over-
|
||||
damaged destroyed recoverable bytes (4/4, now QUARANTINED rather than refused
|
||||
— marks refuse because judgment is not restatable, a booth's description is);
|
||||
and `booth answer` spelled out its own openness test, disagreeing with
|
||||
`booth marks` about a partially-answered pick, which is a direct violation of
|
||||
U2's INV-2. Full triage in `persistent-memory.d/2026-09-22-u5-panels.md`.
|
||||
@@ -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,19 @@
|
||||
# The U2 bug-hunt panel was not ceremony
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**The U2 bug-hunt panel landed and it was not ceremony —
|
||||
`v0.2.2`.** Nine adopted findings across four arms; eight were real against
|
||||
live code and one was already fixed. The headline was **4/4 convergent from
|
||||
four different angles**: `_Locked.__exit__` unlinked `.marks.lock` on the no-op
|
||||
path, and `flock` binds to an INODE — so a writer blocked on the old inode
|
||||
proceeds while the next writer creates a fresh lock file and takes it at once.
|
||||
Two processes then run the read-modify-write concurrently and the later
|
||||
`os.replace` drops a mark, with both of them obeying the protocol. **The
|
||||
cleanup existed to protect the booth's TTL and it was failing at that too**:
|
||||
creating and removing a directory entry bumps the DIRECTORY's mtime, which is
|
||||
what `_newest_mtime` actually seeds from, so a no-op reset the clock it was
|
||||
written to leave alone. Same code region, two defects, one fix — never unlink
|
||||
the lock, exempt `.<name>.lock` dotfiles from `_newest_mtime`, and put the
|
||||
directory's mtime back after creating one. Full triage in
|
||||
`persistent-memory.d/2026-09-22-bug-hunt-panel.md`.
|
||||
@@ -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,23 @@
|
||||
# U5's adoption prediction split in two
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**U5's adoption prediction, SPLIT IN TWO within an hour of
|
||||
landing — and the split is the interesting part.** The baseline was recorded as
|
||||
0 of 26. Fifty minutes after the deploy, `comfy-dev` created `muse-clothed-repro`
|
||||
and it announced itself: `{handle: comfy-dev, why: "", created: ...}`. That peer
|
||||
was told nothing. **The HANDLE propagates for free** — it rides on `booth new`
|
||||
and `booth add`, so every existing CLI caller starts announcing without learning
|
||||
anything, which is the flags-on-existing-verbs decision paying off on day zero.
|
||||
**The WHY does not** — it needs someone to know the flag exists, and this first
|
||||
one is empty.
|
||||
|
||||
So re-measure BOTH on **2026-09-29**, because they answer different questions:
|
||||
|
||||
find ~/booth-data -maxdepth 2 -name .booth.json | wc -l # free
|
||||
grep -l '"why": "[^"]' ~/booth-data/*/.booth.json 2>/dev/null | wc -l # learned
|
||||
|
||||
A high first count and a near-zero second is the predicted shape of "nobody was
|
||||
told", and it is the case the operator's no-announcement decision was designed
|
||||
to be able to see. Do not read the n=1 above as a rate — it is a code-path
|
||||
observation (every CLI caller writes a handle), not a sample.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Two U5 panels, and prose reached a released outage
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**Two cross-frontier panels on U5, and a paraphrase panel reached
|
||||
a production outage two modules away.** 3-of-4 flagged the contract's "4 GB"
|
||||
case as letter-compliant but purpose-defeating; the conformance round found that
|
||||
unbounded read live in U5's code; walking it to the sibling found the SAME hole
|
||||
**live in released `v0.2.2`** — `marks._read_raw` catches `(OSError, ValueError,
|
||||
UnicodeDecodeError)` and `json.loads` on deep nesting raises **RecursionError**,
|
||||
which is none of them, so 400 KB of brackets in one booth returned 500 for `/`
|
||||
and `/healthz` across all 26. The v0.2.2 round HAD flagged it and I closed half:
|
||||
**a finding with two call sites is not closed when one is.** The reusable
|
||||
instruction — **walk a conformance finding to the sibling module even when the
|
||||
sibling is out of scope.** Five of ten conformance findings were tests of mine
|
||||
that pass on the regression they exist to catch, three of them asserting an
|
||||
ARTIFACT of the property rather than the property; that is three nights running
|
||||
on the same shape. Two real bugs neither my tests nor I could see: a bare
|
||||
`booth add` wiped the `why` on the one sequence the feature exists for, and
|
||||
`--title` was write-only. Full triage in
|
||||
`persistent-memory.d/2026-09-22-u5-panels.md`.
|
||||
@@ -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.
|
||||
+73
-343
@@ -21,352 +21,82 @@ _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 and U2 are landed and released.** Current version `0.2.2`, deployed to the
|
||||
live service, 275 tests green, tree clean, 25/25 booth pages verified 200 after
|
||||
the deploy. U1 `ce598b3`; U2 `c7f9437` released as `v0.2.0`, then `5e41108` as
|
||||
`v0.2.1` (four contract-panel findings), then `v0.2.2` carrying the
|
||||
**bug-hunt panel's** nine (below).
|
||||
- **U5 is IMPLEMENTED and unreleased** as of 2026-09-22. `booth/manifest.py`
|
||||
(stdlib-only, INV-1), `.booth.json` per booth, the provenance line on both
|
||||
index lanes and the booth page header, `--why` / `--title` on `booth new` and
|
||||
`booth add`, and the link board + pickup booths announcing themselves as the
|
||||
service's own. 310 tests, live service restarted, 26/26 booth pages verified
|
||||
200 and all 26 rendering `unannounced`. **Deliberately NOT tagged yet**: the
|
||||
cold `/heid-contract-review` panel is still in flight and the code-review and
|
||||
bug-hunt gates have not run. That ordering is the 2026-09-21 lesson applied —
|
||||
a release whose gate is outstanding is premature even when the tier is right.
|
||||
Contract: `docs/contracts/u5_booth_manifest.contract.md` (carries its own
|
||||
seam-review section).
|
||||
- **U5's original framing** (operator, 2026-09-21): **self-announcing booths.**
|
||||
`.booth.json` carrying `{handle, title, why, created}`, written by the CLI from
|
||||
`$ALTHING_HANDLE`; the index card gains provenance and a one-line purpose, and
|
||||
the index becomes the "what landed" feed the link board was being used as. It
|
||||
closes job 5 of the five jobs — the one nobody named, and the reason 145 dead
|
||||
link rows existed. Nothing started: no contract, no blast-radius pass.
|
||||
- **Two things about U5 are already settled and should not be re-derived.**
|
||||
(1) `.booth.json` is a DOTFILE, so `booth_items`' existing `startswith(".")` skip
|
||||
already keeps it out of tiles, counts and zips — the same reason `.marks.json`
|
||||
needed no new exclusion rule. (2) The deterministic-order invariant applies to
|
||||
whatever U5 adds to the index; the index is ordered newest-first by mtime today
|
||||
and that rule must stay stated. Also worth knowing before scoping: enforcing the
|
||||
link rule without giving job 5 a home first just makes it homeless — that is the
|
||||
lesson from the 69% rot, and U5 is the home.
|
||||
- **No heid dispatch is outstanding.** The `/heid-bug-hunt` on U2's diff landed
|
||||
2026-09-22 and shipped as `v0.2.2`; see the dated entry below.
|
||||
- Live service `active` on `:8090`, 25 booths, verified 25 × 3 page types after the
|
||||
last deploy. The booth set churns: `sindra20-engines` and `sindra-finalists` were
|
||||
swept during the session, `cr123a-to-d-sleeve` and `sindra` appeared.
|
||||
- **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 released as `v0.4.0`** (operator approved the minor on 2026-09-22).
|
||||
`c3a97c1` is the unit; the release commit carries the pre-existing fixes the
|
||||
bug-hunt panel surfaced in touched files. The tag waited for the last gate to
|
||||
close, per the `v0.2.0` lesson — see Tried and abandoned.
|
||||
- ⚠ **The 17 consuming handles have NOT been told** that `keep` no longer means
|
||||
"waiting on an answer". That is the one coordination this release genuinely
|
||||
warrants, and a fleetwide post needs operator approval before it is sent.
|
||||
- **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 **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
|
||||
"green-tests-prove-nothing" direction for the code-review gate, and regin's
|
||||
table-vs-signature consistency pass. They are changes to the `/heid*` skills,
|
||||
not to this repo.
|
||||
- The booth set churns hard: 26 → 24 during this session as the sweeper ran.
|
||||
Re-count rather than trusting any number written here.
|
||||
|
||||
## Recent decisions
|
||||
|
||||
- `[2026-09-22]` **The U5 bug-hunt panel found a service-wide hang that the
|
||||
SIZE CAP ITSELF opened — two hours after I added the cap.** `stat` reports
|
||||
size 0 for a FIFO and 0 for a symlink to `/dev/zero`, so both sail under a
|
||||
byte cap and then `read_text` blocks with no EOF or allocates until the kernel
|
||||
intervenes. `list_booths` reads every booth on every `GET /`, so ONE such file
|
||||
stalls the front page for the whole service with no error and no recovery
|
||||
short of a restart. Reproduced (`timeout` returned 124), fixed with an
|
||||
`S_ISREG` check BEFORE the size check in both modules, verified live: the
|
||||
index answered 200 in 36 ms with two FIFOs planted. **The reusable shape:
|
||||
`st_size` answers a different question than "can this be read", and a bound
|
||||
that trusts it inherits everything it does not mean — a hardening fix opened
|
||||
a worse hole than the one it closed.** Also adopted: the upload path wrote the
|
||||
manifest ABOVE its own cleanup guard (4/4), so a failure orphaned a half-booth
|
||||
whose uniquely-named leaked temp then kept it alive forever; replace-over-
|
||||
damaged destroyed recoverable bytes (4/4, now QUARANTINED rather than refused
|
||||
— marks refuse because judgment is not restatable, a booth's description is);
|
||||
and `booth answer` spelled out its own openness test, disagreeing with
|
||||
`booth marks` about a partially-answered pick, which is a direct violation of
|
||||
U2's INV-2. Full triage in `persistent-memory.d/2026-09-22-u5-panels.md`.
|
||||
- `[2026-09-22]` **An existing test stopped me retiring documented behaviour
|
||||
while fixing a race.** The mtime-restore race is real, and the clean fix —
|
||||
ignoring a booth directory's own mtime whenever the booth holds anything —
|
||||
would also have silently retired the rule that RELEASING a kept board resets
|
||||
its clock, which the CLI header, the README and a deliberately-written test
|
||||
all pin. That is a TTL doctrine change, not a bug fix. Fixed the concrete half
|
||||
(a failing `os.utime` used to escape and 500 the route), left the race stated
|
||||
in the code. **A fix that changes a documented rule is a proposal, not a
|
||||
patch.**
|
||||
- `[2026-09-22]` **Two cross-frontier panels on U5, and a paraphrase panel reached
|
||||
a production outage two modules away.** 3-of-4 flagged the contract's "4 GB"
|
||||
case as letter-compliant but purpose-defeating; the conformance round found that
|
||||
unbounded read live in U5's code; walking it to the sibling found the SAME hole
|
||||
**live in released `v0.2.2`** — `marks._read_raw` catches `(OSError, ValueError,
|
||||
UnicodeDecodeError)` and `json.loads` on deep nesting raises **RecursionError**,
|
||||
which is none of them, so 400 KB of brackets in one booth returned 500 for `/`
|
||||
and `/healthz` across all 26. The v0.2.2 round HAD flagged it and I closed half:
|
||||
**a finding with two call sites is not closed when one is.** The reusable
|
||||
instruction — **walk a conformance finding to the sibling module even when the
|
||||
sibling is out of scope.** Five of ten conformance findings were tests of mine
|
||||
that pass on the regression they exist to catch, three of them asserting an
|
||||
ARTIFACT of the property rather than the property; that is three nights running
|
||||
on the same shape. Two real bugs neither my tests nor I could see: a bare
|
||||
`booth add` wiped the `why` on the one sequence the feature exists for, and
|
||||
`--title` was write-only. Full triage in
|
||||
`persistent-memory.d/2026-09-22-u5-panels.md`.
|
||||
- `[2026-09-22]` **U5's adoption prediction, SPLIT IN TWO within an hour of
|
||||
landing — and the split is the interesting part.** The baseline was recorded as
|
||||
0 of 26. Fifty minutes after the deploy, `comfy-dev` created `muse-clothed-repro`
|
||||
and it announced itself: `{handle: comfy-dev, why: "", created: ...}`. That peer
|
||||
was told nothing. **The HANDLE propagates for free** — it rides on `booth new`
|
||||
and `booth add`, so every existing CLI caller starts announcing without learning
|
||||
anything, which is the flags-on-existing-verbs decision paying off on day zero.
|
||||
**The WHY does not** — it needs someone to know the flag exists, and this first
|
||||
one is empty.
|
||||
|
||||
So re-measure BOTH on **2026-09-29**, because they answer different questions:
|
||||
|
||||
find ~/booth-data -maxdepth 2 -name .booth.json | wc -l # free
|
||||
grep -l '"why": "[^"]' ~/booth-data/*/.booth.json 2>/dev/null | wc -l # learned
|
||||
|
||||
A high first count and a near-zero second is the predicted shape of "nobody was
|
||||
told", and it is the case the operator's no-announcement decision was designed
|
||||
to be able to see. Do not read the n=1 above as a rate — it is a code-path
|
||||
observation (every CLI caller writes a handle), not a sample.
|
||||
- `[2026-09-22]` **The U2 bug-hunt panel landed and it was not ceremony —
|
||||
`v0.2.2`.** Nine adopted findings across four arms; eight were real against
|
||||
live code and one was already fixed. The headline was **4/4 convergent from
|
||||
four different angles**: `_Locked.__exit__` unlinked `.marks.lock` on the no-op
|
||||
path, and `flock` binds to an INODE — so a writer blocked on the old inode
|
||||
proceeds while the next writer creates a fresh lock file and takes it at once.
|
||||
Two processes then run the read-modify-write concurrently and the later
|
||||
`os.replace` drops a mark, with both of them obeying the protocol. **The
|
||||
cleanup existed to protect the booth's TTL and it was failing at that too**:
|
||||
creating and removing a directory entry bumps the DIRECTORY's mtime, which is
|
||||
what `_newest_mtime` actually seeds from, so a no-op reset the clock it was
|
||||
written to leave alone. Same code region, two defects, one fix — never unlink
|
||||
the lock, exempt `.<name>.lock` dotfiles from `_newest_mtime`, and put the
|
||||
directory's mtime back after creating one. Full triage in
|
||||
`persistent-memory.d/2026-09-22-bug-hunt-panel.md`.
|
||||
- `[2026-09-22]` **The lenient reader's blast radius was the whole service, not
|
||||
one booth.** `_clean_text` did `(text or "").replace(...)` and `marks_for`
|
||||
sorts on `(created, id)`, so a stored `text` that was a dict or a `created`
|
||||
that was a number raised out of the READ path — and `list_booths` reads every
|
||||
booth's marks on every index load. One hand-edited file 500'd `/` and
|
||||
`/healthz` for all 25 booths. Fixed in two layers, matching the house posture:
|
||||
a named type check (`_entry_type_error`) plus a `_hydrate_safe` backstop that
|
||||
cannot raise, and the panel now RENDERS an unreadable mark as ⚠ broken instead
|
||||
of as an empty note. **The general shape: a lenient reader is only lenient if
|
||||
the leniency is bounded by where it runs.** `marks_for` was written for one
|
||||
booth's page and is called in a loop over every booth.
|
||||
- `[2026-09-22]` **`booth marks` / `booth answer` got real exit codes**, because
|
||||
a read that CRASHED was indistinguishable from a read that said no. `marks`
|
||||
printed a traceback and exited 0 (a caller's `jq` saw success and got
|
||||
nothing); `answer --wait` read a damaged file as "not yet" and spun for the
|
||||
full hour before blaming the operator. Now `0 ok · 1 unanswered/timed-out ·
|
||||
2 no such pick · 3 unreadable`, and `read_error()` was added to `marks.py` so
|
||||
the CLI can ask the question the browser must not: the page stays lenient, the
|
||||
machine consumer gets the truth. Also `--wait` now prints ONCE — it was
|
||||
emitting a whole JSON document per poll, so a captured `--wait` held several
|
||||
concatenated values and parsed as none of them.
|
||||
- `[2026-09-22]` **`scripts/booth` had zero tests and now has five**
|
||||
(`tests/test_cli.py`). The panel's guard-strength tables returned UNVERIFIED
|
||||
for every CLI claim because nothing in the suite executed the script — two of
|
||||
the round's findings lived in exactly that gap. The new tests run the real
|
||||
script under the system `python3`, which makes them a live check on INV-1
|
||||
(stdlib-only) as a side effect: a third-party import in `marks.py` now fails
|
||||
in the suite the same way it would fail on a fleet host.
|
||||
|
||||
- `[2026-09-21]` **v0.2.0 cut and announced; v0.2.1 fixed what the announcement
|
||||
was already wrong about.** Operator approved the minor (a v1 unit closed plus a
|
||||
CLI surface change for 17 consuming handles clears the release-note bar). The
|
||||
note went to 15 handles — the 17 link-board posters minus `nh3-dev`, a host
|
||||
label, and `heid`, an oracle that does not script these verbs. Then the
|
||||
cross-frontier contract panel landed and found **three defects in the code I had
|
||||
just released**, so `v0.2.1` shipped within the hour. Sequence worth remembering:
|
||||
the release was correct by the tier bar and still premature by the discipline —
|
||||
the panel had been dispatched BEFORE implementation and its reply arrived AFTER
|
||||
the tag. **If a gate is in flight, the tag can wait for it.**
|
||||
- `[2026-09-21]` **A write over a damaged `.marks.json` was wiping every mark in
|
||||
the booth.** Shipped in `v0.2.0`, found by the panel (Kimi, converged with
|
||||
Hulda), fixed in `v0.2.1`. `marks_for` is deliberately lenient — unparseable
|
||||
reads as `[]` so a review page still loads — and the write path inherited that
|
||||
leniency through the same reader, so one flag click appended to an empty list and
|
||||
atomically replaced the file. The fix is an **asymmetry**, which is the reusable
|
||||
part: reads stay lenient, writes go strict (`MarksCorrupt`), damaged bytes stay
|
||||
on disk, routes answer 409 not 500. A page that renders without an annotation is
|
||||
recoverable; a file that overwrote the operator's judgment is not. Kimi also
|
||||
named the class correctly — "an author steeped in the design conversation would
|
||||
likely read past" it — and that was accurate.
|
||||
- `[2026-09-21]` **The two review gates are complementary, measured on one unit.**
|
||||
The caller-side **seam review** (nine findings, against the real sibling module
|
||||
surfaces) and the cold **`/heid-contract-review` panel** (four arms,
|
||||
artifact-only) had **zero overlap in both directions** on U2. The seam review
|
||||
found a scope miss the panel structurally could not see: the contract omitted
|
||||
`inline.py`, whose `place()` indexes by subscript, which a frozen dataclass
|
||||
refuses. The panel found three code defects and a missing test the seam review
|
||||
had no lens for. Matches heid's kvasir zero-overlap result on the
|
||||
conformance-versus-hunt axis. **Run both; neither substitutes.**
|
||||
- `[2026-09-21]` **Every one of the panel's code-changing findings came from the
|
||||
AMBIGUITY pass, none from a paraphrase divergence** — and two arms independently
|
||||
proposed cutting the paraphrase to a drift-check for narrative-heavy contracts,
|
||||
because this contract's own frontmatter carries a plain-language narrative and the
|
||||
paraphrase was partly reading my framing back to me. That is a finding about the
|
||||
`/heid-contract-review` **skill**, not about this repo, and it was reported back
|
||||
to heid. Recorded here only so a future session does not rediscover it.
|
||||
- `[2026-09-21]` **Deterministic order is a cross-cutting v1 invariant** —
|
||||
operator directive, mid-implementation. Every ordered collection the Booth
|
||||
renders must have a *stated* rule producing the same sequence on every render
|
||||
of the same state; the rule can be anything defensible (byte order, time, an
|
||||
explicit number, an arbitrary-but-recorded sequence), but no rule at all is
|
||||
forbidden. It binds harder here than elsewhere because the Booth's job is
|
||||
**comparison** — the operator judges tile 47 against tile 47 and refers to
|
||||
artifacts positionally, so an order that moves between renders misfiles a flag
|
||||
or a note rather than crashing. Recorded as `ROADMAP.md` § "Cross-cutting
|
||||
invariant" (with the per-collection table) and `CLAUDE.md` invariant 6, and
|
||||
tested. Still undecided and must be settled before those units ship: **U7's
|
||||
section ordering and compare pairing**, and **U6's bench listing**.
|
||||
- `[2026-09-21]` **U2 (marks) landed.** One primitive replacing three
|
||||
mechanisms. `pick` / `note` / `flag` in one `.marks.json` per booth, one read
|
||||
path (`marks_for`), one openness predicate (`open_marks`), rendered beside the
|
||||
artifact on the tile, at full size in the zoom, and in the panel. `flag` and
|
||||
`note` had no write path at all before this — the selection loop
|
||||
(`golden-candidates`, `sindra-finalists`, the `pancake-*` ladders) was running
|
||||
through chat. 242 tests. Details worth carrying: `asks.py` kept `normalize_ask`
|
||||
and gained `build_answer` (the 2026-09-09 partial-answer semantics preserved by
|
||||
moving, not rewriting) and LOST its five sidecar-storage functions;
|
||||
`GET /b/<n>/marks.json` was added because remote sessions polled
|
||||
`<stem>.answer.json` over HTTP and the sidecar's removal would have taken that
|
||||
capability with it; `/b/<n>/asks` 308s to `/marks`.
|
||||
- `[2026-09-21]` **A partially-answered pick now counts as OPEN** — declared, not
|
||||
smuggled. The old index badge tested `answer is None`, so a half-answered
|
||||
four-question ask read as closed on the index while the panel beside it
|
||||
rendered `◐ partial`: the two disagreed about the same booth. 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.
|
||||
- `[2026-09-21]` **The U2 seam review earned its place, and the record should
|
||||
say how.** Nine findings against the real `booth.asks` / `booth.items` /
|
||||
`booth.inline` surfaces, two of which changed scope or behaviour: `inline.py`
|
||||
was missing from `touches` entirely (its `place()` indexes asks by
|
||||
**subscript**, which a frozen dataclass refuses — nothing else in the service
|
||||
does that), and the partial-answer inconsistency above. The cold
|
||||
`/heid-contract-review` pass is artifact-only by design and structurally
|
||||
cannot see a sibling module, so neither it nor a same-model self-review would
|
||||
have found either. Two more surfaced later and are worth the same note: a
|
||||
SECOND subscript in `inline.place` the seam review undercounted, and a
|
||||
regression in my own legacy importer that a retargeted test caught — a
|
||||
malformed sidecar that renders `⚠ broken` today would have silently vanished
|
||||
on migration.
|
||||
- `[2026-09-21]` **Marks are stored as one `.marks.json` per booth**, atomic
|
||||
temp-file + `os.replace`, `fcntl` lock on the read-modify-write — operator
|
||||
decision, this session. Two alternatives were weighed and lost: a sidecar
|
||||
per item (`<rel>.marks.json`) and extending the existing `<stem>.ask.json`
|
||||
shape. Rationale, and the reason it is not `links.md`-shaped: **(a)** U4
|
||||
makes *"does this booth owe an answer?"* a hot question — the sweep asks it
|
||||
per booth per tick and the index asks it per card per page load, so per-item
|
||||
sidecars turn it into a full walk of all 25 booths, one of which holds 270
|
||||
files; **(b)** `links.md` is an `O_APPEND` content-hash log because **17
|
||||
agent handles write it concurrently**, whereas marks have exactly one writer
|
||||
(the operator, in one browser) and many readers — a different problem that
|
||||
must not inherit the append-log design; **(c)** `.blurred` / `.pins` /
|
||||
`.forever` already establish the per-booth dotfile as the house shape for
|
||||
operator state, and `booth_items()`'s dotfile skip means it costs nothing in
|
||||
counts, galleries or zips. Accepted cost: a corrupt `.marks.json` loses that
|
||||
booth's marks rather than one item's. Implementation deferred to U2 —
|
||||
tracked at `ROADMAP.md` U2 and by this entry.
|
||||
- `[2026-09-21]` **U7's section premise is half wrong, and it is the half that
|
||||
matters** — found by re-measuring `~/booth-data` rather than trusting the IA
|
||||
doc. The IA says sections come from subfolders that already exist on disk;
|
||||
true, but **every booth that actually needs navigation is flat**:
|
||||
`pancake-v3-full` (270 items, 0 subfolders), `pancake-v4-full` (270, 0),
|
||||
`sindra20-engines` (98 items + 99 caption sidecars, 0), `sindra-finalists`
|
||||
(86 + 87, 0). Subfolders exist on exactly two booths — `pewpew-ui-brief` (7,
|
||||
nested to `_ds/powerpellet-design-system-<uuid>/preview`) and `dfa-concepts`
|
||||
(1) — and **both are reports**, the job where grid navigation matters least.
|
||||
So sections stay worth shipping and `Item.section` stays right, but they are
|
||||
**not** "most of the navigation fix": the rail, the filters and grid keyboard
|
||||
are all of it. Worth noting for whoever writes U7: `sindra20-engines` encodes
|
||||
its structure in the **filename prefix** (`b2-s1-<subject>-<seed>`), which is
|
||||
where a grouping heuristic would actually pay. The IA doc's claim about what
|
||||
sections buy needs a line struck — not yet edited.
|
||||
- `[2026-09-21]` **`sindra-finalists` is U2's `flag` motivation caught in the
|
||||
act** — 86 items, every one captioned, and the booth's entire name is "the
|
||||
ones the operator picked." That loop currently runs through chat, which is
|
||||
the defect `flag` closes. Evidence, not argument.
|
||||
- `[2026-09-21]` **The information architecture and the v1 gate landed**
|
||||
(`726822b`): `docs/design/information-architecture.md` names the single
|
||||
defect — *one lifetime (24h from last touch) and one shape (a folder),
|
||||
serving five jobs with different lifetimes and different shapes* — and
|
||||
`ROADMAP.md` gates v1 on seven units, each closing a **measured** defect
|
||||
rather than a wish. Both were written after a measurement pass over the live
|
||||
service, and the measurements are the load-bearing part.
|
||||
- `[2026-09-21]` **The `.forever` diagnosis is a stated, falsifiable
|
||||
prediction.** U4 (derived lifetime) predicts the kept-rate falls to the
|
||||
genuinely-durable booths. Re-measured today: **14 of 25 booths kept (56%)**,
|
||||
against the 54% the IA doc recorded. **Re-count a fortnight after U4 lands.**
|
||||
If it does not move, the diagnosis was wrong and the boolean was doing
|
||||
something else. Tracked in the IA doc's Booth section and by this entry.
|
||||
- `[2026-09-21]` **Extracted from `eshpfi` into its own repo.** The accreted
|
||||
service came over whole, tests included, so `tests/test_booth.py` (1581 lines)
|
||||
is the regression net the v1 rewrite is checked against.
|
||||
- `[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`
|
||||
- `[2026-09-22]` **U5's adoption prediction split in two** — the handle rides for free, the why must be learned — RE-MEASURE 2026-09-29 → `persistent-memory.d/2026-09-22-u5-adoption-split-in-two.md`
|
||||
- `[2026-09-22]` **The U2 bug-hunt panel was not ceremony** — the lock-unlink race and the TTL guard that was failing at its own job → `persistent-memory.d/2026-09-22-u2-bug-hunt-panel.md`
|
||||
- `[2026-09-22]` **The lenient reader's blast radius was the whole service** — marks_for runs per booth per index load; a raise there is an outage → `persistent-memory.d/2026-09-22-lenient-reader-blast-radius.md`
|
||||
- `[2026-09-22]` **`booth marks` / `booth answer` got real exit codes** — read it before changing anything the 17 consuming handles call → `persistent-memory.d/2026-09-22-cli-exit-codes.md`
|
||||
- `[2026-09-22]` **`scripts/booth` went from zero tests to five** — they run the real script under system python3, so they also check INV-1 → `persistent-memory.d/2026-09-22-scripts-booth-got-tests.md`
|
||||
- `[2026-09-21]` **v0.2.0 was tagged while a gate was in flight** — the sequencing lesson: if a gate is outstanding, the tag waits → `persistent-memory.d/2026-09-21-v020-tagged-with-a-gate-in-flight.md`
|
||||
- `[2026-09-21]` **A write over a damaged `.marks.json` wiped the booth** — the reads-lenient / writes-strict asymmetry, and why it exists → `persistent-memory.d/2026-09-21-marks-write-wiped-judgment.md`
|
||||
- `[2026-09-21]` **Seam review and cold panel had zero overlap, twice** — evidence for running both; neither substitutes for the other → `persistent-memory.d/2026-09-21-two-gates-are-complementary.md`
|
||||
- `[2026-09-21]` **Every code-changing finding came from the AMBIGUITY pass** — a finding about the /heid-contract-review skill, not about this repo → `persistent-memory.d/2026-09-21-ambiguity-pass-did-the-work.md`
|
||||
- `[2026-09-21]` **Deterministic order is a cross-cutting v1 invariant** — operator directive; read before adding ANY ordered surface → `persistent-memory.d/2026-09-21-deterministic-order-invariant.md`
|
||||
- `[2026-09-21]` **U2 (marks) landed — one primitive for three mechanisms** — what moved where, and the HTTP mirror remote sessions poll → `persistent-memory.d/2026-09-21-u2-marks-landed.md`
|
||||
- `[2026-09-21]` **A partially-answered pick counts as OPEN** — declared, not smuggled; it is the reading that makes U4 correct → `persistent-memory.d/2026-09-21-partial-answer-counts-as-open.md`
|
||||
- `[2026-09-21]` **The U2 seam review earned its place, and how** — inline.place indexes by subscript — the miss a cold panel cannot see → `persistent-memory.d/2026-09-21-u2-seam-review-earned-it.md`
|
||||
- `[2026-09-21]` **Marks are one `.marks.json` per booth** — operator decision with two rejected alternatives; read before restructuring → `persistent-memory.d/2026-09-21-marks-storage-decision.md`
|
||||
- `[2026-09-21]` **U7's section premise is half wrong** — every booth that needs navigation is FLAT — read before starting U7 → `persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md`
|
||||
- `[2026-09-21]` **`sindra-finalists` is U2's flag motivation, caught live** — evidence, not argument → `persistent-memory.d/2026-09-21-sindra-finalists-is-the-motivation.md`
|
||||
- `[2026-09-21]` **The information architecture and the v1 gate landed** — the single defect the seven units decompose → `persistent-memory.d/2026-09-21-ia-and-v1-gate-landed.md`
|
||||
- `[2026-09-21]` **The `.forever` diagnosis is a falsifiable prediction** — U4's success criterion — re-count a fortnight AFTER U4 lands → `persistent-memory.d/2026-09-21-forever-diagnosis-is-a-prediction.md`
|
||||
- `[2026-09-21]` **Extracted from `eshpfi` into its own repo** — test_booth.py is the regression net the v1 rewrite is checked against → `persistent-memory.d/2026-09-21-extracted-from-eshpfi.md`
|
||||
|
||||
## Tried and abandoned
|
||||
|
||||
- `[2026-09-21]` **Tagging a release while a review gate was still in flight.**
|
||||
`v0.2.0` was cut and announced to 15 consuming handles; the
|
||||
`/heid-contract-review` panel — dispatched BEFORE implementation, as the
|
||||
discipline says — replied afterwards with three defects in the code that had just
|
||||
shipped, one of them silent data loss. Nothing about the tier decision was wrong;
|
||||
the *timing* was. **If a gate is outstanding on the work being released, the tag
|
||||
waits for it.** The cost was a same-hour `v0.2.1` and a correction note to peers
|
||||
who had already verified against the broken version.
|
||||
- `[2026-09-21]` **Letting the write path share the read path's leniency.** See the
|
||||
`MarksCorrupt` decision above. The general shape, worth carrying beyond marks:
|
||||
a tolerant reader and a tolerant writer over the same state are not the same
|
||||
decision, and pointing both at one function silently makes them one. Tolerate on
|
||||
read so the surface still renders; refuse on write so nothing is destroyed.
|
||||
- `[2026-09-21]` **Letting Jinja hot-reload templates while the repo is the
|
||||
deployment root** — the cause of a live outage the same day U2 landed, and the
|
||||
sharpest foot-gun in the repo. `booth.service` sets `WorkingDirectory` to this
|
||||
repo, so the running service imports these files with no build step and no
|
||||
staging copy. Python is read once at process start; Jinja's `FileSystemLoader`
|
||||
re-reads a template **on every render**. Editing `booth.html` therefore
|
||||
deployed it instantly against Python from 22:03 that knew nothing about
|
||||
`item_marks`, and **19 of 25 live booths returned 500** with
|
||||
`UndefinedError: 'item_marks' is undefined`. Neither the old code nor the new
|
||||
code was broken — the service was running both at once.
|
||||
**The lesson that generalises:** a skew between a process and the disk under it
|
||||
is invisible to the test suite by construction, so no amount of green tests
|
||||
would have caught it; the operator found it. Fixed at the source rather than
|
||||
with a reminder — the `Environment` is hand-built with `auto_reload=False`, so
|
||||
there is now ONE staleness rule (nothing takes effect until you restart) and
|
||||
the running process is always a coherent snapshot of one commit. Asserted by
|
||||
`test_templates_do_not_hot_reload_from_disk`. Watch the second-order risk the
|
||||
fix introduces: a hand-built `Environment` does not inherit `autoescape` from
|
||||
the `Jinja2Templates` constructor, and booth names, item names and mark text
|
||||
are all agent-authored strings landing in HTML.
|
||||
|
||||
- `[2026-09-21]` **Five separate mechanisms to get one question next to one
|
||||
artifact** — `.forever`, the link board, `inline.py`'s placeholder DSL,
|
||||
`wrap_verbatim_html`'s six regexes, and the floating amber asks chip plus
|
||||
`/b/<n>/asks`. Every one is a *correct local fix* to the same global
|
||||
mismatch, which is exactly why they accumulated without anyone making a bad
|
||||
call. **The foot-gun is the sixth one:** the next "just add a small thing for
|
||||
this case" reads as reasonable and is the pattern. The git log carries the
|
||||
signature — every feature ships, then takes 2–5 patches for cases the single
|
||||
shape did not anticipate. Check the ROADMAP gate before adding a mechanism.
|
||||
- `[2026-09-21]` **Regex-injecting chrome into arbitrary author HTML**
|
||||
(`wrap_verbatim_html` + `_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`,
|
||||
`_BODY_CLOSE_RE`, `_HTML_CLOSE_RE`, `_ICON_RE`, and the doctype/charset
|
||||
ordering constraints they thread). It works today and is **still live** —
|
||||
but it is the single most fragile thing in the service and it is load-bearing
|
||||
for the operator's most important workflow. Slated for deletion at U3 in
|
||||
favour of a declared seam (`/_booth/embed.js`, mounted through a real DOM
|
||||
API), which costs an author one line and removes the whole class. Do not
|
||||
extend the regex set in the meantime; if a verbatim page breaks, that is an
|
||||
argument for U3, not for a seventh pattern.
|
||||
- `[2026-09-21]` **A boolean escape hatch as the lifetime mechanism.**
|
||||
`.forever` was added because a 24h TTL genuinely did not fit some booths —
|
||||
and then 56% of live booths ended up on it, which means it is not "ephemeral
|
||||
with an exception", it is two lifetimes wearing one lifetime's clothes, with
|
||||
the operator doing the sorting by hand. Replaced at U4 by lifetime derived
|
||||
from state (an open mark pins; viewing is activity; `keep` survives as an
|
||||
explicit reasoned pin rather than the only way to say "not yet").
|
||||
- `[2026-09-21]` **Letting the link board absorb the announce job.** `booth
|
||||
link` is an `O_APPEND` write with no identity and no stated rule, so
|
||||
re-announcing a bench appends a row instead of updating one, and a booth URL
|
||||
rots the moment its booth is swept — **145 of 211 rows (69%) pointed at
|
||||
nothing**, and 22 were the same target re-posted (talk 5×, peedlar 4×). The
|
||||
rot is **structural, not drift**. The lesson that cost the most: enforcing
|
||||
the link rule without first giving the announce job a home (`.booth.json`
|
||||
provenance on the index, U5) just makes it homeless.
|
||||
- `[2026-09-21]` **Tagging a release while a review gate was in flight** — cost a same-hour v0.2.1 and a correction to 15 handles → `persistent-memory.d/2026-09-21-tagging-with-a-gate-in-flight.md`
|
||||
- `[2026-09-21]` **Letting the write path share the read path's leniency** — a tolerant reader and a tolerant writer are not the same decision → `persistent-memory.d/2026-09-21-tolerant-writer-over-tolerant-reader.md`
|
||||
- `[2026-09-21]` **Letting Jinja hot-reload templates in the deployment root** — caused a live outage: 19 of 25 booths at 500. Why auto_reload=False → `persistent-memory.d/2026-09-21-jinja-hot-reload-outage.md`
|
||||
- `[2026-09-21]` **Five mechanisms to get one question beside one artifact** — the accretion signature this whole v1 rewrite is undoing → `persistent-memory.d/2026-09-21-five-mechanisms-one-job.md`
|
||||
- `[2026-09-21]` **Regex-injecting chrome into arbitrary author HTML** — the defect U3 exists to close → `persistent-memory.d/2026-09-21-regex-injecting-chrome.md`
|
||||
- `[2026-09-21]` **A boolean escape hatch as the lifetime mechanism** — why `.forever` is a symptom; the defect U4 exists to close → `persistent-memory.d/2026-09-21-boolean-escape-hatch-as-lifetime.md`
|
||||
- `[2026-09-21]` **Letting the link board absorb the announce job** — 69% rot; U5 gave the job a home, which is what unblocks U6 → `persistent-memory.d/2026-09-21-link-board-absorbing-announce.md`
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "booth"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
+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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user