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.
30 KiB
contract_version, module, purpose, depends_on, language, complexity, estimated_loc, confidence, used_by, touches, assumptions, open_questions
| contract_version | module | purpose | depends_on | language | complexity | estimated_loc | confidence | used_by | touches | assumptions | open_questions | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1.0 | booth.benches | 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. |
|
python | medium | 320 | 0.80 |
|
|
|
|
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:
-
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.jsonand 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. -
Bench re-post (8 rows).
booth linkis an append with no identity, so re-announcing a bench creates a row rather than updating one:talkfive 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 URLstays, 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
@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
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:
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.jsonannouncement thatbooth linkcreates 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-manycontrol 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
links.mdis archived verbatim to~/booth-data/links/links-archive-2026-09-22.mdand 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.booth bench importproposes; the operator applies.- 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.