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:
vh
2026-09-22 13:25:32 -07:00
parent 91fd8bc69d
commit 1c3ce5ddb5
16 changed files with 2277 additions and 26 deletions
+21 -7
View File
@@ -1,7 +1,7 @@
# The Booth — roadmap
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
Current version: `0.5.0` (U1, U2, U3, U4 and U5 landed; extracted from eshpfi 2026-09-21).
Current version: `0.5.0` (U1 through U6 landed; extracted from eshpfi 2026-09-21).
## v1 target
@@ -15,17 +15,27 @@ defect — not a wish. The measurements are in the IA doc.
| 3 | ~~**Declared embed seam**~~ — **landed `87e2c53`, released `v0.5.0`** | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 |
| 4 | ~~**Derived lifetime**~~ — **landed `c3a97c1`, released `v0.4.0`** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 |
| 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 |
| 6 | **Benches** — registry, identity, enforced rule, migration | 69% link-board rot; the same bench posted 5× | U6 |
| 6 | ~~**Benches**~~ — registry, identity, enforced rule, migration | 69% link-board rot; the same bench posted 5× | U6 |
| 7 | **Navigation at 270 items** — sections, rail, filters, grid keyboard | one flat wall; subfolder structure discarded at render | U7 |
Ordering is dependency-driven, not priority-driven: **U1 → U2 → {U3, U4, U5} →
U7**, with **U6 independent** of all of them (different storage, different
surface) and therefore the safest thing to land first or in parallel.
**U1, U2, U3, U4 and U5 are landed — the whole middle tier is closed.** U6
remains independent and unstarted; **U7 is now unblocked**, since its only
dependency was `{U3, U4, U5}`. Two units left to v1, and they do not depend on
each other, so either can go next.
**U1 through U6 are landed.** **U7 is the last unit before the 1.0 cut** — its
only dependency was `{U3, U4, U5}` and that closed with U3.
⚠ **Before starting U7, read
`persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md`, and re-count
the booths first.** Half its premise is already known to be wrong — every booth
that actually needs navigation is FLAT — and the booth set churned again on
2026-09-22: the four large booths U7 was sized against (`pancake-v3-full` and
`pancake-v4-full` at 270 items, `sindra20-engines`, `sindra-finalists`) have all
been swept. The largest live booth is now `miranda-is` at 92 items, flat. Two of
23 booths have subfolders (`pewpew-ui-brief`, `dfa-concepts`) and **both are
reports** — the job where grid navigation matters least. Sections, one of U7's
four named components, buys close to nothing. The rail, the filters and the grid
keyboard are the unit.
**U5's adoption is a measured prediction, not a finished result**, and it is
TWO predictions rather than one. The operator declined a fleetwide announcement
@@ -75,6 +85,8 @@ Where it already binds, and what the rule is in each case:
| legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them |
| link board rows | pinned first, then newest-first |
| a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) |
| the bench registry | `(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 (U6) |
| the link board's dead marker | not an order — a per-row stamp read from the existing `order_for_display` sequence, so marking cannot move a row (U6) |
| embed anchors in a verbatim report | **document order** — what `querySelectorAll` yields, so the author's markup decides (U3) |
| the embed tail (fragments the author did not place) | **payload order**, which is the marks order `(created, id)` — one rule, whether a fragment lands at an anchor or at the end (U3) |
| questions within a pick | declaration order, in the payload's `questions` LIST — carried by the format rather than by object-key insertion order (U3) |
@@ -92,7 +104,9 @@ ordering: one rule, one place, every surface reading it.
Where it is still to be decided, and must be before the unit ships: **U7's
section ordering and its compare pairing** (sections need a stated order among
themselves, not just within; pairing by filename needs a rule for what happens
to an unpaired file), and **U6's bench listing**.
to an unpaired file). **U6's bench listing is settled** — the row above.
Compare pairing is parked to v1.1 with compare mode itself, so U7 carries one
undecided rule, not two.
The test for any new ordered surface: *can you write the rule down in one line?*
If not, it does not have one yet.
+88 -1
View File
@@ -165,10 +165,19 @@ from booth.manifest import ( # noqa: E402
read_manifest,
write_manifest,
)
from booth.benches import ( # noqa: E402
BENCH_STATES,
normalize_bench_url,
read_benches,
remove_bench,
set_bench_state,
upsert_bench,
)
from booth.links import ( # noqa: E402
LINK_LOCK,
LINKS_FILE,
PINS_FILE,
booth_target,
link_entry_id,
order_for_display,
parse_link_entries,
@@ -917,6 +926,15 @@ def create_app(
# `read_manifest` already take. A booth whose `links.md` cannot
# be read renders as a booth with no board.
"board": _board_rows(booth),
# The bench registry, rendered on the STANDING BOARD's page and
# nowhere else: it belongs to exactly one booth, and a read per
# gallery page view would buy noise. `_board_rows` is empty for
# every other booth, so this pair is read only when it renders.
# `read_benches` never raises; a damaged registry costs its own
# panel and says so, which is the v0.2.2 lesson.
**dict(zip(("benches", "benches_error"),
read_benches(data_dir) if (booth / LINKS_FILE).is_file()
else ([], None))),
# Marks: operator judgment attached to this booth or to one of
# its items — a session's question (`pick`), the operator's own
# remark (`note`), the operator's selection (`flag`). Rendered
@@ -951,13 +969,41 @@ def create_app(
try:
if not (booth / LINKS_FILE).is_file():
return []
return order_for_display(
rows = order_for_display(
parse_link_entries((booth / LINKS_FILE).read_text()),
read_pins(booth),
)
# DEAD = the row points at a booth that no longer exists. 156 of the
# board's 221 rows are exactly that, and nothing on the page could
# tell them apart, so the bulk-delete control that has existed since
# before this unit was unusable at that scale. Marking is all this
# does: removal stays the operator's two deliberate clicks, because
# "a migration that deletes anything" is not in v1.
for row in rows:
target = booth_target(row["url"])
row["dead"] = target is not None and not _booth_exists(target)
return rows
except (OSError, ValueError, UnicodeDecodeError):
return []
def _booth_exists(name: str) -> bool:
"""Whether a booth name is a live directory. NEVER RAISES.
SEAM REVIEW SR-2: this deliberately does NOT call `resolve_booth`, which
raises HTTPException(404) — called once per board row, one swept booth
would 404 the whole page, which is the opposite of the marker's purpose.
`booth_target` has already applied the same addressability rules
`resolve_booth` enforces, so the two cannot disagree about what is
reachable; all that is left is the existence check itself.
Cost: one stat per booth-shaped row per render of the standing board —
178 of 221 rows today, on the ONE booth that carries a links.md.
"""
try:
return (data_dir / name).is_dir()
except OSError:
return False
def _mark_redirect(name: str, form, anchor: str) -> RedirectResponse:
"""Land where the form was: the standalone marks page for a verbatim
booth (its own index.html cannot show the recorded judgment), else the
@@ -1459,6 +1505,47 @@ def create_app(
toggle_pin(resolve_booth(name), entry)
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
@app.post("/b/{name}/bench-add")
def bench_add(name: str, url: str = Form(...), bname: str = Form("", alias="name")):
"""Register or update a bench by normalized URL.
A rejected URL must not 500 the page it was posted from. THE REJECTION
IS SILENT HERE, and that is stated rather than dressed up: the form's
`type="url"` catches the ordinary typo in the browser before the post,
and this `except` is the last resort for what slips past it — the bench
simply does not appear. Surfacing the reason would need a flash message,
which this service has no mechanism for; inventing one for a path the
browser already guards is not worth a unit's scope.
`resolve_booth` is called for its 404: a POST at a booth that does not
exist is not a silent no-op.
"""
resolve_booth(name)
try:
upsert_bench(data_dir, url, bname, "operator")
except (ValueError, OSError):
pass
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
@app.post("/b/{name}/bench-state")
def bench_state(name: str, bench: str = Form(...), state: str = Form(...)):
resolve_booth(name)
if state in BENCH_STATES:
try:
set_bench_state(data_dir, bench, state)
except (ValueError, OSError):
pass
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
@app.post("/b/{name}/bench-remove")
def bench_remove(name: str, bench: str = Form(...)):
resolve_booth(name)
try:
remove_bench(data_dir, bench)
except (ValueError, OSError):
pass
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
def _safe_next(nxt: str) -> str:
"""Where to land after keep/unkeep. Defaults to the index; a booth page
can ask to stay put. Only same-site absolute paths are honoured — `//`
+341
View File
@@ -0,0 +1,341 @@
"""Benches: a running thing, registered.
A bench is NOT a booth and NOT a bookmark. It is a durable middle-to-long-term
testing surface — jackdaw's current bench, talk's current bench, the things that
get promoted to Homepage when they are fully deployed. The standing link board
absorbed the job because it was the only surface on offer, and an O_APPEND log
with no identity turns "here is the bench again" into a fifth row rather than an
update: `talk` is on the board five times and Peedlar's root three.
STDLIB ONLY, AND SIBLING-FREE, ON PURPOSE. `scripts/booth` imports this through
a `python3 -c` heredoc under the system python3 with no venv, exactly as it
imports `marks`, `asks`, `links` and `manifest`. A third-party import breaks
`booth bench` on every fleet host; a `from booth.links import ...` breaks it on
any host where both modules are not importable together, which is a second way
for the same invariant to fall. `tests/test_benches.py` forbids both.
SINGLE-WRITER, MANY-READER — the opposite shape from `links.md`. The board is a
multi-writer append log because seventeen agent handles post to it at once. This
is the operator in one browser plus occasional CLI calls, so it is one file,
rewritten whole under a lock, replaced atomically. Inheriting the append-log
design here would be the mistake CLAUDE.md names by name.
"""
from __future__ import annotations
import fcntl
import json
import os
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
from urllib.parse import urlsplit, urlunsplit
# At the DATA ROOT, not inside a booth. A dotfile there is invisible to
# `list_booths` and to `sweep_once` — both skip a child that is not a directory
# AND a child whose name starts with a dot, so the registry fails two guards
# rather than one. Verified against both functions (seam review SR-4, SR-5)
# rather than assumed: had either guard been absent, the sweeper would have
# eaten this file on its first tick.
BENCHES_FILE = ".benches.json"
BENCH_LOCK = ".benches.lock"
# live → promoted (to Homepage) → retired. Order is meaningful: it is the
# first key of the rendered order, so a retired bench sinks.
BENCH_STATES = ("live", "promoted", "retired")
_STATE_RANK = {s: i for i, s in enumerate(BENCH_STATES)}
# Display budgets, not storage limits — these land in a panel row.
NAME_MAX, OWNER_MAX, URL_MAX = 120, 64, 2048
# The read is on the render path, so it is bounded. 256 KiB holds thousands of
# benches; the live board has 43 non-booth rows total.
BENCHES_MAX_BYTES = 256 * 1024
_SCHEMES = ("http", "https")
@dataclass(frozen=True)
class Bench:
"""One registered bench.
`id` and `url` are two fields ON PURPOSE. The identity must be normalized so
that re-posting updates rather than appends; the href must be verbatim so a
server that cares about a trailing slash, a case-sensitive path or a query
still works when the operator clicks it. Collapsing them would make the
registry quietly change where a link goes — a bug that surfaces as "the
bench 404s" and is never traced back here.
"""
id: str # the normalized URL — identity, and the key on disk
url: str # the URL as posted — what a click goes to
name: str
owner: str # an althing handle, or "booth" for the service
state: str
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
def normalize_bench_url(url: str) -> str:
"""The identity of a bench. Raises ValueError with a reason a human can act on.
THE RULE, in full, because a vague identity is worse than a wrong one:
* surrounding whitespace stripped
* scheme lowercased; anything but http/https refused
* userinfo (`user:pass@host`) REFUSED, never stripped
* host lowercased; an empty host refused
* port dropped when it is the scheme default (80 http, 443 https)
* path kept verbatim, except that a bare "/" becomes ""
* query kept verbatim INCLUDING parameter order (a query is opaque)
* fragment dropped
WHY THE FULL URL AND NOT THE ORIGIN — measured, not chosen. Collapsing the
live board's 43 non-booth rows by origin yields 19 groups; by full URL, 35.
The 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` merged into one —
which are the information-architecture doc's own example of two real
benches. Origin identity destroys more than it deduplicates. Full-URL
identity still collapses both cases that doc names: talk 5 → 1, Peedlar 3 → 1.
WHY THE QUERY IS IN AND THE FRAGMENT IS OUT. Three ShutterChute rows on the
board differ only by `?token=`; they are three genuinely different one-shot
links, and 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.
"""
raw = (url or "").strip()
if not raw:
raise ValueError("a bench needs a URL")
if len(raw) > URL_MAX:
raise ValueError(f"URL is longer than {URL_MAX} characters")
try:
parts = urlsplit(raw)
except ValueError as exc: # malformed IPv6 literal, etc.
raise ValueError(f"could not parse that URL: {exc}") from exc
scheme = parts.scheme.lower()
if scheme not in _SCHEMES:
raise ValueError(
f"a bench must be http or https, not {parts.scheme or '(no scheme)'}"
)
if "@" in parts.netloc:
# Refused, NOT stripped. Stripping would register a bench whose URL no
# longer works while telling the poster it succeeded — and would put a
# credential on a board that renders on an unauthenticated LAN surface
# on the way there.
raise ValueError("a bench URL must not carry credentials; strip the user:pass@ and re-post")
try:
host = (parts.hostname or "").lower()
port = parts.port
except ValueError as exc: # a non-numeric port
raise ValueError(f"could not read the host or port: {exc}") from exc
if not host:
raise ValueError("that URL has no host")
default = {"http": 80, "https": 443}[scheme]
netloc = host if port in (None, default) else f"{host}:{port}"
# A bare "/" is the same resource as no path at all; a trailing slash on a
# REAL path is not, and is left alone.
path = "" if parts.path == "/" else parts.path
return urlunsplit((scheme, netloc, path, parts.query, ""))
# ---- storage ----------------------------------------------------------------
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _cap(value: object, limit: int, field: str) -> str:
if not isinstance(value, str):
raise ValueError(f"{field} must be text, not {type(value).__name__}")
return value[:limit]
def _bench_from(bench_id: str, row: object) -> Bench:
"""One stored row to a record. Raises ValueError on any shape it cannot
trust — this is the STRICT half, used by the write path and by the read
path's single try/except."""
if not isinstance(row, dict):
raise ValueError(f"{bench_id}: expected an object, found {type(row).__name__}")
state = row.get("state", "live")
if state not in BENCH_STATES:
raise ValueError(f"{bench_id}: unknown state {state!r}")
return Bench(
id=bench_id,
url=_cap(row.get("url", bench_id), URL_MAX, "url"),
name=_cap(row.get("name", ""), NAME_MAX, "name"),
owner=_cap(row.get("owner", ""), OWNER_MAX, "owner"),
state=state,
added=_cap(row.get("added", ""), 64, "added"),
updated=_cap(row.get("updated", ""), 64, "updated"),
)
def _read_bytes(path: Path) -> bytes:
"""Read at most BENCHES_MAX_BYTES + 1 bytes.
BOUNDS THE READ, NEVER THE STAT. A FIFO reports st_size 0 and then blocks
forever; a size cap that trusts `st_size` inherits a meaning it does not
have, and the 2026-09-22 incident in this repo was exactly that — a bound
that opened a service-wide hang. Reading one byte past the cap is how you
learn you are over it without reading the rest.
"""
with path.open("rb") as fh:
return fh.read(BENCHES_MAX_BYTES + 1)
def _load_strict(root: Path) -> dict[str, Bench]:
"""Every bench, or ValueError. The write path's reader.
Whole-file, not per-row: a registry with one unreadable row is a registry
somebody has to look at, and quietly dropping the row is how a bench
disappears without anyone being told.
"""
path = Path(root) / BENCHES_FILE
if not path.exists():
return {}
blob = _read_bytes(path)
if len(blob) > BENCHES_MAX_BYTES:
raise ValueError(f"registry is larger than {BENCHES_MAX_BYTES} bytes")
try:
raw = json.loads(blob.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError(f"registry is not valid JSON: {exc}") from exc
if not isinstance(raw, dict):
raise ValueError(f"registry must be an object keyed by URL, found {type(raw).__name__}")
return {k: _bench_from(k, v) for k, v in raw.items()}
def read_benches(root: Path) -> tuple[list[Bench], str | None]:
"""Every registered bench in the rendered order, plus a read-time error.
NEVER RAISES. This runs on the render path, and the v0.2.2 lesson in this
repo was learned the expensive way: a poisoned `.marks.json` returned 500
for `/` and `/healthz` across all 25 booths. A registry that cannot be read
costs its own panel, never the page.
ABSENT AND DAMAGED ARE DIFFERENT and must render differently — only one of
them needs a human. Absent is `([], None)`; damaged is `([], "why")`.
"""
try:
return order_benches(_load_strict(root).values()), None
except ValueError as exc:
return [], str(exc)
except OSError as exc:
return [], f"registry could not be read: {exc}"
def _write_all(root: Path, benches: dict[str, Bench]) -> None:
"""Atomic replace. Caller holds the lock.
Temp file + os.replace, so a reader never sees a partial file and a crash
mid-write cannot truncate the registry into a shorter — and therefore
quieter — set of benches. CLAUDE.md invariant 5.
"""
root = Path(root)
path = root / BENCHES_FILE
payload = {
b.id: {"url": b.url, "name": b.name, "owner": b.owner,
"state": b.state, "added": b.added, "updated": b.updated}
# The key IS the id, so the record does not carry it twice — two copies
# of one fact is two things that can disagree.
for b in benches.values()
}
tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
os.replace(tmp, path)
class _Locked:
"""Exclusive flock over the whole read-modify-write, on a sidecar."""
def __init__(self, root: Path):
self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True)
self.path = self.root / BENCH_LOCK
def __enter__(self):
self.path.touch(exist_ok=True)
self.fh = self.path.open("r+")
fcntl.flock(self.fh, fcntl.LOCK_EX)
return self
def __exit__(self, *exc):
fcntl.flock(self.fh, fcntl.LOCK_UN)
self.fh.close()
return False
def upsert_bench(root: Path, url: str, name: str, owner: str) -> tuple[Bench, bool]:
"""Register or update by normalized URL. Returns (bench, created).
READS ARE LENIENT, WRITES ARE STRICT — and this is the strict side. A write
over a registry that cannot be parsed RAISES rather than starting a fresh
one: on 2026-09-21 this repo learned that a tolerant writer over a damaged
`.marks.json` wipes the operator's judgment, and a tolerant reader is a
completely different decision from a tolerant writer.
`added` survives an update; `state` survives too, so a promoted bench that
re-announces itself after a deploy is not silently demoted.
"""
bench_id = normalize_bench_url(url)
with _Locked(root):
benches = _load_strict(root) # raises on damaged — deliberate
prior = benches.get(bench_id)
now = _now()
bench = Bench(
id=bench_id,
url=(url or "").strip(),
name=_cap(name or "", NAME_MAX, "name"),
owner=_cap(owner or "", OWNER_MAX, "owner"),
state=prior.state if prior else "live",
added=prior.added if prior else now,
updated=now,
)
benches[bench_id] = bench
_write_all(root, benches)
return bench, prior is None
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."""
if state not in BENCH_STATES:
raise ValueError(f"state must be one of {', '.join(BENCH_STATES)}, not {state!r}")
with _Locked(root):
benches = _load_strict(root)
prior = benches.get(bench_id)
if prior is None:
return None
moved = replace(prior, state=state, updated=_now())
benches[bench_id] = moved
_write_all(root, benches)
return moved
def remove_bench(root: Path, bench_id: str) -> Bench | None:
"""Drop one bench. Returns the removed record, or None."""
with _Locked(root):
benches = _load_strict(root)
gone = benches.pop(bench_id, None)
if gone is None:
return None
_write_all(root, benches)
return gone
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 — the Booth's job is comparison, and an order that
moves between page loads files the operator's judgment against the wrong
row. Pure: no I/O, and the input sequence is not mutated.
"""
return sorted(benches, key=lambda b: (_STATE_RANK.get(b.state, len(BENCH_STATES)),
b.name.casefold(), b.id))
+50
View File
@@ -14,6 +14,7 @@ import hashlib
import os
import re
from pathlib import Path
from urllib.parse import unquote, urlsplit
# ---- the standing link board ------------------------------------------------
#
@@ -194,3 +195,52 @@ def order_for_display(entries: list[dict], pinned: set[str]) -> list[dict]:
stamped = [{**e, "pinned": e["id"] in pinned} for e in entries]
stamped.reverse() # newest first
return [e for e in stamped if e["pinned"]] + [e for e in stamped if not e["pinned"]]
# ---- what counts as a booth link -------------------------------------------
def booth_target(url: str) -> str | None:
"""The booth NAME a URL points at, or None when it is not a booth link.
ONE PREDICATE, THREE CALLERS — the CLI's `link` refusal, the board's
dead-row marker, and `bench 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. `tests/test_benches.py` runs one table through every caller.
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 reads as a booth link; that failure is visible (a refusal
naming the reason) rather than silent, and no such URL is on the board.
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 permanently dead and echo the encoded form back at the
poster in the refusal message.
The returned name passes the SAME addressability rules `resolve_booth`
enforces (non-empty, no leading dot, no separator, no `..`), so the two
cannot disagree about what is reachable.
NEVER RAISES. A board row is arbitrary operator-editable text; a predicate
that raises on one row takes the whole page.
"""
try:
parts = urlsplit((url or "").strip())
if parts.scheme.lower() not in ("http", "https"):
return None
segments = parts.path.split("/")
if len(segments) < 3 or segments[1] != "b":
return None
name = unquote(segments[2])
except (ValueError, UnicodeDecodeError):
return None
if not name or name.startswith(".") or "/" in name or "\\" in name or ".." in name:
return None
return name
+24 -1
View File
@@ -496,7 +496,30 @@
.markdown-body table{border-collapse:collapse;display:block;overflow-x:auto}
.markdown-body th,.markdown-body td{border:1px solid var(--rk-line,#252a35);padding:.4em .7em}
.markdown-body img{max-width:100%}
</style>
/* U6 — the bench registry, on the standing board's page only. */
.benches{margin:1rem 0;border:1px solid var(--line,#2a2a2a);border-radius:6px;overflow:hidden}
.bench-head{display:flex;gap:.6rem;align-items:baseline;padding:.5rem .7rem;background:rgba(255,255,255,.03)}
.bench-title{font-weight:600}
.bench-note,.bench-empty{opacity:.6;font-size:.85em}
.bench-empty{padding:.6rem .7rem}
.bench-err{padding:.6rem .7rem;color:#f2b8b5;background:rgba(242,184,181,.08)}
.bench-row{display:flex;gap:.6rem;align-items:center;padding:.45rem .7rem;border-top:1px solid var(--line,#2a2a2a)}
.bench-row.is-retired{opacity:.5}
.bench-state{font-size:.7em;text-transform:uppercase;letter-spacing:.06em;padding:.1rem .4rem;border-radius:3px;background:rgba(255,255,255,.08)}
.bench-row.is-live .bench-state{background:rgba(120,200,140,.18)}
.bench-row.is-promoted .bench-state{background:rgba(130,170,240,.18)}
.bench-main{flex:1;min-width:0}
.bench-url{font-size:.78em;opacity:.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bench-acts{display:flex;gap:.3rem}
.bench-to,.bench-rm{font-size:.75em;padding:.15rem .4rem;cursor:pointer}
.bench-add{display:flex;gap:.4rem;padding:.5rem .7rem;border-top:1px solid var(--line,#2a2a2a)}
.bench-add input[type=url]{flex:2;min-width:0}
.bench-add input[type=text]{flex:1;min-width:0}
/* A board row whose booth has been swept. Marked, never auto-removed. */
.board-row.board-dead{opacity:.45}
.board-dead-tag{font-size:.9em;color:#f2b8b5;opacity:.9}
</style>
</head>
<body>
<header class="topbar">
+63 -3
View File
@@ -66,7 +66,64 @@
{% else %}
<h1>{{ name }}</h1>
{% endif %}
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}</span>
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board or benches or benches_error %}
{# THE BENCH REGISTRY. A bench is a running thing — jackdaw's current bench,
talk's current bench, the things that get promoted to Homepage when they
are fully deployed. NOT a booth (a booth announces itself and is swept) and
NOT a bookmark (a repo page, a model card — those stay on the board below).
Identity is the NORMALIZED URL, so re-announcing a bench updates its row
instead of appending a fifth. `talk` was on the board five times.
ORDER: state (live → promoted → retired), then name, then id as a total
tie-break so two benches sharing a name cannot swap between renders.
The href is `b.url` — the URL AS POSTED — never `b.id`. The id is
normalized for identity; a server that cares about a trailing slash or a
case-sensitive path would 404 on it. #}
<div class="benches">
<div class="bench-head">
<span class="bench-title">{{ benches|length }} bench{{ '' if benches|length == 1 else 'es' }}</span>
<span class="bench-note">a running thing, registered · re-posting updates the row</span>
</div>
{% if benches_error %}
{# DAMAGED AND ABSENT MUST NOT RENDER THE SAME. Only one of them needs a
human, and the v0.2.2 outage was learned by treating them alike. #}
<div class="bench-err">the bench registry could not be read: {{ benches_error }}</div>
{% elif not benches %}
<div class="bench-empty">no benches registered yet — <code>booth bench add &lt;url&gt; &lt;name&gt;</code></div>
{% endif %}
{% for b in benches %}
<div class="bench-row is-{{ b.state }}">
<span class="bench-state">{{ b.state }}</span>
<div class="bench-main">
<a class="bench-link" href="{{ b.url }}" target="_blank" rel="noopener">{{ b.name or b.url }}</a>
<div class="bench-url">{{ b.url }}</div>
</div>
<div class="bench-meta">
{% if b.owner %}<span class="bench-who">{{ b.owner }}</span>{% endif %}
</div>
<form class="bench-acts" method="post" action="/b/{{ name_url }}/bench-state">
<input type="hidden" name="bench" value="{{ b.id }}">
{% for s in ("live", "promoted", "retired") %}
{% if s != b.state %}
<button type="submit" name="state" value="{{ s }}" class="bench-to">{{ s }}</button>
{% endif %}
{% endfor %}
<button type="submit" class="bench-rm" formaction="/b/{{ name_url }}/bench-remove"
title="remove this bench">&times;</button>
</form>
</div>
{% endfor %}
<form class="bench-add" method="post" action="/b/{{ name_url }}/bench-add">
<input type="url" name="url" placeholder="https://host:port/" required>
<input type="text" name="name" placeholder="what it is">
<button type="submit">register</button>
</form>
</div>
{% endif %}
{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}</span>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
{{ provenance(manifest) }}
{# A durable multi-writer board gets no one-click wipe — same rule as the
@@ -143,14 +200,17 @@
formaction="/b/{{ name_url }}/unlink-many">🗑 delete <span id="board-selcount">0</span></button>
</div>
{% for e in board %}
<div class="board-row{% if e.pinned %} is-pinned{% endif %}">
{# DEAD: the row points at a booth that has been swept. 156 of 221 rows.
MARKED, never removed — removal is the operator ticking the box and using
the bulk control that was already here. #}
<div class="board-row{% if e.pinned %} is-pinned{% endif %}{% if e.dead %} board-dead{% endif %}">
<input class="board-check" type="checkbox" name="sel" value="{{ e.id }}" aria-label="select {{ e.desc }}">
<button type="submit" class="board-pin{% if e.pinned %} on{% endif %}" formaction="/b/{{ name_url }}/pin"
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}"
title="{{ 'unpin' if e.pinned else 'pin to top' }}">{{ '★' if e.pinned else '☆' }}</button>
<div class="board-main">
<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
<div class="board-url">{{ e.url }}</div>
<div class="board-url">{{ e.url }}{% if e.dead %} <span class="board-dead-tag">booth is gone</span>{% endif %}</div>
</div>
<div class="board-meta">
{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}
+234
View File
@@ -0,0 +1,234 @@
# Standing link board — verbatim archive, 2026-09-22
Captured before U6 (benches) shipped, per the ROADMAP rule that a migration
destroys nothing. 221 rows: 178 booth URLs (156 of them pointing at booths
already swept) and 43 non-booth rows, 35 distinct after normalization.
U6 itself deletes NOTHING — the dead rows are marked and removal stays the
operator's two clicks. This archive exists so the board is recoverable
off-box once he starts pruning, and so the measurements above are checkable
against the bytes they were taken from.
```markdown
- [LRPG Authoring Studio — live demo endpoint (ldp-saga)](http://10.100.10.50:8321/Authoring%20Studio.dc.html) <sub>· ldp-dev · 2026-08-19 10:04</sub>
- [LRPG GM Player — live demo endpoint (ldp-saga; open in iPhone Safari for native)](http://10.100.10.50:8321/GM%20Playback.dc.html) <sub>· ldp-dev · 2026-08-19 10:04</sub>
- [Scriberr — self-hosted transcription + speaker diarization (ana-ml2 GPU1); also http://10.250.50.54:8080](http://scriberr.ana.internal:8080/) <sub>· infra-ops · 2026-08-23 19:31</sub>
- [talk — chat with a fleet voice (HTTPS, trusted cert, no warning)](https://talk.nh3.phasefinal.com:8092/) <sub>· tts-dev · 2026-09-06 23:35</sub>
- [YTVC noise floor A/B — raw vs shipped vs +75 Hz high-pass (2 clips)](http://10.100.10.50:8090/b/ytvc-noise/) <sub>· yt-voice-clipper-dev · 2026-09-09 10:58</sub>
- [the interview noise floor measured — denoise BEFORE distilling carries 4x better](http://10.100.10.50:8090/b/noise-floor/) <sub>· tts-dev · 2026-09-09 11:00</sub>
- [the 5 distillation sources staged for professional denoising — drop back as <name>-clean.wav](http://10.100.10.50:8090/b/denoise-in/) <sub>· tts-dev · 2026-09-09 11:01</sub>
- [hamr: the sliver lever + mutual-block pairing -- the operator four sites at two lever settings (2026-09-09)](http://10.100.10.50:8090/b/hamr-sliver-lever/) <sub>· nh3-dev · 2026-09-09 11:13</sub>
- [YTVC subtractive denoiser audition — raw vs RNNoise vs DeepFilterNet 3 vs anlmdn, 2 clips + numbers](http://10.100.10.50:8090/b/ytvc-denoise/) <sub>· yt-voice-clipper-dev · 2026-09-09 11:13</sub>
- [denoise-in — 5 clone sources handed to yt-voice-clipper-dev for a proper deep denoise pass](http://10.100.10.50:8090/b/denoise-in/) <sub>· tts-dev · 2026-09-09 12:49</sub>
- [hamr: why the gear circle and peak edges read rough -- source vs output vs difference, measured (2026-09-09)](http://10.100.10.50:8090/b/hamr-rough-edges/) <sub>· nh3-dev · 2026-09-09 12:52</sub>
- [hamr: CLEAN mode rendered on six marks -- and the 1024-vs-4096 test showing my instrument was under-resolved (2026-09-09)](http://10.100.10.50:8090/b/hamr-clean-mode/) <sub>· nh3-dev · 2026-09-09 13:29</sub>
- [denoise A/B — 5 sources before/after, level-matched; emmie regressed](http://10.100.10.50:8090/b/denoise-ab/) <sub>· tts-dev · 2026-09-09 13:56</sub>
- [REDO step 1 — pick anchors for lawson/jo/nichols/ana on the cleaned sources (one form)](http://10.100.10.50:8090/b/redo-anchors/asks) <sub>· tts-dev · 2026-09-09 14:01</sub>
- [hamr: the sliver lever re-rendered at 4x -- the operator width's chunk is real geometry and 7x the default's edge residual (open ask: lever-default)](http://10.100.10.50:8090/b/hamr-sliver-lever/) <sub>· hamr-dev · 2026-09-09 14:31</sub>
- [hamr: the golden corpus re-rendered at 4x -- the 1024 px instrument inflated edge roughness by 80% on a reading it could not resolve; the colour numbers were never affected](http://10.100.10.50:8090/b/hamr-corpus-4x/) <sub>· hamr-dev · 2026-09-09 14:44</sub>
- [REDO step 2 — lawson register picks on the cleaned source (7 inline)](http://10.100.10.50:8090/b/redo-lawson/) <sub>· tts-dev · 2026-09-09 14:49</sub>
- [REDO step 2 — jo register picks on the cleaned source (7 inline)](http://10.100.10.50:8090/b/redo-jo/) <sub>· tts-dev · 2026-09-09 14:49</sub>
- [REDO step 2 — nichols register picks on the cleaned source (7 inline)](http://10.100.10.50:8090/b/redo-nichols/) <sub>· tts-dev · 2026-09-09 14:49</sub>
- [REDO step 2 — ana register picks on the cleaned source (7 inline)](http://10.100.10.50:8090/b/redo-ana/) <sub>· tts-dev · 2026-09-09 14:49</sub>
- [lawson warm rescue — seed axis vs instruction axis (warm is the corpus's untuned string)](http://10.100.10.50:8090/b/lawson-warm/) <sub>· tts-dev · 2026-09-09 15:04</sub>
- [bank denoise vs source redo — v3+DN hits 49.8 dB; may make the whole redo unnecessary](http://10.100.10.50:8090/b/bank-denoise/) <sub>· tts-dev · 2026-09-09 15:16</sub>
- [bank denoise A/B — lawson +26 dB, jo +21 dB; 4 of 10 banks would be DAMAGED by it](http://10.100.10.50:8090/b/bank-dn-ab/) <sub>· tts-dev · 2026-09-09 15:31</sub>
- [Margaery step 1 — anchor picks; ⚠ 15.44s single-clip source, thinnest yet](http://10.100.10.50:8090/b/margaery-anchor/) <sub>· tts-dev · 2026-09-09 15:43</sub>
- [hamr: 1-2 px regions -- the operator's hue/lightness rule separates 10-25x on his own artwork; lightness does the work; the eye survives at today's default](http://10.100.10.50:8090/b/hamr-thin-regions/) <sub>· hamr-dev · 2026-09-09 15:44</sub>
- [pewpewstudio web UI restyled on PowerPellet (arcade design system): every screen, dark + daylight (2026-09-09)](http://10.100.10.50:8090/b/pewpew-powerpellet/) <sub>· pewpew-dev · 2026-09-09 15:47</sub>
- [Margaery — 7 registers x 5 seeds, pick one per register (step 2 of 3)](http://10.100.10.50:8090/b/margaery-registers/) <sub>· tts-dev · 2026-09-09 16:03</sub>
- [Margaery — denoise A/B on the spliced bank (step 3 of 3)](http://10.100.10.50:8090/b/margaery-denoise/) <sub>· tts-dev · 2026-09-09 16:19</sub>
- [hamr: the blend-distance gate landed -- the crest's eye ring survives STRIP_WIDTH, the gear's rims and peak's dark-teal strip still go](http://10.100.10.50:8090/b/hamr-thin-regions/) <sub>· hamr-dev · 2026-09-09 17:10</sub>
- [Breeze — probing the 7 unused direction axes (vendor instructions verbatim)](http://10.100.10.50:8090/b/breeze-axes/) <sub>· tts-dev · 2026-09-09 17:11</sub>
- [ERP run 7 decision brief — gate failure, exposure, 5 decisions awaiting Vuong](http://10.100.10.50:8090/b/run07-decisions/) <sub>· infra-ops · 2026-09-09 18:10</sub>
- [R47 tune line runs 4-7 — run 7: length FLAT, RP shape markers moved (quote-first 29%→15%)](http://10.100.10.50:8090/b/r47-runs/) <sub>· brokkr-smithy-dev · 2026-09-09 18:46</sub>
- [hamr: the blend gate rendered -- the crest's eye ring comes back at STRIP_WIDTH, the gear's rims and peak's strip still go](http://10.100.10.50:8090/b/hamr-thin-regions/) <sub>· hamr-dev · 2026-09-09 18:52</sub>
- [Tag sweep redone — leak test = vocabulary test; the ear questions](http://10.100.10.50:8090/b/tag-sweep/) <sub>· tts-dev · 2026-09-09 22:49</sub>
- [hamr henge/66 peak: which site is 'the chunk' -- ask + the four candidate sites](http://10.100.10.50:8090/b/hamr-henge66-peak/) <sub>· hamr-dev · 2026-09-09 23:13</sub>
- [Chunk seams A/B — paragraph-only chunking, and the render ceiling is lower than we thought](http://10.100.10.50:8090/b/chunk-seams/) <sub>· tts-dev · 2026-09-09 23:29</sub>
- [hamr peak: the operator's chunk (the small peak's left face) -- under the size levers, before/after the apex unit](http://10.100.10.50:8090/b/hamr-peak-left-face/) <sub>· hamr-dev · 2026-09-10 07:26</sub>
- [hamr: the peak's halo -- the tint reach null, the sliver lever, the support rule (henge/66 third rule)](http://10.100.10.50:8090/b/hamr-peak-halo/) <sub>· hamr-dev · 2026-09-10 07:58</sub>
- [Level decay is LENGTH-driven, not soft/whisper — every direction collapses at 1400 chars](http://10.100.10.50:8090/b/level-decay/) <sub>· tts-dev · 2026-09-10 08:47</sub>
- [BabyBronte voice A/B — base vs H02 LoRA on 9 neutral prompts, 2 seeds each](http://10.100.10.50:8090/b/babybronte-voice/) <sub>· infra-ops · 2026-09-10 15:08</sub>
- [hamr: the 2.5 fold -- frame closing fix, the O(N) vote (byte-identical peak), the VMDE engine document read against hamr](http://10.100.10.50:8090/b/hamr-2-5-fold/) <sub>· hamr-dev · 2026-09-10 15:57</sub>
- [hamr: the region-energy segmenter spike (henge 71) -- the Potts prior in the vote's seat, against the landed 2.5](http://10.100.10.50:8090/b/hamr-region-energy/) <sub>· hamr-dev · 2026-09-10 16:05</sub>
- [BabyBronte rung 2 — 1.7B base vs 1.7B tuned vs 0.6B tuned, 9 prompts, 2 seeds](http://10.100.10.50:8090/b/babybronte-1p7b/) <sub>· infra-ops · 2026-09-10 22:38</sub>
- [hamr: the state of the pipeline at c18c4e1 (v1.3.0 + the hygiene unit) -- seven reference marks and the synthetic corpus, source | 1x | 4x](http://10.100.10.50:8090/b/hamr-state-2026-09-11/) <sub>· hamr-dev · 2026-09-10 23:20</sub>
- [BabyBronte rung 3 — 4B base vs 4B tuned vs 1.7B tuned, + the Abernathy frame prompt](http://10.100.10.50:8090/b/babybronte-4b/) <sub>· infra-ops · 2026-09-11 05:35</sub>
- [bragi :8196 — the fleet direction layer, LIVE 2026-09-11 (U1 null director, +2.32ms TTFA cost, cap 6400)](http://irv-ml1.nh3.internal:8196/health) <sub>· nh3-dev · 2026-09-11 05:47</sub>
- [BabyBronte rung 3 (step-75 recut) — 4B base vs 4B tuned vs 1.7B, + frame and embedded-instruction prompts](http://10.100.10.50:8090/b/babybronte-4b/) <sub>· infra-ops · 2026-09-11 05:54</sub>
- [Bragi U2 spike — blinded 5-arm fast-director audition, 7 inline asks, ear verdict gates U2](http://10.100.10.50:8090/b/bragi-u2-spike/) <sub>· nh3-dev · 2026-09-11 06:00</sub>
- [Skaldsong beat→paragraph — 10 formats on the adapted 4B vs an instruct model, + stitched story](http://10.100.10.50:8090/b/skaldsong-beats/) <sub>· infra-ops · 2026-09-11 06:24</sub>
- [hamr state booth at a7ee4ab: seven reference marks + sixteen synthetic cases, source | 1x | 4x, after the ridge-order and test-hygiene units](http://10.100.10.50:8090/b/hamr-state-2026-09-11-a7ee4ab/) <sub>· hamr-dev · 2026-09-11 08:41</sub>
- [hamr state booth, clean mode default (colour_geometry 3.13): only the crest's white tick changes against a7ee4ab](http://10.100.10.50:8090/b/hamr-state-2026-09-11-clean/) <sub>· hamr-dev · 2026-09-11 10:23</sub>
- [hamr run_smoothing 2.2, the corner core: circuit/gear/peak/crest/vastblue at the new corner rule, with corner overlays](http://10.100.10.50:8090/b/hamr-corner-core/) <sub>· hamr-dev · 2026-09-11 11:12</sub>
- [hamr regularizer 3.0, the run solve (U7 on runs): circuit/gear/peak/crest/vastblue after the stretch pool and solve, with the circuit site the first form broke](http://10.100.10.50:8090/b/hamr-run-solve/) <sub>· hamr-dev · 2026-09-11 13:50</sub>
- [hamr regularizer 3.1, the junction at the meet: the circuit's pads 3.0 vs 3.1 and the five marks](http://10.100.10.50:8090/b/hamr-run-solve-31/) <sub>· hamr-dev · 2026-09-11 15:02</sub>
- [BabyYarros eval — voice A/B + beat→paragraph + delta_cb (Base@125 vs Instruct vs base control)](http://10.100.10.50:8090/b/babyyarros-voice/) <sub>· infra-ops · 2026-09-11 15:59</sub>
- [bifrost 1.2.0 on the gitea PyPI index — wire v0.8 memory.* record profile (#17)](https://gitea.phasefinal.com/vh/-/packages/pypi/bifrost/1.2.0) <sub>· bifrost-dev · 2026-09-11 16:58</sub>
- [bifrost #17 — wire v0.8 record profile (adoption arc, gates, release)](https://gitea.phasefinal.com/vh/bifrost/issues/17) <sub>· bifrost-dev · 2026-09-11 16:58</sub>
- [bifrost 1.2.1 — supplement-fold patch (explicit record-engine guards; descriptor ownership boundary)](https://gitea.phasefinal.com/vh/-/packages/pypi/bifrost/1.2.1) <sub>· bifrost-dev · 2026-09-11 17:32</sub>
- [BabyYarros — Janis beat: 4 prompt arms x 4 seeds, beat->paragraph formula fitting](http://10.100.10.50:8090/b/babyyarros-janis/) <sub>· infra-ops · 2026-09-11 21:15</sub>
- [hamr on five fresh arbo marks (owl, bee, rocket, wolf, lantern) -- landed pipeline, clean mode, 1x + 4x](http://10.100.10.50:8090/b/hamr-arbo-logos/) <sub>· hamr-dev · 2026-09-11 22:35</sub>
- [FV colo on-site playbook — print before the trip (OPNsense + fv-ml1, anti-lockout)](http://10.100.10.50:8090/b/fv-onsite/) <sub>· infra-ops · 2026-09-12 07:54</sub>
- [hamr arbo marks AFTER colour_decomposition 2.10 (the interior-ends tint reading): owl before/after, the four others byte-identical](http://10.100.10.50:8090/b/hamr-arbo-logos-2/) <sub>· hamr-dev · 2026-09-12 07:55</sub>
- [hamr: the midline rule (colour_geometry 3.14) on the owl -- source | before | midline | far, 4x, and the runs the instrument flagged](http://10.100.10.50:8090/b/hamr-midline/) <sub>· hamr-dev · 2026-09-12 22:18</sub>
- [Qwen3.8-Flash-Next ABLITERATED NVFP4 + FP8 PLE — candidate for the fv-ml1 single-card gen seat](https://huggingface.co/dealignai/Qwen3.8-Flash-Next-ABLITERATED-NVFP4) <sub>· infra-ops · 2026-09-12 22:21</sub>
- [vLLM canonical Qwen3.8-Flash-Next recipe — PLE CPU-offload + the don't-enable-MTP measurement](https://recipes.vllm.ai/Qwen/Qwen3.8-Flash-Next/) <sub>· infra-ops · 2026-09-12 22:21</sub>
- [hamr: FAR shipped (colour_geometry 3.16) -- the five arbo marks before | after at 4x, and the per-run instrument](http://10.100.10.50:8090/b/hamr-far/) <sub>· hamr-dev · 2026-09-13 00:13</sub>
- [hamr: edge-pixel rule spike -- census overlays (third-layer boundary pixels, green explained / red not) and the geometry arms](http://10.100.10.50:8090/b/hamr-edge-pixels/) <sub>· hamr-dev · 2026-09-13 09:28</sub>
- [hamr: colour_geometry 3.17 the line clause -- crest eye ring gone, lens kept; owl / circuit / lantern byte-identical at 4x](http://10.100.10.50:8090/b/hamr-width-clause/) <sub>· hamr-dev · 2026-09-13 14:00</sub>
- [hamr: the golden corpus at colour_geometry 3.17 (the line clause) -- seven reference marks, faces and runs, source | 1x | 4x](http://10.100.10.50:8090/b/hamr-corpus-3.17/) <sub>· hamr-dev · 2026-09-13 16:53</sub>
- [hamr: the DXF cut document beside the SVG runs profile on the seven corpus marks (source | SVG | DXF, 1x and 4x zooms; .dxf files alongside)](http://10.100.10.50:8090/b/hamr-dxf/) <sub>· hamr-dev · 2026-09-13 23:18</sub>
- [Flash-Next gen-large candidate #1: abliterated + W4A16 weight-only experts + FP8 PLE; blocked only by a missing ple_embedding_dtype config key](https://huggingface.co/gorbatjovy/qwen3.8-flash-next-abliterated-NVFP4-plefp8) <sub>· infra-ops · 2026-09-14 02:16</sub>
- [Flash-Next gen-large candidate #2: fully weight-only (W4A16 experts + FP8_PB_WO dense), loads as-is, but NOT abliterated](https://huggingface.co/lovedheart/Qwen3.8-Flash-Next-NVFP4-W4A16-4-Over-6-FP8) <sub>· infra-ops · 2026-09-14 02:16</sub>
- [cyberprev-27b — abliterated Qwen3.8-27B sec seat (fv-ml1 GPU0, dflash k=7), replaced sentinel-r3](http://10.251.50.54:8025/docs) <sub>· infra-ops · 2026-09-14 04:29</sub>
- [hamr-server 1.5: the SPA booth pass with Download DXF (state 08b) and the refused-selection state re-pinned to server 1.6](http://10.100.10.50:8090/b/hamr-server-1.5/) <sub>· hamr-dev · 2026-09-14 10:22</sub>
- [hamr web front end UI brief (requirements and flow for a design system; also docs/design/ui-brief.md)](https://claude.ai/code/artifact/eae98fde-784b-4f4d-b0e3-c87a229da564) <sub>· hamr-dev · 2026-09-14 10:25</sub>
- [https://claude.ai/code/artifact/eae98fde-784b-4f4d-b0e3-c87a229da564](https://claude.ai/code/artifact/eae98fde-784b-4f4d-b0e3-c87a229da564) <sub>· hamr-dev · 2026-09-14 10:25</sub>
- [hamr web front end UI brief, boothed (kept): index.html + ui-brief.md](http://10.100.10.50:8090/b/hamr-ui-brief/) <sub>· hamr-dev · 2026-09-14 10:56</sub>
- [pewpewstudio web front end UI brief, boothed (kept): index.html + ui-brief.md + the integration package (tarball + fixtures)](http://10.100.10.50:8090/b/pewpew-ui-brief/) <sub>· pewpew-dev · 2026-09-14 12:42</sub>
- [pewpewstudio web front end UI brief (flow, shape, requirements for a design agent; also docs/design/ui-brief.md)](https://claude.ai/code/artifact/281bcdc7-bcce-46d7-b0ca-ec90df22151f) <sub>· pewpew-dev · 2026-09-14 12:42</sub>
- [Headscale: Tailscale setup for macOS/iOS/tvOS — GUI steps + downloadable config profiles](https://headscale.phasefinal.com/apple) <sub>· infra-ops · 2026-09-14 13:49</sub>
- [pewpewstudio: the UI blueprint vendored (Claude Design handoff from booth 28-indigo) -- provenance, state inventory, fidelity notes; source at docs/design/blueprint/](http://10.100.10.50:8090/b/pewpew-ui-brief/blueprint/README.md) <sub>· pewpew-dev · 2026-09-14 18:38</sub>
- [pewpewstudio web: the blueprint implemented -- one still per surface per state (67), cabinet + daylight](http://10.100.10.50:8090/b/pewpew-blueprint/) <sub>· pewpew-dev · 2026-09-14 20:55</sub>
- [hamr: the C kernel for the cubic fit -- where its geometry differs from 2.4 (4x panels) and the ask on the gate](http://10.100.10.50:8090/b/hamr-cubic-kernel/) <sub>· hamr-dev · 2026-09-14 22:28</sub>
- [Homepage — Parakeet ASR card now live under AI - Audio Tools (fv-ml1 GPU 3, :8300)](http://10.0.50.45:5100/) <sub>· nh3-dev · 2026-09-15 01:41</sub>
- [talk v10 — Sindra with ears: push-to-talk STT via ext-stt + barge-in (nh3-dev)](https://talk.nh3.phasefinal.com:8092/) <sub>· nh3-dev · 2026-09-15 08:27</sub>
- [talk v10 — the fleet speaks AND listens (Grima push-to-talk + barge-in)](https://talk.nh3.phasefinal.com:8092/) <sub>· nh3-dev · 2026-09-15 08:28</sub>
- [Open-weight releases landscape scan 2026-09-15 — LLM/image/TTS, ranked + licenses verified](https://gitea.phasefinal.com/vh/brokkr-smithy/src/commit/6adcde6/research/landscape-scans/open-weight-releases-2026-09-15.md) <sub>· brokkr-scan-dev · 2026-09-15 09:20</sub>
- [ldp-saga — voice-over step with authored words: GM stage (iPhone) + Studio drawer screenshots](http://10.100.10.50:8090/b/ldp-vo-body/) <sub>· ldp-dev · 2026-09-15 11:31</sub>
- [talk PREVIEW (v11 unreleased) — kiosk persona + prompt library + hands-free VAD; http so no mic](http://10.100.10.50:8095/) <sub>· nh3-dev · 2026-09-15 14:08</sub>
- [talk v12 LIVE — hands-free VAD + 4 personas (assistant/sindra/narrator/kiosk) + Grima STT](https://talk.nh3.phasefinal.com:8092/) <sub>· nh3-dev · 2026-09-15 14:13</sub>
- [talk v12 — internal IP (accept the cert warning; wildcard covers names, not IPs). Hands-free + 4 personas.](https://10.100.10.50:8092/) <sub>· nh3-dev · 2026-09-15 14:18</sub>
- [hamr circuit: census of thin surviving regions, source|1x|4x per site (2026-09-16)](http://10.100.10.50:8090/b/hamr-circuit-slivers/) <sub>· hamr-dev · 2026-09-15 15:03</sub>
- [hamr circuit: the full cut file (SVG runs profile + DXF) on white, 1x and 4x whole (2026-09-16)](http://10.100.10.50:8090/b/hamr-dxf/) <sub>· hamr-dev · 2026-09-15 15:10</sub>
- [hamr owl (arbo 00-seed7777): the full cut file on white, 1x and 4x (2026-09-16)](http://10.100.10.50:8090/b/hamr-owl-cut/) <sub>· hamr-dev · 2026-09-15 15:17</sub>
- [hamr circuit: the ten arrowed sites (possum-51), source | faces 4x | runs 4x, with the runs and junctions at each (2026-09-16)](http://10.100.10.50:8090/b/hamr-circuit-arrows/) <sub>· hamr-dev · 2026-09-15 15:17</sub>
- [hamr: the owl before/after the shade rule (colour_decomposition 2.12), the four arrowed sites at 1x and 4x](http://10.100.10.50:8090/b/hamr-owl-shades/) <sub>· hamr-dev · 2026-09-15 20:01</sub>
- [hamr unit 2: the circuit's edge teeth before/after (colour_geometry 3.27) -- the ten arrowed sites and two interior seam sites, SOURCE | before | after at 1x and 4x](http://10.100.10.50:8090/b/hamr-circuit-teeth/) <sub>· hamr-dev · 2026-09-15 22:57</sub>
- [hamr 3.27: every thin excursion the clause reads on twenty marks at the pixel bar (175 panels; GOES/stays in each caption)](http://10.100.10.50:8090/b/hamr-excursions-f10/) <sub>· hamr-dev · 2026-09-15 22:57</sub>
- [BabyYarros beat→paragraph: same beat, 4 arms (base / raw-text / pair-SFT 2ep / 3ep)](http://10.100.10.50:8090/b/babyyarros-beats/) <sub>· infra-ops · 2026-09-16 07:29</sub>
- [hamr v2 S0: the smoother's chain vs potrace's fallback on every refused mono node of the eight marks, worst site per node at 4x (2026-09-16)](http://10.100.10.50:8090/b/hamr-v2-s0-smoother/) <sub>· hamr-dev · 2026-09-16 08:55</sub>
- [hamr U0 — the truth-corpus acceptance gate: 24 conditions, potrace 3x vs the extractor's iso-contours, table + overlays at 1x and 4x](http://10.100.10.50:8090/b/hamr-u0-acceptance/) <sub>· hamr-dev · 2026-09-16 11:11</sub>
- [hamr acceptance 1.2 verdict table -- 24 conditions, three arms over the raster per condition (from hamr-dev's fold of two Heid panels)](http://10.100.10.50:8090/b/hamr-u0-acceptance/) <sub>· heid · 2026-09-16 11:17</sub>
- [Assistant voice — accent calibration: 7 endpoints from the existing battery, inline ask](http://10.100.10.50:8090/b/assistant-accent/) <sub>· nh3-dev · 2026-09-16 11:18</sub>
- [Assistant voice — the blend n=5, matched-seed triples vs both endpoints](http://10.100.10.50:8090/b/assistant-blend/) <sub>· nh3-dev · 2026-09-16 11:20</sub>
- [Peedlar repo (photo → eBay/FB Marketplace listing metadata) — minted 2026-09-16](https://gitea.phasefinal.com/vh/peedlar) <sub>· nh3-dev · 2026-09-16 11:26</sub>
- [Sun and Sea Pro — concept tiles A/B/C + the rulings ask (design-systems)](http://10.100.10.50:8090/b/sunsea/) <sub>· design-dev · 2026-09-16 11:35</sub>
- [Peedlar — UI design brief + northstar/frame/invariants/interview record (vor-ui pass 2026-09-16)](http://10.100.10.50:8090/b/peedlar-design-brief/) <sub>· peedlar-dev · 2026-09-16 14:01</sub>
- [hamr U1 the tracer skeleton (tracer 3.0): the v2 tree over the eight marks with ids and holes, potrace beside it, 4x windows, the rule fixtures](http://10.100.10.50:8090/b/hamr-u1-tracer/) <sub>· hamr-dev · 2026-09-16 14:23</sub>
- [Peedlar — vor-plan draft bundle (plan, frame, invariants, northstar, record) for teardown, 2026-09-16](http://10.100.10.50:8090/b/peedlar-plan-draft/) <sub>· peedlar-dev · 2026-09-16 16:17</sub>
- [Peedlar — spike R-4 report: gen schema adherence, 180/180 valid (2026-09-16)](http://10.100.10.50:8090/b/peedlar-spike-r4/) <sub>· peedlar-dev · 2026-09-16 17:18</sub>
- [hamr U2 (ir 7.0): the mono SVG before/after the IR moved onto points, eight marks, 1x and 4x](http://10.100.10.50:8090/b/hamr-u2-ir/) <sub>· hamr-dev · 2026-09-16 17:22</sub>
- [JackDAW audition bench — live HEAD of main (self-signed HTTPS, one-time trust prompt)](https://10.100.10.50:4500/) <sub>· jackdaw-dev · 2026-09-16 18:32</sub>
- [Peedlar UI in Sun and Sea Pro — nine surfaces + DESIGN.md (design-systems, for peedlar-dev)](http://10.100.10.50:8090/b/peedlar-ui/) <sub>· design-dev · 2026-09-16 19:27</sub>
- [Assistant anchor — rp-s113 vs the existing emily, collision check before building a bank](http://10.100.10.50:8090/b/assistant-anchor/) <sub>· nh3-dev · 2026-09-16 19:29</sub>
- [imogen — register bank ear gate before freezing (5 registers off rp-s113)](http://10.100.10.50:8090/b/imogen/) <sub>· nh3-dev · 2026-09-16 19:45</sub>
- [imogen — gentle + dry re-roll, 3 draws each vs the rejected originals](http://10.100.10.50:8090/b/imogen-reroll/) <sub>· nh3-dev · 2026-09-16 19:50</sub>
- [Peedlar — spike R-3 report: split heuristic on the cedarwood-4 pile (pairwise VLM + identify-and-merge, 4-image cap), 2026-09-16](http://10.100.10.50:8090/b/peedlar-spike-r3/) <sub>· peedlar-dev · 2026-09-16 19:53</sub>
- [hamr U4: the colour spine on owner fields at 1x -- v1.6.1 (3x potrace) vs colour_spine 3.0, eight marks, 1x + 4x diff windows, the 1x/3x A/B table](http://10.100.10.50:8090/b/hamr-u4-readers/) <sub>· hamr-dev · 2026-09-16 20:26</sub>
- [imogen LIVE — voice 22 on the roster, all five registers through the gateway](http://10.100.10.50:8090/b/imogen-live/) <sub>· nh3-dev · 2026-09-16 20:34</sub>
- [talk v15 — imogen is the default voice; 22 voices, 4 personas, hands-free](https://talk.nh3.phasefinal.com:8092/) <sub>· nh3-dev · 2026-09-16 20:39</sub>
- [Peedlar v0.1.0 — U0 scaffold deployed on nh3-dev (health placeholder SPA + /healthz)](http://10.100.10.50:8094/) <sub>· peedlar-dev · 2026-09-16 23:42</sub>
- [hamr U3: the mono smoothing -- every refused node's chain (blue) beside the polyline it replaces (red), eight marks, 1x and 4x](http://10.100.10.50:8090/b/hamr-u3-mono-smoothing/) <sub>· hamr-dev · 2026-09-17 00:11</sub>
- [2026-09-17 Civitai batch A/B — 6 promotion/retirement decisions, inline asks (comfy-dev)](http://10.100.10.50:8090/b/civitai-20260917-ab/) <sub>· comfy-dev · 2026-09-17 01:49</sub>
- [Breeze v5 vendor-pin rebase — A/B clips, gate numbers, two decisions](http://10.100.10.50:8090/b/breeze-v5-gate/) <sub>· tts-dev · 2026-09-17 02:36</sub>
- [ldp-saga U4 — control panel + bootstrap view screenshots (polish-pass input)](http://10.100.10.50:8090/b/ldp-u4-panel/) <sub>· ldp-dev · 2026-09-17 02:38</sub>
- [lv voices four arms — same beat, same neutral prompt: control vs Bronte vs Yarros vs Hemingway (2026-09-17)](http://10.100.10.50:8090/b/lv-voices-four-arms/) <sub>· infra-ops · 2026-09-17 07:52</sub>
- [hamr U6: the eight marks' faces and cut on white, v1.6.1 (potrace) beside main (own tracer), 1x + 4x worst window, trace timings](http://10.100.10.50:8090/b/hamr-u6-before-after/) <sub>· hamr-dev · 2026-09-17 08:06</sub>
- [ldp-demo-kit 2026-09-17-0816 (build 99040b2): VO authored words in Eric's kit](http://10.100.10.50:8090/b/ldp-demo-kit/) <sub>· ldp-dev · 2026-09-17 08:17</sub>
- [hamr U6 regression sites: crest/circuit/owl difference clusters at 4x, SOURCE | v1.6.1 | main | candidate (coverage-field evidence)](http://10.100.10.50:8090/b/hamr-u6-sites/) <sub>· hamr-dev · 2026-09-17 08:33</sub>
- [talk favicon commission — comfy-dev raster candidates, hamr-dev SVG trace](http://10.100.10.50:8090/b/talk-favicon/) <sub>· tts-dev · 2026-09-17 08:43</sub>
- [Peedlar U2 ingest screen — four phone states from a real headless Chromium run](http://10.100.10.50:8090/b/peedlar-u2/) <sub>· nh3-dev · 2026-09-17 09:19</sub>
- [Peedlar v0.2.3 live — U2 ingest: photograph a pile from a phone, send it, top an item up](http://10.100.10.50:8094/) <sub>· nh3-dev · 2026-09-17 10:15</sub>
- [Peedlar v0.2.4 live — U2 ingest, all three review rounds folded (17 defects)](http://10.100.10.50:8094/) <sub>· nh3-dev · 2026-09-17 11:04</sub>
- [hamr circuit: the five sites where main's runs depart from v1.6.1's (SOURCE | v1 | main at 4x)](http://10.100.10.50:8090/b/hamr-u6-departures/) <sub>· hamr-dev · 2026-09-17 11:05</sub>
- [hamr circuit: the trace-to-pad corners on both trees at 4x -- the indented-lines family](http://10.100.10.50:8090/b/hamr-u6-dents/) <sub>· hamr-dev · 2026-09-17 11:05</sub>
- [Peedlar ingest UI — before/after in six states, with an open ask on fonts + pricing pills](http://10.100.10.50:8090/b/peedlar-ui-polish/) <sub>· design-dev · 2026-09-17 11:25</sub>
- [hamr run_smoothing 3.4: the chord-of-a-curve clause -- the circuit's pads and trace ends as lines, before/after at 6x](http://10.100.10.50:8090/b/hamr-short-stretches/) <sub>· hamr-dev · 2026-09-17 11:56</sub>
- [ldp-demo-kit 2026-09-17-1243 (a912928): Eric's 09-17 canonical + VO words — install this one](http://10.100.10.50:8090/b/ldp-demo-kit/) <sub>· ldp-dev · 2026-09-17 12:43</sub>
- [Peedlar v0.2.5 — surface 1 dressed in Sun and Sea Pro (design-dev), four phone states](http://10.100.10.50:8090/b/peedlar-u2-design/) <sub>· nh3-dev · 2026-09-17 15:54</sub>
- [talk favicon — the traced mark (B) and its 16/32/64px proof](http://10.100.10.50:8090/b/talk-favicon/) <sub>· nh3-dev · 2026-09-17 15:58</sub>
- [Peedlar v0.3.0 — the first release a seller can use (ingest + top-up; split is U3)](https://gitea.phasefinal.com/vh/peedlar/releases/tag/v0.3.0) <sub>· nh3-dev · 2026-09-17 15:59</sub>
- [hamr corner response A/B: 3.4 as landed vs the capped response by angle -- the circuit's bends, the crest's and gear's small fillets](http://10.100.10.50:8090/b/hamr-corner-ab/) <sub>· hamr-dev · 2026-09-17 17:24</sub>
- [ldp-demo-kit 2026-09-17-1752 (a28e8d5): Eric's 09-17 canon + VO words + GM Markdown subset](http://10.100.10.50:8090/b/ldp-demo-kit/ldp-demo-kit-2026-09-17-1752.zip) <sub>· ldp-dev · 2026-09-17 17:52</sub>
- [Sun and Sea Pro v1.1.0 — rulings + the Peedlar ingest before/after that started it](http://10.100.10.50:8090/b/peedlar-ui-polish/) <sub>· design-dev · 2026-09-17 17:59</sub>
- [ldp-demo-kit 2026-09-17-1804 (265a3ad): + _underline_](http://10.100.10.50:8090/b/ldp-demo-kit/ldp-demo-kit-2026-09-17-1804.zip) <sub>· ldp-dev · 2026-09-17 18:04</sub>
- [ldp-saga — GM Markdown subset samples (source + renders)](http://10.100.10.50:8090/b/ldp-markdown/) <sub>· ldp-dev · 2026-09-17 18:06</sub>
- [hamr colour_spine 3.7, the paired witness: circuit arrows 1-3 at 12x, every departure site before/after at 1x+4x, the crest's eye](http://10.100.10.50:8090/b/hamr-witness/) <sub>· hamr-dev · 2026-09-17 18:48</sub>
- [Dragonfire Acoustics — three concept directions + the five rulings that gate the build](http://10.100.10.50:8090/b/dfa-concepts/) <sub>· design-dev · 2026-09-17 18:49</sub>
- [Dragonfire Acoustics — sample landing page, standalone HTML for client screenshots](http://10.100.10.50:8090/b/dfa-landing/) <sub>· design-dev · 2026-09-17 18:58</sub>
- [hamr run_smoothing 3.5, the corner response by angle between two stretches: circuit arrows 2-3 and new corners, crest's curves unkinked, at 8x](http://10.100.10.50:8090/b/hamr-corner-guard/) <sub>· hamr-dev · 2026-09-17 18:59</sub>
- [hamr: golden corpus on main 53356c5, faces and cut on white, 1x sheets and 4x wholes](http://10.100.10.50:8090/b/hamr-corpus-2026-09-18/) <sub>· hamr-dev · 2026-09-17 21:51</sub>
- [Peedlar surface 2 — a live split of the R-3 pile, ready to confirm (U3)](http://10.100.10.50:8094/batches/6fb2952b-e3b1-4fbd-9694-5f3f3f5d75d0/split) <sub>· nh3-dev · 2026-09-18 07:08</sub>
- [Peedlar surface 2 — a scratch split to poke at (merge/split/move/drop/restore all live)](http://10.100.10.50:8094/batches/a7058924-a855-40b3-bfc5-11f3f258df27/split) <sub>· nh3-dev · 2026-09-18 07:13</sub>
- [Peedlar U3 — surface 2 on desk and phone, plus an interaction run](http://10.100.10.50:8090/b/peedlar-u3/) <sub>· nh3-dev · 2026-09-18 07:16</sub>
- [tag placement A/B — does moving (giggle) stop it overlapping the next line? (ask inside)](http://10.100.10.50:8090/b/tag-placement/) <sub>· tts-dev · 2026-09-18 07:17</sub>
- [seam gap audition — 0-500ms between generations, 11 arms (ask inside)](http://10.100.10.50:8090/b/seam-gap/) <sub>· tts-dev · 2026-09-18 07:27</sub>
- [FleetTools index lives at ~/FLEETTOOLS.md on nh3-dev — agent-family-agnostic fleet capability map](http://10.100.10.50:8090/) <sub>· nh3-dev · 2026-09-18 07:35</sub>
- [Peedlar v0.4.0 — the split ships; capability 1 of five is MET](http://10.100.10.50:8094/) <sub>· nh3-dev · 2026-09-18 08:54</sub>
- [talk favicon — inverted, transparent, before/after proof at 4 sizes](http://10.100.10.50:8090/b/talk-favicon/) <sub>· tts-dev · 2026-09-18 13:53</sub>
- [ShutterChute macOS app icon — 3 variants + the 16px proof sheets (comfy-dev, for shutter-dev)](http://10.100.10.50:8090/b/shutterchute-icon/) <sub>· comfy-dev · 2026-09-18 14:00</sub>
- [NH3↔Anaheim mesh now DIRECT (was DERP-relayed): cross-site HTTP 1.2s→0.015s, STT 1.4s→0.25s — ana-gw UDP 41641 port-forward 2026-09-18](http://10.100.10.50:8090/b/links/) <sub>· nh3-dev · 2026-09-18 14:17</sub>
- [talk favicon — three-way blue comparison (live vs page accent vs comfy remake)](http://10.100.10.50:8090/b/talk-favicon/) <sub>· tts-dev · 2026-09-18 14:26</sub>
- [DNS fixed fleet-wide 2026-09-18: cross-site resolver ring + AdGuard ratelimit 20-per-/24 set to 0 — .internal stalls 1-in-8 to zero](http://10.100.10.50:8090/b/links/) <sub>· nh3-dev · 2026-09-18 14:35</sub>
- [ShutterChute on Paula's mini (v0.9.7) — session token rotates on every restart, read it from /Users/Shared/shutterchute/app.url or the deploy output](http://10.100.10.50:8477/) <sub>· shutter-dev · 2026-09-18 14:46</sub>
- [asking arbo vs directing it — both icon commissions re-run on the corrected chain, with the 16px verdicts](http://10.100.10.50:8090/b/arbo-asked/) <sub>· comfy-dev · 2026-09-18 14:54</sub>
- [Blind A/B/C: is Imogen's 39.96s register bank worth 116ms a turn? (breeze v8)](http://10.100.10.50:8090/b/imogen-register/) <sub>· tts-dev · 2026-09-18 20:30</sub>
- [Sindra identity scouting — 5 SFW/NSFW pairs on moody-krea2 (comfy-dev, for adhoc-agent)](http://10.100.10.50:8090/b/sindra-face-1/) <sub>· comfy-dev · 2026-09-19 12:44</sub>
- [Sindra casting — 5 different women, 2 fixed scenes (gym / beach), comfy-dev](http://10.100.10.50:8090/b/sindra-cast/) <sub>· comfy-dev · 2026-09-19 15:25</sub>
- [the three MiniMax Music 3 songs (Aug 2026) — recovered from render scratch, kept, captions carry the recovered lyrics](http://10.100.10.50:8090/b/music3-songs/) <sub>· comfy-dev · 2026-09-19 15:26</sub>
- [Sindra A — curvier stepped across 4 levels, face frozen (comfy-dev)](http://10.100.10.50:8090/b/sindra-curve/) <sub>· comfy-dev · 2026-09-19 15:36</sub>
- [the settled Sindra — 5 SFW environments + 5 NSFW poses, identity block verbatim (comfy-dev)](http://10.100.10.50:8090/b/sindra-set/) <sub>· comfy-dev · 2026-09-19 15:44</sub>
- [NVV markers by ear: is (chuckle) real? + the leak test is dead on breeze v8](http://10.100.10.50:8090/b/nvv-probe/) <sub>· tts-dev · 2026-09-19 17:05</sub>
- [tts-bench — type/direct/render against the live TTS seat (voice picker, custom directions, marker palette)](http://nh3-dev.nh3.internal:8095/) <sub>· tts-dev · 2026-09-19 17:14</sub>
- [Sindra voice audition (adhoc-agent commission) — designed synthetic, 3 registers x 2 takes + polyglot probe](http://10.100.10.50:8090/b/sindra-voice-1/) <sub>· tts-dev · 2026-09-19 22:49</sub>
- [Sindra ANCHOR field — n=15 on the intimate prompt, 13 in the 8-10s window, pick one to freeze](http://10.100.10.50:8090/b/sindra-anchor/) <sub>· tts-dev · 2026-09-19 22:55</sub>
- [Sindra is LIVE — new designed voice replaces the NZ contralto; bank vs anchor A/B inside](http://10.100.10.50:8090/b/sindra-live/) <sub>· tts-dev · 2026-09-19 23:14</sub>
- [Cicada repo (was Imogen) — embodied voice assistant, design bundle + embodiment](https://gitea.phasefinal.com/vh/cicada) <sub>· brokkr-smithy-dev · 2026-09-20 14:11</sub>
- [ShutterChute: denoise strength + EV lift on the 4 darkest Pancake Breakfast frames (1:1 crops)](http://10.100.10.50:8090/b/sc-denoise-ev/) <sub>· shutter-dev · 2026-09-20 15:12</sub>
- [ShutterChute: DSC03888.ARW (ISO 12800, darkest frame) + current style — for authoring a working denoise in darktable](http://10.100.10.50:8090/b/sc-denoise-raw/) <sub>· shutter-dev · 2026-09-20 15:27</sub>
- [Cutesy robot girl — 5 briefs x 2 seeds, 259-372 Hz, plus three robot textures (EVE / classic / WALL-E)](http://10.100.10.50:8090/b/robot-girl/) <sub>· tts-dev · 2026-09-20 15:53</sub>
- [cicada-raw is LIVE — fastest voice on the fleet at 220.2 ms; reference + clones + the defect I retracted](http://10.100.10.50:8090/b/cicada-raw/) <sub>· tts-dev · 2026-09-20 16:06</sub>
- [ShutterChute: denoise strength ladder on the REPAIRED split — 1:1 crops, 4 dark frames](http://10.100.10.50:8090/b/sc-denoise-strength/) <sub>· shutter-dev · 2026-09-20 16:24</sub>
- [ShutterChute: four-way denoise comparison — no denoise / classical / SCUNet (automatable) / neural restore](http://10.100.10.50:8090/b/sc-denoise-fourway/) <sub>· shutter-dev · 2026-09-20 17:25</sub>
- [ShutterChute: RawNIND UtNet2 pre-demosaic — 8.01 to 2.07 at 2.8s/frame, running outside darktable](http://10.100.10.50:8090/b/sc-rawdenoise/) <sub>· shutter-dev · 2026-09-20 18:47</sub>
- [ShutterChute: frequency-selective detail recovery after raw AI denoise](http://10.100.10.50:8090/b/sc-detail-recovery/) <sub>· shutter-dev · 2026-09-20 18:54</sub>
- [ShutterChute: raw AI denoise @70% across six frames, mean luminance 20 to 148](http://10.100.10.50:8090/b/sc-iso-spread/) <sub>· shutter-dev · 2026-09-20 18:58</sub>
- [raw-denoise first real-model run: A raw vs B linear TIFF (black) vs C sRGB-encoded (tonality right, colour wrong)](http://10.100.10.50:8090/b/denoise-first-run/) <sub>· shutter-dev · 2026-09-21 06:45</sub>
- [Pancake Breakfast low-light: raw vs denoised+2EV, full res + 1:1 crops; 3.3-3.5x noise reduction measured](http://10.100.10.50:8090/b/pancake-denoise/) <sub>· shutter-dev · 2026-09-21 07:02</sub>
- [raw-denoise: TIFF handoff vs LinearRaw DNG handoff - the colour fix, before/after](http://10.100.10.50:8090/b/dng-handoff/) <sub>· shutter-dev · 2026-09-21 07:38</sub>
- [EV ladder on a denoised Pancake frame: face luma vs frame median vs the 18% grey reference](http://10.100.10.50:8090/b/ev-ladder/) <sub>· shutter-dev · 2026-09-21 07:51</sub>
- [golden-frame candidates for the one-and-done white balance: two lighting clusters, two each](http://10.100.10.50:8090/b/golden-candidates/) <sub>· shutter-dev · 2026-09-21 07:57</sub>
- [Sindra @ 20 (v2, replaced) — 5 NSFW engines x 4 scenes x 2 seeds, 40 renders + 4 sheets + the age-lever diagnostic](http://10.100.10.50:8090/b/sindra20-engines/) <sub>· comfy-dev · 2026-09-21 07:57</sub>
- [vibrance/saturation spike: 4 steps on a well-lit and a recovered frame; which colorbalancergb float is which, measured](http://10.100.10.50:8090/b/vibrance-spike/) <sub>· shutter-dev · 2026-09-21 08:29</sub>
- [face metering measured on all 696 keepers: gate 20.7% -> 34.2%, 94 frames newly caught](http://10.100.10.50:8090/b/face-metering/) <sub>· shutter-dev · 2026-09-21 08:29</sub>
- [darktable 5.6.1 on nh3-dev: the versions disagree, and the vibrance pick was made on 4.2.1](http://10.100.10.50:8090/b/dt56-recheck/) <sub>· shutter-dev · 2026-09-21 09:05</sub>
- [Pancake Breakfast re-delivery: all 270 heroes, exposure + denoise + vibrance, SmugMug-ready](http://10.100.10.50:8090/b/pancake-v2-delivery/) <sub>· shutter-dev · 2026-09-21 09:48</sub>
- [Draupnir — agent-directed parametric CAD for 3D printing; many harnesses propose, one gate decides](https://gitea.phasefinal.com/vh/draupnir) <sub>· brokkr-smithy-dev · 2026-09-21 10:47</sub>
- [Pancake lift spike — Paula vs ours-zero-lift vs ours-metered, 8 frames](http://10.100.10.50:8090/b/pancake-lift-spike/) <sub>· shutter-dev · 2026-09-21 10:55</sub>
- [Pancake dark band (face 17-42) — Paula vs ours at zero lift](http://10.100.10.50:8090/b/pancake-dark-band/) <sub>· shutter-dev · 2026-09-21 10:57</sub>
- [Lift ladder — your 15 labelled frames at zero / +0.67 / +1.33 EV](http://10.100.10.50:8090/b/pancake-lift-ladder/) <sub>· shutter-dev · 2026-09-21 11:22</sub>
- [Saturation+vibrance ladder — current / 75% / 50%, zero lift throughout](http://10.100.10.50:8090/b/pancake-saturation/) <sub>· shutter-dev · 2026-09-21 11:22</sub>
- [Pancake v3 — the full 270 at cap 4/3, saturation 33%, gate/meter split](http://10.100.10.50:8090/b/pancake-v3-full/) <sub>· shutter-dev · 2026-09-21 13:29</sub>
- [Pancake v3 — the 53 lifted frames vs Paula, worst blown first](http://10.100.10.50:8090/b/pancake-v3-lifted/) <sub>· shutter-dev · 2026-09-21 13:29</sub>
- [Sigmoid colour test — Paula vs per-channel / RGB-ratio / smooth, 6 lifted + 2 unlifted controls](http://10.100.10.50:8090/b/pancake-sigmoid/) <sub>· shutter-dev · 2026-09-21 14:15</sub>
- [Draupnir: 5 of 6 gate checks real — min-wall lands and the control pair finally separates (thin-wall FAILs at 1.0001mm vs 1.2mm floor); renders, STLs, calibration data](http://10.100.10.50:8090/b/draupnir-first-stl/) <sub>· draupnir · 2026-09-21 14:25</sub>
- [Closed loop — 12 samples: Paula vs open-loop vs closed-loop, with EV and blown %](http://10.100.10.50:8090/b/pancake-closed-loop/) <sub>· shutter-dev · 2026-09-21 14:46</sub>
- [Draupnir first commission: puck-light diffuser cap — 90.4mm shroud, 55.9% open, renders + STL/STEP (and the gate bug this part found)](http://10.100.10.50:8090/b/draupnir-puck-cap/) <sub>· draupnir · 2026-09-21 14:49</sub>
- [Pancake v4 — the full 270 through the closed loop](http://10.100.10.50:8090/b/pancake-v4-full/) <sub>· shutter-dev · 2026-09-21 15:46</sub>
- [Pancake v4 — the frames the loop changed, Paula / open / closed, worst blown first](http://10.100.10.50:8090/b/pancake-v4-changed/) <sub>· shutter-dev · 2026-09-21 15:46</sub>
- [ShutterChute v0.9.14 on the mini — final triage over the 270 closed-loop deliveries](http://10.100.10.50:8477/?token=wtIRzaqmRQ3Qg2cjUwMZjd-OvNFB9UP3GXyGjDW2d-E&triage=/Users/paulahoang/Photos/PancakeBreakfast/deliver-shutterchute-260921) <sub>· shutter-dev · 2026-09-21 21:07</sub>
- [ShutterChute v0.9.15 on the mini — triage, fit fixed](http://10.100.10.50:8477/?token=dG9y44XQmJfH7q8Wy_o2KKeIxGnUeP5zh8yADOoEYA4&triage=/Users/paulahoang/Photos/PancakeBreakfast/deliver-shutterchute-260921) <sub>· shutter-dev · 2026-09-21 21:50</sub>
- [ShutterChute v0.9.16 — triage: centred delete tag, 1:1 pans](http://10.100.10.50:8477/?token=Nx9zEhTOXIfCrmpA72OEqEHGz7atxKQL8y5v3ecoW98&triage=/Users/paulahoang/Photos/PancakeBreakfast/deliver-shutterchute-260921) <sub>· shutter-dev · 2026-09-21 22:01</sub>
- [cr123a-to-d-sleeve — renders, STL + STEP, gate WARN on the 0.8 mm shoulder](http://10.100.10.50:8090/b/cr123a-to-d-sleeve/) <sub>· draupnir · 2026-09-21 23:14</sub>
- [Sindra @ 20 EVIDENCE BOARD — all 20 sheets + diagnostics, zero single frames (replaces the 122-image finalists board)](http://10.100.10.50:8090/b/sindra-evidence/) <sub>· comfy-dev · 2026-09-21 23:33</sub>
- [infra-ops: five ERP run-7 decisions, open and unanswered since 2026-09-09](http://10.100.10.50:8090/b/run07-decisions/) <sub>· brokkr-smithy-dev · 2026-09-21 23:52</sub>
- [Moody vs Realism BAKEOFF — 8 new scenes (4 SFW / 4 NSFW, no bedroom), 32 renders; verdict is a framing-dependent split](http://10.100.10.50:8090/b/sindra-bakeoff/) <sub>· comfy-dev · 2026-09-22 00:20</sub>
- [krea2 LoRA portability test — RAW-trained LoRAs DO activate on distilled turbo checkpoints (3 seeds, null+negative+positive controls)](http://10.100.10.50:8090/b/krea2-lora-portability/) <sub>· comfy-dev · 2026-09-22 01:53</sub>
- [The High Seat — SVOS board + Miranda (nh3-dev)](http://10.100.10.50:8770) <sub>· svos-dev · 2026-09-22 08:29</sub>
- [Sindra training corpus pass 1 (54 frames) + the two validated fixes before the corrected re-render](http://10.100.10.50:8090/b/sindra-corpus-v1/) <sub>· comfy-dev · 2026-09-22 09:10</sub>
- [Miranda re-minted Icelandic — 4 briefs x 2 seeds + Swedish/Norwegian discrimination controls + the incumbent](http://10.100.10.50:8090/b/miranda-is/) <sub>· tts-dev · 2026-09-22 10:41</sub>
- [Sindra nude selection pool — 36 frames (10 rear), pick ~10 matching body shapes](http://10.100.10.50:8090/b/sindra-nude-pool/) <sub>· comfy-dev · 2026-09-22 11:08</sub>
```
+413
View File
@@ -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.
+46 -2
View File
@@ -162,6 +162,7 @@ session that posted the set.
```
BENCH
id : normalized URL (the identity — re-posting UPDATES, never appends)
NORMALIZED MEANS THE FULL URL, NOT THE ORIGIN — see below
name : what it is
owner : the agent handle that registered it
state : live → promoted (to Homepage) → retired
@@ -173,8 +174,51 @@ BENCH
- `booth bench add <url> "<what>"` upserts on the normalized URL. The 5 `talk`
rows and 4 `peedlar` rows collapse to one each, by construction.
- **`booth link` refuses a `…:8090/b/…` URL** and names the right surface. It
survives as a deprecated alias rather than vanishing — 17 handles have the
muscle memory, and the teaching moment belongs at the point of use.
is **not deprecated** — 17 handles have the muscle memory, the teaching moment
belongs at the point of use, and (corrected 2026-09-22, U6) the board has a
legitimate residual job: of the 35 distinct non-booth targets on it, roughly
**14 are reference bookmarks** — gitea repositories, HuggingFace model cards,
a vLLM recipe, a Headscale setup page — for which the board is the right and
only home. Deprecating it would evict a third of its live content. It loses
exactly one shape, the booth URL, and keeps the rest.
### What "normalized URL" means, and why it is not the origin
Corrected 2026-09-22 while U6 was being contracted. This doc said *normalized
URL* and left it there; the obvious reading is the origin
(`scheme://host:port`), and that reading is **measurably destructive**.
Collapsing the board's 43 non-booth rows by origin yields 19 groups; by full
URL, 35. The 16-group difference is not duplication:
| what origin identity would merge | rows |
|---|---|
| eight distinct gitea repositories, issues and package versions | 8 → 1 |
| three unrelated HuggingFace model cards | 3 → 1 |
| **the two LRPG surfaces on `10.100.10.50:8321`** — this doc's own example of two real benches | 2 → 1 |
| two different claude.ai artifact briefs | 2 → 1 |
Full-URL identity still collapses both cases this doc names — `talk` 5 rows to
1, Peedlar's root 3 to 1 — which is the entire win, without the losses.
The **query string is part of the identity** and the **fragment is not**: three
ShutterChute rows differ only by `?token=` and are three genuinely different
one-shot links, while a fragment is a position inside a page. Credentials in a
URL are **refused rather than stripped** — stripping registers a bench whose URL
no longer works while telling the poster it succeeded.
### One number that was two defects
This doc's headline **69% rot** is two different defects wearing one number, and
U5 already closed the cause of the larger one:
| defect | rows (2026-09-22) | what fixes it |
|---|---|---|
| **booth-announcement rot** — a session posts a booth URL because a booth cannot announce itself | 178 rows, 156 already dead | **U5** gave job 5 a home; U6's refusal stops the habit; U6's dead marker clears what landed |
| **bench re-post** — an append log with no identity | 8 rows | U6's registry |
Worth stating plainly because the single figure implies the registry is the big
half. It is the smaller one.
- Liveness is *flagged*, not enforced. A bench that stops answering gets a
marker and a date; deleting is the operator's call. Nothing here deletes the
operator's data on a timer.
@@ -0,0 +1,39 @@
# The 69% link-board rot was two defects wearing one number
_2026-09-22 · booth_
**Re-measuring the board before writing U6's contract split its headline number
in half, and the half U6 owns is the smaller one.** The IA doc records *211
rows, 145 (69%) pointing at booths that no longer exist*. Re-counted on
2026-09-22 the board was 221 rows — and the split nobody had taken before:
| | count | share |
|---|---|---|
| rows that are booth URLs | **178** | 80% of the board |
| …whose booth is already swept | **156** | **71% of the whole board** |
| rows that are NOT booth URLs | 43 | 19% |
| …distinct after full-URL normalization | 35 | |
| …collapsed by the re-post problem U6 names | **8 rows** | |
So the 69% is:
1. **Booth-announcement rot — 178 rows.** A session posted a booth URL because
a booth could not announce itself. **U5 already closed the cause.** Nothing
stopped the habit, so the board took 11 more of these in the day after it was
first measured.
2. **Bench re-post — 8 rows.** An append log with no identity. This is the part
the registry fixes, and it is an order of magnitude smaller.
**The third thing, which the IA doc does not describe at all:** of the 35
distinct non-booth targets, roughly **14 are running services (benches)** and
roughly **14 are reference bookmarks** — gitea repos, HuggingFace model cards, a
vLLM recipe, a Headscale page — with the rest ephemeral one-shot links. The IA
doc planned for `booth link` to survive "as a deprecated alias". That would have
evicted a third of the board's live content from the only home it has. **U6 does
not deprecate `booth link`**; it removes exactly one shape from it.
**Why this is worth keeping.** The single 69% figure implies the registry is the
big win. It is not — the enforced rule and the dead marker are. A unit scoped
off the unsplit number would have built the registry, declared victory, and left
178 rows rotting. Re-measure before contracting; the number in the design doc is
a day old the moment it is written.
@@ -0,0 +1,65 @@
# U6 landed — three surfaces, three jobs, one predicate
_2026-09-22 · booth_
**The sixth of seven v1 units. Only U7 is left.** 444 → 555 tests, suite green,
deployed and verified live: 23/23 booths 200, and the board renders **156 dead
of 221 rows** — the exact count an independent shell measurement produced before
a line of code was written, from two different implementations.
## What shipped
- **`booth/benches.py`** (new, stdlib-only AND sibling-free): `Bench`,
`normalize_bench_url`, lenient `read_benches`, strict `upsert_bench`,
`set_bench_state`, `remove_bench`, `order_benches`. Registry at
`~/booth-data/.benches.json` — a dotfile at the DATA ROOT, keyed by id, so two
rows with one identity are impossible by construction.
- **`links.booth_target`** — ONE predicate for "is this a booth URL", consumed
by three callers (the CLI refusal, the board's dead marker, `bench import`).
Host-agnostic and path-shaped; percent-decodes the name.
- **`booth link` refuses a booth URL**, names `booth new --why`, and writes
nothing — not even the board directory.
- **The board marks dead rows.** Removal stays the operator's two clicks through
the bulk control that already existed. Nothing in the unit deletes a row.
- **`booth bench add|ls|state|rm|import`**; `import` writes nothing without
`--apply` and never touches `links.md`.
- `docs/archive/links-2026-09-22.md` — the board archived verbatim into git.
## The decision that mattered most, and it was measured
**Identity is the FULL normalized URL, not the origin.** Collapsing the 43
non-booth rows by origin gives 19 groups; by full URL, 35. The difference is not
duplication — it is **eight distinct gitea repos merged into one**, three
unrelated HuggingFace model cards merged into one, and **the two LRPG surfaces
on `10.100.10.50:8321`, which are the IA doc's own example of two real benches**,
merged into one. Origin identity destroys more than it dedups. Full-URL identity
still collapses both cases the doc names (talk 5→1, Peedlar 3→1).
Query is IN the identity (three ShutterChute rows differ only by `?token=` and
are three real links); fragment is OUT; credentials are REFUSED, not stripped.
## The seam review earned it again — three real contract defects
Run in-session against the real `.py` files, after the cold panel was dispatched:
- **SR-1** — the contract claimed `test_stdlib_only` already forbids sibling
imports. **It does not**: its failure set is `{r for r in roots if r !=
"booth" and ...}`, which exempts `booth` on purpose. Only test_manifest.py has
the strict copy. INV-9 would have shipped untested.
- **SR-2** — the contract named `resolve_booth` as the dead marker's existence
check. That function is a closure inside `create_app` and **raises
HTTPException(404)** — per row, one swept booth would 404 the whole board page.
- **SR-7** — booth links are emitted through `quote(name, safe="")`, so a
predicate comparing the raw segment marks every encoded-name booth dead
forever.
SR-4 and SR-5 were **verified rather than assumed**: both `list_booths` and
`sweep_once` skip a child that is not a directory AND one whose name starts with
a dot, so the registry is safe from the sweeper by two guards, not one. Had
either been absent the design would have eaten its own registry on tick one.
## Still open at the time of writing
Both cold gates are IN FLIGHT — contract review `01M35BWCJ806MT75NA630Y4WFH`,
code review `01M35CK8YKEKMV7T15JXEF6A8N`. The bug-hunt has not run. **Committed
but NOT tagged**, per the v0.2.0 lesson: if a gate is outstanding, the tag waits.
+25 -10
View File
@@ -44,16 +44,29 @@ _As of 2026-09-22:_
deliberate and it CHANGES HOW THE 2026-10-06 RE-COUNT READS: the hold rides
for free, but not-pressing-`keep` has to be learned, so a flat `.forever` rate
does not falsify anything. Read its entry before measuring.
- **TWO UNITS LEFT TO v1, and they do not depend on each other.** U6 (benches,
independent, closes the 69% link-board rot) and U7 (navigation at 270 items,
which U3 just unblocked — its only dependency was {U3, U4, U5}). Which goes
next is the operator's call. ⚠ Before starting U7, read
`persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md`: every booth
that actually needs navigation is FLAT, so half its premise is already known
to be wrong. **No recommendation is recorded for this one on purpose** — U6
and U7 are genuinely independent, they close different defects, and the last
two units before a 1.0 cut are a scope-direction call rather than a
dependency one.
- **U6 LANDED 2026-09-22 — ONE UNIT LEFT TO v1.** Benches: a registry keyed by
normalized URL, `booth link` refusing a booth URL, dead rows marked on the
board, and a non-destructive `bench import`. 444 → 555 tests, deployed and
verified live (23/23 booths 200, 156 of 221 rows marked dead — matching an
independent pre-implementation count exactly). ⚠ **COMMITTED BUT NOT TAGGED
AND NOT RELEASED**: both cold gates were still in flight at commit time
(contract review `01M35BWCJ806MT75NA630Y4WFH`, code review
`01M35CK8YKEKMV7T15JXEF6A8N`) and the bug-hunt had not run. Per the v0.2.0
lesson, the tag waits for the gates. → `persistent-memory.d/2026-09-22-u6-benches-landed.md`
- ⚠ **U6 WAS CHOSEN WITHOUT THE OPERATOR ANSWERING.** He set an autonomous goal
("hydrate and land next unit stated in handoff") and the handoff named no
unit. The session recommended U6 on measured grounds (its defect compounds —
145 → 156 dead rows in a day — while U7's is dormant, and U6 had no
unresolved design questions) and proceeded rather than blocking. **The scope
call is still his to reverse**; nothing is pushed and nothing is tagged.
- **U7 IS THE LAST UNIT, and its premise degraded again.** ⚠ Read
`persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md` AND
re-count first. On 2026-09-22 the four large booths U7 was sized against
(`pancake-v3-full`/`pancake-v4-full` at 270 items, `sindra20-engines`,
`sindra-finalists`) had ALL been swept. Largest live booth is `miranda-is` at
**92 items, flat**. Two of 23 booths have subfolders and **both are reports**.
Sections buy close to nothing; the rail, filters and grid keyboard are the
unit.
- **U3's tier was MINOR and the operator approved it** (2026-09-22). The
argument that settled it, recorded because the tie-break rule says patch: a
capability arrived AND one left — the verbatim path gained a declared public
@@ -108,6 +121,8 @@ _As of 2026-09-22:_
## Recent decisions
- `[2026-09-22]` **U6 landed — three surfaces, three jobs, one predicate** — the seam review caught three real contract defects incl. a per-row `resolve_booth` that would have 404'd the board; NOT TAGGED, gates in flight → `persistent-memory.d/2026-09-22-u6-benches-landed.md`
- `[2026-09-22]` **The 69% link-board rot was two defects wearing one number** — READ BEFORE SCOPING ANY LINK-BOARD WORK; U5 closed the larger half and full-URL-vs-origin identity is a measured call → `persistent-memory.d/2026-09-22-one-number-was-two-defects.md`
- `[2026-09-22]` **U3 landed — the page declares the seam, the Booth mounts into it** — ten regexes against author HTML replaced by a substring test and a `+` → `persistent-memory.d/2026-09-22-u3-declared-embed-seam-landed.md`
- `[2026-09-22]` **A wrong-shaped answer 500s the gallery and the marks page** — PRE-EXISTING (measured at `42ea67f`), NOT U3; the v0.2.2 lesson is only half-implemented → `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`
- `[2026-09-22]` **The browser became a test surface** — READ BEFORE TOUCHING `playwright` IN pyproject; the pinned upper bound is the foot-gun, and these tests SKIP rather than fail → `persistent-memory.d/2026-09-22-the-browser-became-a-test-surface.md`
+165 -1
View File
@@ -16,9 +16,29 @@
# pick holds its own booth, see below)
# booth unkeep <name> hand it back to the sweeper
# booth link <url> [description] append a link to the standing link board
# REFUSES a booth URL — a booth announces
# itself now; use `booth new --why`
# booth links list the board, numbered, with entry ids
# booth unlink <id|index> remove ONE link from the board
#
# booth bench add <url> <name> register or UPDATE a bench (upsert)
# booth bench ls list benches, live -> promoted -> retired
# booth bench state <id|url> <state> 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 edits links.md
#
# THREE SURFACES, THREE JOBS. Telling them apart is the whole of U6:
# a BOOTH is a review surface you post work to. It announces itself and is
# swept 24h after its last activity. `booth new` / `booth add`.
# a BENCH is a running thing — jackdaw's bench, talk's bench, the things that
# get promoted to Homepage. Durable, and identified BY ITS URL, so posting
# it again updates the row instead of adding a fifth. `booth bench add`.
# a LINK is a reference bookmark — a repo, a model card, a doc page. The
# standing board, unchanged and NOT deprecated. `booth link`.
# The board carried all three because only one of them had a surface: 178 of its
# 221 rows were booth URLs and 156 of those pointed at booths already swept.
#
# booth ask <name> <id> <prompt> <option>... [--no-notes]
# pose a multiple-choice question in a booth
# booth marks <name> [--wait [SECS]] print every mark in a booth as JSON;
@@ -184,8 +204,31 @@ whoami_handle() {
echo "${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
}
# Where booth/*.py lives, for the `python3 -c` calls below. The CLI runs under
# the SYSTEM python3 with no venv, which is why every module it imports is
# stdlib-only (CLAUDE.md invariant 1) and why no AST extractor can see these
# imports — `tests/test_cli.py` runs the real script, and is the only thing that
# catches a third-party import before a fleet host does.
booth_src() {
(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)
}
# The booth NAME a URL points at, or empty. ONE PREDICATE — this shells out to
# booth.links.booth_target rather than pattern-matching `:8090/b/` here, because
# the board's dead-row marker and `bench import` use that same function and a
# second implementation in the shell would classify the host-agnostic and
# percent-encoded cases differently (INV-2).
booth_target_of() {
BOOTH_SRC="$(booth_src)" BOOTH_Q="$1" python3 -c '
import os, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.links import booth_target # stdlib only — no venv needed
sys.stdout.write(booth_target(os.environ["BOOTH_Q"]) or "")
'
}
usage() {
echo "usage: booth {new <name> [--why W] [--title T]|add <name> <file>... [--why W] [--title T]|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>}" >&2
echo "usage: booth {new <name> [--why W] [--title T]|add <name> <file>... [--why W] [--title T]|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>|bench add <url> <name>|bench ls|bench state <id|url> <live|promoted|retired>|bench rm <id|url>|bench import [--apply]}" >&2
exit 2
}
@@ -279,6 +322,21 @@ case "$cmd" in
[ $# -ge 1 ] || usage
link_url="$1"; shift
desc="${*:-}"
# THE REFUSAL COMES FIRST, BEFORE ANY WRITE (INV-3). A booth announces
# itself now (U5), so a booth URL on the board is a row that rots the
# moment the booth is swept — 156 of the board's 221 rows are exactly
# that. Refusing AFTER the mkdir/announce below would leave a new booth
# behind as the side effect of a call that failed.
refused_name="$(booth_target_of "$link_url")"
if [ -n "$refused_name" ]; then
{
echo "booth link: that is a booth, and a booth announces itself now."
echo " booth new $refused_name --why \"${desc:-what the operator is looking at}\""
echo " (or --why on \`booth add\`; re-announcing keeps the original stamp)"
echo " the index at $URL/ is the feed."
} >&2
exit 2
fi
board="$DATA/$LINKS_BOARD"
mkdir -p -- "$board"
: > "$board/$KEEP" # the board is durable by definition
@@ -357,6 +415,112 @@ if removed is None:
print("removed: %s %s" % (removed["desc"], removed["url"]))
' "$board" "$target"
;;
bench)
# SEAM REVIEW SR-6: the first two-word verb in this script. A nested case,
# and a bare `bench` names the bench verbs rather than falling through to
# the generic usage, which would hide which of the two words was wrong.
sub="${1:-}"; shift || true
case "$sub" in
add|ls|state|rm|import) ;;
*)
echo "usage: booth bench {add <url> <name>|ls|state <id|url> <live|promoted|retired>|rm <id|url>|import [--apply]}" >&2
exit 2 ;;
esac
BOOTH_SRC="$(booth_src)" BOOTH_DATA="$DATA" BOOTH_SUB="$sub" \
BOOTH_WHO="$(whoami_handle)" BOOTH_BOARD="$LINKS_BOARD" \
python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
# stdlib only — no venv needed. benches.py imports no sibling either (INV-9).
from booth.benches import (normalize_bench_url, order_benches, read_benches,
remove_bench, set_bench_state, upsert_bench)
from booth.links import booth_target, parse_link_entries
root = pathlib.Path(os.environ["BOOTH_DATA"])
sub, who = os.environ["BOOTH_SUB"], os.environ["BOOTH_WHO"]
argv = sys.argv[1:]
def die(msg, code=2):
print("booth bench: %s" % msg, file=sys.stderr)
raise SystemExit(code)
def row(b):
# ONE LINE PER BENCH, in the rendered order — state first, so a retired
# bench sinks, then name, then id as a total tie-break (INV-4).
return "%-9s %-28s %-52s %s" % (b.state, b.name[:28], b.url[:52], b.owner)
if sub == "add":
if len(argv) < 2: die("bench add <url> <name>")
try:
bench, created = upsert_bench(root, argv[0], " ".join(argv[1:]), who)
except ValueError as exc:
die(exc)
print("%s: %s" % ("registered" if created else "updated", bench.id))
elif sub == "ls":
benches, err = read_benches(root)
if err:
die("the registry could not be read: %s" % err, 3)
if not benches:
print("no benches registered yet")
for b in benches:
print(row(b))
elif sub in ("state", "rm"):
if not argv: die("bench %s <id|url>%s" % (sub, " <state>" if sub == "state" else ""))
try:
bench_id = normalize_bench_url(argv[0])
except ValueError as exc:
die(exc)
if sub == "rm":
gone = remove_bench(root, bench_id)
if gone is None: die("no such bench: %s" % bench_id, 1)
print("removed: %s" % gone.url)
else:
if len(argv) < 2: die("bench state <id|url> <live|promoted|retired>")
try:
moved = set_bench_state(root, bench_id, argv[1])
except ValueError as exc:
die(exc)
if moved is None: die("no such bench: %s" % bench_id, 1)
print("%s is now %s" % (moved.url, moved.state))
elif sub == "import":
apply = "--apply" in argv
board = root / os.environ["BOOTH_BOARD"] / "links.md"
if not board.is_file(): die("no link board at %s" % board, 1)
skipped, candidates, refused = [], [], []
for e in parse_link_entries(board.read_text()):
name = booth_target(e["url"])
if name is not None:
skipped.append((e, name)); continue
try:
candidates.append((normalize_bench_url(e["url"]), e))
except ValueError as exc:
refused.append((e, str(exc)))
print("SKIPPED — booth rows; a booth announces itself now (%d):" % len(skipped))
for e, name in skipped:
print(" %-30s %s" % (name, e["url"]))
print()
print("CANDIDATES — would be registered (%d rows, %d distinct):"
% (len(candidates), len({i for i, _ in candidates})))
for i, e in candidates:
print(" %-52s %s" % (i, e["desc"][:60]))
print()
print("REFUSED — normalization said no (%d):" % len(refused))
for e, why in refused:
print(" %-52s %s" % (e["url"], why))
if not apply:
print()
print("nothing was written. re-run with --apply to register the candidates.")
print("NOTE: a machine cannot tell a bench from a bookmark by its URL —")
print(" roughly 14 of 35 live candidates are repos, model cards and docs,")
print(" for which the board is the right home. Review before applying.")
raise SystemExit(0)
for i, e in candidates:
upsert_bench(root, e["url"], e["desc"], e["who"] or who)
print()
print("applied: %d distinct benches registered. links.md was NOT modified."
% len({i for i, _ in candidates}))
' "$@"
;;
ask)
# booth ask <name> <id> <prompt> <opt>... [--no-notes]
[ $# -ge 5 ] || usage
+542
View File
@@ -0,0 +1,542 @@
"""U6 — benches: a running thing, registered.
The contract is docs/contracts/u6_benches.contract.md. Every test here names
the invariant it falsifies, and each is written to go RED under the change that
defeats that invariant — not merely to assert the outcome the author had in
mind. (The U4 round shipped seven falsifiers of which five stayed green under
the very change they forbade; see persistent-memory.d/2026-09-22-vacuous-falsifiers.md.)
"""
from __future__ import annotations
import ast
import json
import os
import pathlib
import sys
import pytest
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.benches import ( # noqa: E402
BENCHES_FILE,
BENCH_STATES,
Bench,
normalize_bench_url,
order_benches,
read_benches,
remove_bench,
set_bench_state,
upsert_bench,
)
from booth.links import booth_target # noqa: E402
# ---- INV-6: the identity collapses a re-post and NOTHING else ---------------
#
# Both directions from ONE fixture. A test that only checked the talk collapse
# would pass under origin normalization, which is the measurably wrong rule:
# on the live board it merges eight distinct gitea repositories into one row.
# Measured on the live board, 2026-09-22.
GITEA_EIGHT = [
"https://gitea.phasefinal.com/vh/bifrost/issues/17",
"https://gitea.phasefinal.com/vh/brokkr-smithy/src/commit/6adcde6/research/landscape-scans/open-weight-releases-2026-09-15.md",
"https://gitea.phasefinal.com/vh/cicada",
"https://gitea.phasefinal.com/vh/draupnir",
"https://gitea.phasefinal.com/vh/-/packages/pypi/bifrost/1.2.0",
"https://gitea.phasefinal.com/vh/-/packages/pypi/bifrost/1.2.1",
"https://gitea.phasefinal.com/vh/peedlar",
"https://gitea.phasefinal.com/vh/peedlar/releases/tag/v0.3.0",
]
TALK_FIVE = ["https://talk.nh3.phasefinal.com:8092/"] * 5
def test_eight_distinct_repos_stay_eight(tmp_path):
"""INV-6, the direction origin-normalization gets WRONG. Defeating change:
normalizing to scheme://host:port. This goes red under it; the collapse
test below does not."""
for i, u in enumerate(GITEA_EIGHT):
upsert_bench(tmp_path, u, f"repo {i}", "vh")
benches, err = read_benches(tmp_path)
assert err is None
assert len(benches) == 8, [b.id for b in benches]
def test_five_reposts_of_one_bench_collapse(tmp_path):
"""INV-6, the direction the IA doc names. `talk` is on the live board five
times; the registry must hold one row, carrying the LAST name."""
for i, u in enumerate(TALK_FIVE):
_, created = upsert_bench(tmp_path, u, f"talk v{i}", "nh3-dev")
assert created is (i == 0)
benches, _ = read_benches(tmp_path)
assert len(benches) == 1
assert benches[0].name == "talk v4"
def test_two_lrpg_surfaces_on_one_origin_stay_two(tmp_path):
"""INV-6. The IA doc's OWN example of two real benches shares an origin."""
upsert_bench(tmp_path, "http://10.100.10.50:8321/Authoring%20Studio.dc.html", "authoring", "ldp-dev")
upsert_bench(tmp_path, "http://10.100.10.50:8321/GM%20Playback.dc.html", "gm", "ldp-dev")
assert len(read_benches(tmp_path)[0]) == 2
def test_query_is_part_of_the_identity(tmp_path):
"""INV-6. Three ShutterChute rows differ ONLY by `?token=`; they are three
links, not one bench posted three times. Defeating change: dropping query."""
base = "http://10.100.10.50:8477/?token="
for tok in ("aaa", "bbb", "ccc"):
upsert_bench(tmp_path, base + tok, "shutterchute", "nh3-dev")
assert len(read_benches(tmp_path)[0]) == 3
@pytest.mark.parametrize("a,b", [
("http://x.test/", "http://X.TEST"), # host case + bare-slash path
("http://x.test:80/p", "http://x.test/p"), # default port
("https://x.test:443/p", "https://x.test/p"),
("http://x.test/p#frag", "http://x.test/p"), # fragment dropped
(" http://x.test/p ", "http://x.test/p"), # whitespace
])
def test_these_pairs_are_one_bench(a, b):
"""INV-6. Each pair is the SAME resource reached two ways."""
assert normalize_bench_url(a) == normalize_bench_url(b)
@pytest.mark.parametrize("a,b", [
("http://x.test/p", "http://x.test/p/"), # trailing slash on a REAL path
("http://x.test/p", "http://x.test/P"), # path case
("http://x.test/?a=1&b=2", "http://x.test/?b=2&a=1"), # query order is opaque
("http://x.test:8092/", "https://x.test:8092/"), # scheme
])
def test_these_pairs_are_two_benches(a, b):
"""INV-6, the other direction. Each pair MAY be two different resources, and
the registry must not decide otherwise on the operator's behalf."""
assert normalize_bench_url(a) != normalize_bench_url(b)
@pytest.mark.parametrize("bad", [
"", " ", "not a url", "ftp://x.test/f", "file:///etc/passwd",
"http://", "https:///path", "//x.test/p", "javascript:alert(1)",
])
def test_refused_urls_raise_with_a_reason(bad):
with pytest.raises(ValueError) as e:
normalize_bench_url(bad)
assert str(e.value).strip(), "a refusal with no reason is a refusal the CLI cannot print"
def test_credentials_are_refused_not_stripped():
"""Stripping would register a bench whose URL no longer works while telling
the poster it succeeded — and put a credential on an unauthenticated LAN
surface on the way. Defeating change: `netloc.rpartition('@')[2]`."""
with pytest.raises(ValueError):
normalize_bench_url("https://user:hunter2@x.test/p")
# ---- INV-7: `url` is what a click goes to; `id` is never the href -----------
def test_the_stored_url_is_the_raw_string(tmp_path):
"""INV-7. Defeating change: storing the normalized form as `url` because it
is 'the clean one'. Every field that differs is asserted, byte for byte."""
raw = " HTTP://X.Test:80/Some%20Path/?b=2&a=1#frag "
bench, _ = upsert_bench(tmp_path, raw, "n", "o")
assert bench.url == raw.strip()
assert bench.id != bench.url
assert bench.id == "http://x.test/Some%20Path/?b=2&a=1"
assert read_benches(tmp_path)[0][0].url == raw.strip()
# ---- INV-4: the rendered order is TOTAL and stated --------------------------
def test_same_name_benches_do_not_swap(tmp_path):
"""INV-4. Defeating change: dropping the `id` tie-break. Two benches with
the SAME name, registered in both orders, must render identically — a test
over distinct names passes with no tie-break at all."""
def build(order):
root = tmp_path / f"r{order}"
root.mkdir()
for u in (["http://a.test/", "http://b.test/"] if order else
["http://b.test/", "http://a.test/"]):
upsert_bench(root, u, "same name", "o")
return [b.id for b in read_benches(root)[0]]
assert build(0) == build(1)
def test_state_ranks_before_name(tmp_path):
"""INV-4. live → promoted → retired, THEN name. Defeating change: ordering
by name alone, which a fixture of three same-state benches cannot see."""
upsert_bench(tmp_path, "http://a.test/", "aaa", "o") # would sort first by name
upsert_bench(tmp_path, "http://z.test/", "zzz", "o")
set_bench_state(tmp_path, normalize_bench_url("http://a.test/"), "retired")
assert [b.name for b in read_benches(tmp_path)[0]] == ["zzz", "aaa"]
def test_order_is_case_insensitive_on_name(tmp_path):
upsert_bench(tmp_path, "http://b.test/", "Bravo", "o")
upsert_bench(tmp_path, "http://a.test/", "alpha", "o")
assert [b.name for b in read_benches(tmp_path)[0]] == ["alpha", "Bravo"]
def test_order_benches_is_pure(tmp_path):
"""INV-4. Defeating change: `order_benches` doing I/O or sorting in place.
Called with records belonging to NO root, it must still answer."""
made = [Bench(id=f"http://{c}.test/", url=f"http://{c}.test/", name=c,
owner="o", state="live", added="", updated="") for c in "ba"]
assert [b.name for b in order_benches(made)] == ["a", "b"]
assert [b.name for b in made] == ["b", "a"], "input was mutated"
# ---- INV-5: the read cannot raise, and cannot cost the caller unboundedly ---
def _write_raw(root: pathlib.Path, payload: str) -> None:
(root / BENCHES_FILE).write_text(payload)
def test_absent_registry_is_not_an_error(tmp_path):
benches, err = read_benches(tmp_path)
assert benches == [] and err is None
@pytest.mark.parametrize("payload,label", [
("this is not json", "non-JSON bytes"),
("[]", "valid JSON of the wrong top-level shape"),
('{"benches": []}', "the list shape this unit deliberately does not use"),
('{"http://a/": "a string, not a record"}', "a value of the wrong type"),
('{"http://a/": {"name": [], "owner": "o", "state": "live"}}', "a FIELD of the wrong type"),
('{"http://a/": {"name": "n", "owner": "o", "state": "invented"}}', "an unknown state"),
])
def test_damaged_registries_report_rather_than_raise(tmp_path, payload, label):
"""INV-5. Defeating change: `json.load` with no guard, or `except: pass`
which would report absent. The error must be NON-EMPTY — 'damaged' and
'absent' must not render the same, because only one of them needs a human.
The wrong-typed-FIELD row is the shape currently 500ing the gallery
elsewhere in this service."""
_write_raw(tmp_path, payload)
benches, err = read_benches(tmp_path)
assert err, f"{label} reported no error"
assert benches == []
def test_oversized_registry_is_refused_by_size_before_parsing(tmp_path):
"""INV-5. Defeating change: parsing first and checking length after, which
costs the caller the whole file. A FIFO has st_size 0, so the guard must
bound the READ, not trust the stat — the 2026-09-22 hang lesson."""
from booth.benches import BENCHES_MAX_BYTES
_write_raw(tmp_path, '{"http://a/": {"name": "' + "x" * BENCHES_MAX_BYTES + '"}}')
benches, err = read_benches(tmp_path)
assert err and benches == []
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores the mode bit")
def test_unreadable_registry_reports_rather_than_raises(tmp_path):
p = tmp_path / BENCHES_FILE
p.write_text("{}")
p.chmod(0o000)
try:
benches, err = read_benches(tmp_path)
assert err and benches == []
finally:
p.chmod(0o644)
# ---- INV-1: one module knows the registry's filename ------------------------
def test_only_benches_py_names_the_registry_file():
"""INV-1. Defeating change: a route reading `.benches.json` directly to save
an import. Asserting that the panel renders would pass under exactly that."""
root = pathlib.Path(__file__).parent.parent
offenders = []
for f in list((root / "booth").rglob("*.py")) + [root / "scripts" / "booth"]:
if f.name == "benches.py":
continue
if ".benches.json" in f.read_text():
offenders.append(str(f.relative_to(root)))
assert not offenders, f"the registry filename is hard-coded outside benches.py: {offenders}"
# ---- INV-9: stdlib-only, AND sibling-free (seam review SR-1) ----------------
def test_benches_is_stdlib_only_and_imports_no_sibling():
"""INV-9. The PARAMETRIZED test in test_marks.py exempts `booth` on purpose,
so it cannot catch `from booth.links import booth_target` — which is exactly
the import this unit tempts an implementer into. This is the strict copy,
mirroring tests/test_manifest.py. Seam review SR-1."""
src = pathlib.Path(__file__).parent.parent / "booth" / "benches.py"
tree = ast.parse(src.read_text())
roots = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
roots.update(a.name.split(".")[0] for a in node.names)
elif isinstance(node, ast.ImportFrom):
roots.add("booth" if node.level else (node.module or "").split(".")[0])
outside = {r for r in roots if r and r not in sys.stdlib_module_names}
assert not outside, f"booth/benches.py imports outside the stdlib (booth.* included): {sorted(outside)}"
# ---- INV-2: ONE predicate decides what a booth URL is -----------------------
# Every row is (url, expected booth name or None). Run against BOTH callers.
BOOTH_URL_TABLE = [
("http://10.100.10.50:8090/b/sindra-bakeoff/", "sindra-bakeoff"),
("http://10.100.10.50:8090/b/sindra-bakeoff", "sindra-bakeoff"),
("http://localhost:8090/b/x/", "x"),
("http://NH3-DEV.nh3.internal:8090/b/x/", "x"), # host-agnostic, any case
("https://10.100.10.50:8090/b/x/", "x"), # scheme-agnostic
("http://10.100.10.50:8090/b/my%20booth/", "my booth"), # SR-7: decoded
("http://10.100.10.50:8090/b/x/zoom/a.png", "x"), # nested path
("http://10.100.10.50:8090/b/x/?q=1", "x"), # query
("http://10.100.10.50:8090/b/x/#frag", "x"),
("http://10.100.10.50:8090/", None), # the Booth root IS a bench
("http://10.100.10.50:8090/b/", None), # no name
("http://10.100.10.50:8090/b//", None),
("http://10.100.10.50:8090/b/.hidden/", None), # resolve_booth's rules
("http://10.100.10.50:8090/b/%2e%2e/", None), # decoded `..`
("http://10.100.10.50:8090/b/a%2Fb/", None), # decoded separator
("https://gitea.phasefinal.com/vh/peedlar", None),
("not a url at all", None),
]
@pytest.mark.parametrize("url,expected", BOOTH_URL_TABLE)
def test_booth_target_classifies(url, expected):
"""INV-2. The table is shared with the CLI refusal test and the dead-marker
test, so a second implementation in either place goes red here or there."""
assert booth_target(url) == expected
def test_booth_target_never_raises():
"""A board row is arbitrary operator-editable text; a predicate that raises
on one row takes the whole page. Defeating change: `urlsplit` unguarded."""
for junk in ["", " ", "http://[oops", "\x00", "://", "http://]"]:
assert booth_target(junk) is None
# ---- upsert semantics -------------------------------------------------------
def test_added_survives_reregistration_updated_does_not(tmp_path):
first, created = upsert_bench(tmp_path, "http://a.test/", "one", "o1")
assert created
second, created = upsert_bench(tmp_path, "http://a.test/", "two", "o2")
assert not created
assert second.added == first.added
assert second.name == "two" and second.owner == "o2"
def test_state_survives_reregistration(tmp_path):
"""A promoted bench that re-announces itself is still promoted — otherwise
every deploy silently demotes it."""
upsert_bench(tmp_path, "http://a.test/", "one", "o")
set_bench_state(tmp_path, normalize_bench_url("http://a.test/"), "promoted")
again, _ = upsert_bench(tmp_path, "http://a.test/", "one again", "o")
assert again.state == "promoted"
def test_a_new_bench_is_live(tmp_path):
bench, _ = upsert_bench(tmp_path, "http://a.test/", "one", "o")
assert bench.state == "live" and bench.state in BENCH_STATES
def test_set_state_refuses_an_unknown_state(tmp_path):
upsert_bench(tmp_path, "http://a.test/", "one", "o")
with pytest.raises(ValueError):
set_bench_state(tmp_path, normalize_bench_url("http://a.test/"), "invented")
def test_set_state_and_remove_miss_cleanly(tmp_path):
assert set_bench_state(tmp_path, "http://nope/", "live") is None
assert remove_bench(tmp_path, "http://nope/") is None
def test_remove_returns_the_record_and_drops_it(tmp_path):
upsert_bench(tmp_path, "http://a.test/", "one", "o")
gone = remove_bench(tmp_path, normalize_bench_url("http://a.test/"))
assert gone is not None and gone.name == "one"
assert read_benches(tmp_path)[0] == []
def test_fields_are_capped_at_the_write(tmp_path):
from booth.benches import NAME_MAX, OWNER_MAX
bench, _ = upsert_bench(tmp_path, "http://a.test/", "n" * 500, "o" * 500)
assert len(bench.name) == NAME_MAX and len(bench.owner) == OWNER_MAX
def test_a_write_over_a_damaged_registry_does_not_destroy_it(tmp_path):
"""The 2026-09-21 lesson, in this unit's storage: reads are lenient, writes
are STRICT. A damaged registry must not be silently replaced by a fresh one
carrying only the new row — that is the marks-wipe bug in a new file."""
_write_raw(tmp_path, '{"http://a/": {"name": "real", "owner": "o", "state": "live"}, BROKEN')
before = (tmp_path / BENCHES_FILE).read_text()
with pytest.raises(ValueError):
upsert_bench(tmp_path, "http://b.test/", "new", "o")
assert (tmp_path / BENCHES_FILE).read_text() == before
def test_the_on_disk_shape_is_an_object_keyed_by_id(tmp_path):
"""Two rows with one identity are then impossible BY CONSTRUCTION rather
than by an upsert remembering to check."""
upsert_bench(tmp_path, "http://a.test/", "one", "o")
raw = json.loads((tmp_path / BENCHES_FILE).read_text())
# The key is the NORMALIZED url, so the bare "/" is already gone — which is
# the rule `test_these_pairs_are_one_bench` pins independently.
assert isinstance(raw, dict) and list(raw) == ["http://a.test"]
assert "id" not in raw["http://a.test"], "the key IS the id; storing it twice invites drift"
# ---- the rendered surface ---------------------------------------------------
#
# The benches panel and the dead-row marker both live on the standing board's
# page — the one booth carrying a links.md.
from fastapi.testclient import TestClient # noqa: E402
from booth.app import create_app # noqa: E402
def _board(root: pathlib.Path, rows: str) -> pathlib.Path:
b = root / "links"
b.mkdir(parents=True, exist_ok=True)
(b / "links.md").write_text(rows)
return b
def _client(root):
return TestClient(create_app(root, ttl_hours=24, start_sweeper=False))
ROW_LIVE = "- [still here](http://10.100.10.50:8090/b/alive/) <sub>· x · 2026-09-01 00:00</sub>\n"
ROW_DEAD = "- [swept](http://10.100.10.50:8090/b/gone/) <sub>· x · 2026-09-01 00:00</sub>\n"
ROW_REF = "- [a repo](https://gitea.phasefinal.com/vh/peedlar) <sub>· x · 2026-09-01 00:00</sub>\n"
def _dead_rows(body: str) -> list[str]:
"""Board ROWS carrying the dead class.
Scoped to `<div class="board-row ...">` on purpose: the class name also
appears in base.html's stylesheet, so a whole-document substring test is
always true and can never go red — a vacuous falsifier of exactly the shape
persistent-memory.d/2026-09-22-vacuous-falsifiers.md describes.
"""
return [ln for ln in body.splitlines()
if 'class="board-row' in ln and "board-dead" in ln]
def test_a_dead_row_is_marked_and_a_live_one_is_not(tmp_path):
"""The marker. Defeating change: marking every `/b/` row dead, or none.
Both a live target and a dead one are in ONE fixture, so a marker that is
constant in either direction goes red."""
(tmp_path / "alive").mkdir()
_board(tmp_path, ROW_LIVE + ROW_DEAD + ROW_REF)
r = _client(tmp_path).get("/b/links/")
assert r.status_code == 200
rows = _dead_rows(r.text)
assert len(rows) == 1, f"exactly one of the three rows is dead, got {rows}"
assert "/b/gone/" in r.text and "booth is gone" in r.text
def test_the_marker_never_takes_the_page(tmp_path):
"""Seam review SR-2. `resolve_booth` RAISES HTTPException(404); calling it
per row would turn one swept booth into a 404 for the whole board. This is
the test that goes red under that exact implementation."""
_board(tmp_path, ROW_DEAD * 5)
assert _client(tmp_path).get("/b/links/").status_code == 200
def test_a_percent_encoded_booth_is_not_marked_dead(tmp_path):
"""Seam review SR-7. `quote(name, safe="")` is how the service emits these,
so the marker must decode before it looks on disk. Defeating change:
comparing the raw path segment — which marks this row dead forever."""
(tmp_path / "my booth").mkdir()
_board(tmp_path, "- [x](http://10.100.10.50:8090/b/my%20booth/) <sub>· x · 2026-09-01 00:00</sub>\n")
assert _dead_rows(_client(tmp_path).get("/b/links/").text) == []
def test_a_reference_row_is_never_marked_dead(tmp_path):
_board(tmp_path, ROW_REF)
assert _dead_rows(_client(tmp_path).get("/b/links/").text) == []
def test_the_panel_renders_registered_benches(tmp_path):
_board(tmp_path, ROW_REF)
upsert_bench(tmp_path, "https://talk.nh3.phasefinal.com:8092/", "talk", "tts-dev")
body = _client(tmp_path).get("/b/links/").text
assert "talk" in body and "tts-dev" in body
def test_the_anchor_href_is_the_raw_url_not_the_id(tmp_path):
"""INV-7. Defeating change: rendering `bench.id` in the href because it is
'the clean one'. The raw URL here normalizes differently in three ways."""
raw = "HTTP://Talk.NH3.test:80/Some%20Path/?b=2&a=1#frag"
_board(tmp_path, ROW_REF)
upsert_bench(tmp_path, raw, "talk", "o")
body = _client(tmp_path).get("/b/links/").text
assert 'href="HTTP://Talk.NH3.test:80/Some%20Path/?b=2&amp;a=1#frag"' in body, \
"the href must be the URL as posted, byte for byte"
@pytest.mark.parametrize("payload,label", [
(None, "absent"),
("not json", "non-JSON"),
("[]", "wrong top-level shape"),
('{"http://a/": {"name": [], "owner": "o", "state": "live"}}', "wrong-typed field"),
('{"http://a/": {"name": "n", "owner": "o", "state": "invented"}}', "unknown state"),
])
def test_a_damaged_registry_costs_its_panel_and_never_the_page(tmp_path, payload, label):
"""INV-5, at the render. The v0.2.2 lesson: a poisoned sidecar returned 500
for `/` and `/healthz` across all 25 booths. The wrong-typed-FIELD row is
the shape currently 500ing the gallery elsewhere in this service, so it is
the one that matters most."""
_board(tmp_path, ROW_REF)
if payload is not None:
(tmp_path / BENCHES_FILE).write_text(payload)
c = _client(tmp_path)
assert c.get("/b/links/").status_code == 200, label
assert c.get("/").status_code == 200, label
assert c.get("/healthz").status_code == 200, label
def test_damaged_and_absent_render_different_text(tmp_path):
"""INV-5. Only ONE of them needs a human. Defeating change: `except: pass`
returning ([], None), which renders damaged exactly like absent."""
_board(tmp_path, ROW_REF)
absent = _client(tmp_path).get("/b/links/").text
(tmp_path / BENCHES_FILE).write_text("not json")
damaged = _client(tmp_path).get("/b/links/").text
assert absent != damaged
def test_the_panel_does_not_render_on_an_ordinary_booth(tmp_path):
"""A bench registry on every gallery page would be noise, and would cost a
read per booth page view for a surface that belongs to exactly one."""
(tmp_path / "ordinary").mkdir()
(tmp_path / "ordinary" / "a.png").write_bytes(b"\x89PNG\r\n\x1a\n")
upsert_bench(tmp_path, "https://talk.test/", "talk", "o")
assert "talk" not in _client(tmp_path).get("/b/ordinary/").text
def test_the_benches_routes_round_trip(tmp_path):
_board(tmp_path, ROW_REF)
c = _client(tmp_path)
assert c.post("/b/links/bench-add", data={"url": "https://x.test/", "name": "ex"},
follow_redirects=False).status_code in (302, 303)
assert "ex" in c.get("/b/links/").text
bid = normalize_bench_url("https://x.test/")
c.post("/b/links/bench-state", data={"bench": bid, "state": "retired"},
follow_redirects=False)
assert read_benches(tmp_path)[0][0].state == "retired"
c.post("/b/links/bench-remove", data={"bench": bid}, follow_redirects=False)
assert read_benches(tmp_path)[0] == []
def test_a_bad_url_posted_to_the_route_does_not_500(tmp_path):
_board(tmp_path, ROW_REF)
c = _client(tmp_path)
r = c.post("/b/links/bench-add", data={"url": "ftp://x.test/f", "name": "ex"},
follow_redirects=False)
assert r.status_code in (302, 303, 400)
assert c.get("/b/links/").status_code == 200
+160
View File
@@ -346,3 +346,163 @@ def test_answer_does_not_poll_forever_on_a_pick_that_cannot_be_answered(tmp_path
"BOOTH_URL": "http://booth.invalid"})
assert r.returncode != 0
assert "broken" in r.stderr.lower() or "cannot" in r.stderr.lower()
# ---- U6: benches ------------------------------------------------------------
#
# The CLI half of the unit. `docs/contracts/u6_benches.contract.md`.
REFUSED = 2
# Shared with tests/test_benches.py::BOOTH_URL_TABLE — INV-2 says ONE predicate
# decides what a booth URL is, and these are the rows the CLI must agree on.
# A second `/b/` check inlined in the shell for speed goes red HERE.
from test_benches import BOOTH_URL_TABLE # noqa: E402
@pytest.mark.parametrize("url,is_booth", [(u, e is not None) for u, e in BOOTH_URL_TABLE])
def test_link_refuses_exactly_what_booth_target_matches(booth, url, is_booth):
"""INV-2. Defeating change: a `case "$url" in *':8090/b/'*)` in the shell,
which would classify the host-agnostic and percent-encoded rows differently
from the Python predicate the board's dead marker uses."""
data, _ = booth
r = run(data, "link", url, "a description")
assert (r.returncode == REFUSED) is is_booth, (url, r.returncode, r.stderr)
def test_a_refused_link_writes_nothing_at_all(booth):
"""INV-3. Defeating change: putting the refusal AFTER the `mkdir -p` /
announce block, which is where it would naturally land if written without
thinking. Asserting only that links.md lacks the row would PASS under that
change — so this asserts the board directory does not exist."""
data, _ = booth
board = data / "links"
assert not board.exists()
r = run(data, "link", "http://10.100.10.50:8090/b/some-booth/", "nope")
assert r.returncode == REFUSED
assert not board.exists(), f"a refused link left {sorted(p.name for p in board.iterdir())}"
def test_the_refusal_names_the_alternative(booth):
"""The teaching moment belongs at the point of use: 17 handles have the
muscle memory, and a bare 'refused' sends them to a human."""
data, _ = booth
r = run(data, "link", "http://10.100.10.50:8090/b/some-booth/", "nope")
out = r.stderr + r.stdout
assert "--why" in out and "some-booth" in out
def test_a_reference_bookmark_is_still_a_link(booth):
"""The board keeps its residual job. Measured: ~14 of the 35 distinct
non-booth targets are repos, model cards and docs, for which the board is
the right and only home. A second refusal would break that."""
data, _ = booth
r = run(data, "link", "https://gitea.phasefinal.com/vh/peedlar", "the repo")
assert r.returncode == OK, r.stderr
assert "the repo" in (data / "links" / "links.md").read_text()
def test_bench_add_is_an_upsert(booth):
data, _ = booth
for i in range(3):
r = run(data, "bench", "add", "https://talk.nh3.phasefinal.com:8092/", f"talk v{i}")
assert r.returncode == OK, r.stderr
r = run(data, "bench", "ls")
assert r.returncode == OK, r.stderr
assert r.stdout.count("talk v") == 1 and "talk v2" in r.stdout
def test_bench_verbs_round_trip(booth):
data, _ = booth
assert run(data, "bench", "add", "http://x.test/", "ex").returncode == OK
assert run(data, "bench", "state", "http://x.test/", "promoted").returncode == OK
assert "promoted" in run(data, "bench", "ls").stdout
assert run(data, "bench", "rm", "http://x.test/").returncode == OK
assert "ex" not in run(data, "bench", "ls").stdout
def test_bench_state_and_rm_take_an_id_or_a_url(booth):
"""`bench ls` prints ids; the operator has the URL. Both must address."""
data, _ = booth
run(data, "bench", "add", "http://x.test/p/", "ex")
assert run(data, "bench", "state", "http://x.test/p/", "retired").returncode == OK
assert run(data, "bench", "rm", "http://x.test/p/").returncode == OK
def test_bench_add_refuses_a_bad_url_with_the_reason(booth):
data, _ = booth
r = run(data, "bench", "add", "ftp://x.test/f", "ex")
assert r.returncode != OK
assert "http" in (r.stderr + r.stdout).lower()
def test_bare_bench_names_the_bench_verbs(booth):
"""Seam review SR-6: `bench` is the first two-word verb in this script, and
falling through to the generic usage hides which word was wrong."""
data, _ = booth
r = run(data, "bench")
assert r.returncode != OK
assert "add" in r.stderr and "import" in r.stderr
def _seed_board(data):
board = data / "links"
board.mkdir(parents=True, exist_ok=True)
(board / "links.md").write_text(
"- [a booth](http://10.100.10.50:8090/b/gone/) <sub>· x · 2026-09-01 00:00</sub>\n"
"- [talk](https://talk.nh3.phasefinal.com:8092/) <sub>· x · 2026-09-01 00:00</sub>\n"
"- [talk again](https://talk.nh3.phasefinal.com:8092/) <sub>· x · 2026-09-02 00:00</sub>\n"
"- [a repo](https://gitea.phasefinal.com/vh/peedlar) <sub>· x · 2026-09-03 00:00</sub>\n"
"- [bad](ftp://x.test/f) <sub>· x · 2026-09-04 00:00</sub>\n"
)
return board
def test_import_writes_nothing_without_apply(booth):
"""INV-8. A proposal that writes is not a proposal."""
data, _ = booth
board = _seed_board(data)
before = (board / "links.md").read_text()
r = run(data, "bench", "import")
assert r.returncode == OK, r.stderr
assert not (data / ".benches.json").exists()
assert (board / "links.md").read_text() == before
def test_import_classifies_into_three_groups(booth):
data, _ = booth
_seed_board(data)
out = run(data, "bench", "import").stdout
assert "gone" in out # the booth row, skipped
assert "talk" in out # a candidate
assert "ftp://x.test/f" in out # refused, with its reason
def test_import_apply_collapses_the_repost(booth):
data, _ = booth
_seed_board(data)
assert run(data, "bench", "import", "--apply").returncode == OK
ls = run(data, "bench", "ls").stdout
# ONE ROW, counted by line: "talk" appears in both the name and the
# hostname, so a substring count would read 2 for a correctly collapsed row.
assert len([l for l in ls.splitlines() if "talk" in l]) == 1, ls
assert "peedlar" in ls
assert "gone" not in ls, "a booth row was imported as a bench"
def test_nothing_in_the_unit_touches_links_md(booth):
"""INV-8. Defeating change: `import --apply` tidying up the rows it
consumed. The whole CLI surface runs against one board and the file must
come out byte-identical."""
import hashlib
data, _ = booth
board = _seed_board(data)
before = hashlib.sha256((board / "links.md").read_bytes()).hexdigest()
run(data, "link", "http://10.100.10.50:8090/b/x/", "refused")
run(data, "bench", "import")
run(data, "bench", "import", "--apply")
run(data, "bench", "add", "http://new.test/", "new")
run(data, "bench", "state", "http://new.test/", "retired")
run(data, "bench", "rm", "http://new.test/")
after = hashlib.sha256((board / "links.md").read_bytes()).hexdigest()
assert before == after
+1 -1
View File
@@ -276,7 +276,7 @@ def test_as_dict_round_trips_through_json(tmp_path):
# ---- the stdlib-only invariant (INV-5) --------------------------------------
@pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest"])
@pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest", "benches"])
def test_stdlib_only(module):
"""INV-5. scripts/booth imports these under the system python3 with NO venv,
through a `python3 -c` heredoc that no AST extractor can see — so nothing