11 Commits
Author SHA1 Message Date
Vuong Hoang 95beede3c3 fix(manifest)!: the size cap opened a service-wide hang; close it
The diff-scoped bug-hunt panel, four arms, artifact-only. Its strongest
finding is one I created two hours earlier while hardening the reader.

`stat` reports size 0 for a FIFO and 0 for a symlink to /dev/zero, so both
sail under the byte cap added for the RecursionError round — and then
`read_text` either blocks in read() with no EOF, so the except never runs,
or allocates until the kernel intervenes. `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.
Reproduced before believing it (timeout returned 124). S_ISREG is checked
BEFORE the size in both modules now; verified against the live service with
two FIFOs planted, which answered 200 in 36ms.

The shape worth carrying: 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.

THE UPLOAD PATH WROTE ABOVE ITS OWN CLEANUP GUARD (4/4)

A failed manifest write orphaned a .uploaded half-booth with no files in
it — and because the temp name now carries a random suffix, nothing ever
overwrote the leak, and .booth.json.<hex>.tmp is not a .lock, so
_newest_mtime counted it and kept that empty booth past every sweep. The
uniqueness fix from the previous round is what made the leak permanent.
Both writes moved inside the guard; the temp is removed on every exit path.

DAMAGED BYTES ARE KEPT, NOT REPLACED (4/4, INV-6)

Marks made this explicit in v0.2.1 and this write path contradicted it: a
manifest 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 — marks refuse 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 copying.

ONE OPENNESS PREDICATE, AS U2 SAID (2/4)

`booth answer` spelled out `if m.answer is None` while `booth marks` asked
`open_marks`, so a partially-answered pick read as done to one verb and
open to the other — at the same instant, on the same booth. U2's INV-2 put
openness in one function precisely so they could not drift. The mirror case
is fixed too: a pick that hydrates broken is refused by the web route, so
`answer --wait` polled an hour on a form nothing could ever land.

ALSO

- now_stamp was whole-second while the importer had moved to microseconds,
  and '-' sorts before '.', so a later mark came out ahead of an earlier
  import inside the same second. One format; the previous round's ordering
  fix had opened this one.
- `_broken` was the third of three directory-name fallbacks and the one
  still handing a raw name into a card's sub-line.
- An identical re-announce rewrote the file and reset the TTL. `booth link`
  does this on every post to the standing board.
- The importer's return went through the bare _hydrate, not _hydrate_safe.
- A marks document could be written larger than it can be read back, and
  then read as no marks at all. Refused at the write instead.
- `choice` reached the answer builder raw while `notes` beside it did not.

AND ONE FINDING DELIBERATELY NOT FULLY CLOSED

The mtime-restore race is real. The clean fix — ignore a booth directory's
own mtime whenever the booth holds anything — also silently retires the
documented 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, and an existing test caught the attempt.
The concrete half is fixed (a failing os.utime escaped and 500'd the
route); the race is stated in the code where the next reader will meet it.

341 tests. Live service restarted, 24/24 booth pages verified.
2026-09-22 02:27:18 -07:00
Vuong Hoang f3193fb054 fix(probe): the disclosure-opening loop was manufacturing its own findings
`page.locator("details:not([open])").all()` hands back POSITIONAL locators
that re-resolve against the current DOM, and `: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 leaves some closed. Those then report
OCCLUDED, which is exactly the false-positive class the block was added to
remove. One on booth-redesign, three on cr123a-to-d-sleeve, one on
denoise-first-run, and invisible as a bug because a false positive is
shaped like a finding.

Measured both hypotheses rather than guessing between them: per-element
loop against a single document-wide evaluate, 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.

One evaluate over the whole document now. All three pages clean.

Also carries the ROADMAP U5 row, the two-panel record in
persistent-memory.d/, and the memory index line for it.
2026-09-22 01:39:43 -07:00
Vuong Hoang c015a917ee fix(manifest): fold in both cross-frontier panels — and a live hole in v0.2.2
Two four-arm artifact-only rounds landed together: the contract paraphrase
(against the pre-seam-review capture) and the code-vs-contract conformance
review (against the amended one), correctly firewalled from each other.
The conformance round found ZERO drift in the strict sense — the code is a
clause-for-clause implementation of the contract — and the weight of both
rounds landed one layer down, in what green tests structurally cannot
report. Full triage in persistent-memory.d/.

A LIVE HOLE IN RELEASED CODE, FOUND ON THE SIBLING MODULE

v0.2.2 adopted the RecursionError finding from the bug-hunt round and
closed half of it: `_hydrate_safe` guards hydration, but `json.loads` runs
above it in `_read_raw`, whose catch list covers neither RecursionError nor
MemoryError. A 400 KB file of nothing but brackets in any ONE booth
therefore still returned 500 for `/` and `/healthz` across every booth on
the service. Confirmed by running it before believing it.

Both modules now bound the read by `stat` before touching the bytes and
catch both classes anyway, so raising a bound later cannot quietly re-open
the hole. The strict half of the marks asymmetry refuses everything the
lenient half tolerates, or a file that reads as "no marks" gets replaced by
a write that believed it.

THE WHY-WIPE

`booth new x --why "..."` then `booth add x out/*.png` erased the sentence
the first command existed to record. Omitted flags meant empty strings and
empty strings overwrote. Two arms predicted it from the contract's wording
alone; every test here passed --why on both calls and so could not see it.
Omitted now means unchanged and an explicit --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/4, and independently top-ranked
by every arm of the paraphrase round. It lands on the booth page heading
with the directory name beside it, because the directory name is the
identity the operator navigates by and refers to positionally.

THREE TESTS THAT COULD NOT FAIL

- test_the_write_is_atomic asserted no *.tmp survived, which a plain
  write_text passes. It asserts the inode changes now. (The first
  replacement was ALSO vacuous — it spied on os.open, which Path.write_text
  reaches through io.open in C and never touches. Recorded in the test,
  because writing a second vacuous test while fixing the first is exactly
  the failure this round is about.)
- The INV-3 preservation test passed against an implementation that
  regenerated `created` every time, because _now() is whole-second
  resolution and back-to-back writes share a stamp. Seeded from 2019 now.
- test_announcing_is_activity passed whether or not _newest_mtime counted
  the manifest, because writing it bumps the directory mtime either way.
  The directory's clock is put back, leaving the file as the only thing
  that can keep the booth alive.

ALSO

- The title fallback skipped the normalizer the explicit value gets; a
  directory name may legally carry a newline and run to 255 bytes.
- Every writer derived the same .booth.json.tmp. Marks are protected from
  that by their flock; the manifest has none, so uniqueness stands in.
- test_stdlib_only was blind to relative imports in all four modules.
- INV-1 had no guard at all; INV-5 named two different promises; the
  negative render states were asserted on the index only.

Contract amended throughout: the 4 GB case is a stat-checked bound rather
than a return constraint, every field of an error-carrying record has a
stated value, INV-1 no longer contradicts INV-3, repo-wide rules are named
in words instead of by a colliding number, and touches admits the macro
partial the implementation added.

329 tests.
2026-09-22 01:29:27 -07:00
Vuong Hoang fac83de8f4 docs(u5): name the verbatim-booth boundary as U3's, not a gap
Five live booths serve the author's HTML raw and the Booth owns no
header there to put a provenance line into — it reaches those pages
through six regexes injected into arbitrary markup, which is the defect
U3 exists to fix. Their index cards carry provenance like everything
else. Written down so the boundary reads as a boundary rather than as
something this unit forgot. Verified on pewpew-ui-brief.
2026-09-22 01:05:05 -07:00
Vuong Hoang aa61fcf5fd test(probe): teach the layout probe about <details>, and guard the flag parser
THE PROBE. A control inside a CLOSED <details> 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. Verified both ways before believing it:
closed, elementFromPoint returns div.gallery; opened, the button itself,
and a real trial click lands on it.

Opening every <details> rather than skipping them is the deliberate
choice. 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 class of control this instrument
exists to check. Fourth false-positive class this probe has grown a
guard for; the other three are already in its header.

THE FLAG PARSER. Three cases that silently break and are cheap to
pin: the flags on either side of the glob (a session should not have to
remember which), a why carrying quotes, an em-dash, a newline and
non-ASCII, and --why with no value after it, which must produce usage
rather than eating the booth name and creating a booth called nothing.

313 tests.
2026-09-22 01:01:20 -07:00
Vuong Hoang c9a175ba4a memory: U5 adoption is a prediction with a re-measure date
Operator declined the fleetwide announcement (2026-09-22) and chose to
let the convention propagate through the README alone, specifically so
adoption can be distinguished from design. Baseline 0 of 26 booths at
landing; re-count 2026-09-29. Near-zero means nobody heard about it,
which is a different failure from nobody wanting it.
2026-09-22 00:56:25 -07:00
Vuong Hoang 75dca53483 docs(probe): the probe covers the index only, and says so now
The docstring claimed a no-argument run probes 'the booth index and
every booth linked from it'. main() probes argv[1:] or the default URL
and follows nothing — so a coverage claim that reads as 26 pages has
always been one. A probe that overstates its reach is worse than one
that states a small reach honestly, because this is the instrument
standing in for a class of bug the test suite structurally cannot see.

Also records the zsh trap that hid it: an unquoted $URLS holding twelve
space-separated URLs arrives as ONE argument, and the probe cheerfully
reports '2 page(s)' while covering two.
2026-09-22 00:54:54 -07:00
Vuong Hoang 67ab7d1cd5 docs(readme): --why, on the page the 17 consuming handles actually read
The quickstart is where a session learns the CLI, so the announcement
verb has to be in the first code block rather than in a section further
down that nobody scrolls to. States the trade plainly: optional, nothing
breaks without it, and a booth that cannot say what it is has no way to
ask for attention except by posting its URL somewhere else.
2026-09-22 00:50:02 -07:00
Vuong Hoang ac35f2441f docs(u5): state what the contract deliberately leaves out
The out-of-scope block is load-bearing for the cross-frontier review
gates — without negative constraints their signal-to-noise drops sharply,
and both /heid-code-review and /heid-bug-hunt refuse to fire without one.
Written for the reviewer, but it is the same list the roadmap gate
produced: the what-landed feed is parked for v1.1, nothing enforces that
a booth must announce itself (rsync is a documented path and never runs
the CLI), and the manifest describes rather than decides — U4 owns
lifetime.
2026-09-22 00:49:22 -07:00
Vuong Hoang a48ef83ef5 feat(manifest): U5 — booths that say who posted them and why
The index card showed 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.

Each booth carries `.booth.json` — {handle, title, why, created} — written
by the CLI from $ALTHING_HANDLE, and the provenance line renders on both
index lanes and on the booth page header.

WHAT IS WHERE

- booth/manifest.py, stdlib-only and importing nothing from booth.* either:
  scripts/booth imports it under the system python3 with no venv, and a
  cross-import between two stdlib-only modules is a second way for that
  invariant to break. It joins the shared test_stdlib_only list and keeps
  a stricter copy of its own.
- The read is lenient and cannot raise. list_booths touches every booth on
  every index load, so a manifest that cannot be parsed costs that booth's
  provenance and nothing else. That is the v0.2.2 lesson applied before the
  same mistake rather than after it.
- Absent and damaged render differently — `unannounced` and `unreadable`.
  Folding "cannot be read" into "never said" would hide the one case
  somebody has to go and fix.
- Re-announcing preserves `created`. A second `booth add` sharpening the
  why is not a second appearance of the booth.
- The write is atomic (invariant 5); the temp file is itself a dotfile, so
  no listing can see it mid-write.

THREE OPERATOR CALLS, 2026-09-22

Flags on the existing new/add verbs rather than a separate `announce` verb
(a second step is the step that gets forgotten, which is the rot's own
mechanism). Unannounced booths get a quiet marker rather than nothing — the
convention is only adoptable if the gap is visible. U5 adds provenance only
and does NOT add a second index ordering keyed on announcement time; that
is a different surface needing its own stated rule, parked for v1.1.

NO EXEMPTION LIST

A pickup booth and the standing link board are created by the service, so
they announce themselves with handle `booth`, which is true rather than
manufactured. One rule — a booth with no manifest is unannounced — instead
of a growing set of special cases.

ALSO

tests/test_booth.py's keep/release assertion was slicing the page on the
bare word `boothhead`, which has lived in the stylesheet far longer than
the assertion has; it was reading CSS and passing on luck, and went red the
first time a new rule landed above the old one. Same assertion, aimed at
the markup. A U5 test had the mirror-image bug: pytest derives tmp_path
from the test name and the index renders data_dir, so a test named
`test_an_unannounced_booth_says_so` put the needle in the haystack itself
and passed against a template that did not yet exist.

310 tests (304 before this unit's CLI half). Live service restarted, 26/26
booth pages verified 200, end-to-end smoke through the real CLI.

NOT TAGGED. The cold contract-review panel is still in flight and the
code-review and bug-hunt gates have not run. Tagging with a gate
outstanding is what made v0.2.0 premature.
2026-09-22 00:48:41 -07:00
Vuong Hoang 109190b0d6 docs(u5): contract for self-announcing booths, plus its seam review
Blast-radius pass first (graphify explain list_booths + grep over every
mkdir and every dotfile skip), then the contract, then a caller-side seam
review against the real sibling module surfaces.

The seam review earned its place again: the contract asserted that the
upload path's filename dedupe set must gain MANIFEST_FILE or an uploaded
file could collide with the manifest. safe_upload_name strips leading
dots, so that collision is unreachable — and the UPLOAD_MARKER entry
already sitting in that set has never been able to matter either. A
scope item the contract reasoned its way into and the sibling refutes.

Operator calls settled 2026-09-22: flags on the existing new/add verbs
rather than a second announce verb; unannounced booths get a quiet
marker rather than nothing; U5 adds provenance only and does not add a
second index ordering keyed on announcement time (parked for v1.1).

Cold contract panel dispatched to heid before this landed; its findings
fold in before any code ships.
2026-09-22 00:37:35 -07:00
19 changed files with 2377 additions and 41 deletions
+22 -3
View File
@@ -22,9 +22,9 @@ filesystem *is* the state.
A booth is **just a folder** under the data dir. Three ways, cheapest first: A booth is **just a folder** under the data dir. Three ways, cheapest first:
```bash ```bash
# 1. On nh3-dev — the helper (services/booth/scripts/booth): # 1. On nh3-dev — the helper (scripts/booth):
booth add my-run out/a.png out/b.png # creates booth + copies, prints URL booth add my-run out/a.png out/b.png --why "pick the denoiser, v3 on the left"
booth new my-run # empty booth, then cp/mv into ~/booth-data/my-run/ booth new my-run --why "..." # empty booth, then cp/mv into ~/booth-data/my-run/
booth url my-run # just print the URL booth url my-run # just print the URL
booth ls # list booths booth ls # list booths
booth rm my-run # wipe now (TTL would anyway) booth rm my-run # wipe now (TTL would anyway)
@@ -39,6 +39,25 @@ rsync -a ./out/ nh3-dev:booth-data/my-run/
Then hand the operator `http://10.100.10.50:8090/b/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 ## Checking that controls can actually be clicked
```bash ```bash
+24 -4
View File
@@ -1,7 +1,7 @@
# The Booth — roadmap # The Booth — roadmap
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md). 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.3.0` (U1, U2 and U5 landed; extracted from eshpfi 2026-09-21).
## v1 target ## v1 target
@@ -14,7 +14,7 @@ defect — not a wish. The measurements are in the IA doc.
| 2 | ~~**Marks**~~ — **landed `c7f9437`, released `v0.2.0`** | 5 mechanisms for 1 job; operator→session loop runs through chat | U2 | | 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 | | 3 | **Declared embed seam** — `/_booth/embed.js`, chrome mounts via DOM | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 |
| 4 | **Derived lifetime** — open marks pin; viewing is activity | 54% of booths on the `.forever` escape hatch | U4 | | 4 | **Derived lifetime** — 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 | | 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 | | 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 | | 7 | **Navigation at 270 items** — sections, rail, filters, grid keyboard | one flat wall; subfolder structure discarded at render | U7 |
@@ -22,8 +22,27 @@ Ordering is dependency-driven, not priority-driven: **U1 → U2 → {U3, U4, U5}
U7**, with **U6 independent** of all of them (different storage, different U7**, with **U6 independent** of all of them (different storage, different
surface) and therefore the safest thing to land first or in parallel. 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. **U1, U2 and U5 are landed.** U3 and U4 are unblocked and unstarted; U6 remains
**U5 is next** (operator, 2026-09-21). U6 remains independent and unstarted. independent and unstarted; U7 waits on the rest.
**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 ### Cross-cutting invariant — deterministic order, everywhere
@@ -53,6 +72,7 @@ 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 | | 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 | | legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them |
| link board rows | pinned first, then newest-first | | link board rows | pinned first, then newest-first |
| a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) |
Where it is still to be decided, and must be before the unit ships: **U7's 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 section ordering and its compare pairing** (sections need a stated order among
+36 -4
View File
@@ -141,6 +141,12 @@ from booth.inline import ( # noqa: E402
has_placeholders, has_placeholders,
place as place_asks, place as place_asks,
) )
from booth.manifest import ( # noqa: E402
MANIFEST_FILE,
SERVICE_HANDLE,
read_manifest,
write_manifest,
)
from booth.links import ( # noqa: E402 from booth.links import ( # noqa: E402
LINK_LOCK, LINK_LOCK,
LINKS_FILE, LINKS_FILE,
@@ -273,6 +279,11 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
# — which is why marks live in one file per booth rather than a sidecar # — which is why marks live in one file per booth rather than a sidecar
# per mark. This loop runs on every index page load. # per mark. This loop runs on every index page load.
marks = marks_for(child) marks = 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} kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
thumb_url = None thumb_url = None
thumb_blurred = False thumb_blurred = False
@@ -290,6 +301,7 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
{ {
"name": child.name, "name": child.name,
"name_url": quote(child.name, safe=""), "name_url": quote(child.name, safe=""),
"manifest": manifest,
"count": len(items), "count": len(items),
"kinds": kinds, "kinds": kinds,
"thumb_url": thumb_url, "thumb_url": thumb_url,
@@ -748,6 +760,10 @@ def create_app(
}, },
"booth_marks": marks_for_target(marks, None), "booth_marks": marks_for_target(marks, None),
"uploaded": (booth / UPLOAD_MARKER).exists(), "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),
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)), "expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
}, },
) )
@@ -796,14 +812,19 @@ def create_app(
notes = _form_text(form, "notes") notes = _form_text(form, "notes")
try: try:
if spec.multi: 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']}") qnotes = {q["key"]: _form_text(form, f"notes.{q['key']}")
for q in spec.questions} for q in spec.questions}
await run_in_threadpool(answer_pick, booth, mark_id, choice, notes, await run_in_threadpool(answer_pick, booth, mark_id, choice, notes,
who=who, qnotes=qnotes) who=who, qnotes=qnotes)
else: 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, 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: except AskError as exc:
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}") return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}")
@@ -1085,11 +1106,22 @@ def create_app(
booth_id = generate_pickup_id(lambda n: (data_dir / n).exists()) booth_id = generate_pickup_id(lambda n: (data_dir / n).exists())
dest = data_dir / booth_id dest = data_dir / booth_id
dest.mkdir(parents=True) dest.mkdir(parents=True)
(dest / UPLOAD_MARKER).write_text("") # stamp as an upload (dotfile, not listed)
total = 0 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: 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): for i, f in enumerate(files):
name = _dedupe_name(safe_upload_name(f.filename, f"file-{i + 1}"), used) name = _dedupe_name(safe_upload_name(f.filename, f"file-{i + 1}"), used)
used.add(name) used.add(name)
+274
View File
@@ -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
+95 -13
View File
@@ -42,6 +42,7 @@ from __future__ import annotations
import fcntl import fcntl
import json import json
import os import os
import stat as statmod
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path 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_FILE = ".marks.json"
MARKS_LOCK = ".marks.lock" MARKS_LOCK = ".marks.lock"
SCHEMA_VERSION = 1 SCHEMA_VERSION = 1
@@ -126,7 +135,17 @@ class Mark:
def now_stamp() -> str: 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: 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 for the same reason: a review surface that will not load is worse than one
that has lost an annotation. that has lost an annotation.
""" """
path = Path(booth) / MARKS_FILE
try: try:
raw = json.loads((Path(booth) / MARKS_FILE).read_text(encoding="utf-8")) st = path.stat()
except (OSError, ValueError, UnicodeDecodeError): # 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 [] return []
if not isinstance(raw, dict): if not isinstance(raw, dict):
return [] return []
@@ -198,18 +225,32 @@ def _read_raw_strict(booth: Path) -> list[dict]:
case where writing would destroy something. case where writing would destroy something.
""" """
path = Path(booth) / MARKS_FILE 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: try:
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
except FileNotFoundError: except FileNotFoundError:
return [] return []
except (OSError, UnicodeDecodeError) as exc: except (OSError, UnicodeDecodeError, MemoryError) as exc:
raise MarksCorrupt(f"{path} cannot be read: {exc}") from exc raise MarksCorrupt(f"{path} cannot be read: {exc}") from exc
if not text.strip(): if not text.strip():
return [] return []
try: try:
raw = json.loads(text) raw = json.loads(text)
except ValueError as exc: except (ValueError, RecursionError, MemoryError) as exc:
raise MarksCorrupt(f"{path} is not valid JSON: {exc}") from 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): if not isinstance(raw, dict) or not isinstance(raw.get("marks"), list):
raise MarksCorrupt(f"{path} is not a marks document") 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)] entries = [e for e in raw["marks"] if isinstance(e, dict) and isinstance(e.get("id"), str)]
@@ -242,8 +283,17 @@ def _write_raw(booth: Path, entries: list[dict]) -> None:
quieter — set of marks.""" quieter — set of marks."""
path = Path(booth) / MARKS_FILE path = Path(booth) / MARKS_FILE
doc = {"version": SCHEMA_VERSION, "marks": entries} 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 = 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) os.replace(tmp, path)
@@ -274,13 +324,41 @@ class _Locked:
# ONCE CREATED, THE LOCK FILE IS NEVER REMOVED (see __exit__). # ONCE CREATED, THE LOCK FILE IS NEVER REMOVED (see __exit__).
if not lock.exists(): if not lock.exists():
# Creating a directory entry bumps the DIRECTORY's mtime, which is # Creating a directory entry bumps the DIRECTORY's mtime, which is
# what `_newest_mtime` seeds from — so making our own lock file # what `_newest_mtime` reads. An earlier version put the clock back
# would itself read as activity. Put the clock back: the lock is # with `os.utime` — which closed the bug and opened a race: the
# machinery, and machinery is not the operator touching the booth. # 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() before = self.booth.stat()
lock.touch() lock.touch()
self._made_lock = True self._made_lock = True
try:
os.utime(self.booth, (before.st_atime, before.st_mtime)) os.utime(self.booth, (before.st_atime, before.st_mtime))
except OSError:
pass
self._lf = lock.open("r+") self._lf = lock.open("r+")
fcntl.flock(self._lf, fcntl.LOCK_EX) fcntl.flock(self._lf, fcntl.LOCK_EX)
try: try:
@@ -671,7 +749,8 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
continue continue
try: try:
decl = json.loads(p.read_text(encoding="utf-8")) 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}")) found.append((mtime, stem, None, f"unreadable ask: {exc}"))
continue continue
if not isinstance(decl, dict): if not isinstance(decl, dict):
@@ -693,7 +772,8 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
loaded = json.loads(ap.read_text(encoding="utf-8")) loaded = json.loads(ap.read_text(encoding="utf-8"))
if isinstance(loaded, dict): if isinstance(loaded, dict):
answer = loaded answer = loaded
except (OSError, ValueError, UnicodeDecodeError): except (OSError, ValueError, UnicodeDecodeError,
RecursionError, MemoryError):
pass pass
prior = by_id.get(stem) prior = by_id.get(stem)
@@ -737,4 +817,6 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
# Hydrated AFTER the lock so a broken declaration surfaces as `error` here # 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. # 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]
+17
View File
@@ -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 %}
+23
View File
@@ -255,6 +255,22 @@
.card .name:hover{text-decoration:none;color:var(--aus-bright-cyan)} .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} .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} .wipe{position:absolute;top:.5rem;right:.5rem;margin:0}
/* ★ keep, mirroring .wipe on the other shoulder of the card. Same /* ★ keep, mirroring .wipe on the other shoulder of the card. Same
hover-to-reveal language as .release in the kept lane. */ hover-to-reveal language as .release in the kept lane. */
@@ -408,6 +424,13 @@
.boothhead h1{margin:0;font-family:var(--font-display);font-weight:600;font-size:1.5rem; .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)} 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} .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} .wipe-lg{position:static}
/* red-outline danger button — legible on the dark canvas, fills on hover */ /* 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); .wipe-lg button{width:auto;height:auto;padding:.42rem .85rem;border-radius:var(--radius-md);
+10
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_provenance.html" import provenance %}
{# The blur toggle, defined ONCE. There are three item branches in this file {# 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 (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 them, so docs rendered with no control at all. A macro makes "patched two of
@@ -55,9 +56,18 @@
{% block content %} {% block content %}
<div class="boothhead"> <div class="boothhead">
<a class="back" href="/">‹ all booths</a> <a class="back" href="/">‹ all booths</a>
{# 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> <h1>{{ name }}</h1>
{% endif %}
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span> <span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% 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>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %} {% 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 {# 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 kept lane on the index. Remove rows with the per-row ×, or release the
board from the index and wipe it from there. #} board from the index and wipe it from there. #}
+3
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_provenance.html" import provenance %}
{% block content %} {% block content %}
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data"> <form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
<label class="drop" for="booth-files"> <label class="drop" for="booth-files">
@@ -39,6 +40,7 @@
<div class="meta"> <div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a> <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' }} · kept · <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> </div>
{# There IS a × here now (operator, 2026-09-21). The old rule was {# 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 release-then-find-it-in-the-other-lane, on the theory that two
@@ -110,6 +112,7 @@
<div class="meta"> <div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a> <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' }} · 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>
{{ provenance(b.manifest) }}
</div> </div>
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb {# 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 both predate this button; until 2026-09-19 the UI could only RELEASE a
@@ -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.
+102
View File
@@ -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.
+76 -1
View File
@@ -26,7 +26,18 @@ _As of 2026-09-22:_
the deploy. U1 `ce598b3`; U2 `c7f9437` released as `v0.2.0`, then `5e41108` as 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 `v0.2.1` (four contract-panel findings), then `v0.2.2` carrying the
**bug-hunt panel's** nine (below). **bug-hunt panel's** nine (below).
- **U5 is the next unit** (operator, 2026-09-21): **self-announcing booths.** - **U5 is IMPLEMENTED and unreleased** as of 2026-09-22. `booth/manifest.py`
(stdlib-only, INV-1), `.booth.json` per booth, the provenance line on both
index lanes and the booth page header, `--why` / `--title` on `booth new` and
`booth add`, and the link board + pickup booths announcing themselves as the
service's own. 310 tests, live service restarted, 26/26 booth pages verified
200 and all 26 rendering `unannounced`. **Deliberately NOT tagged yet**: the
cold `/heid-contract-review` panel is still in flight and the code-review and
bug-hunt gates have not run. That ordering is the 2026-09-21 lesson applied —
a release whose gate is outstanding is premature even when the tier is right.
Contract: `docs/contracts/u5_booth_manifest.contract.md` (carries its own
seam-review section).
- **U5's original framing** (operator, 2026-09-21): **self-announcing booths.**
`.booth.json` carrying `{handle, title, why, created}`, written by the CLI from `.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 `$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 the index becomes the "what landed" feed the link board was being used as. It
@@ -48,6 +59,70 @@ _As of 2026-09-22:_
## Recent decisions ## Recent decisions
- `[2026-09-22]` **The U5 bug-hunt panel found a service-wide hang that the
SIZE CAP ITSELF opened — two hours after I added the cap.** `stat` reports
size 0 for a FIFO and 0 for a symlink to `/dev/zero`, so both sail under a
byte cap and then `read_text` blocks with no EOF or allocates until the kernel
intervenes. `list_booths` reads every booth on every `GET /`, so ONE such file
stalls the front page for the whole service with no error and no recovery
short of a restart. Reproduced (`timeout` returned 124), fixed with an
`S_ISREG` check BEFORE the size check in both modules, verified live: the
index answered 200 in 36 ms with two FIFOs planted. **The reusable shape:
`st_size` answers a different question than "can this be read", and a bound
that trusts it inherits everything it does not mean — a hardening fix opened
a worse hole than the one it closed.** Also adopted: the upload path wrote the
manifest ABOVE its own cleanup guard (4/4), so a failure orphaned a half-booth
whose uniquely-named leaked temp then kept it alive forever; replace-over-
damaged destroyed recoverable bytes (4/4, now QUARANTINED rather than refused
— marks refuse because judgment is not restatable, a booth's description is);
and `booth answer` spelled out its own openness test, disagreeing with
`booth marks` about a partially-answered pick, which is a direct violation of
U2's INV-2. Full triage in `persistent-memory.d/2026-09-22-u5-panels.md`.
- `[2026-09-22]` **An existing test stopped me retiring documented behaviour
while fixing a race.** The mtime-restore race is real, and the clean fix —
ignoring a booth directory's own mtime whenever the booth holds anything —
would also have silently retired the rule that RELEASING a kept board resets
its clock, which the CLI header, the README and a deliberately-written test
all pin. That is a TTL doctrine change, not a bug fix. Fixed the concrete half
(a failing `os.utime` used to escape and 500 the route), left the race stated
in the code. **A fix that changes a documented rule is a proposal, not a
patch.**
- `[2026-09-22]` **Two cross-frontier panels on U5, and a paraphrase panel reached
a production outage two modules away.** 3-of-4 flagged the contract's "4 GB"
case as letter-compliant but purpose-defeating; the conformance round found that
unbounded read live in U5's code; walking it to the sibling found the SAME hole
**live in released `v0.2.2`** — `marks._read_raw` catches `(OSError, ValueError,
UnicodeDecodeError)` and `json.loads` on deep nesting raises **RecursionError**,
which is none of them, so 400 KB of brackets in one booth returned 500 for `/`
and `/healthz` across all 26. The v0.2.2 round HAD flagged it and I closed half:
**a finding with two call sites is not closed when one is.** The reusable
instruction — **walk a conformance finding to the sibling module even when the
sibling is out of scope.** Five of ten conformance findings were tests of mine
that pass on the regression they exist to catch, three of them asserting an
ARTIFACT of the property rather than the property; that is three nights running
on the same shape. Two real bugs neither my tests nor I could see: a bare
`booth add` wiped the `why` on the one sequence the feature exists for, and
`--title` was write-only. Full triage in
`persistent-memory.d/2026-09-22-u5-panels.md`.
- `[2026-09-22]` **U5's adoption prediction, SPLIT IN TWO within an hour of
landing — and the split is the interesting part.** The baseline was recorded as
0 of 26. Fifty minutes after the deploy, `comfy-dev` created `muse-clothed-repro`
and it announced itself: `{handle: comfy-dev, why: "", created: ...}`. That peer
was told nothing. **The HANDLE propagates for free** — it rides on `booth new`
and `booth add`, so every existing CLI caller starts announcing without learning
anything, which is the flags-on-existing-verbs decision paying off on day zero.
**The WHY does not** — it needs someone to know the flag exists, and this first
one is empty.
So re-measure BOTH on **2026-09-29**, because they answer different questions:
find ~/booth-data -maxdepth 2 -name .booth.json | wc -l # free
grep -l '"why": "[^"]' ~/booth-data/*/.booth.json 2>/dev/null | wc -l # learned
A high first count and a near-zero second is the predicted shape of "nobody was
told", and it is the case the operator's no-announcement decision was designed
to be able to see. Do not read the n=1 above as a rate — it is a code-path
observation (every CLI caller writes a handle), not a sample.
- `[2026-09-22]` **The U2 bug-hunt panel landed and it was not ceremony — - `[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 `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 live code and one was already fixed. The headline was **4/4 convergent from
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "booth" name = "booth"
version = "0.2.2" version = "0.3.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." 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" requires-python = ">=3.11"
dependencies = [ dependencies = [
+114 -8
View File
@@ -3,8 +3,10 @@
# folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL # folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL
# back. # back.
# #
# booth new <name> make an empty booth, print its URL # booth new <name> [--why W] [--title T]
# booth add <name> <file>... copy files into a booth (creates it), print URL # 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 url <name> print a booth's URL
# booth ls list booths (kept ones marked ★) # booth ls list booths (kept ones marked ★)
# booth rm <name> wipe a booth now (TTL would eventually anyway) # booth rm <name> wipe a booth now (TTL would eventually anyway)
@@ -28,7 +30,11 @@
# how a broken `.marks.json` used to look like an unanswered question and wait # how a broken `.marks.json` used to look like an unanswered question and wait
# out the full hour. # out the full hour.
# marks 0 read ok · 1 --wait timed out with picks open · 3 unreadable # 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 marks-import <name> import legacy *.ask.json into .marks.json
# booth asks <name> alias for `marks` (deprecated) # booth asks <name> alias for `marks` (deprecated)
# #
@@ -75,6 +81,18 @@
# URLs that then drown in terminal scrollback. They go on a standing kept board # 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. # 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.: # 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/ # rsync -a ./out/ nh3-dev:booth-data/my-run/
set -euo pipefail set -euo pipefail
@@ -85,23 +103,90 @@ KEEP=".forever" # must match KEEP_MARKER in b
BLUR=".blurred" # one booth-relative item path per line; see `blur` below BLUR=".blurred" # one booth-relative item path per line; see `blur` below
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}" 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() { 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 exit 2
} }
cmd="${1:-}"; shift || true cmd="${1:-}"; shift || true
case "$cmd" in case "$cmd" in
new) new)
strip_announce_flags "$@"
set -- ${ARGS+"${ARGS[@]}"}
[ $# -ge 1 ] || usage [ $# -ge 1 ] || usage
mkdir -p -- "$DATA/$1" mkdir -p -- "$DATA/$1"
announce "$DATA/$1" "$(whoami_handle)" "$TITLE" "$WHY"
echo "$URL/b/$1/" echo "$URL/b/$1/"
;; ;;
add) add)
strip_announce_flags "$@"
set -- ${ARGS+"${ARGS[@]}"}
[ $# -ge 2 ] || usage [ $# -ge 2 ] || usage
name="$1"; shift name="$1"; shift
mkdir -p -- "$DATA/$name" mkdir -p -- "$DATA/$name"
cp -- "$@" "$DATA/$name/" cp -- "$@" "$DATA/$name/"
announce "$DATA/$name" "$(whoami_handle)" "$TITLE" "$WHY"
echo "$URL/b/$name/" echo "$URL/b/$name/"
;; ;;
url) url)
@@ -177,6 +262,11 @@ case "$cmd" in
board="$DATA/$LINKS_BOARD" board="$DATA/$LINKS_BOARD"
mkdir -p -- "$board" mkdir -p -- "$board"
: > "$board/$KEEP" # the board is durable by definition : > "$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 # Provenance, because a bare URL is unreadable three days later: who posted
# it, from where, and when. # it, from where, and when.
who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}" who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
@@ -347,7 +437,7 @@ sys.exit(2 if open_marks(marks) else 0)
import json, os, pathlib, sys import json, os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"]) sys.path.insert(0, os.environ["BOOTH_SRC"])
try: 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] booth, mid = sys.argv[1:3]
broken = read_error(pathlib.Path(booth)) broken = read_error(pathlib.Path(booth))
if broken: if broken:
@@ -356,14 +446,27 @@ try:
# id AND shape, matching the web route. Matching on id alone reported a # 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 # note id as "unanswered" and then polled it for an hour — a question that
# could never be answered because it was never a question. # could never be answered because it was never a question.
m = next((x for x in marks_for(pathlib.Path(booth)) marks = marks_for(pathlib.Path(booth))
if x.id == mid and x.shape == "pick"), None) 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: except Exception as exc:
print(f"booth: cannot read marks: {exc}", file=sys.stderr) print(f"booth: cannot read marks: {exc}", file=sys.stderr)
sys.exit(3) sys.exit(3)
if m is None: if m is None:
sys.exit(2) 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) sys.exit(1)
print(json.dumps(m.answer, ensure_ascii=False, indent=2)) print(json.dumps(m.answer, ensure_ascii=False, indent=2))
' "$DATA/$name" "$mid")" || rc=$? ' "$DATA/$name" "$mid")" || rc=$?
@@ -374,6 +477,9 @@ print(json.dumps(m.answer, ensure_ascii=False, indent=2))
# spinning for the full hour on a broken file and then blamed the # spinning for the full hour on a broken file and then blamed the
# operator for not answering. # operator for not answering.
3) echo "cannot read marks in $name" >&2; exit 3 ;; 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 esac
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#mark-$mid" >&2; exit 1; fi if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#mark-$mid" >&2; exit 1; fi
if [ "$(date +%s)" -ge "$deadline" ]; then if [ "$(date +%s)" -ge "$deadline" ]; then
+34 -1
View File
@@ -19,7 +19,14 @@ USAGE
<a python with playwright> scripts/layout-probe.py [URL ...] <a python with playwright> scripts/layout-probe.py [URL ...]
Exits 0 if every control is hittable, 1 if any is occluded. No arguments 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/}
⚠ 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 import sys
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
@@ -62,6 +69,32 @@ def probe(page, url: str) -> list[str]:
card.hover(timeout=1500) card.hover(timeout=1500)
except Exception: except Exception:
pass 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(): for el in page.locator("button, a.dl-link, a.thumb").all():
try: try:
# ⚠ elementFromPoint is VIEWPORT-relative. Without scrolling first, # ⚠ elementFromPoint is VIEWPORT-relative. Without scrolling first,
+7 -1
View File
@@ -1537,7 +1537,13 @@ def test_booth_page_offers_keep_when_ephemeral_and_release_when_kept(client):
_png(d / "x.png") _png(d / "x.png")
body = c.get("/b/bo/").text 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) c.post("/b/bo/keep", data={"next": "/b/bo/"}, follow_redirects=False)
body = c.get("/b/bo/").text body = c.get("/b/bo/").text
+227
View File
@@ -119,3 +119,230 @@ def test_answer_on_a_note_id_says_no_such_pick(booth):
r = run(data, "answer", "b", "note-1") r = run(data, "answer", "b", "note-1")
assert r.returncode == NO_SUCH_PICK assert r.returncode == NO_SUCH_PICK
assert "no such pick" in r.stderr 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()
+719
View File
@@ -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'))}"
+185 -2
View File
@@ -276,19 +276,31 @@ def test_as_dict_round_trips_through_json(tmp_path):
# ---- the stdlib-only invariant (INV-5) -------------------------------------- # ---- 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): def test_stdlib_only(module):
"""INV-5. scripts/booth imports these under the system python3 with NO venv, """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 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` but this test stands between a casual third-party import and `booth ask`
breaking on every fleet host.""" 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" src = pathlib.Path(__file__).parent.parent / "booth" / f"{module}.py"
tree = ast.parse(src.read_text()) tree = ast.parse(src.read_text())
roots = set() roots = set()
for node in ast.walk(tree): for node in ast.walk(tree):
if isinstance(node, ast.Import): if isinstance(node, ast.Import):
roots.update(a.name.split(".")[0] for a in node.names) roots.update(a.name.split(".")[0] for a in node.names)
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: 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]) roots.add(node.module.split(".")[0])
outside = {r for r in roots if r != "booth" and r not in sys.stdlib_module_names} 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)}" 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 html = c.get("/b/b/").text
assert "⚠ broken" in html, "an unreadable mark rendered as an empty note" assert "⚠ broken" in html, "an unreadable mark rendered as an empty note"
assert "n1" in html 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"]