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.
7.0 KiB
The Booth — roadmap
Design: docs/design/information-architecture.md.
Current version: 0.3.0 (U1, U2 and U5 landed; extracted from eshpfi 2026-09-21).
v1 target
What must be true to cut 1.0. Seven capabilities, each closing a measured defect — not a wish. The measurements are in the IA doc.
| # | capability | closes | unit |
|---|---|---|---|
| 1 | ce598b3 |
captions never reach the zoom view (never sent, not lost) | U1 |
| 2 | c7f9437, released v0.2.0 |
5 mechanisms for 1 job; operator→session loop runs through chat | U2 |
| 3 | Declared embed seam — /_booth/embed.js, chrome mounts via DOM |
6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 |
| 4 | Derived lifetime — open marks pin; viewing is activity | 54% of booths on the .forever escape hatch |
U4 |
| 5 | c015a91, released v0.3.0 |
job 5 had no home, so it lived on the link board as 145 dead rows | U5 |
| 6 | Benches — registry, identity, enforced rule, migration | 69% link-board rot; the same bench posted 5× | U6 |
| 7 | Navigation at 270 items — sections, rail, filters, grid keyboard | one flat wall; subfolder structure discarded at render | U7 |
Ordering is dependency-driven, not priority-driven: U1 → U2 → {U3, U4, U5} → U7, with U6 independent of all of them (different storage, different surface) and therefore the safest thing to land first or in parallel.
U1, U2 and U5 are landed. U3 and U4 are unblocked and unstarted; U6 remains 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 newandbooth add, so every existing caller starts announcing without learning anything. - The
whyhas 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
Every ordered collection the Booth renders must have a stated, deterministic order. Not "usually stable", not "whatever the filesystem yields" — a rule someone can name, that produces the same sequence on every render of the same state. The rule itself is free to be anything defensible: byte order over a path, creation time, an explicit number, even an arbitrary-but-recorded sequence. What is forbidden is no rule.
This matters more here than in most services because the Booth's whole job is
comparison. The operator is judging pancake-v3-full against
pancake-v4-full, tile 47 against tile 47. If the order shifts between two page
loads — or differs between the gallery, the zoom ring, the zip manifest and the
marks read — then every positional reference the operator makes ("the third
one from the left", "the one after the banded one") is silently wrong, and a
flag or a note lands on the wrong artifact. Non-determinism does not present as
a bug report; it presents as the operator's judgment being quietly misfiled.
Where it already binds, and what the rule is in each case:
| collection | rule |
|---|---|
| items in a booth | sorted(rel) — byte order over the booth-relative path (U1 INV-3) |
| the zoom prev/next ring | the item order, filtered to images — same sequence, one source |
| caption sidecar resolution | sorted scan, so two media files sharing a stem resolve the same way every time (a real non-determinism U1 removed) |
| marks in a booth | (created, id) — time, with the id as tie-break so two marks written in the same second cannot swap |
| legacy ask import | (mtime, name), which is the order list_asks gave them |
| link board rows | pinned first, then newest-first |
| a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) |
Where it is still to be decided, and must be before the unit ships: U7's section ordering and its compare pairing (sections need a stated order among themselves, not just within; pairing by filename needs a rule for what happens to an unpaired file), and U6's bench listing.
The test for any new ordered surface: can you write the rule down in one line? If not, it does not have one yet.
Explicitly NOT in v1
- Backward compatibility with the
askCLI verbs. Pre-1.0, andask/asks/answerbecome thin aliases overmarksrather than a second code path. The 17 consuming handles get one althing note naming the change — the one case where telling peers is real coordination and not a broadcast. - A migration that deletes anything.
links.mdis archived verbatim and committed before the registry is seeded from it.
Parking lot
Deferred with a home, per the anti-creep gate. Default is park; these were weighed against the v1 path and lost on purpose.
| item | why parked |
|---|---|
| Compare mode — pair-by-name A/B across subfolders | The best idea in the set, and the only one that is a new capability rather than a fix for a measured defect. The four-booth pancake-v3/v4 dance still works. First thing in v1.1. |
| Virtualized / progressive grid loading | Speculative. 270 <img loading="lazy"> may be fine. Measure the real booth before optimising it — if it renders inside a second, this is invented work. |
| Bench uptime history + graphs | The v1 need is "is it dead", which one flag answers. A time series is a different product. |
| Cross-booth search | No evidence of the need in the usage data. |
| Per-viewer state (who has seen what) | The Booth has one viewer. Revisit if that stops being true. |
| Auth | Standing non-goal. LAN/mesh-internal. Blur stays cosmetic and says so. |
Post-v1, already committed
- SVOS theme retrofit by
design-dev. Runs as a parallel track, not a v1 gate: we own the information architecture (it is driven by the measurement above), design-dev owns the visual and interaction system. The handoff is a/vor-uibrief written against the landed v1 structure — the same shapehamr-devandpewpew-devused.
Gate
A proposed feature is on the v1 path or it is parked. Default: parked. When both are defensible, park it — same asymmetry as the patch-default in SemVer. Applies regardless of who proposed it.