Files
booth/docs/design/information-architecture.md
vh 1c3ce5ddb5 feat(u6): benches — a registry with identity, and the rule enforced
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.
2026-09-22 13:25:32 -07:00

15 KiB
Raw Permalink Blame History

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:

<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.