fix(u6): fold the cold contract panel — the import selection gap, and a document arguing with itself

/heid-contract-review panel 01M35BWCJ806MT75NA630Y4WFH. The headline arrived
from all four arms independently and it is a missing feature, not a wording
problem.

`bench import --apply` registered every candidate, while the same contract says
roughly 14 of 35 are reference bookmarks that must stay on the board. There was
no selection mechanism between the dry-run report and the write -- so the write
path did the exact thing this unit's rationale calls impossible, tell a bench
from a bookmark by its URL, silently, to rows that belong where they are. The
report existed precisely because the decision is not mechanizable. `--apply`
now takes the ids the operator names; a bare `--apply` is refused and an
unknown id is refused, both writing nothing.

Two solo findings, both real:

- A successful registration could push the registry past the size its own
  reader refuses, so the LAST bench added would make every other bench
  invisible while reporting success. The writer now respects the reader's cap.
- The credential ban covered bench URLs and not `booth link`, the door this
  unit did not touch -- and the board renders on an unauthenticated LAN
  surface. A password can no longer reach it through either door. A small
  deliberate widening, named rather than smuggled.

Cap semantics were readable three ways (refuse / clip-for-display /
truncate-and-store) with a different build behind each, 4-of-4. Now stated per
field: name and owner truncate, url and state are refused at the write and are
DAMAGE at the read. url is not a display budget -- INV-7 promises the click
goes to the posted address byte for byte, and a clipped URL keeps that promise
in the type system while breaking it in the browser. The code had been clipping
it; fixed.

Two passages disagreed about one character: INV-7's specimen named "a trailing
slash on a non-empty path" as something normalization changes, while the rule
list keeps it and INV-6 makes the two spellings two benches. The rule list is
right; the specimen was wrong. Found by 3-of-4.

Also: INV-6's component list was illustrative where it had to be exhaustive and
was short scheme and port; "writes nothing" appeared twice with different
lists; the dead marker's predicate was readable two ways with 221 rows riding
on it; and INV-2's falsifier read as though three callers agreeing pinned
something, when three callers of one wrong predicate agree perfectly -- the
table's expected values are the real check and now say so.

597 -> 604 tests.
This commit is contained in:
vh
2026-09-22 14:12:36 -07:00
parent 8a7af3eb08
commit 32e3ed65e1
5 changed files with 351 additions and 43 deletions
+91 -26
View File
@@ -123,8 +123,26 @@ upsert" literally and found the code bumping on a state change: the code is
right (a promotion is a change to the record and "last touched" should say so)
and the earlier wording was narrower than what anyone wants the field to mean.
Caps: `name` 120, `owner` 64, `url` 2048, `state` one of three — applied at the
write and again at the read. Each is a display budget, not a storage limit.
**Caps, and what "applied" means for each — stated per field, because it is
not the same verb for all of them.** The cold paraphrase panel found "applied at
the write and again at the read" readable three ways (refuse / clip-for-display
/ truncate-and-store) with a different build behind each, and 4-of-4 arms
flagged it.
| field | cap | at the write | at the read |
|---|---|---|---|
| `name` | 120 | **truncated** | **truncated** |
| `owner` | 64 | **truncated** | **truncated** |
| `url` | 2048 | **refused** (`normalize_bench_url` raises) | **damage** — reported, never clipped |
| `state` | one of three | **refused** | **damage** |
| `id` | 2048 | **refused**, via the url it is derived from | **not applied** — see below |
`name` and `owner` are display budgets: clipping one costs a few characters in
a panel row. **`url` is not a budget and must never be clipped**, at either end
— INV-7 promises the click goes to the posted address byte for byte, and a
shortened URL keeps that promise in the type system while breaking it in the
browser. Nothing this code writes can store an over-long one; a hand-edited
registry can, and that is damage.
**`id` is capped at the WRITE ONLY, and that asymmetry is deliberate.**
`normalize_bench_url` refuses an input over `URL_MAX`, so nothing this code
@@ -228,9 +246,14 @@ 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 writes nothing — nothing at all.** Not the row, not the board
directory, not the `.booth.json` announcement `booth link` creates on first
use, not a lock file. The test asserts the data root's entries are unchanged,
not merely that `links.md` lacks the row.
*(Amended: this listed two items while INV-3 listed four, so a reader of the
prose alone could conclude a lock file was permissible. One list now, and it
is the strict one.)*
- **It is the ONLY new refusal.** A reference bookmark is still a link.
## What renders
@@ -244,6 +267,15 @@ On the standing board's page, above the rows:
checkbox pre-reachable by the existing select-all, so the operator can tick
and use the `unlink-many` control already on the page. **No new delete path.**
**Dead means exactly this, and both halves are load-bearing:**
`booth_target(row.url)` is not None **AND** the name it returns is not a live
directory in the data root. A row that is not a booth link is never dead, no
matter what it points at — the Booth cannot know whether a gitea repo still
exists and must not guess. A booth link whose booth is alive is not dead. No
lifetime arithmetic is involved: a booth one minute from expiry is alive.
*(Stated after 3-of-4 cold arms read the rule two ways — predicate-driven vs
existence-driven — with 221 rows riding on which.)*
A registry that cannot be read renders as a panel carrying its error, never as
an absent panel and never as a 500 — the v0.2.2 lesson, which this repo learned
by returning 500 for `/` and `/healthz` across all 25 booths.
@@ -263,18 +295,29 @@ booth bench add <url> <name> register or update; prints registered/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
booth bench import classify the board's rows; WRITES NOTHING
booth bench import --apply <id>... register ONLY the ids you name
`<id|url>` takes EITHER form because the input is normalized before the lookup,
and normalization is idempotent — an id normalizes to itself. So the id `ls`
prints and the raw URL in the operator's scrollback both address the same row.
Pinned by a test, because it is the property that makes the two-form promise
true rather than merely intended.
```
`import` prints three groups — **booth rows** (skipped; `booth_target` matched),
**candidates** (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.
**candidates** (the normalized id beside the raw URL, so a collapse is visible
before it happens), and **refused** (normalization raised, with the reason).
**`--apply` REQUIRES THE IDS. A bare `--apply` is refused.** This is the
unit's sharpest correction and it came from all four arms of the cold paraphrase
panel independently: the first draft registered every candidate, which made the
write path do the exact thing this document's own rationale calls impossible —
**tell a bench from a bookmark by its URL** — silently, to roughly 14 of 35 rows
that belong on the board. The dry run prints ids; the operator names the ones
that are benches; an id that is not a candidate is refused and nothing is
written. There was no selection mechanism between the report and the write, and
the report existed precisely because the decision is not mechanizable.
## The migration
@@ -282,7 +325,7 @@ operator seeds the registry by reviewing that list.
**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.
2. `booth bench import` proposes; the operator applies **by naming ids**.
3. The 156 dead booth rows are marked, and removed by him or not at all.
## Scope — the blast-radius pass
@@ -330,12 +373,20 @@ that the panel renders would pass under exactly that change.
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.
inlined in the shell for speed, or a regex in `app.py`. One table of URLs
(trailing slash, no slash, nested path, query, uppercase host, a non-Booth host
with a `/b/` path, a `/b/` with no name, a percent-encoded name, a decoded `..`
and a decoded separator) runs through the predicate, the CLI's refusal AND the
render's dead marker.
**AGREEMENT IS THE WEAKER HALF AND IS NOT THE TEST.** Three callers of one
wrong predicate agree perfectly, so agreement alone pins nothing — the table's
**expected values** are the independent check, and the agreement rows exist to
catch a second implementation drifting from the first. Both are asserted; only
one of them would survive `booth_target` itself being wrong. *(Named after a
cold arm pointed out that the falsifier reads as though agreement were
sufficient.)* **A bare `/b/` with no name is NOT a booth link**, and the table
pins that.
**INV-3 — a refused link writes nothing.** No row, no board directory, no
`.booth.json`, no lock file.
@@ -367,8 +418,16 @@ 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.
same normalized URL updates one row; upserting two URLs that differ in **scheme,
host, non-default port, path, or query** creates two. **That list is
EXHAUSTIVE** — the only things normalization discards are a fragment, a
scheme-default port, letter case in the scheme and host, a bare `/` path, and
surrounding whitespace.
*(Amended: this said "path, query or host" with no "only", which reads as
illustrative and left an implementer free to "fix" the rule from the
invariant's wording — and it omitted scheme and port, two of the five. 3-of-4
cold arms flagged it; the falsifier now carries vectors for both.)*
*Falsifiable:* the defeating change is normalizing to the origin. The test
registers the eight gitea URLs measured on the live board and asserts **eight**
benches, then registers `talk`'s five rows and asserts **one** — the same
@@ -378,9 +437,15 @@ 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.
differs from its raw form — **an uppercase host, an explicit default port, and
a fragment** — and asserts the anchor's `href` is the raw string, byte for byte.
*(Amended: this parenthetical used to name "a trailing slash on a non-empty
path" as one of the differences. **It is not one** — the rule list keeps a
non-empty path verbatim, slash included, and INV-6 makes `…/p` and `…/p/` two
benches. Two passages of this document disagreed about the same character, and
3-of-4 cold arms found the contradiction. The rule list is correct; this
sentence was wrong.)*
**INV-8 — nothing this unit ships removes a board row.** The dead marker is a
render-time stamp; `import` without `--apply` writes nothing anywhere; `import`