Files
booth/docs/contracts/u6_benches.contract.md
T
vh 8cb21193dc fix(u6): fold the cold bug-hunt panel — a div in a span, a symlink split, and an append outside its lock
/heid-bug-hunt panel 01M35CRRK2RTVWWF1BN09AFQG3, diff-scoped against 91fd8bc.
The most severe of the three rounds, and three of its four convergent findings
were already closed by our own adversarial pass before the reply landed. Three
were not.

- The benches panel was nested inside the booth header's <span class="sub">.
  The insertion had matched the first `{% if board %}` in the template rather
  than the block-level one. A div inside a span is invalid HTML: the parser
  closes the span implicitly and hoists the div out, orphaning the rest of the
  sub-line. Nothing 500s, which is precisely why no test in this suite could
  see it. Moved to block level, pinned by an offset assertion, and verified
  with a real HTML parser.

- _booth_exists used a bare is_dir() while resolve_booth resolves and requires
  the parent to BE the data root. They disagreed on a symlink: the marker
  called a booth pointing outside the root alive while the page 404s it, so the
  row rendered healthy and the link was dead. Same containment now, and
  ValueError joins OSError in the guard -- one bad row must never cost the
  other 220.

- The board append opened its fd OUTSIDE the lock. `flock LOCK printf ... >>
  board` reads as locked and is not: the shell opens the append fd while
  parsing, before flock acquires. A concurrent unlink replaces the inode via
  os.replace, the old fd still points at the unlinked one, and the append
  succeeds, reports success, and vanishes. Pre-existing rather than this
  unit's, but it is silent data loss in the file this unit lives in. Proved by
  holding the lock and asserting nothing is written.

- The atomic write used a predictable .tmp.<pid> name; a pre-planted symlink
  there redirects the write straight through the replace. mkstemp with O_EXCL
  in the same directory, and an fsync before the replace -- os.replace orders
  the rename, not the data behind it.

Declined and recorded: on a host where booth.links cannot be imported, `booth
link` now refuses every URL rather than only booth ones. True, and kept. A
guard that fails open is not a guard, and that state is a broken install in
which most of the CLI is equally broken.

The sharpest line in the reply is one three arms found independently: this repo
had ALREADY paid for the RecursionError class in marks.py, and the new module
re-introduced the unguarded parse. Reading the new module in isolation would
never have surfaced that.

604 -> 607 tests.
2026-09-22 14:20:06 -07:00

582 lines
44 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.
---
contract_version: "1.0"
module: "booth.benches"
purpose: "A bench is a running thing, registered -- not a booth, and not a bookmark. The standing link board absorbed all three jobs because only one of them had a surface, and it now carries 221 rows of which 178 (80%) are booth announcements and 156 (71% of the whole board) point at booths that were swept. U5 gave the booth announcement a home; this unit gives the RUNNING SERVICE one, and closes the loop by refusing the one shape that now has somewhere better to go. Identity is the normalized URL, so re-announcing a bench UPDATES its row instead of appending a fifth -- `talk` is on the board five times and Peedlar's root three. Nothing on the board is deleted by this unit: the dead rows are MARKED so the operator can see and remove them with the bulk control that already exists."
depends_on:
- "booth.links (`booth_target` is DEFINED here and consumed there -- see INV-2. The board's existing parse/remove/pin machinery is untouched: rows keep their content-hash identity, `links.md` stays an O_APPEND multi-writer log, and no row is rewritten by anything this unit adds.)"
- "booth.app (the dead-row marker needs a booth-exists predicate. IT CANNOT USE `resolve_booth`: that is a CLOSURE inside `create_app`, not importable, and it RAISES HTTPException(404) -- calling it per row would turn one swept booth into a 404 for the whole board page, which is the opposite of the marker's purpose. The marker gets its own non-raising predicate carrying the SAME name-safety rules (no leading dot, no separator, no `..`) and returning False where `resolve_booth` raises. A row is dead when its target directory is absent, not when its target is nearly expired -- no new lifetime arithmetic. Verified against the real function, not assumed: seam review SR-2.)"
language: "python"
complexity: "medium"
estimated_loc: 320
confidence: 0.80
used_by:
- "scripts/booth (`bench add|ls|state|rm|import` are new; `link` gains ONE refusal and is otherwise unchanged)"
- "booth.app.booth_view (the board's rows gain a `dead` stamp; the benches panel renders on the standing board's page)"
- "booth.app.list_booths (unchanged -- named here because it was checked and does NOT need to change: benches live outside the booth namespace and are invisible to it)"
touches:
- "booth/benches.py (new -- the record, normalization, the lenient read, the atomic upsert, the stated order)"
- "booth/links.py (ONE new function, `booth_target`. No existing function changes.)"
- "booth/app.py (`_board_rows` stamps `dead`; the booth view passes `benches`; three POST routes for add/state/remove)"
- "booth/templates/booth.html (the benches panel; the dead-row marker on a board row)"
- "booth/templates/base.html (the .bench-* and .board-dead CSS)"
- "scripts/booth (the five bench verbs, the link refusal, the usage block, the header doc block)"
- "tests/test_benches.py (new)"
- "tests/test_cli.py (the bench verbs and the refusal, run against the real script under system python3)"
- "tests/test_marks.py (test_stdlib_only's parametrize list gains `benches`)"
- "docs/design/information-architecture.md (two corrections the measurement forces -- see 'What the measurement changed')"
- "ROADMAP.md (the bench listing order rule, which was one of the two undecided rows in the deterministic-order table)"
assumptions:
- "IDENTITY IS THE FULL NORMALIZED URL, NOT THE ORIGIN, AND THIS WAS MEASURED RATHER THAN CHOSEN. Collapsing the board's 43 non-booth rows by origin yields 19 groups; by full URL, 35. The 16-group difference is not duplication -- it is EIGHT distinct gitea repositories merged into one row, THREE unrelated HuggingFace model cards merged into one, and the two LRPG surfaces on `10.100.10.50:8321` (`Authoring Studio.dc.html` and `GM Playback.dc.html`) merged into one, which are the IA doc's own example of two real benches. Origin identity would have destroyed more than it deduplicated. Full-URL identity still collapses both cases the IA doc named: `talk` 5 rows to 1, Peedlar's root 3 to 1."
- "THE QUERY STRING IS PART OF THE IDENTITY, the fragment is not. Measured: three ShutterChute rows differ ONLY by `?token=`, and they are three genuinely different one-shot links, not one bench posted three times -- dropping the query would merge them into a bench that is none of them. A fragment is a position inside a page, never a different resource, so it is dropped. Userinfo (`user:pass@`) is REFUSED rather than stripped: a credential must not reach a board that renders on an unauthenticated LAN surface, and silently stripping it would register a bench whose URL no longer works while telling the poster it succeeded."
- "`booth_target` IS HOST-AGNOSTIC AND PATH-SHAPED. A row is a booth link when its path is `/b/<name>` or `/b/<name>/...`, whatever the host. NOT a host allowlist: the fleet reaches this service as `10.100.10.50:8090`, `localhost:8090` and `nh3-dev.nh3.internal:8090`, and an allowlist would silently fail to refuse from whichever name somebody used next -- a rule that fails OPEN on the exact case it exists to catch. The accepted cost is that a third-party URL with a `/b/<x>` path would be misread; the failure is visible (a refusal naming the reason, or a row marked dead) rather than silent, and no such URL exists on the board today."
- "NOTHING THIS UNIT SHIPS DELETES A ROW. ROADMAP names 'a migration that deletes anything' as explicitly not in v1. `links.md` is archived verbatim before the registry is seeded, the import writes nothing without `--apply`, and the 156 dead rows are MARKED, not pruned -- removal stays the operator's two deliberate clicks through the `unlink-many` control that has existed since before this unit. The marker is what makes the existing control usable at 221 rows; it is not a second delete path."
- "THE SERVICE NEVER PROBES THE NETWORK. `read_benches` is a filesystem read on the render path, exactly like `read_manifest` and `marks_for`. A bench's liveness is not checked by this unit at all -- see Out of scope, where the decision and its reversal cost are stated."
- "`booth/benches.py` IS STDLIB-ONLY and joins the CLAUDE.md invariant 1 list, for the same reason `manifest.py` did: `scripts/booth` imports it through a `python3 -c` heredoc under the system python3 with no venv. It must also be SIBLING-FREE -- it does not import `links`, `marks`, `asks` or `manifest`, because a cross-import between two stdlib-only modules is a second way for that invariant to break. `booth_target` therefore lives in `links.py` (the board's module, where the board's callers already are) and `benches.py` does not call it; the CLI and `app.py` each import both."
- "THE REGISTRY IS ONE FILE AT THE DATA ROOT, `~/booth-data/.benches.json` -- a dotfile OUTSIDE the booth namespace. It is therefore not a booth, cannot be swept, cannot be mistaken for one by `list_booths` (which iterates directories), and needs no exclusion rule anywhere. Single-writer with many readers, like marks and unlike `links.md`: the operator in one browser plus CLI calls, so it is a per-file atomic replace under an flock on the read-modify-write, NOT an append log. Inheriting the append-log shape here would be the multi-writer/single-writer mistake CLAUDE.md names."
- "THE ON-DISK SHAPE IS AN OBJECT KEYED BY ID, not a list. Two rows with the same identity are then impossible BY CONSTRUCTION rather than by an upsert remembering to check -- which is the whole point of giving a bench an identity. The rendered order is separate and stated (INV-4); the file's key order is not load-bearing and is never read as an order."
open_questions:
- "ONE BENCH, TWO URLS. `talk` is reachable as both `https://talk.nh3.phasefinal.com:8092/` (trusted cert) and `https://10.100.10.50:8092/` (internal IP, cert warning), and both are on the board with descriptions that say so. Full-URL identity correctly keeps them as two rows, because they ARE two URLs -- but they are one bench. An alias field would merge them; so would letting a bench carry a list of URLs. Neither is designed here: aliasing is a judgment about what counts as the same thing, the registry is ~14 rows, and two rows for one bench is legible. Deferred, not solved."
- "WHETHER `booth link` SHOULD ALSO NUDGE TOWARD `bench add` for a URL that looks like a service root. It is not refused -- measured, roughly 14 of the 35 distinct non-booth targets are reference bookmarks (repos, model cards, docs) for which the board is the right and only home, so a second refusal would break a job the board legitimately still does. A non-blocking hint is defensible and is not in this unit."
---
# U6 — benches
## The defect, stated precisely
Re-measured 2026-09-22 against the live board, because the numbers in the IA
doc are a day old and the board grew:
| | IA doc, 2026-09-21 | today |
|---|---|---|
| rows on the standing board | 211 | **221** |
| rows that are booth URLs | not split out | **178 — 80% of the board** |
| …whose booth no longer exists | 145 (69%) | **156 — 71% of the whole board** |
| rows that are not booth URLs | ~40 | **43** |
| …distinct after normalization | — | **35** |
The headline number in the IA doc — *69% rot* — is **two different defects
wearing one number**, and separating them is what makes this unit the right
size:
1. **Booth-announcement rot (178 rows).** A session posted a booth URL because
a booth could not announce itself. **U5 closed the cause**: a booth now
carries `.booth.json` and the index is the feed. Nothing yet stops the
habit, so the board took 11 more of these rows in the day since it was
measured. This unit's *enforced rule* is the stopper, and the *dead marker*
is what lets the operator clear what already landed.
2. **Bench re-post (8 rows).** `booth link` is an append with no identity, so
re-announcing a bench creates a row rather than updating one: `talk` five
times, Peedlar's root three. This unit's *registry* is the fix, and it is
the smaller half — which is worth saying plainly, because the IA doc's
single 69% figure implies otherwise.
A third thing the measurement found, which the IA doc does not describe: **the
board has a legitimate residual job.** Of the 35 distinct non-booth targets,
roughly 14 are running services (benches) and roughly 14 are reference
bookmarks — gitea repositories, HuggingFace model cards, a vLLM recipe, a
Headscale setup page. The IA doc plans for `booth link` to survive "as a
deprecated alias". That would deprecate the only home a third of its live
content has. **`booth link` is not deprecated by this unit.** It loses exactly
one shape — the booth URL — and keeps the rest.
## What the measurement changed
Two lines of `docs/design/information-architecture.md` are wrong and are
corrected in the same commit, rather than left for a reader to trip over:
- **`id : normalized URL`** stays, but the doc does not say what normalized
means, and the obvious reading — the origin — is measurably destructive here
(8 gitea repos into one row). The doc gains the rule and the number behind it.
- **"`booth link` … survives as a deprecated alias rather than vanishing"** is
struck. It survives as itself, minus one refused shape, for the reason above.
## The record
```python
@dataclass(frozen=True)
class Bench:
id: str # the normalized URL — the identity, and the dict key on disk
url: str # the URL AS POSTED — what a click goes to
name: str # what it is
owner: str # the althing handle that registered it, or "booth"
state: str # "live" | "promoted" | "retired"
added: str # ISO-8601 with offset, from the FIRST registration
updated: str # ISO-8601 with offset, from the most recent upsert
error: str | None = None # a read-time verdict; never stored
```
`id` and `url` are two fields on purpose. The identity must be normalized so
that re-posting updates; the href must be verbatim so that a URL whose server
cares about a trailing slash, a case-sensitive path or a query still works when
clicked. Collapsing them would make the registry quietly change where a link
goes, which is the kind of bug that surfaces as "the operator clicked a bench
and got a 404" and is never traced back here.
`added` survives re-registration; `updated` does not. That is the same shape as
U5's `created`, and for the same reason: an upsert is the same bench saying
something new about itself, not a new bench.
**`updated` means the last MUTATION of the record, not the last upsert** —
`set_bench_state` bumps it too. Amended after the cold panel read "most recent
upsert" literally and found the code bumping on a state change: the code is
right (a promotion is a change to the record and "last touched" should say so)
and the earlier wording was narrower than what anyone wants the field to mean.
**Caps, and what "applied" means for each — stated per field, because it is
not the same verb for all of them.** The cold paraphrase panel found "applied at
the write and again at the read" readable three ways (refuse / clip-for-display
/ truncate-and-store) with a different build behind each, and 4-of-4 arms
flagged it.
| field | cap | at the write | at the read |
|---|---|---|---|
| `name` | 120 | **truncated** | **truncated** |
| `owner` | 64 | **truncated** | **truncated** |
| `url` | 2048 | **refused** (`normalize_bench_url` raises) | **damage** — reported, never clipped |
| `state` | one of three | **refused** | **damage** |
| `id` | 2048 | **refused**, via the url it is derived from | **not applied** — see below |
`name` and `owner` are display budgets: clipping one costs a few characters in
a panel row. **`url` is not a budget and must never be clipped**, at either end
— INV-7 promises the click goes to the posted address byte for byte, and a
shortened URL keeps that promise in the type system while breaking it in the
browser. Nothing this code writes can store an over-long one; a hand-edited
registry can, and that is damage.
**`id` is capped at the WRITE ONLY, and that asymmetry is deliberate.**
`normalize_bench_url` refuses an input over `URL_MAX`, so nothing this code
writes can exceed it. On the read the id is the dict KEY and it is the locator
every control posts back — `bench state`, `bench rm`, and the panel's remove
button all address by it. Truncating a hand-edited over-long key on read would
produce a row the operator can see and cannot act on, which is strictly worse
than a long one. Amended after the cold panel found the code and the contract
disagreeing here; the code was right.
## Signatures
```python
BENCHES_FILE = ".benches.json" # at the DATA ROOT — not inside a booth
BENCH_LOCK = ".benches.lock"
BENCH_STATES = ("live", "promoted", "retired")
NAME_MAX, OWNER_MAX, URL_MAX = 120, 64, 2048
BENCHES_MAX_BYTES = 256 * 1024
def normalize_bench_url(url: str) -> str:
"""The identity of a bench. Raises ValueError with a reason a human can act
on -- the CLI prints it verbatim.
THE RULE, in full, because it is the identity and a vague identity is worse
than a wrong one:
* surrounding whitespace stripped
* scheme lowercased; anything but http/https is refused
* userinfo (`user:pass@host`) is REFUSED, never stripped
* host lowercased; an empty host is refused
* port dropped when it is the scheme default (80 for http, 443 for https)
* path kept verbatim, except that a bare "/" becomes ""
* query kept verbatim, INCLUDING its parameter order (a query is opaque)
* fragment dropped
"""
def read_benches(root: Path) -> tuple[list[Bench], str | None]:
"""Every registered bench, in the order of `order_benches`, plus a read-time
error or None. NEVER RAISES -- this is on the render path (v0.2.2 lesson)."""
def upsert_bench(root: Path, url: str, name: str, owner: str) -> tuple[Bench, bool]:
"""Register or update by normalized URL. Returns (bench, created).
`added` is preserved on update; `url`, `name`, `owner`, `updated` are
replaced. `state` is preserved on update and is "live" on create."""
def set_bench_state(root: Path, bench_id: str, state: str) -> Bench | None:
"""Move a bench between live / promoted / retired. None if no such bench."""
def remove_bench(root: Path, bench_id: str) -> Bench | None:
"""Drop one bench. Returns the removed record, or None."""
def order_benches(benches: Iterable[Bench]) -> list[Bench]:
"""ORDER: (state rank, name casefolded, id) -- live before promoted before
retired, then alphabetical, with the id as a total tie-break so two benches
sharing a name cannot swap between renders. CLAUDE.md invariant 6."""
```
And in `booth/links.py`, the one addition:
```python
def booth_target(url: str) -> str | None:
"""The booth NAME a URL points at, or None when it is not a booth URL.
ONE PREDICATE, THREE CALLERS -- the CLI's refusal, the board's dead marker,
and the import's classifier. They must agree: a rule that refuses a shape
the board then fails to mark as dead (or the reverse) is two readers of one
truth, which is the bug this repo has now paid for three times.
THE NAME SEGMENT IS PERCENT-DECODED. `app.py` emits booth links through
`quote(name, safe="")`, so a booth whose name needs encoding appears on the
board encoded. Comparing the raw segment against a directory name would mark
every such booth dead and would print the encoded form back at the poster in
the refusal message. Seam review SR-7.
Returns the DECODED name. A path of `/b/` with no name, or a decoded name
that is empty, starts with a dot, or contains a separator or `..`, is not a
booth link (None) — the same rules `resolve_booth` enforces, so the two
cannot disagree about what is addressable.
"""
```
## The enforced rule
`booth link <url>` refuses when `booth_target(url)` is not None:
```
$ booth link http://10.100.10.50:8090/b/sindra-bakeoff/ "the bakeoff"
booth link: that is a booth, and a booth announces itself now.
booth new sindra-bakeoff --why "the bakeoff" (or --why on `booth add`)
the index at http://10.100.10.50:8090/ is the feed.
exit 2
```
Three properties this refusal must have, each of which is an invariant below:
- **It names the alternative.** The teaching moment belongs at the point of use;
17 handles have the muscle memory and a bare "refused" would send them to a
human.
- **It writes nothing — nothing at all.** Not the row, not the board
directory, not the `.booth.json` announcement `booth link` creates on first
use, not a lock file. The test asserts the data root's entries are unchanged,
not merely that `links.md` lacks the row.
*(Amended: this listed two items while INV-3 listed four, so a reader of the
prose alone could conclude a lock file was permissible. One list now, and it
is the strict one.)*
- **It is the ONLY new refusal.** A reference bookmark is still a link.
## What renders
On the standing board's page, above the rows:
- **The benches panel** — each bench as name, URL, owner, state, and the date
it was added; ordered by `order_benches`. Controls to change state and to
remove, both POST, both reversible in one click except remove.
- **A board row whose booth is gone is marked dead** — visibly, with its
checkbox pre-reachable by the existing select-all, so the operator can tick
and use the `unlink-many` control already on the page. **No new delete path.**
**Dead means exactly this, and both halves are load-bearing:**
`booth_target(row.url)` is not None **AND** the name it returns is not a live
directory in the data root. A row that is not a booth link is never dead, no
matter what it points at — the Booth cannot know whether a gitea repo still
exists and must not guess. A booth link whose booth is alive is not dead. No
lifetime arithmetic is involved: a booth one minute from expiry is alive.
*(Stated after 3-of-4 cold arms read the rule two ways — predicate-driven vs
existence-driven — with 221 rows riding on which.)*
A registry that cannot be read renders as a panel carrying its error, never as
an absent panel and never as a 500 — the v0.2.2 lesson, which this repo learned
by returning 500 for `/` and `/healthz` across all 25 booths.
**The panel is gated on PAGE IDENTITY — the booth carries a `links.md` — and
never on content.** A content gate (`board or benches`) hides the panel AND its
registration form exactly when the board is empty and the registry absent,
which is the state a fresh deployment starts in and the one where "no benches
registered yet" is most worth saying. That is the same defect as a damaged
panel rendering as an absent one, one level up. Amended after the cold panel
found the content gate shipped.
## The CLI surface
```
booth bench add <url> <name> register or update; prints registered/updated
booth bench ls list, in the rendered order, with ids
booth bench state <id|url> <s> live | promoted | retired
booth bench rm <id|url> remove one
booth bench import classify the board's rows; WRITES NOTHING
booth bench import --apply <id>... register ONLY the ids you name
`<id|url>` takes EITHER form because the input is normalized before the lookup,
and normalization is idempotent — an id normalizes to itself. So the id `ls`
prints and the raw URL in the operator's scrollback both address the same row.
Pinned by a test, because it is the property that makes the two-form promise
true rather than merely intended.
```
`import` prints three groups — **booth rows** (skipped; `booth_target` matched),
**candidates** (the normalized id beside the raw URL, so a collapse is visible
before it happens), and **refused** (normalization raised, with the reason).
**`--apply` REQUIRES THE IDS. A bare `--apply` is refused.** This is the
unit's sharpest correction and it came from all four arms of the cold paraphrase
panel independently: the first draft registered every candidate, which made the
write path do the exact thing this document's own rationale calls impossible —
**tell a bench from a bookmark by its URL** — silently, to roughly 14 of 35 rows
that belong on the board. The dry run prints ids; the operator names the ones
that are benches; an id that is not a candidate is refused and nothing is
written. There was no selection mechanism between the report and the write, and
the report existed precisely because the decision is not mechanizable.
## The migration
1. `links.md` is archived verbatim to `~/booth-data/links/links-archive-2026-09-22.md`
**and committed to this repo**, before anything else. Nothing the operator
wrote is destroyed, and the archive is version-controlled rather than living
only on one box.
2. `booth bench import` proposes; the operator applies **by naming ids**.
3. The 156 dead booth rows are marked, and removed by him or not at all.
## Scope — the blast-radius pass
`graphify explain` over `remove_link_entry`, `parse_link_entries`,
`order_for_display`, `read_pins` and `toggle_pin`, cross-checked with grep
because graphify cannot see the CLI's `python3 -c` import (it reports the
`app.py` importers and the test callers; `scripts/booth:353` is invisible to it
— the exact blindness CLAUDE.md names).
No existing function in `links.py` changes signature or behaviour. The board's
rows keep their content-hash identity, so every pin, every `unlink` id in the
operator's history, and every concurrent `booth link` append keep working
untouched.
## Out of scope
- **Liveness probing.** The IA doc's BENCH shape carries `last_checked` /
`last_ok`; ROADMAP's v1 row does not — it names *registry, identity, enforced
rule, migration*, and the parking lot already parks the uptime history. This
unit ships none of it, deliberately: it is the only part that does network
I/O, which is the part that reliably takes 2–5 follow-up patches for cases the
first shape did not anticipate — the accretion signature this whole rewrite is
undoing. The record is designed so adding it later is purely additive (the
read is lenient to unknown keys, so an older Booth reading a newer file does
not break). **This is a scope reduction against the IA doc and the operator
can reverse it; the cost of reversing it is one field pair and one CLI verb.**
- **Pruning the board.** Not in v1, by ROADMAP.
- **Bench aliases.** See open questions.
- **A bench page.** A bench is a link to somewhere else; giving it a page here
would make the Booth a directory service.
- **Any change to how booths announce themselves.** That was U5 and it landed.
## Invariants
**INV-1 — one module knows the registry's filename and shape.**
`booth/benches.py` is the only place `.benches.json` is named, parsed or
written. No route body and no CLI branch constructs the path or reads the JSON.
*Falsifiable:* a test that fails if the literal `.benches.json` appears anywhere
outside `benches.py` — and specifically fails under the change that defeats it,
which is a route reading the file directly to save an import. Asserting only
that the panel renders would pass under exactly that change.
**INV-2 — one predicate decides what a booth URL is.** `links.booth_target` is
the only implementation, and the CLI's refusal, the dead marker and the import's
classifier all call it.
*Falsifiable:* the defeating change is a second implementation — a `/b/` check
inlined in the shell for speed, or a regex in `app.py`. One table of URLs
(trailing slash, no slash, nested path, query, uppercase host, a non-Booth host
with a `/b/` path, a `/b/` with no name, a percent-encoded name, a decoded `..`
and a decoded separator) runs through the predicate, the CLI's refusal AND the
render's dead marker.
**AGREEMENT IS THE WEAKER HALF AND IS NOT THE TEST.** Three callers of one
wrong predicate agree perfectly, so agreement alone pins nothing — the table's
**expected values** are the independent check, and the agreement rows exist to
catch a second implementation drifting from the first. Both are asserted; only
one of them would survive `booth_target` itself being wrong. *(Named after a
cold arm pointed out that the falsifier reads as though agreement were
sufficient.)* **A bare `/b/` with no name is NOT a booth link**, and the table
pins that.
**INV-3 — a refused link writes nothing.** No row, no board directory, no
`.booth.json`, no lock file.
*Falsifiable:* the defeating change is moving the refusal after the `mkdir -p` /
`announce` block in the `link` branch — which is where it would naturally land
if written without thinking. The test refuses a link into a data root with NO
`links` booth and asserts the directory still does not exist, not merely that
`links.md` lacks the row. Asserting the row's absence alone would pass under the
defeating change.
**INV-4 — the rendered bench order is total and stated.** `(state rank, name
casefolded, id)`.
*Falsifiable:* the defeating change is dropping the `id` tie-break, which leaves
two benches sharing a name in whatever order the dict yielded. The test
registers two benches with the SAME name in both insertion orders and asserts
the same output sequence from both. A test over distinct names would pass with
no tie-break at all.
**INV-5 — the read cannot raise, and cannot cost the caller unboundedly.**
`read_benches` returns `([], "...")` for damaged, absent, oversized, or
unreadable; it never propagates. Over `BENCHES_MAX_BYTES` is refused by size
before it is parsed.
*Falsifiable:* the defeating change is `json.load` without the guard. The test
GETs the standing board's page with the registry (a) absent, (b) holding
non-JSON bytes, (c) holding valid JSON of the wrong shape, (d) holding a
well-formed record with a wrong-typed field, (e) over the size cap, and (f)
chmod'd unreadable, asserting 200 for all six AND that (b)–(f) render a visible
error rather than an empty panel. Case (d) is the one that matters: it is the
shape that is currently 500ing the gallery elsewhere in this service.
**INV-6 — the identity collapses a re-post and nothing else.** Upserting the
same normalized URL updates one row; upserting two URLs that differ in **scheme,
host, non-default port, path, or query** creates two. **That list is
EXHAUSTIVE** — the only things normalization discards are a fragment, a
scheme-default port, letter case in the scheme and host, a bare `/` path, and
surrounding whitespace.
*(Amended: this said "path, query or host" with no "only", which reads as
illustrative and left an implementer free to "fix" the rule from the
invariant's wording — and it omitted scheme and port, two of the five. 3-of-4
cold arms flagged it; the falsifier now carries vectors for both.)*
*Falsifiable:* the defeating change is normalizing to the origin. The test
registers the eight gitea URLs measured on the live board and asserts **eight**
benches, then registers `talk`'s five rows and asserts **one** — the same
fixture proves both directions. A test that only checked the talk collapse would
pass under origin normalization, which is precisely the wrong rule.
**INV-7 — `url` is what a click goes to; `id` is never rendered as an href.**
*Falsifiable:* the defeating change is rendering `bench.id` in the anchor
because it is "the clean one". The test registers a URL whose normalization
differs from its raw form — **an uppercase host, an explicit default port, and
a fragment** — and asserts the anchor's `href` is the raw string, byte for byte.
*(Amended: this parenthetical used to name "a trailing slash on a non-empty
path" as one of the differences. **It is not one** — the rule list keeps a
non-empty path verbatim, slash included, and INV-6 makes `…/p` and `…/p/` two
benches. Two passages of this document disagreed about the same character, and
3-of-4 cold arms found the contradiction. The rule list is correct; this
sentence was wrong.)*
**INV-8 — nothing this unit ships removes a board row.** The dead marker is a
render-time stamp; `import` without `--apply` writes nothing anywhere; `import`
with `--apply` writes only the registry and its lock sidecar (`.benches.json`,
`.benches.lock`) and never touches `links.md`.
*(Amended: this said "writes only `.benches.json`", which contradicted the
unit's own assumption that every read-modify-write is held under an flock on a
sidecar. The cold panel caught the contract arguing with itself. The
load-bearing half — `links.md` is not touched — is unchanged and is what the
test hashes.)*
*Falsifiable:* the defeating change is `import --apply` "tidying up" the rows it
consumed. The test snapshots `links.md` byte for byte, runs the full unit's CLI
surface against it — refusal, import, import --apply, bench add, bench rm — and
asserts the file is unchanged, including its mtime-independent content hash.
**INV-9 — stdlib-only, and sibling-free.** `booth/benches.py` imports nothing
outside the standard library and nothing from `booth.*`.
*Falsifiable:* the defeating change is `from booth.links import booth_target` —
which is the natural thing to write, since `booth_target` is the predicate this
unit's CLI branch also needs.
**The existing parametrized `test_stdlib_only` in tests/test_marks.py DOES
NOT CATCH THAT, and an earlier draft of this contract claimed it did.** Its
failure set is `{r for r in roots if r != "booth" and r not in
sys.stdlib_module_names}` — it exempts `booth` explicitly, so a sibling import
passes it clean. The sibling-free clause exists only in the stricter copy in
tests/test_manifest.py. Adding `benches` to the parametrized list therefore
buys stdlib-only and NOT sibling-free. So: `benches` joins that list AND
`tests/test_benches.py` carries its own stricter copy, mirroring `manifest`'s,
which fails on a `booth` root. Verified by reading the real test — seam review
SR-1.
## Seam review — what the real sibling surfaces said
Run in-session against the actual `.py` files rather than their contracts,
after the cold panel was dispatched and before any code. Seven checks, five
findings, three of them real defects in this document. `/heid-contract-review`
is artifact-only by design and structurally cannot run this pass: its arms read
this file and are forbidden the siblings it borrows from.
| # | seam | what the real surface said | disposition |
|---|---|---|---|
| **SR-1** | `test_stdlib_only` (tests/test_marks.py) | **The contract was wrong.** It claimed the parametrized test "already carries" the sibling-free clause. It does not — its failure set is `{r for r in roots if r != "booth" and ...}`, which exempts `booth` on purpose. Only tests/test_manifest.py:209 has the strict copy. | **Fixed.** INV-9 now requires both: the parametrize entry AND a stricter copy in `tests/test_benches.py`. Without this the unit would have shipped with its own INV-9 untested. |
| **SR-2** | `resolve_booth` (booth/app.py) | **The contract invited an outage.** It named `resolve_booth` as the existence check for the dead marker. That function is a closure inside `create_app` (not importable) and **raises HTTPException(404)** — called per row, one swept booth would 404 the entire board page. It also calls `.resolve()`, a syscall per row, 178 of them on this board. | **Fixed.** `depends_on` now forbids it explicitly and specifies an own non-raising predicate with the same name-safety rules. Cost stated below. |
| **SR-7** | `quote(name, safe="")` (app.py, booth link emission) | **The contract was silent on encoding.** Booth links are emitted percent-encoded. A `booth_target` comparing the raw path segment to a directory name marks every encoded-name booth permanently dead and echoes the encoded form back in the refusal. | **Fixed.** `booth_target` decodes, and applies `resolve_booth`'s own addressability rules so the two cannot disagree. |
| **SR-6** | `scripts/booth` dispatch (flat `case "$cmd"`, 13 single-word verbs) | Not a defect — a gap. **`bench add` would be the first two-word verb in this script.** Nothing about the existing dispatch anticipates one, and `booth bench` with no sub-verb must not fall through into the generic usage in a way that hides which word was wrong. | **Recorded.** A nested `case` under `bench)`, and a bare `bench` prints the bench verbs specifically. Named so the implementer does not invent a third pattern. |
| **SR-3** | `data_dir` (booth/app.py) vs `DATA` (scripts/booth) | The service resolves and expands its root in `create_app`; the CLI derives it from `$BOOTH_DATA_DIR`. Two independent derivations of one path. | **No change.** This is already true of `links.md`, `.marks.json` and `.booth.json` — pre-existing and out of this unit's scope. Recorded so it is a known property rather than a discovery. |
| **SR-4** | `list_booths` (booth/app.py) | **Confirmed, not assumed.** `if not child.is_dir() or child.name.startswith("."): continue` — `.benches.json` fails both guards. The index cannot see the registry. | **Verified.** The assumption stands on read code. |
| **SR-5** | `sweep_once` (booth/app.py) | **Confirmed, not assumed — and this was the dangerous one.** The sweeper iterates the data root and could in principle delete the registry. It cannot: the same `is_dir()` + leading-dot pair guards it, and `shutil.rmtree` is reached only past both. | **Verified.** Had either guard been absent this unit would have shipped a design that eats its own registry on the first tick. |
**The per-render cost, stated because SR-2 surfaced it.** The dead marker runs
once per board row: 221 rows today, 178 of which parse as booth links and cost
one `is_dir()` each. That is one `stat` per booth row per render of the standing
board's page — and the page already does a `booth_items` walk plus a `hold_read`
per booth on the index, so it is not a new order of magnitude. It is bounded by
the row count, it touches no network, and it is confined to the ONE booth that
carries a `links.md`. If the board ever grows past a few thousand rows this
becomes worth caching; at 221 it would be premature.
## Code review — what the cold panel found
`/heid-code-review` panel `01M35CK8YKEKMV7T15JXEF6A8N`, four arms, verdict
**NOT drift-zero**. Folded in full. Three findings were independently reported
by **all four arms**, which is the signature of a contract clause that was
written as prose and never converted into an assertion.
| # | finding | arms | disposition |
|---|---|---|---|
| **A** | **The panel dropped the added date.** *What renders* says "the date it was added"; `b.added` appeared nowhere in the template and no test asked for it. | 4/4 | **Fixed** — rendered, and pinned by a test. |
| **B** | **`bench ls` printed no ids**, and the truncated URL it printed was not pasteable into `bench state\|rm`. Worse: the test's own docstring *claimed* it printed ids while asserting nothing — a claim standing in for evidence, which is how the drift would have survived CI. | 4/4 | **Fixed** — the id prints whole and last; the test now round-trips what `ls` prints back through `bench state`. |
| **C** | **`bench import` printed the description, not the raw URL**, beside each id — hiding the five-rows-of-talk collapse the clause exists to expose. | 4/4 | **Fixed** — raw URL beside the id, description demoted to a continuation line. |
| **D** | **An IPv6 literal lost its brackets.** `http://[::1]:8080/a` normalized to `http://::1:8080/a` — not another spelling but a BROKEN identity, so a re-post never matches the row. | 3/4 | **Fixed** — bracketed literals are re-wrapped; an *unbracketed* one is refused with a reason rather than guessed at. |
| **H** | **INV-4's tie-break falsifier could not fail.** `_write_all` serializes with `sort_keys=True`, so both insertion orders came back off disk already id-sorted and removing the tie-break left the test green. | 1/4 | **Fixed** — the test now calls `order_benches` directly with records that tie on both prior keys. A vacuous falsifier of exactly the class `persistent-memory.d/2026-09-22-vacuous-falsifiers.md` names, found by a cold reader and not by us. |
| **I** | **An empty board hid the whole panel**, registration form included — the state a fresh deployment starts in. | 1/4 | **Fixed** — gated on page identity. |
| **J** | **The `booth link` refusal could fail OPEN** on a name bash's `$()` erases, because it classified by captured-text emptiness. | 1/4 | **Fixed** — the predicate answers with a `B:`/`N` sentinel, so no name can be mistaken for "not a booth". |
| **K** | A FIFO at the registry path blocked in `open()`; a deeply-nested JSON `RecursionError` escaped the `except (ValueError, OSError)` pair. | 1/4 | **The FIFO half was already fixed** by our own pass before the reply landed. **The RecursionError half was not** — 200k open brackets is 200 KB, well inside the byte cap, and it 500'd the page the function exists to protect. Fixed. |
| **E** | The read does not apply the `id` cap the contract promised. | 3/4 | **Contract amended, code kept.** The id is the locator every control posts back; truncating a hand-edited over-long key would make a row visible and unactionable. |
| **F, G** | INV-5's render test covered 5 of 6 cases and asserted only status 200; INV-2's URL table never ran through the dead-marker render. | 4/4, 3/4 | **Both fixed** — the render test now covers oversized, unreadable and FIFO and asserts the error is *visible*; the full table runs through the marker. |
**Also folded from the per-invariant vacuity pass** (the arms' "what would still
pass" section, which is the single most useful thing the panel produced):
INV-6 had no vector asserting a non-default port is part of the identity, so
"always omit the port" passed every row; INV-3 asserted only that `links/` was
absent, so a refusal touching any other sidecar passed; INV-8's hashed sequence
omitted `bench ls`; INV-9's AST walk is defeated by `__import__("booth.links")`.
All four closed.
**Declined:** nothing. **Amended rather than fixed:** E, `updated`'s meaning,
INV-8's file list, the `registered`/`created` wording, and every line number in
this document's prose — the panel found two already stale, which is the whole
argument against putting them in prose at all.
## Bug hunt — what the cold panel found
`/heid-bug-hunt` panel `01M35CRRK2RTVWWF1BN09AFQG3`, four arms, diff-scoped
against `91fd8bc`. The most severe of the three rounds, and **three of its four
convergent findings were already closed by our own adversarial pass before the
reply landed** — which is the complementarity the skill claims, measured in both
directions on one diff.
| finding | arms | state when the reply landed |
|---|---|---|
| **A single malformed board row blanks the ENTIRE 221-row board.** `%00` in a booth name decodes to an embedded NUL; `Path.is_dir()` raises **ValueError**, not `OSError`; `_board_rows`' blanket handler returns `[]`. Every row vanishes, the page still 200s, nothing says why. | 4/4 | **Already fixed** (control-character guard). |
| **`RecursionError` escapes `read_benches` and 500s the board page.** ~4 KB of nested brackets, well under the byte cap. **Three arms independently cited the precedent: this repo already paid for this exact class in `marks.py`** — the new module re-introduced the unguarded parse. | 4/4 | **Already fixed.** |
| **A FIFO still blocks the render path** while the code comment claims the hang lesson was applied. | 4/4 | **Already fixed** — and the comment that lied about it was the thing that made us look. |
| **IPv6 bracket loss.** Second independent sighting, same root. | 4/4 | **Already fixed** by the code-review round. |
| **The benches panel is nested inside `<span class="sub">`.** A `<div>` in a `<span>`: the parser closes the span implicitly and hoists the div out, orphaning the rest of the sub-line. Nothing 500s, which is why no test could see it. | 3/4 | **OPEN — fixed now.** Moved to block level; pinned by an offset assertion and verified with a real HTML parser (0 block-in-span violations). |
| **`_booth_exists` and `resolve_booth` disagree on a symlink.** The marker called a booth pointing outside the data root alive while the page 404s it — the row renders healthy and the link is dead. | 3/4 | **OPEN — fixed now.** Same containment, same rules. |
| **The board append opens its fd OUTSIDE the lock.** `flock LOCK printf … >> board` reads as locked and is not: the shell opens the append fd while parsing. A concurrent `unlink` replaces the inode via `os.replace`, the old fd keeps pointing at the unlinked one, and the append **succeeds, reports success, and vanishes.** | solo | **OPEN — fixed now.** Pre-existing, not this unit's, but it is silent data loss in the file this unit lives in. Proved by holding the lock and asserting nothing is written. |
| **A pre-planted symlink at the predictable `.benches.json.tmp.<pid>`** defeats the atomic write. The replace is atomic, not safe. | solo | **OPEN — fixed now.** `mkstemp` (O_EXCL, same directory), plus an `fsync` before the replace, because `os.replace` orders the rename and not the data behind it. |
| A successful registration can cross the read cap and poison the registry; an empty board hides the panel. | solo | **Already fixed** by the contract round. |
**Declined, with the reasoning recorded.** Kimi: the `python3 -c` guard under
`set -e` means that on a host where `booth.links` is not importable, `booth
link` now refuses **every** URL, not just booth ones — the refusal mechanism
refuses everything, while the sibling `announce` call degrades gracefully.
**True, and kept as-is deliberately.** A guard that fails open is not a guard,
and the state it describes (the package unreachable from the script that
computes its path from its own location) is a broken install in which `booth
new`, `booth add` and `booth ask` are equally broken. Loud failure with a
message naming what is missing beats silent non-enforcement. Recorded rather
than silently dismissed, because the asymmetry with `announce` is real.
**What the round says about the method.** The two lenses were complementary in
both directions on one diff: the cold panel found three live defects the
in-session pass missed (all three invisible to a test — a layout nesting, a
symlink disagreement, a lock-ordering race), and the in-session pass had already
closed three of the panel's four convergent findings. Neither substitutes for
the other. The sharpest single line in the reply is the one noting this repo had
already paid for the `RecursionError` class in `marks.py` — **a new module
re-introduced a bug the codebase had a test for**, which no amount of
reading the new module in isolation would surface.