--- 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` (app.py:798), 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. Caps, applied at the write and again at the read: `name` 120, `owner` 64, `id` and `url` 2048, `state` one of three. Each is a display budget, not a storage limit. ## 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.** Not the row, and not the board's `.booth.json` announcement that `booth link` creates on first use — a refused call must not leave a new booth behind as a side effect. - **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.** 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 CLI surface ``` booth bench add register or update; prints created/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 [--apply] classify the board's rows; WRITES NOTHING without --apply, and never touches links.md ``` `import` prints three groups — **booth rows** (skipped; `booth_target` matched), **candidates** (would be registered, with the normalized id beside the raw URL so a collapse is visible before it happens), and **refused** (normalization raised, with the reason). Under `--apply` it upserts the candidates and prints the same three groups plus what it did. It is not automatic and it is not run by this unit: roughly 14 of 35 candidates are reference bookmarks that belong on the board, and a machine cannot tell a bench from a bookmark by its URL. The operator seeds the registry by reviewing that list. ## 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. 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`. The test asserts AGREEMENT rather than behaviour: a 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) is run through the CLI's refusal AND the render's dead marker, and the two must classify every row identically. A test that only checked the refusal would stay green while the marker drifted. **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 path, query or host creates two. *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 (a trailing slash on a non-empty path, an uppercase host, a fragment) and asserts the anchor's `href` is the raw string, byte for byte. **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 `.benches.json`. *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` (tests/test_marks.py:280) 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 at tests/test_manifest.py:209. 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:280) | **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` (app.py:798) | **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` (app.py:704) vs `DATA` (scripts/booth:120) | 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` (app.py:418) | **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` (app.py:386) | **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.