diff --git a/ROADMAP.md b/ROADMAP.md
index 36c79fc..18129bd 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,7 +1,7 @@
# The Booth — roadmap
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
-Current version: `0.3.0` (U1, U2, U4 and U5 landed; extracted from eshpfi 2026-09-21).
+Current version: `0.4.0` (U1, U2, U4 and U5 landed; extracted from eshpfi 2026-09-21).
## v1 target
@@ -13,7 +13,7 @@ defect — not a wish. The measurements are in the IA doc.
| 1 | ~~**One item record**~~ — **landed `ce598b3`** | captions never reach the zoom view (never sent, not lost) | U1 |
| 2 | ~~**Marks**~~ — **landed `c7f9437`, released `v0.2.0`** | 5 mechanisms for 1 job; operator→session loop runs through chat | U2 |
| 3 | **Declared embed seam** — `/_booth/embed.js`, chrome mounts via DOM | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 |
-| 4 | ~~**Derived lifetime**~~ — **landed** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 |
+| 4 | ~~**Derived lifetime**~~ — **landed `c3a97c1`, released `v0.4.0`** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 |
| 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 |
| 6 | **Benches** — registry, identity, enforced rule, migration | 69% link-board rot; the same bench posted 5× | U6 |
| 7 | **Navigation at 270 items** — sections, rail, filters, grid keyboard | one flat wall; subfolder structure discarded at render | U7 |
diff --git a/booth/app.py b/booth/app.py
index e8a20bb..892fcf0 100644
--- a/booth/app.py
+++ b/booth/app.py
@@ -487,7 +487,12 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
"mtime": mtime,
}
)
- booths.sort(key=lambda b: b["mtime"], reverse=True)
+ # Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime
+ # booths ordered by whatever `iterdir()` yielded, which is not a rule — and
+ # invariant 6 is not "usually stable", it is a sentence you can write down.
+ # Two booths created by one `rsync` batch share an mtime exactly, and the
+ # operator refers to cards positionally.
+ booths.sort(key=lambda b: (b["mtime"], b["name"]), reverse=True)
return booths
@@ -914,13 +919,13 @@ def create_app(
# Ordered pinned-first then newest-first, each row stamped with a
# `pinned` flag. Empty list for every other booth, so the template
# branch simply does not fire.
- "board": (
- order_for_display(
- parse_link_entries((booth / LINKS_FILE).read_text()),
- read_pins(booth),
- )
- if (booth / LINKS_FILE).is_file() else []
- ),
+ # `is_file()` then an UNGUARDED read was a 500 waiting on a
+ # mode change or an EIO: the board is one tile on this page, and
+ # a page that will not load is worse than one missing a tile —
+ # the same posture `read_blurred`, `marks_for` and
+ # `read_manifest` already take. A booth whose `links.md` cannot
+ # be read renders as a booth with no board.
+ "board": _board_rows(booth),
# Marks: operator judgment attached to this booth or to one of
# its items — a session's question (`pick`), the operator's own
# remark (`note`), the operator's selection (`flag`). Rendered
@@ -947,6 +952,21 @@ def create_app(
},
)
+ def _board_rows(booth: Path) -> list[dict]:
+ """The link board's rows, or [] for a board that cannot be read.
+
+ NEVER RAISES, for the reason every other read on this page does not:
+ one damaged file must cost its own tile, not the booth page."""
+ try:
+ if not (booth / LINKS_FILE).is_file():
+ return []
+ return order_for_display(
+ parse_link_entries((booth / LINKS_FILE).read_text()),
+ read_pins(booth),
+ )
+ except (OSError, ValueError, UnicodeDecodeError):
+ return []
+
def _mark_redirect(name: str, form, anchor: str) -> RedirectResponse:
"""Land where the form was: the standalone marks page for a verbatim
booth (its own index.html cannot show the recorded judgment), else the
@@ -1197,12 +1217,29 @@ def create_app(
the whole booth instead of one question at a time.
"""
booth = resolve_booth(name)
- marks = marks_for(booth)
- return JSONResponse({
+ marks, read_err = hold_read(booth)
+ body = {
"booth": name,
"marks": [as_dict(m) for m in marks],
"open": [m.id for m in open_marks(marks)],
- })
+ }
+ # A DAMAGED file used to come back as an empty list and nothing else,
+ # which is indistinguishable from "you were never asked anything" — and
+ # this endpoint is the ONLY reader a remote session has. Its filesystem
+ # sibling has told the truth since U2: `booth marks` exits 3 on an
+ # unreadable file precisely so a caller can tell "not yet" from
+ # "broken". One question, two surfaces, two answers.
+ #
+ # The STATUS stays 200 and that is deliberate. Reads are lenient here —
+ # the same rule that keeps a poisoned booth from 500ing the index — and
+ # a pinned status code is a promise to remote clients this fix has no
+ # business breaking. The information goes in the body instead: a client
+ # that wants the CLI's exit-3 parity reads `error`, and one that does
+ # not behaves exactly as it does today.
+ if read_err is not None:
+ body["error"] = "this booth's .marks.json cannot be read"
+ body["detail"] = read_err
+ return JSONResponse(body)
@app.get("/b/{name}/view", response_class=HTMLResponse)
def booth_view_file(request: Request, name: str, f: str):
diff --git a/booth/templates/index.html b/booth/templates/index.html
index 1443505..b56647c 100644
--- a/booth/templates/index.html
+++ b/booth/templates/index.html
@@ -70,11 +70,11 @@
a label changes width. #}
@@ -124,7 +124,7 @@
@@ -161,5 +161,39 @@
}
});
})();
+
+ /* Destructive-action confirmation, delegated and DATA-DRIVEN.
+ These were an inline onsubmit calling confirm() with the booth NAME
+ interpolated straight into the JS string literal. Jinja's autoescape is
+ HTML-attribute escaping, not JS-string escaping: the browser decodes the
+ entity back to a quote before the JS parser ever sees it, so a booth name
+ crafted to close that string executed on submit. Booth names are
+ agent-authored — making a folder under the data dir is the whole API — so
+ that is a live path, not a theoretical one.
+
+ The name now travels as a DATA ATTRIBUTE, where escaping is escaping, and
+ never reaches a JS string literal. Same pattern the board controls already
+ use. With JS off the form submits without a prompt, which is what every
+ no-JS browser here already did. */
+ (function () {
+ var WORDS = {
+ release: function (n) {
+ return 'Release \u201c' + n + '\u201d?\n\nIt moves to the ephemeral lane so you '
+ + 'can wipe it from there. Nothing is deleted by this step.';
+ },
+ 'wipe-kept': function (n) {
+ return 'WIPE the KEPT booth \u201c' + n + '\u201d?\n\nThis deletes it and its files '
+ + 'immediately. Kept booths are the ones nothing else will clean up, so nobody '
+ + 'else is going to do this for you \u2014 and nothing brings it back.';
+ },
+ wipe: function (n) { return 'Wipe booth \u201c' + n + '\u201d?'; }
+ };
+ document.addEventListener('submit', function (ev) {
+ var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
+ if (!form) return;
+ var word = WORDS[form.getAttribute('data-confirm')];
+ if (word && !confirm(word(form.getAttribute('data-booth') || ''))) ev.preventDefault();
+ }, true);
+ })();
{% endblock %}
diff --git a/persistent-memory.md b/persistent-memory.md
index 6ad1ac9..715e163 100644
--- a/persistent-memory.md
+++ b/persistent-memory.md
@@ -25,14 +25,13 @@ _As of 2026-09-22:_
`5e41108` → `v0.2.1`, `026a1fc` → `v0.2.2`; U5 `c015a91` + `95beede` →
`v0.3.0`. **U4 landed 2026-09-22** — 396 tests green (341 → 396), deployed and
verified live, 24/24 booth pages 200, layout probe clean.
-- **U4 IS NOT YET RELEASED — the version bump is an open operator decision.**
- Recommended **v0.3.0 → v0.4.0 (minor)**: U4 changes what `keep` MEANS for 17
- agent handles ("stop pressing keep when you are only waiting"), which is
- release-note-worthy at the pre-1.0 bar and the same tier U2 and U5 took. The
- defensible alternative is v0.3.1 if U4 reads as internal plumbing, since no
- CLI verb or URL changed shape. **Commits are not releases**, so the work is
- committed unbumped and untagged; the tag waits on his word and nothing is
- blocked by the wait.
+- **U4 released as `v0.4.0`** (operator approved the minor on 2026-09-22).
+ `c3a97c1` is the unit; the release commit carries the pre-existing fixes the
+ bug-hunt panel surfaced in touched files. The tag waited for the last gate to
+ close, per the `v0.2.0` lesson — see Tried and abandoned.
+- ⚠ **The 17 consuming handles have NOT been told** that `keep` no longer means
+ "waiting on an answer". That is the one coordination this release genuinely
+ warrants, and a fleetwide post needs operator approval before it is sent.
- **THE NEXT UNIT IS THE OPERATOR'S CALL.** U3 (declared embed seam) and U6
(benches) are both unblocked; U7 waits on the rest. U6 is independent of
everything and was conceptually unblocked by U5 giving job 5 a home; U3 is
diff --git a/pyproject.toml b/pyproject.toml
index 6f31bc9..d045e80 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "booth"
-version = "0.3.0"
+version = "0.4.0"
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
requires-python = ">=3.11"
dependencies = [
diff --git a/tests/test_lifetime.py b/tests/test_lifetime.py
index 0ad6dc5..6c2bf6e 100644
--- a/tests/test_lifetime.py
+++ b/tests/test_lifetime.py
@@ -14,6 +14,7 @@ test names are that contract's.
import json
import os
import pathlib
+import re
import stat
import subprocess
import time
@@ -996,3 +997,102 @@ def test_a_VALID_but_empty_marks_document_does_NOT_hold(tmp_path):
assert hold_reason(*hold_read(booth)) is None
assert sweep_once(tmp_path, ttl_seconds=3600) == ["b"]
+
+
+# ---- pre-existing defects the bug-hunt panel surfaced ----------------------
+
+
+def test_marks_json_says_so_when_the_file_is_damaged(tmp_path):
+ """Gróa's strongest solo. `booth marks` exits 3 on an unreadable file so a
+ caller can tell "not yet" from "broken"; the HTTP mirror — the ONLY reader a
+ remote session has — reported the same damage as an empty success. One
+ question, two surfaces, two answers, which is the thing U4 exists to stop.
+
+ The status stays 200 on purpose: reads are lenient here, and a pinned
+ status is a promise to remote clients this fix has no business breaking.
+ `test_a_corrupt_marks_file_gives_the_browser_a_409_not_a_500` pins it.
+ """
+ app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
+ c = TestClient(app)
+ booth = tmp_path / "b"
+ _touch(booth / "a.png")
+
+ clean = c.get("/b/b/marks.json").json()
+ assert "error" not in clean
+
+ (booth / ".marks.json").write_text("{{{ not json")
+ damaged = c.get("/b/b/marks.json")
+
+ assert damaged.status_code == 200
+ assert "cannot be read" in damaged.json()["error"]
+ assert damaged.json()["open"] == []
+
+
+def test_an_unreadable_links_file_does_not_take_down_the_booth_page(client):
+ """Hulda, pre-existing. `is_file()` then an unguarded `read_text()` was a
+ 500 waiting on a mode change or an EIO. The board is one tile on that page,
+ and a page that will not load is worse than one missing a tile — the same
+ posture `read_blurred`, `marks_for` and `read_manifest` already take."""
+ c, data = client
+ booth = data / "links"
+ _touch(booth / "a.png")
+ (booth / "links.md").write_text("- [x](http://x/) · y · 2026-01-01 00:00\n")
+ os.chmod(booth / "links.md", 0)
+ try:
+ assert c.get("/b/links/").status_code == 200
+ finally:
+ os.chmod(booth / "links.md", 0o644)
+
+
+def test_the_index_order_has_a_tie_breaker(tmp_path):
+ """CLAUDE.md invariant 6. Sorting on mtime alone left equal-mtime booths in
+ whatever order `iterdir()` yielded, which is not a rule — and two booths
+ landed by one `rsync` batch share an mtime exactly. The operator refers to
+ cards positionally, so a sequence that moves between renders misfiles his
+ judgment rather than crashing."""
+ when = time.time() - 100
+ for n in ("charlie", "alpha", "bravo"):
+ _touch(tmp_path / n / "a.png", when=when)
+ os.utime(tmp_path / n, (when, when))
+
+ runs = {tuple(b["name"] for b in list_booths(tmp_path, ttl_seconds=86400))
+ for _ in range(5)}
+
+ assert len(runs) == 1, "the same state must render the same sequence"
+ assert runs.pop() == ("charlie", "bravo", "alpha"), "newest first, then name"
+
+
+def test_a_booth_name_cannot_reach_a_js_string_context(client):
+ """Hulda, solo, pre-existing and live. The confirm dialogs interpolated the
+ booth NAME into a JS string literal inside `onsubmit`. Jinja's autoescape is
+ HTML-attribute escaping, not JS-string escaping: the browser decodes `'`
+ back to `'` before the JS parser sees it, so a booth named `'+alert(1)+'`
+ executed on submit. Booth names are agent-authored — making a folder is the
+ whole API — so that is a live path.
+ """
+ c, data = client
+ # A canary spelled so that nothing in the repo's prose can collide with it —
+ # the first version of this test matched the fix's OWN comment explaining
+ # what it fixed, which is a passing test measuring the wrong thing.
+ hostile = "'+xssCanary7+'"
+ _touch(data / hostile / "a.png")
+ _touch(data / "kept-one" / "a.png")
+ _touch(data / "kept-one" / KEEP_MARKER)
+
+ html = c.get("/").text
+
+ # The ATTRIBUTE form, not the bare word — the replacement's own comment
+ # explains what it replaced and says "onsubmit" while doing so.
+ assert "onsubmit=" not in html, "no inline handler may carry a name at all"
+ assert "onclick=" not in html
+
+ # The name DOES appear as visible text, correctly escaped, and that is the
+ # point: HTML-escaping is the right escaping for an HTML text node. What
+ # must not happen is the name reaching a place where the JS parser reads it.
+ scripts = re.findall(r"", html, re.S)
+ assert scripts, "the page does ship script, so this check is not vacuous"
+ for block in scripts:
+ assert "xssCanary7" not in block.replace("'", "'"), "the name is in a script"
+
+ assert 'data-confirm="wipe"' in html, "the name travels as data, where escaping is escaping"
+ assert ">'+xssCanary7+'<" in html, "and still renders as the name it is"