--- 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/` or `/b//...`, 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/` 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 ` 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 register or update; prints registered/updated booth bench ls list, in the rendered order, with ids booth bench state live | promoted | retired booth bench rm remove one booth bench import classify the board's rows; WRITES NOTHING booth bench import --apply ... register ONLY the ids you name `` 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 ``.** A `
` in a ``: 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.`** 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.