Files
booth/docs/design/information-architecture.md
T
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

317 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.