The standing link board carried three jobs because only one of them had a surface. Re-measured before contracting, its 221 rows split into 178 booth announcements (156 already dead) and 43 non-booth rows, of which 8 are the same bench re-posted. U5 gave the booth announcement a home; this gives the running service one, and refuses the one shape that now has somewhere better to go. - booth/benches.py (new, stdlib-only and sibling-free): the Bench record, URL normalization as the identity, a lenient read on the render path and a strict read on the write path, atomic replace under an flock, and a stated total order (state rank, name casefolded, id). - links.booth_target: ONE predicate for "is this a booth URL", consumed by the CLI refusal, the board's dead marker and bench import. Host-agnostic, path-shaped, percent-decoded, never raises. - booth link refuses a booth URL, names `booth new --why`, and writes nothing — not the row, not the board directory, not the announcement. - The board marks rows whose booth has been swept. Nothing here deletes a row: removal stays the operator's two clicks through the existing bulk control. - booth bench add|ls|state|rm|import. import writes nothing without --apply and never edits links.md. - docs/archive/links-2026-09-22.md: the board archived verbatim into git. Identity is the FULL normalized URL, not the origin, and that was measured: origin identity collapses the 43 non-booth rows to 19 groups by merging eight distinct gitea repositories into one row, three unrelated HuggingFace model cards into one, and the two LRPG surfaces on 10.100.10.50:8321 — the design doc's own example of two real benches — into one. Full-URL identity still collapses both cases that doc names: talk 5 to 1, Peedlar 3 to 1. booth link is NOT deprecated. Roughly 14 of the 35 distinct non-booth targets are reference bookmarks for which the board is the right and only home; the design doc's plan to deprecate it would have evicted a third of its live content. Corrected there, along with what "normalized URL" means. The seam review found three real defects in the contract before any code: the claim that test_stdlib_only already forbids sibling imports (it exempts `booth` on purpose), naming resolve_booth as the dead marker's existence check (it raises HTTPException(404), so one swept booth would have 404'd the whole board page), and silence on percent-encoding (booth links are emitted through quote(name, safe=""), so a raw comparison marks every encoded booth dead forever). That both list_booths and sweep_once skip the registry was verified against the real functions rather than assumed. 444 -> 555 tests. Deployed and verified live: 23/23 booths 200, and the board renders 156 dead of 221 rows, matching an independent pre-implementation count. NOT TAGGED: both cold gates are in flight (contract review 01M35BWCJ806MT75NA630Y4WFH, code review 01M35CK8YKEKMV7T15JXEF6A8N) and the bug-hunt has not run. Per the v0.2.0 lesson, the tag waits for the gates.
317 lines
15 KiB
Markdown
317 lines
15 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)
|
||
NORMALIZED MEANS THE FULL URL, NOT THE ORIGIN — see below
|
||
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
|
||
is **not deprecated** — 17 handles have the muscle memory, the teaching moment
|
||
belongs at the point of use, and (corrected 2026-09-22, U6) the board has a
|
||
legitimate residual job: of the 35 distinct non-booth targets on it, roughly
|
||
**14 are reference bookmarks** — gitea repositories, HuggingFace model cards,
|
||
a vLLM recipe, a Headscale setup page — for which the board is the right and
|
||
only home. Deprecating it would evict a third of its live content. It loses
|
||
exactly one shape, the booth URL, and keeps the rest.
|
||
|
||
### What "normalized URL" means, and why it is not the origin
|
||
|
||
Corrected 2026-09-22 while U6 was being contracted. This doc said *normalized
|
||
URL* and left it there; the obvious reading is the origin
|
||
(`scheme://host:port`), and that reading is **measurably destructive**.
|
||
|
||
Collapsing the board's 43 non-booth rows by origin yields 19 groups; by full
|
||
URL, 35. The 16-group difference is not duplication:
|
||
|
||
| what origin identity would merge | rows |
|
||
|---|---|
|
||
| eight distinct gitea repositories, issues and package versions | 8 → 1 |
|
||
| three unrelated HuggingFace model cards | 3 → 1 |
|
||
| **the two LRPG surfaces on `10.100.10.50:8321`** — this doc's own example of two real benches | 2 → 1 |
|
||
| two different claude.ai artifact briefs | 2 → 1 |
|
||
|
||
Full-URL identity still collapses both cases this doc names — `talk` 5 rows to
|
||
1, Peedlar's root 3 to 1 — which is the entire win, without the losses.
|
||
|
||
The **query string is part of the identity** and the **fragment is not**: three
|
||
ShutterChute rows differ only by `?token=` and are three genuinely different
|
||
one-shot links, while a fragment is a position inside a page. Credentials in a
|
||
URL are **refused rather than stripped** — stripping registers a bench whose URL
|
||
no longer works while telling the poster it succeeded.
|
||
|
||
### One number that was two defects
|
||
|
||
This doc's headline **69% rot** is two different defects wearing one number, and
|
||
U5 already closed the cause of the larger one:
|
||
|
||
| defect | rows (2026-09-22) | what fixes it |
|
||
|---|---|---|
|
||
| **booth-announcement rot** — a session posts a booth URL because a booth cannot announce itself | 178 rows, 156 already dead | **U5** gave job 5 a home; U6's refusal stops the habit; U6's dead marker clears what landed |
|
||
| **bench re-post** — an append log with no identity | 8 rows | U6's registry |
|
||
|
||
Worth stating plainly because the single figure implies the registry is the big
|
||
half. It is the smaller one.
|
||
- 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.
|