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.
This commit is contained in:
vh
2026-09-22 10:43:41 -07:00
parent 42ea67f33f
commit 87e2c5364c
17 changed files with 2015 additions and 503 deletions
+14 -4
View File
@@ -1,7 +1,8 @@
# 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.4.0` (U1, U2, U3, U4 and U5 landed; extracted from eshpfi 2026-09-21).
U3's release tier is with the operator — it reads minor, and minor needs his approval.
## v1 target
@@ -12,7 +13,7 @@ defect — not a wish. The measurements are in the IA doc.
|---|---|---|---|
| 1 | ~~**One item record**~~ — **landed `ce598b3`** | captions never reach the zoom view (never sent, not lost) | U1 |
| 2 | ~~**Marks**~~ — **landed `c7f9437`, released `v0.2.0`** | 5 mechanisms for 1 job; operator→session loop runs through chat | U2 |
| 3 | **Declared embed seam** — `/_booth/embed.js`, chrome mounts via DOM | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 |
| 3 | ~~**Declared embed seam**~~ — **landed 2026-09-22** | 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 +23,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 +76,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