Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7996fbd597 | ||
|
|
5c20e2f4d5 | ||
|
|
87e2c5364c | ||
|
|
42ea67f33f | ||
|
|
8f81d8f9d0 | ||
|
|
c75d7a2797 | ||
|
|
c3a97c1b64 | ||
|
|
d37b81ab9f | ||
|
|
95beede3c3 | ||
|
|
f3193fb054 | ||
|
|
c015a917ee | ||
|
|
fac83de8f4 | ||
|
|
aa61fcf5fd | ||
|
|
c9a175ba4a | ||
|
|
75dca53483 | ||
|
|
67ab7d1cd5 | ||
|
|
ac35f2441f | ||
|
|
a48ef83ef5 | ||
|
|
109190b0d6 |
@@ -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.
|
||||
@@ -84,6 +85,12 @@ doing so, never resolved the caption — the operator's "zoomed images lose
|
||||
their annotations" bug. It was not a rendering bug; it was three readers of one
|
||||
truth.
|
||||
|
||||
**U3 extended this to the verbatim path.** A booth's own `index.html` now gets
|
||||
its chrome from `/_booth/embed.js`, which *places* server-rendered fragments and
|
||||
never builds one. The fragments come from the same `_ask_inline.html` macros the
|
||||
gallery page uses, handed over `/b/<name>/embed.json`. A second renderer in
|
||||
JavaScript would be the same bug in a new language.
|
||||
|
||||
### 4. Re-export, don't move-and-break
|
||||
|
||||
Names that moved from `app.py` to `items.py` (`classify`, `doc_kind`,
|
||||
@@ -121,9 +128,14 @@ the operator's judgment being quietly misfiled.
|
||||
|
||||
Current rules: items `sorted(rel)`; the zoom ring is that order filtered to
|
||||
images; captions resolve over a sorted scan; marks `(created, id)`; legacy
|
||||
import `(mtime, name)`; link rows pinned-then-newest. `ROADMAP.md` carries the
|
||||
table and the two places still undecided (U7 sections and compare pairing, U6
|
||||
bench listing).
|
||||
import `(mtime, name)`; link rows pinned-then-newest; a verbatim report's embed
|
||||
anchors in document order, its tail in payload order, its questions in
|
||||
declaration order. `ROADMAP.md` carries the table and the two places still
|
||||
undecided (U7 sections and compare pairing, U6 bench listing).
|
||||
|
||||
U3's rows are the first that bind **across a language boundary** — decided in
|
||||
Python, honoured in JavaScript. A string assertion cannot see that, which is
|
||||
why `tests/test_embed_browser.py` exists.
|
||||
|
||||
When you add an ordered surface, state its rule in the docstring. If you cannot
|
||||
state it in one line, it does not have one.
|
||||
@@ -178,8 +190,15 @@ one caused an outage.
|
||||
is that template work needs a restart to see, and that price is the point.
|
||||
`test_templates_do_not_hot_reload_from_disk` holds the line.
|
||||
|
||||
**So: after ANY edit here — Python or template — the live service is stale until
|
||||
you restart it.** If you are touching this repo while the operator may be using
|
||||
3. **`booth/static/embed.js` is the third thing that would have hot-reloaded,
|
||||
and it does not.** U3 gave the service a static asset living in the
|
||||
deployment root; it is read ONCE in `create_app` and served from memory with
|
||||
an ETag over its content, for exactly the reason above. Same rule, same test
|
||||
shape (`test_embed_js_does_not_hot_reload_from_disk`). Anything else this
|
||||
repo learns to serve from disk inherits the rule — read it at startup.
|
||||
|
||||
**So: after ANY edit here — Python, template or static asset — the live service
|
||||
is stale until you restart it.** If you are touching this repo while the operator may be using
|
||||
the service, either restart promptly or expect him to be looking at the old
|
||||
version. Never leave the tree in a state where a restart would 500.
|
||||
|
||||
@@ -196,6 +215,15 @@ curl -s localhost:8090/healthz # the live service (systemd --user)
|
||||
systemctl --user restart booth.service # after a code change, to see it live
|
||||
```
|
||||
|
||||
`tests/test_embed_browser.py` drives a real Chromium against a real uvicorn on
|
||||
an ephemeral port — the only place U3's placement and `form=` binding can be
|
||||
observed at all. Browsers are NOT downloaded per project; they live box-wide in
|
||||
`/opt/ms-playwright`. The file **skips rather than fails** when playwright or a
|
||||
usable browser is missing, so the suite stays green anywhere. If those tests
|
||||
start skipping on this box, the pinned `playwright>=1.60,<1.63` in
|
||||
`pyproject.toml` has drifted past the shared store — read the comment there
|
||||
before raising the bound.
|
||||
|
||||
`booth.service` is a user unit installed to `~/.config/systemd/user/`. The repo
|
||||
copy is the source; edits there need a `daemon-reload`.
|
||||
|
||||
|
||||
@@ -15,16 +15,18 @@ 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
|
||||
|
||||
A booth is **just a folder** under the data dir. Three ways, cheapest first:
|
||||
|
||||
```bash
|
||||
# 1. On nh3-dev — the helper (services/booth/scripts/booth):
|
||||
booth add my-run out/a.png out/b.png # creates booth + copies, prints URL
|
||||
booth new my-run # empty booth, then cp/mv into ~/booth-data/my-run/
|
||||
# 1. On nh3-dev — the helper (scripts/booth):
|
||||
booth add my-run out/a.png out/b.png --why "pick the denoiser, v3 on the left"
|
||||
booth new my-run --why "..." # empty booth, then cp/mv into ~/booth-data/my-run/
|
||||
booth url my-run # just print the URL
|
||||
booth ls # list booths
|
||||
booth rm my-run # wipe now (TTL would anyway)
|
||||
@@ -39,6 +41,25 @@ rsync -a ./out/ nh3-dev:booth-data/my-run/
|
||||
|
||||
Then hand the operator `http://10.100.10.50:8090/b/my-run/`.
|
||||
|
||||
### Say what it is — `--why`
|
||||
|
||||
**`--why` is one line telling the operator what he is looking at and why.** It
|
||||
lands on the index card and on the booth page next to your handle (taken from
|
||||
`$ALTHING_HANDLE`), stored as `.booth.json` in the booth.
|
||||
|
||||
It is optional and nothing breaks without it — a booth with no announcement
|
||||
renders as `unannounced`, which is also what every booth created by `rsync` or
|
||||
a bare `mkdir` looks like. But a booth that cannot say what it is has no way to
|
||||
ask for attention except by posting its URL somewhere else, and that is exactly
|
||||
how the link board ended up 69% dead rows. **The booth is the place to say it.**
|
||||
|
||||
```bash
|
||||
booth add r18-ab out/*.png --why "which denoiser — v3 left, v4 right" --title "R18 A/B"
|
||||
```
|
||||
|
||||
A second `new` or `add` on the same booth updates the why and keeps the
|
||||
original creation stamp: the booth appeared once.
|
||||
|
||||
## Checking that controls can actually be clicked
|
||||
|
||||
```bash
|
||||
@@ -140,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
|
||||
@@ -149,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)
|
||||
|
||||
@@ -419,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
|
||||
|
||||
|
||||
+41
-6
@@ -1,7 +1,7 @@
|
||||
# The Booth — roadmap
|
||||
|
||||
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
|
||||
Current version: `0.2.1` (U1 + U2 landed; extracted from eshpfi 2026-09-21).
|
||||
Current version: `0.5.0` (U1, U2, U3, U4 and U5 landed; extracted from eshpfi 2026-09-21).
|
||||
|
||||
## v1 target
|
||||
|
||||
@@ -12,9 +12,9 @@ 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 |
|
||||
| 5 | **Self-announcing booths** — `.booth.json`, provenance on the index | job 5 had no home, so it lived on the link board as 145 dead rows | U5 |
|
||||
| 3 | ~~**Declared embed seam**~~ — **landed `87e2c53`, released `v0.5.0`** | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 |
|
||||
| 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,8 +22,29 @@ 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 and U2 are landed**, which unblocks U3, U4 and U5 — all three read marks.
|
||||
**U5 is next** (operator, 2026-09-21). U6 remains independent and unstarted.
|
||||
**U1, U2, U3, U4 and U5 are landed — the whole middle tier is closed.** U6
|
||||
remains independent and unstarted; **U7 is now unblocked**, since its only
|
||||
dependency was `{U3, U4, U5}`. Two units left to v1, and they do not depend on
|
||||
each other, so either can go next.
|
||||
|
||||
**U5's adoption is a measured prediction, not a finished result**, and it is
|
||||
TWO predictions rather than one. The operator declined a fleetwide announcement
|
||||
so that adoption could be told apart from design; within fifty minutes of the
|
||||
deploy a peer that had been told nothing (`comfy-dev`) created a booth and it
|
||||
announced itself with a handle and an empty `why`. That is the split:
|
||||
|
||||
- **The handle rides for free.** It is written by `booth new` and `booth add`,
|
||||
so every existing caller starts announcing without learning anything.
|
||||
- **The `why` has to be learned.** It needs someone to know the flag exists.
|
||||
|
||||
Both get re-measured on **2026-09-29**:
|
||||
|
||||
find ~/booth-data -maxdepth 2 -name .booth.json | wc -l # free
|
||||
grep -l '"why": "[^"]' ~/booth-data/*/.booth.json | wc -l # learned
|
||||
|
||||
A high first count with a near-zero second is the predicted shape of "nobody was
|
||||
told" — an adoption failure fixed by announcing, which is a different thing from
|
||||
nobody wanting it. Same instrument as U4's `.forever` prediction below.
|
||||
|
||||
### Cross-cutting invariant — deterministic order, everywhere
|
||||
|
||||
@@ -53,6 +74,20 @@ Where it already binds, and what the rule is in each case:
|
||||
| marks in a booth | `(created, id)` — time, with the id as tie-break so two marks written in the same second cannot swap |
|
||||
| legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them |
|
||||
| link board rows | pinned first, then newest-first |
|
||||
| a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) |
|
||||
| embed anchors in a verbatim report | **document order** — what `querySelectorAll` yields, so the author's markup decides (U3) |
|
||||
| the embed tail (fragments the author did not place) | **payload order**, which is the marks order `(created, id)` — one rule, whether a fragment lands at an anchor or at the end (U3) |
|
||||
| questions within a pick | declaration order, in the payload's `questions` LIST — carried by the format rather than by object-key insertion order (U3) |
|
||||
|
||||
U3's three rows are the first case where the rule binds across a language
|
||||
boundary: the order is decided in Python and honoured in JavaScript, and a
|
||||
browser test asserts it rather than a string assertion that could not see it.
|
||||
|
||||
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
|
||||
|
||||
+535
-209
@@ -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.
|
||||
"""
|
||||
@@ -33,7 +44,9 @@ import shutil
|
||||
import time
|
||||
import zipfile
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import replace
|
||||
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 +98,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,22 +145,25 @@ 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,
|
||||
set_flag,
|
||||
write_note,
|
||||
)
|
||||
from booth.inline import ( # noqa: E402
|
||||
form_id as ask_form_id,
|
||||
has_placeholders,
|
||||
place as place_asks,
|
||||
from booth.manifest import ( # noqa: E402
|
||||
MANIFEST_FILE,
|
||||
SERVICE_HANDLE,
|
||||
read_manifest,
|
||||
write_manifest,
|
||||
)
|
||||
from booth.links import ( # noqa: E402
|
||||
LINK_LOCK,
|
||||
@@ -183,18 +207,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
|
||||
@@ -216,8 +256,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]:
|
||||
@@ -226,10 +360,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():
|
||||
@@ -240,6 +389,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)
|
||||
@@ -272,7 +423,25 @@ 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
|
||||
# over EVERY booth on every index page load.
|
||||
manifest = read_manifest(child)
|
||||
kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
|
||||
thumb_url = None
|
||||
thumb_blurred = False
|
||||
@@ -290,6 +459,7 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
{
|
||||
"name": child.name,
|
||||
"name_url": quote(child.name, safe=""),
|
||||
"manifest": manifest,
|
||||
"count": len(items),
|
||||
"kinds": kinds,
|
||||
"thumb_url": thumb_url,
|
||||
@@ -302,11 +472,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
|
||||
|
||||
|
||||
@@ -359,119 +541,91 @@ def _zip_filename(name: str) -> str:
|
||||
return f"{safe or 'booth'}.zip"
|
||||
|
||||
|
||||
# ---- verbatim-index.html wrapper -------------------------------------------
|
||||
# ---- the declared embed seam (U3) ------------------------------------------
|
||||
|
||||
# Mirror of base.html's favicon (the app templates set it there; this is the copy
|
||||
# injected into a booth's *verbatim* index.html so a raw page inherits the same
|
||||
# icon). Keep the two in sync if the Booth's icon ever changes.
|
||||
# Mirror of base.html's favicon. The app templates set it there; this copy is
|
||||
# what `/b/<name>/embed.json` hands to a VERBATIM report, so a raw page inherits
|
||||
# the same icon. Keep the two in sync if the Booth's icon ever changes.
|
||||
FAVICON_HREF = (
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'"
|
||||
"%3E%3Crect width='32' height='32' rx='7' fill='%23171a23'/%3E%3Ccircle cx='16' "
|
||||
"cy='16' r='6' fill='none' stroke='%2342dcd1' stroke-width='2.5'/%3E%3Ccircle "
|
||||
"cx='16' cy='16' r='2.2' fill='%2342dcd1'/%3E%3C/svg%3E"
|
||||
)
|
||||
FAVICON_LINK = f'<link rel="icon" href="{FAVICON_HREF}">'
|
||||
|
||||
# A self-contained floating "back to all booths" chip injected into verbatim
|
||||
# booths. Scoped class + fixed positioning + max z-index so it overlays the raw
|
||||
# page without touching its layout; hidden in print so downloaded reports stay clean.
|
||||
_BACK_CHIP = (
|
||||
'<a href="/" class="booth-nav-home" aria-label="back to all booths">‹ all booths</a>'
|
||||
# top-right: empty on left-aligned report layouts (a top-left chip clips the
|
||||
# page title), and consistent with the zoom view's top-right back affordance.
|
||||
"<style>.booth-nav-home{position:fixed;top:0;right:0;z-index:2147483647;"
|
||||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
|
||||
"font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
|
||||
"color:#dfe7ef;text-decoration:none;letter-spacing:.01em;"
|
||||
"background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);border-radius:8px;"
|
||||
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);"
|
||||
"box-shadow:0 2px 10px rgba(0,0,0,.35);transition:background .18s,border-color .18s}"
|
||||
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}"
|
||||
"@media print{.booth-nav-home{display:none}}</style>"
|
||||
)
|
||||
# The seam a verbatim report declares to get the Booth's chrome. ONE line, and
|
||||
# the Booth appends it only when the page has not declared it itself.
|
||||
EMBED_SRC = "/_booth/embed.js"
|
||||
EMBED_SCRIPT_TAG = f'<script src="{EMBED_SRC}" defer></script>'
|
||||
EMBED_JS_PATH = Path(__file__).parent / "static" / "embed.js"
|
||||
|
||||
# A booth's own index.html is served VERBATIM, so the asks panel — which lives in
|
||||
# the auto-gallery template — can never appear on it. Without this chip an ask
|
||||
# posted into a custom-report booth is INVISIBLE to the operator with nothing to
|
||||
# say so (found 2026-09-09 on `emmie-anchor`: valid ask, CLI listed it, page
|
||||
# showed nothing). Same injection mechanism as the back chip; it links to the
|
||||
# standalone /asks page, which renders the real forms.
|
||||
def asks_chip(name: str, open_count: int, href: str | None = None) -> str:
|
||||
if open_count < 1:
|
||||
return ""
|
||||
label = f"? {open_count} open ask" + ("" if open_count == 1 else "s")
|
||||
href = href or f"/b/{quote(name, safe='')}/asks"
|
||||
return (
|
||||
f'<a href="{href}" class="booth-nav-asks">{label}</a>'
|
||||
"<style>.booth-nav-asks{position:fixed;top:0;right:7.2rem;z-index:2147483647;"
|
||||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
|
||||
"font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
|
||||
"color:#171a23;text-decoration:none;letter-spacing:.01em;"
|
||||
"background:#ffe14e;border:1px solid #ffe14e;border-radius:8px;"
|
||||
"box-shadow:0 2px 10px rgba(0,0,0,.35);transition:filter .18s}"
|
||||
".booth-nav-asks:hover{filter:brightness(1.08)}"
|
||||
"@media print{.booth-nav-asks{display:none}}</style>"
|
||||
)
|
||||
# What counts as DECLARING the seam. Two substring tests, one per quote style,
|
||||
# and each requires `src=` immediately before the path.
|
||||
#
|
||||
# The bare path was the first draft and it was wrong in the dangerous
|
||||
# direction. A report that merely MENTIONS `/_booth/embed.js` — in a code
|
||||
# sample, a comment, a sentence about this very feature — would have been read
|
||||
# as declaring it, served untouched, and silently shown no chrome at all. The
|
||||
# Booth's own design reports are exactly the pages that would quote it.
|
||||
#
|
||||
# These tests fail in the harmless direction instead. An unusual spelling
|
||||
# (`src = "…"` with spaces, an unquoted attribute, a `?v=2` suffix) is read as
|
||||
# NOT declared, so a second tag is appended — and embed.js mounts once
|
||||
# regardless, because it guards on `window.__boothEmbed`. A missed declaration
|
||||
# costs a duplicate tag; a false one costs the operator his chrome.
|
||||
_EMBED_DECLARATIONS = (f'src="{EMBED_SRC}"', f"src='{EMBED_SRC}'")
|
||||
|
||||
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw
|
||||
|
||||
|
||||
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw (unwrapped)
|
||||
def declares_embed(html: str) -> bool:
|
||||
"""Whether a verbatim page already asks for the Booth's chrome.
|
||||
|
||||
_ICON_RE = re.compile(r"<link\b[^>]*\brel\s*=\s*[\"']?[^\"'>]*icon", re.IGNORECASE)
|
||||
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", re.IGNORECASE)
|
||||
_HTML_OPEN_RE = re.compile(r"<html\b[^>]*>", re.IGNORECASE)
|
||||
_DOCTYPE_RE = re.compile(r"<!doctype[^>]*>", re.IGNORECASE)
|
||||
_BODY_CLOSE_RE = re.compile(r"</body\s*>", re.IGNORECASE)
|
||||
_HTML_CLOSE_RE = re.compile(r"</html\s*>", re.IGNORECASE)
|
||||
|
||||
|
||||
def _insert_before(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
|
||||
m = pattern.search(html)
|
||||
if m:
|
||||
return html[: m.start()] + snippet + html[m.start() :], True
|
||||
return html, False
|
||||
|
||||
|
||||
def _insert_after(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
|
||||
m = pattern.search(html)
|
||||
if m:
|
||||
return html[: m.end()] + snippet + html[m.end() :], True
|
||||
return html, False
|
||||
|
||||
|
||||
def wrap_verbatim_html(html: str, favicon_link: str = FAVICON_LINK, extra: str = "") -> str:
|
||||
"""Inject a floating 'all booths' back-chip — and the Booth favicon, if the page
|
||||
declares none — into a booth's verbatim index.html, without altering the page's
|
||||
rendered content.
|
||||
|
||||
Robust to the compact HTML real booths use (`<!doctype html><meta charset><title>
|
||||
<style>…content`, no explicit head/body). The two hard constraints:
|
||||
* NEVER put anything ahead of a leading <!doctype> — that forces quirks mode.
|
||||
* Keep the charset <meta> within the first 1024 bytes so it's still honoured.
|
||||
So the favicon lands at the first head-ish seam (before </head>, else after
|
||||
<html>, else right after the doctype — a ~250B link keeps charset in range), and
|
||||
the fixed-position chip is appended at the END of the document (before </body> /
|
||||
</html> or appended), which renders top-left regardless and disturbs nothing.
|
||||
TWO SUBSTRING TESTS. This is the entire detection half of what used to be
|
||||
six regular expressions run against arbitrary author HTML — and the other
|
||||
half, the insertion, is a `+`. See `_EMBED_DECLARATIONS` for why it matches
|
||||
`src="…"` rather than the bare path: both spellings fail toward appending a
|
||||
harmless duplicate rather than toward silently withholding the chrome.
|
||||
"""
|
||||
if favicon_link and not _ICON_RE.search(html):
|
||||
for inserter, pat in (
|
||||
(_insert_before, _HEAD_CLOSE_RE), # inside an explicit <head>
|
||||
(_insert_after, _HTML_OPEN_RE), # top of an explicit <html>
|
||||
(_insert_after, _DOCTYPE_RE), # right after the doctype (compact HTML)
|
||||
):
|
||||
html, done = inserter(html, pat, favicon_link)
|
||||
if done:
|
||||
break
|
||||
else:
|
||||
html = favicon_link + html # bare fragment, no doctype: safe to prepend
|
||||
return any(d in html for d in _EMBED_DECLARATIONS)
|
||||
|
||||
chips = _BACK_CHIP + (extra or "")
|
||||
for pat in (_BODY_CLOSE_RE, _HTML_CLOSE_RE):
|
||||
html, done = _insert_before(html, pat, chips)
|
||||
if done:
|
||||
break
|
||||
else:
|
||||
html = html + chips # no </body>/</html>: append to the end
|
||||
return html
|
||||
|
||||
def embed_verbatim(raw: bytes) -> bytes:
|
||||
"""The ONLY thing the Booth does to a verbatim report. BYTES IN, BYTES OUT.
|
||||
|
||||
Appended, never inserted, and never prepended. That is what retires both of
|
||||
the old wrapper's hard constraints rather than satisfying them more
|
||||
carefully: nothing can displace a leading doctype into quirks mode and
|
||||
nothing can push the charset <meta> out of its first-1024-byte detection
|
||||
window, because nothing in front of them moves. Content after `</html>` is
|
||||
parsed into the body by every browser, so there is no seam to find.
|
||||
|
||||
⚠ IT TAKES BYTES BECAUSE TEXT WAS QUIETLY EDITING THE DOCUMENT. The first
|
||||
version read the file with `read_text()` and returned a str. That opens in
|
||||
UNIVERSAL-NEWLINE mode, so a report written with CRLF came back with LF —
|
||||
and `errors="replace"` turned any byte that was not valid UTF-8 into U+FFFD.
|
||||
A declaring page was therefore NOT served as its author wrote it, which is
|
||||
this unit's headline promise, and the test could not see it because its
|
||||
fixture was LF-only ASCII. Found by a cross-frontier bug-hunt panel.
|
||||
|
||||
Decoding still happens — `declares_embed` needs a string to look in — but
|
||||
the decoded copy is used ONLY to answer that question. What goes on the wire
|
||||
is the original bytes, plus the tag's bytes when it is appended, so the
|
||||
source is a byte-exact prefix of the response.
|
||||
"""
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
return raw if declares_embed(text) else raw + EMBED_SCRIPT_TAG.encode("utf-8")
|
||||
|
||||
|
||||
def ask_form_id(stem: str) -> str:
|
||||
"""The shared `<form>` id a pick's scattered question groups bind to with
|
||||
the HTML5 `form=` attribute.
|
||||
|
||||
Moved here from `booth/inline.py` when U3 deleted that module: it is not
|
||||
placement machinery, it is what makes four radio groups spread down a report
|
||||
submit as ONE POST, which is what a multi-question ask requires.
|
||||
"""
|
||||
return f"bk-ask-form-{re.sub(r'[^A-Za-z0-9_-]', '-', stem)}"
|
||||
|
||||
|
||||
# ---- uploads (browser drop-off for pickup) ---------------------------------
|
||||
@@ -579,6 +733,13 @@ def create_app(
|
||||
env.filters["dur"] = human_dur
|
||||
templates = Jinja2Templates(env=env)
|
||||
|
||||
# embed.js IS READ ONCE, HERE, for exactly the reason above. It is the third
|
||||
# kind of thing this repo serves, and the only one that would otherwise be
|
||||
# free to hot-reload from the deployment root — which is the skew that put
|
||||
# 19 of 25 booths at 500. One rule: nothing takes effect until you restart.
|
||||
embed_js = EMBED_JS_PATH.read_text(encoding="utf-8")
|
||||
embed_etag = '"%s"' % hashlib.sha256(embed_js.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
task = None
|
||||
@@ -669,6 +830,20 @@ def create_app(
|
||||
def healthz():
|
||||
return {"ok": True, "ttl_hours": ttl_hours, "booths": len(list_booths(data_dir, ttl_seconds))}
|
||||
|
||||
@app.get(EMBED_SRC)
|
||||
def embed_script():
|
||||
"""The declared seam's one static asset.
|
||||
|
||||
Served from the startup read, with an ETag over its content so a
|
||||
browser revalidates instead of holding a stale copy across a restart —
|
||||
`no-cache` here means "ask me", not "do not store".
|
||||
"""
|
||||
return Response(
|
||||
content=embed_js,
|
||||
media_type="text/javascript; charset=utf-8",
|
||||
headers={"ETag": embed_etag, "Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
@app.get("/b/{name}", include_in_schema=False)
|
||||
def booth_redirect(name: str):
|
||||
resolve_booth(name)
|
||||
@@ -677,6 +852,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.
|
||||
@@ -687,18 +866,20 @@ def create_app(
|
||||
)
|
||||
own_index = booth / "index.html"
|
||||
if own_index.is_file():
|
||||
# Serve the operator's verbatim report, but inject a floating
|
||||
# back-to-booths chip + the Booth favicon (if it declares none) so a
|
||||
# raw page still has a way home. Small HTML -> read + wrap in memory;
|
||||
# a pathological large file falls back to serving raw, unwrapped.
|
||||
# The operator's verbatim report. U3: the page declares the seam and
|
||||
# the Booth mounts into it — so a page carrying the script tag is
|
||||
# served exactly as written, and one that is not gets that single
|
||||
# line appended. Nothing is parsed, matched or inserted.
|
||||
#
|
||||
# The read is still bounded: a pathological file falls back to
|
||||
# serving raw, which costs it the chrome exactly as it did before.
|
||||
try:
|
||||
if own_index.stat().st_size <= WRAP_MAX_BYTES:
|
||||
raw = own_index.read_text(encoding="utf-8", errors="replace")
|
||||
# Asks render INLINE, where the report author put them (or
|
||||
# appended, if they marked nothing) — a question about an
|
||||
# artifact belongs beside that artifact, not on another page.
|
||||
body, tail = inject_asks(name, booth, raw)
|
||||
return HTMLResponse(wrap_verbatim_html(body, extra=tail))
|
||||
# ONE read, and it is a byte read: see embed_verbatim.
|
||||
return Response(
|
||||
content=embed_verbatim(own_index.read_bytes()),
|
||||
media_type="text/html; charset=utf-8",
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
return FileResponse(str(own_index), media_type="text/html")
|
||||
@@ -709,7 +890,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",
|
||||
@@ -727,13 +910,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
|
||||
@@ -748,10 +931,33 @@ def create_app(
|
||||
},
|
||||
"booth_marks": marks_for_target(marks, None),
|
||||
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
||||
# The same provenance line the index card carries. Deliberate:
|
||||
# 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
|
||||
@@ -796,14 +1002,19 @@ def create_app(
|
||||
notes = _form_text(form, "notes")
|
||||
try:
|
||||
if spec.multi:
|
||||
choice = {q["key"]: form.get(f"choice.{q['key']}") for q in spec.questions}
|
||||
choice = {q["key"]: _form_text(form, f"choice.{q['key']}")
|
||||
for q in spec.questions}
|
||||
qnotes = {q["key"]: _form_text(form, f"notes.{q['key']}")
|
||||
for q in spec.questions}
|
||||
await run_in_threadpool(answer_pick, booth, mark_id, choice, notes,
|
||||
who=who, qnotes=qnotes)
|
||||
else:
|
||||
# `choice` through the same reader as `notes`. It was raw, so a
|
||||
# multipart FILE part named `choice` reached the answer builder
|
||||
# as an UploadFile — the asymmetry that had already been fixed
|
||||
# once on the field beside it.
|
||||
await run_in_threadpool(answer_pick, booth, mark_id,
|
||||
form.get("choice"), notes, who=who)
|
||||
_form_text(form, "choice"), notes, who=who)
|
||||
except AskError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}")
|
||||
@@ -878,73 +1089,108 @@ def create_app(
|
||||
|
||||
_frag = templates.env.get_template("_ask_inline.html").module
|
||||
|
||||
def inject_asks(name: str, booth: Path, html: str) -> tuple[str, str]:
|
||||
"""(body, tail) for a verbatim booth: placeholders substituted in place,
|
||||
and whatever still has to be appended before </body>.
|
||||
def _pick_fragments(name: str, mark) -> dict:
|
||||
"""One pick, rendered into the pieces a page can mount independently.
|
||||
|
||||
Marked-up pages get each fragment exactly where the author put it. An
|
||||
unmarked page gets the whole ask appended — an ask is NEVER invisible,
|
||||
which is the guarantee; markup only moves it somewhere better. A stem
|
||||
whose questions were placed but whose submit block was not gets that
|
||||
block appended, so a scattered form is always submittable.
|
||||
Rendered HERE, by the same Jinja macros the gallery page uses, so there
|
||||
is exactly ONE renderer of an ask. embed.js places these; it never
|
||||
builds one. A second renderer in JavaScript is the shape INV-1 was
|
||||
written to stop after the zoom view re-derived an item and lost its
|
||||
captions doing it.
|
||||
"""
|
||||
picks = [m for m in marks_for(booth) if m.shape == "pick"]
|
||||
if not picks:
|
||||
return html, ""
|
||||
url = quote(name, safe="")
|
||||
fid = ask_form_id(mark.id)
|
||||
if mark.error:
|
||||
# `whole` renders the broken-ask box. A question the session
|
||||
# believes it posted has to be visible; the pieces of a pick that
|
||||
# could not be read do not exist to offer.
|
||||
return {"id": mark.id, "error": mark.error,
|
||||
"whole": str(_frag.whole(mark, fid, url)), "submit": "",
|
||||
"questions": []}
|
||||
return {
|
||||
"id": mark.id,
|
||||
"error": None,
|
||||
"whole": str(_frag.whole(mark, fid, url)),
|
||||
"submit": str(_frag.submit(mark, fid, url)),
|
||||
# A LIST, not an object keyed by question key: a single-question
|
||||
# pick normalizes to one question whose key is None, which JSON
|
||||
# would write as the string "null" and so invent a name. The list
|
||||
# also carries declaration order in the format itself.
|
||||
"questions": [
|
||||
{"key": q.get("key"), "html": str(_frag.question(mark, q, fid, url))}
|
||||
for q in mark.questions
|
||||
],
|
||||
}
|
||||
|
||||
seen: set[str] = set()
|
||||
def _safe_fragments(name: str, mark) -> dict:
|
||||
"""`_pick_fragments`, with the promise that it cannot raise.
|
||||
|
||||
def render(kind: str, mark, key: str | None) -> str:
|
||||
fid = ask_form_id(mark.id)
|
||||
if kind == "whole":
|
||||
frag = str(_frag.whole(mark, fid, url))
|
||||
elif kind == "submit":
|
||||
frag = str(_frag.submit(mark, fid, url))
|
||||
else:
|
||||
q = next(q for q in mark.questions if q.get("key") == key)
|
||||
frag = str(_frag.question(mark, q, fid, url))
|
||||
# An anchor on the FIRST fragment of each pick, wherever it landed,
|
||||
# so the floating chip can jump to it on a long report. Computed
|
||||
# here rather than in the macros because only the caller knows
|
||||
# which fragment came first.
|
||||
if mark.id not in seen:
|
||||
seen.add(mark.id)
|
||||
frag = f'<a id="bk-ask-{mark.id}-top"></a>' + frag
|
||||
return frag
|
||||
`marks_for` hydrates an entry whose JSON is well-formed but whose SHAPE
|
||||
is wrong — `{"answer": {"answers": []}}` survives `_hydrate` with no
|
||||
error and then raises `UndefinedError` in the template, because the
|
||||
macro asks a list for `.get`. Verified, not assumed.
|
||||
|
||||
tail = [str(_frag.styles())]
|
||||
if has_placeholders(html):
|
||||
html, placed, submitted = place_asks(html, picks, render)
|
||||
for m in picks:
|
||||
keys = placed.get(m.id)
|
||||
if keys is None:
|
||||
tail.append(render("whole", m, None)) # unmarked: never dropped
|
||||
continue
|
||||
if m.error:
|
||||
continue
|
||||
if None not in keys:
|
||||
# Partially marked up: append every question the author did
|
||||
# NOT place. A multi-question pick needs all of them or the
|
||||
# POST is a 400 — met only after the operator fills it in.
|
||||
for q in m.questions:
|
||||
if q.get("key") not in keys:
|
||||
tail.append(render("question", m, q.get("key")))
|
||||
if m.id not in submitted:
|
||||
tail.append(render("submit", m, None)) # scattered but submittable
|
||||
else:
|
||||
for m in picks:
|
||||
tail.append(render("whole", m, None))
|
||||
This endpoint renders every pick in the booth on every page load of the
|
||||
operator's report, so one such entry would 500 the whole seam and the
|
||||
report would show no chrome at all — while `hold_read` reported the file
|
||||
as perfectly readable. Same leniency `_hydrate_safe` already applies one
|
||||
layer down, at the layer that actually renders: one unreadable pick
|
||||
costs that pick, never the page.
|
||||
"""
|
||||
try:
|
||||
return _pick_fragments(name, mark)
|
||||
except Exception as exc: # noqa: BLE001 - deliberate
|
||||
broken = replace(mark, error=f"this question could not be rendered: {exc}")
|
||||
return {"id": mark.id, "error": broken.error,
|
||||
"whole": str(_frag.whole(broken, ask_form_id(mark.id),
|
||||
quote(name, safe=""))),
|
||||
"submit": "", "questions": []}
|
||||
|
||||
# The chip is a JUMP LINK to the inline block, not a way out to a
|
||||
# separate page: on a long report the question can be well below the
|
||||
# fold, and "there is a question waiting" still has to be visible at
|
||||
# first paint.
|
||||
still_open = open_marks(picks) # INV-2: not re-derived here
|
||||
if still_open:
|
||||
tail.append(asks_chip(name, len(still_open),
|
||||
href=f'#bk-ask-{still_open[0].id}-top'))
|
||||
return html, "".join(tail)
|
||||
@app.get("/b/{name}/embed.json")
|
||||
def booth_embed_json(name: str):
|
||||
"""Everything a verbatim report needs to mount the Booth's chrome.
|
||||
|
||||
The READ half of the declared seam. `embed.js` fetches this and places
|
||||
what comes back; every decision — what a mark says, whether it is still
|
||||
open, what order the marks come in — is made here and never re-derived
|
||||
on the page.
|
||||
|
||||
Marks are ordered `(created, id)`, which is what both readers below
|
||||
sort by. Questions are in declaration order. `open` is `open_marks`,
|
||||
the ONE openness predicate, so a half-answered multi-question pick
|
||||
counts as open here exactly as it does on the index badge.
|
||||
|
||||
DOES NOT RECORD A VIEW. `booth_view` already did, above both of its
|
||||
early returns; counting a script's fetch of the page it is already on
|
||||
would reset the TTL on machinery rather than on the operator.
|
||||
|
||||
The read is LENIENT and the status stays 200, copied from
|
||||
`/marks.json`: a damaged `.marks.json` must cost the chrome, never the
|
||||
operator's report. That is the v0.2.2 lesson.
|
||||
"""
|
||||
booth = resolve_booth(name)
|
||||
marks, read_err = hold_read(booth) # ONE read; see list_booths
|
||||
if read_err is not None:
|
||||
marks = marks_for(booth)
|
||||
picks = [m for m in marks if m.shape == "pick"]
|
||||
body = {
|
||||
"booth": name,
|
||||
# No `home`: the way-home chip mounts from a constant BEFORE this
|
||||
# fetch, so that a failed one still leaves the operator a way out.
|
||||
# Carrying the value anyway would put a second representation of it
|
||||
# on the wire for nothing to read.
|
||||
"favicon": FAVICON_HREF,
|
||||
# Picks only. It is also what keeps a flag's `flag:<target>` id —
|
||||
# the one mark id containing the separator an anchor spec splits
|
||||
# on — out of a payload whose specs split on the first colon.
|
||||
"marks": [_safe_fragments(name, m) for m in picks],
|
||||
"open": [m.id for m in open_marks(picks)],
|
||||
}
|
||||
if read_err is not None:
|
||||
body["marks"] = []
|
||||
body["error"] = "this booth's .marks.json cannot be read"
|
||||
body["detail"] = read_err
|
||||
return JSONResponse(body)
|
||||
|
||||
@app.get("/b/{name}/marks", response_class=HTMLResponse)
|
||||
def booth_marks_page(request: Request, name: str):
|
||||
@@ -952,13 +1198,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)
|
||||
@@ -981,12 +1243,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):
|
||||
@@ -1008,6 +1287,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 = {
|
||||
@@ -1085,11 +1372,22 @@ def create_app(
|
||||
booth_id = generate_pickup_id(lambda n: (data_dir / n).exists())
|
||||
dest = data_dir / booth_id
|
||||
dest.mkdir(parents=True)
|
||||
(dest / UPLOAD_MARKER).write_text("") # stamp as an upload (dotfile, not listed)
|
||||
|
||||
total = 0
|
||||
used: set = {UPLOAD_MARKER}
|
||||
# Both markers are belt-and-braces: `safe_upload_name` strips leading
|
||||
# dots, so an uploaded file can never be named either of them. Listed
|
||||
# anyway so the set says what the directory already contains.
|
||||
used: set = {UPLOAD_MARKER, MANIFEST_FILE}
|
||||
try:
|
||||
(dest / UPLOAD_MARKER).write_text("") # dotfile, not listed
|
||||
# A booth the SERVICE made says so, rather than being exempted from
|
||||
# the unannounced marker. INSIDE the guard, with the marker: both
|
||||
# sat above it, so a failure here left a half-booth on disk with no
|
||||
# files in it — and the manifest's unique temp name meant a leaked
|
||||
# `.booth.json.<hex>.tmp` was never overwritten, was not a `.lock`,
|
||||
# and so kept that empty booth alive past every sweep. Found 4/4.
|
||||
write_manifest(dest, SERVICE_HANDLE, title=booth_id,
|
||||
why="browser upload, for pickup")
|
||||
for i, f in enumerate(files):
|
||||
name = _dedupe_name(safe_upload_name(f.filename, f"file-{i + 1}"), used)
|
||||
used.add(name)
|
||||
@@ -1178,7 +1476,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")
|
||||
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
"""Inline ask placement inside a booth's VERBATIM index.html.
|
||||
|
||||
A booth that ships its own `index.html` is served untouched, so the auto-gallery
|
||||
template's asks panel never renders there. The first fix was a chip linking to a
|
||||
separate `/asks` page; the operator's verdict on that (2026-09-09) was that the
|
||||
question belongs WITH the artifacts it is about — a four-voice audition wants the
|
||||
radio group for each voice under that voice's audio, not on another page.
|
||||
|
||||
So the report author marks where each piece goes, with a placeholder element:
|
||||
|
||||
<div data-booth-ask="anchors"></div> the whole ask: every question + submit
|
||||
<div data-booth-ask="anchors:lawson"></div> just that question's radios
|
||||
<div data-booth-ask-submit="anchors"></div> the notes field + submit button
|
||||
|
||||
Per-question fragments bind to ONE form via the HTML5 `form=` attribute, so four
|
||||
groups scattered down a page still submit as a single POST — which is what a
|
||||
multi-question ask requires (every question or 400). No JavaScript.
|
||||
|
||||
An `<!-- booth:ask anchors -->` comment works the same way, for authors who would
|
||||
rather not put an empty div in their markup.
|
||||
|
||||
Placement is OPTIONAL. A page with no placeholders gets the whole ask appended at
|
||||
the end of its body, so an ask is never invisible — that guarantee is the point,
|
||||
and marking it up only moves it somewhere better.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# <div data-booth-ask="stem"></div> / <span data-booth-ask="stem:key"></span>
|
||||
_EL_RE = re.compile(
|
||||
r"<(?P<tag>[A-Za-z][\w-]*)\b[^>]*?\bdata-booth-ask=\"(?P<spec>[^\"]+)\"[^>]*?>"
|
||||
r"(?:\s*</(?P=tag)\s*>)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SUBMIT_EL_RE = re.compile(
|
||||
r"<(?P<tag>[A-Za-z][\w-]*)\b[^>]*?\bdata-booth-ask-submit=\"(?P<spec>[^\"]+)\"[^>]*?>"
|
||||
r"(?:\s*</(?P=tag)\s*>)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# <!-- booth:ask stem --> / <!-- booth:ask stem:key --> / <!-- booth:ask-submit stem -->
|
||||
_COMMENT_RE = re.compile(r"<!--\s*booth:ask\s+(?P<spec>[^\s>-][^\s>]*)\s*-->", re.IGNORECASE)
|
||||
_COMMENT_SUBMIT_RE = re.compile(r"<!--\s*booth:ask-submit\s+(?P<spec>[^\s>]+)\s*-->", re.IGNORECASE)
|
||||
|
||||
|
||||
def split_spec(spec: str) -> tuple[str, str | None]:
|
||||
"""`"anchors:lawson"` -> `("anchors", "lawson")`; `"anchors"` -> `("anchors", None)`."""
|
||||
stem, sep, key = spec.strip().partition(":")
|
||||
return stem.strip(), (key.strip() or None) if sep else None
|
||||
|
||||
|
||||
def has_placeholders(html: str) -> bool:
|
||||
return bool(
|
||||
_EL_RE.search(html) or _SUBMIT_EL_RE.search(html)
|
||||
or _COMMENT_RE.search(html) or _COMMENT_SUBMIT_RE.search(html)
|
||||
)
|
||||
|
||||
|
||||
def form_id(stem: str) -> str:
|
||||
return f"bk-ask-form-{re.sub(r'[^A-Za-z0-9_-]', '-', stem)}"
|
||||
|
||||
|
||||
def place(html: str, asks: list, render) -> tuple[str, dict[str, set], set[str]]:
|
||||
"""Substitute every placeholder with rendered ask HTML.
|
||||
|
||||
`render(kind, ask, key)` returns the fragment for kind in
|
||||
{"whole", "question", "submit"}. Returns the new html; a map of stem ->
|
||||
the set of question keys placed inline (with `None` in the set meaning the
|
||||
WHOLE ask was placed); and the set of stems whose submit block was placed
|
||||
explicitly.
|
||||
|
||||
The caller needs the per-key detail, not just "this stem appeared
|
||||
somewhere": a multi-question ask requires EVERY question on submit, so a
|
||||
page that marks up two of four questions must still be handed the other two
|
||||
or the form is unsubmittable — a 400 the operator would meet only after
|
||||
filling it in.
|
||||
|
||||
A placeholder naming an ask this booth does not have is left ALONE, not
|
||||
blanked: silently eating the author's markup would hide a typo'd stem, and
|
||||
an untouched empty div is invisible anyway.
|
||||
"""
|
||||
# Marks index by ATTRIBUTE, not subscript: `place` was the one consumer in
|
||||
# the service that did `a["stem"]`, which a frozen dataclass refuses. Caught
|
||||
# by the U2 seam review (SR-1) — the cold contract pass cannot see a sibling
|
||||
# module's surface by design, so nothing else would have found it before the
|
||||
# first verbatim booth 500'd.
|
||||
by_stem = {a.id: a for a in asks}
|
||||
placed: dict[str, set] = {}
|
||||
submitted: set[str] = set()
|
||||
|
||||
def sub_main(m: re.Match) -> str:
|
||||
stem, key = split_spec(m.group("spec"))
|
||||
ask = by_stem.get(stem)
|
||||
if ask is None:
|
||||
return m.group(0)
|
||||
if key is None:
|
||||
placed.setdefault(stem, set()).add(None)
|
||||
submitted.add(stem)
|
||||
return render("whole", ask, None)
|
||||
q = next((q for q in ask.questions if q.get("key") == key), None)
|
||||
if q is None:
|
||||
return m.group(0)
|
||||
placed.setdefault(stem, set()).add(key)
|
||||
return render("question", ask, key)
|
||||
|
||||
def sub_submit(m: re.Match) -> str:
|
||||
stem, _ = split_spec(m.group("spec"))
|
||||
ask = by_stem.get(stem)
|
||||
if ask is None:
|
||||
return m.group(0)
|
||||
placed.setdefault(stem, set())
|
||||
submitted.add(stem)
|
||||
return render("submit", ask, None)
|
||||
|
||||
for pat, fn in ((_EL_RE, sub_main), (_COMMENT_RE, sub_main),
|
||||
(_SUBMIT_EL_RE, sub_submit), (_COMMENT_SUBMIT_RE, sub_submit)):
|
||||
html = pat.sub(fn, html)
|
||||
return html, placed, submitted
|
||||
@@ -0,0 +1,274 @@
|
||||
"""A booth's own announcement — who posted it, and why.
|
||||
|
||||
U5. The index card used to show a name, an item count and a countdown, and
|
||||
nothing the poster chose. An agent with something to show therefore had no way
|
||||
to make the booth say "look at this" and posted a URL to the link board
|
||||
instead — which is why 145 of that board's 210 rows (69%) ended up pointing at
|
||||
booths that had already been swept. The board was absorbing a job it was never
|
||||
shaped for. This is the shape.
|
||||
|
||||
.booth.json -> {"handle": ..., "title": ..., "why": ..., "created": ...}
|
||||
|
||||
⚠ STDLIB ONLY, and it imports nothing from `booth.*` either.
|
||||
|
||||
`scripts/booth` — the CLI every fleet session uses — imports this module
|
||||
directly under the system `python3` with no venv, through a `python3 -c`
|
||||
heredoc no AST extractor can see. A single third-party import here breaks
|
||||
`booth new` and `booth add` on every host, and the failure surfaces in an
|
||||
agent's session rather than in ours. The ban extends to sibling `booth` modules:
|
||||
importing `marks` to reuse its atomic write would drag marks' own import list
|
||||
into this one's, so the four-line pattern is copied instead. `test_stdlib_only`
|
||||
in tests/test_manifest.py is the only thing standing here.
|
||||
|
||||
Contract: docs/contracts/u5_booth_manifest.contract.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat as statmod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
MANIFEST_FILE = ".booth.json"
|
||||
|
||||
# A `why` renders inside a card's sub-line, so it is one line by construction
|
||||
# rather than by convention — enforced at the WRITE so nothing downstream has to
|
||||
# remember. The caps are display budgets, not storage limits.
|
||||
HANDLE_MAX = 64
|
||||
TITLE_MAX = 120
|
||||
WHY_MAX = 200
|
||||
CREATED_MAX = 64
|
||||
|
||||
# A manifest is four short fields. Anything near this is not one, and reading it
|
||||
# into memory to find that out is the wrong order of operations: `list_booths`
|
||||
# calls the reader once per booth on every index load, so an unbounded read is
|
||||
# the service-wide outage the lenient reader exists to prevent, arriving in a
|
||||
# different costume. Checked by `stat`, before the bytes are touched.
|
||||
MANIFEST_MAX_BYTES = 64 * 1024
|
||||
|
||||
# Where bytes that could not be read go when a re-announcement replaces them.
|
||||
# ONE fixed name, deliberately: a timestamped quarantine accumulates forever in
|
||||
# a folder nothing prunes, and the most recent damage is the only copy anybody
|
||||
# would look at. A dotfile, so it is invisible to every listing and zip.
|
||||
QUARANTINE_FILE = ".booth.json.broken"
|
||||
|
||||
# The handle a booth created by the service itself carries. A pickup booth and
|
||||
# the standing link board are made by the Booth, not by an agent, and saying so
|
||||
# is true rather than manufactured — which is the whole reason there is no
|
||||
# exemption list. One rule: a booth with no manifest is unannounced.
|
||||
SERVICE_HANDLE = "booth"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
"""One booth's announcement.
|
||||
|
||||
`handle` is an althing agent handle, or `SERVICE_HANDLE` for a booth the
|
||||
Booth made. `error` is a read-time verdict and is never stored.
|
||||
"""
|
||||
|
||||
handle: str
|
||||
title: str
|
||||
why: str
|
||||
created: str
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _one_line(value, limit: int) -> str:
|
||||
"""One line, bounded. Collapses ALL runs of whitespace, not only newlines —
|
||||
a tab or a forty-space indent in a `why` renders as badly inside a card's
|
||||
sub-line as a newline does, and the field is one line by construction."""
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return " ".join(value.split())[:limit]
|
||||
|
||||
|
||||
def _temp_path(booth: Path) -> Path:
|
||||
"""A scratch name no other writer will pick.
|
||||
|
||||
Every writer used to derive the same `.booth.json.tmp`, so two `booth add`
|
||||
calls on one booth could interleave through a stale descriptor into the
|
||||
published path. Marks are protected from that by their flock; the manifest
|
||||
deliberately has none — it is written once at creation, not read-modify-
|
||||
written per click — so uniqueness is what stands in for the lock. Still a
|
||||
dotfile, so no listing, gallery or zip can see it mid-write.
|
||||
"""
|
||||
return booth / f"{MANIFEST_FILE}.{secrets.token_hex(4)}.tmp"
|
||||
|
||||
|
||||
def _as_doc(m: "Manifest") -> dict:
|
||||
"""The stored shape of a record, for the no-op comparison."""
|
||||
return {"handle": m.handle, "title": m.title, "why": m.why, "created": m.created}
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def read_manifest(booth: Path) -> Manifest | None:
|
||||
"""This booth's announcement, or None if it never made one.
|
||||
|
||||
LENIENT, AND IT NEVER RAISES (INV-2). `list_booths` calls this once per
|
||||
booth on every index page load, so a read that can raise is a service-wide
|
||||
outage wearing a single-booth bug's clothes. That is not hypothetical: a
|
||||
poisoned `.marks.json` did exactly that to `/` and `/healthz` across all 25
|
||||
live booths, and the fix shipped in v0.2.2. Same posture, applied before the
|
||||
same mistake rather than after it.
|
||||
|
||||
Absent -> None. Present but unreadable -> a Manifest carrying `error`, so a
|
||||
card can say `unreadable` instead of quietly showing the same thing as a
|
||||
booth that never announced (INV-5). Folding the two together would hide the
|
||||
one case somebody has to go and fix.
|
||||
|
||||
Only `handle` is required. A hand-written manifest is a supported input —
|
||||
the file is plain JSON in a folder the operator owns, and half the point of
|
||||
the Booth is that a booth is just a directory.
|
||||
"""
|
||||
booth = Path(booth)
|
||||
path = booth / MANIFEST_FILE
|
||||
# BOUNDED BEFORE THE READ. "Never raises" was not true of an unbounded one:
|
||||
# a 4 GB file raises MemoryError and a deeply nested document raises
|
||||
# RecursionError out of `json.loads`, and neither is an OSError or a
|
||||
# ValueError. Both escape into `list_booths`, which calls this per booth on
|
||||
# every index load — so one file returns 500 for the whole front page. Size
|
||||
# first, by `stat`; then catch the two classes anyway, because a bound that
|
||||
# is one day raised should not quietly re-open the hole.
|
||||
try:
|
||||
st = path.stat()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
return _broken(booth, f"cannot be read: {exc}")
|
||||
# ⚠ REGULAR-FILE FIRST, then size. `st_size` answers a different question
|
||||
# than "can this be read": it is 0 for a FIFO and 0 for /dev/zero, so both
|
||||
# sail under the cap, and then `read_text` either blocks forever with no EOF
|
||||
# or allocates until the kernel intervenes. The bound ABOVE is what made
|
||||
# this reachable — a cap that trusts st_size inherits everything st_size
|
||||
# does not mean. One such file stalls every `GET /` and `/healthz`.
|
||||
if not statmod.S_ISREG(st.st_mode):
|
||||
return _broken(booth, "is not a regular file")
|
||||
if st.st_size > MANIFEST_MAX_BYTES:
|
||||
return _broken(booth, f"is too large to be a manifest ({st.st_size} bytes)")
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, UnicodeDecodeError, MemoryError) as exc:
|
||||
return _broken(booth, f"cannot be read: {exc}")
|
||||
if not text.strip():
|
||||
return _broken(booth, "is empty")
|
||||
try:
|
||||
raw = json.loads(text)
|
||||
except (ValueError, RecursionError, MemoryError) as exc:
|
||||
return _broken(booth, f"is not valid JSON: {type(exc).__name__}")
|
||||
if not isinstance(raw, dict):
|
||||
return _broken(booth, "is not a JSON object")
|
||||
|
||||
handle = _one_line(raw.get("handle"), HANDLE_MAX)
|
||||
if not handle:
|
||||
return _broken(booth, "names no handle")
|
||||
return Manifest(
|
||||
handle=handle,
|
||||
# `or booth.name` goes THROUGH the normalizer too. A directory name may
|
||||
# legally carry a newline on POSIX and may run to 255 bytes, and the
|
||||
# fallback used to hand either straight into a card's sub-line.
|
||||
title=_one_line(raw.get("title"), TITLE_MAX) or _one_line(booth.name, TITLE_MAX),
|
||||
why=_one_line(raw.get("why"), WHY_MAX),
|
||||
created=_one_line(raw.get("created"), CREATED_MAX),
|
||||
)
|
||||
|
||||
|
||||
def _broken(booth: Path, reason: str) -> Manifest:
|
||||
# The directory name goes through the normalizer here too. This was the
|
||||
# THIRD fallback of three; the write path's and the read path's were fixed a
|
||||
# round earlier and this one was missed, with the same consequence — a
|
||||
# newline or 255 bytes of directory name straight into a card's sub-line.
|
||||
return Manifest(handle="", title=_one_line(booth.name, TITLE_MAX), why="",
|
||||
created="", error=f"{MANIFEST_FILE} {reason}")
|
||||
|
||||
|
||||
def write_manifest(booth: Path, handle: str, *, title: str | None = None,
|
||||
why: str | None = None) -> Manifest:
|
||||
"""Announce a booth, atomically (CLAUDE.md invariant 5).
|
||||
|
||||
Temp file + `os.replace`, because the CLI writes this in one process while
|
||||
the browser reads it in another — a reader must never see a half-written
|
||||
document. The temp file is itself a dotfile, so no listing, gallery or zip
|
||||
can see it mid-write either.
|
||||
|
||||
OMITTED MEANS UNCHANGED; `""` MEANS CLEAR. `title` and `why` default to
|
||||
None, not to the empty string, because the ordinary sequence is
|
||||
`booth new x --why "..."` and then `booth add x out/*.png` — and while
|
||||
omission meant empty, that second command silently erased the sentence the
|
||||
first one existed to record. Two arms of the contract panel predicted it
|
||||
from the wording alone; every test written for this module passed `--why`
|
||||
on both calls and so could not see it.
|
||||
|
||||
RE-ANNOUNCING PRESERVES `created` (INV-3). It is when the booth APPEARED,
|
||||
and saying something more about it later is not a second appearance. A
|
||||
`created` that cannot be read back is replaced rather than guessed at: a
|
||||
stamp that is silently wrong is worse than one that is silently new.
|
||||
|
||||
An empty `handle` becomes `SERVICE_HANDLE` rather than being refused — a
|
||||
manifest with no handle does not read back at all, and an unreadable file is
|
||||
the worse outcome. Unreachable from the CLI, whose fallback chain always
|
||||
yields something; callers of this function directly should pass a real one.
|
||||
"""
|
||||
booth = Path(booth)
|
||||
booth.mkdir(parents=True, exist_ok=True)
|
||||
prior = read_manifest(booth)
|
||||
usable = prior if prior and not prior.error else None
|
||||
created = usable.created if usable and usable.created else _now()
|
||||
|
||||
record = Manifest(
|
||||
handle=_one_line(handle, HANDLE_MAX) or SERVICE_HANDLE,
|
||||
title=(_one_line(title, TITLE_MAX) if title is not None
|
||||
else (usable.title if usable else "")) or _one_line(booth.name, TITLE_MAX),
|
||||
why=(_one_line(why, WHY_MAX) if why is not None
|
||||
else (usable.why if usable else "")),
|
||||
created=created,
|
||||
)
|
||||
path = booth / MANIFEST_FILE
|
||||
doc = {"handle": record.handle, "title": record.title,
|
||||
"why": record.why, "created": record.created}
|
||||
|
||||
# A write that changes nothing is not activity and must not reset the
|
||||
# booth's TTL — the rule marks learned in v0.2.0, applied here because
|
||||
# `booth link` re-announces the standing board on EVERY post to it.
|
||||
if prior is not None and not prior.error and _as_doc(prior) == doc:
|
||||
return record
|
||||
|
||||
# NOTHING THAT COULD NOT BE READ IS DESTROYED. Reads stay lenient, writes
|
||||
# go strict, damaged bytes stay on disk — the doctrine marks made explicit
|
||||
# in v0.2.1, which this write path contradicted by replacing them outright.
|
||||
# A file that fails on ONE field still holds the others, and a `why` the
|
||||
# re-announcer never kept anywhere is exactly what went missing.
|
||||
#
|
||||
# QUARANTINED rather than REFUSED, which is where this diverges from marks:
|
||||
# refusing would fail `booth add` and lose the files it was mid-way through
|
||||
# copying, and a booth's own description is restatable in a way the
|
||||
# operator's judgment is not.
|
||||
if prior is not None and prior.error:
|
||||
try:
|
||||
os.replace(path, booth / QUARANTINE_FILE)
|
||||
except OSError:
|
||||
pass # nothing to preserve beats failing the write
|
||||
|
||||
tmp = _temp_path(booth)
|
||||
try:
|
||||
tmp.write_text(
|
||||
json.dumps(doc, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
# A leaked temp is worse here than it would be with a fixed name: the
|
||||
# unique suffix means nothing ever overwrites it, and it is not a
|
||||
# `.lock`, so `_newest_mtime` counts it and it keeps a dead booth alive
|
||||
# forever. Cleaning up is the price of the uniqueness.
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
return record
|
||||
+136
-15
@@ -42,6 +42,7 @@ from __future__ import annotations
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import stat as statmod
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -73,6 +74,14 @@ class MarksCorrupt(RuntimeError):
|
||||
"""
|
||||
|
||||
|
||||
# A booth's whole judgment lives in one document, so this is generous — a
|
||||
# 270-item booth flagged throughout, with notes, is far under it. What it rules
|
||||
# out is the case that is not marks at all: an unbounded read raises MemoryError
|
||||
# and a deeply nested one raises RecursionError out of `json.loads`, neither of
|
||||
# which is an OSError or a ValueError, and `list_booths` calls the reader once
|
||||
# per booth on every index load. Bounded by `stat`, before the bytes are read.
|
||||
MARKS_MAX_BYTES = 4 * 1024 * 1024
|
||||
|
||||
MARKS_FILE = ".marks.json"
|
||||
MARKS_LOCK = ".marks.lock"
|
||||
SCHEMA_VERSION = 1
|
||||
@@ -126,7 +135,17 @@ class Mark:
|
||||
|
||||
|
||||
def now_stamp() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
"""ONE stamp format across every writer in this module.
|
||||
|
||||
MICROSECONDS, matching `import_legacy_asks`. They diverged when the
|
||||
importer was moved to sub-second precision to stop same-second sidecars
|
||||
re-sorting — and the divergence opened a fresh ordering bug in the other
|
||||
direction, because `-` (0x2D) sorts before `.` (0x2E): a whole-second stamp
|
||||
lands ahead of ANY fractional stamp in the same second, so a later mark came
|
||||
out before an earlier import. Marks sort on `(created, id)`; one format is
|
||||
what makes that rule statable.
|
||||
"""
|
||||
return datetime.now().astimezone().isoformat(timespec="microseconds")
|
||||
|
||||
|
||||
def _clean_text(text) -> str:
|
||||
@@ -173,9 +192,17 @@ def _read_raw(booth: Path) -> list[dict]:
|
||||
for the same reason: a review surface that will not load is worse than one
|
||||
that has lost an annotation.
|
||||
"""
|
||||
path = Path(booth) / MARKS_FILE
|
||||
try:
|
||||
raw = json.loads((Path(booth) / MARKS_FILE).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, UnicodeDecodeError):
|
||||
st = path.stat()
|
||||
# Regular-file first, then size. `st_size` is 0 for a FIFO and 0 for a
|
||||
# symlink to /dev/zero, so both pass a byte cap and then `read_text`
|
||||
# either blocks with no EOF or allocates until the kernel intervenes.
|
||||
# This loop runs over EVERY booth on every index load.
|
||||
if not statmod.S_ISREG(st.st_mode) or st.st_size > MARKS_MAX_BYTES:
|
||||
return []
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, UnicodeDecodeError, RecursionError, MemoryError):
|
||||
return []
|
||||
if not isinstance(raw, dict):
|
||||
return []
|
||||
@@ -190,26 +217,51 @@ 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:
|
||||
st = path.stat()
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except OSError as exc:
|
||||
raise MarksCorrupt(f"{path} cannot be read: {exc}") from exc
|
||||
# The strict half has to refuse everything the lenient half tolerates, or a
|
||||
# file that reads as "no marks" gets replaced by a write that believed it.
|
||||
if not statmod.S_ISREG(st.st_mode):
|
||||
raise MarksCorrupt(f"{path} is not a regular file")
|
||||
if st.st_size > MARKS_MAX_BYTES:
|
||||
raise MarksCorrupt(
|
||||
f"{path} is too large to be a marks document ({st.st_size} bytes)")
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
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)
|
||||
except ValueError as exc:
|
||||
raise MarksCorrupt(f"{path} is not valid JSON: {exc}") from exc
|
||||
except (ValueError, RecursionError, MemoryError) as exc:
|
||||
raise MarksCorrupt(
|
||||
f"{path} is not valid JSON: {type(exc).__name__}") from exc
|
||||
if not isinstance(raw, dict) or not isinstance(raw.get("marks"), list):
|
||||
raise MarksCorrupt(f"{path} is not a marks document")
|
||||
entries = [e for e in raw["marks"] if isinstance(e, dict) and isinstance(e.get("id"), str)]
|
||||
@@ -242,8 +294,17 @@ def _write_raw(booth: Path, entries: list[dict]) -> None:
|
||||
quieter — set of marks."""
|
||||
path = Path(booth) / MARKS_FILE
|
||||
doc = {"version": SCHEMA_VERSION, "marks": entries}
|
||||
body = json.dumps(doc, ensure_ascii=False, indent=2) + "\n"
|
||||
# The read bound is on the STORED bytes and `indent=2` grows them, so a
|
||||
# document that fits in memory can land over the limit on disk and then read
|
||||
# back as no marks at all. Refuse loudly instead: a write that fails is
|
||||
# recoverable, a file that silently empties is not.
|
||||
if len(body.encode("utf-8")) > MARKS_MAX_BYTES:
|
||||
raise MarksCorrupt(
|
||||
f"{path} would be larger than this version can read back "
|
||||
f"({len(body.encode('utf-8'))} bytes)")
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
tmp.write_text(body, encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
@@ -274,13 +335,41 @@ class _Locked:
|
||||
# ONCE CREATED, THE LOCK FILE IS NEVER REMOVED (see __exit__).
|
||||
if not lock.exists():
|
||||
# Creating a directory entry bumps the DIRECTORY's mtime, which is
|
||||
# what `_newest_mtime` seeds from — so making our own lock file
|
||||
# would itself read as activity. Put the clock back: the lock is
|
||||
# machinery, and machinery is not the operator touching the booth.
|
||||
# what `_newest_mtime` reads. An earlier version put the clock back
|
||||
# with `os.utime` — which closed the bug and opened a race: the
|
||||
# restore ran before the flock, so anything landing in the window
|
||||
# between the stat and the utime had its bump rolled backward. An
|
||||
# `rsync -a` batch is the case that bites, because it PRESERVES
|
||||
# source mtimes and so has only the directory's freshness to look
|
||||
# alive by. It could also raise OSError on a read-only directory
|
||||
# and take the route down with it.
|
||||
#
|
||||
# THE RESTORE STAYS, and the honest reason is that the alternative
|
||||
# was worse. Ignoring a booth directory's own mtime whenever the
|
||||
# booth holds anything would close the race outright — and would
|
||||
# also silently retire the documented behaviour that RELEASING a
|
||||
# kept board resets its clock, which the CLI header, the README and
|
||||
# a deliberate test all pin. That is a TTL doctrine change, not a
|
||||
# bug fix, and it does not belong in one.
|
||||
#
|
||||
# ⚠ RESIDUAL RACE, stated rather than papered over: between the stat
|
||||
# and the utime, another writer's directory-entry change can be
|
||||
# rolled backward. The case that bites is an `rsync -a` batch, which
|
||||
# preserves source mtimes and so has only the directory's freshness
|
||||
# to look alive by. The window is the two syscalls below and the
|
||||
# booth must also be one being written to at that instant.
|
||||
#
|
||||
# The concrete half IS fixed: a failing utime (read-only directory,
|
||||
# a booth whose owner we are not) used to escape and take the whole
|
||||
# route down with a 500. Not putting the clock back is a cost this
|
||||
# module can absorb; not answering the request is not.
|
||||
before = self.booth.stat()
|
||||
lock.touch()
|
||||
self._made_lock = True
|
||||
os.utime(self.booth, (before.st_atime, before.st_mtime))
|
||||
try:
|
||||
os.utime(self.booth, (before.st_atime, before.st_mtime))
|
||||
except OSError:
|
||||
pass
|
||||
self._lf = lock.open("r+")
|
||||
fcntl.flock(self._lf, fcntl.LOCK_EX)
|
||||
try:
|
||||
@@ -467,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]
|
||||
@@ -671,7 +788,8 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
continue
|
||||
try:
|
||||
decl = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, UnicodeDecodeError) as exc:
|
||||
except (OSError, ValueError, UnicodeDecodeError,
|
||||
RecursionError, MemoryError) as exc:
|
||||
found.append((mtime, stem, None, f"unreadable ask: {exc}"))
|
||||
continue
|
||||
if not isinstance(decl, dict):
|
||||
@@ -693,7 +811,8 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
loaded = json.loads(ap.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict):
|
||||
answer = loaded
|
||||
except (OSError, ValueError, UnicodeDecodeError):
|
||||
except (OSError, ValueError, UnicodeDecodeError,
|
||||
RecursionError, MemoryError):
|
||||
pass
|
||||
|
||||
prior = by_id.get(stem)
|
||||
@@ -737,4 +856,6 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
|
||||
# Hydrated AFTER the lock so a broken declaration surfaces as `error` here
|
||||
# exactly as it does on a normal read, rather than through a second path.
|
||||
return [_hydrate(e) for e in created]
|
||||
# `_hydrate_safe`, not `_hydrate`: this is the one path that reads entries
|
||||
# it did not write, and it was the one without the guard.
|
||||
return [_hydrate_safe(e) for e in created]
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/* The Booth - the declared embed seam (U3).
|
||||
*
|
||||
* A booth that ships its own index.html is served verbatim. This script is how
|
||||
* the Booth's chrome gets onto that page WITHOUT the Booth reaching into it:
|
||||
* the report carries one line,
|
||||
*
|
||||
* <script src="/_booth/embed.js" defer></script>
|
||||
*
|
||||
* and everything below mounts through real DOM APIs. It replaced ten regular
|
||||
* expressions applied to author HTML - six hunting for a place to hang a
|
||||
* favicon and a chip, four substituting rendered markup into the author's own
|
||||
* tags. A page that declares this line is now served exactly as written.
|
||||
*
|
||||
* WHAT THIS SCRIPT DOES NOT DECIDE: what a mark says, whether it is still open,
|
||||
* or what order marks come in. Every fragment below is rendered server-side by
|
||||
* the same Jinja macros the gallery page uses, and `open` is computed by
|
||||
* `open_marks`. Two renderers of one truth is the bug INV-1 exists to stop -
|
||||
* the zoom view once re-derived an item and lost its captions doing it.
|
||||
*
|
||||
* Served from a read taken ONCE at app startup. Editing this file does nothing
|
||||
* until `systemctl --user restart booth.service`, exactly like the templates,
|
||||
* and for the same reason: on 2026-09-21 a hot-reloading template put 19 of 25
|
||||
* booths at 500 against Python that had never heard of the context it wanted.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
if (window.__boothEmbed) return; // declared AND appended: mount once
|
||||
window.__boothEmbed = true;
|
||||
|
||||
var CSS = [
|
||||
/* ---- the way home, and the open-asks jump ---- */
|
||||
".booth-nav-home,.booth-nav-asks{position:fixed;top:0;z-index:2147483647;",
|
||||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;border-radius:8px;",
|
||||
"text-decoration:none;letter-spacing:.01em;box-shadow:0 2px 10px rgba(0,0,0,.35)}",
|
||||
/* top-right: a top-left chip clips the page title on left-aligned report
|
||||
layouts, and this matches the zoom view's back affordance. */
|
||||
".booth-nav-home{right:0;font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||||
"color:#dfe7ef;background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);",
|
||||
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .18s,border-color .18s}",
|
||||
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}",
|
||||
".booth-nav-asks{right:7.2rem;font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||||
"color:#171a23;background:#ffe14e;border:1px solid #ffe14e;transition:filter .18s}",
|
||||
".booth-nav-asks:hover{filter:brightness(1.08)}",
|
||||
"@media print{.booth-nav-home,.booth-nav-asks{display:none}}",
|
||||
/* ---- ask fragments. Self-contained: the host page carries its own CSS and
|
||||
nothing here may inherit from it, so the palette adapts via
|
||||
prefers-color-scheme rather than borrowing. ---- */
|
||||
".bk-ask{margin:1.1rem 0;padding:.85rem .95rem;border:1px solid rgba(128,140,160,.34);",
|
||||
"border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);",
|
||||
"font:15px/1.5 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}",
|
||||
".bk-ask.bk-done{border-top-color:#3fae6a}",
|
||||
".bk-ask.bk-skip{border-top-color:#6f7c8c}",
|
||||
".bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}",
|
||||
".bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;",
|
||||
"letter-spacing:.12em;text-transform:uppercase;color:#c9a227}",
|
||||
".bk-ask.bk-done .bk-ask-tag{color:#3fae6a}",
|
||||
".bk-ask-title{margin:0 0 .15rem;font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;opacity:.62}",
|
||||
".bk-ask-prompt{margin:0 0 .6rem;font-weight:600}",
|
||||
".bk-ask-opts{display:flex;flex-direction:column;gap:.3rem}",
|
||||
".bk-ask-opt{display:flex;align-items:flex-start;gap:.55rem;padding:.45rem .6rem;cursor:pointer;",
|
||||
"border:1px solid rgba(128,140,160,.3);border-radius:6px;background:rgba(128,140,160,.06)}",
|
||||
".bk-ask-opt:hover{border-color:rgba(128,140,160,.62)}",
|
||||
".bk-ask-opt:has(input:checked){border-color:#2fa8a0;background:rgba(47,168,160,.13)}",
|
||||
".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:#2fa8a0}",
|
||||
".bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}",
|
||||
".bk-ask-det{font-size:.8rem;opacity:.68}",
|
||||
".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;",
|
||||
"font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);",
|
||||
"border:1px solid rgba(128,140,160,.34);border-radius:6px;resize:vertical}",
|
||||
".bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;",
|
||||
"letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;",
|
||||
"background:#2fa8a0;color:#08131a}",
|
||||
".bk-ask-go:hover{filter:brightness(1.09)}",
|
||||
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}",
|
||||
".bk-ask-was b{opacity:1}",
|
||||
".bk-ask-err{color:#d6452a;font-size:.86rem}",
|
||||
"@media (prefers-color-scheme: light){.bk-ask-tag{color:#8a6d10}.bk-ask-go{color:#fff}}",
|
||||
"@media print{.bk-ask{break-inside:avoid}}"
|
||||
].join("");
|
||||
|
||||
/* The anchor attributes. `data-booth-mark` is canonical - U2 made an ask one
|
||||
shape of mark - and `data-booth-ask` is kept because two of the four live
|
||||
verbatim booths spell it that way, in the operator's own reports. */
|
||||
var MAIN_SEL = "[data-booth-mark],[data-booth-ask]";
|
||||
var SUBMIT_SEL = "[data-booth-mark-submit],[data-booth-ask-submit]";
|
||||
var WHOLE = " whole"; // the set member meaning "the whole ask landed here"
|
||||
|
||||
function attr(el, a, b) {
|
||||
var v = el.getAttribute(a);
|
||||
return v === null ? el.getAttribute(b) : v;
|
||||
}
|
||||
|
||||
/* "batch:r1" -> ["batch", "r1"]; "batch" -> ["batch", null]. Split on the
|
||||
FIRST colon: a question key cannot contain one (asks._KEY_RE) and neither
|
||||
can a pick id (asks.valid_stem), so this is unambiguous for everything the
|
||||
payload carries. A flag's id IS `flag:<target>`, which is why the payload
|
||||
carries picks only. */
|
||||
function splitSpec(spec) {
|
||||
var s = (spec || "").trim();
|
||||
var i = s.indexOf(":");
|
||||
if (i < 0) return [s, null];
|
||||
return [s.slice(0, i).trim(), s.slice(i + 1).trim() || null];
|
||||
}
|
||||
|
||||
function boothName() {
|
||||
var tag = document.querySelector("script[data-booth]");
|
||||
if (tag) return tag.getAttribute("data-booth");
|
||||
var parts = location.pathname.split("/"); // ["", "b", "<name>", ...]
|
||||
if (parts.length < 3 || parts[1] !== "b" || !parts[2]) return null;
|
||||
try { return decodeURIComponent(parts[2]); } catch (e) { return parts[2]; }
|
||||
}
|
||||
|
||||
function mount(el, html) {
|
||||
/* beforeend, NOT replaceWith: the author's element and its contents survive
|
||||
and the fragment lands inside it. `<div class="ask" data-booth-ask="...">
|
||||
<h3>heading</h3>` is live markup today, and the regex it replaced ate
|
||||
both the wrapper class and the heading's framing.
|
||||
|
||||
Returns the elements it actually inserted. The chip needs to jump to a
|
||||
fragment WE mounted, not to whatever the document happens to have with a
|
||||
matching id — see chipTarget. */
|
||||
var before = el.children.length;
|
||||
el.insertAdjacentHTML("beforeend", html);
|
||||
return Array.prototype.slice.call(el.children, before);
|
||||
}
|
||||
|
||||
function styles() {
|
||||
if (document.getElementById("booth-embed-css")) return;
|
||||
var st = document.createElement("style");
|
||||
st.id = "booth-embed-css";
|
||||
st.textContent = CSS;
|
||||
(document.head || document.documentElement).appendChild(st);
|
||||
}
|
||||
|
||||
function favicon(href) {
|
||||
/* The question `_ICON_RE` and its three head-seam siblings were asking of
|
||||
raw text. Same question, asked of a parsed document. */
|
||||
if (!href || document.querySelector('link[rel~="icon"]')) return;
|
||||
var link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
link.href = href;
|
||||
(document.head || document.documentElement).appendChild(link);
|
||||
}
|
||||
|
||||
function homeChip(href) {
|
||||
var a = document.createElement("a");
|
||||
a.className = "booth-nav-home";
|
||||
a.href = href || "/";
|
||||
a.setAttribute("aria-label", "back to all booths");
|
||||
a.textContent = "‹ all booths";
|
||||
document.body.appendChild(a);
|
||||
}
|
||||
|
||||
function chipTarget(mounted, markId) {
|
||||
/* The earliest IN DOCUMENT ORDER of the elements WE mounted for this mark.
|
||||
Not an id-prefix search over the whole document: a panel pointed out that
|
||||
an author's own `<section id="bk-ask-winner-background">` satisfies any
|
||||
prefix rule — hyphen boundary included — and would hijack the jump. Only
|
||||
elements this script inserted are candidates, which is the identity the
|
||||
deleted `bk-ask-<id>-top` anchor used to guarantee. */
|
||||
var mine = mounted[markId] || [];
|
||||
var first = null;
|
||||
for (var i = 0; i < mine.length; i++) {
|
||||
var el = mine[i];
|
||||
if (!el.id || !document.contains(el)) continue;
|
||||
if (first === null ||
|
||||
(first.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)) {
|
||||
first = el;
|
||||
}
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
function asksChip(openIds, mounted) {
|
||||
if (!openIds.length) return;
|
||||
/* A JUMP LINK, not a way out to another page: on a long report the question
|
||||
can be well below the fold and "there is a question waiting" still has to
|
||||
be visible at first paint. */
|
||||
var first = chipTarget(mounted, openIds[0]);
|
||||
var a = document.createElement("a");
|
||||
a.className = "booth-nav-asks";
|
||||
a.href = first ? "#" + first.id : "/b/" + encodeURIComponent(boothName() || "") + "/marks";
|
||||
a.textContent = "? " + openIds.length + " open ask" + (openIds.length === 1 ? "" : "s");
|
||||
document.body.appendChild(a);
|
||||
}
|
||||
|
||||
function reassociate() {
|
||||
/* SCOPED TO OUR OWN FRAGMENTS (`.bk-ask [form]`), deliberately: the Booth
|
||||
does not rewrite attributes on elements the author wrote, even to help.
|
||||
A control bound to its <form> by the HTML5 `form=` attribute resolves its
|
||||
form owner when it is inserted. The fragments go in in VISUAL order, so a
|
||||
question can land before the submit block that carries the <form>.
|
||||
Chromium 151 re-resolves this correctly - measured 2026-09-22, N=3 per
|
||||
condition, with a form-first positive control and a points-at-nothing
|
||||
negative control. The sensitivity floor of that probe is ONE ENGINE, and
|
||||
the failure it would hide is a form the operator fills in whose controls
|
||||
reach no form at all, so the button does nothing and nothing is saved.
|
||||
Three lines, so the engine stops mattering. */
|
||||
var bound = document.querySelectorAll(".bk-ask [form]");
|
||||
for (var i = 0; i < bound.length; i++) {
|
||||
var v = bound[i].getAttribute("form");
|
||||
bound[i].removeAttribute("form");
|
||||
bound[i].setAttribute("form", v);
|
||||
}
|
||||
}
|
||||
|
||||
function hasForm(markId) {
|
||||
/* `form_id` in booth/app.py builds the same string. Kept in step by the
|
||||
fragments themselves: the submit macro emits exactly this id. */
|
||||
return !!document.getElementById(
|
||||
"bk-ask-form-" + markId.replace(/[^A-Za-z0-9_-]/g, "-"));
|
||||
}
|
||||
|
||||
function place(marks) {
|
||||
/* Object.create(null), NOT {} — three times, and it is not style.
|
||||
A mark id and a question key are both `[A-Za-z0-9][A-Za-z0-9._-]*`
|
||||
(asks.valid_stem, asks._KEY_RE), so `toString` and `constructor` are
|
||||
legal in both. Against a plain object, an author writing
|
||||
`data-booth-mark="toString"` — an anchor naming NO mark — gets
|
||||
Object.prototype.toString back, passes the `if (!mark)` guard it was
|
||||
supposed to fail, and throws on `mark.questions.length`. That aborts
|
||||
`place` before the tail, so the page loses EVERY ask, from one typo in
|
||||
the author's own markup. The `placed` set has the mirror bug: inherited
|
||||
`got.constructor` reads as "already placed" and silently drops a real
|
||||
question. Found by a cross-frontier code-review panel. */
|
||||
var by = Object.create(null);
|
||||
for (var i = 0; i < marks.length; i++) by[marks[i].id] = marks[i];
|
||||
var placed = Object.create(null); // id -> {key or WHOLE: true}
|
||||
var submitted = Object.create(null);
|
||||
var mounted = Object.create(null); // id -> [elements this script inserted]
|
||||
|
||||
function note(id, key) {
|
||||
if (!placed[id]) placed[id] = Object.create(null);
|
||||
if (key !== undefined) placed[id][key] = true;
|
||||
}
|
||||
|
||||
function record(id, els) {
|
||||
if (!mounted[id]) mounted[id] = [];
|
||||
for (var n = 0; n < els.length; n++) mounted[id].push(els[n]);
|
||||
}
|
||||
|
||||
// 1. whole / per-question anchors, in DOCUMENT ORDER.
|
||||
var anchors = document.querySelectorAll(MAIN_SEL);
|
||||
for (var a = 0; a < anchors.length; a++) {
|
||||
var el = anchors[a];
|
||||
var spec = splitSpec(attr(el, "data-booth-mark", "data-booth-ask"));
|
||||
var mark = by[spec[0]];
|
||||
if (!mark) continue; // a typo'd id is LEFT ALONE, not blanked
|
||||
if (spec[1] === null) {
|
||||
record(mark.id, mount(el, mark.whole));
|
||||
note(mark.id, WHOLE);
|
||||
if (hasForm(mark.id)) submitted[mark.id] = true;
|
||||
continue;
|
||||
}
|
||||
var q = null;
|
||||
for (var k = 0; k < mark.questions.length; k++) {
|
||||
if (mark.questions[k].key === spec[1]) { q = mark.questions[k]; break; }
|
||||
}
|
||||
if (!q) continue; // names no question: also left alone
|
||||
record(mark.id, mount(el, q.html));
|
||||
note(mark.id, spec[1]);
|
||||
}
|
||||
|
||||
// 2. explicit submit anchors.
|
||||
var subs = document.querySelectorAll(SUBMIT_SEL);
|
||||
for (var s = 0; s < subs.length; s++) {
|
||||
var sel = subs[s];
|
||||
var sid = splitSpec(attr(sel, "data-booth-mark-submit", "data-booth-ask-submit"))[0];
|
||||
var sm = by[sid];
|
||||
if (!sm) continue;
|
||||
/* A BROKEN pick has no submit block — its `submit` is the empty string and
|
||||
its diagnostic lives in `whole`. Mounting nothing here and then marking
|
||||
it placed made the tail skip it, so the "broken ask" box never rendered
|
||||
at the one surface built to show it. Leave the anchor alone, exactly as
|
||||
an anchor naming no mark is left alone, and let the tail mount the
|
||||
diagnostic. */
|
||||
if (sm.error) continue;
|
||||
record(sm.id, mount(sel, sm.submit));
|
||||
note(sm.id);
|
||||
/* ...and only count it submitted if the <form> SURVIVED. An author who
|
||||
puts this anchor inside their own <form> loses ours: the HTML parser
|
||||
drops a nested form element outright. Every control's `form=` would
|
||||
then point at nothing, the tail would not add a fallback because we
|
||||
said it was handled, and the operator would fill the whole thing in and
|
||||
click a button that does nothing. */
|
||||
if (hasForm(sm.id)) submitted[sm.id] = true;
|
||||
}
|
||||
|
||||
// 3. the tail, in PAYLOAD order - `(created, id)`. An ask is never
|
||||
// invisible: an unmarked page gets the whole thing, and a partially
|
||||
// marked one gets every question the author did not place, because a
|
||||
// question the operator cannot see is a question he cannot answer, and
|
||||
// a submission with NOTHING picked is refused outright (400), so a page
|
||||
// showing two of four questions can strand a pick that looks answerable.
|
||||
// (A PARTIAL answer is accepted and recorded — that is deliberate.)
|
||||
var holder = document.createElement("div");
|
||||
for (var m = 0; m < marks.length; m++) {
|
||||
var mk = marks[m];
|
||||
var got = placed[mk.id];
|
||||
var was = holder.children.length;
|
||||
if (!got) {
|
||||
holder.insertAdjacentHTML("beforeend", mk.whole);
|
||||
record(mk.id, Array.prototype.slice.call(holder.children, was));
|
||||
continue;
|
||||
}
|
||||
if (mk.error) continue;
|
||||
if (!got[WHOLE]) {
|
||||
for (var q2 = 0; q2 < mk.questions.length; q2++) {
|
||||
var qq = mk.questions[q2];
|
||||
if (!got[qq.key]) holder.insertAdjacentHTML("beforeend", qq.html);
|
||||
}
|
||||
}
|
||||
if (!submitted[mk.id]) holder.insertAdjacentHTML("beforeend", mk.submit);
|
||||
record(mk.id, Array.prototype.slice.call(holder.children, was));
|
||||
}
|
||||
var tail = document.createDocumentFragment();
|
||||
while (holder.firstChild) tail.appendChild(holder.firstChild);
|
||||
document.body.appendChild(tail);
|
||||
return mounted;
|
||||
}
|
||||
|
||||
function start() {
|
||||
var name = boothName();
|
||||
if (!name || !document.body) return;
|
||||
styles();
|
||||
// Mounted BEFORE the fetch and from a constant, so a failed or slow fetch
|
||||
// still leaves the operator a way out. That is why the payload carries no
|
||||
// `home` — a value on the wire that nothing reads is a second
|
||||
// representation of one fact, waiting to disagree with the first.
|
||||
homeChip("/");
|
||||
fetch("/b/" + encodeURIComponent(name) + "/embed.json", { credentials: "same-origin" })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data) return;
|
||||
favicon(data.favicon);
|
||||
var mounted = place(data.marks || []);
|
||||
reassociate();
|
||||
asksChip(data.open || [], mounted);
|
||||
document.dispatchEvent(new CustomEvent("booth:mounted", { detail: { booth: name } }));
|
||||
})
|
||||
.catch(function () { /* the report is the operator's; a failed fetch costs
|
||||
the chrome, never the page. */ });
|
||||
}
|
||||
|
||||
/* The declared line carries `defer`, but an author may not copy it exactly. */
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
@@ -1,56 +1,22 @@
|
||||
{# Self-contained ask fragments injected into a booth's VERBATIM index.html.
|
||||
{# Self-contained ask fragments for a booth's VERBATIM index.html.
|
||||
|
||||
The page is served untouched and carries its own CSS, so nothing here may
|
||||
inherit from base.html: every fragment ships its own scoped `.bk-ask-*`
|
||||
styles (emitted once, by `styles()`), and the palette adapts via
|
||||
prefers-color-scheme rather than borrowing the host page's.
|
||||
inherit from base.html. Since U3 these fragments do not reach the page by
|
||||
string substitution: they are rendered here, handed over
|
||||
`/b/<name>/embed.json`, and MOUNTED INTO THE DOM by `/_booth/embed.js`. The
|
||||
scoped `.bk-ask-*` styles live in that file alongside the code that needs
|
||||
them, which is why this template no longer emits a `styles()` block.
|
||||
|
||||
These macros stay the ONE renderer of an ask fragment. embed.js places what
|
||||
comes back and never builds one.
|
||||
|
||||
Per-question fragments are wired to ONE form with the HTML5 `form=`
|
||||
attribute, so a four-voice report can put each radio group under its own
|
||||
audio block and still submit all four picks in a single POST — which is what
|
||||
audio block and still submit all four picks in a single POST -- which is what
|
||||
the multi-question ask requires. The <form> element itself is empty and
|
||||
lives with the submit block. No JavaScript.
|
||||
lives with the submit block.
|
||||
#}
|
||||
|
||||
{% macro styles() %}
|
||||
<style>
|
||||
.bk-ask{margin:1.1rem 0;padding:.85rem .95rem;border:1px solid rgba(128,140,160,.34);
|
||||
border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);
|
||||
font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||
.bk-ask.bk-done{border-top-color:#3fae6a}
|
||||
.bk-ask.bk-skip{border-top-color:#6f7c8c}
|
||||
.bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}
|
||||
.bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
letter-spacing:.12em;text-transform:uppercase;color:#c9a227}
|
||||
.bk-ask.bk-done .bk-ask-tag{color:#3fae6a}
|
||||
.bk-ask-title{margin:0 0 .15rem;font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;opacity:.62}
|
||||
.bk-ask-prompt{margin:0 0 .6rem;font-weight:600}
|
||||
.bk-ask-opts{display:flex;flex-direction:column;gap:.3rem}
|
||||
.bk-ask-opt{display:flex;align-items:flex-start;gap:.55rem;padding:.45rem .6rem;cursor:pointer;
|
||||
border:1px solid rgba(128,140,160,.3);border-radius:6px;background:rgba(128,140,160,.06)}
|
||||
.bk-ask-opt:hover{border-color:rgba(128,140,160,.62)}
|
||||
.bk-ask-opt:has(input:checked){border-color:#2fa8a0;background:rgba(47,168,160,.13)}
|
||||
.bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:#2fa8a0}
|
||||
.bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}
|
||||
.bk-ask-det{font-size:.8rem;opacity:.68}
|
||||
.bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;
|
||||
font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);
|
||||
border:1px solid rgba(128,140,160,.34);border-radius:6px;resize:vertical}
|
||||
.bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;
|
||||
background:#2fa8a0;color:#08131a}
|
||||
.bk-ask-go:hover{filter:brightness(1.09)}
|
||||
.bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}
|
||||
.bk-ask-was b{opacity:1}
|
||||
.bk-ask-err{color:#d6452a;font-size:.86rem}
|
||||
@media (prefers-color-scheme: light){
|
||||
.bk-ask-tag{color:#8a6d10}
|
||||
.bk-ask-go{color:#fff}
|
||||
}
|
||||
@media print{.bk-ask{break-inside:avoid}}
|
||||
</style>
|
||||
{% endmacro %}
|
||||
|
||||
{# One question's radio group, bound to the shared form by id. #}
|
||||
{% macro question(a, q, form_id, name_url, standalone=False) %}
|
||||
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{# THE ANNOUNCEMENT — who posted this booth and why. Defined ONCE and called
|
||||
from both index lanes and the booth page header: the kept lane is a separate
|
||||
block, and patching only the ephemeral one would leave the durable,
|
||||
most-looked-at boards with exactly the defect this closes.
|
||||
|
||||
Four states, and `unannounced` is distinct from `unreadable` on purpose —
|
||||
folding "cannot be read" into "never said" hides the one case somebody has to
|
||||
go and fix. The classes are the test hooks; the words are for the operator. #}
|
||||
{% macro provenance(m) -%}
|
||||
{% if m is none %}
|
||||
<div class="prov prov-none">unannounced</div>
|
||||
{% elif m.error %}
|
||||
<div class="prov prov-broken" title="{{ m.error }}">unreadable</div>
|
||||
{% else %}
|
||||
<div class="prov"><span class="prov-who">{{ m.handle }}</span>{% if m.why %} · <span class="prov-why">{{ m.why }}</span>{% endif %}</div>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
@@ -255,6 +255,22 @@
|
||||
.card .name:hover{text-decoration:none;color:var(--aus-bright-cyan)}
|
||||
.card .sub{color:var(--fg-3);font-size:.72rem;font-family:var(--font-mono);letter-spacing:.03em;margin-top:.3rem}
|
||||
|
||||
/* THE ANNOUNCEMENT — who posted this booth and why (U5). Same size and
|
||||
rhythm as .sub above it, because it is the same class of information: a
|
||||
second line of card metadata, not a heading. The handle carries the only
|
||||
colour, so a scan down the index reads as a column of posters. */
|
||||
.prov{margin-top:.28rem;font-size:.72rem;font-family:var(--font-mono);
|
||||
letter-spacing:.03em;color:var(--fg-3);line-height:1.45;
|
||||
overflow-wrap:anywhere}
|
||||
.prov-who{color:var(--fg-2)}
|
||||
.prov-why{color:var(--fg-3)}
|
||||
/* Quiet on purpose. 26 booths arrived before this convention existed and
|
||||
rsync keeps making more, so the marker has to be visible-if-you-look and
|
||||
never a badge shouting 26 times. `unreadable` gets the warning tint
|
||||
because, unlike `unannounced`, it is something somebody has to fix. */
|
||||
.prov-none{color:var(--fg-muted);font-style:italic}
|
||||
.prov-broken{color:var(--aus-bright-yellow,#e8c547);font-style:italic;cursor:help}
|
||||
|
||||
.wipe{position:absolute;top:.5rem;right:.5rem;margin:0}
|
||||
/* ★ keep, mirroring .wipe on the other shoulder of the card. Same
|
||||
hover-to-reveal language as .release in the kept lane. */
|
||||
@@ -314,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);
|
||||
@@ -408,6 +429,13 @@
|
||||
.boothhead h1{margin:0;font-family:var(--font-display);font-weight:600;font-size:1.5rem;
|
||||
letter-spacing:-.01em;word-break:break-word;flex:1 1 auto;color:var(--fg-0)}
|
||||
.boothhead .sub{color:var(--fg-3);font-size:.74rem;font-family:var(--font-mono);letter-spacing:.06em}
|
||||
/* Its own row under the title, not another chip in the flex line — a `why`
|
||||
can run to WHY_MAX and would otherwise shove the zip link around. */
|
||||
.boothhead .prov{flex:0 0 100%;margin-top:-.35rem}
|
||||
/* The directory name beside a manifest title: quieter than the title, but
|
||||
never absent — it is what the URL says and what "the third one" refers to. */
|
||||
.h1-slug{font-family:var(--font-mono);font-size:.62em;font-weight:400;
|
||||
letter-spacing:.06em;color:var(--fg-3);margin-left:.5rem;white-space:nowrap}
|
||||
.wipe-lg{position:static}
|
||||
/* red-outline danger button — legible on the dark canvas, fills on hover */
|
||||
.wipe-lg button{width:auto;height:auto;padding:.42rem .85rem;border-radius:var(--radius-md);
|
||||
|
||||
@@ -1,4 +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
|
||||
@@ -55,9 +57,18 @@
|
||||
{% block content %}
|
||||
<div class="boothhead">
|
||||
<a class="back" href="/">‹ all booths</a>
|
||||
<h1>{{ name }}</h1>
|
||||
<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>
|
||||
{# The manifest's TITLE is the display name; the directory name stays visible
|
||||
beside it because that is the identity the operator navigates by and refers
|
||||
to positionally, and losing it would be losing the thing the URL says.
|
||||
Index cards keep the directory name alone for the same reason. #}
|
||||
{% if manifest and not manifest.error and manifest.title and manifest.title != name %}
|
||||
<h1>{{ manifest.title }} <span class="h1-slug">{{ name }}</span></h1>
|
||||
{% 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 %} · {{ 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
|
||||
kept lane on the index. Remove rows with the per-row ×, or release the
|
||||
board from the index and wipe it from there. #}
|
||||
|
||||
@@ -1,4 +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">
|
||||
@@ -38,7 +40,8 @@
|
||||
</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
|
||||
release-then-find-it-in-the-other-lane, on the theory that two
|
||||
@@ -67,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>
|
||||
@@ -109,7 +112,8 @@
|
||||
</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
|
||||
both predate this button; until 2026-09-19 the UI could only RELEASE a
|
||||
@@ -120,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>
|
||||
@@ -157,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,471 @@
|
||||
---
|
||||
contract_version: "1.0"
|
||||
module: "booth.app (verbatim serving) + booth/static/embed.js"
|
||||
purpose: "A booth that ships its own index.html is the operator's most important surface -- his design reviews, his audition reports, his briefs -- and the Booth reaches into it with six regular expressions against arbitrary author HTML plus a placeholder DSL that substitutes rendered markup by pattern. Both work today and both are the single most fragile thing in the service. This unit replaces the whole class with a DECLARED SEAM: the page carries one line (`<script src=\"/_booth/embed.js\" defer></script>`), the Booth mounts its chrome through real DOM APIs, and a page that declares the line is served with ZERO Booth markup added to it. A page that does not declare it gets that one line appended at the end -- the only remaining mutation, and it needs no pattern matching at all. Operator ruling, 2026-09-21: the page declares itself, the Booth mounts into it."
|
||||
depends_on:
|
||||
- "booth.marks (marks_for, open_marks, hold_read -- the pick records the payload renders. UNCHANGED by this unit: U3 changes how fragments REACH the page, never what a mark is. Read against booth/marks.py, not against the U2 contract's prose -- see the seam review.)"
|
||||
- "booth/templates/_ask_inline.html (the `whole` / `question` / `submit` macros stay the ONE renderer of an ask fragment, called from the embed payload instead of from inject_asks. Its `styles()` macro is DELETED -- inject_asks was its only caller and the CSS moves into embed.js so the chrome is one asset. Macro signatures are otherwise untouched.)"
|
||||
- "booth.asks.normalize_ask (TRANSITIVE, through `marks._hydrate`, and named because the payload shape depends on it: a MULTI ask normalizes to questions whose `key` matches `^[A-Za-z0-9][A-Za-z0-9._-]{0,60}$`, and a SINGLE-question ask normalizes to exactly one question whose `key` is `None`. Both facts are load-bearing -- the first makes splitting an anchor spec on the first colon unambiguous, the second is why `questions` is a list. Read against booth/asks.py:138-223.)"
|
||||
- "booth.app.FAVICON_HREF (the data-URI icon, carried in the payload rather than copied into embed.js -- a third copy of that string is exactly the multiple-readers-of-one-truth shape the repo's ONE-RESOLVER rule (CLAUDE.md invariant 3 -- NOT this contract's INV-1, which is the untouched-page rule; a cold arm read the two as one label and was right to) exists to stop. base.html's literal copy predates this unit and is out of scope.)"
|
||||
language: "python + javascript"
|
||||
complexity: "medium"
|
||||
estimated_loc: 420
|
||||
confidence: 0.82
|
||||
used_by:
|
||||
- "booth.app.booth_view (the verbatim branch: one read, one substring check, one conditional append -- replacing inject_asks + wrap_verbatim_html entirely)"
|
||||
- "the operator's verbatim reports (4 of 21 live booths ship their own index.html; 2 of those 4 use the placement DSL, so the migration is not hypothetical)"
|
||||
- "report authors (the declared line is the new public API for a booth that wants Booth chrome where it chooses)"
|
||||
touches:
|
||||
- "booth/static/embed.js (NEW -- the mount script and the chrome CSS, one asset. Read ONCE at app startup, never per request; see INV-5.)"
|
||||
- "booth/app.py (DELETE wrap_verbatim_html, _insert_before, _insert_after, _ICON_RE, _HEAD_CLOSE_RE, _HTML_OPEN_RE, _DOCTYPE_RE, _BODY_CLOSE_RE, _HTML_CLOSE_RE, _BACK_CHIP, asks_chip, inject_asks, FAVICON_LINK and the `from booth.inline import` block. ADD EMBED_SRC/EMBED_SCRIPT_TAG, the startup read of embed.js, GET /_booth/embed.js, GET /b/{name}/embed.json, and the rewritten verbatim branch of booth_view.)"
|
||||
- "booth/inline.py (DELETED ENTIRELY -- 119 lines. Nothing else imports it; `scripts/booth` never did, so the stdlib-only CLI surface is untouched. ONE line survives the module: `form_id`, which builds the shared form element id the fragments bind to, moves into booth/app.py beside the route that renders them. It is not placement machinery and dying with the placement engine would take the fragments with it. Seam review, SR-3.)"
|
||||
- "booth/templates/_ask_inline.html (DELETE the `styles()` macro and rewrite the header comment: the fragments are now mounted by embed.js, not substituted by regex, and `No JavaScript` stops being true.)"
|
||||
- "tests/test_booth.py (DELETE the five test_wrap_* tests and test_verbatim_booth_wrapped_with_back_chip -- they test a mechanism this unit removes; the FAVICON_LINK import goes with them)"
|
||||
- "tests/test_asks.py (the inline-placement block, ~L480-590: assertions that server-rendered fragments appear in the page body become assertions about the embed payload. The BEHAVIOUR they encode -- every question reachable, a scattered form still submittable, a typo'd id left alone -- is preserved and re-asserted, half in Python and half in the browser.)"
|
||||
- "tests/test_embed.py (NEW -- the payload, the injection rule, the no-hot-reload guard)"
|
||||
- "tests/test_embed_browser.py (NEW -- Playwright against a real Chromium: the placement algorithm and the form association, neither of which the Python suite can see. SKIPS, never fails, when playwright or the shared browser is unavailable.)"
|
||||
- "pyproject.toml (test extra gains `playwright>=1.60,<1.63` -- the range is the set of releases whose pinned Chromium revision is present in the box-wide /opt/ms-playwright store. Stated explicitly because it is invisible otherwise: 1.63 wants chromium-1243, which is NOT there, and the failure is an opaque `Executable doesn't exist`.)"
|
||||
- "docs/design/information-architecture.md (the `What this deletes` list becomes what this DID delete; the standalone /asks bullet is corrected -- U2 already reduced it to a 308)"
|
||||
- "ROADMAP.md (U3 row struck through; the ordering table gains the two orders this unit states)"
|
||||
assumptions:
|
||||
- "THE OPERATOR ALREADY RULED ON THE SEAM (2026-09-21, recorded in the IA doc): the page declares itself and the Booth mounts into it, via `<script src=\"/_booth/embed.js\" defer></script>`. That ruling ACCEPTS a JavaScript dependency on the verbatim path, which today has none. This contract does not re-open it. What the contract DOES do is state the consequence plainly so it is not discovered later -- see the degradation assumption below."
|
||||
- "THE ONLY REMAINING SERVER-SIDE MUTATION IS A CONDITIONAL APPEND, AND IT NEEDS NO PATTERN AT ALL. Two substring tests (`src=\"/_booth/embed.js\"` and its single-quoted twin), then a concatenation. ⚠ THE BARE PATH WAS THE FIRST DRAFT AND IT FAILED IN THE DANGEROUS DIRECTION: a report that merely MENTIONS the path -- a code sample, a comment, a sentence about this feature, which the Booth's own design reports are the likeliest pages to contain -- would have counted as declaring it, been served untouched, and shown no chrome at all, silently. Requiring `src=` immediately before the path flips the failure direction: an unusual spelling (`src = \"...\"`, an unquoted attribute, a `?v=2` suffix) reads as NOT declared, so a second tag is appended and embed.js mounts once anyway on its `window.__boothEmbed` guard. A missed declaration costs a duplicate tag; a false one costs the operator his chrome. Three of four cold-panel arms found this independently. This is why all six regexes die rather than collapsing to one: content appended AFTER `</html>` is parsed into the body by every browser, so there is nothing to find. Nothing is ever PREPENDED, which is what retires both of wrap_verbatim_html's hard constraints in one stroke -- no doctype can be displaced into quirks mode and no charset meta can be pushed out of the first 1024 bytes, because nothing moves."
|
||||
- "THE FAVICON MOVES FROM A REGEX TO A DOM QUERY. `_ICON_RE` existed to answer `does this page already declare an icon`, against raw text, and three more regexes existed to find a head-ish seam to put one in. embed.js asks `document.querySelector('link[rel~=\"icon\"]')` and appends to `document.head`. That is the same question and the same action, asked of a parsed document instead of a string -- and it is four of the six regexes."
|
||||
- "FRAGMENTS ARE STILL RENDERED BY JINJA, ONLY PLACED BY JAVASCRIPT. The payload carries server-rendered HTML from the EXISTING `_ask_inline.html` macros. Re-implementing the ask form in JavaScript would make two renderers of one truth, which is precisely the shape the repo's ONE-RESOLVER rule (CLAUDE.md invariant 3) was written to stop after the zoom view lost its captions. embed.js does DOM placement and nothing else: it never decides what a mark says, whether it is open, or what order marks come in."
|
||||
- "PLACEMENT IS AN ANCHOR-FILL, NOT A REPLACEMENT, AND THAT IS A DELIBERATE CHANGE FROM TODAY. `_EL_RE` matches an author's opening tag and SUBSTITUTES it, so `<div class=\"ask\" data-booth-ask=\"dfa:logo\"><h3>The one asset that must survive</h3>` loses both the wrapper's class and -- visually -- its framing, leaving the author's heading orphaned and the closing `</div>` stray. That is live today on `dfa-concepts`. embed.js uses `el.insertAdjacentHTML('beforeend', frag)`: the author's element and its contents survive and the fragment lands inside, under the heading. Strictly closer to what the markup says, and it is the behaviour a DOM API gives for free."
|
||||
- "`data-booth-mark` IS CANONICAL; `data-booth-ask` IS A KEPT ALIAS. U2 made an ask one shape of mark and the IA doc names the anchor `data-booth-mark`. But 2 of the 4 live verbatim booths use the `data-booth-ask` spelling, in the operator's own reports, so the selector accepts both -- one extra clause in one selector string. Same for `data-booth-mark-submit` / `data-booth-ask-submit`. Renaming without the alias would break a live report to save nothing."
|
||||
- "THE HTML-COMMENT PLACEHOLDERS ARE DROPPED, NOT PORTED. `<!-- booth:ask stem -->` and `<!-- booth:ask-submit stem -->` have ZERO users across all 21 live booths. Walking comment nodes to keep them would be real complexity bought for nobody, in the unit whose entire point is deletion. A page that used one degrades to the append path -- the ask still renders, at the end -- so the never-invisible guarantee holds even for a caller we do not know about."
|
||||
- "DEGRADATION WITH JAVASCRIPT OFF IS A REAL LOSS AND IT IS NAMED HERE. Today the verbatim path is zero-JS: an ask renders server-side and submits through a plain form. After this unit, no JS means no chrome on the report -- no ask, no way home, no icon. The guarantee that an ask is NEVER INVISIBLE survives in a weaker and still-true form, through surfaces that need no script: the index card carries the open-mark badge, and `/b/<name>/marks` renders every mark server-side. This is the cost of the operator's ruling, stated once so nobody meets it as a surprise."
|
||||
- "EMBED.JS IS READ ONCE AT STARTUP, FOR THE REASON TEMPLATES ARE. Serving it from disk per request would give the service a third staleness rule, and a live asset editable under a running process is exactly what put 19 of 25 booths at 500 on 2026-09-21. One rule in this repo: nothing takes effect until you restart. INV-5 holds the line the same way `test_templates_do_not_hot_reload_from_disk` does."
|
||||
- "THE PAYLOAD ENDPOINT DOES NOT RECORD A VIEW. `booth_view` already calls `record_view` above both early returns (U4), and `.viewed` is a deliberate look. A fetch issued by a script on a page that has ALREADY been recorded would double-count activity and reset the TTL on machinery rather than on the operator -- the same distinction the `.lock` exemption draws in `_newest_mtime`."
|
||||
- "THE READ IS LENIENT AND THE STATUS STAYS 200, copied deliberately from `/b/{name}/marks.json`. A damaged `.marks.json` must not 500 the operator's report; it returns an `error` in the body and embed.js mounts the nav anyway. This is the v0.2.2 lesson and the posture every read path in this service already takes."
|
||||
- "WRAP_MAX_BYTES SURVIVES UNCHANGED, at 8 MiB, with the same raw-FileResponse fallback. The work behind it is now trivial, but the READ is not: the largest live verbatim booth is 280 KB and a pathological one still should not be pulled into memory. A booth over the cap loses its chrome exactly as it does today -- no regression, and the constant keeps its existing test."
|
||||
open_questions:
|
||||
- "Whether `/_booth/embed.js` should eventually carry the gallery page's chrome too, making one embed for both surfaces. Out of scope: the gallery page is server-rendered end to end and has no seam problem to solve."
|
||||
- "Whether a booth should be able to suppress injection entirely (a `.no-embed` dotfile) for a report that wants to be served truly untouched. No live booth wants it; declaring the line and then not using it is already most of the way there. Parked rather than designed."
|
||||
---
|
||||
|
||||
# U3 — the declared embed seam
|
||||
|
||||
## The defect, stated precisely
|
||||
|
||||
A booth that ships its own `index.html` is served verbatim. That is the whole
|
||||
promise of the verbatim path, and the Booth breaks it twice on the way out:
|
||||
|
||||
1. **`wrap_verbatim_html`** searches arbitrary author HTML with six regular
|
||||
expressions — `_ICON_RE`, `_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`,
|
||||
`_BODY_CLOSE_RE`, `_HTML_CLOSE_RE` — to find somewhere to put a favicon and
|
||||
somewhere to put a floating chip, while threading two constraints it cannot
|
||||
verify: never put anything ahead of a leading doctype, and keep the charset
|
||||
meta inside the first 1024 bytes.
|
||||
2. **`booth/inline.py`** matches a placeholder DSL with four more patterns and
|
||||
substitutes rendered HTML into the author's markup by string replacement.
|
||||
|
||||
Ten patterns, applied to documents the Booth did not write, does not parse, and
|
||||
cannot validate. It works. It is also the single most fragile thing in the
|
||||
service, and it is load-bearing for the operator's most important workflow.
|
||||
|
||||
The failure this invites is not a crash. It is a report that renders *slightly*
|
||||
wrong — and there is a live specimen already. `dfa-concepts/index.html` writes:
|
||||
|
||||
```html
|
||||
<div class="ask" data-booth-ask="dfa:logo"><h3>The one asset that must survive</h3>
|
||||
```
|
||||
|
||||
`_EL_RE` matches the opening `<div …>` and replaces it. The author's `.ask`
|
||||
wrapper class is gone, the `<h3>` is orphaned, and the `</div>` further down is
|
||||
stray. Nobody filed a bug, because a page that is 95% right does not look broken.
|
||||
|
||||
## The seam
|
||||
|
||||
Operator ruling, 2026-09-21. A report carries one line:
|
||||
|
||||
```html
|
||||
<script src="/_booth/embed.js" defer></script>
|
||||
```
|
||||
|
||||
and the Booth mounts its chrome through real DOM APIs. Three consequences, and
|
||||
the third is the one worth stating out loud:
|
||||
|
||||
- **A page that declares the line is served with nothing added to it.** Not
|
||||
"one small injection" — nothing. The body is what the author wrote.
|
||||
- **A page that does not declare it gets that one line appended at the end.**
|
||||
A substring test and a concatenation; no pattern, nothing prepended, no
|
||||
constraint to thread.
|
||||
- **Both of `wrap_verbatim_html`'s hard constraints stop existing** rather than
|
||||
being satisfied more carefully. You cannot displace a doctype you never move,
|
||||
and you cannot push a charset meta out of the detection window by appending.
|
||||
|
||||
## What crosses the seam
|
||||
|
||||
`GET /b/{name}/embed.json` — server-rendered fragments, and nothing embed.js has
|
||||
to decide for itself:
|
||||
|
||||
```json
|
||||
{
|
||||
"booth": "dfa-concepts",
|
||||
"home": "/",
|
||||
"favicon": "data:image/svg+xml,…",
|
||||
"open": ["dfa"],
|
||||
"marks": [
|
||||
{
|
||||
"id": "dfa",
|
||||
"error": null,
|
||||
"whole": "<div class=\"bk-ask\" …>",
|
||||
"submit": "<div class=\"bk-ask\" …>",
|
||||
"questions": [
|
||||
{"key": "logo", "html": "<div …>"},
|
||||
{"key": "display", "html": "<div …>"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Every HTML string comes from the `_ask_inline.html` macros that render the same
|
||||
fragments today. `open` is `open_marks(picks)` — computed once, server-side, and
|
||||
never re-derived in JavaScript.
|
||||
|
||||
**A payload whose `.marks.json` could not be read has a stated shape**, because
|
||||
an arm asked and the first draft did not say: `marks` is `[]`, `open` is `[]`,
|
||||
`booth` / `home` / `favicon` are present as normal, and top-level `error` and
|
||||
`detail` carry the verdict. Status stays 200, copied from `/marks.json` — a
|
||||
pinned status code is a promise to remote clients, and the information goes in
|
||||
the body instead. The nav mounts; nothing else does. A per-mark `error` is a
|
||||
different thing: that is ONE unreadable pick inside a file that read fine.
|
||||
|
||||
**`questions` is a LIST, and `key` may be `null`.** This is not a style choice.
|
||||
A single-question pick normalizes to `questions: [{"key": None, …}]`
|
||||
(`asks.normalize_ask`), so a JSON object keyed by question key would serialize
|
||||
that key as the string `"null"` — inventing a name that collides with a real key
|
||||
and that JavaScript would have to translate back. A list also carries declaration
|
||||
order in the format itself rather than leaning on object-key insertion order.
|
||||
A `null` key matches no anchor spec, which is correct and is exactly what
|
||||
`place` does today: a single-question pick is addressed as a whole or not at all.
|
||||
Found by the seam review; see SR-2.
|
||||
|
||||
## How the script learns which booth it is on
|
||||
|
||||
**The find of the contract-review round, three arms independently**, and the one
|
||||
gap that made the rest unimplementable as first written: the declared line is
|
||||
byte-identical for every booth, the payload endpoint needs `{name}`, and the
|
||||
name arrives *inside* the response the fetch needs the name to make.
|
||||
|
||||
The rule, stated once:
|
||||
|
||||
> **The booth name is the second path segment of the page's own address.** A
|
||||
> verbatim report is served at `/b/<name>/`, so `embed.js` reads
|
||||
> `location.pathname`, takes segment 2, and `decodeURIComponent`s it. A page
|
||||
> whose address is not `/b/<name>/...` mounts nothing and returns quietly.
|
||||
>
|
||||
> **Override:** a `<script data-booth="...">` attribute wins if present. The
|
||||
> Booth never writes one — the appended tag is exactly the documented line — but
|
||||
> an author embedding a report elsewhere needs a way to say so, and one optional
|
||||
> attribute is cheaper than a second endpoint.
|
||||
|
||||
This makes the URL grammar part of the public seam, which is the honest reading:
|
||||
an author who writes the line is relying on where the Booth serves them, and
|
||||
that should be written down rather than inferred.
|
||||
|
||||
## The placement algorithm
|
||||
|
||||
The same algorithm `inject_asks` runs today, expressed against a DOM instead of
|
||||
a string. It is written out here because it is the part that moves languages,
|
||||
and a reviewer has to be able to check the two against each other.
|
||||
|
||||
```
|
||||
placed : Map<markId, Set<key | WHOLE>>
|
||||
submitted : Set<markId>
|
||||
|
||||
1. every element matching
|
||||
[data-booth-mark], [data-booth-ask] -- in document order
|
||||
spec -> (id, key?) by splitting on the first ":"
|
||||
mark unknown -> leave the element ALONE (a typo stays visible)
|
||||
key absent -> mount whole; placed[id] += WHOLE; submitted += id
|
||||
key names no question -> leave the element ALONE
|
||||
key present -> mount question; placed[id] += key
|
||||
|
||||
2. every element matching
|
||||
[data-booth-mark-submit], [data-booth-ask-submit]
|
||||
mark unknown -> leave alone
|
||||
otherwise -> mount submit; placed[id] ||= {}; submitted += id
|
||||
|
||||
3. tail, appended to <body> in payload order. THE ARROWS ARE EXCLUSIVE, NOT
|
||||
CUMULATIVE -- first match wins and the mark is done. An arm read them as
|
||||
four independent tests, under which one unplaced mark would mount its whole
|
||||
form AND every question AND a submit block; the notation allowed it and the
|
||||
prose did not forbid it:
|
||||
|
||||
if id not in placed: append whole; NEXT MARK
|
||||
elif mark.error: append nothing; NEXT MARK
|
||||
else:
|
||||
if WHOLE not in placed[id]: append every question not in placed[id]
|
||||
if id not in submitted: append submit (scattered, still submittable)
|
||||
|
||||
4. re-associate: for every control carrying form="…", remove and re-set the
|
||||
attribute, so its form owner is resolved after all fragments are in place.
|
||||
|
||||
5. chip: if `open` is non-empty, link it to the FIRST element in document order
|
||||
whose id is EXACTLY `bk-ask-<open[0]>` or begins `bk-ask-<open[0]>-`.
|
||||
A bare prefix match would send the chip to `bk-ask-batch2-r1` for the mark
|
||||
`batch`, or to an author's own element -- flagged by a cold arm, and the
|
||||
trailing hyphen is what rules it out.
|
||||
|
||||
Two more rules the first draft left to the selector rather than stating:
|
||||
|
||||
- **An element carrying BOTH `data-booth-mark` and `data-booth-ask` uses the
|
||||
canonical one.** The alias exists for reports written before the rename, not
|
||||
to double a mount.
|
||||
- **A submit anchor's spec is its stem; any `:key` on it is IGNORED.** There is
|
||||
no per-question submit block — one pick has one `<form>`, which is the whole
|
||||
reason the `form=` binding exists.
|
||||
```
|
||||
|
||||
**`mount` is `el.insertAdjacentHTML('beforeend', frag)`** — the anchor element
|
||||
and its existing contents survive; the fragment lands inside. See the assumption
|
||||
on anchor-fill for why this is a deliberate change and not an accident.
|
||||
|
||||
**Step 4 is measured, not assumed.** Chromium 151 resolves a control's form owner
|
||||
correctly even when the control is inserted before its `<form>`: a probe run
|
||||
2026-09-22 (N=3 per condition, with a form-first positive control and a
|
||||
points-at-nothing negative control) returned `F, F, F` for control-first and
|
||||
`null, null, null` for the negative. So the pass is *not* needed in Chromium.
|
||||
It is three lines, it costs nothing, and the sensitivity floor of that probe is
|
||||
**one engine** — the operator's own browser was not measured. The failure it
|
||||
guards against is a form the operator fills in whose controls reach no form,
|
||||
so the button does nothing.
|
||||
|
||||
**Step 5 deletes an element.** Today `inject_asks` injects `<a id="bk-ask-<id>-top">`
|
||||
before the first fragment of each pick so the chip has somewhere to jump. The
|
||||
fragments already carry ids; document order in a live DOM is directly queryable;
|
||||
the extra anchor is not needed.
|
||||
|
||||
## Invariants
|
||||
|
||||
Each is falsifiable by a change that a test must catch going red. The
|
||||
*Falsifiable:* line names that change — not a test that merely mentions the
|
||||
invariant. (Five of seven U4 falsifiers were vacuous; see
|
||||
`persistent-memory.d/2026-09-22-vacuous-falsifiers.md`.)
|
||||
|
||||
**INV-1 — A page that declares the seam is served BYTE FOR BYTE.**
|
||||
The response body for a verbatim booth whose `index.html` contains
|
||||
`src="/_booth/embed.js"` (either quote style) is exactly the bytes on disk.
|
||||
⚠ **Bytes, not text, and that is a correction.** The first implementation read
|
||||
with `read_text()`, which opens in universal-newline mode: a CRLF report came
|
||||
back LF, and `errors="replace"` turned any non-UTF-8 byte into U+FFFD. A
|
||||
declaring page was NOT served as its author wrote it — the headline promise —
|
||||
and the test could not see it, because its fixture was LF-only ASCII. The file
|
||||
is decoded only to ask whether it declares the seam; what goes on the wire is
|
||||
the original bytes. A page that only mentions the path is NOT declaring it — see the
|
||||
conditional-append assumption for which way that has to fail.
|
||||
*Falsifiable:* append anything — a chip, a comment, a newline — to the declaring
|
||||
branch's response and `test_declaring_page_is_served_untouched` fails on a
|
||||
whole-body equality, not on a substring absence.
|
||||
|
||||
**INV-2 — A page that does not declare the seam, AND IS UNDER `WRAP_MAX_BYTES`,
|
||||
is mutated exactly once, at the end.** The response is the source BYTES plus
|
||||
`EMBED_SCRIPT_TAG`'s bytes and nothing else, with the source a byte-exact
|
||||
prefix of it.
|
||||
⚠ **The size cap is an explicit exception, not an oversight** — two cold arms
|
||||
read the invariant's universal wording against the raw-`FileResponse`
|
||||
assumption and found them prescribing different responses for the same page. An
|
||||
over-cap page is mutated ZERO times and loses its chrome, exactly as it did
|
||||
before this unit.
|
||||
*Falsifiable:* insert the tag before `</head>` instead of appending, or add the
|
||||
favicon link back, and `test_undeclared_page_gains_only_the_tag` fails the
|
||||
prefix assertion. The exception has its own test,
|
||||
`test_an_oversize_verbatim_page_is_served_raw`, which fails if the append starts
|
||||
firing above the cap.
|
||||
|
||||
**INV-3 — No regular expression is applied to author HTML.**
|
||||
The verbatim branch of `booth_view` performs two `in` tests and one `+`.
|
||||
⚠ **The first draft of this falsifier was VACUOUS and three arms caught it.**
|
||||
It name-matched the six deleted patterns, so reintroducing the same regex under
|
||||
a new name — `_TAIL_RE`, applied in the verbatim branch — left the test green,
|
||||
on this contract's central promise. Worse, this repo's own vacuity pass missed
|
||||
it, because the mutation it tried was the named one: **a vacuity pass is only as
|
||||
good as the mutation it picks, and picking the one the contract names is how it
|
||||
agrees with itself.**
|
||||
*Falsifiable:* `test_no_regex_touches_author_html` walks the AST of
|
||||
`booth/app.py` and asserts the module performs **exactly one** regex operation
|
||||
— `ask_form_id`'s `re.sub` over a mark id, which is not a page — plus that
|
||||
`booth/inline.py` does not exist. Any regex anywhere in the module, under any
|
||||
name, fails it. Verified by mutation: a renamed `_TAIL_RE.sub` in
|
||||
`embed_verbatim` goes red, and the unmutated control stays green.
|
||||
|
||||
**INV-4 — The payload is the only source of what a mark says.**
|
||||
embed.js never decides openness, order, or content. `open` comes from
|
||||
`open_marks`; `marks` order is `marks_for` order; `questions` order is
|
||||
declaration order.
|
||||
*Falsifiable:* the claim ranges over three things and so does the check.
|
||||
**Openness:** have embed.js derive open marks from a `bk-done` class and
|
||||
`test_the_chip_count_comes_from_the_server` fails on a half-answered pick, which
|
||||
`open_marks` calls open and the rendered state does not. **Order:** reverse the
|
||||
tail iteration and `test_the_tail_follows_payload_order` fails. **Content:** the
|
||||
fragments are strings the page never authors, which
|
||||
`test_every_piece_the_author_can_place_is_offered` pins on the server side.
|
||||
|
||||
**INV-5 — `/_booth/embed.js` is read once at startup.**
|
||||
*Falsifiable:* change the route to `read_text()` per request and
|
||||
`test_embed_js_does_not_hot_reload_from_disk` fails — it mutates the file on
|
||||
disk after the app is built and asserts the served body is unchanged.
|
||||
|
||||
**INV-6 — Every ordered collection this unit renders has a stated rule.**
|
||||
Anchors are visited in **document order** (`querySelectorAll`). The tail is
|
||||
appended in **payload order**, which is `(created, id)` — the rule `marks_for`
|
||||
and `hold_read` both sort by, stated here as the rule rather than as one
|
||||
function's name. Questions
|
||||
within a mark are in **declaration order**. The chip targets the **first element
|
||||
in document order** whose id starts with the open mark's prefix.
|
||||
*Falsifiable:* sort the tail by anything else — id, key, insertion — and
|
||||
`test_tail_order_is_payload_order` fails against a fixture whose creation order
|
||||
and id order disagree.
|
||||
|
||||
**INV-7 — Every question of every READABLE pick reaches the document, on a
|
||||
page that runs the script.** Either placed at an anchor or appended, and every
|
||||
pick with a placed question has a submit block.
|
||||
⚠ **Two qualifiers, both added because arms read the first wording literally and
|
||||
were right.** *Readable*: a pick carrying `error` has no questions to place —
|
||||
`marks._hydrate` gives it an empty list — so the tail mounts its broken-ask box
|
||||
and stops, and an unqualified "every pick" would have demanded placement the
|
||||
algorithm forbids in exactly the damaged-data case the leniency posture exists
|
||||
for. *Reaches the document*, not "is visible": the Booth cannot police an author
|
||||
who hides their own anchor, and a guarantee that claimed to would be unenforceable
|
||||
rather than strict.
|
||||
*Falsifiable:* drop the "append the questions the author did not place" branch
|
||||
and `test_partially_marked_page_still_shows_every_question` fails in the browser
|
||||
with 2 of 4 radio groups present.
|
||||
|
||||
## Out of scope (deferred or never)
|
||||
|
||||
Named so a reviewer does not read them as drift.
|
||||
|
||||
- **The gallery page's chrome.** Only a booth's own `index.html` is served
|
||||
verbatim; every other surface is server-rendered end to end and has no seam
|
||||
problem. `/_booth/embed.js` is not loaded there and is not meant to be.
|
||||
- **Re-rendering an ask in JavaScript.** The payload carries server-rendered
|
||||
HTML and embed.js places it. A JS renderer would be a second renderer of one
|
||||
truth — the bug the repo's one-resolver rule exists to stop.
|
||||
- **A no-JavaScript fallback on the verbatim path.** The operator's 2026-09-21
|
||||
ruling accepts the script dependency. The never-invisible guarantee degrades
|
||||
to surfaces that need no script (the index card's badge, `/b/<name>/marks`),
|
||||
and that is the stated cost, not an oversight to be fixed here.
|
||||
- **The HTML-comment placeholders** `<!-- booth:ask … -->`. Zero users across
|
||||
all 21 live booths; dropped rather than ported. A page that used one falls
|
||||
back to the append path, so its ask still renders.
|
||||
- **`_ask_inline.html`'s dead `standalone=False` macro parameter.** No caller
|
||||
has passed `True` since U2 turned the standalone asks page into a 308.
|
||||
Deleting it is tidy-up and changes a macro signature for no behavioural gain.
|
||||
- **`base.html`'s literal duplicate of the favicon data URI.** It predates this
|
||||
unit. The payload reads `FAVICON_HREF`, so this unit adds no third copy; it
|
||||
does not remove the second.
|
||||
- **`WRAP_MAX_BYTES` and its raw-serve fallback.** Unchanged at 8 MiB. A booth
|
||||
over the cap loses its chrome exactly as it did before — no regression, and
|
||||
the constant keeps its existing test.
|
||||
- **`GET /b/<name>/asks`.** Already a 308 into `/marks` since U2. Left alone:
|
||||
the URL is in the operator's history and in landed reports.
|
||||
- **Pushing, and the version bump tier.** Minor needs the operator's approval.
|
||||
|
||||
## Slices
|
||||
|
||||
| # | slice | red→green on |
|
||||
|---|---|---|
|
||||
| 1 | `GET /b/{name}/embed.json` — payload shape, order, leniency, no view recorded | payload tests; existing 410 stay green |
|
||||
| 2 | `GET /_booth/embed.js` — served from a startup read, ETag, no hot reload | INV-5 |
|
||||
| 3 | the verbatim branch rewritten; `inject_asks` and `wrap_verbatim_html` deleted | INV-1, INV-2, INV-3 |
|
||||
| 4 | `booth/static/embed.js` — nav, favicon, styles, no marks yet | browser: chip present, icon set, declaring page untouched |
|
||||
| 5 | placement: anchors, tail, submit, re-association | browser: INV-4, INV-6, INV-7; the live `dfa-concepts` and `sindra-voice-1` shapes as fixtures |
|
||||
| 6 | delete `inline.py`; retire the six tests that test the deleted mechanism; docs | suite green, IA doc and ROADMAP updated |
|
||||
|
||||
## Seam review
|
||||
|
||||
The sibling-aware pass, run in-session against the real module surfaces rather
|
||||
than against the sibling contracts' prose. `/heid-contract-review` is
|
||||
artifact-only by design and structurally cannot see `booth/marks.py`, so this is
|
||||
the only gate that can check what the contract borrows from it.
|
||||
|
||||
| # | finding | disposition |
|
||||
|---|---|---|
|
||||
| **SR-1** | The order invariant named `marks_for`'s ordering. The route actually reads through `hold_read` — one read answering both "what is here" and "can it be read", per the TOCTOU lesson — and only falls back to `marks_for` on the error path. Both sort `(created, id)`, so the contract was not wrong, but it named a function where it meant a rule. | **Amended.** INV-6 states the rule. The route's reader is named in the payload section. |
|
||||
| **SR-2** | **The payload shape was wrong.** `questions` as a JSON object keyed by question key breaks on a single-question pick, whose only question has `key: None` (`asks.normalize_ask`, the `multi: False` branch) — `json.dumps` writes that key as the string `"null"`. Every one-question ask in the fleet hits it, including the live `sindra-voice-1`. | **Scope fix.** `questions` is a list of `{key, html}`; `key` is nullable; declaration order is carried by the format. `booth.asks.normalize_ask` added to `depends_on`. |
|
||||
| **SR-3** | `inline.form_id` was inside the module the contract deletes entirely, but it is not placement machinery — it builds the shared `<form>` id the question fragments bind to with `form=`. Deleting the module as written would delete the fragments' ability to submit. | **Scope miss.** `form_id` moves to `booth/app.py`; `touches` says so. |
|
||||
| **SR-4** | A FLAG mark's id is literally `flag:<target>` (`marks.flag_id`) — it contains the separator the anchor spec splits on. It never reaches the payload only because the payload filters `shape == "pick"`, and pick ids are `valid_stem`-checked (no colon). | **No change, stated.** The filter is load-bearing, not incidental; a later widening of the payload to all shapes would break the split rule silently. |
|
||||
| **SR-5** | `_ask_inline.html`'s `question(a, q, form_id, name_url, standalone=False)` has had no caller passing `standalone=True` since the standalone asks page became a 308 in U2. Dead parameter on a macro this unit edits. | **Out of scope, noted.** Deleting it is tidy-up, not this unit's work, and it changes a macro signature for no behavioural gain. |
|
||||
|
||||
## Contract review — the cold panel
|
||||
|
||||
`/heid-contract-review`, four arms, dispatched `01M351WKV666D681SSRNY7D7X6`.
|
||||
Triaged per the cross-frontier discipline: adopted on merits, not on authority.
|
||||
|
||||
| # | finding | arms | disposition |
|
||||
|---|---|---|---|
|
||||
| **CR-1** | **The seam never tells `embed.js` which booth it is on.** The declared line is byte-identical for every booth, the payload endpoint needs `{name}`, and the name arrives inside the response the fetch needs it to make. Every other section depends on this unstated hop. | 3 of 4, independently | **Genuine add, and the round's headline.** The code already derived it from `location.pathname`; the CONTRACT did not say so, which makes a "public API" whose discovery mechanism is unspecified not fully one. New section: *How the script learns which booth it is on*. No code change. |
|
||||
| **CR-2** | **INV-3's falsifier was vacuous** — it name-matched the six deleted patterns, so a renamed regex applied to the page body kept it green, on this contract's central promise. | 3 of 4 | **Genuine add, and a CODE-side fix.** The test now asserts `booth/app.py` performs exactly one regex operation anywhere in the module. Verified by mutation in both directions. The lesson is sharper than the fix: **this repo's own vacuity pass missed it because it tried the mutation the contract named** — a pass that picks the named mutation agrees with itself. |
|
||||
| **CR-3** | **Declaration by bare substring fails in the dangerous direction.** A report that merely mentions `/_booth/embed.js` — a code sample, a comment — counted as declaring it and was served with no chrome at all, silently. | 3 of 4 | **Genuine add, CODE-side.** Detection now requires `src="…"` (either quote style), which fails toward a harmless duplicate tag instead. New test covers prose, comment and `?v=2` spellings. |
|
||||
| **CR-4** | **INV-2 and the size cap prescribe different responses** for an over-cap non-declaring page, and neither the invariant's wording nor a named falsifier carved the exception. | 2 of 4 | **Genuine add.** INV-2 now states the cap as an explicit exception and names the test that holds it. Code and test were already right. |
|
||||
| **CR-5** | **The tail's four arrows read as independent tests**, under which one unplaced mark mounts its whole form AND every question AND a submit block. | 1 | **Genuine add.** The notation allowed it and the prose did not forbid it. The block is now explicit if/elif/else. Code was already exclusive. |
|
||||
| **CR-6** | **INV-7 quantified over picks the algorithm filters** (errored picks) and over "visible", which placement cannot guarantee. | 2 of 4 | **Genuine add, wording.** INV-7 is now scoped to READABLE picks and claims *reaches the document*, not *is visible*. |
|
||||
| **CR-7** | The chip's prefix rule can select `bk-ask-batch2-r1` for mark `batch`, or an author's own element. | 1 | **Sharpening.** The code always matched exactly-or-hyphen; the contract said "starts with". Wording fixed, and `test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix` now holds it. |
|
||||
| **CR-8** | Precedence undefined when one element carries both attribute spellings; submit-anchor key handling unstated. | 1 | **Sharpening.** Both stated; `test_the_canonical_attribute_wins_when_both_are_present` added. |
|
||||
| **CR-9** | The damaged-`.marks.json` payload shape was never stated — per-mark `error` was the only error shown. | 1 | **Genuine add, wording.** Stated in *What crosses the seam*. Test already existed. |
|
||||
| **CR-10** | "INV-1" names two different obligations — this contract's untouched-page rule, and the repo's one-resolver rule the assumptions cite. | 1 | **Genuine add, wording.** The assumptions now name CLAUDE.md invariant 3 explicitly. A real collision: the local falsifier goes red on an added newline and stays green if embed.js becomes a second renderer. |
|
||||
| **CR-11** | INV-4's falsifier covered openness while the invariant claimed openness, order AND content. | 1 | **Sharpening.** The falsifier now names a test per clause. |
|
||||
| **CR-12** | `html.questions` keyed by question name vs the top-level `questions` list — which is authoritative? And INV-4 naming `marks_for`'s order while INV-6 fixed `(created, id)`. | 2 | **Settled before the reply landed.** The in-session seam review collapsed both (SR-1, SR-2) while the panel was in flight. Independent convergence on the same two spots — worth recording, not re-fixing. |
|
||||
|
||||
**One arm's finding not adopted**, and the reason: that a question mounted into
|
||||
an author-hidden anchor is still invisible. True, and out of reach — the Booth
|
||||
cannot police an author hiding their own markup. Answered by narrowing INV-7's
|
||||
claim rather than by chasing actual visibility (CR-6).
|
||||
|
||||
**Methodology note the panel raised on its own**, relayed by heid: 5 of 8 arms
|
||||
across two unrelated callers the same evening independently proposed promoting
|
||||
the end-to-end seam-walk from a conditional deliverable to a mandatory one.
|
||||
CR-1 is a direct product of that exercise. Recorded here as evidence; the skill
|
||||
change is the operator's call, not this repo's.
|
||||
|
||||
## Bug hunt — the cold panel
|
||||
|
||||
`/heid-bug-hunt`, four arms, artifact-only over the merge-base diff, dispatched
|
||||
`01M352TPCSN52G6NGJ07T5WSGY`. ⚠ **The snapshot predates the contract-review
|
||||
fixes**, so two of its findings were already closed when the reply landed; the
|
||||
arms flagged the staleness themselves.
|
||||
|
||||
| # | finding | arms | disposition |
|
||||
|---|---|---|---|
|
||||
| **BH-1** | **A declaring page was NOT served as written.** `read_text()` opens in universal-newline mode, so a CRLF report came back LF, and `errors="replace"` replaced any non-UTF-8 byte. The headline promise, broken by the read itself — and invisible to a test whose fixture is LF-only ASCII. | 1 | **Genuine add, and the best finding of the round.** The verbatim branch reads and serves BYTES; the decoded copy answers only "does it declare?". INV-1 and INV-2 now state the byte-level promise, with a CRLF-plus-invalid-byte fixture. |
|
||||
| **BH-2** | **A submit anchor inside the author's own `<form>` loses ours** — the HTML parser drops a nested form outright. Every control's `form=` then points at nothing, and the code recorded the pick as submitted so the tail added no fallback. The operator fills it in and the button does nothing. | 1 | **Genuine add.** A submit anchor counts as submitted only if the form actually survived (`hasForm`); otherwise the tail supplies one at body level, where no form encloses it. |
|
||||
| **BH-3** | **A broken pick's diagnostic never rendered from a submit-only anchor.** An errored pick's `submit` is empty; mounting that and marking it placed made the tail skip it, so the "broken ask" box vanished from the one surface built to show it. | 3 of 4 | **Genuine add.** A submit anchor for an errored pick is left alone, exactly as an anchor naming no mark is, and the tail mounts the diagnostic. |
|
||||
| **BH-4** | **An author's own element can hijack the chip.** `<section id="bk-ask-winner-background">` satisfies any id-prefix rule — the hyphen boundary from CR-7 included. | 4 of 4 | **Genuine add, and it supersedes CR-7's fix.** The chip now searches only the elements THIS SCRIPT MOUNTED, which is the identity the deleted `bk-ask-<id>-top` anchor used to guarantee, and takes the earliest of those by `compareDocumentPosition`. |
|
||||
| **BH-5** | **No error boundary around fragment rendering.** A `.marks.json` that is well-formed JSON with a wrong-shaped `answer` hydrates with no error and then raises in the macro. | 1, `needs-repro` | **Genuine add — reproduced before building for it.** `_safe_fragments` returns a per-mark error record, the same leniency `_hydrate_safe` applies one layer down. ⚠ **The gallery and marks pages still 500 on it, and that is PRE-EXISTING** — measured at `42ea67f`. Out of scope here and recorded rather than quietly widened: `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`. |
|
||||
| **BH-6** | Prototype pollution in the placement maps (`toString` as a mark id, `constructor` as a question key). | 1 | **Already fixed this round** as CR-13, from the code-review panel. Two panels, two lenses, the same defect independently — the strongest signal of the evening that the lenses are not redundant. |
|
||||
| **BH-7** | Bare-substring declaration suppresses the chrome. | 4 of 4 | **Already fixed** as CR-3, before the reply landed. |
|
||||
|
||||
**One correction the panel made to this repo's own prose, adopted:** several
|
||||
comments claimed a multi-question pick POSTs a 400 unless every question is
|
||||
answered. It does not — `test_empty_submission_is_refused_with_400` refuses a
|
||||
WHOLLY EMPTY submission, and a partial answer is accepted and recorded on
|
||||
purpose. The real reason an unplaced question must still be appended is simpler
|
||||
and was being obscured: **a question that never reaches the page cannot be
|
||||
answered at all.** Fixed in `embed.js`, the browser tests and this contract.
|
||||
|
||||
**Not adopted:** the bundle's framing called the service Flask. It is FastAPI;
|
||||
the arm noticed and declined to reason from it, which is the right handling.
|
||||
|
||||
## Vacuity pass — final
|
||||
|
||||
21 mutations, each drawn from an invariant's CLAIM rather than its falsifier's
|
||||
example, each run against its named test, plus an unmutated control run.
|
||||
**21/21 caught, control green.**
|
||||
|
||||
The pass earned its place three times over and none of them was the first run:
|
||||
|
||||
1. It reported **7/7** before the contract panel, which then showed INV-3 was
|
||||
vacuous — because the mutation applied was the one the contract named.
|
||||
2. Re-run **against that fix**, it found the fix's own hole (an aliased
|
||||
`import re as _r`).
|
||||
3. Re-run after the bug-hunt fixes, it reported seven **MUTATION-MISS** rows —
|
||||
its loud-failure mode, firing correctly because the fixes had moved the code
|
||||
out from under stale mutations — and then one genuine **VACUOUS**: the
|
||||
sibling-mark chip test had its fixture arranged so the right answer was also
|
||||
the first answer. Rewritten so the sibling comes first, which is the only
|
||||
arrangement that can tell the two implementations apart.
|
||||
@@ -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,405 @@
|
||||
---
|
||||
contract_version: "1.0"
|
||||
module: "booth.manifest"
|
||||
purpose: "A booth that says what it IS and who posted it. Today the index card shows a name, an item count and a countdown -- nothing about provenance or purpose -- so an agent that wants the operator to look at something has no way to make the booth say so, and posts a URL to the link board instead. That is job 5 (`Announce`), the job nobody named, and its absence is the measured cause of 145 dead link rows (69% of the board pointing at booths that no longer exist). This unit gives job 5 a home: each booth carries `.booth.json` -- `{handle, title, why, created}`, written by the CLI from `$ALTHING_HANDLE` -- and the index card and the booth page header render it. Enforcing the link rule WITHOUT giving job 5 a home first just makes it homeless; this is the home."
|
||||
depends_on:
|
||||
- "booth.items (the dotfile skip in `booth_items` -- `.booth.json` is excluded from tiles, counts and zips by the EXISTING `p.name.startswith('.')` rule at items.py:182, exactly as `.marks.json` is. No new exclusion rule is added or needed. Verified, not assumed: `test_a_manifest_is_not_an_item` asserts it.)"
|
||||
- "booth.marks (the `_write_raw` shape only -- temp file + os.replace, per CLAUDE.md invariant 5. Copied as a pattern, NOT imported: manifest.py must not depend on marks.py, because the CLI imports each module on its own.)"
|
||||
language: "python"
|
||||
complexity: "low"
|
||||
estimated_loc: 150
|
||||
confidence: 0.85
|
||||
used_by:
|
||||
- "booth.app.list_booths (the index card gains `manifest` -- one file read per booth, alongside the `marks_for` read already there)"
|
||||
- "booth.app.booth_view (the booth page header gains the same provenance line; a booth URL handed to the operator lands HERE, not on the index, and job 5 is literally 'operator, look at this')"
|
||||
- "booth.app.upload (a pickup booth announces itself as the Booth's own)"
|
||||
- "scripts/booth (`new` and `add` gain `--why` / `--title`; `link` announces the standing board)"
|
||||
touches:
|
||||
- "booth/manifest.py (new -- the record, the write, the lenient read)"
|
||||
- "booth/app.py (list_booths gains one key; booth_view gains one key; the /upload path writes a manifest. It also adds MANIFEST_FILE to the `used` dedupe set -- CONSISTENCY, not a fix: SR-1 established the collision is unreachable because `safe_upload_name` strips leading dots, which is equally true of the `UPLOAD_MARKER` entry that has sat in that set since before this unit.)"
|
||||
- "booth/templates/_provenance.html (new -- the provenance macro, defined ONCE and called from both index lanes and the booth header. Not in the first draft of this inventory: the implementation added the partial rather than repeating the four-state conditional three times, which is SR-6 plus the blurtoggle lesson, and the inventory lagged the decision.)"
|
||||
- "booth/templates/index.html (the provenance line on both lanes' cards -- kept AND ephemeral, or the kept lane silently keeps the old defect)"
|
||||
- "booth/templates/booth.html (the provenance line in the boothhead, and the h1 renders `title` with the directory name beside it)"
|
||||
- "booth/templates/base.html (the .prov-* CSS)"
|
||||
- "scripts/booth (`new` / `add` flag parse; `link` board announcement; usage string; the header doc block)"
|
||||
- "tests/test_manifest.py (new)"
|
||||
- "tests/test_marks.py (test_stdlib_only's parametrize list gains `manifest`)"
|
||||
assumptions:
|
||||
- "THE MANIFEST IS A DOTFILE, and that is the whole integration story. `booth_items` skips `name.startswith('.')` (items.py:182), `zip_booth` skips it (app.py:351), and the legacy ask scan skips it (marks.py:656). So `.booth.json` costs nothing in item counts, galleries, zips or migration, and needs no new exclusion anywhere. This is the same reason `.marks.json` needed none. Settled -- do not re-derive it."
|
||||
- "WRITING A MANIFEST IS ACTIVITY. `.booth.json` is a dotfile but NOT a `.lock` dotfile, so `_newest_mtime` counts it (app.py:192 excludes only `.<name>.lock`). Creating or re-announcing a booth resets its TTL, which is correct: both are somebody touching it. The lock exemption exists for machinery that a READ path creates; this is a deliberate write."
|
||||
- "THE READ IS LENIENT AND THE FAILURE IS VISIBLE. `list_booths` reads every booth on every index load, so a manifest that cannot be parsed must never raise -- that is the v0.2.2 lesson, learned when a poisoned `.marks.json` returned 500 for `/` and `/healthz` across all 25 booths. `read_manifest` returns None for absent and a `Manifest` carrying `error` for damaged, and the card distinguishes them (`unannounced` vs `unreadable`). Silently treating damaged as absent would hide the one case somebody has to fix."
|
||||
- "THE WRITE IS ATOMIC (CLAUDE.md invariant 5, NOT this unit's INV-5). Temp file + os.replace onto a name no other writer derives, because the CLI writes it in one process while the browser reads it in another -- and because two `booth add` calls on one booth would otherwise share a scratch name, which the atomic-write promise says nothing about: it promises readers never see a partial file, not that writers never race. The pattern is copied from `marks._write_raw` rather than imported: `scripts/booth` imports each module directly under the system python3, and a cross-import between two stdlib-only modules is a second way for INV-1 to break."
|
||||
- "`booth/manifest.py` IS STDLIB-ONLY and joins the CLAUDE.md invariant 1 list. `scripts/booth` imports it through a `python3 -c` heredoc with no venv, exactly as it imports `marks`, `asks` and `links`. `test_stdlib_only` is parametrized and gains `manifest`; that test is the only thing standing between a casual third-party import and `booth new` breaking on every fleet host."
|
||||
- "A MISSING MANIFEST IS NORMAL, NOT AN ERROR. All 26 live booths have none, and `rsync -a ./out/ nh3-dev:booth-data/my-run/` -- the documented path for every host that is not nh3-dev -- never runs the CLI at all, so unannounced booths keep arriving after this lands. The card marks them quietly and nothing refuses to render, expire, zip or sweep."
|
||||
- "THE BOOTH ANNOUNCES ITS OWN BOOTHS rather than exempting them. A pickup booth and the standing link board are created BY the service, so they are written with `handle: booth` -- which is true, not manufactured. The alternative was a pile of exemptions from the unannounced marker; this way there is one rule (a booth with no manifest is unannounced) and no special cases. `handle` therefore names an agent handle OR the service, and the field's docstring says so."
|
||||
- "NOTHING NEW IS ORDERED, so CLAUDE.md invariant 6 (every ordered collection has a stated, deterministic rule) does not bind here -- there is no new collection for it to bind to. The manifest is one flat record per booth. The index keeps its stated rule -- kept lane first, then ephemeral newest-first by `_newest_mtime` -- and U5 does NOT add a second ordering keyed on `created` (operator, 2026-09-22). A what-landed feed ordered by announcement time is a genuinely different surface: it needs its own stated rule, it competes with the existing order for what 'the third one' means, and it has nothing to sort the 26 manifest-less booths by. Parked for v1.1."
|
||||
open_questions:
|
||||
- "Whether `why` should also reach the zip manifest or a `booth ls` column. Both are one-liners over the same record and neither is on the v1 path; deferred rather than designed."
|
||||
---
|
||||
|
||||
# U5 — self-announcing booths
|
||||
|
||||
## The defect, stated precisely
|
||||
|
||||
The index card is the only thing an agent can put in front of the operator, and
|
||||
it carries no information the agent chose. Name, item count, countdown, a
|
||||
thumbnail. Everything about *why this exists* has to travel some other way.
|
||||
|
||||
So it travelled some other way. `booth link` exists because a session with
|
||||
something to show had no way to make the booth itself say "look at this", and
|
||||
the link board absorbed job 5 until **145 of its 210 rows (69%) pointed at
|
||||
booths that had already been swept**. The rot is not a link-board bug. The board
|
||||
was doing a job it was never shaped for, because the shaped thing did not exist.
|
||||
|
||||
The lesson the measurement carries, and the reason this unit comes before any
|
||||
link-board enforcement: **enforcing the link rule without giving job 5 a home
|
||||
just makes it homeless.**
|
||||
|
||||
## The record
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
handle: str # an althing handle, or "booth" for one the service made
|
||||
title: str # display name; falls back to the directory name
|
||||
why: str # ONE line: what the operator is looking at and why
|
||||
created: str # ISO-8601 with offset, from the FIRST announcement
|
||||
error: str | None = None # a read-time verdict; never stored
|
||||
```
|
||||
|
||||
`.booth.json` on disk is the same four fields, no `error`.
|
||||
|
||||
**Every field on an error-carrying record has a stated value**, because the
|
||||
templates render the record and a careless fill would re-raise the outage in
|
||||
the renderer: `handle` and `why` and `created` are `""`, `title` is the
|
||||
normalized directory name, and `error` says which of the six refusals fired.
|
||||
`created` being `""` is what makes `write_manifest` treat a damaged prior as
|
||||
having no stamp to preserve (INV-3).
|
||||
|
||||
Caps, all applied at the write and again at the read: `handle` 64, `title` 120,
|
||||
`why` 200, `created` 64. Each is a **display budget**, not a storage limit —
|
||||
they exist because these strings land in a card's sub-line.
|
||||
|
||||
## Signatures
|
||||
|
||||
```python
|
||||
MANIFEST_FILE = ".booth.json"
|
||||
HANDLE_MAX, TITLE_MAX, WHY_MAX = 64, 120, 200
|
||||
|
||||
|
||||
MANIFEST_MAX_BYTES = 64 * 1024
|
||||
QUARANTINE_FILE = ".booth.json.broken"
|
||||
|
||||
|
||||
def read_manifest(booth: Path) -> Manifest | None:
|
||||
"""This booth's announcement, or None if it never made one.
|
||||
|
||||
LENIENT, and never raises. `list_booths` calls this once per booth on every
|
||||
index page load, so a damaged file must cost that booth's provenance and
|
||||
nothing else — the same posture `marks_for` takes, for the reason v0.2.2
|
||||
made expensive: a read that can raise, called in a loop over every booth,
|
||||
is a service-wide outage wearing a single-booth bug's clothes.
|
||||
|
||||
"NEVER RAISES" IS BOUNDED, NOT MERELY CAUGHT. An earlier draft of this
|
||||
contract named a 4 GB file as a tested case and constrained only the RETURN
|
||||
— which is letter-compliant and purpose-defeating: reading four gigabytes
|
||||
per booth per index load recreates the same outage in slow motion. The size
|
||||
is checked by `stat` BEFORE the bytes are touched, and the two exception
|
||||
classes that are neither `OSError` nor `ValueError` — `MemoryError` from a
|
||||
huge document, `RecursionError` from a deeply nested one — are caught as
|
||||
well, so that raising the bound one day cannot quietly re-open the hole.
|
||||
|
||||
REGULAR-FILE FIRST, THEN SIZE — and the order is the whole point. `st_size`
|
||||
is 0 for a FIFO and 0 for a symlink to `/dev/zero`, so both sail under any
|
||||
byte cap and then the read either blocks forever with no EOF or allocates
|
||||
until the kernel intervenes. The bound is what made this reachable: a cap
|
||||
that trusts `st_size` inherits everything `st_size` does not mean. One such
|
||||
file stalls every `GET /` and `/healthz`, with no error and no recovery
|
||||
short of a restart.
|
||||
|
||||
Absent -> None. Present but too large, unreadable, unparseable, not an
|
||||
object, or missing `handle` -> a Manifest carrying `error`, so the card can
|
||||
say `unreadable` rather than quietly showing the same thing as a booth that
|
||||
never announced.
|
||||
"""
|
||||
|
||||
|
||||
def write_manifest(booth: Path, handle: str, *, title: str | None = None,
|
||||
why: str | None = None) -> Manifest:
|
||||
"""Announce a booth. Atomic per CLAUDE.md invariant 5: temp file +
|
||||
os.replace, onto a temp name no other writer will pick.
|
||||
|
||||
OMITTED MEANS UNCHANGED; `""` MEANS CLEAR. `title` and `why` default to
|
||||
None. The ordinary sequence is `booth new x --why "..."` then
|
||||
`booth add x out/*.png`, and while omission meant `""` the second command
|
||||
silently erased the sentence the first one existed to record. The shell
|
||||
carries the distinction by leaving the environment variable UNSET rather
|
||||
than empty.
|
||||
|
||||
Re-announcing PRESERVES the original `created` — `created` is when the
|
||||
booth appeared, and saying something more about it later is not a second
|
||||
appearance. A prior record carrying `error`, or one whose `created` is
|
||||
`""`, is treated as having no stamp to preserve and gets `now()`: a stamp
|
||||
that is silently wrong is worse than one that is silently new.
|
||||
|
||||
A WRITE THAT CHANGES NOTHING IS NOT ACTIVITY and does not touch the file,
|
||||
so it cannot reset the booth's TTL — the rule marks learned in v0.2.0,
|
||||
needed here because `booth link` re-announces the standing board on every
|
||||
single post to it.
|
||||
|
||||
BYTES THAT COULD NOT BE READ ARE KEPT, not replaced. See INV-6.
|
||||
|
||||
A FAILED WRITE LEAVES NOTHING BEHIND. The temp name carries a random suffix
|
||||
so two writers cannot share it — which also means nothing ever overwrites an
|
||||
orphan, and `.booth.json.<hex>.tmp` is not a `.lock`, so `_newest_mtime`
|
||||
counts it and a leak would keep a dead booth alive forever. Cleaned up on
|
||||
every exit path.
|
||||
|
||||
`title` falls back to the directory name, THROUGH the same normalizer the
|
||||
explicit value gets — a directory name may legally carry a newline on POSIX
|
||||
and may run to 255 bytes, and the fallback used to hand either straight
|
||||
into a card's sub-line.
|
||||
|
||||
Every stored string is collapsed to a single line — all runs of whitespace,
|
||||
not only newlines, because a tab or a forty-space indent renders as badly
|
||||
in a sub-line as a newline does — and truncated to its cap.
|
||||
|
||||
An empty `handle` becomes `"booth"` rather than being refused: a manifest
|
||||
naming no handle does not read back at all, and an unreadable file is the
|
||||
worse outcome. Unreachable from the CLI, whose fallback chain always yields
|
||||
something; a direct caller should pass a real one.
|
||||
"""
|
||||
```
|
||||
|
||||
## What renders
|
||||
|
||||
One line, on both surfaces, driven by the same record. The example booth below
|
||||
is the directory `r18-ab`, announced by the handle `booth-dev`:
|
||||
|
||||
| state | the provenance line, on an index card AND on the booth header |
|
||||
|---|---|
|
||||
| announced, with a why | `booth-dev · pick the winning denoiser` |
|
||||
| announced, no why | `booth-dev` |
|
||||
| no manifest | `unannounced` (muted) |
|
||||
| damaged manifest | `unreadable` (muted, warning tint, `title=` carries the reason) |
|
||||
|
||||
**`title` renders too, and on exactly one surface.** An earlier draft stored it,
|
||||
surfaced a `--title` flag for it, and rendered it nowhere — a promise of a
|
||||
display name with no display, caught 4-of-4 and ranked first independently by
|
||||
every arm. It lands on the **booth page heading**, where there is room:
|
||||
`<h1>R18 A/B <span class=h1-slug>r18-ab</span></h1>`. The **index card keeps
|
||||
the directory name alone**, because that is the identity the operator navigates
|
||||
by and refers to positionally, and CLAUDE.md invariant 6 is about exactly that
|
||||
kind of reference surviving a re-render. When `title` equals the directory name
|
||||
— the default — the heading is unchanged from today.
|
||||
|
||||
**Both index lanes get it.** The kept lane renders first and is a separate block
|
||||
in `index.html`; patching only the ephemeral lane would leave the 15 kept booths
|
||||
— the durable, most-looked-at ones — with exactly the defect this closes. This
|
||||
is the `blurtoggle` lesson (three item branches, one macro) applied to two lanes.
|
||||
|
||||
**The booth page header gets it too**, and that is deliberate scope, not creep:
|
||||
a booth URL handed to the operator lands on the booth page, never on the index.
|
||||
Job 5 is "operator, look at this", and the page he actually opens is where the
|
||||
answer has to be.
|
||||
|
||||
## The CLI surface
|
||||
|
||||
Operator decision, 2026-09-22 — flags on the existing verbs, not a second verb:
|
||||
|
||||
```sh
|
||||
booth new r18-ab --why "pick the winning denoiser"
|
||||
booth add r18-ab out/*.png --why "second pass, sharper" --title "R18 A/B"
|
||||
booth new scratch # still legal — handle + created, no why
|
||||
```
|
||||
|
||||
`handle` comes from `$ALTHING_HANDLE`, falling back to `$BOOTH_SOURCE` then
|
||||
`hostname -s` — the same resolution `booth link` already uses for its rows, so
|
||||
provenance means the same thing on the board and on the card.
|
||||
|
||||
**Nothing existing breaks.** A bare `booth new x` / `booth add x f.png` keeps
|
||||
working; the flags are optional and may sit on either side of the file
|
||||
arguments, because a glob is usually last and a flag usually after it and
|
||||
nothing enforces that. The alternative — a separate `booth announce` verb — was
|
||||
rejected because a second step is the step that gets forgotten, which is the
|
||||
69% rot's own mechanism.
|
||||
|
||||
**A bare re-announce does not wipe what the last one said.** On a booth that has
|
||||
never announced, a bare `new`/`add` writes `{handle, created}` with no `why`. On
|
||||
one that HAS, an omitted flag leaves the stored value alone and only a supplied
|
||||
one overwrites — `--why ""` still clears, which is a different intention. This
|
||||
distinction is load-bearing rather than polite: `booth new x --why "…"` followed
|
||||
by `booth add x out/*.png` is the ordinary sequence, and the naive reading
|
||||
erases the sentence on the second command.
|
||||
|
||||
**The handle is the CLI's three-step chain**, not `$ALTHING_HANDLE` alone:
|
||||
`${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s)}}`, identical to the one
|
||||
`booth link` already uses for its rows, so provenance means the same thing on
|
||||
the board and on the card. A session with no handle set still announces, as its
|
||||
host.
|
||||
|
||||
## Scope — the blast-radius pass
|
||||
|
||||
Graphify + grep, both run, because neither is sufficient alone (graphify is
|
||||
blind to function-local and DI-injected imports; grep misses transitive reach).
|
||||
|
||||
**Every site that creates a booth directory:**
|
||||
|
||||
| site | gets a manifest? |
|
||||
|---|---|
|
||||
| `scripts/booth new` (line 97) | yes — `$ALTHING_HANDLE` |
|
||||
| `scripts/booth add` (line 103) | yes — `$ALTHING_HANDLE` |
|
||||
| `scripts/booth link` (line 178) | yes — `handle: booth`, the standing board |
|
||||
| `app.upload` (app.py:1087) | yes — `handle: booth`, a pickup booth |
|
||||
| `marks._Locked.__enter__` (marks.py:267) | **no** — `mkdir(exist_ok=True)` on the write path; a mark written to a booth that does not exist is not an announcement, and manifest.py must not be imported by marks.py (INV-1 cross-import) |
|
||||
| `rsync` from another host | **no** — no CLI runs; this is why `unannounced` exists |
|
||||
|
||||
**Every reader of a booth's facts:** `list_booths` (app.py:251) and `booth_view`
|
||||
— confirmed by `graphify explain list_booths` (15 edges, 4 test consumers) and
|
||||
by grep for `data_dir.iterdir` (two sites, both in app.py, both enumerating
|
||||
booths for exactly these two surfaces).
|
||||
|
||||
**Sites that already exclude the new file and need no change**, each verified
|
||||
rather than assumed: `items.booth_items` (items.py:182), `app.zip_booth`
|
||||
(app.py:351), `marks.import_legacy_asks` (marks.py:656).
|
||||
|
||||
**One site the first draft of this contract got WRONG, corrected by the seam
|
||||
review** (SR-1, below): the upload path's `used: set = {UPLOAD_MARKER}` filename
|
||||
dedupe set does **not** need to gain `MANIFEST_FILE`. The implementation adds it
|
||||
anyway, as consistency with the equally-unreachable entry already there, and
|
||||
says so in a comment rather than claiming it prevents anything.
|
||||
|
||||
⚠ **Line numbers in this section are the PRE-CHANGE coordinates** the
|
||||
blast-radius pass was run against, kept because that is what makes the pass
|
||||
auditable. They have moved; `grep` the symbol, do not trust the number.
|
||||
|
||||
## Seam review — what the real sibling surfaces said
|
||||
|
||||
Caller-side pass against the actual modules, not against their prose. Run after
|
||||
the cold contract panel was dispatched and before any code.
|
||||
|
||||
**SR-1 — the upload-collision change is unnecessary, and so is the one already
|
||||
there.** `safe_upload_name` (app.py) does `base = base.lstrip(".")` with the
|
||||
comment "a leading dot would hide the file from every listing", so an uploaded
|
||||
file can never be named `.booth.json` — or `.uploaded`, which means the existing
|
||||
`UPLOAD_MARKER` entry in that set has never been able to matter either. Adding
|
||||
`MANIFEST_FILE` alongside it is consistency with a redundant guard, not a fix
|
||||
for a reachable collision. Do it or don't; what the contract may not do is claim
|
||||
it prevents something. **This is the exact class the seam review exists for: a
|
||||
scope item the contract asserted from its own reasoning and the sibling's real
|
||||
surface refutes.**
|
||||
|
||||
**SR-2 — the atomic-write pattern transfers cleanly to a dotfile, verified not
|
||||
assumed.** `marks._write_raw` derives its temp name as
|
||||
`path.with_suffix(path.suffix + ".tmp")`. For a dotfile with an extension that
|
||||
is not obviously safe — `Path(".booth.json").stem` is `".booth"`, which looks
|
||||
alarming — but `.suffix` is `".json"` and the result is `.booth.json.tmp`.
|
||||
Checked against the interpreter. The temp file is itself a dotfile, so
|
||||
`booth_items` and `zip_booth` skip it and no reader can see it mid-write.
|
||||
|
||||
**SR-3 — the dotfile skips are on `p.name`, and all three use `rglob` or
|
||||
`iterdir` over the booth.** `items.booth_items` (items.py:182), `app.zip_booth`
|
||||
(app.py:351) and `marks.import_legacy_asks` (marks.py:656) each test
|
||||
`p.name.startswith(".")`. A manifest at the booth root is skipped by every one
|
||||
of them. Confirmed by reading the three loops, not by trusting the claim.
|
||||
|
||||
**SR-4 — `test_stdlib_only` is parametrized `["marks", "asks", "links"]`**
|
||||
(tests/test_marks.py:279) and gains `"manifest"` as a fourth entry. The test's
|
||||
docstring calls this INV-5 while `CLAUDE.md` calls it invariant 1; that
|
||||
inconsistency predates this unit and is left alone.
|
||||
|
||||
**SR-5 — `.booth.json` is reachable over HTTP at `/b/<name>/.booth.json`.**
|
||||
`booth_file` refuses only path escapes and non-files, not dotfiles, so a remote
|
||||
session with no filesystem access can read a booth's announcement the same way
|
||||
it already polls `/b/<n>/marks.json`. That is a feature and it is now written
|
||||
down; there is no secret in a manifest, and the Booth has no auth by design.
|
||||
|
||||
**SR-6 — `list_booths` returns plain dicts and the templates read them by key.**
|
||||
`b.manifest` resolves through Jinja's getitem fallback. A None manifest must be
|
||||
guarded with an explicit `{% if %}` rather than relying on `b.manifest.handle`
|
||||
rendering as Undefined, because the two lanes' cards differ and a silent
|
||||
Undefined in one of them is how the kept lane would quietly keep the old defect.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Deliberately deferred or never. Divergence here is not drift.
|
||||
|
||||
- **A second index ordering keyed on `created`** — a "what landed" feed. Operator
|
||||
decision, 2026-09-22: parked for v1.1. It is a new ordered collection needing
|
||||
its own stated rule, it competes with the existing order for what "the third
|
||||
one" means, and it has nothing to sort the 26 manifest-less booths by.
|
||||
- **`why` in the zip manifest, or a `booth ls` column.** One-liners over the
|
||||
same record, neither on the v1 path.
|
||||
- **Enforcing that a booth MUST announce itself.** `rsync` is the documented
|
||||
path for every host that is not nh3-dev and never runs the CLI, so a refusal
|
||||
would break the documented workflow. The marker is the whole mechanism.
|
||||
- **Deleting, expiring or migrating anything based on the manifest.** U4 owns
|
||||
lifetime; this unit only describes.
|
||||
- **Any change to how items, marks, blur, keep or the link board work.** The
|
||||
manifest is a dotfile and every existing listing already skips it.
|
||||
- **Auth, or treating a manifest as trusted.** Standing non-goal; the Booth is
|
||||
LAN-internal and a hand-written `.booth.json` is a supported input.
|
||||
- **Provenance ON a verbatim-`index.html` booth's own page.** Five live booths
|
||||
serve the author's HTML raw, and the Booth owns no header there to put a line
|
||||
into — it currently reaches those pages through six regexes injected into
|
||||
arbitrary markup, which is precisely the defect U3 exists to fix. Their INDEX
|
||||
cards carry provenance like everything else; the page itself waits for U3's
|
||||
declared embed seam. Verified on `pewpew-ui-brief`: page renders 200, card
|
||||
reads `unannounced`.
|
||||
|
||||
## Invariants
|
||||
|
||||
Numbered INV-1..5 and local to this unit. Where a repo-wide rule is meant it is
|
||||
named in words — "CLAUDE.md invariant 5", "CLAUDE.md invariant 6" — never by a
|
||||
bare number, because an earlier draft used `INV-5` for both the repo's
|
||||
atomic-write rule and this unit's render rule and the collision was caught
|
||||
3-of-4.
|
||||
|
||||
**INV-1 — one module knows the filename.** `booth/manifest.py` is the only
|
||||
module that names `MANIFEST_FILE`. No route body, template or CLI verb opens or
|
||||
parses `.booth.json`; `write_manifest` reads it back inside that module, which
|
||||
is what INV-3 requires and is not an exception to this rule. Falsifiable and
|
||||
tested: no other file under `booth/` contains the literal `.booth.json`.
|
||||
|
||||
**INV-2 — the read cannot raise, AND cannot cost the caller unboundedly.**
|
||||
`read_manifest` returns for every input: an absent directory, a `.booth.json`
|
||||
that is a list, a string, `null`, empty, not UTF-8, wrong-typed, missing its
|
||||
handle, nested deeply enough to overflow the parser's stack, and one larger
|
||||
than `MANIFEST_MAX_BYTES` — which is refused by `stat` before a byte is read,
|
||||
because a bound that only constrains the RETURN recreates the outage in slow
|
||||
motion. Tested per case, the size and depth cases included.
|
||||
|
||||
**INV-3 — `created` survives re-announcement.** A second `write_manifest` on the
|
||||
same booth preserves the first `created`. A prior record carrying `error`, or
|
||||
one whose `created` is `""`, has no stamp to preserve and gets `now()`. Tested
|
||||
against a stamp that could not have come from `now()` — `_now()` is whole-second
|
||||
resolution, so back-to-back writes share a timestamp and a naive test passes
|
||||
against an implementation that regenerates it every time.
|
||||
|
||||
**INV-4 — stdlib-only, and sibling-free** (this is CLAUDE.md invariant 1
|
||||
extended by one clause). `booth/manifest.py` imports nothing outside the
|
||||
standard library and nothing from `booth.*` — a cross-import between two
|
||||
stdlib-only modules is a second way for the repo rule to break. Relative
|
||||
imports count; the AST walk sees them.
|
||||
|
||||
**INV-6 — bytes that could not be read are never destroyed.** When
|
||||
`write_manifest` replaces a manifest whose read returned `error`, the old bytes
|
||||
move to `QUARANTINE_FILE` first. This is the doctrine marks made explicit in
|
||||
v0.2.1 — reads lenient, writes strict, damaged bytes stay on disk — and this
|
||||
unit contradicted it by replacing outright, so a file that failed on ONE field
|
||||
lost the others with it, including a `why` the re-announcer may never have kept
|
||||
anywhere.
|
||||
|
||||
It diverges from marks in HOW it honours the rule, and the divergence is the
|
||||
interesting part. Marks REFUSE the write and answer 409, because the operator's
|
||||
judgment is not restatable. A manifest QUARANTINES and proceeds, because
|
||||
refusing would fail `booth add` and lose the files it was mid-way through
|
||||
copying — and a booth's own description is something its poster can say again.
|
||||
One fixed quarantine name rather than a timestamped series: nothing prunes a
|
||||
booth but the sweep, and the most recent damage is the only copy anyone opens.
|
||||
|
||||
**INV-5 — unannounced and unreadable render DIFFERENT TEXT.** Not merely
|
||||
different styling: the words differ (`unannounced` / `unreadable`), so the
|
||||
distinction survives a stylesheet change and a reader who cannot see colour. A
|
||||
one-pixel difference would satisfy a looser wording and encode nothing, and the
|
||||
point is that one of the two states is something somebody has to go and fix.
|
||||
@@ -206,21 +206,41 @@ a real DOM API. Marks land at `data-booth-mark="<id>"` anchors, which keeps the
|
||||
page*. If the line is absent, the Booth injects it at **one** insertion point, so
|
||||
every existing verbatim booth keeps working untouched.
|
||||
|
||||
**What this deletes**, and this is the whole point of the decision:
|
||||
**What this deleted** — landed as U3, 2026-09-22:
|
||||
|
||||
- `booth/inline.py` — 114 lines of placeholder DSL, entirely
|
||||
- `booth/inline.py` — 119 lines of placeholder DSL, entirely. One line survived:
|
||||
`form_id`, which builds the shared `<form>` id scattered question groups bind
|
||||
to, and which moved to `app.py` beside the route that renders them.
|
||||
- `wrap_verbatim_html` and its six regexes against arbitrary HTML
|
||||
(`_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`, `_BODY_CLOSE_RE`,
|
||||
`_HTML_CLOSE_RE`, `_ICON_RE`) and the doctype/charset-ordering constraints
|
||||
they are threading
|
||||
- `_BACK_CHIP`, `asks_chip` — two floating chips positioned by guessed offsets
|
||||
- `GET /b/<name>/asks` — the standalone page that existed only because a verbatim
|
||||
booth could not show its own asks
|
||||
`_HTML_CLOSE_RE`, `_ICON_RE`) **and both of the constraints they were
|
||||
threading.** Not satisfied more carefully — gone: nothing can displace a
|
||||
leading doctype into quirks mode and nothing can push the charset `<meta>`
|
||||
out of its detection window, because the Booth only ever APPENDS now.
|
||||
- `_BACK_CHIP`, `asks_chip` — two floating chips positioned by guessed offsets.
|
||||
embed.js builds both in the DOM.
|
||||
- the `styles()` macro. The scoped `.bk-ask-*` rules live in embed.js next to
|
||||
the code that mounts them, emitted once by construction instead of by a
|
||||
seen-set.
|
||||
- `GET /b/<name>/asks` was already a 308 into `/marks` by U2; this unit left it
|
||||
there. The standalone page it named is gone, but the URL is in the operator's
|
||||
history and in landed reports, and a dead link teaches nothing.
|
||||
|
||||
Regex-injecting into arbitrary author HTML is the single most fragile thing in
|
||||
the service, and it is load-bearing for the operator's most important workflow.
|
||||
**What replaced them is a substring test and a `+`.** `if EMBED_SRC not in
|
||||
html: html += EMBED_SCRIPT_TAG`. A page that declares the line is served with
|
||||
nothing added to it at all.
|
||||
|
||||
Regex-injecting into arbitrary author HTML was the single most fragile thing in
|
||||
the service, and it was load-bearing for the operator's most important workflow.
|
||||
A declared seam costs the author one line and removes the whole class.
|
||||
|
||||
**What it cost, stated because it is real.** The verbatim path used to work with
|
||||
no JavaScript: an ask rendered server-side and submitted through a plain form.
|
||||
It now needs the script. The guarantee that an ask is never invisible survives
|
||||
in a weaker and still-true form through surfaces that need no script — the index
|
||||
card's open-mark badge, and `/b/<name>/marks`, which renders every mark
|
||||
server-side.
|
||||
|
||||
---
|
||||
|
||||
# Navigation
|
||||
|
||||
@@ -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,74 @@
|
||||
# A wrong-shaped answer 500s the gallery and the marks page — PRE-EXISTING, NOT U3
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**Found by the U3 bug-hunt panel, measured against `42ea67f` — the commit
|
||||
BEFORE U3 — so it is not this unit's doing and was not fixed by it.** U3's own
|
||||
surface is guarded; these two are not.
|
||||
|
||||
## The defect
|
||||
|
||||
`.marks.json` that is **well-formed JSON with a wrong-shaped value** passes
|
||||
every reader and then raises in the renderer:
|
||||
|
||||
```json
|
||||
{"id": "batch", "shape": "pick", "answer": {"answers": [], "notes": ""}}
|
||||
```
|
||||
|
||||
`_hydrate` only checks `isinstance(entry.get("answer"), dict)` — it never
|
||||
validates `answer["answers"]`. So `marks_for` and `hold_read` both return the
|
||||
mark with `error = None` and **no read error at all**, and then
|
||||
`_ask_inline.html` does `a.answer.answers.get(q.key)`, Jinja asks a list for
|
||||
`.get`, and it raises `UndefinedError`.
|
||||
|
||||
Measured, not reasoned:
|
||||
|
||||
PRE-U3 (42ea67f) gallery page: 500
|
||||
PRE-U3 (42ea67f) marks page: 500
|
||||
PRE-U3 (42ea67f) index: 200
|
||||
|
||||
The index survives because it never renders a fragment.
|
||||
|
||||
## Why it matters more than it looks
|
||||
|
||||
This is **the v0.2.2 shape with a different trigger**. That outage was a
|
||||
`.marks.json` that could not be PARSED; the reader was made lenient and the
|
||||
index stopped 500ing. This one parses perfectly and breaks one layer further in,
|
||||
at render time, where no leniency exists — so the lesson "one damaged file must
|
||||
cost its own tile, not the page" is only half-implemented. `read_error` is
|
||||
answering a narrower question than every caller assumes.
|
||||
|
||||
## What U3 did and did not do
|
||||
|
||||
U3 added `_safe_fragments` around `_pick_fragments`, so `/b/<name>/embed.json`
|
||||
returns a per-mark `error` record instead of a 500 — the same posture
|
||||
`_hydrate_safe` takes one layer down. That protects **the verbatim path only**.
|
||||
|
||||
`booth.html` and `marks.html` call the same macros with no such guard. Left
|
||||
alone deliberately: the gallery is named out of scope in the U3 contract, and
|
||||
widening a unit mid-flight to cover a pre-existing defect in a surface it never
|
||||
touched is the scope drift the roadmap gate exists to stop.
|
||||
|
||||
## The design question it deserves, when it is picked up
|
||||
|
||||
Not "wrap the other two call sites" — that is the third copy of one guard. The
|
||||
real question is **where the boundary belongs**:
|
||||
|
||||
1. **In `_hydrate`**, validating the answer shape so a wrong-shaped answer
|
||||
becomes `error` at hydration and every surface inherits the fix. Cleanest,
|
||||
and consistent with declarations already being normalized on read — but it
|
||||
widens what `error` means.
|
||||
2. **At each render site**, per-mark, as U3 did. Honest and local; three copies.
|
||||
3. **In the template**, defensively. Cheapest and worst — it hides the fact
|
||||
that anything is wrong.
|
||||
|
||||
(1) is the shape the rest of this module already argues for: one predicate,
|
||||
one place. Worth an operator decision because it changes what a `Mark` can be.
|
||||
|
||||
⚠ Reproduce with the fixture in
|
||||
`tests/test_embed.py::test_a_wrongly_shaped_answer_costs_its_pick_not_the_report`,
|
||||
whose closing comment points back here.
|
||||
|
||||
Related: [[2026-09-21-marks-write-wiped-judgment]],
|
||||
[[2026-09-22-lenient-reader-blast-radius]],
|
||||
[[2026-09-22-u3-declared-embed-seam-landed]].
|
||||
@@ -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,54 @@
|
||||
# No fleetwide notice for U4 — and what that does to the prediction
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**Operator decision, 2026-09-22: do NOT tell the 17 consuming handles that
|
||||
`keep` has stopped being the way to say "waiting on an answer".** No broadcast.
|
||||
Same posture he took on U5, and the same instrument: adoption gets told apart
|
||||
from design because nobody was primed.
|
||||
|
||||
⚠ **THIS CHANGES HOW THE 2026-10-06 RE-COUNT MUST BE READ, and a session that
|
||||
misses this will draw the wrong conclusion from a true number.**
|
||||
|
||||
U4 has two halves and they do NOT have the same adoption cost:
|
||||
|
||||
- **The hold rides for free.** A session that runs `booth ask` gets its booth
|
||||
held with no knowledge of anything. The operator answering releases it. No
|
||||
peer has to learn a thing for the mechanism to work.
|
||||
- **NOT PRESSING `keep` HAS TO BE LEARNED.** U4 makes the sentinel unnecessary
|
||||
for the "not yet" case; it does not make it unavailable, and nothing stops a
|
||||
habit. An agent that has always pressed `keep` while waiting will keep
|
||||
pressing it.
|
||||
|
||||
**So a flat `.forever` rate on 2026-10-06 does NOT falsify the diagnosis.** It
|
||||
is exactly what "the mechanism works and nobody was told" looks like — the U5
|
||||
shape, one unit later. Reporting a null result without this caveat would retire
|
||||
a correct diagnosis on the strength of an uncontrolled measurement.
|
||||
|
||||
**Use these instead, and report all three.** The raw rate stays as context, not
|
||||
as the verdict:
|
||||
|
||||
1. **The overlap — booths with an open pick AND `.forever`.** Was **3** on
|
||||
2026-09-22, which is the positive control for the whole diagnosis. It falls
|
||||
only if peers learn; it is the *adoption* number.
|
||||
|
||||
comm -12 <(grep -l '"shape": "pick"' ~/booth-data/*/.marks.json | xargs -n1 dirname | sort) \
|
||||
<(dirname ~/booth-data/*/.forever | sort) | wc -l
|
||||
|
||||
2. **Did the hold ever bind?** Count booths that were held past their TTL and
|
||||
therefore survived a sweep they would otherwise have lost. This needs no
|
||||
peer to change anything, so it is the honest test of whether the mechanism
|
||||
is load-bearing at all. **If it is ZERO over a fortnight, the diagnosis was
|
||||
wrong about VOLUME** — the "not yet" case is rarer than the sentinel rate
|
||||
suggested — and that is a real finding. The sweeper logs what it wipes;
|
||||
nothing yet logs what it spares, so **this counter does not exist and would
|
||||
have to be added before it can be read.** Say so rather than guessing.
|
||||
3. **The raw `.forever` rate** — 17 of 24 (70%) on 2026-09-22. Context only,
|
||||
now that the no-notice decision has made it a measurement of habit rather
|
||||
than of need.
|
||||
|
||||
⚠ **Sensitivity floor, stated because a bare "no effect" is unfalsifiable:**
|
||||
only **4 of 24** booths carried marks at all on 2026-09-22. The hold cannot
|
||||
bind on a booth with no marks, so at that population the mechanism can touch at
|
||||
most a sixth of the fleet, and an effect smaller than one or two booths is not
|
||||
resolvable by any of these counts. Related: `[[2026-09-22-forever-had-a-live-positive-control]]`.
|
||||
@@ -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,87 @@
|
||||
# A vacuity pass that tries the contract's own mutation agrees with itself
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
The contract-time **vacuity pass** — for each invariant, name a change that
|
||||
defeats it and check the named test goes red — was proposed independently by
|
||||
Regin and Kimi on U4's paraphrase round, and U4's own code-review panel then
|
||||
showed **five of seven** U4 falsifiers were vacuous: a green test *cited* by an
|
||||
`INV` rather than a test that would *fail* if the invariant broke. See
|
||||
[[2026-09-22-vacuous-falsifiers]].
|
||||
|
||||
U3 ran the pass as a real instrument rather than a promise. Script in the
|
||||
session scratchpad; for each invariant it applies the mutation the contract's
|
||||
*Falsifiable:* line names, runs the single named test, and asserts a **non-zero**
|
||||
exit, restoring the file in a `finally` either way.
|
||||
|
||||
| INV | mutation applied | verdict |
|
||||
|---|---|---|
|
||||
| 1 declaring page untouched | append `<!-- booth -->` to the declaring branch | FALSIFIED |
|
||||
| 2 appended, never inserted | insert the tag before `<title>` instead | FALSIFIED |
|
||||
| 3 no regex on author HTML | re-declare `_ICON_RE` in `app.py` | FALSIFIED |
|
||||
| 4 openness is the server's | have `embed.js` derive open from `bk-done` | FALSIFIED |
|
||||
| 5 embed.js read once | `read_text()` per request in the route | FALSIFIED |
|
||||
| 6 tail in payload order | iterate the marks list backwards | FALSIFIED |
|
||||
| 7 unplaced questions appended | short-circuit the append branch to `if (false)` | FALSIFIED |
|
||||
|
||||
**7/7**, and — the part that makes it a measurement rather than a ritual — an
|
||||
**unmutated control run** confirming all seven named tests are green when
|
||||
nothing is broken. Without that control, a script whose mutation silently failed
|
||||
to apply (the text not found, the wrong file) reports the same clean-looking
|
||||
table. The script halts with `MUTATION-MISS` if its target string is absent,
|
||||
for exactly that reason.
|
||||
|
||||
## Why it is worth the ten minutes
|
||||
|
||||
Three of the seven falsifiers are in `embed.js`, which the Python suite cannot
|
||||
see at all. INV-4, INV-6 and INV-7 are held **only** by browser tests, and
|
||||
"there is a browser test named after this invariant" is precisely the kind of
|
||||
claim that feels like coverage and can be empty. Two of those three mutations
|
||||
are one-token edits — `marks.length - 1` and `if (false)` — so the cost of
|
||||
checking was minutes and the cost of being wrong was an invariant nobody was
|
||||
holding.
|
||||
|
||||
**The general shape:** an instrument that cannot fail loudly will fail quietly.
|
||||
Same family as the `(gasp)` tag-detection specimen in the global measurement
|
||||
rule, and as the zsh word-splitting bug that shipped an empty heid bundle —
|
||||
[[2026-09-22-four-paths-to-one-fail-open-delete]]. A clean result and a broken
|
||||
method are indistinguishable from the output alone unless something in the
|
||||
method is designed to go red.
|
||||
|
||||
## ⚠ AND THEN THE COLD PANEL SHOWED ONE OF THE SEVEN WAS VACUOUS ANYWAY
|
||||
|
||||
The table above is real and it was **not sufficient**. The `/heid-contract-review`
|
||||
panel (`01M351WKV666D681SSRNY7D7X6`) — three of four arms, independently —
|
||||
showed **INV-3's falsifier was vacuous**, on this contract's central promise, and
|
||||
the pass above had passed it.
|
||||
|
||||
**Why the pass missed it.** INV-3 claims *no regular expression is applied to
|
||||
author HTML*. The test name-matched the six DELETED patterns. The mutation the
|
||||
pass applied was re-declaring `_ICON_RE` — **the pattern the contract named** —
|
||||
which the name-match caught. The mutation the invariant actually forbids is a
|
||||
regex under a *new* name (`_TAIL_RE.sub(...)` in the verbatim branch), and that
|
||||
sailed through green.
|
||||
|
||||
> **The mutation has to come from the INVARIANT'S CLAIM, not from the
|
||||
> FALSIFIER'S EXAMPLE.** A pass that applies the contract's own suggested
|
||||
> mutation is testing the contract against itself, and it will agree.
|
||||
|
||||
**Then the fix had a hole too, and only a re-run found it.** The repaired test
|
||||
asserts `booth/app.py` performs exactly one regex operation. Re-running the pass
|
||||
*against the fix* showed an aliased `import re as _r` routes around the call
|
||||
check under a name it does not know — still VACUOUS. Closed with an import-shape
|
||||
assertion. **Run the pass on the repair, not only on the draft.**
|
||||
|
||||
Final state: **10/10 falsifiable**, control green, the two extra rows being the
|
||||
panel's own findings turned into falsifiers.
|
||||
|
||||
## What this is evidence for
|
||||
|
||||
U4 measured the problem (five of seven vacuous). U3 measured a pass working
|
||||
(7/7), then measured **the pass's own blind spot**, then measured the fix's
|
||||
blind spot. All three belong in the case if the vacuity-pass proposal is ever
|
||||
put to the operator as a `/heid*` skill amendment — and the second and third
|
||||
are the parts that stop it being adopted as a ritual that always passes.
|
||||
|
||||
Related: [[2026-09-22-u3-declared-embed-seam-landed]],
|
||||
[[2026-09-22-the-browser-became-a-test-surface]].
|
||||
@@ -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,76 @@
|
||||
# The browser became a test surface, and the version bound is the foot-gun
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
U3 moved load-bearing logic out of Python and into JavaScript: which fragment
|
||||
lands at which anchor, what gets appended, and whether a `<form>` scattered down
|
||||
a report still owns the controls pointing at it. **The Python suite is blind to
|
||||
every one of those.** Shipping U3 with only payload-shape tests would have
|
||||
deleted ~10 real tests and replaced them with assertions that cannot see the
|
||||
thing the operator actually depends on.
|
||||
|
||||
So `tests/test_embed_browser.py` drives a real Chromium against a real uvicorn
|
||||
on an ephemeral port. 12 tests. It found nothing on the first run — but the
|
||||
probe that preceded it settled a design question no amount of spec-reading
|
||||
would have.
|
||||
|
||||
## The probe, and why it had controls
|
||||
|
||||
**Question:** if a control carrying `form="F"` is inserted into the DOM *before*
|
||||
`<form id="F">` exists, does it become that form's control? The HTML spec resets
|
||||
form owner on insertion and on the `form` attribute changing — it does NOT list
|
||||
"a matching form was inserted later". The U3 design inserts fragments in visual
|
||||
order, so this happens routinely.
|
||||
|
||||
Four conditions, N=3 each, in Chromium 151 headless:
|
||||
|
||||
| condition | `input.form?.id` |
|
||||
|---|---|
|
||||
| A — form inserted first (**positive control**) | `F, F, F` |
|
||||
| B — control inserted first (**the question**) | `F, F, F` |
|
||||
| C — `form="NOPE"`, no such form (**negative control**) | `null, null, null` |
|
||||
| D — remove and re-set the attribute (the proposed fix) | `F, F, F` |
|
||||
|
||||
The positive control proves the instrument can see association at all; the
|
||||
negative proves it is not manufacturing it. Without both, B's answer means
|
||||
nothing — that is the whole lesson of
|
||||
[[2026-09-22-vacuous-falsifiers]] applied before the code instead of after.
|
||||
|
||||
**The answer is: Chromium re-resolves it, so the fix is unnecessary there.**
|
||||
The fix shipped anyway. **Sensitivity floor: ONE ENGINE.** The operator's own
|
||||
browser was not measured, the failure mode is a form that looks filled in and
|
||||
POSTs a 400, and the guard is three lines. The measurement says "not needed
|
||||
here"; it does not say "not needed".
|
||||
|
||||
## The foot-gun, which bit before the tests were written
|
||||
|
||||
Browsers are **box-wide** in `/opt/ms-playwright` with
|
||||
`PLAYWRIGHT_BROWSERS_PATH` wired globally — there is no per-project
|
||||
`playwright install`. Each playwright release pins **one** Chromium revision, and
|
||||
a release wanting a revision the shared store lacks dies with:
|
||||
|
||||
Executable doesn't exist at /opt/ms-playwright/chromium_headless_shell-1243/…
|
||||
|
||||
That is not a missing-dependency error and it does not name the real problem.
|
||||
The store had 1223 / 1228 / 1234; `playwright` 1.63 wanted 1243. The mapping:
|
||||
|
||||
1.60 -> 1223 1.61 -> 1228 1.62 -> 1234 1.63 -> 1243
|
||||
|
||||
Hence `playwright>=1.60,<1.63` in `pyproject.toml`, **with the upper bound as the
|
||||
point** and the reason in a comment beside it. A bare `playwright` would break
|
||||
the suite on the next resolve, opaquely.
|
||||
|
||||
## The hermeticity trade, and how it is paid
|
||||
|
||||
A browser layer makes the suite non-hermetic — it can go red for an environment
|
||||
reason. `tests/test_embed_browser.py` therefore **skips, never fails**, when
|
||||
playwright or a usable browser is missing (`pytest.importorskip`, plus a
|
||||
`pytest.skip` on any launch failure). `pytest -q` stays green anywhere; the
|
||||
browser layer is purely additive.
|
||||
|
||||
⚠ **The failure mode of that choice: if those 12 tests start SKIPPING on this
|
||||
box, U3's placement logic is untested and the suite still says green.** If the
|
||||
count drops from 431, check the skip reason before anything else — the pinned
|
||||
bound has probably drifted past the shared store.
|
||||
|
||||
Related: [[2026-09-22-u3-declared-embed-seam-landed]].
|
||||
@@ -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,88 @@
|
||||
# U3 landed — the page declares the seam, the Booth mounts into it
|
||||
|
||||
_2026-09-22 · booth_
|
||||
|
||||
**Ten regular expressions against author-written HTML are gone.** Six in
|
||||
`wrap_verbatim_html` hunting for somewhere to hang a favicon and a chip, four in
|
||||
`booth/inline.py` substituting rendered ask markup into the author's own tags.
|
||||
What replaced them, in full:
|
||||
|
||||
```python
|
||||
return html if declares_embed(html) else html + EMBED_SCRIPT_TAG
|
||||
```
|
||||
|
||||
A substring test and a `+`. **Both of the old wrapper's hard constraints stopped
|
||||
existing rather than being satisfied more carefully** — nothing can displace a
|
||||
leading doctype into quirks mode and nothing can push the charset `<meta>` out
|
||||
of its first-1024-byte window, because nothing in front of them ever moves.
|
||||
|
||||
## What moved where
|
||||
|
||||
| was | is |
|
||||
|---|---|
|
||||
| `wrap_verbatim_html` + 6 regexes | `embed_verbatim` — one `in`, one `+` |
|
||||
| `booth/inline.py`, 119 lines | deleted; `form_id` survived into `app.py` |
|
||||
| `_BACK_CHIP`, `asks_chip` | built in the DOM by `embed.js` |
|
||||
| `inject_asks` | `GET /b/<name>/embed.json` + placement in `embed.js` |
|
||||
| `_ask_inline.html`'s `styles()` | the CSS lives in `embed.js` |
|
||||
| `FAVICON_LINK` string injection | `document.querySelector('link[rel~="icon"]')` |
|
||||
|
||||
**The fragments are still rendered by Jinja.** `embed.js` places what comes back
|
||||
and never builds one — a second renderer in JavaScript would be the same bug
|
||||
INV-1 exists to stop, in a new language. The payload also decides openness
|
||||
(`open_marks`) and order, so the page has no opinion about either.
|
||||
|
||||
## The thing the contract got wrong, and the seam review caught
|
||||
|
||||
The payload first keyed `questions` by question key. **A single-question pick
|
||||
normalizes to `questions: [{"key": None, …}]`** (`asks.normalize_ask`, the
|
||||
`multi: False` branch), and `json.dumps` writes that key as the string `"null"`
|
||||
— inventing a name that collides with a real key. Every one-question ask in the
|
||||
fleet would have hit it, including the live `sindra-voice-1`. `questions` is a
|
||||
LIST of `{key, html}` now; the key is nullable, and declaration order rides in
|
||||
the format instead of leaning on object-key insertion order.
|
||||
|
||||
The cold contract panel could not have found this: it is a fact about
|
||||
`booth/asks.py`, which an artifact-only reader never sees. Third time the seam
|
||||
review has caught what the cold pass structurally cannot — see
|
||||
[[2026-09-21-two-gates-are-complementary]].
|
||||
|
||||
## The live report that was already subtly broken
|
||||
|
||||
`dfa-concepts/index.html` writes `<div class="ask" data-booth-ask="dfa:logo">
|
||||
<h3>The one asset that must survive</h3>`. `_EL_RE` matched the **opening tag**
|
||||
and replaced it, so the author's `.ask` wrapper class vanished, the heading was
|
||||
orphaned and the `</div>` went stray. Nobody filed a bug, because a page that is
|
||||
95% right does not look broken.
|
||||
|
||||
`el.insertAdjacentHTML("beforeend", frag)` keeps the element and its contents
|
||||
and puts the fragment inside. Verified live in a real browser: 5 author `.ask`
|
||||
wrappers intact, 5 headings intact, 14 radios mounted inside them, zero console
|
||||
errors. **The replacement is not just less fragile, it renders the operator's
|
||||
own report more faithfully than the thing it replaced.**
|
||||
|
||||
## The cost, stated because it is real
|
||||
|
||||
The verbatim path used to work with **no JavaScript** — server-rendered ask, plain
|
||||
form POST, HTML5 `form=` binding resolved at parse time. It needs the script now.
|
||||
The operator's 2026-09-21 ruling accepts that; this entry records the consequence
|
||||
so nobody meets it as a surprise. The never-invisible guarantee survives in a
|
||||
weaker and still-true form through surfaces needing no script: the index card's
|
||||
open-mark badge, and `/b/<name>/marks`.
|
||||
|
||||
## Anchor syntax
|
||||
|
||||
`data-booth-mark` is canonical (U2 made an ask one shape of mark).
|
||||
`data-booth-ask` is a kept alias — 2 of the 4 live verbatim booths spell it that
|
||||
way, in the operator's own reports, and the alias is one clause in one selector
|
||||
string. The `<!-- booth:ask … -->` comment forms were **dropped, not ported**:
|
||||
zero users across all 21 live booths, and a page that used one falls back to the
|
||||
append path, so its ask still renders.
|
||||
|
||||
## Verification
|
||||
|
||||
431 tests (410 → 431). Live: all 21 booths 200, and each of the four verbatim
|
||||
booths grew by exactly 46 bytes — `len(EMBED_SCRIPT_TAG)`, one append, nothing
|
||||
else. Related: [[2026-09-22-the-browser-became-a-test-surface]],
|
||||
[[2026-09-22-seven-of-seven-falsifiers]],
|
||||
[[2026-09-21-regex-injecting-chrome]].
|
||||
@@ -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,102 @@
|
||||
# U5's two cross-frontier panels — full triage
|
||||
|
||||
**Date:** 2026-09-22 · **Paraphrase:** thread `01M340PNVRS21HPASZT38PXQPN` ·
|
||||
**Conformance:** thread `01M341E9XAPZEFBSPK9HPGAM0S` · **Shipped as:** `v0.3.0`
|
||||
|
||||
Two four-arm artifact-only rounds, dispatched ~30 minutes apart and correctly
|
||||
firewalled: the paraphrase ran the **pre-seam-review** capture (073612), the
|
||||
conformance round the **SR-amended** one (074901). Heid diffed the two at
|
||||
intake and said so.
|
||||
|
||||
The conformance round's honest headline is Kimi's: **zero drift in the strict
|
||||
sense — the code is a clause-for-clause implementation of the contract.** Both
|
||||
rounds' weight landed one layer down, in test strength and contract finish.
|
||||
|
||||
## The result worth keeping
|
||||
|
||||
**A paraphrase panel reading nothing but prose reached a production outage two
|
||||
modules away.** 3-of-4 flagged INV-2's "4 GB" case as *letter-compliant but
|
||||
purpose-defeating* — the invariant constrained the RETURN, not the cost, so an
|
||||
unbounded read "recreates the outage in slow motion". The conformance round then
|
||||
found that exact unbounded read live in U5's shipped code. Walking it to the
|
||||
sibling module found the same hole **live in released `v0.2.2`**: `marks.py`'s
|
||||
`_read_raw` catches `(OSError, ValueError, UnicodeDecodeError)`, and
|
||||
`json.loads` on a deeply nested document raises **RecursionError**, which is
|
||||
none of them. A 400 KB file of nothing but brackets in any ONE booth returned
|
||||
500 for `/` and `/healthz` across all 26.
|
||||
|
||||
**The v0.2.2 round had flagged this and I closed half of it.** Kimi's R5(c)
|
||||
named RecursionError explicitly; I adopted "wrap `_hydrate` per-entry" and left
|
||||
the `json.loads` above it unguarded. **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 formally out of scope.** Heid captured it as its own
|
||||
lesson.
|
||||
|
||||
## The densest class was tests that could not fail
|
||||
|
||||
Five of ten adopted conformance findings were tests of mine that pass on the
|
||||
regression they exist to catch. Three shared one shape — **asserting an
|
||||
ARTIFACT of the property instead of the property**:
|
||||
|
||||
| test | asserted | should have asserted |
|
||||
|---|---|---|
|
||||
| `test_the_write_is_atomic` | no `*.tmp` survived | the inode changes (`write_text` leaves no temp file either) |
|
||||
| INV-3 preservation | a stamp survived a window shorter than the stamp's own resolution | a stamp from 2019 |
|
||||
| `test_announcing_is_activity` | age via the directory mtime, which the write bumps either way | the file's own mtime, directory clock restored |
|
||||
|
||||
That is the same shape as the marks round's guard-strength finding the night
|
||||
before — **three nights running**. Proposed to heid as a standing
|
||||
"green-tests-prove-nothing" direction for the skill; routed to the operator
|
||||
alongside two other methodology proposals from the same night.
|
||||
|
||||
⚠ **My first replacement for the atomicity test was ALSO vacuous.** It spied on
|
||||
`os.open` to prove the published path was never written directly — which passes
|
||||
trivially, because `Path.write_text` reaches the syscall through `io.open` in C
|
||||
and never touches the Python-level `os.open`. The dead end is recorded in the
|
||||
test's own docstring rather than deleted.
|
||||
|
||||
## Two real bugs the tests were structurally blind to
|
||||
|
||||
**`booth new x --why "…"` then `booth add x out/*.png` erased the why.** Omitted
|
||||
flags meant empty strings; empty strings overwrote. Two arms predicted it *from
|
||||
the contract's wording alone* — "gains a manifest with no `why`" does not
|
||||
distinguish a first write from a re-announce with the flags omitted. Every test
|
||||
written for this module passed `--why` on both calls, so none could see it.
|
||||
Omitted means unchanged now; `--why ""` still clears. The shell carries the
|
||||
distinction by leaving the variable UNSET, not empty.
|
||||
|
||||
**`--title` was write-only** — stored, flag-surfaced, rendered nowhere. 4-of-4,
|
||||
independently top-ranked by every arm of the paraphrase round. It renders on the
|
||||
booth page heading with the directory name kept beside it, because the directory
|
||||
name is the identity the operator navigates by and refers to positionally.
|
||||
|
||||
## Contract-finish, and why it mattered
|
||||
|
||||
**INV-1 contradicted its own falsifiable criterion** (4/4) — "the only place
|
||||
`.booth.json` is opened" versus INV-3's read-back, which forces `write_manifest`
|
||||
to open it. One half was already false of a correct implementation. Restated as
|
||||
*one module knows the filename*, which is true, falsifiable and now tested.
|
||||
|
||||
**INV-5 named two different promises** (3/4) — the repo's atomic-write rule and
|
||||
this unit's render rule. Repo-wide rules are named in words now, never by a bare
|
||||
number that can collide with a local one.
|
||||
|
||||
Regin's meta-observation is the round's methodology keeper and was borne out:
|
||||
**flags cluster where the same rule is re-voiced per signature**, and four of
|
||||
eleven contract edits were reconciling a docstring against a prose section
|
||||
saying the same thing slightly differently. A table-vs-signature consistency
|
||||
pass would beat the format's prose bias.
|
||||
|
||||
## Declined / parked
|
||||
|
||||
- **Custom booth pages skip provenance** (hulda, solo, verified) — settled
|
||||
independently as U3's seam ~20 minutes before the reply landed. Convergence,
|
||||
not an adoption.
|
||||
- **Empty-handle coercion misattributes to the service** — kept, documented. A
|
||||
manifest naming no handle does not read back at all, and an unreadable file is
|
||||
the worse outcome. Unreachable from the CLI.
|
||||
- **`used`-set: `touches` versus SR-1 unreconciled** — the code adds the entry
|
||||
as consistency with the equally-unreachable `UPLOAD_MARKER` entry that
|
||||
predates this unit, and says so rather than claiming it prevents anything.
|
||||
@@ -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.
|
||||
+107
-268
@@ -21,277 +21,116 @@ _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 the next unit** (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, U3, U4 and U5 are landed — the whole middle tier is closed.** U1
|
||||
`ce598b3`; U2 `c7f9437` → `v0.2.0`, `5e41108` → `v0.2.1`, `026a1fc` →
|
||||
`v0.2.2`; U5 `c015a91` + `95beede` → `v0.3.0`; U4 `c3a97c1` → `v0.4.0`.
|
||||
**U3 landed 2026-09-22 and released as `v0.5.0`** — 444 tests green
|
||||
(410 → 444), deployed and verified live, 23/23 booth pages 200, and each of
|
||||
the four verbatim booths served at exactly +46 bytes, which is
|
||||
`len(EMBED_SCRIPT_TAG)` — one append, nothing else. `87e2c53` is the unit,
|
||||
`5c20e2f` the panel fixes. Operator approved the minor and authorised the
|
||||
push on 2026-09-22; **this is the first push of this repo's history** — it was
|
||||
26 commits ahead of `origin/main` before it, so every earlier tag reached the
|
||||
remote at the same time.
|
||||
- **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 are NOT being told** that `keep` no longer means
|
||||
"waiting on an answer" — operator decision, 2026-09-22, no broadcast. This is
|
||||
deliberate and it CHANGES HOW THE 2026-10-06 RE-COUNT READS: the hold rides
|
||||
for free, but not-pressing-`keep` has to be learned, so a flat `.forever` rate
|
||||
does not falsify anything. Read its entry before measuring.
|
||||
- **TWO UNITS LEFT TO v1, and they do not depend on each other.** U6 (benches,
|
||||
independent, closes the 69% link-board rot) and U7 (navigation at 270 items,
|
||||
which U3 just unblocked — its only dependency was {U3, U4, U5}). Which goes
|
||||
next is the operator's call. ⚠ Before starting U7, read
|
||||
`persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md`: every booth
|
||||
that actually needs navigation is FLAT, so half its premise is already known
|
||||
to be wrong.
|
||||
- **U3's tier was MINOR and the operator approved it** (2026-09-22). The
|
||||
argument that settled it, recorded because the tie-break rule says patch: a
|
||||
capability arrived AND one left — the verbatim path gained a declared public
|
||||
API (`<script src="/_booth/embed.js" defer>`) and lost no-JavaScript
|
||||
operation. That asymmetry is what made it not a tie.
|
||||
- **ALL FOUR U3 GATES ARE CLOSED.** In-session seam review (5 findings, SR-2 a
|
||||
real payload-shape bug); `/heid-contract-review`
|
||||
(`01M351WKV666D681SSRNY7D7X6`, 12 findings, 10 adopted, 2 already settled by
|
||||
the seam review while it was in flight, 1 declined);
|
||||
`/heid-code-review` (`01M352RXV1ZET566KV73C7TSB8`, 3 more vacuous falsifiers
|
||||
+ the prototype-pollution bug); `/heid-bug-hunt`
|
||||
(`01M352TPCSN52G6NGJ07T5WSGY`, 5 net-new, incl. the byte-exactness break).
|
||||
**Seven of the adopted findings were CODE fixes, not wording** — the cold
|
||||
gates were not ceremony on this unit. The **seam review ran in-session and is
|
||||
folded in** — five findings as a table at the end of the U3 contract, and SR-2
|
||||
was a real payload-shape bug the cold panel structurally could not see. U4's three and U5's three are all closed
|
||||
(`01M34VX0SH23Y3VC92E7GM4S70`, `01M34WAFJC3RTERFYBBZJN1SVG`,
|
||||
`01M34Y2R0RAJRSN36Q8K4KAB36`; `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.
|
||||
- **FOUR methodology proposals sit with the operator, all UNTRACKED BY OPERATOR
|
||||
CHOICE** (no issue, no ticket — they are `/heid*` skill changes, not this
|
||||
repo's work, and are recorded here only so they are not lost). Three are from
|
||||
the U5 round: 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.
|
||||
The fourth is new and is the one with evidence behind it: a **contract-time
|
||||
VACUITY PASS** — for each invariant, name a change that defeats it and check
|
||||
the test goes red. Regin and Kimi proposed it independently on the U4
|
||||
paraphrase round; the code-review panel then showed five of seven U4
|
||||
falsifiers were vacuous, and heid rates that the strongest single data point
|
||||
for it so far. See `persistent-memory.d/2026-09-22-vacuous-falsifiers.md`.
|
||||
- The booth set churns hard: 26 → 24 → 25 across the last two sessions as the
|
||||
sweeper ran. Re-count rather than trusting any number written here.
|
||||
|
||||
## Recent decisions
|
||||
|
||||
- `[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]` **U3 landed — the page declares the seam, the Booth mounts into it** — ten regexes against author HTML replaced by a substring test and a `+` → `persistent-memory.d/2026-09-22-u3-declared-embed-seam-landed.md`
|
||||
- `[2026-09-22]` **A wrong-shaped answer 500s the gallery and the marks page** — PRE-EXISTING (measured at `42ea67f`), NOT U3; the v0.2.2 lesson is only half-implemented → `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`
|
||||
- `[2026-09-22]` **The browser became a test surface** — READ BEFORE TOUCHING `playwright` IN pyproject; the pinned upper bound is the foot-gun, and these tests SKIP rather than fail → `persistent-memory.d/2026-09-22-the-browser-became-a-test-surface.md`
|
||||
- `[2026-09-22]` **A vacuity pass that tries the contract's own mutation agrees with itself** — U3 ran one, reported 7/7, and a cold panel then showed one of the seven was vacuous; READ BEFORE WRITING A *Falsifiable:* LINE → `persistent-memory.d/2026-09-22-seven-of-seven-falsifiers.md`
|
||||
- `[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]` **No fleetwide notice for U4, and what that does to the prediction** — READ BEFORE THE 2026-10-06 RE-COUNT; a flat rate does not falsify the diagnosis → `persistent-memory.d/2026-09-22-no-notice-and-what-it-does-to-the-prediction.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`
|
||||
|
||||
+10
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "booth"
|
||||
version = "0.2.2"
|
||||
version = "0.5.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 = [
|
||||
@@ -15,6 +15,15 @@ dependencies = [
|
||||
test = [
|
||||
"pytest>=8.0",
|
||||
"httpx>=0.27", # fastapi TestClient
|
||||
# U3's embed seam moves placement into the browser, where no string
|
||||
# assertion can see it. Browsers are NOT downloaded per project: they live
|
||||
# box-wide in /opt/ms-playwright with PLAYWRIGHT_BROWSERS_PATH wired
|
||||
# globally. THE UPPER BOUND IS THE POINT -- each playwright release pins a
|
||||
# Chromium revision, and one that wants a revision the shared store does
|
||||
# not have dies with an opaque "Executable doesn't exist" rather than a
|
||||
# missing-dependency error. 1.60-1.62 map to chromium 1223/1228/1234, all
|
||||
# present. Raise the bound only after the store has the newer revision.
|
||||
"playwright>=1.60,<1.63",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
+143
-17
@@ -3,13 +3,17 @@
|
||||
# folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL
|
||||
# back.
|
||||
#
|
||||
# booth new <name> make an empty booth, print its URL
|
||||
# booth add <name> <file>... copy files into a booth (creates it), print URL
|
||||
# booth new <name> [--why W] [--title T]
|
||||
# make an empty booth, print its URL
|
||||
# booth add <name> <file>... [--why W] [--title T]
|
||||
# copy files into a booth (creates it), print URL
|
||||
# booth url <name> print a booth's URL
|
||||
# booth ls list booths (kept ones marked ★)
|
||||
# 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
|
||||
@@ -28,7 +32,11 @@
|
||||
# how a broken `.marks.json` used to look like an unanswered question and wait
|
||||
# out the full hour.
|
||||
# marks 0 read ok · 1 --wait timed out with picks open · 3 unreadable
|
||||
# answer 0 answered · 1 unanswered · 2 no such pick · 3 unreadable
|
||||
# answer 0 answered · 1 unanswered · 2 no such pick · 3 unreadable ·
|
||||
# 4 the pick hydrated broken and can never be answered
|
||||
#
|
||||
# `answer` and `marks` use the SAME openness predicate. A partially-answered
|
||||
# pick is still open to both; a broken one is closed to both.
|
||||
# booth marks-import <name> import legacy *.ask.json into .marks.json
|
||||
# booth asks <name> alias for `marks` (deprecated)
|
||||
#
|
||||
@@ -53,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
|
||||
@@ -66,15 +90,29 @@
|
||||
# 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
|
||||
# instead, with provenance, so they outlive the session that produced them.
|
||||
#
|
||||
# ANNOUNCE YOUR BOOTH. `--why` is one line saying what the operator is looking
|
||||
# at and why he should care; it lands on the index card and on the booth page
|
||||
# beside your handle, taken from $ALTHING_HANDLE. It is optional and nothing
|
||||
# breaks without it — but a booth that cannot say what it is has no way to ask
|
||||
# for attention except by posting its URL somewhere, which is exactly how the
|
||||
# link board came to be 69% dead rows. The booth is the place to say it.
|
||||
#
|
||||
# booth add r18-ab out/*.png --why "pick the denoiser, left column is v3"
|
||||
#
|
||||
# Re-announcing (a second `new` or `add` on the same booth) updates the why and
|
||||
# KEEPS the original creation stamp: the booth appeared once.
|
||||
#
|
||||
# On a host that is NOT nh3-dev, rsync into the data dir instead, e.g.:
|
||||
# rsync -a ./out/ nh3-dev:booth-data/my-run/
|
||||
set -euo pipefail
|
||||
@@ -85,23 +123,90 @@ KEEP=".forever" # must match KEEP_MARKER in b
|
||||
BLUR=".blurred" # one booth-relative item path per line; see `blur` below
|
||||
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
|
||||
|
||||
# `--why` / `--title` for `new` and `add`. Pulled out of "$@" wherever they
|
||||
# appear, so `booth add b *.png --why "..."` and `booth add b --why "..." *.png`
|
||||
# both work — a glob is usually last and a flag usually after it, but nothing
|
||||
# enforces that and a session should not have to care.
|
||||
# OMITTED IS NOT EMPTY. `booth new x --why "..."` then `booth add x out/*.png`
|
||||
# is the ordinary sequence, and while an omitted flag meant "" the second
|
||||
# command silently erased the sentence the first one existed to record. So the
|
||||
# shell tracks WHETHER the flag was given, and only passes it on when it was —
|
||||
# an explicit `--why ""` still clears, which is a different intention.
|
||||
WHY=""; TITLE=""; WHY_SET=0; TITLE_SET=0; ARGS=()
|
||||
strip_announce_flags() {
|
||||
ARGS=(); WHY_SET=0; TITLE_SET=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--why) [ $# -ge 2 ] || usage; WHY="$2"; WHY_SET=1; shift 2 ;;
|
||||
--title) [ $# -ge 2 ] || usage; TITLE="$2"; TITLE_SET=1; shift 2 ;;
|
||||
--why=*) WHY="${1#--why=}"; WHY_SET=1; shift ;;
|
||||
--title=*) TITLE="${1#--title=}"; TITLE_SET=1; shift ;;
|
||||
*) ARGS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Announce a booth. Goes through booth/manifest.py rather than printf-ing JSON
|
||||
# from the shell, because a why containing a quote, a backslash or a newline is
|
||||
# not an edge case — it is a sentence somebody wrote.
|
||||
# announce <dir> <handle> [title] [why] — the trailing two are passed as
|
||||
# environment variables that are UNSET when the flag was not given, because
|
||||
# that is the only way the shell can say "leave it alone" rather than "".
|
||||
announce() {
|
||||
local -a envs
|
||||
envs=( "BOOTH_SRC=$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)"
|
||||
"BOOTH_ANN_DIR=$1" "BOOTH_ANN_HANDLE=$2" )
|
||||
[ "${TITLE_SET:-0}" = 1 ] && envs+=( "BOOTH_ANN_TITLE=${3:-}" )
|
||||
[ "${WHY_SET:-0}" = 1 ] && envs+=( "BOOTH_ANN_WHY=${4:-}" )
|
||||
env "${envs[@]}" python3 -c '
|
||||
import os, pathlib, sys
|
||||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||
try:
|
||||
from booth.manifest import write_manifest
|
||||
kw = {}
|
||||
# Absent means the flag was omitted; present-and-empty means it was given
|
||||
# as "" and the poster meant to take the line back.
|
||||
if "BOOTH_ANN_TITLE" in os.environ: kw["title"] = os.environ["BOOTH_ANN_TITLE"]
|
||||
if "BOOTH_ANN_WHY" in os.environ: kw["why"] = os.environ["BOOTH_ANN_WHY"]
|
||||
write_manifest(pathlib.Path(os.environ["BOOTH_ANN_DIR"]),
|
||||
os.environ["BOOTH_ANN_HANDLE"], **kw)
|
||||
except Exception as exc:
|
||||
# A booth that could not announce itself is still a booth. Say so on stderr
|
||||
# and carry on: failing `booth add` over its metadata would lose the files
|
||||
# the session just copied, which is a far worse trade.
|
||||
print(f"booth: could not write the announcement: {exc}", file=sys.stderr)
|
||||
'
|
||||
}
|
||||
|
||||
# Who is posting. The same chain `link` uses for its rows, so provenance means
|
||||
# the same thing on the board and on the card.
|
||||
whoami_handle() {
|
||||
echo "${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>}" >&2
|
||||
echo "usage: booth {new <name> [--why W] [--title T]|add <name> <file>... [--why W] [--title T]|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>}" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
cmd="${1:-}"; shift || true
|
||||
case "$cmd" in
|
||||
new)
|
||||
strip_announce_flags "$@"
|
||||
set -- ${ARGS+"${ARGS[@]}"}
|
||||
[ $# -ge 1 ] || usage
|
||||
mkdir -p -- "$DATA/$1"
|
||||
announce "$DATA/$1" "$(whoami_handle)" "$TITLE" "$WHY"
|
||||
echo "$URL/b/$1/"
|
||||
;;
|
||||
add)
|
||||
strip_announce_flags "$@"
|
||||
set -- ${ARGS+"${ARGS[@]}"}
|
||||
[ $# -ge 2 ] || usage
|
||||
name="$1"; shift
|
||||
mkdir -p -- "$DATA/$name"
|
||||
cp -- "$@" "$DATA/$name/"
|
||||
announce "$DATA/$name" "$(whoami_handle)" "$TITLE" "$WHY"
|
||||
echo "$URL/b/$name/"
|
||||
;;
|
||||
url)
|
||||
@@ -177,6 +282,11 @@ case "$cmd" in
|
||||
board="$DATA/$LINKS_BOARD"
|
||||
mkdir -p -- "$board"
|
||||
: > "$board/$KEEP" # the board is durable by definition
|
||||
# The board announces itself as the SERVICE's, not as any one agent's:
|
||||
# seventeen handles post to it, so no handle owns it. Idempotent — a second
|
||||
# link keeps the original creation stamp.
|
||||
TITLE_SET=1 WHY_SET=1 announce "$board" "booth" "$LINKS_BOARD" \
|
||||
"the standing link board — every agent session posts here"
|
||||
# Provenance, because a bare URL is unreadable three days later: who posted
|
||||
# it, from where, and when.
|
||||
who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
|
||||
@@ -347,7 +457,7 @@ sys.exit(2 if open_marks(marks) else 0)
|
||||
import json, os, pathlib, sys
|
||||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||
try:
|
||||
from booth.marks import marks_for, read_error
|
||||
from booth.marks import marks_for, open_marks, read_error
|
||||
booth, mid = sys.argv[1:3]
|
||||
broken = read_error(pathlib.Path(booth))
|
||||
if broken:
|
||||
@@ -356,14 +466,27 @@ try:
|
||||
# id AND shape, matching the web route. Matching on id alone reported a
|
||||
# note id as "unanswered" and then polled it for an hour — a question that
|
||||
# could never be answered because it was never a question.
|
||||
m = next((x for x in marks_for(pathlib.Path(booth))
|
||||
if x.id == mid and x.shape == "pick"), None)
|
||||
marks = marks_for(pathlib.Path(booth))
|
||||
m = next((x for x in marks if x.id == mid and x.shape == "pick"), None)
|
||||
# THE openness predicate, not a second spelling of it. `answer is None` is
|
||||
# what this read used to test, and it disagreed with `marks --wait` on a
|
||||
# PARTIALLY answered pick: one verb returned the half-filled form while the
|
||||
# other blocked on the same booth at the same instant. U2 put openness in
|
||||
# one function precisely so the two could not drift.
|
||||
still_open = m is not None and m in open_marks(marks)
|
||||
except Exception as exc:
|
||||
print(f"booth: cannot read marks: {exc}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
if m is None:
|
||||
sys.exit(2)
|
||||
if m.answer is None:
|
||||
if m.error:
|
||||
# Not open, and never going to be: the web route refuses this form with a
|
||||
# 400, so waiting on it is waiting on nothing. `marks --wait` already
|
||||
# returns immediately here; this is the other half of that agreement.
|
||||
print(f"booth: pick is broken and cannot be answered: {m.error}",
|
||||
file=sys.stderr)
|
||||
sys.exit(4)
|
||||
if still_open:
|
||||
sys.exit(1)
|
||||
print(json.dumps(m.answer, ensure_ascii=False, indent=2))
|
||||
' "$DATA/$name" "$mid")" || rc=$?
|
||||
@@ -374,6 +497,9 @@ print(json.dumps(m.answer, ensure_ascii=False, indent=2))
|
||||
# spinning for the full hour on a broken file and then blamed the
|
||||
# operator for not answering.
|
||||
3) echo "cannot read marks in $name" >&2; exit 3 ;;
|
||||
# A pick that hydrated broken is refused by the web route, so no answer
|
||||
# can ever land. Waiting on it is waiting on nothing.
|
||||
4) exit 4 ;;
|
||||
esac
|
||||
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#mark-$mid" >&2; exit 1; fi
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
|
||||
+42
-1
@@ -19,7 +19,22 @@ USAGE
|
||||
<a python with playwright> scripts/layout-probe.py [URL ...]
|
||||
|
||||
Exits 0 if every control is hittable, 1 if any is occluded. No arguments
|
||||
probes the booth index and every booth linked from it.
|
||||
probes the INDEX ONLY — it does not follow booth links, and the docstring
|
||||
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[@]}"`.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
@@ -62,6 +77,32 @@ def probe(page, url: str) -> list[str]:
|
||||
card.hover(timeout=1500)
|
||||
except Exception:
|
||||
pass
|
||||
# ⚠ OPEN EVERY <details> FIRST. A control inside a CLOSED one is laid out
|
||||
# but sits outside its collapsed parent's box, so `elementFromPoint` at its
|
||||
# centre returns an ancestor and it reports OCCLUDED — 23 of them on
|
||||
# `sindra-set`, every one a false positive, because the only way an operator
|
||||
# reaches that button is by opening the disclosure first. Verified both
|
||||
# ways: closed -> elementFromPoint returns div.gallery; opened -> the button
|
||||
# itself, and a real trial click lands on it.
|
||||
#
|
||||
# Opening rather than SKIPPING is deliberate. Skipping would make the probe
|
||||
# quiet by declaring put-away controls out of scope, and the add-note button
|
||||
# inside `details.item-addnote` is exactly the kind of control this
|
||||
# instrument exists to check. Open it and ask the real question.
|
||||
#
|
||||
# ⚠ ONE evaluate over the whole document, NOT a locator loop. `.all()` hands
|
||||
# back positional locators that re-resolve against the CURRENT DOM, and
|
||||
# `details:not([open])` stops matching an element the moment it is opened —
|
||||
# so opening them one at a time shrinks the set underneath the indices and
|
||||
# some are never opened at all. That left exactly the closed-<details>
|
||||
# false positives this block exists to remove: 1 on booth-redesign, 3 on
|
||||
# cr123a-to-d-sleeve, stable across five runs and invisible as a bug
|
||||
# because a false positive looks like a finding. Measured both ways at
|
||||
# 150 ms and 1000 ms settle: the loop reports them at either wait, the
|
||||
# single pass reports none at either. The variable was the method, not the
|
||||
# timing.
|
||||
page.evaluate("document.querySelectorAll('details').forEach(d => d.open = true)")
|
||||
page.wait_for_timeout(150)
|
||||
for el in page.locator("button, a.dl-link, a.thumb").all():
|
||||
try:
|
||||
# ⚠ elementFromPoint is VIEWPORT-relative. Without scrolling first,
|
||||
|
||||
+71
-67
@@ -13,7 +13,7 @@ import pathlib
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from booth.app import build_gallery, create_app, list_booths
|
||||
from booth.app import EMBED_SCRIPT_TAG, build_gallery, create_app, list_booths
|
||||
from booth.asks import (
|
||||
ANSWER_SUFFIX,
|
||||
ASK_SUFFIX,
|
||||
@@ -410,32 +410,37 @@ def test_declare_pick_accepts_a_full_multi_doc(tmp_path):
|
||||
# into the verbatim page plus a standalone /asks page that carries the forms.
|
||||
|
||||
|
||||
def test_verbatim_booth_renders_the_ask_inline(client):
|
||||
def test_verbatim_booth_offers_the_ask_over_the_seam(client):
|
||||
"""U3: the report is served as written and the ask crosses the declared seam.
|
||||
Before U3 the fragments were substituted into the page body by regex; the
|
||||
guarantee that the ask is reachable FROM THE REPORT, not from another page,
|
||||
is unchanged — it is the delivery that moved."""
|
||||
c, data = client
|
||||
b = _ask(data / "b")
|
||||
(b / "index.html").write_text("<!doctype html><title>report</title><body>hi</body>")
|
||||
html = c.get("/b/b/").text
|
||||
assert "hi" in html # the report is still served verbatim
|
||||
assert "Which render wins?" in html # ...with the ask ON it, not elsewhere
|
||||
assert 'type="radio"' in html and 'action="/b/b/answer"' in html
|
||||
assert "bk-ask" in html # self-contained fragment styles
|
||||
assert "booth-nav-asks" in html # chip remains, as a jump link
|
||||
assert "#bk-ask-winner-top" in html
|
||||
assert "Which render wins?" not in html # ...and NOTHING was injected into it
|
||||
assert html.endswith(EMBED_SCRIPT_TAG)
|
||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||
assert "Which render wins?" in m["whole"]
|
||||
assert 'type="radio"' in m["whole"] and 'action="/b/b/answer"' in m["submit"]
|
||||
|
||||
|
||||
def test_verbatim_chip_disappears_once_answered(client):
|
||||
c, data = client
|
||||
b = _ask(data / "b")
|
||||
(b / "index.html").write_text("<!doctype html><body>hi</body>")
|
||||
assert c.get("/b/b/embed.json").json()["open"] == ["winner"]
|
||||
answer_pick(b, "winner", "A — baseline")
|
||||
assert "booth-nav-asks" not in c.get("/b/b/").text
|
||||
assert c.get("/b/b/embed.json").json()["open"] == []
|
||||
|
||||
|
||||
def test_verbatim_booth_without_asks_is_untouched(client):
|
||||
c, data = client
|
||||
(data / "b").mkdir()
|
||||
(data / "b" / "index.html").write_text("<!doctype html><body>hi</body>")
|
||||
assert "booth-nav-asks" not in c.get("/b/b/").text
|
||||
assert c.get("/b/b/embed.json").json()["marks"] == []
|
||||
|
||||
|
||||
def test_asks_page_renders_forms_and_answers_back_to_itself(client):
|
||||
@@ -477,42 +482,64 @@ def test_asks_page_shows_a_single_ask_title(client):
|
||||
assert "emmie — pick the anchor" in c.get("/b/b/marks").text
|
||||
|
||||
|
||||
# ---- inline placement in a verbatim report -----------------------------------
|
||||
# ---- placement in a verbatim report ------------------------------------------
|
||||
#
|
||||
# Operator verdict 2026-09-09 on the separate /asks page: "the asks should be
|
||||
# inline with the artifacts, not on a separate page." A four-voice audition wants
|
||||
# each voice's radio group under that voice's audio, and one submit for the lot.
|
||||
|
||||
#
|
||||
# U3 kept the semantics and moved the mechanism. The author still marks up where
|
||||
# each piece goes; the pieces are still rendered by the `_ask_inline.html`
|
||||
# macros; they now reach the page through `/b/<name>/embed.json` and are mounted
|
||||
# by `/_booth/embed.js` instead of substituted into the author's tags by regex.
|
||||
#
|
||||
# So the placement ASSERTIONS moved too, and where each half lives is not
|
||||
# arbitrary: what the server offers is checked here, in Python; where it LANDS,
|
||||
# and whether a form scattered down a report actually submits, is checked in
|
||||
# tests/test_embed_browser.py against a real DOM. No string assertion can see
|
||||
# the second thing, and that is exactly the part the operator depends on.
|
||||
|
||||
REPORT = """<!doctype html><title>audition</title><body>
|
||||
<h1>Three voices</h1>
|
||||
<section id="lawson"><audio src="a.wav"></audio>
|
||||
<div data-booth-ask="batch:r1"></div></section>
|
||||
<section id="jo"><audio src="b.wav"></audio>
|
||||
<!-- booth:ask batch:r2 --></section>
|
||||
<div data-booth-mark="batch:r2"></div></section>
|
||||
<div data-booth-ask-submit="batch"></div>
|
||||
<script src="/_booth/embed.js" defer></script>
|
||||
</body>"""
|
||||
|
||||
|
||||
def test_per_question_placeholders_land_where_the_author_put_them(client):
|
||||
def test_the_author_markup_is_never_touched_by_the_server(client):
|
||||
"""The whole point of the seam. A page that declares it comes back exactly
|
||||
as written — placeholders still empty, waiting for the DOM."""
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
(b / "index.html").write_text(REPORT)
|
||||
html = c.get("/b/b/").text
|
||||
# each group is inside its own section, in document order
|
||||
lawson = html.index('id="lawson"')
|
||||
jo = html.index('id="jo"')
|
||||
assert lawson < html.index('name="choice.r1"') < jo
|
||||
assert jo < html.index('name="choice.r2"')
|
||||
# one shared form, bound by the HTML5 form= attribute, submitted once
|
||||
assert html.count('<form id="bk-ask-form-batch"') == 1
|
||||
assert html.count('action="/b/b/answer"') == 1
|
||||
assert html.count('form="bk-ask-form-batch"') >= 4
|
||||
# the submit block landed at its own placeholder, not appended after </body>
|
||||
assert html.index("bk-ask-form-batch") < html.index("</body>")
|
||||
assert c.get("/b/b/").text == REPORT
|
||||
|
||||
|
||||
def test_inline_form_submits_every_question_in_one_post(client):
|
||||
def test_every_piece_the_author_can_place_is_offered(client):
|
||||
"""One fragment per addressable piece: the whole ask, each question, and the
|
||||
submit block that carries the shared <form>. The author's markup decides
|
||||
which are used; the payload never decides for them."""
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||
assert [q["key"] for q in m["questions"]] == ["r1", "r2"]
|
||||
assert 'name="choice.r1"' in m["questions"][0]["html"]
|
||||
assert 'name="choice.r2"' in m["questions"][1]["html"]
|
||||
# ONE form, and it lives with the submit block, so question groups scattered
|
||||
# down a report bind to it by id from wherever they sit.
|
||||
assert m["submit"].count('<form id="bk-ask-form-batch"') == 1
|
||||
assert m["submit"].count('action="/b/b/answer"') == 1
|
||||
assert 'form="bk-ask-form-batch"' in m["questions"][0]["html"]
|
||||
assert 'form="bk-ask-form-batch"' in m["questions"][1]["html"]
|
||||
|
||||
|
||||
def test_a_scattered_form_still_posts_as_one_answer(client):
|
||||
"""The POST half of the multi-question guarantee, which U3 did not touch:
|
||||
every question in one request, or the route refuses it."""
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
(b / "index.html").write_text(REPORT)
|
||||
@@ -521,44 +548,21 @@ def test_inline_form_submits_every_question_in_one_post(client):
|
||||
assert r.status_code == 303
|
||||
ans = _answer_of(b, "batch")
|
||||
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r2"]["choice"] == "d"
|
||||
# and the recorded pick now shows inline, on the report itself
|
||||
html = c.get("/b/b/").text
|
||||
assert "recorded:" in html and "bk-done" in html
|
||||
assert 'value="keep" required checked' in html.replace("\n", " ") or "checked" in html
|
||||
# and the recorded pick comes back marked answered, on the report's own seam
|
||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||
assert "recorded:" in m["whole"] and "bk-done" in m["whole"]
|
||||
assert "checked" in m["questions"][0]["html"]
|
||||
|
||||
|
||||
def test_whole_ask_placeholder_renders_everything_there(client):
|
||||
c, data = client
|
||||
b = _ask(data / "b")
|
||||
(b / "index.html").write_text('<!doctype html><body><p>x</p><div data-booth-ask="winner"></div></body>')
|
||||
html = c.get("/b/b/").text
|
||||
assert html.index("Which render wins?") > html.index("<p>x</p>")
|
||||
assert html.index("bk-ask-go") < html.index("</body>") # submit placed inline too
|
||||
|
||||
|
||||
def test_placeholder_for_a_missing_ask_is_left_alone(client):
|
||||
c, data = client
|
||||
b = _ask(data / "b")
|
||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="typo"></div></body>')
|
||||
html = c.get("/b/b/").text
|
||||
assert 'data-booth-ask="typo"' in html # author's markup untouched, not blanked
|
||||
assert "Which render wins?" in html # the real ask still appended, never lost
|
||||
|
||||
|
||||
def test_questions_placed_without_a_submit_still_get_one(client):
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch:r1"></div></body>')
|
||||
html = c.get("/b/b/").text
|
||||
assert html.count('<form id="bk-ask-form-batch"') == 1 # appended, so it is submittable
|
||||
assert 'name="choice.r2"' in html # r2 unplaced -> must still appear
|
||||
|
||||
|
||||
def test_styles_are_emitted_once(client):
|
||||
def test_the_page_carries_no_fragment_styles(client):
|
||||
"""`styles()` is gone from the template: the scoped `.bk-ask-*` rules live in
|
||||
embed.js, next to the code that mounts them. One asset, emitted once by
|
||||
construction rather than by a seen-set."""
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
(b / "index.html").write_text(REPORT)
|
||||
assert c.get("/b/b/").text.count(".bk-ask-opt:has(input:checked)") == 1
|
||||
assert ".bk-ask-opt:has(input:checked)" not in c.get("/b/b/").text
|
||||
assert c.get("/_booth/embed.js").text.count(".bk-ask-opt:has(input:checked)") == 1
|
||||
|
||||
|
||||
def test_radios_are_not_html_required_anywhere(client):
|
||||
@@ -566,20 +570,20 @@ def test_radios_are_not_html_required_anywhere(client):
|
||||
is exactly what stopped the operator leaving one blank."""
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
assert "required" not in c.get("/b/b/").text
|
||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
|
||||
assert "required" not in c.get("/b/b/").text
|
||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||
assert "required" not in m["whole"]
|
||||
assert not any("required" in q["html"] for q in m["questions"])
|
||||
assert "required" not in c.get("/b/b/marks").text
|
||||
|
||||
|
||||
def test_partial_answer_renders_as_skipped_inline(client):
|
||||
def test_partial_answer_renders_as_skipped(client):
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
|
||||
(b / "index.html").write_text(REPORT)
|
||||
c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"})
|
||||
html = c.get("/b/b/").text
|
||||
assert "bk-skip" in html and "left blank" in html
|
||||
assert "1 of 2 answered" in html
|
||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||
assert "bk-skip" in m["whole"] and "left blank" in m["whole"]
|
||||
assert "1 of 2 answered" in m["submit"]
|
||||
|
||||
|
||||
def test_empty_submission_is_refused_with_400(client):
|
||||
|
||||
+28
-58
@@ -16,7 +16,7 @@ from booth.app import (
|
||||
remove_link_entry,
|
||||
toggle_pin,
|
||||
booth_age_seconds,
|
||||
FAVICON_LINK,
|
||||
EMBED_SCRIPT_TAG,
|
||||
KEEP_MARKER,
|
||||
build_gallery,
|
||||
classify,
|
||||
@@ -30,7 +30,6 @@ from booth.app import (
|
||||
render_doc,
|
||||
safe_upload_name,
|
||||
sweep_once,
|
||||
wrap_verbatim_html,
|
||||
)
|
||||
|
||||
PICKUP_RE = re.compile(r"^(\d{1,2}-[a-z]+|[a-z]+-\d{1,2})$")
|
||||
@@ -459,58 +458,21 @@ def test_view_nonviewable_redirects_to_raw(client):
|
||||
assert r.headers["location"] == "/b/run1/data.bin"
|
||||
|
||||
|
||||
# ---- verbatim-index.html wrapper --------------------------------------------
|
||||
# ---- verbatim-index.html serving -------------------------------------------
|
||||
#
|
||||
# U3 replaced the injection wrapper with a declared seam. The five `test_wrap_*`
|
||||
# tests and `test_verbatim_booth_wrapped_with_back_chip` that stood here tested
|
||||
# `wrap_verbatim_html` — six regexes hunting a head-ish seam for a favicon and a
|
||||
# body-ish seam for a chip, plus the doctype and charset-window constraints they
|
||||
# threaded. None of those constraints can be violated by an append, so there is
|
||||
# nothing left of them to assert. What replaced them lives in tests/test_embed.py
|
||||
# (the payload, the one appended tag, whole-body equality for a declaring page)
|
||||
# and tests/test_embed_browser.py (the mount, in a real DOM).
|
||||
#
|
||||
# What stays here is what did NOT change: the file route is still raw.
|
||||
|
||||
|
||||
def test_wrap_injects_chip_and_favicon():
|
||||
html = "<html><head><title>Brief</title></head><body><h1>REPORT</h1></body></html>"
|
||||
out = wrap_verbatim_html(html)
|
||||
assert 'class="booth-nav-home"' in out # floating back chip
|
||||
assert 'href="/"' in out # points at the main booth index
|
||||
assert "all booths" in out
|
||||
assert FAVICON_LINK in out # favicon inherited
|
||||
assert "<h1>REPORT</h1>" in out # original content preserved
|
||||
# favicon lands in the head, chip lands in the body
|
||||
assert out.index(FAVICON_LINK) < out.index("</head>")
|
||||
assert out.index("booth-nav-home") > out.index("<body>")
|
||||
|
||||
|
||||
def test_wrap_respects_existing_favicon():
|
||||
html = '<html><head><link rel="icon" href="data:image/png;base64,AAAA"></head><body>x</body></html>'
|
||||
out = wrap_verbatim_html(html)
|
||||
assert FAVICON_LINK not in out # the page's own icon wins
|
||||
assert out.count('rel="icon"') == 1
|
||||
assert 'class="booth-nav-home"' in out # chip is still added
|
||||
|
||||
|
||||
def test_wrap_bare_fragment_appends_chip():
|
||||
out = wrap_verbatim_html("<h1>bare fragment</h1>") # no doctype/head/body
|
||||
assert 'class="booth-nav-home"' in out
|
||||
assert out.rstrip().endswith("</style>") # chip appended at the end
|
||||
assert FAVICON_LINK in out # no doctype -> safe to prepend the icon
|
||||
assert out.index(FAVICON_LINK) < out.index("bare") # icon ahead of content (implied head)
|
||||
|
||||
|
||||
def test_wrap_no_head_injects_favicon():
|
||||
out = wrap_verbatim_html("<body><h1>no head</h1></body>")
|
||||
assert 'class="booth-nav-home"' in out
|
||||
assert FAVICON_LINK in out # injected even without an explicit <head>
|
||||
|
||||
|
||||
def test_wrap_compact_doctype_stays_first():
|
||||
# the real-booth shape: compact HTML, no explicit head/body. The injection must
|
||||
# not push anything ahead of the doctype (quirks mode) or past the charset window.
|
||||
html = "<!doctype html><meta charset=utf-8><title>T</title><style>body{margin:0}</style><h1>REPORT</h1>"
|
||||
out = wrap_verbatim_html(html)
|
||||
assert out.lstrip().lower().startswith("<!doctype") # doctype still first -> standards mode
|
||||
assert FAVICON_LINK in out
|
||||
assert out.index(FAVICON_LINK) < out.index("<h1>") # icon in the implied head, before content
|
||||
assert out.index("charset") < 1024 # charset meta stays in the detection window
|
||||
assert 'class="booth-nav-home"' in out
|
||||
assert out.index("booth-nav-home") > out.index("<h1>REPORT</h1>") # chip appended after content
|
||||
|
||||
|
||||
def test_verbatim_booth_wrapped_with_back_chip(client):
|
||||
def test_verbatim_booth_is_served_with_the_seam(client):
|
||||
c, data = client
|
||||
d = data / "brief"
|
||||
d.mkdir()
|
||||
@@ -518,13 +480,11 @@ def test_verbatim_booth_wrapped_with_back_chip(client):
|
||||
r = c.get("/b/brief/")
|
||||
assert r.status_code == 200
|
||||
assert "BRIEF" in r.text # content preserved
|
||||
assert 'class="booth-nav-home"' in r.text # back chip injected
|
||||
assert 'href="/"' in r.text
|
||||
assert 'rel="icon"' in r.text # favicon inherited
|
||||
assert r.text.endswith(EMBED_SCRIPT_TAG) # ...and the seam, appended
|
||||
|
||||
|
||||
def test_verbatim_index_raw_file_route_unwrapped(client):
|
||||
# the file route (/b/<name>/index.html) still serves the raw bytes — the chip
|
||||
# the file route (/b/<name>/index.html) still serves the raw bytes — the seam
|
||||
# only rides on the booth view (/b/<name>/), so downloads/assets stay verbatim
|
||||
c, data = client
|
||||
d = data / "brief"
|
||||
@@ -532,7 +492,7 @@ def test_verbatim_index_raw_file_route_unwrapped(client):
|
||||
(d / "index.html").write_text("<html><body><h1>BRIEF</h1></body></html>")
|
||||
r = c.get("/b/brief/index.html")
|
||||
assert r.status_code == 200
|
||||
assert "booth-nav-home" not in r.text
|
||||
assert "_booth/embed.js" not in r.text
|
||||
|
||||
|
||||
# ---- .md / .txt in-booth doc viewer -----------------------------------------
|
||||
@@ -781,6 +741,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()
|
||||
@@ -1537,7 +1501,13 @@ def test_booth_page_offers_keep_when_ephemeral_and_release_when_kept(client):
|
||||
_png(d / "x.png")
|
||||
|
||||
body = c.get("/b/bo/").text
|
||||
assert "☆ keep" in body and "release" not in body.split("boothhead")[1][:900]
|
||||
# Sliced on the ELEMENT, not the bare word: `boothhead` has appeared in the
|
||||
# stylesheet this page carries since long before this assertion, so
|
||||
# `split("boothhead")[1]` was reading CSS and passing on luck. It went red
|
||||
# the first time a new rule landed above the old one (U5's .prov), which is
|
||||
# the only reason anybody noticed. Same assertion, aimed at the markup.
|
||||
head = body.split('class="boothhead"')[1][:900]
|
||||
assert "☆ keep" in body and "release" not in head
|
||||
|
||||
c.post("/b/bo/keep", data={"next": "/b/bo/"}, follow_redirects=False)
|
||||
body = c.get("/b/bo/").text
|
||||
|
||||
@@ -119,3 +119,230 @@ def test_answer_on_a_note_id_says_no_such_pick(booth):
|
||||
r = run(data, "answer", "b", "note-1")
|
||||
assert r.returncode == NO_SUCH_PICK
|
||||
assert "no such pick" in r.stderr
|
||||
|
||||
|
||||
# ---- U5: self-announcing booths ---------------------------------------------
|
||||
|
||||
|
||||
def _manifest(booth_dir):
|
||||
import sys
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||
from booth.manifest import read_manifest
|
||||
return read_manifest(booth_dir)
|
||||
|
||||
|
||||
def test_new_announces_the_booth(tmp_path):
|
||||
"""`$ALTHING_HANDLE` is the whole provenance story: the session already has
|
||||
it, so the booth can say who made it without anybody typing a name."""
|
||||
env = {**os.environ, "ALTHING_HANDLE": "shutter-dev"}
|
||||
r = subprocess.run([str(SCRIPT), "new", "r18-ab", "--why", "pick the winner"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
env={**env, "BOOTH_DATA_DIR": str(tmp_path),
|
||||
"BOOTH_URL": "http://booth.invalid"})
|
||||
assert r.returncode == 0, r.stderr
|
||||
m = _manifest(tmp_path / "r18-ab")
|
||||
assert m.handle == "shutter-dev"
|
||||
assert m.why == "pick the winner"
|
||||
|
||||
|
||||
def test_new_without_a_why_is_still_legal(tmp_path):
|
||||
"""The flags are optional and existing call sites keep working. A booth
|
||||
that says only who made it is still a booth that said something."""
|
||||
r = subprocess.run([str(SCRIPT), "new", "scratch"], capture_output=True,
|
||||
text=True, timeout=30,
|
||||
env={**os.environ, "ALTHING_HANDLE": "booth-dev",
|
||||
"BOOTH_DATA_DIR": str(tmp_path),
|
||||
"BOOTH_URL": "http://booth.invalid"})
|
||||
assert r.returncode == 0, r.stderr
|
||||
m = _manifest(tmp_path / "scratch")
|
||||
assert m.handle == "booth-dev" and m.why == ""
|
||||
|
||||
|
||||
def test_add_announces_and_still_copies_the_files(tmp_path):
|
||||
"""`add` is the verb most sessions actually use — it creates the booth AND
|
||||
fills it — so the why has to ride on it or it rides nowhere."""
|
||||
src = tmp_path / "src"
|
||||
src.mkdir()
|
||||
(src / "a.txt").write_text("content")
|
||||
r = subprocess.run([str(SCRIPT), "add", "r18-ab", str(src / "a.txt"),
|
||||
"--why", "second pass", "--title", "R18 A/B"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
env={**os.environ, "ALTHING_HANDLE": "booth-dev",
|
||||
"BOOTH_DATA_DIR": str(tmp_path),
|
||||
"BOOTH_URL": "http://booth.invalid"})
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert (tmp_path / "r18-ab" / "a.txt").read_text() == "content"
|
||||
m = _manifest(tmp_path / "r18-ab")
|
||||
assert m.why == "second pass" and m.title == "R18 A/B"
|
||||
|
||||
|
||||
def test_add_re_announcing_keeps_the_original_created(tmp_path):
|
||||
"""The common shape: `new` opens the booth, `add` drops the second batch and
|
||||
sharpens the why. The booth appeared once."""
|
||||
env = {**os.environ, "ALTHING_HANDLE": "booth-dev",
|
||||
"BOOTH_DATA_DIR": str(tmp_path), "BOOTH_URL": "http://booth.invalid"}
|
||||
src = tmp_path / "a.txt"
|
||||
src.write_text("x")
|
||||
subprocess.run([str(SCRIPT), "new", "b", "--why", "first"], check=True,
|
||||
capture_output=True, timeout=30, env=env)
|
||||
first = _manifest(tmp_path / "b").created
|
||||
subprocess.run([str(SCRIPT), "add", "b", str(src), "--why", "sharper"],
|
||||
check=True, capture_output=True, timeout=30, env=env)
|
||||
|
||||
after = _manifest(tmp_path / "b")
|
||||
assert after.created == first
|
||||
assert after.why == "sharper"
|
||||
|
||||
|
||||
def test_the_link_board_announces_itself_as_the_booths_own(tmp_path):
|
||||
"""No exemption list. The standing board is made by the service and posted
|
||||
to by seventeen handles, so no single agent owns it — `booth` is the
|
||||
truthful answer, and it keeps the rule to one line."""
|
||||
r = subprocess.run([str(SCRIPT), "link", "http://example.invalid", "a thing"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
env={**os.environ, "ALTHING_HANDLE": "booth-dev",
|
||||
"BOOTH_DATA_DIR": str(tmp_path),
|
||||
"BOOTH_URL": "http://booth.invalid"})
|
||||
assert r.returncode == 0, r.stderr
|
||||
m = _manifest(tmp_path / "links")
|
||||
assert m is not None and m.handle == "booth"
|
||||
assert m.why
|
||||
|
||||
|
||||
def test_the_flags_can_sit_on_either_side_of_the_files(tmp_path):
|
||||
"""`booth add b *.png --why "..."` and `booth add b --why "..." *.png` both
|
||||
work. A glob is usually last and a flag usually after it, but nothing
|
||||
enforces that and a session should not have to remember which."""
|
||||
src = tmp_path / "a.png"
|
||||
src.write_bytes(b"x")
|
||||
env = {**os.environ, "ALTHING_HANDLE": "booth-dev",
|
||||
"BOOTH_DATA_DIR": str(tmp_path), "BOOTH_URL": "http://booth.invalid"}
|
||||
for name, args in (("after", ["add", "after", str(src), "--why", "w"]),
|
||||
("before", ["add", "before", "--why", "w", str(src)])):
|
||||
r = subprocess.run([str(SCRIPT), *args], capture_output=True, text=True,
|
||||
timeout=30, env=env)
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert _manifest(tmp_path / name).why == "w"
|
||||
assert (tmp_path / name / "a.png").exists(), "the files stopped being copied"
|
||||
|
||||
|
||||
def test_a_why_survives_quotes_and_non_ascii_and_is_flattened(tmp_path):
|
||||
"""The reason this goes through manifest.py instead of printf-ing JSON from
|
||||
the shell: a why containing a quote, a backslash or a newline is not an edge
|
||||
case, it is a sentence somebody wrote. Newlines flatten because the field
|
||||
renders inside a card's sub-line."""
|
||||
r = subprocess.run(
|
||||
[str(SCRIPT), "new", "b", "--why", 'he said "pick v3" — line1\nline2 · ünï'],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
env={**os.environ, "ALTHING_HANDLE": "booth-dev",
|
||||
"BOOTH_DATA_DIR": str(tmp_path), "BOOTH_URL": "http://booth.invalid"})
|
||||
assert r.returncode == 0, r.stderr
|
||||
why = _manifest(tmp_path / "b").why
|
||||
assert why == 'he said "pick v3" — line1 line2 · ünï'
|
||||
|
||||
|
||||
def test_a_flag_with_no_value_does_not_eat_the_booth_name(tmp_path):
|
||||
"""`booth new b --why` with nothing after it must not consume `b` as the
|
||||
value and then create a booth called nothing. Usage, and no directory."""
|
||||
r = subprocess.run([str(SCRIPT), "new", "b", "--why"], capture_output=True,
|
||||
text=True, timeout=30,
|
||||
env={**os.environ, "BOOTH_DATA_DIR": str(tmp_path),
|
||||
"BOOTH_URL": "http://booth.invalid"})
|
||||
assert r.returncode == 2
|
||||
assert "usage:" in r.stderr
|
||||
assert not (tmp_path / "b").exists()
|
||||
|
||||
|
||||
def test_a_bare_add_does_not_wipe_the_why_the_new_set(tmp_path):
|
||||
"""`booth new x --why "..."` then `booth add x out/*.png` is THE sequence,
|
||||
and the second call must not erase the first one's sentence. The module
|
||||
distinguishes omitted from empty; the shell has to carry that distinction
|
||||
across, which means an UNSET variable, not an empty one."""
|
||||
env = {**os.environ, "ALTHING_HANDLE": "booth-dev",
|
||||
"BOOTH_DATA_DIR": str(tmp_path), "BOOTH_URL": "http://booth.invalid"}
|
||||
src = tmp_path / "a.png"
|
||||
src.write_bytes(b"x")
|
||||
|
||||
subprocess.run([str(SCRIPT), "new", "b", "--why", "pick the denoiser",
|
||||
"--title", "R18 A/B"],
|
||||
check=True, capture_output=True, timeout=30, env=env)
|
||||
subprocess.run([str(SCRIPT), "add", "b", str(src)],
|
||||
check=True, capture_output=True, timeout=30, env=env)
|
||||
|
||||
m = _manifest(tmp_path / "b")
|
||||
assert m.why == "pick the denoiser", "a bare `booth add` wiped the why"
|
||||
assert m.title == "R18 A/B"
|
||||
|
||||
|
||||
def test_an_explicitly_empty_why_still_clears_it(tmp_path):
|
||||
"""Omitted means unchanged; supplied-and-empty means the poster meant to
|
||||
take it back. Both have to be reachable from the shell."""
|
||||
env = {**os.environ, "ALTHING_HANDLE": "booth-dev",
|
||||
"BOOTH_DATA_DIR": str(tmp_path), "BOOTH_URL": "http://booth.invalid"}
|
||||
subprocess.run([str(SCRIPT), "new", "b", "--why", "wrong"], check=True,
|
||||
capture_output=True, timeout=30, env=env)
|
||||
subprocess.run([str(SCRIPT), "new", "b", "--why", ""], check=True,
|
||||
capture_output=True, timeout=30, env=env)
|
||||
|
||||
assert _manifest(tmp_path / "b").why == ""
|
||||
|
||||
|
||||
def test_answer_and_marks_agree_about_what_open_means(tmp_path):
|
||||
"""U2 made `_is_open` THE openness predicate — "nothing else may spell this
|
||||
out" — and `booth answer`'s reader spelled it out anyway, as
|
||||
`if m.answer is None`. So a PARTIALLY answered pick read as done to
|
||||
`answer` and still-open to `marks --wait`: one verb returns the half-filled
|
||||
form and the other blocks on the same booth at the same instant.
|
||||
|
||||
Found 2/4. The two verbs are the session's whole view of the loop, and a
|
||||
session that asks both gets two answers.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||
from booth.marks import answer_pick, declare_pick
|
||||
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
declare_pick(b, "batch", {
|
||||
"title": "R18",
|
||||
"questions": [
|
||||
{"key": "q1", "prompt": "One?", "options": ["keep", "drop"]},
|
||||
{"key": "q2", "prompt": "Two?", "options": ["keep", "drop"]},
|
||||
],
|
||||
})
|
||||
answer_pick(b, "batch", {"q1": "keep", "q2": None}) # partial
|
||||
|
||||
env = {**os.environ, "BOOTH_DATA_DIR": str(tmp_path),
|
||||
"BOOTH_URL": "http://booth.invalid"}
|
||||
marks = subprocess.run([str(SCRIPT), "marks", "b"], capture_output=True,
|
||||
text=True, timeout=30, env=env)
|
||||
answer = subprocess.run([str(SCRIPT), "answer", "b", "batch"],
|
||||
capture_output=True, text=True, timeout=30, env=env)
|
||||
|
||||
still_open = "batch" in json.loads(marks.stdout)["open"]
|
||||
assert still_open, "a partial answer stopped counting as open"
|
||||
assert answer.returncode == UNANSWERED, (
|
||||
"`answer` called a partially-answered pick done while `marks` called it open"
|
||||
)
|
||||
|
||||
|
||||
def test_answer_does_not_poll_forever_on_a_pick_that_cannot_be_answered(tmp_path):
|
||||
"""The mirror failure. A pick whose declaration went bad hydrates with
|
||||
`error` set, which makes it NOT open — so `marks --wait` returns at once
|
||||
while `answer --wait` polled the full hour against a form the web route
|
||||
refuses with a 400. Nothing was ever going to land."""
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
(b / ".marks.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"marks": [{"id": "broken", "shape": "pick", "declaration": {},
|
||||
"error": "pick has no declaration",
|
||||
"created": "2026-09-21T00:00:00.000000+00:00"}],
|
||||
}))
|
||||
|
||||
r = subprocess.run([str(SCRIPT), "answer", "b", "broken", "--wait", "8"],
|
||||
capture_output=True, text=True, timeout=40,
|
||||
env={**os.environ, "BOOTH_DATA_DIR": str(tmp_path),
|
||||
"BOOTH_URL": "http://booth.invalid"})
|
||||
assert r.returncode != 0
|
||||
assert "broken" in r.stderr.lower() or "cannot" in r.stderr.lower()
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
"""U3 — the declared embed seam, server side.
|
||||
|
||||
The Booth used to reach into a verbatim report with ten regular expressions: six
|
||||
to find somewhere to hang a favicon and a chip, four to substitute rendered ask
|
||||
markup into the author's own tags. This unit replaces all of it with a seam the
|
||||
page declares:
|
||||
|
||||
<script src="/_booth/embed.js" defer></script>
|
||||
|
||||
What is tested here is the SERVER half — the payload that crosses the seam, the
|
||||
one static asset, and the single conditional append that is now the only thing
|
||||
the Booth does to author HTML. The half that mounts fragments into a live DOM
|
||||
lives in tests/test_embed_browser.py, because no amount of string assertion can
|
||||
see whether a form actually submits.
|
||||
|
||||
Contract: docs/contracts/u3_declared_embed_seam.contract.md
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from booth.app import EMBED_SCRIPT_TAG, EMBED_SRC, create_app
|
||||
from booth.marks import answer_pick, declare_pick, set_flag, write_note
|
||||
|
||||
DECLARED = f'<!doctype html><title>r</title><body>hi<script src="{EMBED_SRC}" defer></script></body>'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||
return TestClient(app), tmp_path
|
||||
|
||||
|
||||
def _pick(booth, stem="winner", **kw):
|
||||
doc = {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
|
||||
doc.update(kw)
|
||||
booth.mkdir(parents=True, exist_ok=True)
|
||||
declare_pick(booth, stem, doc)
|
||||
return booth
|
||||
|
||||
|
||||
def _multi(booth, stem="batch"):
|
||||
booth.mkdir(parents=True, exist_ok=True)
|
||||
declare_pick(booth, stem, {"title": "Round one", "questions": [
|
||||
{"key": "r1", "prompt": "First?", "options": ["a", "b"]},
|
||||
{"key": "r2", "prompt": "Second?", "options": ["a", "b"]},
|
||||
]})
|
||||
return booth
|
||||
|
||||
|
||||
# ---- slice 1: the payload ----------------------------------------------------
|
||||
|
||||
|
||||
def test_embed_payload_carries_a_fragment_for_every_shape(client):
|
||||
c, data = client
|
||||
_multi(data / "b")
|
||||
body = c.get("/b/b/embed.json").json()
|
||||
assert body["booth"] == "b"
|
||||
assert "home" not in body, "a value nothing reads is a second copy waiting to drift"
|
||||
assert body["favicon"].startswith("data:image/svg+xml,")
|
||||
(m,) = body["marks"]
|
||||
assert m["id"] == "batch" and m["error"] is None
|
||||
assert "First?" in m["whole"] and "Second?" in m["whole"]
|
||||
assert 'action="/b/b/answer"' in m["submit"]
|
||||
assert [q["key"] for q in m["questions"]] == ["r1", "r2"] # declaration order
|
||||
assert "First?" in m["questions"][0]["html"]
|
||||
assert 'type="radio"' in m["questions"][0]["html"]
|
||||
|
||||
|
||||
def test_a_single_question_pick_has_one_question_with_a_null_key(client):
|
||||
"""SR-2. `normalize_ask` gives a single-question ask `key: None`, so the
|
||||
payload cannot key questions by name — JSON would write that as "null" and
|
||||
invent a name. Every one-question ask in the fleet hits this."""
|
||||
c, data = client
|
||||
_pick(data / "b")
|
||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||
assert [q["key"] for q in m["questions"]] == [None]
|
||||
assert "Which render wins?" in m["questions"][0]["html"]
|
||||
|
||||
|
||||
def test_marks_are_ordered_by_creation_before_id(client):
|
||||
"""`created` LEADS. The fixture makes creation order and alphabetical order
|
||||
disagree, because a fixture where they agree cannot tell the stated rule
|
||||
from a plain id sort — which is what the first version of this test did, and
|
||||
what a panel caught by reading the fixture rather than the assertion."""
|
||||
c, data = client
|
||||
b = _pick(data / "b", "zebra")
|
||||
_pick(b, "alpha")
|
||||
raw = json.loads((b / ".marks.json").read_text())
|
||||
stamps = {"zebra": "2026-09-22T10:00:00.000000-07:00", # first
|
||||
"alpha": "2026-09-22T11:00:00.000000-07:00"} # second
|
||||
for e in raw["marks"]:
|
||||
e["created"] = stamps[e["id"]]
|
||||
(b / ".marks.json").write_text(json.dumps(raw))
|
||||
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["zebra", "alpha"]
|
||||
|
||||
|
||||
def test_the_id_is_only_the_tie_break(client):
|
||||
"""And with `created` equal, the id decides — so two marks written in the
|
||||
same second cannot swap between renders."""
|
||||
c, data = client
|
||||
b = _pick(data / "b", "zebra")
|
||||
_pick(b, "alpha")
|
||||
raw = json.loads((b / ".marks.json").read_text())
|
||||
for e in raw["marks"]:
|
||||
e["created"] = "2026-09-22T10:00:00.000000-07:00"
|
||||
(b / ".marks.json").write_text(json.dumps(raw))
|
||||
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["alpha", "zebra"]
|
||||
|
||||
|
||||
def test_open_is_computed_by_the_server_not_the_page(client):
|
||||
"""INV-4. The chip count follows `open_marks`, which is the ONE openness
|
||||
predicate — a half-answered multi-question pick is still open."""
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
assert c.get("/b/b/embed.json").json()["open"] == ["batch"]
|
||||
answer_pick(b, "batch", {"r1": "a"})
|
||||
assert c.get("/b/b/embed.json").json()["open"] == ["batch"] # partial is OPEN
|
||||
answer_pick(b, "batch", {"r1": "a", "r2": "b"})
|
||||
assert c.get("/b/b/embed.json").json()["open"] == []
|
||||
|
||||
|
||||
def test_the_payload_carries_picks_only(client):
|
||||
"""SR-4. Notes and flags never reach it, which is also what keeps a flag's
|
||||
`flag:<target>` id — the one mark id containing the anchor separator — out
|
||||
of a payload whose specs split on the first colon."""
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
(b / "shot.png").write_bytes(b"x")
|
||||
write_note(b, None, "a remark")
|
||||
set_flag(b, "shot.png", True)
|
||||
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["winner"]
|
||||
|
||||
|
||||
def test_a_damaged_marks_file_does_not_500_the_report(client):
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
(b / ".marks.json").write_text("{not json")
|
||||
r = c.get("/b/b/embed.json")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["marks"] == [] and r.json()["error"]
|
||||
|
||||
|
||||
def test_a_broken_pick_offers_whole_and_nothing_else(client):
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
raw = json.loads((b / ".marks.json").read_text())
|
||||
raw["marks"][0]["declaration"] = {"prompt": "p"} # no options -> AskError
|
||||
(b / ".marks.json").write_text(json.dumps(raw))
|
||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||
assert m["error"] and m["questions"] == [] and m["submit"] == ""
|
||||
assert "broken ask" in m["whole"]
|
||||
|
||||
|
||||
def test_the_payload_does_not_record_a_view(client):
|
||||
"""`booth_view` already recorded the look, above both of its early returns.
|
||||
A script's fetch of the page it is already on must not count a second time
|
||||
or reset the TTL on machinery instead of on the operator."""
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
(b / "index.html").write_text(DECLARED)
|
||||
c.get("/b/b/")
|
||||
before = os.stat(b / ".viewed").st_mtime_ns
|
||||
time.sleep(0.01)
|
||||
c.get("/b/b/embed.json")
|
||||
assert os.stat(b / ".viewed").st_mtime_ns == before
|
||||
|
||||
|
||||
def test_embed_payload_404s_for_an_unknown_booth(client):
|
||||
c, _ = client
|
||||
assert c.get("/b/nope/embed.json").status_code == 404
|
||||
|
||||
|
||||
# ---- slice 2: the one static asset -------------------------------------------
|
||||
|
||||
|
||||
def test_embed_js_is_served_as_javascript(client):
|
||||
c, _ = client
|
||||
r = c.get(EMBED_SRC)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("text/javascript")
|
||||
assert "data-booth-mark" in r.text
|
||||
|
||||
|
||||
def test_embed_js_does_not_hot_reload_from_disk(tmp_path):
|
||||
"""INV-5, and the 2026-09-21 lesson restated. A live asset editable under a
|
||||
running process is how 19 of 25 booths hit 500 with the Python from 22:03
|
||||
and the templates from 23:40. One rule in this repo: nothing takes effect
|
||||
until you restart."""
|
||||
import pathlib
|
||||
|
||||
import booth.app as app_mod
|
||||
|
||||
src = pathlib.Path(app_mod.__file__).parent / "static" / "embed.js"
|
||||
original = src.read_text()
|
||||
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||
c = TestClient(app)
|
||||
try:
|
||||
# Poisoned BEFORE the first request, not between two of them. The
|
||||
# earlier shape passed for a route that read the file lazily and cached
|
||||
# on first use — which is not "read once at startup", and is exactly the
|
||||
# staleness this invariant exists to forbid.
|
||||
src.write_text("/* POISONED */\n")
|
||||
first = c.get(EMBED_SRC).text
|
||||
assert "POISONED" not in first, "embed.js is read at request time, not at startup"
|
||||
assert first == original
|
||||
assert c.get(EMBED_SRC).text == first
|
||||
finally:
|
||||
src.write_text(original)
|
||||
|
||||
|
||||
# ---- slice 3: what the Booth does to author HTML -----------------------------
|
||||
|
||||
|
||||
def test_declaring_page_is_served_untouched(client):
|
||||
"""INV-1. Whole-body equality, not a substring absence: the promise is that
|
||||
NOTHING is added, and an absence assertion cannot tell a clean page from one
|
||||
carrying something nobody thought to look for."""
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
(b / "index.html").write_text(DECLARED)
|
||||
assert c.get("/b/b/").text == DECLARED
|
||||
|
||||
|
||||
def test_undeclared_page_gains_only_the_tag(client):
|
||||
"""INV-2. Appended, so the source is a strict prefix — nothing is inserted,
|
||||
nothing is prepended, and neither the doctype nor the charset window moves."""
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
src = "<!doctype html><meta charset=utf-8><title>r</title><h1>REPORT</h1>"
|
||||
(b / "index.html").write_text(src)
|
||||
out = c.get("/b/b/").text
|
||||
# The literal, not just the constant: `out == src + EMBED_SCRIPT_TAG` also
|
||||
# holds when EMBED_SCRIPT_TAG is the empty string, which is a mutation this
|
||||
# test exists to catch. A panel found it by reading the assertion, not the
|
||||
# code.
|
||||
assert out == src + '<script src="/_booth/embed.js" defer></script>'
|
||||
assert out == src + EMBED_SCRIPT_TAG
|
||||
assert len(out) > len(src)
|
||||
assert out.lower().lstrip().startswith("<!doctype")
|
||||
assert out.index("charset") < 1024
|
||||
|
||||
|
||||
def test_a_booth_with_no_marks_still_gets_the_seam(client):
|
||||
"""The seam carries the way home and the icon too, so it is not conditional
|
||||
on there being an ask — the old chip was not either."""
|
||||
c, data = client
|
||||
(data / "b").mkdir()
|
||||
(data / "b" / "index.html").write_text("<h1>bare fragment</h1>")
|
||||
assert c.get("/b/b/").text == "<h1>bare fragment</h1>" + EMBED_SCRIPT_TAG
|
||||
|
||||
|
||||
def test_a_declaring_page_is_served_BYTE_for_byte(client):
|
||||
"""INV-1, at the level the promise is actually made.
|
||||
|
||||
The first version read the file with `read_text()`, which opens in
|
||||
universal-newline mode: a report written with CRLF came back with LF, and
|
||||
`errors="replace"` turned any non-UTF-8 byte into U+FFFD. A declaring page
|
||||
was NOT served as its author wrote it — the headline promise — and the
|
||||
original test could not see it, because its fixture was LF-only ASCII.
|
||||
Found by a cross-frontier bug-hunt panel.
|
||||
"""
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
src = (b'<!doctype html>\r\n<title>r</title>\r\n<body>caf\xe9 \xff\r\n'
|
||||
b'<script src="/_booth/embed.js" defer></script>\r\n</body>')
|
||||
(b / "index.html").write_bytes(src)
|
||||
r = c.get("/b/b/")
|
||||
assert r.content == src, "the operator's document was edited on the way out"
|
||||
assert b"\r\n" in r.content and b"\xff" in r.content
|
||||
|
||||
|
||||
def test_an_undeclared_page_keeps_every_byte_and_gains_the_tag(client):
|
||||
"""INV-2, same level: the source is a BYTE-exact prefix of the response."""
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
src = b'<!doctype html>\r\n<title>r</title>\r\n<body>caf\xe9 \xff\r\n</body>'
|
||||
(b / "index.html").write_bytes(src)
|
||||
r = c.get("/b/b/")
|
||||
assert r.content == src + EMBED_SCRIPT_TAG.encode("utf-8")
|
||||
assert r.content.startswith(src)
|
||||
|
||||
|
||||
def test_a_wrongly_shaped_answer_costs_its_pick_not_the_report(client):
|
||||
"""`marks_for` hydrates `{"answer": {"answers": []}}` with no error — the
|
||||
JSON is well formed, the SHAPE is not — and the template then asks a list
|
||||
for `.get`. This endpoint renders every pick on every load of the operator's
|
||||
report, so an unguarded raise here is the whole seam gone while `hold_read`
|
||||
calls the file perfectly readable. Verified reachable, not assumed."""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir(parents=True, exist_ok=True)
|
||||
declare_pick(b, "batch", {"title": "T", "questions": [
|
||||
{"key": "r1", "prompt": "A?", "options": ["x", "y"]},
|
||||
{"key": "r2", "prompt": "B?", "options": ["x", "y"]}]})
|
||||
_pick(b, "healthy")
|
||||
raw = json.loads((b / ".marks.json").read_text())
|
||||
for e in raw["marks"]:
|
||||
if e["id"] == "batch":
|
||||
e["answer"] = {"answers": [], "notes": ""}
|
||||
(b / ".marks.json").write_text(json.dumps(raw))
|
||||
|
||||
(b / "index.html").write_text(DECLARED)
|
||||
r = c.get("/b/b/embed.json")
|
||||
assert r.status_code == 200
|
||||
by = {m["id"]: m for m in r.json()["marks"]}
|
||||
assert by["batch"]["error"] and "could not be rendered" in by["batch"]["error"]
|
||||
assert "broken ask" in by["batch"]["whole"]
|
||||
# and the booth's other pick is untouched — one bad entry costs one entry
|
||||
assert by["healthy"]["error"] is None
|
||||
assert "Which render wins?" in by["healthy"]["whole"]
|
||||
assert c.get("/b/b/").status_code == 200
|
||||
|
||||
# ⚠ THE GALLERY AND MARKS PAGES STILL 500 ON THIS ENTRY, and that is NOT
|
||||
# U3's doing — measured at 42ea67f, the commit before this unit. They render
|
||||
# the same macro without this guard. Out of scope here (the gallery is named
|
||||
# out of scope in the contract) and recorded rather than quietly widened:
|
||||
# see persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md
|
||||
|
||||
|
||||
def test_no_regex_touches_author_html():
|
||||
"""INV-3, and the version that actually falsifies it.
|
||||
|
||||
The first draft of this test name-matched the six deleted patterns. A cold
|
||||
panel pointed out — correctly, and on the contract's CENTRAL promise — that
|
||||
reintroducing the same regex under a new name (`_TAIL_RE`, applied in the
|
||||
verbatim branch) would leave it green. A test that guards names does not
|
||||
guard behaviour, and this repo's own vacuity pass missed it because the
|
||||
mutation it tried was the named one.
|
||||
|
||||
So: `booth/app.py` is allowed EXACTLY ONE regex operation, and it is
|
||||
`ask_form_id`'s `re.sub` over a mark id — not over a page. Any other regex
|
||||
anywhere in the module fails here, whatever it is called. If a future
|
||||
change genuinely needs one, the failure is the conversation: say which
|
||||
string it reads and why it is not author HTML.
|
||||
"""
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
import booth.app as app_mod
|
||||
|
||||
root = pathlib.Path(app_mod.__file__).parent
|
||||
assert not (root / "inline.py").exists(), "booth/inline.py survived U3"
|
||||
tree = ast.parse((root / "app.py").read_text())
|
||||
|
||||
# every node -> the function it sits in, so a finding names its site
|
||||
site = {}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
for child in ast.walk(node):
|
||||
site.setdefault(child, node.name)
|
||||
|
||||
# `re` reaches this module ONE way: a plain module-level `import re`. An
|
||||
# alias (`import re as _r`) or a direct name import (`from re import sub`)
|
||||
# would route around the call check below under a name it does not know —
|
||||
# found by re-running the vacuity pass against the FIXED test, which is the
|
||||
# only reason it is here and is the argument for running that pass on a fix
|
||||
# and not only on a draft.
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
assert not (a.name == "re" and a.asname), f"`re` aliased as {a.asname}"
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
assert node.module != "re", f"names imported from re: {[a.name for a in node.names]}"
|
||||
|
||||
METHODS = {"search", "sub", "subn", "match", "fullmatch", "finditer",
|
||||
"findall", "split", "compile", "escape"}
|
||||
found = []
|
||||
for node in ast.walk(tree):
|
||||
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
|
||||
continue
|
||||
f = node.func
|
||||
on_re = isinstance(f.value, ast.Name) and f.value.id == "re"
|
||||
on_pattern = (isinstance(f.value, ast.Name) and f.value.id.endswith("_RE")
|
||||
and f.attr in METHODS)
|
||||
if on_re or on_pattern:
|
||||
found.append((site.get(node, "<module level>"), f.attr))
|
||||
|
||||
assert found == [("ask_form_id", "sub")], (
|
||||
f"booth/app.py performs regex operations outside ask_form_id: {found}"
|
||||
)
|
||||
|
||||
# the six named patterns and the chips are gone, and stay gone
|
||||
assigned = {
|
||||
t.id
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Assign)
|
||||
for t in node.targets
|
||||
if isinstance(t, ast.Name)
|
||||
}
|
||||
gone = {"_ICON_RE", "_HEAD_CLOSE_RE", "_HTML_OPEN_RE", "_DOCTYPE_RE",
|
||||
"_BODY_CLOSE_RE", "_HTML_CLOSE_RE", "_BACK_CHIP", "FAVICON_LINK"}
|
||||
assert not (assigned & gone), f"deleted names are back: {sorted(assigned & gone)}"
|
||||
funcs = {n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
|
||||
assert not ({"wrap_verbatim_html", "asks_chip", "inject_asks",
|
||||
"_insert_before", "_insert_after"} & funcs)
|
||||
|
||||
|
||||
def test_a_page_that_only_mentions_the_path_is_not_declaring_it(client):
|
||||
"""A report that QUOTES the seam — a code sample, a comment, a sentence
|
||||
about this very feature — is not declaring it, and the Booth's own design
|
||||
reports are the pages most likely to do that. Read as declared, such a page
|
||||
would be served untouched and show no chrome at all, silently.
|
||||
|
||||
The detection therefore fails the other way: an unrecognised spelling gets a
|
||||
duplicate tag, and embed.js mounts once regardless.
|
||||
"""
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
for body in (
|
||||
"<!doctype html><body><p>add <code>/_booth/embed.js</code> to your report</p></body>",
|
||||
"<!doctype html><body><!-- src=/_booth/embed.js --></body>",
|
||||
'<!doctype html><body><script src="/_booth/embed.js?v=2"></script></body>',
|
||||
):
|
||||
(b / "index.html").write_text(body)
|
||||
assert c.get("/b/b/").text == body + EMBED_SCRIPT_TAG, body
|
||||
|
||||
# and the real declaration, in either quote style, is honoured
|
||||
for decl in (f'<script src="{EMBED_SRC}" defer></script>',
|
||||
f"<script src='{EMBED_SRC}' defer></script>"):
|
||||
body = f"<!doctype html><body>hi{decl}</body>"
|
||||
(b / "index.html").write_text(body)
|
||||
assert c.get("/b/b/").text == body
|
||||
|
||||
|
||||
def test_an_oversize_verbatim_page_is_served_raw(client, monkeypatch):
|
||||
"""WRAP_MAX_BYTES survives: a pathological file is still not pulled into
|
||||
memory, and it loses its chrome exactly as it does today."""
|
||||
import booth.app as app_mod
|
||||
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
(b / "index.html").write_text("<h1>huge</h1>")
|
||||
monkeypatch.setattr(app_mod, "WRAP_MAX_BYTES", 4)
|
||||
assert c.get("/b/b/").text == "<h1>huge</h1>"
|
||||
@@ -0,0 +1,539 @@
|
||||
"""U3 — the declared embed seam, in a real DOM.
|
||||
|
||||
The Python suite can prove what the server OFFERS. It cannot prove where a
|
||||
fragment lands, whether the author's own markup survived the mount, or whether
|
||||
four radio groups scattered down a report still submit as one POST — and that
|
||||
last one is the operator's most important workflow. Before U3 those properties
|
||||
were true by construction, because the server did the placing and the `form=`
|
||||
bindings were static by the time the page was parsed. Now they are true because
|
||||
`/_booth/embed.js` does it in a live document, which is a different kind of
|
||||
claim and needs a different kind of test.
|
||||
|
||||
So: a real uvicorn on an ephemeral port, a real Chromium.
|
||||
|
||||
SKIPS, NEVER FAILS, when playwright or the shared browser is unavailable. The
|
||||
box-wide store at /opt/ms-playwright pins specific Chromium revisions and a
|
||||
playwright release that wants a newer one dies with an opaque "Executable
|
||||
doesn't exist" — see pyproject's version bound. A test layer that goes red for
|
||||
an environment reason teaches nothing and trains people to ignore it.
|
||||
"""
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from booth.app import create_app
|
||||
|
||||
playwright_api = pytest.importorskip(
|
||||
"playwright.sync_api", reason="playwright is not installed"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def browser():
|
||||
with playwright_api.sync_playwright() as pw:
|
||||
try:
|
||||
b = pw.chromium.launch()
|
||||
except Exception as exc: # noqa: BLE001 - any launch failure is a skip
|
||||
pytest.skip(f"no usable chromium: {exc}")
|
||||
yield b
|
||||
b.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live(tmp_path):
|
||||
"""A real server, because a browser cannot talk to a TestClient."""
|
||||
import uvicorn
|
||||
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
|
||||
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
|
||||
server = uvicorn.Server(config)
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.time() + 10
|
||||
while not server.started and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
if not server.started:
|
||||
pytest.skip("uvicorn did not come up")
|
||||
try:
|
||||
yield f"http://127.0.0.1:{port}", tmp_path
|
||||
finally:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=10)
|
||||
|
||||
|
||||
SEAM = '<script src="/_booth/embed.js" defer></script>'
|
||||
|
||||
|
||||
def _multi(booth):
|
||||
from booth.marks import declare_pick
|
||||
|
||||
booth.mkdir(parents=True, exist_ok=True)
|
||||
declare_pick(booth, "batch", {"title": "Round one", "questions": [
|
||||
{"key": "r1", "prompt": "First?", "options": ["keep", "cut"]},
|
||||
{"key": "r2", "prompt": "Second?", "options": ["keep", "cut"]},
|
||||
]})
|
||||
return booth
|
||||
|
||||
|
||||
def _single(booth):
|
||||
from booth.marks import declare_pick
|
||||
|
||||
booth.mkdir(parents=True, exist_ok=True)
|
||||
declare_pick(booth, "winner", {"prompt": "Which render wins?",
|
||||
"options": ["A — baseline", "B — async"]})
|
||||
return booth
|
||||
|
||||
|
||||
def _open(browser, base, name, html, booth):
|
||||
(booth / "index.html").write_text(html, encoding="utf-8")
|
||||
page = browser.new_page()
|
||||
page.goto(f"{base}/b/{name}/", wait_until="networkidle")
|
||||
return page
|
||||
|
||||
|
||||
def _answer(booth):
|
||||
raw = json.loads((booth / ".marks.json").read_text())
|
||||
return raw["marks"][0].get("answer")
|
||||
|
||||
|
||||
# ---- the chrome --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_declaring_page_gets_its_chrome_mounted(browser, live):
|
||||
base, data = live
|
||||
b = _single(data / "b")
|
||||
page = _open(browser, base, "b", f"<!doctype html><title>r</title><body><h1>R</h1>{SEAM}</body>", b)
|
||||
page.wait_for_selector(".booth-nav-home")
|
||||
assert page.locator("h1").inner_text() == "R" # the report is intact
|
||||
assert page.locator(".booth-nav-home").get_attribute("href").endswith("/")
|
||||
# the favicon question, asked of a parsed document instead of raw text
|
||||
assert page.locator('link[rel="icon"]').count() == 1
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_page_that_never_declared_the_seam_still_mounts(browser, live):
|
||||
"""The appended path: every verbatim booth that predates U3 keeps working
|
||||
without its author touching it."""
|
||||
base, data = live
|
||||
b = _single(data / "b")
|
||||
page = _open(browser, base, "b", "<!doctype html><body><h1>OLD</h1></body>", b)
|
||||
page.wait_for_selector(".bk-ask")
|
||||
assert page.locator("h1").inner_text() == "OLD"
|
||||
assert page.locator(".booth-nav-home").count() == 1
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_page_that_declares_its_own_icon_keeps_it(browser, live):
|
||||
base, data = live
|
||||
b = _single(data / "b")
|
||||
page = _open(
|
||||
browser, base, "b",
|
||||
f'<!doctype html><head><link rel="icon" href="data:image/png;base64,AAAA">'
|
||||
f"</head><body>x{SEAM}</body>", b)
|
||||
page.wait_for_selector(".booth-nav-home")
|
||||
icons = page.locator('link[rel="icon"]')
|
||||
assert icons.count() == 1
|
||||
assert icons.get_attribute("href").startswith("data:image/png")
|
||||
page.close()
|
||||
|
||||
|
||||
# ---- placement ---------------------------------------------------------------
|
||||
|
||||
|
||||
REPORT = f"""<!doctype html><title>audition</title><body>
|
||||
<h1>Three voices</h1>
|
||||
<section id="lawson"><audio src="a.wav"></audio>
|
||||
<div data-booth-ask="batch:r1"></div></section>
|
||||
<section id="jo"><audio src="b.wav"></audio>
|
||||
<div data-booth-mark="batch:r2"></div></section>
|
||||
<div data-booth-ask-submit="batch"></div>
|
||||
{SEAM}
|
||||
</body>"""
|
||||
|
||||
|
||||
def test_each_question_lands_where_the_author_put_it(browser, live):
|
||||
"""The 2026-09-09 ruling, enforced in the DOM: the question for a voice sits
|
||||
under that voice, not on another page and not in a pile at the end. Both
|
||||
attribute spellings, because live reports use the older one."""
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
page = _open(browser, base, "b", REPORT, b)
|
||||
page.wait_for_selector("#lawson .bk-ask")
|
||||
assert page.locator('#lawson input[name="choice.r1"]').count() == 2
|
||||
assert page.locator('#jo input[name="choice.r2"]').count() == 2
|
||||
# nothing spilled to the end of the body: every piece had an anchor
|
||||
assert page.locator("body > .bk-ask").count() == 0
|
||||
assert page.locator("form#bk-ask-form-batch").count() == 1
|
||||
page.close()
|
||||
|
||||
|
||||
def test_the_authors_wrapper_and_its_contents_survive_the_mount(browser, live):
|
||||
"""The live `dfa-concepts` shape — a non-empty styled wrapper carrying the
|
||||
anchor attribute. The regex this replaced matched the opening tag and
|
||||
SUBSTITUTED it, eating the class and orphaning the heading. beforeend keeps
|
||||
both and puts the radios under the heading, which is what the markup says."""
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
page = _open(browser, base, "b", (
|
||||
'<!doctype html><body><div class="ask" data-booth-ask="batch:r1">'
|
||||
f"<h3>The one asset that must survive</h3></div>{SEAM}</body>"), b)
|
||||
page.wait_for_selector(".ask .bk-ask")
|
||||
assert page.locator("div.ask").count() == 1 # class kept
|
||||
assert page.locator(".ask h3").inner_text() == "The one asset that must survive"
|
||||
assert page.locator('.ask input[name="choice.r1"]').count() == 2 # radios inside
|
||||
page.close()
|
||||
|
||||
|
||||
def test_an_unplaced_question_is_appended_and_so_is_its_submit(browser, live):
|
||||
"""INV-7. A multi-question pick needs EVERY question on submit or the POST is
|
||||
unanswerable: a question that never reaches the page cannot be picked, and
|
||||
a submission with nothing picked at all is refused outright."""
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
page = _open(browser, base, "b",
|
||||
f'<!doctype html><body><div data-booth-mark="batch:r1"></div>{SEAM}</body>', b)
|
||||
# attached, not visible: the shared <form> is deliberately empty and so has
|
||||
# no box — the controls that bind to it are what the operator sees.
|
||||
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
||||
# BOTH halves. Asserting only the appended r2 let a mutation that silently
|
||||
# swallowed the anchored r1 — while still recording it as placed — pass.
|
||||
assert page.locator('input[name="choice.r1"]').count() == 2 # anchored, mounted
|
||||
assert page.locator('input[name="choice.r2"]').count() == 2 # unplaced, appended
|
||||
assert page.locator("form#bk-ask-form-batch").count() == 1 # submittable
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_page_with_no_anchors_gets_the_whole_ask(browser, live):
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
page = _open(browser, base, "b", f"<!doctype html><body><p>x</p>{SEAM}</body>", b)
|
||||
page.wait_for_selector(".bk-ask")
|
||||
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||
assert page.locator('input[name="choice.r2"]').count() == 2
|
||||
page.close()
|
||||
|
||||
|
||||
def test_an_anchor_naming_no_mark_is_left_alone(browser, live):
|
||||
"""A typo'd id stays visible as the author's own empty element rather than
|
||||
being blanked — and the real ask is still never lost."""
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
page = _open(browser, base, "b",
|
||||
f'<!doctype html><body><div id="t" data-booth-mark="typo"></div>{SEAM}</body>', b)
|
||||
page.wait_for_selector(".bk-ask")
|
||||
assert page.locator("#t").inner_html().strip() == ""
|
||||
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||
page.close()
|
||||
|
||||
|
||||
def test_the_tail_follows_payload_order(browser, live):
|
||||
"""INV-6. Creation order and alphabetical order DISAGREE here on purpose:
|
||||
`winner` is created first, `batch` second, so payload order is
|
||||
winner-then-batch while an id sort would give the reverse. The first version
|
||||
of this fixture made the two identical, so sorting the tail alphabetically
|
||||
in JavaScript passed it — caught by a panel reading the fixture."""
|
||||
base, data = live
|
||||
b = _single(data / "b")
|
||||
_multi(b)
|
||||
raw = json.loads((b / ".marks.json").read_text())
|
||||
stamps = {"winner": "2026-09-22T10:00:00.000000-07:00", # first
|
||||
"batch": "2026-09-22T11:00:00.000000-07:00"} # second
|
||||
for e in raw["marks"]:
|
||||
e["created"] = stamps[e["id"]]
|
||||
(b / ".marks.json").write_text(json.dumps(raw))
|
||||
page = _open(browser, base, "b", f"<!doctype html><body>{SEAM}</body>", b)
|
||||
page.wait_for_selector(".bk-ask")
|
||||
ids = page.eval_on_selector_all("[id^='bk-ask-']", "els => els.map(e => e.id)")
|
||||
batch = [i for i, v in enumerate(ids) if "batch" in v]
|
||||
winner = [i for i, v in enumerate(ids) if "winner" in v]
|
||||
assert batch and winner, ids
|
||||
# winner (created first) ahead of batch (created second) — the OPPOSITE of
|
||||
# alphabetical, so an id sort cannot pass this.
|
||||
assert max(winner) < min(batch), ids
|
||||
page.close()
|
||||
|
||||
|
||||
# ---- the one that actually matters ------------------------------------------
|
||||
|
||||
|
||||
def test_a_form_scattered_down_the_report_submits_every_question(browser, live):
|
||||
"""THE load-bearing browser test.
|
||||
|
||||
Four radio groups under four different artifacts, one <form> somewhere else
|
||||
entirely, bound only by the HTML5 `form=` attribute — and now inserted into
|
||||
a live document in visual order, which means a control can land before the
|
||||
form it points at. If form-owner resolution does not survive that, the
|
||||
operator fills the whole thing in and the button saves nothing.
|
||||
|
||||
It was true by construction before U3 (static HTML, resolved at parse). It
|
||||
is true by measurement now. That is the trade this test pays for.
|
||||
"""
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
page = _open(browser, base, "b", REPORT, b)
|
||||
page.wait_for_selector("#lawson .bk-ask")
|
||||
page.check('#lawson input[name="choice.r1"][value="keep"]')
|
||||
page.check('#jo input[name="choice.r2"][value="cut"]')
|
||||
with page.expect_navigation():
|
||||
page.click("button.bk-ask-go")
|
||||
ans = _answer(b)
|
||||
assert ans is not None, "the scattered form submitted nothing"
|
||||
assert ans["answers"]["r1"]["choice"] == "keep"
|
||||
assert ans["answers"]["r2"]["choice"] == "cut", \
|
||||
"a question bound by form= did not reach the POST"
|
||||
assert ans["complete"] is True
|
||||
page.close()
|
||||
|
||||
|
||||
def test_the_chip_jumps_to_the_first_fragment_of_the_open_ask(browser, live):
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
page = _open(browser, base, "b", REPORT, b)
|
||||
page.wait_for_selector(".booth-nav-asks")
|
||||
chip = page.locator(".booth-nav-asks")
|
||||
assert chip.inner_text() == "? 1 open ask"
|
||||
target = chip.get_attribute("href")
|
||||
# the EARLIEST match in document order, not merely a match: the report
|
||||
# anchors r1 above r2 above the submit block, so any later one is wrong.
|
||||
ids = page.eval_on_selector_all("[id^='bk-ask-batch']", "els => els.map(e => e.id)")
|
||||
assert ids, "no batch fragment mounted"
|
||||
assert target == "#" + ids[0], (target, ids)
|
||||
page.close()
|
||||
|
||||
|
||||
def test_an_anchor_named_like_an_object_property_does_not_kill_the_page(browser, live):
|
||||
"""`toString` is a legal mark id (`asks.valid_stem`) and therefore a legal
|
||||
thing for an author to typo into an anchor. Against a plain `{}` lookup it
|
||||
came back as Object.prototype.toString — truthy, so it sailed past the
|
||||
unknown-mark guard and threw on `.questions.length`, aborting placement
|
||||
before the tail and costing the page EVERY ask. One typo, no chrome, no
|
||||
error the operator would see. Found by a cross-frontier panel."""
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
page = _open(browser, base, "b", (
|
||||
'<!doctype html><body><div id="t" data-booth-mark="toString"></div>'
|
||||
f'<div id="v" data-booth-mark="valueOf:r1"></div>{SEAM}</body>'), b)
|
||||
page.wait_for_selector(".bk-ask")
|
||||
assert page.locator("#t").inner_html().strip() == "" # left alone
|
||||
assert page.locator("#v").inner_html().strip() == "" # left alone
|
||||
# and the real ask still mounted, which is what the bug destroyed
|
||||
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||
assert page.locator('input[name="choice.r2"]').count() == 2
|
||||
assert page.locator("form#bk-ask-form-batch").count() == 1
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_question_keyed_like_an_object_property_is_not_swallowed(browser, live):
|
||||
"""The mirror of the same bug, on the `placed` set. `constructor` matches
|
||||
`asks._KEY_RE`, and against a plain object an inherited `got.constructor`
|
||||
read as ALREADY PLACED — so a question the author did not anchor was
|
||||
silently dropped from the tail, which is INV-7's whole subject."""
|
||||
from booth.marks import declare_pick
|
||||
|
||||
base, data = live
|
||||
b = data / "b"
|
||||
b.mkdir(parents=True, exist_ok=True)
|
||||
declare_pick(b, "batch", {"title": "Round one", "questions": [
|
||||
{"key": "r1", "prompt": "First?", "options": ["keep", "cut"]},
|
||||
{"key": "constructor", "prompt": "Second?", "options": ["keep", "cut"]},
|
||||
]})
|
||||
page = _open(browser, base, "b",
|
||||
f'<!doctype html><body><div data-booth-mark="batch:r1"></div>{SEAM}</body>', b)
|
||||
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
||||
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||
assert page.locator('input[name="choice.constructor"]').count() == 2, \
|
||||
"an unplaced question was swallowed by an inherited property"
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_submit_anchor_inside_the_authors_own_form_still_submits(browser, live):
|
||||
"""A submit anchor placed inside the author's own `<form>` loses ours: the
|
||||
HTML parser drops a nested form element outright. The controls' `form=`
|
||||
then points at nothing, the button does nothing, and the operator finds out
|
||||
by filling the whole thing in. Found by a cross-frontier bug-hunt panel; the
|
||||
fix is to count the anchor submitted only if the form actually survived, so
|
||||
the tail supplies one at body level where no form encloses it."""
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
page = _open(browser, base, "b", (
|
||||
'<!doctype html><body>'
|
||||
'<div data-booth-mark="batch:r1"></div>'
|
||||
'<div data-booth-mark="batch:r2"></div>'
|
||||
'<form id="mine" action="/elsewhere">'
|
||||
'<div data-booth-ask-submit="batch"></div></form>'
|
||||
f"{SEAM}</body>"), b)
|
||||
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
||||
assert page.locator("form#bk-ask-form-batch").count() == 1
|
||||
page.check('input[name="choice.r1"][value="keep"]')
|
||||
page.check('input[name="choice.r2"][value="cut"]')
|
||||
with page.expect_navigation():
|
||||
page.click("button.bk-ask-go")
|
||||
ans = _answer(b)
|
||||
assert ans is not None, "the button reached no form"
|
||||
assert ans["answers"]["r1"]["choice"] == "keep"
|
||||
assert ans["answers"]["r2"]["choice"] == "cut"
|
||||
page.close()
|
||||
|
||||
|
||||
def test_a_broken_pick_shows_its_diagnostic_even_from_a_submit_anchor(browser, live):
|
||||
"""A broken pick has no submit block — its `submit` is empty and the
|
||||
diagnostic lives in `whole`. Mounting that empty string and then recording
|
||||
the pick as placed made the tail skip it, so the 'broken ask' box never
|
||||
rendered at the one surface built to show it. A question the session
|
||||
believes it posted has to be visible."""
|
||||
base, data = live
|
||||
b = _single(data / "b")
|
||||
raw = json.loads((b / ".marks.json").read_text())
|
||||
raw["marks"][0]["declaration"] = {"prompt": "p"} # no options -> AskError
|
||||
(b / ".marks.json").write_text(json.dumps(raw))
|
||||
page = _open(browser, base, "b",
|
||||
f'<!doctype html><body><div id="s" data-booth-ask-submit="winner">'
|
||||
f"</div>{SEAM}</body>", b)
|
||||
page.wait_for_selector(".bk-ask")
|
||||
assert page.locator("#s").inner_html().strip() == "" # anchor left alone
|
||||
# inner_text() is the RENDERED text, and `.bk-ask-tag` is uppercased by CSS —
|
||||
# so assert the diagnostic itself, which is the part that has to reach him.
|
||||
shown = page.locator("body").inner_text()
|
||||
assert "this question could not be read" in shown, shown
|
||||
assert "options" in shown # the actual reason
|
||||
page.close()
|
||||
|
||||
|
||||
def test_an_author_element_cannot_hijack_the_chip(browser, live):
|
||||
"""`<section id="bk-ask-winner-background">` satisfies any id-prefix rule,
|
||||
hyphen boundary included. The chip therefore searches only the elements THIS
|
||||
SCRIPT MOUNTED — the identity the deleted `bk-ask-<id>-top` anchor used to
|
||||
guarantee — and takes the earliest of those in document order."""
|
||||
base, data = live
|
||||
b = _single(data / "b")
|
||||
page = _open(browser, base, "b", (
|
||||
'<!doctype html><body><section id="bk-ask-winner-background">notes</section>'
|
||||
f"<p>report</p>{SEAM}</body>"), b)
|
||||
page.wait_for_selector(".booth-nav-asks")
|
||||
target = page.locator(".booth-nav-asks").get_attribute("href")
|
||||
assert target != "#bk-ask-winner-background"
|
||||
landed = page.locator(target)
|
||||
assert landed.count() == 1
|
||||
assert landed.evaluate("e => e.classList.contains('bk-ask')"), \
|
||||
"the chip jumped to something the Booth did not mount"
|
||||
page.close()
|
||||
|
||||
|
||||
def test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix(browser, live):
|
||||
"""A SIBLING MARK's fragment must not take the chip, even when it is earlier
|
||||
in the document. `batch2`'s id starts with `batch`, so the original
|
||||
id-prefix rule could land on it; the mounted-elements rule cannot, because
|
||||
the candidates are partitioned by mark.
|
||||
|
||||
⚠ The first version of this test put the sibling's fragment AFTER the open
|
||||
mark's, so the right answer was also the first answer and pooling every
|
||||
mark's elements passed it. The vacuity pass caught that; the fixture now
|
||||
puts the sibling FIRST, which is the only arrangement that can tell the two
|
||||
implementations apart.
|
||||
"""
|
||||
from booth.marks import declare_pick
|
||||
|
||||
base, data = live
|
||||
b = _multi(data / "b") # `batch`, created first
|
||||
declare_pick(b, "batch2", {"prompt": "Unrelated?", "options": ["x", "y"]})
|
||||
|
||||
# batch2 is anchored at the very top; batch is unanchored and so lands in
|
||||
# the tail, at the END of the body. Document order is therefore batch2's
|
||||
# fragments, then batch's.
|
||||
page = _open(browser, base, "b", (
|
||||
'<!doctype html><body><div data-booth-mark="batch2"></div>'
|
||||
f"<p>report</p>{SEAM}</body>"), b)
|
||||
page.wait_for_selector(".booth-nav-asks")
|
||||
|
||||
ids = page.eval_on_selector_all("[id^='bk-ask-']", "els => els.map(e => e.id)")
|
||||
assert any("batch2" in i for i in ids) and any(
|
||||
"batch2" not in i and "batch" in i for i in ids), ids
|
||||
assert ids.index(next(i for i in ids if "batch2" in i)) < \
|
||||
ids.index(next(i for i in ids if "batch2" not in i and "batch" in i)), \
|
||||
f"fixture is wrong: the sibling must come FIRST, got {ids}"
|
||||
|
||||
# `open` is (created, id) -> batch before batch2, so the chip targets batch
|
||||
target = page.locator(".booth-nav-asks").get_attribute("href")
|
||||
assert "batch2" not in target, f"the chip landed on the sibling mark: {target}"
|
||||
assert target.startswith("#bk-ask-batch")
|
||||
assert page.locator(target).count() == 1
|
||||
page.close()
|
||||
|
||||
|
||||
def test_the_canonical_attribute_wins_when_both_are_present(browser, live):
|
||||
"""`data-booth-mark` is canonical and `data-booth-ask` is the kept alias.
|
||||
An element carrying both is not a case any live report has, but the
|
||||
precedence has to be decided somewhere rather than by selector order."""
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
page = _open(browser, base, "b", (
|
||||
'<!doctype html><body><div id="a" data-booth-mark="batch:r2" '
|
||||
f'data-booth-ask="batch:r1"></div>{SEAM}</body>'), b)
|
||||
page.wait_for_selector("#a .bk-ask")
|
||||
assert page.locator('#a input[name="choice.r2"]').count() == 2 # canonical
|
||||
assert page.locator('#a input[name="choice.r1"]').count() == 0 # alias ignored
|
||||
# r1 was never placed, so INV-7 still puts it somewhere
|
||||
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||
page.close()
|
||||
|
||||
|
||||
def test_the_chip_count_comes_from_the_server(browser, live):
|
||||
"""INV-4. A half-answered multi-question pick is STILL OPEN, and the page
|
||||
does not get to have an opinion about that — `open_marks` decides."""
|
||||
from booth.marks import answer_pick
|
||||
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
answer_pick(b, "batch", {"r1": "keep"})
|
||||
page = _open(browser, base, "b", REPORT, b)
|
||||
page.wait_for_selector(".bk-ask")
|
||||
assert page.locator(".booth-nav-asks").count() == 1
|
||||
answer_pick(b, "batch", {"r1": "keep", "r2": "cut"})
|
||||
page.reload(wait_until="networkidle")
|
||||
page.wait_for_selector(".bk-ask")
|
||||
assert page.locator(".booth-nav-asks").count() == 0
|
||||
page.close()
|
||||
|
||||
|
||||
def test_the_chip_follows_a_payload_that_disagrees_with_the_fragments(browser, live):
|
||||
"""INV-4, and the version that actually falsifies it.
|
||||
|
||||
The test above uses honest fixtures, so a client that INFERRED openness from
|
||||
the rendered fragments would pass it — the fragments and `open` always agree
|
||||
when the server computes both. A panel pointed out that this never creates
|
||||
the disagreement it claims to test.
|
||||
|
||||
So: intercept the response and make `open` lie. The fragments say fully
|
||||
answered; the payload says two are open. The chip must follow the payload,
|
||||
because the payload is the only thing that decides.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from booth.marks import answer_pick
|
||||
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
answer_pick(b, "batch", {"r1": "keep", "r2": "cut"}) # nothing is open
|
||||
(b / "index.html").write_text(f"<!doctype html><body>{SEAM}</body>", encoding="utf-8")
|
||||
|
||||
def lie(route):
|
||||
body = _json.loads(route.fetch().text())
|
||||
assert body["open"] == [], "fixture is not answered; the lie would be true"
|
||||
body["open"] = ["batch", "batch"]
|
||||
route.fulfill(status=200, content_type="application/json",
|
||||
body=_json.dumps(body))
|
||||
|
||||
page = browser.new_page()
|
||||
page.route("**/embed.json", lie)
|
||||
page.goto(f"{base}/b/b/", wait_until="networkidle")
|
||||
page.wait_for_selector(".booth-nav-asks")
|
||||
assert page.locator(".booth-nav-asks").inner_text() == "? 2 open asks"
|
||||
page.close()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,719 @@
|
||||
"""U5 — self-announcing booths.
|
||||
|
||||
A booth carries `.booth.json` saying who posted it and why, and the index card
|
||||
and the booth page render it. Closes job 5 (`Announce`) — the job nobody named,
|
||||
whose absence is the measured cause of 145 dead link rows.
|
||||
|
||||
See docs/contracts/u5_booth_manifest.contract.md.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from booth.manifest import MANIFEST_FILE, Manifest, read_manifest, write_manifest
|
||||
|
||||
|
||||
# ---- slice 1: the record and its storage ------------------------------------
|
||||
|
||||
|
||||
def test_an_announcement_round_trips(tmp_path):
|
||||
b = tmp_path / "r18-ab"
|
||||
b.mkdir()
|
||||
written = write_manifest(b, "booth-dev", why="pick the winning denoiser")
|
||||
|
||||
assert (b / MANIFEST_FILE).is_file()
|
||||
got = read_manifest(b)
|
||||
assert got == written
|
||||
assert got.handle == "booth-dev"
|
||||
assert got.why == "pick the winning denoiser"
|
||||
assert got.error is None
|
||||
|
||||
|
||||
def test_the_title_falls_back_to_the_directory_name(tmp_path):
|
||||
"""A booth always has a display name. `title` is the one the poster chose
|
||||
when there is one, and the folder name is a perfectly good one when there
|
||||
is not — an empty heading on a card is worse than a plain one."""
|
||||
b = tmp_path / "r18-ab"
|
||||
b.mkdir()
|
||||
assert write_manifest(b, "booth-dev").title == "r18-ab"
|
||||
assert write_manifest(b, "booth-dev", title="R18 A/B").title == "R18 A/B"
|
||||
|
||||
|
||||
def test_a_booth_that_never_announced_reads_as_none(tmp_path):
|
||||
"""The normal case for every booth that predates this unit, and for every
|
||||
booth that arrives by rsync — the documented path for any host that is not
|
||||
nh3-dev, which never runs the CLI at all."""
|
||||
b = tmp_path / "quiet"
|
||||
b.mkdir()
|
||||
assert read_manifest(b) is None
|
||||
assert read_manifest(tmp_path / "does-not-exist") is None
|
||||
|
||||
|
||||
def test_one_line_by_construction_not_by_convention(tmp_path):
|
||||
"""`why` renders inside a card's sub-line, so a newline in it would break
|
||||
the card rather than the field. Truncation and newline-stripping happen at
|
||||
the WRITE, so nothing downstream has to remember."""
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
m = write_manifest(b, "booth-dev", why="first line\nsecond line\r\nthird")
|
||||
assert "\n" not in m.why and "\r" not in m.why
|
||||
assert "first line" in m.why and "second line" in m.why
|
||||
|
||||
long = write_manifest(b, "booth-dev", why="x" * 5000)
|
||||
assert len(long.why) <= 200
|
||||
assert len(write_manifest(b, "y" * 500).handle) <= 64
|
||||
assert len(write_manifest(b, "booth-dev", title="t" * 500).title) <= 120
|
||||
|
||||
|
||||
# ---- slice 2: the read cannot raise (INV-2) ---------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
b"{truncated", # not JSON at all
|
||||
b"[]", # JSON, wrong shape
|
||||
b'"a string"', # JSON, wronger shape
|
||||
b"null",
|
||||
b'{"handle": 7}', # right shape, wrong type
|
||||
b'{"why": "no handle here"}', # the one required field missing
|
||||
b"\xff\xfe not utf-8",
|
||||
b"",
|
||||
],
|
||||
ids=["truncated", "list", "string", "null", "wrong-type", "no-handle",
|
||||
"not-utf8", "empty"],
|
||||
)
|
||||
def test_a_damaged_manifest_never_raises(tmp_path, payload):
|
||||
"""INV-2. `list_booths` calls this once per booth on every index page load,
|
||||
so a read that can raise is a service-wide outage wearing a single-booth
|
||||
bug's clothes. That is not a hypothetical — a poisoned `.marks.json` did
|
||||
exactly that to `/` and `/healthz` across all 25 booths, and the fix shipped
|
||||
in v0.2.2. The same reader posture, applied before the same mistake."""
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
(b / MANIFEST_FILE).write_bytes(payload)
|
||||
|
||||
got = read_manifest(b)
|
||||
assert isinstance(got, Manifest)
|
||||
assert got.error, "a damaged manifest read clean"
|
||||
|
||||
|
||||
def test_damaged_is_not_the_same_as_absent(tmp_path):
|
||||
"""INV-5. Silently folding "cannot be read" into "never announced" would
|
||||
hide the one case somebody has to go and fix."""
|
||||
absent = tmp_path / "absent"
|
||||
absent.mkdir()
|
||||
damaged = tmp_path / "damaged"
|
||||
damaged.mkdir()
|
||||
(damaged / MANIFEST_FILE).write_text("{oops")
|
||||
|
||||
assert read_manifest(absent) is None
|
||||
assert read_manifest(damaged).error
|
||||
|
||||
|
||||
def test_a_manifest_the_module_did_not_write_still_reads(tmp_path):
|
||||
"""Hand-written is a supported input: the file is plain JSON in a folder the
|
||||
operator owns, and half the point is that a booth is just a directory. Only
|
||||
`handle` is required; everything else has a default."""
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
(b / MANIFEST_FILE).write_text(json.dumps({"handle": "shutter-dev"}))
|
||||
|
||||
got = read_manifest(b)
|
||||
assert got.handle == "shutter-dev" and got.error is None
|
||||
assert got.title == "b"
|
||||
assert got.why == ""
|
||||
|
||||
|
||||
# ---- slice 3: re-announcement (INV-3) ---------------------------------------
|
||||
|
||||
|
||||
def test_re_announcing_preserves_created(tmp_path):
|
||||
"""INV-3. `created` is when the booth APPEARED. Saying something more about
|
||||
it later is not a second appearance, and a `booth add` on an existing booth
|
||||
is the common case — the poster adds the second batch and sharpens the why."""
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
first = write_manifest(b, "booth-dev", why="first pass")
|
||||
second = write_manifest(b, "booth-dev", why="second pass, sharper")
|
||||
|
||||
assert second.created == first.created
|
||||
assert second.why == "second pass, sharper"
|
||||
|
||||
|
||||
def test_re_announcing_over_a_damaged_file_does_not_inherit_its_created(tmp_path):
|
||||
"""A `created` that cannot be read back is replaced rather than guessed at.
|
||||
The alternative is a stamp that is silently wrong, which is worse than one
|
||||
that is silently new."""
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
(b / MANIFEST_FILE).write_text("{not json")
|
||||
|
||||
m = write_manifest(b, "booth-dev", why="rescued")
|
||||
assert m.created and m.error is None
|
||||
assert read_manifest(b).why == "rescued"
|
||||
|
||||
|
||||
# ---- slice 4: the write is atomic, and invisible to every listing -----------
|
||||
|
||||
|
||||
def test_the_write_leaves_no_temp_file(tmp_path):
|
||||
"""Half of the atomic-write promise, and the weaker half — see
|
||||
`test_the_write_replaces_rather_than_truncating` for the part that actually
|
||||
discriminates. Kept because a leaked `.tmp` is its own small defect: it
|
||||
would sit in the booth forever and, unlike the manifest, nothing would ever
|
||||
overwrite it."""
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
write_manifest(b, "booth-dev", why="x")
|
||||
assert not list(b.glob("*.tmp")), "a temp file survived the write"
|
||||
|
||||
|
||||
def test_a_manifest_is_not_an_item(tmp_path):
|
||||
"""The whole integration story: it is a DOTFILE, so the existing
|
||||
`startswith('.')` skip in `booth_items` already keeps it out of tiles,
|
||||
counts and zips. No new exclusion rule anywhere. Asserted rather than
|
||||
assumed, because the claim is load-bearing for the contract's scope."""
|
||||
from booth.app import zip_booth
|
||||
from booth.items import booth_items
|
||||
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
(b / "a.txt").write_text("real content")
|
||||
write_manifest(b, "booth-dev", why="x")
|
||||
|
||||
assert [i.rel for i in booth_items(b)] == ["a.txt"]
|
||||
assert MANIFEST_FILE not in zip_booth(b).decode("latin-1")
|
||||
|
||||
|
||||
def test_announcing_is_activity(tmp_path):
|
||||
"""A manifest is a dotfile but not a `.lock` dotfile, so `_newest_mtime`
|
||||
counts it. Creating or re-announcing a booth resets its TTL, which is right:
|
||||
both are somebody touching it. The lock exemption added in v0.2.2 is for
|
||||
machinery a READ path creates; this is a deliberate write."""
|
||||
from booth.app import booth_age_seconds
|
||||
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
old = 1_000_000_000
|
||||
os.utime(b, (old, old))
|
||||
|
||||
write_manifest(b, "booth-dev", why="look at this")
|
||||
assert booth_age_seconds(b, now=old + 90_000) < 86_400
|
||||
|
||||
|
||||
def test_stdlib_only():
|
||||
"""INV-4, and the reason this module exists separately from anything that
|
||||
imports a third-party package. `scripts/booth` imports it under the system
|
||||
python3 with NO venv, through a `python3 -c` heredoc no AST extractor can
|
||||
see. It must also not import `booth.*`: a cross-import between two
|
||||
stdlib-only modules is a second way for the invariant to break."""
|
||||
src = pathlib.Path(__file__).parent.parent / "booth" / "manifest.py"
|
||||
roots = set()
|
||||
for node in ast.walk(ast.parse(src.read_text())):
|
||||
if isinstance(node, ast.Import):
|
||||
roots.update(a.name.split(".")[0] for a in node.names)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
# A RELATIVE import (`from . import marks`) carries no module root
|
||||
# and used to pass this walk unseen — which matters more here than
|
||||
# in the shared copy, because this module forbids sibling imports
|
||||
# outright. Recorded as `booth` so the assertion below catches it.
|
||||
roots.add("booth" if node.level else
|
||||
(node.module or "").split(".")[0])
|
||||
assert not (roots - set(sys.stdlib_module_names)), (
|
||||
f"booth/manifest.py imports outside the stdlib: "
|
||||
f"{sorted(roots - set(sys.stdlib_module_names))}"
|
||||
)
|
||||
|
||||
|
||||
# ---- slice 5: what the operator actually sees -------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from booth.app import create_app
|
||||
return TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)), tmp_path
|
||||
|
||||
|
||||
def _booth(data, name, *, kept=False):
|
||||
b = data / name
|
||||
b.mkdir()
|
||||
(b / "a.txt").write_text("content")
|
||||
if kept:
|
||||
(b / ".forever").touch()
|
||||
return b
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kept", [False, True], ids=["ephemeral", "kept"])
|
||||
def test_the_index_card_carries_the_announcement(client, kept):
|
||||
"""BOTH LANES. Kept boards render first and are a separate block in
|
||||
index.html, so patching only the ephemeral lane would leave the 15 kept
|
||||
booths — the durable, most-looked-at ones — with exactly the defect this
|
||||
unit closes. Same lesson as the `blurtoggle` macro: three branches, one
|
||||
definition; here it is two lanes and one rule."""
|
||||
c, data = client
|
||||
b = _booth(data, "r18-ab", kept=kept)
|
||||
write_manifest(b, "booth-dev", why="pick the winning denoiser")
|
||||
|
||||
html = c.get("/").text
|
||||
assert "booth-dev" in html
|
||||
assert "pick the winning denoiser" in html
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kept", [False, True], ids=["ephemeral", "kept"])
|
||||
def test_a_booth_that_never_spoke_up_is_marked(client, kept):
|
||||
"""All 26 live booths are in this state, and rsync keeps making more. The
|
||||
marker is what makes the convention adoptable at all: the link board rotted
|
||||
to 69% precisely because nothing ever showed which rows were dead.
|
||||
|
||||
ASSERTED ON THE CLASS, not on the word, and the test is named around it.
|
||||
`pytest`'s `tmp_path` is derived from the TEST NAME and the index renders
|
||||
`data_dir` in its empty-state hint — so a test called
|
||||
`test_an_unannounced_booth_says_so` put the literal string "unannounced"
|
||||
into the page and passed against a template that did not yet exist. A
|
||||
structural hook cannot be spelled by accident — though it has to be the
|
||||
rendered ELEMENT and not the bare class, since base.html ships a
|
||||
`.prov-none{...}` rule into the very same page."""
|
||||
c, data = client
|
||||
_booth(data, "quiet", kept=kept)
|
||||
|
||||
html = c.get("/").text
|
||||
assert 'class="prov prov-none"' in html
|
||||
assert "unannounced" in html
|
||||
|
||||
|
||||
def test_a_damaged_manifest_reads_differently_from_an_absent_one(client):
|
||||
"""INV-5 on the surface the operator looks at, not just in the reader."""
|
||||
c, data = client
|
||||
b = _booth(data, "damaged")
|
||||
(b / MANIFEST_FILE).write_text("{oops")
|
||||
|
||||
html = c.get("/").text
|
||||
assert 'class="prov prov-broken"' in html
|
||||
assert "unreadable" in html
|
||||
assert c.get("/b/damaged/").status_code == 200
|
||||
|
||||
|
||||
def test_an_announced_booth_with_no_why_shows_only_its_handle(client):
|
||||
"""`booth new x` with no --why is legal and common. The card shows who made
|
||||
it and does not invent a purpose or leave a dangling separator."""
|
||||
c, data = client
|
||||
b = _booth(data, "scratch")
|
||||
write_manifest(b, "booth-dev")
|
||||
|
||||
html = c.get("/").text
|
||||
assert "booth-dev" in html
|
||||
assert 'class="prov prov-none"' not in html
|
||||
|
||||
|
||||
def test_the_booth_page_header_carries_it_too(client):
|
||||
"""Deliberate scope, not creep: a booth URL handed to the operator lands
|
||||
HERE, never on the index. Job 5 is 'operator, look at this', so the page he
|
||||
actually opens is where the answer has to be."""
|
||||
c, data = client
|
||||
b = _booth(data, "r18-ab")
|
||||
write_manifest(b, "booth-dev", why="pick the winning denoiser")
|
||||
|
||||
html = c.get("/b/r18-ab/").text
|
||||
assert "booth-dev" in html
|
||||
assert "pick the winning denoiser" in html
|
||||
|
||||
|
||||
def test_a_poisoned_manifest_cannot_take_down_the_index(client):
|
||||
"""The v0.2.2 lesson, asserted for the new reader before it can repeat:
|
||||
`list_booths` touches every booth on every page load, so one bad file must
|
||||
cost that booth's provenance and nothing else."""
|
||||
c, data = client
|
||||
_booth(data, "good")
|
||||
bad = _booth(data, "bad")
|
||||
(bad / MANIFEST_FILE).write_bytes(b"\xff\xfe not utf-8 at all")
|
||||
|
||||
assert c.get("/").status_code == 200
|
||||
assert c.get("/healthz").status_code == 200
|
||||
|
||||
|
||||
def test_a_pickup_booth_announces_itself_as_the_booths_own(client):
|
||||
"""No exemption list. A booth the service made says the service made it,
|
||||
which is true — and it keeps the rule to one line: a booth with no manifest
|
||||
is unannounced."""
|
||||
c, data = client
|
||||
r = c.post("/upload", files=[("files", ("a.txt", b"hello", "text/plain"))],
|
||||
follow_redirects=False)
|
||||
assert r.status_code in (200, 303)
|
||||
|
||||
booth = next(p for p in data.iterdir() if p.is_dir())
|
||||
got = read_manifest(booth)
|
||||
assert got is not None and got.handle == "booth"
|
||||
assert 'class="prov prov-none"' not in c.get("/").text
|
||||
|
||||
|
||||
# ---- findings from the cross-frontier CODE-REVIEW panel, 2026-09-22 ----------
|
||||
#
|
||||
# Heid panel (thread 01M341E9XAPZEFBSPK9HPGAM0S). Four arms, artifact-only.
|
||||
# The round found ZERO drift in the strict sense and landed its weight one layer
|
||||
# down, in test strength: five of the ten adopted findings are tests of mine
|
||||
# that pass on the regression they exist to catch.
|
||||
|
||||
|
||||
def test_the_read_survives_a_document_no_one_can_parse(tmp_path):
|
||||
"""INV-2 said "never raises" and named a 4 GB file as a tested case. It was
|
||||
not tested, and it did not hold: `except ValueError` catches a truncated
|
||||
document, but `json.loads` on deeply nested input raises RecursionError,
|
||||
which is not a ValueError and is not an OSError either.
|
||||
|
||||
`list_booths` calls this once per booth on every index load, so the one
|
||||
file costs the whole front page — the exact outage shape the invariant
|
||||
cites as its reason for existing. Three of four arms reached it
|
||||
independently; the eight-payload parametrize above has no size or depth
|
||||
case, so the hole stayed green.
|
||||
"""
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
(b / MANIFEST_FILE).write_text("[" * 200_000 + "]" * 200_000)
|
||||
|
||||
got = read_manifest(b)
|
||||
assert isinstance(got, Manifest) and got.error
|
||||
|
||||
|
||||
def test_the_read_refuses_a_document_too_large_to_be_a_manifest(tmp_path):
|
||||
"""The other half of INV-2's named case. A manifest is four short fields;
|
||||
anything approaching a megabyte is not one, and reading it into memory to
|
||||
discover that is the wrong order of operations. Bounded BEFORE the read, so
|
||||
the size is checked by `stat` rather than survived."""
|
||||
from booth.manifest import MANIFEST_MAX_BYTES
|
||||
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
(b / MANIFEST_FILE).write_text('{"handle": "x", "why": "' +
|
||||
"y" * (MANIFEST_MAX_BYTES + 100) + '"}')
|
||||
|
||||
got = read_manifest(b)
|
||||
assert isinstance(got, Manifest) and got.error
|
||||
assert "too large" in got.error
|
||||
|
||||
|
||||
def test_a_hostile_directory_name_does_not_reach_the_record_raw(tmp_path):
|
||||
"""`_one_line(title, TITLE_MAX) or booth.name` — the FALLBACK skips the
|
||||
normalization the explicit value gets. A directory name may legally carry a
|
||||
newline on POSIX and may be 255 bytes, and either lands in a card's
|
||||
sub-line. Same shape on the read path's fallback."""
|
||||
# 200-odd bytes, under the filesystem's own 255 limit but well over
|
||||
# TITLE_MAX — and a newline, which POSIX permits in a filename.
|
||||
name = "we" + "i" * 200 + "rd\nname"
|
||||
b = tmp_path / name
|
||||
b.mkdir()
|
||||
|
||||
m = write_manifest(b, "booth-dev")
|
||||
assert "\n" not in m.title and len(m.title) <= 120
|
||||
assert "\n" not in read_manifest(b).title
|
||||
|
||||
|
||||
def test_the_write_replaces_rather_than_truncating(tmp_path):
|
||||
"""The previous version of this test asserted only that no `*.tmp` file
|
||||
survived — which a plain `write_text` passes, since it leaves no temp file
|
||||
either. All four arms said so, and they were right.
|
||||
|
||||
THE INODE IS THE DISCRIMINATOR. `os.replace` publishes a different file over
|
||||
the old name, so the inode changes; truncate-and-rewrite keeps it. That is
|
||||
also exactly why the promise holds for a concurrent reader: it either has
|
||||
the old inode, intact, or opens the new one, complete. A test of the
|
||||
mechanism rather than of its litter.
|
||||
|
||||
(An earlier draft spied on `os.open` to prove the published path was never
|
||||
opened for writing. It passed — vacuously. `Path.write_text` reaches the
|
||||
syscall through `io.open` in C and never touches the Python-level
|
||||
`os.open`, so the spy could not have fired either way. Recorded because
|
||||
writing a second vacuous test while fixing the first is the failure mode
|
||||
this whole round is about.)
|
||||
"""
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
published = b / MANIFEST_FILE
|
||||
|
||||
write_manifest(b, "booth-dev", why="first")
|
||||
first_inode = published.stat().st_ino
|
||||
write_manifest(b, "booth-dev", why="second")
|
||||
|
||||
assert published.stat().st_ino != first_inode, (
|
||||
"the manifest was rewritten in place, not replaced"
|
||||
)
|
||||
assert read_manifest(b).why == "second"
|
||||
|
||||
|
||||
def test_the_temp_file_is_not_a_name_two_writers_share(tmp_path):
|
||||
"""Every writer derived the same `.booth.json.tmp`. Two `booth add` calls on
|
||||
one booth could then interleave through a stale descriptor into the
|
||||
published path — the atomic-write promise is that READERS never see a
|
||||
partial file, and it says nothing about two writers sharing a scratch name.
|
||||
Marks are protected from this by their flock; the manifest has none."""
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
seen = set()
|
||||
for i in range(5):
|
||||
write_manifest(b, "booth-dev", why=f"pass {i}")
|
||||
seen.update(p.name for p in b.iterdir() if p.name != MANIFEST_FILE)
|
||||
assert not seen, f"left temp files behind: {sorted(seen)}"
|
||||
|
||||
from booth.manifest import _temp_path
|
||||
names = {_temp_path(b).name for _ in range(20)}
|
||||
assert len(names) > 1, "every writer derives the same temp name"
|
||||
|
||||
|
||||
def test_a_bare_re_announce_does_not_wipe_the_why(tmp_path):
|
||||
"""THE WORKFLOW IS `new --why` THEN `add`. Omitted flags meant empty
|
||||
strings, and empty strings overwrote — so the second command silently
|
||||
erased the sentence the first one existed to record, on the single most
|
||||
common sequence this feature has.
|
||||
|
||||
Two arms of the paraphrase panel predicted it from the contract's wording
|
||||
alone ("gains a manifest with no why" does not distinguish a first write
|
||||
from a re-announce with the flags omitted). Every test I wrote passed
|
||||
`--why` on both calls, so none of them could see it.
|
||||
|
||||
Omitted now means UNCHANGED; only a value that was actually supplied
|
||||
overwrites, and an explicit empty string still clears.
|
||||
"""
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
write_manifest(b, "booth-dev", title="R18 A/B", why="pick the denoiser")
|
||||
|
||||
write_manifest(b, "booth-dev") # a bare `booth add`
|
||||
kept = read_manifest(b)
|
||||
assert kept.why == "pick the denoiser", "a bare re-announce wiped the why"
|
||||
assert kept.title == "R18 A/B"
|
||||
|
||||
write_manifest(b, "booth-dev", why="sharper") # supplied: overwrites
|
||||
assert read_manifest(b).why == "sharper"
|
||||
|
||||
write_manifest(b, "booth-dev", why="") # explicit: clears
|
||||
assert read_manifest(b).why == ""
|
||||
|
||||
|
||||
def test_re_announcing_preserves_a_created_from_before_this_second(tmp_path):
|
||||
"""`_now()` is whole-second resolution, so two `write_manifest` calls in a
|
||||
row share a timestamp and the old preservation test passed even against an
|
||||
implementation that regenerated `created` every time. Three of four arms
|
||||
caught it. Seed a stamp that could not have come from now()."""
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
(b / MANIFEST_FILE).write_text(json.dumps({
|
||||
"handle": "booth-dev", "title": "b", "why": "first",
|
||||
"created": "2019-03-04T11:22:33-08:00",
|
||||
}))
|
||||
|
||||
assert write_manifest(b, "booth-dev", why="second").created == \
|
||||
"2019-03-04T11:22:33-08:00"
|
||||
|
||||
|
||||
def test_only_the_manifest_module_opens_the_manifest(tmp_path):
|
||||
"""INV-1, which had no guard anywhere. One resolver is only one resolver
|
||||
while nothing else learns the filename."""
|
||||
root = pathlib.Path(__file__).parent.parent
|
||||
offenders = []
|
||||
for src in sorted((root / "booth").glob("*.py")):
|
||||
if src.name == "manifest.py":
|
||||
continue
|
||||
tree = ast.parse(src.read_text())
|
||||
# STRING CONSTANTS, not raw text. A comment naming the file is prose
|
||||
# about the design and harms nothing — the first version of this test
|
||||
# scanned the whole source and went red on a comment explaining why a
|
||||
# leaked `.booth.json.<hex>.tmp` keeps a booth alive. The invariant is
|
||||
# about code that knows the filename, so ask the code.
|
||||
docstrings = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Module, ast.ClassDef,
|
||||
ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
body = getattr(node, "body", None)
|
||||
if body and isinstance(body[0], ast.Expr) and \
|
||||
isinstance(body[0].value, ast.Constant):
|
||||
docstrings.add(id(body[0].value))
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Constant) and isinstance(node.value, str)
|
||||
and id(node) not in docstrings and ".booth.json" in node.value):
|
||||
offenders.append(f"{src.name}:{node.lineno}")
|
||||
assert not offenders, f"{offenders} name the manifest file in code"
|
||||
|
||||
|
||||
def test_announcing_is_activity_via_the_manifest_file_itself(tmp_path):
|
||||
"""The previous version could not fail. Writing the manifest creates a
|
||||
directory entry, which bumps the DIRECTORY's mtime, so the booth read as
|
||||
fresh whether or not `_newest_mtime` counted the manifest at all — a test
|
||||
of the side effect rather than of the thing.
|
||||
|
||||
Put the directory's clock back afterwards, leaving the manifest's own mtime
|
||||
as the only thing that can keep the booth alive."""
|
||||
import os
|
||||
|
||||
from booth.app import booth_age_seconds
|
||||
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
old = 1_000_000_000
|
||||
os.utime(b, (old, old))
|
||||
write_manifest(b, "booth-dev", why="look at this")
|
||||
os.utime(b, (old, old)) # only the file can save it now
|
||||
|
||||
assert booth_age_seconds(b, now=old + 90_000) < 86_400
|
||||
|
||||
|
||||
def test_the_booth_header_marks_an_unannounced_booth_too(client):
|
||||
"""The negative states were asserted on `/` only, so a header that rendered
|
||||
provenance for clean manifests and nothing for the other two would have
|
||||
passed the whole suite."""
|
||||
c, data = client
|
||||
_booth(data, "quiet")
|
||||
damaged = _booth(data, "damaged")
|
||||
(damaged / MANIFEST_FILE).write_text("{oops")
|
||||
|
||||
assert 'class="prov prov-none"' in c.get("/b/quiet/").text
|
||||
assert 'class="prov prov-broken"' in c.get("/b/damaged/").text
|
||||
|
||||
|
||||
def test_the_title_reaches_a_surface(client):
|
||||
"""`--title` promised a display name and nothing rendered it — 4/4 on the
|
||||
paraphrase panel, independently the top-ranked flag of that round. It lands
|
||||
on the booth page heading, where there is room for it; the INDEX card keeps
|
||||
the directory name, because that is the identity the operator navigates and
|
||||
refers to positionally."""
|
||||
c, data = client
|
||||
b = _booth(data, "r18-ab")
|
||||
write_manifest(b, "booth-dev", title="R18 A/B — denoiser bakeoff", why="w")
|
||||
|
||||
page = c.get("/b/r18-ab/").text
|
||||
assert "R18 A/B — denoiser bakeoff" in page
|
||||
assert "r18-ab" in page, "the directory name stopped being visible"
|
||||
|
||||
|
||||
# ---- findings from the cross-frontier BUG-HUNT panel, 2026-09-22 -------------
|
||||
#
|
||||
# Heid panel (thread 01M343SXX27Z47C3STXXRC7M42). Four arms, artifact-only,
|
||||
# diff-scoped. The strongest finding is one the SIZE CAP ITSELF opened.
|
||||
|
||||
|
||||
def test_a_reader_never_blocks_on_a_file_that_is_not_a_file(tmp_path):
|
||||
"""`stat` reports size 0 for a FIFO, so it sails under the byte cap — and
|
||||
then `read_text` blocks in `read` with no EOF, so the `except` never runs
|
||||
and the call never returns. `list_booths` reads every booth on every `GET /`
|
||||
and `/healthz`, so ONE such file stalls the front page for the whole service,
|
||||
with no error and no recovery short of a restart.
|
||||
|
||||
A symlink to `/dev/zero` is the same hole with unbounded allocation instead
|
||||
of a hang: `st_size` is 0 there too.
|
||||
|
||||
Two of four arms reached it independently. The bound added an hour earlier
|
||||
is what made it reachable — `st_size` answers a different question than
|
||||
"can this be read", and a cap that trusts it inherits the difference.
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
os.mkfifo(b / MANIFEST_FILE)
|
||||
|
||||
# ⚠ ALARMED. Without this the RED state of this test does not fail, it HANGS
|
||||
# — which is the defect itself, and is also useless as a signal: a suite that
|
||||
# stops is indistinguishable from a suite that is slow. Five seconds is a
|
||||
# thousand times the budget a read of a four-field file should need.
|
||||
def _timeout(signum, frame):
|
||||
raise AssertionError("read_manifest blocked on a FIFO and never returned")
|
||||
|
||||
old_handler = signal.signal(signal.SIGALRM, _timeout)
|
||||
signal.alarm(5)
|
||||
try:
|
||||
got = read_manifest(b)
|
||||
finally:
|
||||
signal.alarm(0)
|
||||
signal.signal(signal.SIGALRM, old_handler)
|
||||
|
||||
assert isinstance(got, Manifest) and got.error
|
||||
assert "regular file" in got.error
|
||||
|
||||
|
||||
def test_a_damaged_manifest_is_kept_when_it_is_replaced(tmp_path):
|
||||
"""4/4, and it contradicted this repo's own doctrine. Marks made the rule
|
||||
explicit in v0.2.1 — reads stay lenient, writes go strict, damaged bytes
|
||||
STAY ON DISK — and the manifest's write replaced them outright.
|
||||
|
||||
The sharpest leg: a file that fails on ONE field still holds the others.
|
||||
`{"handle": 7, "why": "the thing I wanted you to look at"}` reads as broken
|
||||
and used to be destroyed whole, taking a `why` the re-announcer may not have
|
||||
kept anywhere.
|
||||
|
||||
Quarantined rather than refused: refusing would fail `booth add` and lose
|
||||
the files it was copying, which is the worse trade. One fixed-name
|
||||
quarantine, so this cannot accumulate.
|
||||
"""
|
||||
from booth.manifest import QUARANTINE_FILE
|
||||
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
damaged = json.dumps({"handle": 7, "why": "the thing I wanted you to see"})
|
||||
(b / MANIFEST_FILE).write_text(damaged)
|
||||
|
||||
write_manifest(b, "booth-dev", why="rescued")
|
||||
|
||||
assert read_manifest(b).why == "rescued"
|
||||
assert (b / QUARANTINE_FILE).read_text() == damaged, "the damaged bytes were destroyed"
|
||||
|
||||
|
||||
def test_a_broken_record_normalizes_the_directory_name_too(tmp_path):
|
||||
"""The third fallback. `write_manifest`'s and `read_manifest`'s were fixed
|
||||
in the previous round and `_broken`'s was missed — same raw `booth.name`,
|
||||
same card sub-line, same newline."""
|
||||
b = tmp_path / ("wei" + "i" * 200 + "rd\nname")
|
||||
b.mkdir()
|
||||
(b / MANIFEST_FILE).write_text("{oops")
|
||||
|
||||
got = read_manifest(b)
|
||||
assert got.error and "\n" not in got.title and len(got.title) <= 120
|
||||
|
||||
|
||||
def test_an_identical_re_announce_does_not_touch_the_booth(tmp_path):
|
||||
"""Marks learned this in v0.2.0: a write that changes nothing is not
|
||||
activity and must not reset a booth's TTL. The manifest wrote
|
||||
unconditionally, so `booth add` on an unchanged booth kept a dead one alive
|
||||
— and `booth link` does it on every single post to the standing board."""
|
||||
import os
|
||||
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
write_manifest(b, "booth-dev", why="x")
|
||||
path = b / MANIFEST_FILE
|
||||
os.utime(path, (1_000_000_000, 1_000_000_000))
|
||||
os.utime(b, (1_000_000_000, 1_000_000_000))
|
||||
before = path.stat().st_mtime
|
||||
|
||||
write_manifest(b, "booth-dev", why="x") # identical
|
||||
|
||||
assert path.stat().st_mtime == before, "an identical re-announce rewrote the file"
|
||||
|
||||
|
||||
def test_a_failed_write_leaves_no_temp_file_behind(tmp_path):
|
||||
"""The unique temp name fixed a cross-writer hazard and created a litter
|
||||
one: a fixed name is overwritten by the next writer, a random one is not.
|
||||
And `.booth.json.<hex>.tmp` is NOT a `.lock`, so `_newest_mtime` counts it —
|
||||
an orphaned temp would keep a dead booth alive forever."""
|
||||
import os
|
||||
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
real_replace = os.replace
|
||||
|
||||
def boom(src, dst, *a, **kw):
|
||||
raise OSError("no space left on device")
|
||||
|
||||
os.replace = boom
|
||||
try:
|
||||
with pytest.raises(OSError):
|
||||
write_manifest(b, "booth-dev", why="x")
|
||||
finally:
|
||||
os.replace = real_replace
|
||||
|
||||
assert not list(b.glob("*.tmp")), f"orphaned temp: {list(b.glob('*.tmp'))}"
|
||||
+186
-3
@@ -276,20 +276,32 @@ def test_as_dict_round_trips_through_json(tmp_path):
|
||||
# ---- the stdlib-only invariant (INV-5) --------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", ["marks", "asks", "links"])
|
||||
@pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest"])
|
||||
def test_stdlib_only(module):
|
||||
"""INV-5. scripts/booth imports these under the system python3 with NO venv,
|
||||
through a `python3 -c` heredoc that no AST extractor can see — so nothing
|
||||
but this test stands between a casual third-party import and `booth ask`
|
||||
breaking on every fleet host."""
|
||||
# `manifest` also carries a stricter copy in tests/test_manifest.py, which
|
||||
# additionally forbids importing `booth.*` — a cross-import between two
|
||||
# stdlib-only modules is a second way for this invariant to break.
|
||||
src = pathlib.Path(__file__).parent.parent / "booth" / f"{module}.py"
|
||||
tree = ast.parse(src.read_text())
|
||||
roots = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
roots.update(a.name.split(".")[0] for a in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
||||
roots.add(node.module.split(".")[0])
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
# `node.level > 0` is a RELATIVE import (`from . import marks`),
|
||||
# which has no `module` root to inspect and used to slip through
|
||||
# this walk entirely. It cannot reach outside the package, so it is
|
||||
# stdlib-safe by construction — but it is recorded rather than
|
||||
# ignored, because `manifest.py` additionally forbids importing a
|
||||
# sibling and its own test needs to see one.
|
||||
if node.level:
|
||||
roots.add("booth")
|
||||
elif node.module:
|
||||
roots.add(node.module.split(".")[0])
|
||||
outside = {r for r in roots if r != "booth" and r not in sys.stdlib_module_names}
|
||||
assert not outside, f"booth/{module}.py imports non-stdlib: {sorted(outside)}"
|
||||
|
||||
@@ -1191,3 +1203,174 @@ def test_an_unreadable_mark_is_visible_on_the_page(client):
|
||||
html = c.get("/b/b/").text
|
||||
assert "⚠ broken" in html, "an unreadable mark rendered as an empty note"
|
||||
assert "n1" in html
|
||||
|
||||
|
||||
def test_a_marks_file_no_one_can_parse_does_not_take_down_the_index(tmp_path):
|
||||
"""The v0.2.2 round adopted the RecursionError finding and closed only half
|
||||
of it. `_hydrate_safe` guards hydration; `json.loads` runs BEFORE that, in
|
||||
`_read_raw`, whose `except (OSError, ValueError, UnicodeDecodeError)` does
|
||||
not cover RecursionError or MemoryError.
|
||||
|
||||
So a 400 KB file of nothing but brackets, in any one booth, still returned
|
||||
500 for `/` and `/healthz` across every booth on the service. Found by the
|
||||
U5 code-review panel against the sibling module and confirmed by running it.
|
||||
The read is bounded now and both classes are caught.
|
||||
"""
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
(booth / MARKS_FILE).write_text("[" * 200_000 + "]" * 200_000)
|
||||
|
||||
assert marks_for(booth) == []
|
||||
|
||||
|
||||
def test_a_marks_file_too_large_to_be_marks_is_refused_before_it_is_read(tmp_path):
|
||||
"""Bounded by `stat`, not survived. A booth holds one marks document, and
|
||||
the index reads every booth's on every page load."""
|
||||
from booth.marks import MARKS_MAX_BYTES
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
(booth / MARKS_FILE).write_text(" " * (MARKS_MAX_BYTES + 10))
|
||||
|
||||
assert marks_for(booth) == []
|
||||
|
||||
|
||||
def test_a_write_over_an_unparseable_marks_file_still_refuses(tmp_path):
|
||||
"""The strict half of the asymmetry has to see the same failures the lenient
|
||||
half does, or a file that reads as "no marks" gets replaced by a write that
|
||||
believed it. Same two exception classes, same bound."""
|
||||
from booth.marks import MarksCorrupt, set_flag
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
(booth / MARKS_FILE).write_text("[" * 200_000 + "]" * 200_000)
|
||||
|
||||
with pytest.raises(MarksCorrupt):
|
||||
set_flag(booth, "a.png", True)
|
||||
|
||||
|
||||
# ---- findings from the U5 diff-scoped BUG-HUNT panel, 2026-09-22 ------------
|
||||
|
||||
|
||||
def test_the_marks_reader_never_blocks_on_a_file_that_is_not_a_file(tmp_path):
|
||||
"""Same hole the size cap opened in the manifest, in the sibling it was
|
||||
copied from. `st_size` is 0 for a FIFO, so it passes the cap, and then
|
||||
`read_text` blocks with no EOF. `list_booths` reads every booth's marks on
|
||||
every `GET /` and `/healthz`."""
|
||||
import os
|
||||
import signal
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
os.mkfifo(booth / MARKS_FILE)
|
||||
|
||||
def _timeout(signum, frame):
|
||||
raise AssertionError("marks_for blocked on a FIFO and never returned")
|
||||
|
||||
old = signal.signal(signal.SIGALRM, _timeout)
|
||||
signal.alarm(5)
|
||||
try:
|
||||
assert marks_for(booth) == []
|
||||
finally:
|
||||
signal.alarm(0)
|
||||
signal.signal(signal.SIGALRM, old)
|
||||
|
||||
|
||||
def test_new_marks_and_imported_marks_share_one_stamp_format(tmp_path):
|
||||
"""The v0.2.2 fix for the legacy-import ordering opened a NEW ordering bug,
|
||||
which is the shape worth remembering. `import_legacy_asks` moved to
|
||||
microsecond precision while `now_stamp` stayed at whole seconds, and `-` is
|
||||
0x2D against `.` at 0x2E — so `...T10:00:00-07:00` sorts BEFORE
|
||||
`...T10:00:00.500000-07:00`, putting a LATER mark ahead of an EARLIER
|
||||
import inside the same second.
|
||||
|
||||
Deterministic order is a v1 invariant precisely because the operator refers
|
||||
to things positionally. One format, or the rule cannot be stated.
|
||||
"""
|
||||
from booth.marks import now_stamp
|
||||
|
||||
stamp = now_stamp()
|
||||
assert "." in stamp.split("T")[1], f"now_stamp is not sub-second: {stamp}"
|
||||
assert len(stamp.split(".")[1].split("+")[0].split("-")[0]) == 6
|
||||
|
||||
|
||||
def test_the_importer_cannot_raise_out_of_a_poisoned_entry(tmp_path):
|
||||
"""`marks_for` routes every entry through `_hydrate_safe`; the importer's
|
||||
return still went through the bare `_hydrate`, so the one path that reads
|
||||
entries it did not write was the one without the guard."""
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
(booth / MARKS_FILE).write_text(json.dumps({
|
||||
"version": 1,
|
||||
"marks": [{"id": "n1", "shape": "note", "text": {"bad": True},
|
||||
"created": "2026-09-21T00:00:00+00:00"}],
|
||||
}))
|
||||
(booth / f"q1{ASK_SUFFIX}").write_text(json.dumps(_single()))
|
||||
|
||||
from booth.marks import import_legacy_asks
|
||||
out = import_legacy_asks(booth) # must not raise
|
||||
assert isinstance(out, list)
|
||||
|
||||
|
||||
def test_a_document_that_would_not_read_back_is_refused_at_the_write(tmp_path):
|
||||
"""The read bound is on the STORED bytes and the write adds `indent=2`, so a
|
||||
document that fits in memory can land over the limit on disk and then read
|
||||
back as no marks at all — every mark in the booth gone, silently. Refuse
|
||||
loudly instead: a write that fails is recoverable.
|
||||
|
||||
Asserted against `_write_raw` directly, because no single mark can get
|
||||
there: `_clean_text` caps a note at TEXT_MAX and a flag is a fixed shape.
|
||||
The reachable path is accumulation — `_note_id` puts no ceiling on how many
|
||||
notes one booth may carry — which is thousands of writes, not one. Testing
|
||||
it through `write_note` would need a fixture nobody could justify, and
|
||||
would be testing the cap rather than the guard.
|
||||
"""
|
||||
from booth.marks import MARKS_MAX_BYTES, MarksCorrupt, _write_raw
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
bulk = [{"id": f"note-{i}", "shape": "note", "text": "x" * 500,
|
||||
"created": "2026-09-21T00:00:00.000000+00:00"}
|
||||
for i in range(MARKS_MAX_BYTES // 400)]
|
||||
|
||||
with pytest.raises(MarksCorrupt):
|
||||
_write_raw(booth, bulk)
|
||||
assert not (booth / MARKS_FILE).exists(), "a refused write still landed"
|
||||
|
||||
|
||||
def test_a_clock_restore_that_fails_does_not_take_the_route_down(tmp_path):
|
||||
"""The concrete half of the mtime-restore finding.
|
||||
|
||||
`_Locked.__enter__` puts the booth directory's clock back after creating its
|
||||
lock, and `os.utime` can fail — a read-only directory, a booth whose owner
|
||||
we are not. It used to escape into the route and answer 500 for what is
|
||||
otherwise a perfectly good request. Not putting the clock back is a cost
|
||||
this module can absorb; not answering is not.
|
||||
|
||||
The RACE half of that finding is documented in the code and deliberately not
|
||||
closed: the alternative fix would silently retire the documented behaviour
|
||||
that releasing a kept board resets its clock
|
||||
(`test_releasing_a_board_RESETS_its_ttl_clock` pins that on purpose), which
|
||||
is a TTL doctrine change rather than a bug fix.
|
||||
"""
|
||||
import os
|
||||
|
||||
from booth.marks import MARKS_LOCK, set_flag
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
real_utime = os.utime
|
||||
|
||||
def boom(path, *a, **kw):
|
||||
if str(path) == str(booth):
|
||||
raise PermissionError("read-only directory")
|
||||
return real_utime(path, *a, **kw)
|
||||
|
||||
os.utime = boom
|
||||
try:
|
||||
assert set_flag(booth, "a.png", True) is not None
|
||||
finally:
|
||||
os.utime = real_utime
|
||||
|
||||
assert (booth / MARKS_LOCK).exists()
|
||||
assert [m.target for m in marks_for(booth)] == ["a.png"]
|
||||
|
||||
Reference in New Issue
Block a user