All four pre-date U4 and sit in files it touched, which is why a diff-scoped
robustness lens saw them. They are separated from the unit's own commit so the
feature history stays readable; the release tags both.
* A booth name reached a JS string context. The confirm dialogs interpolated
the name into a string literal inside `onsubmit`. Jinja's autoescape is
HTML-attribute escaping, not JS-string escaping: the browser decodes the
entity back to a quote before the JS parser sees it, so a name crafted to
close the string executed on submit. Booth names are agent-authored — making
a folder under the data dir is the whole API — so this was a live path, not a
theoretical one. The name now travels as a data attribute to a delegated
handler, where escaping is escaping.
* An unreadable `links.md` returned 500 for the whole booth page. `is_file()`
then an unguarded `read_text()`. The board is one tile on that page, and a
page that will not load is worse than one missing a tile — the posture
`read_blurred`, `marks_for` and `read_manifest` already take.
* The index order had no tie-breaker, which violates the deterministic-order
invariant. Equal-mtime booths fell back to whatever `iterdir()` yielded, and
two booths landed by one `rsync` batch share an mtime exactly. Now
`(mtime, name)` reverse: newest first, then name. The operator refers to
cards positionally, so a sequence that moves between renders misfiles his
judgment rather than crashing.
* `/b/<n>/marks.json` reported damage as empty success. `booth marks` exits 3
on an unreadable file precisely so a caller can tell "not yet" from "broken";
the HTTP mirror — the only reader a remote session has — returned the same
empty list for both. It now carries `error` and `detail`. The status stays
200 deliberately: reads are lenient here, and a pinned status code is a
promise to remote clients this fix has no business breaking.
Each has a regression test. 410 tests.