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.
273 lines
13 KiB
Markdown
273 lines
13 KiB
Markdown
# 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 link` is an `O_APPEND` write 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
|
||
`.forever` gets 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.
|
||
- `keep` survives 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 `.forever` rate 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 5 `talk`
|
||
rows and 4 `peedlar` rows collapse to one each, by construction.
|
||
- **`booth link` refuses 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
|
||
|
||
1. `links.md` is archived verbatim to `~/booth-data/links/links-archive-<date>.md`
|
||
**and** committed to this repo. Nothing the operator wrote is destroyed.
|
||
2. The ~40 durable rows seed the registry, deduplicated.
|
||
3. 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:
|
||
|
||
```html
|
||
<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 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 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.
|
||
|
||
**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, `f` flags, `n` opens a note, `Enter`
|
||
zooms, `Esc` backs 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-full` dance — 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 `:8090` must not be in a booth.
|
||
- **No database.** The filesystem stays the state; `ls ~/booth-data` still 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.
|