5 Commits
Author SHA1 Message Date
vh 7996fbd597 chore(release): v0.5.0 — U3, the declared embed seam
Minor rather than patch, and the tie-break rule says default to patch, so the
reason is worth stating: a capability arrived AND one left. Report authors gain
a declared public API -- one line, `<script src="/_booth/embed.js" defer>`, plus
the `data-booth-mark` anchor syntax -- and the verbatim path loses
no-JavaScript operation, which it had since it existed.

That asymmetry is what makes it not a tie. Either half alone would have been
defensible as a patch.

Operator approved 2026-09-22.
2026-09-22 12:58:35 -07:00
vh 5c20e2f4d5 fix(u3): seven defects two cold panels found in the declared seam
The /heid-code-review and /heid-bug-hunt panels, artifact-only over the U3
diff, between them found four real defects and three vacuous falsifiers. Both
snapshots predate the contract-review fixes, so two of their findings were
already closed; the rest are here.

Prototype pollution in the placement maps. A mark id and a question key are
both [A-Za-z0-9][A-Za-z0-9._-]*, so `toString` and `constructor` are legal in
each. Against a plain `{}` an anchor naming NO mark returned an inherited
function, passed the guard meant to reject it, and threw on .questions.length
-- aborting placement before the tail, so one typo in author markup cost the
page every ask. The `placed` set had the mirror bug: inherited
`got.constructor` read as already-placed and silently dropped a question.
Object.create(null), three times. Found independently by both panels.

A declaring page was not served as written. read_text() opens in
universal-newline mode, so a CRLF report came back LF, and errors="replace"
replaced every byte that was not valid UTF-8. That is this unit's headline
promise, broken by the read itself, and the test could not see it because its
fixture was LF-only ASCII. The verbatim branch reads and serves bytes now; the
decoded copy answers only "does it declare the seam?".

A submit anchor inside the author's own <form> lost ours -- the parser drops a
nested form element outright -- while the code still recorded the pick as
submitted, so no fallback was appended. Every control's form= pointed at
nothing and the button did nothing. It counts as submitted only if the form
survived.

A broken pick's diagnostic never rendered from a submit-only anchor: an errored
pick's submit block is empty, and mounting that then marking it placed made the
tail skip the "broken ask" box entirely. The anchor is left alone instead.

An author's own element could hijack the open-ask chip -- id="bk-ask-winner-
background" satisfies any prefix rule, hyphen boundary included. The chip now
searches only elements this script mounted, which is the identity the deleted
bk-ask-<id>-top anchor used to guarantee, and takes the earliest by
compareDocumentPosition.

No error boundary around fragment rendering. A .marks.json that is well-formed
JSON with a wrong-shaped answer hydrates with no error and then raises in the
macro; this endpoint renders every pick on every load of the report, so that
was the whole seam gone while hold_read called the file readable. Reproduced
before building for it. _safe_fragments gives it the per-mark leniency
_hydrate_safe already applies one layer down.

The gallery and marks pages still 500 on that same entry. Measured at 42ea67f
-- it predates this unit, they render the same macro with no guard, and the
gallery is named out of scope in the contract. Recorded, not quietly widened:
persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md

Also corrected: several comments claimed a multi-question pick POSTs a 400
unless every question is answered. It does not -- an empty submission is
refused, a partial one is recorded on purpose. The real reason an unplaced
question must still be appended is that a question which never reaches the page
cannot be answered at all.

Vacuity pass rebuilt around the rule this session learned: the mutation comes
from the invariant's claim, never from the falsifier's example. 21 mutations,
21 caught, unmutated control green. Getting there took three rounds -- it
passed INV-3 with the contract's own mutation, then found its own fix's hole,
then flagged seven stale mutations and one genuinely vacuous fixture whose
sibling-mark arrangement made the right answer also the first answer.

444 tests. Deployed and verified: 23/23 booths 200, and all four live verbatim
reports served at exactly +46 bytes -- len(EMBED_SCRIPT_TAG) -- with the
authors' own wrappers and headings intact and no console errors.
2026-09-22 11:22:37 -07:00
vh 87e2c5364c feat(u3): a verbatim report declares the seam, the Booth mounts into it
A booth that ships its own index.html was served through ten regular
expressions applied to markup the Booth did not write: six in
wrap_verbatim_html hunting for somewhere to hang a favicon and a chip, four
in booth/inline.py substituting rendered ask markup into the author's own
tags. Both worked. Both were the most fragile thing in the service, on the
path the operator uses most.

The whole class is replaced by a declared seam. A report carries one line —
<script src="/_booth/embed.js" defer></script> — and the chrome mounts
through DOM APIs. What the server does to author HTML is now, in full:

    return html if declares_embed(html) else html + EMBED_SCRIPT_TAG

Two substring tests and a concatenation. Both of the old wrapper's hard
constraints stop existing rather than being satisfied more carefully:
nothing can displace a leading doctype into quirks mode and nothing can push
the charset meta out of its detection window, because nothing in front of
them ever moves. A page that declares the seam is served exactly as written.

Fragments are still rendered by the _ask_inline.html macros and handed over
GET /b/<name>/embed.json; embed.js places them and decides nothing. Openness
comes from open_marks, order from (created, id), questions in declaration
order. A single-question pick normalizes to key None, so the payload carries
questions as a list rather than an object — keying by name would serialize
that as the string "null".

Placement is an anchor fill, not a replacement: el.insertAdjacentHTML(
'beforeend'), so an author's wrapper and its contents survive. The regex it
replaces was eating the opening tag of dfa-concepts' styled .ask blocks and
orphaning their headings, live, unreported.

data-booth-mark is canonical; data-booth-ask stays a kept alias because two
live reports use it. The comment placeholders are dropped — no users.

Declared cost: the verbatim path now needs JavaScript. The never-invisible
guarantee holds through the index badge and /b/<name>/marks, both of which
render server-side.

Deleted: booth/inline.py entire, wrap_verbatim_html and its six patterns,
_BACK_CHIP, asks_chip, inject_asks, FAVICON_LINK, the styles() macro.

Tests 410 -> 434. tests/test_embed_browser.py drives a real Chromium: the
placement algorithm and the form= binding of a scattered multi-question form
cannot be observed any other way, and that binding was measured rather than
assumed (N=3 per condition, with a form-first positive control and a
points-at-nothing negative control).

Contract: docs/contracts/u3_declared_embed_seam.contract.md, with the
in-session seam review and the cold contract panel both recorded. Two of the
panel's findings were code fixes: a vacuous INV-3 falsifier that a renamed
regex walked straight through, and a bare-substring seam detection that read
a report merely quoting the path as declaring it and silently served it with
no chrome.
2026-09-22 10:43:41 -07:00
vh 42ea67f33f memory: snapshot — U4 released at v0.4.0, next unit undecided
Current state rewritten for the post-U4 position: 410 tests, v0.4.0 tagged,
tree not pushed, all three gates closed. Carries the session's U3 recommendation
with its three grounds AND its counter-argument, so the operator can take the
call without reloading the unit.

The methodology-proposals row goes from three to four and is now marked
explicitly untracked by operator choice — the new one is the contract-time
vacuity pass, which is the only one of the four with measured evidence behind
it after five of seven U4 falsifiers turned out not to discriminate.
2026-09-22 10:06:30 -07:00
vh 8f81d8f9d0 memory: no fleetwide notice for U4, and the measurement caveat it creates
Operator decision 2026-09-22: no broadcast to the 17 consuming handles. Same
posture as U5 — adoption gets told apart from design because nobody was primed.

The consequence is a measurement one and it needed writing down before it was
lost. U4's two halves have different adoption costs: the hold rides for free
(a session runs `booth ask` and its booth is held, knowing nothing), but NOT
pressing `keep` has to be learned. So a flat `.forever` rate on 2026-10-06 is
exactly what 'the mechanism works and nobody was told' looks like, and reading
it as a falsification would retire a correct diagnosis on an uncontrolled
measurement.

Records the three counts to report instead, and states the sensitivity floor:
only 4 of 24 booths carry marks at all, so the hold can touch at most a sixth
of the fleet and an effect below one or two booths is not resolvable.
2026-09-22 10:04:51 -07:00
19 changed files with 2621 additions and 507 deletions
+32 -5
View File
@@ -85,6 +85,12 @@ doing so, never resolved the caption — the operator's "zoomed images lose
their annotations" bug. It was not a rendering bug; it was three readers of one
truth.
**U3 extended this to the verbatim path.** A booth's own `index.html` now gets
its chrome from `/_booth/embed.js`, which *places* server-rendered fragments and
never builds one. The fragments come from the same `_ask_inline.html` macros the
gallery page uses, handed over `/b/<name>/embed.json`. A second renderer in
JavaScript would be the same bug in a new language.
### 4. Re-export, don't move-and-break
Names that moved from `app.py` to `items.py` (`classify`, `doc_kind`,
@@ -122,9 +128,14 @@ the operator's judgment being quietly misfiled.
Current rules: items `sorted(rel)`; the zoom ring is that order filtered to
images; captions resolve over a sorted scan; marks `(created, id)`; legacy
import `(mtime, name)`; link rows pinned-then-newest. `ROADMAP.md` carries the
table and the two places still undecided (U7 sections and compare pairing, U6
bench listing).
import `(mtime, name)`; link rows pinned-then-newest; a verbatim report's embed
anchors in document order, its tail in payload order, its questions in
declaration order. `ROADMAP.md` carries the table and the two places still
undecided (U7 sections and compare pairing, U6 bench listing).
U3's rows are the first that bind **across a language boundary** — decided in
Python, honoured in JavaScript. A string assertion cannot see that, which is
why `tests/test_embed_browser.py` exists.
When you add an ordered surface, state its rule in the docstring. If you cannot
state it in one line, it does not have one.
@@ -179,8 +190,15 @@ one caused an outage.
is that template work needs a restart to see, and that price is the point.
`test_templates_do_not_hot_reload_from_disk` holds the line.
**So: after ANY edit here — Python or template — the live service is stale until
you restart it.** If you are touching this repo while the operator may be using
3. **`booth/static/embed.js` is the third thing that would have hot-reloaded,
and it does not.** U3 gave the service a static asset living in the
deployment root; it is read ONCE in `create_app` and served from memory with
an ETag over its content, for exactly the reason above. Same rule, same test
shape (`test_embed_js_does_not_hot_reload_from_disk`). Anything else this
repo learns to serve from disk inherits the rule — read it at startup.
**So: after ANY edit here — Python, template or static asset — the live service
is stale until you restart it.** If you are touching this repo while the operator may be using
the service, either restart promptly or expect him to be looking at the old
version. Never leave the tree in a state where a restart would 500.
@@ -197,6 +215,15 @@ curl -s localhost:8090/healthz # the live service (systemd --user)
systemctl --user restart booth.service # after a code change, to see it live
```
`tests/test_embed_browser.py` drives a real Chromium against a real uvicorn on
an ephemeral port — the only place U3's placement and `form=` binding can be
observed at all. Browsers are NOT downloaded per project; they live box-wide in
`/opt/ms-playwright`. The file **skips rather than fails** when playwright or a
usable browser is missing, so the suite stays green anywhere. If those tests
start skipping on this box, the pinned `playwright>=1.60,<1.63` in
`pyproject.toml` has drifted past the shared store — read the comment there
before raising the bound.
`booth.service` is a user unit installed to `~/.config/systemd/user/`. The repo
copy is the source; edits there need a `daemon-reload`.
+13 -4
View File
@@ -1,7 +1,7 @@
# The Booth — roadmap
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
Current version: `0.4.0` (U1, U2, U4 and U5 landed; extracted from eshpfi 2026-09-21).
Current version: `0.5.0` (U1, U2, U3, U4 and U5 landed; extracted from eshpfi 2026-09-21).
## v1 target
@@ -12,7 +12,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 |
| 3 | ~~**Declared embed seam**~~ — **landed `87e2c53`, released `v0.5.0`** | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 |
| 4 | ~~**Derived lifetime**~~ — **landed `c3a97c1`, released `v0.4.0`** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 |
| 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 |
| 6 | **Benches** — registry, identity, enforced rule, migration | 69% link-board rot; the same bench posted 5× | U6 |
@@ -22,8 +22,10 @@ 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, U4 and U5 are landed.** U3 is unblocked and unstarted; U6 remains
independent and unstarted; U7 waits on the rest.
**U1, U2, U3, U4 and U5 are landed — the whole middle tier is closed.** U6
remains independent and unstarted; **U7 is now unblocked**, since its only
dependency was `{U3, U4, U5}`. Two units left to v1, and they do not depend on
each other, so either can go next.
**U5's adoption is a measured prediction, not a finished result**, and it is
TWO predictions rather than one. The operator declined a fleetwide announcement
@@ -73,6 +75,13 @@ Where it already binds, and what the rule is in each case:
| legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them |
| link board rows | pinned first, then newest-first |
| a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) |
| embed anchors in a verbatim report | **document order** — what `querySelectorAll` yields, so the author's markup decides (U3) |
| the embed tail (fragments the author did not place) | **payload order**, which is the marks order `(created, id)` — one rule, whether a fragment lands at an anchor or at the end (U3) |
| questions within a pick | declaration order, in the payload's `questions` LIST — carried by the format rather than by object-key insertion order (U3) |
U3's three rows are the first case where the rule binds across a language
boundary: the order is decided in Python and honoured in JavaScript, and a
browser test asserts it rather than a string assertion that could not see it.
U4 added no ordered collection — a booth's lifetime is one state per booth,
not a sequence — so the rule above did not need a new row. The three lifetime
+200 -174
View File
@@ -44,6 +44,7 @@ import shutil
import time
import zipfile
from contextlib import asynccontextmanager
from dataclasses import replace
from pathlib import Path
from typing import Sequence
from urllib.parse import quote, unquote
@@ -158,11 +159,6 @@ from booth.marks import ( # noqa: E402
set_flag,
write_note,
)
from booth.inline import ( # noqa: E402
form_id as ask_form_id,
has_placeholders,
place as place_asks,
)
from booth.manifest import ( # noqa: E402
MANIFEST_FILE,
SERVICE_HANDLE,
@@ -545,119 +541,91 @@ def _zip_filename(name: str) -> str:
return f"{safe or 'booth'}.zip"
# ---- verbatim-index.html wrapper -------------------------------------------
# ---- the declared embed seam (U3) ------------------------------------------
# Mirror of base.html's favicon (the app templates set it there; this is the copy
# injected into a booth's *verbatim* index.html so a raw page inherits the same
# icon). Keep the two in sync if the Booth's icon ever changes.
# Mirror of base.html's favicon. The app templates set it there; this copy is
# what `/b/<name>/embed.json` hands to a VERBATIM report, so a raw page inherits
# the same icon. Keep the two in sync if the Booth's icon ever changes.
FAVICON_HREF = (
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'"
"%3E%3Crect width='32' height='32' rx='7' fill='%23171a23'/%3E%3Ccircle cx='16' "
"cy='16' r='6' fill='none' stroke='%2342dcd1' stroke-width='2.5'/%3E%3Ccircle "
"cx='16' cy='16' r='2.2' fill='%2342dcd1'/%3E%3C/svg%3E"
)
FAVICON_LINK = f'<link rel="icon" href="{FAVICON_HREF}">'
# A self-contained floating "back to all booths" chip injected into verbatim
# booths. Scoped class + fixed positioning + max z-index so it overlays the raw
# page without touching its layout; hidden in print so downloaded reports stay clean.
_BACK_CHIP = (
'<a href="/" class="booth-nav-home" aria-label="back to all booths">‹ all booths</a>'
# top-right: empty on left-aligned report layouts (a top-left chip clips the
# page title), and consistent with the zoom view's top-right back affordance.
"<style>.booth-nav-home{position:fixed;top:0;right:0;z-index:2147483647;"
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
"font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
"color:#dfe7ef;text-decoration:none;letter-spacing:.01em;"
"background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);border-radius:8px;"
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);"
"box-shadow:0 2px 10px rgba(0,0,0,.35);transition:background .18s,border-color .18s}"
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}"
"@media print{.booth-nav-home{display:none}}</style>"
)
# The seam a verbatim report declares to get the Booth's chrome. ONE line, and
# the Booth appends it only when the page has not declared it itself.
EMBED_SRC = "/_booth/embed.js"
EMBED_SCRIPT_TAG = f'<script src="{EMBED_SRC}" defer></script>'
EMBED_JS_PATH = Path(__file__).parent / "static" / "embed.js"
# A booth's own index.html is served VERBATIM, so the asks panel — which lives in
# the auto-gallery template — can never appear on it. Without this chip an ask
# posted into a custom-report booth is INVISIBLE to the operator with nothing to
# say so (found 2026-09-09 on `emmie-anchor`: valid ask, CLI listed it, page
# showed nothing). Same injection mechanism as the back chip; it links to the
# standalone /asks page, which renders the real forms.
def asks_chip(name: str, open_count: int, href: str | None = None) -> str:
if open_count < 1:
return ""
label = f"? {open_count} open ask" + ("" if open_count == 1 else "s")
href = href or f"/b/{quote(name, safe='')}/asks"
return (
f'<a href="{href}" class="booth-nav-asks">{label}</a>'
"<style>.booth-nav-asks{position:fixed;top:0;right:7.2rem;z-index:2147483647;"
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
"font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
"color:#171a23;text-decoration:none;letter-spacing:.01em;"
"background:#ffe14e;border:1px solid #ffe14e;border-radius:8px;"
"box-shadow:0 2px 10px rgba(0,0,0,.35);transition:filter .18s}"
".booth-nav-asks:hover{filter:brightness(1.08)}"
"@media print{.booth-nav-asks{display:none}}</style>"
)
# What counts as DECLARING the seam. Two substring tests, one per quote style,
# and each requires `src=` immediately before the path.
#
# The bare path was the first draft and it was wrong in the dangerous
# direction. A report that merely MENTIONS `/_booth/embed.js` — in a code
# sample, a comment, a sentence about this very feature — would have been read
# as declaring it, served untouched, and silently shown no chrome at all. The
# Booth's own design reports are exactly the pages that would quote it.
#
# These tests fail in the harmless direction instead. An unusual spelling
# (`src = "…"` with spaces, an unquoted attribute, a `?v=2` suffix) is read as
# NOT declared, so a second tag is appended — and embed.js mounts once
# regardless, because it guards on `window.__boothEmbed`. A missed declaration
# costs a duplicate tag; a false one costs the operator his chrome.
_EMBED_DECLARATIONS = (f'src="{EMBED_SRC}"', f"src='{EMBED_SRC}'")
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw (unwrapped)
def declares_embed(html: str) -> bool:
"""Whether a verbatim page already asks for the Booth's chrome.
_ICON_RE = re.compile(r"<link\b[^>]*\brel\s*=\s*[\"']?[^\"'>]*icon", re.IGNORECASE)
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", re.IGNORECASE)
_HTML_OPEN_RE = re.compile(r"<html\b[^>]*>", re.IGNORECASE)
_DOCTYPE_RE = re.compile(r"<!doctype[^>]*>", re.IGNORECASE)
_BODY_CLOSE_RE = re.compile(r"</body\s*>", re.IGNORECASE)
_HTML_CLOSE_RE = re.compile(r"</html\s*>", re.IGNORECASE)
def _insert_before(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
m = pattern.search(html)
if m:
return html[: m.start()] + snippet + html[m.start() :], True
return html, False
def _insert_after(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
m = pattern.search(html)
if m:
return html[: m.end()] + snippet + html[m.end() :], True
return html, False
def wrap_verbatim_html(html: str, favicon_link: str = FAVICON_LINK, extra: str = "") -> str:
"""Inject a floating 'all booths' back-chip — and the Booth favicon, if the page
declares none — into a booth's verbatim index.html, without altering the page's
rendered content.
Robust to the compact HTML real booths use (`<!doctype html><meta charset><title>
<style>…content`, no explicit head/body). The two hard constraints:
* NEVER put anything ahead of a leading <!doctype> — that forces quirks mode.
* Keep the charset <meta> within the first 1024 bytes so it's still honoured.
So the favicon lands at the first head-ish seam (before </head>, else after
<html>, else right after the doctype — a ~250B link keeps charset in range), and
the fixed-position chip is appended at the END of the document (before </body> /
</html> or appended), which renders top-left regardless and disturbs nothing.
TWO SUBSTRING TESTS. This is the entire detection half of what used to be
six regular expressions run against arbitrary author HTML — and the other
half, the insertion, is a `+`. See `_EMBED_DECLARATIONS` for why it matches
`src="…"` rather than the bare path: both spellings fail toward appending a
harmless duplicate rather than toward silently withholding the chrome.
"""
if favicon_link and not _ICON_RE.search(html):
for inserter, pat in (
(_insert_before, _HEAD_CLOSE_RE), # inside an explicit <head>
(_insert_after, _HTML_OPEN_RE), # top of an explicit <html>
(_insert_after, _DOCTYPE_RE), # right after the doctype (compact HTML)
):
html, done = inserter(html, pat, favicon_link)
if done:
break
else:
html = favicon_link + html # bare fragment, no doctype: safe to prepend
return any(d in html for d in _EMBED_DECLARATIONS)
chips = _BACK_CHIP + (extra or "")
for pat in (_BODY_CLOSE_RE, _HTML_CLOSE_RE):
html, done = _insert_before(html, pat, chips)
if done:
break
else:
html = html + chips # no </body>/</html>: append to the end
return html
def embed_verbatim(raw: bytes) -> bytes:
"""The ONLY thing the Booth does to a verbatim report. BYTES IN, BYTES OUT.
Appended, never inserted, and never prepended. That is what retires both of
the old wrapper's hard constraints rather than satisfying them more
carefully: nothing can displace a leading doctype into quirks mode and
nothing can push the charset <meta> out of its first-1024-byte detection
window, because nothing in front of them moves. Content after `</html>` is
parsed into the body by every browser, so there is no seam to find.
⚠ IT TAKES BYTES BECAUSE TEXT WAS QUIETLY EDITING THE DOCUMENT. The first
version read the file with `read_text()` and returned a str. That opens in
UNIVERSAL-NEWLINE mode, so a report written with CRLF came back with LF —
and `errors="replace"` turned any byte that was not valid UTF-8 into U+FFFD.
A declaring page was therefore NOT served as its author wrote it, which is
this unit's headline promise, and the test could not see it because its
fixture was LF-only ASCII. Found by a cross-frontier bug-hunt panel.
Decoding still happens — `declares_embed` needs a string to look in — but
the decoded copy is used ONLY to answer that question. What goes on the wire
is the original bytes, plus the tag's bytes when it is appended, so the
source is a byte-exact prefix of the response.
"""
text = raw.decode("utf-8", errors="replace")
return raw if declares_embed(text) else raw + EMBED_SCRIPT_TAG.encode("utf-8")
def ask_form_id(stem: str) -> str:
"""The shared `<form>` id a pick's scattered question groups bind to with
the HTML5 `form=` attribute.
Moved here from `booth/inline.py` when U3 deleted that module: it is not
placement machinery, it is what makes four radio groups spread down a report
submit as ONE POST, which is what a multi-question ask requires.
"""
return f"bk-ask-form-{re.sub(r'[^A-Za-z0-9_-]', '-', stem)}"
# ---- uploads (browser drop-off for pickup) ---------------------------------
@@ -765,6 +733,13 @@ def create_app(
env.filters["dur"] = human_dur
templates = Jinja2Templates(env=env)
# embed.js IS READ ONCE, HERE, for exactly the reason above. It is the third
# kind of thing this repo serves, and the only one that would otherwise be
# free to hot-reload from the deployment root — which is the skew that put
# 19 of 25 booths at 500. One rule: nothing takes effect until you restart.
embed_js = EMBED_JS_PATH.read_text(encoding="utf-8")
embed_etag = '"%s"' % hashlib.sha256(embed_js.encode("utf-8")).hexdigest()[:16]
@asynccontextmanager
async def lifespan(app: FastAPI):
task = None
@@ -855,6 +830,20 @@ def create_app(
def healthz():
return {"ok": True, "ttl_hours": ttl_hours, "booths": len(list_booths(data_dir, ttl_seconds))}
@app.get(EMBED_SRC)
def embed_script():
"""The declared seam's one static asset.
Served from the startup read, with an ETag over its content so a
browser revalidates instead of holding a stale copy across a restart —
`no-cache` here means "ask me", not "do not store".
"""
return Response(
content=embed_js,
media_type="text/javascript; charset=utf-8",
headers={"ETag": embed_etag, "Cache-Control": "no-cache"},
)
@app.get("/b/{name}", include_in_schema=False)
def booth_redirect(name: str):
resolve_booth(name)
@@ -877,18 +866,20 @@ def create_app(
)
own_index = booth / "index.html"
if own_index.is_file():
# Serve the operator's verbatim report, but inject a floating
# back-to-booths chip + the Booth favicon (if it declares none) so a
# raw page still has a way home. Small HTML -> read + wrap in memory;
# a pathological large file falls back to serving raw, unwrapped.
# The operator's verbatim report. U3: the page declares the seam and
# the Booth mounts into it — so a page carrying the script tag is
# served exactly as written, and one that is not gets that single
# line appended. Nothing is parsed, matched or inserted.
#
# The read is still bounded: a pathological file falls back to
# serving raw, which costs it the chrome exactly as it did before.
try:
if own_index.stat().st_size <= WRAP_MAX_BYTES:
raw = own_index.read_text(encoding="utf-8", errors="replace")
# Asks render INLINE, where the report author put them (or
# appended, if they marked nothing) — a question about an
# artifact belongs beside that artifact, not on another page.
body, tail = inject_asks(name, booth, raw)
return HTMLResponse(wrap_verbatim_html(body, extra=tail))
# ONE read, and it is a byte read: see embed_verbatim.
return Response(
content=embed_verbatim(own_index.read_bytes()),
media_type="text/html; charset=utf-8",
)
except OSError:
pass
return FileResponse(str(own_index), media_type="text/html")
@@ -1098,73 +1089,108 @@ def create_app(
_frag = templates.env.get_template("_ask_inline.html").module
def inject_asks(name: str, booth: Path, html: str) -> tuple[str, str]:
"""(body, tail) for a verbatim booth: placeholders substituted in place,
and whatever still has to be appended before </body>.
def _pick_fragments(name: str, mark) -> dict:
"""One pick, rendered into the pieces a page can mount independently.
Marked-up pages get each fragment exactly where the author put it. An
unmarked page gets the whole ask appended — an ask is NEVER invisible,
which is the guarantee; markup only moves it somewhere better. A stem
whose questions were placed but whose submit block was not gets that
block appended, so a scattered form is always submittable.
Rendered HERE, by the same Jinja macros the gallery page uses, so there
is exactly ONE renderer of an ask. embed.js places these; it never
builds one. A second renderer in JavaScript is the shape INV-1 was
written to stop after the zoom view re-derived an item and lost its
captions doing it.
"""
picks = [m for m in marks_for(booth) if m.shape == "pick"]
if not picks:
return html, ""
url = quote(name, safe="")
fid = ask_form_id(mark.id)
if mark.error:
# `whole` renders the broken-ask box. A question the session
# believes it posted has to be visible; the pieces of a pick that
# could not be read do not exist to offer.
return {"id": mark.id, "error": mark.error,
"whole": str(_frag.whole(mark, fid, url)), "submit": "",
"questions": []}
return {
"id": mark.id,
"error": None,
"whole": str(_frag.whole(mark, fid, url)),
"submit": str(_frag.submit(mark, fid, url)),
# A LIST, not an object keyed by question key: a single-question
# pick normalizes to one question whose key is None, which JSON
# would write as the string "null" and so invent a name. The list
# also carries declaration order in the format itself.
"questions": [
{"key": q.get("key"), "html": str(_frag.question(mark, q, fid, url))}
for q in mark.questions
],
}
seen: set[str] = set()
def _safe_fragments(name: str, mark) -> dict:
"""`_pick_fragments`, with the promise that it cannot raise.
def render(kind: str, mark, key: str | None) -> str:
fid = ask_form_id(mark.id)
if kind == "whole":
frag = str(_frag.whole(mark, fid, url))
elif kind == "submit":
frag = str(_frag.submit(mark, fid, url))
else:
q = next(q for q in mark.questions if q.get("key") == key)
frag = str(_frag.question(mark, q, fid, url))
# An anchor on the FIRST fragment of each pick, wherever it landed,
# so the floating chip can jump to it on a long report. Computed
# here rather than in the macros because only the caller knows
# which fragment came first.
if mark.id not in seen:
seen.add(mark.id)
frag = f'<a id="bk-ask-{mark.id}-top"></a>' + frag
return frag
`marks_for` hydrates an entry whose JSON is well-formed but whose SHAPE
is wrong — `{"answer": {"answers": []}}` survives `_hydrate` with no
error and then raises `UndefinedError` in the template, because the
macro asks a list for `.get`. Verified, not assumed.
tail = [str(_frag.styles())]
if has_placeholders(html):
html, placed, submitted = place_asks(html, picks, render)
for m in picks:
keys = placed.get(m.id)
if keys is None:
tail.append(render("whole", m, None)) # unmarked: never dropped
continue
if m.error:
continue
if None not in keys:
# Partially marked up: append every question the author did
# NOT place. A multi-question pick needs all of them or the
# POST is a 400 — met only after the operator fills it in.
for q in m.questions:
if q.get("key") not in keys:
tail.append(render("question", m, q.get("key")))
if m.id not in submitted:
tail.append(render("submit", m, None)) # scattered but submittable
else:
for m in picks:
tail.append(render("whole", m, None))
This endpoint renders every pick in the booth on every page load of the
operator's report, so one such entry would 500 the whole seam and the
report would show no chrome at all — while `hold_read` reported the file
as perfectly readable. Same leniency `_hydrate_safe` already applies one
layer down, at the layer that actually renders: one unreadable pick
costs that pick, never the page.
"""
try:
return _pick_fragments(name, mark)
except Exception as exc: # noqa: BLE001 - deliberate
broken = replace(mark, error=f"this question could not be rendered: {exc}")
return {"id": mark.id, "error": broken.error,
"whole": str(_frag.whole(broken, ask_form_id(mark.id),
quote(name, safe=""))),
"submit": "", "questions": []}
# The chip is a JUMP LINK to the inline block, not a way out to a
# separate page: on a long report the question can be well below the
# fold, and "there is a question waiting" still has to be visible at
# first paint.
still_open = open_marks(picks) # INV-2: not re-derived here
if still_open:
tail.append(asks_chip(name, len(still_open),
href=f'#bk-ask-{still_open[0].id}-top'))
return html, "".join(tail)
@app.get("/b/{name}/embed.json")
def booth_embed_json(name: str):
"""Everything a verbatim report needs to mount the Booth's chrome.
The READ half of the declared seam. `embed.js` fetches this and places
what comes back; every decision — what a mark says, whether it is still
open, what order the marks come in — is made here and never re-derived
on the page.
Marks are ordered `(created, id)`, which is what both readers below
sort by. Questions are in declaration order. `open` is `open_marks`,
the ONE openness predicate, so a half-answered multi-question pick
counts as open here exactly as it does on the index badge.
DOES NOT RECORD A VIEW. `booth_view` already did, above both of its
early returns; counting a script's fetch of the page it is already on
would reset the TTL on machinery rather than on the operator.
The read is LENIENT and the status stays 200, copied from
`/marks.json`: a damaged `.marks.json` must cost the chrome, never the
operator's report. That is the v0.2.2 lesson.
"""
booth = resolve_booth(name)
marks, read_err = hold_read(booth) # ONE read; see list_booths
if read_err is not None:
marks = marks_for(booth)
picks = [m for m in marks if m.shape == "pick"]
body = {
"booth": name,
# No `home`: the way-home chip mounts from a constant BEFORE this
# fetch, so that a failed one still leaves the operator a way out.
# Carrying the value anyway would put a second representation of it
# on the wire for nothing to read.
"favicon": FAVICON_HREF,
# Picks only. It is also what keeps a flag's `flag:<target>` id —
# the one mark id containing the separator an anchor spec splits
# on — out of a payload whose specs split on the first colon.
"marks": [_safe_fragments(name, m) for m in picks],
"open": [m.id for m in open_marks(picks)],
}
if read_err is not None:
body["marks"] = []
body["error"] = "this booth's .marks.json cannot be read"
body["detail"] = read_err
return JSONResponse(body)
@app.get("/b/{name}/marks", response_class=HTMLResponse)
def booth_marks_page(request: Request, name: str):
-119
View File
@@ -1,119 +0,0 @@
"""Inline ask placement inside a booth's VERBATIM index.html.
A booth that ships its own `index.html` is served untouched, so the auto-gallery
template's asks panel never renders there. The first fix was a chip linking to a
separate `/asks` page; the operator's verdict on that (2026-09-09) was that the
question belongs WITH the artifacts it is about — a four-voice audition wants the
radio group for each voice under that voice's audio, not on another page.
So the report author marks where each piece goes, with a placeholder element:
<div data-booth-ask="anchors"></div> the whole ask: every question + submit
<div data-booth-ask="anchors:lawson"></div> just that question's radios
<div data-booth-ask-submit="anchors"></div> the notes field + submit button
Per-question fragments bind to ONE form via the HTML5 `form=` attribute, so four
groups scattered down a page still submit as a single POST — which is what a
multi-question ask requires (every question or 400). No JavaScript.
An `<!-- booth:ask anchors -->` comment works the same way, for authors who would
rather not put an empty div in their markup.
Placement is OPTIONAL. A page with no placeholders gets the whole ask appended at
the end of its body, so an ask is never invisible — that guarantee is the point,
and marking it up only moves it somewhere better.
"""
from __future__ import annotations
import re
# <div data-booth-ask="stem"></div> / <span data-booth-ask="stem:key"></span>
_EL_RE = re.compile(
r"<(?P<tag>[A-Za-z][\w-]*)\b[^>]*?\bdata-booth-ask=\"(?P<spec>[^\"]+)\"[^>]*?>"
r"(?:\s*</(?P=tag)\s*>)?",
re.IGNORECASE,
)
_SUBMIT_EL_RE = re.compile(
r"<(?P<tag>[A-Za-z][\w-]*)\b[^>]*?\bdata-booth-ask-submit=\"(?P<spec>[^\"]+)\"[^>]*?>"
r"(?:\s*</(?P=tag)\s*>)?",
re.IGNORECASE,
)
# <!-- booth:ask stem --> / <!-- booth:ask stem:key --> / <!-- booth:ask-submit stem -->
_COMMENT_RE = re.compile(r"<!--\s*booth:ask\s+(?P<spec>[^\s>-][^\s>]*)\s*-->", re.IGNORECASE)
_COMMENT_SUBMIT_RE = re.compile(r"<!--\s*booth:ask-submit\s+(?P<spec>[^\s>]+)\s*-->", re.IGNORECASE)
def split_spec(spec: str) -> tuple[str, str | None]:
"""`"anchors:lawson"` -> `("anchors", "lawson")`; `"anchors"` -> `("anchors", None)`."""
stem, sep, key = spec.strip().partition(":")
return stem.strip(), (key.strip() or None) if sep else None
def has_placeholders(html: str) -> bool:
return bool(
_EL_RE.search(html) or _SUBMIT_EL_RE.search(html)
or _COMMENT_RE.search(html) or _COMMENT_SUBMIT_RE.search(html)
)
def form_id(stem: str) -> str:
return f"bk-ask-form-{re.sub(r'[^A-Za-z0-9_-]', '-', stem)}"
def place(html: str, asks: list, render) -> tuple[str, dict[str, set], set[str]]:
"""Substitute every placeholder with rendered ask HTML.
`render(kind, ask, key)` returns the fragment for kind in
{"whole", "question", "submit"}. Returns the new html; a map of stem ->
the set of question keys placed inline (with `None` in the set meaning the
WHOLE ask was placed); and the set of stems whose submit block was placed
explicitly.
The caller needs the per-key detail, not just "this stem appeared
somewhere": a multi-question ask requires EVERY question on submit, so a
page that marks up two of four questions must still be handed the other two
or the form is unsubmittable — a 400 the operator would meet only after
filling it in.
A placeholder naming an ask this booth does not have is left ALONE, not
blanked: silently eating the author's markup would hide a typo'd stem, and
an untouched empty div is invisible anyway.
"""
# Marks index by ATTRIBUTE, not subscript: `place` was the one consumer in
# the service that did `a["stem"]`, which a frozen dataclass refuses. Caught
# by the U2 seam review (SR-1) — the cold contract pass cannot see a sibling
# module's surface by design, so nothing else would have found it before the
# first verbatim booth 500'd.
by_stem = {a.id: a for a in asks}
placed: dict[str, set] = {}
submitted: set[str] = set()
def sub_main(m: re.Match) -> str:
stem, key = split_spec(m.group("spec"))
ask = by_stem.get(stem)
if ask is None:
return m.group(0)
if key is None:
placed.setdefault(stem, set()).add(None)
submitted.add(stem)
return render("whole", ask, None)
q = next((q for q in ask.questions if q.get("key") == key), None)
if q is None:
return m.group(0)
placed.setdefault(stem, set()).add(key)
return render("question", ask, key)
def sub_submit(m: re.Match) -> str:
stem, _ = split_spec(m.group("spec"))
ask = by_stem.get(stem)
if ask is None:
return m.group(0)
placed.setdefault(stem, set())
submitted.add(stem)
return render("submit", ask, None)
for pat, fn in ((_EL_RE, sub_main), (_COMMENT_RE, sub_main),
(_SUBMIT_EL_RE, sub_submit), (_COMMENT_SUBMIT_RE, sub_submit)):
html = pat.sub(fn, html)
return html, placed, submitted
+351
View File
@@ -0,0 +1,351 @@
/* The Booth - the declared embed seam (U3).
*
* A booth that ships its own index.html is served verbatim. This script is how
* the Booth's chrome gets onto that page WITHOUT the Booth reaching into it:
* the report carries one line,
*
* <script src="/_booth/embed.js" defer></script>
*
* and everything below mounts through real DOM APIs. It replaced ten regular
* expressions applied to author HTML - six hunting for a place to hang a
* favicon and a chip, four substituting rendered markup into the author's own
* tags. A page that declares this line is now served exactly as written.
*
* WHAT THIS SCRIPT DOES NOT DECIDE: what a mark says, whether it is still open,
* or what order marks come in. Every fragment below is rendered server-side by
* the same Jinja macros the gallery page uses, and `open` is computed by
* `open_marks`. Two renderers of one truth is the bug INV-1 exists to stop -
* the zoom view once re-derived an item and lost its captions doing it.
*
* Served from a read taken ONCE at app startup. Editing this file does nothing
* until `systemctl --user restart booth.service`, exactly like the templates,
* and for the same reason: on 2026-09-21 a hot-reloading template put 19 of 25
* booths at 500 against Python that had never heard of the context it wanted.
*/
(function () {
"use strict";
if (window.__boothEmbed) return; // declared AND appended: mount once
window.__boothEmbed = true;
var CSS = [
/* ---- the way home, and the open-asks jump ---- */
".booth-nav-home,.booth-nav-asks{position:fixed;top:0;z-index:2147483647;",
"display:inline-block;margin:.6rem;padding:.34rem .72rem;border-radius:8px;",
"text-decoration:none;letter-spacing:.01em;box-shadow:0 2px 10px rgba(0,0,0,.35)}",
/* top-right: a top-left chip clips the page title on left-aligned report
layouts, and this matches the zoom view's back affordance. */
".booth-nav-home{right:0;font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
"color:#dfe7ef;background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);",
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .18s,border-color .18s}",
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}",
".booth-nav-asks{right:7.2rem;font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
"color:#171a23;background:#ffe14e;border:1px solid #ffe14e;transition:filter .18s}",
".booth-nav-asks:hover{filter:brightness(1.08)}",
"@media print{.booth-nav-home,.booth-nav-asks{display:none}}",
/* ---- ask fragments. Self-contained: the host page carries its own CSS and
nothing here may inherit from it, so the palette adapts via
prefers-color-scheme rather than borrowing. ---- */
".bk-ask{margin:1.1rem 0;padding:.85rem .95rem;border:1px solid rgba(128,140,160,.34);",
"border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);",
"font:15px/1.5 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}",
".bk-ask.bk-done{border-top-color:#3fae6a}",
".bk-ask.bk-skip{border-top-color:#6f7c8c}",
".bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}",
".bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;",
"letter-spacing:.12em;text-transform:uppercase;color:#c9a227}",
".bk-ask.bk-done .bk-ask-tag{color:#3fae6a}",
".bk-ask-title{margin:0 0 .15rem;font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;opacity:.62}",
".bk-ask-prompt{margin:0 0 .6rem;font-weight:600}",
".bk-ask-opts{display:flex;flex-direction:column;gap:.3rem}",
".bk-ask-opt{display:flex;align-items:flex-start;gap:.55rem;padding:.45rem .6rem;cursor:pointer;",
"border:1px solid rgba(128,140,160,.3);border-radius:6px;background:rgba(128,140,160,.06)}",
".bk-ask-opt:hover{border-color:rgba(128,140,160,.62)}",
".bk-ask-opt:has(input:checked){border-color:#2fa8a0;background:rgba(47,168,160,.13)}",
".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:#2fa8a0}",
".bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}",
".bk-ask-det{font-size:.8rem;opacity:.68}",
".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;",
"font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);",
"border:1px solid rgba(128,140,160,.34);border-radius:6px;resize:vertical}",
".bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;",
"letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;",
"background:#2fa8a0;color:#08131a}",
".bk-ask-go:hover{filter:brightness(1.09)}",
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}",
".bk-ask-was b{opacity:1}",
".bk-ask-err{color:#d6452a;font-size:.86rem}",
"@media (prefers-color-scheme: light){.bk-ask-tag{color:#8a6d10}.bk-ask-go{color:#fff}}",
"@media print{.bk-ask{break-inside:avoid}}"
].join("");
/* The anchor attributes. `data-booth-mark` is canonical - U2 made an ask one
shape of mark - and `data-booth-ask` is kept because two of the four live
verbatim booths spell it that way, in the operator's own reports. */
var MAIN_SEL = "[data-booth-mark],[data-booth-ask]";
var SUBMIT_SEL = "[data-booth-mark-submit],[data-booth-ask-submit]";
var WHOLE = " whole"; // the set member meaning "the whole ask landed here"
function attr(el, a, b) {
var v = el.getAttribute(a);
return v === null ? el.getAttribute(b) : v;
}
/* "batch:r1" -> ["batch", "r1"]; "batch" -> ["batch", null]. Split on the
FIRST colon: a question key cannot contain one (asks._KEY_RE) and neither
can a pick id (asks.valid_stem), so this is unambiguous for everything the
payload carries. A flag's id IS `flag:<target>`, which is why the payload
carries picks only. */
function splitSpec(spec) {
var s = (spec || "").trim();
var i = s.indexOf(":");
if (i < 0) return [s, null];
return [s.slice(0, i).trim(), s.slice(i + 1).trim() || null];
}
function boothName() {
var tag = document.querySelector("script[data-booth]");
if (tag) return tag.getAttribute("data-booth");
var parts = location.pathname.split("/"); // ["", "b", "<name>", ...]
if (parts.length < 3 || parts[1] !== "b" || !parts[2]) return null;
try { return decodeURIComponent(parts[2]); } catch (e) { return parts[2]; }
}
function mount(el, html) {
/* beforeend, NOT replaceWith: the author's element and its contents survive
and the fragment lands inside it. `<div class="ask" data-booth-ask="...">
<h3>heading</h3>` is live markup today, and the regex it replaced ate
both the wrapper class and the heading's framing.
Returns the elements it actually inserted. The chip needs to jump to a
fragment WE mounted, not to whatever the document happens to have with a
matching id — see chipTarget. */
var before = el.children.length;
el.insertAdjacentHTML("beforeend", html);
return Array.prototype.slice.call(el.children, before);
}
function styles() {
if (document.getElementById("booth-embed-css")) return;
var st = document.createElement("style");
st.id = "booth-embed-css";
st.textContent = CSS;
(document.head || document.documentElement).appendChild(st);
}
function favicon(href) {
/* The question `_ICON_RE` and its three head-seam siblings were asking of
raw text. Same question, asked of a parsed document. */
if (!href || document.querySelector('link[rel~="icon"]')) return;
var link = document.createElement("link");
link.rel = "icon";
link.href = href;
(document.head || document.documentElement).appendChild(link);
}
function homeChip(href) {
var a = document.createElement("a");
a.className = "booth-nav-home";
a.href = href || "/";
a.setAttribute("aria-label", "back to all booths");
a.textContent = "‹ all booths";
document.body.appendChild(a);
}
function chipTarget(mounted, markId) {
/* The earliest IN DOCUMENT ORDER of the elements WE mounted for this mark.
Not an id-prefix search over the whole document: a panel pointed out that
an author's own `<section id="bk-ask-winner-background">` satisfies any
prefix rule — hyphen boundary included — and would hijack the jump. Only
elements this script inserted are candidates, which is the identity the
deleted `bk-ask-<id>-top` anchor used to guarantee. */
var mine = mounted[markId] || [];
var first = null;
for (var i = 0; i < mine.length; i++) {
var el = mine[i];
if (!el.id || !document.contains(el)) continue;
if (first === null ||
(first.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)) {
first = el;
}
}
return first;
}
function asksChip(openIds, mounted) {
if (!openIds.length) return;
/* A JUMP LINK, not a way out to another page: on a long report the question
can be well below the fold and "there is a question waiting" still has to
be visible at first paint. */
var first = chipTarget(mounted, openIds[0]);
var a = document.createElement("a");
a.className = "booth-nav-asks";
a.href = first ? "#" + first.id : "/b/" + encodeURIComponent(boothName() || "") + "/marks";
a.textContent = "? " + openIds.length + " open ask" + (openIds.length === 1 ? "" : "s");
document.body.appendChild(a);
}
function reassociate() {
/* SCOPED TO OUR OWN FRAGMENTS (`.bk-ask [form]`), deliberately: the Booth
does not rewrite attributes on elements the author wrote, even to help.
A control bound to its <form> by the HTML5 `form=` attribute resolves its
form owner when it is inserted. The fragments go in in VISUAL order, so a
question can land before the submit block that carries the <form>.
Chromium 151 re-resolves this correctly - measured 2026-09-22, N=3 per
condition, with a form-first positive control and a points-at-nothing
negative control. The sensitivity floor of that probe is ONE ENGINE, and
the failure it would hide is a form the operator fills in whose controls
reach no form at all, so the button does nothing and nothing is saved.
Three lines, so the engine stops mattering. */
var bound = document.querySelectorAll(".bk-ask [form]");
for (var i = 0; i < bound.length; i++) {
var v = bound[i].getAttribute("form");
bound[i].removeAttribute("form");
bound[i].setAttribute("form", v);
}
}
function hasForm(markId) {
/* `form_id` in booth/app.py builds the same string. Kept in step by the
fragments themselves: the submit macro emits exactly this id. */
return !!document.getElementById(
"bk-ask-form-" + markId.replace(/[^A-Za-z0-9_-]/g, "-"));
}
function place(marks) {
/* Object.create(null), NOT {} — three times, and it is not style.
A mark id and a question key are both `[A-Za-z0-9][A-Za-z0-9._-]*`
(asks.valid_stem, asks._KEY_RE), so `toString` and `constructor` are
legal in both. Against a plain object, an author writing
`data-booth-mark="toString"` — an anchor naming NO mark — gets
Object.prototype.toString back, passes the `if (!mark)` guard it was
supposed to fail, and throws on `mark.questions.length`. That aborts
`place` before the tail, so the page loses EVERY ask, from one typo in
the author's own markup. The `placed` set has the mirror bug: inherited
`got.constructor` reads as "already placed" and silently drops a real
question. Found by a cross-frontier code-review panel. */
var by = Object.create(null);
for (var i = 0; i < marks.length; i++) by[marks[i].id] = marks[i];
var placed = Object.create(null); // id -> {key or WHOLE: true}
var submitted = Object.create(null);
var mounted = Object.create(null); // id -> [elements this script inserted]
function note(id, key) {
if (!placed[id]) placed[id] = Object.create(null);
if (key !== undefined) placed[id][key] = true;
}
function record(id, els) {
if (!mounted[id]) mounted[id] = [];
for (var n = 0; n < els.length; n++) mounted[id].push(els[n]);
}
// 1. whole / per-question anchors, in DOCUMENT ORDER.
var anchors = document.querySelectorAll(MAIN_SEL);
for (var a = 0; a < anchors.length; a++) {
var el = anchors[a];
var spec = splitSpec(attr(el, "data-booth-mark", "data-booth-ask"));
var mark = by[spec[0]];
if (!mark) continue; // a typo'd id is LEFT ALONE, not blanked
if (spec[1] === null) {
record(mark.id, mount(el, mark.whole));
note(mark.id, WHOLE);
if (hasForm(mark.id)) submitted[mark.id] = true;
continue;
}
var q = null;
for (var k = 0; k < mark.questions.length; k++) {
if (mark.questions[k].key === spec[1]) { q = mark.questions[k]; break; }
}
if (!q) continue; // names no question: also left alone
record(mark.id, mount(el, q.html));
note(mark.id, spec[1]);
}
// 2. explicit submit anchors.
var subs = document.querySelectorAll(SUBMIT_SEL);
for (var s = 0; s < subs.length; s++) {
var sel = subs[s];
var sid = splitSpec(attr(sel, "data-booth-mark-submit", "data-booth-ask-submit"))[0];
var sm = by[sid];
if (!sm) continue;
/* A BROKEN pick has no submit block — its `submit` is the empty string and
its diagnostic lives in `whole`. Mounting nothing here and then marking
it placed made the tail skip it, so the "broken ask" box never rendered
at the one surface built to show it. Leave the anchor alone, exactly as
an anchor naming no mark is left alone, and let the tail mount the
diagnostic. */
if (sm.error) continue;
record(sm.id, mount(sel, sm.submit));
note(sm.id);
/* ...and only count it submitted if the <form> SURVIVED. An author who
puts this anchor inside their own <form> loses ours: the HTML parser
drops a nested form element outright. Every control's `form=` would
then point at nothing, the tail would not add a fallback because we
said it was handled, and the operator would fill the whole thing in and
click a button that does nothing. */
if (hasForm(sm.id)) submitted[sm.id] = true;
}
// 3. the tail, in PAYLOAD order - `(created, id)`. An ask is never
// invisible: an unmarked page gets the whole thing, and a partially
// marked one gets every question the author did not place, because a
// question the operator cannot see is a question he cannot answer, and
// a submission with NOTHING picked is refused outright (400), so a page
// showing two of four questions can strand a pick that looks answerable.
// (A PARTIAL answer is accepted and recorded — that is deliberate.)
var holder = document.createElement("div");
for (var m = 0; m < marks.length; m++) {
var mk = marks[m];
var got = placed[mk.id];
var was = holder.children.length;
if (!got) {
holder.insertAdjacentHTML("beforeend", mk.whole);
record(mk.id, Array.prototype.slice.call(holder.children, was));
continue;
}
if (mk.error) continue;
if (!got[WHOLE]) {
for (var q2 = 0; q2 < mk.questions.length; q2++) {
var qq = mk.questions[q2];
if (!got[qq.key]) holder.insertAdjacentHTML("beforeend", qq.html);
}
}
if (!submitted[mk.id]) holder.insertAdjacentHTML("beforeend", mk.submit);
record(mk.id, Array.prototype.slice.call(holder.children, was));
}
var tail = document.createDocumentFragment();
while (holder.firstChild) tail.appendChild(holder.firstChild);
document.body.appendChild(tail);
return mounted;
}
function start() {
var name = boothName();
if (!name || !document.body) return;
styles();
// Mounted BEFORE the fetch and from a constant, so a failed or slow fetch
// still leaves the operator a way out. That is why the payload carries no
// `home` — a value on the wire that nothing reads is a second
// representation of one fact, waiting to disagree with the first.
homeChip("/");
fetch("/b/" + encodeURIComponent(name) + "/embed.json", { credentials: "same-origin" })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data) return;
favicon(data.favicon);
var mounted = place(data.marks || []);
reassociate();
asksChip(data.open || [], mounted);
document.dispatchEvent(new CustomEvent("booth:mounted", { detail: { booth: name } }));
})
.catch(function () { /* the report is the operator's; a failed fetch costs
the chrome, never the page. */ });
}
/* The declared line carries `defer`, but an author may not copy it exactly. */
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}
})();
+11 -45
View File
@@ -1,56 +1,22 @@
{# Self-contained ask fragments injected into a booth's VERBATIM index.html.
{# Self-contained ask fragments for a booth's VERBATIM index.html.
The page is served untouched and carries its own CSS, so nothing here may
inherit from base.html: every fragment ships its own scoped `.bk-ask-*`
styles (emitted once, by `styles()`), and the palette adapts via
prefers-color-scheme rather than borrowing the host page's.
inherit from base.html. Since U3 these fragments do not reach the page by
string substitution: they are rendered here, handed over
`/b/<name>/embed.json`, and MOUNTED INTO THE DOM by `/_booth/embed.js`. The
scoped `.bk-ask-*` styles live in that file alongside the code that needs
them, which is why this template no longer emits a `styles()` block.
These macros stay the ONE renderer of an ask fragment. embed.js places what
comes back and never builds one.
Per-question fragments are wired to ONE form with the HTML5 `form=`
attribute, so a four-voice report can put each radio group under its own
audio block and still submit all four picks in a single POST — which is what
audio block and still submit all four picks in a single POST -- which is what
the multi-question ask requires. The <form> element itself is empty and
lives with the submit block. No JavaScript.
lives with the submit block.
#}
{% macro styles() %}
<style>
.bk-ask{margin:1.1rem 0;padding:.85rem .95rem;border:1px solid rgba(128,140,160,.34);
border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);
font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.bk-ask.bk-done{border-top-color:#3fae6a}
.bk-ask.bk-skip{border-top-color:#6f7c8c}
.bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}
.bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
letter-spacing:.12em;text-transform:uppercase;color:#c9a227}
.bk-ask.bk-done .bk-ask-tag{color:#3fae6a}
.bk-ask-title{margin:0 0 .15rem;font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;opacity:.62}
.bk-ask-prompt{margin:0 0 .6rem;font-weight:600}
.bk-ask-opts{display:flex;flex-direction:column;gap:.3rem}
.bk-ask-opt{display:flex;align-items:flex-start;gap:.55rem;padding:.45rem .6rem;cursor:pointer;
border:1px solid rgba(128,140,160,.3);border-radius:6px;background:rgba(128,140,160,.06)}
.bk-ask-opt:hover{border-color:rgba(128,140,160,.62)}
.bk-ask-opt:has(input:checked){border-color:#2fa8a0;background:rgba(47,168,160,.13)}
.bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:#2fa8a0}
.bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}
.bk-ask-det{font-size:.8rem;opacity:.68}
.bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;
font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);
border:1px solid rgba(128,140,160,.34);border-radius:6px;resize:vertical}
.bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;
background:#2fa8a0;color:#08131a}
.bk-ask-go:hover{filter:brightness(1.09)}
.bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}
.bk-ask-was b{opacity:1}
.bk-ask-err{color:#d6452a;font-size:.86rem}
@media (prefers-color-scheme: light){
.bk-ask-tag{color:#8a6d10}
.bk-ask-go{color:#fff}
}
@media print{.bk-ask{break-inside:avoid}}
</style>
{% endmacro %}
{# One question's radio group, bound to the shared form by id. #}
{% macro question(a, q, form_id, name_url, standalone=False) %}
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
@@ -0,0 +1,471 @@
---
contract_version: "1.0"
module: "booth.app (verbatim serving) + booth/static/embed.js"
purpose: "A booth that ships its own index.html is the operator's most important surface -- his design reviews, his audition reports, his briefs -- and the Booth reaches into it with six regular expressions against arbitrary author HTML plus a placeholder DSL that substitutes rendered markup by pattern. Both work today and both are the single most fragile thing in the service. This unit replaces the whole class with a DECLARED SEAM: the page carries one line (`<script src=\"/_booth/embed.js\" defer></script>`), the Booth mounts its chrome through real DOM APIs, and a page that declares the line is served with ZERO Booth markup added to it. A page that does not declare it gets that one line appended at the end -- the only remaining mutation, and it needs no pattern matching at all. Operator ruling, 2026-09-21: the page declares itself, the Booth mounts into it."
depends_on:
- "booth.marks (marks_for, open_marks, hold_read -- the pick records the payload renders. UNCHANGED by this unit: U3 changes how fragments REACH the page, never what a mark is. Read against booth/marks.py, not against the U2 contract's prose -- see the seam review.)"
- "booth/templates/_ask_inline.html (the `whole` / `question` / `submit` macros stay the ONE renderer of an ask fragment, called from the embed payload instead of from inject_asks. Its `styles()` macro is DELETED -- inject_asks was its only caller and the CSS moves into embed.js so the chrome is one asset. Macro signatures are otherwise untouched.)"
- "booth.asks.normalize_ask (TRANSITIVE, through `marks._hydrate`, and named because the payload shape depends on it: a MULTI ask normalizes to questions whose `key` matches `^[A-Za-z0-9][A-Za-z0-9._-]{0,60}$`, and a SINGLE-question ask normalizes to exactly one question whose `key` is `None`. Both facts are load-bearing -- the first makes splitting an anchor spec on the first colon unambiguous, the second is why `questions` is a list. Read against booth/asks.py:138-223.)"
- "booth.app.FAVICON_HREF (the data-URI icon, carried in the payload rather than copied into embed.js -- a third copy of that string is exactly the multiple-readers-of-one-truth shape the repo's ONE-RESOLVER rule (CLAUDE.md invariant 3 -- NOT this contract's INV-1, which is the untouched-page rule; a cold arm read the two as one label and was right to) exists to stop. base.html's literal copy predates this unit and is out of scope.)"
language: "python + javascript"
complexity: "medium"
estimated_loc: 420
confidence: 0.82
used_by:
- "booth.app.booth_view (the verbatim branch: one read, one substring check, one conditional append -- replacing inject_asks + wrap_verbatim_html entirely)"
- "the operator's verbatim reports (4 of 21 live booths ship their own index.html; 2 of those 4 use the placement DSL, so the migration is not hypothetical)"
- "report authors (the declared line is the new public API for a booth that wants Booth chrome where it chooses)"
touches:
- "booth/static/embed.js (NEW -- the mount script and the chrome CSS, one asset. Read ONCE at app startup, never per request; see INV-5.)"
- "booth/app.py (DELETE wrap_verbatim_html, _insert_before, _insert_after, _ICON_RE, _HEAD_CLOSE_RE, _HTML_OPEN_RE, _DOCTYPE_RE, _BODY_CLOSE_RE, _HTML_CLOSE_RE, _BACK_CHIP, asks_chip, inject_asks, FAVICON_LINK and the `from booth.inline import` block. ADD EMBED_SRC/EMBED_SCRIPT_TAG, the startup read of embed.js, GET /_booth/embed.js, GET /b/{name}/embed.json, and the rewritten verbatim branch of booth_view.)"
- "booth/inline.py (DELETED ENTIRELY -- 119 lines. Nothing else imports it; `scripts/booth` never did, so the stdlib-only CLI surface is untouched. ONE line survives the module: `form_id`, which builds the shared form element id the fragments bind to, moves into booth/app.py beside the route that renders them. It is not placement machinery and dying with the placement engine would take the fragments with it. Seam review, SR-3.)"
- "booth/templates/_ask_inline.html (DELETE the `styles()` macro and rewrite the header comment: the fragments are now mounted by embed.js, not substituted by regex, and `No JavaScript` stops being true.)"
- "tests/test_booth.py (DELETE the five test_wrap_* tests and test_verbatim_booth_wrapped_with_back_chip -- they test a mechanism this unit removes; the FAVICON_LINK import goes with them)"
- "tests/test_asks.py (the inline-placement block, ~L480-590: assertions that server-rendered fragments appear in the page body become assertions about the embed payload. The BEHAVIOUR they encode -- every question reachable, a scattered form still submittable, a typo'd id left alone -- is preserved and re-asserted, half in Python and half in the browser.)"
- "tests/test_embed.py (NEW -- the payload, the injection rule, the no-hot-reload guard)"
- "tests/test_embed_browser.py (NEW -- Playwright against a real Chromium: the placement algorithm and the form association, neither of which the Python suite can see. SKIPS, never fails, when playwright or the shared browser is unavailable.)"
- "pyproject.toml (test extra gains `playwright>=1.60,<1.63` -- the range is the set of releases whose pinned Chromium revision is present in the box-wide /opt/ms-playwright store. Stated explicitly because it is invisible otherwise: 1.63 wants chromium-1243, which is NOT there, and the failure is an opaque `Executable doesn't exist`.)"
- "docs/design/information-architecture.md (the `What this deletes` list becomes what this DID delete; the standalone /asks bullet is corrected -- U2 already reduced it to a 308)"
- "ROADMAP.md (U3 row struck through; the ordering table gains the two orders this unit states)"
assumptions:
- "THE OPERATOR ALREADY RULED ON THE SEAM (2026-09-21, recorded in the IA doc): the page declares itself and the Booth mounts into it, via `<script src=\"/_booth/embed.js\" defer></script>`. That ruling ACCEPTS a JavaScript dependency on the verbatim path, which today has none. This contract does not re-open it. What the contract DOES do is state the consequence plainly so it is not discovered later -- see the degradation assumption below."
- "THE ONLY REMAINING SERVER-SIDE MUTATION IS A CONDITIONAL APPEND, AND IT NEEDS NO PATTERN AT ALL. Two substring tests (`src=\"/_booth/embed.js\"` and its single-quoted twin), then a concatenation. ⚠ THE BARE PATH WAS THE FIRST DRAFT AND IT FAILED IN THE DANGEROUS DIRECTION: a report that merely MENTIONS the path -- a code sample, a comment, a sentence about this feature, which the Booth's own design reports are the likeliest pages to contain -- would have counted as declaring it, been served untouched, and shown no chrome at all, silently. Requiring `src=` immediately before the path flips the failure direction: an unusual spelling (`src = \"...\"`, an unquoted attribute, a `?v=2` suffix) reads as NOT declared, so a second tag is appended and embed.js mounts once anyway on its `window.__boothEmbed` guard. A missed declaration costs a duplicate tag; a false one costs the operator his chrome. Three of four cold-panel arms found this independently. This is why all six regexes die rather than collapsing to one: content appended AFTER `</html>` is parsed into the body by every browser, so there is nothing to find. Nothing is ever PREPENDED, which is what retires both of wrap_verbatim_html's hard constraints in one stroke -- no doctype can be displaced into quirks mode and no charset meta can be pushed out of the first 1024 bytes, because nothing moves."
- "THE FAVICON MOVES FROM A REGEX TO A DOM QUERY. `_ICON_RE` existed to answer `does this page already declare an icon`, against raw text, and three more regexes existed to find a head-ish seam to put one in. embed.js asks `document.querySelector('link[rel~=\"icon\"]')` and appends to `document.head`. That is the same question and the same action, asked of a parsed document instead of a string -- and it is four of the six regexes."
- "FRAGMENTS ARE STILL RENDERED BY JINJA, ONLY PLACED BY JAVASCRIPT. The payload carries server-rendered HTML from the EXISTING `_ask_inline.html` macros. Re-implementing the ask form in JavaScript would make two renderers of one truth, which is precisely the shape the repo's ONE-RESOLVER rule (CLAUDE.md invariant 3) was written to stop after the zoom view lost its captions. embed.js does DOM placement and nothing else: it never decides what a mark says, whether it is open, or what order marks come in."
- "PLACEMENT IS AN ANCHOR-FILL, NOT A REPLACEMENT, AND THAT IS A DELIBERATE CHANGE FROM TODAY. `_EL_RE` matches an author's opening tag and SUBSTITUTES it, so `<div class=\"ask\" data-booth-ask=\"dfa:logo\"><h3>The one asset that must survive</h3>` loses both the wrapper's class and -- visually -- its framing, leaving the author's heading orphaned and the closing `</div>` stray. That is live today on `dfa-concepts`. embed.js uses `el.insertAdjacentHTML('beforeend', frag)`: the author's element and its contents survive and the fragment lands inside, under the heading. Strictly closer to what the markup says, and it is the behaviour a DOM API gives for free."
- "`data-booth-mark` IS CANONICAL; `data-booth-ask` IS A KEPT ALIAS. U2 made an ask one shape of mark and the IA doc names the anchor `data-booth-mark`. But 2 of the 4 live verbatim booths use the `data-booth-ask` spelling, in the operator's own reports, so the selector accepts both -- one extra clause in one selector string. Same for `data-booth-mark-submit` / `data-booth-ask-submit`. Renaming without the alias would break a live report to save nothing."
- "THE HTML-COMMENT PLACEHOLDERS ARE DROPPED, NOT PORTED. `<!-- booth:ask stem -->` and `<!-- booth:ask-submit stem -->` have ZERO users across all 21 live booths. Walking comment nodes to keep them would be real complexity bought for nobody, in the unit whose entire point is deletion. A page that used one degrades to the append path -- the ask still renders, at the end -- so the never-invisible guarantee holds even for a caller we do not know about."
- "DEGRADATION WITH JAVASCRIPT OFF IS A REAL LOSS AND IT IS NAMED HERE. Today the verbatim path is zero-JS: an ask renders server-side and submits through a plain form. After this unit, no JS means no chrome on the report -- no ask, no way home, no icon. The guarantee that an ask is NEVER INVISIBLE survives in a weaker and still-true form, through surfaces that need no script: the index card carries the open-mark badge, and `/b/<name>/marks` renders every mark server-side. This is the cost of the operator's ruling, stated once so nobody meets it as a surprise."
- "EMBED.JS IS READ ONCE AT STARTUP, FOR THE REASON TEMPLATES ARE. Serving it from disk per request would give the service a third staleness rule, and a live asset editable under a running process is exactly what put 19 of 25 booths at 500 on 2026-09-21. One rule in this repo: nothing takes effect until you restart. INV-5 holds the line the same way `test_templates_do_not_hot_reload_from_disk` does."
- "THE PAYLOAD ENDPOINT DOES NOT RECORD A VIEW. `booth_view` already calls `record_view` above both early returns (U4), and `.viewed` is a deliberate look. A fetch issued by a script on a page that has ALREADY been recorded would double-count activity and reset the TTL on machinery rather than on the operator -- the same distinction the `.lock` exemption draws in `_newest_mtime`."
- "THE READ IS LENIENT AND THE STATUS STAYS 200, copied deliberately from `/b/{name}/marks.json`. A damaged `.marks.json` must not 500 the operator's report; it returns an `error` in the body and embed.js mounts the nav anyway. This is the v0.2.2 lesson and the posture every read path in this service already takes."
- "WRAP_MAX_BYTES SURVIVES UNCHANGED, at 8 MiB, with the same raw-FileResponse fallback. The work behind it is now trivial, but the READ is not: the largest live verbatim booth is 280 KB and a pathological one still should not be pulled into memory. A booth over the cap loses its chrome exactly as it does today -- no regression, and the constant keeps its existing test."
open_questions:
- "Whether `/_booth/embed.js` should eventually carry the gallery page's chrome too, making one embed for both surfaces. Out of scope: the gallery page is server-rendered end to end and has no seam problem to solve."
- "Whether a booth should be able to suppress injection entirely (a `.no-embed` dotfile) for a report that wants to be served truly untouched. No live booth wants it; declaring the line and then not using it is already most of the way there. Parked rather than designed."
---
# U3 — the declared embed seam
## The defect, stated precisely
A booth that ships its own `index.html` is served verbatim. That is the whole
promise of the verbatim path, and the Booth breaks it twice on the way out:
1. **`wrap_verbatim_html`** searches arbitrary author HTML with six regular
expressions — `_ICON_RE`, `_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`,
`_BODY_CLOSE_RE`, `_HTML_CLOSE_RE` — to find somewhere to put a favicon and
somewhere to put a floating chip, while threading two constraints it cannot
verify: never put anything ahead of a leading doctype, and keep the charset
meta inside the first 1024 bytes.
2. **`booth/inline.py`** matches a placeholder DSL with four more patterns and
substitutes rendered HTML into the author's markup by string replacement.
Ten patterns, applied to documents the Booth did not write, does not parse, and
cannot validate. It works. It is also the single most fragile thing in the
service, and it is load-bearing for the operator's most important workflow.
The failure this invites is not a crash. It is a report that renders *slightly*
wrong — and there is a live specimen already. `dfa-concepts/index.html` writes:
```html
<div class="ask" data-booth-ask="dfa:logo"><h3>The one asset that must survive</h3>
```
`_EL_RE` matches the opening `<div …>` and replaces it. The author's `.ask`
wrapper class is gone, the `<h3>` is orphaned, and the `</div>` further down is
stray. Nobody filed a bug, because a page that is 95% right does not look broken.
## The seam
Operator ruling, 2026-09-21. A report carries one line:
```html
<script src="/_booth/embed.js" defer></script>
```
and the Booth mounts its chrome through real DOM APIs. Three consequences, and
the third is the one worth stating out loud:
- **A page that declares the line is served with nothing added to it.** Not
"one small injection" — nothing. The body is what the author wrote.
- **A page that does not declare it gets that one line appended at the end.**
A substring test and a concatenation; no pattern, nothing prepended, no
constraint to thread.
- **Both of `wrap_verbatim_html`'s hard constraints stop existing** rather than
being satisfied more carefully. You cannot displace a doctype you never move,
and you cannot push a charset meta out of the detection window by appending.
## What crosses the seam
`GET /b/{name}/embed.json` — server-rendered fragments, and nothing embed.js has
to decide for itself:
```json
{
"booth": "dfa-concepts",
"home": "/",
"favicon": "data:image/svg+xml,…",
"open": ["dfa"],
"marks": [
{
"id": "dfa",
"error": null,
"whole": "<div class=\"bk-ask\" …>",
"submit": "<div class=\"bk-ask\" …>",
"questions": [
{"key": "logo", "html": "<div …>"},
{"key": "display", "html": "<div …>"}
]
}
]
}
```
Every HTML string comes from the `_ask_inline.html` macros that render the same
fragments today. `open` is `open_marks(picks)` — computed once, server-side, and
never re-derived in JavaScript.
**A payload whose `.marks.json` could not be read has a stated shape**, because
an arm asked and the first draft did not say: `marks` is `[]`, `open` is `[]`,
`booth` / `home` / `favicon` are present as normal, and top-level `error` and
`detail` carry the verdict. Status stays 200, copied from `/marks.json` — a
pinned status code is a promise to remote clients, and the information goes in
the body instead. The nav mounts; nothing else does. A per-mark `error` is a
different thing: that is ONE unreadable pick inside a file that read fine.
**`questions` is a LIST, and `key` may be `null`.** This is not a style choice.
A single-question pick normalizes to `questions: [{"key": None, …}]`
(`asks.normalize_ask`), so a JSON object keyed by question key would serialize
that key as the string `"null"` — inventing a name that collides with a real key
and that JavaScript would have to translate back. A list also carries declaration
order in the format itself rather than leaning on object-key insertion order.
A `null` key matches no anchor spec, which is correct and is exactly what
`place` does today: a single-question pick is addressed as a whole or not at all.
Found by the seam review; see SR-2.
## How the script learns which booth it is on
**The find of the contract-review round, three arms independently**, and the one
gap that made the rest unimplementable as first written: the declared line is
byte-identical for every booth, the payload endpoint needs `{name}`, and the
name arrives *inside* the response the fetch needs the name to make.
The rule, stated once:
> **The booth name is the second path segment of the page's own address.** A
> verbatim report is served at `/b/<name>/`, so `embed.js` reads
> `location.pathname`, takes segment 2, and `decodeURIComponent`s it. A page
> whose address is not `/b/<name>/...` mounts nothing and returns quietly.
>
> **Override:** a `<script data-booth="...">` attribute wins if present. The
> Booth never writes one — the appended tag is exactly the documented line — but
> an author embedding a report elsewhere needs a way to say so, and one optional
> attribute is cheaper than a second endpoint.
This makes the URL grammar part of the public seam, which is the honest reading:
an author who writes the line is relying on where the Booth serves them, and
that should be written down rather than inferred.
## The placement algorithm
The same algorithm `inject_asks` runs today, expressed against a DOM instead of
a string. It is written out here because it is the part that moves languages,
and a reviewer has to be able to check the two against each other.
```
placed : Map<markId, Set<key | WHOLE>>
submitted : Set<markId>
1. every element matching
[data-booth-mark], [data-booth-ask] -- in document order
spec -> (id, key?) by splitting on the first ":"
mark unknown -> leave the element ALONE (a typo stays visible)
key absent -> mount whole; placed[id] += WHOLE; submitted += id
key names no question -> leave the element ALONE
key present -> mount question; placed[id] += key
2. every element matching
[data-booth-mark-submit], [data-booth-ask-submit]
mark unknown -> leave alone
otherwise -> mount submit; placed[id] ||= {}; submitted += id
3. tail, appended to <body> in payload order. THE ARROWS ARE EXCLUSIVE, NOT
CUMULATIVE -- first match wins and the mark is done. An arm read them as
four independent tests, under which one unplaced mark would mount its whole
form AND every question AND a submit block; the notation allowed it and the
prose did not forbid it:
if id not in placed: append whole; NEXT MARK
elif mark.error: append nothing; NEXT MARK
else:
if WHOLE not in placed[id]: append every question not in placed[id]
if id not in submitted: append submit (scattered, still submittable)
4. re-associate: for every control carrying form="…", remove and re-set the
attribute, so its form owner is resolved after all fragments are in place.
5. chip: if `open` is non-empty, link it to the FIRST element in document order
whose id is EXACTLY `bk-ask-<open[0]>` or begins `bk-ask-<open[0]>-`.
A bare prefix match would send the chip to `bk-ask-batch2-r1` for the mark
`batch`, or to an author's own element -- flagged by a cold arm, and the
trailing hyphen is what rules it out.
Two more rules the first draft left to the selector rather than stating:
- **An element carrying BOTH `data-booth-mark` and `data-booth-ask` uses the
canonical one.** The alias exists for reports written before the rename, not
to double a mount.
- **A submit anchor's spec is its stem; any `:key` on it is IGNORED.** There is
no per-question submit block — one pick has one `<form>`, which is the whole
reason the `form=` binding exists.
```
**`mount` is `el.insertAdjacentHTML('beforeend', frag)`** — the anchor element
and its existing contents survive; the fragment lands inside. See the assumption
on anchor-fill for why this is a deliberate change and not an accident.
**Step 4 is measured, not assumed.** Chromium 151 resolves a control's form owner
correctly even when the control is inserted before its `<form>`: a probe run
2026-09-22 (N=3 per condition, with a form-first positive control and a
points-at-nothing negative control) returned `F, F, F` for control-first and
`null, null, null` for the negative. So the pass is *not* needed in Chromium.
It is three lines, it costs nothing, and the sensitivity floor of that probe is
**one engine** — the operator's own browser was not measured. The failure it
guards against is a form the operator fills in whose controls reach no form,
so the button does nothing.
**Step 5 deletes an element.** Today `inject_asks` injects `<a id="bk-ask-<id>-top">`
before the first fragment of each pick so the chip has somewhere to jump. The
fragments already carry ids; document order in a live DOM is directly queryable;
the extra anchor is not needed.
## Invariants
Each is falsifiable by a change that a test must catch going red. The
*Falsifiable:* line names that change — not a test that merely mentions the
invariant. (Five of seven U4 falsifiers were vacuous; see
`persistent-memory.d/2026-09-22-vacuous-falsifiers.md`.)
**INV-1 — A page that declares the seam is served BYTE FOR BYTE.**
The response body for a verbatim booth whose `index.html` contains
`src="/_booth/embed.js"` (either quote style) is exactly the bytes on disk.
⚠ **Bytes, not text, and that is a correction.** The first implementation read
with `read_text()`, which opens in universal-newline mode: a CRLF report came
back LF, and `errors="replace"` turned any non-UTF-8 byte into U+FFFD. A
declaring page was NOT served as its author wrote it — the headline promise —
and the test could not see it, because its fixture was LF-only ASCII. The file
is decoded only to ask whether it declares the seam; what goes on the wire is
the original bytes. A page that only mentions the path is NOT declaring it — see the
conditional-append assumption for which way that has to fail.
*Falsifiable:* append anything — a chip, a comment, a newline — to the declaring
branch's response and `test_declaring_page_is_served_untouched` fails on a
whole-body equality, not on a substring absence.
**INV-2 — A page that does not declare the seam, AND IS UNDER `WRAP_MAX_BYTES`,
is mutated exactly once, at the end.** The response is the source BYTES plus
`EMBED_SCRIPT_TAG`'s bytes and nothing else, with the source a byte-exact
prefix of it.
⚠ **The size cap is an explicit exception, not an oversight** — two cold arms
read the invariant's universal wording against the raw-`FileResponse`
assumption and found them prescribing different responses for the same page. An
over-cap page is mutated ZERO times and loses its chrome, exactly as it did
before this unit.
*Falsifiable:* insert the tag before `</head>` instead of appending, or add the
favicon link back, and `test_undeclared_page_gains_only_the_tag` fails the
prefix assertion. The exception has its own test,
`test_an_oversize_verbatim_page_is_served_raw`, which fails if the append starts
firing above the cap.
**INV-3 — No regular expression is applied to author HTML.**
The verbatim branch of `booth_view` performs two `in` tests and one `+`.
⚠ **The first draft of this falsifier was VACUOUS and three arms caught it.**
It name-matched the six deleted patterns, so reintroducing the same regex under
a new name — `_TAIL_RE`, applied in the verbatim branch — left the test green,
on this contract's central promise. Worse, this repo's own vacuity pass missed
it, because the mutation it tried was the named one: **a vacuity pass is only as
good as the mutation it picks, and picking the one the contract names is how it
agrees with itself.**
*Falsifiable:* `test_no_regex_touches_author_html` walks the AST of
`booth/app.py` and asserts the module performs **exactly one** regex operation
— `ask_form_id`'s `re.sub` over a mark id, which is not a page — plus that
`booth/inline.py` does not exist. Any regex anywhere in the module, under any
name, fails it. Verified by mutation: a renamed `_TAIL_RE.sub` in
`embed_verbatim` goes red, and the unmutated control stays green.
**INV-4 — The payload is the only source of what a mark says.**
embed.js never decides openness, order, or content. `open` comes from
`open_marks`; `marks` order is `marks_for` order; `questions` order is
declaration order.
*Falsifiable:* the claim ranges over three things and so does the check.
**Openness:** have embed.js derive open marks from a `bk-done` class and
`test_the_chip_count_comes_from_the_server` fails on a half-answered pick, which
`open_marks` calls open and the rendered state does not. **Order:** reverse the
tail iteration and `test_the_tail_follows_payload_order` fails. **Content:** the
fragments are strings the page never authors, which
`test_every_piece_the_author_can_place_is_offered` pins on the server side.
**INV-5 — `/_booth/embed.js` is read once at startup.**
*Falsifiable:* change the route to `read_text()` per request and
`test_embed_js_does_not_hot_reload_from_disk` fails — it mutates the file on
disk after the app is built and asserts the served body is unchanged.
**INV-6 — Every ordered collection this unit renders has a stated rule.**
Anchors are visited in **document order** (`querySelectorAll`). The tail is
appended in **payload order**, which is `(created, id)` — the rule `marks_for`
and `hold_read` both sort by, stated here as the rule rather than as one
function's name. Questions
within a mark are in **declaration order**. The chip targets the **first element
in document order** whose id starts with the open mark's prefix.
*Falsifiable:* sort the tail by anything else — id, key, insertion — and
`test_tail_order_is_payload_order` fails against a fixture whose creation order
and id order disagree.
**INV-7 — Every question of every READABLE pick reaches the document, on a
page that runs the script.** Either placed at an anchor or appended, and every
pick with a placed question has a submit block.
⚠ **Two qualifiers, both added because arms read the first wording literally and
were right.** *Readable*: a pick carrying `error` has no questions to place —
`marks._hydrate` gives it an empty list — so the tail mounts its broken-ask box
and stops, and an unqualified "every pick" would have demanded placement the
algorithm forbids in exactly the damaged-data case the leniency posture exists
for. *Reaches the document*, not "is visible": the Booth cannot police an author
who hides their own anchor, and a guarantee that claimed to would be unenforceable
rather than strict.
*Falsifiable:* drop the "append the questions the author did not place" branch
and `test_partially_marked_page_still_shows_every_question` fails in the browser
with 2 of 4 radio groups present.
## Out of scope (deferred or never)
Named so a reviewer does not read them as drift.
- **The gallery page's chrome.** Only a booth's own `index.html` is served
verbatim; every other surface is server-rendered end to end and has no seam
problem. `/_booth/embed.js` is not loaded there and is not meant to be.
- **Re-rendering an ask in JavaScript.** The payload carries server-rendered
HTML and embed.js places it. A JS renderer would be a second renderer of one
truth — the bug the repo's one-resolver rule exists to stop.
- **A no-JavaScript fallback on the verbatim path.** The operator's 2026-09-21
ruling accepts the script dependency. The never-invisible guarantee degrades
to surfaces that need no script (the index card's badge, `/b/<name>/marks`),
and that is the stated cost, not an oversight to be fixed here.
- **The HTML-comment placeholders** `<!-- booth:ask … -->`. Zero users across
all 21 live booths; dropped rather than ported. A page that used one falls
back to the append path, so its ask still renders.
- **`_ask_inline.html`'s dead `standalone=False` macro parameter.** No caller
has passed `True` since U2 turned the standalone asks page into a 308.
Deleting it is tidy-up and changes a macro signature for no behavioural gain.
- **`base.html`'s literal duplicate of the favicon data URI.** It predates this
unit. The payload reads `FAVICON_HREF`, so this unit adds no third copy; it
does not remove the second.
- **`WRAP_MAX_BYTES` and its raw-serve fallback.** Unchanged at 8 MiB. A booth
over the cap loses its chrome exactly as it did before — no regression, and
the constant keeps its existing test.
- **`GET /b/<name>/asks`.** Already a 308 into `/marks` since U2. Left alone:
the URL is in the operator's history and in landed reports.
- **Pushing, and the version bump tier.** Minor needs the operator's approval.
## Slices
| # | slice | red→green on |
|---|---|---|
| 1 | `GET /b/{name}/embed.json` — payload shape, order, leniency, no view recorded | payload tests; existing 410 stay green |
| 2 | `GET /_booth/embed.js` — served from a startup read, ETag, no hot reload | INV-5 |
| 3 | the verbatim branch rewritten; `inject_asks` and `wrap_verbatim_html` deleted | INV-1, INV-2, INV-3 |
| 4 | `booth/static/embed.js` — nav, favicon, styles, no marks yet | browser: chip present, icon set, declaring page untouched |
| 5 | placement: anchors, tail, submit, re-association | browser: INV-4, INV-6, INV-7; the live `dfa-concepts` and `sindra-voice-1` shapes as fixtures |
| 6 | delete `inline.py`; retire the six tests that test the deleted mechanism; docs | suite green, IA doc and ROADMAP updated |
## Seam review
The sibling-aware pass, run in-session against the real module surfaces rather
than against the sibling contracts' prose. `/heid-contract-review` is
artifact-only by design and structurally cannot see `booth/marks.py`, so this is
the only gate that can check what the contract borrows from it.
| # | finding | disposition |
|---|---|---|
| **SR-1** | The order invariant named `marks_for`'s ordering. The route actually reads through `hold_read` — one read answering both "what is here" and "can it be read", per the TOCTOU lesson — and only falls back to `marks_for` on the error path. Both sort `(created, id)`, so the contract was not wrong, but it named a function where it meant a rule. | **Amended.** INV-6 states the rule. The route's reader is named in the payload section. |
| **SR-2** | **The payload shape was wrong.** `questions` as a JSON object keyed by question key breaks on a single-question pick, whose only question has `key: None` (`asks.normalize_ask`, the `multi: False` branch) — `json.dumps` writes that key as the string `"null"`. Every one-question ask in the fleet hits it, including the live `sindra-voice-1`. | **Scope fix.** `questions` is a list of `{key, html}`; `key` is nullable; declaration order is carried by the format. `booth.asks.normalize_ask` added to `depends_on`. |
| **SR-3** | `inline.form_id` was inside the module the contract deletes entirely, but it is not placement machinery — it builds the shared `<form>` id the question fragments bind to with `form=`. Deleting the module as written would delete the fragments' ability to submit. | **Scope miss.** `form_id` moves to `booth/app.py`; `touches` says so. |
| **SR-4** | A FLAG mark's id is literally `flag:<target>` (`marks.flag_id`) — it contains the separator the anchor spec splits on. It never reaches the payload only because the payload filters `shape == "pick"`, and pick ids are `valid_stem`-checked (no colon). | **No change, stated.** The filter is load-bearing, not incidental; a later widening of the payload to all shapes would break the split rule silently. |
| **SR-5** | `_ask_inline.html`'s `question(a, q, form_id, name_url, standalone=False)` has had no caller passing `standalone=True` since the standalone asks page became a 308 in U2. Dead parameter on a macro this unit edits. | **Out of scope, noted.** Deleting it is tidy-up, not this unit's work, and it changes a macro signature for no behavioural gain. |
## Contract review — the cold panel
`/heid-contract-review`, four arms, dispatched `01M351WKV666D681SSRNY7D7X6`.
Triaged per the cross-frontier discipline: adopted on merits, not on authority.
| # | finding | arms | disposition |
|---|---|---|---|
| **CR-1** | **The seam never tells `embed.js` which booth it is on.** The declared line is byte-identical for every booth, the payload endpoint needs `{name}`, and the name arrives inside the response the fetch needs it to make. Every other section depends on this unstated hop. | 3 of 4, independently | **Genuine add, and the round's headline.** The code already derived it from `location.pathname`; the CONTRACT did not say so, which makes a "public API" whose discovery mechanism is unspecified not fully one. New section: *How the script learns which booth it is on*. No code change. |
| **CR-2** | **INV-3's falsifier was vacuous** — it name-matched the six deleted patterns, so a renamed regex applied to the page body kept it green, on this contract's central promise. | 3 of 4 | **Genuine add, and a CODE-side fix.** The test now asserts `booth/app.py` performs exactly one regex operation anywhere in the module. Verified by mutation in both directions. The lesson is sharper than the fix: **this repo's own vacuity pass missed it because it tried the mutation the contract named** — a pass that picks the named mutation agrees with itself. |
| **CR-3** | **Declaration by bare substring fails in the dangerous direction.** A report that merely mentions `/_booth/embed.js` — a code sample, a comment — counted as declaring it and was served with no chrome at all, silently. | 3 of 4 | **Genuine add, CODE-side.** Detection now requires `src="…"` (either quote style), which fails toward a harmless duplicate tag instead. New test covers prose, comment and `?v=2` spellings. |
| **CR-4** | **INV-2 and the size cap prescribe different responses** for an over-cap non-declaring page, and neither the invariant's wording nor a named falsifier carved the exception. | 2 of 4 | **Genuine add.** INV-2 now states the cap as an explicit exception and names the test that holds it. Code and test were already right. |
| **CR-5** | **The tail's four arrows read as independent tests**, under which one unplaced mark mounts its whole form AND every question AND a submit block. | 1 | **Genuine add.** The notation allowed it and the prose did not forbid it. The block is now explicit if/elif/else. Code was already exclusive. |
| **CR-6** | **INV-7 quantified over picks the algorithm filters** (errored picks) and over "visible", which placement cannot guarantee. | 2 of 4 | **Genuine add, wording.** INV-7 is now scoped to READABLE picks and claims *reaches the document*, not *is visible*. |
| **CR-7** | The chip's prefix rule can select `bk-ask-batch2-r1` for mark `batch`, or an author's own element. | 1 | **Sharpening.** The code always matched exactly-or-hyphen; the contract said "starts with". Wording fixed, and `test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix` now holds it. |
| **CR-8** | Precedence undefined when one element carries both attribute spellings; submit-anchor key handling unstated. | 1 | **Sharpening.** Both stated; `test_the_canonical_attribute_wins_when_both_are_present` added. |
| **CR-9** | The damaged-`.marks.json` payload shape was never stated — per-mark `error` was the only error shown. | 1 | **Genuine add, wording.** Stated in *What crosses the seam*. Test already existed. |
| **CR-10** | "INV-1" names two different obligations — this contract's untouched-page rule, and the repo's one-resolver rule the assumptions cite. | 1 | **Genuine add, wording.** The assumptions now name CLAUDE.md invariant 3 explicitly. A real collision: the local falsifier goes red on an added newline and stays green if embed.js becomes a second renderer. |
| **CR-11** | INV-4's falsifier covered openness while the invariant claimed openness, order AND content. | 1 | **Sharpening.** The falsifier now names a test per clause. |
| **CR-12** | `html.questions` keyed by question name vs the top-level `questions` list — which is authoritative? And INV-4 naming `marks_for`'s order while INV-6 fixed `(created, id)`. | 2 | **Settled before the reply landed.** The in-session seam review collapsed both (SR-1, SR-2) while the panel was in flight. Independent convergence on the same two spots — worth recording, not re-fixing. |
**One arm's finding not adopted**, and the reason: that a question mounted into
an author-hidden anchor is still invisible. True, and out of reach — the Booth
cannot police an author hiding their own markup. Answered by narrowing INV-7's
claim rather than by chasing actual visibility (CR-6).
**Methodology note the panel raised on its own**, relayed by heid: 5 of 8 arms
across two unrelated callers the same evening independently proposed promoting
the end-to-end seam-walk from a conditional deliverable to a mandatory one.
CR-1 is a direct product of that exercise. Recorded here as evidence; the skill
change is the operator's call, not this repo's.
## Bug hunt — the cold panel
`/heid-bug-hunt`, four arms, artifact-only over the merge-base diff, dispatched
`01M352TPCSN52G6NGJ07T5WSGY`. ⚠ **The snapshot predates the contract-review
fixes**, so two of its findings were already closed when the reply landed; the
arms flagged the staleness themselves.
| # | finding | arms | disposition |
|---|---|---|---|
| **BH-1** | **A declaring page was NOT served as written.** `read_text()` opens in universal-newline mode, so a CRLF report came back LF, and `errors="replace"` replaced any non-UTF-8 byte. The headline promise, broken by the read itself — and invisible to a test whose fixture is LF-only ASCII. | 1 | **Genuine add, and the best finding of the round.** The verbatim branch reads and serves BYTES; the decoded copy answers only "does it declare?". INV-1 and INV-2 now state the byte-level promise, with a CRLF-plus-invalid-byte fixture. |
| **BH-2** | **A submit anchor inside the author's own `<form>` loses ours** — the HTML parser drops a nested form outright. Every control's `form=` then points at nothing, and the code recorded the pick as submitted so the tail added no fallback. The operator fills it in and the button does nothing. | 1 | **Genuine add.** A submit anchor counts as submitted only if the form actually survived (`hasForm`); otherwise the tail supplies one at body level, where no form encloses it. |
| **BH-3** | **A broken pick's diagnostic never rendered from a submit-only anchor.** An errored pick's `submit` is empty; mounting that and marking it placed made the tail skip it, so the "broken ask" box vanished from the one surface built to show it. | 3 of 4 | **Genuine add.** A submit anchor for an errored pick is left alone, exactly as an anchor naming no mark is, and the tail mounts the diagnostic. |
| **BH-4** | **An author's own element can hijack the chip.** `<section id="bk-ask-winner-background">` satisfies any id-prefix rule — the hyphen boundary from CR-7 included. | 4 of 4 | **Genuine add, and it supersedes CR-7's fix.** The chip now searches only the elements THIS SCRIPT MOUNTED, which is the identity the deleted `bk-ask-<id>-top` anchor used to guarantee, and takes the earliest of those by `compareDocumentPosition`. |
| **BH-5** | **No error boundary around fragment rendering.** A `.marks.json` that is well-formed JSON with a wrong-shaped `answer` hydrates with no error and then raises in the macro. | 1, `needs-repro` | **Genuine add — reproduced before building for it.** `_safe_fragments` returns a per-mark error record, the same leniency `_hydrate_safe` applies one layer down. ⚠ **The gallery and marks pages still 500 on it, and that is PRE-EXISTING** — measured at `42ea67f`. Out of scope here and recorded rather than quietly widened: `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`. |
| **BH-6** | Prototype pollution in the placement maps (`toString` as a mark id, `constructor` as a question key). | 1 | **Already fixed this round** as CR-13, from the code-review panel. Two panels, two lenses, the same defect independently — the strongest signal of the evening that the lenses are not redundant. |
| **BH-7** | Bare-substring declaration suppresses the chrome. | 4 of 4 | **Already fixed** as CR-3, before the reply landed. |
**One correction the panel made to this repo's own prose, adopted:** several
comments claimed a multi-question pick POSTs a 400 unless every question is
answered. It does not — `test_empty_submission_is_refused_with_400` refuses a
WHOLLY EMPTY submission, and a partial answer is accepted and recorded on
purpose. The real reason an unplaced question must still be appended is simpler
and was being obscured: **a question that never reaches the page cannot be
answered at all.** Fixed in `embed.js`, the browser tests and this contract.
**Not adopted:** the bundle's framing called the service Flask. It is FastAPI;
the arm noticed and declined to reason from it, which is the right handling.
## Vacuity pass — final
21 mutations, each drawn from an invariant's CLAIM rather than its falsifier's
example, each run against its named test, plus an unmutated control run.
**21/21 caught, control green.**
The pass earned its place three times over and none of them was the first run:
1. It reported **7/7** before the contract panel, which then showed INV-3 was
vacuous — because the mutation applied was the one the contract named.
2. Re-run **against that fix**, it found the fix's own hole (an aliased
`import re as _r`).
3. Re-run after the bug-hunt fixes, it reported seven **MUTATION-MISS** rows —
its loud-failure mode, firing correctly because the fixes had moved the code
out from under stale mutations — and then one genuine **VACUOUS**: the
sibling-mark chip test had its fixture arranged so the right answer was also
the first answer. Rewritten so the sibling comes first, which is the only
arrangement that can tell the two implementations apart.
+29 -9
View File
@@ -206,21 +206,41 @@ a real DOM API. Marks land at `data-booth-mark="<id>"` anchors, which keeps the
page*. If the line is absent, the Booth injects it at **one** insertion point, so
every existing verbatim booth keeps working untouched.
**What this deletes**, and this is the whole point of the decision:
**What this deleted** — landed as U3, 2026-09-22:
- `booth/inline.py` — 114 lines of placeholder DSL, entirely
- `booth/inline.py` — 119 lines of placeholder DSL, entirely. One line survived:
`form_id`, which builds the shared `<form>` id scattered question groups bind
to, and which moved to `app.py` beside the route that renders them.
- `wrap_verbatim_html` and its six regexes against arbitrary HTML
(`_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`, `_BODY_CLOSE_RE`,
`_HTML_CLOSE_RE`, `_ICON_RE`) and the doctype/charset-ordering constraints
they are threading
- `_BACK_CHIP`, `asks_chip` — two floating chips positioned by guessed offsets
- `GET /b/<name>/asks` — the standalone page that existed only because a verbatim
booth could not show its own asks
`_HTML_CLOSE_RE`, `_ICON_RE`) **and both of the constraints they were
threading.** Not satisfied more carefully — gone: nothing can displace a
leading doctype into quirks mode and nothing can push the charset `<meta>`
out of its detection window, because the Booth only ever APPENDS now.
- `_BACK_CHIP`, `asks_chip` — two floating chips positioned by guessed offsets.
embed.js builds both in the DOM.
- the `styles()` macro. The scoped `.bk-ask-*` rules live in embed.js next to
the code that mounts them, emitted once by construction instead of by a
seen-set.
- `GET /b/<name>/asks` was already a 308 into `/marks` by U2; this unit left it
there. The standalone page it named is gone, but the URL is in the operator's
history and in landed reports, and a dead link teaches nothing.
Regex-injecting into arbitrary author HTML is the single most fragile thing in
the service, and it is load-bearing for the operator's most important workflow.
**What replaced them is a substring test and a `+`.** `if EMBED_SRC not in
html: html += EMBED_SCRIPT_TAG`. A page that declares the line is served with
nothing added to it at all.
Regex-injecting into arbitrary author HTML was the single most fragile thing in
the service, and it was load-bearing for the operator's most important workflow.
A declared seam costs the author one line and removes the whole class.
**What it cost, stated because it is real.** The verbatim path used to work with
no JavaScript: an ask rendered server-side and submitted through a plain form.
It now needs the script. The guarantee that an ask is never invisible survives
in a weaker and still-true form through surfaces that need no script — the index
card's open-mark badge, and `/b/<name>/marks`, which renders every mark
server-side.
---
# Navigation
@@ -0,0 +1,74 @@
# A wrong-shaped answer 500s the gallery and the marks page — PRE-EXISTING, NOT U3
_2026-09-22 · booth_
**Found by the U3 bug-hunt panel, measured against `42ea67f` — the commit
BEFORE U3 — so it is not this unit's doing and was not fixed by it.** U3's own
surface is guarded; these two are not.
## The defect
`.marks.json` that is **well-formed JSON with a wrong-shaped value** passes
every reader and then raises in the renderer:
```json
{"id": "batch", "shape": "pick", "answer": {"answers": [], "notes": ""}}
```
`_hydrate` only checks `isinstance(entry.get("answer"), dict)` — it never
validates `answer["answers"]`. So `marks_for` and `hold_read` both return the
mark with `error = None` and **no read error at all**, and then
`_ask_inline.html` does `a.answer.answers.get(q.key)`, Jinja asks a list for
`.get`, and it raises `UndefinedError`.
Measured, not reasoned:
PRE-U3 (42ea67f) gallery page: 500
PRE-U3 (42ea67f) marks page: 500
PRE-U3 (42ea67f) index: 200
The index survives because it never renders a fragment.
## Why it matters more than it looks
This is **the v0.2.2 shape with a different trigger**. That outage was a
`.marks.json` that could not be PARSED; the reader was made lenient and the
index stopped 500ing. This one parses perfectly and breaks one layer further in,
at render time, where no leniency exists — so the lesson "one damaged file must
cost its own tile, not the page" is only half-implemented. `read_error` is
answering a narrower question than every caller assumes.
## What U3 did and did not do
U3 added `_safe_fragments` around `_pick_fragments`, so `/b/<name>/embed.json`
returns a per-mark `error` record instead of a 500 — the same posture
`_hydrate_safe` takes one layer down. That protects **the verbatim path only**.
`booth.html` and `marks.html` call the same macros with no such guard. Left
alone deliberately: the gallery is named out of scope in the U3 contract, and
widening a unit mid-flight to cover a pre-existing defect in a surface it never
touched is the scope drift the roadmap gate exists to stop.
## The design question it deserves, when it is picked up
Not "wrap the other two call sites" — that is the third copy of one guard. The
real question is **where the boundary belongs**:
1. **In `_hydrate`**, validating the answer shape so a wrong-shaped answer
becomes `error` at hydration and every surface inherits the fix. Cleanest,
and consistent with declarations already being normalized on read — but it
widens what `error` means.
2. **At each render site**, per-mark, as U3 did. Honest and local; three copies.
3. **In the template**, defensively. Cheapest and worst — it hides the fact
that anything is wrong.
(1) is the shape the rest of this module already argues for: one predicate,
one place. Worth an operator decision because it changes what a `Mark` can be.
⚠ Reproduce with the fixture in
`tests/test_embed.py::test_a_wrongly_shaped_answer_costs_its_pick_not_the_report`,
whose closing comment points back here.
Related: [[2026-09-21-marks-write-wiped-judgment]],
[[2026-09-22-lenient-reader-blast-radius]],
[[2026-09-22-u3-declared-embed-seam-landed]].
@@ -0,0 +1,54 @@
# No fleetwide notice for U4 — and what that does to the prediction
_2026-09-22 · booth_
**Operator decision, 2026-09-22: do NOT tell the 17 consuming handles that
`keep` has stopped being the way to say "waiting on an answer".** No broadcast.
Same posture he took on U5, and the same instrument: adoption gets told apart
from design because nobody was primed.
⚠ **THIS CHANGES HOW THE 2026-10-06 RE-COUNT MUST BE READ, and a session that
misses this will draw the wrong conclusion from a true number.**
U4 has two halves and they do NOT have the same adoption cost:
- **The hold rides for free.** A session that runs `booth ask` gets its booth
held with no knowledge of anything. The operator answering releases it. No
peer has to learn a thing for the mechanism to work.
- **NOT PRESSING `keep` HAS TO BE LEARNED.** U4 makes the sentinel unnecessary
for the "not yet" case; it does not make it unavailable, and nothing stops a
habit. An agent that has always pressed `keep` while waiting will keep
pressing it.
**So a flat `.forever` rate on 2026-10-06 does NOT falsify the diagnosis.** It
is exactly what "the mechanism works and nobody was told" looks like — the U5
shape, one unit later. Reporting a null result without this caveat would retire
a correct diagnosis on the strength of an uncontrolled measurement.
**Use these instead, and report all three.** The raw rate stays as context, not
as the verdict:
1. **The overlap — booths with an open pick AND `.forever`.** Was **3** on
2026-09-22, which is the positive control for the whole diagnosis. It falls
only if peers learn; it is the *adoption* number.
comm -12 <(grep -l '"shape": "pick"' ~/booth-data/*/.marks.json | xargs -n1 dirname | sort) \
<(dirname ~/booth-data/*/.forever | sort) | wc -l
2. **Did the hold ever bind?** Count booths that were held past their TTL and
therefore survived a sweep they would otherwise have lost. This needs no
peer to change anything, so it is the honest test of whether the mechanism
is load-bearing at all. **If it is ZERO over a fortnight, the diagnosis was
wrong about VOLUME** — the "not yet" case is rarer than the sentinel rate
suggested — and that is a real finding. The sweeper logs what it wipes;
nothing yet logs what it spares, so **this counter does not exist and would
have to be added before it can be read.** Say so rather than guessing.
3. **The raw `.forever` rate** — 17 of 24 (70%) on 2026-09-22. Context only,
now that the no-notice decision has made it a measurement of habit rather
than of need.
⚠ **Sensitivity floor, stated because a bare "no effect" is unfalsifiable:**
only **4 of 24** booths carried marks at all on 2026-09-22. The hold cannot
bind on a booth with no marks, so at that population the mechanism can touch at
most a sixth of the fleet, and an effect smaller than one or two booths is not
resolvable by any of these counts. Related: `[[2026-09-22-forever-had-a-live-positive-control]]`.
@@ -0,0 +1,87 @@
# A vacuity pass that tries the contract's own mutation agrees with itself
_2026-09-22 · booth_
The contract-time **vacuity pass** — for each invariant, name a change that
defeats it and check the named test goes red — was proposed independently by
Regin and Kimi on U4's paraphrase round, and U4's own code-review panel then
showed **five of seven** U4 falsifiers were vacuous: a green test *cited* by an
`INV` rather than a test that would *fail* if the invariant broke. See
[[2026-09-22-vacuous-falsifiers]].
U3 ran the pass as a real instrument rather than a promise. Script in the
session scratchpad; for each invariant it applies the mutation the contract's
*Falsifiable:* line names, runs the single named test, and asserts a **non-zero**
exit, restoring the file in a `finally` either way.
| INV | mutation applied | verdict |
|---|---|---|
| 1 declaring page untouched | append `<!-- booth -->` to the declaring branch | FALSIFIED |
| 2 appended, never inserted | insert the tag before `<title>` instead | FALSIFIED |
| 3 no regex on author HTML | re-declare `_ICON_RE` in `app.py` | FALSIFIED |
| 4 openness is the server's | have `embed.js` derive open from `bk-done` | FALSIFIED |
| 5 embed.js read once | `read_text()` per request in the route | FALSIFIED |
| 6 tail in payload order | iterate the marks list backwards | FALSIFIED |
| 7 unplaced questions appended | short-circuit the append branch to `if (false)` | FALSIFIED |
**7/7**, and — the part that makes it a measurement rather than a ritual — an
**unmutated control run** confirming all seven named tests are green when
nothing is broken. Without that control, a script whose mutation silently failed
to apply (the text not found, the wrong file) reports the same clean-looking
table. The script halts with `MUTATION-MISS` if its target string is absent,
for exactly that reason.
## Why it is worth the ten minutes
Three of the seven falsifiers are in `embed.js`, which the Python suite cannot
see at all. INV-4, INV-6 and INV-7 are held **only** by browser tests, and
"there is a browser test named after this invariant" is precisely the kind of
claim that feels like coverage and can be empty. Two of those three mutations
are one-token edits — `marks.length - 1` and `if (false)` — so the cost of
checking was minutes and the cost of being wrong was an invariant nobody was
holding.
**The general shape:** an instrument that cannot fail loudly will fail quietly.
Same family as the `(gasp)` tag-detection specimen in the global measurement
rule, and as the zsh word-splitting bug that shipped an empty heid bundle —
[[2026-09-22-four-paths-to-one-fail-open-delete]]. A clean result and a broken
method are indistinguishable from the output alone unless something in the
method is designed to go red.
## ⚠ AND THEN THE COLD PANEL SHOWED ONE OF THE SEVEN WAS VACUOUS ANYWAY
The table above is real and it was **not sufficient**. The `/heid-contract-review`
panel (`01M351WKV666D681SSRNY7D7X6`) — three of four arms, independently —
showed **INV-3's falsifier was vacuous**, on this contract's central promise, and
the pass above had passed it.
**Why the pass missed it.** INV-3 claims *no regular expression is applied to
author HTML*. The test name-matched the six DELETED patterns. The mutation the
pass applied was re-declaring `_ICON_RE` — **the pattern the contract named** —
which the name-match caught. The mutation the invariant actually forbids is a
regex under a *new* name (`_TAIL_RE.sub(...)` in the verbatim branch), and that
sailed through green.
> **The mutation has to come from the INVARIANT'S CLAIM, not from the
> FALSIFIER'S EXAMPLE.** A pass that applies the contract's own suggested
> mutation is testing the contract against itself, and it will agree.
**Then the fix had a hole too, and only a re-run found it.** The repaired test
asserts `booth/app.py` performs exactly one regex operation. Re-running the pass
*against the fix* showed an aliased `import re as _r` routes around the call
check under a name it does not know — still VACUOUS. Closed with an import-shape
assertion. **Run the pass on the repair, not only on the draft.**
Final state: **10/10 falsifiable**, control green, the two extra rows being the
panel's own findings turned into falsifiers.
## What this is evidence for
U4 measured the problem (five of seven vacuous). U3 measured a pass working
(7/7), then measured **the pass's own blind spot**, then measured the fix's
blind spot. All three belong in the case if the vacuity-pass proposal is ever
put to the operator as a `/heid*` skill amendment — and the second and third
are the parts that stop it being adopted as a ritual that always passes.
Related: [[2026-09-22-u3-declared-embed-seam-landed]],
[[2026-09-22-the-browser-became-a-test-surface]].
@@ -0,0 +1,76 @@
# The browser became a test surface, and the version bound is the foot-gun
_2026-09-22 · booth_
U3 moved load-bearing logic out of Python and into JavaScript: which fragment
lands at which anchor, what gets appended, and whether a `<form>` scattered down
a report still owns the controls pointing at it. **The Python suite is blind to
every one of those.** Shipping U3 with only payload-shape tests would have
deleted ~10 real tests and replaced them with assertions that cannot see the
thing the operator actually depends on.
So `tests/test_embed_browser.py` drives a real Chromium against a real uvicorn
on an ephemeral port. 12 tests. It found nothing on the first run — but the
probe that preceded it settled a design question no amount of spec-reading
would have.
## The probe, and why it had controls
**Question:** if a control carrying `form="F"` is inserted into the DOM *before*
`<form id="F">` exists, does it become that form's control? The HTML spec resets
form owner on insertion and on the `form` attribute changing — it does NOT list
"a matching form was inserted later". The U3 design inserts fragments in visual
order, so this happens routinely.
Four conditions, N=3 each, in Chromium 151 headless:
| condition | `input.form?.id` |
|---|---|
| A — form inserted first (**positive control**) | `F, F, F` |
| B — control inserted first (**the question**) | `F, F, F` |
| C — `form="NOPE"`, no such form (**negative control**) | `null, null, null` |
| D — remove and re-set the attribute (the proposed fix) | `F, F, F` |
The positive control proves the instrument can see association at all; the
negative proves it is not manufacturing it. Without both, B's answer means
nothing — that is the whole lesson of
[[2026-09-22-vacuous-falsifiers]] applied before the code instead of after.
**The answer is: Chromium re-resolves it, so the fix is unnecessary there.**
The fix shipped anyway. **Sensitivity floor: ONE ENGINE.** The operator's own
browser was not measured, the failure mode is a form that looks filled in and
POSTs a 400, and the guard is three lines. The measurement says "not needed
here"; it does not say "not needed".
## The foot-gun, which bit before the tests were written
Browsers are **box-wide** in `/opt/ms-playwright` with
`PLAYWRIGHT_BROWSERS_PATH` wired globally — there is no per-project
`playwright install`. Each playwright release pins **one** Chromium revision, and
a release wanting a revision the shared store lacks dies with:
Executable doesn't exist at /opt/ms-playwright/chromium_headless_shell-1243/…
That is not a missing-dependency error and it does not name the real problem.
The store had 1223 / 1228 / 1234; `playwright` 1.63 wanted 1243. The mapping:
1.60 -> 1223 1.61 -> 1228 1.62 -> 1234 1.63 -> 1243
Hence `playwright>=1.60,<1.63` in `pyproject.toml`, **with the upper bound as the
point** and the reason in a comment beside it. A bare `playwright` would break
the suite on the next resolve, opaquely.
## The hermeticity trade, and how it is paid
A browser layer makes the suite non-hermetic — it can go red for an environment
reason. `tests/test_embed_browser.py` therefore **skips, never fails**, when
playwright or a usable browser is missing (`pytest.importorskip`, plus a
`pytest.skip` on any launch failure). `pytest -q` stays green anywhere; the
browser layer is purely additive.
⚠ **The failure mode of that choice: if those 12 tests start SKIPPING on this
box, U3's placement logic is untested and the suite still says green.** If the
count drops from 431, check the skip reason before anything else — the pinned
bound has probably drifted past the shared store.
Related: [[2026-09-22-u3-declared-embed-seam-landed]].
@@ -0,0 +1,88 @@
# U3 landed — the page declares the seam, the Booth mounts into it
_2026-09-22 · booth_
**Ten regular expressions against author-written HTML are gone.** Six in
`wrap_verbatim_html` hunting for somewhere to hang a favicon and a chip, four in
`booth/inline.py` substituting rendered ask markup into the author's own tags.
What replaced them, in full:
```python
return html if declares_embed(html) else html + EMBED_SCRIPT_TAG
```
A substring test and a `+`. **Both of the old wrapper's hard constraints stopped
existing rather than being satisfied more carefully** — nothing can displace a
leading doctype into quirks mode and nothing can push the charset `<meta>` out
of its first-1024-byte window, because nothing in front of them ever moves.
## What moved where
| was | is |
|---|---|
| `wrap_verbatim_html` + 6 regexes | `embed_verbatim` — one `in`, one `+` |
| `booth/inline.py`, 119 lines | deleted; `form_id` survived into `app.py` |
| `_BACK_CHIP`, `asks_chip` | built in the DOM by `embed.js` |
| `inject_asks` | `GET /b/<name>/embed.json` + placement in `embed.js` |
| `_ask_inline.html`'s `styles()` | the CSS lives in `embed.js` |
| `FAVICON_LINK` string injection | `document.querySelector('link[rel~="icon"]')` |
**The fragments are still rendered by Jinja.** `embed.js` places what comes back
and never builds one — a second renderer in JavaScript would be the same bug
INV-1 exists to stop, in a new language. The payload also decides openness
(`open_marks`) and order, so the page has no opinion about either.
## The thing the contract got wrong, and the seam review caught
The payload first keyed `questions` by question key. **A single-question pick
normalizes to `questions: [{"key": None, …}]`** (`asks.normalize_ask`, the
`multi: False` branch), and `json.dumps` writes that key as the string `"null"`
— inventing a name that collides with a real key. Every one-question ask in the
fleet would have hit it, including the live `sindra-voice-1`. `questions` is a
LIST of `{key, html}` now; the key is nullable, and declaration order rides in
the format instead of leaning on object-key insertion order.
The cold contract panel could not have found this: it is a fact about
`booth/asks.py`, which an artifact-only reader never sees. Third time the seam
review has caught what the cold pass structurally cannot — see
[[2026-09-21-two-gates-are-complementary]].
## The live report that was already subtly broken
`dfa-concepts/index.html` writes `<div class="ask" data-booth-ask="dfa:logo">
<h3>The one asset that must survive</h3>`. `_EL_RE` matched the **opening tag**
and replaced it, so the author's `.ask` wrapper class vanished, the heading was
orphaned and the `</div>` went stray. Nobody filed a bug, because a page that is
95% right does not look broken.
`el.insertAdjacentHTML("beforeend", frag)` keeps the element and its contents
and puts the fragment inside. Verified live in a real browser: 5 author `.ask`
wrappers intact, 5 headings intact, 14 radios mounted inside them, zero console
errors. **The replacement is not just less fragile, it renders the operator's
own report more faithfully than the thing it replaced.**
## The cost, stated because it is real
The verbatim path used to work with **no JavaScript** — server-rendered ask, plain
form POST, HTML5 `form=` binding resolved at parse time. It needs the script now.
The operator's 2026-09-21 ruling accepts that; this entry records the consequence
so nobody meets it as a surprise. The never-invisible guarantee survives in a
weaker and still-true form through surfaces needing no script: the index card's
open-mark badge, and `/b/<name>/marks`.
## Anchor syntax
`data-booth-mark` is canonical (U2 made an ask one shape of mark).
`data-booth-ask` is a kept alias — 2 of the 4 live verbatim booths spell it that
way, in the operator's own reports, and the alias is one clause in one selector
string. The `<!-- booth:ask … -->` comment forms were **dropped, not ported**:
zero users across all 21 live booths, and a page that used one falls back to the
append path, so its ask still renders.
## Verification
431 tests (410 → 431). Live: all 21 booths 200, and each of the four verbatim
booths grew by exactly 46 bytes — `len(EMBED_SCRIPT_TAG)`, one append, nothing
else. Related: [[2026-09-22-the-browser-became-a-test-surface]],
[[2026-09-22-seven-of-seven-falsifiers]],
[[2026-09-21-regex-injecting-chrome]].
+60 -26
View File
@@ -21,28 +21,51 @@ _As of 2026-09-22:_
- **v1 is gated on seven units** in `ROADMAP.md`, dependency-ordered
**U1 → U2 → {U3, U4, U5} → U7**, with **U6 independent**.
- **U1, U2, U4 and U5 are landed.** U1 `ce598b3`; U2 `c7f9437` → `v0.2.0`,
`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.
- **U1, U2, U3, U4 and U5 are landed — the whole middle tier is closed.** U1
`ce598b3`; U2 `c7f9437` → `v0.2.0`, `5e41108` → `v0.2.1`, `026a1fc` →
`v0.2.2`; U5 `c015a91` + `95beede` → `v0.3.0`; U4 `c3a97c1` → `v0.4.0`.
**U3 landed 2026-09-22 and released as `v0.5.0`** — 444 tests green
(410 → 444), deployed and verified live, 23/23 booth pages 200, and each of
the four verbatim booths served at exactly +46 bytes, which is
`len(EMBED_SCRIPT_TAG)` — one append, nothing else. `87e2c53` is the unit,
`5c20e2f` the panel fixes. Operator approved the minor and authorised the
push on 2026-09-22; **this is the first push of this repo's history** — it was
26 commits ahead of `origin/main` before it, so every earlier tag reached the
remote at the same time.
- **U4 released as `v0.4.0`** (operator approved the minor on 2026-09-22).
`c3a97c1` is the unit; the release commit carries the pre-existing fixes the
bug-hunt panel surfaced in touched files. The tag waited for the last gate to
close, per the `v0.2.0` lesson — see Tried and abandoned.
- ⚠ **The 17 consuming handles 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
where verbatim-booth provenance was deferred to, and U4 added a fourth reason
to want it — a verbatim booth has no Booth-rendered header, so its lifetime
line lives only on the index card and the marks page.
- **No gate is outstanding.** All three ran on U4 and were folded in: the
`/heid-contract-review` panel (`01M34VX0SH23Y3VC92E7GM4S70`), the
`/heid-code-review` panel (`01M34WAFJC3RTERFYBBZJN1SVG`) and the
`/heid-bug-hunt` (`01M34Y2R0RAJRSN36Q8K4KAB36`). All loops closed with heid.
The U5 round's three are also closed (`01M340PNVRS21HPASZT38PXQPN`,
- ⚠ **The 17 consuming handles are NOT being told** that `keep` no longer means
"waiting on an answer" — operator decision, 2026-09-22, no broadcast. This is
deliberate and it CHANGES HOW THE 2026-10-06 RE-COUNT READS: the hold rides
for free, but not-pressing-`keep` has to be learned, so a flat `.forever` rate
does not falsify anything. Read its entry before measuring.
- **TWO UNITS LEFT TO v1, and they do not depend on each other.** U6 (benches,
independent, closes the 69% link-board rot) and U7 (navigation at 270 items,
which U3 just unblocked — its only dependency was {U3, U4, U5}). Which goes
next is the operator's call. ⚠ Before starting U7, read
`persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md`: every booth
that actually needs navigation is FLAT, so half its premise is already known
to be wrong.
- **U3's tier was MINOR and the operator approved it** (2026-09-22). The
argument that settled it, recorded because the tie-break rule says patch: a
capability arrived AND one left — the verbatim path gained a declared public
API (`<script src="/_booth/embed.js" defer>`) and lost no-JavaScript
operation. That asymmetry is what made it not a tie.
- **ALL FOUR U3 GATES ARE CLOSED.** In-session seam review (5 findings, SR-2 a
real payload-shape bug); `/heid-contract-review`
(`01M351WKV666D681SSRNY7D7X6`, 12 findings, 10 adopted, 2 already settled by
the seam review while it was in flight, 1 declined);
`/heid-code-review` (`01M352RXV1ZET566KV73C7TSB8`, 3 more vacuous falsifiers
+ the prototype-pollution bug); `/heid-bug-hunt`
(`01M352TPCSN52G6NGJ07T5WSGY`, 5 net-new, incl. the byte-exactness break).
**Seven of the adopted findings were CODE fixes, not wording** — the cold
gates were not ceremony on this unit. The **seam review ran in-session and is
folded in** — five findings as a table at the end of the U3 contract, and SR-2
was a real payload-shape bug the cold panel structurally could not see. U4's three and U5's three are all closed
(`01M34VX0SH23Y3VC92E7GM4S70`, `01M34WAFJC3RTERFYBBZJN1SVG`,
`01M34Y2R0RAJRSN36Q8K4KAB36`; `01M340PNVRS21HPASZT38PXQPN`,
`01M341E9XAPZEFBSPK9HPGAM0S`, `01M343SXX27Z47C3STXXRC7M42`).
- **Two dated predictions are pending and must not be forgotten.** U5's adoption
re-measure on **2026-09-29** (two counts, see its entry — already at 3 of 24
@@ -51,19 +74,30 @@ _As of 2026-09-22:_
U4's success criterion. ⚠ Only 4 booths carry marks at all, so the hold's live
blast radius is small and the prediction rests on both halves of U4 — see its
entry for what a null result would and would not mean.
- **Three methodology proposals from this session sit with the operator**, routed
by heid rather than decided unilaterally: reshaping the paraphrase gate toward
a drift-check for narrative-heavy contracts, a standing
"green-tests-prove-nothing" direction for the code-review gate, and regin's
table-vs-signature consistency pass. They are changes to the `/heid*` skills,
not to this repo.
- The booth set churns hard: 26 → 24 during this session as the sweeper ran.
Re-count rather than trusting any number written here.
- **FOUR methodology proposals sit with the operator, all UNTRACKED BY OPERATOR
CHOICE** (no issue, no ticket — they are `/heid*` skill changes, not this
repo's work, and are recorded here only so they are not lost). Three are from
the U5 round: reshaping the paraphrase gate toward a drift-check for
narrative-heavy contracts, a standing "green-tests-prove-nothing" direction
for the code-review gate, and regin's table-vs-signature consistency pass.
The fourth is new and is the one with evidence behind it: a **contract-time
VACUITY PASS** — for each invariant, name a change that defeats it and check
the test goes red. Regin and Kimi proposed it independently on the U4
paraphrase round; the code-review panel then showed five of seven U4
falsifiers were vacuous, and heid rates that the strongest single data point
for it so far. See `persistent-memory.d/2026-09-22-vacuous-falsifiers.md`.
- The booth set churns hard: 26 → 24 → 25 across the last two sessions as the
sweeper ran. Re-count rather than trusting any number written here.
## Recent decisions
- `[2026-09-22]` **U3 landed — the page declares the seam, the Booth mounts into it** — ten regexes against author HTML replaced by a substring test and a `+` → `persistent-memory.d/2026-09-22-u3-declared-embed-seam-landed.md`
- `[2026-09-22]` **A wrong-shaped answer 500s the gallery and the marks page** — PRE-EXISTING (measured at `42ea67f`), NOT U3; the v0.2.2 lesson is only half-implemented → `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`
- `[2026-09-22]` **The browser became a test surface** — READ BEFORE TOUCHING `playwright` IN pyproject; the pinned upper bound is the foot-gun, and these tests SKIP rather than fail → `persistent-memory.d/2026-09-22-the-browser-became-a-test-surface.md`
- `[2026-09-22]` **A vacuity pass that tries the contract's own mutation agrees with itself** — U3 ran one, reported 7/7, and a cold panel then showed one of the seven was vacuous; READ BEFORE WRITING A *Falsifiable:* LINE → `persistent-memory.d/2026-09-22-seven-of-seven-falsifiers.md`
- `[2026-09-22]` **U4 landed — lifetime is derived, not declared** — three states, viewing is activity, and no new arithmetic anywhere → `persistent-memory.d/2026-09-22-u4-derived-lifetime-landed.md`
- `[2026-09-22]` **The `.forever` diagnosis got a live positive control** — 3 of the 4 booths awaiting an answer were ALSO hand-pinned — RE-COUNT 2026-10-06 → `persistent-memory.d/2026-09-22-forever-had-a-live-positive-control.md`
- `[2026-09-22]` **No fleetwide notice for U4, and what that does to the prediction** — READ BEFORE THE 2026-10-06 RE-COUNT; a flat rate does not falsify the diagnosis → `persistent-memory.d/2026-09-22-no-notice-and-what-it-does-to-the-prediction.md`
- `[2026-09-22]` **Four independent paths to one fail-open delete** — the bug-hunt panel's class, and the zsh word-splitting trap that shipped an empty bundle → `persistent-memory.d/2026-09-22-four-paths-to-one-fail-open-delete.md`
- `[2026-09-22]` **Two reads of one file are not one read of one state** — a TOCTOU seam that composes two correct readers into a fail-open delete → `persistent-memory.d/2026-09-22-two-reads-are-not-one-state.md`
- `[2026-09-22]` **Five of seven INV falsifiers did not falsify anything** — read before writing a *Falsifiable:* line; a green test cited one rather than being one → `persistent-memory.d/2026-09-22-vacuous-falsifiers.md`
+10 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "booth"
version = "0.4.0"
version = "0.5.0"
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
requires-python = ">=3.11"
dependencies = [
@@ -15,6 +15,15 @@ dependencies = [
test = [
"pytest>=8.0",
"httpx>=0.27", # fastapi TestClient
# U3's embed seam moves placement into the browser, where no string
# assertion can see it. Browsers are NOT downloaded per project: they live
# box-wide in /opt/ms-playwright with PLAYWRIGHT_BROWSERS_PATH wired
# globally. THE UPPER BOUND IS THE POINT -- each playwright release pins a
# Chromium revision, and one that wants a revision the shared store does
# not have dies with an opaque "Executable doesn't exist" rather than a
# missing-dependency error. 1.60-1.62 map to chromium 1223/1228/1234, all
# present. Raise the bound only after the store has the newer revision.
"playwright>=1.60,<1.63",
]
[build-system]
+71 -67
View File
@@ -13,7 +13,7 @@ import pathlib
import pytest
from fastapi.testclient import TestClient
from booth.app import build_gallery, create_app, list_booths
from booth.app import EMBED_SCRIPT_TAG, build_gallery, create_app, list_booths
from booth.asks import (
ANSWER_SUFFIX,
ASK_SUFFIX,
@@ -410,32 +410,37 @@ def test_declare_pick_accepts_a_full_multi_doc(tmp_path):
# into the verbatim page plus a standalone /asks page that carries the forms.
def test_verbatim_booth_renders_the_ask_inline(client):
def test_verbatim_booth_offers_the_ask_over_the_seam(client):
"""U3: the report is served as written and the ask crosses the declared seam.
Before U3 the fragments were substituted into the page body by regex; the
guarantee that the ask is reachable FROM THE REPORT, not from another page,
is unchanged — it is the delivery that moved."""
c, data = client
b = _ask(data / "b")
(b / "index.html").write_text("<!doctype html><title>report</title><body>hi</body>")
html = c.get("/b/b/").text
assert "hi" in html # the report is still served verbatim
assert "Which render wins?" in html # ...with the ask ON it, not elsewhere
assert 'type="radio"' in html and 'action="/b/b/answer"' in html
assert "bk-ask" in html # self-contained fragment styles
assert "booth-nav-asks" in html # chip remains, as a jump link
assert "#bk-ask-winner-top" in html
assert "Which render wins?" not in html # ...and NOTHING was injected into it
assert html.endswith(EMBED_SCRIPT_TAG)
(m,) = c.get("/b/b/embed.json").json()["marks"]
assert "Which render wins?" in m["whole"]
assert 'type="radio"' in m["whole"] and 'action="/b/b/answer"' in m["submit"]
def test_verbatim_chip_disappears_once_answered(client):
c, data = client
b = _ask(data / "b")
(b / "index.html").write_text("<!doctype html><body>hi</body>")
assert c.get("/b/b/embed.json").json()["open"] == ["winner"]
answer_pick(b, "winner", "A — baseline")
assert "booth-nav-asks" not in c.get("/b/b/").text
assert c.get("/b/b/embed.json").json()["open"] == []
def test_verbatim_booth_without_asks_is_untouched(client):
c, data = client
(data / "b").mkdir()
(data / "b" / "index.html").write_text("<!doctype html><body>hi</body>")
assert "booth-nav-asks" not in c.get("/b/b/").text
assert c.get("/b/b/embed.json").json()["marks"] == []
def test_asks_page_renders_forms_and_answers_back_to_itself(client):
@@ -477,42 +482,64 @@ def test_asks_page_shows_a_single_ask_title(client):
assert "emmie — pick the anchor" in c.get("/b/b/marks").text
# ---- inline placement in a verbatim report -----------------------------------
# ---- placement in a verbatim report ------------------------------------------
#
# Operator verdict 2026-09-09 on the separate /asks page: "the asks should be
# inline with the artifacts, not on a separate page." A four-voice audition wants
# each voice's radio group under that voice's audio, and one submit for the lot.
#
# U3 kept the semantics and moved the mechanism. The author still marks up where
# each piece goes; the pieces are still rendered by the `_ask_inline.html`
# macros; they now reach the page through `/b/<name>/embed.json` and are mounted
# by `/_booth/embed.js` instead of substituted into the author's tags by regex.
#
# So the placement ASSERTIONS moved too, and where each half lives is not
# arbitrary: what the server offers is checked here, in Python; where it LANDS,
# and whether a form scattered down a report actually submits, is checked in
# tests/test_embed_browser.py against a real DOM. No string assertion can see
# the second thing, and that is exactly the part the operator depends on.
REPORT = """<!doctype html><title>audition</title><body>
<h1>Three voices</h1>
<section id="lawson"><audio src="a.wav"></audio>
<div data-booth-ask="batch:r1"></div></section>
<section id="jo"><audio src="b.wav"></audio>
<!-- booth:ask batch:r2 --></section>
<div data-booth-mark="batch:r2"></div></section>
<div data-booth-ask-submit="batch"></div>
<script src="/_booth/embed.js" defer></script>
</body>"""
def test_per_question_placeholders_land_where_the_author_put_them(client):
def test_the_author_markup_is_never_touched_by_the_server(client):
"""The whole point of the seam. A page that declares it comes back exactly
as written — placeholders still empty, waiting for the DOM."""
c, data = client
b = _multi(data / "b")
(b / "index.html").write_text(REPORT)
html = c.get("/b/b/").text
# each group is inside its own section, in document order
lawson = html.index('id="lawson"')
jo = html.index('id="jo"')
assert lawson < html.index('name="choice.r1"') < jo
assert jo < html.index('name="choice.r2"')
# one shared form, bound by the HTML5 form= attribute, submitted once
assert html.count('<form id="bk-ask-form-batch"') == 1
assert html.count('action="/b/b/answer"') == 1
assert html.count('form="bk-ask-form-batch"') >= 4
# the submit block landed at its own placeholder, not appended after </body>
assert html.index("bk-ask-form-batch") < html.index("</body>")
assert c.get("/b/b/").text == REPORT
def test_inline_form_submits_every_question_in_one_post(client):
def test_every_piece_the_author_can_place_is_offered(client):
"""One fragment per addressable piece: the whole ask, each question, and the
submit block that carries the shared <form>. The author's markup decides
which are used; the payload never decides for them."""
c, data = client
b = _multi(data / "b")
(m,) = c.get("/b/b/embed.json").json()["marks"]
assert [q["key"] for q in m["questions"]] == ["r1", "r2"]
assert 'name="choice.r1"' in m["questions"][0]["html"]
assert 'name="choice.r2"' in m["questions"][1]["html"]
# ONE form, and it lives with the submit block, so question groups scattered
# down a report bind to it by id from wherever they sit.
assert m["submit"].count('<form id="bk-ask-form-batch"') == 1
assert m["submit"].count('action="/b/b/answer"') == 1
assert 'form="bk-ask-form-batch"' in m["questions"][0]["html"]
assert 'form="bk-ask-form-batch"' in m["questions"][1]["html"]
def test_a_scattered_form_still_posts_as_one_answer(client):
"""The POST half of the multi-question guarantee, which U3 did not touch:
every question in one request, or the route refuses it."""
c, data = client
b = _multi(data / "b")
(b / "index.html").write_text(REPORT)
@@ -521,44 +548,21 @@ def test_inline_form_submits_every_question_in_one_post(client):
assert r.status_code == 303
ans = _answer_of(b, "batch")
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r2"]["choice"] == "d"
# and the recorded pick now shows inline, on the report itself
html = c.get("/b/b/").text
assert "recorded:" in html and "bk-done" in html
assert 'value="keep" required checked' in html.replace("\n", " ") or "checked" in html
# and the recorded pick comes back marked answered, on the report's own seam
(m,) = c.get("/b/b/embed.json").json()["marks"]
assert "recorded:" in m["whole"] and "bk-done" in m["whole"]
assert "checked" in m["questions"][0]["html"]
def test_whole_ask_placeholder_renders_everything_there(client):
c, data = client
b = _ask(data / "b")
(b / "index.html").write_text('<!doctype html><body><p>x</p><div data-booth-ask="winner"></div></body>')
html = c.get("/b/b/").text
assert html.index("Which render wins?") > html.index("<p>x</p>")
assert html.index("bk-ask-go") < html.index("</body>") # submit placed inline too
def test_placeholder_for_a_missing_ask_is_left_alone(client):
c, data = client
b = _ask(data / "b")
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="typo"></div></body>')
html = c.get("/b/b/").text
assert 'data-booth-ask="typo"' in html # author's markup untouched, not blanked
assert "Which render wins?" in html # the real ask still appended, never lost
def test_questions_placed_without_a_submit_still_get_one(client):
c, data = client
b = _multi(data / "b")
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch:r1"></div></body>')
html = c.get("/b/b/").text
assert html.count('<form id="bk-ask-form-batch"') == 1 # appended, so it is submittable
assert 'name="choice.r2"' in html # r2 unplaced -> must still appear
def test_styles_are_emitted_once(client):
def test_the_page_carries_no_fragment_styles(client):
"""`styles()` is gone from the template: the scoped `.bk-ask-*` rules live in
embed.js, next to the code that mounts them. One asset, emitted once by
construction rather than by a seen-set."""
c, data = client
b = _multi(data / "b")
(b / "index.html").write_text(REPORT)
assert c.get("/b/b/").text.count(".bk-ask-opt:has(input:checked)") == 1
assert ".bk-ask-opt:has(input:checked)" not in c.get("/b/b/").text
assert c.get("/_booth/embed.js").text.count(".bk-ask-opt:has(input:checked)") == 1
def test_radios_are_not_html_required_anywhere(client):
@@ -566,20 +570,20 @@ def test_radios_are_not_html_required_anywhere(client):
is exactly what stopped the operator leaving one blank."""
c, data = client
b = _multi(data / "b")
assert "required" not in c.get("/b/b/").text
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
assert "required" not in c.get("/b/b/").text
(m,) = c.get("/b/b/embed.json").json()["marks"]
assert "required" not in m["whole"]
assert not any("required" in q["html"] for q in m["questions"])
assert "required" not in c.get("/b/b/marks").text
def test_partial_answer_renders_as_skipped_inline(client):
def test_partial_answer_renders_as_skipped(client):
c, data = client
b = _multi(data / "b")
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
(b / "index.html").write_text(REPORT)
c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"})
html = c.get("/b/b/").text
assert "bk-skip" in html and "left blank" in html
assert "1 of 2 answered" in html
(m,) = c.get("/b/b/embed.json").json()["marks"]
assert "bk-skip" in m["whole"] and "left blank" in m["whole"]
assert "1 of 2 answered" in m["submit"]
def test_empty_submission_is_refused_with_400(client):
+17 -57
View File
@@ -16,7 +16,7 @@ from booth.app import (
remove_link_entry,
toggle_pin,
booth_age_seconds,
FAVICON_LINK,
EMBED_SCRIPT_TAG,
KEEP_MARKER,
build_gallery,
classify,
@@ -30,7 +30,6 @@ from booth.app import (
render_doc,
safe_upload_name,
sweep_once,
wrap_verbatim_html,
)
PICKUP_RE = re.compile(r"^(\d{1,2}-[a-z]+|[a-z]+-\d{1,2})$")
@@ -459,58 +458,21 @@ def test_view_nonviewable_redirects_to_raw(client):
assert r.headers["location"] == "/b/run1/data.bin"
# ---- verbatim-index.html wrapper --------------------------------------------
# ---- verbatim-index.html serving -------------------------------------------
#
# U3 replaced the injection wrapper with a declared seam. The five `test_wrap_*`
# tests and `test_verbatim_booth_wrapped_with_back_chip` that stood here tested
# `wrap_verbatim_html` — six regexes hunting a head-ish seam for a favicon and a
# body-ish seam for a chip, plus the doctype and charset-window constraints they
# threaded. None of those constraints can be violated by an append, so there is
# nothing left of them to assert. What replaced them lives in tests/test_embed.py
# (the payload, the one appended tag, whole-body equality for a declaring page)
# and tests/test_embed_browser.py (the mount, in a real DOM).
#
# What stays here is what did NOT change: the file route is still raw.
def test_wrap_injects_chip_and_favicon():
html = "<html><head><title>Brief</title></head><body><h1>REPORT</h1></body></html>"
out = wrap_verbatim_html(html)
assert 'class="booth-nav-home"' in out # floating back chip
assert 'href="/"' in out # points at the main booth index
assert "all booths" in out
assert FAVICON_LINK in out # favicon inherited
assert "<h1>REPORT</h1>" in out # original content preserved
# favicon lands in the head, chip lands in the body
assert out.index(FAVICON_LINK) < out.index("</head>")
assert out.index("booth-nav-home") > out.index("<body>")
def test_wrap_respects_existing_favicon():
html = '<html><head><link rel="icon" href="data:image/png;base64,AAAA"></head><body>x</body></html>'
out = wrap_verbatim_html(html)
assert FAVICON_LINK not in out # the page's own icon wins
assert out.count('rel="icon"') == 1
assert 'class="booth-nav-home"' in out # chip is still added
def test_wrap_bare_fragment_appends_chip():
out = wrap_verbatim_html("<h1>bare fragment</h1>") # no doctype/head/body
assert 'class="booth-nav-home"' in out
assert out.rstrip().endswith("</style>") # chip appended at the end
assert FAVICON_LINK in out # no doctype -> safe to prepend the icon
assert out.index(FAVICON_LINK) < out.index("bare") # icon ahead of content (implied head)
def test_wrap_no_head_injects_favicon():
out = wrap_verbatim_html("<body><h1>no head</h1></body>")
assert 'class="booth-nav-home"' in out
assert FAVICON_LINK in out # injected even without an explicit <head>
def test_wrap_compact_doctype_stays_first():
# the real-booth shape: compact HTML, no explicit head/body. The injection must
# not push anything ahead of the doctype (quirks mode) or past the charset window.
html = "<!doctype html><meta charset=utf-8><title>T</title><style>body{margin:0}</style><h1>REPORT</h1>"
out = wrap_verbatim_html(html)
assert out.lstrip().lower().startswith("<!doctype") # doctype still first -> standards mode
assert FAVICON_LINK in out
assert out.index(FAVICON_LINK) < out.index("<h1>") # icon in the implied head, before content
assert out.index("charset") < 1024 # charset meta stays in the detection window
assert 'class="booth-nav-home"' in out
assert out.index("booth-nav-home") > out.index("<h1>REPORT</h1>") # chip appended after content
def test_verbatim_booth_wrapped_with_back_chip(client):
def test_verbatim_booth_is_served_with_the_seam(client):
c, data = client
d = data / "brief"
d.mkdir()
@@ -518,13 +480,11 @@ def test_verbatim_booth_wrapped_with_back_chip(client):
r = c.get("/b/brief/")
assert r.status_code == 200
assert "BRIEF" in r.text # content preserved
assert 'class="booth-nav-home"' in r.text # back chip injected
assert 'href="/"' in r.text
assert 'rel="icon"' in r.text # favicon inherited
assert r.text.endswith(EMBED_SCRIPT_TAG) # ...and the seam, appended
def test_verbatim_index_raw_file_route_unwrapped(client):
# the file route (/b/<name>/index.html) still serves the raw bytes — the chip
# the file route (/b/<name>/index.html) still serves the raw bytes — the seam
# only rides on the booth view (/b/<name>/), so downloads/assets stay verbatim
c, data = client
d = data / "brief"
@@ -532,7 +492,7 @@ def test_verbatim_index_raw_file_route_unwrapped(client):
(d / "index.html").write_text("<html><body><h1>BRIEF</h1></body></html>")
r = c.get("/b/brief/index.html")
assert r.status_code == 200
assert "booth-nav-home" not in r.text
assert "_booth/embed.js" not in r.text
# ---- .md / .txt in-booth doc viewer -----------------------------------------
+438
View File
@@ -0,0 +1,438 @@
"""U3 — the declared embed seam, server side.
The Booth used to reach into a verbatim report with ten regular expressions: six
to find somewhere to hang a favicon and a chip, four to substitute rendered ask
markup into the author's own tags. This unit replaces all of it with a seam the
page declares:
<script src="/_booth/embed.js" defer></script>
What is tested here is the SERVER half — the payload that crosses the seam, the
one static asset, and the single conditional append that is now the only thing
the Booth does to author HTML. The half that mounts fragments into a live DOM
lives in tests/test_embed_browser.py, because no amount of string assertion can
see whether a form actually submits.
Contract: docs/contracts/u3_declared_embed_seam.contract.md
"""
import json
import os
import time
import pytest
from fastapi.testclient import TestClient
from booth.app import EMBED_SCRIPT_TAG, EMBED_SRC, create_app
from booth.marks import answer_pick, declare_pick, set_flag, write_note
DECLARED = f'<!doctype html><title>r</title><body>hi<script src="{EMBED_SRC}" defer></script></body>'
@pytest.fixture
def client(tmp_path):
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
return TestClient(app), tmp_path
def _pick(booth, stem="winner", **kw):
doc = {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
doc.update(kw)
booth.mkdir(parents=True, exist_ok=True)
declare_pick(booth, stem, doc)
return booth
def _multi(booth, stem="batch"):
booth.mkdir(parents=True, exist_ok=True)
declare_pick(booth, stem, {"title": "Round one", "questions": [
{"key": "r1", "prompt": "First?", "options": ["a", "b"]},
{"key": "r2", "prompt": "Second?", "options": ["a", "b"]},
]})
return booth
# ---- slice 1: the payload ----------------------------------------------------
def test_embed_payload_carries_a_fragment_for_every_shape(client):
c, data = client
_multi(data / "b")
body = c.get("/b/b/embed.json").json()
assert body["booth"] == "b"
assert "home" not in body, "a value nothing reads is a second copy waiting to drift"
assert body["favicon"].startswith("data:image/svg+xml,")
(m,) = body["marks"]
assert m["id"] == "batch" and m["error"] is None
assert "First?" in m["whole"] and "Second?" in m["whole"]
assert 'action="/b/b/answer"' in m["submit"]
assert [q["key"] for q in m["questions"]] == ["r1", "r2"] # declaration order
assert "First?" in m["questions"][0]["html"]
assert 'type="radio"' in m["questions"][0]["html"]
def test_a_single_question_pick_has_one_question_with_a_null_key(client):
"""SR-2. `normalize_ask` gives a single-question ask `key: None`, so the
payload cannot key questions by name — JSON would write that as "null" and
invent a name. Every one-question ask in the fleet hits this."""
c, data = client
_pick(data / "b")
(m,) = c.get("/b/b/embed.json").json()["marks"]
assert [q["key"] for q in m["questions"]] == [None]
assert "Which render wins?" in m["questions"][0]["html"]
def test_marks_are_ordered_by_creation_before_id(client):
"""`created` LEADS. The fixture makes creation order and alphabetical order
disagree, because a fixture where they agree cannot tell the stated rule
from a plain id sort — which is what the first version of this test did, and
what a panel caught by reading the fixture rather than the assertion."""
c, data = client
b = _pick(data / "b", "zebra")
_pick(b, "alpha")
raw = json.loads((b / ".marks.json").read_text())
stamps = {"zebra": "2026-09-22T10:00:00.000000-07:00", # first
"alpha": "2026-09-22T11:00:00.000000-07:00"} # second
for e in raw["marks"]:
e["created"] = stamps[e["id"]]
(b / ".marks.json").write_text(json.dumps(raw))
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["zebra", "alpha"]
def test_the_id_is_only_the_tie_break(client):
"""And with `created` equal, the id decides — so two marks written in the
same second cannot swap between renders."""
c, data = client
b = _pick(data / "b", "zebra")
_pick(b, "alpha")
raw = json.loads((b / ".marks.json").read_text())
for e in raw["marks"]:
e["created"] = "2026-09-22T10:00:00.000000-07:00"
(b / ".marks.json").write_text(json.dumps(raw))
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["alpha", "zebra"]
def test_open_is_computed_by_the_server_not_the_page(client):
"""INV-4. The chip count follows `open_marks`, which is the ONE openness
predicate — a half-answered multi-question pick is still open."""
c, data = client
b = _multi(data / "b")
assert c.get("/b/b/embed.json").json()["open"] == ["batch"]
answer_pick(b, "batch", {"r1": "a"})
assert c.get("/b/b/embed.json").json()["open"] == ["batch"] # partial is OPEN
answer_pick(b, "batch", {"r1": "a", "r2": "b"})
assert c.get("/b/b/embed.json").json()["open"] == []
def test_the_payload_carries_picks_only(client):
"""SR-4. Notes and flags never reach it, which is also what keeps a flag's
`flag:<target>` id — the one mark id containing the anchor separator — out
of a payload whose specs split on the first colon."""
c, data = client
b = _pick(data / "b")
(b / "shot.png").write_bytes(b"x")
write_note(b, None, "a remark")
set_flag(b, "shot.png", True)
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["winner"]
def test_a_damaged_marks_file_does_not_500_the_report(client):
c, data = client
b = _pick(data / "b")
(b / ".marks.json").write_text("{not json")
r = c.get("/b/b/embed.json")
assert r.status_code == 200
assert r.json()["marks"] == [] and r.json()["error"]
def test_a_broken_pick_offers_whole_and_nothing_else(client):
c, data = client
b = _pick(data / "b")
raw = json.loads((b / ".marks.json").read_text())
raw["marks"][0]["declaration"] = {"prompt": "p"} # no options -> AskError
(b / ".marks.json").write_text(json.dumps(raw))
(m,) = c.get("/b/b/embed.json").json()["marks"]
assert m["error"] and m["questions"] == [] and m["submit"] == ""
assert "broken ask" in m["whole"]
def test_the_payload_does_not_record_a_view(client):
"""`booth_view` already recorded the look, above both of its early returns.
A script's fetch of the page it is already on must not count a second time
or reset the TTL on machinery instead of on the operator."""
c, data = client
b = _pick(data / "b")
(b / "index.html").write_text(DECLARED)
c.get("/b/b/")
before = os.stat(b / ".viewed").st_mtime_ns
time.sleep(0.01)
c.get("/b/b/embed.json")
assert os.stat(b / ".viewed").st_mtime_ns == before
def test_embed_payload_404s_for_an_unknown_booth(client):
c, _ = client
assert c.get("/b/nope/embed.json").status_code == 404
# ---- slice 2: the one static asset -------------------------------------------
def test_embed_js_is_served_as_javascript(client):
c, _ = client
r = c.get(EMBED_SRC)
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/javascript")
assert "data-booth-mark" in r.text
def test_embed_js_does_not_hot_reload_from_disk(tmp_path):
"""INV-5, and the 2026-09-21 lesson restated. A live asset editable under a
running process is how 19 of 25 booths hit 500 with the Python from 22:03
and the templates from 23:40. One rule in this repo: nothing takes effect
until you restart."""
import pathlib
import booth.app as app_mod
src = pathlib.Path(app_mod.__file__).parent / "static" / "embed.js"
original = src.read_text()
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
c = TestClient(app)
try:
# Poisoned BEFORE the first request, not between two of them. The
# earlier shape passed for a route that read the file lazily and cached
# on first use — which is not "read once at startup", and is exactly the
# staleness this invariant exists to forbid.
src.write_text("/* POISONED */\n")
first = c.get(EMBED_SRC).text
assert "POISONED" not in first, "embed.js is read at request time, not at startup"
assert first == original
assert c.get(EMBED_SRC).text == first
finally:
src.write_text(original)
# ---- slice 3: what the Booth does to author HTML -----------------------------
def test_declaring_page_is_served_untouched(client):
"""INV-1. Whole-body equality, not a substring absence: the promise is that
NOTHING is added, and an absence assertion cannot tell a clean page from one
carrying something nobody thought to look for."""
c, data = client
b = _pick(data / "b")
(b / "index.html").write_text(DECLARED)
assert c.get("/b/b/").text == DECLARED
def test_undeclared_page_gains_only_the_tag(client):
"""INV-2. Appended, so the source is a strict prefix — nothing is inserted,
nothing is prepended, and neither the doctype nor the charset window moves."""
c, data = client
b = _pick(data / "b")
src = "<!doctype html><meta charset=utf-8><title>r</title><h1>REPORT</h1>"
(b / "index.html").write_text(src)
out = c.get("/b/b/").text
# The literal, not just the constant: `out == src + EMBED_SCRIPT_TAG` also
# holds when EMBED_SCRIPT_TAG is the empty string, which is a mutation this
# test exists to catch. A panel found it by reading the assertion, not the
# code.
assert out == src + '<script src="/_booth/embed.js" defer></script>'
assert out == src + EMBED_SCRIPT_TAG
assert len(out) > len(src)
assert out.lower().lstrip().startswith("<!doctype")
assert out.index("charset") < 1024
def test_a_booth_with_no_marks_still_gets_the_seam(client):
"""The seam carries the way home and the icon too, so it is not conditional
on there being an ask — the old chip was not either."""
c, data = client
(data / "b").mkdir()
(data / "b" / "index.html").write_text("<h1>bare fragment</h1>")
assert c.get("/b/b/").text == "<h1>bare fragment</h1>" + EMBED_SCRIPT_TAG
def test_a_declaring_page_is_served_BYTE_for_byte(client):
"""INV-1, at the level the promise is actually made.
The first version read the file with `read_text()`, which opens in
universal-newline mode: a report written with CRLF came back with LF, and
`errors="replace"` turned any non-UTF-8 byte into U+FFFD. A declaring page
was NOT served as its author wrote it — the headline promise — and the
original test could not see it, because its fixture was LF-only ASCII.
Found by a cross-frontier bug-hunt panel.
"""
c, data = client
b = _pick(data / "b")
src = (b'<!doctype html>\r\n<title>r</title>\r\n<body>caf\xe9 \xff\r\n'
b'<script src="/_booth/embed.js" defer></script>\r\n</body>')
(b / "index.html").write_bytes(src)
r = c.get("/b/b/")
assert r.content == src, "the operator's document was edited on the way out"
assert b"\r\n" in r.content and b"\xff" in r.content
def test_an_undeclared_page_keeps_every_byte_and_gains_the_tag(client):
"""INV-2, same level: the source is a BYTE-exact prefix of the response."""
c, data = client
b = _pick(data / "b")
src = b'<!doctype html>\r\n<title>r</title>\r\n<body>caf\xe9 \xff\r\n</body>'
(b / "index.html").write_bytes(src)
r = c.get("/b/b/")
assert r.content == src + EMBED_SCRIPT_TAG.encode("utf-8")
assert r.content.startswith(src)
def test_a_wrongly_shaped_answer_costs_its_pick_not_the_report(client):
"""`marks_for` hydrates `{"answer": {"answers": []}}` with no error — the
JSON is well formed, the SHAPE is not — and the template then asks a list
for `.get`. This endpoint renders every pick on every load of the operator's
report, so an unguarded raise here is the whole seam gone while `hold_read`
calls the file perfectly readable. Verified reachable, not assumed."""
c, data = client
b = data / "b"
b.mkdir(parents=True, exist_ok=True)
declare_pick(b, "batch", {"title": "T", "questions": [
{"key": "r1", "prompt": "A?", "options": ["x", "y"]},
{"key": "r2", "prompt": "B?", "options": ["x", "y"]}]})
_pick(b, "healthy")
raw = json.loads((b / ".marks.json").read_text())
for e in raw["marks"]:
if e["id"] == "batch":
e["answer"] = {"answers": [], "notes": ""}
(b / ".marks.json").write_text(json.dumps(raw))
(b / "index.html").write_text(DECLARED)
r = c.get("/b/b/embed.json")
assert r.status_code == 200
by = {m["id"]: m for m in r.json()["marks"]}
assert by["batch"]["error"] and "could not be rendered" in by["batch"]["error"]
assert "broken ask" in by["batch"]["whole"]
# and the booth's other pick is untouched — one bad entry costs one entry
assert by["healthy"]["error"] is None
assert "Which render wins?" in by["healthy"]["whole"]
assert c.get("/b/b/").status_code == 200
# ⚠ THE GALLERY AND MARKS PAGES STILL 500 ON THIS ENTRY, and that is NOT
# U3's doing — measured at 42ea67f, the commit before this unit. They render
# the same macro without this guard. Out of scope here (the gallery is named
# out of scope in the contract) and recorded rather than quietly widened:
# see persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md
def test_no_regex_touches_author_html():
"""INV-3, and the version that actually falsifies it.
The first draft of this test name-matched the six deleted patterns. A cold
panel pointed out — correctly, and on the contract's CENTRAL promise — that
reintroducing the same regex under a new name (`_TAIL_RE`, applied in the
verbatim branch) would leave it green. A test that guards names does not
guard behaviour, and this repo's own vacuity pass missed it because the
mutation it tried was the named one.
So: `booth/app.py` is allowed EXACTLY ONE regex operation, and it is
`ask_form_id`'s `re.sub` over a mark id — not over a page. Any other regex
anywhere in the module fails here, whatever it is called. If a future
change genuinely needs one, the failure is the conversation: say which
string it reads and why it is not author HTML.
"""
import ast
import pathlib
import booth.app as app_mod
root = pathlib.Path(app_mod.__file__).parent
assert not (root / "inline.py").exists(), "booth/inline.py survived U3"
tree = ast.parse((root / "app.py").read_text())
# every node -> the function it sits in, so a finding names its site
site = {}
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
for child in ast.walk(node):
site.setdefault(child, node.name)
# `re` reaches this module ONE way: a plain module-level `import re`. An
# alias (`import re as _r`) or a direct name import (`from re import sub`)
# would route around the call check below under a name it does not know —
# found by re-running the vacuity pass against the FIXED test, which is the
# only reason it is here and is the argument for running that pass on a fix
# and not only on a draft.
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
assert not (a.name == "re" and a.asname), f"`re` aliased as {a.asname}"
elif isinstance(node, ast.ImportFrom):
assert node.module != "re", f"names imported from re: {[a.name for a in node.names]}"
METHODS = {"search", "sub", "subn", "match", "fullmatch", "finditer",
"findall", "split", "compile", "escape"}
found = []
for node in ast.walk(tree):
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
continue
f = node.func
on_re = isinstance(f.value, ast.Name) and f.value.id == "re"
on_pattern = (isinstance(f.value, ast.Name) and f.value.id.endswith("_RE")
and f.attr in METHODS)
if on_re or on_pattern:
found.append((site.get(node, "<module level>"), f.attr))
assert found == [("ask_form_id", "sub")], (
f"booth/app.py performs regex operations outside ask_form_id: {found}"
)
# the six named patterns and the chips are gone, and stay gone
assigned = {
t.id
for node in ast.walk(tree)
if isinstance(node, ast.Assign)
for t in node.targets
if isinstance(t, ast.Name)
}
gone = {"_ICON_RE", "_HEAD_CLOSE_RE", "_HTML_OPEN_RE", "_DOCTYPE_RE",
"_BODY_CLOSE_RE", "_HTML_CLOSE_RE", "_BACK_CHIP", "FAVICON_LINK"}
assert not (assigned & gone), f"deleted names are back: {sorted(assigned & gone)}"
funcs = {n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
assert not ({"wrap_verbatim_html", "asks_chip", "inject_asks",
"_insert_before", "_insert_after"} & funcs)
def test_a_page_that_only_mentions_the_path_is_not_declaring_it(client):
"""A report that QUOTES the seam — a code sample, a comment, a sentence
about this very feature — is not declaring it, and the Booth's own design
reports are the pages most likely to do that. Read as declared, such a page
would be served untouched and show no chrome at all, silently.
The detection therefore fails the other way: an unrecognised spelling gets a
duplicate tag, and embed.js mounts once regardless.
"""
c, data = client
b = _pick(data / "b")
for body in (
"<!doctype html><body><p>add <code>/_booth/embed.js</code> to your report</p></body>",
"<!doctype html><body><!-- src=/_booth/embed.js --></body>",
'<!doctype html><body><script src="/_booth/embed.js?v=2"></script></body>',
):
(b / "index.html").write_text(body)
assert c.get("/b/b/").text == body + EMBED_SCRIPT_TAG, body
# and the real declaration, in either quote style, is honoured
for decl in (f'<script src="{EMBED_SRC}" defer></script>',
f"<script src='{EMBED_SRC}' defer></script>"):
body = f"<!doctype html><body>hi{decl}</body>"
(b / "index.html").write_text(body)
assert c.get("/b/b/").text == body
def test_an_oversize_verbatim_page_is_served_raw(client, monkeypatch):
"""WRAP_MAX_BYTES survives: a pathological file is still not pulled into
memory, and it loses its chrome exactly as it does today."""
import booth.app as app_mod
c, data = client
b = _pick(data / "b")
(b / "index.html").write_text("<h1>huge</h1>")
monkeypatch.setattr(app_mod, "WRAP_MAX_BYTES", 4)
assert c.get("/b/b/").text == "<h1>huge</h1>"
+539
View File
@@ -0,0 +1,539 @@
"""U3 — the declared embed seam, in a real DOM.
The Python suite can prove what the server OFFERS. It cannot prove where a
fragment lands, whether the author's own markup survived the mount, or whether
four radio groups scattered down a report still submit as one POST — and that
last one is the operator's most important workflow. Before U3 those properties
were true by construction, because the server did the placing and the `form=`
bindings were static by the time the page was parsed. Now they are true because
`/_booth/embed.js` does it in a live document, which is a different kind of
claim and needs a different kind of test.
So: a real uvicorn on an ephemeral port, a real Chromium.
SKIPS, NEVER FAILS, when playwright or the shared browser is unavailable. The
box-wide store at /opt/ms-playwright pins specific Chromium revisions and a
playwright release that wants a newer one dies with an opaque "Executable
doesn't exist" — see pyproject's version bound. A test layer that goes red for
an environment reason teaches nothing and trains people to ignore it.
"""
import json
import socket
import threading
import time
import pytest
from booth.app import create_app
playwright_api = pytest.importorskip(
"playwright.sync_api", reason="playwright is not installed"
)
@pytest.fixture(scope="module")
def browser():
with playwright_api.sync_playwright() as pw:
try:
b = pw.chromium.launch()
except Exception as exc: # noqa: BLE001 - any launch failure is a skip
pytest.skip(f"no usable chromium: {exc}")
yield b
b.close()
@pytest.fixture
def live(tmp_path):
"""A real server, because a browser cannot talk to a TestClient."""
import uvicorn
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
deadline = time.time() + 10
while not server.started and time.time() < deadline:
time.sleep(0.02)
if not server.started:
pytest.skip("uvicorn did not come up")
try:
yield f"http://127.0.0.1:{port}", tmp_path
finally:
server.should_exit = True
thread.join(timeout=10)
SEAM = '<script src="/_booth/embed.js" defer></script>'
def _multi(booth):
from booth.marks import declare_pick
booth.mkdir(parents=True, exist_ok=True)
declare_pick(booth, "batch", {"title": "Round one", "questions": [
{"key": "r1", "prompt": "First?", "options": ["keep", "cut"]},
{"key": "r2", "prompt": "Second?", "options": ["keep", "cut"]},
]})
return booth
def _single(booth):
from booth.marks import declare_pick
booth.mkdir(parents=True, exist_ok=True)
declare_pick(booth, "winner", {"prompt": "Which render wins?",
"options": ["A — baseline", "B — async"]})
return booth
def _open(browser, base, name, html, booth):
(booth / "index.html").write_text(html, encoding="utf-8")
page = browser.new_page()
page.goto(f"{base}/b/{name}/", wait_until="networkidle")
return page
def _answer(booth):
raw = json.loads((booth / ".marks.json").read_text())
return raw["marks"][0].get("answer")
# ---- the chrome --------------------------------------------------------------
def test_a_declaring_page_gets_its_chrome_mounted(browser, live):
base, data = live
b = _single(data / "b")
page = _open(browser, base, "b", f"<!doctype html><title>r</title><body><h1>R</h1>{SEAM}</body>", b)
page.wait_for_selector(".booth-nav-home")
assert page.locator("h1").inner_text() == "R" # the report is intact
assert page.locator(".booth-nav-home").get_attribute("href").endswith("/")
# the favicon question, asked of a parsed document instead of raw text
assert page.locator('link[rel="icon"]').count() == 1
page.close()
def test_a_page_that_never_declared_the_seam_still_mounts(browser, live):
"""The appended path: every verbatim booth that predates U3 keeps working
without its author touching it."""
base, data = live
b = _single(data / "b")
page = _open(browser, base, "b", "<!doctype html><body><h1>OLD</h1></body>", b)
page.wait_for_selector(".bk-ask")
assert page.locator("h1").inner_text() == "OLD"
assert page.locator(".booth-nav-home").count() == 1
page.close()
def test_a_page_that_declares_its_own_icon_keeps_it(browser, live):
base, data = live
b = _single(data / "b")
page = _open(
browser, base, "b",
f'<!doctype html><head><link rel="icon" href="data:image/png;base64,AAAA">'
f"</head><body>x{SEAM}</body>", b)
page.wait_for_selector(".booth-nav-home")
icons = page.locator('link[rel="icon"]')
assert icons.count() == 1
assert icons.get_attribute("href").startswith("data:image/png")
page.close()
# ---- placement ---------------------------------------------------------------
REPORT = f"""<!doctype html><title>audition</title><body>
<h1>Three voices</h1>
<section id="lawson"><audio src="a.wav"></audio>
<div data-booth-ask="batch:r1"></div></section>
<section id="jo"><audio src="b.wav"></audio>
<div data-booth-mark="batch:r2"></div></section>
<div data-booth-ask-submit="batch"></div>
{SEAM}
</body>"""
def test_each_question_lands_where_the_author_put_it(browser, live):
"""The 2026-09-09 ruling, enforced in the DOM: the question for a voice sits
under that voice, not on another page and not in a pile at the end. Both
attribute spellings, because live reports use the older one."""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", REPORT, b)
page.wait_for_selector("#lawson .bk-ask")
assert page.locator('#lawson input[name="choice.r1"]').count() == 2
assert page.locator('#jo input[name="choice.r2"]').count() == 2
# nothing spilled to the end of the body: every piece had an anchor
assert page.locator("body > .bk-ask").count() == 0
assert page.locator("form#bk-ask-form-batch").count() == 1
page.close()
def test_the_authors_wrapper_and_its_contents_survive_the_mount(browser, live):
"""The live `dfa-concepts` shape — a non-empty styled wrapper carrying the
anchor attribute. The regex this replaced matched the opening tag and
SUBSTITUTED it, eating the class and orphaning the heading. beforeend keeps
both and puts the radios under the heading, which is what the markup says."""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", (
'<!doctype html><body><div class="ask" data-booth-ask="batch:r1">'
f"<h3>The one asset that must survive</h3></div>{SEAM}</body>"), b)
page.wait_for_selector(".ask .bk-ask")
assert page.locator("div.ask").count() == 1 # class kept
assert page.locator(".ask h3").inner_text() == "The one asset that must survive"
assert page.locator('.ask input[name="choice.r1"]').count() == 2 # radios inside
page.close()
def test_an_unplaced_question_is_appended_and_so_is_its_submit(browser, live):
"""INV-7. A multi-question pick needs EVERY question on submit or the POST is
unanswerable: a question that never reaches the page cannot be picked, and
a submission with nothing picked at all is refused outright."""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b",
f'<!doctype html><body><div data-booth-mark="batch:r1"></div>{SEAM}</body>', b)
# attached, not visible: the shared <form> is deliberately empty and so has
# no box — the controls that bind to it are what the operator sees.
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
# BOTH halves. Asserting only the appended r2 let a mutation that silently
# swallowed the anchored r1 — while still recording it as placed — pass.
assert page.locator('input[name="choice.r1"]').count() == 2 # anchored, mounted
assert page.locator('input[name="choice.r2"]').count() == 2 # unplaced, appended
assert page.locator("form#bk-ask-form-batch").count() == 1 # submittable
page.close()
def test_a_page_with_no_anchors_gets_the_whole_ask(browser, live):
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", f"<!doctype html><body><p>x</p>{SEAM}</body>", b)
page.wait_for_selector(".bk-ask")
assert page.locator('input[name="choice.r1"]').count() == 2
assert page.locator('input[name="choice.r2"]').count() == 2
page.close()
def test_an_anchor_naming_no_mark_is_left_alone(browser, live):
"""A typo'd id stays visible as the author's own empty element rather than
being blanked — and the real ask is still never lost."""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b",
f'<!doctype html><body><div id="t" data-booth-mark="typo"></div>{SEAM}</body>', b)
page.wait_for_selector(".bk-ask")
assert page.locator("#t").inner_html().strip() == ""
assert page.locator('input[name="choice.r1"]').count() == 2
page.close()
def test_the_tail_follows_payload_order(browser, live):
"""INV-6. Creation order and alphabetical order DISAGREE here on purpose:
`winner` is created first, `batch` second, so payload order is
winner-then-batch while an id sort would give the reverse. The first version
of this fixture made the two identical, so sorting the tail alphabetically
in JavaScript passed it — caught by a panel reading the fixture."""
base, data = live
b = _single(data / "b")
_multi(b)
raw = json.loads((b / ".marks.json").read_text())
stamps = {"winner": "2026-09-22T10:00:00.000000-07:00", # first
"batch": "2026-09-22T11:00:00.000000-07:00"} # second
for e in raw["marks"]:
e["created"] = stamps[e["id"]]
(b / ".marks.json").write_text(json.dumps(raw))
page = _open(browser, base, "b", f"<!doctype html><body>{SEAM}</body>", b)
page.wait_for_selector(".bk-ask")
ids = page.eval_on_selector_all("[id^='bk-ask-']", "els => els.map(e => e.id)")
batch = [i for i, v in enumerate(ids) if "batch" in v]
winner = [i for i, v in enumerate(ids) if "winner" in v]
assert batch and winner, ids
# winner (created first) ahead of batch (created second) — the OPPOSITE of
# alphabetical, so an id sort cannot pass this.
assert max(winner) < min(batch), ids
page.close()
# ---- the one that actually matters ------------------------------------------
def test_a_form_scattered_down_the_report_submits_every_question(browser, live):
"""THE load-bearing browser test.
Four radio groups under four different artifacts, one <form> somewhere else
entirely, bound only by the HTML5 `form=` attribute — and now inserted into
a live document in visual order, which means a control can land before the
form it points at. If form-owner resolution does not survive that, the
operator fills the whole thing in and the button saves nothing.
It was true by construction before U3 (static HTML, resolved at parse). It
is true by measurement now. That is the trade this test pays for.
"""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", REPORT, b)
page.wait_for_selector("#lawson .bk-ask")
page.check('#lawson input[name="choice.r1"][value="keep"]')
page.check('#jo input[name="choice.r2"][value="cut"]')
with page.expect_navigation():
page.click("button.bk-ask-go")
ans = _answer(b)
assert ans is not None, "the scattered form submitted nothing"
assert ans["answers"]["r1"]["choice"] == "keep"
assert ans["answers"]["r2"]["choice"] == "cut", \
"a question bound by form= did not reach the POST"
assert ans["complete"] is True
page.close()
def test_the_chip_jumps_to_the_first_fragment_of_the_open_ask(browser, live):
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", REPORT, b)
page.wait_for_selector(".booth-nav-asks")
chip = page.locator(".booth-nav-asks")
assert chip.inner_text() == "? 1 open ask"
target = chip.get_attribute("href")
# the EARLIEST match in document order, not merely a match: the report
# anchors r1 above r2 above the submit block, so any later one is wrong.
ids = page.eval_on_selector_all("[id^='bk-ask-batch']", "els => els.map(e => e.id)")
assert ids, "no batch fragment mounted"
assert target == "#" + ids[0], (target, ids)
page.close()
def test_an_anchor_named_like_an_object_property_does_not_kill_the_page(browser, live):
"""`toString` is a legal mark id (`asks.valid_stem`) and therefore a legal
thing for an author to typo into an anchor. Against a plain `{}` lookup it
came back as Object.prototype.toString — truthy, so it sailed past the
unknown-mark guard and threw on `.questions.length`, aborting placement
before the tail and costing the page EVERY ask. One typo, no chrome, no
error the operator would see. Found by a cross-frontier panel."""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", (
'<!doctype html><body><div id="t" data-booth-mark="toString"></div>'
f'<div id="v" data-booth-mark="valueOf:r1"></div>{SEAM}</body>'), b)
page.wait_for_selector(".bk-ask")
assert page.locator("#t").inner_html().strip() == "" # left alone
assert page.locator("#v").inner_html().strip() == "" # left alone
# and the real ask still mounted, which is what the bug destroyed
assert page.locator('input[name="choice.r1"]').count() == 2
assert page.locator('input[name="choice.r2"]').count() == 2
assert page.locator("form#bk-ask-form-batch").count() == 1
page.close()
def test_a_question_keyed_like_an_object_property_is_not_swallowed(browser, live):
"""The mirror of the same bug, on the `placed` set. `constructor` matches
`asks._KEY_RE`, and against a plain object an inherited `got.constructor`
read as ALREADY PLACED — so a question the author did not anchor was
silently dropped from the tail, which is INV-7's whole subject."""
from booth.marks import declare_pick
base, data = live
b = data / "b"
b.mkdir(parents=True, exist_ok=True)
declare_pick(b, "batch", {"title": "Round one", "questions": [
{"key": "r1", "prompt": "First?", "options": ["keep", "cut"]},
{"key": "constructor", "prompt": "Second?", "options": ["keep", "cut"]},
]})
page = _open(browser, base, "b",
f'<!doctype html><body><div data-booth-mark="batch:r1"></div>{SEAM}</body>', b)
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
assert page.locator('input[name="choice.r1"]').count() == 2
assert page.locator('input[name="choice.constructor"]').count() == 2, \
"an unplaced question was swallowed by an inherited property"
page.close()
def test_a_submit_anchor_inside_the_authors_own_form_still_submits(browser, live):
"""A submit anchor placed inside the author's own `<form>` loses ours: the
HTML parser drops a nested form element outright. The controls' `form=`
then points at nothing, the button does nothing, and the operator finds out
by filling the whole thing in. Found by a cross-frontier bug-hunt panel; the
fix is to count the anchor submitted only if the form actually survived, so
the tail supplies one at body level where no form encloses it."""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", (
'<!doctype html><body>'
'<div data-booth-mark="batch:r1"></div>'
'<div data-booth-mark="batch:r2"></div>'
'<form id="mine" action="/elsewhere">'
'<div data-booth-ask-submit="batch"></div></form>'
f"{SEAM}</body>"), b)
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
assert page.locator("form#bk-ask-form-batch").count() == 1
page.check('input[name="choice.r1"][value="keep"]')
page.check('input[name="choice.r2"][value="cut"]')
with page.expect_navigation():
page.click("button.bk-ask-go")
ans = _answer(b)
assert ans is not None, "the button reached no form"
assert ans["answers"]["r1"]["choice"] == "keep"
assert ans["answers"]["r2"]["choice"] == "cut"
page.close()
def test_a_broken_pick_shows_its_diagnostic_even_from_a_submit_anchor(browser, live):
"""A broken pick has no submit block — its `submit` is empty and the
diagnostic lives in `whole`. Mounting that empty string and then recording
the pick as placed made the tail skip it, so the 'broken ask' box never
rendered at the one surface built to show it. A question the session
believes it posted has to be visible."""
base, data = live
b = _single(data / "b")
raw = json.loads((b / ".marks.json").read_text())
raw["marks"][0]["declaration"] = {"prompt": "p"} # no options -> AskError
(b / ".marks.json").write_text(json.dumps(raw))
page = _open(browser, base, "b",
f'<!doctype html><body><div id="s" data-booth-ask-submit="winner">'
f"</div>{SEAM}</body>", b)
page.wait_for_selector(".bk-ask")
assert page.locator("#s").inner_html().strip() == "" # anchor left alone
# inner_text() is the RENDERED text, and `.bk-ask-tag` is uppercased by CSS —
# so assert the diagnostic itself, which is the part that has to reach him.
shown = page.locator("body").inner_text()
assert "this question could not be read" in shown, shown
assert "options" in shown # the actual reason
page.close()
def test_an_author_element_cannot_hijack_the_chip(browser, live):
"""`<section id="bk-ask-winner-background">` satisfies any id-prefix rule,
hyphen boundary included. The chip therefore searches only the elements THIS
SCRIPT MOUNTED — the identity the deleted `bk-ask-<id>-top` anchor used to
guarantee — and takes the earliest of those in document order."""
base, data = live
b = _single(data / "b")
page = _open(browser, base, "b", (
'<!doctype html><body><section id="bk-ask-winner-background">notes</section>'
f"<p>report</p>{SEAM}</body>"), b)
page.wait_for_selector(".booth-nav-asks")
target = page.locator(".booth-nav-asks").get_attribute("href")
assert target != "#bk-ask-winner-background"
landed = page.locator(target)
assert landed.count() == 1
assert landed.evaluate("e => e.classList.contains('bk-ask')"), \
"the chip jumped to something the Booth did not mount"
page.close()
def test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix(browser, live):
"""A SIBLING MARK's fragment must not take the chip, even when it is earlier
in the document. `batch2`'s id starts with `batch`, so the original
id-prefix rule could land on it; the mounted-elements rule cannot, because
the candidates are partitioned by mark.
⚠ The first version of this test put the sibling's fragment AFTER the open
mark's, so the right answer was also the first answer and pooling every
mark's elements passed it. The vacuity pass caught that; the fixture now
puts the sibling FIRST, which is the only arrangement that can tell the two
implementations apart.
"""
from booth.marks import declare_pick
base, data = live
b = _multi(data / "b") # `batch`, created first
declare_pick(b, "batch2", {"prompt": "Unrelated?", "options": ["x", "y"]})
# batch2 is anchored at the very top; batch is unanchored and so lands in
# the tail, at the END of the body. Document order is therefore batch2's
# fragments, then batch's.
page = _open(browser, base, "b", (
'<!doctype html><body><div data-booth-mark="batch2"></div>'
f"<p>report</p>{SEAM}</body>"), b)
page.wait_for_selector(".booth-nav-asks")
ids = page.eval_on_selector_all("[id^='bk-ask-']", "els => els.map(e => e.id)")
assert any("batch2" in i for i in ids) and any(
"batch2" not in i and "batch" in i for i in ids), ids
assert ids.index(next(i for i in ids if "batch2" in i)) < \
ids.index(next(i for i in ids if "batch2" not in i and "batch" in i)), \
f"fixture is wrong: the sibling must come FIRST, got {ids}"
# `open` is (created, id) -> batch before batch2, so the chip targets batch
target = page.locator(".booth-nav-asks").get_attribute("href")
assert "batch2" not in target, f"the chip landed on the sibling mark: {target}"
assert target.startswith("#bk-ask-batch")
assert page.locator(target).count() == 1
page.close()
def test_the_canonical_attribute_wins_when_both_are_present(browser, live):
"""`data-booth-mark` is canonical and `data-booth-ask` is the kept alias.
An element carrying both is not a case any live report has, but the
precedence has to be decided somewhere rather than by selector order."""
base, data = live
b = _multi(data / "b")
page = _open(browser, base, "b", (
'<!doctype html><body><div id="a" data-booth-mark="batch:r2" '
f'data-booth-ask="batch:r1"></div>{SEAM}</body>'), b)
page.wait_for_selector("#a .bk-ask")
assert page.locator('#a input[name="choice.r2"]').count() == 2 # canonical
assert page.locator('#a input[name="choice.r1"]').count() == 0 # alias ignored
# r1 was never placed, so INV-7 still puts it somewhere
assert page.locator('input[name="choice.r1"]').count() == 2
page.close()
def test_the_chip_count_comes_from_the_server(browser, live):
"""INV-4. A half-answered multi-question pick is STILL OPEN, and the page
does not get to have an opinion about that — `open_marks` decides."""
from booth.marks import answer_pick
base, data = live
b = _multi(data / "b")
answer_pick(b, "batch", {"r1": "keep"})
page = _open(browser, base, "b", REPORT, b)
page.wait_for_selector(".bk-ask")
assert page.locator(".booth-nav-asks").count() == 1
answer_pick(b, "batch", {"r1": "keep", "r2": "cut"})
page.reload(wait_until="networkidle")
page.wait_for_selector(".bk-ask")
assert page.locator(".booth-nav-asks").count() == 0
page.close()
def test_the_chip_follows_a_payload_that_disagrees_with_the_fragments(browser, live):
"""INV-4, and the version that actually falsifies it.
The test above uses honest fixtures, so a client that INFERRED openness from
the rendered fragments would pass it — the fragments and `open` always agree
when the server computes both. A panel pointed out that this never creates
the disagreement it claims to test.
So: intercept the response and make `open` lie. The fragments say fully
answered; the payload says two are open. The chip must follow the payload,
because the payload is the only thing that decides.
"""
import json as _json
from booth.marks import answer_pick
base, data = live
b = _multi(data / "b")
answer_pick(b, "batch", {"r1": "keep", "r2": "cut"}) # nothing is open
(b / "index.html").write_text(f"<!doctype html><body>{SEAM}</body>", encoding="utf-8")
def lie(route):
body = _json.loads(route.fetch().text())
assert body["open"] == [], "fixture is not answered; the lie would be true"
body["open"] = ["batch", "batch"]
route.fulfill(status=200, content_type="application/json",
body=_json.dumps(body))
page = browser.new_page()
page.route("**/embed.json", lie)
page.goto(f"{base}/b/b/", wait_until="networkidle")
page.wait_for_selector(".booth-nav-asks")
assert page.locator(".booth-nav-asks").inner_text() == "? 2 open asks"
page.close()