Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3126deca00 | ||
|
|
bf351a26d1 | ||
|
|
2f85692e95 | ||
|
|
6938d21085 | ||
|
|
8bf5343049 | ||
|
|
a306e2dc6d | ||
|
|
b50f41bb36 | ||
|
|
e15ee2c4ab | ||
|
|
400e254da6 | ||
|
|
1b394dde18 | ||
|
|
e702be4e1a | ||
|
|
c5ac49356f | ||
|
|
3296a868fa | ||
|
|
8cb21193dc | ||
|
|
e3853e2692 | ||
|
|
32e3ed65e1 | ||
|
|
8a7af3eb08 | ||
|
|
0a2bb1d26c | ||
|
|
8c7f2127eb | ||
|
|
1c3ce5ddb5 | ||
|
|
91fd8bc69d | ||
|
|
7996fbd597 | ||
|
|
5c20e2f4d5 | ||
|
|
87e2c5364c | ||
|
|
42ea67f33f | ||
|
|
8f81d8f9d0 |
@@ -85,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`,
|
||||||
@@ -122,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.
|
||||||
@@ -179,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.
|
||||||
|
|
||||||
@@ -197,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`.
|
||||||
|
|
||||||
|
|||||||
+49
-10
@@ -1,7 +1,12 @@
|
|||||||
# 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.4.0` (U1, U2, U4 and U5 landed; extracted from eshpfi 2026-09-21).
|
Current version: `1.0.0b1` (**U1 through U7 landed — every v1 capability is
|
||||||
|
in**; extracted from eshpfi 2026-09-21). **The v1 target is MET and staged as a
|
||||||
|
beta** (operator, 2026-09-22): feature-complete, external testing, no new
|
||||||
|
features — the remaining work is bugs. `1.0.0` final is cut when the beta
|
||||||
|
survives; per the canonical policy an rc would be cut from the same commit,
|
||||||
|
but a beta may still take fixes.
|
||||||
|
|
||||||
## v1 target
|
## v1 target
|
||||||
|
|
||||||
@@ -12,18 +17,33 @@ 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**~~ — **landed `c3a97c1`, released `v0.4.0`** | 70% of booths on the `.forever` escape hatch (54% when first counted) | 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**~~ — ~~sections~~ **filename groups**, rail, filters, grid keyboard — **landed, unreleased** | one flat wall; 0 of 11 galleries have subfolders, so grouping comes from the filename | 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, U4 and U5 are landed.** U3 is unblocked and unstarted; U6 remains
|
**ALL SEVEN UNITS ARE LANDED.** U7 closed last; its only dependency was
|
||||||
independent and unstarted; U7 waits on the rest.
|
`{U3, U4, U5}` and that closed with U3.
|
||||||
|
|
||||||
|
⚠ **What U7 actually shipped is not what this row first described, and the
|
||||||
|
difference is measured.** Sections were dropped for filename-prefix groups
|
||||||
|
(operator-ratified 2026-09-22) because zero of eleven gallery booths have a
|
||||||
|
subdirectory. Then the *grouping rule itself* changed at implementation: the
|
||||||
|
contract's `strip a trailing digit run` yields 24 groups for `sindra-bakeoff`'s
|
||||||
|
40 images and 27 for `sindra`'s 30 — a rail with a row per tile — because it
|
||||||
|
keys on the end of the stem, where the instance number lives. The shipped rule
|
||||||
|
keys on the **first separator-delimited segment**, where the family lives, and
|
||||||
|
gives 4 and 2. The full re-measurement across all 17 live booths is in
|
||||||
|
`docs/contracts/u7_navigation.contract.md`.
|
||||||
|
|
||||||
|
**The v1 target is met.** What remains is a release decision the operator owns:
|
||||||
|
cut `1.0`, or take a `0.7.0` staging release first. Nothing in the code is
|
||||||
|
waiting on it.
|
||||||
|
|
||||||
**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,6 +93,17 @@ 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) |
|
||||||
|
| **groups among themselves** | **the position of each group's first member in the rendered sequence** — `sorted(rel)` narrowed by the filter, never re-sorted. Walking the rendered list once into an insertion-ordered dict IS the rule, so there is no second sort to drift from it (U7) |
|
||||||
|
| **items within a group** | not a separate order — a group is a label on a tile, not a container. The grid stays `sorted(rel)` and groups interleave in it freely (U7) |
|
||||||
|
| 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,
|
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
|
not a sequence — so the rule above did not need a new row. The three lifetime
|
||||||
@@ -80,10 +111,18 @@ surfaces (index card, booth header, marks page) render through ONE macro
|
|||||||
precisely so they cannot disagree, which is the same property stated for
|
precisely so they cannot disagree, which is the same property stated for
|
||||||
ordering: one rule, one place, every surface reading it.
|
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
|
**U7's group ordering is SETTLED and SHIPPED** (operator, 2026-09-22): groups
|
||||||
section ordering and its compare pairing** (sections need a stated order among
|
order by the position of their first member in the rendered sequence, so the
|
||||||
themselves, not just within; pairing by filename needs a rule for what happens
|
rail reads in the same direction as the grid. Subfolder sections were dropped
|
||||||
to an unpaired file), and **U6's bench listing**.
|
in favour of filename-prefix groups on measured grounds — zero of eleven
|
||||||
|
gallery booths have a subdirectory.
|
||||||
|
|
||||||
|
**Nothing in this table is undecided any more.** The two U7 rules that were
|
||||||
|
(section ordering among themselves, compare pairing) resolved differently:
|
||||||
|
section ordering is MOOT, because U7 renders no section rail — `Item.section`
|
||||||
|
still exists and is still derived, it simply has no ordered surface. Compare
|
||||||
|
pairing rode into v1.1 with compare mode itself. **U6's bench listing is
|
||||||
|
settled** — the row above.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
+41
-2
@@ -1,3 +1,42 @@
|
|||||||
"""The Booth — ephemeral media drop board. See booth.app for the server."""
|
"""The Booth — ephemeral media drop board. See booth.app for the server.
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
⚠ THIS FILE IS EFFECTIVELY STDLIB-ONLY and nothing used to say so. `scripts/booth`
|
||||||
|
imports `booth.links` / `booth.marks` / `booth.manifest` under the SYSTEM python3
|
||||||
|
with no venv, and importing any of them executes this module first — so a single
|
||||||
|
third-party import here breaks `booth ask` on every fleet host exactly as one in
|
||||||
|
those three would. `test_stdlib_only` now covers `__init__` for that reason.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||||
|
|
||||||
|
|
||||||
|
def _declared_version() -> str:
|
||||||
|
"""The version of the code actually running, read from `pyproject.toml`.
|
||||||
|
|
||||||
|
⚠ NOT `importlib.metadata`, and the reason is this repo's own shape: there
|
||||||
|
is no build step and no install step — `booth.service` runs uvicorn with
|
||||||
|
WorkingDirectory set to the repo, so the running code IS this tree.
|
||||||
|
Installed metadata describes a DIFFERENT artifact and was found saying
|
||||||
|
`0.3.0` (a vestigial dist-info, three releases stale, with no package
|
||||||
|
directory behind it) while the tree was at `1.0.0b1`. A confidently wrong
|
||||||
|
number that varies by environment is worse than the hardcoded `0.1.0` this
|
||||||
|
replaced, which at least failed the same way everywhere.
|
||||||
|
|
||||||
|
Falls back to installed metadata for the case this repo does not have but a
|
||||||
|
consumer might: packaged as a wheel, where pyproject does not ship.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return tomllib.loads(_PYPROJECT.read_text())["project"]["version"]
|
||||||
|
except (OSError, KeyError, tomllib.TOMLDecodeError):
|
||||||
|
try:
|
||||||
|
from importlib.metadata import version
|
||||||
|
|
||||||
|
return version("booth")
|
||||||
|
except Exception:
|
||||||
|
return "0.0.0+unknown"
|
||||||
|
|
||||||
|
|
||||||
|
__version__ = _declared_version()
|
||||||
|
|||||||
+410
-178
@@ -44,6 +44,7 @@ import shutil
|
|||||||
import time
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
@@ -158,21 +159,25 @@ from booth.marks import ( # noqa: E402
|
|||||||
set_flag,
|
set_flag,
|
||||||
write_note,
|
write_note,
|
||||||
)
|
)
|
||||||
from booth.inline import ( # noqa: E402
|
|
||||||
form_id as ask_form_id,
|
|
||||||
has_placeholders,
|
|
||||||
place as place_asks,
|
|
||||||
)
|
|
||||||
from booth.manifest import ( # noqa: E402
|
from booth.manifest import ( # noqa: E402
|
||||||
MANIFEST_FILE,
|
MANIFEST_FILE,
|
||||||
SERVICE_HANDLE,
|
SERVICE_HANDLE,
|
||||||
read_manifest,
|
read_manifest,
|
||||||
write_manifest,
|
write_manifest,
|
||||||
)
|
)
|
||||||
|
from booth.benches import ( # noqa: E402
|
||||||
|
BENCH_STATES,
|
||||||
|
normalize_bench_url,
|
||||||
|
read_benches,
|
||||||
|
remove_bench,
|
||||||
|
set_bench_state,
|
||||||
|
upsert_bench,
|
||||||
|
)
|
||||||
from booth.links import ( # noqa: E402
|
from booth.links import ( # noqa: E402
|
||||||
LINK_LOCK,
|
LINK_LOCK,
|
||||||
LINKS_FILE,
|
LINKS_FILE,
|
||||||
PINS_FILE,
|
PINS_FILE,
|
||||||
|
booth_target,
|
||||||
link_entry_id,
|
link_entry_id,
|
||||||
order_for_display,
|
order_for_display,
|
||||||
parse_link_entries,
|
parse_link_entries,
|
||||||
@@ -515,6 +520,8 @@ def build_gallery(child: Path) -> list[dict]:
|
|||||||
"doc": it.doc,
|
"doc": it.doc,
|
||||||
"url": it.url,
|
"url": it.url,
|
||||||
"section": it.section,
|
"section": it.section,
|
||||||
|
# U7. Derived in the resolver (INV-1); this only carries it.
|
||||||
|
"group": it.group,
|
||||||
"caption": it.caption,
|
"caption": it.caption,
|
||||||
"rendered": rendered,
|
"rendered": rendered,
|
||||||
"rendered_html": rendered_html,
|
"rendered_html": rendered_html,
|
||||||
@@ -545,119 +552,91 @@ def _zip_filename(name: str) -> str:
|
|||||||
return f"{safe or 'booth'}.zip"
|
return f"{safe or 'booth'}.zip"
|
||||||
|
|
||||||
|
|
||||||
# ---- verbatim-index.html wrapper -------------------------------------------
|
# ---- the declared embed seam (U3) ------------------------------------------
|
||||||
|
|
||||||
# Mirror of base.html's favicon (the app templates set it there; this is the copy
|
# Mirror of base.html's favicon. The app templates set it there; this copy is
|
||||||
# injected into a booth's *verbatim* index.html so a raw page inherits the same
|
# what `/b/<name>/embed.json` hands to a VERBATIM report, so a raw page inherits
|
||||||
# icon). Keep the two in sync if the Booth's icon ever changes.
|
# the same icon. Keep the two in sync if the Booth's icon ever changes.
|
||||||
FAVICON_HREF = (
|
FAVICON_HREF = (
|
||||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'"
|
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'"
|
||||||
"%3E%3Crect width='32' height='32' rx='7' fill='%23171a23'/%3E%3Ccircle cx='16' "
|
"%3E%3Crect width='32' height='32' rx='7' fill='%23171a23'/%3E%3Ccircle cx='16' "
|
||||||
"cy='16' r='6' fill='none' stroke='%2342dcd1' stroke-width='2.5'/%3E%3Ccircle "
|
"cy='16' r='6' fill='none' stroke='%2342dcd1' stroke-width='2.5'/%3E%3Ccircle "
|
||||||
"cx='16' cy='16' r='2.2' fill='%2342dcd1'/%3E%3C/svg%3E"
|
"cx='16' cy='16' r='2.2' fill='%2342dcd1'/%3E%3C/svg%3E"
|
||||||
)
|
)
|
||||||
FAVICON_LINK = f'<link rel="icon" href="{FAVICON_HREF}">'
|
|
||||||
|
|
||||||
# A self-contained floating "back to all booths" chip injected into verbatim
|
# The seam a verbatim report declares to get the Booth's chrome. ONE line, and
|
||||||
# booths. Scoped class + fixed positioning + max z-index so it overlays the raw
|
# the Booth appends it only when the page has not declared it itself.
|
||||||
# page without touching its layout; hidden in print so downloaded reports stay clean.
|
EMBED_SRC = "/_booth/embed.js"
|
||||||
_BACK_CHIP = (
|
EMBED_SCRIPT_TAG = f'<script src="{EMBED_SRC}" defer></script>'
|
||||||
'<a href="/" class="booth-nav-home" aria-label="back to all booths">‹ all booths</a>'
|
EMBED_JS_PATH = Path(__file__).parent / "static" / "embed.js"
|
||||||
# top-right: empty on left-aligned report layouts (a top-left chip clips the
|
|
||||||
# page title), and consistent with the zoom view's top-right back affordance.
|
|
||||||
"<style>.booth-nav-home{position:fixed;top:0;right:0;z-index:2147483647;"
|
|
||||||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
|
|
||||||
"font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
|
|
||||||
"color:#dfe7ef;text-decoration:none;letter-spacing:.01em;"
|
|
||||||
"background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);border-radius:8px;"
|
|
||||||
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);"
|
|
||||||
"box-shadow:0 2px 10px rgba(0,0,0,.35);transition:background .18s,border-color .18s}"
|
|
||||||
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}"
|
|
||||||
"@media print{.booth-nav-home{display:none}}</style>"
|
|
||||||
)
|
|
||||||
|
|
||||||
# A booth's own index.html is served VERBATIM, so the asks panel — which lives in
|
# What counts as DECLARING the seam. Two substring tests, one per quote style,
|
||||||
# the auto-gallery template — can never appear on it. Without this chip an ask
|
# and each requires `src=` immediately before the path.
|
||||||
# posted into a custom-report booth is INVISIBLE to the operator with nothing to
|
#
|
||||||
# say so (found 2026-09-09 on `emmie-anchor`: valid ask, CLI listed it, page
|
# The bare path was the first draft and it was wrong in the dangerous
|
||||||
# showed nothing). Same injection mechanism as the back chip; it links to the
|
# direction. A report that merely MENTIONS `/_booth/embed.js` — in a code
|
||||||
# standalone /asks page, which renders the real forms.
|
# sample, a comment, a sentence about this very feature — would have been read
|
||||||
def asks_chip(name: str, open_count: int, href: str | None = None) -> str:
|
# as declaring it, served untouched, and silently shown no chrome at all. The
|
||||||
if open_count < 1:
|
# Booth's own design reports are exactly the pages that would quote it.
|
||||||
return ""
|
#
|
||||||
label = f"? {open_count} open ask" + ("" if open_count == 1 else "s")
|
# These tests fail in the harmless direction instead. An unusual spelling
|
||||||
href = href or f"/b/{quote(name, safe='')}/asks"
|
# (`src = "…"` with spaces, an unquoted attribute, a `?v=2` suffix) is read as
|
||||||
return (
|
# NOT declared, so a second tag is appended — and embed.js mounts once
|
||||||
f'<a href="{href}" class="booth-nav-asks">{label}</a>'
|
# regardless, because it guards on `window.__boothEmbed`. A missed declaration
|
||||||
"<style>.booth-nav-asks{position:fixed;top:0;right:7.2rem;z-index:2147483647;"
|
# costs a duplicate tag; a false one costs the operator his chrome.
|
||||||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
|
_EMBED_DECLARATIONS = (f'src="{EMBED_SRC}"', f"src='{EMBED_SRC}'")
|
||||||
"font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
|
|
||||||
"color:#171a23;text-decoration:none;letter-spacing:.01em;"
|
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw
|
||||||
"background:#ffe14e;border:1px solid #ffe14e;border-radius:8px;"
|
|
||||||
"box-shadow:0 2px 10px rgba(0,0,0,.35);transition:filter .18s}"
|
|
||||||
".booth-nav-asks:hover{filter:brightness(1.08)}"
|
|
||||||
"@media print{.booth-nav-asks{display:none}}</style>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw (unwrapped)
|
def declares_embed(html: str) -> bool:
|
||||||
|
"""Whether a verbatim page already asks for the Booth's chrome.
|
||||||
|
|
||||||
_ICON_RE = re.compile(r"<link\b[^>]*\brel\s*=\s*[\"']?[^\"'>]*icon", re.IGNORECASE)
|
TWO SUBSTRING TESTS. This is the entire detection half of what used to be
|
||||||
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", re.IGNORECASE)
|
six regular expressions run against arbitrary author HTML — and the other
|
||||||
_HTML_OPEN_RE = re.compile(r"<html\b[^>]*>", re.IGNORECASE)
|
half, the insertion, is a `+`. See `_EMBED_DECLARATIONS` for why it matches
|
||||||
_DOCTYPE_RE = re.compile(r"<!doctype[^>]*>", re.IGNORECASE)
|
`src="…"` rather than the bare path: both spellings fail toward appending a
|
||||||
_BODY_CLOSE_RE = re.compile(r"</body\s*>", re.IGNORECASE)
|
harmless duplicate rather than toward silently withholding the chrome.
|
||||||
_HTML_CLOSE_RE = re.compile(r"</html\s*>", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def _insert_before(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
|
|
||||||
m = pattern.search(html)
|
|
||||||
if m:
|
|
||||||
return html[: m.start()] + snippet + html[m.start() :], True
|
|
||||||
return html, False
|
|
||||||
|
|
||||||
|
|
||||||
def _insert_after(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
|
|
||||||
m = pattern.search(html)
|
|
||||||
if m:
|
|
||||||
return html[: m.end()] + snippet + html[m.end() :], True
|
|
||||||
return html, False
|
|
||||||
|
|
||||||
|
|
||||||
def wrap_verbatim_html(html: str, favicon_link: str = FAVICON_LINK, extra: str = "") -> str:
|
|
||||||
"""Inject a floating 'all booths' back-chip — and the Booth favicon, if the page
|
|
||||||
declares none — into a booth's verbatim index.html, without altering the page's
|
|
||||||
rendered content.
|
|
||||||
|
|
||||||
Robust to the compact HTML real booths use (`<!doctype html><meta charset><title>
|
|
||||||
<style>…content`, no explicit head/body). The two hard constraints:
|
|
||||||
* NEVER put anything ahead of a leading <!doctype> — that forces quirks mode.
|
|
||||||
* Keep the charset <meta> within the first 1024 bytes so it's still honoured.
|
|
||||||
So the favicon lands at the first head-ish seam (before </head>, else after
|
|
||||||
<html>, else right after the doctype — a ~250B link keeps charset in range), and
|
|
||||||
the fixed-position chip is appended at the END of the document (before </body> /
|
|
||||||
</html> or appended), which renders top-left regardless and disturbs nothing.
|
|
||||||
"""
|
"""
|
||||||
if favicon_link and not _ICON_RE.search(html):
|
return any(d in html for d in _EMBED_DECLARATIONS)
|
||||||
for inserter, pat in (
|
|
||||||
(_insert_before, _HEAD_CLOSE_RE), # inside an explicit <head>
|
|
||||||
(_insert_after, _HTML_OPEN_RE), # top of an explicit <html>
|
|
||||||
(_insert_after, _DOCTYPE_RE), # right after the doctype (compact HTML)
|
|
||||||
):
|
|
||||||
html, done = inserter(html, pat, favicon_link)
|
|
||||||
if done:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
html = favicon_link + html # bare fragment, no doctype: safe to prepend
|
|
||||||
|
|
||||||
chips = _BACK_CHIP + (extra or "")
|
|
||||||
for pat in (_BODY_CLOSE_RE, _HTML_CLOSE_RE):
|
def embed_verbatim(raw: bytes) -> bytes:
|
||||||
html, done = _insert_before(html, pat, chips)
|
"""The ONLY thing the Booth does to a verbatim report. BYTES IN, BYTES OUT.
|
||||||
if done:
|
|
||||||
break
|
Appended, never inserted, and never prepended. That is what retires both of
|
||||||
else:
|
the old wrapper's hard constraints rather than satisfying them more
|
||||||
html = html + chips # no </body>/</html>: append to the end
|
carefully: nothing can displace a leading doctype into quirks mode and
|
||||||
return html
|
nothing can push the charset <meta> out of its first-1024-byte detection
|
||||||
|
window, because nothing in front of them moves. Content after `</html>` is
|
||||||
|
parsed into the body by every browser, so there is no seam to find.
|
||||||
|
|
||||||
|
⚠ IT TAKES BYTES BECAUSE TEXT WAS QUIETLY EDITING THE DOCUMENT. The first
|
||||||
|
version read the file with `read_text()` and returned a str. That opens in
|
||||||
|
UNIVERSAL-NEWLINE mode, so a report written with CRLF came back with LF —
|
||||||
|
and `errors="replace"` turned any byte that was not valid UTF-8 into U+FFFD.
|
||||||
|
A declaring page was therefore NOT served as its author wrote it, which is
|
||||||
|
this unit's headline promise, and the test could not see it because its
|
||||||
|
fixture was LF-only ASCII. Found by a cross-frontier bug-hunt panel.
|
||||||
|
|
||||||
|
Decoding still happens — `declares_embed` needs a string to look in — but
|
||||||
|
the decoded copy is used ONLY to answer that question. What goes on the wire
|
||||||
|
is the original bytes, plus the tag's bytes when it is appended, so the
|
||||||
|
source is a byte-exact prefix of the response.
|
||||||
|
"""
|
||||||
|
text = raw.decode("utf-8", errors="replace")
|
||||||
|
return raw if declares_embed(text) else raw + EMBED_SCRIPT_TAG.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def ask_form_id(stem: str) -> str:
|
||||||
|
"""The shared `<form>` id a pick's scattered question groups bind to with
|
||||||
|
the HTML5 `form=` attribute.
|
||||||
|
|
||||||
|
Moved here from `booth/inline.py` when U3 deleted that module: it is not
|
||||||
|
placement machinery, it is what makes four radio groups spread down a report
|
||||||
|
submit as ONE POST, which is what a multi-question ask requires.
|
||||||
|
"""
|
||||||
|
return f"bk-ask-form-{re.sub(r'[^A-Za-z0-9_-]', '-', stem)}"
|
||||||
|
|
||||||
|
|
||||||
# ---- uploads (browser drop-off for pickup) ---------------------------------
|
# ---- uploads (browser drop-off for pickup) ---------------------------------
|
||||||
@@ -765,6 +744,13 @@ def create_app(
|
|||||||
env.filters["dur"] = human_dur
|
env.filters["dur"] = human_dur
|
||||||
templates = Jinja2Templates(env=env)
|
templates = Jinja2Templates(env=env)
|
||||||
|
|
||||||
|
# embed.js IS READ ONCE, HERE, for exactly the reason above. It is the third
|
||||||
|
# kind of thing this repo serves, and the only one that would otherwise be
|
||||||
|
# free to hot-reload from the deployment root — which is the skew that put
|
||||||
|
# 19 of 25 booths at 500. One rule: nothing takes effect until you restart.
|
||||||
|
embed_js = EMBED_JS_PATH.read_text(encoding="utf-8")
|
||||||
|
embed_etag = '"%s"' % hashlib.sha256(embed_js.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
task = None
|
task = None
|
||||||
@@ -855,13 +841,27 @@ def create_app(
|
|||||||
def healthz():
|
def healthz():
|
||||||
return {"ok": True, "ttl_hours": ttl_hours, "booths": len(list_booths(data_dir, ttl_seconds))}
|
return {"ok": True, "ttl_hours": ttl_hours, "booths": len(list_booths(data_dir, ttl_seconds))}
|
||||||
|
|
||||||
|
@app.get(EMBED_SRC)
|
||||||
|
def embed_script():
|
||||||
|
"""The declared seam's one static asset.
|
||||||
|
|
||||||
|
Served from the startup read, with an ETag over its content so a
|
||||||
|
browser revalidates instead of holding a stale copy across a restart —
|
||||||
|
`no-cache` here means "ask me", not "do not store".
|
||||||
|
"""
|
||||||
|
return Response(
|
||||||
|
content=embed_js,
|
||||||
|
media_type="text/javascript; charset=utf-8",
|
||||||
|
headers={"ETag": embed_etag, "Cache-Control": "no-cache"},
|
||||||
|
)
|
||||||
|
|
||||||
@app.get("/b/{name}", include_in_schema=False)
|
@app.get("/b/{name}", include_in_schema=False)
|
||||||
def booth_redirect(name: str):
|
def booth_redirect(name: str):
|
||||||
resolve_booth(name)
|
resolve_booth(name)
|
||||||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=307)
|
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=307)
|
||||||
|
|
||||||
@app.get("/b/{name}/", response_class=HTMLResponse)
|
@app.get("/b/{name}/", response_class=HTMLResponse)
|
||||||
def booth_view(request: Request, name: str, download: int = 0):
|
def booth_view(request: Request, name: str, download: int = 0, filter: str = "all"):
|
||||||
booth = resolve_booth(name)
|
booth = resolve_booth(name)
|
||||||
# U4: viewing is activity. ABOVE both early returns — the zip download
|
# U4: viewing is activity. ABOVE both early returns — the zip download
|
||||||
# and the verbatim-index.html branch are looks at this booth too, and a
|
# and the verbatim-index.html branch are looks at this booth too, and a
|
||||||
@@ -877,18 +877,20 @@ def create_app(
|
|||||||
)
|
)
|
||||||
own_index = booth / "index.html"
|
own_index = booth / "index.html"
|
||||||
if own_index.is_file():
|
if own_index.is_file():
|
||||||
# Serve the operator's verbatim report, but inject a floating
|
# The operator's verbatim report. U3: the page declares the seam and
|
||||||
# back-to-booths chip + the Booth favicon (if it declares none) so a
|
# the Booth mounts into it — so a page carrying the script tag is
|
||||||
# raw page still has a way home. Small HTML -> read + wrap in memory;
|
# served exactly as written, and one that is not gets that single
|
||||||
# a pathological large file falls back to serving raw, unwrapped.
|
# line appended. Nothing is parsed, matched or inserted.
|
||||||
|
#
|
||||||
|
# The read is still bounded: a pathological file falls back to
|
||||||
|
# serving raw, which costs it the chrome exactly as it did before.
|
||||||
try:
|
try:
|
||||||
if own_index.stat().st_size <= WRAP_MAX_BYTES:
|
if own_index.stat().st_size <= WRAP_MAX_BYTES:
|
||||||
raw = own_index.read_text(encoding="utf-8", errors="replace")
|
# ONE read, and it is a byte read: see embed_verbatim.
|
||||||
# Asks render INLINE, where the report author put them (or
|
return Response(
|
||||||
# appended, if they marked nothing) — a question about an
|
content=embed_verbatim(own_index.read_bytes()),
|
||||||
# artifact belongs beside that artifact, not on another page.
|
media_type="text/html; charset=utf-8",
|
||||||
body, tail = inject_asks(name, booth, raw)
|
)
|
||||||
return HTMLResponse(wrap_verbatim_html(body, extra=tail))
|
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return FileResponse(str(own_index), media_type="text/html")
|
return FileResponse(str(own_index), media_type="text/html")
|
||||||
@@ -902,6 +904,7 @@ def create_app(
|
|||||||
held_marks, read_err = hold_read(booth) # ONE read; see list_booths
|
held_marks, read_err = hold_read(booth) # ONE read; see list_booths
|
||||||
hold = hold_reason(held_marks, read_err)
|
hold = hold_reason(held_marks, read_err)
|
||||||
marks = held_marks if read_err is None else marks_for(booth)
|
marks = held_marks if read_err is None else marks_for(booth)
|
||||||
|
rail, shown = _rail(gallery, marks, filter)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"booth.html",
|
"booth.html",
|
||||||
@@ -912,7 +915,16 @@ def create_app(
|
|||||||
# The page could not previously tell keep from release, so it
|
# The page could not previously tell keep from release, so it
|
||||||
# offered neither and you had to go back to the index.
|
# offered neither and you had to go back to the index.
|
||||||
"kept": is_kept(booth),
|
"kept": is_kept(booth),
|
||||||
"items": gallery,
|
# THE GRID RENDERS `shown`; everything else reads `gallery`.
|
||||||
|
# Filtering is a VIEW: `shown` is `gallery` with non-matching
|
||||||
|
# items removed and NOTHING re-sorted, so "the third one" means
|
||||||
|
# the same thing with a filter on as with it off. Sorting by
|
||||||
|
# anything filter-derived would look right and silently misfile
|
||||||
|
# the operator's judgment — CLAUDE.md invariant 6.
|
||||||
|
"items": shown,
|
||||||
|
"all_items": gallery,
|
||||||
|
"rail": rail,
|
||||||
|
"filter": rail["active"],
|
||||||
# A booth carrying links.md is the standing link board: render
|
# A booth carrying links.md is the standing link board: render
|
||||||
# its rows as real UI (link, provenance, pin, per-row + bulk
|
# its rows as real UI (link, provenance, pin, per-row + bulk
|
||||||
# remove) instead of a markdown blob you can only edit by hand.
|
# remove) instead of a markdown blob you can only edit by hand.
|
||||||
@@ -926,6 +938,23 @@ def create_app(
|
|||||||
# `read_manifest` already take. A booth whose `links.md` cannot
|
# `read_manifest` already take. A booth whose `links.md` cannot
|
||||||
# be read renders as a booth with no board.
|
# be read renders as a booth with no board.
|
||||||
"board": _board_rows(booth),
|
"board": _board_rows(booth),
|
||||||
|
# The bench registry, rendered on the STANDING BOARD's page and
|
||||||
|
# nowhere else: it belongs to exactly one booth, and a read per
|
||||||
|
# gallery page view would buy noise. `_board_rows` is empty for
|
||||||
|
# every other booth, so this pair is read only when it renders.
|
||||||
|
# `read_benches` never raises; a damaged registry costs its own
|
||||||
|
# panel and says so, which is the v0.2.2 lesson.
|
||||||
|
# `is_board` is PAGE IDENTITY, not page content. Gating the
|
||||||
|
# panel on `board or benches` hid it — and its registration
|
||||||
|
# form — exactly when the board was empty and the registry
|
||||||
|
# absent, which is the state a new deployment starts in and the
|
||||||
|
# one where "no benches registered yet" is most worth saying.
|
||||||
|
# A panel that disappears when it has nothing to show is the
|
||||||
|
# same defect as a damaged panel rendering as an absent one.
|
||||||
|
"is_board": (booth / LINKS_FILE).is_file(),
|
||||||
|
**dict(zip(("benches", "benches_error"),
|
||||||
|
read_benches(data_dir) if (booth / LINKS_FILE).is_file()
|
||||||
|
else ([], None))),
|
||||||
# Marks: operator judgment attached to this booth or to one of
|
# Marks: operator judgment attached to this booth or to one of
|
||||||
# its items — a session's question (`pick`), the operator's own
|
# its items — a session's question (`pick`), the operator's own
|
||||||
# remark (`note`), the operator's selection (`flag`). Rendered
|
# remark (`note`), the operator's selection (`flag`). Rendered
|
||||||
@@ -934,7 +963,9 @@ def create_app(
|
|||||||
"marks": marks,
|
"marks": marks,
|
||||||
"marks_open": len(open_marks(marks)),
|
"marks_open": len(open_marks(marks)),
|
||||||
# Per-item marks, keyed by rel, so a tile reads its own judgment
|
# Per-item marks, keyed by rel, so a tile reads its own judgment
|
||||||
# without every tile re-filtering the whole list.
|
# without every tile re-filtering the whole list. Keyed off the
|
||||||
|
# FULL gallery, not the filtered one, so a tile hidden by the
|
||||||
|
# current filter still has its marks if the filter changes.
|
||||||
"item_marks": {
|
"item_marks": {
|
||||||
it["name"]: marks_for_target(marks, it["name"]) for it in gallery
|
it["name"]: marks_for_target(marks, it["name"]) for it in gallery
|
||||||
},
|
},
|
||||||
@@ -952,6 +983,84 @@ def create_app(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
FILTERS = ("all", "flagged", "annotated", "unanswered")
|
||||||
|
|
||||||
|
def _rail(gallery: list[dict], marks, requested: str) -> tuple[dict, list[dict]]:
|
||||||
|
"""Per-filter counts, and the items the grid should render.
|
||||||
|
|
||||||
|
`requested` ARRIVES FROM A URL, which is operator-editable and
|
||||||
|
link-shared, so an unknown value falls back to `all` rather than
|
||||||
|
indexing a dict by it. A filter nobody can mistype into a 500.
|
||||||
|
|
||||||
|
`unanswered` means HAS AN OPEN PICK — the U4 hold predicate, which
|
||||||
|
already exists and already has a home. The other reading ("has no mark
|
||||||
|
at all") is a genuinely different question and is an open question on
|
||||||
|
the U7 contract, not something to guess at here.
|
||||||
|
"""
|
||||||
|
active = requested if requested in FILTERS else "all"
|
||||||
|
open_ids = {m.id for m in open_marks(marks)}
|
||||||
|
buckets: dict[str, list[dict]] = {f: [] for f in FILTERS}
|
||||||
|
for it in gallery:
|
||||||
|
mine = marks_for_target(marks, it["name"])
|
||||||
|
buckets["all"].append(it)
|
||||||
|
if any(m.shape == "flag" and m.flagged for m in mine):
|
||||||
|
buckets["flagged"].append(it)
|
||||||
|
if any(m.shape == "note" for m in mine):
|
||||||
|
buckets["annotated"].append(it)
|
||||||
|
if any(m.id in open_ids for m in mine):
|
||||||
|
buckets["unanswered"].append(it)
|
||||||
|
shown = buckets[active]
|
||||||
|
rail = {
|
||||||
|
"active": active,
|
||||||
|
# ORDER: the declaration order of FILTERS. Stated because a rail is
|
||||||
|
# an ordered collection and invariant 6 binds to it like any other.
|
||||||
|
"counts": [{"key": f, "n": len(buckets[f])} for f in FILTERS],
|
||||||
|
"total": len(gallery),
|
||||||
|
"groups": _groups(shown),
|
||||||
|
}
|
||||||
|
return rail, shown
|
||||||
|
|
||||||
|
def _groups(shown: list[dict]) -> list[dict]:
|
||||||
|
"""The jump-to-group rows, or [] when grouping would not help.
|
||||||
|
|
||||||
|
DERIVED FROM `shown`, NOT FROM THE FULL GALLERY, so every anchor lands
|
||||||
|
on a tile the page actually rendered. A row pointing at an item the
|
||||||
|
current filter has hidden scrolls nowhere, which is the same defect as
|
||||||
|
a wrong id arriving by a different route.
|
||||||
|
|
||||||
|
ORDER: the position of each group's FIRST member in the rendered
|
||||||
|
sequence — which is `sorted(rel)` narrowed by the filter and never
|
||||||
|
re-sorted. So the rail reads in the direction the grid does, and adding
|
||||||
|
a file reshuffles nothing unless it lands first in its group. Settled by
|
||||||
|
the operator 2026-09-22; ROADMAP carries the row. `dict` preserves
|
||||||
|
insertion order, so walking `shown` once IS the rule.
|
||||||
|
|
||||||
|
⚠ THE RAIL IS ABSENT UNLESS GROUPING IS INFORMATIVE: two or more
|
||||||
|
groups, and the middle group holding more than one item. TWO
|
||||||
|
degeneracies, not one. The contract named only the first --
|
||||||
|
`sindra` and `sc-iso-spread` put every file in ONE group, and a rail
|
||||||
|
with a single row cannot navigate. The second is the one the live set
|
||||||
|
actually exhibits: `pewpew-ui-brief` yields 23 groups for 34 items and
|
||||||
|
`dfa-concepts` 13 for 20, a rail that is a second copy of the grid.
|
||||||
|
Both render as no rail, because a navigation affordance that cannot
|
||||||
|
navigate is worse than none -- it occupies the space where the real one
|
||||||
|
would be.
|
||||||
|
"""
|
||||||
|
by_group: dict[str, list[dict]] = {}
|
||||||
|
for it in shown:
|
||||||
|
if it["group"] is not None:
|
||||||
|
by_group.setdefault(it["group"], []).append(it)
|
||||||
|
sizes = sorted(len(v) for v in by_group.values())
|
||||||
|
if len(sizes) < 2 or sizes[len(sizes) // 2] <= 1:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
# The anchor is the FIRST member's existing tile id. The template
|
||||||
|
# already stamps `id="item-<rel>"` on every figure; minting a
|
||||||
|
# parallel `#group-<key>` would be a second identity for one tile.
|
||||||
|
{"key": k, "n": len(v), "anchor": f"item-{v[0]['name']}"}
|
||||||
|
for k, v in by_group.items()
|
||||||
|
]
|
||||||
|
|
||||||
def _board_rows(booth: Path) -> list[dict]:
|
def _board_rows(booth: Path) -> list[dict]:
|
||||||
"""The link board's rows, or [] for a board that cannot be read.
|
"""The link board's rows, or [] for a board that cannot be read.
|
||||||
|
|
||||||
@@ -960,13 +1069,50 @@ def create_app(
|
|||||||
try:
|
try:
|
||||||
if not (booth / LINKS_FILE).is_file():
|
if not (booth / LINKS_FILE).is_file():
|
||||||
return []
|
return []
|
||||||
return order_for_display(
|
rows = order_for_display(
|
||||||
parse_link_entries((booth / LINKS_FILE).read_text()),
|
parse_link_entries((booth / LINKS_FILE).read_text()),
|
||||||
read_pins(booth),
|
read_pins(booth),
|
||||||
)
|
)
|
||||||
|
# DEAD = the row points at a booth that no longer exists. 156 of the
|
||||||
|
# board's 221 rows are exactly that, and nothing on the page could
|
||||||
|
# tell them apart, so the bulk-delete control that has existed since
|
||||||
|
# before this unit was unusable at that scale. Marking is all this
|
||||||
|
# does: removal stays the operator's two deliberate clicks, because
|
||||||
|
# "a migration that deletes anything" is not in v1.
|
||||||
|
for row in rows:
|
||||||
|
target = booth_target(row["url"])
|
||||||
|
row["dead"] = target is not None and not _booth_exists(target)
|
||||||
|
return rows
|
||||||
except (OSError, ValueError, UnicodeDecodeError):
|
except (OSError, ValueError, UnicodeDecodeError):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def _booth_exists(name: str) -> bool:
|
||||||
|
"""Whether a booth name is a live directory. NEVER RAISES.
|
||||||
|
|
||||||
|
SEAM REVIEW SR-2: this deliberately does NOT call `resolve_booth`, which
|
||||||
|
raises HTTPException(404) — called once per board row, one swept booth
|
||||||
|
would 404 the whole page, which is the opposite of the marker's purpose.
|
||||||
|
`booth_target` has already applied the same addressability rules
|
||||||
|
`resolve_booth` enforces, so the two cannot disagree about what is
|
||||||
|
reachable; all that is left is the existence check itself.
|
||||||
|
|
||||||
|
Cost: one stat per booth-shaped row per render of the standing board —
|
||||||
|
178 of 221 rows today, on the ONE booth that carries a links.md.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
candidate = (data_dir / name).resolve()
|
||||||
|
# THE SAME CONTAINMENT `resolve_booth` ENFORCES. Without it the two
|
||||||
|
# disagree on a symlink: the marker would call a booth pointing
|
||||||
|
# outside the data root ALIVE while the page 404s it, so the row
|
||||||
|
# renders healthy and the link is dead — the worst of both, and
|
||||||
|
# invisible. 3-of-4 cold bug-hunt arms found the disagreement.
|
||||||
|
return candidate.parent == data_dir and candidate.is_dir()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
# ValueError, not only OSError: an embedded NUL raises it rather
|
||||||
|
# than an OSError, and this predicate runs once per board row — one
|
||||||
|
# bad row must never cost the other 220.
|
||||||
|
return False
|
||||||
|
|
||||||
def _mark_redirect(name: str, form, anchor: str) -> RedirectResponse:
|
def _mark_redirect(name: str, form, anchor: str) -> RedirectResponse:
|
||||||
"""Land where the form was: the standalone marks page for a verbatim
|
"""Land where the form was: the standalone marks page for a verbatim
|
||||||
booth (its own index.html cannot show the recorded judgment), else the
|
booth (its own index.html cannot show the recorded judgment), else the
|
||||||
@@ -1098,73 +1244,118 @@ def create_app(
|
|||||||
|
|
||||||
_frag = templates.env.get_template("_ask_inline.html").module
|
_frag = templates.env.get_template("_ask_inline.html").module
|
||||||
|
|
||||||
def inject_asks(name: str, booth: Path, html: str) -> tuple[str, str]:
|
def _pick_fragments(name: str, mark) -> dict:
|
||||||
"""(body, tail) for a verbatim booth: placeholders substituted in place,
|
"""One pick, rendered into the pieces a page can mount independently.
|
||||||
and whatever still has to be appended before </body>.
|
|
||||||
|
|
||||||
Marked-up pages get each fragment exactly where the author put it. An
|
Rendered HERE, by the same Jinja macros the gallery page uses, so there
|
||||||
unmarked page gets the whole ask appended — an ask is NEVER invisible,
|
is exactly ONE renderer of an ask. embed.js places these; it never
|
||||||
which is the guarantee; markup only moves it somewhere better. A stem
|
builds one. A second renderer in JavaScript is the shape INV-1 was
|
||||||
whose questions were placed but whose submit block was not gets that
|
written to stop after the zoom view re-derived an item and lost its
|
||||||
block appended, so a scattered form is always submittable.
|
captions doing it.
|
||||||
"""
|
"""
|
||||||
picks = [m for m in marks_for(booth) if m.shape == "pick"]
|
|
||||||
if not picks:
|
|
||||||
return html, ""
|
|
||||||
url = quote(name, safe="")
|
url = quote(name, safe="")
|
||||||
|
fid = ask_form_id(mark.id)
|
||||||
|
if mark.error:
|
||||||
|
# `whole` renders the broken-ask box. A question the session
|
||||||
|
# believes it posted has to be visible; the pieces of a pick that
|
||||||
|
# could not be read do not exist to offer.
|
||||||
|
return {"id": mark.id, "error": mark.error,
|
||||||
|
"whole": str(_frag.whole(mark, fid, url)), "submit": "",
|
||||||
|
"questions": []}
|
||||||
|
return {
|
||||||
|
"id": mark.id,
|
||||||
|
"error": None,
|
||||||
|
"whole": str(_frag.whole(mark, fid, url)),
|
||||||
|
"submit": str(_frag.submit(mark, fid, url)),
|
||||||
|
# A LIST, not an object keyed by question key: a single-question
|
||||||
|
# pick normalizes to one question whose key is None, which JSON
|
||||||
|
# would write as the string "null" and so invent a name. The list
|
||||||
|
# also carries declaration order in the format itself.
|
||||||
|
"questions": [
|
||||||
|
{"key": q.get("key"), "html": str(_frag.question(mark, q, fid, url))}
|
||||||
|
for q in mark.questions
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
seen: set[str] = set()
|
def _safe_fragments(name: str, mark) -> dict:
|
||||||
|
"""`_pick_fragments`, with the promise that it cannot raise.
|
||||||
|
|
||||||
def render(kind: str, mark, key: str | None) -> str:
|
`marks_for` hydrates an entry whose JSON is well-formed but whose SHAPE
|
||||||
fid = ask_form_id(mark.id)
|
is wrong — `{"answer": {"answers": []}}` survives `_hydrate` with no
|
||||||
if kind == "whole":
|
error and then raises `UndefinedError` in the template, because the
|
||||||
frag = str(_frag.whole(mark, fid, url))
|
macro asks a list for `.get`. Verified, not assumed.
|
||||||
elif kind == "submit":
|
|
||||||
frag = str(_frag.submit(mark, fid, url))
|
|
||||||
else:
|
|
||||||
q = next(q for q in mark.questions if q.get("key") == key)
|
|
||||||
frag = str(_frag.question(mark, q, fid, url))
|
|
||||||
# An anchor on the FIRST fragment of each pick, wherever it landed,
|
|
||||||
# so the floating chip can jump to it on a long report. Computed
|
|
||||||
# here rather than in the macros because only the caller knows
|
|
||||||
# which fragment came first.
|
|
||||||
if mark.id not in seen:
|
|
||||||
seen.add(mark.id)
|
|
||||||
frag = f'<a id="bk-ask-{mark.id}-top"></a>' + frag
|
|
||||||
return frag
|
|
||||||
|
|
||||||
tail = [str(_frag.styles())]
|
This endpoint renders every pick in the booth on every page load of the
|
||||||
if has_placeholders(html):
|
operator's report, so one such entry would 500 the whole seam and the
|
||||||
html, placed, submitted = place_asks(html, picks, render)
|
report would show no chrome at all — while `hold_read` reported the file
|
||||||
for m in picks:
|
as perfectly readable. Same leniency `_hydrate_safe` already applies one
|
||||||
keys = placed.get(m.id)
|
layer down, at the layer that actually renders: one unreadable pick
|
||||||
if keys is None:
|
costs that pick, never the page.
|
||||||
tail.append(render("whole", m, None)) # unmarked: never dropped
|
"""
|
||||||
continue
|
try:
|
||||||
if m.error:
|
return _pick_fragments(name, mark)
|
||||||
continue
|
except Exception as exc: # noqa: BLE001 - deliberate
|
||||||
if None not in keys:
|
broken = replace(mark, error=f"this question could not be rendered: {exc}")
|
||||||
# Partially marked up: append every question the author did
|
try:
|
||||||
# NOT place. A multi-question pick needs all of them or the
|
whole = str(_frag.whole(broken, ask_form_id(mark.id),
|
||||||
# POST is a 400 — met only after the operator fills it in.
|
quote(name, safe="")))
|
||||||
for q in m.questions:
|
except Exception: # noqa: BLE001 - deliberate
|
||||||
if q.get("key") not in keys:
|
# THE HANDLER MUST SURVIVE THE FAILURE IT IS HANDLING. The
|
||||||
tail.append(render("question", m, q.get("key")))
|
# fallback re-rendered through the SAME macro module that had
|
||||||
if m.id not in submitted:
|
# just raised, so when `whole` itself was the broken thing this
|
||||||
tail.append(render("submit", m, None)) # scattered but submittable
|
# guard re-raised and took the report anyway — a guard that
|
||||||
else:
|
# only works when the failure is somewhere else. Found while
|
||||||
for m in picks:
|
# building a falsifier for the guard: the falsifier tripped it.
|
||||||
tail.append(render("whole", m, None))
|
# Plain text, escaped by the caller, no macro involved.
|
||||||
|
whole = ""
|
||||||
|
return {"id": mark.id, "error": broken.error,
|
||||||
|
"whole": whole, "submit": "", "questions": []}
|
||||||
|
|
||||||
# The chip is a JUMP LINK to the inline block, not a way out to a
|
@app.get("/b/{name}/embed.json")
|
||||||
# separate page: on a long report the question can be well below the
|
def booth_embed_json(name: str):
|
||||||
# fold, and "there is a question waiting" still has to be visible at
|
"""Everything a verbatim report needs to mount the Booth's chrome.
|
||||||
# first paint.
|
|
||||||
still_open = open_marks(picks) # INV-2: not re-derived here
|
The READ half of the declared seam. `embed.js` fetches this and places
|
||||||
if still_open:
|
what comes back; every decision — what a mark says, whether it is still
|
||||||
tail.append(asks_chip(name, len(still_open),
|
open, what order the marks come in — is made here and never re-derived
|
||||||
href=f'#bk-ask-{still_open[0].id}-top'))
|
on the page.
|
||||||
return html, "".join(tail)
|
|
||||||
|
Marks are ordered `(created, id)`, which is what both readers below
|
||||||
|
sort by. Questions are in declaration order. `open` is `open_marks`,
|
||||||
|
the ONE openness predicate, so a half-answered multi-question pick
|
||||||
|
counts as open here exactly as it does on the index badge.
|
||||||
|
|
||||||
|
DOES NOT RECORD A VIEW. `booth_view` already did, above both of its
|
||||||
|
early returns; counting a script's fetch of the page it is already on
|
||||||
|
would reset the TTL on machinery rather than on the operator.
|
||||||
|
|
||||||
|
The read is LENIENT and the status stays 200, copied from
|
||||||
|
`/marks.json`: a damaged `.marks.json` must cost the chrome, never the
|
||||||
|
operator's report. That is the v0.2.2 lesson.
|
||||||
|
"""
|
||||||
|
booth = resolve_booth(name)
|
||||||
|
marks, read_err = hold_read(booth) # ONE read; see list_booths
|
||||||
|
if read_err is not None:
|
||||||
|
marks = marks_for(booth)
|
||||||
|
picks = [m for m in marks if m.shape == "pick"]
|
||||||
|
body = {
|
||||||
|
"booth": name,
|
||||||
|
# No `home`: the way-home chip mounts from a constant BEFORE this
|
||||||
|
# fetch, so that a failed one still leaves the operator a way out.
|
||||||
|
# Carrying the value anyway would put a second representation of it
|
||||||
|
# on the wire for nothing to read.
|
||||||
|
"favicon": FAVICON_HREF,
|
||||||
|
# Picks only. It is also what keeps a flag's `flag:<target>` id —
|
||||||
|
# the one mark id containing the separator an anchor spec splits
|
||||||
|
# on — out of a payload whose specs split on the first colon.
|
||||||
|
"marks": [_safe_fragments(name, m) for m in picks],
|
||||||
|
"open": [m.id for m in open_marks(picks)],
|
||||||
|
}
|
||||||
|
if read_err is not None:
|
||||||
|
body["marks"] = []
|
||||||
|
body["error"] = "this booth's .marks.json cannot be read"
|
||||||
|
body["detail"] = read_err
|
||||||
|
return JSONResponse(body)
|
||||||
|
|
||||||
@app.get("/b/{name}/marks", response_class=HTMLResponse)
|
@app.get("/b/{name}/marks", response_class=HTMLResponse)
|
||||||
def booth_marks_page(request: Request, name: str):
|
def booth_marks_page(request: Request, name: str):
|
||||||
@@ -1433,6 +1624,47 @@ def create_app(
|
|||||||
toggle_pin(resolve_booth(name), entry)
|
toggle_pin(resolve_booth(name), entry)
|
||||||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
|
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
|
||||||
|
|
||||||
|
@app.post("/b/{name}/bench-add")
|
||||||
|
def bench_add(name: str, url: str = Form(...), bname: str = Form("", alias="name")):
|
||||||
|
"""Register or update a bench by normalized URL.
|
||||||
|
|
||||||
|
A rejected URL must not 500 the page it was posted from. THE REJECTION
|
||||||
|
IS SILENT HERE, and that is stated rather than dressed up: the form's
|
||||||
|
`type="url"` catches the ordinary typo in the browser before the post,
|
||||||
|
and this `except` is the last resort for what slips past it — the bench
|
||||||
|
simply does not appear. Surfacing the reason would need a flash message,
|
||||||
|
which this service has no mechanism for; inventing one for a path the
|
||||||
|
browser already guards is not worth a unit's scope.
|
||||||
|
|
||||||
|
`resolve_booth` is called for its 404: a POST at a booth that does not
|
||||||
|
exist is not a silent no-op.
|
||||||
|
"""
|
||||||
|
resolve_booth(name)
|
||||||
|
try:
|
||||||
|
upsert_bench(data_dir, url, bname, "operator")
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
|
||||||
|
|
||||||
|
@app.post("/b/{name}/bench-state")
|
||||||
|
def bench_state(name: str, bench: str = Form(...), state: str = Form(...)):
|
||||||
|
resolve_booth(name)
|
||||||
|
if state in BENCH_STATES:
|
||||||
|
try:
|
||||||
|
set_bench_state(data_dir, bench, state)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
|
||||||
|
|
||||||
|
@app.post("/b/{name}/bench-remove")
|
||||||
|
def bench_remove(name: str, bench: str = Form(...)):
|
||||||
|
resolve_booth(name)
|
||||||
|
try:
|
||||||
|
remove_bench(data_dir, bench)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
|
||||||
|
|
||||||
def _safe_next(nxt: str) -> str:
|
def _safe_next(nxt: str) -> str:
|
||||||
"""Where to land after keep/unkeep. Defaults to the index; a booth page
|
"""Where to land after keep/unkeep. Defaults to the index; a booth page
|
||||||
can ask to stay put. Only same-site absolute paths are honoured — `//`
|
can ask to stay put. Only same-site absolute paths are honoured — `//`
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -15,6 +15,7 @@ See docs/contracts/u1_item_record.contract.md.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
@@ -89,6 +90,7 @@ class Item:
|
|||||||
url: str
|
url: str
|
||||||
kind: str
|
kind: str
|
||||||
section: str | None
|
section: str | None
|
||||||
|
group: str | None
|
||||||
caption: str | None
|
caption: str | None
|
||||||
blurred: bool
|
blurred: bool
|
||||||
doc: str | None
|
doc: str | None
|
||||||
@@ -115,6 +117,43 @@ def _section_of(rel: str) -> str | None:
|
|||||||
return None if str(parent) == "." else parent.as_posix()
|
return None if str(parent) == "." else parent.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
# One separator run between name segments. A filename is the only grouping
|
||||||
|
# signal the live booths actually carry: 0 of 11 galleries have a subdirectory.
|
||||||
|
_SEG = re.compile(r"[-_. ]+")
|
||||||
|
|
||||||
|
|
||||||
|
def _group_of(rel: str) -> str | None:
|
||||||
|
"""The grouping key for an item, or None when it has none.
|
||||||
|
|
||||||
|
THE RULE, in one line: **the first separator-delimited segment of the
|
||||||
|
basename's stem — with a trailing digit run stripped only when the stem has
|
||||||
|
no separator at all.** `00-sheet-c1-market-noon.png` -> `00`;
|
||||||
|
`m-c1-market-noon-9401.png` -> `m`; `flag-rear.png` -> `flag`;
|
||||||
|
`ac01.png` -> `ac` (no separator, so the digits are the separator);
|
||||||
|
`v30-seed8302.png` -> `v30` (separator present, so `v30` survives and does
|
||||||
|
not merge with `v35`, which is the axis that booth is about).
|
||||||
|
|
||||||
|
None for a stem with nothing before the digits -- `01.png` has no prefix to
|
||||||
|
group on, and inventing one would file every numbered render under the
|
||||||
|
empty string.
|
||||||
|
|
||||||
|
⚠ THIS IS NOT THE RULE THE CONTRACT FIRST NAMED. `strip ONE trailing run of
|
||||||
|
digits` was measured against the live set on 2026-09-22 and yields 24 groups
|
||||||
|
for sindra-bakeoff's 40 images and 27 for sindra's 30 -- a rail with one row
|
||||||
|
per tile. The contract's own table claimed 5 and 1 for those two booths;
|
||||||
|
neither reproduces under the rule it states beside them. The rewritten table
|
||||||
|
carries the re-measurement.
|
||||||
|
|
||||||
|
Derived HERE and nowhere else (INV-1). A route body that re-derived it would
|
||||||
|
be the caption bug in a new field.
|
||||||
|
"""
|
||||||
|
stem = Path(rel).stem # basename without its last suffix; `a.tar.gz` -> `a.tar`
|
||||||
|
segs = _SEG.split(stem)
|
||||||
|
if len(segs) == 1:
|
||||||
|
return re.sub(r"\d+$", "", stem) or None
|
||||||
|
return segs[0] or None
|
||||||
|
|
||||||
|
|
||||||
def _resolve_captions(by_rel: dict[str, Path]) -> tuple[dict[str, str], set[str]]:
|
def _resolve_captions(by_rel: dict[str, Path]) -> tuple[dict[str, str], set[str]]:
|
||||||
"""(caption-by-rel, rels consumed as sidecars).
|
"""(caption-by-rel, rels consumed as sidecars).
|
||||||
|
|
||||||
@@ -203,6 +242,7 @@ def booth_items(booth: Path) -> list[Item]:
|
|||||||
url=quote(rel, safe="/"),
|
url=quote(rel, safe="/"),
|
||||||
kind=classify(p.name),
|
kind=classify(p.name),
|
||||||
section=_section_of(rel),
|
section=_section_of(rel),
|
||||||
|
group=_group_of(rel),
|
||||||
caption=caption.get(rel),
|
caption=caption.get(rel),
|
||||||
blurred=rel in blurred,
|
blurred=rel in blurred,
|
||||||
doc=doc_kind(p.name),
|
doc=doc_kind(p.name),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -488,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,
|
||||||
|
|||||||
@@ -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' %}
|
||||||
|
|||||||
@@ -496,7 +496,50 @@
|
|||||||
.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}
|
||||||
|
/* U7 — the rail, and the grid cursor. */
|
||||||
|
.rail{position:sticky;top:0;z-index:5;display:flex;gap:.5rem;align-items:baseline;
|
||||||
|
padding:.4rem .6rem;margin:.6rem 0;background:var(--bg,#111);
|
||||||
|
border-bottom:1px solid var(--line,#2a2a2a);flex-wrap:wrap}
|
||||||
|
.rail-total{font-weight:600}
|
||||||
|
.rail-f{font-size:.85em;padding:.1rem .45rem;border-radius:3px;text-decoration:none;
|
||||||
|
opacity:.65;border:1px solid transparent}
|
||||||
|
.rail-f:hover{opacity:1}
|
||||||
|
.rail-f.on{opacity:1;border-color:var(--line,#2a2a2a);background:rgba(255,255,255,.06)}
|
||||||
|
/* The group row. Wraps rather than scrolls: 16 groups is the live maximum
|
||||||
|
and a horizontal scroller hides half of them behind a gesture. */
|
||||||
|
.rail-groups{display:flex;gap:.35rem;flex-wrap:wrap;align-items:baseline;
|
||||||
|
padding-left:.5rem;margin-left:.25rem;border-left:1px solid var(--line,#2a2a2a)}
|
||||||
|
.rail-g{font-size:.8em;padding:.1rem .4rem;border-radius:3px;text-decoration:none;
|
||||||
|
opacity:.6;border:1px solid transparent}
|
||||||
|
.rail-g:hover{opacity:1;border-color:var(--line,#2a2a2a)}
|
||||||
|
/* The jumped-to tile, so a fragment jump says where it landed. */
|
||||||
|
figure.item:target{outline:2px dashed #7aa2f7;outline-offset:3px}
|
||||||
|
figure.item.is-cursor{outline:2px solid #7aa2f7;outline-offset:2px}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
|||||||
+158
-3
@@ -113,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
|
||||||
@@ -143,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 %}
|
||||||
@@ -171,7 +243,40 @@
|
|||||||
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
|
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
|
||||||
links.md is rendered as the board above and filtered out), so a plain else
|
links.md is rendered as the board above and filtered out), so a plain else
|
||||||
would emit an empty <div class="gallery"> under the board. #}
|
would emit an empty <div class="gallery"> under the board. #}
|
||||||
<div class="gallery">
|
{# THE RAIL. Totals and per-filter counts, as LINKS with a query parameter —
|
||||||
|
resolved server-side, so the whole thing works with JavaScript off. The
|
||||||
|
gallery is the surface the operator actually reviews on and U3 already
|
||||||
|
cost the verbatim path its no-JS operation; this one does not repeat that.
|
||||||
|
|
||||||
|
ORDER: the declaration order of FILTERS in app.py. A rail is an ordered
|
||||||
|
collection and invariant 6 binds to it like any other.
|
||||||
|
|
||||||
|
THE GROUP ROW is `rail.groups`, which is EMPTY unless grouping is
|
||||||
|
informative — see `_groups` in app.py. `{% raw %}{% if rail.groups %}{% endraw %}`
|
||||||
|
is therefore the whole guard; the two degenerate cases (one group for
|
||||||
|
everything, one group per item) are decided in Python, where they can be
|
||||||
|
measured, rather than by a count in a template. #}
|
||||||
|
<div class="rail">
|
||||||
|
<span class="rail-total">{{ rail.total }} item{{ '' if rail.total == 1 else 's' }}</span>
|
||||||
|
{% for f in rail.counts %}
|
||||||
|
<a class="rail-f{% if f.key == filter %} on{% endif %}"
|
||||||
|
data-filter="{{ f.key }}"
|
||||||
|
href="/b/{{ name_url }}/{% if f.key != 'all' %}?filter={{ f.key }}{% endif %}"
|
||||||
|
{% if f.key == filter %}aria-current="true"{% endif %}>{{ f.key }} <b>{{ f.n }}</b></a>
|
||||||
|
{% endfor %}
|
||||||
|
{% if rail.groups %}
|
||||||
|
<nav class="rail-groups" aria-label="jump to group">
|
||||||
|
{% for g in rail.groups %}
|
||||||
|
{# The anchor is the first member's EXISTING tile id, so a group has one
|
||||||
|
identity on the page rather than two. Plain fragment links: no JS,
|
||||||
|
and the browser's own back button undoes the jump. #}
|
||||||
|
<a class="rail-g" data-group="{{ g.key }}"
|
||||||
|
href="#{{ g.anchor }}">{{ g.key }} <b>{{ g.n }}</b></a>
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="gallery" id="grid" tabindex="-1">
|
||||||
{% for it in items %}
|
{% for it in items %}
|
||||||
{% if it.doc and it.rendered is not none %}
|
{% if it.doc and it.rendered is not none %}
|
||||||
{# Docs render INLINE, collapsible, and closable — not a link to a
|
{# Docs render INLINE, collapsible, and closable — not a link to a
|
||||||
@@ -257,6 +362,56 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if items %}
|
||||||
|
<script id="gridkeys">
|
||||||
|
/* GRID KEYBOARD — U7. Additive by construction: every action it reaches is a
|
||||||
|
control that already exists on the tile and already works with a mouse, so
|
||||||
|
the page is complete without this file. It is bound ONLY when there is a
|
||||||
|
grid ({% raw %}{% if items %}{% endraw %} above): binding it on the standing
|
||||||
|
link board would swallow `f` and flag nothing.
|
||||||
|
|
||||||
|
Focus moves in RENDER ORDER, which is the item order filtered by the current
|
||||||
|
filter and never re-sorted — so `→` walks the grid in the same sequence the
|
||||||
|
operator reads it, and the same sequence the zoom ring uses. */
|
||||||
|
(function () {
|
||||||
|
var grid = document.getElementById('grid');
|
||||||
|
if (!grid) return;
|
||||||
|
var tiles = function () { return [].slice.call(grid.querySelectorAll('figure.item')); };
|
||||||
|
var at = -1;
|
||||||
|
function focus(i) {
|
||||||
|
var t = tiles();
|
||||||
|
if (!t.length) return;
|
||||||
|
at = Math.max(0, Math.min(i, t.length - 1));
|
||||||
|
t.forEach(function (el, j) { el.classList.toggle('is-cursor', j === at); });
|
||||||
|
t[at].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
function current() { var t = tiles(); return at >= 0 && at < t.length ? t[at] : null; }
|
||||||
|
function click(sel) {
|
||||||
|
var el = current(); if (!el) return;
|
||||||
|
var b = el.querySelector(sel); if (b) b.click();
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
/* Never steal a key the operator is typing into a note or a URL bar. */
|
||||||
|
var tag = (e.target.tagName || '').toLowerCase();
|
||||||
|
if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowRight': focus(at + 1); e.preventDefault(); break;
|
||||||
|
case 'ArrowLeft': focus(at <= 0 ? 0 : at - 1); e.preventDefault(); break;
|
||||||
|
case 'f': click('.flagbtn, [name="target"]'); e.preventDefault(); break;
|
||||||
|
case 'n': var el = current();
|
||||||
|
if (el) { var f = el.querySelector('input[type=text], textarea');
|
||||||
|
if (f) { f.focus(); e.preventDefault(); } }
|
||||||
|
break;
|
||||||
|
case 'Enter': click('a[href^="view"]'); break;
|
||||||
|
case 'Escape':
|
||||||
|
tiles().forEach(function (x) { x.classList.remove('is-cursor'); });
|
||||||
|
at = -1; break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
<script>
|
<script>
|
||||||
/* Copy-to-clipboard for any .copy-btn[data-copy]. The Booth serves over plain
|
/* Copy-to-clipboard for any .copy-btn[data-copy]. The Booth serves over plain
|
||||||
HTTP on a LAN IP, where navigator.clipboard is undefined (secure-context
|
HTTP on a LAN IP, where navigator.clipboard is undefined (secure-context
|
||||||
|
|||||||
@@ -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,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.
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
---
|
||||||
|
contract_version: "0.1-PROPOSED"
|
||||||
|
status: "LANDED 2026-09-22, all four components. The operator ratified the scope departure (drop subfolder sections, add filename-prefix groups) and settled the `unanswered` open question in favour of the shipped reading. Rail, filters and grid keyboard landed at a306e2d; the groups landed in the commit carrying this revision, which also DELETED tests/test_navigation.py::test_no_group_rail_is_shipped_yet — the guard that held the departure back while the ruling was outstanding. ⚠ TWO THINGS IN THIS CONTRACT CHANGED AT IMPLEMENTATION, both measured rather than preferred: the grouping RULE (see Signatures) and INV-3, which guarded one degeneracy and needed to guard two. The original text of both is kept below, struck, because the reasoning is the useful part."
|
||||||
|
module: "booth.items + booth.app (gallery navigation)"
|
||||||
|
purpose: "The last unit before the 1.0 cut. A gallery booth renders as one flat wall with no way to filter it, no way to move through it from the keyboard, and no grouping — so a review of sixty-odd renders is a scroll-and-squint. ROADMAP names four components: sections, a sticky rail, filters, grid keyboard. THE MEASUREMENT KILLS THE FIRST AND REPLACES IT: not one of the eleven live gallery booths has a subdirectory, so sections buy nothing, while a filename-prefix heuristic yields 5-16 sensible groups on four of the five large galleries. This unit ships the rail, the filters, the grid keyboard, and GROUPS DERIVED FROM FILENAMES rather than from a directory tree that does not exist."
|
||||||
|
depends_on:
|
||||||
|
- "booth.items.booth_items (INV-1: one resolver for item facts. `Item` gains ONE field, `group`, derived here and nowhere else. No route body derives it, exactly as no route body derives `section`, `caption` or `blurred`.)"
|
||||||
|
- "booth.items.Item.section (ALREADY EXISTS from U1 and STAYS. This unit does not delete it and does not render a rail from it — those are different questions. A booth that does have subdirectories keeps its section values; nothing regresses.)"
|
||||||
|
- "booth.app.build_gallery (the thin adapter over `booth_items`; it shapes items for the template and is where `group` reaches the page)"
|
||||||
|
- "booth.app.image_chain (the zoom prev/next ring. UNCHANGED, and named here because it was CHECKED: the ring is the item order filtered to images, and grouping must not reorder it -- a filter that changed what `next` means would misfile the operator's judgment, which is CLAUDE.md invariant 6's whole reason for existing.)"
|
||||||
|
language: "python + jinja + a little javascript"
|
||||||
|
complexity: "medium"
|
||||||
|
estimated_loc: 300
|
||||||
|
confidence: 0.6
|
||||||
|
used_by:
|
||||||
|
- "booth.app.booth_view (the gallery page gains a rail and a filter state; the grid gains keyboard focus)"
|
||||||
|
touches:
|
||||||
|
- "booth/items.py (the `group` field and its derivation)"
|
||||||
|
- "booth/app.py (build_gallery carries `group`; booth_view passes group counts)"
|
||||||
|
- "booth/templates/booth.html (the rail, the filter controls, the grid's focus affordances)"
|
||||||
|
- "booth/templates/base.html (rail + focus CSS)"
|
||||||
|
- "booth/static/embed.js (NOT TOUCHED — named because it was checked; the verbatim path has no grid)"
|
||||||
|
- "tests/test_items.py (group derivation)"
|
||||||
|
- "tests/test_navigation.py (new — rail, filters, keyboard)"
|
||||||
|
- "ROADMAP.md (the deterministic-order table gains the group row; U7's row is rewritten)"
|
||||||
|
assumptions:
|
||||||
|
- "THE SCOPE DEPARTURE WAS RATIFIED BY THE OPERATOR 2026-09-22. ROADMAP's U7 row said `sections, rail, filters, grid keyboard`; this contract drops sections and adds filename groups. The evidence is in `persistent-memory.d/2026-09-22-u7-remeasured-before-scoping.md`: zero of eleven gallery booths have a subdirectory, the only two booths that do are reports, and `pewpew-ui-brief`'s seven subdirectories hold one image between them."
|
||||||
|
- "THE GROUP HEURISTIC DEGENERATES IN TWO DIRECTIONS, NOT ONE, AND THIS CONTRACT ORIGINALLY SAW ONLY THE FIRST. (a) ONE GROUP FOR EVERYTHING -- live specimen `sc-iso-spread`, `DSC0001.jpg` through `DSC0006.jpg`. (b) ONE GROUP PER ITEM -- live specimens `pewpew-ui-brief` at 23 groups for 34 items and `dfa-concepts` at 13 for 20. Both render as NO rail, because a navigation affordance that cannot navigate is worse than none: it occupies the space where the real one would be. Degeneracy (b) is the one the shipped rule actually meets on the live set, and the contract as first written would have shipped it everywhere."
|
||||||
|
- "GROUPING IS A VIEW, NEVER A REORDERING. The item order stays `sorted(rel)` (U1 INV-3) and the zoom ring stays that order filtered to images. Grouping and filtering change what is SHOWN and never the sequence -- so `the third one` means the same thing with a filter on as with it off, and a flag lands where the operator thinks it does. This is the whole of CLAUDE.md invariant 6 applied to a surface that did not exist when it was written."
|
||||||
|
- "THE PAGE WORKS WITH NO JAVASCRIPT. Filters are links with a query parameter, resolved server-side; the rail is anchors. Keyboard is the one genuinely JS-only affordance and it is additive -- the page is fully usable without it. U3 cost the verbatim path its no-JS operation and said so plainly; this unit must not quietly do the same to the gallery, which is the surface the operator actually reviews on."
|
||||||
|
- "VIRTUALIZATION STAYS PARKED. The largest gallery is 66 images. ROADMAP parks progressive loading with `measure the real booth before optimising it`; at this size a lazy grid is almost certainly fine, and inventing the work is the failure the parking lot exists to prevent."
|
||||||
|
open_questions:
|
||||||
|
- "WHETHER THE GROUP HEURISTIC SHOULD BE OVERRIDABLE. A booth could carry a `.groups` dotfile naming its own grouping, the way `.blurred` names blur. Not designed here: no live booth wants it, the heuristic is right on four of five, and adding an override before anyone has been failed by the default is speculative. Parked, not solved."
|
||||||
|
- "RESOLVED 2026-09-22 — `unanswered` means `has an open pick`, the U4 hold predicate, which is what shipped. The `has no mark at all` reading is a genuinely different question and is PARKED for v1.1 rather than pending."
|
||||||
|
---
|
||||||
|
|
||||||
|
# U7 — navigation at the size the booths actually are
|
||||||
|
|
||||||
|
**LANDED — all four components.**
|
||||||
|
|
||||||
|
| component | ROADMAP says | state |
|
||||||
|
|---|---|---|
|
||||||
|
| sticky rail | ratified | **landed** — totals + per-filter counts, links not scripts |
|
||||||
|
| filters | ratified | **landed** — all / flagged / annotated / unanswered |
|
||||||
|
| grid keyboard | ratified | **landed** — `←/→ f n Enter Esc`, bound only when a grid exists |
|
||||||
|
| **sections → filename groups** | **departs from it** | **landed** — ratified by the operator 2026-09-22. `test_no_group_rail_is_shipped_yet`, the guard that held it back, was deleted in the same commit that built it. |
|
||||||
|
|
||||||
|
`unanswered` means **has an open pick** — the U4 hold predicate. **Settled by
|
||||||
|
the operator 2026-09-22**; the "has no mark at all" reading is a different
|
||||||
|
question and is parked, not pending.
|
||||||
|
|
||||||
|
## The defect, re-measured rather than inherited
|
||||||
|
|
||||||
|
ROADMAP sizes this unit for 270 items. **The largest gallery is now 81 items
|
||||||
|
and 40 images.** The four booths it was written against were swept on
|
||||||
|
2026-09-22 and the set churned again during that session. The defect is real
|
||||||
|
and the sizing is not:
|
||||||
|
|
||||||
|
| | ROADMAP's premise | measured 2026-09-22 |
|
||||||
|
|---|---|---|
|
||||||
|
| largest gallery | 270 images, one flat wall | **`sindra-bakeoff`, 40 images** |
|
||||||
|
| galleries with subdirectories | "sections come from subfolders, which already exist" | **0 of 11** |
|
||||||
|
| booths with subdirectories at all | — | 2, and **both are reports** |
|
||||||
|
| grouping signal that does exist | — | **the filename prefix** |
|
||||||
|
|
||||||
|
## Sections are dead. The prefix is not.
|
||||||
|
|
||||||
|
⚠ **THE TABLE BELOW IS THE RE-MEASUREMENT, AND IT DISAGREES WITH THE ONE THIS
|
||||||
|
CONTRACT WAS WRITTEN ON.** The original claimed the rule `strip ONE trailing
|
||||||
|
run of digits` produced **5** groups on `sindra-bakeoff` and **1** on `sindra`.
|
||||||
|
Neither reproduces: that rule gives **24** and **27**. The original table's own
|
||||||
|
worked example says so out loud — it notes `00-sheet-c1-market-noon.png` has no
|
||||||
|
trailing digit run and therefore groups as its whole stem, which makes eight of
|
||||||
|
bakeoff's forty images eight singleton groups. **The numbers 5 and 1 are
|
||||||
|
reproducible only by two OTHER rules** (first-two-segments gives exactly 5 on
|
||||||
|
bakeoff; first-segment gives exactly 1 on sindra), so the table that justified
|
||||||
|
this design was assembled from more than one heuristic. Caught by implementing
|
||||||
|
the stated rule and running it against the live set rather than trusting the
|
||||||
|
table beside it.
|
||||||
|
|
||||||
|
**The shipped rule** — first separator-delimited segment, destemmed only when
|
||||||
|
the stem has no separator — measured against all 17 live booths, 2026-09-22.
|
||||||
|
`G` is groups, `med` the middle group's size, `sing` the singleton groups:
|
||||||
|
|
||||||
|
| booth | items | G | med | sing | rail? |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `sindra-corpus-v1` | 66 | 11 | 5 | 4 | **yes** — `ac 12 · bu 10 · cu 12 · fb 12 · … · wu 8` |
|
||||||
|
| `sindra-sfw-pool` | 59 | 6 | 11 | 0 | **yes** |
|
||||||
|
| `sindra-nude-pool` | 42 | 9 | 4 | 1 | **yes** |
|
||||||
|
| `sindra-bakeoff` | 41 | 4 | 12 | 1 | **yes** — `00 · README · m · r`, the three real families |
|
||||||
|
| `sindra` | 31 | 2 | 15 | 1 | **yes** |
|
||||||
|
| `muse-clothed-repro` | 7 | 3 | 2 | 1 | **yes** — `v30`/`v35`, the axis that booth is about |
|
||||||
|
| `pewpew-ui-brief` | 34 | 23 | 1 | 19 | no — **degeneracy (b)** |
|
||||||
|
| `dfa-concepts` | 20 | 13 | 1 | 8 | no — **degeneracy (b)** |
|
||||||
|
| `cr123a-to-d-sleeve` | 7 | 6 | 1 | 5 | no — degeneracy (b) |
|
||||||
|
| `sc-iso-spread` | 6 | 1 | 6 | 0 | no — **degeneracy (a)**, `DSC0001`–`DSC0006` |
|
||||||
|
| `music3-songs`, `krea2-lora-portability` | 3 | 1 | 3 | 0 | no — degeneracy (a) |
|
||||||
|
| `miranda-is`, `sindra-voice-1` | 47 / 10 | 10 / 6 | 2 / 2 | 3 / 2 | **no grid at all** — both carry `index.html` and take the verbatim path |
|
||||||
|
|
||||||
|
**Why the rule changed.** `strip ONE trailing run of digits` keys on the END of
|
||||||
|
the stem, which is where the *instance number* lives — so it separates
|
||||||
|
`m-c1-market-noon-9401` from `m-c2-rain-street-9403`, which are the same family.
|
||||||
|
The shipped rule keys on the START, which is where the *family* lives. The
|
||||||
|
competing heuristics measured and rejected: split-on-second-hyphen (59 groups
|
||||||
|
from 59 files), and destemming the first segment unconditionally (merges `v30`
|
||||||
|
with `v35`).
|
||||||
|
|
||||||
|
**The honest cost.** Destemming a flat stem is what makes `ac01.png` → `ac`
|
||||||
|
work, and it is exactly what would merge `v30` with `v35` if applied to a
|
||||||
|
segmented name. The rule therefore has a conditional in it, which is one more
|
||||||
|
thing than "take the first segment" — paid because `sindra-corpus-v1`, the
|
||||||
|
largest gallery, is entirely flat names.
|
||||||
|
|
||||||
|
## What ships
|
||||||
|
|
||||||
|
1. **`Item.group`** — derived once, in the resolver, beside `section`.
|
||||||
|
2. **A sticky rail** — total, per-group counts, per-filter counts, jump-to-group
|
||||||
|
anchors. **Absent entirely when there is one group or fewer.**
|
||||||
|
3. **Filters** — all / flagged / annotated / unanswered, as server-resolved
|
||||||
|
query parameters so they work with JS off.
|
||||||
|
4. **Grid keyboard** — `←/→` move focus, `f` flags, `n` opens a note, `Enter`
|
||||||
|
zooms, `Esc` clears focus. Additive; the page is complete without it.
|
||||||
|
|
||||||
|
## Signatures
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _group_of(rel: str) -> str | None:
|
||||||
|
"""The grouping key for an item, or None when it has none.
|
||||||
|
|
||||||
|
THE RULE, in one line: the first separator-delimited segment of the
|
||||||
|
basename's stem -- with a trailing digit run stripped only when the stem has
|
||||||
|
no separator at all.
|
||||||
|
|
||||||
|
00-sheet-c1-market-noon.png -> 00
|
||||||
|
m-c1-market-noon-9401.png -> m
|
||||||
|
flag-rear.png -> flag
|
||||||
|
ac01.png -> ac (no separator: the digits ARE it)
|
||||||
|
DSC0001.jpg -> DSC
|
||||||
|
v30-seed8302.png -> v30 (separator present, so v30 != v35)
|
||||||
|
01.png -> None (nothing before the digits)
|
||||||
|
|
||||||
|
Derived HERE and nowhere else (INV-1).
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
~~**SUPERSEDED — the rule this contract was written with.**~~ *"take the stem of
|
||||||
|
the basename, strip ONE trailing run of digits and any single separator before
|
||||||
|
it. `ac01.png` → `ac`; `00-sheet-c1-market-noon.png` → `00-sheet-c1-market-noon`
|
||||||
|
(no trailing digit run, so the whole stem); `flag-rear.png` → `flag-rear`."*
|
||||||
|
Kept struck rather than deleted: it is the rule the measurement table above was
|
||||||
|
supposed to describe, and the mismatch between the two is the thing worth
|
||||||
|
remembering. It keys on the end of the stem, where the instance number lives,
|
||||||
|
and so splits families rather than gathering them.
|
||||||
|
|
||||||
|
## Ordering — the rule, because invariant 6 binds
|
||||||
|
|
||||||
|
| collection | rule |
|
||||||
|
|---|---|
|
||||||
|
| items | **unchanged** — `sorted(rel)` (U1 INV-3) |
|
||||||
|
| the zoom ring | **unchanged** — item order filtered to images |
|
||||||
|
| **groups among themselves** | **the position of each group's FIRST member in the RENDERED sequence** — which is `sorted(rel)` narrowed by the filter and never re-sorted. So the rail reads in the same direction the grid does, and adding a file never reshuffles the rail unless it lands first in its group. Implemented by walking `shown` once into an insertion-ordered `dict`: the walk IS the rule, so there is no second sort to drift from it. |
|
||||||
|
| items within a group | **unchanged** — they are a filtered view of `sorted(rel)`, never re-sorted |
|
||||||
|
| the filtered grid | **unchanged** — `sorted(rel)` with non-matching items hidden |
|
||||||
|
|
||||||
|
This closes ROADMAP's outstanding U7 order question. Compare pairing is not
|
||||||
|
this unit's problem — compare mode is parked to v1.1 with the pairing rule.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
**INV-1 — one resolver derives the group.** `_group_of` is called only from
|
||||||
|
`booth_items`. *Falsifiable:* the defeating change is a route or template
|
||||||
|
computing a prefix inline. The test asserts no call to `_group_of` survives
|
||||||
|
inside `create_app` — the same assertion U1 makes for `classify` and
|
||||||
|
`render_doc`, which is why it is the shape used here.
|
||||||
|
|
||||||
|
**INV-2 — grouping and filtering never reorder.** *Falsifiable:* the defeating
|
||||||
|
change is sorting by `(group, rel)` to make the grid render contiguously, which
|
||||||
|
looks right and silently changes what "the third one" means. The test renders a
|
||||||
|
booth whose groups interleave in `sorted(rel)` order and asserts the rendered
|
||||||
|
item sequence is **byte-identical** with grouping on and off, and that
|
||||||
|
`image_chain` is unchanged under every filter.
|
||||||
|
|
||||||
|
**INV-3 — a rail that cannot navigate does not render, in EITHER direction of
|
||||||
|
degeneracy.** The rail is absent unless grouping is informative: **two or more
|
||||||
|
groups, and the middle group holding more than one item.**
|
||||||
|
|
||||||
|
- **(a) one group for everything.** Live specimen `sc-iso-spread`:
|
||||||
|
`DSC0001.jpg`–`DSC0006.jpg`, one group, six images. A rail with a single row
|
||||||
|
cannot navigate.
|
||||||
|
- **(b) one group per item.** Live specimens `pewpew-ui-brief` (23 groups for
|
||||||
|
34 items) and `dfa-concepts` (13 for 20). A rail with a row per tile is a
|
||||||
|
second copy of the grid.
|
||||||
|
|
||||||
|
*Falsifiable:* two defeating changes, each with its own test. `{% if
|
||||||
|
rail.groups %}` in the template is true for a single group and true for N
|
||||||
|
singletons — so the decision lives in Python, where it can be measured, and the
|
||||||
|
template guard is the whole of it. Dropping the `>= 2` term reds
|
||||||
|
`test_no_group_rail_when_there_is_only_one_group`; dropping the median term
|
||||||
|
reds `test_no_group_rail_when_every_item_is_its_own_group`. Both mutations were
|
||||||
|
RUN.
|
||||||
|
|
||||||
|
~~**SUPERSEDED — INV-3 as first written.**~~ *"one group renders NO rail …the
|
||||||
|
test uses the real `sindra`-shaped fixture (thirty files, one prefix)."* Two
|
||||||
|
things wrong with it, and the second is why this is kept: the `sindra` fixture
|
||||||
|
does not exist (that booth yields 27 groups under the rule stated beside it,
|
||||||
|
and 2 under the shipped one — `sc-iso-spread` is the real specimen), and it
|
||||||
|
guarded only degeneracy (a) when (b) is the one the live set actually
|
||||||
|
exhibits. A contract that had shipped as written would have put a 23-row rail
|
||||||
|
on `pewpew-ui-brief`.
|
||||||
|
|
||||||
|
**INV-4 — a filter is a link, not a script.** *Falsifiable:* the defeating
|
||||||
|
change is binding filters to a click handler. The test fetches the filtered URL
|
||||||
|
directly and asserts the server returned the filtered grid, with no JS executed.
|
||||||
|
|
||||||
|
**INV-5 — the keyboard never fires on a booth with no grid.** *Falsifiable:*
|
||||||
|
the defeating change is binding the handler unconditionally, so `f` on the
|
||||||
|
standing link board flags nothing and swallows the keystroke. The test asserts
|
||||||
|
the handler is not bound when `items` is empty.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Sections as a rail.** Measured worthless; `Item.section` is untouched.
|
||||||
|
- **Compare mode.** Parked to v1.1 with its pairing rule.
|
||||||
|
- **Virtualized loading.** Parked; measure first.
|
||||||
|
- **A `.groups` override file.** See open questions.
|
||||||
|
- **Anything on the verbatim path.** It has no grid.
|
||||||
@@ -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,66 @@
|
|||||||
|
# PENDING — fleet note to the 17 consuming handles
|
||||||
|
|
||||||
|
**Status: DRAFTED, NOT SENT.** Blocked by the auto-mode classifier on
|
||||||
|
2026-09-22 because it is a multi-recipient send, which CLAUDE.md gates on
|
||||||
|
explicit operator approval. The operator's blanket "accept all recs" was
|
||||||
|
read as ratifying the note's CONTENT, not as the specific broadcast
|
||||||
|
approval that rule requires — and the classifier agreed. Not worked around.
|
||||||
|
|
||||||
|
**To send it:** the operator says go, or adds a Bash permission rule for
|
||||||
|
`postbox send`. Recipients (17, from the live board's provenance):
|
||||||
|
|
||||||
|
hamr-dev tts-dev nh3-dev shutter-dev infra-ops comfy-dev ldp-dev
|
||||||
|
design-dev pewpew-dev peedlar-dev brokkr-smithy-dev draupnir
|
||||||
|
bifrost-dev yt-voice-clipper-dev svos-dev jackdaw-dev brokkr-scan-dev
|
||||||
|
|
||||||
|
Subject: `booth: \`booth link\` now refuses a booth URL — use \`booth new --why\` instead`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
ONE CHANGE THAT AFFECTS YOU, and it is a refusal you would otherwise hit
|
||||||
|
without knowing why.
|
||||||
|
|
||||||
|
`booth link` now REFUSES a booth URL.
|
||||||
|
|
||||||
|
$ booth link http://10.100.10.50:8090/b/my-run/ "the renders"
|
||||||
|
booth link: that is a booth, and a booth announces itself now.
|
||||||
|
booth new my-run --why "the renders"
|
||||||
|
the index at http://10.100.10.50:8090/ is the feed.
|
||||||
|
exit 2
|
||||||
|
|
||||||
|
WHY. A booth announces itself now — `booth new` and `booth add` write a
|
||||||
|
`.booth.json` carrying your handle and a one-line `--why`, and the index
|
||||||
|
renders it. Posting the URL to the board on top of that creates a row that
|
||||||
|
rots the moment the booth is swept. Measured on the live board: 178 of its 221
|
||||||
|
rows were booth URLs and 156 of those already pointed at nothing.
|
||||||
|
|
||||||
|
WHAT TO DO INSTEAD. Nothing extra — just use `--why`:
|
||||||
|
|
||||||
|
booth new my-run --why "8 renders, pick the two that hold at 4K"
|
||||||
|
booth add my-run out/*.png --why "..."
|
||||||
|
|
||||||
|
The operator sees it on the index with your handle beside it.
|
||||||
|
|
||||||
|
WHAT IS UNCHANGED. `booth link` is NOT deprecated and keeps working for
|
||||||
|
everything else — repos, model cards, docs, recipes, any durable reference.
|
||||||
|
Roughly 14 of the board's 35 distinct non-booth links are exactly that and the
|
||||||
|
board is still their home. Only the booth-URL shape is refused.
|
||||||
|
|
||||||
|
ALSO NEW, and optional: `booth bench add <url> <name>` registers a RUNNING
|
||||||
|
SERVICE — your current bench, the thing that gets promoted to Homepage.
|
||||||
|
Identity is the URL, so re-posting UPDATES the row instead of adding a fifth
|
||||||
|
(`talk` was on the board five times). `booth bench ls` lists them.
|
||||||
|
|
||||||
|
a BOOTH is work to review. Announces itself, swept after 24h.
|
||||||
|
a BENCH is a running thing. Registered, durable, upserted by URL.
|
||||||
|
a LINK is a reference bookmark. The board, unchanged.
|
||||||
|
|
||||||
|
ONE MORE, since it is easy to miss: `booth link` also refuses a URL carrying
|
||||||
|
credentials (`user:pass@host`). The board renders on an unauthenticated LAN
|
||||||
|
surface.
|
||||||
|
|
||||||
|
Shipped in booth v0.6.0/v0.6.1, deployed and live. No action needed from you
|
||||||
|
unless you have a script that posts booth URLs to the board — that will now
|
||||||
|
exit 2 rather than silently adding a dead row.
|
||||||
|
|
||||||
|
-- booth-dev
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# An approved directive misrouted because pane_find addresses by a rolling title
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**An operator-approved directive (D-0011, sent by Miranda, telling booth-dev to
|
||||||
|
begin U7) landed on the infra-ops handle instead.** Worth keeping for the
|
||||||
|
mechanism, not the incident: the incident resolved cleanly and the mechanism
|
||||||
|
did not.
|
||||||
|
|
||||||
|
## What happened, and why nothing broke
|
||||||
|
|
||||||
|
`pane_find` matched `terminal_2` **by its ROLLING PANE TITLE**, and that pane is
|
||||||
|
the eshpfi-management seat rather than booth-dev. Miranda confirmed all of this
|
||||||
|
directly when asked.
|
||||||
|
|
||||||
|
infra-ops caught it and **deliberately did not relay the content as an
|
||||||
|
instruction** — their reasoning, which is exactly right: a directive arriving as
|
||||||
|
"infra-ops says Miranda says Vuong says" is two hops from the source, and a peer
|
||||||
|
passing operator authority along is the thing the rules warn about. They sent a
|
||||||
|
routing report instead, quoting only the two lines that identified the target.
|
||||||
|
|
||||||
|
This session then **did not act on it**, and asked Miranda directly rather than
|
||||||
|
taking a peer's word for the operator's. She confirmed it was genuine and
|
||||||
|
**superseded pending the sections ruling**. Both loops closed in two messages.
|
||||||
|
|
||||||
|
## The part that is still true tomorrow
|
||||||
|
|
||||||
|
**A pane title that changes as work moves through the pane is not a stable
|
||||||
|
address.** It put an approved directive on the wrong seat, and:
|
||||||
|
|
||||||
|
- **the failure is silent from the sender's side.** Miranda had no signal it
|
||||||
|
went astray until infra-ops spoke up. A directive that misroutes to a quiet
|
||||||
|
or busy seat simply evaporates.
|
||||||
|
- it landed somewhere that caught it. That was luck, not design.
|
||||||
|
|
||||||
|
Reported to infra-ops as an ops matter (`01M35JJ9034E64HMA8X9C21R2N`), with the
|
||||||
|
mechanism named and no fix proposed — not this repo's call. **Not tracked
|
||||||
|
anywhere by booth-dev**; recorded here only so the next session does not
|
||||||
|
re-derive it if a directive goes missing again.
|
||||||
|
|
||||||
|
## The rule this confirms
|
||||||
|
|
||||||
|
The CLAUDE.md Miranda exception is for Miranda relaying **directly**. A
|
||||||
|
second-hand report of a Miranda relay is one hop too far, and infra-ops said so
|
||||||
|
before this session had to. Going to the source cost two messages and settled it.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# A mutation harness that certified a broken test, twice, for two reasons
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
This repo already knows that **an assertion which has never seen its own
|
||||||
|
defeating change is not known to falsify anything** — two prior entries say so
|
||||||
|
([[2026-09-22-vacuous-falsifiers]], [[2026-09-22-seven-of-seven-falsifiers]]).
|
||||||
|
So U7's groups were built with a harness that applies each defeating change and
|
||||||
|
asserts the named test goes red. **The harness itself had two defects, and both
|
||||||
|
produce the same lie: a falsifier certified without being run.**
|
||||||
|
|
||||||
|
## Defect 1 — no green baseline
|
||||||
|
|
||||||
|
A test that is **already red** reports RED for every mutation thrown at it. The
|
||||||
|
escaping test had an arithmetic slip (counted `<` against `<a`/`<nav`/`</` and
|
||||||
|
forgot the two `<b>` elements), so it was failing for a reason unrelated to
|
||||||
|
escaping — and the harness cheerfully reported `RED ✓ the rail markup is emitted
|
||||||
|
with |safe`. **Run the test unmutated first; a non-zero baseline is a harness
|
||||||
|
failure, not a proven falsifier.**
|
||||||
|
|
||||||
|
## Defect 2 — the bytecode cache, which is the subtle one
|
||||||
|
|
||||||
|
`if len(sizes) < 2` → `if len(sizes) < 1` is **byte-identical in size**. CPython
|
||||||
|
validates a `.pyc` against the source's `(mtime, size)` at **one-second
|
||||||
|
granularity** — so a mutation that lands in the same second as the revert before
|
||||||
|
it is invisible, the cached bytecode is reused, and **the harness runs the
|
||||||
|
unmutated code and reports the falsifier proven.**
|
||||||
|
|
||||||
|
The tell was non-determinism with no cause: INV-3a certified RED on one run and
|
||||||
|
GREEN on the next with neither the test nor the code changing, and reproduced by
|
||||||
|
hand every time. Fix: delete `__pycache__` and set `PYTHONDONTWRITEBYTECODE=1`
|
||||||
|
in the subprocess environment before every run.
|
||||||
|
|
||||||
|
⚠ **This bites any same-size source mutation**, which is most interesting ones:
|
||||||
|
comparison flips, off-by-one constants, `and`↔`or`, `<`↔`>`. A mutation harness
|
||||||
|
without cache defeat is biased toward exactly the mutations most worth running.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
12 falsifiers, 12 proved, stable across consecutive runs. Two of them only
|
||||||
|
after these fixes — and one of the twelve (`test_group_order_is_the_position_of
|
||||||
|
_the_first_member`) was genuinely vacuous on the first pass: its `w, x, y`
|
||||||
|
fixture's positional order **happened to be alphabetical**, so it stayed green
|
||||||
|
under the alphabetical-sort mutation it forbade. Rebuilt so all three plausible
|
||||||
|
rules (position, alphabetical, count) disagree.
|
||||||
|
|
||||||
|
**The harness lives in the session scratchpad and dies with the session.**
|
||||||
|
Whether it becomes `scripts/` is an open question for the operator — this repo
|
||||||
|
has now been bitten by vacuous falsifiers three times, and prose in a memory
|
||||||
|
file is not an instrument.
|
||||||
@@ -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,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,49 @@
|
|||||||
|
# The operator ruled on all five open items at once
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**"accept all recs, or make good ones, write it to handoff so I can clear."** A
|
||||||
|
blanket ratification. Four of the five executed; one was stopped by the
|
||||||
|
permission layer and is recorded rather than worked around.
|
||||||
|
|
||||||
|
| # | item | ruling | state |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Drop subfolder sections for filename-prefix groups | **APPROVED** | **not yet built** — the next session's first job |
|
||||||
|
| 2 | What `unanswered` filters on | **open pick** (the shipped reading) | settled; the other reading parked to v1.1 |
|
||||||
|
| 3 | Push `main` | **PUSH** | **DONE** — 16 commits + `v0.6.0` + `v0.6.1` now on `origin` |
|
||||||
|
| 4 | The 17-handle althing note | send it | **BLOCKED** — see below |
|
||||||
|
| 5 | Marks guard placement | **stays at `_hydrate`** | already there; nothing to do |
|
||||||
|
|
||||||
|
## Two things the blanket ruling did NOT cover, and why
|
||||||
|
|
||||||
|
**The broadcast was blocked by the auto-mode classifier, and that was right.**
|
||||||
|
CLAUDE.md gates any multi-recipient althing send on *explicit* operator
|
||||||
|
approval — "ask, then send, never send and report" — because the cost is
|
||||||
|
multiplied by the recipient count and paid out of budgets the sender never
|
||||||
|
sees. A blanket "accept all recs" ratifies the note's **content**; it is not the
|
||||||
|
specific, informed broadcast approval that rule asks for. The classifier agreed
|
||||||
|
and **it was not worked around**. Draft, rationale and the 17-name recipient
|
||||||
|
list live at `docs/pending/fleet-note-booth-link-refusal.md` so they survive a
|
||||||
|
context clear; it needs his explicit go or a `postbox send` permission rule.
|
||||||
|
|
||||||
|
**"No seeding yet" survives the blanket ruling**, because it was a SPECIFIC
|
||||||
|
prior instruction rather than a recommendation of this session's. A blanket
|
||||||
|
acceptance of recommendations does not overwrite a direct instruction pointing
|
||||||
|
the other way. `.benches.json` still does not exist in `~/booth-data`.
|
||||||
|
|
||||||
|
## The push, recorded because it is a first
|
||||||
|
|
||||||
|
`main` was **16 commits ahead** with two release tags unpushed and the whole of
|
||||||
|
U6 single-copy on one box. Pushed with `--follow-tags`, then the two tags
|
||||||
|
explicitly — `--follow-tags` pushed neither, because both tags are LIGHTWEIGHT
|
||||||
|
per the SemVer policy and that flag only carries annotated ones. Worth knowing:
|
||||||
|
**a lightweight release tag needs its own `git push origin <tag>`.**
|
||||||
|
|
||||||
|
## The trap this leaves behind, and it is a real one
|
||||||
|
|
||||||
|
`tests/test_navigation.py::test_no_group_rail_is_shipped_yet` was written to
|
||||||
|
**stop an unapproved group rail from arriving by accident**. The rail is now
|
||||||
|
approved, so that test has inverted: it will block the correct work and read
|
||||||
|
like a genuine invariant while doing it. **Whoever builds the group rail must
|
||||||
|
delete it in the same commit.** A guard that outlives its reason is worse than
|
||||||
|
no guard, because the next reader trusts it.
|
||||||
@@ -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,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,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,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,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,77 @@
|
|||||||
|
# U7 landed — and the number that justified it did not reproduce
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**The last v1 unit is in.** The three ratified components landed at `a306e2d`;
|
||||||
|
the fourth — filename-prefix groups replacing subfolder sections — landed here,
|
||||||
|
with `test_no_group_rail_is_shipped_yet` deleted in the same commit that built
|
||||||
|
what it guarded against. **All seven v1 capabilities are now landed.**
|
||||||
|
|
||||||
|
## The part worth remembering: the contract's own measurement was wrong
|
||||||
|
|
||||||
|
The contract stated a rule and, beside it, a table of what that rule produced.
|
||||||
|
**They are not the same computation.** Implementing the stated rule and running
|
||||||
|
it against the live set:
|
||||||
|
|
||||||
|
| booth | contract claimed | stated rule actually gives |
|
||||||
|
|---|---|---|
|
||||||
|
| `sindra-corpus-v1` | 16 | 16 ✓ |
|
||||||
|
| `sindra-sfw-pool` | 10 | 10 ✓ |
|
||||||
|
| `sindra-nude-pool` | 12 | 12 ✓ |
|
||||||
|
| **`sindra-bakeoff`** | **5** | **24** |
|
||||||
|
| **`sindra`** | **1 (degenerate)** | **27** |
|
||||||
|
|
||||||
|
Three of five matched, which is what made it survive review. The two that did
|
||||||
|
not were **the two load-bearing rows**: bakeoff was the "this pays" evidence and
|
||||||
|
sindra was the degenerate case INV-3 was written for.
|
||||||
|
|
||||||
|
**The contract contradicts itself in plain sight and nobody caught it.** Its own
|
||||||
|
worked example says `00-sheet-c1-market-noon.png` has no trailing digit run and
|
||||||
|
therefore groups as its whole stem — which makes eight of bakeoff's forty images
|
||||||
|
eight singleton groups, so 5 was never reachable. And the numbers ARE
|
||||||
|
reproducible, just not by one rule: **first-two-segments gives exactly 5 on
|
||||||
|
bakeoff; first-segment gives exactly 1 on sindra.** The table was assembled from
|
||||||
|
two different heuristics and written up as one.
|
||||||
|
|
||||||
|
⚠ **A cold contract-review panel cannot catch this, and did not.** The panel
|
||||||
|
reads the artifact; the artifact is internally plausible. Only running the
|
||||||
|
stated rule against the live data falsifies it. **A measurement inside a
|
||||||
|
contract is not reviewed by reviewing the contract** — it is reviewed by
|
||||||
|
re-running it, and that is now a thing to do before implementing any contract
|
||||||
|
whose scope rests on a number.
|
||||||
|
|
||||||
|
## The degeneracy it guarded was the wrong one
|
||||||
|
|
||||||
|
INV-3 guarded **one group for everything** ("a rail with one entry cannot
|
||||||
|
navigate"). The live set's actual failure is the opposite: **one group per
|
||||||
|
item** — `pewpew-ui-brief` 23 groups for 34 items, `dfa-concepts` 13 for 20. The
|
||||||
|
contract as written would have shipped a 23-row rail that is a second copy of
|
||||||
|
the grid. INV-3 now guards both, with a live specimen each:
|
||||||
|
|
||||||
|
- **(a)** `sc-iso-spread` — `DSC0001.jpg`–`DSC0006.jpg`, one group of six.
|
||||||
|
- **(b)** `pewpew-ui-brief` — 23 groups, 19 of them singletons.
|
||||||
|
|
||||||
|
The shipped predicate, one line: **two or more groups, and the middle group
|
||||||
|
holding more than one item.** It gets all 17 booths right.
|
||||||
|
|
||||||
|
## The shipped rule, and why it differs
|
||||||
|
|
||||||
|
`strip ONE trailing run of digits` keys on the END of the stem, which is where
|
||||||
|
the *instance number* lives — so it splits `m-c1-market-noon-9401` from
|
||||||
|
`m-c2-rain-street-9403`, which are the same family. The shipped rule keys on the
|
||||||
|
**first separator-delimited segment**, where the family lives, destemming only
|
||||||
|
when the stem has no separator at all (so `ac01` → `ac`, but `v30-seed8302` and
|
||||||
|
`v35-seed8302` stay apart — that split is the axis `muse-clothed-repro` is
|
||||||
|
about).
|
||||||
|
|
||||||
|
Live result: `sindra-corpus-v1` renders `ac 12 · bu 10 · cu 12 · fb 12 · … ·
|
||||||
|
wu 8` over 66 images. `sindra-bakeoff` renders `00 · README · m · r`, which are
|
||||||
|
its three real families.
|
||||||
|
|
||||||
|
## Also true, and easy to trip on
|
||||||
|
|
||||||
|
**`miranda-is` and `sindra-voice-1` group beautifully and get no rail** — both
|
||||||
|
carry `index.html`, so they take the verbatim path and have no grid at all. A
|
||||||
|
measurement taken with `booth_items` alone predicts a rail for them; the route
|
||||||
|
does not. Measure the RENDERED surface, not the resolver, when the question is
|
||||||
|
"what will the operator see".
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# U7 re-measured before scoping — sections are dead, filename prefixes are not
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**Pre-work, not the unit.** The standing instruction is "re-count the booths
|
||||||
|
before scoping U7". Done, on the live set (19 booths). No U7 code, no U7
|
||||||
|
contract — this exists so the scope call is a thirty-second read.
|
||||||
|
|
||||||
|
## The set as it actually is
|
||||||
|
|
||||||
|
| booth | items | images | subdirs | shape |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `miranda-is` | 92 | 0 | 0 | report |
|
||||||
|
| `sindra-bakeoff` | 81 | 40 | **0** | gallery |
|
||||||
|
| `sindra-corpus-v1` | 66 | 66 | **0** | gallery |
|
||||||
|
| `sindra` | 61 | 30 | **0** | gallery |
|
||||||
|
| `sindra-sfw-pool` | 59 | 59 | **0** | gallery |
|
||||||
|
| `sindra-nude-pool` | 42 | 42 | **0** | gallery |
|
||||||
|
| `pewpew-ui-brief` | 34 | 1 | 7 | **report** |
|
||||||
|
| `dfa-concepts` | 21 | 14 | 1 | **report** |
|
||||||
|
| …11 more | ≤19 | | 0 | |
|
||||||
|
|
||||||
|
## Finding 1 — sections are worth ZERO, and this is now measured twice
|
||||||
|
|
||||||
|
**Not one gallery booth has a subdirectory.** Zero of eleven. The only two
|
||||||
|
booths with subfolders are both **reports**, the job where grid navigation
|
||||||
|
matters least, and `pewpew-ui-brief`'s seven subdirs hold one image.
|
||||||
|
|
||||||
|
The IA doc calls sections "most of the navigation fix". On this set they are
|
||||||
|
none of it. Cutting `Item.section` rendering from U7 costs nothing measurable.
|
||||||
|
(`Item.section` already exists from U1 and stays — this is about whether U7
|
||||||
|
builds a section RAIL, not about deleting a field.)
|
||||||
|
|
||||||
|
## Finding 2 — the grouping signal is in the FILENAME, and it pays
|
||||||
|
|
||||||
|
Tested two heuristics against every large gallery. Strip a trailing digit-run
|
||||||
|
from the stem and group on what remains:
|
||||||
|
|
||||||
|
| booth | images | groups | verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `sindra-corpus-v1` | 66 | **16** | useful |
|
||||||
|
| `sindra-nude-pool` | 42 | **12** | useful |
|
||||||
|
| `sindra-sfw-pool` | 59 | **10** | useful |
|
||||||
|
| `sindra-bakeoff` | 40 | **5** | useful |
|
||||||
|
| `sindra` | 30 | **1** | **degenerates** |
|
||||||
|
|
||||||
|
Specimens: `00-sheet-c1-market-noon.png`, `ac01.png`, `a01.png`,
|
||||||
|
`flag-rear.png`. The competing heuristic — split on the second hyphen — is
|
||||||
|
useless everywhere (59 "groups" from 59 files).
|
||||||
|
|
||||||
|
So a prefix heuristic pays on **4 of 5** large galleries and collapses to one
|
||||||
|
group on the fifth. **That is a filter/grouping affordance, not a section
|
||||||
|
rail**, and it must degrade gracefully to "one group" rather than render a
|
||||||
|
useless single-section rail.
|
||||||
|
|
||||||
|
## What this implies for the scope, stated as a recommendation not a decision
|
||||||
|
|
||||||
|
U7 as written is four things: sections, a sticky rail, filters, grid keyboard.
|
||||||
|
The measurement says **drop sections, keep the other three**, and consider
|
||||||
|
prefix-grouping as the thing sections were supposed to be — with a stated
|
||||||
|
degenerate case.
|
||||||
|
|
||||||
|
⚠ The sizing case has also changed: the unit was scoped against 270-item
|
||||||
|
booths and **the largest gallery is now 81 items / 40 images**. Everything
|
||||||
|
about virtualization stays parked ([[2026-09-21-ia-and-v1-gate-landed]] names
|
||||||
|
it); at 66 images a lazy grid is fine and measuring it first is the rule.
|
||||||
|
|
||||||
|
**The booth set churned again during this session** — `sindra-sfw-pool` (59
|
||||||
|
images) appeared and the `pancake-*` set went. Re-count again before writing
|
||||||
|
the contract; do not trust this table either.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# U7 is three-quarters built and blocked on one word
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**The last v1 unit, decomposed by what the operator has already ratified versus
|
||||||
|
what he has not.** ROADMAP's U7 row names four components. Three were already
|
||||||
|
approved there and are **built, tested and deployed** (`a306e2d`). The fourth is
|
||||||
|
a scope departure and is **deliberately not built**.
|
||||||
|
|
||||||
|
| component | ROADMAP | state |
|
||||||
|
|---|---|---|
|
||||||
|
| sticky rail | ratified | **landed** — totals + per-filter counts |
|
||||||
|
| filters | ratified | **landed** — all / flagged / annotated / unanswered |
|
||||||
|
| grid keyboard | ratified | **landed** — `←/→ f n Enter Esc`, bound only when a grid exists |
|
||||||
|
| **sections → filename groups** | **departs** | **NOT BUILT** |
|
||||||
|
|
||||||
|
`tests/test_navigation.py::test_no_group_rail_is_shipped_yet` fails the moment
|
||||||
|
somebody builds the group rail anyway, so the departure cannot arrive by
|
||||||
|
accident while the ruling is outstanding.
|
||||||
|
|
||||||
|
## The question, and why it is his
|
||||||
|
|
||||||
|
**Drop subfolder sections for filename-prefix groups — yes or no?**
|
||||||
|
|
||||||
|
Measured (see [[2026-09-22-u7-remeasured-before-scoping]]): **zero of eleven
|
||||||
|
gallery booths have a subdirectory**, so sections buy nothing; stripping a
|
||||||
|
trailing digit-run from the stem yields **5–16 sensible groups on four of the
|
||||||
|
five large galleries** and degenerates to one group on the fifth. The
|
||||||
|
replacement is better on the evidence — but swapping a ratified component for
|
||||||
|
an unratified one is scope direction, not implementation.
|
||||||
|
|
||||||
|
Contract at `docs/contracts/u7_navigation.contract.md`, status
|
||||||
|
`PARTIALLY LANDED`, with the departure named as the operator's call.
|
||||||
|
|
||||||
|
## Decisions taken under stated assumption, both cheap to reverse
|
||||||
|
|
||||||
|
- **`unanswered` means HAS AN OPEN PICK** — the U4 hold predicate, which already
|
||||||
|
exists. The other reading ("has no mark at all") is a genuinely different
|
||||||
|
question and stays an open question on the contract.
|
||||||
|
- **Filters are LINKS, not scripts**, resolved server-side, so the gallery keeps
|
||||||
|
working with JavaScript off. U3 cost the verbatim path its no-JS operation and
|
||||||
|
said so plainly; the gallery is the surface the operator actually reviews on,
|
||||||
|
and this unit does not repeat it there.
|
||||||
|
|
||||||
|
## The vacuous falsifier, written an hour after the entry about them
|
||||||
|
|
||||||
|
`test_filtering_never_reorders` compared each filtered view against the
|
||||||
|
**unfiltered response** — so a mutation reversing the order reversed both sides
|
||||||
|
and it **stayed green under the exact change it forbade.** Caught only by
|
||||||
|
running the mutation rather than trusting the assertion.
|
||||||
|
|
||||||
|
Rewritten against an independent truth: U1 INV-3 says the order IS `sorted(rel)`,
|
||||||
|
so each view must be sorted, full stop, with no reference to another response.
|
||||||
|
Re-verified RED. **Every new falsifier in this session was mutation-checked
|
||||||
|
after this**, and that is the practice to keep — see
|
||||||
|
[[2026-09-22-vacuous-falsifiers]].
|
||||||
+79
-40
@@ -19,51 +19,90 @@ 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
|
- **U6 SHIPPED (`v0.6.0`) with a late fix (`v0.6.1`). PUSHED.** `main` and both
|
||||||
**U1 → U2 → {U3, U4, U5} → U7**, with **U6 independent**.
|
tags are on `origin` as of 2026-09-22 — the tree is no longer single-copy.
|
||||||
- **U1, U2, U4 and U5 are landed.** U1 `ce598b3`; U2 `c7f9437` → `v0.2.0`,
|
→ `persistent-memory.d/2026-09-22-u6-benches-released.md`
|
||||||
`5e41108` → `v0.2.1`, `026a1fc` → `v0.2.2`; U5 `c015a91` + `95beede` →
|
- **THE OPERATOR RULED ON EVERYTHING OUTSTANDING (2026-09-22, "accept all
|
||||||
`v0.3.0`. **U4 landed 2026-09-22** — 396 tests green (341 → 396), deployed and
|
recs").** Four of five settled and executed; one blocked by the permission
|
||||||
verified live, 24/24 booth pages 200, layout probe clean.
|
layer. Nothing is waiting on him. →
|
||||||
- **U4 released as `v0.4.0`** (operator approved the minor on 2026-09-22).
|
`persistent-memory.d/2026-09-22-operator-ruled-on-the-open-five.md`
|
||||||
`c3a97c1` is the unit; the release commit carries the pre-existing fixes the
|
- ✅ **U7 IS LANDED — ALL SEVEN v1 UNITS ARE IN.** The fourth component
|
||||||
bug-hunt panel surfaced in touched files. The tag waited for the last gate to
|
(filename-prefix groups) is built; `test_no_group_rail_is_shipped_yet` was
|
||||||
close, per the `v0.2.0` lesson — see Tried and abandoned.
|
deleted in the same commit, as required.
|
||||||
- ⚠ **The 17 consuming handles have NOT been told** that `keep` no longer means
|
→ `persistent-memory.d/2026-09-22-u7-landed-and-a-table-that-did-not-reproduce.md`
|
||||||
"waiting on an answer". That is the one coordination this release genuinely
|
- 🔶 **THE 1.0 CUT IS NOW A DECISION, NOT A DEPENDENCY, AND IT IS HIS.** The v1
|
||||||
warrants, and a fleetwide post needs operator approval before it is sent.
|
target is met. A major bump needs explicit operator approval; nothing in the
|
||||||
- **THE NEXT UNIT IS THE OPERATOR'S CALL.** U3 (declared embed seam) and U6
|
code is waiting on it. The open fork: cut `1.0`, or stage a `0.7.0` first.
|
||||||
(benches) are both unblocked; U7 waits on the rest. U6 is independent of
|
**Not bumped — the work is committed as commits, which are not releases.**
|
||||||
everything and was conceptually unblocked by U5 giving job 5 a home; U3 is
|
- ⚠ **U7'S CONTRACT CARRIED A MEASUREMENT THAT DID NOT REPRODUCE**, and it was
|
||||||
where verbatim-booth provenance was deferred to, and U4 added a fourth reason
|
the number the scope departure rested on. The stated rule gives 24 and 27
|
||||||
to want it — a verbatim booth has no Booth-rendered header, so its lifetime
|
groups where the table claimed 5 and 1; the table was assembled from two
|
||||||
line lives only on the index card and the marks page.
|
different heuristics. **A cold contract-review panel cannot catch this** — the
|
||||||
- **No gate is outstanding.** All three ran on U4 and were folded in: the
|
artifact is internally plausible. Re-run any measurement a contract's scope
|
||||||
`/heid-contract-review` panel (`01M34VX0SH23Y3VC92E7GM4S70`), the
|
rests on before implementing it. Same detail file.
|
||||||
`/heid-code-review` panel (`01M34WAFJC3RTERFYBBZJN1SVG`) and the
|
- ⚠ **A MUTATION HARNESS NEEDS A GREEN BASELINE AND CACHE DEFEAT**, or it
|
||||||
`/heid-bug-hunt` (`01M34Y2R0RAJRSN36Q8K4KAB36`). All loops closed with heid.
|
certifies falsifiers without running them. Both defects bit in one session.
|
||||||
The U5 round's three are also closed (`01M340PNVRS21HPASZT38PXQPN`,
|
→ `persistent-memory.d/2026-09-22-a-mutation-harness-that-certified-a-broken-test.md`
|
||||||
`01M341E9XAPZEFBSPK9HPGAM0S`, `01M343SXX27Z47C3STXXRC7M42`).
|
- **639 tests green; 12/12 new falsifiers mutation-proved. Deployed; 21/21
|
||||||
- **Two dated predictions are pending and must not be forgotten.** U5's adoption
|
booths 200.** ⚠ The set churned again mid-session (19 → 21).
|
||||||
re-measure on **2026-09-29** (two counts, see its entry — already at 3 of 24
|
- 🔶 **A bug-hunt panel is IN FLIGHT** — heid thread `01M368G2Y0JMTJ2T7M3JMTXV5Z`,
|
||||||
announced and 2 with a `why`, all from peers told nothing), and the `.forever`
|
dispatched 2026-09-22 21:31 PDT over the U7-groups diff. If its reply has not
|
||||||
re-count **on or after 2026-10-06**, a fortnight after U4 landed, which is
|
been consumed, drain `/althing:inbox` and triage before treating U7 as closed.
|
||||||
U4's success criterion. ⚠ Only 4 booths carry marks at all, so the hold's live
|
- 🛑 **STANDING RULING — NO ANNOUNCEMENTS OUT OF THIS REPO, AND THE OPERATOR
|
||||||
blast radius is small and the prediction rests on both halves of U4 — see its
|
SENDS THE EVENTUAL ONE HIMSELF** (operator, 2026-09-22). Verbatim: *"no
|
||||||
entry for what a null result would and would not mean.
|
announcements until the entire arc is done, and even then i'll do it myself."*
|
||||||
- **Three methodology proposals from this session sit with the operator**, routed
|
Two clauses, both binding: **(a)** no althing announcement of any kind ships
|
||||||
by heid rather than decided unilaterally: reshaping the paraphrase gate toward
|
from booth-dev until the v1 arc is COMPLETE — not per-unit, not at the 1.0
|
||||||
a drift-check for narrative-heavy contracts, a standing
|
tag, not "just the peers who consume it"; **(b)** when the arc IS done, the
|
||||||
"green-tests-prove-nothing" direction for the code-review gate, and regin's
|
announcement is HIS to send, not a thing to ask permission for. This is
|
||||||
table-vs-signature consistency pass. They are changes to the `/heid*` skills,
|
STRICTER than `~/.claude/CLAUDE.md`'s broadcast gate, which merely requires
|
||||||
not to this repo.
|
approval — here the send is not the agent's to make at all, so *asking* is
|
||||||
- The booth set churns hard: 26 → 24 during this session as the sweeper ran.
|
also out of scope. Do not offer, draft-and-await, or surface it as a pending
|
||||||
Re-count rather than trusting any number written here.
|
decision; it is settled and not a standing question.
|
||||||
|
**The 17-handle `booth link` note is consequently REASSIGNED, not blocked.**
|
||||||
|
The full draft + recipient list stays at
|
||||||
|
`docs/pending/fleet-note-booth-link-refusal.md` as MATERIAL FOR HIM. It is no
|
||||||
|
longer an open loop, no longer awaiting approval, and no longer a thing to
|
||||||
|
raise. Same for anything U4's `keep`-semantics change would have warranted
|
||||||
|
telling peers.
|
||||||
|
- ⚠ **NOT SEEDED, and this survives the blanket ruling.** "No seeding yet" was a
|
||||||
|
SPECIFIC prior instruction, not a recommendation of mine, so "accept all recs"
|
||||||
|
does not override it. `.benches.json` does not exist in `~/booth-data`.
|
||||||
|
- **A U7 directive (D-0011) misrouted to infra-ops and is SUPERSEDED.** Miranda
|
||||||
|
confirmed directly. Nothing to act on.
|
||||||
|
→ `persistent-memory.d/2026-09-22-a-directive-misrouted-by-pane-title.md`
|
||||||
|
- ⚠ **THE 17 CONSUMING HANDLES WERE NEVER TOLD that `keep` stopped meaning
|
||||||
|
"waiting on an answer"** — and the note above does not tell them either; it is
|
||||||
|
about `booth link`. **This CHANGES HOW THE 2026-10-06 RE-COUNT READS**: a flat
|
||||||
|
`.forever` rate does NOT falsify the diagnosis.
|
||||||
|
- **Two dated predictions pending, not to be run early.** U5's adoption
|
||||||
|
re-measure **2026-09-29**; the `.forever` re-count **on or after 2026-10-06**.
|
||||||
|
- **FIVE `/heid*` methodology proposals sit with the operator**, untracked by
|
||||||
|
his choice.
|
||||||
|
- The booth set churns hard: 26 → 24 → 25 → 23 → **19**. Re-count rather than
|
||||||
|
trusting any number here.
|
||||||
|
|
||||||
## Recent decisions
|
## Recent decisions
|
||||||
|
|
||||||
|
- `[2026-09-22]` ✅ **U7 landed — and the number that justified it did not reproduce** — all seven v1 units are in; READ BEFORE TRUSTING A MEASUREMENT INSIDE A CONTRACT, and before assuming a degeneracy guard covers the degeneracy you actually have → `persistent-memory.d/2026-09-22-u7-landed-and-a-table-that-did-not-reproduce.md`
|
||||||
|
- `[2026-09-22]` ⚠ **A mutation harness certified a broken test, twice, for two reasons** — no green baseline, and the pyc cache silently reverting same-size mutations; READ BEFORE WRITING ONE → `persistent-memory.d/2026-09-22-a-mutation-harness-that-certified-a-broken-test.md`
|
||||||
|
- `[2026-09-22]` 🛑 **STANDING: no announcements out of this repo until the arc is done, and he sends that one himself** — verbatim *"no announcements until the entire arc is done, and even then i'll do it myself."* Stricter than the house broadcast gate: the send is not the agent's to make, so **asking is also out of scope**. The drafted 17-handle note is REASSIGNED to him, not blocked — see the in-flight row above; do not raise it again.
|
||||||
|
- `[2026-09-22]` **The operator ruled on all five open items at once** — four executed incl. the first push; the broadcast was blocked by the permission layer and is drafted at `docs/pending/` → `persistent-memory.d/2026-09-22-operator-ruled-on-the-open-five.md`
|
||||||
|
- `[2026-09-22]` **U7 is three-quarters built and blocked on one word** — the ratified three landed; the sections-vs-groups departure is NOT built and is the operator's call, tracked at `docs/contracts/u7_navigation.contract.md` → `persistent-memory.d/2026-09-22-u7-three-quarters-and-one-ruling.md`
|
||||||
|
- `[2026-09-22]` **An approved directive misrouted because pane_find addresses by a rolling pane title** — resolved; the MECHANISM is the durable part, reported to infra-ops, untracked by booth-dev → `persistent-memory.d/2026-09-22-a-directive-misrouted-by-pane-title.md`
|
||||||
|
- `[2026-09-22]` **U7 re-measured before scoping — sections are dead, filename prefixes are not** — PRE-WORK ONLY, no unit started; read before writing U7's contract → `persistent-memory.d/2026-09-22-u7-remeasured-before-scoping.md`
|
||||||
|
- `[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`
|
||||||
|
- `[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`
|
||||||
|
- `[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`
|
||||||
|
- `[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`
|
||||||
|
- `[2026-09-22]` **The 69% link-board rot was two defects wearing one number** — READ BEFORE SCOPING ANY LINK-BOARD WORK; U5 closed the larger half and full-URL-vs-origin identity is a measured call → `persistent-memory.d/2026-09-22-one-number-was-two-defects.md`
|
||||||
|
- `[2026-09-22]` **U3 landed — the page declares the seam, the Booth mounts into it** — ten regexes against author HTML replaced by a substring test and a `+` → `persistent-memory.d/2026-09-22-u3-declared-embed-seam-landed.md`
|
||||||
|
- `[2026-09-22]` **A wrong-shaped answer 500s the gallery and the marks page** — PRE-EXISTING (measured at `42ea67f`), NOT U3; the v0.2.2 lesson is only half-implemented → `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`
|
||||||
|
- `[2026-09-22]` **The browser became a test surface** — READ BEFORE TOUCHING `playwright` IN pyproject; the pinned upper bound is the foot-gun, and these tests SKIP rather than fail → `persistent-memory.d/2026-09-22-the-browser-became-a-test-surface.md`
|
||||||
|
- `[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`
|
||||||
- `[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`
|
- `[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`
|
||||||
- `[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`
|
- `[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`
|
||||||
|
- `[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`
|
||||||
- `[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`
|
- `[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`
|
||||||
- `[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`
|
- `[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`
|
||||||
- `[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`
|
- `[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`
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "booth"
|
name = "booth"
|
||||||
version = "0.4.0"
|
version = "1.0.0b1"
|
||||||
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]
|
||||||
|
|||||||
+264
-4
@@ -16,9 +16,33 @@
|
|||||||
# pick holds its own booth, see below)
|
# 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;
|
||||||
@@ -184,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,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
|
||||||
@@ -299,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)
|
||||||
@@ -357,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
|
||||||
|
|||||||
+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"
|
||||||
+17
-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 -----------------------------------------
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -258,3 +258,61 @@ def test_list_booths_counts_match_the_resolver(tmp_path):
|
|||||||
|
|
||||||
got = list_booths(tmp_path, ttl_seconds=86400)[0]
|
got = list_booths(tmp_path, ttl_seconds=86400)[0]
|
||||||
assert got["count"] == len(booth_items(b)) == 2
|
assert got["count"] == len(booth_items(b)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# --- U7: the group, derived here and nowhere else -------------------------
|
||||||
|
#
|
||||||
|
# ⚠ THE RULE IS NOT THE ONE THE CONTRACT FIRST STATED, and the change is
|
||||||
|
# measured rather than preferred. The contract's `strip ONE trailing run of
|
||||||
|
# digits` yields 24 groups for sindra-bakeoff's 40 images and 27 for sindra's
|
||||||
|
# 30 — a rail with one row per tile, which is a second copy of the grid rather
|
||||||
|
# than a way through it. Measured against all 17 live booths on 2026-09-22;
|
||||||
|
# the numbers are in the contract's rewritten table.
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_of_takes_the_first_segment(tmp_path):
|
||||||
|
from booth.items import _group_of
|
||||||
|
|
||||||
|
assert _group_of("00-sheet-c1-market-noon.png") == "00"
|
||||||
|
assert _group_of("m-c1-market-noon-9401.png") == "m"
|
||||||
|
assert _group_of("flag-rear.png") == "flag"
|
||||||
|
assert _group_of("v30-seed8302-HELD.png") == "v30"
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_of_destems_only_a_flat_name(tmp_path):
|
||||||
|
"""`ac01.png` has no separator, so the digits ARE the separator and the
|
||||||
|
group is `ac`. `v30-seed8302` HAS one, so `v30` survives intact — stripping
|
||||||
|
there would merge v30 with v35, which is the axis that booth is about."""
|
||||||
|
from booth.items import _group_of
|
||||||
|
|
||||||
|
assert _group_of("ac01.png") == "ac"
|
||||||
|
assert _group_of("DSC0001.jpg") == "DSC"
|
||||||
|
assert _group_of("v30-seed8302.png") == "v30"
|
||||||
|
assert _group_of("v35-seed8302.png") == "v35"
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_of_is_none_when_there_is_no_prefix(tmp_path):
|
||||||
|
"""A stem that is entirely digits has nothing to group on. Inventing one
|
||||||
|
would file every numbered render under the empty string."""
|
||||||
|
from booth.items import _group_of
|
||||||
|
|
||||||
|
assert _group_of("01.png") is None
|
||||||
|
assert _group_of("0042.jpg") is None
|
||||||
|
assert _group_of("-leading.png") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_is_derived_from_the_basename_not_the_path(tmp_path):
|
||||||
|
"""A booth WITH subdirectories still groups on the filename. Sections and
|
||||||
|
groups are different questions; `Item.section` still carries the path."""
|
||||||
|
from booth.items import _group_of
|
||||||
|
|
||||||
|
assert _group_of("sub/dir/ac01.png") == "ac"
|
||||||
|
|
||||||
|
|
||||||
|
def test_booth_items_carries_the_group(tmp_path):
|
||||||
|
b = tmp_path / "g"
|
||||||
|
_touch(b / "ac01.png")
|
||||||
|
_touch(b / "ac02.png")
|
||||||
|
_touch(b / "99.png")
|
||||||
|
got = {it.rel: it.group for it in booth_items(b)}
|
||||||
|
assert got == {"ac01.png": "ac", "ac02.png": "ac", "99.png": None}
|
||||||
|
|||||||
+85
-1
@@ -7,6 +7,8 @@ See docs/contracts/u2_marks.contract.md.
|
|||||||
"""
|
"""
|
||||||
import ast
|
import ast
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -276,7 +278,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", "__init__"])
|
||||||
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 +1376,85 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_package_version_carries_no_literal_of_its_own():
|
||||||
|
"""`booth.__version__` said `0.1.0` through six releases while pyproject
|
||||||
|
said `0.6.1` — a second copy of one fact, drifting silently, found only
|
||||||
|
while cutting 1.0.
|
||||||
|
|
||||||
|
THE ASSERTION IS THE ABSENCE OF A LITERAL, not agreement with pyproject:
|
||||||
|
`__version__` is now READ from pyproject, so comparing the two would be
|
||||||
|
circular and would prove only that the read works. The defeating change is
|
||||||
|
hardcoding a number back into this module, and that is what this catches.
|
||||||
|
"""
|
||||||
|
src = (pathlib.Path(__file__).parent.parent / "booth" / "__init__.py").read_text()
|
||||||
|
literals = re.findall(r'__version__\s*=\s*["\']([^"\']+)["\']', src)
|
||||||
|
assert not literals, f"booth/__init__.py hardcodes a version again: {literals}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_package_version_is_the_one_the_tree_declares():
|
||||||
|
"""And it resolves, from the tree, to what pyproject says — NOT to whatever
|
||||||
|
a stale dist-info in some venv happens to record. Found saying `0.3.0` from
|
||||||
|
installed metadata while the tree was at `1.0.0b1`."""
|
||||||
|
import booth
|
||||||
|
|
||||||
|
declared = tomllib.loads(
|
||||||
|
(pathlib.Path(__file__).parent.parent / "pyproject.toml").read_text()
|
||||||
|
)["project"]["version"]
|
||||||
|
assert booth.__version__ == declared
|
||||||
|
assert booth.__version__ != "0.0.0+unknown", "the pyproject read fell through"
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
"""U7 — the rail, the filters, the grid keyboard, and the groups.
|
||||||
|
|
||||||
|
All four components. The fourth — replacing directory sections with
|
||||||
|
filename-derived groups — was a scope DEPARTURE from ROADMAP's U7 row and was
|
||||||
|
ratified by the operator on 2026-09-22; `test_no_group_rail_is_shipped_yet`,
|
||||||
|
the guard that held it back while the ruling was outstanding, was deleted in
|
||||||
|
the commit that built it. A guard that outlives its reason is worse than no
|
||||||
|
guard, because the next reader trusts it.
|
||||||
|
|
||||||
|
`unanswered` is taken to mean HAS AN OPEN PICK — the U4 hold predicate, which
|
||||||
|
already exists and already has a home. The alternative reading ("has no mark at
|
||||||
|
all") is a real and different question and is the contract's open question.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from booth.app import create_app # noqa: E402
|
||||||
|
from booth.marks import declare_pick, set_flag, write_note # noqa: E402
|
||||||
|
|
||||||
|
PNG = b"\x89PNG\r\n\x1a\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def gallery(tmp_path):
|
||||||
|
"""A booth with one of each: flagged, annotated, open pick, and plain."""
|
||||||
|
b = tmp_path / "g"
|
||||||
|
b.mkdir()
|
||||||
|
for n in ("a.png", "b.png", "c.png", "d.png"):
|
||||||
|
(b / n).write_bytes(PNG)
|
||||||
|
set_flag(b, "a.png", True)
|
||||||
|
write_note(b, "b.png", "a remark")
|
||||||
|
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]}, target="c.png")
|
||||||
|
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||||
|
return TestClient(app), b
|
||||||
|
|
||||||
|
|
||||||
|
def _tiles(body: str) -> list[str]:
|
||||||
|
"""The rels the grid actually rendered, in render order."""
|
||||||
|
import re
|
||||||
|
# `data-item` already exists on every tile (both the doc and media
|
||||||
|
# variants). Reusing it rather than adding a parallel `data-rel` is the
|
||||||
|
# same one-fact-one-place discipline INV-1 states for item facts.
|
||||||
|
return re.findall(r'data-item="([^"]+)"', body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rail_counts_every_filter(gallery):
|
||||||
|
c, _ = gallery
|
||||||
|
body = c.get("/b/g/").text
|
||||||
|
assert 'class="rail"' in body
|
||||||
|
for token in ("all", "flagged", "annotated", "unanswered"):
|
||||||
|
assert f'data-filter="{token}"' in body, token
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("flt,expected", [
|
||||||
|
("all", ["a.png", "b.png", "c.png", "d.png"]),
|
||||||
|
("flagged", ["a.png"]),
|
||||||
|
("annotated", ["b.png"]),
|
||||||
|
("unanswered", ["c.png"]),
|
||||||
|
])
|
||||||
|
def test_a_filter_narrows_the_grid_server_side(gallery, flt, expected):
|
||||||
|
"""INV-4: a filter is a LINK, not a script. Fetched directly, with no JS
|
||||||
|
executed, the server must return the narrowed grid.
|
||||||
|
|
||||||
|
Defeating change: binding filters to a click handler and returning the full
|
||||||
|
grid for every URL — under which this test gets four tiles every time."""
|
||||||
|
c, _ = gallery
|
||||||
|
assert _tiles(c.get(f"/b/g/?filter={flt}").text) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_filtering_never_reorders(gallery):
|
||||||
|
"""INV-2, the load-bearing one. Grouping and filtering are VIEWS.
|
||||||
|
|
||||||
|
The defeating change is sorting the grid by anything derived from the
|
||||||
|
filter — which looks right and silently changes what "the third one" means,
|
||||||
|
the misfiled-judgment failure CLAUDE.md invariant 6 exists to prevent.
|
||||||
|
|
||||||
|
Asserted as a SUBSEQUENCE rather than a set: order is the property, so a
|
||||||
|
filter that returned the right tiles in the wrong sequence must go red."""
|
||||||
|
c, _ = gallery
|
||||||
|
# ⚠ THE BASELINE IS COMPUTED INDEPENDENTLY, and that is the whole test.
|
||||||
|
# The first version of this compared each filtered view against the
|
||||||
|
# UNFILTERED RESPONSE — and a mutation that reversed the order reversed
|
||||||
|
# both sides, so it stayed green under the exact change it forbade. Caught
|
||||||
|
# by running the mutation rather than trusting the assertion, which is the
|
||||||
|
# discipline in persistent-memory.d/2026-09-22-vacuous-falsifiers.md and
|
||||||
|
# which this test failed first time out.
|
||||||
|
#
|
||||||
|
# The independent truth is U1 INV-3: the item order IS `sorted(rel)`. So
|
||||||
|
# each filtered view must be sorted, full stop, with no reference to any
|
||||||
|
# other response.
|
||||||
|
for flt in ("all", "flagged", "annotated", "unanswered"):
|
||||||
|
got = _tiles(c.get(f"/b/g/?filter={flt}").text)
|
||||||
|
assert got == sorted(got), f"{flt} rendered out of sorted(rel) order: {got}"
|
||||||
|
# and every filtered view is a SUBSEQUENCE of the true order, not a reshuffle
|
||||||
|
every = sorted(["a.png", "b.png", "c.png", "d.png"])
|
||||||
|
for flt in ("all", "flagged", "annotated", "unanswered"):
|
||||||
|
got = _tiles(c.get(f"/b/g/?filter={flt}").text)
|
||||||
|
assert got == [r for r in every if r in got], flt
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_filter_falls_back_to_all_and_does_not_500(gallery):
|
||||||
|
"""A filter arrives from a URL, which is operator-editable and link-shared.
|
||||||
|
Defeating change: indexing a dict by the raw parameter."""
|
||||||
|
c, _ = gallery
|
||||||
|
for junk in ("nonsense", "", "../../etc", "flagged;drop"):
|
||||||
|
r = c.get(f"/b/g/?filter={junk}")
|
||||||
|
assert r.status_code == 200, junk
|
||||||
|
assert len(_tiles(r.text)) == 4, junk
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_zoom_ring_is_identical_under_every_filter(gallery):
|
||||||
|
"""The ring is the item order filtered to images and must not notice the
|
||||||
|
grid's filter — otherwise `next` means something different depending on how
|
||||||
|
the operator arrived, and a flag lands on the wrong artifact.
|
||||||
|
|
||||||
|
Defeating change: building the ring from the filtered list."""
|
||||||
|
c, _ = gallery
|
||||||
|
rings = set()
|
||||||
|
for flt in ("all", "flagged", "annotated", "unanswered"):
|
||||||
|
c.get(f"/b/g/?filter={flt}")
|
||||||
|
body = c.get("/b/g/b.png?view=1").text
|
||||||
|
import re
|
||||||
|
rings.add(tuple(re.findall(r'href="([^"]*\.png[^"]*)"', body)))
|
||||||
|
assert len(rings) == 1, f"the ring changed with the filter: {rings}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rail_is_absent_on_a_booth_with_no_grid(tmp_path):
|
||||||
|
"""INV-5's sibling: a rail over nothing is chrome. The standing link board
|
||||||
|
has no items, so it must not render one."""
|
||||||
|
b = tmp_path / "links"
|
||||||
|
b.mkdir()
|
||||||
|
(b / "links.md").write_text("- [r](https://x.test/) <sub>· a · 2026-09-01 00:00</sub>\n")
|
||||||
|
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
||||||
|
assert 'class="rail"' not in c.get("/b/links/").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_keyboard_is_not_bound_when_there_is_no_grid(tmp_path):
|
||||||
|
"""INV-5. Defeating change: binding the handler unconditionally, so `f` on
|
||||||
|
the standing link board swallows the keystroke and flags nothing."""
|
||||||
|
b = tmp_path / "links"
|
||||||
|
b.mkdir()
|
||||||
|
(b / "links.md").write_text("- [r](https://x.test/) <sub>· a · 2026-09-01 00:00</sub>\n")
|
||||||
|
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
||||||
|
assert "gridkeys" not in c.get("/b/links/").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_keyboard_is_bound_when_there_is_one(gallery):
|
||||||
|
c, _ = gallery
|
||||||
|
assert "gridkeys" in c.get("/b/g/").text
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# --- U7 slice 2: the groups ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def grouped(tmp_path):
|
||||||
|
"""Two groups whose members INTERLEAVE in `sorted(rel)`.
|
||||||
|
|
||||||
|
`a/x1.png, a/y1.png, b/x2.png, b/y2.png` is the sorted order; group `x` is
|
||||||
|
at positions 0 and 2, group `y` at 1 and 3. That interleaving is the whole
|
||||||
|
point of the fixture — a grid re-sorted by `(group, rel)` to make groups
|
||||||
|
render contiguously would pass every set-based assertion and fail these.
|
||||||
|
"""
|
||||||
|
b = tmp_path / "g"
|
||||||
|
for rel in ("a/x1.png", "a/y1.png", "b/x2.png", "b/y2.png"):
|
||||||
|
p = b / rel
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_bytes(PNG)
|
||||||
|
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||||
|
return TestClient(app), b
|
||||||
|
|
||||||
|
|
||||||
|
def _groups(body: str) -> list[str]:
|
||||||
|
"""The group keys the rail listed, in render order."""
|
||||||
|
import re
|
||||||
|
return re.findall(r'data-group="([^"]+)"', body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rail_lists_groups_when_grouping_is_informative(grouped):
|
||||||
|
c, _ = grouped
|
||||||
|
body = c.get("/b/g/").text
|
||||||
|
assert 'class="rail-groups"' in body
|
||||||
|
assert _groups(body) == ["x", "y"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_order_is_the_position_of_the_first_member(tmp_path):
|
||||||
|
"""The settled rule (ROADMAP, operator 2026-09-22): groups order by where
|
||||||
|
each group's FIRST member falls in the rendered sequence.
|
||||||
|
|
||||||
|
⚠ THIS FIXTURE IS BUILT SO THE THREE PLAUSIBLE RULES ALL DISAGREE. The
|
||||||
|
first version used `w, x, y` — whose positional order happens to BE
|
||||||
|
alphabetical, so it stayed green under the very change it forbade. Caught
|
||||||
|
by running the mutation, not by reading the assertion; the same trap
|
||||||
|
persistent-memory.d/2026-09-22-vacuous-falsifiers.md names and the same one
|
||||||
|
`test_filtering_never_reorders` fell into an hour after it was written.
|
||||||
|
|
||||||
|
sorted(rel): a/z1 a/z2 b/a1 b/a2 b/a3 c/m1 c/m2
|
||||||
|
by position: z (0), a (2), m (5) <- the rule
|
||||||
|
alphabetical: a, m, z <- wrong, and differs
|
||||||
|
by count: a(3), z(2), m(2) <- wrong, and differs
|
||||||
|
"""
|
||||||
|
b = tmp_path / "g"
|
||||||
|
for rel in ("a/z1.png", "a/z2.png", "b/a1.png", "b/a2.png", "b/a3.png",
|
||||||
|
"c/m1.png", "c/m2.png"):
|
||||||
|
q = b / rel
|
||||||
|
q.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
q.write_bytes(PNG)
|
||||||
|
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
||||||
|
assert _groups(c.get("/b/g/").text) == ["z", "a", "m"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouping_never_reorders_the_grid(grouped):
|
||||||
|
"""INV-2, the load-bearing one.
|
||||||
|
|
||||||
|
The defeating change is sorting the grid by `(group, rel)` so groups render
|
||||||
|
contiguously — which looks right, passes any set comparison, and silently
|
||||||
|
changes what "the third one" means. This fixture interleaves precisely so
|
||||||
|
that change goes red.
|
||||||
|
|
||||||
|
The baseline is INDEPENDENT (U1 INV-3: the order IS `sorted(rel)`), not a
|
||||||
|
second response — the vacuous-falsifier trap this suite already fell into
|
||||||
|
once."""
|
||||||
|
c, _ = grouped
|
||||||
|
tiles = _tiles(c.get("/b/g/").text)
|
||||||
|
assert tiles == ["a/x1.png", "a/y1.png", "b/x2.png", "b/y2.png"]
|
||||||
|
assert tiles == sorted(tiles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_group_anchor_lands_on_a_rendered_tile(grouped):
|
||||||
|
"""A jump-to-group link that scrolls nowhere is worse than no link. Every
|
||||||
|
anchor must name an id the page actually carries.
|
||||||
|
|
||||||
|
Defeating change: anchoring to the group KEY (`#group-x`) while the tiles
|
||||||
|
carry `id="item-<rel>"` — which renders, looks right, and does nothing."""
|
||||||
|
import re
|
||||||
|
c, _ = grouped
|
||||||
|
body = c.get("/b/g/").text
|
||||||
|
hrefs = re.findall(r'class="rail-g"[^>]*href="#([^"]+)"', body)
|
||||||
|
assert hrefs, "the rail rendered no group anchors"
|
||||||
|
for h in hrefs:
|
||||||
|
assert f'id="{h}"' in body, f"anchor #{h} names no element on the page"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_group_rail_when_every_item_is_its_own_group(gallery):
|
||||||
|
"""INV-3's real failure mode, and it is NOT the one the contract feared.
|
||||||
|
|
||||||
|
`a.png b.png c.png d.png` yields four groups of one — a rail that is a
|
||||||
|
second copy of the grid. Measured live: `pewpew-ui-brief` gives 23 groups
|
||||||
|
for 34 items, `dfa-concepts` 13 for 20. The contract only guarded the
|
||||||
|
opposite degeneracy (one group for everything), which is why this test
|
||||||
|
exists.
|
||||||
|
|
||||||
|
Defeating change: `{% if rail.groups %}`, true for four singletons."""
|
||||||
|
c, _ = gallery
|
||||||
|
body = c.get("/b/g/").text
|
||||||
|
assert 'class="rail-groups"' not in body
|
||||||
|
assert 'class="rail"' in body, "the filter rail must still be here"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_group_rail_when_there_is_only_one_group(tmp_path):
|
||||||
|
"""INV-3 as the contract states it, with the live specimen: `sc-iso-spread`
|
||||||
|
is `DSC0001.jpg` through `DSC0006.jpg` — one group, six images.
|
||||||
|
|
||||||
|
Defeating change: `{% if rail.groups %}`, true for a single group."""
|
||||||
|
b = tmp_path / "flat"
|
||||||
|
b.mkdir()
|
||||||
|
for i in range(1, 7):
|
||||||
|
(b / f"DSC{i:04d}.jpg").write_bytes(PNG)
|
||||||
|
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
||||||
|
body = c.get("/b/flat/").text
|
||||||
|
assert 'class="rail-groups"' not in body
|
||||||
|
assert 'class="rail"' in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_groups_describe_the_filtered_grid(tmp_path):
|
||||||
|
"""The rail describes what is ON SCREEN. An anchor to a group the filter
|
||||||
|
has hidden would scroll nowhere — the same defect as a wrong id, arriving
|
||||||
|
by a different route.
|
||||||
|
|
||||||
|
Three groups of two; the flag covers `x` and `y` entirely and `z` not at
|
||||||
|
all. Under `?filter=flagged` the rail must list x and y and MUST NOT list
|
||||||
|
z, whose two tiles are not on the page.
|
||||||
|
|
||||||
|
Defeating change: deriving groups from the full gallery rather than from
|
||||||
|
the rendered list — under which `z` appears and its anchor goes nowhere."""
|
||||||
|
b = tmp_path / "g"
|
||||||
|
b.mkdir()
|
||||||
|
for n in ("x1.png", "x2.png", "y1.png", "y2.png", "z1.png", "z2.png"):
|
||||||
|
(b / n).write_bytes(PNG)
|
||||||
|
for n in ("x1.png", "x2.png", "y1.png", "y2.png"):
|
||||||
|
set_flag(b, n, True)
|
||||||
|
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
||||||
|
|
||||||
|
assert _groups(c.get("/b/g/").text) == ["x", "y", "z"]
|
||||||
|
body = c.get("/b/g/?filter=flagged").text
|
||||||
|
assert _groups(body) == ["x", "y"]
|
||||||
|
assert _tiles(body) == ["x1.png", "x2.png", "y1.png", "y2.png"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_filtered_view_too_small_to_group_drops_the_group_row(grouped):
|
||||||
|
"""The informativeness rule binds to the RENDERED list, not to the booth.
|
||||||
|
|
||||||
|
One flagged tile is one group of one, which cannot navigate — so the group
|
||||||
|
row goes away even though the unfiltered booth has a perfectly good one.
|
||||||
|
The filter rail stays, because that is how the operator gets back."""
|
||||||
|
c, b = grouped
|
||||||
|
set_flag(b, "a/x1.png", True)
|
||||||
|
assert 'class="rail-groups"' in c.get("/b/g/").text
|
||||||
|
body = c.get("/b/g/?filter=flagged").text
|
||||||
|
assert 'class="rail-groups"' not in body
|
||||||
|
assert 'class="rail"' in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_zoom_ring_ignores_grouping(grouped):
|
||||||
|
"""The ring is `sorted(rel)` filtered to images and must not notice groups
|
||||||
|
any more than it notices filters.
|
||||||
|
|
||||||
|
THE FIXTURE IS THE FALSIFIER. From `a/x1.png`, sorted order says next is
|
||||||
|
`a/y1.png` — a DIFFERENT group. A ring rebuilt per group would say
|
||||||
|
`b/x2.png`, the next member of group `x`, and `→` would start walking a
|
||||||
|
sequence the operator never saw on the page. That is invariant 6's
|
||||||
|
misfiled-judgment failure exactly: the flag lands on the wrong artifact."""
|
||||||
|
import re
|
||||||
|
c, _ = grouped
|
||||||
|
body = c.get("/b/g/view?f=a/x1.png").text
|
||||||
|
nxt = re.findall(r'class="vnav vnext" href="\?f=([^"&]+)"', body)
|
||||||
|
assert nxt == ["a/y1.png"], f"the ring followed the group, not sorted(rel): {nxt}"
|
||||||
|
# and the zoom page has no group chrome at all — it is one artifact, not a wall
|
||||||
|
assert "data-group" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_route_body_derives_a_group(gallery):
|
||||||
|
"""INV-1, the same assertion U1 makes for `classify` and `render_doc`.
|
||||||
|
|
||||||
|
Defeating change: a route or template computing a prefix inline — the
|
||||||
|
caption bug in a new field."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import booth.app as app_mod
|
||||||
|
|
||||||
|
src = inspect.getsource(app_mod.create_app)
|
||||||
|
assert "_group_of" not in src, "create_app must read Item.group, not derive it"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_hostile_filename_cannot_break_out_of_the_rail(tmp_path):
|
||||||
|
"""Group keys and anchors are AGENT-AUTHORED — they are filenames, and a
|
||||||
|
session makes a booth by making a folder with no validation anywhere in the
|
||||||
|
path. CLAUDE.md names autoescape as load-bearing for exactly this.
|
||||||
|
|
||||||
|
Defeating change: building the rail markup with `|safe`, or assembling the
|
||||||
|
href by string concatenation outside Jinja. Both render, both look right,
|
||||||
|
and both put attacker-controlled bytes into an attribute."""
|
||||||
|
b = tmp_path / "g"
|
||||||
|
b.mkdir()
|
||||||
|
for n in ('q"x1.png', 'q"x2.png', "s<script>1.png", "s<script>2.png"):
|
||||||
|
(b / n).write_bytes(PNG)
|
||||||
|
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
||||||
|
import re
|
||||||
|
r = c.get("/b/g/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.text
|
||||||
|
|
||||||
|
# THE RAIL ITSELF, isolated — asserting over the whole page would pass on a
|
||||||
|
# booth where the escaping happened somewhere else.
|
||||||
|
nav = re.search(r'<nav class="rail-groups".*?</nav>', body, re.S)
|
||||||
|
assert nav, "the rail rendered no group row"
|
||||||
|
nav = nav.group(0)
|
||||||
|
|
||||||
|
# No tag the template did not write, and no attribute the filename closed.
|
||||||
|
# Asserted as the SET of element names rather than by counting `<`, which
|
||||||
|
# the first version got wrong by forgetting the `<b>` counts — an arithmetic
|
||||||
|
# slip that made the test red for a reason unrelated to escaping.
|
||||||
|
tags = set(re.findall(r"</?([a-zA-Z][a-zA-Z0-9]*)", nav))
|
||||||
|
assert tags == {"nav", "a", "b"}, f"the rail grew an element: {tags}"
|
||||||
|
assert 'data-group="q"' not in nav, "the quote closed the attribute"
|
||||||
|
assert "<script>" in nav and "<script" not in nav
|
||||||
|
assert """ in nav or """ in nav, "the quote was not escaped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_group_key_is_never_the_empty_string(tmp_path):
|
||||||
|
"""`_group_of` returns None rather than "" for a stem with nothing before
|
||||||
|
the digits. A "" key would render a nameless rail row that files every
|
||||||
|
numbered render under it — the failure the None is there to prevent.
|
||||||
|
|
||||||
|
Defeating change: `return segs[0]` without the `or None`."""
|
||||||
|
b = tmp_path / "g"
|
||||||
|
b.mkdir()
|
||||||
|
for n in ("01.png", "02.png", "03.png", "ac1.png", "ac2.png"):
|
||||||
|
(b / n).write_bytes(PNG)
|
||||||
|
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
||||||
|
body = c.get("/b/g/").text
|
||||||
|
assert 'data-group=""' not in body
|
||||||
|
assert "" not in _groups(body)
|
||||||
Reference in New Issue
Block a user