Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b394dde18 | ||
|
|
e702be4e1a | ||
|
|
c5ac49356f | ||
|
|
3296a868fa | ||
|
|
8cb21193dc | ||
|
|
e3853e2692 | ||
|
|
32e3ed65e1 | ||
|
|
8a7af3eb08 | ||
|
|
0a2bb1d26c | ||
|
|
8c7f2127eb | ||
|
|
1c3ce5ddb5 | ||
|
|
91fd8bc69d | ||
|
|
7996fbd597 | ||
|
|
5c20e2f4d5 | ||
|
|
87e2c5364c | ||
|
|
42ea67f33f | ||
|
|
8f81d8f9d0 | ||
|
|
c75d7a2797 | ||
|
|
c3a97c1b64 | ||
|
|
d37b81ab9f |
@@ -62,8 +62,9 @@ test is the only thing standing here.
|
|||||||
No database. `ls ~/booth-data` tells you everything the service knows.
|
No database. `ls ~/booth-data` tells you everything the service knows.
|
||||||
|
|
||||||
Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep),
|
Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep),
|
||||||
`.blurred` (one rel per line), `.marks.json` + `.marks.lock` (judgment), `.pins`
|
`.viewed` (last deliberate look — U4's "viewing is activity"), `.blurred` (one
|
||||||
(link-board pin ids), `.uploaded` (upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new
|
rel per line), `.marks.json` + `.marks.lock` (judgment), `.pins` (link-board pin
|
||||||
|
ids), `.uploaded` (upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new
|
||||||
dotfile costs nothing in item counts, galleries or zips. That skip is why the
|
dotfile costs nothing in item counts, galleries or zips. That skip is why the
|
||||||
dotfile is the right shape for new operator state — use it rather than
|
dotfile is the right shape for new operator state — use it rather than
|
||||||
inventing a sidecar-per-item.
|
inventing a sidecar-per-item.
|
||||||
@@ -84,6 +85,12 @@ doing so, never resolved the caption — the operator's "zoomed images lose
|
|||||||
their annotations" bug. It was not a rendering bug; it was three readers of one
|
their annotations" bug. It was not a rendering bug; it was three readers of one
|
||||||
truth.
|
truth.
|
||||||
|
|
||||||
|
**U3 extended this to the verbatim path.** A booth's own `index.html` now gets
|
||||||
|
its chrome from `/_booth/embed.js`, which *places* server-rendered fragments and
|
||||||
|
never builds one. The fragments come from the same `_ask_inline.html` macros the
|
||||||
|
gallery page uses, handed over `/b/<name>/embed.json`. A second renderer in
|
||||||
|
JavaScript would be the same bug in a new language.
|
||||||
|
|
||||||
### 4. Re-export, don't move-and-break
|
### 4. Re-export, don't move-and-break
|
||||||
|
|
||||||
Names that moved from `app.py` to `items.py` (`classify`, `doc_kind`,
|
Names that moved from `app.py` to `items.py` (`classify`, `doc_kind`,
|
||||||
@@ -121,9 +128,14 @@ the operator's judgment being quietly misfiled.
|
|||||||
|
|
||||||
Current rules: items `sorted(rel)`; the zoom ring is that order filtered to
|
Current rules: items `sorted(rel)`; the zoom ring is that order filtered to
|
||||||
images; captions resolve over a sorted scan; marks `(created, id)`; legacy
|
images; captions resolve over a sorted scan; marks `(created, id)`; legacy
|
||||||
import `(mtime, name)`; link rows pinned-then-newest. `ROADMAP.md` carries the
|
import `(mtime, name)`; link rows pinned-then-newest; a verbatim report's embed
|
||||||
table and the two places still undecided (U7 sections and compare pairing, U6
|
anchors in document order, its tail in payload order, its questions in
|
||||||
bench listing).
|
declaration order. `ROADMAP.md` carries the table and the two places still
|
||||||
|
undecided (U7 sections and compare pairing, U6 bench listing).
|
||||||
|
|
||||||
|
U3's rows are the first that bind **across a language boundary** — decided in
|
||||||
|
Python, honoured in JavaScript. A string assertion cannot see that, which is
|
||||||
|
why `tests/test_embed_browser.py` exists.
|
||||||
|
|
||||||
When you add an ordered surface, state its rule in the docstring. If you cannot
|
When you add an ordered surface, state its rule in the docstring. If you cannot
|
||||||
state it in one line, it does not have one.
|
state it in one line, it does not have one.
|
||||||
@@ -178,8 +190,15 @@ one caused an outage.
|
|||||||
is that template work needs a restart to see, and that price is the point.
|
is that template work needs a restart to see, and that price is the point.
|
||||||
`test_templates_do_not_hot_reload_from_disk` holds the line.
|
`test_templates_do_not_hot_reload_from_disk` holds the line.
|
||||||
|
|
||||||
**So: after ANY edit here — Python or template — the live service is stale until
|
3. **`booth/static/embed.js` is the third thing that would have hot-reloaded,
|
||||||
you restart it.** If you are touching this repo while the operator may be using
|
and it does not.** U3 gave the service a static asset living in the
|
||||||
|
deployment root; it is read ONCE in `create_app` and served from memory with
|
||||||
|
an ETag over its content, for exactly the reason above. Same rule, same test
|
||||||
|
shape (`test_embed_js_does_not_hot_reload_from_disk`). Anything else this
|
||||||
|
repo learns to serve from disk inherits the rule — read it at startup.
|
||||||
|
|
||||||
|
**So: after ANY edit here — Python, template or static asset — the live service
|
||||||
|
is stale until you restart it.** If you are touching this repo while the operator may be using
|
||||||
the service, either restart promptly or expect him to be looking at the old
|
the service, either restart promptly or expect him to be looking at the old
|
||||||
version. Never leave the tree in a state where a restart would 500.
|
version. Never leave the tree in a state where a restart would 500.
|
||||||
|
|
||||||
@@ -196,6 +215,15 @@ curl -s localhost:8090/healthz # the live service (systemd --user)
|
|||||||
systemctl --user restart booth.service # after a code change, to see it live
|
systemctl --user restart booth.service # after a code change, to see it live
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`tests/test_embed_browser.py` drives a real Chromium against a real uvicorn on
|
||||||
|
an ephemeral port — the only place U3's placement and `form=` binding can be
|
||||||
|
observed at all. Browsers are NOT downloaded per project; they live box-wide in
|
||||||
|
`/opt/ms-playwright`. The file **skips rather than fails** when playwright or a
|
||||||
|
usable browser is missing, so the suite stays green anywhere. If those tests
|
||||||
|
start skipping on this box, the pinned `playwright>=1.60,<1.63` in
|
||||||
|
`pyproject.toml` has drifted past the shared store — read the comment there
|
||||||
|
before raising the bound.
|
||||||
|
|
||||||
`booth.service` is a user unit installed to `~/.config/systemd/user/`. The repo
|
`booth.service` is a user unit installed to `~/.config/systemd/user/`. The repo
|
||||||
copy is the source; edits there need a `daemon-reload`.
|
copy is the source; edits there need a `daemon-reload`.
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ filesystem *is* the state.
|
|||||||
- **Live:** http://10.100.10.50:8090/ (nh3-dev) · linked from Homepage → *Apps → The Booth*
|
- **Live:** http://10.100.10.50:8090/ (nh3-dev) · linked from Homepage → *Apps → The Booth*
|
||||||
- **Data dir:** `~/booth-data/` on nh3-dev (one subfolder per booth)
|
- **Data dir:** `~/booth-data/` on nh3-dev (one subfolder per booth)
|
||||||
- **TTL:** 24h, measured from the newest mtime in a booth's tree (it lives while
|
- **TTL:** 24h, measured from the newest mtime in a booth's tree (it lives while
|
||||||
you're touching it, self-destructs 24h after you stop)
|
you're touching it, self-destructs 24h after you stop). Two things hold a booth
|
||||||
|
open past that: the `.forever` sentinel, and **an unanswered question** — see
|
||||||
|
*Lifetime* below. **Opening a booth page is activity**; polling it is not.
|
||||||
|
|
||||||
## How a session posts
|
## How a session posts
|
||||||
|
|
||||||
@@ -159,7 +161,46 @@ is exactly why the direct `×` was worth adding.
|
|||||||
an ephemeral booth could only be kept from a shell. The `/keep` route and the
|
an ephemeral booth could only be kept from a shell. The `/keep` route and the
|
||||||
CLI verb both already existed; only the button was missing.
|
CLI verb both already existed; only the button was missing.
|
||||||
|
|
||||||
## Kept boards — the one exception to the 24h rule
|
## Lifetime — derived, not declared
|
||||||
|
|
||||||
|
A booth is in exactly one of three states, and only the first is a button you
|
||||||
|
press:
|
||||||
|
|
||||||
|
| state | what puts it there | swept? |
|
||||||
|
|---|---|---|
|
||||||
|
| **kept** | you pressed `keep` / dropped `.forever` | never |
|
||||||
|
| **held** | an **unanswered pick**, or a `.marks.json` the service cannot read | not while that holds |
|
||||||
|
| **ephemeral** | everything else | 24h after the last activity |
|
||||||
|
|
||||||
|
**An open question holds its own booth.** A session that runs `booth ask` does
|
||||||
|
not also need to `keep` the booth — the booth cannot be swept while the operator
|
||||||
|
still owes it an answer, and it is released automatically when he answers. A
|
||||||
|
*partially* answered multi-question pick still counts as open, so a review in
|
||||||
|
flight is never swept out from under him. The index card and the booth header
|
||||||
|
say `held until answered` where the countdown would be, so a booth that has
|
||||||
|
stopped counting down always tells you why.
|
||||||
|
|
||||||
|
**Viewing is activity.** A deliberate GET of a booth's own page — the gallery, a
|
||||||
|
verbatim report, the zoom view, the marks page, a zip download — resets the
|
||||||
|
clock. If the operator is still looking at it, it is still alive. Browsing the
|
||||||
|
index does **not** count, and neither does a session polling `marks.json` or
|
||||||
|
`booth marks --wait`: machine reads are deliberately excluded, so an agent
|
||||||
|
cannot hold its own booth open by waiting on it.
|
||||||
|
|
||||||
|
**A held booth is still yours to delete.** The hold is protection from the
|
||||||
|
timer, never from you: `booth rm`, the UI ×, and `DELETE /b/<name>` all work
|
||||||
|
exactly as before. `sweep_once` is the only thing that honours a hold, exactly
|
||||||
|
as it is the only thing that honours `.forever`.
|
||||||
|
|
||||||
|
**Why this exists:** `.forever` used to be the only way to say three different
|
||||||
|
things — "this is durable", "I haven't answered yet", and "I'm still looking at
|
||||||
|
it" — and the measurement showed it carrying all three. On 2026-09-22, 17 of 24
|
||||||
|
live booths (70%) held the sentinel, up from 54% the day before; three of the
|
||||||
|
four booths in the fleet awaiting an answer had been pinned by hand as well.
|
||||||
|
Only the first meaning is what `keep` means. The other two the service already
|
||||||
|
knew and did not consult.
|
||||||
|
|
||||||
|
### Kept boards — the explicit pin
|
||||||
|
|
||||||
A booth containing a **`.forever`** dotfile is **never swept**, and renders in
|
A booth containing a **`.forever`** dotfile is **never swept**, and renders in
|
||||||
its own **Kept** lane at the top of the index (blue top edge, `★ kept` badge, no
|
its own **Kept** lane at the top of the index (blue top edge, `★ kept` badge, no
|
||||||
@@ -168,6 +209,7 @@ still ephemeral, so nobody inherits a cleanup chore they didn't ask for.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
booth keep my-board # drop the sentinel — exempt from the sweep, forever
|
booth keep my-board # drop the sentinel — exempt from the sweep, forever
|
||||||
|
# (NOT for "waiting on an answer" — the pick holds it)
|
||||||
booth unkeep my-board # release the pin — the board rejoins the sweep
|
booth unkeep my-board # release the pin — the board rejoins the sweep
|
||||||
booth rm my-board # delete it NOW (works on kept boards; says so when it was kept)
|
booth rm my-board # delete it NOW (works on kept boards; says so when it was kept)
|
||||||
|
|
||||||
@@ -438,11 +480,10 @@ wipe it from there. Release is reversible — press keep again and nothing was
|
|||||||
lost. From the CLI, `booth rm <name>` deletes a kept board immediately and
|
lost. From the CLI, `booth rm <name>` deletes a kept board immediately and
|
||||||
tells you it was kept.
|
tells you it was kept.
|
||||||
|
|
||||||
**Do not "unkeep and let it expire."** Removing the sentinel *bumps the booth
|
**Do not "unkeep and let it expire."** **Releasing a board is activity** — you
|
||||||
directory's mtime*, and a booth's age is the newest mtime in its tree — so a
|
just touched it — so a released board's clock **resets** and it survives another
|
||||||
released board's clock **resets** and it survives another full TTL.
|
full TTL. Unkeep-and-wait is a 24-hour delay, not a delete. Use the × or
|
||||||
Unkeep-and-wait is a 24-hour delay, not a delete. Use the × or `booth rm` when
|
`booth rm` when you mean now.
|
||||||
you mean now.
|
|
||||||
|
|
||||||
## Ops
|
## Ops
|
||||||
|
|
||||||
|
|||||||
+36
-7
@@ -1,7 +1,7 @@
|
|||||||
# The Booth — roadmap
|
# The Booth — roadmap
|
||||||
|
|
||||||
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
|
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
|
||||||
Current version: `0.3.0` (U1, U2 and U5 landed; extracted from eshpfi 2026-09-21).
|
Current version: `0.6.1` (U1 through U6 landed; extracted from eshpfi 2026-09-21).
|
||||||
|
|
||||||
## v1 target
|
## v1 target
|
||||||
|
|
||||||
@@ -12,18 +12,30 @@ defect — not a wish. The measurements are in the IA doc.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | ~~**One item record**~~ — **landed `ce598b3`** | captions never reach the zoom view (never sent, not lost) | U1 |
|
| 1 | ~~**One item record**~~ — **landed `ce598b3`** | captions never reach the zoom view (never sent, not lost) | U1 |
|
||||||
| 2 | ~~**Marks**~~ — **landed `c7f9437`, released `v0.2.0`** | 5 mechanisms for 1 job; operator→session loop runs through chat | U2 |
|
| 2 | ~~**Marks**~~ — **landed `c7f9437`, released `v0.2.0`** | 5 mechanisms for 1 job; operator→session loop runs through chat | U2 |
|
||||||
| 3 | **Declared embed seam** — `/_booth/embed.js`, chrome mounts via DOM | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 |
|
| 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** — open marks pin; viewing is activity | 54% of booths on the `.forever` escape hatch | U4 |
|
| 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 |
|
| 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**~~ — **landed `1c3ce5d`, released `v0.6.0`** | 69% link-board rot (re-measured: 178 booth rows + 8 bench re-posts) | U6 |
|
||||||
| 7 | **Navigation at 270 items** — sections, rail, filters, grid keyboard | one flat wall; subfolder structure discarded at render | U7 |
|
| 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} →
|
Ordering is dependency-driven, not priority-driven: **U1 → U2 → {U3, U4, U5} →
|
||||||
U7**, with **U6 independent** of all of them (different storage, different
|
U7**, with **U6 independent** of all of them (different storage, different
|
||||||
surface) and therefore the safest thing to land first or in parallel.
|
surface) and therefore the safest thing to land first or in parallel.
|
||||||
|
|
||||||
**U1, U2 and U5 are landed.** U3 and U4 are unblocked and unstarted; U6 remains
|
**U1 through U6 are landed.** **U7 is the last unit before the 1.0 cut** — its
|
||||||
independent and unstarted; U7 waits on the rest.
|
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
|
**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
|
TWO predictions rather than one. The operator declined a fleetwide announcement
|
||||||
@@ -73,11 +85,28 @@ 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 |
|
| legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them |
|
||||||
| link board rows | pinned first, then newest-first |
|
| link board rows | pinned first, then newest-first |
|
||||||
| a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) |
|
| 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) |
|
||||||
|
|
||||||
|
U3's three rows are the first case where the rule binds across a language
|
||||||
|
boundary: the order is decided in Python and honoured in JavaScript, and a
|
||||||
|
browser test asserts it rather than a string assertion that could not see it.
|
||||||
|
|
||||||
|
U4 added no ordered collection — a booth's lifetime is one state per booth,
|
||||||
|
not a sequence — so the rule above did not need a new row. The three lifetime
|
||||||
|
surfaces (index card, booth header, marks page) render through ONE macro
|
||||||
|
precisely so they cannot disagree, which is the same property stated for
|
||||||
|
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
|
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
|
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
|
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?*
|
The test for any new ordered surface: *can you write the rule down in one line?*
|
||||||
If not, it does not have one yet.
|
If not, it does not have one yet.
|
||||||
|
|||||||
+615
-207
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
|||||||
|
"""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
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
|
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")
|
||||||
|
|
||||||
|
# RE-WRAP A BRACKETED IPv6 LITERAL. `urlsplit().hostname` strips the
|
||||||
|
# brackets, and rebuilding the netloc from it produces `http://::1:8080/a`
|
||||||
|
# — not a different spelling of the same URL but a BROKEN one, so a re-post
|
||||||
|
# never matches the row the operator thinks they are updating. The bracket
|
||||||
|
# is part of the authority's syntax, not decoration. Detected by the colon,
|
||||||
|
# which cannot appear in a hostname or an IPv4 literal.
|
||||||
|
if ":" in host:
|
||||||
|
host = f"[{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}")
|
||||||
|
url = row.get("url", bench_id)
|
||||||
|
if not isinstance(url, str):
|
||||||
|
raise ValueError(f"{bench_id}: url must be text, not {type(url).__name__}")
|
||||||
|
if len(url) > URL_MAX:
|
||||||
|
# REFUSED, NOT TRUNCATED — unlike `name` and `owner`. Those are display
|
||||||
|
# budgets and clipping one costs a few characters in a panel row. A
|
||||||
|
# clipped URL is a DEAD ANCHOR, and INV-7 promises the click goes to the
|
||||||
|
# posted address byte for byte; silently shortening it keeps the promise
|
||||||
|
# in the type system and breaks it in the browser. Nothing this code
|
||||||
|
# writes can get here (normalize refuses over-long input); a hand-edited
|
||||||
|
# registry can, and it is damage, which is what the reader reports.
|
||||||
|
raise ValueError(f"{bench_id}: url is longer than {URL_MAX} characters")
|
||||||
|
return Bench(
|
||||||
|
id=bench_id,
|
||||||
|
url=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 from a REGULAR FILE.
|
||||||
|
|
||||||
|
REGULAR-FILE FIRST, THEN SIZE, THEN A BOUNDED READ — in that order, and the
|
||||||
|
order is the whole point. A named pipe blocks in `open()`, before any byte
|
||||||
|
cap can apply: bounding the read does NOT close that hole, and an earlier
|
||||||
|
draft of this module claimed it did while hanging on the first FIFO put at
|
||||||
|
this path. `read_benches` is on the board page's render path, so that hang
|
||||||
|
is a request that never returns and, with enough of them, the threadpool
|
||||||
|
behind every route. `marks.py` learned this on 2026-09-22 and guards with
|
||||||
|
`S_ISREG`; this is the same guard, not a new idea.
|
||||||
|
|
||||||
|
The bounded read stays, for the case the stat cannot answer: a regular file
|
||||||
|
that GREW between the stat and the read.
|
||||||
|
"""
|
||||||
|
st = os.stat(path)
|
||||||
|
if not stat.S_ISREG(st.st_mode):
|
||||||
|
raise ValueError(f"{path.name} is not a regular file")
|
||||||
|
if st.st_size > BENCHES_MAX_BYTES:
|
||||||
|
raise ValueError(f"registry is larger than {BENCHES_MAX_BYTES} bytes")
|
||||||
|
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}"
|
||||||
|
except RecursionError:
|
||||||
|
# Deeply nested JSON (`[[[[...`) blows the stack inside json.loads, and
|
||||||
|
# RecursionError is neither ValueError nor OSError — so it escaped the
|
||||||
|
# pair above and 500'd the page this function exists to protect. The
|
||||||
|
# byte cap does not help: 200k open brackets is 200 KB.
|
||||||
|
return [], "registry is nested too deeply to parse"
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
# Per-pid scratch name so two writers cannot share it: the atomic-replace
|
||||||
|
# promise is that a READER never sees a partial file, not that two writers
|
||||||
|
# never collide on the way there.
|
||||||
|
body = json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
||||||
|
# THE WRITER RESPECTS THE READER'S CAP. Without this, a successful
|
||||||
|
# registration can push the file past BENCHES_MAX_BYTES and every
|
||||||
|
# subsequent read fails — so the LAST bench somebody added is the one that
|
||||||
|
# makes all the others invisible, and the write that did it reported
|
||||||
|
# success. The reader is lenient about damage; it is not lenient about
|
||||||
|
# size, and a writer that ignores a limit its own reader enforces is
|
||||||
|
# manufacturing exactly the state the leniency exists to survive.
|
||||||
|
if len(body.encode("utf-8")) > BENCHES_MAX_BYTES:
|
||||||
|
raise ValueError(
|
||||||
|
f"that registration would push the registry past {BENCHES_MAX_BYTES} "
|
||||||
|
f"bytes, which its own reader refuses; nothing was written")
|
||||||
|
# AN UNPREDICTABLE SCRATCH NAME, IN THE SAME DIRECTORY. `.tmp.<pid>` is
|
||||||
|
# guessable, and a pre-planted symlink there redirects the write straight
|
||||||
|
# through the atomic replace — the replace is atomic, not safe. mkstemp
|
||||||
|
# creates with O_EXCL and 0600, so it cannot land on someone else's file.
|
||||||
|
# Same directory because os.replace is only atomic within a filesystem.
|
||||||
|
fd, tmpname = tempfile.mkstemp(dir=str(root), prefix=".benches-", suffix=".tmp")
|
||||||
|
tmp = Path(tmpname)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(body)
|
||||||
|
fh.flush()
|
||||||
|
# FSYNC BEFORE THE REPLACE. os.replace orders the rename, not the
|
||||||
|
# DATA behind it: without this, a power loss can publish a name
|
||||||
|
# pointing at bytes that never reached the disk, which is a
|
||||||
|
# truncated registry wearing a successful write's clothes.
|
||||||
|
os.fsync(fh.fileno())
|
||||||
|
os.chmod(tmp, 0o644) # mkstemp's 0600 is tighter than the rest
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except BaseException:
|
||||||
|
# A write that dies between create and replace would otherwise strand
|
||||||
|
# the scratch file beside the registry forever. The prior registry is
|
||||||
|
# untouched either way — os.replace is the only thing that publishes.
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
-119
@@ -1,119 +0,0 @@
|
|||||||
"""Inline ask placement inside a booth's VERBATIM index.html.
|
|
||||||
|
|
||||||
A booth that ships its own `index.html` is served untouched, so the auto-gallery
|
|
||||||
template's asks panel never renders there. The first fix was a chip linking to a
|
|
||||||
separate `/asks` page; the operator's verdict on that (2026-09-09) was that the
|
|
||||||
question belongs WITH the artifacts it is about — a four-voice audition wants the
|
|
||||||
radio group for each voice under that voice's audio, not on another page.
|
|
||||||
|
|
||||||
So the report author marks where each piece goes, with a placeholder element:
|
|
||||||
|
|
||||||
<div data-booth-ask="anchors"></div> the whole ask: every question + submit
|
|
||||||
<div data-booth-ask="anchors:lawson"></div> just that question's radios
|
|
||||||
<div data-booth-ask-submit="anchors"></div> the notes field + submit button
|
|
||||||
|
|
||||||
Per-question fragments bind to ONE form via the HTML5 `form=` attribute, so four
|
|
||||||
groups scattered down a page still submit as a single POST — which is what a
|
|
||||||
multi-question ask requires (every question or 400). No JavaScript.
|
|
||||||
|
|
||||||
An `<!-- booth:ask anchors -->` comment works the same way, for authors who would
|
|
||||||
rather not put an empty div in their markup.
|
|
||||||
|
|
||||||
Placement is OPTIONAL. A page with no placeholders gets the whole ask appended at
|
|
||||||
the end of its body, so an ask is never invisible — that guarantee is the point,
|
|
||||||
and marking it up only moves it somewhere better.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
# <div data-booth-ask="stem"></div> / <span data-booth-ask="stem:key"></span>
|
|
||||||
_EL_RE = re.compile(
|
|
||||||
r"<(?P<tag>[A-Za-z][\w-]*)\b[^>]*?\bdata-booth-ask=\"(?P<spec>[^\"]+)\"[^>]*?>"
|
|
||||||
r"(?:\s*</(?P=tag)\s*>)?",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_SUBMIT_EL_RE = re.compile(
|
|
||||||
r"<(?P<tag>[A-Za-z][\w-]*)\b[^>]*?\bdata-booth-ask-submit=\"(?P<spec>[^\"]+)\"[^>]*?>"
|
|
||||||
r"(?:\s*</(?P=tag)\s*>)?",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
# <!-- booth:ask stem --> / <!-- booth:ask stem:key --> / <!-- booth:ask-submit stem -->
|
|
||||||
_COMMENT_RE = re.compile(r"<!--\s*booth:ask\s+(?P<spec>[^\s>-][^\s>]*)\s*-->", re.IGNORECASE)
|
|
||||||
_COMMENT_SUBMIT_RE = re.compile(r"<!--\s*booth:ask-submit\s+(?P<spec>[^\s>]+)\s*-->", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def split_spec(spec: str) -> tuple[str, str | None]:
|
|
||||||
"""`"anchors:lawson"` -> `("anchors", "lawson")`; `"anchors"` -> `("anchors", None)`."""
|
|
||||||
stem, sep, key = spec.strip().partition(":")
|
|
||||||
return stem.strip(), (key.strip() or None) if sep else None
|
|
||||||
|
|
||||||
|
|
||||||
def has_placeholders(html: str) -> bool:
|
|
||||||
return bool(
|
|
||||||
_EL_RE.search(html) or _SUBMIT_EL_RE.search(html)
|
|
||||||
or _COMMENT_RE.search(html) or _COMMENT_SUBMIT_RE.search(html)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def form_id(stem: str) -> str:
|
|
||||||
return f"bk-ask-form-{re.sub(r'[^A-Za-z0-9_-]', '-', stem)}"
|
|
||||||
|
|
||||||
|
|
||||||
def place(html: str, asks: list, render) -> tuple[str, dict[str, set], set[str]]:
|
|
||||||
"""Substitute every placeholder with rendered ask HTML.
|
|
||||||
|
|
||||||
`render(kind, ask, key)` returns the fragment for kind in
|
|
||||||
{"whole", "question", "submit"}. Returns the new html; a map of stem ->
|
|
||||||
the set of question keys placed inline (with `None` in the set meaning the
|
|
||||||
WHOLE ask was placed); and the set of stems whose submit block was placed
|
|
||||||
explicitly.
|
|
||||||
|
|
||||||
The caller needs the per-key detail, not just "this stem appeared
|
|
||||||
somewhere": a multi-question ask requires EVERY question on submit, so a
|
|
||||||
page that marks up two of four questions must still be handed the other two
|
|
||||||
or the form is unsubmittable — a 400 the operator would meet only after
|
|
||||||
filling it in.
|
|
||||||
|
|
||||||
A placeholder naming an ask this booth does not have is left ALONE, not
|
|
||||||
blanked: silently eating the author's markup would hide a typo'd stem, and
|
|
||||||
an untouched empty div is invisible anyway.
|
|
||||||
"""
|
|
||||||
# Marks index by ATTRIBUTE, not subscript: `place` was the one consumer in
|
|
||||||
# the service that did `a["stem"]`, which a frozen dataclass refuses. Caught
|
|
||||||
# by the U2 seam review (SR-1) — the cold contract pass cannot see a sibling
|
|
||||||
# module's surface by design, so nothing else would have found it before the
|
|
||||||
# first verbatim booth 500'd.
|
|
||||||
by_stem = {a.id: a for a in asks}
|
|
||||||
placed: dict[str, set] = {}
|
|
||||||
submitted: set[str] = set()
|
|
||||||
|
|
||||||
def sub_main(m: re.Match) -> str:
|
|
||||||
stem, key = split_spec(m.group("spec"))
|
|
||||||
ask = by_stem.get(stem)
|
|
||||||
if ask is None:
|
|
||||||
return m.group(0)
|
|
||||||
if key is None:
|
|
||||||
placed.setdefault(stem, set()).add(None)
|
|
||||||
submitted.add(stem)
|
|
||||||
return render("whole", ask, None)
|
|
||||||
q = next((q for q in ask.questions if q.get("key") == key), None)
|
|
||||||
if q is None:
|
|
||||||
return m.group(0)
|
|
||||||
placed.setdefault(stem, set()).add(key)
|
|
||||||
return render("question", ask, key)
|
|
||||||
|
|
||||||
def sub_submit(m: re.Match) -> str:
|
|
||||||
stem, _ = split_spec(m.group("spec"))
|
|
||||||
ask = by_stem.get(stem)
|
|
||||||
if ask is None:
|
|
||||||
return m.group(0)
|
|
||||||
placed.setdefault(stem, set())
|
|
||||||
submitted.add(stem)
|
|
||||||
return render("submit", ask, None)
|
|
||||||
|
|
||||||
for pat, fn in ((_EL_RE, sub_main), (_COMMENT_RE, sub_main),
|
|
||||||
(_SUBMIT_EL_RE, sub_submit), (_COMMENT_SUBMIT_RE, sub_submit)):
|
|
||||||
html = pat.sub(fn, html)
|
|
||||||
return html, placed, submitted
|
|
||||||
@@ -14,6 +14,7 @@ import hashlib
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlsplit
|
||||||
|
|
||||||
# ---- the standing link board ------------------------------------------------
|
# ---- the standing link board ------------------------------------------------
|
||||||
#
|
#
|
||||||
@@ -194,3 +195,58 @@ def order_for_display(entries: list[dict], pinned: set[str]) -> list[dict]:
|
|||||||
stamped = [{**e, "pinned": e["id"] in pinned} for e in entries]
|
stamped = [{**e, "pinned": e["id"] in pinned} for e in entries]
|
||||||
stamped.reverse() # newest first
|
stamped.reverse() # newest first
|
||||||
return [e for e in stamped if e["pinned"]] + [e for e in stamped if not e["pinned"]]
|
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
|
||||||
|
# `unquote` will happily hand back a NUL or a newline, and neither can name
|
||||||
|
# a directory. Unfiltered they reach `is_dir()` (ValueError on an embedded
|
||||||
|
# NUL, which is NOT an OSError and so escapes the marker's guard), the
|
||||||
|
# refusal message the CLI prints, and the marker the board renders.
|
||||||
|
if any(ch in name for ch in "\x00") or any(ord(ch) < 0x20 for ch in name):
|
||||||
|
return None
|
||||||
|
return name
|
||||||
|
|||||||
+74
-1
@@ -217,12 +217,21 @@ def _fingerprint(entries: list[dict]) -> str:
|
|||||||
return json.dumps(entries, sort_keys=True, ensure_ascii=False)
|
return json.dumps(entries, sort_keys=True, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def _read_raw_strict(booth: Path) -> list[dict]:
|
def _read_raw_strict(booth: Path, *, blank_is_corrupt: bool = False) -> list[dict]:
|
||||||
"""Like `_read_raw`, but RAISES `MarksCorrupt` on a file it cannot parse.
|
"""Like `_read_raw`, but RAISES `MarksCorrupt` on a file it cannot parse.
|
||||||
|
|
||||||
Absent, empty and valid-but-empty are all "no marks yet" and are fine — the
|
Absent, empty and valid-but-empty are all "no marks yet" and are fine — the
|
||||||
distinction that matters is bytes-present-but-unreadable, because that is the
|
distinction that matters is bytes-present-but-unreadable, because that is the
|
||||||
case where writing would destroy something.
|
case where writing would destroy something.
|
||||||
|
|
||||||
|
`blank_is_corrupt` is the DELETE path's reading of a present-but-whitespace
|
||||||
|
file, and only the delete path's: this writer never produces a blank marks
|
||||||
|
document, so a blank one that exists is something that went wrong, and
|
||||||
|
`rmtree` is not the response to that. The write path keeps the lenient
|
||||||
|
reading — a blank file is safe to overwrite, which is the question
|
||||||
|
`_Locked` is asking. A VALID document with an empty `marks` list is not
|
||||||
|
blank and never holds: that is what deleting the last mark leaves behind,
|
||||||
|
and it must stay sweepable.
|
||||||
"""
|
"""
|
||||||
path = Path(booth) / MARKS_FILE
|
path = Path(booth) / MARKS_FILE
|
||||||
try:
|
try:
|
||||||
@@ -245,6 +254,8 @@ def _read_raw_strict(booth: Path) -> list[dict]:
|
|||||||
except (OSError, UnicodeDecodeError, MemoryError) as exc:
|
except (OSError, UnicodeDecodeError, MemoryError) as exc:
|
||||||
raise MarksCorrupt(f"{path} cannot be read: {exc}") from exc
|
raise MarksCorrupt(f"{path} cannot be read: {exc}") from exc
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
|
if blank_is_corrupt:
|
||||||
|
raise MarksCorrupt(f"{path} is present but holds no marks document")
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
raw = json.loads(text)
|
raw = json.loads(text)
|
||||||
@@ -477,6 +488,40 @@ def _hydrate(entry: dict) -> Mark:
|
|||||||
norm = normalize_ask(decl, mid)
|
norm = normalize_ask(decl, mid)
|
||||||
except AskError as exc:
|
except AskError as exc:
|
||||||
return Mark(**base, declaration=decl, answer=answer, error=str(exc))
|
return Mark(**base, declaration=decl, answer=answer, error=str(exc))
|
||||||
|
# THE ANSWER'S SHAPE IS VALIDATED HERE, at the ONE boundary every
|
||||||
|
# surface crosses — not at the three render sites that happen to draw
|
||||||
|
# it today, and not defensively in the template, which would hide that
|
||||||
|
# anything is wrong.
|
||||||
|
#
|
||||||
|
# `{"answer": {"answers": [], "notes": ""}}` is well-formed JSON with a
|
||||||
|
# wrong-shaped value. It passed `_entry_type_error`, passed the
|
||||||
|
# `isinstance(answer, dict)` check above, and `marks_for` and
|
||||||
|
# `hold_read` both reported the mark HEALTHY with no read error — and
|
||||||
|
# then `_ask_inline.html` did `a.answer.answers.get(q.key)`, Jinja asked
|
||||||
|
# a LIST for `.get`, and the gallery page and the marks page returned
|
||||||
|
# 500. Measured at 42ea67f, so it predates U3; U3 guarded only its own
|
||||||
|
# surface with `_safe_fragments` and left these two by scope.
|
||||||
|
#
|
||||||
|
# This is the v0.2.2 lesson finished rather than half-done. That outage
|
||||||
|
# was a file that could not be PARSED and the reader was made lenient;
|
||||||
|
# this one parses perfectly and breaks one layer further in, at render,
|
||||||
|
# where no leniency exists. `read_error` was answering a narrower
|
||||||
|
# question than every caller assumed.
|
||||||
|
#
|
||||||
|
# ONLY the multi case is checked, because only the multi case indexes:
|
||||||
|
# a single-question pick's answer IS the record, with no `answers` key
|
||||||
|
# to get wrong. Requiring one unconditionally would break every single
|
||||||
|
# pick, which is the direction a too-eager guard fails in.
|
||||||
|
if norm["multi"] and isinstance(answer, dict) and \
|
||||||
|
not isinstance(answer.get("answers"), dict):
|
||||||
|
return Mark(**base, declaration=decl, answer=None,
|
||||||
|
prompt=norm["prompt"], title=norm["title"],
|
||||||
|
multi=norm["multi"], questions=norm["questions"],
|
||||||
|
options=norm.get("options", []),
|
||||||
|
notes_enabled=norm["notes"], notes_label=norm["notes_label"],
|
||||||
|
error="this pick's answer is stored in a shape the page "
|
||||||
|
"cannot render; the answer was dropped and the "
|
||||||
|
"question is unanswered")
|
||||||
return Mark(
|
return Mark(
|
||||||
**base,
|
**base,
|
||||||
declaration=decl,
|
declaration=decl,
|
||||||
@@ -545,6 +590,34 @@ def open_marks(marks: Sequence[Mark]) -> list[Mark]:
|
|||||||
return [m for m in marks if _is_open(m)]
|
return [m for m in marks if _is_open(m)]
|
||||||
|
|
||||||
|
|
||||||
|
def hold_read(booth: Path) -> tuple[list[Mark], str | None]:
|
||||||
|
"""ONE read of `.marks.json`, answering both questions the LIFETIME rule asks:
|
||||||
|
what is still open, and whether the file could be read at all.
|
||||||
|
|
||||||
|
U4 decides whether a booth may be SWEPT from those two facts. Asking them
|
||||||
|
with two calls — `marks_for` then `read_error` — reads the file twice, and
|
||||||
|
two reads of one file are not one read of one state: a write or a repair
|
||||||
|
landing between them yields a pair that never described the booth at any
|
||||||
|
instant. The losing pair is `([], None)` — no marks, no error — which is
|
||||||
|
exactly the one that deletes. Cross-frontier review (2026-09-22) found it;
|
||||||
|
that is why this exists rather than the obvious two calls.
|
||||||
|
|
||||||
|
When the file reads clean the marks are byte-identical to `marks_for`'s:
|
||||||
|
`_read_raw_strict` raises rather than dropping an entry, so a non-raising
|
||||||
|
strict read returns the same entries the lenient read would, hydrated and
|
||||||
|
sorted the same way. The caller can therefore use this ONE read for the
|
||||||
|
display too, and fall back to `marks_for` only on the error path, where
|
||||||
|
leniency is the point.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
entries = _read_raw_strict(booth, blank_is_corrupt=True)
|
||||||
|
except MarksCorrupt as exc:
|
||||||
|
return [], str(exc)
|
||||||
|
marks = [_hydrate_safe(e) for e in entries]
|
||||||
|
marks.sort(key=lambda m: (m.created, m.id))
|
||||||
|
return marks, None
|
||||||
|
|
||||||
|
|
||||||
def marks_for_target(marks: Sequence[Mark], rel: str | None) -> list[Mark]:
|
def marks_for_target(marks: Sequence[Mark], rel: str | None) -> list[Mark]:
|
||||||
"""The marks attached to one item, or to the booth itself for None."""
|
"""The marks attached to one item, or to the booth itself for None."""
|
||||||
return [m for m in marks if m.target == rel]
|
return [m for m in marks if m.target == rel]
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
/* The Booth - the declared embed seam (U3).
|
||||||
|
*
|
||||||
|
* A booth that ships its own index.html is served verbatim. This script is how
|
||||||
|
* the Booth's chrome gets onto that page WITHOUT the Booth reaching into it:
|
||||||
|
* the report carries one line,
|
||||||
|
*
|
||||||
|
* <script src="/_booth/embed.js" defer></script>
|
||||||
|
*
|
||||||
|
* and everything below mounts through real DOM APIs. It replaced ten regular
|
||||||
|
* expressions applied to author HTML - six hunting for a place to hang a
|
||||||
|
* favicon and a chip, four substituting rendered markup into the author's own
|
||||||
|
* tags. A page that declares this line is now served exactly as written.
|
||||||
|
*
|
||||||
|
* WHAT THIS SCRIPT DOES NOT DECIDE: what a mark says, whether it is still open,
|
||||||
|
* or what order marks come in. Every fragment below is rendered server-side by
|
||||||
|
* the same Jinja macros the gallery page uses, and `open` is computed by
|
||||||
|
* `open_marks`. Two renderers of one truth is the bug INV-1 exists to stop -
|
||||||
|
* the zoom view once re-derived an item and lost its captions doing it.
|
||||||
|
*
|
||||||
|
* Served from a read taken ONCE at app startup. Editing this file does nothing
|
||||||
|
* until `systemctl --user restart booth.service`, exactly like the templates,
|
||||||
|
* and for the same reason: on 2026-09-21 a hot-reloading template put 19 of 25
|
||||||
|
* booths at 500 against Python that had never heard of the context it wanted.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
if (window.__boothEmbed) return; // declared AND appended: mount once
|
||||||
|
window.__boothEmbed = true;
|
||||||
|
|
||||||
|
var CSS = [
|
||||||
|
/* ---- the way home, and the open-asks jump ---- */
|
||||||
|
".booth-nav-home,.booth-nav-asks{position:fixed;top:0;z-index:2147483647;",
|
||||||
|
"display:inline-block;margin:.6rem;padding:.34rem .72rem;border-radius:8px;",
|
||||||
|
"text-decoration:none;letter-spacing:.01em;box-shadow:0 2px 10px rgba(0,0,0,.35)}",
|
||||||
|
/* top-right: a top-left chip clips the page title on left-aligned report
|
||||||
|
layouts, and this matches the zoom view's back affordance. */
|
||||||
|
".booth-nav-home{right:0;font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||||||
|
"color:#dfe7ef;background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);",
|
||||||
|
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .18s,border-color .18s}",
|
||||||
|
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}",
|
||||||
|
".booth-nav-asks{right:7.2rem;font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||||||
|
"color:#171a23;background:#ffe14e;border:1px solid #ffe14e;transition:filter .18s}",
|
||||||
|
".booth-nav-asks:hover{filter:brightness(1.08)}",
|
||||||
|
"@media print{.booth-nav-home,.booth-nav-asks{display:none}}",
|
||||||
|
/* ---- ask fragments. Self-contained: the host page carries its own CSS and
|
||||||
|
nothing here may inherit from it, so the palette adapts via
|
||||||
|
prefers-color-scheme rather than borrowing. ---- */
|
||||||
|
".bk-ask{margin:1.1rem 0;padding:.85rem .95rem;border:1px solid rgba(128,140,160,.34);",
|
||||||
|
"border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);",
|
||||||
|
"font:15px/1.5 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}",
|
||||||
|
".bk-ask.bk-done{border-top-color:#3fae6a}",
|
||||||
|
".bk-ask.bk-skip{border-top-color:#6f7c8c}",
|
||||||
|
".bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}",
|
||||||
|
".bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;",
|
||||||
|
"letter-spacing:.12em;text-transform:uppercase;color:#c9a227}",
|
||||||
|
".bk-ask.bk-done .bk-ask-tag{color:#3fae6a}",
|
||||||
|
".bk-ask-title{margin:0 0 .15rem;font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;opacity:.62}",
|
||||||
|
".bk-ask-prompt{margin:0 0 .6rem;font-weight:600}",
|
||||||
|
".bk-ask-opts{display:flex;flex-direction:column;gap:.3rem}",
|
||||||
|
".bk-ask-opt{display:flex;align-items:flex-start;gap:.55rem;padding:.45rem .6rem;cursor:pointer;",
|
||||||
|
"border:1px solid rgba(128,140,160,.3);border-radius:6px;background:rgba(128,140,160,.06)}",
|
||||||
|
".bk-ask-opt:hover{border-color:rgba(128,140,160,.62)}",
|
||||||
|
".bk-ask-opt:has(input:checked){border-color:#2fa8a0;background:rgba(47,168,160,.13)}",
|
||||||
|
".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:#2fa8a0}",
|
||||||
|
".bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}",
|
||||||
|
".bk-ask-det{font-size:.8rem;opacity:.68}",
|
||||||
|
".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;",
|
||||||
|
"font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);",
|
||||||
|
"border:1px solid rgba(128,140,160,.34);border-radius:6px;resize:vertical}",
|
||||||
|
".bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;",
|
||||||
|
"letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;",
|
||||||
|
"background:#2fa8a0;color:#08131a}",
|
||||||
|
".bk-ask-go:hover{filter:brightness(1.09)}",
|
||||||
|
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}",
|
||||||
|
".bk-ask-was b{opacity:1}",
|
||||||
|
".bk-ask-err{color:#d6452a;font-size:.86rem}",
|
||||||
|
"@media (prefers-color-scheme: light){.bk-ask-tag{color:#8a6d10}.bk-ask-go{color:#fff}}",
|
||||||
|
"@media print{.bk-ask{break-inside:avoid}}"
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
/* The anchor attributes. `data-booth-mark` is canonical - U2 made an ask one
|
||||||
|
shape of mark - and `data-booth-ask` is kept because two of the four live
|
||||||
|
verbatim booths spell it that way, in the operator's own reports. */
|
||||||
|
var MAIN_SEL = "[data-booth-mark],[data-booth-ask]";
|
||||||
|
var SUBMIT_SEL = "[data-booth-mark-submit],[data-booth-ask-submit]";
|
||||||
|
var WHOLE = " whole"; // the set member meaning "the whole ask landed here"
|
||||||
|
|
||||||
|
function attr(el, a, b) {
|
||||||
|
var v = el.getAttribute(a);
|
||||||
|
return v === null ? el.getAttribute(b) : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "batch:r1" -> ["batch", "r1"]; "batch" -> ["batch", null]. Split on the
|
||||||
|
FIRST colon: a question key cannot contain one (asks._KEY_RE) and neither
|
||||||
|
can a pick id (asks.valid_stem), so this is unambiguous for everything the
|
||||||
|
payload carries. A flag's id IS `flag:<target>`, which is why the payload
|
||||||
|
carries picks only. */
|
||||||
|
function splitSpec(spec) {
|
||||||
|
var s = (spec || "").trim();
|
||||||
|
var i = s.indexOf(":");
|
||||||
|
if (i < 0) return [s, null];
|
||||||
|
return [s.slice(0, i).trim(), s.slice(i + 1).trim() || null];
|
||||||
|
}
|
||||||
|
|
||||||
|
function boothName() {
|
||||||
|
var tag = document.querySelector("script[data-booth]");
|
||||||
|
if (tag) return tag.getAttribute("data-booth");
|
||||||
|
var parts = location.pathname.split("/"); // ["", "b", "<name>", ...]
|
||||||
|
if (parts.length < 3 || parts[1] !== "b" || !parts[2]) return null;
|
||||||
|
try { return decodeURIComponent(parts[2]); } catch (e) { return parts[2]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(el, html) {
|
||||||
|
/* beforeend, NOT replaceWith: the author's element and its contents survive
|
||||||
|
and the fragment lands inside it. `<div class="ask" data-booth-ask="...">
|
||||||
|
<h3>heading</h3>` is live markup today, and the regex it replaced ate
|
||||||
|
both the wrapper class and the heading's framing.
|
||||||
|
|
||||||
|
Returns the elements it actually inserted. The chip needs to jump to a
|
||||||
|
fragment WE mounted, not to whatever the document happens to have with a
|
||||||
|
matching id — see chipTarget. */
|
||||||
|
var before = el.children.length;
|
||||||
|
el.insertAdjacentHTML("beforeend", html);
|
||||||
|
return Array.prototype.slice.call(el.children, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
function styles() {
|
||||||
|
if (document.getElementById("booth-embed-css")) return;
|
||||||
|
var st = document.createElement("style");
|
||||||
|
st.id = "booth-embed-css";
|
||||||
|
st.textContent = CSS;
|
||||||
|
(document.head || document.documentElement).appendChild(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
function favicon(href) {
|
||||||
|
/* The question `_ICON_RE` and its three head-seam siblings were asking of
|
||||||
|
raw text. Same question, asked of a parsed document. */
|
||||||
|
if (!href || document.querySelector('link[rel~="icon"]')) return;
|
||||||
|
var link = document.createElement("link");
|
||||||
|
link.rel = "icon";
|
||||||
|
link.href = href;
|
||||||
|
(document.head || document.documentElement).appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
function homeChip(href) {
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.className = "booth-nav-home";
|
||||||
|
a.href = href || "/";
|
||||||
|
a.setAttribute("aria-label", "back to all booths");
|
||||||
|
a.textContent = "‹ all booths";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipTarget(mounted, markId) {
|
||||||
|
/* The earliest IN DOCUMENT ORDER of the elements WE mounted for this mark.
|
||||||
|
Not an id-prefix search over the whole document: a panel pointed out that
|
||||||
|
an author's own `<section id="bk-ask-winner-background">` satisfies any
|
||||||
|
prefix rule — hyphen boundary included — and would hijack the jump. Only
|
||||||
|
elements this script inserted are candidates, which is the identity the
|
||||||
|
deleted `bk-ask-<id>-top` anchor used to guarantee. */
|
||||||
|
var mine = mounted[markId] || [];
|
||||||
|
var first = null;
|
||||||
|
for (var i = 0; i < mine.length; i++) {
|
||||||
|
var el = mine[i];
|
||||||
|
if (!el.id || !document.contains(el)) continue;
|
||||||
|
if (first === null ||
|
||||||
|
(first.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)) {
|
||||||
|
first = el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asksChip(openIds, mounted) {
|
||||||
|
if (!openIds.length) return;
|
||||||
|
/* A JUMP LINK, not a way out to another page: on a long report the question
|
||||||
|
can be well below the fold and "there is a question waiting" still has to
|
||||||
|
be visible at first paint. */
|
||||||
|
var first = chipTarget(mounted, openIds[0]);
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.className = "booth-nav-asks";
|
||||||
|
a.href = first ? "#" + first.id : "/b/" + encodeURIComponent(boothName() || "") + "/marks";
|
||||||
|
a.textContent = "? " + openIds.length + " open ask" + (openIds.length === 1 ? "" : "s");
|
||||||
|
document.body.appendChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reassociate() {
|
||||||
|
/* SCOPED TO OUR OWN FRAGMENTS (`.bk-ask [form]`), deliberately: the Booth
|
||||||
|
does not rewrite attributes on elements the author wrote, even to help.
|
||||||
|
A control bound to its <form> by the HTML5 `form=` attribute resolves its
|
||||||
|
form owner when it is inserted. The fragments go in in VISUAL order, so a
|
||||||
|
question can land before the submit block that carries the <form>.
|
||||||
|
Chromium 151 re-resolves this correctly - measured 2026-09-22, N=3 per
|
||||||
|
condition, with a form-first positive control and a points-at-nothing
|
||||||
|
negative control. The sensitivity floor of that probe is ONE ENGINE, and
|
||||||
|
the failure it would hide is a form the operator fills in whose controls
|
||||||
|
reach no form at all, so the button does nothing and nothing is saved.
|
||||||
|
Three lines, so the engine stops mattering. */
|
||||||
|
var bound = document.querySelectorAll(".bk-ask [form]");
|
||||||
|
for (var i = 0; i < bound.length; i++) {
|
||||||
|
var v = bound[i].getAttribute("form");
|
||||||
|
bound[i].removeAttribute("form");
|
||||||
|
bound[i].setAttribute("form", v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasForm(markId) {
|
||||||
|
/* `form_id` in booth/app.py builds the same string. Kept in step by the
|
||||||
|
fragments themselves: the submit macro emits exactly this id. */
|
||||||
|
return !!document.getElementById(
|
||||||
|
"bk-ask-form-" + markId.replace(/[^A-Za-z0-9_-]/g, "-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function place(marks) {
|
||||||
|
/* Object.create(null), NOT {} — three times, and it is not style.
|
||||||
|
A mark id and a question key are both `[A-Za-z0-9][A-Za-z0-9._-]*`
|
||||||
|
(asks.valid_stem, asks._KEY_RE), so `toString` and `constructor` are
|
||||||
|
legal in both. Against a plain object, an author writing
|
||||||
|
`data-booth-mark="toString"` — an anchor naming NO mark — gets
|
||||||
|
Object.prototype.toString back, passes the `if (!mark)` guard it was
|
||||||
|
supposed to fail, and throws on `mark.questions.length`. That aborts
|
||||||
|
`place` before the tail, so the page loses EVERY ask, from one typo in
|
||||||
|
the author's own markup. The `placed` set has the mirror bug: inherited
|
||||||
|
`got.constructor` reads as "already placed" and silently drops a real
|
||||||
|
question. Found by a cross-frontier code-review panel. */
|
||||||
|
var by = Object.create(null);
|
||||||
|
for (var i = 0; i < marks.length; i++) by[marks[i].id] = marks[i];
|
||||||
|
var placed = Object.create(null); // id -> {key or WHOLE: true}
|
||||||
|
var submitted = Object.create(null);
|
||||||
|
var mounted = Object.create(null); // id -> [elements this script inserted]
|
||||||
|
|
||||||
|
function note(id, key) {
|
||||||
|
if (!placed[id]) placed[id] = Object.create(null);
|
||||||
|
if (key !== undefined) placed[id][key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(id, els) {
|
||||||
|
if (!mounted[id]) mounted[id] = [];
|
||||||
|
for (var n = 0; n < els.length; n++) mounted[id].push(els[n]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. whole / per-question anchors, in DOCUMENT ORDER.
|
||||||
|
var anchors = document.querySelectorAll(MAIN_SEL);
|
||||||
|
for (var a = 0; a < anchors.length; a++) {
|
||||||
|
var el = anchors[a];
|
||||||
|
var spec = splitSpec(attr(el, "data-booth-mark", "data-booth-ask"));
|
||||||
|
var mark = by[spec[0]];
|
||||||
|
if (!mark) continue; // a typo'd id is LEFT ALONE, not blanked
|
||||||
|
if (spec[1] === null) {
|
||||||
|
record(mark.id, mount(el, mark.whole));
|
||||||
|
note(mark.id, WHOLE);
|
||||||
|
if (hasForm(mark.id)) submitted[mark.id] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var q = null;
|
||||||
|
for (var k = 0; k < mark.questions.length; k++) {
|
||||||
|
if (mark.questions[k].key === spec[1]) { q = mark.questions[k]; break; }
|
||||||
|
}
|
||||||
|
if (!q) continue; // names no question: also left alone
|
||||||
|
record(mark.id, mount(el, q.html));
|
||||||
|
note(mark.id, spec[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. explicit submit anchors.
|
||||||
|
var subs = document.querySelectorAll(SUBMIT_SEL);
|
||||||
|
for (var s = 0; s < subs.length; s++) {
|
||||||
|
var sel = subs[s];
|
||||||
|
var sid = splitSpec(attr(sel, "data-booth-mark-submit", "data-booth-ask-submit"))[0];
|
||||||
|
var sm = by[sid];
|
||||||
|
if (!sm) continue;
|
||||||
|
/* A BROKEN pick has no submit block — its `submit` is the empty string and
|
||||||
|
its diagnostic lives in `whole`. Mounting nothing here and then marking
|
||||||
|
it placed made the tail skip it, so the "broken ask" box never rendered
|
||||||
|
at the one surface built to show it. Leave the anchor alone, exactly as
|
||||||
|
an anchor naming no mark is left alone, and let the tail mount the
|
||||||
|
diagnostic. */
|
||||||
|
if (sm.error) continue;
|
||||||
|
record(sm.id, mount(sel, sm.submit));
|
||||||
|
note(sm.id);
|
||||||
|
/* ...and only count it submitted if the <form> SURVIVED. An author who
|
||||||
|
puts this anchor inside their own <form> loses ours: the HTML parser
|
||||||
|
drops a nested form element outright. Every control's `form=` would
|
||||||
|
then point at nothing, the tail would not add a fallback because we
|
||||||
|
said it was handled, and the operator would fill the whole thing in and
|
||||||
|
click a button that does nothing. */
|
||||||
|
if (hasForm(sm.id)) submitted[sm.id] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. the tail, in PAYLOAD order - `(created, id)`. An ask is never
|
||||||
|
// invisible: an unmarked page gets the whole thing, and a partially
|
||||||
|
// marked one gets every question the author did not place, because a
|
||||||
|
// question the operator cannot see is a question he cannot answer, and
|
||||||
|
// a submission with NOTHING picked is refused outright (400), so a page
|
||||||
|
// showing two of four questions can strand a pick that looks answerable.
|
||||||
|
// (A PARTIAL answer is accepted and recorded — that is deliberate.)
|
||||||
|
var holder = document.createElement("div");
|
||||||
|
for (var m = 0; m < marks.length; m++) {
|
||||||
|
var mk = marks[m];
|
||||||
|
var got = placed[mk.id];
|
||||||
|
var was = holder.children.length;
|
||||||
|
if (!got) {
|
||||||
|
holder.insertAdjacentHTML("beforeend", mk.whole);
|
||||||
|
record(mk.id, Array.prototype.slice.call(holder.children, was));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (mk.error) continue;
|
||||||
|
if (!got[WHOLE]) {
|
||||||
|
for (var q2 = 0; q2 < mk.questions.length; q2++) {
|
||||||
|
var qq = mk.questions[q2];
|
||||||
|
if (!got[qq.key]) holder.insertAdjacentHTML("beforeend", qq.html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!submitted[mk.id]) holder.insertAdjacentHTML("beforeend", mk.submit);
|
||||||
|
record(mk.id, Array.prototype.slice.call(holder.children, was));
|
||||||
|
}
|
||||||
|
var tail = document.createDocumentFragment();
|
||||||
|
while (holder.firstChild) tail.appendChild(holder.firstChild);
|
||||||
|
document.body.appendChild(tail);
|
||||||
|
return mounted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
var name = boothName();
|
||||||
|
if (!name || !document.body) return;
|
||||||
|
styles();
|
||||||
|
// Mounted BEFORE the fetch and from a constant, so a failed or slow fetch
|
||||||
|
// still leaves the operator a way out. That is why the payload carries no
|
||||||
|
// `home` — a value on the wire that nothing reads is a second
|
||||||
|
// representation of one fact, waiting to disagree with the first.
|
||||||
|
homeChip("/");
|
||||||
|
fetch("/b/" + encodeURIComponent(name) + "/embed.json", { credentials: "same-origin" })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data) return;
|
||||||
|
favicon(data.favicon);
|
||||||
|
var mounted = place(data.marks || []);
|
||||||
|
reassociate();
|
||||||
|
asksChip(data.open || [], mounted);
|
||||||
|
document.dispatchEvent(new CustomEvent("booth:mounted", { detail: { booth: name } }));
|
||||||
|
})
|
||||||
|
.catch(function () { /* the report is the operator's; a failed fetch costs
|
||||||
|
the chrome, never the page. */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The declared line carries `defer`, but an author may not copy it exactly. */
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", start);
|
||||||
|
} else {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -1,56 +1,22 @@
|
|||||||
{# Self-contained ask fragments injected into a booth's VERBATIM index.html.
|
{# Self-contained ask fragments for a booth's VERBATIM index.html.
|
||||||
|
|
||||||
The page is served untouched and carries its own CSS, so nothing here may
|
The page is served untouched and carries its own CSS, so nothing here may
|
||||||
inherit from base.html: every fragment ships its own scoped `.bk-ask-*`
|
inherit from base.html. Since U3 these fragments do not reach the page by
|
||||||
styles (emitted once, by `styles()`), and the palette adapts via
|
string substitution: they are rendered here, handed over
|
||||||
prefers-color-scheme rather than borrowing the host page's.
|
`/b/<name>/embed.json`, and MOUNTED INTO THE DOM by `/_booth/embed.js`. The
|
||||||
|
scoped `.bk-ask-*` styles live in that file alongside the code that needs
|
||||||
|
them, which is why this template no longer emits a `styles()` block.
|
||||||
|
|
||||||
|
These macros stay the ONE renderer of an ask fragment. embed.js places what
|
||||||
|
comes back and never builds one.
|
||||||
|
|
||||||
Per-question fragments are wired to ONE form with the HTML5 `form=`
|
Per-question fragments are wired to ONE form with the HTML5 `form=`
|
||||||
attribute, so a four-voice report can put each radio group under its own
|
attribute, so a four-voice report can put each radio group under its own
|
||||||
audio block and still submit all four picks in a single POST — which is what
|
audio block and still submit all four picks in a single POST -- which is what
|
||||||
the multi-question ask requires. The <form> element itself is empty and
|
the multi-question ask requires. The <form> element itself is empty and
|
||||||
lives with the submit block. No JavaScript.
|
lives with the submit block.
|
||||||
#}
|
#}
|
||||||
|
|
||||||
{% macro styles() %}
|
|
||||||
<style>
|
|
||||||
.bk-ask{margin:1.1rem 0;padding:.85rem .95rem;border:1px solid rgba(128,140,160,.34);
|
|
||||||
border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);
|
|
||||||
font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
|
||||||
.bk-ask.bk-done{border-top-color:#3fae6a}
|
|
||||||
.bk-ask.bk-skip{border-top-color:#6f7c8c}
|
|
||||||
.bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}
|
|
||||||
.bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
||||||
letter-spacing:.12em;text-transform:uppercase;color:#c9a227}
|
|
||||||
.bk-ask.bk-done .bk-ask-tag{color:#3fae6a}
|
|
||||||
.bk-ask-title{margin:0 0 .15rem;font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;opacity:.62}
|
|
||||||
.bk-ask-prompt{margin:0 0 .6rem;font-weight:600}
|
|
||||||
.bk-ask-opts{display:flex;flex-direction:column;gap:.3rem}
|
|
||||||
.bk-ask-opt{display:flex;align-items:flex-start;gap:.55rem;padding:.45rem .6rem;cursor:pointer;
|
|
||||||
border:1px solid rgba(128,140,160,.3);border-radius:6px;background:rgba(128,140,160,.06)}
|
|
||||||
.bk-ask-opt:hover{border-color:rgba(128,140,160,.62)}
|
|
||||||
.bk-ask-opt:has(input:checked){border-color:#2fa8a0;background:rgba(47,168,160,.13)}
|
|
||||||
.bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:#2fa8a0}
|
|
||||||
.bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}
|
|
||||||
.bk-ask-det{font-size:.8rem;opacity:.68}
|
|
||||||
.bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;
|
|
||||||
font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);
|
|
||||||
border:1px solid rgba(128,140,160,.34);border-radius:6px;resize:vertical}
|
|
||||||
.bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
||||||
letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;
|
|
||||||
background:#2fa8a0;color:#08131a}
|
|
||||||
.bk-ask-go:hover{filter:brightness(1.09)}
|
|
||||||
.bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}
|
|
||||||
.bk-ask-was b{opacity:1}
|
|
||||||
.bk-ask-err{color:#d6452a;font-size:.86rem}
|
|
||||||
@media (prefers-color-scheme: light){
|
|
||||||
.bk-ask-tag{color:#8a6d10}
|
|
||||||
.bk-ask-go{color:#fff}
|
|
||||||
}
|
|
||||||
@media print{.bk-ask{break-inside:avoid}}
|
|
||||||
</style>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{# One question's radio group, bound to the shared form by id. #}
|
{# One question's radio group, bound to the shared form by id. #}
|
||||||
{% macro question(a, q, form_id, name_url, standalone=False) %}
|
{% macro question(a, q, form_id, name_url, standalone=False) %}
|
||||||
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
|
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{# The lifetime line, defined ONCE and called from four surfaces: the index
|
||||||
|
card (both lanes), the booth header (both branches) and the marks page.
|
||||||
|
|
||||||
|
U4: a booth's lifetime is derived from its own state, and a booth that is
|
||||||
|
not counting down must always SAY WHY — an invisible rule that silently
|
||||||
|
stopped the clock would be strictly worse than the `.forever` boolean it
|
||||||
|
replaces, because that one was at least visible as a lane.
|
||||||
|
|
||||||
|
`hold` is the REASON, straight off `hold_reason()`, not a bool beside a
|
||||||
|
string that can disagree with it. Kept wins over a hold because a kept booth
|
||||||
|
is exempt either way, and showing two reasons for one EXEMPTION is the
|
||||||
|
two-representations-of-one-state trap.
|
||||||
|
|
||||||
|
Unreadable marks are the exception and ride along even on a kept board:
|
||||||
|
damaged judgment is not a second exemption, it is a thing somebody has to go
|
||||||
|
and fix, and the kept lane holds the durable boards — the ones where losing
|
||||||
|
the operator's marks costs most. #}
|
||||||
|
{% macro lifetime(kept, hold, expires_in) -%}
|
||||||
|
{%- if kept -%}
|
||||||
|
kept{% if hold == "unreadable" %} · <span class="held held-broken" title="a mark in this booth cannot be read">marks unreadable</span>{% endif %}
|
||||||
|
{%- elif hold == "unreadable" -%}
|
||||||
|
<span class="held held-broken" title="a mark in this booth cannot be read, so the sweeper will not take it">held · marks unreadable</span>
|
||||||
|
{%- elif hold == "open" -%}
|
||||||
|
<span class="held" title="an unanswered question holds this booth open">held until answered</span>
|
||||||
|
{%- else -%}
|
||||||
|
expires in {{ expires_in|dur }}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endmacro %}
|
||||||
@@ -330,6 +330,11 @@
|
|||||||
use for state), green check once answered; the accent is a TOP edge, per
|
use for state), green check once answered; the accent is a TOP edge, per
|
||||||
Australis, never a coloured left border. */
|
Australis, never a coloured left border. */
|
||||||
.badge-mark{background:var(--aus-bright-yellow);color:var(--fg-on-accent)}
|
.badge-mark{background:var(--aus-bright-yellow);color:var(--fg-on-accent)}
|
||||||
|
/* U4: the lifetime line's HELD states. Marked rather than styled into
|
||||||
|
invisibility — the whole safety argument for an unbounded hold is that
|
||||||
|
a booth which stopped counting down says so where the countdown was. */
|
||||||
|
.held{color:var(--aus-bright-yellow)}
|
||||||
|
.held-broken{color:var(--fg-3);text-decoration:underline dotted}
|
||||||
.thumb .badge+.badge-mark{top:2.2rem}
|
.thumb .badge+.badge-mark{top:2.2rem}
|
||||||
.marks{display:flex;flex-direction:column;gap:.9rem;margin:.2rem 0 1.4rem}
|
.marks{display:flex;flex-direction:column;gap:.9rem;margin:.2rem 0 1.4rem}
|
||||||
.mark{border:1px solid var(--border-subtle);border-top:2px solid var(--aus-bright-yellow);
|
.mark{border:1px solid var(--border-subtle);border-top:2px solid var(--aus-bright-yellow);
|
||||||
@@ -491,7 +496,31 @@
|
|||||||
.markdown-body table{border-collapse:collapse;display:block;overflow-x:auto}
|
.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 th,.markdown-body td{border:1px solid var(--rk-line,#252a35);padding:.4em .7em}
|
||||||
.markdown-body img{max-width:100%}
|
.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-meta{display:flex;flex-direction:column;align-items:flex-end;font-size:.75em;opacity:.6}
|
||||||
|
.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_provenance.html" import provenance %}
|
{% from "_provenance.html" import provenance %}
|
||||||
|
{% from "_lifetime.html" import lifetime %}
|
||||||
{# The blur toggle, defined ONCE. There are three item branches in this file
|
{# The blur toggle, defined ONCE. There are three item branches in this file
|
||||||
(doc / media / other) and the first cut of this feature patched only one of
|
(doc / media / other) and the first cut of this feature patched only one of
|
||||||
them, so docs rendered with no control at all. A macro makes "patched two of
|
them, so docs rendered with no control at all. A macro makes "patched two of
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<h1>{{ name }}</h1>
|
<h1>{{ name }}</h1>
|
||||||
{% endif %}
|
{% 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 %}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
|
<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>
|
||||||
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
|
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
|
||||||
{{ provenance(manifest) }}
|
{{ provenance(manifest) }}
|
||||||
{# A durable multi-writer board gets no one-click wipe — same rule as the
|
{# A durable multi-writer board gets no one-click wipe — same rule as the
|
||||||
@@ -112,6 +113,75 @@
|
|||||||
{% include "_marks.html" %}
|
{% include "_marks.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# THE BENCH REGISTRY — BLOCK LEVEL, and that placement is load-bearing.
|
||||||
|
This <div> spent one commit nested inside the `<span class="sub">` of the
|
||||||
|
booth header, because the insertion matched the FIRST `{% if board %}` in
|
||||||
|
the file rather than the block-level one. A <div> inside a <span> is
|
||||||
|
invalid HTML: the parser closes the span implicitly and hoists the div
|
||||||
|
out, orphaning the rest of the sub-line. Three of four cold bug-hunt arms
|
||||||
|
found it and the seat confirmed it in the live document by byte offset.
|
||||||
|
Keep this block between the marks panel and the board form. #}
|
||||||
|
{% if is_board %}
|
||||||
|
{# 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 <url> <name></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 %}
|
||||||
|
{# The date it was REGISTERED, not the date it was last touched: `added`
|
||||||
|
survives re-registration and `updated` does not, so `added` is the
|
||||||
|
one that answers "how long has this been around". #}
|
||||||
|
{% if b.added %}<span class="bench-when">{{ b.added[:10] }}</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">×</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 %}
|
{% if board %}
|
||||||
{# THE STANDING LINK BOARD. Every agent session on the fleet appends here, so
|
{# THE STANDING LINK BOARD. Every agent session on the fleet appends here, so
|
||||||
this is the one booth where the useful granularity is the ROW, not the
|
this is the one booth where the useful granularity is the ROW, not the
|
||||||
@@ -142,14 +212,17 @@
|
|||||||
formaction="/b/{{ name_url }}/unlink-many">🗑 delete <span id="board-selcount">0</span></button>
|
formaction="/b/{{ name_url }}/unlink-many">🗑 delete <span id="board-selcount">0</span></button>
|
||||||
</div>
|
</div>
|
||||||
{% for e in board %}
|
{% 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 }}">
|
<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"
|
<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' }}"
|
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>
|
title="{{ 'unpin' if e.pinned else 'pin to top' }}">{{ '★' if e.pinned else '☆' }}</button>
|
||||||
<div class="board-main">
|
<div class="board-main">
|
||||||
<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
|
<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>
|
||||||
<div class="board-meta">
|
<div class="board-meta">
|
||||||
{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}
|
{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_provenance.html" import provenance %}
|
{% from "_provenance.html" import provenance %}
|
||||||
|
{% from "_lifetime.html" import lifetime %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
||||||
<label class="drop" for="booth-files">
|
<label class="drop" for="booth-files">
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · kept · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · {{ lifetime(true, b.hold, b.expires_in) }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||||
{{ provenance(b.manifest) }}
|
{{ provenance(b.manifest) }}
|
||||||
</div>
|
</div>
|
||||||
{# There IS a × here now (operator, 2026-09-21). The old rule was
|
{# There IS a × here now (operator, 2026-09-21). The old rule was
|
||||||
@@ -69,11 +70,11 @@
|
|||||||
a label changes width. #}
|
a label changes width. #}
|
||||||
<div class="kept-actions">
|
<div class="kept-actions">
|
||||||
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
||||||
onsubmit="return confirm('Release \u201c{{ b.name }}\u201d?\n\nIt moves to the ephemeral lane so you can wipe it from there. Nothing is deleted by this step.')">
|
data-booth="{{ b.name }}" data-confirm="release">
|
||||||
<button title="release this board so it can be wiped">release</button>
|
<button title="release this board so it can be wiped">release</button>
|
||||||
</form>
|
</form>
|
||||||
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
|
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
|
||||||
onsubmit="return confirm('WIPE the KEPT booth \u201c{{ b.name }}\u201d?\n\nThis deletes it and its files immediately. Kept booths are the ones nothing else will clean up, so nobody else is going to do this for you — and nothing brings it back.')">
|
data-booth="{{ b.name }}" data-confirm="wipe-kept">
|
||||||
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
|
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +112,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · {{ lifetime(false, b.hold, b.expires_in) }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||||
{{ provenance(b.manifest) }}
|
{{ provenance(b.manifest) }}
|
||||||
</div>
|
</div>
|
||||||
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
|
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
|
||||||
@@ -123,7 +124,7 @@
|
|||||||
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
|
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
|
||||||
</form>
|
</form>
|
||||||
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
||||||
onsubmit="return confirm('Wipe booth “{{ b.name }}”?')">
|
data-booth="{{ b.name }}" data-confirm="wipe">
|
||||||
<button title="wipe now" aria-label="wipe booth">×</button>
|
<button title="wipe now" aria-label="wipe booth">×</button>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
@@ -160,5 +161,39 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/* Destructive-action confirmation, delegated and DATA-DRIVEN.
|
||||||
|
These were an inline onsubmit calling confirm() with the booth NAME
|
||||||
|
interpolated straight into the JS string literal. Jinja's autoescape is
|
||||||
|
HTML-attribute escaping, not JS-string escaping: the browser decodes the
|
||||||
|
entity back to a quote before the JS parser ever sees it, so a booth name
|
||||||
|
crafted to close that string executed on submit. Booth names are
|
||||||
|
agent-authored — making a folder under the data dir is the whole API — so
|
||||||
|
that is a live path, not a theoretical one.
|
||||||
|
|
||||||
|
The name now travels as a DATA ATTRIBUTE, where escaping is escaping, and
|
||||||
|
never reaches a JS string literal. Same pattern the board controls already
|
||||||
|
use. With JS off the form submits without a prompt, which is what every
|
||||||
|
no-JS browser here already did. */
|
||||||
|
(function () {
|
||||||
|
var WORDS = {
|
||||||
|
release: function (n) {
|
||||||
|
return 'Release \u201c' + n + '\u201d?\n\nIt moves to the ephemeral lane so you '
|
||||||
|
+ 'can wipe it from there. Nothing is deleted by this step.';
|
||||||
|
},
|
||||||
|
'wipe-kept': function (n) {
|
||||||
|
return 'WIPE the KEPT booth \u201c' + n + '\u201d?\n\nThis deletes it and its files '
|
||||||
|
+ 'immediately. Kept booths are the ones nothing else will clean up, so nobody '
|
||||||
|
+ 'else is going to do this for you \u2014 and nothing brings it back.';
|
||||||
|
},
|
||||||
|
wipe: function (n) { return 'Wipe booth \u201c' + n + '\u201d?'; }
|
||||||
|
};
|
||||||
|
document.addEventListener('submit', function (ev) {
|
||||||
|
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
|
||||||
|
if (!form) return;
|
||||||
|
var word = WORDS[form.getAttribute('data-confirm')];
|
||||||
|
if (word && !confirm(word(form.getAttribute('data-booth') || ''))) ev.preventDefault();
|
||||||
|
}, true);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "_lifetime.html" import lifetime %}
|
||||||
{% block title %}{{ name }} · marks · The Booth{% endblock %}
|
{% block title %}{{ name }} · marks · The Booth{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{# The marks page for a booth whose own index.html is served VERBATIM. That page
|
{# The marks page for a booth whose own index.html is served VERBATIM. That page
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
{# `marks_open` comes from open_marks() — the ONE openness predicate (INV-2).
|
{# `marks_open` comes from open_marks() — the ONE openness predicate (INV-2).
|
||||||
This used to re-derive it in Jinja as `selectattr('answer', 'none')`, which
|
This used to re-derive it in Jinja as `selectattr('answer', 'none')`, which
|
||||||
read a half-answered pick as closed. #}
|
read a half-answered pick as closed. #}
|
||||||
<span class="sub">{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }}</span>
|
<span class="sub">{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% if marks %}
|
{% if marks %}
|
||||||
{% include "_marks.html" %}
|
{% include "_marks.html" %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
```
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
---
|
||||||
|
contract_version: "1.0"
|
||||||
|
module: "booth.app (verbatim serving) + booth/static/embed.js"
|
||||||
|
purpose: "A booth that ships its own index.html is the operator's most important surface -- his design reviews, his audition reports, his briefs -- and the Booth reaches into it with six regular expressions against arbitrary author HTML plus a placeholder DSL that substitutes rendered markup by pattern. Both work today and both are the single most fragile thing in the service. This unit replaces the whole class with a DECLARED SEAM: the page carries one line (`<script src=\"/_booth/embed.js\" defer></script>`), the Booth mounts its chrome through real DOM APIs, and a page that declares the line is served with ZERO Booth markup added to it. A page that does not declare it gets that one line appended at the end -- the only remaining mutation, and it needs no pattern matching at all. Operator ruling, 2026-09-21: the page declares itself, the Booth mounts into it."
|
||||||
|
depends_on:
|
||||||
|
- "booth.marks (marks_for, open_marks, hold_read -- the pick records the payload renders. UNCHANGED by this unit: U3 changes how fragments REACH the page, never what a mark is. Read against booth/marks.py, not against the U2 contract's prose -- see the seam review.)"
|
||||||
|
- "booth/templates/_ask_inline.html (the `whole` / `question` / `submit` macros stay the ONE renderer of an ask fragment, called from the embed payload instead of from inject_asks. Its `styles()` macro is DELETED -- inject_asks was its only caller and the CSS moves into embed.js so the chrome is one asset. Macro signatures are otherwise untouched.)"
|
||||||
|
- "booth.asks.normalize_ask (TRANSITIVE, through `marks._hydrate`, and named because the payload shape depends on it: a MULTI ask normalizes to questions whose `key` matches `^[A-Za-z0-9][A-Za-z0-9._-]{0,60}$`, and a SINGLE-question ask normalizes to exactly one question whose `key` is `None`. Both facts are load-bearing -- the first makes splitting an anchor spec on the first colon unambiguous, the second is why `questions` is a list. Read against booth/asks.py:138-223.)"
|
||||||
|
- "booth.app.FAVICON_HREF (the data-URI icon, carried in the payload rather than copied into embed.js -- a third copy of that string is exactly the multiple-readers-of-one-truth shape the repo's ONE-RESOLVER rule (CLAUDE.md invariant 3 -- NOT this contract's INV-1, which is the untouched-page rule; a cold arm read the two as one label and was right to) exists to stop. base.html's literal copy predates this unit and is out of scope.)"
|
||||||
|
language: "python + javascript"
|
||||||
|
complexity: "medium"
|
||||||
|
estimated_loc: 420
|
||||||
|
confidence: 0.82
|
||||||
|
used_by:
|
||||||
|
- "booth.app.booth_view (the verbatim branch: one read, one substring check, one conditional append -- replacing inject_asks + wrap_verbatim_html entirely)"
|
||||||
|
- "the operator's verbatim reports (4 of 21 live booths ship their own index.html; 2 of those 4 use the placement DSL, so the migration is not hypothetical)"
|
||||||
|
- "report authors (the declared line is the new public API for a booth that wants Booth chrome where it chooses)"
|
||||||
|
touches:
|
||||||
|
- "booth/static/embed.js (NEW -- the mount script and the chrome CSS, one asset. Read ONCE at app startup, never per request; see INV-5.)"
|
||||||
|
- "booth/app.py (DELETE wrap_verbatim_html, _insert_before, _insert_after, _ICON_RE, _HEAD_CLOSE_RE, _HTML_OPEN_RE, _DOCTYPE_RE, _BODY_CLOSE_RE, _HTML_CLOSE_RE, _BACK_CHIP, asks_chip, inject_asks, FAVICON_LINK and the `from booth.inline import` block. ADD EMBED_SRC/EMBED_SCRIPT_TAG, the startup read of embed.js, GET /_booth/embed.js, GET /b/{name}/embed.json, and the rewritten verbatim branch of booth_view.)"
|
||||||
|
- "booth/inline.py (DELETED ENTIRELY -- 119 lines. Nothing else imports it; `scripts/booth` never did, so the stdlib-only CLI surface is untouched. ONE line survives the module: `form_id`, which builds the shared form element id the fragments bind to, moves into booth/app.py beside the route that renders them. It is not placement machinery and dying with the placement engine would take the fragments with it. Seam review, SR-3.)"
|
||||||
|
- "booth/templates/_ask_inline.html (DELETE the `styles()` macro and rewrite the header comment: the fragments are now mounted by embed.js, not substituted by regex, and `No JavaScript` stops being true.)"
|
||||||
|
- "tests/test_booth.py (DELETE the five test_wrap_* tests and test_verbatim_booth_wrapped_with_back_chip -- they test a mechanism this unit removes; the FAVICON_LINK import goes with them)"
|
||||||
|
- "tests/test_asks.py (the inline-placement block, ~L480-590: assertions that server-rendered fragments appear in the page body become assertions about the embed payload. The BEHAVIOUR they encode -- every question reachable, a scattered form still submittable, a typo'd id left alone -- is preserved and re-asserted, half in Python and half in the browser.)"
|
||||||
|
- "tests/test_embed.py (NEW -- the payload, the injection rule, the no-hot-reload guard)"
|
||||||
|
- "tests/test_embed_browser.py (NEW -- Playwright against a real Chromium: the placement algorithm and the form association, neither of which the Python suite can see. SKIPS, never fails, when playwright or the shared browser is unavailable.)"
|
||||||
|
- "pyproject.toml (test extra gains `playwright>=1.60,<1.63` -- the range is the set of releases whose pinned Chromium revision is present in the box-wide /opt/ms-playwright store. Stated explicitly because it is invisible otherwise: 1.63 wants chromium-1243, which is NOT there, and the failure is an opaque `Executable doesn't exist`.)"
|
||||||
|
- "docs/design/information-architecture.md (the `What this deletes` list becomes what this DID delete; the standalone /asks bullet is corrected -- U2 already reduced it to a 308)"
|
||||||
|
- "ROADMAP.md (U3 row struck through; the ordering table gains the two orders this unit states)"
|
||||||
|
assumptions:
|
||||||
|
- "THE OPERATOR ALREADY RULED ON THE SEAM (2026-09-21, recorded in the IA doc): the page declares itself and the Booth mounts into it, via `<script src=\"/_booth/embed.js\" defer></script>`. That ruling ACCEPTS a JavaScript dependency on the verbatim path, which today has none. This contract does not re-open it. What the contract DOES do is state the consequence plainly so it is not discovered later -- see the degradation assumption below."
|
||||||
|
- "THE ONLY REMAINING SERVER-SIDE MUTATION IS A CONDITIONAL APPEND, AND IT NEEDS NO PATTERN AT ALL. Two substring tests (`src=\"/_booth/embed.js\"` and its single-quoted twin), then a concatenation. ⚠ THE BARE PATH WAS THE FIRST DRAFT AND IT FAILED IN THE DANGEROUS DIRECTION: a report that merely MENTIONS the path -- a code sample, a comment, a sentence about this feature, which the Booth's own design reports are the likeliest pages to contain -- would have counted as declaring it, been served untouched, and shown no chrome at all, silently. Requiring `src=` immediately before the path flips the failure direction: an unusual spelling (`src = \"...\"`, an unquoted attribute, a `?v=2` suffix) reads as NOT declared, so a second tag is appended and embed.js mounts once anyway on its `window.__boothEmbed` guard. A missed declaration costs a duplicate tag; a false one costs the operator his chrome. Three of four cold-panel arms found this independently. This is why all six regexes die rather than collapsing to one: content appended AFTER `</html>` is parsed into the body by every browser, so there is nothing to find. Nothing is ever PREPENDED, which is what retires both of wrap_verbatim_html's hard constraints in one stroke -- no doctype can be displaced into quirks mode and no charset meta can be pushed out of the first 1024 bytes, because nothing moves."
|
||||||
|
- "THE FAVICON MOVES FROM A REGEX TO A DOM QUERY. `_ICON_RE` existed to answer `does this page already declare an icon`, against raw text, and three more regexes existed to find a head-ish seam to put one in. embed.js asks `document.querySelector('link[rel~=\"icon\"]')` and appends to `document.head`. That is the same question and the same action, asked of a parsed document instead of a string -- and it is four of the six regexes."
|
||||||
|
- "FRAGMENTS ARE STILL RENDERED BY JINJA, ONLY PLACED BY JAVASCRIPT. The payload carries server-rendered HTML from the EXISTING `_ask_inline.html` macros. Re-implementing the ask form in JavaScript would make two renderers of one truth, which is precisely the shape the repo's ONE-RESOLVER rule (CLAUDE.md invariant 3) was written to stop after the zoom view lost its captions. embed.js does DOM placement and nothing else: it never decides what a mark says, whether it is open, or what order marks come in."
|
||||||
|
- "PLACEMENT IS AN ANCHOR-FILL, NOT A REPLACEMENT, AND THAT IS A DELIBERATE CHANGE FROM TODAY. `_EL_RE` matches an author's opening tag and SUBSTITUTES it, so `<div class=\"ask\" data-booth-ask=\"dfa:logo\"><h3>The one asset that must survive</h3>` loses both the wrapper's class and -- visually -- its framing, leaving the author's heading orphaned and the closing `</div>` stray. That is live today on `dfa-concepts`. embed.js uses `el.insertAdjacentHTML('beforeend', frag)`: the author's element and its contents survive and the fragment lands inside, under the heading. Strictly closer to what the markup says, and it is the behaviour a DOM API gives for free."
|
||||||
|
- "`data-booth-mark` IS CANONICAL; `data-booth-ask` IS A KEPT ALIAS. U2 made an ask one shape of mark and the IA doc names the anchor `data-booth-mark`. But 2 of the 4 live verbatim booths use the `data-booth-ask` spelling, in the operator's own reports, so the selector accepts both -- one extra clause in one selector string. Same for `data-booth-mark-submit` / `data-booth-ask-submit`. Renaming without the alias would break a live report to save nothing."
|
||||||
|
- "THE HTML-COMMENT PLACEHOLDERS ARE DROPPED, NOT PORTED. `<!-- booth:ask stem -->` and `<!-- booth:ask-submit stem -->` have ZERO users across all 21 live booths. Walking comment nodes to keep them would be real complexity bought for nobody, in the unit whose entire point is deletion. A page that used one degrades to the append path -- the ask still renders, at the end -- so the never-invisible guarantee holds even for a caller we do not know about."
|
||||||
|
- "DEGRADATION WITH JAVASCRIPT OFF IS A REAL LOSS AND IT IS NAMED HERE. Today the verbatim path is zero-JS: an ask renders server-side and submits through a plain form. After this unit, no JS means no chrome on the report -- no ask, no way home, no icon. The guarantee that an ask is NEVER INVISIBLE survives in a weaker and still-true form, through surfaces that need no script: the index card carries the open-mark badge, and `/b/<name>/marks` renders every mark server-side. This is the cost of the operator's ruling, stated once so nobody meets it as a surprise."
|
||||||
|
- "EMBED.JS IS READ ONCE AT STARTUP, FOR THE REASON TEMPLATES ARE. Serving it from disk per request would give the service a third staleness rule, and a live asset editable under a running process is exactly what put 19 of 25 booths at 500 on 2026-09-21. One rule in this repo: nothing takes effect until you restart. INV-5 holds the line the same way `test_templates_do_not_hot_reload_from_disk` does."
|
||||||
|
- "THE PAYLOAD ENDPOINT DOES NOT RECORD A VIEW. `booth_view` already calls `record_view` above both early returns (U4), and `.viewed` is a deliberate look. A fetch issued by a script on a page that has ALREADY been recorded would double-count activity and reset the TTL on machinery rather than on the operator -- the same distinction the `.lock` exemption draws in `_newest_mtime`."
|
||||||
|
- "THE READ IS LENIENT AND THE STATUS STAYS 200, copied deliberately from `/b/{name}/marks.json`. A damaged `.marks.json` must not 500 the operator's report; it returns an `error` in the body and embed.js mounts the nav anyway. This is the v0.2.2 lesson and the posture every read path in this service already takes."
|
||||||
|
- "WRAP_MAX_BYTES SURVIVES UNCHANGED, at 8 MiB, with the same raw-FileResponse fallback. The work behind it is now trivial, but the READ is not: the largest live verbatim booth is 280 KB and a pathological one still should not be pulled into memory. A booth over the cap loses its chrome exactly as it does today -- no regression, and the constant keeps its existing test."
|
||||||
|
open_questions:
|
||||||
|
- "Whether `/_booth/embed.js` should eventually carry the gallery page's chrome too, making one embed for both surfaces. Out of scope: the gallery page is server-rendered end to end and has no seam problem to solve."
|
||||||
|
- "Whether a booth should be able to suppress injection entirely (a `.no-embed` dotfile) for a report that wants to be served truly untouched. No live booth wants it; declaring the line and then not using it is already most of the way there. Parked rather than designed."
|
||||||
|
---
|
||||||
|
|
||||||
|
# U3 — the declared embed seam
|
||||||
|
|
||||||
|
## The defect, stated precisely
|
||||||
|
|
||||||
|
A booth that ships its own `index.html` is served verbatim. That is the whole
|
||||||
|
promise of the verbatim path, and the Booth breaks it twice on the way out:
|
||||||
|
|
||||||
|
1. **`wrap_verbatim_html`** searches arbitrary author HTML with six regular
|
||||||
|
expressions — `_ICON_RE`, `_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`,
|
||||||
|
`_BODY_CLOSE_RE`, `_HTML_CLOSE_RE` — to find somewhere to put a favicon and
|
||||||
|
somewhere to put a floating chip, while threading two constraints it cannot
|
||||||
|
verify: never put anything ahead of a leading doctype, and keep the charset
|
||||||
|
meta inside the first 1024 bytes.
|
||||||
|
2. **`booth/inline.py`** matches a placeholder DSL with four more patterns and
|
||||||
|
substitutes rendered HTML into the author's markup by string replacement.
|
||||||
|
|
||||||
|
Ten patterns, applied to documents the Booth did not write, does not parse, and
|
||||||
|
cannot validate. It works. It is also the single most fragile thing in the
|
||||||
|
service, and it is load-bearing for the operator's most important workflow.
|
||||||
|
|
||||||
|
The failure this invites is not a crash. It is a report that renders *slightly*
|
||||||
|
wrong — and there is a live specimen already. `dfa-concepts/index.html` writes:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="ask" data-booth-ask="dfa:logo"><h3>The one asset that must survive</h3>
|
||||||
|
```
|
||||||
|
|
||||||
|
`_EL_RE` matches the opening `<div …>` and replaces it. The author's `.ask`
|
||||||
|
wrapper class is gone, the `<h3>` is orphaned, and the `</div>` further down is
|
||||||
|
stray. Nobody filed a bug, because a page that is 95% right does not look broken.
|
||||||
|
|
||||||
|
## The seam
|
||||||
|
|
||||||
|
Operator ruling, 2026-09-21. A report carries one line:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="/_booth/embed.js" defer></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
and the Booth mounts its chrome through real DOM APIs. Three consequences, and
|
||||||
|
the third is the one worth stating out loud:
|
||||||
|
|
||||||
|
- **A page that declares the line is served with nothing added to it.** Not
|
||||||
|
"one small injection" — nothing. The body is what the author wrote.
|
||||||
|
- **A page that does not declare it gets that one line appended at the end.**
|
||||||
|
A substring test and a concatenation; no pattern, nothing prepended, no
|
||||||
|
constraint to thread.
|
||||||
|
- **Both of `wrap_verbatim_html`'s hard constraints stop existing** rather than
|
||||||
|
being satisfied more carefully. You cannot displace a doctype you never move,
|
||||||
|
and you cannot push a charset meta out of the detection window by appending.
|
||||||
|
|
||||||
|
## What crosses the seam
|
||||||
|
|
||||||
|
`GET /b/{name}/embed.json` — server-rendered fragments, and nothing embed.js has
|
||||||
|
to decide for itself:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"booth": "dfa-concepts",
|
||||||
|
"home": "/",
|
||||||
|
"favicon": "data:image/svg+xml,…",
|
||||||
|
"open": ["dfa"],
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"id": "dfa",
|
||||||
|
"error": null,
|
||||||
|
"whole": "<div class=\"bk-ask\" …>",
|
||||||
|
"submit": "<div class=\"bk-ask\" …>",
|
||||||
|
"questions": [
|
||||||
|
{"key": "logo", "html": "<div …>"},
|
||||||
|
{"key": "display", "html": "<div …>"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every HTML string comes from the `_ask_inline.html` macros that render the same
|
||||||
|
fragments today. `open` is `open_marks(picks)` — computed once, server-side, and
|
||||||
|
never re-derived in JavaScript.
|
||||||
|
|
||||||
|
**A payload whose `.marks.json` could not be read has a stated shape**, because
|
||||||
|
an arm asked and the first draft did not say: `marks` is `[]`, `open` is `[]`,
|
||||||
|
`booth` / `home` / `favicon` are present as normal, and top-level `error` and
|
||||||
|
`detail` carry the verdict. Status stays 200, copied from `/marks.json` — a
|
||||||
|
pinned status code is a promise to remote clients, and the information goes in
|
||||||
|
the body instead. The nav mounts; nothing else does. A per-mark `error` is a
|
||||||
|
different thing: that is ONE unreadable pick inside a file that read fine.
|
||||||
|
|
||||||
|
**`questions` is a LIST, and `key` may be `null`.** This is not a style choice.
|
||||||
|
A single-question pick normalizes to `questions: [{"key": None, …}]`
|
||||||
|
(`asks.normalize_ask`), so a JSON object keyed by question key would serialize
|
||||||
|
that key as the string `"null"` — inventing a name that collides with a real key
|
||||||
|
and that JavaScript would have to translate back. A list also carries declaration
|
||||||
|
order in the format itself rather than leaning on object-key insertion order.
|
||||||
|
A `null` key matches no anchor spec, which is correct and is exactly what
|
||||||
|
`place` does today: a single-question pick is addressed as a whole or not at all.
|
||||||
|
Found by the seam review; see SR-2.
|
||||||
|
|
||||||
|
## How the script learns which booth it is on
|
||||||
|
|
||||||
|
**The find of the contract-review round, three arms independently**, and the one
|
||||||
|
gap that made the rest unimplementable as first written: the declared line is
|
||||||
|
byte-identical for every booth, the payload endpoint needs `{name}`, and the
|
||||||
|
name arrives *inside* the response the fetch needs the name to make.
|
||||||
|
|
||||||
|
The rule, stated once:
|
||||||
|
|
||||||
|
> **The booth name is the second path segment of the page's own address.** A
|
||||||
|
> verbatim report is served at `/b/<name>/`, so `embed.js` reads
|
||||||
|
> `location.pathname`, takes segment 2, and `decodeURIComponent`s it. A page
|
||||||
|
> whose address is not `/b/<name>/...` mounts nothing and returns quietly.
|
||||||
|
>
|
||||||
|
> **Override:** a `<script data-booth="...">` attribute wins if present. The
|
||||||
|
> Booth never writes one — the appended tag is exactly the documented line — but
|
||||||
|
> an author embedding a report elsewhere needs a way to say so, and one optional
|
||||||
|
> attribute is cheaper than a second endpoint.
|
||||||
|
|
||||||
|
This makes the URL grammar part of the public seam, which is the honest reading:
|
||||||
|
an author who writes the line is relying on where the Booth serves them, and
|
||||||
|
that should be written down rather than inferred.
|
||||||
|
|
||||||
|
## The placement algorithm
|
||||||
|
|
||||||
|
The same algorithm `inject_asks` runs today, expressed against a DOM instead of
|
||||||
|
a string. It is written out here because it is the part that moves languages,
|
||||||
|
and a reviewer has to be able to check the two against each other.
|
||||||
|
|
||||||
|
```
|
||||||
|
placed : Map<markId, Set<key | WHOLE>>
|
||||||
|
submitted : Set<markId>
|
||||||
|
|
||||||
|
1. every element matching
|
||||||
|
[data-booth-mark], [data-booth-ask] -- in document order
|
||||||
|
spec -> (id, key?) by splitting on the first ":"
|
||||||
|
mark unknown -> leave the element ALONE (a typo stays visible)
|
||||||
|
key absent -> mount whole; placed[id] += WHOLE; submitted += id
|
||||||
|
key names no question -> leave the element ALONE
|
||||||
|
key present -> mount question; placed[id] += key
|
||||||
|
|
||||||
|
2. every element matching
|
||||||
|
[data-booth-mark-submit], [data-booth-ask-submit]
|
||||||
|
mark unknown -> leave alone
|
||||||
|
otherwise -> mount submit; placed[id] ||= {}; submitted += id
|
||||||
|
|
||||||
|
3. tail, appended to <body> in payload order. THE ARROWS ARE EXCLUSIVE, NOT
|
||||||
|
CUMULATIVE -- first match wins and the mark is done. An arm read them as
|
||||||
|
four independent tests, under which one unplaced mark would mount its whole
|
||||||
|
form AND every question AND a submit block; the notation allowed it and the
|
||||||
|
prose did not forbid it:
|
||||||
|
|
||||||
|
if id not in placed: append whole; NEXT MARK
|
||||||
|
elif mark.error: append nothing; NEXT MARK
|
||||||
|
else:
|
||||||
|
if WHOLE not in placed[id]: append every question not in placed[id]
|
||||||
|
if id not in submitted: append submit (scattered, still submittable)
|
||||||
|
|
||||||
|
4. re-associate: for every control carrying form="…", remove and re-set the
|
||||||
|
attribute, so its form owner is resolved after all fragments are in place.
|
||||||
|
|
||||||
|
5. chip: if `open` is non-empty, link it to the FIRST element in document order
|
||||||
|
whose id is EXACTLY `bk-ask-<open[0]>` or begins `bk-ask-<open[0]>-`.
|
||||||
|
A bare prefix match would send the chip to `bk-ask-batch2-r1` for the mark
|
||||||
|
`batch`, or to an author's own element -- flagged by a cold arm, and the
|
||||||
|
trailing hyphen is what rules it out.
|
||||||
|
|
||||||
|
Two more rules the first draft left to the selector rather than stating:
|
||||||
|
|
||||||
|
- **An element carrying BOTH `data-booth-mark` and `data-booth-ask` uses the
|
||||||
|
canonical one.** The alias exists for reports written before the rename, not
|
||||||
|
to double a mount.
|
||||||
|
- **A submit anchor's spec is its stem; any `:key` on it is IGNORED.** There is
|
||||||
|
no per-question submit block — one pick has one `<form>`, which is the whole
|
||||||
|
reason the `form=` binding exists.
|
||||||
|
```
|
||||||
|
|
||||||
|
**`mount` is `el.insertAdjacentHTML('beforeend', frag)`** — the anchor element
|
||||||
|
and its existing contents survive; the fragment lands inside. See the assumption
|
||||||
|
on anchor-fill for why this is a deliberate change and not an accident.
|
||||||
|
|
||||||
|
**Step 4 is measured, not assumed.** Chromium 151 resolves a control's form owner
|
||||||
|
correctly even when the control is inserted before its `<form>`: a probe run
|
||||||
|
2026-09-22 (N=3 per condition, with a form-first positive control and a
|
||||||
|
points-at-nothing negative control) returned `F, F, F` for control-first and
|
||||||
|
`null, null, null` for the negative. So the pass is *not* needed in Chromium.
|
||||||
|
It is three lines, it costs nothing, and the sensitivity floor of that probe is
|
||||||
|
**one engine** — the operator's own browser was not measured. The failure it
|
||||||
|
guards against is a form the operator fills in whose controls reach no form,
|
||||||
|
so the button does nothing.
|
||||||
|
|
||||||
|
**Step 5 deletes an element.** Today `inject_asks` injects `<a id="bk-ask-<id>-top">`
|
||||||
|
before the first fragment of each pick so the chip has somewhere to jump. The
|
||||||
|
fragments already carry ids; document order in a live DOM is directly queryable;
|
||||||
|
the extra anchor is not needed.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
Each is falsifiable by a change that a test must catch going red. The
|
||||||
|
*Falsifiable:* line names that change — not a test that merely mentions the
|
||||||
|
invariant. (Five of seven U4 falsifiers were vacuous; see
|
||||||
|
`persistent-memory.d/2026-09-22-vacuous-falsifiers.md`.)
|
||||||
|
|
||||||
|
**INV-1 — A page that declares the seam is served BYTE FOR BYTE.**
|
||||||
|
The response body for a verbatim booth whose `index.html` contains
|
||||||
|
`src="/_booth/embed.js"` (either quote style) is exactly the bytes on disk.
|
||||||
|
⚠ **Bytes, not text, and that is a correction.** The first implementation read
|
||||||
|
with `read_text()`, which opens in universal-newline mode: a CRLF report came
|
||||||
|
back LF, and `errors="replace"` turned any non-UTF-8 byte into U+FFFD. A
|
||||||
|
declaring page was NOT served as its author wrote it — the headline promise —
|
||||||
|
and the test could not see it, because its fixture was LF-only ASCII. The file
|
||||||
|
is decoded only to ask whether it declares the seam; what goes on the wire is
|
||||||
|
the original bytes. A page that only mentions the path is NOT declaring it — see the
|
||||||
|
conditional-append assumption for which way that has to fail.
|
||||||
|
*Falsifiable:* append anything — a chip, a comment, a newline — to the declaring
|
||||||
|
branch's response and `test_declaring_page_is_served_untouched` fails on a
|
||||||
|
whole-body equality, not on a substring absence.
|
||||||
|
|
||||||
|
**INV-2 — A page that does not declare the seam, AND IS UNDER `WRAP_MAX_BYTES`,
|
||||||
|
is mutated exactly once, at the end.** The response is the source BYTES plus
|
||||||
|
`EMBED_SCRIPT_TAG`'s bytes and nothing else, with the source a byte-exact
|
||||||
|
prefix of it.
|
||||||
|
⚠ **The size cap is an explicit exception, not an oversight** — two cold arms
|
||||||
|
read the invariant's universal wording against the raw-`FileResponse`
|
||||||
|
assumption and found them prescribing different responses for the same page. An
|
||||||
|
over-cap page is mutated ZERO times and loses its chrome, exactly as it did
|
||||||
|
before this unit.
|
||||||
|
*Falsifiable:* insert the tag before `</head>` instead of appending, or add the
|
||||||
|
favicon link back, and `test_undeclared_page_gains_only_the_tag` fails the
|
||||||
|
prefix assertion. The exception has its own test,
|
||||||
|
`test_an_oversize_verbatim_page_is_served_raw`, which fails if the append starts
|
||||||
|
firing above the cap.
|
||||||
|
|
||||||
|
**INV-3 — No regular expression is applied to author HTML.**
|
||||||
|
The verbatim branch of `booth_view` performs two `in` tests and one `+`.
|
||||||
|
⚠ **The first draft of this falsifier was VACUOUS and three arms caught it.**
|
||||||
|
It name-matched the six deleted patterns, so reintroducing the same regex under
|
||||||
|
a new name — `_TAIL_RE`, applied in the verbatim branch — left the test green,
|
||||||
|
on this contract's central promise. Worse, this repo's own vacuity pass missed
|
||||||
|
it, because the mutation it tried was the named one: **a vacuity pass is only as
|
||||||
|
good as the mutation it picks, and picking the one the contract names is how it
|
||||||
|
agrees with itself.**
|
||||||
|
*Falsifiable:* `test_no_regex_touches_author_html` walks the AST of
|
||||||
|
`booth/app.py` and asserts the module performs **exactly one** regex operation
|
||||||
|
— `ask_form_id`'s `re.sub` over a mark id, which is not a page — plus that
|
||||||
|
`booth/inline.py` does not exist. Any regex anywhere in the module, under any
|
||||||
|
name, fails it. Verified by mutation: a renamed `_TAIL_RE.sub` in
|
||||||
|
`embed_verbatim` goes red, and the unmutated control stays green.
|
||||||
|
|
||||||
|
**INV-4 — The payload is the only source of what a mark says.**
|
||||||
|
embed.js never decides openness, order, or content. `open` comes from
|
||||||
|
`open_marks`; `marks` order is `marks_for` order; `questions` order is
|
||||||
|
declaration order.
|
||||||
|
*Falsifiable:* the claim ranges over three things and so does the check.
|
||||||
|
**Openness:** have embed.js derive open marks from a `bk-done` class and
|
||||||
|
`test_the_chip_count_comes_from_the_server` fails on a half-answered pick, which
|
||||||
|
`open_marks` calls open and the rendered state does not. **Order:** reverse the
|
||||||
|
tail iteration and `test_the_tail_follows_payload_order` fails. **Content:** the
|
||||||
|
fragments are strings the page never authors, which
|
||||||
|
`test_every_piece_the_author_can_place_is_offered` pins on the server side.
|
||||||
|
|
||||||
|
**INV-5 — `/_booth/embed.js` is read once at startup.**
|
||||||
|
*Falsifiable:* change the route to `read_text()` per request and
|
||||||
|
`test_embed_js_does_not_hot_reload_from_disk` fails — it mutates the file on
|
||||||
|
disk after the app is built and asserts the served body is unchanged.
|
||||||
|
|
||||||
|
**INV-6 — Every ordered collection this unit renders has a stated rule.**
|
||||||
|
Anchors are visited in **document order** (`querySelectorAll`). The tail is
|
||||||
|
appended in **payload order**, which is `(created, id)` — the rule `marks_for`
|
||||||
|
and `hold_read` both sort by, stated here as the rule rather than as one
|
||||||
|
function's name. Questions
|
||||||
|
within a mark are in **declaration order**. The chip targets the **first element
|
||||||
|
in document order** whose id starts with the open mark's prefix.
|
||||||
|
*Falsifiable:* sort the tail by anything else — id, key, insertion — and
|
||||||
|
`test_tail_order_is_payload_order` fails against a fixture whose creation order
|
||||||
|
and id order disagree.
|
||||||
|
|
||||||
|
**INV-7 — Every question of every READABLE pick reaches the document, on a
|
||||||
|
page that runs the script.** Either placed at an anchor or appended, and every
|
||||||
|
pick with a placed question has a submit block.
|
||||||
|
⚠ **Two qualifiers, both added because arms read the first wording literally and
|
||||||
|
were right.** *Readable*: a pick carrying `error` has no questions to place —
|
||||||
|
`marks._hydrate` gives it an empty list — so the tail mounts its broken-ask box
|
||||||
|
and stops, and an unqualified "every pick" would have demanded placement the
|
||||||
|
algorithm forbids in exactly the damaged-data case the leniency posture exists
|
||||||
|
for. *Reaches the document*, not "is visible": the Booth cannot police an author
|
||||||
|
who hides their own anchor, and a guarantee that claimed to would be unenforceable
|
||||||
|
rather than strict.
|
||||||
|
*Falsifiable:* drop the "append the questions the author did not place" branch
|
||||||
|
and `test_partially_marked_page_still_shows_every_question` fails in the browser
|
||||||
|
with 2 of 4 radio groups present.
|
||||||
|
|
||||||
|
## Out of scope (deferred or never)
|
||||||
|
|
||||||
|
Named so a reviewer does not read them as drift.
|
||||||
|
|
||||||
|
- **The gallery page's chrome.** Only a booth's own `index.html` is served
|
||||||
|
verbatim; every other surface is server-rendered end to end and has no seam
|
||||||
|
problem. `/_booth/embed.js` is not loaded there and is not meant to be.
|
||||||
|
- **Re-rendering an ask in JavaScript.** The payload carries server-rendered
|
||||||
|
HTML and embed.js places it. A JS renderer would be a second renderer of one
|
||||||
|
truth — the bug the repo's one-resolver rule exists to stop.
|
||||||
|
- **A no-JavaScript fallback on the verbatim path.** The operator's 2026-09-21
|
||||||
|
ruling accepts the script dependency. The never-invisible guarantee degrades
|
||||||
|
to surfaces that need no script (the index card's badge, `/b/<name>/marks`),
|
||||||
|
and that is the stated cost, not an oversight to be fixed here.
|
||||||
|
- **The HTML-comment placeholders** `<!-- booth:ask … -->`. Zero users across
|
||||||
|
all 21 live booths; dropped rather than ported. A page that used one falls
|
||||||
|
back to the append path, so its ask still renders.
|
||||||
|
- **`_ask_inline.html`'s dead `standalone=False` macro parameter.** No caller
|
||||||
|
has passed `True` since U2 turned the standalone asks page into a 308.
|
||||||
|
Deleting it is tidy-up and changes a macro signature for no behavioural gain.
|
||||||
|
- **`base.html`'s literal duplicate of the favicon data URI.** It predates this
|
||||||
|
unit. The payload reads `FAVICON_HREF`, so this unit adds no third copy; it
|
||||||
|
does not remove the second.
|
||||||
|
- **`WRAP_MAX_BYTES` and its raw-serve fallback.** Unchanged at 8 MiB. A booth
|
||||||
|
over the cap loses its chrome exactly as it did before — no regression, and
|
||||||
|
the constant keeps its existing test.
|
||||||
|
- **`GET /b/<name>/asks`.** Already a 308 into `/marks` since U2. Left alone:
|
||||||
|
the URL is in the operator's history and in landed reports.
|
||||||
|
- **Pushing, and the version bump tier.** Minor needs the operator's approval.
|
||||||
|
|
||||||
|
## Slices
|
||||||
|
|
||||||
|
| # | slice | red→green on |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `GET /b/{name}/embed.json` — payload shape, order, leniency, no view recorded | payload tests; existing 410 stay green |
|
||||||
|
| 2 | `GET /_booth/embed.js` — served from a startup read, ETag, no hot reload | INV-5 |
|
||||||
|
| 3 | the verbatim branch rewritten; `inject_asks` and `wrap_verbatim_html` deleted | INV-1, INV-2, INV-3 |
|
||||||
|
| 4 | `booth/static/embed.js` — nav, favicon, styles, no marks yet | browser: chip present, icon set, declaring page untouched |
|
||||||
|
| 5 | placement: anchors, tail, submit, re-association | browser: INV-4, INV-6, INV-7; the live `dfa-concepts` and `sindra-voice-1` shapes as fixtures |
|
||||||
|
| 6 | delete `inline.py`; retire the six tests that test the deleted mechanism; docs | suite green, IA doc and ROADMAP updated |
|
||||||
|
|
||||||
|
## Seam review
|
||||||
|
|
||||||
|
The sibling-aware pass, run in-session against the real module surfaces rather
|
||||||
|
than against the sibling contracts' prose. `/heid-contract-review` is
|
||||||
|
artifact-only by design and structurally cannot see `booth/marks.py`, so this is
|
||||||
|
the only gate that can check what the contract borrows from it.
|
||||||
|
|
||||||
|
| # | finding | disposition |
|
||||||
|
|---|---|---|
|
||||||
|
| **SR-1** | The order invariant named `marks_for`'s ordering. The route actually reads through `hold_read` — one read answering both "what is here" and "can it be read", per the TOCTOU lesson — and only falls back to `marks_for` on the error path. Both sort `(created, id)`, so the contract was not wrong, but it named a function where it meant a rule. | **Amended.** INV-6 states the rule. The route's reader is named in the payload section. |
|
||||||
|
| **SR-2** | **The payload shape was wrong.** `questions` as a JSON object keyed by question key breaks on a single-question pick, whose only question has `key: None` (`asks.normalize_ask`, the `multi: False` branch) — `json.dumps` writes that key as the string `"null"`. Every one-question ask in the fleet hits it, including the live `sindra-voice-1`. | **Scope fix.** `questions` is a list of `{key, html}`; `key` is nullable; declaration order is carried by the format. `booth.asks.normalize_ask` added to `depends_on`. |
|
||||||
|
| **SR-3** | `inline.form_id` was inside the module the contract deletes entirely, but it is not placement machinery — it builds the shared `<form>` id the question fragments bind to with `form=`. Deleting the module as written would delete the fragments' ability to submit. | **Scope miss.** `form_id` moves to `booth/app.py`; `touches` says so. |
|
||||||
|
| **SR-4** | A FLAG mark's id is literally `flag:<target>` (`marks.flag_id`) — it contains the separator the anchor spec splits on. It never reaches the payload only because the payload filters `shape == "pick"`, and pick ids are `valid_stem`-checked (no colon). | **No change, stated.** The filter is load-bearing, not incidental; a later widening of the payload to all shapes would break the split rule silently. |
|
||||||
|
| **SR-5** | `_ask_inline.html`'s `question(a, q, form_id, name_url, standalone=False)` has had no caller passing `standalone=True` since the standalone asks page became a 308 in U2. Dead parameter on a macro this unit edits. | **Out of scope, noted.** Deleting it is tidy-up, not this unit's work, and it changes a macro signature for no behavioural gain. |
|
||||||
|
|
||||||
|
## Contract review — the cold panel
|
||||||
|
|
||||||
|
`/heid-contract-review`, four arms, dispatched `01M351WKV666D681SSRNY7D7X6`.
|
||||||
|
Triaged per the cross-frontier discipline: adopted on merits, not on authority.
|
||||||
|
|
||||||
|
| # | finding | arms | disposition |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **CR-1** | **The seam never tells `embed.js` which booth it is on.** The declared line is byte-identical for every booth, the payload endpoint needs `{name}`, and the name arrives inside the response the fetch needs it to make. Every other section depends on this unstated hop. | 3 of 4, independently | **Genuine add, and the round's headline.** The code already derived it from `location.pathname`; the CONTRACT did not say so, which makes a "public API" whose discovery mechanism is unspecified not fully one. New section: *How the script learns which booth it is on*. No code change. |
|
||||||
|
| **CR-2** | **INV-3's falsifier was vacuous** — it name-matched the six deleted patterns, so a renamed regex applied to the page body kept it green, on this contract's central promise. | 3 of 4 | **Genuine add, and a CODE-side fix.** The test now asserts `booth/app.py` performs exactly one regex operation anywhere in the module. Verified by mutation in both directions. The lesson is sharper than the fix: **this repo's own vacuity pass missed it because it tried the mutation the contract named** — a pass that picks the named mutation agrees with itself. |
|
||||||
|
| **CR-3** | **Declaration by bare substring fails in the dangerous direction.** A report that merely mentions `/_booth/embed.js` — a code sample, a comment — counted as declaring it and was served with no chrome at all, silently. | 3 of 4 | **Genuine add, CODE-side.** Detection now requires `src="…"` (either quote style), which fails toward a harmless duplicate tag instead. New test covers prose, comment and `?v=2` spellings. |
|
||||||
|
| **CR-4** | **INV-2 and the size cap prescribe different responses** for an over-cap non-declaring page, and neither the invariant's wording nor a named falsifier carved the exception. | 2 of 4 | **Genuine add.** INV-2 now states the cap as an explicit exception and names the test that holds it. Code and test were already right. |
|
||||||
|
| **CR-5** | **The tail's four arrows read as independent tests**, under which one unplaced mark mounts its whole form AND every question AND a submit block. | 1 | **Genuine add.** The notation allowed it and the prose did not forbid it. The block is now explicit if/elif/else. Code was already exclusive. |
|
||||||
|
| **CR-6** | **INV-7 quantified over picks the algorithm filters** (errored picks) and over "visible", which placement cannot guarantee. | 2 of 4 | **Genuine add, wording.** INV-7 is now scoped to READABLE picks and claims *reaches the document*, not *is visible*. |
|
||||||
|
| **CR-7** | The chip's prefix rule can select `bk-ask-batch2-r1` for mark `batch`, or an author's own element. | 1 | **Sharpening.** The code always matched exactly-or-hyphen; the contract said "starts with". Wording fixed, and `test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix` now holds it. |
|
||||||
|
| **CR-8** | Precedence undefined when one element carries both attribute spellings; submit-anchor key handling unstated. | 1 | **Sharpening.** Both stated; `test_the_canonical_attribute_wins_when_both_are_present` added. |
|
||||||
|
| **CR-9** | The damaged-`.marks.json` payload shape was never stated — per-mark `error` was the only error shown. | 1 | **Genuine add, wording.** Stated in *What crosses the seam*. Test already existed. |
|
||||||
|
| **CR-10** | "INV-1" names two different obligations — this contract's untouched-page rule, and the repo's one-resolver rule the assumptions cite. | 1 | **Genuine add, wording.** The assumptions now name CLAUDE.md invariant 3 explicitly. A real collision: the local falsifier goes red on an added newline and stays green if embed.js becomes a second renderer. |
|
||||||
|
| **CR-11** | INV-4's falsifier covered openness while the invariant claimed openness, order AND content. | 1 | **Sharpening.** The falsifier now names a test per clause. |
|
||||||
|
| **CR-12** | `html.questions` keyed by question name vs the top-level `questions` list — which is authoritative? And INV-4 naming `marks_for`'s order while INV-6 fixed `(created, id)`. | 2 | **Settled before the reply landed.** The in-session seam review collapsed both (SR-1, SR-2) while the panel was in flight. Independent convergence on the same two spots — worth recording, not re-fixing. |
|
||||||
|
|
||||||
|
**One arm's finding not adopted**, and the reason: that a question mounted into
|
||||||
|
an author-hidden anchor is still invisible. True, and out of reach — the Booth
|
||||||
|
cannot police an author hiding their own markup. Answered by narrowing INV-7's
|
||||||
|
claim rather than by chasing actual visibility (CR-6).
|
||||||
|
|
||||||
|
**Methodology note the panel raised on its own**, relayed by heid: 5 of 8 arms
|
||||||
|
across two unrelated callers the same evening independently proposed promoting
|
||||||
|
the end-to-end seam-walk from a conditional deliverable to a mandatory one.
|
||||||
|
CR-1 is a direct product of that exercise. Recorded here as evidence; the skill
|
||||||
|
change is the operator's call, not this repo's.
|
||||||
|
|
||||||
|
## Bug hunt — the cold panel
|
||||||
|
|
||||||
|
`/heid-bug-hunt`, four arms, artifact-only over the merge-base diff, dispatched
|
||||||
|
`01M352TPCSN52G6NGJ07T5WSGY`. ⚠ **The snapshot predates the contract-review
|
||||||
|
fixes**, so two of its findings were already closed when the reply landed; the
|
||||||
|
arms flagged the staleness themselves.
|
||||||
|
|
||||||
|
| # | finding | arms | disposition |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **BH-1** | **A declaring page was NOT served as written.** `read_text()` opens in universal-newline mode, so a CRLF report came back LF, and `errors="replace"` replaced any non-UTF-8 byte. The headline promise, broken by the read itself — and invisible to a test whose fixture is LF-only ASCII. | 1 | **Genuine add, and the best finding of the round.** The verbatim branch reads and serves BYTES; the decoded copy answers only "does it declare?". INV-1 and INV-2 now state the byte-level promise, with a CRLF-plus-invalid-byte fixture. |
|
||||||
|
| **BH-2** | **A submit anchor inside the author's own `<form>` loses ours** — the HTML parser drops a nested form outright. Every control's `form=` then points at nothing, and the code recorded the pick as submitted so the tail added no fallback. The operator fills it in and the button does nothing. | 1 | **Genuine add.** A submit anchor counts as submitted only if the form actually survived (`hasForm`); otherwise the tail supplies one at body level, where no form encloses it. |
|
||||||
|
| **BH-3** | **A broken pick's diagnostic never rendered from a submit-only anchor.** An errored pick's `submit` is empty; mounting that and marking it placed made the tail skip it, so the "broken ask" box vanished from the one surface built to show it. | 3 of 4 | **Genuine add.** A submit anchor for an errored pick is left alone, exactly as an anchor naming no mark is, and the tail mounts the diagnostic. |
|
||||||
|
| **BH-4** | **An author's own element can hijack the chip.** `<section id="bk-ask-winner-background">` satisfies any id-prefix rule — the hyphen boundary from CR-7 included. | 4 of 4 | **Genuine add, and it supersedes CR-7's fix.** The chip now searches only the elements THIS SCRIPT MOUNTED, which is the identity the deleted `bk-ask-<id>-top` anchor used to guarantee, and takes the earliest of those by `compareDocumentPosition`. |
|
||||||
|
| **BH-5** | **No error boundary around fragment rendering.** A `.marks.json` that is well-formed JSON with a wrong-shaped `answer` hydrates with no error and then raises in the macro. | 1, `needs-repro` | **Genuine add — reproduced before building for it.** `_safe_fragments` returns a per-mark error record, the same leniency `_hydrate_safe` applies one layer down. ⚠ **The gallery and marks pages still 500 on it, and that is PRE-EXISTING** — measured at `42ea67f`. Out of scope here and recorded rather than quietly widened: `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`. **CLOSED 2026-09-22**, after U6, at the hydration boundary rather than by a third copy of this guard — so `_safe_fragments` no longer has a reachable natural trigger and is now a pure backstop, falsified synthetically. Hardening the falsifier found that this guard's own fallback re-rendered through the macro module that had just raised, so it re-raised whenever `whole` was the broken thing; fixed in the same pass. |
|
||||||
|
| **BH-6** | Prototype pollution in the placement maps (`toString` as a mark id, `constructor` as a question key). | 1 | **Already fixed this round** as CR-13, from the code-review panel. Two panels, two lenses, the same defect independently — the strongest signal of the evening that the lenses are not redundant. |
|
||||||
|
| **BH-7** | Bare-substring declaration suppresses the chrome. | 4 of 4 | **Already fixed** as CR-3, before the reply landed. |
|
||||||
|
|
||||||
|
**One correction the panel made to this repo's own prose, adopted:** several
|
||||||
|
comments claimed a multi-question pick POSTs a 400 unless every question is
|
||||||
|
answered. It does not — `test_empty_submission_is_refused_with_400` refuses a
|
||||||
|
WHOLLY EMPTY submission, and a partial answer is accepted and recorded on
|
||||||
|
purpose. The real reason an unplaced question must still be appended is simpler
|
||||||
|
and was being obscured: **a question that never reaches the page cannot be
|
||||||
|
answered at all.** Fixed in `embed.js`, the browser tests and this contract.
|
||||||
|
|
||||||
|
**Not adopted:** the bundle's framing called the service Flask. It is FastAPI;
|
||||||
|
the arm noticed and declined to reason from it, which is the right handling.
|
||||||
|
|
||||||
|
## Vacuity pass — final
|
||||||
|
|
||||||
|
21 mutations, each drawn from an invariant's CLAIM rather than its falsifier's
|
||||||
|
example, each run against its named test, plus an unmutated control run.
|
||||||
|
**21/21 caught, control green.**
|
||||||
|
|
||||||
|
The pass earned its place three times over and none of them was the first run:
|
||||||
|
|
||||||
|
1. It reported **7/7** before the contract panel, which then showed INV-3 was
|
||||||
|
vacuous — because the mutation applied was the one the contract named.
|
||||||
|
2. Re-run **against that fix**, it found the fix's own hole (an aliased
|
||||||
|
`import re as _r`).
|
||||||
|
3. Re-run after the bug-hunt fixes, it reported seven **MUTATION-MISS** rows —
|
||||||
|
its loud-failure mode, firing correctly because the fixes had moved the code
|
||||||
|
out from under stale mutations — and then one genuine **VACUOUS**: the
|
||||||
|
sibling-mark chip test had its fixture arranged so the right answer was also
|
||||||
|
the first answer. Rewritten so the sibling comes first, which is the only
|
||||||
|
arrangement that can tell the two implementations apart.
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
---
|
||||||
|
contract_version: "1.0"
|
||||||
|
module: "booth.app (lifetime)"
|
||||||
|
purpose: "A booth's lifetime stops being a boolean somebody remembered to press and becomes a fact derived from the booth's own state. Today there is ONE lifetime (24h from the newest mtime in the tree) and ONE escape hatch (`.forever`), and the measurement says the escape hatch is carrying the main load: 17 of 24 live booths (70%) hold the sentinel, up from the 13 of 24 (54%) counted on 2026-09-21. That is not `ephemeral with an exception`; it is two lifetimes wearing one lifetime's clothes, with the operator doing the sorting by hand. This unit adds the two facts the sweeper was missing -- a booth the operator still owes an answer to is HELD, and looking at a booth is ACTIVITY -- so the cases that were pressing `.forever` for `not yet` stop needing it, and `keep` is left meaning only what it says: this is durable."
|
||||||
|
depends_on:
|
||||||
|
- "booth.marks (`hold_read` -- ADDED BY THIS UNIT, the one-read pair the lifetime rule needs; and `open_marks` -- THE openness predicate, built for this unit and saying so in its own docstring: `Open is the reading that makes U4 correct: a lifetime rule that unpinned a booth on the first radio click would sweep a review in flight.` U4 CALLS it and does not re-derive it. Also `marks_for` (lenient read, never raises) and `read_error` (strict read, total -- it catches its own `MarksCorrupt` and returns a string). Verified against booth/marks.py, not against U2's contract prose: `marks_for` is `_read_raw` + `_hydrate_safe` + sort at marks.py:514; `read_error` is `_read_raw_strict` in a try/except at marks.py:262 and has no raising path.)"
|
||||||
|
- "booth.items (the dotfile skip in `booth_items` at items.py -- `.viewed` is excluded from tiles, counts and zips by the EXISTING `p.name.startswith('.')` rule, exactly as `.marks.json`, `.booth.json` and `.forever` are. No new exclusion is added or needed.)"
|
||||||
|
language: "python"
|
||||||
|
complexity: "medium"
|
||||||
|
estimated_loc: 130
|
||||||
|
used_by:
|
||||||
|
- "booth.app.sweep_once (gains the hold check beside the keep check -- the one place reaper policy lives)"
|
||||||
|
- "booth.app.list_booths (the index card gains `held` and `marks_error`, so the card can say WHY it is not counting down)"
|
||||||
|
- "booth.app.booth_view / booth_view_file / booth_marks_page (each records a view; `/b/<n>/asks` is a 308 redirect into the last of these and so needs no call of its own)"
|
||||||
|
- "booth.app.booth_unkeep (release is activity -- stated, where it used to be an accident of directory mtime)"
|
||||||
|
- "booth/templates/index.html, booth/templates/booth.html (the lifetime line: `expires in X` / `held until answered` / `kept`)"
|
||||||
|
touches:
|
||||||
|
- "booth/app.py (VIEW_MARKER, record_view, is_held; sweep_once, list_booths, booth_view, booth_view_file, booth_marks_page, booth_unkeep; the module docstring's lifetime paragraph)"
|
||||||
|
- "booth/templates/index.html (the ephemeral card's sub-line becomes a three-state lifetime line)"
|
||||||
|
- "booth/templates/booth.html (the same three-state line in the boothhead)"
|
||||||
|
- "booth/templates/_lifetime.html (new -- the lifetime macro, defined ONCE and called from three surfaces. Not in the first draft of this inventory: a four-state conditional repeated three times is the blurtoggle lesson, and U5 had already established the partial as the house answer.)"
|
||||||
|
- "booth/templates/marks.html (INV-4's third surface. A verbatim booth has no Booth-rendered header, so without this the booths most likely to be HELD -- a report that asks something -- would be the ones that never say so. Found by looking at the live service, not by the suite.)"
|
||||||
|
- "booth/templates/base.html (one CSS rule for the held state)"
|
||||||
|
- "scripts/booth (the header's `THE 24h RULE AND ITS ONE EXCEPTION` block, which states the old doctrine as the whole doctrine, and the `DO NOT unkeep and let it expire` block. The WARNING STAYS AND STAYS TRUE -- release still buys a full TTL, so unkeep-and-wait is still a delay rather than a delete. What changes is that it stops being phrased as a surprise about directory metadata and starts being phrased as the rule it now is. The paraphrase panel read the touches line as possibly meaning the advice was being retired; it is not.)"
|
||||||
|
- "README.md (the TTL paragraph)"
|
||||||
|
- "CLAUDE.md (invariant 2's dotfile list gains `.viewed`)"
|
||||||
|
- "tests/test_lifetime.py (new)"
|
||||||
|
- "tests/test_booth.py (ONE cross-reference comment. The draft said the two release-clock tests would gain an assertion that the marker is written; implementation showed they must not. `test_releasing_a_board_RESETS_its_ttl_clock` unlinks the sentinel BY HAND, not through the route, so it is a test of the mtime mechanism and asserting a route side-effect in it would be testing the wrong thing. The route behaviour is `test_releasing_a_board_RECORDS_A_VIEW` in the new file; the comment points at it. No existing assertion is touched.)"
|
||||||
|
assumptions:
|
||||||
|
- "A VIEW IS RECORDED AS A DOTFILE, AND THE EXISTING AGE RULE READS IT. `.viewed` is a dotfile but NOT a `.lock` dotfile, so `_newest_mtime` already counts it (app.py:220 excludes only `.<name>.lock`). There is therefore NO new arithmetic in `booth_age_seconds`, `is_expired` or `expires_in`: `age = now - newest mtime in the tree` is unchanged, and a view is simply one more thing in the tree. One mechanism, not two. This is the same reason `.booth.json` needed no integration work in U5."
|
||||||
|
- "THE LOCK EXEMPTION IS WHY THIS IS SAFE. `_newest_mtime` excludes `.<name>.lock` because those are created by a READ-MODIFY-WRITE path, including one that changes nothing -- machinery, not activity. `.viewed` is the opposite: it is written only by a deliberate GET of a booth's own page. The exemption's rule (`machinery does not count, deliberate acts do`) is unchanged and this lands on the counted side of it."
|
||||||
|
- "RECORDING A VIEW MUST NEVER FAIL THE REQUEST. `record_view` swallows `OSError` -- a read-only mount, a booth owned by another uid, a full disk. The same posture `marks._Locked.__enter__` takes on its `os.utime` and for the same reason, stated there: `Not putting the clock back is a cost this module can absorb; not answering the request is not.` A booth that cannot record a view simply expires on its content mtime, which is today's behaviour."
|
||||||
|
- "HOLD IS FAIL-SAFE, WHERE READS ARE FAIL-OPEN. `marks_for` is lenient by design -- a damaged `.marks.json` reads as no marks, because a review surface that will not render is worse than one that has lost an annotation. That trade is right for a RENDER and wrong for a DELETE: the same leniency on the sweep path would wipe the booth whose judgment we had just failed to read, artifacts and all. So `is_held` treats an unreadable marks file as held. Reads lenient, deletes strict -- the same asymmetry U2 established between `marks_for` and `_Locked`, extended to the reaper. `THE REAPER` IS THE WHOLE SCOPE OF `deletes strict`, and the paraphrase panel ranked the ambiguity here first by stake: a HAND delete is never strict. `booth rm`, `POST /b/<n>/delete` and `DELETE /b/<n>` take a booth held by unreadable marks exactly as they take a kept one, which is what gives that hold -- the one nothing releases on its own -- an exit at all. Strictness is a property of the TIMER, never of the operator."
|
||||||
|
- "THE LIFETIME DECISION COMES FROM ONE READ, and that is a correction to this contract's first draft. The draft specified `is_held(marks_for(child), read_error(child))` -- two reads, presented as one answer. They are not: a write or a repair landing between them yields a pair that described the booth at no instant, and the losing pair is `([], None)` -- no marks and no error -- which is exactly the pair that DELETES. Hulda found it on the paraphrase round (2026-09-22) and it is the finding that changed code rather than prose. `booth.marks.hold_read(booth) -> (marks, error)` is the fix: one strict read answering both questions the lifetime rule asks, so `sweep_once` now does ONE read per booth per tick rather than two. And because `_read_raw_strict` RAISES rather than dropping an entry, a non-raising strict read returns exactly what the lenient read would -- so the index uses that same one read for its badge too, falling back to `marks_for` only on the error path, where leniency is the point."
|
||||||
|
- "AN OPEN PICK HOLDS; A NOTE OR A FLAG DOES NOT. `_is_open` returns False for every shape but `pick`, and False for a pick carrying `error`. That is already correct for U4 and is NOT changed here: a note is the operator's output, not an owed answer, and a pick that hydrated broken can never be answered, so holding a booth on one would be holding it forever for nothing (the CLI already spells that case as exit code 4). A PARTIALLY-answered pick IS open and DOES hold -- operator-settled 2026-09-21, and the reason `open_marks` exists rather than an `answer is None` test."
|
||||||
|
- "THE HOLD IS UNBOUNDED, AND THAT IS THE POINT -- BUT IT MUST BE VISIBLE. A booth with an unanswered pick is never swept, however old. This is a new way for a booth to become immortal, and it is deliberate: unanswered is unfinished. What makes it safe is not a bound, it is VISIBILITY plus TWO exits that already exist. The card and the booth header say `held until answered` in place of the countdown, so a booth that is not counting down always says why; and `booth rm` / the UI `x` delete a held booth exactly as before -- `sweep_once` is the only caller that honours a hold, precisely as it is the only caller that honours `is_kept`."
|
||||||
|
- "`keep` IS UNCHANGED AND KEEPS ITS LANE. `.forever` still exempts, still renders in the kept lane, still round-trips through `booth keep` / `booth unkeep` and the UI. U4 does not deprecate it, narrow it or add a reason field to it. The prediction is that its RATE falls because the `not yet` cases stop needing it -- and a prediction is falsified by measuring, not by removing the thing being measured."
|
||||||
|
- "THE MTIME-RESTORE RACE IN `marks._Locked.__enter__` IS EXPLICITLY CONSIDERED AND LEFT OPEN. The bug-hunt panel flagged it and it was held for U4 because closing it means changing TTL doctrine. U4's answer is that the doctrine stands: the clean fix (ignore a booth directory's own mtime whenever the booth holds anything) would close a two-syscall window that opens ONCE per booth ever, and would in exchange break every `rsync -a` populated booth -- which preserves source mtimes and so has ONLY the directory's freshness to look alive by, and which is the documented path for every host that is not nh3-dev. That is a larger hole than the one being closed. Decided, not deferred; see the OUT OF SCOPE section."
|
||||||
|
open_questions:
|
||||||
|
- "Whether `booth ls` should mark held booths the way it marks kept ones with a star. Cheap, and it would need `is_held` (or a stdlib-only sibling) reachable from the CLI. Sessions already have `booth marks`, which answers the same question about their own booth, so this is convenience rather than capability. Parked, not designed."
|
||||||
|
- "Whether a booth held ONLY by an unreadable `.marks.json` should surface on the index as something to repair, beyond the `marks unreadable` label. It is a held booth that nothing will release, which is the one case where the unbounded hold has no natural exit. The label makes it visible; a repair affordance is a different unit."
|
||||||
|
---
|
||||||
|
|
||||||
|
# U4 — derived lifetime
|
||||||
|
|
||||||
|
## The defect, stated precisely
|
||||||
|
|
||||||
|
> **One lifetime (24h from last touch) and one shape (a folder), serving five
|
||||||
|
> jobs with different lifetimes.** — `docs/design/information-architecture.md`
|
||||||
|
|
||||||
|
`.forever` is the escape hatch for that mismatch, and the measurement says it is
|
||||||
|
no longer an exception:
|
||||||
|
|
||||||
|
| date | booths carrying `.forever` | rate |
|
||||||
|
|---|---|---|
|
||||||
|
| 2026-09-21 (IA doc) | 13 of 24 | 54% |
|
||||||
|
| 2026-09-21 (re-count) | 14 of 25 | 56% |
|
||||||
|
| 2026-09-22 | **17 of 24** | **70%** |
|
||||||
|
|
||||||
|
Both the rate and the absolute count rose, so this is not the denominator
|
||||||
|
shrinking as the sweeper ran. A boolean that 70% of the population sets is not
|
||||||
|
an exception, it is the default with extra steps.
|
||||||
|
|
||||||
|
The reason it gets pressed is that it is the only way to say any of these:
|
||||||
|
|
||||||
|
| what the operator means | what he has to press |
|
||||||
|
|---|---|
|
||||||
|
| "this is a durable reference" | `.forever` |
|
||||||
|
| "I have not answered the question yet" | `.forever` |
|
||||||
|
| "I am still looking at this" | `.forever` |
|
||||||
|
|
||||||
|
Only the first is what `keep` means. The other two are facts the service already
|
||||||
|
holds and does not consult: **there is an open pick in `.marks.json`**, and
|
||||||
|
**somebody just loaded the page**. U4 consults them.
|
||||||
|
|
||||||
|
### The diagnosis has a live positive control
|
||||||
|
|
||||||
|
Counted 2026-09-22 against `~/booth-data`. A census of the whole population, not
|
||||||
|
a sample, and every value is a deterministic file fact (existence, mtime) — so
|
||||||
|
one observation per booth is the measurement, not an anecdote. The population
|
||||||
|
churns (26 -> 24 over the previous session); re-count rather than trusting these.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| live booths | 24 |
|
||||||
|
| carrying `.forever` | 17 (70%) |
|
||||||
|
| carrying `.marks.json` at all | 4 |
|
||||||
|
| of those, with an open pick | **4 of 4** |
|
||||||
|
| **open pick AND `.forever`** | **3** |
|
||||||
|
|
||||||
|
Three of the four booths in the fleet that are waiting on an answer have ALSO
|
||||||
|
been pinned by hand. That is the "not yet" case, caught in the act: the operator
|
||||||
|
pressed the durable-reference sentinel because there was no other way to say
|
||||||
|
"do not take this, I have not answered it". U4 makes those three stop needing it.
|
||||||
|
|
||||||
|
The staleness distribution says the same thing from the other side. Of the 17
|
||||||
|
kept booths, **10 are under ONE day old** — younger than the TTL, so the
|
||||||
|
sentinel has bought them nothing yet and was pressed pre-emptively. (An earlier
|
||||||
|
draft of this paragraph said "12 under 1.5 days" and called that younger than
|
||||||
|
the TTL; 1.5 days is not younger than 24 hours, and the claim only holds at the
|
||||||
|
one-day line. Caught by the cross-frontier paraphrase panel, 2026-09-22 — the
|
||||||
|
measurement was right and the sentence was not.) Only 4 are old enough
|
||||||
|
(2.4-4.6 days) that `keep` is the only reason they still exist. A
|
||||||
|
sentinel pressed on a booth that was in no danger is not a durability decision;
|
||||||
|
it is "not yet", written in the only vocabulary available.
|
||||||
|
|
||||||
|
⚠ The hold's live blast radius is SMALL today — 4 booths have marks at all. The
|
||||||
|
17-to-something prediction therefore rests on both halves of this unit, and on
|
||||||
|
the sentinel becoming unnecessary rather than becoming forbidden. If the rate
|
||||||
|
does not move, the honest readings are: the diagnosis was wrong, OR the habit
|
||||||
|
outlived the need, and the fortnight re-count cannot tell those apart on its
|
||||||
|
own. The three open-pick-plus-`.forever` booths are the ones to watch, because
|
||||||
|
for them the mechanism is now unambiguous.
|
||||||
|
|
||||||
|
## The record
|
||||||
|
|
||||||
|
A booth is in exactly one lifetime state, decided in this order:
|
||||||
|
|
||||||
|
```
|
||||||
|
KEPT .forever present never swept (unchanged)
|
||||||
|
HELD an open pick, or a never swept while (new)
|
||||||
|
.marks.json we cannot read that holds
|
||||||
|
EPHEMERAL otherwise swept when
|
||||||
|
age > ttl (unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
`age` is unchanged: `now - _newest_mtime(booth)`, the newest mtime in the tree
|
||||||
|
excluding `.<name>.lock`. **Viewing is folded in through that existing rule**,
|
||||||
|
not beside it — a view writes `.viewed`, which is a dotfile and not a lock
|
||||||
|
dotfile, so the age function already counts it. There is no new arithmetic.
|
||||||
|
|
||||||
|
### What counts as a view
|
||||||
|
|
||||||
|
One line, because CLAUDE.md invariant 6's test applies to rules as well as
|
||||||
|
orders: **a deliberately-requested response FROM a booth's own page route is a
|
||||||
|
view; a machine read, an asset fetch, and a request that does not resolve are
|
||||||
|
not.**
|
||||||
|
|
||||||
|
Three words in that rule are load-bearing and the first draft said "HTML page",
|
||||||
|
which was wrong twice. `?download=1` is a zip served by the booth-page route and
|
||||||
|
IS a view — the operator asking for the whole booth is as deliberate as looking
|
||||||
|
at it. And a `/view?f=<missing>` that 404s is NOT one: the route matters, but so
|
||||||
|
does whether anything was served, or a crawler walking dead zoom URLs holds a
|
||||||
|
booth open forever. `record_view` therefore sits below the zoom route's file
|
||||||
|
validation and above the booth route's verbatim/zip fork.
|
||||||
|
|
||||||
|
| route | view? | why |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /b/<n>/` | **yes** | the booth page — gallery, verbatim report, or `?download=1` zip |
|
||||||
|
| `GET /b/<n>/view?f=…` | **yes** | the zoom / doc page; a bookmarked zoom URL is somebody looking |
|
||||||
|
| `GET /b/<n>/marks` | **yes** | the standalone judgment page — for a verbatim booth this IS the booth page |
|
||||||
|
| `GET /b/<n>/marks.json` | no | a session polling. An agent must not be able to hold its own booth open |
|
||||||
|
| `GET /b/<n>/<file>` | no | issued BY the page. A hotlinked image would otherwise keep a booth alive |
|
||||||
|
| `GET /` | no | the IA's rule: "deliberate act, so it cannot be triggered by browsing the index" |
|
||||||
|
| `GET /healthz` | no | a monitor is not a viewer |
|
||||||
|
|
||||||
|
⚠ Named rather than hidden: `scripts/layout-probe.py` sweeps every booth page,
|
||||||
|
so running it resets every booth's clock. That is the correct reading of the
|
||||||
|
rule (it is a GET of every booth page), it is recoverable (one extra TTL), and
|
||||||
|
it is a dev tool. A note goes in the probe.
|
||||||
|
|
||||||
|
⚠ A browser that speculatively prefetches a hovered link records a view the
|
||||||
|
operator did not quite take. Accepted: the failure mode is a booth living one
|
||||||
|
extra day because he nearly opened it, and the alternative is sniffing
|
||||||
|
`Sec-Fetch-*` headers, which is a fragile rule pretending to be a crisp one.
|
||||||
|
|
||||||
|
**Checked, because it would have been silent:** nothing in the fleet polls a
|
||||||
|
booth *page*. Homepage's `siteMonitor` for the Booth is
|
||||||
|
`http://10.100.10.50:8090/healthz`, which is on the not-a-view list; there is no
|
||||||
|
cron entry and no systemd timer touching `/b/…`. Had Homepage been pointed at a
|
||||||
|
booth URL instead, every booth would have become immortal on deploy and nothing
|
||||||
|
would have reported it.
|
||||||
|
|
||||||
|
### Release is activity, on purpose
|
||||||
|
|
||||||
|
Removing `.forever` bumps the booth directory's mtime, so a released board
|
||||||
|
survives another full TTL. Today that is an **accident** of directory metadata
|
||||||
|
that `app.py` documents as "not intuitive" and `scripts/booth` warns against.
|
||||||
|
|
||||||
|
U4 does not change the behaviour and does not retire the test that pins it. It
|
||||||
|
changes the behaviour's *reason*: `booth_unkeep` calls `record_view`, so a
|
||||||
|
released board gets one full TTL because **releasing a board is somebody
|
||||||
|
touching it**, which is a rule, and no longer because of which syscall happened
|
||||||
|
to write a directory entry, which is not.
|
||||||
|
|
||||||
|
The existing tests (`test_releasing_a_board_RESETS_its_ttl_clock`,
|
||||||
|
`test_released_board_is_sweepable_once_it_ages_again`) are untouched, and that
|
||||||
|
is a correction to this contract's first draft, which said they would each gain
|
||||||
|
an assertion that the marker is present. They must not: the first one unlinks
|
||||||
|
the sentinel **by hand**, not through the route, so it is a test of the mtime
|
||||||
|
mechanism and a route side-effect does not belong in it. The route behaviour
|
||||||
|
gets its own test in the new file, and the old test gains a comment pointing at
|
||||||
|
it.
|
||||||
|
|
||||||
|
**The marker's mtime must be NOW**, which `Path.touch()` gives and which the
|
||||||
|
contract's first draft left unsaid. An implementation that wrote the file with
|
||||||
|
any older timestamp would satisfy "the marker is there" while the extra TTL
|
||||||
|
still came from the directory-mtime accident this section exists to replace —
|
||||||
|
the new reason would be decoration over the old mechanism. Flagged by the
|
||||||
|
paraphrase panel, 2026-09-22.
|
||||||
|
|
||||||
|
## Signatures
|
||||||
|
|
||||||
|
```python
|
||||||
|
# booth/app.py
|
||||||
|
|
||||||
|
VIEW_MARKER = ".viewed"
|
||||||
|
"""Records the last deliberate look at a booth. A dotfile, so `booth_items`
|
||||||
|
skips it and it costs nothing in counts, galleries or zips — and NOT a `.lock`
|
||||||
|
dotfile, so `_newest_mtime` counts it and the existing age rule picks up the
|
||||||
|
view with no new arithmetic."""
|
||||||
|
|
||||||
|
|
||||||
|
def record_view(booth: Path) -> None:
|
||||||
|
"""Note that somebody deliberately looked at this booth.
|
||||||
|
|
||||||
|
Touches VIEW_MARKER; `_newest_mtime` does the rest. NEVER raises: a
|
||||||
|
read-only mount, a booth we do not own or a full disk cost the timestamp,
|
||||||
|
not the page. A booth whose view cannot be recorded simply ages on its
|
||||||
|
content mtime, which is today's behaviour for every booth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def is_held(marks: Sequence[Mark], error: str | None) -> bool:
|
||||||
|
"""True if this booth still owes the operator an answer and must not be swept.
|
||||||
|
|
||||||
|
PURE — it takes the result of a read and does none of its own, so the index
|
||||||
|
card and the sweeper cannot answer differently about the same booth. That
|
||||||
|
is U1's rule (one resolver, every surface reads the record) applied to
|
||||||
|
lifetime.
|
||||||
|
|
||||||
|
FAIL-SAFE on `error`. `marks_for` is lenient because a review page that
|
||||||
|
will not render is worse than one missing an annotation; the same leniency
|
||||||
|
on the DELETE path would wipe the booth whose judgment we had just failed
|
||||||
|
to read. Reads lenient, deletes strict.
|
||||||
|
|
||||||
|
Openness itself is `open_marks` and nothing else (U2 INV-2).
|
||||||
|
"""
|
||||||
|
return error is not None or bool(open_marks(marks))
|
||||||
|
```
|
||||||
|
|
||||||
|
`is_expired` is **unchanged** and stays a pure age question — the existing
|
||||||
|
separation ("expiry arithmetic and reaper policy are kept apart so they cannot
|
||||||
|
drift into each other") is the reason `is_kept` is not consulted there either.
|
||||||
|
`sweep_once` remains the only caller that honours a pin, and now honours two.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# booth/marks.py — stdlib only, like the rest of that module
|
||||||
|
|
||||||
|
def hold_read(booth: Path) -> tuple[list[Mark], str | None]:
|
||||||
|
"""ONE read of `.marks.json`, answering BOTH questions the lifetime rule
|
||||||
|
asks: what is still open, and whether the file could be read at all.
|
||||||
|
|
||||||
|
Two calls would read the file twice, and two reads of one file are not one
|
||||||
|
read of one state — the pair that loses the race is `([], None)`, which is
|
||||||
|
the pair that deletes.
|
||||||
|
|
||||||
|
On a clean file the marks are what `marks_for` would return, because
|
||||||
|
`_read_raw_strict` raises rather than dropping an entry. So one read serves
|
||||||
|
the badge too, and the lenient reader comes back only on the error path.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
def sweep_once(data_dir, ttl_seconds, now=None) -> list[str]:
|
||||||
|
...
|
||||||
|
if is_kept(child):
|
||||||
|
continue
|
||||||
|
if is_held(*hold_read(child)): # NEW — ONE read
|
||||||
|
continue
|
||||||
|
if is_expired(child, ttl_seconds, now):
|
||||||
|
shutil.rmtree(child)
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
def list_booths(data_dir, ttl_seconds, now=None) -> list[dict]:
|
||||||
|
...
|
||||||
|
marks, marks_error = hold_read(child) # NEW — one read, both facts
|
||||||
|
if marks_error is not None:
|
||||||
|
marks = marks_for(child) # lenient, for the panel
|
||||||
|
booths.append({
|
||||||
|
...
|
||||||
|
"marks_error": marks_error, # NEW — the card says so
|
||||||
|
"held": is_held(marks, marks_error), # NEW — the same predicate
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## What renders
|
||||||
|
|
||||||
|
The lifetime line, on the ephemeral index card and in the booth header. Three
|
||||||
|
states, one of which is new:
|
||||||
|
|
||||||
|
| state | line | why |
|
||||||
|
|---|---|---|
|
||||||
|
| ephemeral | `12 items · expires in 3h 20m` | unchanged |
|
||||||
|
| held, open pick | `12 items · held until answered` | says what holds it AND what releases it |
|
||||||
|
| held, unreadable | `12 items · held · marks unreadable` | the one hold nothing will release on its own |
|
||||||
|
| kept | `12 items · kept` | unchanged, kept lane |
|
||||||
|
|
||||||
|
**The hold REPLACES the countdown at every age, not only once the booth is
|
||||||
|
old.** A held booth that is four hours old shows `held until answered`, not
|
||||||
|
`expires in 20h`. `expires_in` is still computed and still correct (INV-1);
|
||||||
|
it is simply not what the surface says, because a number counting down to a
|
||||||
|
deletion that will not happen is the silent-stopped-clock failure in its other
|
||||||
|
costume — the screen announcing an expiry the sweeper will never carry out.
|
||||||
|
Flagged as readable-two-ways by the paraphrase panel, 2026-09-22; settled here.
|
||||||
|
|
||||||
|
A booth that is not counting down **always says why**. That is the whole safety
|
||||||
|
argument for an unbounded hold: `.forever` was at least visible as a lane; an
|
||||||
|
invisible rule that silently stops the clock would be strictly worse than the
|
||||||
|
boolean it replaces.
|
||||||
|
|
||||||
|
**Three surfaces, not two**, and the third was found by looking at the live
|
||||||
|
service rather than by the suite. A verbatim booth's own `index.html` is served
|
||||||
|
untouched by design, so it has no Booth-rendered header for the line to live in
|
||||||
|
— and a report that ASKS the operator something is the archetype of a held
|
||||||
|
booth. `GET /b/<n>/marks` is the only other page whose chrome the Booth owns, so
|
||||||
|
the line goes there too. Without it, the booths most likely to be held would be
|
||||||
|
exactly the ones that never said they were. (U3 is the unit that gives a
|
||||||
|
verbatim booth real chrome; until then, this is the honest coverage.)
|
||||||
|
|
||||||
|
Kept beats held in the display, because a kept booth is in the kept lane and is
|
||||||
|
exempt either way — showing two reasons for one exemption is the
|
||||||
|
two-representations-of-one-state trap `flag_id`'s docstring names.
|
||||||
|
|
||||||
|
**An unreadable marks file is the exception, and it rides along even on a kept
|
||||||
|
board**: `kept · marks unreadable`. Damaged judgment is not a second exemption,
|
||||||
|
it is a thing somebody has to go and fix, and the kept lane holds the durable
|
||||||
|
boards — the ones where losing the operator's marks costs most. A kept card that
|
||||||
|
said only `kept` would hide the single case that needs a human. The card's
|
||||||
|
`held` and `marks_error` are therefore RAW FACTS, true regardless of keep, and
|
||||||
|
only the display has a precedence. The paraphrase panel found the two readings
|
||||||
|
of "exactly one lifetime state" that this settles.
|
||||||
|
|
||||||
|
## Scope — the blast-radius pass
|
||||||
|
|
||||||
|
`graphify explain` on `sweep_once`, `is_kept`, `list_booths`, `_newest_mtime`,
|
||||||
|
`booth_age_seconds`, `open_marks`, `KEEP_MARKER`, cross-checked with grep.
|
||||||
|
|
||||||
|
Graphify reported the call structure and, as expected, **missed both route
|
||||||
|
callers of `list_booths`** (`index()` and `healthz()`, now at app.py:761 and :774) —
|
||||||
|
they are function-local inside `create_app`, which is the known AST blind spot.
|
||||||
|
Grep caught them. Neither tool alone was sufficient; this is the third unit in
|
||||||
|
a row where that has been true.
|
||||||
|
|
||||||
|
**Production, 7 files:** `booth/app.py`, `booth/marks.py` (`hold_read`, added),
|
||||||
|
`booth/templates/_lifetime.html` (new), `booth/templates/index.html`,
|
||||||
|
`booth/templates/booth.html`, `booth/templates/marks.html`,
|
||||||
|
`booth/templates/base.html`.
|
||||||
|
**Docs/CLI, 4 files:** `scripts/booth`, `scripts/layout-probe.py`, `README.md`,
|
||||||
|
`CLAUDE.md`.
|
||||||
|
**Tests, 2 files:** `tests/test_lifetime.py` (new), `tests/test_booth.py`.
|
||||||
|
|
||||||
|
⚠ This census said "Production, 4 files" in the first draft and omitted
|
||||||
|
`_lifetime.html`, `marks.html`, `marks.py` and `layout-probe.py` — three of
|
||||||
|
which the body text elsewhere required, which is the contradiction both
|
||||||
|
Gróa and Hulda flagged independently. An inventory that disagrees with the
|
||||||
|
prose next to it is worse than no inventory: it reads as a closed set.
|
||||||
|
|
||||||
|
Not touched, and checked rather than assumed: `booth/items.py`,
|
||||||
|
`booth/manifest.py`, `booth/links.py`, `booth/asks.py`, `booth/inline.py`.
|
||||||
|
|
||||||
|
## The three cross-frontier panels, and what they changed
|
||||||
|
|
||||||
|
All three ran on 2026-09-22 and all three earned their place — and each found
|
||||||
|
a class the other two could not. Triaged per the cross-frontier discipline
|
||||||
|
rather than adopted.
|
||||||
|
|
||||||
|
**Paraphrase panel** (`01M34VX0SH23Y3VC92E7GM4S70`, four arms). Seven flags.
|
||||||
|
Five folded into the prose above: the hold replacing the countdown at every
|
||||||
|
age, `deletes strict` scoping to the reaper alone, the zip and the 404 in the
|
||||||
|
view rule, the marker's mtime, and the CLI warning staying true. Two changed
|
||||||
|
more than wording:
|
||||||
|
|
||||||
|
- **Hulda — the two reads are not one state.** The only finding on this round
|
||||||
|
that changed CODE. See the `hold_read` assumption in the frontmatter.
|
||||||
|
- **Gróa and Hulda, independently — the blast-radius census contradicted the
|
||||||
|
prose beside it.** It named four production files while the body required
|
||||||
|
three more. An inventory that disagrees with its own document is worse than
|
||||||
|
none, because it reads as a closed set.
|
||||||
|
|
||||||
|
Hulda also caught a number: this contract claimed 12 kept booths were "under
|
||||||
|
1.5 days old — younger than the TTL". One and a half days is not younger than
|
||||||
|
twenty-four hours. The measurement was right, the sentence was not, and it is
|
||||||
|
the one place the diagnosis overstated itself.
|
||||||
|
|
||||||
|
**Code-vs-contract panel** (`01M34WAFJC3RTERFYBBZJN1SVG`, four arms). **All
|
||||||
|
four arms found the same drift** — the strongest signal either panel produced
|
||||||
|
on this unit. The booth header's sub-line forks on `{% if board %}`, and the
|
||||||
|
lifetime macro sat only in the `{% else %}`, so a booth carrying `links.md`
|
||||||
|
rendered a link count and nothing at all about its lifetime. INV-4 says the
|
||||||
|
templates have no path that renders neither; that was a path, reachable by the
|
||||||
|
release button or by a hand-made board.
|
||||||
|
|
||||||
|
Regin and Kimi recommended amending INV-4 to carve the board header out, on the
|
||||||
|
grounds that board-header layout belongs to U7. **Declined; the code is fixed
|
||||||
|
instead.** Cutting an invariant down to fit an implementation gap is the wrong
|
||||||
|
direction when the fix is one template edit, and U7 owns navigation and section
|
||||||
|
layout — not whether a header states a lifetime. Gróa's "fix it" was right.
|
||||||
|
|
||||||
|
The same panel showed that **most of the INV falsifier tests did not
|
||||||
|
discriminate**, which is the more useful half of the round. The header test
|
||||||
|
never rendered a board. The kept-beats-held test only rendered the index, where
|
||||||
|
kept cards took a hardcoded string and never reached the macro at all. The
|
||||||
|
INV-5 test called `record_view` directly instead of GETting the routes the
|
||||||
|
invariant is about. The INV-7 tests asserted the marker's absence rather than
|
||||||
|
the age, so a handler writing any other non-dot file would have passed. INV-6's
|
||||||
|
had no doomed sibling, so "spare everything" would have passed. Each is now
|
||||||
|
written to fail under the change that defeats it, and the board-header pair was
|
||||||
|
verified RED against the pre-fix template rather than assumed.
|
||||||
|
|
||||||
|
**Bug-hunt panel** (`01M34Y2R0RAJRSN36Q8K4KAB36`, four arms). The round that
|
||||||
|
changed the most code, and the one that found a class the other two could not
|
||||||
|
see by construction: **a read that FAILED still resolving to "no hold", and
|
||||||
|
therefore to a delete.** That is the invariant this unit declared to the panel,
|
||||||
|
and the panel found **four independent paths through it. No single arm found
|
||||||
|
all four.**
|
||||||
|
|
||||||
|
1. **An entry-level hydration error lost its hold.** `.marks.json` parses, one
|
||||||
|
mark fails normalization, `_hydrate_safe` returns a `Mark` carrying `error`,
|
||||||
|
and `_is_open` returns False for an errored pick — on purpose, because a
|
||||||
|
broken pick can never be answered. So the booth read as not-held and swept,
|
||||||
|
while the panel beside it rendered the broken mark in full. The fail-safe was
|
||||||
|
built for FILE-level damage and missed ENTRY-level. This is the strongest
|
||||||
|
finding of all three rounds.
|
||||||
|
2. **A present-but-blank `.marks.json` swept.** `_read_raw_strict` early-returns
|
||||||
|
for whitespace-only content — right for the write path it was written for,
|
||||||
|
wrong for the delete path. Our writer never produces a blank marks document,
|
||||||
|
so a blank one that exists is something that went wrong.
|
||||||
|
3. **`_newest_mtime` returned 0.0 when the booth's own stat failed**, making it
|
||||||
|
maximally ancient and therefore the FIRST thing the sweeper takes. Pre-dates
|
||||||
|
U4; U4 is what turned the age read into a life-or-death read.
|
||||||
|
4. **`is_kept` collapsed a stat failure into not-kept.** `Path.exists()` maps
|
||||||
|
ELOOP and EACCES to False, so a kept booth whose sentinel could not be
|
||||||
|
stat'd became sweepable.
|
||||||
|
|
||||||
|
**`is_held` is gone; `hold_reason` replaced it.** A boolean plus a separate
|
||||||
|
error string is two representations of one state, and Regin independently
|
||||||
|
flagged that the display could not distinguish the two holds. One function now
|
||||||
|
returns the REASON — `"open"`, `"unreadable"`, or None — and every surface reads
|
||||||
|
it off the same value the sweeper acts on. That closes findings 1 and Regin's
|
||||||
|
together, which is why it is a rewrite rather than an extra clause.
|
||||||
|
|
||||||
|
**Convergent, 3-of-4: `record_view` followed a planted symlink.** `Path.touch()`
|
||||||
|
follows an existing link, so a booth carrying `.viewed -> /anywhere` turned every
|
||||||
|
page view into an mtime write at an arbitrary path under the service uid — and
|
||||||
|
any fleet session can write into a booth, because making a folder is the whole
|
||||||
|
API. Now an `O_NOFOLLOW` create plus `os.utime(fd)`, so a planted link raises
|
||||||
|
ELOOP into the existing swallow and view-recording quietly stops for that booth.
|
||||||
|
The `utime` is also what makes the marker read as NOW, which this contract
|
||||||
|
already required and `O_CREAT` alone does not do.
|
||||||
|
|
||||||
|
**Two more the panel found in code this unit touched:**
|
||||||
|
|
||||||
|
- **`?f=.marks.lock` held a booth open.** The zoom route recorded a view for any
|
||||||
|
path that stats inside the booth, including a lock file the service created
|
||||||
|
itself. `record_view` now sits below `find_item` and fires only for a real
|
||||||
|
item — which also makes the comment beside it true, where before it claimed
|
||||||
|
more than the code did.
|
||||||
|
- **Releasing an ALREADY-released booth refreshed its TTL forever.** The
|
||||||
|
unconditional `record_view` on `unkeep` contradicted that route's own no-op
|
||||||
|
promise and diverged from the CLI, which removes the sentinel without
|
||||||
|
recording anything. Now gated on something actually having been released. The
|
||||||
|
same edit fixes a pre-existing 500: a `.forever` that is a DIRECTORY raised
|
||||||
|
`IsADirectoryError` straight through the route, which made the card's release
|
||||||
|
button permanently dead for that booth.
|
||||||
|
|
||||||
|
**Also fixed: a docstring this unit's own fix made stale.** `sweep_once` still
|
||||||
|
claimed "one lenient read plus one strict read" after `hold_read` reduced it to
|
||||||
|
one. Kimi's framing is the right reason to care — a maintainer "optimizes" back
|
||||||
|
to two calls on the comment's authority, and rebuilds the seam the function
|
||||||
|
exists to kill.
|
||||||
|
|
||||||
|
**Re-declared as parked, not adopted:** Regin distinguished a stale-DECISION
|
||||||
|
window (hold checked, then rmtree) from the torn-FILE race already parked at
|
||||||
|
`park/booth-sweeper-rename-then-delete-to-close-the`. The distinction is real
|
||||||
|
and the fix is the same rename-then-delete, so it parks with its sibling.
|
||||||
|
|
||||||
|
**Five pre-existing defects the panel surfaced in touched files** — a booth name
|
||||||
|
reaching a JS string context, an unguarded `links.md` read, an index sort with
|
||||||
|
no tie-breaker, `marks.json` reporting damage as empty success — are fixed in
|
||||||
|
their own commit rather than smuggled into this unit's. See that commit.
|
||||||
|
|
||||||
|
⚠ **The capture tooling failed silently and the panel caught it, not us.** The
|
||||||
|
`files/` tree shipped to the arms was EMPTY: the snapshot loop iterated `for f
|
||||||
|
in $IN` over a multi-line variable, and **zsh does not word-split unquoted
|
||||||
|
parameter expansions** the way bash does, so it ran once against a path that was
|
||||||
|
the whole list. jekyll recovered by re-applying the bundled diff to HEAD and
|
||||||
|
verified every file byte-identical, so the round is sound — but the failure mode
|
||||||
|
is the dangerous one: an empty bundle reads exactly like a clean result.
|
||||||
|
|
||||||
|
## Seam review — against the real module surface
|
||||||
|
|
||||||
|
Checked against `booth/marks.py` itself, not against U2's contract prose.
|
||||||
|
|
||||||
|
| borrowed | real surface | verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| `open_marks(marks)` | `marks.py:542`, takes `Sequence[Mark]`, returns `list[Mark]` | matches |
|
||||||
|
| `marks_for(booth)` | `marks.py:514`, `_read_raw` + `_hydrate_safe` + sort; total | matches |
|
||||||
|
| `read_error(booth)` | `marks.py:262`, returns `str \| None`, catches its own `MarksCorrupt` | matches — **and it is total**, which `is_held`'s fail-safe branch depends on |
|
||||||
|
| `_is_open` semantics | `marks.py:525`: `pick` only, `error is None`, partial counts open | matches the assumption above |
|
||||||
|
| `_newest_mtime` lock rule | `app.py:220`: skips `p.name.startswith(".") and p.name.endswith(".lock")` | `.viewed` is counted — confirmed at the source, not inferred |
|
||||||
|
| `Mark` import in app.py | app.py:145-160 imports `open_marks`, `marks_for`, `marks_for_target`, `as_dict` — **not `Mark`** | `is_held`'s annotation needs `Mark` added to that import list |
|
||||||
|
| `read_error` import in app.py | **not imported either** — U2 left it to the CLI, which is its only caller today | must be added to the same block; U4 is its first in-service consumer |
|
||||||
|
| `zip_booth` dotfile skip | `app.py:445`ff: `p.is_file() and not p.name.startswith(".")` | `.viewed` never reaches a zip — confirmed, not inferred from `booth_items` |
|
||||||
|
| `booth_items` dotfile skip | `items.py:182`: `not p.is_file() or p.name.startswith(".")` | `.viewed` is not an item |
|
||||||
|
| `GET /b/<n>/asks` | `app.py:1105`, a **308 redirect** to `/marks`, not its own render | records a view through the `/marks` handler. No separate call, and adding one would double-count |
|
||||||
|
| route concurrency | `booth_view`, `booth_view_file`, `booth_marks_page` are all `def`, not `async def` | FastAPI runs them in a threadpool, so `record_view`'s write cannot block the event loop |
|
||||||
|
|
||||||
|
Three rows of that table are the kind of thing only this pass finds: the cold
|
||||||
|
panel reads one contract, and a signature that is fine in isolation says nothing
|
||||||
|
about whether the name it needs is in scope at the call site.
|
||||||
|
|
||||||
|
**SR-1 — why `read_error` is safe to call per booth per index load, which the
|
||||||
|
signatures alone do not say.** `_read_raw_strict` checks `S_ISREG` *before* it
|
||||||
|
calls `read_text` (marks.py:236). That ordering is the v0.2.2 fix: `st_size` is
|
||||||
|
0 for a FIFO and 0 for a symlink to `/dev/zero`, so a size cap alone lets both
|
||||||
|
through and `read_text` then either blocks with no EOF or allocates until the
|
||||||
|
kernel intervenes — across every booth, on `GET /`, which is a service-wide
|
||||||
|
hang rather than one bad card. U4's decision to spend a second read on the hot
|
||||||
|
path depends on that guard already being there. It is; checked at the source.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **A bound on the hold.** An abandoned pick holds its booth forever. Detecting
|
||||||
|
"abandoned" needs state the Booth does not have (is any session still
|
||||||
|
polling?), and the honest alternative — an arbitrary N-day cap — trades a
|
||||||
|
visible immortal booth for a silent deletion of an open question. Visibility
|
||||||
|
plus `booth rm` is the answer for v1.
|
||||||
|
- **A reason string on `.forever`.** "keep survives as an explicit, reasoned
|
||||||
|
pin" is read here as *a pin the operator reasoned about*, not *a pin carrying
|
||||||
|
a recorded reason*. A `why` on keep does not close the measured defect — the
|
||||||
|
70% is people using keep for things that are not keep, and this unit gives
|
||||||
|
those things their own mechanism. Parked per the anti-creep gate.
|
||||||
|
- **A third index lane for held booths.** A booth waiting on the operator is the
|
||||||
|
most actionable thing on the index, and it already carries the `? N open`
|
||||||
|
badge. Lane structure and ordering are U7's, and adding a lane here would set
|
||||||
|
an ordering rule that U7 then has to live with.
|
||||||
|
- **Closing the `marks._Locked.__enter__` mtime-restore race.** See the
|
||||||
|
assumption above: the clean fix costs every `rsync -a` populated booth. The
|
||||||
|
comment there stays, and stays accurate.
|
||||||
|
- **`booth ls` marking held booths.** Open question, parked.
|
||||||
|
- **Closing the view-during-sweep race, which U4 WIDENS.** `sweep_once` calls
|
||||||
|
`shutil.rmtree` without holding anything, so a write landing inside that call
|
||||||
|
can make it raise partway and leave a stump directory. The race is
|
||||||
|
pre-existing — every write route has always had it — but U4 widens it,
|
||||||
|
because `record_view` fires on every booth-page GET and the case that
|
||||||
|
collides is precisely "the first look at a booth that has been silent for 24
|
||||||
|
hours", which is the state the sweeper acts on.
|
||||||
|
|
||||||
|
The fix is known and small: `os.rename` the booth to `.sweeping-<name>` first
|
||||||
|
(atomic, and a dotfolder the scan already skips), then `rmtree` the renamed
|
||||||
|
path, plus a cleanup of leftovers at the top of each tick for the
|
||||||
|
crash-between-the-two case. It is NOT done here, per the anti-creep gate:
|
||||||
|
both "in" and "park" are defensible, so it parks. The arithmetic is that the
|
||||||
|
collision needs a GET inside a ~10 ms `rmtree` on a booth nobody has opened in
|
||||||
|
a day, the sweeper ticks every 15 minutes, and the consequence is a stump that
|
||||||
|
survives one more TTL — against which a sweeper rewrite is not a v1-path
|
||||||
|
trade. Named here so it is a decision and not an oversight, and parked on
|
||||||
|
the henge at `park/booth-sweeper-rename-then-delete-to-close-the` (id 83)
|
||||||
|
so it has a home rather than only a paragraph.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
**INV-1 — Age arithmetic is unchanged.** `booth_age_seconds`, `is_expired` and
|
||||||
|
the `expires_in` values on both surfaces are computed exactly as before. A view
|
||||||
|
enters through `_newest_mtime` as a file in the tree, not as a term in a new
|
||||||
|
formula. *Falsifiable:* a booth with a `.viewed` and a booth with any other
|
||||||
|
non-lock dotfile of the same mtime report the same age.
|
||||||
|
|
||||||
|
**INV-2 — `sweep_once` is the only caller that honours a hold.** `is_expired`
|
||||||
|
stays a pure age question; `booth rm`, `POST /b/<n>/delete` and
|
||||||
|
`DELETE /b/<n>` delete a held booth exactly as they delete a kept one.
|
||||||
|
*Falsifiable:* a held booth is still reported expired by `is_expired` and is
|
||||||
|
still deleted by the delete routes.
|
||||||
|
|
||||||
|
**INV-3 — One predicate, ONE READ, one answer.** The index card's `held`, the
|
||||||
|
booth header's, the marks page's and the sweeper's exemption all come from the
|
||||||
|
same pure `is_held`, and each call's two inputs come from a SINGLE read of
|
||||||
|
`.marks.json` via `hold_read` — never from two reads stitched together, which
|
||||||
|
is a pair that described the booth at no instant. *Falsifiable:* for any booth, what
|
||||||
|
`list_booths` reports as `held` and what `sweep_once` refuses to take agree —
|
||||||
|
tested directly rather than by inspection, because that is the falsifiable form.
|
||||||
|
(`open_marks` is still called directly for the `N open` COUNT. A count is not a
|
||||||
|
lifetime decision, and the first draft of this invariant forbade it by accident
|
||||||
|
— the rule is that no *exemption* and no *held label* is derived except through
|
||||||
|
`is_held`.)
|
||||||
|
|
||||||
|
**INV-4 — A booth that is not counting down says why.** Every non-kept booth
|
||||||
|
renders either a countdown or a named hold on **every surface whose chrome the
|
||||||
|
Booth owns**: the index card, the booth header, and the marks page (which is
|
||||||
|
the only one of the three a verbatim booth has). *Falsifiable:* the templates
|
||||||
|
have no path that renders neither, and the one line is a single macro rather
|
||||||
|
than three conditionals that can drift.
|
||||||
|
|
||||||
|
**INV-5 — Recording a view cannot fail a request.** `record_view` swallows
|
||||||
|
`OSError`. *Falsifiable:* a booth whose directory is read-only still returns 200
|
||||||
|
for its page, its zoom page and its marks page.
|
||||||
|
|
||||||
|
**INV-6 — An unreadable `.marks.json` holds its booth.** The reaper never
|
||||||
|
deletes judgment it could not read. *Falsifiable:* a booth with a corrupt
|
||||||
|
`.marks.json`, aged past the TTL, survives `sweep_once`.
|
||||||
|
|
||||||
|
**INV-7 — Machine reads do not hold a booth open.** `GET /b/<n>/marks.json` and
|
||||||
|
`GET /b/<n>/<file>` do not write `VIEW_MARKER`. *Falsifiable:* polling either,
|
||||||
|
repeatedly, leaves the booth's age untouched.
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
---
|
||||||
|
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`, 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.
|
||||||
|
|
||||||
|
**`updated` means the last MUTATION of the record, not the last upsert** —
|
||||||
|
`set_bench_state` bumps it too. Amended after the cold panel read "most recent
|
||||||
|
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, 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
|
||||||
|
writes can exceed it. On the read the id is the dict KEY and it is the locator
|
||||||
|
every control posts back — `bench state`, `bench rm`, and the panel's remove
|
||||||
|
button all address by it. Truncating a hand-edited over-long key on read would
|
||||||
|
produce a row the operator can see and cannot act on, which is strictly worse
|
||||||
|
than a long one. Amended after the cold panel found the code and the contract
|
||||||
|
disagreeing here; the code was right.
|
||||||
|
|
||||||
|
## 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 — 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
|
||||||
|
|
||||||
|
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.**
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**The panel is gated on PAGE IDENTITY — the booth carries a `links.md` — and
|
||||||
|
never on content.** A content gate (`board or benches`) hides the panel AND its
|
||||||
|
registration form exactly when the board is empty and the registry absent,
|
||||||
|
which is the state a fresh deployment starts in and the one where "no benches
|
||||||
|
registered yet" is most worth saying. That is the same defect as a damaged
|
||||||
|
panel rendering as an absent one, one level up. Amended after the cold panel
|
||||||
|
found the content gate shipped.
|
||||||
|
|
||||||
|
## The CLI surface
|
||||||
|
|
||||||
|
```
|
||||||
|
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 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** (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
|
||||||
|
|
||||||
|
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 **by naming ids**.
|
||||||
|
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`. 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.
|
||||||
|
*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 **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
|
||||||
|
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 — **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`
|
||||||
|
with `--apply` writes only the registry and its lock sidecar (`.benches.json`,
|
||||||
|
`.benches.lock`) and never touches `links.md`.
|
||||||
|
|
||||||
|
*(Amended: this said "writes only `.benches.json`", which contradicted the
|
||||||
|
unit's own assumption that every read-modify-write is held under an flock on a
|
||||||
|
sidecar. The cold panel caught the contract arguing with itself. The
|
||||||
|
load-bearing half — `links.md` is not touched — is unchanged and is what the
|
||||||
|
test hashes.)*
|
||||||
|
*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` in tests/test_marks.py 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 in
|
||||||
|
tests/test_manifest.py. 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) | **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` (booth/app.py) | **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` (booth/app.py) vs `DATA` (scripts/booth) | 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` (booth/app.py) | **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` (booth/app.py) | **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.
|
||||||
|
|
||||||
|
## Code review — what the cold panel found
|
||||||
|
|
||||||
|
`/heid-code-review` panel `01M35CK8YKEKMV7T15JXEF6A8N`, four arms, verdict
|
||||||
|
**NOT drift-zero**. Folded in full. Three findings were independently reported
|
||||||
|
by **all four arms**, which is the signature of a contract clause that was
|
||||||
|
written as prose and never converted into an assertion.
|
||||||
|
|
||||||
|
| # | finding | arms | disposition |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **A** | **The panel dropped the added date.** *What renders* says "the date it was added"; `b.added` appeared nowhere in the template and no test asked for it. | 4/4 | **Fixed** — rendered, and pinned by a test. |
|
||||||
|
| **B** | **`bench ls` printed no ids**, and the truncated URL it printed was not pasteable into `bench state\|rm`. Worse: the test's own docstring *claimed* it printed ids while asserting nothing — a claim standing in for evidence, which is how the drift would have survived CI. | 4/4 | **Fixed** — the id prints whole and last; the test now round-trips what `ls` prints back through `bench state`. |
|
||||||
|
| **C** | **`bench import` printed the description, not the raw URL**, beside each id — hiding the five-rows-of-talk collapse the clause exists to expose. | 4/4 | **Fixed** — raw URL beside the id, description demoted to a continuation line. |
|
||||||
|
| **D** | **An IPv6 literal lost its brackets.** `http://[::1]:8080/a` normalized to `http://::1:8080/a` — not another spelling but a BROKEN identity, so a re-post never matches the row. | 3/4 | **Fixed** — bracketed literals are re-wrapped; an *unbracketed* one is refused with a reason rather than guessed at. |
|
||||||
|
| **H** | **INV-4's tie-break falsifier could not fail.** `_write_all` serializes with `sort_keys=True`, so both insertion orders came back off disk already id-sorted and removing the tie-break left the test green. | 1/4 | **Fixed** — the test now calls `order_benches` directly with records that tie on both prior keys. A vacuous falsifier of exactly the class `persistent-memory.d/2026-09-22-vacuous-falsifiers.md` names, found by a cold reader and not by us. |
|
||||||
|
| **I** | **An empty board hid the whole panel**, registration form included — the state a fresh deployment starts in. | 1/4 | **Fixed** — gated on page identity. |
|
||||||
|
| **J** | **The `booth link` refusal could fail OPEN** on a name bash's `$()` erases, because it classified by captured-text emptiness. | 1/4 | **Fixed** — the predicate answers with a `B:`/`N` sentinel, so no name can be mistaken for "not a booth". |
|
||||||
|
| **K** | A FIFO at the registry path blocked in `open()`; a deeply-nested JSON `RecursionError` escaped the `except (ValueError, OSError)` pair. | 1/4 | **The FIFO half was already fixed** by our own pass before the reply landed. **The RecursionError half was not** — 200k open brackets is 200 KB, well inside the byte cap, and it 500'd the page the function exists to protect. Fixed. |
|
||||||
|
| **E** | The read does not apply the `id` cap the contract promised. | 3/4 | **Contract amended, code kept.** The id is the locator every control posts back; truncating a hand-edited over-long key would make a row visible and unactionable. |
|
||||||
|
| **F, G** | INV-5's render test covered 5 of 6 cases and asserted only status 200; INV-2's URL table never ran through the dead-marker render. | 4/4, 3/4 | **Both fixed** — the render test now covers oversized, unreadable and FIFO and asserts the error is *visible*; the full table runs through the marker. |
|
||||||
|
|
||||||
|
**Also folded from the per-invariant vacuity pass** (the arms' "what would still
|
||||||
|
pass" section, which is the single most useful thing the panel produced):
|
||||||
|
INV-6 had no vector asserting a non-default port is part of the identity, so
|
||||||
|
"always omit the port" passed every row; INV-3 asserted only that `links/` was
|
||||||
|
absent, so a refusal touching any other sidecar passed; INV-8's hashed sequence
|
||||||
|
omitted `bench ls`; INV-9's AST walk is defeated by `__import__("booth.links")`.
|
||||||
|
All four closed.
|
||||||
|
|
||||||
|
**Declined:** nothing. **Amended rather than fixed:** E, `updated`'s meaning,
|
||||||
|
INV-8's file list, the `registered`/`created` wording, and every line number in
|
||||||
|
this document's prose — the panel found two already stale, which is the whole
|
||||||
|
argument against putting them in prose at all.
|
||||||
|
|
||||||
|
## Bug hunt — what the cold panel found
|
||||||
|
|
||||||
|
`/heid-bug-hunt` panel `01M35CRRK2RTVWWF1BN09AFQG3`, four arms, diff-scoped
|
||||||
|
against `91fd8bc`. The most severe of the three rounds, and **three of its four
|
||||||
|
convergent findings were already closed by our own adversarial pass before the
|
||||||
|
reply landed** — which is the complementarity the skill claims, measured in both
|
||||||
|
directions on one diff.
|
||||||
|
|
||||||
|
| finding | arms | state when the reply landed |
|
||||||
|
|---|---|---|
|
||||||
|
| **A single malformed board row blanks the ENTIRE 221-row board.** `%00` in a booth name decodes to an embedded NUL; `Path.is_dir()` raises **ValueError**, not `OSError`; `_board_rows`' blanket handler returns `[]`. Every row vanishes, the page still 200s, nothing says why. | 4/4 | **Already fixed** (control-character guard). |
|
||||||
|
| **`RecursionError` escapes `read_benches` and 500s the board page.** ~4 KB of nested brackets, well under the byte cap. **Three arms independently cited the precedent: this repo already paid for this exact class in `marks.py`** — the new module re-introduced the unguarded parse. | 4/4 | **Already fixed.** |
|
||||||
|
| **A FIFO still blocks the render path** while the code comment claims the hang lesson was applied. | 4/4 | **Already fixed** — and the comment that lied about it was the thing that made us look. |
|
||||||
|
| **IPv6 bracket loss.** Second independent sighting, same root. | 4/4 | **Already fixed** by the code-review round. |
|
||||||
|
| **The benches panel is nested inside `<span class="sub">`.** A `<div>` in a `<span>`: the parser closes the span implicitly and hoists the div out, orphaning the rest of the sub-line. Nothing 500s, which is why no test could see it. | 3/4 | **OPEN — fixed now.** Moved to block level; pinned by an offset assertion and verified with a real HTML parser (0 block-in-span violations). |
|
||||||
|
| **`_booth_exists` and `resolve_booth` disagree on a symlink.** The marker called a booth pointing outside the data root alive while the page 404s it — the row renders healthy and the link is dead. | 3/4 | **OPEN — fixed now.** Same containment, same rules. |
|
||||||
|
| **The board append opens its fd OUTSIDE the lock.** `flock LOCK printf … >> board` reads as locked and is not: the shell opens the append fd while parsing. A concurrent `unlink` replaces the inode via `os.replace`, the old fd keeps pointing at the unlinked one, and the append **succeeds, reports success, and vanishes.** | solo | **OPEN — fixed now.** Pre-existing, not this unit's, but it is silent data loss in the file this unit lives in. Proved by holding the lock and asserting nothing is written. |
|
||||||
|
| **A pre-planted symlink at the predictable `.benches.json.tmp.<pid>`** defeats the atomic write. The replace is atomic, not safe. | solo | **OPEN — fixed now.** `mkstemp` (O_EXCL, same directory), plus an `fsync` before the replace, because `os.replace` orders the rename and not the data behind it. |
|
||||||
|
| A successful registration can cross the read cap and poison the registry; an empty board hides the panel. | solo | **Already fixed** by the contract round. |
|
||||||
|
|
||||||
|
**Declined, with the reasoning recorded.** Kimi: the `python3 -c` guard under
|
||||||
|
`set -e` means that on a host where `booth.links` is not importable, `booth
|
||||||
|
link` now refuses **every** URL, not just booth ones — the refusal mechanism
|
||||||
|
refuses everything, while the sibling `announce` call degrades gracefully.
|
||||||
|
**True, and kept as-is deliberately.** A guard that fails open is not a guard,
|
||||||
|
and the state it describes (the package unreachable from the script that
|
||||||
|
computes its path from its own location) is a broken install in which `booth
|
||||||
|
new`, `booth add` and `booth ask` are equally broken. Loud failure with a
|
||||||
|
message naming what is missing beats silent non-enforcement. Recorded rather
|
||||||
|
than silently dismissed, because the asymmetry with `announce` is real.
|
||||||
|
|
||||||
|
**What the round says about the method.** The two lenses were complementary in
|
||||||
|
both directions on one diff: the cold panel found three live defects the
|
||||||
|
in-session pass missed (all three invisible to a test — a layout nesting, a
|
||||||
|
symlink disagreement, a lock-ordering race), and the in-session pass had already
|
||||||
|
closed three of the panel's four convergent findings. Neither substitutes for
|
||||||
|
the other. The sharpest single line in the reply is the one noting this repo had
|
||||||
|
already paid for the `RecursionError` class in `marks.py` — **a new module
|
||||||
|
re-introduced a bug the codebase had a test for**, which no amount of
|
||||||
|
reading the new module in isolation would surface.
|
||||||
@@ -162,6 +162,7 @@ session that posted the set.
|
|||||||
```
|
```
|
||||||
BENCH
|
BENCH
|
||||||
id : normalized URL (the identity — re-posting UPDATES, never appends)
|
id : normalized URL (the identity — re-posting UPDATES, never appends)
|
||||||
|
NORMALIZED MEANS THE FULL URL, NOT THE ORIGIN — see below
|
||||||
name : what it is
|
name : what it is
|
||||||
owner : the agent handle that registered it
|
owner : the agent handle that registered it
|
||||||
state : live → promoted (to Homepage) → retired
|
state : live → promoted (to Homepage) → retired
|
||||||
@@ -173,8 +174,51 @@ BENCH
|
|||||||
- `booth bench add <url> "<what>"` upserts on the normalized URL. The 5 `talk`
|
- `booth bench add <url> "<what>"` upserts on the normalized URL. The 5 `talk`
|
||||||
rows and 4 `peedlar` rows collapse to one each, by construction.
|
rows and 4 `peedlar` rows collapse to one each, by construction.
|
||||||
- **`booth link` refuses a `…:8090/b/…` URL** and names the right surface. It
|
- **`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
|
is **not deprecated** — 17 handles have the muscle memory, the teaching moment
|
||||||
muscle memory, and the teaching moment belongs at the point of use.
|
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
|
- 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
|
marker and a date; deleting is the operator's call. Nothing here deletes the
|
||||||
operator's data on a timer.
|
operator's data on a timer.
|
||||||
@@ -206,21 +250,41 @@ a real DOM API. Marks land at `data-booth-mark="<id>"` anchors, which keeps the
|
|||||||
page*. If the line is absent, the Booth injects it at **one** insertion point, so
|
page*. If the line is absent, the Booth injects it at **one** insertion point, so
|
||||||
every existing verbatim booth keeps working untouched.
|
every existing verbatim booth keeps working untouched.
|
||||||
|
|
||||||
**What this deletes**, and this is the whole point of the decision:
|
**What this deleted** — landed as U3, 2026-09-22:
|
||||||
|
|
||||||
- `booth/inline.py` — 114 lines of placeholder DSL, entirely
|
- `booth/inline.py` — 119 lines of placeholder DSL, entirely. One line survived:
|
||||||
|
`form_id`, which builds the shared `<form>` id scattered question groups bind
|
||||||
|
to, and which moved to `app.py` beside the route that renders them.
|
||||||
- `wrap_verbatim_html` and its six regexes against arbitrary HTML
|
- `wrap_verbatim_html` and its six regexes against arbitrary HTML
|
||||||
(`_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`, `_BODY_CLOSE_RE`,
|
(`_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`, `_BODY_CLOSE_RE`,
|
||||||
`_HTML_CLOSE_RE`, `_ICON_RE`) and the doctype/charset-ordering constraints
|
`_HTML_CLOSE_RE`, `_ICON_RE`) **and both of the constraints they were
|
||||||
they are threading
|
threading.** Not satisfied more carefully — gone: nothing can displace a
|
||||||
- `_BACK_CHIP`, `asks_chip` — two floating chips positioned by guessed offsets
|
leading doctype into quirks mode and nothing can push the charset `<meta>`
|
||||||
- `GET /b/<name>/asks` — the standalone page that existed only because a verbatim
|
out of its detection window, because the Booth only ever APPENDS now.
|
||||||
booth could not show its own asks
|
- `_BACK_CHIP`, `asks_chip` — two floating chips positioned by guessed offsets.
|
||||||
|
embed.js builds both in the DOM.
|
||||||
|
- the `styles()` macro. The scoped `.bk-ask-*` rules live in embed.js next to
|
||||||
|
the code that mounts them, emitted once by construction instead of by a
|
||||||
|
seen-set.
|
||||||
|
- `GET /b/<name>/asks` was already a 308 into `/marks` by U2; this unit left it
|
||||||
|
there. The standalone page it named is gone, but the URL is in the operator's
|
||||||
|
history and in landed reports, and a dead link teaches nothing.
|
||||||
|
|
||||||
Regex-injecting into arbitrary author HTML is the single most fragile thing in
|
**What replaced them is a substring test and a `+`.** `if EMBED_SRC not in
|
||||||
the service, and it is load-bearing for the operator's most important workflow.
|
html: html += EMBED_SCRIPT_TAG`. A page that declares the line is served with
|
||||||
|
nothing added to it at all.
|
||||||
|
|
||||||
|
Regex-injecting into arbitrary author HTML was the single most fragile thing in
|
||||||
|
the service, and it was load-bearing for the operator's most important workflow.
|
||||||
A declared seam costs the author one line and removes the whole class.
|
A declared seam costs the author one line and removes the whole class.
|
||||||
|
|
||||||
|
**What it cost, stated because it is real.** The verbatim path used to work with
|
||||||
|
no JavaScript: an ask rendered server-side and submitted through a plain form.
|
||||||
|
It now needs the script. The guarantee that an ask is never invisible survives
|
||||||
|
in a weaker and still-true form through surfaces that need no script — the index
|
||||||
|
card's open-mark badge, and `/b/<name>/marks`, which renders every mark
|
||||||
|
server-side.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Every code-changing finding came from the AMBIGUITY pass
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**Every one of the panel's code-changing findings came from the
|
||||||
|
AMBIGUITY pass, none from a paraphrase divergence** — and two arms independently
|
||||||
|
proposed cutting the paraphrase to a drift-check for narrative-heavy contracts,
|
||||||
|
because this contract's own frontmatter carries a plain-language narrative and the
|
||||||
|
paraphrase was partly reading my framing back to me. That is a finding about the
|
||||||
|
`/heid-contract-review` **skill**, not about this repo, and it was reported back
|
||||||
|
to heid. Recorded here only so a future session does not rediscover it.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# A boolean escape hatch as the lifetime mechanism
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**A boolean escape hatch as the lifetime mechanism.**
|
||||||
|
`.forever` was added because a 24h TTL genuinely did not fit some booths —
|
||||||
|
and then 56% of live booths ended up on it, which means it is not "ephemeral
|
||||||
|
with an exception", it is two lifetimes wearing one lifetime's clothes, with
|
||||||
|
the operator doing the sorting by hand. Replaced at U4 by lifetime derived
|
||||||
|
from state (an open mark pins; viewing is activity; `keep` survives as an
|
||||||
|
explicit reasoned pin rather than the only way to say "not yet").
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Deterministic order is a cross-cutting v1 invariant
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**Deterministic order is a cross-cutting v1 invariant** —
|
||||||
|
operator directive, mid-implementation. Every ordered collection the Booth
|
||||||
|
renders must have a *stated* rule producing the same sequence on every render
|
||||||
|
of the same state; the rule can be anything defensible (byte order, time, an
|
||||||
|
explicit number, an arbitrary-but-recorded sequence), but no rule at all is
|
||||||
|
forbidden. It binds harder here than elsewhere because the Booth's job is
|
||||||
|
**comparison** — the operator judges tile 47 against tile 47 and refers to
|
||||||
|
artifacts positionally, so an order that moves between renders misfiles a flag
|
||||||
|
or a note rather than crashing. Recorded as `ROADMAP.md` § "Cross-cutting
|
||||||
|
invariant" (with the per-collection table) and `CLAUDE.md` invariant 6, and
|
||||||
|
tested. Still undecided and must be settled before those units ship: **U7's
|
||||||
|
section ordering and compare pairing**, and **U6's bench listing**.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Extracted from `eshpfi` into its own repo
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**Extracted from `eshpfi` into its own repo.** The accreted
|
||||||
|
service came over whole, tests included, so `tests/test_booth.py` (1581 lines)
|
||||||
|
is the regression net the v1 rewrite is checked against.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Five mechanisms to get one question beside one artifact
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**Five separate mechanisms to get one question next to one
|
||||||
|
artifact** — `.forever`, the link board, `inline.py`'s placeholder DSL,
|
||||||
|
`wrap_verbatim_html`'s six regexes, and the floating amber asks chip plus
|
||||||
|
`/b/<n>/asks`. Every one is a *correct local fix* to the same global
|
||||||
|
mismatch, which is exactly why they accumulated without anyone making a bad
|
||||||
|
call. **The foot-gun is the sixth one:** the next "just add a small thing for
|
||||||
|
this case" reads as reasonable and is the pattern. The git log carries the
|
||||||
|
signature — every feature ships, then takes 2–5 patches for cases the single
|
||||||
|
shape did not anticipate. Check the ROADMAP gate before adding a mechanism.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# The `.forever` diagnosis is a falsifiable prediction
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**The `.forever` diagnosis is a stated, falsifiable
|
||||||
|
prediction.** U4 (derived lifetime) predicts the kept-rate falls to the
|
||||||
|
genuinely-durable booths. Re-measured today: **14 of 25 booths kept (56%)**,
|
||||||
|
against the 54% the IA doc recorded. **Re-count a fortnight after U4 lands.**
|
||||||
|
If it does not move, the diagnosis was wrong and the boolean was doing
|
||||||
|
something else. Tracked in the IA doc's Booth section and by this entry.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# The information architecture and the v1 gate landed
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**The information architecture and the v1 gate landed**
|
||||||
|
(`726822b`): `docs/design/information-architecture.md` names the single
|
||||||
|
defect — *one lifetime (24h from last touch) and one shape (a folder),
|
||||||
|
serving five jobs with different lifetimes and different shapes* — and
|
||||||
|
`ROADMAP.md` gates v1 on seven units, each closing a **measured** defect
|
||||||
|
rather than a wish. Both were written after a measurement pass over the live
|
||||||
|
service, and the measurements are the load-bearing part.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Letting Jinja hot-reload templates in the deployment root
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**Letting Jinja hot-reload templates while the repo is the
|
||||||
|
deployment root** — the cause of a live outage the same day U2 landed, and the
|
||||||
|
sharpest foot-gun in the repo. `booth.service` sets `WorkingDirectory` to this
|
||||||
|
repo, so the running service imports these files with no build step and no
|
||||||
|
staging copy. Python is read once at process start; Jinja's `FileSystemLoader`
|
||||||
|
re-reads a template **on every render**. Editing `booth.html` therefore
|
||||||
|
deployed it instantly against Python from 22:03 that knew nothing about
|
||||||
|
`item_marks`, and **19 of 25 live booths returned 500** with
|
||||||
|
`UndefinedError: 'item_marks' is undefined`. Neither the old code nor the new
|
||||||
|
code was broken — the service was running both at once.
|
||||||
|
**The lesson that generalises:** a skew between a process and the disk under it
|
||||||
|
is invisible to the test suite by construction, so no amount of green tests
|
||||||
|
would have caught it; the operator found it. Fixed at the source rather than
|
||||||
|
with a reminder — the `Environment` is hand-built with `auto_reload=False`, so
|
||||||
|
there is now ONE staleness rule (nothing takes effect until you restart) and
|
||||||
|
the running process is always a coherent snapshot of one commit. Asserted by
|
||||||
|
`test_templates_do_not_hot_reload_from_disk`. Watch the second-order risk the
|
||||||
|
fix introduces: a hand-built `Environment` does not inherit `autoescape` from
|
||||||
|
the `Jinja2Templates` constructor, and booth names, item names and mark text
|
||||||
|
are all agent-authored strings landing in HTML.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Letting the link board absorb the announce job
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**Letting the link board absorb the announce job.** `booth
|
||||||
|
link` is an `O_APPEND` write with no identity and no stated rule, so
|
||||||
|
re-announcing a bench appends a row instead of updating one, and a booth URL
|
||||||
|
rots the moment its booth is swept — **145 of 211 rows (69%) pointed at
|
||||||
|
nothing**, and 22 were the same target re-posted (talk 5×, peedlar 4×). The
|
||||||
|
rot is **structural, not drift**. The lesson that cost the most: enforcing
|
||||||
|
the link rule without first giving the announce job a home (`.booth.json`
|
||||||
|
provenance on the index, U5) just makes it homeless.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Marks are one `.marks.json` per booth
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**Marks are stored as one `.marks.json` per booth**, atomic
|
||||||
|
temp-file + `os.replace`, `fcntl` lock on the read-modify-write — operator
|
||||||
|
decision, this session. Two alternatives were weighed and lost: a sidecar
|
||||||
|
per item (`<rel>.marks.json`) and extending the existing `<stem>.ask.json`
|
||||||
|
shape. Rationale, and the reason it is not `links.md`-shaped: **(a)** U4
|
||||||
|
makes *"does this booth owe an answer?"* a hot question — the sweep asks it
|
||||||
|
per booth per tick and the index asks it per card per page load, so per-item
|
||||||
|
sidecars turn it into a full walk of all 25 booths, one of which holds 270
|
||||||
|
files; **(b)** `links.md` is an `O_APPEND` content-hash log because **17
|
||||||
|
agent handles write it concurrently**, whereas marks have exactly one writer
|
||||||
|
(the operator, in one browser) and many readers — a different problem that
|
||||||
|
must not inherit the append-log design; **(c)** `.blurred` / `.pins` /
|
||||||
|
`.forever` already establish the per-booth dotfile as the house shape for
|
||||||
|
operator state, and `booth_items()`'s dotfile skip means it costs nothing in
|
||||||
|
counts, galleries or zips. Accepted cost: a corrupt `.marks.json` loses that
|
||||||
|
booth's marks rather than one item's. Implementation deferred to U2 —
|
||||||
|
tracked at `ROADMAP.md` U2 and by this entry.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# A write over a damaged `.marks.json` wiped the booth
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**A write over a damaged `.marks.json` was wiping every mark in
|
||||||
|
the booth.** Shipped in `v0.2.0`, found by the panel (Kimi, converged with
|
||||||
|
Hulda), fixed in `v0.2.1`. `marks_for` is deliberately lenient — unparseable
|
||||||
|
reads as `[]` so a review page still loads — and the write path inherited that
|
||||||
|
leniency through the same reader, so one flag click appended to an empty list and
|
||||||
|
atomically replaced the file. The fix is an **asymmetry**, which is the reusable
|
||||||
|
part: reads stay lenient, writes go strict (`MarksCorrupt`), damaged bytes stay
|
||||||
|
on disk, routes answer 409 not 500. A page that renders without an annotation is
|
||||||
|
recoverable; a file that overwrote the operator's judgment is not. Kimi also
|
||||||
|
named the class correctly — "an author steeped in the design conversation would
|
||||||
|
likely read past" it — and that was accurate.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# A partially-answered pick counts as OPEN
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**A partially-answered pick now counts as OPEN** — declared, not
|
||||||
|
smuggled. The old index badge tested `answer is None`, so a half-answered
|
||||||
|
four-question ask read as closed on the index while the panel beside it
|
||||||
|
rendered `◐ partial`: the two disagreed about the same booth. Open is the
|
||||||
|
reading that makes U4 correct — a lifetime rule that unpinned a booth on the
|
||||||
|
first radio click would sweep a review in flight.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Regex-injecting chrome into arbitrary author HTML
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**Regex-injecting chrome into arbitrary author HTML**
|
||||||
|
(`wrap_verbatim_html` + `_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`,
|
||||||
|
`_BODY_CLOSE_RE`, `_HTML_CLOSE_RE`, `_ICON_RE`, and the doctype/charset
|
||||||
|
ordering constraints they thread). It works today and is **still live** —
|
||||||
|
but it is the single most fragile thing in the service and it is load-bearing
|
||||||
|
for the operator's most important workflow. Slated for deletion at U3 in
|
||||||
|
favour of a declared seam (`/_booth/embed.js`, mounted through a real DOM
|
||||||
|
API), which costs an author one line and removes the whole class. Do not
|
||||||
|
extend the regex set in the meantime; if a verbatim page breaks, that is an
|
||||||
|
argument for U3, not for a seventh pattern.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# `sindra-finalists` is U2's flag motivation, caught live
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**`sindra-finalists` is U2's `flag` motivation caught in the
|
||||||
|
act** — 86 items, every one captioned, and the booth's entire name is "the
|
||||||
|
ones the operator picked." That loop currently runs through chat, which is
|
||||||
|
the defect `flag` closes. Evidence, not argument.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Tagging a release while a review gate was in flight
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**Tagging a release while a review gate was still in flight.**
|
||||||
|
`v0.2.0` was cut and announced to 15 consuming handles; the
|
||||||
|
`/heid-contract-review` panel — dispatched BEFORE implementation, as the
|
||||||
|
discipline says — replied afterwards with three defects in the code that had just
|
||||||
|
shipped, one of them silent data loss. Nothing about the tier decision was wrong;
|
||||||
|
the *timing* was. **If a gate is outstanding on the work being released, the tag
|
||||||
|
waits for it.** The cost was a same-hour `v0.2.1` and a correction note to peers
|
||||||
|
who had already verified against the broken version.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Letting the write path share the read path's leniency
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**Letting the write path share the read path's leniency.** See the
|
||||||
|
`MarksCorrupt` decision above. The general shape, worth carrying beyond marks:
|
||||||
|
a tolerant reader and a tolerant writer over the same state are not the same
|
||||||
|
decision, and pointing both at one function silently makes them one. Tolerate on
|
||||||
|
read so the surface still renders; refuse on write so nothing is destroyed.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Seam review and cold panel had zero overlap, twice
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**The two review gates are complementary, measured on one unit.**
|
||||||
|
The caller-side **seam review** (nine findings, against the real sibling module
|
||||||
|
surfaces) and the cold **`/heid-contract-review` panel** (four arms,
|
||||||
|
artifact-only) had **zero overlap in both directions** on U2. The seam review
|
||||||
|
found a scope miss the panel structurally could not see: the contract omitted
|
||||||
|
`inline.py`, whose `place()` indexes by subscript, which a frozen dataclass
|
||||||
|
refuses. The panel found three code defects and a missing test the seam review
|
||||||
|
had no lens for. Matches heid's kvasir zero-overlap result on the
|
||||||
|
conformance-versus-hunt axis. **Run both; neither substitutes.**
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# U2 (marks) landed — one primitive for three mechanisms
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**U2 (marks) landed.** One primitive replacing three
|
||||||
|
mechanisms. `pick` / `note` / `flag` in one `.marks.json` per booth, one read
|
||||||
|
path (`marks_for`), one openness predicate (`open_marks`), rendered beside the
|
||||||
|
artifact on the tile, at full size in the zoom, and in the panel. `flag` and
|
||||||
|
`note` had no write path at all before this — the selection loop
|
||||||
|
(`golden-candidates`, `sindra-finalists`, the `pancake-*` ladders) was running
|
||||||
|
through chat. 242 tests. Details worth carrying: `asks.py` kept `normalize_ask`
|
||||||
|
and gained `build_answer` (the 2026-09-09 partial-answer semantics preserved by
|
||||||
|
moving, not rewriting) and LOST its five sidecar-storage functions;
|
||||||
|
`GET /b/<n>/marks.json` was added because remote sessions polled
|
||||||
|
`<stem>.answer.json` over HTTP and the sidecar's removal would have taken that
|
||||||
|
capability with it; `/b/<n>/asks` 308s to `/marks`.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# The U2 seam review earned its place, and how
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**The U2 seam review earned its place, and the record should
|
||||||
|
say how.** Nine findings against the real `booth.asks` / `booth.items` /
|
||||||
|
`booth.inline` surfaces, two of which changed scope or behaviour: `inline.py`
|
||||||
|
was missing from `touches` entirely (its `place()` indexes asks by
|
||||||
|
**subscript**, which a frozen dataclass refuses — nothing else in the service
|
||||||
|
does that), and the partial-answer inconsistency above. The cold
|
||||||
|
`/heid-contract-review` pass is artifact-only by design and structurally
|
||||||
|
cannot see a sibling module, so neither it nor a same-model self-review would
|
||||||
|
have found either. Two more surfaced later and are worth the same note: a
|
||||||
|
SECOND subscript in `inline.place` the seam review undercounted, and a
|
||||||
|
regression in my own legacy importer that a retargeted test caught — a
|
||||||
|
malformed sidecar that renders `⚠ broken` today would have silently vanished
|
||||||
|
on migration.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# U7's section premise is half wrong
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**U7's section premise is half wrong, and it is the half that
|
||||||
|
matters** — found by re-measuring `~/booth-data` rather than trusting the IA
|
||||||
|
doc. The IA says sections come from subfolders that already exist on disk;
|
||||||
|
true, but **every booth that actually needs navigation is flat**:
|
||||||
|
`pancake-v3-full` (270 items, 0 subfolders), `pancake-v4-full` (270, 0),
|
||||||
|
`sindra20-engines` (98 items + 99 caption sidecars, 0), `sindra-finalists`
|
||||||
|
(86 + 87, 0). Subfolders exist on exactly two booths — `pewpew-ui-brief` (7,
|
||||||
|
nested to `_ds/powerpellet-design-system-<uuid>/preview`) and `dfa-concepts`
|
||||||
|
(1) — and **both are reports**, the job where grid navigation matters least.
|
||||||
|
So sections stay worth shipping and `Item.section` stays right, but they are
|
||||||
|
**not** "most of the navigation fix": the rail, the filters and grid keyboard
|
||||||
|
are all of it. Worth noting for whoever writes U7: `sindra20-engines` encodes
|
||||||
|
its structure in the **filename prefix** (`b2-s1-<subject>-<seed>`), which is
|
||||||
|
where a grouping heuristic would actually pay. The IA doc's claim about what
|
||||||
|
sections buy needs a line struck — not yet edited.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# v0.2.0 was tagged while a gate was in flight
|
||||||
|
|
||||||
|
_2026-09-21 · booth_
|
||||||
|
|
||||||
|
**v0.2.0 cut and announced; v0.2.1 fixed what the announcement
|
||||||
|
was already wrong about.** Operator approved the minor (a v1 unit closed plus a
|
||||||
|
CLI surface change for 17 consuming handles clears the release-note bar). The
|
||||||
|
note went to 15 handles — the 17 link-board posters minus `nh3-dev`, a host
|
||||||
|
label, and `heid`, an oracle that does not script these verbs. Then the
|
||||||
|
cross-frontier contract panel landed and found **three defects in the code I had
|
||||||
|
just released**, so `v0.2.1` shipped within the hour. Sequence worth remembering:
|
||||||
|
the release was correct by the tier bar and still premature by the discipline —
|
||||||
|
the panel had been dispatched BEFORE implementation and its reply arrived AFTER
|
||||||
|
the tag. **If a gate is in flight, the tag can wait for it.**
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# A wrong-shaped answer 500s the gallery and the marks page — CLOSED 2026-09-22
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**Found by the U3 bug-hunt panel, measured against `42ea67f` — the commit
|
||||||
|
BEFORE U3 — so it is not this unit's doing and was not fixed by it.** U3's own
|
||||||
|
surface is guarded; these two are not.
|
||||||
|
|
||||||
|
## The defect
|
||||||
|
|
||||||
|
`.marks.json` that is **well-formed JSON with a wrong-shaped value** passes
|
||||||
|
every reader and then raises in the renderer:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id": "batch", "shape": "pick", "answer": {"answers": [], "notes": ""}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`_hydrate` only checks `isinstance(entry.get("answer"), dict)` — it never
|
||||||
|
validates `answer["answers"]`. So `marks_for` and `hold_read` both return the
|
||||||
|
mark with `error = None` and **no read error at all**, and then
|
||||||
|
`_ask_inline.html` does `a.answer.answers.get(q.key)`, Jinja asks a list for
|
||||||
|
`.get`, and it raises `UndefinedError`.
|
||||||
|
|
||||||
|
Measured, not reasoned:
|
||||||
|
|
||||||
|
PRE-U3 (42ea67f) gallery page: 500
|
||||||
|
PRE-U3 (42ea67f) marks page: 500
|
||||||
|
PRE-U3 (42ea67f) index: 200
|
||||||
|
|
||||||
|
The index survives because it never renders a fragment.
|
||||||
|
|
||||||
|
## Why it matters more than it looks
|
||||||
|
|
||||||
|
This is **the v0.2.2 shape with a different trigger**. That outage was a
|
||||||
|
`.marks.json` that could not be PARSED; the reader was made lenient and the
|
||||||
|
index stopped 500ing. This one parses perfectly and breaks one layer further in,
|
||||||
|
at render time, where no leniency exists — so the lesson "one damaged file must
|
||||||
|
cost its own tile, not the page" is only half-implemented. `read_error` is
|
||||||
|
answering a narrower question than every caller assumes.
|
||||||
|
|
||||||
|
## What U3 did and did not do
|
||||||
|
|
||||||
|
U3 added `_safe_fragments` around `_pick_fragments`, so `/b/<name>/embed.json`
|
||||||
|
returns a per-mark `error` record instead of a 500 — the same posture
|
||||||
|
`_hydrate_safe` takes one layer down. That protects **the verbatim path only**.
|
||||||
|
|
||||||
|
`booth.html` and `marks.html` call the same macros with no such guard. Left
|
||||||
|
alone deliberately: the gallery is named out of scope in the U3 contract, and
|
||||||
|
widening a unit mid-flight to cover a pre-existing defect in a surface it never
|
||||||
|
touched is the scope drift the roadmap gate exists to stop.
|
||||||
|
|
||||||
|
## The design question it deserves, when it is picked up
|
||||||
|
|
||||||
|
Not "wrap the other two call sites" — that is the third copy of one guard. The
|
||||||
|
real question is **where the boundary belongs**:
|
||||||
|
|
||||||
|
1. **In `_hydrate`**, validating the answer shape so a wrong-shaped answer
|
||||||
|
becomes `error` at hydration and every surface inherits the fix. Cleanest,
|
||||||
|
and consistent with declarations already being normalized on read — but it
|
||||||
|
widens what `error` means.
|
||||||
|
2. **At each render site**, per-mark, as U3 did. Honest and local; three copies.
|
||||||
|
3. **In the template**, defensively. Cheapest and worst — it hides the fact
|
||||||
|
that anything is wrong.
|
||||||
|
|
||||||
|
(1) is the shape the rest of this module already argues for: one predicate,
|
||||||
|
one place. Worth an operator decision because it changes what a `Mark` can be.
|
||||||
|
|
||||||
|
⚠ Reproduce with the fixture in
|
||||||
|
`tests/test_embed.py::test_a_wrongly_shaped_answer_costs_its_pick_not_the_report`,
|
||||||
|
whose closing comment points back here.
|
||||||
|
|
||||||
|
Related: [[2026-09-21-marks-write-wiped-judgment]],
|
||||||
|
[[2026-09-22-lenient-reader-blast-radius]],
|
||||||
|
[[2026-09-22-u3-declared-embed-seam-landed]].
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLOSED — 2026-09-22, after U6, at option (1)
|
||||||
|
|
||||||
|
Fixed in `_hydrate`, the option this entry argued for: **one predicate, one
|
||||||
|
place, every surface inherits it.** The operator was asked three times where the
|
||||||
|
guard belonged and did not answer; the placement was taken under the stated
|
||||||
|
assumption, and it is cheap to move if he disagrees — the whole fix is one
|
||||||
|
condition in one function.
|
||||||
|
|
||||||
|
**Only the MULTI case is checked**, because only the multi case indexes: a
|
||||||
|
single-question pick's answer IS the record, with no `answers` key to get wrong.
|
||||||
|
Requiring one unconditionally would break every single pick — the direction a
|
||||||
|
too-eager guard fails in, and it has its own test.
|
||||||
|
|
||||||
|
Measured before and after, on the gallery booth (no `index.html`):
|
||||||
|
|
||||||
|
before /b/g/ 500 /b/g/marks 500 / 200 /healthz 200
|
||||||
|
after /b/g/ 200 /b/g/marks 200 / 200 /healthz 200
|
||||||
|
and the error is VISIBLE on the page, and the booth's
|
||||||
|
OTHER, healthy pick still renders
|
||||||
|
|
||||||
|
**Two things fell out of it that are worth more than the fix.**
|
||||||
|
|
||||||
|
1. **`_safe_fragments` lost its natural trigger.** Probed every wrong answer
|
||||||
|
shape reachable from a `.marks.json`: `answers` as a list, a string or null
|
||||||
|
all become hydration errors now, and a wrong-typed VALUE inside `answers`
|
||||||
|
renders without raising because Jinja absorbs attribute access on a
|
||||||
|
non-mapping. So U3's guard is now a pure backstop with **no reachable
|
||||||
|
natural input**. Its test was rewritten to a synthetic trigger that says so —
|
||||||
|
patching the shared macro module through `app.state.templates` — rather than
|
||||||
|
left asserting a path nothing reaches. An untested guard and a guard tested
|
||||||
|
by an unreachable input are the same thing.
|
||||||
|
|
||||||
|
2. **The guard's own handler could not survive the failure it was handling.**
|
||||||
|
Building that falsifier tripped it: `_safe_fragments` caught a raising
|
||||||
|
`_pick_fragments` and then rebuilt the broken-ask box **through the same
|
||||||
|
macro module that had just raised**, so when `whole` itself was broken the
|
||||||
|
handler re-raised and took the whole report. Fixed, with its own test. Found
|
||||||
|
by accident, which is the usual way.
|
||||||
|
|
||||||
|
Both new falsifiers were **verified RED against their defeating change** rather
|
||||||
|
than assumed — the discipline from [[2026-09-22-vacuous-falsifiers]], applied to
|
||||||
|
the fix for the entry that names it.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# `booth marks` / `booth answer` got real exit codes
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**`booth marks` / `booth answer` got real exit codes**, because
|
||||||
|
a read that CRASHED was indistinguishable from a read that said no. `marks`
|
||||||
|
printed a traceback and exited 0 (a caller's `jq` saw success and got
|
||||||
|
nothing); `answer --wait` read a damaged file as "not yet" and spun for the
|
||||||
|
full hour before blaming the operator. Now `0 ok · 1 unanswered/timed-out ·
|
||||||
|
2 no such pick · 3 unreadable`, and `read_error()` was added to `marks.py` so
|
||||||
|
the CLI can ask the question the browser must not: the page stays lenient, the
|
||||||
|
machine consumer gets the truth. Also `--wait` now prints ONCE — it was
|
||||||
|
emitting a whole JSON document per poll, so a captured `--wait` held several
|
||||||
|
concatenated values and parsed as none of them.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# An existing test stopped me retiring documented behaviour
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**An existing test stopped me retiring documented behaviour
|
||||||
|
while fixing a race.** The mtime-restore race is real, and the clean fix —
|
||||||
|
ignoring a booth directory's own mtime whenever the booth holds anything —
|
||||||
|
would also have silently retired the rule that RELEASING a kept board resets
|
||||||
|
its clock, which the CLI header, the README and a deliberately-written test
|
||||||
|
all pin. That is a TTL doctrine change, not a bug fix. Fixed the concrete half
|
||||||
|
(a failing `os.utime` used to escape and 500 the route), left the race stated
|
||||||
|
in the code. **A fix that changes a documented rule is a proposal, not a
|
||||||
|
patch.**
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# The `.forever` diagnosis got a live positive control
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
The U4 diagnosis was that `.forever` is the only way to say three different
|
||||||
|
things — "this is durable", "I have not answered yet", "I am still looking" —
|
||||||
|
and that only the first is what keep means. That was an argument. **On
|
||||||
|
2026-09-22 it stopped being one.**
|
||||||
|
|
||||||
|
Census of `~/booth-data`, whole population, every value a deterministic file
|
||||||
|
fact:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| live booths | 24 |
|
||||||
|
| carrying `.forever` | 17 (70%, up from 54% on 2026-09-21) |
|
||||||
|
| carrying `.marks.json` at all | 4 |
|
||||||
|
| of those, with an open pick | **4 of 4** |
|
||||||
|
| **open pick AND `.forever`** | **3** |
|
||||||
|
|
||||||
|
**Three of the four booths in the entire fleet that were waiting on an answer
|
||||||
|
had also been pinned by hand.** That is the "not yet" case caught in the act,
|
||||||
|
not inferred from a rate.
|
||||||
|
|
||||||
|
The staleness distribution says it from the other side: **10 of the 17 kept
|
||||||
|
booths were under one day old** — younger than the TTL, so the sentinel had
|
||||||
|
bought them nothing and was pressed pre-emptively. Only 4 were old enough
|
||||||
|
(2.4-4.6 days) that keep is the reason they still existed.
|
||||||
|
|
||||||
|
⚠ **A number I got wrong, caught by a cross-frontier arm, kept here because the
|
||||||
|
class repeats.** The contract first said "12 are under 1.5 days old — younger
|
||||||
|
than the TTL". The TTL is 24 hours. 1.5 days is not younger than 24 hours. The
|
||||||
|
measurement was sound and the sentence was not; the claim only holds at the
|
||||||
|
one-day line, where it is 10 rather than 12. Nobody on the Claude side caught
|
||||||
|
it, including the author twice.
|
||||||
|
|
||||||
|
⚠ **The hold's live blast radius is SMALL** — only 4 booths have marks at all —
|
||||||
|
so the `.forever` re-count prediction rests on BOTH halves of U4 and on the
|
||||||
|
sentinel becoming unnecessary rather than forbidden. **RE-COUNT A FORTNIGHT
|
||||||
|
AFTER U4 LANDS**, i.e. on or after **2026-10-06**. If the rate does not move,
|
||||||
|
the honest readings are "the diagnosis was wrong" OR "the habit outlived the
|
||||||
|
need", and a bare re-count cannot tell those apart. **The three
|
||||||
|
open-pick-plus-`.forever` booths are the ones to watch**, because for them the
|
||||||
|
mechanism is now unambiguous.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Four independent paths to one fail-open delete
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
The U4 bug-hunt panel declared invariant was **"a deletion decision must never
|
||||||
|
be made from a read that failed"**. The panel found **four independent paths
|
||||||
|
through it, and no single arm found all four.** That is the strongest argument
|
||||||
|
yet for running the panel rather than one arm.
|
||||||
|
|
||||||
|
1. **An entry-level hydration error lost its hold** (the round's best finding).
|
||||||
|
`.marks.json` parses; one mark fails normalization; `_hydrate_safe` returns a
|
||||||
|
`Mark` carrying `error`; `_is_open` returns False for an errored pick — **on
|
||||||
|
purpose**, because a broken pick can never be answered. So the booth read as
|
||||||
|
not-held and **swept**, while the panel beside it rendered the broken mark in
|
||||||
|
full. The fail-safe had been built for FILE-level damage and missed
|
||||||
|
ENTRY-level. A mark we cannot read is judgment we cannot see; deleting the
|
||||||
|
booth it belongs to is the one thing we must not do with it.
|
||||||
|
2. **A present-but-blank `.marks.json` swept.** `_read_raw_strict` early-returns
|
||||||
|
for whitespace-only content — correct for the WRITE path it was written for
|
||||||
|
(a blank file is safe to overwrite), wrong for the DELETE path. Fixed with a
|
||||||
|
`blank_is_corrupt=True` flag used only by `hold_read`. ⚠ The near-regression
|
||||||
|
worth remembering: a **valid document with an empty `marks` list** is what
|
||||||
|
deleting the last mark leaves behind, and holding on THAT would make every
|
||||||
|
finished booth immortal. Blank bytes are damage; an empty list is an answer.
|
||||||
|
3. **`_newest_mtime` returned 0.0 when the booth's own stat failed**, which made
|
||||||
|
it maximally ancient and therefore the FIRST thing the sweeper takes — a
|
||||||
|
permissions problem resolving to a deletion. Now returns `now`: not knowing a
|
||||||
|
booth's age is a reason to leave it alone. ⚠ Per-entry `FileNotFoundError`
|
||||||
|
stays a skip, because a dangling symlink raises it and has no mtime worth
|
||||||
|
counting; only OTHER stat errors mean "something is here we cannot read".
|
||||||
|
4. **`is_kept` collapsed a stat failure into not-kept.** `Path.exists()` maps
|
||||||
|
ELOOP and EACCES to False. Now `lstat`, with any non-ENOENT error reading as
|
||||||
|
KEPT, and a `.forever` symlink counting dangling or not.
|
||||||
|
|
||||||
|
**`is_held` was replaced by `hold_reason`, which returns the REASON** —
|
||||||
|
`"open"`, `"unreadable"`, or None — rather than a bool beside a separate error
|
||||||
|
string. Two representations of one state drift; Regin independently flagged that
|
||||||
|
the display could not tell the two holds apart. One value, read by the sweeper
|
||||||
|
and by all four rendering surfaces.
|
||||||
|
|
||||||
|
**Convergent 3-of-4, and the one with teeth beyond lifetime:** `record_view`
|
||||||
|
used `Path.touch()`, which FOLLOWS an existing symlink. A booth carrying a
|
||||||
|
planted `.viewed -> /anywhere` turned every page view into an mtime write at an
|
||||||
|
arbitrary path under the service uid — and **any fleet session can write into a
|
||||||
|
booth, because making a folder is the whole API.** Now `os.open(..., O_NOFOLLOW)`
|
||||||
|
plus `os.utime(fd)`; a planted link raises ELOOP into the existing swallow.
|
||||||
|
|
||||||
|
⚠ **THE CAPTURE TOOLING FAILED SILENTLY AND THE PEER CAUGHT IT, NOT US.** The
|
||||||
|
snapshot `files/` tree shipped to the arms was EMPTY. The loop was
|
||||||
|
`for f in $IN` over a multi-line variable — and **zsh does not word-split
|
||||||
|
unquoted parameter expansions the way bash does**, so it iterated once against a
|
||||||
|
path that was the entire list. jekyll recovered by re-applying the bundled diff
|
||||||
|
to HEAD and verified every file byte-identical, so the round was sound. **The
|
||||||
|
failure mode is the dangerous one: an empty bundle reads exactly like a clean
|
||||||
|
result.** Quote-and-split explicitly (`print -r -- $IN | while read f`) or build
|
||||||
|
the list as a real array. Same family as `[[2026-09-22-vacuous-falsifiers]]` —
|
||||||
|
an instrument that cannot fail loudly will fail quietly.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# The lenient reader's blast radius was the whole service
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**The lenient reader's blast radius was the whole service, not
|
||||||
|
one booth.** `_clean_text` did `(text or "").replace(...)` and `marks_for`
|
||||||
|
sorts on `(created, id)`, so a stored `text` that was a dict or a `created`
|
||||||
|
that was a number raised out of the READ path — and `list_booths` reads every
|
||||||
|
booth's marks on every index load. One hand-edited file 500'd `/` and
|
||||||
|
`/healthz` for all 25 booths. Fixed in two layers, matching the house posture:
|
||||||
|
a named type check (`_entry_type_error`) plus a `_hydrate_safe` backstop that
|
||||||
|
cannot raise, and the panel now RENDERS an unreadable mark as ⚠ broken instead
|
||||||
|
of as an empty note. **The general shape: a lenient reader is only lenient if
|
||||||
|
the leniency is bounded by where it runs.** `marks_for` was written for one
|
||||||
|
booth's page and is called in a loop over every booth.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# No fleetwide notice for U4 — and what that does to the prediction
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**Operator decision, 2026-09-22: do NOT tell the 17 consuming handles that
|
||||||
|
`keep` has stopped being the way to say "waiting on an answer".** No broadcast.
|
||||||
|
Same posture he took on U5, and the same instrument: adoption gets told apart
|
||||||
|
from design because nobody was primed.
|
||||||
|
|
||||||
|
⚠ **THIS CHANGES HOW THE 2026-10-06 RE-COUNT MUST BE READ, and a session that
|
||||||
|
misses this will draw the wrong conclusion from a true number.**
|
||||||
|
|
||||||
|
U4 has two halves and they do NOT have the same adoption cost:
|
||||||
|
|
||||||
|
- **The hold rides for free.** A session that runs `booth ask` gets its booth
|
||||||
|
held with no knowledge of anything. The operator answering releases it. No
|
||||||
|
peer has to learn a thing for the mechanism to work.
|
||||||
|
- **NOT PRESSING `keep` HAS TO BE LEARNED.** U4 makes the sentinel unnecessary
|
||||||
|
for the "not yet" case; it does not make it unavailable, and nothing stops a
|
||||||
|
habit. An agent that has always pressed `keep` while waiting will keep
|
||||||
|
pressing it.
|
||||||
|
|
||||||
|
**So a flat `.forever` rate on 2026-10-06 does NOT falsify the diagnosis.** It
|
||||||
|
is exactly what "the mechanism works and nobody was told" looks like — the U5
|
||||||
|
shape, one unit later. Reporting a null result without this caveat would retire
|
||||||
|
a correct diagnosis on the strength of an uncontrolled measurement.
|
||||||
|
|
||||||
|
**Use these instead, and report all three.** The raw rate stays as context, not
|
||||||
|
as the verdict:
|
||||||
|
|
||||||
|
1. **The overlap — booths with an open pick AND `.forever`.** Was **3** on
|
||||||
|
2026-09-22, which is the positive control for the whole diagnosis. It falls
|
||||||
|
only if peers learn; it is the *adoption* number.
|
||||||
|
|
||||||
|
comm -12 <(grep -l '"shape": "pick"' ~/booth-data/*/.marks.json | xargs -n1 dirname | sort) \
|
||||||
|
<(dirname ~/booth-data/*/.forever | sort) | wc -l
|
||||||
|
|
||||||
|
2. **Did the hold ever bind?** Count booths that were held past their TTL and
|
||||||
|
therefore survived a sweep they would otherwise have lost. This needs no
|
||||||
|
peer to change anything, so it is the honest test of whether the mechanism
|
||||||
|
is load-bearing at all. **If it is ZERO over a fortnight, the diagnosis was
|
||||||
|
wrong about VOLUME** — the "not yet" case is rarer than the sentinel rate
|
||||||
|
suggested — and that is a real finding. The sweeper logs what it wipes;
|
||||||
|
nothing yet logs what it spares, so **this counter does not exist and would
|
||||||
|
have to be added before it can be read.** Say so rather than guessing.
|
||||||
|
3. **The raw `.forever` rate** — 17 of 24 (70%) on 2026-09-22. Context only,
|
||||||
|
now that the no-notice decision has made it a measurement of habit rather
|
||||||
|
than of need.
|
||||||
|
|
||||||
|
⚠ **Sensitivity floor, stated because a bare "no effect" is unfalsifiable:**
|
||||||
|
only **4 of 24** booths carried marks at all on 2026-09-22. The hold cannot
|
||||||
|
bind on a booth with no marks, so at that population the mechanism can touch at
|
||||||
|
most a sixth of the fleet, and an effect smaller than one or two booths is not
|
||||||
|
resolvable by any of these counts. Related: `[[2026-09-22-forever-had-a-live-positive-control]]`.
|
||||||
@@ -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,11 @@
|
|||||||
|
# `scripts/booth` went from zero tests to five
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**`scripts/booth` had zero tests and now has five**
|
||||||
|
(`tests/test_cli.py`). The panel's guard-strength tables returned UNVERIFIED
|
||||||
|
for every CLI claim because nothing in the suite executed the script — two of
|
||||||
|
the round's findings lived in exactly that gap. The new tests run the real
|
||||||
|
script under the system `python3`, which makes them a live check on INV-1
|
||||||
|
(stdlib-only) as a side effect: a third-party import in `marks.py` now fails
|
||||||
|
in the suite the same way it would fail on a fleet host.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# A vacuity pass that tries the contract's own mutation agrees with itself
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
The contract-time **vacuity pass** — for each invariant, name a change that
|
||||||
|
defeats it and check the named test goes red — was proposed independently by
|
||||||
|
Regin and Kimi on U4's paraphrase round, and U4's own code-review panel then
|
||||||
|
showed **five of seven** U4 falsifiers were vacuous: a green test *cited* by an
|
||||||
|
`INV` rather than a test that would *fail* if the invariant broke. See
|
||||||
|
[[2026-09-22-vacuous-falsifiers]].
|
||||||
|
|
||||||
|
U3 ran the pass as a real instrument rather than a promise. Script in the
|
||||||
|
session scratchpad; for each invariant it applies the mutation the contract's
|
||||||
|
*Falsifiable:* line names, runs the single named test, and asserts a **non-zero**
|
||||||
|
exit, restoring the file in a `finally` either way.
|
||||||
|
|
||||||
|
| INV | mutation applied | verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 declaring page untouched | append `<!-- booth -->` to the declaring branch | FALSIFIED |
|
||||||
|
| 2 appended, never inserted | insert the tag before `<title>` instead | FALSIFIED |
|
||||||
|
| 3 no regex on author HTML | re-declare `_ICON_RE` in `app.py` | FALSIFIED |
|
||||||
|
| 4 openness is the server's | have `embed.js` derive open from `bk-done` | FALSIFIED |
|
||||||
|
| 5 embed.js read once | `read_text()` per request in the route | FALSIFIED |
|
||||||
|
| 6 tail in payload order | iterate the marks list backwards | FALSIFIED |
|
||||||
|
| 7 unplaced questions appended | short-circuit the append branch to `if (false)` | FALSIFIED |
|
||||||
|
|
||||||
|
**7/7**, and — the part that makes it a measurement rather than a ritual — an
|
||||||
|
**unmutated control run** confirming all seven named tests are green when
|
||||||
|
nothing is broken. Without that control, a script whose mutation silently failed
|
||||||
|
to apply (the text not found, the wrong file) reports the same clean-looking
|
||||||
|
table. The script halts with `MUTATION-MISS` if its target string is absent,
|
||||||
|
for exactly that reason.
|
||||||
|
|
||||||
|
## Why it is worth the ten minutes
|
||||||
|
|
||||||
|
Three of the seven falsifiers are in `embed.js`, which the Python suite cannot
|
||||||
|
see at all. INV-4, INV-6 and INV-7 are held **only** by browser tests, and
|
||||||
|
"there is a browser test named after this invariant" is precisely the kind of
|
||||||
|
claim that feels like coverage and can be empty. Two of those three mutations
|
||||||
|
are one-token edits — `marks.length - 1` and `if (false)` — so the cost of
|
||||||
|
checking was minutes and the cost of being wrong was an invariant nobody was
|
||||||
|
holding.
|
||||||
|
|
||||||
|
**The general shape:** an instrument that cannot fail loudly will fail quietly.
|
||||||
|
Same family as the `(gasp)` tag-detection specimen in the global measurement
|
||||||
|
rule, and as the zsh word-splitting bug that shipped an empty heid bundle —
|
||||||
|
[[2026-09-22-four-paths-to-one-fail-open-delete]]. A clean result and a broken
|
||||||
|
method are indistinguishable from the output alone unless something in the
|
||||||
|
method is designed to go red.
|
||||||
|
|
||||||
|
## ⚠ AND THEN THE COLD PANEL SHOWED ONE OF THE SEVEN WAS VACUOUS ANYWAY
|
||||||
|
|
||||||
|
The table above is real and it was **not sufficient**. The `/heid-contract-review`
|
||||||
|
panel (`01M351WKV666D681SSRNY7D7X6`) — three of four arms, independently —
|
||||||
|
showed **INV-3's falsifier was vacuous**, on this contract's central promise, and
|
||||||
|
the pass above had passed it.
|
||||||
|
|
||||||
|
**Why the pass missed it.** INV-3 claims *no regular expression is applied to
|
||||||
|
author HTML*. The test name-matched the six DELETED patterns. The mutation the
|
||||||
|
pass applied was re-declaring `_ICON_RE` — **the pattern the contract named** —
|
||||||
|
which the name-match caught. The mutation the invariant actually forbids is a
|
||||||
|
regex under a *new* name (`_TAIL_RE.sub(...)` in the verbatim branch), and that
|
||||||
|
sailed through green.
|
||||||
|
|
||||||
|
> **The mutation has to come from the INVARIANT'S CLAIM, not from the
|
||||||
|
> FALSIFIER'S EXAMPLE.** A pass that applies the contract's own suggested
|
||||||
|
> mutation is testing the contract against itself, and it will agree.
|
||||||
|
|
||||||
|
**Then the fix had a hole too, and only a re-run found it.** The repaired test
|
||||||
|
asserts `booth/app.py` performs exactly one regex operation. Re-running the pass
|
||||||
|
*against the fix* showed an aliased `import re as _r` routes around the call
|
||||||
|
check under a name it does not know — still VACUOUS. Closed with an import-shape
|
||||||
|
assertion. **Run the pass on the repair, not only on the draft.**
|
||||||
|
|
||||||
|
Final state: **10/10 falsifiable**, control green, the two extra rows being the
|
||||||
|
panel's own findings turned into falsifiers.
|
||||||
|
|
||||||
|
## What this is evidence for
|
||||||
|
|
||||||
|
U4 measured the problem (five of seven vacuous). U3 measured a pass working
|
||||||
|
(7/7), then measured **the pass's own blind spot**, then measured the fix's
|
||||||
|
blind spot. All three belong in the case if the vacuity-pass proposal is ever
|
||||||
|
put to the operator as a `/heid*` skill amendment — and the second and third
|
||||||
|
are the parts that stop it being adopted as a ritual that always passes.
|
||||||
|
|
||||||
|
Related: [[2026-09-22-u3-declared-embed-seam-landed]],
|
||||||
|
[[2026-09-22-the-browser-became-a-test-surface]].
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# The size cap opened a service-wide hang
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**The U5 bug-hunt panel found a service-wide hang that the
|
||||||
|
SIZE CAP ITSELF opened — two hours after I added the cap.** `stat` reports
|
||||||
|
size 0 for a FIFO and 0 for a symlink to `/dev/zero`, so both sail under a
|
||||||
|
byte cap and then `read_text` blocks with no EOF or allocates until the kernel
|
||||||
|
intervenes. `list_booths` reads every booth on every `GET /`, so ONE such file
|
||||||
|
stalls the front page for the whole service with no error and no recovery
|
||||||
|
short of a restart. Reproduced (`timeout` returned 124), fixed with an
|
||||||
|
`S_ISREG` check BEFORE the size check in both modules, verified live: the
|
||||||
|
index answered 200 in 36 ms with two FIFOs planted. **The reusable shape:
|
||||||
|
`st_size` answers a different question than "can this be read", and a bound
|
||||||
|
that trusts it inherits everything it does not mean — a hardening fix opened
|
||||||
|
a worse hole than the one it closed.** Also adopted: the upload path wrote the
|
||||||
|
manifest ABOVE its own cleanup guard (4/4), so a failure orphaned a half-booth
|
||||||
|
whose uniquely-named leaked temp then kept it alive forever; replace-over-
|
||||||
|
damaged destroyed recoverable bytes (4/4, now QUARANTINED rather than refused
|
||||||
|
— marks refuse because judgment is not restatable, a booth's description is);
|
||||||
|
and `booth answer` spelled out its own openness test, disagreeing with
|
||||||
|
`booth marks` about a partially-answered pick, which is a direct violation of
|
||||||
|
U2's INV-2. Full triage in `persistent-memory.d/2026-09-22-u5-panels.md`.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# The browser became a test surface, and the version bound is the foot-gun
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
U3 moved load-bearing logic out of Python and into JavaScript: which fragment
|
||||||
|
lands at which anchor, what gets appended, and whether a `<form>` scattered down
|
||||||
|
a report still owns the controls pointing at it. **The Python suite is blind to
|
||||||
|
every one of those.** Shipping U3 with only payload-shape tests would have
|
||||||
|
deleted ~10 real tests and replaced them with assertions that cannot see the
|
||||||
|
thing the operator actually depends on.
|
||||||
|
|
||||||
|
So `tests/test_embed_browser.py` drives a real Chromium against a real uvicorn
|
||||||
|
on an ephemeral port. 12 tests. It found nothing on the first run — but the
|
||||||
|
probe that preceded it settled a design question no amount of spec-reading
|
||||||
|
would have.
|
||||||
|
|
||||||
|
## The probe, and why it had controls
|
||||||
|
|
||||||
|
**Question:** if a control carrying `form="F"` is inserted into the DOM *before*
|
||||||
|
`<form id="F">` exists, does it become that form's control? The HTML spec resets
|
||||||
|
form owner on insertion and on the `form` attribute changing — it does NOT list
|
||||||
|
"a matching form was inserted later". The U3 design inserts fragments in visual
|
||||||
|
order, so this happens routinely.
|
||||||
|
|
||||||
|
Four conditions, N=3 each, in Chromium 151 headless:
|
||||||
|
|
||||||
|
| condition | `input.form?.id` |
|
||||||
|
|---|---|
|
||||||
|
| A — form inserted first (**positive control**) | `F, F, F` |
|
||||||
|
| B — control inserted first (**the question**) | `F, F, F` |
|
||||||
|
| C — `form="NOPE"`, no such form (**negative control**) | `null, null, null` |
|
||||||
|
| D — remove and re-set the attribute (the proposed fix) | `F, F, F` |
|
||||||
|
|
||||||
|
The positive control proves the instrument can see association at all; the
|
||||||
|
negative proves it is not manufacturing it. Without both, B's answer means
|
||||||
|
nothing — that is the whole lesson of
|
||||||
|
[[2026-09-22-vacuous-falsifiers]] applied before the code instead of after.
|
||||||
|
|
||||||
|
**The answer is: Chromium re-resolves it, so the fix is unnecessary there.**
|
||||||
|
The fix shipped anyway. **Sensitivity floor: ONE ENGINE.** The operator's own
|
||||||
|
browser was not measured, the failure mode is a form that looks filled in and
|
||||||
|
POSTs a 400, and the guard is three lines. The measurement says "not needed
|
||||||
|
here"; it does not say "not needed".
|
||||||
|
|
||||||
|
## The foot-gun, which bit before the tests were written
|
||||||
|
|
||||||
|
Browsers are **box-wide** in `/opt/ms-playwright` with
|
||||||
|
`PLAYWRIGHT_BROWSERS_PATH` wired globally — there is no per-project
|
||||||
|
`playwright install`. Each playwright release pins **one** Chromium revision, and
|
||||||
|
a release wanting a revision the shared store lacks dies with:
|
||||||
|
|
||||||
|
Executable doesn't exist at /opt/ms-playwright/chromium_headless_shell-1243/…
|
||||||
|
|
||||||
|
That is not a missing-dependency error and it does not name the real problem.
|
||||||
|
The store had 1223 / 1228 / 1234; `playwright` 1.63 wanted 1243. The mapping:
|
||||||
|
|
||||||
|
1.60 -> 1223 1.61 -> 1228 1.62 -> 1234 1.63 -> 1243
|
||||||
|
|
||||||
|
Hence `playwright>=1.60,<1.63` in `pyproject.toml`, **with the upper bound as the
|
||||||
|
point** and the reason in a comment beside it. A bare `playwright` would break
|
||||||
|
the suite on the next resolve, opaquely.
|
||||||
|
|
||||||
|
## The hermeticity trade, and how it is paid
|
||||||
|
|
||||||
|
A browser layer makes the suite non-hermetic — it can go red for an environment
|
||||||
|
reason. `tests/test_embed_browser.py` therefore **skips, never fails**, when
|
||||||
|
playwright or a usable browser is missing (`pytest.importorskip`, plus a
|
||||||
|
`pytest.skip` on any launch failure). `pytest -q` stays green anywhere; the
|
||||||
|
browser layer is purely additive.
|
||||||
|
|
||||||
|
⚠ **The failure mode of that choice: if those 12 tests start SKIPPING on this
|
||||||
|
box, U3's placement logic is untested and the suite still says green.** If the
|
||||||
|
count drops from 431, check the skip reason before anything else — the pinned
|
||||||
|
bound has probably drifted past the shared store.
|
||||||
|
|
||||||
|
Related: [[2026-09-22-u3-declared-embed-seam-landed]].
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# The third one-branch template miss — this repo's recurring blind spot
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**All four arms of the U4 code-review panel found the same drift, independently.**
|
||||||
|
That is the strongest convergence either panel has produced here.
|
||||||
|
|
||||||
|
The booth header's sub-line forks on `{% if board %}`, and the U4 lifetime macro
|
||||||
|
had been added only to the `{% else %}`. So **a booth carrying `links.md`
|
||||||
|
rendered a link count and nothing at all about its lifetime** — no countdown, no
|
||||||
|
hold — while INV-4 said the templates have no path that renders neither. The
|
||||||
|
standing board being kept by construction (`booth link` drops `.forever` on
|
||||||
|
first use) is what hid it; a **released** board or a hand-made `links.md` booth
|
||||||
|
is a live non-kept booth on that path, and both are reachable from the UI.
|
||||||
|
|
||||||
|
**This is the third of the same shape in this repo's short history:**
|
||||||
|
|
||||||
|
1. `blurtoggle` — the blur only patched the image/video `<figure>`; inline docs
|
||||||
|
render through their OWN branch and shipped unblurred. Suite green; a live
|
||||||
|
look caught it.
|
||||||
|
2. verbatim chrome — a verbatim booth's own `index.html` is served untouched, so
|
||||||
|
the inline marks panel never renders there. Found by looking at the live
|
||||||
|
service during U4, not by the suite.
|
||||||
|
3. the board branch — this one.
|
||||||
|
|
||||||
|
**The pattern: the suite renders the surface the author was thinking about.**
|
||||||
|
Every one of these was a second branch of a conditional the author had already
|
||||||
|
satisfied once and stopped reading. A cold reader with no idea which branch was
|
||||||
|
"the real one" finds them; the author does not, and neither does a test the
|
||||||
|
author wrote.
|
||||||
|
|
||||||
|
**Practical consequence for this repo.** When a template gains a fact, grep the
|
||||||
|
template for `{% if %}` in the block you edited and render EVERY branch in a
|
||||||
|
test — one test per branch, each rendering only its own surface, or the passing
|
||||||
|
test on branch A will mask the omission on branch B. U4 now has one per surface
|
||||||
|
(index card, booth header, board header, marks page) for exactly this reason.
|
||||||
|
|
||||||
|
Declined, and worth recording: Regin and Kimi both recommended amending INV-4 to
|
||||||
|
carve the board header out, on the grounds that board layout belongs to U7.
|
||||||
|
**Cutting an invariant down to fit an implementation gap is the wrong direction
|
||||||
|
when the fix is one template edit**, and U7 owns navigation and section layout —
|
||||||
|
not whether a header states a lifetime.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Three cold panels on one unit, and what each lens could only see alone
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
U6 ran all three `/heid*` gates plus two in-session passes. **Every one of the
|
||||||
|
five found something the others structurally could not**, which is the
|
||||||
|
strongest evidence this repo has for running them all rather than picking one.
|
||||||
|
|
||||||
|
## The scoreboard
|
||||||
|
|
||||||
|
| gate | when | found |
|
||||||
|
|---|---|---|
|
||||||
|
| **seam review** (in-session, sibling-aware) | before code | **3 real contract defects** — a claim about a sibling test that was false, `resolve_booth` named as a per-row predicate when it RAISES 404, and silence on percent-encoding |
|
||||||
|
| **adversarial self-pass** (in-session) | during | **4 defects** — a FIFO hang, `unquote` leaking control characters, a fail-closed-by-accident guard, a stranded scratch file |
|
||||||
|
| **`/heid-contract-review`** (4 arms) | parallel | **the import/apply selection gap, 4-of-4** — plus per-field cap semantics, and two passages of the document contradicting each other |
|
||||||
|
| **`/heid-code-review`** (4 arms) | parallel | **3 surface-drift findings 4-of-4**, an IPv6 identity bug, and **a falsifier that could not fail** |
|
||||||
|
| **`/heid-bug-hunt`** (4 arms) | parallel | a `<div>` inside a `<span>`, a symlink disagreement, an append outside its lock |
|
||||||
|
|
||||||
|
## The three findings worth remembering
|
||||||
|
|
||||||
|
**1. The highest-value finding was a MISSING FEATURE, and the paraphrase lens
|
||||||
|
found it.** `bench import --apply` registered every candidate while the same
|
||||||
|
contract said ~14 of 35 were bookmarks that must stay on the board. The dry-run
|
||||||
|
report existed *because* the decision is not mechanizable — and then `--apply`
|
||||||
|
ignored it. A code-vs-contract lens cannot see this: the code matched the
|
||||||
|
contract. Only reading the contract *as prose*, for what it promises a human,
|
||||||
|
surfaces "these two sentences cannot both be satisfied."
|
||||||
|
|
||||||
|
**2. A falsifier that could not fail, again.** INV-4's tie-break test went
|
||||||
|
through the registry, and `_write_all` serializes with `sort_keys=True` — so
|
||||||
|
both insertion orders came back off disk already id-sorted, and removing the
|
||||||
|
tie-break left the test green. Same class as the five vacuous U4 falsifiers.
|
||||||
|
**We ran a vacuity pass and still shipped one**; a cold reader caught it. See
|
||||||
|
[[2026-09-22-vacuous-falsifiers]].
|
||||||
|
|
||||||
|
**3. The single sharpest line came from a cross-module memory no new-module
|
||||||
|
review could have.** Three bug-hunt arms independently noted that **this repo
|
||||||
|
had already paid for the `RecursionError` class in `marks.py`, with a test
|
||||||
|
documenting it — and the new module re-introduced the unguarded parse.** No
|
||||||
|
amount of reading `benches.py` in isolation surfaces that.
|
||||||
|
|
||||||
|
## Complementarity, measured in both directions on one diff
|
||||||
|
|
||||||
|
The bug-hunt panel found **three live defects the in-session pass missed** — all
|
||||||
|
three invisible to any test (a layout nesting, a symlink disagreement, a
|
||||||
|
lock-ordering race). The in-session pass had **already closed three of that
|
||||||
|
panel's four convergent findings** before the reply landed. Neither substitutes
|
||||||
|
for the other, and this round is the cleanest specimen of it so far.
|
||||||
|
|
||||||
|
**One finding was declined**, with reasoning recorded in the contract: on a host
|
||||||
|
where `booth.links` cannot be imported, `booth link` now refuses every URL
|
||||||
|
rather than only booth ones. A guard that fails open is not a guard, and that
|
||||||
|
state is a broken install where most of the CLI is equally broken.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Two reads of one file are not one read of one state
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**The one finding across both U4 panels that changed code rather than prose,
|
||||||
|
and it came from Hulda (Codex) on the CONTRACT-paraphrase round — before any
|
||||||
|
code existed.**
|
||||||
|
|
||||||
|
The contract specified the hold check as:
|
||||||
|
|
||||||
|
is_held(marks_for(child), read_error(child))
|
||||||
|
|
||||||
|
Two reads of `.marks.json`, presented as one answer. They are not. A write or a
|
||||||
|
repair landing between them yields a pair that described the booth at **no
|
||||||
|
instant**, and the losing pair is `([], None)` — no marks, no error — which is
|
||||||
|
**exactly the pair that deletes**. A lenient reader plus a strict reader, each
|
||||||
|
correct on its own, compose into a fail-open delete.
|
||||||
|
|
||||||
|
The fix is `booth.marks.hold_read(booth) -> (marks, error)`: ONE strict read
|
||||||
|
answering both questions. `sweep_once` now does one read per booth per tick
|
||||||
|
instead of two. And because `_read_raw_strict` **raises rather than dropping an
|
||||||
|
entry**, a non-raising strict read returns exactly what the lenient read would —
|
||||||
|
so the index uses that same one read for its badge too and falls back to
|
||||||
|
`marks_for` only on the error path, where leniency is the point. Better than the
|
||||||
|
original in both correctness and cost.
|
||||||
|
|
||||||
|
**The generalisable class, in heid's words: a two-read seam presented as one
|
||||||
|
answer is a TOCTOU race even when nothing on the page looks concurrent.** Worth
|
||||||
|
looking for anywhere two reader functions with different strictness feed one
|
||||||
|
decision — especially when that decision ends in `rmtree`.
|
||||||
|
|
||||||
|
Related: `[[2026-09-21-marks-write-wiped-judgment]]` is the same
|
||||||
|
reads-lenient/writes-strict asymmetry; U4 extends it to the reaper with
|
||||||
|
"deletes strict", whose scope is **the sweeper only** — a hand delete is never
|
||||||
|
strict, which is what gives an unreadable-marks hold an exit at all.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# The U2 bug-hunt panel was not ceremony
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**The U2 bug-hunt panel landed and it was not ceremony —
|
||||||
|
`v0.2.2`.** Nine adopted findings across four arms; eight were real against
|
||||||
|
live code and one was already fixed. The headline was **4/4 convergent from
|
||||||
|
four different angles**: `_Locked.__exit__` unlinked `.marks.lock` on the no-op
|
||||||
|
path, and `flock` binds to an INODE — so a writer blocked on the old inode
|
||||||
|
proceeds while the next writer creates a fresh lock file and takes it at once.
|
||||||
|
Two processes then run the read-modify-write concurrently and the later
|
||||||
|
`os.replace` drops a mark, with both of them obeying the protocol. **The
|
||||||
|
cleanup existed to protect the booth's TTL and it was failing at that too**:
|
||||||
|
creating and removing a directory entry bumps the DIRECTORY's mtime, which is
|
||||||
|
what `_newest_mtime` actually seeds from, so a no-op reset the clock it was
|
||||||
|
written to leave alone. Same code region, two defects, one fix — never unlink
|
||||||
|
the lock, exempt `.<name>.lock` dotfiles from `_newest_mtime`, and put the
|
||||||
|
directory's mtime back after creating one. Full triage in
|
||||||
|
`persistent-memory.d/2026-09-22-bug-hunt-panel.md`.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# U3 landed — the page declares the seam, the Booth mounts into it
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**Ten regular expressions against author-written HTML are gone.** Six in
|
||||||
|
`wrap_verbatim_html` hunting for somewhere to hang a favicon and a chip, four in
|
||||||
|
`booth/inline.py` substituting rendered ask markup into the author's own tags.
|
||||||
|
What replaced them, in full:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return html if declares_embed(html) else html + EMBED_SCRIPT_TAG
|
||||||
|
```
|
||||||
|
|
||||||
|
A substring test and a `+`. **Both of the old wrapper's hard constraints stopped
|
||||||
|
existing rather than being satisfied more carefully** — nothing can displace a
|
||||||
|
leading doctype into quirks mode and nothing can push the charset `<meta>` out
|
||||||
|
of its first-1024-byte window, because nothing in front of them ever moves.
|
||||||
|
|
||||||
|
## What moved where
|
||||||
|
|
||||||
|
| was | is |
|
||||||
|
|---|---|
|
||||||
|
| `wrap_verbatim_html` + 6 regexes | `embed_verbatim` — one `in`, one `+` |
|
||||||
|
| `booth/inline.py`, 119 lines | deleted; `form_id` survived into `app.py` |
|
||||||
|
| `_BACK_CHIP`, `asks_chip` | built in the DOM by `embed.js` |
|
||||||
|
| `inject_asks` | `GET /b/<name>/embed.json` + placement in `embed.js` |
|
||||||
|
| `_ask_inline.html`'s `styles()` | the CSS lives in `embed.js` |
|
||||||
|
| `FAVICON_LINK` string injection | `document.querySelector('link[rel~="icon"]')` |
|
||||||
|
|
||||||
|
**The fragments are still rendered by Jinja.** `embed.js` places what comes back
|
||||||
|
and never builds one — a second renderer in JavaScript would be the same bug
|
||||||
|
INV-1 exists to stop, in a new language. The payload also decides openness
|
||||||
|
(`open_marks`) and order, so the page has no opinion about either.
|
||||||
|
|
||||||
|
## The thing the contract got wrong, and the seam review caught
|
||||||
|
|
||||||
|
The payload first keyed `questions` by question key. **A single-question pick
|
||||||
|
normalizes to `questions: [{"key": None, …}]`** (`asks.normalize_ask`, the
|
||||||
|
`multi: False` branch), and `json.dumps` writes that key as the string `"null"`
|
||||||
|
— inventing a name that collides with a real key. Every one-question ask in the
|
||||||
|
fleet would have hit it, including the live `sindra-voice-1`. `questions` is a
|
||||||
|
LIST of `{key, html}` now; the key is nullable, and declaration order rides in
|
||||||
|
the format instead of leaning on object-key insertion order.
|
||||||
|
|
||||||
|
The cold contract panel could not have found this: it is a fact about
|
||||||
|
`booth/asks.py`, which an artifact-only reader never sees. Third time the seam
|
||||||
|
review has caught what the cold pass structurally cannot — see
|
||||||
|
[[2026-09-21-two-gates-are-complementary]].
|
||||||
|
|
||||||
|
## The live report that was already subtly broken
|
||||||
|
|
||||||
|
`dfa-concepts/index.html` writes `<div class="ask" data-booth-ask="dfa:logo">
|
||||||
|
<h3>The one asset that must survive</h3>`. `_EL_RE` matched the **opening tag**
|
||||||
|
and replaced it, so the author's `.ask` wrapper class vanished, the heading was
|
||||||
|
orphaned and the `</div>` went stray. Nobody filed a bug, because a page that is
|
||||||
|
95% right does not look broken.
|
||||||
|
|
||||||
|
`el.insertAdjacentHTML("beforeend", frag)` keeps the element and its contents
|
||||||
|
and puts the fragment inside. Verified live in a real browser: 5 author `.ask`
|
||||||
|
wrappers intact, 5 headings intact, 14 radios mounted inside them, zero console
|
||||||
|
errors. **The replacement is not just less fragile, it renders the operator's
|
||||||
|
own report more faithfully than the thing it replaced.**
|
||||||
|
|
||||||
|
## The cost, stated because it is real
|
||||||
|
|
||||||
|
The verbatim path used to work with **no JavaScript** — server-rendered ask, plain
|
||||||
|
form POST, HTML5 `form=` binding resolved at parse time. It needs the script now.
|
||||||
|
The operator's 2026-09-21 ruling accepts that; this entry records the consequence
|
||||||
|
so nobody meets it as a surprise. The never-invisible guarantee survives in a
|
||||||
|
weaker and still-true form through surfaces needing no script: the index card's
|
||||||
|
open-mark badge, and `/b/<name>/marks`.
|
||||||
|
|
||||||
|
## Anchor syntax
|
||||||
|
|
||||||
|
`data-booth-mark` is canonical (U2 made an ask one shape of mark).
|
||||||
|
`data-booth-ask` is a kept alias — 2 of the 4 live verbatim booths spell it that
|
||||||
|
way, in the operator's own reports, and the alias is one clause in one selector
|
||||||
|
string. The `<!-- booth:ask … -->` comment forms were **dropped, not ported**:
|
||||||
|
zero users across all 21 live booths, and a page that used one falls back to the
|
||||||
|
append path, so its ask still renders.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
431 tests (410 → 431). Live: all 21 booths 200, and each of the four verbatim
|
||||||
|
booths grew by exactly 46 bytes — `len(EMBED_SCRIPT_TAG)`, one append, nothing
|
||||||
|
else. Related: [[2026-09-22-the-browser-became-a-test-surface]],
|
||||||
|
[[2026-09-22-seven-of-seven-falsifiers]],
|
||||||
|
[[2026-09-21-regex-injecting-chrome]].
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# U4 landed — lifetime is derived, not declared
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**A booth's lifetime stopped being a boolean somebody remembered to press.**
|
||||||
|
Three states now, and `sweep_once` is the only thing that honours the first two:
|
||||||
|
|
||||||
|
KEPT `.forever` present never swept (unchanged)
|
||||||
|
HELD an open pick, or marks we cannot read never swept (new)
|
||||||
|
EPHEMERAL everything else 24h (unchanged)
|
||||||
|
|
||||||
|
Plus **viewing is activity**: a deliberately-served response from a booth's own
|
||||||
|
page route writes `.viewed`. That dotfile is not a `.lock` dotfile, so
|
||||||
|
`_newest_mtime` already counts it — **there is no new arithmetic anywhere**.
|
||||||
|
`booth_age_seconds`, `is_expired` and `expires_in` are byte-for-byte what they
|
||||||
|
were. A view is one more thing in the tree, which is the same trick `.booth.json`
|
||||||
|
used in U5.
|
||||||
|
|
||||||
|
**What counts as a view, and why the exclusions matter more than the inclusions.**
|
||||||
|
`/b/<n>/` (gallery, verbatim report, `?download=1` zip), `/b/<n>/view` and
|
||||||
|
`/b/<n>/marks` count. `/b/<n>/marks.json`, asset GETs, `/`, `/healthz` and a
|
||||||
|
zoom URL that 404s do NOT. The marks.json exclusion is load-bearing: **an agent
|
||||||
|
must not be able to hold its own booth open by polling for the answer it is
|
||||||
|
waiting on.** `/b/<n>/asks` is a 308 into `/marks` and records through it — one
|
||||||
|
call, not two.
|
||||||
|
|
||||||
|
Checked because it would have been silent: **nothing in the fleet polls a booth
|
||||||
|
page.** Homepage's `siteMonitor` for the Booth is `/healthz`, which is on the
|
||||||
|
not-a-view list. Had it been pointed at a booth URL, every booth would have
|
||||||
|
become immortal on deploy and nothing would have reported it.
|
||||||
|
|
||||||
|
**The hold is unbounded and that is the point** — unanswered is unfinished. What
|
||||||
|
makes it safe is visibility plus two exits that already existed: the card and
|
||||||
|
every Booth-owned header say `held until answered` where the countdown was, and
|
||||||
|
`booth rm` / the UI x / `DELETE /b/<n>` take a held booth exactly as they take a
|
||||||
|
kept one. **A hold is protection from the timer, never from the operator.**
|
||||||
|
|
||||||
|
**Release is activity, stated rather than accidental.** Releasing a kept board
|
||||||
|
still buys a full TTL — unchanged — but now because `booth_unkeep` calls
|
||||||
|
`record_view`, which is a rule, and no longer because unlinking a file happened
|
||||||
|
to bump a directory's mtime, which is not. The CLI warning against
|
||||||
|
"unkeep and let it expire" stays and stays true.
|
||||||
|
|
||||||
|
⚠ **Running `scripts/layout-probe.py` over booth pages resets every booth's
|
||||||
|
clock**, because a GET of a booth page is a view and the probe is not exempt
|
||||||
|
from its own rule. Harmless, recoverable, and noted in the probe so nobody
|
||||||
|
debugs it later as a sweeper that stopped working.
|
||||||
|
|
||||||
|
Contract: `docs/contracts/u4_derived_lifetime.contract.md`. Both heid panels ran
|
||||||
|
and the bug hunt after them; see the sibling entries.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# U5's adoption prediction split in two
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**U5's adoption prediction, SPLIT IN TWO within an hour of
|
||||||
|
landing — and the split is the interesting part.** The baseline was recorded as
|
||||||
|
0 of 26. Fifty minutes after the deploy, `comfy-dev` created `muse-clothed-repro`
|
||||||
|
and it announced itself: `{handle: comfy-dev, why: "", created: ...}`. That peer
|
||||||
|
was told nothing. **The HANDLE propagates for free** — it rides on `booth new`
|
||||||
|
and `booth add`, so every existing CLI caller starts announcing without learning
|
||||||
|
anything, which is the flags-on-existing-verbs decision paying off on day zero.
|
||||||
|
**The WHY does not** — it needs someone to know the flag exists, and this first
|
||||||
|
one is empty.
|
||||||
|
|
||||||
|
So re-measure BOTH on **2026-09-29**, because they answer different questions:
|
||||||
|
|
||||||
|
find ~/booth-data -maxdepth 2 -name .booth.json | wc -l # free
|
||||||
|
grep -l '"why": "[^"]' ~/booth-data/*/.booth.json 2>/dev/null | wc -l # learned
|
||||||
|
|
||||||
|
A high first count and a near-zero second is the predicted shape of "nobody was
|
||||||
|
told", and it is the case the operator's no-announcement decision was designed
|
||||||
|
to be able to see. Do not read the n=1 above as a rate — it is a code-path
|
||||||
|
observation (every CLI caller writes a handle), not a sample.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Two U5 panels, and prose reached a released outage
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**Two cross-frontier panels on U5, and a paraphrase panel reached
|
||||||
|
a production outage two modules away.** 3-of-4 flagged the contract's "4 GB"
|
||||||
|
case as letter-compliant but purpose-defeating; the conformance round found that
|
||||||
|
unbounded read live in U5's code; walking it to the sibling found the SAME hole
|
||||||
|
**live in released `v0.2.2`** — `marks._read_raw` catches `(OSError, ValueError,
|
||||||
|
UnicodeDecodeError)` and `json.loads` on deep nesting raises **RecursionError**,
|
||||||
|
which is none of them, so 400 KB of brackets in one booth returned 500 for `/`
|
||||||
|
and `/healthz` across all 26. The v0.2.2 round HAD flagged it and I closed half:
|
||||||
|
**a finding with two call sites is not closed when one is.** The reusable
|
||||||
|
instruction — **walk a conformance finding to the sibling module even when the
|
||||||
|
sibling is out of scope.** Five of ten conformance findings were tests of mine
|
||||||
|
that pass on the regression they exist to catch, three of them asserting an
|
||||||
|
ARTIFACT of the property rather than the property; that is three nights running
|
||||||
|
on the same shape. Two real bugs neither my tests nor I could see: a bare
|
||||||
|
`booth add` wiped the `why` on the one sequence the feature exists for, and
|
||||||
|
`--title` was write-only. Full triage in
|
||||||
|
`persistent-memory.d/2026-09-22-u5-panels.md`.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## How it closed
|
||||||
|
|
||||||
|
All three cold gates came back and were folded in full, with exactly one finding
|
||||||
|
declined. Released as `v0.6.0` — see [[2026-09-22-u6-benches-released]] and
|
||||||
|
[[2026-09-22-three-cold-panels-on-one-unit]]. The tag waited for the gates, per
|
||||||
|
the v0.2.0 lesson, and that sequencing was right: the panels produced ten code
|
||||||
|
fixes after this entry was first written.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# U6 released as v0.6.0 — benches, and the number that was two defects
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**The sixth of seven v1 units. Only U7 remains.** 444 → 607 tests. Tagged
|
||||||
|
`v0.6.0` (minor, operator-approved). **NOT PUSHED** — push is his call.
|
||||||
|
|
||||||
|
## What shipped
|
||||||
|
|
||||||
|
- **`booth/benches.py`** — stdlib-only AND sibling-free. `Bench`,
|
||||||
|
`normalize_bench_url` (the identity), a lenient `read_benches` on the render
|
||||||
|
path and a strict `_load_strict` on the write path, `mkstemp` + `fsync` +
|
||||||
|
`os.replace` under an flock, and `order_benches` with a stated total order
|
||||||
|
`(state rank, name casefolded, id)`.
|
||||||
|
- **`links.booth_target`** — ONE predicate for "is this a booth URL",
|
||||||
|
host-agnostic, path-shaped, percent-decoding, control-character-rejecting,
|
||||||
|
never raising. Three callers: the CLI refusal, the board's dead marker,
|
||||||
|
`bench import`.
|
||||||
|
- **`booth link` refuses** a booth URL (naming `booth new --why`) and a
|
||||||
|
credentialed one, writing nothing in either case.
|
||||||
|
- **The board marks dead rows** — 161 of 221 live. Removal stays the operator's
|
||||||
|
two clicks through the bulk control that already existed. Nothing deletes.
|
||||||
|
- **`booth bench add|ls|state|rm|import`**. `--apply` REQUIRES the ids.
|
||||||
|
|
||||||
|
## The decision that shaped the unit, and it was measured
|
||||||
|
|
||||||
|
**The design doc's headline "69% rot" was two defects wearing one number**, and
|
||||||
|
splitting them is what made the unit the right size — see
|
||||||
|
[[2026-09-22-one-number-was-two-defects]]. 178 of 221 rows are booth
|
||||||
|
announcements (156 already dead) whose *cause* U5 had already closed; only 8 are
|
||||||
|
the bench re-post the registry fixes. A unit scoped off the unsplit number would
|
||||||
|
have built the registry, declared victory, and left 178 rows rotting.
|
||||||
|
|
||||||
|
**Identity is the FULL normalized URL, not the origin**, and that was measured
|
||||||
|
rather than chosen: origin identity merges 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. It destroys more than it deduplicates.
|
||||||
|
|
||||||
|
**`booth link` is NOT deprecated**, against the design doc's plan. 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. Deprecating it would have
|
||||||
|
evicted a third of its live content. The IA doc is corrected.
|
||||||
|
|
||||||
|
## The gates
|
||||||
|
|
||||||
|
All four closed, and every one paid — see
|
||||||
|
[[2026-09-22-three-cold-panels-on-one-unit]]. Contract review
|
||||||
|
`01M35BWCJ806MT75NA630Y4WFH`, code review `01M35CK8YKEKMV7T15JXEF6A8N`, bug hunt
|
||||||
|
`01M35CRRK2RTVWWF1BN09AFQG3`, one consolidated reply sent to heid at
|
||||||
|
`01M35FY8QZTB9E5VR4WXDSBGEV`.
|
||||||
|
|
||||||
|
## Live evidence, unplanned
|
||||||
|
|
||||||
|
The sweeper ran mid-session: **23 booths → 19**, and dead board rows went
|
||||||
|
**156 → 161 in about fifteen minutes**. The defect compounding in real time
|
||||||
|
while the fix was being built — which is the argument for U6-before-U7 playing
|
||||||
|
out on its own.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Five of seven INV falsifiers did not falsify anything
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
The U4 contract carried seven invariants, each with a *Falsifiable:* line, and
|
||||||
|
each had a test. **The code-review panel showed that five of the seven tests
|
||||||
|
would still pass under a change that defeats the invariant they name.** Gróa's
|
||||||
|
"per INV entry, what would still pass" section is the single most useful thing
|
||||||
|
either panel produced on this unit.
|
||||||
|
|
||||||
|
| INV | what the test asserted | what still passed |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 (no new arithmetic) | the clock moved after a view | special-casing `.viewed` inside `_newest_mtime` — the exact new arithmetic INV-1 forbids |
|
||||||
|
| 3 (`is_held` is pure) | the right answer, once | `is_held` doing I/O, or `return True` unconditionally |
|
||||||
|
| 4 (every surface says why) | a substring on `GET /` | dropping the line from the booth header, the marks page, or the board branch |
|
||||||
|
| 5 (a view cannot fail a request) | `record_view` did not raise | a second `touch` outside the guard, 500ing all three routes |
|
||||||
|
| 6 (unreadable marks hold) | the corrupt booth survived | a sweeper that deletes nothing at all (no doomed sibling in the fixture) |
|
||||||
|
| 7 (machine reads do not hold) | `.viewed` was absent | a handler writing any other non-dot file, holding the booth open just as well |
|
||||||
|
|
||||||
|
**The shape of the error is the same every time: the test asserted the OUTCOME
|
||||||
|
the author was thinking about, not the DISCRIMINATOR the invariant names.** A
|
||||||
|
green test proved the happy path and nothing about the invariant. Writing the
|
||||||
|
falsifiable line in the contract did not produce a falsifying test — it produced
|
||||||
|
a test that *cited* one.
|
||||||
|
|
||||||
|
Fixed by rewriting each to fail under the change that defeats it: same-mtime
|
||||||
|
equivalence with an arbitrary non-lock dotfile (plus a `.lock` that must NOT
|
||||||
|
count); `is_held` called with marks belonging to a booth that does not exist on
|
||||||
|
disk; one test per rendered surface, each rendering only its own; the three
|
||||||
|
routes GET against a chmod'd booth; a doomed sibling; the AGE asserted rather
|
||||||
|
than the marker. **The board-header pair was verified RED against the pre-fix
|
||||||
|
template rather than assumed** — which is the step that makes "fixed, not
|
||||||
|
amended" trustworthy.
|
||||||
|
|
||||||
|
**The method to keep: for each invariant, name a change that defeats it and ask
|
||||||
|
whether the test goes red.** If you cannot name one, the invariant is not
|
||||||
|
falsifiable yet. Regin and Kimi independently proposed this as a contract-time
|
||||||
|
"vacuity pass"; heid rates this round the strongest evidence for it so far, and
|
||||||
|
it is a `/heid*` skill proposal sitting with the operator, not a change to this
|
||||||
|
repo.
|
||||||
+101
-345
@@ -19,354 +19,110 @@ loop it turned out to actually be.
|
|||||||
|
|
||||||
_As of 2026-09-22:_
|
_As of 2026-09-22:_
|
||||||
|
|
||||||
- **v1 is gated on seven units** in `ROADMAP.md`, dependency-ordered
|
- **NOTHING IS IN FLIGHT.** U6 (benches) landed, all four review gates closed,
|
||||||
**U1 → U2 → {U3, U4, U5} → U7**, with **U6 independent**.
|
**released as `v0.6.0`** and deployed. Tree clean at `3296a86`, 607 tests
|
||||||
- **U1 and U2 are landed and released.** Current version `0.2.2`, deployed to the
|
green, 19/19 booths 200 live. ⚠ **NOT PUSHED** — push is the operator's call
|
||||||
live service, 275 tests green, tree clean, 25/25 booth pages verified 200 after
|
and he did not give it this session; `main` is ahead of `origin/main`.
|
||||||
the deploy. U1 `ce598b3`; U2 `c7f9437` released as `v0.2.0`, then `5e41108` as
|
→ `persistent-memory.d/2026-09-22-u6-benches-released.md`
|
||||||
`v0.2.1` (four contract-panel findings), then `v0.2.2` carrying the
|
- **v1 is gated on seven units. SIX ARE LANDED. U7 IS THE LAST ONE.** U1
|
||||||
**bug-hunt panel's** nine (below).
|
`ce598b3`; U2 → `v0.2.0`/`v0.2.1`/`v0.2.2`; U5 → `v0.3.0`; U4 → `v0.4.0`;
|
||||||
- **U5 is IMPLEMENTED and unreleased** as of 2026-09-22. `booth/manifest.py`
|
U3 → `v0.5.0`; U6 `1c3ce5d` → `v0.6.0`.
|
||||||
(stdlib-only, INV-1), `.booth.json` per booth, the provenance line on both
|
- ⚠ **Before starting U7, read
|
||||||
index lanes and the booth page header, `--why` / `--title` on `booth new` and
|
`persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md` AND
|
||||||
`booth add`, and the link board + pickup booths announcing themselves as the
|
re-count the booths first.** Its premise has degraded twice over: every booth
|
||||||
service's own. 310 tests, live service restarted, 26/26 booth pages verified
|
that needs navigation is FLAT, and on 2026-09-22 the four large booths it was
|
||||||
200 and all 26 rendering `unannounced`. **Deliberately NOT tagged yet**: the
|
sized against (`pancake-v3-full`/`pancake-v4-full` at 270 items,
|
||||||
cold `/heid-contract-review` panel is still in flight and the code-review and
|
`sindra20-engines`, `sindra-finalists`) had ALL been swept. Largest live booth
|
||||||
bug-hunt gates have not run. That ordering is the 2026-09-21 lesson applied —
|
is `miranda-is` at 92 items. Two of 19 booths have subfolders and both are
|
||||||
a release whose gate is outstanding is premature even when the tier is right.
|
reports. Sections buy close to nothing; the rail, filters and grid keyboard
|
||||||
Contract: `docs/contracts/u5_booth_manifest.contract.md` (carries its own
|
are the unit.
|
||||||
seam-review section).
|
- **THE LAST OPEN DEFECT IS CLOSED.** The wrong-shaped `answer` that 500'd the
|
||||||
- **U5's original framing** (operator, 2026-09-21): **self-announcing booths.**
|
gallery and marks pages (pre-existing, measured at `42ea67f`) is fixed at
|
||||||
`.booth.json` carrying `{handle, title, why, created}`, written by the CLI from
|
`_hydrate` — the placement the session recommended three times and the
|
||||||
`$ALTHING_HANDLE`; the index card gains provenance and a one-line purpose, and
|
operator never ruled on, **taken under a stated assumption and cheap to move**
|
||||||
the index becomes the "what landed" feed the link board was being used as. It
|
(one condition in one function) if he disagrees. Measured before/after: both
|
||||||
closes job 5 of the five jobs — the one nobody named, and the reason 145 dead
|
pages 500 → 200, error visible, the booth's other pick untouched. Two things
|
||||||
link rows existed. Nothing started: no contract, no blast-radius pass.
|
fell out of it that matter more than the fix — U3's `_safe_fragments` lost its
|
||||||
- **Two things about U5 are already settled and should not be re-derived.**
|
natural trigger and is now a synthetically-falsified backstop, and that guard's
|
||||||
(1) `.booth.json` is a DOTFILE, so `booth_items`' existing `startswith(".")` skip
|
own handler could not survive the failure it was handling. Read
|
||||||
already keeps it out of tiles, counts and zips — the same reason `.marks.json`
|
`persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`
|
||||||
needed no new exclusion rule. (2) The deterministic-order invariant applies to
|
before touching marks rendering anywhere.
|
||||||
whatever U5 adds to the index; the index is ordered newest-first by mtime today
|
- ⚠ **TWO OPERATOR DECISIONS ARE OUTSTANDING AND BOTH ARE DELIBERATELY NOT
|
||||||
and that rule must stay stated. Also worth knowing before scoping: enforcing the
|
DONE.** (1) The single althing note to the 17 handles about `booth link`
|
||||||
link rule without giving job 5 a home first just makes it homeless — that is the
|
refusing booth URLs — gated as multi-recipient, drafted nowhere, NOT SENT.
|
||||||
lesson from the 69% rot, and U5 is the home.
|
(2) Seeding the bench registry from the board — he said "no seeding yet", so
|
||||||
- **No heid dispatch is outstanding.** The `/heid-bug-hunt` on U2's diff landed
|
`booth bench import --apply` has NOT been run against live data and
|
||||||
2026-09-22 and shipped as `v0.2.2`; see the dated entry below.
|
`.benches.json` does not exist in `~/booth-data`.
|
||||||
- Live service `active` on `:8090`, 25 booths, verified 25 × 3 page types after the
|
- ⚠ **THE 17 CONSUMING HANDLES WERE NEVER TOLD that `keep` stopped meaning
|
||||||
last deploy. The booth set churns: `sindra20-engines` and `sindra-finalists` were
|
"waiting on an answer"** — operator decision 2026-09-22, no broadcast, and it
|
||||||
swept during the session, `cr123a-to-d-sleeve` and `sindra` appeared.
|
still stands. **This 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 the diagnosis. Read its entry before measuring.
|
||||||
|
- **A remote exists and `main` is AHEAD of it.** `origin` is
|
||||||
|
`git@gitea.phasefinal.com:vh/booth.git`; the first push of this repo's history
|
||||||
|
was 2026-09-22 (26 commits, `v0.2.0`–`v0.5.0` in one motion). As of this
|
||||||
|
snapshot `main` is **8 commits ahead of `origin/main`** — the whole of U6
|
||||||
|
including `v0.6.0`. Pushing is the operator's call.
|
||||||
|
- **Two dated predictions are pending and must not be run early.** U5's adoption
|
||||||
|
re-measure on **2026-09-29**; the `.forever` re-count **on or after
|
||||||
|
2026-10-06**. Before the second, read
|
||||||
|
`persistent-memory.d/2026-09-22-no-notice-and-what-it-does-to-the-prediction.md`.
|
||||||
|
- **FIVE methodology proposals sit with the operator, untracked by his choice**
|
||||||
|
— four from earlier rounds plus Kimi's new one: promote "the falsifiable test
|
||||||
|
is weaker than the invariant it guards" to its own ambiguity class in
|
||||||
|
`/heid-contract-review`. It now has two data points in this repo (five of
|
||||||
|
seven U4 falsifiers vacuous; U6 shipped a tie-break falsifier that could not
|
||||||
|
fail). They are `/heid*` skill changes, not this repo's work.
|
||||||
|
- The booth set churns hard: 26 → 24 → 25 → 23 → **19** across five sessions.
|
||||||
|
Re-count rather than trusting any number written here.
|
||||||
|
|
||||||
## Recent decisions
|
## Recent decisions
|
||||||
|
|
||||||
- `[2026-09-22]` **The U5 bug-hunt panel found a service-wide hang that the
|
- `[2026-09-22]` **The last open defect closed, and building its falsifier found another** — the wrong-shaped answer fixed at `_hydrate`; `_safe_fragments` lost its natural trigger and its handler could not survive the failure it handled → `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`
|
||||||
SIZE CAP ITSELF opened — two hours after I added the cap.** `stat` reports
|
- `[2026-09-22]` **U6 released as `v0.6.0` — benches, and the number that was two defects** — six of seven v1 units landed, NOT PUSHED → `persistent-memory.d/2026-09-22-u6-benches-released.md`
|
||||||
size 0 for a FIFO and 0 for a symlink to `/dev/zero`, so both sail under a
|
- `[2026-09-22]` **Three cold panels on one unit, and what each lens could only see alone** — READ BEFORE DECIDING TO SKIP A GATE; all five passes found something the others structurally could not → `persistent-memory.d/2026-09-22-three-cold-panels-on-one-unit.md`
|
||||||
byte cap and then `read_text` blocks with no EOF or allocates until the kernel
|
- `[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 → `persistent-memory.d/2026-09-22-u6-benches-landed.md`
|
||||||
intervenes. `list_booths` reads every booth on every `GET /`, so ONE such file
|
- `[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`
|
||||||
stalls the front page for the whole service with no error and no recovery
|
- `[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`
|
||||||
short of a restart. Reproduced (`timeout` returned 124), fixed with an
|
- `[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`
|
||||||
`S_ISREG` check BEFORE the size check in both modules, verified live: the
|
- `[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`
|
||||||
index answered 200 in 36 ms with two FIFOs planted. **The reusable shape:
|
- `[2026-09-22]` **A vacuity pass that tries the contract's own mutation agrees with itself** — U3 ran one, reported 7/7, and a cold panel then showed one of the seven was vacuous; READ BEFORE WRITING A *Falsifiable:* LINE → `persistent-memory.d/2026-09-22-seven-of-seven-falsifiers.md`
|
||||||
`st_size` answers a different question than "can this be read", and a bound
|
- `[2026-09-22]` **U4 landed — lifetime is derived, not declared** — three states, viewing is activity, and no new arithmetic anywhere → `persistent-memory.d/2026-09-22-u4-derived-lifetime-landed.md`
|
||||||
that trusts it inherits everything it does not mean — a hardening fix opened
|
- `[2026-09-22]` **The `.forever` diagnosis got a live positive control** — 3 of the 4 booths awaiting an answer were ALSO hand-pinned — RE-COUNT 2026-10-06 → `persistent-memory.d/2026-09-22-forever-had-a-live-positive-control.md`
|
||||||
a worse hole than the one it closed.** Also adopted: the upload path wrote the
|
- `[2026-09-22]` **No fleetwide notice for U4, and what that does to the prediction** — READ BEFORE THE 2026-10-06 RE-COUNT; a flat rate does not falsify the diagnosis → `persistent-memory.d/2026-09-22-no-notice-and-what-it-does-to-the-prediction.md`
|
||||||
manifest ABOVE its own cleanup guard (4/4), so a failure orphaned a half-booth
|
- `[2026-09-22]` **Four independent paths to one fail-open delete** — the bug-hunt panel's class, and the zsh word-splitting trap that shipped an empty bundle → `persistent-memory.d/2026-09-22-four-paths-to-one-fail-open-delete.md`
|
||||||
whose uniquely-named leaked temp then kept it alive forever; replace-over-
|
- `[2026-09-22]` **Two reads of one file are not one read of one state** — a TOCTOU seam that composes two correct readers into a fail-open delete → `persistent-memory.d/2026-09-22-two-reads-are-not-one-state.md`
|
||||||
damaged destroyed recoverable bytes (4/4, now QUARANTINED rather than refused
|
- `[2026-09-22]` **Five of seven INV falsifiers did not falsify anything** — read before writing a *Falsifiable:* line; a green test cited one rather than being one → `persistent-memory.d/2026-09-22-vacuous-falsifiers.md`
|
||||||
— marks refuse because judgment is not restatable, a booth's description is);
|
- `[2026-09-22]` **The third one-branch template miss** — this repo's recurring blind spot; read before adding a fact to any template → `persistent-memory.d/2026-09-22-third-one-branch-template-miss.md`
|
||||||
and `booth answer` spelled out its own openness test, disagreeing with
|
- `[2026-09-22]` **The size cap opened a service-wide hang** — a FIFO has st_size 0; a bound that trusts it inherits what it does not mean → `persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md`
|
||||||
`booth marks` about a partially-answered pick, which is a direct violation of
|
- `[2026-09-22]` **An existing test stopped me retiring documented behaviour** — the clean fix for the mtime race would have silently changed TTL doctrine → `persistent-memory.d/2026-09-22-doctrine-not-defect.md`
|
||||||
U2's INV-2. Full triage in `persistent-memory.d/2026-09-22-u5-panels.md`.
|
- `[2026-09-22]` **Two U5 panels, and prose reached a released outage** — read the detail before assuming a conformance finding stops at its own module → `persistent-memory.d/2026-09-22-u5-panels-reached-a-released-bug.md`
|
||||||
- `[2026-09-22]` **An existing test stopped me retiring documented behaviour
|
- `[2026-09-22]` **U5's adoption prediction split in two** — the handle rides for free, the why must be learned — RE-MEASURE 2026-09-29 → `persistent-memory.d/2026-09-22-u5-adoption-split-in-two.md`
|
||||||
while fixing a race.** The mtime-restore race is real, and the clean fix —
|
- `[2026-09-22]` **The U2 bug-hunt panel was not ceremony** — the lock-unlink race and the TTL guard that was failing at its own job → `persistent-memory.d/2026-09-22-u2-bug-hunt-panel.md`
|
||||||
ignoring a booth directory's own mtime whenever the booth holds anything —
|
- `[2026-09-22]` **The lenient reader's blast radius was the whole service** — marks_for runs per booth per index load; a raise there is an outage → `persistent-memory.d/2026-09-22-lenient-reader-blast-radius.md`
|
||||||
would also have silently retired the rule that RELEASING a kept board resets
|
- `[2026-09-22]` **`booth marks` / `booth answer` got real exit codes** — read it before changing anything the 17 consuming handles call → `persistent-memory.d/2026-09-22-cli-exit-codes.md`
|
||||||
its clock, which the CLI header, the README and a deliberately-written test
|
- `[2026-09-22]` **`scripts/booth` went from zero tests to five** — they run the real script under system python3, so they also check INV-1 → `persistent-memory.d/2026-09-22-scripts-booth-got-tests.md`
|
||||||
all pin. That is a TTL doctrine change, not a bug fix. Fixed the concrete half
|
- `[2026-09-21]` **v0.2.0 was tagged while a gate was in flight** — the sequencing lesson: if a gate is outstanding, the tag waits → `persistent-memory.d/2026-09-21-v020-tagged-with-a-gate-in-flight.md`
|
||||||
(a failing `os.utime` used to escape and 500 the route), left the race stated
|
- `[2026-09-21]` **A write over a damaged `.marks.json` wiped the booth** — the reads-lenient / writes-strict asymmetry, and why it exists → `persistent-memory.d/2026-09-21-marks-write-wiped-judgment.md`
|
||||||
in the code. **A fix that changes a documented rule is a proposal, not a
|
- `[2026-09-21]` **Seam review and cold panel had zero overlap, twice** — evidence for running both; neither substitutes for the other → `persistent-memory.d/2026-09-21-two-gates-are-complementary.md`
|
||||||
patch.**
|
- `[2026-09-21]` **Every code-changing finding came from the AMBIGUITY pass** — a finding about the /heid-contract-review skill, not about this repo → `persistent-memory.d/2026-09-21-ambiguity-pass-did-the-work.md`
|
||||||
- `[2026-09-22]` **Two cross-frontier panels on U5, and a paraphrase panel reached
|
- `[2026-09-21]` **Deterministic order is a cross-cutting v1 invariant** — operator directive; read before adding ANY ordered surface → `persistent-memory.d/2026-09-21-deterministic-order-invariant.md`
|
||||||
a production outage two modules away.** 3-of-4 flagged the contract's "4 GB"
|
- `[2026-09-21]` **U2 (marks) landed — one primitive for three mechanisms** — what moved where, and the HTTP mirror remote sessions poll → `persistent-memory.d/2026-09-21-u2-marks-landed.md`
|
||||||
case as letter-compliant but purpose-defeating; the conformance round found that
|
- `[2026-09-21]` **A partially-answered pick counts as OPEN** — declared, not smuggled; it is the reading that makes U4 correct → `persistent-memory.d/2026-09-21-partial-answer-counts-as-open.md`
|
||||||
unbounded read live in U5's code; walking it to the sibling found the SAME hole
|
- `[2026-09-21]` **The U2 seam review earned its place, and how** — inline.place indexes by subscript — the miss a cold panel cannot see → `persistent-memory.d/2026-09-21-u2-seam-review-earned-it.md`
|
||||||
**live in released `v0.2.2`** — `marks._read_raw` catches `(OSError, ValueError,
|
- `[2026-09-21]` **Marks are one `.marks.json` per booth** — operator decision with two rejected alternatives; read before restructuring → `persistent-memory.d/2026-09-21-marks-storage-decision.md`
|
||||||
UnicodeDecodeError)` and `json.loads` on deep nesting raises **RecursionError**,
|
- `[2026-09-21]` **U7's section premise is half wrong** — every booth that needs navigation is FLAT — read before starting U7 → `persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md`
|
||||||
which is none of them, so 400 KB of brackets in one booth returned 500 for `/`
|
- `[2026-09-21]` **`sindra-finalists` is U2's flag motivation, caught live** — evidence, not argument → `persistent-memory.d/2026-09-21-sindra-finalists-is-the-motivation.md`
|
||||||
and `/healthz` across all 26. The v0.2.2 round HAD flagged it and I closed half:
|
- `[2026-09-21]` **The information architecture and the v1 gate landed** — the single defect the seven units decompose → `persistent-memory.d/2026-09-21-ia-and-v1-gate-landed.md`
|
||||||
**a finding with two call sites is not closed when one is.** The reusable
|
- `[2026-09-21]` **The `.forever` diagnosis is a falsifiable prediction** — U4's success criterion — re-count a fortnight AFTER U4 lands → `persistent-memory.d/2026-09-21-forever-diagnosis-is-a-prediction.md`
|
||||||
instruction — **walk a conformance finding to the sibling module even when the
|
- `[2026-09-21]` **Extracted from `eshpfi` into its own repo** — test_booth.py is the regression net the v1 rewrite is checked against → `persistent-memory.d/2026-09-21-extracted-from-eshpfi.md`
|
||||||
sibling is out of scope.** Five of ten conformance findings were tests of mine
|
|
||||||
that pass on the regression they exist to catch, three of them asserting an
|
|
||||||
ARTIFACT of the property rather than the property; that is three nights running
|
|
||||||
on the same shape. Two real bugs neither my tests nor I could see: a bare
|
|
||||||
`booth add` wiped the `why` on the one sequence the feature exists for, and
|
|
||||||
`--title` was write-only. Full triage in
|
|
||||||
`persistent-memory.d/2026-09-22-u5-panels.md`.
|
|
||||||
- `[2026-09-22]` **U5's adoption prediction, SPLIT IN TWO within an hour of
|
|
||||||
landing — and the split is the interesting part.** The baseline was recorded as
|
|
||||||
0 of 26. Fifty minutes after the deploy, `comfy-dev` created `muse-clothed-repro`
|
|
||||||
and it announced itself: `{handle: comfy-dev, why: "", created: ...}`. That peer
|
|
||||||
was told nothing. **The HANDLE propagates for free** — it rides on `booth new`
|
|
||||||
and `booth add`, so every existing CLI caller starts announcing without learning
|
|
||||||
anything, which is the flags-on-existing-verbs decision paying off on day zero.
|
|
||||||
**The WHY does not** — it needs someone to know the flag exists, and this first
|
|
||||||
one is empty.
|
|
||||||
|
|
||||||
So re-measure BOTH on **2026-09-29**, because they answer different questions:
|
|
||||||
|
|
||||||
find ~/booth-data -maxdepth 2 -name .booth.json | wc -l # free
|
|
||||||
grep -l '"why": "[^"]' ~/booth-data/*/.booth.json 2>/dev/null | wc -l # learned
|
|
||||||
|
|
||||||
A high first count and a near-zero second is the predicted shape of "nobody was
|
|
||||||
told", and it is the case the operator's no-announcement decision was designed
|
|
||||||
to be able to see. Do not read the n=1 above as a rate — it is a code-path
|
|
||||||
observation (every CLI caller writes a handle), not a sample.
|
|
||||||
- `[2026-09-22]` **The U2 bug-hunt panel landed and it was not ceremony —
|
|
||||||
`v0.2.2`.** Nine adopted findings across four arms; eight were real against
|
|
||||||
live code and one was already fixed. The headline was **4/4 convergent from
|
|
||||||
four different angles**: `_Locked.__exit__` unlinked `.marks.lock` on the no-op
|
|
||||||
path, and `flock` binds to an INODE — so a writer blocked on the old inode
|
|
||||||
proceeds while the next writer creates a fresh lock file and takes it at once.
|
|
||||||
Two processes then run the read-modify-write concurrently and the later
|
|
||||||
`os.replace` drops a mark, with both of them obeying the protocol. **The
|
|
||||||
cleanup existed to protect the booth's TTL and it was failing at that too**:
|
|
||||||
creating and removing a directory entry bumps the DIRECTORY's mtime, which is
|
|
||||||
what `_newest_mtime` actually seeds from, so a no-op reset the clock it was
|
|
||||||
written to leave alone. Same code region, two defects, one fix — never unlink
|
|
||||||
the lock, exempt `.<name>.lock` dotfiles from `_newest_mtime`, and put the
|
|
||||||
directory's mtime back after creating one. Full triage in
|
|
||||||
`persistent-memory.d/2026-09-22-bug-hunt-panel.md`.
|
|
||||||
- `[2026-09-22]` **The lenient reader's blast radius was the whole service, not
|
|
||||||
one booth.** `_clean_text` did `(text or "").replace(...)` and `marks_for`
|
|
||||||
sorts on `(created, id)`, so a stored `text` that was a dict or a `created`
|
|
||||||
that was a number raised out of the READ path — and `list_booths` reads every
|
|
||||||
booth's marks on every index load. One hand-edited file 500'd `/` and
|
|
||||||
`/healthz` for all 25 booths. Fixed in two layers, matching the house posture:
|
|
||||||
a named type check (`_entry_type_error`) plus a `_hydrate_safe` backstop that
|
|
||||||
cannot raise, and the panel now RENDERS an unreadable mark as ⚠ broken instead
|
|
||||||
of as an empty note. **The general shape: a lenient reader is only lenient if
|
|
||||||
the leniency is bounded by where it runs.** `marks_for` was written for one
|
|
||||||
booth's page and is called in a loop over every booth.
|
|
||||||
- `[2026-09-22]` **`booth marks` / `booth answer` got real exit codes**, because
|
|
||||||
a read that CRASHED was indistinguishable from a read that said no. `marks`
|
|
||||||
printed a traceback and exited 0 (a caller's `jq` saw success and got
|
|
||||||
nothing); `answer --wait` read a damaged file as "not yet" and spun for the
|
|
||||||
full hour before blaming the operator. Now `0 ok · 1 unanswered/timed-out ·
|
|
||||||
2 no such pick · 3 unreadable`, and `read_error()` was added to `marks.py` so
|
|
||||||
the CLI can ask the question the browser must not: the page stays lenient, the
|
|
||||||
machine consumer gets the truth. Also `--wait` now prints ONCE — it was
|
|
||||||
emitting a whole JSON document per poll, so a captured `--wait` held several
|
|
||||||
concatenated values and parsed as none of them.
|
|
||||||
- `[2026-09-22]` **`scripts/booth` had zero tests and now has five**
|
|
||||||
(`tests/test_cli.py`). The panel's guard-strength tables returned UNVERIFIED
|
|
||||||
for every CLI claim because nothing in the suite executed the script — two of
|
|
||||||
the round's findings lived in exactly that gap. The new tests run the real
|
|
||||||
script under the system `python3`, which makes them a live check on INV-1
|
|
||||||
(stdlib-only) as a side effect: a third-party import in `marks.py` now fails
|
|
||||||
in the suite the same way it would fail on a fleet host.
|
|
||||||
|
|
||||||
- `[2026-09-21]` **v0.2.0 cut and announced; v0.2.1 fixed what the announcement
|
|
||||||
was already wrong about.** Operator approved the minor (a v1 unit closed plus a
|
|
||||||
CLI surface change for 17 consuming handles clears the release-note bar). The
|
|
||||||
note went to 15 handles — the 17 link-board posters minus `nh3-dev`, a host
|
|
||||||
label, and `heid`, an oracle that does not script these verbs. Then the
|
|
||||||
cross-frontier contract panel landed and found **three defects in the code I had
|
|
||||||
just released**, so `v0.2.1` shipped within the hour. Sequence worth remembering:
|
|
||||||
the release was correct by the tier bar and still premature by the discipline —
|
|
||||||
the panel had been dispatched BEFORE implementation and its reply arrived AFTER
|
|
||||||
the tag. **If a gate is in flight, the tag can wait for it.**
|
|
||||||
- `[2026-09-21]` **A write over a damaged `.marks.json` was wiping every mark in
|
|
||||||
the booth.** Shipped in `v0.2.0`, found by the panel (Kimi, converged with
|
|
||||||
Hulda), fixed in `v0.2.1`. `marks_for` is deliberately lenient — unparseable
|
|
||||||
reads as `[]` so a review page still loads — and the write path inherited that
|
|
||||||
leniency through the same reader, so one flag click appended to an empty list and
|
|
||||||
atomically replaced the file. The fix is an **asymmetry**, which is the reusable
|
|
||||||
part: reads stay lenient, writes go strict (`MarksCorrupt`), damaged bytes stay
|
|
||||||
on disk, routes answer 409 not 500. A page that renders without an annotation is
|
|
||||||
recoverable; a file that overwrote the operator's judgment is not. Kimi also
|
|
||||||
named the class correctly — "an author steeped in the design conversation would
|
|
||||||
likely read past" it — and that was accurate.
|
|
||||||
- `[2026-09-21]` **The two review gates are complementary, measured on one unit.**
|
|
||||||
The caller-side **seam review** (nine findings, against the real sibling module
|
|
||||||
surfaces) and the cold **`/heid-contract-review` panel** (four arms,
|
|
||||||
artifact-only) had **zero overlap in both directions** on U2. The seam review
|
|
||||||
found a scope miss the panel structurally could not see: the contract omitted
|
|
||||||
`inline.py`, whose `place()` indexes by subscript, which a frozen dataclass
|
|
||||||
refuses. The panel found three code defects and a missing test the seam review
|
|
||||||
had no lens for. Matches heid's kvasir zero-overlap result on the
|
|
||||||
conformance-versus-hunt axis. **Run both; neither substitutes.**
|
|
||||||
- `[2026-09-21]` **Every one of the panel's code-changing findings came from the
|
|
||||||
AMBIGUITY pass, none from a paraphrase divergence** — and two arms independently
|
|
||||||
proposed cutting the paraphrase to a drift-check for narrative-heavy contracts,
|
|
||||||
because this contract's own frontmatter carries a plain-language narrative and the
|
|
||||||
paraphrase was partly reading my framing back to me. That is a finding about the
|
|
||||||
`/heid-contract-review` **skill**, not about this repo, and it was reported back
|
|
||||||
to heid. Recorded here only so a future session does not rediscover it.
|
|
||||||
- `[2026-09-21]` **Deterministic order is a cross-cutting v1 invariant** —
|
|
||||||
operator directive, mid-implementation. Every ordered collection the Booth
|
|
||||||
renders must have a *stated* rule producing the same sequence on every render
|
|
||||||
of the same state; the rule can be anything defensible (byte order, time, an
|
|
||||||
explicit number, an arbitrary-but-recorded sequence), but no rule at all is
|
|
||||||
forbidden. It binds harder here than elsewhere because the Booth's job is
|
|
||||||
**comparison** — the operator judges tile 47 against tile 47 and refers to
|
|
||||||
artifacts positionally, so an order that moves between renders misfiles a flag
|
|
||||||
or a note rather than crashing. Recorded as `ROADMAP.md` § "Cross-cutting
|
|
||||||
invariant" (with the per-collection table) and `CLAUDE.md` invariant 6, and
|
|
||||||
tested. Still undecided and must be settled before those units ship: **U7's
|
|
||||||
section ordering and compare pairing**, and **U6's bench listing**.
|
|
||||||
- `[2026-09-21]` **U2 (marks) landed.** One primitive replacing three
|
|
||||||
mechanisms. `pick` / `note` / `flag` in one `.marks.json` per booth, one read
|
|
||||||
path (`marks_for`), one openness predicate (`open_marks`), rendered beside the
|
|
||||||
artifact on the tile, at full size in the zoom, and in the panel. `flag` and
|
|
||||||
`note` had no write path at all before this — the selection loop
|
|
||||||
(`golden-candidates`, `sindra-finalists`, the `pancake-*` ladders) was running
|
|
||||||
through chat. 242 tests. Details worth carrying: `asks.py` kept `normalize_ask`
|
|
||||||
and gained `build_answer` (the 2026-09-09 partial-answer semantics preserved by
|
|
||||||
moving, not rewriting) and LOST its five sidecar-storage functions;
|
|
||||||
`GET /b/<n>/marks.json` was added because remote sessions polled
|
|
||||||
`<stem>.answer.json` over HTTP and the sidecar's removal would have taken that
|
|
||||||
capability with it; `/b/<n>/asks` 308s to `/marks`.
|
|
||||||
- `[2026-09-21]` **A partially-answered pick now counts as OPEN** — declared, not
|
|
||||||
smuggled. The old index badge tested `answer is None`, so a half-answered
|
|
||||||
four-question ask read as closed on the index while the panel beside it
|
|
||||||
rendered `◐ partial`: the two disagreed about the same booth. Open is the
|
|
||||||
reading that makes U4 correct — a lifetime rule that unpinned a booth on the
|
|
||||||
first radio click would sweep a review in flight.
|
|
||||||
- `[2026-09-21]` **The U2 seam review earned its place, and the record should
|
|
||||||
say how.** Nine findings against the real `booth.asks` / `booth.items` /
|
|
||||||
`booth.inline` surfaces, two of which changed scope or behaviour: `inline.py`
|
|
||||||
was missing from `touches` entirely (its `place()` indexes asks by
|
|
||||||
**subscript**, which a frozen dataclass refuses — nothing else in the service
|
|
||||||
does that), and the partial-answer inconsistency above. The cold
|
|
||||||
`/heid-contract-review` pass is artifact-only by design and structurally
|
|
||||||
cannot see a sibling module, so neither it nor a same-model self-review would
|
|
||||||
have found either. Two more surfaced later and are worth the same note: a
|
|
||||||
SECOND subscript in `inline.place` the seam review undercounted, and a
|
|
||||||
regression in my own legacy importer that a retargeted test caught — a
|
|
||||||
malformed sidecar that renders `⚠ broken` today would have silently vanished
|
|
||||||
on migration.
|
|
||||||
- `[2026-09-21]` **Marks are stored as one `.marks.json` per booth**, atomic
|
|
||||||
temp-file + `os.replace`, `fcntl` lock on the read-modify-write — operator
|
|
||||||
decision, this session. Two alternatives were weighed and lost: a sidecar
|
|
||||||
per item (`<rel>.marks.json`) and extending the existing `<stem>.ask.json`
|
|
||||||
shape. Rationale, and the reason it is not `links.md`-shaped: **(a)** U4
|
|
||||||
makes *"does this booth owe an answer?"* a hot question — the sweep asks it
|
|
||||||
per booth per tick and the index asks it per card per page load, so per-item
|
|
||||||
sidecars turn it into a full walk of all 25 booths, one of which holds 270
|
|
||||||
files; **(b)** `links.md` is an `O_APPEND` content-hash log because **17
|
|
||||||
agent handles write it concurrently**, whereas marks have exactly one writer
|
|
||||||
(the operator, in one browser) and many readers — a different problem that
|
|
||||||
must not inherit the append-log design; **(c)** `.blurred` / `.pins` /
|
|
||||||
`.forever` already establish the per-booth dotfile as the house shape for
|
|
||||||
operator state, and `booth_items()`'s dotfile skip means it costs nothing in
|
|
||||||
counts, galleries or zips. Accepted cost: a corrupt `.marks.json` loses that
|
|
||||||
booth's marks rather than one item's. Implementation deferred to U2 —
|
|
||||||
tracked at `ROADMAP.md` U2 and by this entry.
|
|
||||||
- `[2026-09-21]` **U7's section premise is half wrong, and it is the half that
|
|
||||||
matters** — found by re-measuring `~/booth-data` rather than trusting the IA
|
|
||||||
doc. The IA says sections come from subfolders that already exist on disk;
|
|
||||||
true, but **every booth that actually needs navigation is flat**:
|
|
||||||
`pancake-v3-full` (270 items, 0 subfolders), `pancake-v4-full` (270, 0),
|
|
||||||
`sindra20-engines` (98 items + 99 caption sidecars, 0), `sindra-finalists`
|
|
||||||
(86 + 87, 0). Subfolders exist on exactly two booths — `pewpew-ui-brief` (7,
|
|
||||||
nested to `_ds/powerpellet-design-system-<uuid>/preview`) and `dfa-concepts`
|
|
||||||
(1) — and **both are reports**, the job where grid navigation matters least.
|
|
||||||
So sections stay worth shipping and `Item.section` stays right, but they are
|
|
||||||
**not** "most of the navigation fix": the rail, the filters and grid keyboard
|
|
||||||
are all of it. Worth noting for whoever writes U7: `sindra20-engines` encodes
|
|
||||||
its structure in the **filename prefix** (`b2-s1-<subject>-<seed>`), which is
|
|
||||||
where a grouping heuristic would actually pay. The IA doc's claim about what
|
|
||||||
sections buy needs a line struck — not yet edited.
|
|
||||||
- `[2026-09-21]` **`sindra-finalists` is U2's `flag` motivation caught in the
|
|
||||||
act** — 86 items, every one captioned, and the booth's entire name is "the
|
|
||||||
ones the operator picked." That loop currently runs through chat, which is
|
|
||||||
the defect `flag` closes. Evidence, not argument.
|
|
||||||
- `[2026-09-21]` **The information architecture and the v1 gate landed**
|
|
||||||
(`726822b`): `docs/design/information-architecture.md` names the single
|
|
||||||
defect — *one lifetime (24h from last touch) and one shape (a folder),
|
|
||||||
serving five jobs with different lifetimes and different shapes* — and
|
|
||||||
`ROADMAP.md` gates v1 on seven units, each closing a **measured** defect
|
|
||||||
rather than a wish. Both were written after a measurement pass over the live
|
|
||||||
service, and the measurements are the load-bearing part.
|
|
||||||
- `[2026-09-21]` **The `.forever` diagnosis is a stated, falsifiable
|
|
||||||
prediction.** U4 (derived lifetime) predicts the kept-rate falls to the
|
|
||||||
genuinely-durable booths. Re-measured today: **14 of 25 booths kept (56%)**,
|
|
||||||
against the 54% the IA doc recorded. **Re-count a fortnight after U4 lands.**
|
|
||||||
If it does not move, the diagnosis was wrong and the boolean was doing
|
|
||||||
something else. Tracked in the IA doc's Booth section and by this entry.
|
|
||||||
- `[2026-09-21]` **Extracted from `eshpfi` into its own repo.** The accreted
|
|
||||||
service came over whole, tests included, so `tests/test_booth.py` (1581 lines)
|
|
||||||
is the regression net the v1 rewrite is checked against.
|
|
||||||
|
|
||||||
## Tried and abandoned
|
## Tried and abandoned
|
||||||
|
|
||||||
- `[2026-09-21]` **Tagging a release while a review gate was still in flight.**
|
- `[2026-09-21]` **Tagging a release while a review gate was in flight** — cost a same-hour v0.2.1 and a correction to 15 handles → `persistent-memory.d/2026-09-21-tagging-with-a-gate-in-flight.md`
|
||||||
`v0.2.0` was cut and announced to 15 consuming handles; the
|
- `[2026-09-21]` **Letting the write path share the read path's leniency** — a tolerant reader and a tolerant writer are not the same decision → `persistent-memory.d/2026-09-21-tolerant-writer-over-tolerant-reader.md`
|
||||||
`/heid-contract-review` panel — dispatched BEFORE implementation, as the
|
- `[2026-09-21]` **Letting Jinja hot-reload templates in the deployment root** — caused a live outage: 19 of 25 booths at 500. Why auto_reload=False → `persistent-memory.d/2026-09-21-jinja-hot-reload-outage.md`
|
||||||
discipline says — replied afterwards with three defects in the code that had just
|
- `[2026-09-21]` **Five mechanisms to get one question beside one artifact** — the accretion signature this whole v1 rewrite is undoing → `persistent-memory.d/2026-09-21-five-mechanisms-one-job.md`
|
||||||
shipped, one of them silent data loss. Nothing about the tier decision was wrong;
|
- `[2026-09-21]` **Regex-injecting chrome into arbitrary author HTML** — the defect U3 exists to close → `persistent-memory.d/2026-09-21-regex-injecting-chrome.md`
|
||||||
the *timing* was. **If a gate is outstanding on the work being released, the tag
|
- `[2026-09-21]` **A boolean escape hatch as the lifetime mechanism** — why `.forever` is a symptom; the defect U4 exists to close → `persistent-memory.d/2026-09-21-boolean-escape-hatch-as-lifetime.md`
|
||||||
waits for it.** The cost was a same-hour `v0.2.1` and a correction note to peers
|
- `[2026-09-21]` **Letting the link board absorb the announce job** — 69% rot; U5 gave the job a home, which is what unblocks U6 → `persistent-memory.d/2026-09-21-link-board-absorbing-announce.md`
|
||||||
who had already verified against the broken version.
|
|
||||||
- `[2026-09-21]` **Letting the write path share the read path's leniency.** See the
|
|
||||||
`MarksCorrupt` decision above. The general shape, worth carrying beyond marks:
|
|
||||||
a tolerant reader and a tolerant writer over the same state are not the same
|
|
||||||
decision, and pointing both at one function silently makes them one. Tolerate on
|
|
||||||
read so the surface still renders; refuse on write so nothing is destroyed.
|
|
||||||
- `[2026-09-21]` **Letting Jinja hot-reload templates while the repo is the
|
|
||||||
deployment root** — the cause of a live outage the same day U2 landed, and the
|
|
||||||
sharpest foot-gun in the repo. `booth.service` sets `WorkingDirectory` to this
|
|
||||||
repo, so the running service imports these files with no build step and no
|
|
||||||
staging copy. Python is read once at process start; Jinja's `FileSystemLoader`
|
|
||||||
re-reads a template **on every render**. Editing `booth.html` therefore
|
|
||||||
deployed it instantly against Python from 22:03 that knew nothing about
|
|
||||||
`item_marks`, and **19 of 25 live booths returned 500** with
|
|
||||||
`UndefinedError: 'item_marks' is undefined`. Neither the old code nor the new
|
|
||||||
code was broken — the service was running both at once.
|
|
||||||
**The lesson that generalises:** a skew between a process and the disk under it
|
|
||||||
is invisible to the test suite by construction, so no amount of green tests
|
|
||||||
would have caught it; the operator found it. Fixed at the source rather than
|
|
||||||
with a reminder — the `Environment` is hand-built with `auto_reload=False`, so
|
|
||||||
there is now ONE staleness rule (nothing takes effect until you restart) and
|
|
||||||
the running process is always a coherent snapshot of one commit. Asserted by
|
|
||||||
`test_templates_do_not_hot_reload_from_disk`. Watch the second-order risk the
|
|
||||||
fix introduces: a hand-built `Environment` does not inherit `autoescape` from
|
|
||||||
the `Jinja2Templates` constructor, and booth names, item names and mark text
|
|
||||||
are all agent-authored strings landing in HTML.
|
|
||||||
|
|
||||||
- `[2026-09-21]` **Five separate mechanisms to get one question next to one
|
|
||||||
artifact** — `.forever`, the link board, `inline.py`'s placeholder DSL,
|
|
||||||
`wrap_verbatim_html`'s six regexes, and the floating amber asks chip plus
|
|
||||||
`/b/<n>/asks`. Every one is a *correct local fix* to the same global
|
|
||||||
mismatch, which is exactly why they accumulated without anyone making a bad
|
|
||||||
call. **The foot-gun is the sixth one:** the next "just add a small thing for
|
|
||||||
this case" reads as reasonable and is the pattern. The git log carries the
|
|
||||||
signature — every feature ships, then takes 2–5 patches for cases the single
|
|
||||||
shape did not anticipate. Check the ROADMAP gate before adding a mechanism.
|
|
||||||
- `[2026-09-21]` **Regex-injecting chrome into arbitrary author HTML**
|
|
||||||
(`wrap_verbatim_html` + `_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`,
|
|
||||||
`_BODY_CLOSE_RE`, `_HTML_CLOSE_RE`, `_ICON_RE`, and the doctype/charset
|
|
||||||
ordering constraints they thread). It works today and is **still live** —
|
|
||||||
but it is the single most fragile thing in the service and it is load-bearing
|
|
||||||
for the operator's most important workflow. Slated for deletion at U3 in
|
|
||||||
favour of a declared seam (`/_booth/embed.js`, mounted through a real DOM
|
|
||||||
API), which costs an author one line and removes the whole class. Do not
|
|
||||||
extend the regex set in the meantime; if a verbatim page breaks, that is an
|
|
||||||
argument for U3, not for a seventh pattern.
|
|
||||||
- `[2026-09-21]` **A boolean escape hatch as the lifetime mechanism.**
|
|
||||||
`.forever` was added because a 24h TTL genuinely did not fit some booths —
|
|
||||||
and then 56% of live booths ended up on it, which means it is not "ephemeral
|
|
||||||
with an exception", it is two lifetimes wearing one lifetime's clothes, with
|
|
||||||
the operator doing the sorting by hand. Replaced at U4 by lifetime derived
|
|
||||||
from state (an open mark pins; viewing is activity; `keep` survives as an
|
|
||||||
explicit reasoned pin rather than the only way to say "not yet").
|
|
||||||
- `[2026-09-21]` **Letting the link board absorb the announce job.** `booth
|
|
||||||
link` is an `O_APPEND` write with no identity and no stated rule, so
|
|
||||||
re-announcing a bench appends a row instead of updating one, and a booth URL
|
|
||||||
rots the moment its booth is swept — **145 of 211 rows (69%) pointed at
|
|
||||||
nothing**, and 22 were the same target re-posted (talk 5×, peedlar 4×). The
|
|
||||||
rot is **structural, not drift**. The lesson that cost the most: enforcing
|
|
||||||
the link rule without first giving the announce job a home (`.booth.json`
|
|
||||||
provenance on the index, U5) just makes it homeless.
|
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "booth"
|
name = "booth"
|
||||||
version = "0.3.0"
|
version = "0.6.1"
|
||||||
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
|
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -15,6 +15,15 @@ dependencies = [
|
|||||||
test = [
|
test = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"httpx>=0.27", # fastapi TestClient
|
"httpx>=0.27", # fastapi TestClient
|
||||||
|
# U3's embed seam moves placement into the browser, where no string
|
||||||
|
# assertion can see it. Browsers are NOT downloaded per project: they live
|
||||||
|
# box-wide in /opt/ms-playwright with PLAYWRIGHT_BROWSERS_PATH wired
|
||||||
|
# globally. THE UPPER BOUND IS THE POINT -- each playwright release pins a
|
||||||
|
# Chromium revision, and one that wants a revision the shared store does
|
||||||
|
# not have dies with an opaque "Executable doesn't exist" rather than a
|
||||||
|
# missing-dependency error. 1.60-1.62 map to chromium 1223/1228/1234, all
|
||||||
|
# present. Raise the bound only after the store has the newer revision.
|
||||||
|
"playwright>=1.60,<1.63",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
+293
-13
@@ -12,11 +12,37 @@
|
|||||||
# booth rm <name> wipe a booth now (TTL would eventually anyway)
|
# booth rm <name> wipe a booth now (TTL would eventually anyway)
|
||||||
#
|
#
|
||||||
# booth keep <name> exempt a booth from the 24h sweep, forever
|
# booth keep <name> exempt a booth from the 24h sweep, forever
|
||||||
|
# (NOT for "waiting on an answer" — an open
|
||||||
|
# pick holds its own booth, see below)
|
||||||
# booth unkeep <name> hand it back to the sweeper
|
# booth unkeep <name> hand it back to the sweeper
|
||||||
# booth link <url> [description] append a link to the standing link board
|
# 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 links list the board, numbered, with entry ids
|
||||||
# booth unlink <id|index> remove ONE link from the board
|
# 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 classify the board's rows; writes NOTHING
|
||||||
|
# booth bench import --apply <id>... register ONLY the ids you name. A bare
|
||||||
|
# --apply is REFUSED: a machine cannot tell
|
||||||
|
# a bench from a bookmark by its URL, and
|
||||||
|
# ~14 of 35 live candidates are bookmarks.
|
||||||
|
# links.md is never edited by either form.
|
||||||
|
#
|
||||||
|
# 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]
|
# booth ask <name> <id> <prompt> <option>... [--no-notes]
|
||||||
# pose a multiple-choice question in a booth
|
# pose a multiple-choice question in a booth
|
||||||
# booth marks <name> [--wait [SECS]] print every mark in a booth as JSON;
|
# booth marks <name> [--wait [SECS]] print every mark in a booth as JSON;
|
||||||
@@ -59,12 +85,28 @@
|
|||||||
# access, so they poll the HTTP mirror instead:
|
# access, so they poll the HTTP mirror instead:
|
||||||
# http://10.100.10.50:8090/b/<name>/marks.json
|
# http://10.100.10.50:8090/b/<name>/marks.json
|
||||||
#
|
#
|
||||||
# THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last
|
# THE 24h RULE AND ITS THREE STATES. Every booth is wiped 24h after its last
|
||||||
# activity — that is the contract, and it is why nobody has to clean up after
|
# activity — that is the contract, and it is why nobody has to clean up after
|
||||||
# themselves. `keep` drops a `.forever` sentinel that exempts one booth from the
|
# themselves. Two things exempt a booth, and only the first is a button:
|
||||||
# sweep and moves it into its own lane at the top of the index. Use it for
|
#
|
||||||
# durable operator-facing boards, not for run output. `unkeep` is just `rm` of
|
# KEPT `keep` drops a `.forever` sentinel that exempts one booth from the
|
||||||
# the sentinel, so putting a board back under the sweeper costs nothing.
|
# sweep and moves it into its own lane at the top of the index. Use it
|
||||||
|
# for durable operator-facing boards, not for run output. `unkeep` is
|
||||||
|
# just `rm` of the sentinel, so putting a board back costs nothing.
|
||||||
|
# HELD a booth with an UNANSWERED pick is never swept, automatically, for as
|
||||||
|
# long as the question is open. You do not press anything: `booth ask`
|
||||||
|
# is what holds it, and the operator answering is what releases it. A
|
||||||
|
# partially-answered pick still counts as open, so a review in flight
|
||||||
|
# cannot be swept out from under him.
|
||||||
|
#
|
||||||
|
# So: DO NOT `keep` a booth just because you are waiting on an answer. That was
|
||||||
|
# the old workaround, it is what made 70% of live booths "durable", and it is
|
||||||
|
# no longer needed. `keep` means durable. The question holds its own booth.
|
||||||
|
#
|
||||||
|
# VIEWING IS ACTIVITY TOO. The operator opening a booth page resets its clock —
|
||||||
|
# if he is still looking at it, it is still alive. Your polling does NOT: `booth
|
||||||
|
# marks --wait` and the `marks.json` endpoint are machine reads and deliberately
|
||||||
|
# do not count, so a session cannot hold its own booth open by waiting on it.
|
||||||
#
|
#
|
||||||
# DELETING A KEPT BOARD: `booth rm <name>` works on kept boards too and deletes
|
# DELETING A KEPT BOARD: `booth rm <name>` works on kept boards too and deletes
|
||||||
# NOW — it announces that the board was kept, so wiping something durable is
|
# NOW — it announces that the board was kept, so wiping something durable is
|
||||||
@@ -72,10 +114,12 @@
|
|||||||
# card drops the sentinel, the card moves to the ephemeral lane, and the × wipes
|
# card drops the sentinel, the card moves to the ephemeral lane, and the × wipes
|
||||||
# it from there.
|
# it from there.
|
||||||
#
|
#
|
||||||
# DO NOT "unkeep and let it expire". Removing the sentinel BUMPS the booth
|
# DO NOT "unkeep and let it expire". RELEASING A BOARD IS ACTIVITY — you just
|
||||||
# directory's mtime, and a booth's age is the newest mtime in its tree — so a
|
# touched it — so a released board's clock resets and it survives another full
|
||||||
# released board's clock RESETS and it survives another full 24h. Unkeep-and-wait
|
# 24h. Unkeep-and-wait is a delay, not a delete. Use `rm` (or the UI ×) when you
|
||||||
# is a delay, not a delete. Use `rm` (or the UI ×) when you mean now.
|
# mean now. (This was true before U4 as an accident of directory mtime; it is
|
||||||
|
# now the stated rule, which is why it no longer needs a warning shaped like a
|
||||||
|
# surprise.)
|
||||||
#
|
#
|
||||||
# `link` is the reason the exception exists: agent sessions hand the operator
|
# `link` is the reason the exception exists: agent sessions hand the operator
|
||||||
# URLs that then drown in terminal scrollback. They go on a standing kept board
|
# URLs that then drown in terminal scrollback. They go on a standing kept board
|
||||||
@@ -164,8 +208,39 @@ whoami_handle() {
|
|||||||
echo "${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
|
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).
|
||||||
|
# Prints `B:<name>` for a booth URL and `N` for anything else.
|
||||||
|
#
|
||||||
|
# A SENTINEL, NOT AN EMPTY STRING. Command substitution strips trailing
|
||||||
|
# newlines, so a predicate that answers with the bare name cannot distinguish
|
||||||
|
# "not a booth" from "a booth whose name bash just erased" — and the guard
|
||||||
|
# then fails OPEN on that edge, which is the one direction a guard must never
|
||||||
|
# fail. The prefix makes the answer unambiguous whatever the name contains.
|
||||||
|
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
|
||||||
|
name = booth_target(os.environ["BOOTH_Q"])
|
||||||
|
sys.stdout.write("N" if name is None else "B:" + name)
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
usage() {
|
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 <id>...]}" >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +334,57 @@ case "$cmd" in
|
|||||||
[ $# -ge 1 ] || usage
|
[ $# -ge 1 ] || usage
|
||||||
link_url="$1"; shift
|
link_url="$1"; shift
|
||||||
desc="${*:-}"
|
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.
|
||||||
|
# `|| pred_rc=$?` so a BROKEN PREDICATE is handled here rather than aborting
|
||||||
|
# the script under `set -e` with a raw Python traceback and nothing else.
|
||||||
|
# The direction is FAIL-CLOSED and stays that way: if we cannot tell whether
|
||||||
|
# this is a booth, we do not append. A guard that fails open is not a guard,
|
||||||
|
# and the cost of being wrong in the other direction is one message telling
|
||||||
|
# the poster exactly what broke.
|
||||||
|
pred_rc=0
|
||||||
|
refused_name="$(booth_target_of "$link_url" 2>/dev/null)" || pred_rc=$?
|
||||||
|
if [ "$pred_rc" -ne 0 ]; then
|
||||||
|
{
|
||||||
|
echo "booth link: could not check whether that URL is a booth, so nothing was posted."
|
||||||
|
echo " the check runs booth/links.py under the system python3 with no venv."
|
||||||
|
echo " re-run from a checkout where \`python3 -c 'import booth.links'\` works,"
|
||||||
|
echo " or post it from a host that has one."
|
||||||
|
} >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
case "$refused_name" in
|
||||||
|
N) refused_name="" ;;
|
||||||
|
B:*) refused_name="${refused_name#B:}" ;;
|
||||||
|
*)
|
||||||
|
echo "booth link: the booth check answered something unrecognised; nothing was posted." >&2
|
||||||
|
exit 3 ;;
|
||||||
|
esac
|
||||||
|
# CREDENTIALS DO NOT GO ON THE BOARD, through any door. `normalize_bench_url`
|
||||||
|
# refuses userinfo for a bench; `booth link` is the door this unit did not
|
||||||
|
# touch, and the board renders on an unauthenticated LAN surface. A small,
|
||||||
|
# deliberate widening of the unit -- named rather than smuggled.
|
||||||
|
case "$link_url" in
|
||||||
|
*://*@*)
|
||||||
|
{
|
||||||
|
echo "booth link: that URL carries credentials (user:pass@host) and the board"
|
||||||
|
echo " is readable by anyone who can reach this service. Nothing was posted."
|
||||||
|
echo " strip the credentials and post it again."
|
||||||
|
} >&2
|
||||||
|
exit 2 ;;
|
||||||
|
esac
|
||||||
|
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"
|
board="$DATA/$LINKS_BOARD"
|
||||||
mkdir -p -- "$board"
|
mkdir -p -- "$board"
|
||||||
: > "$board/$KEEP" # the board is durable by definition
|
: > "$board/$KEEP" # the board is durable by definition
|
||||||
@@ -279,9 +405,20 @@ case "$cmd" in
|
|||||||
# shared lock this line could land inside that window and be rewritten
|
# shared lock this line could land inside that window and be rewritten
|
||||||
# away by the prune.
|
# away by the prune.
|
||||||
touch -- "$board/.links.lock"
|
touch -- "$board/.links.lock"
|
||||||
flock "$board/.links.lock" \
|
# THE REDIRECTION OPENS INSIDE THE LOCK, which is why this is `sh -c` and
|
||||||
printf -- '- [%s](%s) <sub>· %s · %s</sub>\n' \
|
# not a bare printf. `flock LOCK printf ... >> board` reads as locked and is
|
||||||
"${desc:-$link_url}" "$link_url" "$who" "$when" >> "$board/links.md"
|
# not: the SHELL opens the append fd while parsing, before flock acquires
|
||||||
|
# anything. If a concurrent `unlink` rewrites the board in that window, the
|
||||||
|
# rewrite lands on a NEW inode via os.replace and this fd still points at
|
||||||
|
# the old, unlinked one — so the append succeeds, reports success, and the
|
||||||
|
# row is gone. Found by a cold bug-hunt arm; pre-existing, not U6's, but it
|
||||||
|
# is a silent data loss in the file this unit spends its time in.
|
||||||
|
BK_DESC="${desc:-$link_url}" BK_URL="$link_url" BK_WHO="$who" BK_WHEN="$when" \
|
||||||
|
BK_BOARD="$board/links.md" \
|
||||||
|
flock "$board/.links.lock" sh -c '
|
||||||
|
printf -- "- [%s](%s) <sub>· %s · %s</sub>\n" \
|
||||||
|
"$BK_DESC" "$BK_URL" "$BK_WHO" "$BK_WHEN" >> "$BK_BOARD"
|
||||||
|
'
|
||||||
echo "$URL/b/$LINKS_BOARD/"
|
echo "$URL/b/$LINKS_BOARD/"
|
||||||
;;
|
;;
|
||||||
links)
|
links)
|
||||||
@@ -337,6 +474,149 @@ if removed is None:
|
|||||||
print("removed: %s %s" % (removed["desc"], removed["url"]))
|
print("removed: %s %s" % (removed["desc"], removed["url"]))
|
||||||
' "$board" "$target"
|
' "$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 <id>...]}" >&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).
|
||||||
|
# THE ID IS PRINTED WHOLE AND UNTRUNCATED, because it is the locator
|
||||||
|
# `bench state` and `bench rm` take: a truncated one is not an id, it is a
|
||||||
|
# string that looks like one and silently addresses nothing. The name and
|
||||||
|
# the added date are the truncatable columns.
|
||||||
|
return "%-9s %-10s %-16s %-24s %s" % (
|
||||||
|
b.state, b.added[:10], b.owner[:16], b.name[:24], b.id)
|
||||||
|
|
||||||
|
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")
|
||||||
|
else:
|
||||||
|
print("%-9s %-10s %-16s %-24s %s"
|
||||||
|
% ("STATE", "ADDED", "OWNER", "NAME", "ID (pass to state|rm)"))
|
||||||
|
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
|
||||||
|
picked = [a for a in argv if a != "--apply"]
|
||||||
|
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:
|
||||||
|
# THE NORMALIZED ID BESIDE THE RAW URL, which is the whole point of the
|
||||||
|
# proposal: five rows of `talk` collapsing to one is only visible if you
|
||||||
|
# can see which five raw URLs produced the one id. The description is
|
||||||
|
# the thing to drop here, not the URL.
|
||||||
|
print(" %-52s %s" % (i, e["url"]))
|
||||||
|
if e["desc"]:
|
||||||
|
print(" %-52s %s" % ("", e["desc"][:70]))
|
||||||
|
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.")
|
||||||
|
print(" booth bench import --apply <id>... register ONLY the ids you name")
|
||||||
|
print()
|
||||||
|
print("A MACHINE CANNOT TELL A BENCH FROM A BOOKMARK BY ITS URL. On the live")
|
||||||
|
print("board roughly 14 of 35 candidates are repos, model cards and docs, for")
|
||||||
|
print("which the board is the right and only home. So `--apply` takes the ids")
|
||||||
|
print("YOU pick from the list above; it will not register the whole set.")
|
||||||
|
raise SystemExit(0)
|
||||||
|
# SELECTION IS MANDATORY. A bare `--apply` would do exactly the thing this
|
||||||
|
# rationale of this very unit says is impossible -- decide bench-vs-bookmark
|
||||||
|
# URL -- and it would do it silently, to ~14 rows that belong on the board.
|
||||||
|
# The dry-run prints the ids; the operator names the ones that are benches.
|
||||||
|
if not picked:
|
||||||
|
print()
|
||||||
|
print("booth bench import --apply needs the ids to register.", file=sys.stderr)
|
||||||
|
print(" nothing was written. copy the ids you want from the list above:",
|
||||||
|
file=sys.stderr)
|
||||||
|
print(" booth bench import --apply <id> [<id>...]", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
by_id = {i: e for i, e in candidates}
|
||||||
|
unknown = [i for i in picked if i not in by_id]
|
||||||
|
if unknown:
|
||||||
|
print()
|
||||||
|
for i in unknown:
|
||||||
|
print("not a candidate id: %s" % i, file=sys.stderr)
|
||||||
|
print("nothing was written.", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
for i in picked:
|
||||||
|
e = by_id[i]
|
||||||
|
upsert_bench(root, e["url"], e["desc"], e["who"] or who)
|
||||||
|
print()
|
||||||
|
print("applied: %d bench(es) registered. links.md was NOT modified." % len(set(picked)))
|
||||||
|
' "$@"
|
||||||
|
;;
|
||||||
ask)
|
ask)
|
||||||
# booth ask <name> <id> <prompt> <opt>... [--no-notes]
|
# booth ask <name> <id> <prompt> <opt>... [--no-notes]
|
||||||
[ $# -ge 5 ] || usage
|
[ $# -ge 5 ] || usage
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ claimed it did until 2026-09-22. Pass booth URLs explicitly to cover them:
|
|||||||
|
|
||||||
scripts/layout-probe.py http://10.100.10.50:8090/{,b/my-run/}
|
scripts/layout-probe.py http://10.100.10.50:8090/{,b/my-run/}
|
||||||
|
|
||||||
|
⚠ PROBING A BOOTH PAGE RESETS THAT BOOTH'S TTL CLOCK (U4). A GET of `/b/<n>/`
|
||||||
|
is a view, and a view is activity — that is the rule, and this script is not
|
||||||
|
exempt from it just because it is ours. Sweeping every booth page therefore
|
||||||
|
buys every booth another full TTL. Harmless and recoverable (nothing is
|
||||||
|
deleted, things merely live longer), named here so nobody debugs it later as a
|
||||||
|
sweeper that stopped working. The index-only default does NOT do this: browsing
|
||||||
|
the index is deliberately not a view.
|
||||||
|
|
||||||
⚠ In zsh an unquoted `$URLS` does NOT word-split, so a variable holding
|
⚠ In zsh an unquoted `$URLS` does NOT word-split, so a variable holding
|
||||||
several URLs arrives as ONE argument and the probe silently reports
|
several URLs arrives as ONE argument and the probe silently reports
|
||||||
"2 page(s)" while covering two. Use an array and `"${URLS[@]}"`.
|
"2 page(s)" while covering two. Use an array and `"${URLS[@]}"`.
|
||||||
|
|||||||
+71
-67
@@ -13,7 +13,7 @@ import pathlib
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from booth.app import build_gallery, create_app, list_booths
|
from booth.app import EMBED_SCRIPT_TAG, build_gallery, create_app, list_booths
|
||||||
from booth.asks import (
|
from booth.asks import (
|
||||||
ANSWER_SUFFIX,
|
ANSWER_SUFFIX,
|
||||||
ASK_SUFFIX,
|
ASK_SUFFIX,
|
||||||
@@ -410,32 +410,37 @@ def test_declare_pick_accepts_a_full_multi_doc(tmp_path):
|
|||||||
# into the verbatim page plus a standalone /asks page that carries the forms.
|
# into the verbatim page plus a standalone /asks page that carries the forms.
|
||||||
|
|
||||||
|
|
||||||
def test_verbatim_booth_renders_the_ask_inline(client):
|
def test_verbatim_booth_offers_the_ask_over_the_seam(client):
|
||||||
|
"""U3: the report is served as written and the ask crosses the declared seam.
|
||||||
|
Before U3 the fragments were substituted into the page body by regex; the
|
||||||
|
guarantee that the ask is reachable FROM THE REPORT, not from another page,
|
||||||
|
is unchanged — it is the delivery that moved."""
|
||||||
c, data = client
|
c, data = client
|
||||||
b = _ask(data / "b")
|
b = _ask(data / "b")
|
||||||
(b / "index.html").write_text("<!doctype html><title>report</title><body>hi</body>")
|
(b / "index.html").write_text("<!doctype html><title>report</title><body>hi</body>")
|
||||||
html = c.get("/b/b/").text
|
html = c.get("/b/b/").text
|
||||||
assert "hi" in html # the report is still served verbatim
|
assert "hi" in html # the report is still served verbatim
|
||||||
assert "Which render wins?" in html # ...with the ask ON it, not elsewhere
|
assert "Which render wins?" not in html # ...and NOTHING was injected into it
|
||||||
assert 'type="radio"' in html and 'action="/b/b/answer"' in html
|
assert html.endswith(EMBED_SCRIPT_TAG)
|
||||||
assert "bk-ask" in html # self-contained fragment styles
|
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||||
assert "booth-nav-asks" in html # chip remains, as a jump link
|
assert "Which render wins?" in m["whole"]
|
||||||
assert "#bk-ask-winner-top" in html
|
assert 'type="radio"' in m["whole"] and 'action="/b/b/answer"' in m["submit"]
|
||||||
|
|
||||||
|
|
||||||
def test_verbatim_chip_disappears_once_answered(client):
|
def test_verbatim_chip_disappears_once_answered(client):
|
||||||
c, data = client
|
c, data = client
|
||||||
b = _ask(data / "b")
|
b = _ask(data / "b")
|
||||||
(b / "index.html").write_text("<!doctype html><body>hi</body>")
|
(b / "index.html").write_text("<!doctype html><body>hi</body>")
|
||||||
|
assert c.get("/b/b/embed.json").json()["open"] == ["winner"]
|
||||||
answer_pick(b, "winner", "A — baseline")
|
answer_pick(b, "winner", "A — baseline")
|
||||||
assert "booth-nav-asks" not in c.get("/b/b/").text
|
assert c.get("/b/b/embed.json").json()["open"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_verbatim_booth_without_asks_is_untouched(client):
|
def test_verbatim_booth_without_asks_is_untouched(client):
|
||||||
c, data = client
|
c, data = client
|
||||||
(data / "b").mkdir()
|
(data / "b").mkdir()
|
||||||
(data / "b" / "index.html").write_text("<!doctype html><body>hi</body>")
|
(data / "b" / "index.html").write_text("<!doctype html><body>hi</body>")
|
||||||
assert "booth-nav-asks" not in c.get("/b/b/").text
|
assert c.get("/b/b/embed.json").json()["marks"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_asks_page_renders_forms_and_answers_back_to_itself(client):
|
def test_asks_page_renders_forms_and_answers_back_to_itself(client):
|
||||||
@@ -477,42 +482,64 @@ def test_asks_page_shows_a_single_ask_title(client):
|
|||||||
assert "emmie — pick the anchor" in c.get("/b/b/marks").text
|
assert "emmie — pick the anchor" in c.get("/b/b/marks").text
|
||||||
|
|
||||||
|
|
||||||
# ---- inline placement in a verbatim report -----------------------------------
|
# ---- placement in a verbatim report ------------------------------------------
|
||||||
#
|
#
|
||||||
# Operator verdict 2026-09-09 on the separate /asks page: "the asks should be
|
# Operator verdict 2026-09-09 on the separate /asks page: "the asks should be
|
||||||
# inline with the artifacts, not on a separate page." A four-voice audition wants
|
# inline with the artifacts, not on a separate page." A four-voice audition wants
|
||||||
# each voice's radio group under that voice's audio, and one submit for the lot.
|
# each voice's radio group under that voice's audio, and one submit for the lot.
|
||||||
|
#
|
||||||
|
# U3 kept the semantics and moved the mechanism. The author still marks up where
|
||||||
|
# each piece goes; the pieces are still rendered by the `_ask_inline.html`
|
||||||
|
# macros; they now reach the page through `/b/<name>/embed.json` and are mounted
|
||||||
|
# by `/_booth/embed.js` instead of substituted into the author's tags by regex.
|
||||||
|
#
|
||||||
|
# So the placement ASSERTIONS moved too, and where each half lives is not
|
||||||
|
# arbitrary: what the server offers is checked here, in Python; where it LANDS,
|
||||||
|
# and whether a form scattered down a report actually submits, is checked in
|
||||||
|
# tests/test_embed_browser.py against a real DOM. No string assertion can see
|
||||||
|
# the second thing, and that is exactly the part the operator depends on.
|
||||||
|
|
||||||
REPORT = """<!doctype html><title>audition</title><body>
|
REPORT = """<!doctype html><title>audition</title><body>
|
||||||
<h1>Three voices</h1>
|
<h1>Three voices</h1>
|
||||||
<section id="lawson"><audio src="a.wav"></audio>
|
<section id="lawson"><audio src="a.wav"></audio>
|
||||||
<div data-booth-ask="batch:r1"></div></section>
|
<div data-booth-ask="batch:r1"></div></section>
|
||||||
<section id="jo"><audio src="b.wav"></audio>
|
<section id="jo"><audio src="b.wav"></audio>
|
||||||
<!-- booth:ask batch:r2 --></section>
|
<div data-booth-mark="batch:r2"></div></section>
|
||||||
<div data-booth-ask-submit="batch"></div>
|
<div data-booth-ask-submit="batch"></div>
|
||||||
|
<script src="/_booth/embed.js" defer></script>
|
||||||
</body>"""
|
</body>"""
|
||||||
|
|
||||||
|
|
||||||
def test_per_question_placeholders_land_where_the_author_put_them(client):
|
def test_the_author_markup_is_never_touched_by_the_server(client):
|
||||||
|
"""The whole point of the seam. A page that declares it comes back exactly
|
||||||
|
as written — placeholders still empty, waiting for the DOM."""
|
||||||
c, data = client
|
c, data = client
|
||||||
b = _multi(data / "b")
|
b = _multi(data / "b")
|
||||||
(b / "index.html").write_text(REPORT)
|
(b / "index.html").write_text(REPORT)
|
||||||
html = c.get("/b/b/").text
|
assert c.get("/b/b/").text == REPORT
|
||||||
# each group is inside its own section, in document order
|
|
||||||
lawson = html.index('id="lawson"')
|
|
||||||
jo = html.index('id="jo"')
|
|
||||||
assert lawson < html.index('name="choice.r1"') < jo
|
|
||||||
assert jo < html.index('name="choice.r2"')
|
|
||||||
# one shared form, bound by the HTML5 form= attribute, submitted once
|
|
||||||
assert html.count('<form id="bk-ask-form-batch"') == 1
|
|
||||||
assert html.count('action="/b/b/answer"') == 1
|
|
||||||
assert html.count('form="bk-ask-form-batch"') >= 4
|
|
||||||
# the submit block landed at its own placeholder, not appended after </body>
|
|
||||||
assert html.index("bk-ask-form-batch") < html.index("</body>")
|
|
||||||
|
|
||||||
|
|
||||||
def test_inline_form_submits_every_question_in_one_post(client):
|
def test_every_piece_the_author_can_place_is_offered(client):
|
||||||
|
"""One fragment per addressable piece: the whole ask, each question, and the
|
||||||
|
submit block that carries the shared <form>. The author's markup decides
|
||||||
|
which are used; the payload never decides for them."""
|
||||||
|
c, data = client
|
||||||
|
b = _multi(data / "b")
|
||||||
|
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||||
|
assert [q["key"] for q in m["questions"]] == ["r1", "r2"]
|
||||||
|
assert 'name="choice.r1"' in m["questions"][0]["html"]
|
||||||
|
assert 'name="choice.r2"' in m["questions"][1]["html"]
|
||||||
|
# ONE form, and it lives with the submit block, so question groups scattered
|
||||||
|
# down a report bind to it by id from wherever they sit.
|
||||||
|
assert m["submit"].count('<form id="bk-ask-form-batch"') == 1
|
||||||
|
assert m["submit"].count('action="/b/b/answer"') == 1
|
||||||
|
assert 'form="bk-ask-form-batch"' in m["questions"][0]["html"]
|
||||||
|
assert 'form="bk-ask-form-batch"' in m["questions"][1]["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_scattered_form_still_posts_as_one_answer(client):
|
||||||
|
"""The POST half of the multi-question guarantee, which U3 did not touch:
|
||||||
|
every question in one request, or the route refuses it."""
|
||||||
c, data = client
|
c, data = client
|
||||||
b = _multi(data / "b")
|
b = _multi(data / "b")
|
||||||
(b / "index.html").write_text(REPORT)
|
(b / "index.html").write_text(REPORT)
|
||||||
@@ -521,44 +548,21 @@ def test_inline_form_submits_every_question_in_one_post(client):
|
|||||||
assert r.status_code == 303
|
assert r.status_code == 303
|
||||||
ans = _answer_of(b, "batch")
|
ans = _answer_of(b, "batch")
|
||||||
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r2"]["choice"] == "d"
|
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r2"]["choice"] == "d"
|
||||||
# and the recorded pick now shows inline, on the report itself
|
# and the recorded pick comes back marked answered, on the report's own seam
|
||||||
html = c.get("/b/b/").text
|
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||||
assert "recorded:" in html and "bk-done" in html
|
assert "recorded:" in m["whole"] and "bk-done" in m["whole"]
|
||||||
assert 'value="keep" required checked' in html.replace("\n", " ") or "checked" in html
|
assert "checked" in m["questions"][0]["html"]
|
||||||
|
|
||||||
|
|
||||||
def test_whole_ask_placeholder_renders_everything_there(client):
|
def test_the_page_carries_no_fragment_styles(client):
|
||||||
c, data = client
|
"""`styles()` is gone from the template: the scoped `.bk-ask-*` rules live in
|
||||||
b = _ask(data / "b")
|
embed.js, next to the code that mounts them. One asset, emitted once by
|
||||||
(b / "index.html").write_text('<!doctype html><body><p>x</p><div data-booth-ask="winner"></div></body>')
|
construction rather than by a seen-set."""
|
||||||
html = c.get("/b/b/").text
|
|
||||||
assert html.index("Which render wins?") > html.index("<p>x</p>")
|
|
||||||
assert html.index("bk-ask-go") < html.index("</body>") # submit placed inline too
|
|
||||||
|
|
||||||
|
|
||||||
def test_placeholder_for_a_missing_ask_is_left_alone(client):
|
|
||||||
c, data = client
|
|
||||||
b = _ask(data / "b")
|
|
||||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="typo"></div></body>')
|
|
||||||
html = c.get("/b/b/").text
|
|
||||||
assert 'data-booth-ask="typo"' in html # author's markup untouched, not blanked
|
|
||||||
assert "Which render wins?" in html # the real ask still appended, never lost
|
|
||||||
|
|
||||||
|
|
||||||
def test_questions_placed_without_a_submit_still_get_one(client):
|
|
||||||
c, data = client
|
|
||||||
b = _multi(data / "b")
|
|
||||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch:r1"></div></body>')
|
|
||||||
html = c.get("/b/b/").text
|
|
||||||
assert html.count('<form id="bk-ask-form-batch"') == 1 # appended, so it is submittable
|
|
||||||
assert 'name="choice.r2"' in html # r2 unplaced -> must still appear
|
|
||||||
|
|
||||||
|
|
||||||
def test_styles_are_emitted_once(client):
|
|
||||||
c, data = client
|
c, data = client
|
||||||
b = _multi(data / "b")
|
b = _multi(data / "b")
|
||||||
(b / "index.html").write_text(REPORT)
|
(b / "index.html").write_text(REPORT)
|
||||||
assert c.get("/b/b/").text.count(".bk-ask-opt:has(input:checked)") == 1
|
assert ".bk-ask-opt:has(input:checked)" not in c.get("/b/b/").text
|
||||||
|
assert c.get("/_booth/embed.js").text.count(".bk-ask-opt:has(input:checked)") == 1
|
||||||
|
|
||||||
|
|
||||||
def test_radios_are_not_html_required_anywhere(client):
|
def test_radios_are_not_html_required_anywhere(client):
|
||||||
@@ -566,20 +570,20 @@ def test_radios_are_not_html_required_anywhere(client):
|
|||||||
is exactly what stopped the operator leaving one blank."""
|
is exactly what stopped the operator leaving one blank."""
|
||||||
c, data = client
|
c, data = client
|
||||||
b = _multi(data / "b")
|
b = _multi(data / "b")
|
||||||
assert "required" not in c.get("/b/b/").text
|
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
|
assert "required" not in m["whole"]
|
||||||
assert "required" not in c.get("/b/b/").text
|
assert not any("required" in q["html"] for q in m["questions"])
|
||||||
assert "required" not in c.get("/b/b/marks").text
|
assert "required" not in c.get("/b/b/marks").text
|
||||||
|
|
||||||
|
|
||||||
def test_partial_answer_renders_as_skipped_inline(client):
|
def test_partial_answer_renders_as_skipped(client):
|
||||||
c, data = client
|
c, data = client
|
||||||
b = _multi(data / "b")
|
b = _multi(data / "b")
|
||||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
|
(b / "index.html").write_text(REPORT)
|
||||||
c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"})
|
c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"})
|
||||||
html = c.get("/b/b/").text
|
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||||
assert "bk-skip" in html and "left blank" in html
|
assert "bk-skip" in m["whole"] and "left blank" in m["whole"]
|
||||||
assert "1 of 2 answered" in html
|
assert "1 of 2 answered" in m["submit"]
|
||||||
|
|
||||||
|
|
||||||
def test_empty_submission_is_refused_with_400(client):
|
def test_empty_submission_is_refused_with_400(client):
|
||||||
|
|||||||
@@ -0,0 +1,889 @@
|
|||||||
|
"""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
|
||||||
|
("http://[::1]:80/a", "http://[::1]/a"), # default port, bracketed
|
||||||
|
("http://[::1]/A", "http://[::1]/A"), # bracket round-trips
|
||||||
|
])
|
||||||
|
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
|
||||||
|
# A NON-DEFAULT PORT IS PART OF THE IDENTITY. Without this vector, "always
|
||||||
|
# omit the port" passes every other row in this file — caught by the cold
|
||||||
|
# panel's per-invariant "what would still pass" pass, not by us.
|
||||||
|
("http://x.test:8092/p", "http://x.test/p"),
|
||||||
|
("https://x.test:8443/p", "https://x.test/p"),
|
||||||
|
])
|
||||||
|
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():
|
||||||
|
"""INV-4. Defeating change: dropping the `id` tie-break.
|
||||||
|
|
||||||
|
THIS TEST USED TO GO THROUGH THE REGISTRY AND COULD NOT FAIL. `_write_all`
|
||||||
|
serializes with `sort_keys=True`, so whatever order two benches were
|
||||||
|
inserted in, they came back off disk already id-sorted — and removing the
|
||||||
|
tie-break from `order_benches` left it green. A vacuous falsifier of
|
||||||
|
exactly the shape persistent-memory.d/2026-09-22-vacuous-falsifiers.md
|
||||||
|
describes: it asserted the outcome the author had in mind rather than the
|
||||||
|
discriminator the invariant names. Caught by the cold panel (hulda, solo),
|
||||||
|
not by us.
|
||||||
|
|
||||||
|
So it calls `order_benches` DIRECTLY, with records that tie on both prior
|
||||||
|
keys, presented in both orders. Nothing upstream can pre-sort them.
|
||||||
|
"""
|
||||||
|
def recs(order):
|
||||||
|
pair = [
|
||||||
|
Bench(id="http://a.test/", url="http://a.test/", name="same name",
|
||||||
|
owner="o", state="live", added="", updated=""),
|
||||||
|
Bench(id="http://b.test/", url="http://b.test/", name="same name",
|
||||||
|
owner="o", state="live", added="", updated=""),
|
||||||
|
]
|
||||||
|
return pair if order else list(reversed(pair))
|
||||||
|
assert [b.id for b in order_benches(recs(0))] == \
|
||||||
|
[b.id for b in order_benches(recs(1))]
|
||||||
|
assert [b.id for b in order_benches(recs(1))] == ["http://a.test/", "http://b.test/"]
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
import booth.benches as B
|
||||||
|
_write_raw(tmp_path, '{"http://a/": {"name": "' + "x" * B.BENCHES_MAX_BYTES + '"}}')
|
||||||
|
benches, err = read_benches(tmp_path)
|
||||||
|
assert err and benches == []
|
||||||
|
# AND PROVE THE PARSE WAS NEVER REACHED. Asserting only the eventual result
|
||||||
|
# passes an implementation that loads the whole document and checks its
|
||||||
|
# length afterwards — which costs the caller exactly what the cap exists to
|
||||||
|
# save. Booby-trap json.loads: if it runs, the test says so. Cold panel,
|
||||||
|
# hulda F11.
|
||||||
|
import json as _json
|
||||||
|
tripped = []
|
||||||
|
real = _json.loads
|
||||||
|
|
||||||
|
def trap(*a, **k):
|
||||||
|
tripped.append(True)
|
||||||
|
return real(*a, **k)
|
||||||
|
B.json.loads = trap
|
||||||
|
try:
|
||||||
|
benches, err = read_benches(tmp_path)
|
||||||
|
finally:
|
||||||
|
B.json.loads = real
|
||||||
|
assert err and not tripped, "the oversized registry was parsed before it was refused"
|
||||||
|
|
||||||
|
|
||||||
|
@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)}"
|
||||||
|
# A STRING IMPORT IS INVISIBLE TO THE WALK ABOVE. `__import__("booth.links")`
|
||||||
|
# or `importlib.import_module(...)` inside a function defeats it entirely,
|
||||||
|
# and that is the exact shape someone reaches for when a sibling import is
|
||||||
|
# refused by review. Caught by the cold panel's per-invariant vacuity pass.
|
||||||
|
called = {n.func.id for n in ast.walk(tree)
|
||||||
|
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)}
|
||||||
|
assert "__import__" not in called, "benches.py imports by string, defeating the AST walk"
|
||||||
|
assert "importlib" not in roots, "benches.py can import anything at runtime via importlib"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 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),
|
||||||
|
# THE ACCEPTED COST, MADE EXPLICIT. The predicate is host-agnostic on
|
||||||
|
# purpose — a host allowlist fails OPEN on whichever name somebody reaches
|
||||||
|
# this service by next — so a third-party URL with a `/b/<x>` path reads as
|
||||||
|
# a booth link and is refused. The contract names this trade-off; the table
|
||||||
|
# had no row exercising it, so nothing pinned the behaviour either way.
|
||||||
|
# Cold panel, hulda F10.
|
||||||
|
("https://example.com/b/not-ours/", "not-ours"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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"
|
||||||
|
assert second.updated >= first.updated
|
||||||
|
|
||||||
|
|
||||||
|
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&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"),
|
||||||
|
("OVERSIZED", "over the size cap"),
|
||||||
|
("UNREADABLE", "chmod 000"),
|
||||||
|
("FIFO", "a named pipe"),
|
||||||
|
])
|
||||||
|
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)
|
||||||
|
reg = tmp_path / BENCHES_FILE
|
||||||
|
if payload == "OVERSIZED":
|
||||||
|
from booth.benches import BENCHES_MAX_BYTES
|
||||||
|
reg.write_text('{"http://a/": {"name": "' + "x" * BENCHES_MAX_BYTES + '"}}')
|
||||||
|
elif payload == "UNREADABLE":
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
pytest.skip("root ignores the mode bit")
|
||||||
|
reg.write_text("{}")
|
||||||
|
reg.chmod(0o000)
|
||||||
|
elif payload == "FIFO":
|
||||||
|
os.mkfifo(reg)
|
||||||
|
elif payload is not None:
|
||||||
|
reg.write_text(payload)
|
||||||
|
try:
|
||||||
|
c = _client(tmp_path)
|
||||||
|
body = c.get("/b/links/")
|
||||||
|
assert body.status_code == 200, label
|
||||||
|
assert c.get("/").status_code == 200, label
|
||||||
|
assert c.get("/healthz").status_code == 200, label
|
||||||
|
# AND THE ERROR IS VISIBLE. Asserting only 200 was the gap: a render
|
||||||
|
# that swallowed the failure and drew an empty panel passed every case
|
||||||
|
# here while telling the operator nothing needed fixing. Absent is the
|
||||||
|
# one case that must NOT show an error.
|
||||||
|
shown = "the bench registry could not be read" in body.text
|
||||||
|
assert shown is (payload is not None), label
|
||||||
|
finally:
|
||||||
|
if payload == "UNREADABLE":
|
||||||
|
reg.chmod(0o644)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---- found by the in-session adversarial pass, after the cold panels shipped -
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fifo_at_the_registry_path_cannot_hang_the_render(tmp_path):
|
||||||
|
"""A NAMED PIPE IS NOT A REGULAR FILE, AND open() BLOCKS ON IT.
|
||||||
|
|
||||||
|
This is the 2026-09-22 lesson recurring in a new file: a size cap that
|
||||||
|
bounds the READ does not help, because the hang is in `open()` — a FIFO
|
||||||
|
with no writer blocks there forever, before a single byte is bounded.
|
||||||
|
`read_benches` runs on the board page's render path, so one FIFO would hang
|
||||||
|
that request and, with enough hits, the threadpool behind every route.
|
||||||
|
|
||||||
|
The guard is a REGULAR-FILE check before the open, which is what marks.py
|
||||||
|
already does (`stat.S_ISREG`). Defeating change: reverting to `path.open()`
|
||||||
|
guarded only by a byte cap — which is what this unit shipped first, while
|
||||||
|
its docstring claimed the cap closed exactly this hole.
|
||||||
|
"""
|
||||||
|
os.mkfifo(tmp_path / BENCHES_FILE)
|
||||||
|
benches, err = read_benches(tmp_path) # must RETURN, not block
|
||||||
|
assert benches == [] and err
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_directory_at_the_registry_path_is_an_error_not_a_crash(tmp_path):
|
||||||
|
(tmp_path / BENCHES_FILE).mkdir()
|
||||||
|
benches, err = read_benches(tmp_path)
|
||||||
|
assert benches == [] and err
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("encoded", ["%00", "%0a", "%0d", "%09", "%1b"])
|
||||||
|
def test_a_control_character_is_not_an_addressable_booth(encoded):
|
||||||
|
"""`unquote` happily produces a NUL or a newline, and neither can name a
|
||||||
|
real directory. Left unfiltered they reach `is_dir()` (which raises
|
||||||
|
ValueError on an embedded NUL on some paths), the refusal message the CLI
|
||||||
|
prints, and the marker the board renders. Defeating change: dropping the
|
||||||
|
control-character clause — the `%2e%2e` and `%2f` rows above stay green
|
||||||
|
under it, so this needs its own."""
|
||||||
|
assert booth_target(f"http://h:8090/b/{encoded}/") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalization_is_idempotent(tmp_path):
|
||||||
|
"""LOAD-BEARING for `bench state <id|url>` and `bench rm <id|url>`: both
|
||||||
|
normalize whatever they are handed, so an id must normalize to itself or
|
||||||
|
addressing a bench by the id the registry stores would miss it. Defeating
|
||||||
|
change: any rule that rewrites an already-normalized form."""
|
||||||
|
for u in (GITEA_EIGHT + TALK_FIVE + [
|
||||||
|
"http://x.test/", "http://x.test:8080/p/", "https://x.test/?a=1",
|
||||||
|
"HTTP://X.Test:80/Some%20Path/?b=2&a=1#frag",
|
||||||
|
]):
|
||||||
|
once = normalize_bench_url(u)
|
||||||
|
assert normalize_bench_url(once) == once, u
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_write_leaves_no_scratch_file(tmp_path, monkeypatch):
|
||||||
|
"""The temp file is named per-pid so two writers cannot share it, but a
|
||||||
|
write that dies between create and replace would strand it beside the
|
||||||
|
registry forever. Defeating change: dropping the cleanup."""
|
||||||
|
import booth.benches as B
|
||||||
|
upsert_bench(tmp_path, "http://a.test/", "one", "o")
|
||||||
|
real = B.os.replace
|
||||||
|
|
||||||
|
def boom(src, dst):
|
||||||
|
raise OSError("disk full")
|
||||||
|
monkeypatch.setattr(B.os, "replace", boom)
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
upsert_bench(tmp_path, "http://b.test/", "two", "o")
|
||||||
|
monkeypatch.setattr(B.os, "replace", real)
|
||||||
|
strays = [p.name for p in tmp_path.iterdir() if ".tmp" in p.name]
|
||||||
|
assert not strays, strays
|
||||||
|
# and the prior registry is intact — a failed write destroys nothing
|
||||||
|
assert [b.name for b in read_benches(tmp_path)[0]] == ["one"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_ipv6_literal_keeps_its_brackets():
|
||||||
|
"""`urlsplit().hostname` strips them, and a netloc rebuilt from it is not
|
||||||
|
another spelling of the URL — it is a broken one, so a re-post never
|
||||||
|
matches the row the operator means to update. Defeating change: rebuilding
|
||||||
|
netloc from `hostname` with no re-wrap, which is what this shipped as."""
|
||||||
|
assert normalize_bench_url("http://[::1]:8080/a") == "http://[::1]:8080/a"
|
||||||
|
assert normalize_bench_url("http://[2001:DB8::1]/p") == "http://[2001:db8::1]/p"
|
||||||
|
assert normalize_bench_url("HTTP://[::1]:80/p") == "http://[::1]/p"
|
||||||
|
# An UNBRACKETED IPv6 netloc is refused with a reason, not repaired:
|
||||||
|
# `urlsplit(...).port` raises on `::1:8080` because it cannot tell the
|
||||||
|
# address from the port — which is precisely why the brackets exist. The
|
||||||
|
# refusal is the honest answer; guessing where the address ends would be
|
||||||
|
# inventing an identity out of an ambiguous string.
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
normalize_bench_url("http://::1:8080/a")
|
||||||
|
|
||||||
|
|
||||||
|
def test_deeply_nested_json_does_not_escape_the_read(tmp_path):
|
||||||
|
"""RecursionError is neither ValueError nor OSError, so it went straight
|
||||||
|
past `read_benches`'s except pair and 500'd the page the function exists to
|
||||||
|
protect. The byte cap does not help: 200k open brackets is 200 KB, well
|
||||||
|
inside it. Defeating change: dropping the RecursionError arm."""
|
||||||
|
(tmp_path / BENCHES_FILE).write_text("[" * 200_000)
|
||||||
|
benches, err = read_benches(tmp_path)
|
||||||
|
assert benches == [] and err
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_panel_renders_on_an_EMPTY_board(tmp_path):
|
||||||
|
"""The panel is gated on PAGE IDENTITY, not page content. Gating on
|
||||||
|
`board or benches` hid the panel and its registration form exactly when the
|
||||||
|
board was empty and the registry absent — the state a new deployment starts
|
||||||
|
in, and the one where "no benches registered yet" is most worth saying.
|
||||||
|
Defeating change: any content-derived gate."""
|
||||||
|
_board(tmp_path, "")
|
||||||
|
body = _client(tmp_path).get("/b/links/").text
|
||||||
|
assert "no benches registered yet" in body
|
||||||
|
assert "bench-add" in body, "the registration form vanished with the panel"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_panel_shows_when_a_bench_was_added(tmp_path):
|
||||||
|
"""INV-N/What renders: the contract says the panel shows the date it was
|
||||||
|
added; `b.added` appeared nowhere in the template and no test asked. All
|
||||||
|
four cold arms found this independently."""
|
||||||
|
_board(tmp_path, ROW_REF)
|
||||||
|
upsert_bench(tmp_path, "https://talk.test/", "talk", "o")
|
||||||
|
added = read_benches(tmp_path)[0][0].added[:10]
|
||||||
|
assert added in _client(tmp_path).get("/b/links/").text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url,expected", BOOTH_URL_TABLE)
|
||||||
|
def test_the_dead_marker_classifies_the_SAME_table(tmp_path, url, expected):
|
||||||
|
"""INV-2 names RENDER-LEVEL agreement, and the marker tests never ran the
|
||||||
|
table — three hand-written rows with no query between them, so a marker
|
||||||
|
that stopped calling `booth_target` and treated `?q=1` as "not a booth"
|
||||||
|
stayed green while disagreeing with the CLI. Caught by the cold panel.
|
||||||
|
|
||||||
|
Every row whose target does not exist on disk must be marked dead; every
|
||||||
|
non-booth row must not be."""
|
||||||
|
_board(tmp_path, f"- [r]({url}) <sub>· x · 2026-09-01 00:00</sub>\n")
|
||||||
|
marked = bool(_dead_rows(_client(tmp_path).get("/b/links/").text))
|
||||||
|
assert marked is (expected is not None), (url, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_updated_is_replaced_and_added_is_not(tmp_path, monkeypatch):
|
||||||
|
"""The other half of `test_added_survives_reregistration_updated_does_not`,
|
||||||
|
which asserted only the half in the first clause of its own name.
|
||||||
|
|
||||||
|
The stamp has SECOND resolution, so a fast test cannot tell a replaced
|
||||||
|
`updated` from a frozen one by comparing real clocks — `>=` passes either
|
||||||
|
way, which is a falsifier that cannot fail. The clock is driven instead, so
|
||||||
|
"was it rewritten" is answerable. Cold panel, hulda F12.
|
||||||
|
|
||||||
|
Defeating change: carrying `updated` forward from the prior record the way
|
||||||
|
`added` is carried, which every real-clock assertion in this file survives.
|
||||||
|
"""
|
||||||
|
import booth.benches as B
|
||||||
|
ticks = iter(["2026-01-01T00:00:00+00:00",
|
||||||
|
"2026-06-06T06:06:06+00:00",
|
||||||
|
"2026-12-31T23:59:59+00:00"])
|
||||||
|
monkeypatch.setattr(B, "_now", lambda: next(ticks))
|
||||||
|
|
||||||
|
first, _ = upsert_bench(tmp_path, "http://a.test/", "one", "o")
|
||||||
|
assert first.added == first.updated == "2026-01-01T00:00:00+00:00"
|
||||||
|
|
||||||
|
second, _ = upsert_bench(tmp_path, "http://a.test/", "two", "o")
|
||||||
|
assert second.added == "2026-01-01T00:00:00+00:00", "added must survive an upsert"
|
||||||
|
assert second.updated == "2026-06-06T06:06:06+00:00", "updated must be replaced"
|
||||||
|
|
||||||
|
# A STATE CHANGE IS A MUTATION and bumps it too — this is what the contract
|
||||||
|
# was amended to say, after the panel read "most recent upsert" literally.
|
||||||
|
third = set_bench_state(tmp_path, normalize_bench_url("http://a.test/"), "retired")
|
||||||
|
assert third.added == "2026-01-01T00:00:00+00:00"
|
||||||
|
assert third.updated == "2026-12-31T23:59:59+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_registration_cannot_make_the_registry_unreadable(tmp_path):
|
||||||
|
"""Cold contract panel, hulda solo: the write path permitted a file the
|
||||||
|
reader then refuses on size — so the LAST bench somebody added would be the
|
||||||
|
one that made every other bench invisible, and the write that did it
|
||||||
|
reported success.
|
||||||
|
|
||||||
|
Defeating change: dropping the size check from `_write_all`. The reader is
|
||||||
|
lenient about damage and deliberately NOT lenient about size; a writer
|
||||||
|
ignoring a limit its own reader enforces manufactures exactly the state
|
||||||
|
that leniency exists to survive."""
|
||||||
|
from booth.benches import BENCHES_MAX_BYTES, NAME_MAX
|
||||||
|
n = 0
|
||||||
|
while True:
|
||||||
|
n += 1
|
||||||
|
try:
|
||||||
|
upsert_bench(tmp_path, f"http://h{n}.test/{'p' * 1800}", "x" * NAME_MAX, "o")
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "past" in str(exc) and str(BENCHES_MAX_BYTES) in str(exc)
|
||||||
|
break
|
||||||
|
assert n < 500, "never hit the cap; widen the fixture"
|
||||||
|
# THE REGISTRY IS STILL READABLE, and still holds everything that fit.
|
||||||
|
benches, err = read_benches(tmp_path)
|
||||||
|
assert err is None, err
|
||||||
|
assert len(benches) == n - 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_over_long_stored_url_is_damage_not_a_silent_clip(tmp_path):
|
||||||
|
"""Cold contract panel, 4-of-4 on cap semantics: "applied at the read" did
|
||||||
|
not say TRUNCATE or REFUSE, and the code had picked truncate for every
|
||||||
|
field. For `name` and `owner` that is right — they are display budgets and
|
||||||
|
clipping costs a few characters in a panel row. For `url` it is wrong:
|
||||||
|
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. Defeating change: routing `url` back through `_cap`."""
|
||||||
|
from booth.benches import URL_MAX
|
||||||
|
long_url = "http://a/" + "p" * (URL_MAX + 10)
|
||||||
|
_write_raw(tmp_path, json.dumps({"http://a/": {
|
||||||
|
"url": long_url, "name": "n", "owner": "o", "state": "live"}}))
|
||||||
|
benches, err = read_benches(tmp_path)
|
||||||
|
assert err and benches == [], "an over-long url was clipped into a dead anchor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_name_and_owner_ARE_clipped_at_the_read(tmp_path):
|
||||||
|
"""The other half of the same rule, so the asymmetry is pinned in both
|
||||||
|
directions rather than asserted in one."""
|
||||||
|
from booth.benches import NAME_MAX, OWNER_MAX
|
||||||
|
_write_raw(tmp_path, json.dumps({"http://a/": {
|
||||||
|
"url": "http://a/", "name": "n" * 500, "owner": "o" * 500, "state": "live"}}))
|
||||||
|
benches, err = read_benches(tmp_path)
|
||||||
|
assert err is None
|
||||||
|
assert len(benches[0].name) == NAME_MAX and len(benches[0].owner) == OWNER_MAX
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_benches_panel_is_not_nested_inside_a_span(tmp_path):
|
||||||
|
"""Cold bug-hunt panel, 3-of-4, seat-confirmed by byte offset in the live
|
||||||
|
document: the panel `<div>` had landed INSIDE the booth header's
|
||||||
|
`<span class="sub">`, because the insertion matched the first
|
||||||
|
`{% if board %}` in the template rather than the block-level one.
|
||||||
|
|
||||||
|
A `<div>` inside a `<span>` is invalid HTML — the parser closes the span
|
||||||
|
implicitly and hoists the div out, orphaning the rest of the sub-line. It
|
||||||
|
renders "fine" in the sense that nothing 500s, which is exactly why no
|
||||||
|
other test in this file could see it.
|
||||||
|
|
||||||
|
Checked the way the seat checked it: by offset. Defeating change: moving
|
||||||
|
the panel back above the sub-span's close."""
|
||||||
|
_board(tmp_path, ROW_REF)
|
||||||
|
upsert_bench(tmp_path, "https://talk.test/", "talk", "o")
|
||||||
|
body = _client(tmp_path).get("/b/links/").text
|
||||||
|
sub_open = body.index('<span class="sub">')
|
||||||
|
sub_close = body.index("</span>", body.index("· ", sub_open))
|
||||||
|
panel = body.index('<div class="benches">')
|
||||||
|
assert not (sub_open < panel < sub_close), (
|
||||||
|
f"the benches div (offset {panel}) sits inside the sub span "
|
||||||
|
f"({sub_open}..{sub_close})")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_symlinked_booth_is_dead_to_the_marker_as_it_is_to_the_page(tmp_path):
|
||||||
|
"""Cold bug-hunt panel, 3-of-4: `_booth_exists` used a bare `is_dir()`
|
||||||
|
while `resolve_booth` resolves and requires the parent to BE the data root.
|
||||||
|
They disagreed on a symlink — the marker called a booth pointing outside
|
||||||
|
the root alive while the page 404s it, so the row rendered healthy and the
|
||||||
|
link was dead. The worst of both, and invisible.
|
||||||
|
|
||||||
|
Defeating change: dropping the containment check from `_booth_exists`."""
|
||||||
|
outside = tmp_path.parent / f"outside-{tmp_path.name}"
|
||||||
|
outside.mkdir()
|
||||||
|
try:
|
||||||
|
(tmp_path / "escapee").symlink_to(outside, target_is_directory=True)
|
||||||
|
except OSError:
|
||||||
|
pytest.skip("no symlink support here")
|
||||||
|
_board(tmp_path, "- [x](http://h:8090/b/escapee/) <sub>· a · 2026-09-01 00:00</sub>\n")
|
||||||
|
c = _client(tmp_path)
|
||||||
|
body = c.get("/b/links/")
|
||||||
|
assert body.status_code == 200
|
||||||
|
# the page's own verdict on that name, which the marker must agree with
|
||||||
|
assert c.get("/b/escapee/").status_code == 404
|
||||||
|
assert _dead_rows(body.text), "the marker called a booth alive that the page 404s"
|
||||||
+21
-57
@@ -16,7 +16,7 @@ from booth.app import (
|
|||||||
remove_link_entry,
|
remove_link_entry,
|
||||||
toggle_pin,
|
toggle_pin,
|
||||||
booth_age_seconds,
|
booth_age_seconds,
|
||||||
FAVICON_LINK,
|
EMBED_SCRIPT_TAG,
|
||||||
KEEP_MARKER,
|
KEEP_MARKER,
|
||||||
build_gallery,
|
build_gallery,
|
||||||
classify,
|
classify,
|
||||||
@@ -30,7 +30,6 @@ from booth.app import (
|
|||||||
render_doc,
|
render_doc,
|
||||||
safe_upload_name,
|
safe_upload_name,
|
||||||
sweep_once,
|
sweep_once,
|
||||||
wrap_verbatim_html,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
PICKUP_RE = re.compile(r"^(\d{1,2}-[a-z]+|[a-z]+-\d{1,2})$")
|
PICKUP_RE = re.compile(r"^(\d{1,2}-[a-z]+|[a-z]+-\d{1,2})$")
|
||||||
@@ -459,58 +458,21 @@ def test_view_nonviewable_redirects_to_raw(client):
|
|||||||
assert r.headers["location"] == "/b/run1/data.bin"
|
assert r.headers["location"] == "/b/run1/data.bin"
|
||||||
|
|
||||||
|
|
||||||
# ---- verbatim-index.html wrapper --------------------------------------------
|
# ---- verbatim-index.html serving -------------------------------------------
|
||||||
|
#
|
||||||
|
# U3 replaced the injection wrapper with a declared seam. The five `test_wrap_*`
|
||||||
|
# tests and `test_verbatim_booth_wrapped_with_back_chip` that stood here tested
|
||||||
|
# `wrap_verbatim_html` — six regexes hunting a head-ish seam for a favicon and a
|
||||||
|
# body-ish seam for a chip, plus the doctype and charset-window constraints they
|
||||||
|
# threaded. None of those constraints can be violated by an append, so there is
|
||||||
|
# nothing left of them to assert. What replaced them lives in tests/test_embed.py
|
||||||
|
# (the payload, the one appended tag, whole-body equality for a declaring page)
|
||||||
|
# and tests/test_embed_browser.py (the mount, in a real DOM).
|
||||||
|
#
|
||||||
|
# What stays here is what did NOT change: the file route is still raw.
|
||||||
|
|
||||||
|
|
||||||
def test_wrap_injects_chip_and_favicon():
|
def test_verbatim_booth_is_served_with_the_seam(client):
|
||||||
html = "<html><head><title>Brief</title></head><body><h1>REPORT</h1></body></html>"
|
|
||||||
out = wrap_verbatim_html(html)
|
|
||||||
assert 'class="booth-nav-home"' in out # floating back chip
|
|
||||||
assert 'href="/"' in out # points at the main booth index
|
|
||||||
assert "all booths" in out
|
|
||||||
assert FAVICON_LINK in out # favicon inherited
|
|
||||||
assert "<h1>REPORT</h1>" in out # original content preserved
|
|
||||||
# favicon lands in the head, chip lands in the body
|
|
||||||
assert out.index(FAVICON_LINK) < out.index("</head>")
|
|
||||||
assert out.index("booth-nav-home") > out.index("<body>")
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrap_respects_existing_favicon():
|
|
||||||
html = '<html><head><link rel="icon" href="data:image/png;base64,AAAA"></head><body>x</body></html>'
|
|
||||||
out = wrap_verbatim_html(html)
|
|
||||||
assert FAVICON_LINK not in out # the page's own icon wins
|
|
||||||
assert out.count('rel="icon"') == 1
|
|
||||||
assert 'class="booth-nav-home"' in out # chip is still added
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrap_bare_fragment_appends_chip():
|
|
||||||
out = wrap_verbatim_html("<h1>bare fragment</h1>") # no doctype/head/body
|
|
||||||
assert 'class="booth-nav-home"' in out
|
|
||||||
assert out.rstrip().endswith("</style>") # chip appended at the end
|
|
||||||
assert FAVICON_LINK in out # no doctype -> safe to prepend the icon
|
|
||||||
assert out.index(FAVICON_LINK) < out.index("bare") # icon ahead of content (implied head)
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrap_no_head_injects_favicon():
|
|
||||||
out = wrap_verbatim_html("<body><h1>no head</h1></body>")
|
|
||||||
assert 'class="booth-nav-home"' in out
|
|
||||||
assert FAVICON_LINK in out # injected even without an explicit <head>
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrap_compact_doctype_stays_first():
|
|
||||||
# the real-booth shape: compact HTML, no explicit head/body. The injection must
|
|
||||||
# not push anything ahead of the doctype (quirks mode) or past the charset window.
|
|
||||||
html = "<!doctype html><meta charset=utf-8><title>T</title><style>body{margin:0}</style><h1>REPORT</h1>"
|
|
||||||
out = wrap_verbatim_html(html)
|
|
||||||
assert out.lstrip().lower().startswith("<!doctype") # doctype still first -> standards mode
|
|
||||||
assert FAVICON_LINK in out
|
|
||||||
assert out.index(FAVICON_LINK) < out.index("<h1>") # icon in the implied head, before content
|
|
||||||
assert out.index("charset") < 1024 # charset meta stays in the detection window
|
|
||||||
assert 'class="booth-nav-home"' in out
|
|
||||||
assert out.index("booth-nav-home") > out.index("<h1>REPORT</h1>") # chip appended after content
|
|
||||||
|
|
||||||
|
|
||||||
def test_verbatim_booth_wrapped_with_back_chip(client):
|
|
||||||
c, data = client
|
c, data = client
|
||||||
d = data / "brief"
|
d = data / "brief"
|
||||||
d.mkdir()
|
d.mkdir()
|
||||||
@@ -518,13 +480,11 @@ def test_verbatim_booth_wrapped_with_back_chip(client):
|
|||||||
r = c.get("/b/brief/")
|
r = c.get("/b/brief/")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "BRIEF" in r.text # content preserved
|
assert "BRIEF" in r.text # content preserved
|
||||||
assert 'class="booth-nav-home"' in r.text # back chip injected
|
assert r.text.endswith(EMBED_SCRIPT_TAG) # ...and the seam, appended
|
||||||
assert 'href="/"' in r.text
|
|
||||||
assert 'rel="icon"' in r.text # favicon inherited
|
|
||||||
|
|
||||||
|
|
||||||
def test_verbatim_index_raw_file_route_unwrapped(client):
|
def test_verbatim_index_raw_file_route_unwrapped(client):
|
||||||
# the file route (/b/<name>/index.html) still serves the raw bytes — the chip
|
# the file route (/b/<name>/index.html) still serves the raw bytes — the seam
|
||||||
# only rides on the booth view (/b/<name>/), so downloads/assets stay verbatim
|
# only rides on the booth view (/b/<name>/), so downloads/assets stay verbatim
|
||||||
c, data = client
|
c, data = client
|
||||||
d = data / "brief"
|
d = data / "brief"
|
||||||
@@ -532,7 +492,7 @@ def test_verbatim_index_raw_file_route_unwrapped(client):
|
|||||||
(d / "index.html").write_text("<html><body><h1>BRIEF</h1></body></html>")
|
(d / "index.html").write_text("<html><body><h1>BRIEF</h1></body></html>")
|
||||||
r = c.get("/b/brief/index.html")
|
r = c.get("/b/brief/index.html")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "booth-nav-home" not in r.text
|
assert "_booth/embed.js" not in r.text
|
||||||
|
|
||||||
|
|
||||||
# ---- .md / .txt in-booth doc viewer -----------------------------------------
|
# ---- .md / .txt in-booth doc viewer -----------------------------------------
|
||||||
@@ -781,6 +741,10 @@ def test_releasing_a_board_RESETS_its_ttl_clock(tmp_path):
|
|||||||
|
|
||||||
(kept / KEEP_MARKER).unlink()
|
(kept / KEEP_MARKER).unlink()
|
||||||
|
|
||||||
|
# Unlinking the sentinel by hand, which is what this test is about: the
|
||||||
|
# directory-entry change is what moves the clock. Releasing through the
|
||||||
|
# ROUTE now also records a view, so the behaviour is stated rather than
|
||||||
|
# incidental — `test_releasing_a_board_RECORDS_A_VIEW` in test_lifetime.py.
|
||||||
assert booth_age_seconds(kept) < 60, "unlink bumped the dir mtime"
|
assert booth_age_seconds(kept) < 60, "unlink bumped the dir mtime"
|
||||||
assert sweep_once(tmp_path, ttl_seconds=3600) == [], "so it is NOT swept yet"
|
assert sweep_once(tmp_path, ttl_seconds=3600) == [], "so it is NOT swept yet"
|
||||||
assert kept.exists()
|
assert kept.exists()
|
||||||
|
|||||||
@@ -346,3 +346,316 @@ def test_answer_does_not_poll_forever_on_a_pick_that_cannot_be_answered(tmp_path
|
|||||||
"BOOTH_URL": "http://booth.invalid"})
|
"BOOTH_URL": "http://booth.invalid"})
|
||||||
assert r.returncode != 0
|
assert r.returncode != 0
|
||||||
assert "broken" in r.stderr.lower() or "cannot" in r.stderr.lower()
|
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
|
||||||
|
from booth.benches import normalize_bench_url as normalize_bench_url_cli # 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()
|
||||||
|
before = sorted(p.name for p in data.iterdir())
|
||||||
|
r = run(data, "link", "http://10.100.10.50:8090/b/some-booth/", "nope")
|
||||||
|
assert r.returncode == REFUSED
|
||||||
|
assert not board.exists(), "a refused link created the board directory"
|
||||||
|
# NOTHING AT ALL, not just no board. Asserting only `links/`'s absence let
|
||||||
|
# a refusal that touched `.benches.lock` (or any other sidecar) on its way
|
||||||
|
# out stay green — the cold panel's vacuity pass named exactly that.
|
||||||
|
assert sorted(p.name for p in data.iterdir()) == before, "a refused link wrote something"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
# THE ID, WHOLE AND UNTRUNCATED, because it is the locator `bench state`
|
||||||
|
# and `bench rm` take. An earlier version of this test had a docstring
|
||||||
|
# claiming `bench ls` prints ids and asserted nothing of the kind, while
|
||||||
|
# the code printed a url truncated to 52 columns — a claim standing in for
|
||||||
|
# evidence, which is how the drift would have survived CI. Found by all
|
||||||
|
# four cold arms independently.
|
||||||
|
bid = normalize_bench_url_cli("https://talk.nh3.phasefinal.com:8092/")
|
||||||
|
assert bid in r.stdout, r.stdout
|
||||||
|
# and what ls prints is addressable, end to end
|
||||||
|
line = [l for l in r.stdout.splitlines() if "talk v2" in l][0]
|
||||||
|
printed_id = line.split()[-1]
|
||||||
|
assert run(data, "bench", "state", printed_id, "promoted").returncode == OK
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
This used to invoke both verbs with the URL only, twice, while its docstring
|
||||||
|
claimed it covered the id — the same claim-not-evidence shape as the `ls`
|
||||||
|
docstring. A raw URL whose normalization DIFFERS from it is used, so the two
|
||||||
|
columns are genuinely distinct inputs. Cold panel, regin F8.
|
||||||
|
"""
|
||||||
|
data, _ = booth
|
||||||
|
raw = "HTTP://X.Test:80/p/?b=2&a=1#frag"
|
||||||
|
bid = normalize_bench_url_cli(raw)
|
||||||
|
assert bid != raw, "pick a URL whose normalization actually differs"
|
||||||
|
run(data, "bench", "add", raw, "ex")
|
||||||
|
# by the ID the registry stores
|
||||||
|
assert run(data, "bench", "state", bid, "retired").returncode == OK
|
||||||
|
assert "retired" in run(data, "bench", "ls").stdout
|
||||||
|
# and by the RAW URL the operator has in their scrollback
|
||||||
|
assert run(data, "bench", "state", raw, "live").returncode == OK
|
||||||
|
assert "live" in run(data, "bench", "ls").stdout
|
||||||
|
assert run(data, "bench", "rm", raw).returncode == OK
|
||||||
|
run(data, "bench", "add", raw, "ex again")
|
||||||
|
assert run(data, "bench", "rm", bid).returncode == OK
|
||||||
|
assert "ex" not in run(data, "bench", "ls").stdout
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
# THE RAW URL BESIDE THE NORMALIZED ID, which is the entire point of the
|
||||||
|
# proposal: five rows of `talk` collapsing to one is only checkable if you
|
||||||
|
# can see which raw URLs produced the one id. This printed the description
|
||||||
|
# instead, so the collapse was invisible in the one place it had to be
|
||||||
|
# visible. All four cold arms found it.
|
||||||
|
assert out.count("https://talk.nh3.phasefinal.com:8092/") >= 2, out
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_apply_refuses_and_writes_nothing(booth):
|
||||||
|
"""THE SELECTION GAP — all four cold contract-review arms, independently.
|
||||||
|
|
||||||
|
`--apply` used to register every candidate, while the same contract says
|
||||||
|
roughly 14 of 35 are reference bookmarks that must STAY on the board. That
|
||||||
|
made the write path do the exact thing the unit's own rationale calls
|
||||||
|
impossible — tell a bench from a bookmark by its URL — silently, to rows
|
||||||
|
that belong where they are. The dry-run prints ids; `--apply` takes the
|
||||||
|
ones the operator names, and refuses without them.
|
||||||
|
|
||||||
|
Defeating change: restoring the register-everything branch."""
|
||||||
|
data, _ = booth
|
||||||
|
_seed_board(data)
|
||||||
|
r = run(data, "bench", "import", "--apply")
|
||||||
|
assert r.returncode == REFUSED
|
||||||
|
assert "needs the ids" in r.stderr
|
||||||
|
assert not (data / ".benches.json").exists(), "a bare --apply wrote the registry"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_refuses_an_id_that_is_not_a_candidate(booth):
|
||||||
|
data, _ = booth
|
||||||
|
_seed_board(data)
|
||||||
|
r = run(data, "bench", "import", "--apply", "http://not-on-the-board/")
|
||||||
|
assert r.returncode == REFUSED
|
||||||
|
assert "not a candidate id" in r.stderr
|
||||||
|
assert not (data / ".benches.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_registers_ONLY_the_named_ids(booth):
|
||||||
|
"""The bookmark stays a bookmark unless the operator says otherwise."""
|
||||||
|
data, _ = booth
|
||||||
|
_seed_board(data)
|
||||||
|
talk = normalize_bench_url_cli("https://talk.nh3.phasefinal.com:8092/")
|
||||||
|
assert run(data, "bench", "import", "--apply", talk).returncode == OK
|
||||||
|
ls = run(data, "bench", "ls").stdout
|
||||||
|
assert "peedlar" not in ls, "an unnamed candidate was registered anyway"
|
||||||
|
assert len([l for l in ls.splitlines() if "talk" in l]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_apply_collapses_the_repost(booth):
|
||||||
|
data, _ = booth
|
||||||
|
_seed_board(data)
|
||||||
|
talk = normalize_bench_url_cli("https://talk.nh3.phasefinal.com:8092/")
|
||||||
|
repo = normalize_bench_url_cli("https://gitea.phasefinal.com/vh/peedlar")
|
||||||
|
assert run(data, "bench", "import", "--apply", talk, repo).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") # refused, writes nothing
|
||||||
|
run(data, "bench", "import", "--apply",
|
||||||
|
normalize_bench_url_cli("https://talk.nh3.phasefinal.com:8092/"))
|
||||||
|
run(data, "bench", "add", "http://new.test/", "new")
|
||||||
|
run(data, "bench", "state", "http://new.test/", "retired")
|
||||||
|
run(data, "bench", "ls") # a read verb can truncate too
|
||||||
|
run(data, "bench", "rm", "http://new.test/")
|
||||||
|
after = hashlib.sha256((board / "links.md").read_bytes()).hexdigest()
|
||||||
|
assert before == after
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_fails_CLOSED_when_the_booth_check_cannot_run(booth, tmp_path):
|
||||||
|
"""A guard that fails open is not a guard. If `booth.links` cannot be
|
||||||
|
imported, `booth link` must post NOTHING and say why — not append the row
|
||||||
|
it could not classify, and not abort with a bare traceback.
|
||||||
|
|
||||||
|
Defeating change: dropping the `|| pred_rc=$?` handling, which under
|
||||||
|
`set -e` aborts with a Python traceback (safe, but unactionable), or
|
||||||
|
treating a failed check as "not a booth" (unsafe — fails open)."""
|
||||||
|
data, _ = booth
|
||||||
|
lone = tmp_path / "lone" / "scripts"
|
||||||
|
lone.mkdir(parents=True)
|
||||||
|
(lone / "booth").write_text(SCRIPT.read_text())
|
||||||
|
(lone / "booth").chmod(0o755)
|
||||||
|
r = subprocess.run([str(lone / "booth"), "link", "https://ok.test/x", "a bookmark"],
|
||||||
|
capture_output=True, text=True, cwd="/tmp", timeout=30,
|
||||||
|
env={**os.environ, "BOOTH_DATA_DIR": str(data),
|
||||||
|
"BOOTH_URL": "http://booth.invalid"})
|
||||||
|
assert r.returncode != OK
|
||||||
|
assert "could not check" in r.stderr, r.stderr
|
||||||
|
assert not (data / "links" / "links.md").exists(), "a row landed despite an unusable check"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_credential_never_reaches_the_board(booth):
|
||||||
|
"""`normalize_bench_url` refuses userinfo for a bench; `booth link` was the
|
||||||
|
door this unit did not touch, and the board renders on an unauthenticated
|
||||||
|
LAN surface. Cold contract panel, groa solo. A deliberate small widening of
|
||||||
|
the unit, named rather than smuggled."""
|
||||||
|
data, _ = booth
|
||||||
|
r = run(data, "link", "https://user:hunter2@x.test/p", "leaky")
|
||||||
|
assert r.returncode != OK
|
||||||
|
assert "credentials" in r.stderr
|
||||||
|
assert not (data / "links").exists(), "a credentialed URL created the board"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_append_happens_INSIDE_the_lock(booth):
|
||||||
|
"""Cold bug-hunt panel, hulda solo. `flock LOCK printf ... >> board` reads
|
||||||
|
as locked and is not: the SHELL opens the append fd while parsing, before
|
||||||
|
flock acquires. A concurrent `unlink` rewriting the board in that window
|
||||||
|
replaces the inode, the old fd keeps pointing at the unlinked one, and the
|
||||||
|
append succeeds, reports success, and vanishes.
|
||||||
|
|
||||||
|
Proved by holding the lock: if the open were outside it, `booth link` would
|
||||||
|
write and exit while blocked. Defeating change: reverting to the bare
|
||||||
|
`flock LOCK printf ... >>` form, under which this test writes the row.
|
||||||
|
"""
|
||||||
|
import fcntl
|
||||||
|
data, _ = booth
|
||||||
|
board = data / "links"
|
||||||
|
board.mkdir(parents=True)
|
||||||
|
(board / "links.md").write_text("")
|
||||||
|
lock = board / ".links.lock"
|
||||||
|
lock.touch()
|
||||||
|
with lock.open("r+") as lf:
|
||||||
|
fcntl.flock(lf, fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
# subprocess.run directly: `run()` pins timeout=30 itself.
|
||||||
|
with pytest.raises(subprocess.TimeoutExpired):
|
||||||
|
subprocess.run(
|
||||||
|
[str(SCRIPT), "link", "https://ok.test/x", "blocked"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
env={**os.environ, "BOOTH_DATA_DIR": str(data),
|
||||||
|
"BOOTH_URL": "http://booth.invalid"})
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lf, fcntl.LOCK_UN)
|
||||||
|
assert (board / "links.md").read_text() == "", \
|
||||||
|
"the row was appended while another writer held the lock"
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
"""U3 — the declared embed seam, server side.
|
||||||
|
|
||||||
|
The Booth used to reach into a verbatim report with ten regular expressions: six
|
||||||
|
to find somewhere to hang a favicon and a chip, four to substitute rendered ask
|
||||||
|
markup into the author's own tags. This unit replaces all of it with a seam the
|
||||||
|
page declares:
|
||||||
|
|
||||||
|
<script src="/_booth/embed.js" defer></script>
|
||||||
|
|
||||||
|
What is tested here is the SERVER half — the payload that crosses the seam, the
|
||||||
|
one static asset, and the single conditional append that is now the only thing
|
||||||
|
the Booth does to author HTML. The half that mounts fragments into a live DOM
|
||||||
|
lives in tests/test_embed_browser.py, because no amount of string assertion can
|
||||||
|
see whether a form actually submits.
|
||||||
|
|
||||||
|
Contract: docs/contracts/u3_declared_embed_seam.contract.md
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from booth.app import EMBED_SCRIPT_TAG, EMBED_SRC, create_app
|
||||||
|
from booth.marks import answer_pick, declare_pick, set_flag, write_note
|
||||||
|
|
||||||
|
DECLARED = f'<!doctype html><title>r</title><body>hi<script src="{EMBED_SRC}" defer></script></body>'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||||
|
return TestClient(app), tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(booth, stem="winner", **kw):
|
||||||
|
doc = {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
|
||||||
|
doc.update(kw)
|
||||||
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(booth, stem, doc)
|
||||||
|
return booth
|
||||||
|
|
||||||
|
|
||||||
|
def _multi(booth, stem="batch"):
|
||||||
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(booth, stem, {"title": "Round one", "questions": [
|
||||||
|
{"key": "r1", "prompt": "First?", "options": ["a", "b"]},
|
||||||
|
{"key": "r2", "prompt": "Second?", "options": ["a", "b"]},
|
||||||
|
]})
|
||||||
|
return booth
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 1: the payload ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_embed_payload_carries_a_fragment_for_every_shape(client):
|
||||||
|
c, data = client
|
||||||
|
_multi(data / "b")
|
||||||
|
body = c.get("/b/b/embed.json").json()
|
||||||
|
assert body["booth"] == "b"
|
||||||
|
assert "home" not in body, "a value nothing reads is a second copy waiting to drift"
|
||||||
|
assert body["favicon"].startswith("data:image/svg+xml,")
|
||||||
|
(m,) = body["marks"]
|
||||||
|
assert m["id"] == "batch" and m["error"] is None
|
||||||
|
assert "First?" in m["whole"] and "Second?" in m["whole"]
|
||||||
|
assert 'action="/b/b/answer"' in m["submit"]
|
||||||
|
assert [q["key"] for q in m["questions"]] == ["r1", "r2"] # declaration order
|
||||||
|
assert "First?" in m["questions"][0]["html"]
|
||||||
|
assert 'type="radio"' in m["questions"][0]["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_single_question_pick_has_one_question_with_a_null_key(client):
|
||||||
|
"""SR-2. `normalize_ask` gives a single-question ask `key: None`, so the
|
||||||
|
payload cannot key questions by name — JSON would write that as "null" and
|
||||||
|
invent a name. Every one-question ask in the fleet hits this."""
|
||||||
|
c, data = client
|
||||||
|
_pick(data / "b")
|
||||||
|
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||||
|
assert [q["key"] for q in m["questions"]] == [None]
|
||||||
|
assert "Which render wins?" in m["questions"][0]["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_marks_are_ordered_by_creation_before_id(client):
|
||||||
|
"""`created` LEADS. The fixture makes creation order and alphabetical order
|
||||||
|
disagree, because a fixture where they agree cannot tell the stated rule
|
||||||
|
from a plain id sort — which is what the first version of this test did, and
|
||||||
|
what a panel caught by reading the fixture rather than the assertion."""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b", "zebra")
|
||||||
|
_pick(b, "alpha")
|
||||||
|
raw = json.loads((b / ".marks.json").read_text())
|
||||||
|
stamps = {"zebra": "2026-09-22T10:00:00.000000-07:00", # first
|
||||||
|
"alpha": "2026-09-22T11:00:00.000000-07:00"} # second
|
||||||
|
for e in raw["marks"]:
|
||||||
|
e["created"] = stamps[e["id"]]
|
||||||
|
(b / ".marks.json").write_text(json.dumps(raw))
|
||||||
|
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["zebra", "alpha"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_id_is_only_the_tie_break(client):
|
||||||
|
"""And with `created` equal, the id decides — so two marks written in the
|
||||||
|
same second cannot swap between renders."""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b", "zebra")
|
||||||
|
_pick(b, "alpha")
|
||||||
|
raw = json.loads((b / ".marks.json").read_text())
|
||||||
|
for e in raw["marks"]:
|
||||||
|
e["created"] = "2026-09-22T10:00:00.000000-07:00"
|
||||||
|
(b / ".marks.json").write_text(json.dumps(raw))
|
||||||
|
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["alpha", "zebra"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_is_computed_by_the_server_not_the_page(client):
|
||||||
|
"""INV-4. The chip count follows `open_marks`, which is the ONE openness
|
||||||
|
predicate — a half-answered multi-question pick is still open."""
|
||||||
|
c, data = client
|
||||||
|
b = _multi(data / "b")
|
||||||
|
assert c.get("/b/b/embed.json").json()["open"] == ["batch"]
|
||||||
|
answer_pick(b, "batch", {"r1": "a"})
|
||||||
|
assert c.get("/b/b/embed.json").json()["open"] == ["batch"] # partial is OPEN
|
||||||
|
answer_pick(b, "batch", {"r1": "a", "r2": "b"})
|
||||||
|
assert c.get("/b/b/embed.json").json()["open"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_payload_carries_picks_only(client):
|
||||||
|
"""SR-4. Notes and flags never reach it, which is also what keeps a flag's
|
||||||
|
`flag:<target>` id — the one mark id containing the anchor separator — out
|
||||||
|
of a payload whose specs split on the first colon."""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
(b / "shot.png").write_bytes(b"x")
|
||||||
|
write_note(b, None, "a remark")
|
||||||
|
set_flag(b, "shot.png", True)
|
||||||
|
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["winner"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_damaged_marks_file_does_not_500_the_report(client):
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
(b / ".marks.json").write_text("{not json")
|
||||||
|
r = c.get("/b/b/embed.json")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["marks"] == [] and r.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_pick_offers_whole_and_nothing_else(client):
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
raw = json.loads((b / ".marks.json").read_text())
|
||||||
|
raw["marks"][0]["declaration"] = {"prompt": "p"} # no options -> AskError
|
||||||
|
(b / ".marks.json").write_text(json.dumps(raw))
|
||||||
|
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||||
|
assert m["error"] and m["questions"] == [] and m["submit"] == ""
|
||||||
|
assert "broken ask" in m["whole"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_payload_does_not_record_a_view(client):
|
||||||
|
"""`booth_view` already recorded the look, above both of its early returns.
|
||||||
|
A script's fetch of the page it is already on must not count a second time
|
||||||
|
or reset the TTL on machinery instead of on the operator."""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
(b / "index.html").write_text(DECLARED)
|
||||||
|
c.get("/b/b/")
|
||||||
|
before = os.stat(b / ".viewed").st_mtime_ns
|
||||||
|
time.sleep(0.01)
|
||||||
|
c.get("/b/b/embed.json")
|
||||||
|
assert os.stat(b / ".viewed").st_mtime_ns == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_embed_payload_404s_for_an_unknown_booth(client):
|
||||||
|
c, _ = client
|
||||||
|
assert c.get("/b/nope/embed.json").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 2: the one static asset -------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_embed_js_is_served_as_javascript(client):
|
||||||
|
c, _ = client
|
||||||
|
r = c.get(EMBED_SRC)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"].startswith("text/javascript")
|
||||||
|
assert "data-booth-mark" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_embed_js_does_not_hot_reload_from_disk(tmp_path):
|
||||||
|
"""INV-5, and the 2026-09-21 lesson restated. A live asset editable under a
|
||||||
|
running process is how 19 of 25 booths hit 500 with the Python from 22:03
|
||||||
|
and the templates from 23:40. One rule in this repo: nothing takes effect
|
||||||
|
until you restart."""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import booth.app as app_mod
|
||||||
|
|
||||||
|
src = pathlib.Path(app_mod.__file__).parent / "static" / "embed.js"
|
||||||
|
original = src.read_text()
|
||||||
|
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||||
|
c = TestClient(app)
|
||||||
|
try:
|
||||||
|
# Poisoned BEFORE the first request, not between two of them. The
|
||||||
|
# earlier shape passed for a route that read the file lazily and cached
|
||||||
|
# on first use — which is not "read once at startup", and is exactly the
|
||||||
|
# staleness this invariant exists to forbid.
|
||||||
|
src.write_text("/* POISONED */\n")
|
||||||
|
first = c.get(EMBED_SRC).text
|
||||||
|
assert "POISONED" not in first, "embed.js is read at request time, not at startup"
|
||||||
|
assert first == original
|
||||||
|
assert c.get(EMBED_SRC).text == first
|
||||||
|
finally:
|
||||||
|
src.write_text(original)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 3: what the Booth does to author HTML -----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_declaring_page_is_served_untouched(client):
|
||||||
|
"""INV-1. Whole-body equality, not a substring absence: the promise is that
|
||||||
|
NOTHING is added, and an absence assertion cannot tell a clean page from one
|
||||||
|
carrying something nobody thought to look for."""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
(b / "index.html").write_text(DECLARED)
|
||||||
|
assert c.get("/b/b/").text == DECLARED
|
||||||
|
|
||||||
|
|
||||||
|
def test_undeclared_page_gains_only_the_tag(client):
|
||||||
|
"""INV-2. Appended, so the source is a strict prefix — nothing is inserted,
|
||||||
|
nothing is prepended, and neither the doctype nor the charset window moves."""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
src = "<!doctype html><meta charset=utf-8><title>r</title><h1>REPORT</h1>"
|
||||||
|
(b / "index.html").write_text(src)
|
||||||
|
out = c.get("/b/b/").text
|
||||||
|
# The literal, not just the constant: `out == src + EMBED_SCRIPT_TAG` also
|
||||||
|
# holds when EMBED_SCRIPT_TAG is the empty string, which is a mutation this
|
||||||
|
# test exists to catch. A panel found it by reading the assertion, not the
|
||||||
|
# code.
|
||||||
|
assert out == src + '<script src="/_booth/embed.js" defer></script>'
|
||||||
|
assert out == src + EMBED_SCRIPT_TAG
|
||||||
|
assert len(out) > len(src)
|
||||||
|
assert out.lower().lstrip().startswith("<!doctype")
|
||||||
|
assert out.index("charset") < 1024
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_booth_with_no_marks_still_gets_the_seam(client):
|
||||||
|
"""The seam carries the way home and the icon too, so it is not conditional
|
||||||
|
on there being an ask — the old chip was not either."""
|
||||||
|
c, data = client
|
||||||
|
(data / "b").mkdir()
|
||||||
|
(data / "b" / "index.html").write_text("<h1>bare fragment</h1>")
|
||||||
|
assert c.get("/b/b/").text == "<h1>bare fragment</h1>" + EMBED_SCRIPT_TAG
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_declaring_page_is_served_BYTE_for_byte(client):
|
||||||
|
"""INV-1, at the level the promise is actually made.
|
||||||
|
|
||||||
|
The first version read the file with `read_text()`, which opens in
|
||||||
|
universal-newline mode: a report written with CRLF came back with LF, and
|
||||||
|
`errors="replace"` turned any non-UTF-8 byte into U+FFFD. A declaring page
|
||||||
|
was NOT served as its author wrote it — the headline promise — and the
|
||||||
|
original test could not see it, because its fixture was LF-only ASCII.
|
||||||
|
Found by a cross-frontier bug-hunt panel.
|
||||||
|
"""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
src = (b'<!doctype html>\r\n<title>r</title>\r\n<body>caf\xe9 \xff\r\n'
|
||||||
|
b'<script src="/_booth/embed.js" defer></script>\r\n</body>')
|
||||||
|
(b / "index.html").write_bytes(src)
|
||||||
|
r = c.get("/b/b/")
|
||||||
|
assert r.content == src, "the operator's document was edited on the way out"
|
||||||
|
assert b"\r\n" in r.content and b"\xff" in r.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_undeclared_page_keeps_every_byte_and_gains_the_tag(client):
|
||||||
|
"""INV-2, same level: the source is a BYTE-exact prefix of the response."""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
src = b'<!doctype html>\r\n<title>r</title>\r\n<body>caf\xe9 \xff\r\n</body>'
|
||||||
|
(b / "index.html").write_bytes(src)
|
||||||
|
r = c.get("/b/b/")
|
||||||
|
assert r.content == src + EMBED_SCRIPT_TAG.encode("utf-8")
|
||||||
|
assert r.content.startswith(src)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_wrongly_shaped_answer_costs_its_pick_not_the_report(client):
|
||||||
|
"""`marks_for` hydrates `{"answer": {"answers": []}}` with no error — the
|
||||||
|
JSON is well formed, the SHAPE is not — and the template then asks a list
|
||||||
|
for `.get`. This endpoint renders every pick on every load of the operator's
|
||||||
|
report, so an unguarded raise here is the whole seam gone while `hold_read`
|
||||||
|
calls the file perfectly readable. Verified reachable, not assumed."""
|
||||||
|
c, data = client
|
||||||
|
b = data / "b"
|
||||||
|
b.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(b, "batch", {"title": "T", "questions": [
|
||||||
|
{"key": "r1", "prompt": "A?", "options": ["x", "y"]},
|
||||||
|
{"key": "r2", "prompt": "B?", "options": ["x", "y"]}]})
|
||||||
|
_pick(b, "healthy")
|
||||||
|
raw = json.loads((b / ".marks.json").read_text())
|
||||||
|
for e in raw["marks"]:
|
||||||
|
if e["id"] == "batch":
|
||||||
|
e["answer"] = {"answers": [], "notes": ""}
|
||||||
|
(b / ".marks.json").write_text(json.dumps(raw))
|
||||||
|
|
||||||
|
(b / "index.html").write_text(DECLARED)
|
||||||
|
r = c.get("/b/b/embed.json")
|
||||||
|
assert r.status_code == 200
|
||||||
|
by = {m["id"]: m for m in r.json()["marks"]}
|
||||||
|
# THE PROMISE, NOT THE LAYER. This used to pin the string
|
||||||
|
# `_safe_fragments` produces ("could not be rendered"), which made the test
|
||||||
|
# an assertion about WHICH guard fired. As of the `_hydrate` answer-shape
|
||||||
|
# check, this input is caught one layer earlier and never reaches
|
||||||
|
# `_pick_fragments` at all — the endpoint's promise is unchanged and the
|
||||||
|
# error is better (it names what is wrong with the stored answer instead of
|
||||||
|
# reporting a render failure), so the assertion moved to the promise.
|
||||||
|
# `_safe_fragments` is still the backstop and is still falsified, by
|
||||||
|
# `test_safe_fragments_still_catches_what_hydration_cannot` below.
|
||||||
|
assert by["batch"]["error"], "a wrong-shaped answer reported no error"
|
||||||
|
assert "broken ask" in by["batch"]["whole"]
|
||||||
|
# and the booth's other pick is untouched — one bad entry costs one entry
|
||||||
|
assert by["healthy"]["error"] is None
|
||||||
|
assert "Which render wins?" in by["healthy"]["whole"]
|
||||||
|
assert c.get("/b/b/").status_code == 200
|
||||||
|
|
||||||
|
# ⚠ THE GALLERY AND MARKS PAGES USED TO 500 ON THIS ENTRY, and that was NOT
|
||||||
|
# U3's doing — measured at 42ea67f, the commit before that unit. CLOSED
|
||||||
|
# 2026-09-22 at the hydration boundary rather than by a third copy of this
|
||||||
|
# guard: see tests/test_marks.py
|
||||||
|
# ::test_a_wrong_shaped_answer_is_an_error_at_hydration_not_a_500 and
|
||||||
|
# persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_fragments_still_catches_what_hydration_cannot(client):
|
||||||
|
"""U3's `_safe_fragments` guard, kept falsifiable after `_hydrate` took its
|
||||||
|
natural trigger away.
|
||||||
|
|
||||||
|
The answer-shape check in `_hydrate` now catches every wrong answer shape
|
||||||
|
reachable from a `.marks.json` — probed 2026-09-22: `answers` as a list, a
|
||||||
|
string or null all become hydration errors, and a wrong-typed VALUE inside
|
||||||
|
`answers` renders without raising, because Jinja absorbs attribute access
|
||||||
|
on a non-mapping. **No natural input reaches `_safe_fragments` by this
|
||||||
|
route any more**, and a test that kept pretending one did would assert
|
||||||
|
nothing — which is the failure this suite has now paid for twice.
|
||||||
|
|
||||||
|
So the trigger is synthetic and says so: the shared `_ask_inline` macro
|
||||||
|
module is made to raise. `_pick_fragments` resolves `whole` off that object
|
||||||
|
per call, and `create_app` stashes the environment on `app.state`, so this
|
||||||
|
reaches the very object the closure captured. What it pins is the guard
|
||||||
|
itself — one raising pick costs that pick, never the report.
|
||||||
|
|
||||||
|
Defeating change: removing the try/except in `_safe_fragments`, under which
|
||||||
|
this returns 500.
|
||||||
|
"""
|
||||||
|
c, data = client
|
||||||
|
b = data / "b"
|
||||||
|
b.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(b, "batch", {"prompt": "Which?", "options": ["x", "y"]})
|
||||||
|
(b / "index.html").write_text(DECLARED)
|
||||||
|
|
||||||
|
frag = c.app.state.templates.env.get_template("_ask_inline.html").module
|
||||||
|
real_submit = frag.submit
|
||||||
|
|
||||||
|
def explode(*a, **k):
|
||||||
|
raise RuntimeError("synthetic render failure")
|
||||||
|
|
||||||
|
object.__setattr__(frag, "submit", explode)
|
||||||
|
try:
|
||||||
|
assert frag.submit is explode, "the patch did not take; this test is vacuous"
|
||||||
|
r = c.get("/b/b/embed.json")
|
||||||
|
assert r.status_code == 200, "a raising fragment renderer took the whole report"
|
||||||
|
by = {m["id"]: m for m in r.json()["marks"]}
|
||||||
|
assert by["batch"]["error"] and "could not be rendered" in by["batch"]["error"]
|
||||||
|
assert by["batch"]["whole"], "the fallback rendered nothing at all"
|
||||||
|
finally:
|
||||||
|
object.__setattr__(frag, "submit", real_submit)
|
||||||
|
|
||||||
|
# and the guard is not sticky — with the macro restored, the pick is fine
|
||||||
|
assert c.get("/b/b/embed.json").json()["marks"][0]["error"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_handler_survives_the_failure_it_is_handling(client):
|
||||||
|
"""`_safe_fragments` caught a raising `_pick_fragments` and then rebuilt the
|
||||||
|
broken-ask box THROUGH THE SAME MACRO MODULE that had just raised. So when
|
||||||
|
`whole` itself was the broken thing, the handler re-raised and took the
|
||||||
|
whole report — a guard that only worked when the failure was somewhere
|
||||||
|
else.
|
||||||
|
|
||||||
|
Found by accident: the first draft of the falsifier above patched `whole`,
|
||||||
|
and the guard failed rather than caught. Defeating change: removing the
|
||||||
|
inner try/except, under which this returns 500."""
|
||||||
|
c, data = client
|
||||||
|
b = data / "b"
|
||||||
|
b.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(b, "batch", {"prompt": "Which?", "options": ["x", "y"]})
|
||||||
|
(b / "index.html").write_text(DECLARED)
|
||||||
|
|
||||||
|
frag = c.app.state.templates.env.get_template("_ask_inline.html").module
|
||||||
|
real_whole = frag.whole
|
||||||
|
|
||||||
|
def explode(*a, **k):
|
||||||
|
raise RuntimeError("even the fallback macro is broken")
|
||||||
|
|
||||||
|
object.__setattr__(frag, "whole", explode)
|
||||||
|
try:
|
||||||
|
r = c.get("/b/b/embed.json")
|
||||||
|
assert r.status_code == 200, "the handler re-raised through the broken macro"
|
||||||
|
assert r.json()["marks"][0]["error"]
|
||||||
|
finally:
|
||||||
|
object.__setattr__(frag, "whole", real_whole)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_regex_touches_author_html():
|
||||||
|
"""INV-3, and the version that actually falsifies it.
|
||||||
|
|
||||||
|
The first draft of this test name-matched the six deleted patterns. A cold
|
||||||
|
panel pointed out — correctly, and on the contract's CENTRAL promise — that
|
||||||
|
reintroducing the same regex under a new name (`_TAIL_RE`, applied in the
|
||||||
|
verbatim branch) would leave it green. A test that guards names does not
|
||||||
|
guard behaviour, and this repo's own vacuity pass missed it because the
|
||||||
|
mutation it tried was the named one.
|
||||||
|
|
||||||
|
So: `booth/app.py` is allowed EXACTLY ONE regex operation, and it is
|
||||||
|
`ask_form_id`'s `re.sub` over a mark id — not over a page. Any other regex
|
||||||
|
anywhere in the module fails here, whatever it is called. If a future
|
||||||
|
change genuinely needs one, the failure is the conversation: say which
|
||||||
|
string it reads and why it is not author HTML.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import booth.app as app_mod
|
||||||
|
|
||||||
|
root = pathlib.Path(app_mod.__file__).parent
|
||||||
|
assert not (root / "inline.py").exists(), "booth/inline.py survived U3"
|
||||||
|
tree = ast.parse((root / "app.py").read_text())
|
||||||
|
|
||||||
|
# every node -> the function it sits in, so a finding names its site
|
||||||
|
site = {}
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
for child in ast.walk(node):
|
||||||
|
site.setdefault(child, node.name)
|
||||||
|
|
||||||
|
# `re` reaches this module ONE way: a plain module-level `import re`. An
|
||||||
|
# alias (`import re as _r`) or a direct name import (`from re import sub`)
|
||||||
|
# would route around the call check below under a name it does not know —
|
||||||
|
# found by re-running the vacuity pass against the FIXED test, which is the
|
||||||
|
# only reason it is here and is the argument for running that pass on a fix
|
||||||
|
# and not only on a draft.
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
for a in node.names:
|
||||||
|
assert not (a.name == "re" and a.asname), f"`re` aliased as {a.asname}"
|
||||||
|
elif isinstance(node, ast.ImportFrom):
|
||||||
|
assert node.module != "re", f"names imported from re: {[a.name for a in node.names]}"
|
||||||
|
|
||||||
|
METHODS = {"search", "sub", "subn", "match", "fullmatch", "finditer",
|
||||||
|
"findall", "split", "compile", "escape"}
|
||||||
|
found = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
|
||||||
|
continue
|
||||||
|
f = node.func
|
||||||
|
on_re = isinstance(f.value, ast.Name) and f.value.id == "re"
|
||||||
|
on_pattern = (isinstance(f.value, ast.Name) and f.value.id.endswith("_RE")
|
||||||
|
and f.attr in METHODS)
|
||||||
|
if on_re or on_pattern:
|
||||||
|
found.append((site.get(node, "<module level>"), f.attr))
|
||||||
|
|
||||||
|
assert found == [("ask_form_id", "sub")], (
|
||||||
|
f"booth/app.py performs regex operations outside ask_form_id: {found}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# the six named patterns and the chips are gone, and stay gone
|
||||||
|
assigned = {
|
||||||
|
t.id
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Assign)
|
||||||
|
for t in node.targets
|
||||||
|
if isinstance(t, ast.Name)
|
||||||
|
}
|
||||||
|
gone = {"_ICON_RE", "_HEAD_CLOSE_RE", "_HTML_OPEN_RE", "_DOCTYPE_RE",
|
||||||
|
"_BODY_CLOSE_RE", "_HTML_CLOSE_RE", "_BACK_CHIP", "FAVICON_LINK"}
|
||||||
|
assert not (assigned & gone), f"deleted names are back: {sorted(assigned & gone)}"
|
||||||
|
funcs = {n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
|
||||||
|
assert not ({"wrap_verbatim_html", "asks_chip", "inject_asks",
|
||||||
|
"_insert_before", "_insert_after"} & funcs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_page_that_only_mentions_the_path_is_not_declaring_it(client):
|
||||||
|
"""A report that QUOTES the seam — a code sample, a comment, a sentence
|
||||||
|
about this very feature — is not declaring it, and the Booth's own design
|
||||||
|
reports are the pages most likely to do that. Read as declared, such a page
|
||||||
|
would be served untouched and show no chrome at all, silently.
|
||||||
|
|
||||||
|
The detection therefore fails the other way: an unrecognised spelling gets a
|
||||||
|
duplicate tag, and embed.js mounts once regardless.
|
||||||
|
"""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
for body in (
|
||||||
|
"<!doctype html><body><p>add <code>/_booth/embed.js</code> to your report</p></body>",
|
||||||
|
"<!doctype html><body><!-- src=/_booth/embed.js --></body>",
|
||||||
|
'<!doctype html><body><script src="/_booth/embed.js?v=2"></script></body>',
|
||||||
|
):
|
||||||
|
(b / "index.html").write_text(body)
|
||||||
|
assert c.get("/b/b/").text == body + EMBED_SCRIPT_TAG, body
|
||||||
|
|
||||||
|
# and the real declaration, in either quote style, is honoured
|
||||||
|
for decl in (f'<script src="{EMBED_SRC}" defer></script>',
|
||||||
|
f"<script src='{EMBED_SRC}' defer></script>"):
|
||||||
|
body = f"<!doctype html><body>hi{decl}</body>"
|
||||||
|
(b / "index.html").write_text(body)
|
||||||
|
assert c.get("/b/b/").text == body
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_oversize_verbatim_page_is_served_raw(client, monkeypatch):
|
||||||
|
"""WRAP_MAX_BYTES survives: a pathological file is still not pulled into
|
||||||
|
memory, and it loses its chrome exactly as it does today."""
|
||||||
|
import booth.app as app_mod
|
||||||
|
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
(b / "index.html").write_text("<h1>huge</h1>")
|
||||||
|
monkeypatch.setattr(app_mod, "WRAP_MAX_BYTES", 4)
|
||||||
|
assert c.get("/b/b/").text == "<h1>huge</h1>"
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
"""U3 — the declared embed seam, in a real DOM.
|
||||||
|
|
||||||
|
The Python suite can prove what the server OFFERS. It cannot prove where a
|
||||||
|
fragment lands, whether the author's own markup survived the mount, or whether
|
||||||
|
four radio groups scattered down a report still submit as one POST — and that
|
||||||
|
last one is the operator's most important workflow. Before U3 those properties
|
||||||
|
were true by construction, because the server did the placing and the `form=`
|
||||||
|
bindings were static by the time the page was parsed. Now they are true because
|
||||||
|
`/_booth/embed.js` does it in a live document, which is a different kind of
|
||||||
|
claim and needs a different kind of test.
|
||||||
|
|
||||||
|
So: a real uvicorn on an ephemeral port, a real Chromium.
|
||||||
|
|
||||||
|
SKIPS, NEVER FAILS, when playwright or the shared browser is unavailable. The
|
||||||
|
box-wide store at /opt/ms-playwright pins specific Chromium revisions and a
|
||||||
|
playwright release that wants a newer one dies with an opaque "Executable
|
||||||
|
doesn't exist" — see pyproject's version bound. A test layer that goes red for
|
||||||
|
an environment reason teaches nothing and trains people to ignore it.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from booth.app import create_app
|
||||||
|
|
||||||
|
playwright_api = pytest.importorskip(
|
||||||
|
"playwright.sync_api", reason="playwright is not installed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def browser():
|
||||||
|
with playwright_api.sync_playwright() as pw:
|
||||||
|
try:
|
||||||
|
b = pw.chromium.launch()
|
||||||
|
except Exception as exc: # noqa: BLE001 - any launch failure is a skip
|
||||||
|
pytest.skip(f"no usable chromium: {exc}")
|
||||||
|
yield b
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def live(tmp_path):
|
||||||
|
"""A real server, because a browser cannot talk to a TestClient."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||||
|
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
thread = threading.Thread(target=server.run, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while not server.started and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
if not server.started:
|
||||||
|
pytest.skip("uvicorn did not come up")
|
||||||
|
try:
|
||||||
|
yield f"http://127.0.0.1:{port}", tmp_path
|
||||||
|
finally:
|
||||||
|
server.should_exit = True
|
||||||
|
thread.join(timeout=10)
|
||||||
|
|
||||||
|
|
||||||
|
SEAM = '<script src="/_booth/embed.js" defer></script>'
|
||||||
|
|
||||||
|
|
||||||
|
def _multi(booth):
|
||||||
|
from booth.marks import declare_pick
|
||||||
|
|
||||||
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(booth, "batch", {"title": "Round one", "questions": [
|
||||||
|
{"key": "r1", "prompt": "First?", "options": ["keep", "cut"]},
|
||||||
|
{"key": "r2", "prompt": "Second?", "options": ["keep", "cut"]},
|
||||||
|
]})
|
||||||
|
return booth
|
||||||
|
|
||||||
|
|
||||||
|
def _single(booth):
|
||||||
|
from booth.marks import declare_pick
|
||||||
|
|
||||||
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(booth, "winner", {"prompt": "Which render wins?",
|
||||||
|
"options": ["A — baseline", "B — async"]})
|
||||||
|
return booth
|
||||||
|
|
||||||
|
|
||||||
|
def _open(browser, base, name, html, booth):
|
||||||
|
(booth / "index.html").write_text(html, encoding="utf-8")
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(f"{base}/b/{name}/", wait_until="networkidle")
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
def _answer(booth):
|
||||||
|
raw = json.loads((booth / ".marks.json").read_text())
|
||||||
|
return raw["marks"][0].get("answer")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the chrome --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_declaring_page_gets_its_chrome_mounted(browser, live):
|
||||||
|
base, data = live
|
||||||
|
b = _single(data / "b")
|
||||||
|
page = _open(browser, base, "b", f"<!doctype html><title>r</title><body><h1>R</h1>{SEAM}</body>", b)
|
||||||
|
page.wait_for_selector(".booth-nav-home")
|
||||||
|
assert page.locator("h1").inner_text() == "R" # the report is intact
|
||||||
|
assert page.locator(".booth-nav-home").get_attribute("href").endswith("/")
|
||||||
|
# the favicon question, asked of a parsed document instead of raw text
|
||||||
|
assert page.locator('link[rel="icon"]').count() == 1
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_page_that_never_declared_the_seam_still_mounts(browser, live):
|
||||||
|
"""The appended path: every verbatim booth that predates U3 keeps working
|
||||||
|
without its author touching it."""
|
||||||
|
base, data = live
|
||||||
|
b = _single(data / "b")
|
||||||
|
page = _open(browser, base, "b", "<!doctype html><body><h1>OLD</h1></body>", b)
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
assert page.locator("h1").inner_text() == "OLD"
|
||||||
|
assert page.locator(".booth-nav-home").count() == 1
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_page_that_declares_its_own_icon_keeps_it(browser, live):
|
||||||
|
base, data = live
|
||||||
|
b = _single(data / "b")
|
||||||
|
page = _open(
|
||||||
|
browser, base, "b",
|
||||||
|
f'<!doctype html><head><link rel="icon" href="data:image/png;base64,AAAA">'
|
||||||
|
f"</head><body>x{SEAM}</body>", b)
|
||||||
|
page.wait_for_selector(".booth-nav-home")
|
||||||
|
icons = page.locator('link[rel="icon"]')
|
||||||
|
assert icons.count() == 1
|
||||||
|
assert icons.get_attribute("href").startswith("data:image/png")
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- placement ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
REPORT = f"""<!doctype html><title>audition</title><body>
|
||||||
|
<h1>Three voices</h1>
|
||||||
|
<section id="lawson"><audio src="a.wav"></audio>
|
||||||
|
<div data-booth-ask="batch:r1"></div></section>
|
||||||
|
<section id="jo"><audio src="b.wav"></audio>
|
||||||
|
<div data-booth-mark="batch:r2"></div></section>
|
||||||
|
<div data-booth-ask-submit="batch"></div>
|
||||||
|
{SEAM}
|
||||||
|
</body>"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_question_lands_where_the_author_put_it(browser, live):
|
||||||
|
"""The 2026-09-09 ruling, enforced in the DOM: the question for a voice sits
|
||||||
|
under that voice, not on another page and not in a pile at the end. Both
|
||||||
|
attribute spellings, because live reports use the older one."""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", REPORT, b)
|
||||||
|
page.wait_for_selector("#lawson .bk-ask")
|
||||||
|
assert page.locator('#lawson input[name="choice.r1"]').count() == 2
|
||||||
|
assert page.locator('#jo input[name="choice.r2"]').count() == 2
|
||||||
|
# nothing spilled to the end of the body: every piece had an anchor
|
||||||
|
assert page.locator("body > .bk-ask").count() == 0
|
||||||
|
assert page.locator("form#bk-ask-form-batch").count() == 1
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_authors_wrapper_and_its_contents_survive_the_mount(browser, live):
|
||||||
|
"""The live `dfa-concepts` shape — a non-empty styled wrapper carrying the
|
||||||
|
anchor attribute. The regex this replaced matched the opening tag and
|
||||||
|
SUBSTITUTED it, eating the class and orphaning the heading. beforeend keeps
|
||||||
|
both and puts the radios under the heading, which is what the markup says."""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", (
|
||||||
|
'<!doctype html><body><div class="ask" data-booth-ask="batch:r1">'
|
||||||
|
f"<h3>The one asset that must survive</h3></div>{SEAM}</body>"), b)
|
||||||
|
page.wait_for_selector(".ask .bk-ask")
|
||||||
|
assert page.locator("div.ask").count() == 1 # class kept
|
||||||
|
assert page.locator(".ask h3").inner_text() == "The one asset that must survive"
|
||||||
|
assert page.locator('.ask input[name="choice.r1"]').count() == 2 # radios inside
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unplaced_question_is_appended_and_so_is_its_submit(browser, live):
|
||||||
|
"""INV-7. A multi-question pick needs EVERY question on submit or the POST is
|
||||||
|
unanswerable: a question that never reaches the page cannot be picked, and
|
||||||
|
a submission with nothing picked at all is refused outright."""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b",
|
||||||
|
f'<!doctype html><body><div data-booth-mark="batch:r1"></div>{SEAM}</body>', b)
|
||||||
|
# attached, not visible: the shared <form> is deliberately empty and so has
|
||||||
|
# no box — the controls that bind to it are what the operator sees.
|
||||||
|
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
||||||
|
# BOTH halves. Asserting only the appended r2 let a mutation that silently
|
||||||
|
# swallowed the anchored r1 — while still recording it as placed — pass.
|
||||||
|
assert page.locator('input[name="choice.r1"]').count() == 2 # anchored, mounted
|
||||||
|
assert page.locator('input[name="choice.r2"]').count() == 2 # unplaced, appended
|
||||||
|
assert page.locator("form#bk-ask-form-batch").count() == 1 # submittable
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_page_with_no_anchors_gets_the_whole_ask(browser, live):
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", f"<!doctype html><body><p>x</p>{SEAM}</body>", b)
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||||
|
assert page.locator('input[name="choice.r2"]').count() == 2
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_anchor_naming_no_mark_is_left_alone(browser, live):
|
||||||
|
"""A typo'd id stays visible as the author's own empty element rather than
|
||||||
|
being blanked — and the real ask is still never lost."""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b",
|
||||||
|
f'<!doctype html><body><div id="t" data-booth-mark="typo"></div>{SEAM}</body>', b)
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
assert page.locator("#t").inner_html().strip() == ""
|
||||||
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_tail_follows_payload_order(browser, live):
|
||||||
|
"""INV-6. Creation order and alphabetical order DISAGREE here on purpose:
|
||||||
|
`winner` is created first, `batch` second, so payload order is
|
||||||
|
winner-then-batch while an id sort would give the reverse. The first version
|
||||||
|
of this fixture made the two identical, so sorting the tail alphabetically
|
||||||
|
in JavaScript passed it — caught by a panel reading the fixture."""
|
||||||
|
base, data = live
|
||||||
|
b = _single(data / "b")
|
||||||
|
_multi(b)
|
||||||
|
raw = json.loads((b / ".marks.json").read_text())
|
||||||
|
stamps = {"winner": "2026-09-22T10:00:00.000000-07:00", # first
|
||||||
|
"batch": "2026-09-22T11:00:00.000000-07:00"} # second
|
||||||
|
for e in raw["marks"]:
|
||||||
|
e["created"] = stamps[e["id"]]
|
||||||
|
(b / ".marks.json").write_text(json.dumps(raw))
|
||||||
|
page = _open(browser, base, "b", f"<!doctype html><body>{SEAM}</body>", b)
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
ids = page.eval_on_selector_all("[id^='bk-ask-']", "els => els.map(e => e.id)")
|
||||||
|
batch = [i for i, v in enumerate(ids) if "batch" in v]
|
||||||
|
winner = [i for i, v in enumerate(ids) if "winner" in v]
|
||||||
|
assert batch and winner, ids
|
||||||
|
# winner (created first) ahead of batch (created second) — the OPPOSITE of
|
||||||
|
# alphabetical, so an id sort cannot pass this.
|
||||||
|
assert max(winner) < min(batch), ids
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the one that actually matters ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_form_scattered_down_the_report_submits_every_question(browser, live):
|
||||||
|
"""THE load-bearing browser test.
|
||||||
|
|
||||||
|
Four radio groups under four different artifacts, one <form> somewhere else
|
||||||
|
entirely, bound only by the HTML5 `form=` attribute — and now inserted into
|
||||||
|
a live document in visual order, which means a control can land before the
|
||||||
|
form it points at. If form-owner resolution does not survive that, the
|
||||||
|
operator fills the whole thing in and the button saves nothing.
|
||||||
|
|
||||||
|
It was true by construction before U3 (static HTML, resolved at parse). It
|
||||||
|
is true by measurement now. That is the trade this test pays for.
|
||||||
|
"""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", REPORT, b)
|
||||||
|
page.wait_for_selector("#lawson .bk-ask")
|
||||||
|
page.check('#lawson input[name="choice.r1"][value="keep"]')
|
||||||
|
page.check('#jo input[name="choice.r2"][value="cut"]')
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.click("button.bk-ask-go")
|
||||||
|
ans = _answer(b)
|
||||||
|
assert ans is not None, "the scattered form submitted nothing"
|
||||||
|
assert ans["answers"]["r1"]["choice"] == "keep"
|
||||||
|
assert ans["answers"]["r2"]["choice"] == "cut", \
|
||||||
|
"a question bound by form= did not reach the POST"
|
||||||
|
assert ans["complete"] is True
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_chip_jumps_to_the_first_fragment_of_the_open_ask(browser, live):
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", REPORT, b)
|
||||||
|
page.wait_for_selector(".booth-nav-asks")
|
||||||
|
chip = page.locator(".booth-nav-asks")
|
||||||
|
assert chip.inner_text() == "? 1 open ask"
|
||||||
|
target = chip.get_attribute("href")
|
||||||
|
# the EARLIEST match in document order, not merely a match: the report
|
||||||
|
# anchors r1 above r2 above the submit block, so any later one is wrong.
|
||||||
|
ids = page.eval_on_selector_all("[id^='bk-ask-batch']", "els => els.map(e => e.id)")
|
||||||
|
assert ids, "no batch fragment mounted"
|
||||||
|
assert target == "#" + ids[0], (target, ids)
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_anchor_named_like_an_object_property_does_not_kill_the_page(browser, live):
|
||||||
|
"""`toString` is a legal mark id (`asks.valid_stem`) and therefore a legal
|
||||||
|
thing for an author to typo into an anchor. Against a plain `{}` lookup it
|
||||||
|
came back as Object.prototype.toString — truthy, so it sailed past the
|
||||||
|
unknown-mark guard and threw on `.questions.length`, aborting placement
|
||||||
|
before the tail and costing the page EVERY ask. One typo, no chrome, no
|
||||||
|
error the operator would see. Found by a cross-frontier panel."""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", (
|
||||||
|
'<!doctype html><body><div id="t" data-booth-mark="toString"></div>'
|
||||||
|
f'<div id="v" data-booth-mark="valueOf:r1"></div>{SEAM}</body>'), b)
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
assert page.locator("#t").inner_html().strip() == "" # left alone
|
||||||
|
assert page.locator("#v").inner_html().strip() == "" # left alone
|
||||||
|
# and the real ask still mounted, which is what the bug destroyed
|
||||||
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||||
|
assert page.locator('input[name="choice.r2"]').count() == 2
|
||||||
|
assert page.locator("form#bk-ask-form-batch").count() == 1
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_question_keyed_like_an_object_property_is_not_swallowed(browser, live):
|
||||||
|
"""The mirror of the same bug, on the `placed` set. `constructor` matches
|
||||||
|
`asks._KEY_RE`, and against a plain object an inherited `got.constructor`
|
||||||
|
read as ALREADY PLACED — so a question the author did not anchor was
|
||||||
|
silently dropped from the tail, which is INV-7's whole subject."""
|
||||||
|
from booth.marks import declare_pick
|
||||||
|
|
||||||
|
base, data = live
|
||||||
|
b = data / "b"
|
||||||
|
b.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(b, "batch", {"title": "Round one", "questions": [
|
||||||
|
{"key": "r1", "prompt": "First?", "options": ["keep", "cut"]},
|
||||||
|
{"key": "constructor", "prompt": "Second?", "options": ["keep", "cut"]},
|
||||||
|
]})
|
||||||
|
page = _open(browser, base, "b",
|
||||||
|
f'<!doctype html><body><div data-booth-mark="batch:r1"></div>{SEAM}</body>', b)
|
||||||
|
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
||||||
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||||
|
assert page.locator('input[name="choice.constructor"]').count() == 2, \
|
||||||
|
"an unplaced question was swallowed by an inherited property"
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_submit_anchor_inside_the_authors_own_form_still_submits(browser, live):
|
||||||
|
"""A submit anchor placed inside the author's own `<form>` loses ours: the
|
||||||
|
HTML parser drops a nested form element outright. The controls' `form=`
|
||||||
|
then points at nothing, the button does nothing, and the operator finds out
|
||||||
|
by filling the whole thing in. Found by a cross-frontier bug-hunt panel; the
|
||||||
|
fix is to count the anchor submitted only if the form actually survived, so
|
||||||
|
the tail supplies one at body level where no form encloses it."""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", (
|
||||||
|
'<!doctype html><body>'
|
||||||
|
'<div data-booth-mark="batch:r1"></div>'
|
||||||
|
'<div data-booth-mark="batch:r2"></div>'
|
||||||
|
'<form id="mine" action="/elsewhere">'
|
||||||
|
'<div data-booth-ask-submit="batch"></div></form>'
|
||||||
|
f"{SEAM}</body>"), b)
|
||||||
|
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
||||||
|
assert page.locator("form#bk-ask-form-batch").count() == 1
|
||||||
|
page.check('input[name="choice.r1"][value="keep"]')
|
||||||
|
page.check('input[name="choice.r2"][value="cut"]')
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.click("button.bk-ask-go")
|
||||||
|
ans = _answer(b)
|
||||||
|
assert ans is not None, "the button reached no form"
|
||||||
|
assert ans["answers"]["r1"]["choice"] == "keep"
|
||||||
|
assert ans["answers"]["r2"]["choice"] == "cut"
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_pick_shows_its_diagnostic_even_from_a_submit_anchor(browser, live):
|
||||||
|
"""A broken pick has no submit block — its `submit` is empty and the
|
||||||
|
diagnostic lives in `whole`. Mounting that empty string and then recording
|
||||||
|
the pick as placed made the tail skip it, so the 'broken ask' box never
|
||||||
|
rendered at the one surface built to show it. A question the session
|
||||||
|
believes it posted has to be visible."""
|
||||||
|
base, data = live
|
||||||
|
b = _single(data / "b")
|
||||||
|
raw = json.loads((b / ".marks.json").read_text())
|
||||||
|
raw["marks"][0]["declaration"] = {"prompt": "p"} # no options -> AskError
|
||||||
|
(b / ".marks.json").write_text(json.dumps(raw))
|
||||||
|
page = _open(browser, base, "b",
|
||||||
|
f'<!doctype html><body><div id="s" data-booth-ask-submit="winner">'
|
||||||
|
f"</div>{SEAM}</body>", b)
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
assert page.locator("#s").inner_html().strip() == "" # anchor left alone
|
||||||
|
# inner_text() is the RENDERED text, and `.bk-ask-tag` is uppercased by CSS —
|
||||||
|
# so assert the diagnostic itself, which is the part that has to reach him.
|
||||||
|
shown = page.locator("body").inner_text()
|
||||||
|
assert "this question could not be read" in shown, shown
|
||||||
|
assert "options" in shown # the actual reason
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_author_element_cannot_hijack_the_chip(browser, live):
|
||||||
|
"""`<section id="bk-ask-winner-background">` satisfies any id-prefix rule,
|
||||||
|
hyphen boundary included. The chip therefore searches only the elements THIS
|
||||||
|
SCRIPT MOUNTED — the identity the deleted `bk-ask-<id>-top` anchor used to
|
||||||
|
guarantee — and takes the earliest of those in document order."""
|
||||||
|
base, data = live
|
||||||
|
b = _single(data / "b")
|
||||||
|
page = _open(browser, base, "b", (
|
||||||
|
'<!doctype html><body><section id="bk-ask-winner-background">notes</section>'
|
||||||
|
f"<p>report</p>{SEAM}</body>"), b)
|
||||||
|
page.wait_for_selector(".booth-nav-asks")
|
||||||
|
target = page.locator(".booth-nav-asks").get_attribute("href")
|
||||||
|
assert target != "#bk-ask-winner-background"
|
||||||
|
landed = page.locator(target)
|
||||||
|
assert landed.count() == 1
|
||||||
|
assert landed.evaluate("e => e.classList.contains('bk-ask')"), \
|
||||||
|
"the chip jumped to something the Booth did not mount"
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix(browser, live):
|
||||||
|
"""A SIBLING MARK's fragment must not take the chip, even when it is earlier
|
||||||
|
in the document. `batch2`'s id starts with `batch`, so the original
|
||||||
|
id-prefix rule could land on it; the mounted-elements rule cannot, because
|
||||||
|
the candidates are partitioned by mark.
|
||||||
|
|
||||||
|
⚠ The first version of this test put the sibling's fragment AFTER the open
|
||||||
|
mark's, so the right answer was also the first answer and pooling every
|
||||||
|
mark's elements passed it. The vacuity pass caught that; the fixture now
|
||||||
|
puts the sibling FIRST, which is the only arrangement that can tell the two
|
||||||
|
implementations apart.
|
||||||
|
"""
|
||||||
|
from booth.marks import declare_pick
|
||||||
|
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b") # `batch`, created first
|
||||||
|
declare_pick(b, "batch2", {"prompt": "Unrelated?", "options": ["x", "y"]})
|
||||||
|
|
||||||
|
# batch2 is anchored at the very top; batch is unanchored and so lands in
|
||||||
|
# the tail, at the END of the body. Document order is therefore batch2's
|
||||||
|
# fragments, then batch's.
|
||||||
|
page = _open(browser, base, "b", (
|
||||||
|
'<!doctype html><body><div data-booth-mark="batch2"></div>'
|
||||||
|
f"<p>report</p>{SEAM}</body>"), b)
|
||||||
|
page.wait_for_selector(".booth-nav-asks")
|
||||||
|
|
||||||
|
ids = page.eval_on_selector_all("[id^='bk-ask-']", "els => els.map(e => e.id)")
|
||||||
|
assert any("batch2" in i for i in ids) and any(
|
||||||
|
"batch2" not in i and "batch" in i for i in ids), ids
|
||||||
|
assert ids.index(next(i for i in ids if "batch2" in i)) < \
|
||||||
|
ids.index(next(i for i in ids if "batch2" not in i and "batch" in i)), \
|
||||||
|
f"fixture is wrong: the sibling must come FIRST, got {ids}"
|
||||||
|
|
||||||
|
# `open` is (created, id) -> batch before batch2, so the chip targets batch
|
||||||
|
target = page.locator(".booth-nav-asks").get_attribute("href")
|
||||||
|
assert "batch2" not in target, f"the chip landed on the sibling mark: {target}"
|
||||||
|
assert target.startswith("#bk-ask-batch")
|
||||||
|
assert page.locator(target).count() == 1
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_canonical_attribute_wins_when_both_are_present(browser, live):
|
||||||
|
"""`data-booth-mark` is canonical and `data-booth-ask` is the kept alias.
|
||||||
|
An element carrying both is not a case any live report has, but the
|
||||||
|
precedence has to be decided somewhere rather than by selector order."""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", (
|
||||||
|
'<!doctype html><body><div id="a" data-booth-mark="batch:r2" '
|
||||||
|
f'data-booth-ask="batch:r1"></div>{SEAM}</body>'), b)
|
||||||
|
page.wait_for_selector("#a .bk-ask")
|
||||||
|
assert page.locator('#a input[name="choice.r2"]').count() == 2 # canonical
|
||||||
|
assert page.locator('#a input[name="choice.r1"]').count() == 0 # alias ignored
|
||||||
|
# r1 was never placed, so INV-7 still puts it somewhere
|
||||||
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_chip_count_comes_from_the_server(browser, live):
|
||||||
|
"""INV-4. A half-answered multi-question pick is STILL OPEN, and the page
|
||||||
|
does not get to have an opinion about that — `open_marks` decides."""
|
||||||
|
from booth.marks import answer_pick
|
||||||
|
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
answer_pick(b, "batch", {"r1": "keep"})
|
||||||
|
page = _open(browser, base, "b", REPORT, b)
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
assert page.locator(".booth-nav-asks").count() == 1
|
||||||
|
answer_pick(b, "batch", {"r1": "keep", "r2": "cut"})
|
||||||
|
page.reload(wait_until="networkidle")
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
assert page.locator(".booth-nav-asks").count() == 0
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_chip_follows_a_payload_that_disagrees_with_the_fragments(browser, live):
|
||||||
|
"""INV-4, and the version that actually falsifies it.
|
||||||
|
|
||||||
|
The test above uses honest fixtures, so a client that INFERRED openness from
|
||||||
|
the rendered fragments would pass it — the fragments and `open` always agree
|
||||||
|
when the server computes both. A panel pointed out that this never creates
|
||||||
|
the disagreement it claims to test.
|
||||||
|
|
||||||
|
So: intercept the response and make `open` lie. The fragments say fully
|
||||||
|
answered; the payload says two are open. The chip must follow the payload,
|
||||||
|
because the payload is the only thing that decides.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
from booth.marks import answer_pick
|
||||||
|
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
answer_pick(b, "batch", {"r1": "keep", "r2": "cut"}) # nothing is open
|
||||||
|
(b / "index.html").write_text(f"<!doctype html><body>{SEAM}</body>", encoding="utf-8")
|
||||||
|
|
||||||
|
def lie(route):
|
||||||
|
body = _json.loads(route.fetch().text())
|
||||||
|
assert body["open"] == [], "fixture is not answered; the lie would be true"
|
||||||
|
body["open"] = ["batch", "batch"]
|
||||||
|
route.fulfill(status=200, content_type="application/json",
|
||||||
|
body=_json.dumps(body))
|
||||||
|
|
||||||
|
page = browser.new_page()
|
||||||
|
page.route("**/embed.json", lie)
|
||||||
|
page.goto(f"{base}/b/b/", wait_until="networkidle")
|
||||||
|
page.wait_for_selector(".booth-nav-asks")
|
||||||
|
assert page.locator(".booth-nav-asks").inner_text() == "? 2 open asks"
|
||||||
|
page.close()
|
||||||
File diff suppressed because it is too large
Load Diff
+55
-1
@@ -276,7 +276,7 @@ def test_as_dict_round_trips_through_json(tmp_path):
|
|||||||
# ---- the stdlib-only invariant (INV-5) --------------------------------------
|
# ---- 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):
|
def test_stdlib_only(module):
|
||||||
"""INV-5. scripts/booth imports these under the system python3 with NO venv,
|
"""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
|
through a `python3 -c` heredoc that no AST extractor can see — so nothing
|
||||||
@@ -1374,3 +1374,57 @@ def test_a_clock_restore_that_fails_does_not_take_the_route_down(tmp_path):
|
|||||||
|
|
||||||
assert (booth / MARKS_LOCK).exists()
|
assert (booth / MARKS_LOCK).exists()
|
||||||
assert [m.target for m in marks_for(booth)] == ["a.png"]
|
assert [m.target for m in marks_for(booth)] == ["a.png"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the wrong-shaped answer, closed at the hydration boundary --------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_wrong_shaped_answer_is_an_error_at_hydration_not_a_500(tmp_path):
|
||||||
|
"""A `.marks.json` that is well-formed JSON with a wrong-shaped `answer`
|
||||||
|
passed every reader and then raised in the TEMPLATE: `_hydrate` checked only
|
||||||
|
that `answer` was a dict, never that `answer["answers"]` was one, so
|
||||||
|
`marks_for` and `hold_read` both reported the mark healthy with no read
|
||||||
|
error — and `_ask_inline.html` asked a list for `.get`.
|
||||||
|
|
||||||
|
Measured at `42ea67f`, so it predates U3. U3 guarded its own surface with
|
||||||
|
`_safe_fragments` and left the gallery and marks pages alone by scope. This
|
||||||
|
closes it at the boundary the rest of the module already argues for: ONE
|
||||||
|
predicate, ONE place, every surface inherits it.
|
||||||
|
|
||||||
|
Defeating change: restoring the bare `isinstance(answer, dict)` check —
|
||||||
|
under which `error` is None here and both pages 500.
|
||||||
|
"""
|
||||||
|
declare_pick(tmp_path, "batch", {"title": "T", "questions": [
|
||||||
|
{"key": "r1", "prompt": "A?", "options": ["x", "y"]},
|
||||||
|
{"key": "r2", "prompt": "B?", "options": ["x", "y"]}]})
|
||||||
|
raw = json.loads((tmp_path / ".marks.json").read_text())
|
||||||
|
for e in raw["marks"]:
|
||||||
|
if e["id"] == "batch":
|
||||||
|
e["answer"] = {"answers": [], "notes": ""}
|
||||||
|
(tmp_path / ".marks.json").write_text(json.dumps(raw))
|
||||||
|
|
||||||
|
mark = {m.id: m for m in marks_for(tmp_path)}["batch"]
|
||||||
|
assert mark.error, "a wrong-shaped answer hydrated as healthy"
|
||||||
|
assert "answer" in mark.error
|
||||||
|
# AND the mark is not silently emptied — the declaration survives, so the
|
||||||
|
# operator can still see WHICH question broke rather than a bare error.
|
||||||
|
assert mark.declaration is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_healthy_multi_answer_still_hydrates(tmp_path):
|
||||||
|
"""The other direction, so the guard cannot be satisfied by rejecting
|
||||||
|
everything. Defeating change: requiring `answers` unconditionally, which
|
||||||
|
would break every single-question pick."""
|
||||||
|
declare_pick(tmp_path, "multi", {"title": "T", "questions": [
|
||||||
|
{"key": "r1", "prompt": "A?", "options": ["x", "y"]}]})
|
||||||
|
declare_pick(tmp_path, "single", {"prompt": "Which?", "options": ["x", "y"]})
|
||||||
|
raw = json.loads((tmp_path / ".marks.json").read_text())
|
||||||
|
for e in raw["marks"]:
|
||||||
|
if e["id"] == "multi":
|
||||||
|
e["answer"] = {"answers": {"r1": {"choice": "x", "notes": ""}}, "notes": ""}
|
||||||
|
if e["id"] == "single":
|
||||||
|
e["answer"] = {"choice": "x", "notes": ""}
|
||||||
|
(tmp_path / ".marks.json").write_text(json.dumps(raw))
|
||||||
|
by = {m.id: m for m in marks_for(tmp_path)}
|
||||||
|
assert by["multi"].error is None, by["multi"].error
|
||||||
|
assert by["single"].error is None, by["single"].error
|
||||||
|
|||||||
Reference in New Issue
Block a user