feat(u6): benches — a registry with identity, and the rule enforced
The standing link board carried three jobs because only one of them had a surface. Re-measured before contracting, its 221 rows split into 178 booth announcements (156 already dead) and 43 non-booth rows, of which 8 are the same bench re-posted. U5 gave the booth announcement a home; this gives the running service one, and refuses the one shape that now has somewhere better to go. - booth/benches.py (new, stdlib-only and sibling-free): the Bench record, URL normalization as the identity, a lenient read on the render path and a strict read on the write path, atomic replace under an flock, and a stated total order (state rank, name casefolded, id). - links.booth_target: ONE predicate for "is this a booth URL", consumed by the CLI refusal, the board's dead marker and bench import. Host-agnostic, path-shaped, percent-decoded, never raises. - booth link refuses a booth URL, names `booth new --why`, and writes nothing — not the row, not the board directory, not the announcement. - The board marks rows whose booth has been swept. Nothing here deletes a row: removal stays the operator's two clicks through the existing bulk control. - booth bench add|ls|state|rm|import. import writes nothing without --apply and never edits links.md. - docs/archive/links-2026-09-22.md: the board archived verbatim into git. Identity is the FULL normalized URL, not the origin, and that was measured: origin identity collapses the 43 non-booth rows to 19 groups by merging eight distinct gitea repositories into one row, three unrelated HuggingFace model cards into one, and the two LRPG surfaces on 10.100.10.50:8321 — the design doc's own example of two real benches — into one. Full-URL identity still collapses both cases that doc names: talk 5 to 1, Peedlar 3 to 1. booth link is NOT deprecated. Roughly 14 of the 35 distinct non-booth targets are reference bookmarks for which the board is the right and only home; the design doc's plan to deprecate it would have evicted a third of its live content. Corrected there, along with what "normalized URL" means. The seam review found three real defects in the contract before any code: the claim that test_stdlib_only already forbids sibling imports (it exempts `booth` on purpose), naming resolve_booth as the dead marker's existence check (it raises HTTPException(404), so one swept booth would have 404'd the whole board page), and silence on percent-encoding (booth links are emitted through quote(name, safe=""), so a raw comparison marks every encoded booth dead forever). That both list_booths and sweep_once skip the registry was verified against the real functions rather than assumed. 444 -> 555 tests. Deployed and verified live: 23/23 booths 200, and the board renders 156 dead of 221 rows, matching an independent pre-implementation count. NOT TAGGED: both cold gates are in flight (contract review 01M35BWCJ806MT75NA630Y4WFH, code review 01M35CK8YKEKMV7T15JXEF6A8N) and the bug-hunt has not run. Per the v0.2.0 lesson, the tag waits for the gates.
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
---
|
||||
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/<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.
|
||||
|
||||
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 <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.** 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 <url> <name> register or update; prints created/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 [--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.
|
||||
Reference in New Issue
Block a user