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.
13 KiB
The Booth — information architecture
Written 2026-09-21, after a measurement pass over the live service. Supersedes
the accreted model described in README.md; the README is rewritten when this
lands.
What the Booth is, stated once
The Booth is the fleet's operator-review surface. Agents post work; the operator looks at it, judges it, and the judgment gets back to the agent.
It was built as a file-shuttle. That was the whim, and the whim was wrong in a productive way — the thing people actually needed was the review loop, and they built it out of folders because folders were what was on offer.
The measurement that drove this
Taken 2026-09-21 against the live service and ~/booth-data:
| observation | number |
|---|---|
| rows on the standing link board | 211 |
| …pointing at a booth that no longer exists | 145 (69%) |
| …that are the same target re-posted (talk 5×, peedlar 4×) | 22 |
| …that are genuinely durable targets | 40 (19%) |
| live booths | 24 |
…carrying the .forever escape hatch |
13 (54%) |
| largest booth | 270 images, rendered as one flat wall |
| distinct agent handles posting | 17 |
| commits to ship the asks primitive, in one day | 5 |
Two of those are load-bearing and neither was obvious before counting:
- 69% rot is structural, not drift.
booth linkis anO_APPENDwrite with no identity and no stated rule, so re-announcing a bench creates a row rather than updating one, and a booth URL rots the moment its booth is swept. - 54% kept means the escape hatch is carrying the main load. A 24h TTL with a boolean override is not "ephemeral with an exception"; it is two lifetimes wearing one lifetime's clothes, and the operator is doing the sorting by hand.
The defect behind every tacked-on part
One lifetime (24h from last touch) and one shape (a folder), serving five jobs with different lifetimes and different shapes.
Every bolt-on is a correct local fix to that one global mismatch:
| bolt-on | the mismatch it patches |
|---|---|
.forever |
"this one shouldn't expire" |
| the link board | "this one isn't a folder" |
inline.py (a placeholder DSL) |
"this one isn't a gallery" |
wrap_verbatim_html (6 regexes into arbitrary HTML) |
"the Booth doesn't own this page" |
the floating amber asks chip + /b/<n>/asks |
"the operator can't see the question" |
Five mechanisms exist to get one question next to one artifact. That is the definition of accretion, and the git log shows its signature: every feature ships, then takes 2–5 patches for cases the single shape did not anticipate.
The five jobs
| # | job | example on the live service | what it needs |
|---|---|---|---|
| 1 | Review a set | sindra20-engines (197 files), pancake-v3-full (270) |
grid, sections, zoom, filter, flag-the-winners |
| 2 | Review a change | pancake-v3-full vs pancake-v4-full |
pairing by name, A/B |
| 3 | Read a report | pewpew-ui-brief, run07-decisions |
prose + figures + nav, chrome the Booth owns |
| 4 | Decide | asks | the question beside the artifact |
| 5 | Announce | 145 dead link rows | "operator, look at this" |
Job 5 is the one nobody named. The link board was absorbing it: an agent posts a booth URL because a booth has no way to announce itself — the index card shows a name, an item count and a countdown, but not who made it or what it is for. Enforcing the link rule without giving job 5 a home just makes it homeless.
The model
Four nouns. Everything else is a view over them.
1. Booth — a review surface with a derived lifetime
A folder, as before. What changes is that lifetime is derived from state, not set by a boolean:
- Default: swept 24h after last activity. Unchanged, and still the contract that means nobody cleans up after themselves.
- An open mark pins its booth. A booth the operator still owes an answer to
cannot be swept. (Today it can — a real defect, and one of the reasons
.forevergets pressed.) - Viewing a booth page is activity. If he is still looking at it, it is still alive. Deliberate act, so it cannot be triggered by browsing the index.
keepsurvives as an explicit, reasoned pin — not as the only way to say "not yet".
Expected effect: the 54% kept rate falls to the booths that are genuinely durable references. This is a prediction, and it is measurable — re-count the
.foreverrate a fortnight after this lands. If it does not move, the diagnosis was wrong and the boolean was doing something else.
Each booth carries .booth.json: {handle, title, why, created} — written by
the CLI from $ALTHING_HANDLE. This is what closes job 5. The index card
gains provenance and a one-line purpose, and the index becomes the "what landed"
feed the link board was being used as.
2. Item — one record, resolved once, carried everywhere
Today build_gallery() folds a .txt sidecar into an item — and
booth_view_file() passes only {name, file, file_url} to the zoom template.
The caption is not lost in rendering; it is never sent. Same for the doc view.
So: one booth_items(booth) resolver returns the full record —
{rel, kind, caption, marks, flagged, blurred, section, url} — and the gallery,
the zoom view, the doc view, the compare view and the zip manifest all read from
it. The zoom-loses-annotation bug is then fixed by construction, not by
patching one more template and hoping the next one remembers.
Sections come from subfolders, which already exist on disk and are currently
thrown away. pewpew-ui-brief has integration/ and blueprint/;
dfa-concepts has source/. rglob flattens them into one wall. Rendering the
structure the poster already created costs nothing and is most of the navigation
fix.
3. Mark — operator judgment attached to a target
The unification, and the reason "comments, votes and asks" stop being three tacked-on things:
An ask is the session asking the operator. An annotation is the operator telling the session. A vote is the operator pointing at the good ones. All three are operator judgment attached to an artifact. One primitive.
MARK
target : the booth, or one item in it
shape : pick — one of N options the session declared in advance (was: ask)
note — free text the operator volunteered (was: comment)
flag — this one / not this one (was: vote)
writer : the operator, in the browser
reader : the session — `booth marks <booth> [--wait]`
One storage model, one read path, one rendering slot (beside the artifact), one
place where "is anything still open?" is computed. pick keeps today's ask
semantics wholesale, including partial answers and the multi-question form — that
behaviour is operator-settled (2026-09-09) and is preserved, not redesigned.
flag is the one that makes a 270-image booth tractable, and it closes a loop
that is currently running through chat: golden-candidates, sindra-finalists
and the pancake-* ladders are all the operator picking winners from a set and
then telling the session in conversation. Flags make that readable by the
session that posted the set.
4. Bench — a running thing, registered
Not a booth. The operator's rule, stated by him and now enforced in code:
Links are for durable middle-to-long-term testing surfaces — jackdaw's current bench, talk's current bench — the things that get promoted to Homepage when fully deployed.
BENCH
id : normalized URL (the identity — re-posting UPDATES, never appends)
name : what it is
owner : the agent handle that registered it
state : live → promoted (to Homepage) → retired
health : last_checked, last_ok (probed; flagged when dead, never auto-deleted)
- Stored at
~/booth-data/.benches.json— a dotfile, outside the booth namespace, so it can neither be swept nor mistaken for a booth. booth bench add <url> "<what>"upserts on the normalized URL. The 5talkrows and 4peedlarrows collapse to one each, by construction.booth linkrefuses a…:8090/b/…URL and names the right surface. It survives as a deprecated alias rather than vanishing — 17 handles have the muscle memory, and the teaching moment belongs at the point of use.- Liveness is flagged, not enforced. A bench that stops answering gets a marker and a date; deleting is the operator's call. Nothing here deletes the operator's data on a timer.
Migrating the 211 existing rows
links.mdis archived verbatim to~/booth-data/links/links-archive-<date>.mdand committed to this repo. Nothing the operator wrote is destroyed.- The ~40 durable rows seed the registry, deduplicated.
- The 145 dead rows and 26 live booth-links are not carried over — the first point at nothing, the second are booths, which the index now announces itself.
The frame: who owns the page
Operator ruling, 2026-09-21: the page declares itself, the Booth mounts into it.
A report includes one line:
<script src="/_booth/embed.js" defer></script>
…and the Booth's chrome — nav, marks, annotations, the way home — mounts through
a real DOM API. Marks land at data-booth-mark="<id>" anchors, which keeps the
2026-09-09 ruling that asks render inline beside the artifact, not on a separate
page. If the line is absent, the Booth injects it at one insertion point, so
every existing verbatim booth keeps working untouched.
What this deleted — landed as U3, 2026-09-22:
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 toapp.pybeside the route that renders them.wrap_verbatim_htmland its six regexes against arbitrary HTML (_HEAD_CLOSE_RE,_HTML_OPEN_RE,_DOCTYPE_RE,_BODY_CLOSE_RE,_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>/askswas already a 308 into/marksby 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.
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
Sized for the real case — 270 items — not the demo case.
- Sections from subfolders (free; the data is already there).
- A sticky rail: total, per-section counts, and filters — all / flagged / annotated / unanswered. Jump-to-section.
- Keyboard in the grid, not only in the viewer. Today prev/next exists only
inside the zoom view:
←/→move,fflags,nopens a note,Enterzooms,Escbacks out. A 270-item review is a keyboard task. - Zoom keeps the item's annotations — the same item record, so the caption, notes and flag state are all present at full size.
- Compare, detected rather than configured: when two subfolders share
filenames, offer pair-by-name A/B. This is the
pancake-v3-full/pancake-v4-fulldance — currently four separate booths diffed by eye across two browser tabs — collapsed into one booth with two subfolders.
Non-goals, kept from the original and still right
- No auth. LAN/mesh-internal, and blur stays explicitly cosmetic. Anything
that must not be seen by whoever can reach
:8090must not be in a booth. - No database. The filesystem stays the state;
ls ~/booth-datastill tells you everything. Marks, benches and booth metadata are all flat files. - No upload API for booths. A session makes a folder. That is the whole API, and it is why every agent family can use this without a client.