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.
This commit is contained in:
vh
2026-09-22 13:25:32 -07:00
parent 91fd8bc69d
commit 1c3ce5ddb5
16 changed files with 2277 additions and 26 deletions
@@ -0,0 +1,39 @@
# The 69% link-board rot was two defects wearing one number
_2026-09-22 · booth_
**Re-measuring the board before writing U6's contract split its headline number
in half, and the half U6 owns is the smaller one.** The IA doc records *211
rows, 145 (69%) pointing at booths that no longer exist*. Re-counted on
2026-09-22 the board was 221 rows — and the split nobody had taken before:
| | count | share |
|---|---|---|
| rows that are booth URLs | **178** | 80% of the board |
| …whose booth is already swept | **156** | **71% of the whole board** |
| rows that are NOT booth URLs | 43 | 19% |
| …distinct after full-URL normalization | 35 | |
| …collapsed by the re-post problem U6 names | **8 rows** | |
So the 69% is:
1. **Booth-announcement rot — 178 rows.** A session posted a booth URL because
a booth could not announce itself. **U5 already closed the cause.** Nothing
stopped the habit, so the board took 11 more of these in the day after it was
first measured.
2. **Bench re-post — 8 rows.** An append log with no identity. This is the part
the registry fixes, and it is an order of magnitude smaller.
**The third thing, which the IA doc does not describe at all:** of the 35
distinct non-booth targets, roughly **14 are running services (benches)** and
roughly **14 are reference bookmarks** — gitea repos, HuggingFace model cards, a
vLLM recipe, a Headscale page — with the rest ephemeral one-shot links. The IA
doc planned for `booth link` to survive "as a deprecated alias". That would have
evicted a third of the board's live content from the only home it has. **U6 does
not deprecate `booth link`**; it removes exactly one shape from it.
**Why this is worth keeping.** The single 69% figure implies the registry is the
big win. It is not — the enforced rule and the dead marker are. A unit scoped
off the unsplit number would have built the registry, declared victory, and left
178 rows rotting. Re-measure before contracting; the number in the design doc is
a day old the moment it is written.
@@ -0,0 +1,65 @@
# U6 landed — three surfaces, three jobs, one predicate
_2026-09-22 · booth_
**The sixth of seven v1 units. Only U7 is left.** 444 → 555 tests, suite green,
deployed and verified live: 23/23 booths 200, and the board renders **156 dead
of 221 rows** — the exact count an independent shell measurement produced before
a line of code was written, from two different implementations.
## What shipped
- **`booth/benches.py`** (new, stdlib-only AND sibling-free): `Bench`,
`normalize_bench_url`, lenient `read_benches`, strict `upsert_bench`,
`set_bench_state`, `remove_bench`, `order_benches`. Registry at
`~/booth-data/.benches.json` — a dotfile at the DATA ROOT, keyed by id, so two
rows with one identity are impossible by construction.
- **`links.booth_target`** — ONE predicate for "is this a booth URL", consumed
by three callers (the CLI refusal, the board's dead marker, `bench import`).
Host-agnostic and path-shaped; percent-decodes the name.
- **`booth link` refuses a booth URL**, names `booth new --why`, and writes
nothing — not even the board directory.
- **The board marks dead rows.** Removal stays the operator's two clicks through
the bulk control that already existed. Nothing in the unit deletes a row.
- **`booth bench add|ls|state|rm|import`**; `import` writes nothing without
`--apply` and never touches `links.md`.
- `docs/archive/links-2026-09-22.md` — the board archived verbatim into git.
## The decision that mattered most, and it was measured
**Identity is the FULL normalized URL, not the origin.** Collapsing the 43
non-booth rows by origin gives 19 groups; by full URL, 35. The difference is not
duplication — it is **eight distinct gitea repos merged into one**, three
unrelated HuggingFace model cards merged into one, and **the two LRPG surfaces
on `10.100.10.50:8321`, which are the IA doc's own example of two real benches**,
merged into one. Origin identity destroys more than it dedups. Full-URL identity
still collapses both cases the doc names (talk 5→1, Peedlar 3→1).
Query is IN the identity (three ShutterChute rows differ only by `?token=` and
are three real links); fragment is OUT; credentials are REFUSED, not stripped.
## The seam review earned it again — three real contract defects
Run in-session against the real `.py` files, after the cold panel was dispatched:
- **SR-1** — the contract claimed `test_stdlib_only` already forbids sibling
imports. **It does not**: its failure set is `{r for r in roots if r !=
"booth" and ...}`, which exempts `booth` on purpose. Only test_manifest.py has
the strict copy. INV-9 would have shipped untested.
- **SR-2** — the contract named `resolve_booth` as the dead marker's existence
check. That function is a closure inside `create_app` and **raises
HTTPException(404)** — per row, one swept booth would 404 the whole board page.
- **SR-7** — booth links are emitted through `quote(name, safe="")`, so a
predicate comparing the raw segment marks every encoded-name booth dead
forever.
SR-4 and SR-5 were **verified rather than assumed**: both `list_booths` and
`sweep_once` skip a child that is not a directory AND one whose name starts with
a dot, so the registry is safe from the sweeper by two guards, not one. Had
either been absent the design would have eaten its own registry on tick one.
## Still open at the time of writing
Both cold gates are IN FLIGHT — contract review `01M35BWCJ806MT75NA630Y4WFH`,
code review `01M35CK8YKEKMV7T15JXEF6A8N`. The bug-hunt has not run. **Committed
but NOT tagged**, per the v0.2.0 lesson: if a gate is outstanding, the tag waits.