9 Commits
Author SHA1 Message Date
vh 3126deca00 chore(release): 1.0.0b1 — the v1 target, staged as a beta
All seven v1 capabilities are landed (ROADMAP's v1 target is met), so this is
the first release of the 1.x train. Staged as a beta rather than cut final on
the operator's call: per the canonical policy `-beta.N` means feature-complete,
external testing, no new features, focus is on bugs — which is exactly this
state, with a cross-frontier bug-hunt panel outstanding on U7's diff.

The repo learned this sequencing the hard way once: v0.2.0 was tagged and
announced while a contract panel was in flight, the panel found three defects in
the code just released, and v0.2.1 shipped within the hour. A beta is the
designed answer to that, not a workaround for it.

ALSO FIXES A SECOND COPY OF THE VERSION, found while cutting this one.
`booth.__version__` was the literal `0.1.0` and had been wrong through six
releases. It is now read from pyproject.toml — deliberately NOT from
importlib.metadata, which describes a different artifact: this repo has no build
step and no install step (booth.service runs uvicorn with WorkingDirectory set
to the tree), and the venv was carrying a vestigial booth-0.3.0.dist-info with
no package directory behind it. Installed metadata therefore reported 0.3.0 for
a tree at 1.0.0b1 — confidently wrong and varying by environment, which is worse
than a literal that at least fails the same way everywhere.

booth/__init__.py is also, it turns out, effectively stdlib-only: scripts/booth
imports booth.links / booth.marks / booth.manifest under the system python3 with
no venv, and every one of those executes the package root first. Nothing
asserted it. test_stdlib_only now covers __init__, and the no-venv import path
is verified under python3.11 reporting 1.0.0b1.

642 tests green.
2026-09-22 21:40:09 -07:00
vh bf351a26d1 feat(u7): filename groups — the last v1 unit, and a table that did not reproduce
Completes U7 with its fourth component: a jump-to-group rail derived from
filename prefixes, replacing the subfolder sections ROADMAP named. The scope
departure was ratified by the operator 2026-09-22; this commit deletes
test_no_group_rail_is_shipped_yet, the guard that held it back, in the same
change that builds what it guarded against.

All seven v1 capabilities are now landed. The 1.0 cut is a decision, not a
dependency, and it is the operator's — no version bump here, because a commit
is not a release.

THE RULE CHANGED AT IMPLEMENTATION, ON MEASURED GROUNDS. The contract specified
`strip ONE trailing run of digits`; run against the live set that 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 contract's own table claimed 5 and 1 for those two booths and neither
reproduces; the numbers are reachable only by two OTHER heuristics, so the table
that justified the design was assembled from more than one rule. Its own worked
example contradicts it in plain sight.

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`
while `v30-seed8302` and `v35-seed8302` stay apart. Re-measured across all 17
live booths; the table is in the contract.

INV-3 GAINED ITS SECOND DEGENERACY. The contract guarded one group for
everything (sc-iso-spread: DSC0001-DSC0006). The live set's actual failure is
the opposite — pewpew-ui-brief yields 23 groups for 34 items, dfa-concepts 13
for 20 — and the contract as written would have shipped a rail that is a second
copy of the grid. The rail now renders only when grouping is informative: two or
more groups, and the middle group holding more than one item. That predicate
gets all 17 booths right.

Grouping is a VIEW. The grid stays sorted(rel) and the zoom ring stays that
order filtered to images; the group fixture interleaves across subdirectories
precisely so a (group, rel) re-sort goes red. Groups are derived from the
RENDERED list, not the full gallery, so no anchor points at a filtered-out tile.

booth/items.py       _group_of + Item.group, derived in the resolver (INV-1)
booth/app.py         _groups() builds the rail rows; build_gallery carries it
booth/templates/     the rail-groups nav and its CSS
tests/               +16 tests; 639 green

Every new falsifier was proved by running its defeating change (12/12). Three
were vacuous first time out: one fixture's positional order happened to be
alphabetical, one assertion miscounted elements, and the harness itself
certified a broken test twice — no green baseline, and byte-identical mutations
silently defeated by the pyc cache's one-second mtime granularity.
2026-09-22 21:33:54 -07:00
vh 2f85692e95 memory: a standing no-announcements ruling — the send is the operator's, not the agent's to ask about 2026-09-22 21:05:46 -07:00
vh 6938d21085 memory: the operator ruled on all five — U7's departure approved, main pushed
"accept all recs, or make good ones." Four of five executed.

APPROVED: drop subfolder sections for filename-prefix groups. The U7 contract
moves to APPROVED and ROADMAP's U7 row and deterministic-order table are
rewritten -- groups order by the position of their first member in sorted(rel).

SETTLED: `unanswered` means has-an-open-pick, the reading that shipped. The
has-no-mark-at-all reading is a different question and is parked to v1.1 rather
than left pending.

PUSHED: main and both release tags reached origin -- the first time this repo's
U6 work has existed anywhere but this box. Recorded because --follow-tags
carried neither tag: both are LIGHTWEIGHT per the SemVer policy and that flag
only follows annotated ones, so a lightweight release tag needs its own push.

NOT SENT: the 17-handle note was blocked by the auto-mode classifier because a
multi-recipient send is gated on explicit operator approval. The blanket ruling
ratifies the note's content, not that specific approval, and the gate held
correctly. Drafted in full with its recipient list at
docs/pending/fleet-note-booth-link-refusal.md so it survives a context clear.
Not worked around.

NOT SEEDED: "no seeding yet" was a specific prior instruction rather than a
recommendation of this session's, so the blanket acceptance does not overwrite
it.

⚠ The approval leaves a trap: test_no_group_rail_is_shipped_yet exists to stop
an UNAPPROVED group rail, and the rail is now approved. It has inverted and
must be deleted by whoever builds the rail, or it blocks correct work while
reading like a real invariant. Named in the handoff's first step for that
reason.
2026-09-22 19:49:39 -07:00
vh 8bf5343049 memory: snapshot — U7 three-quarters built, blocked on one ruling
U6 shipped as v0.6.0 and a late fix as v0.6.1; U7's three ratified components
(rail, filters, grid keyboard) are landed and the fourth is deliberately not,
because swapping subfolder sections for filename-derived groups is a scope
departure the operator has not ruled on. A test fails if anyone builds it
anyway.

Two new detail files. One decomposes U7 by ratified-versus-not and records the
two decisions taken under stated assumption. The other keeps the mechanism
behind today's misrouted directive: pane_find addresses seats by a ROLLING PANE
TITLE, which is not a stable address, and the failure is silent from the
sender's side -- Miranda had no signal until infra-ops flagged it. The incident
resolved; the mechanism did not.

The generated handoff committed the modality failure its own step-7 read exists
to catch: it listed push, seed and the 17-handle note as imperative Next steps
when all three are explicitly gated. Rewritten as do-nots, Next steps emptied.
Recorded here because it is the second time the generator has needed that
backstop.
2026-09-22 17:38:40 -07:00
vh a306e2dc6d feat(u7): the rail, the filters and the grid keyboard — the ratified three
ROADMAP's U7 row names four components. Three of them -- a sticky rail,
filters, and grid keyboard -- are already ratified there and are implemented
here. The fourth, replacing directory sections with filename-derived groups, is
a scope DEPARTURE the operator has not ruled on and is deliberately not built;
test_no_group_rail_is_shipped_yet fails the moment somebody builds it anyway,
so it cannot arrive by accident while he is away.

Filters are links carrying a query parameter, resolved server-side, so the
gallery keeps working with JavaScript off -- U3 already cost the verbatim path
its no-JS operation and said so, and the gallery is the surface the operator
actually reviews on. An unknown filter falls back to `all` rather than indexing
a dict by a value that arrives from an operator-editable URL.

`unanswered` means HAS AN OPEN PICK, the U4 hold predicate that already exists.
The other reading is a real and different question and stays open on the
contract rather than being guessed at.

Filtering is a VIEW and never reorders. The grid renders `sorted(rel)` with
non-matching items removed, so "the third one" means the same thing with a
filter on as with it off, and the zoom ring is untouched by any filter -- a
ring that changed with the grid would make `next` depend on how the operator
arrived, which is the misfiled-judgment failure invariant 6 exists for.

⚠ The first version of that invariant's test was VACUOUS and the mutation run
caught it: it compared each filtered view against the unfiltered RESPONSE, so a
reversing mutation reversed both sides and it stayed green under the exact
change it forbade. Rewritten against an independent truth -- U1 INV-3 says the
order IS sorted(rel) -- and re-verified RED. Written an hour after the entry
describing this exact failure class, which is worth recording.

611 -> 623 tests.
2026-09-22 14:45:57 -07:00
vh b50f41bb36 docs(u7): a PROPOSED contract for the last unit — scope departs from ROADMAP on measured grounds
Not approved and not implemented. Frontmatter status says so, the body says so
twice, and the one scope-direction call in it is named as the operator's.

ROADMAP's U7 row is sections, rail, filters, grid keyboard. The measurement
recorded in persistent-memory.d/2026-09-22-u7-remeasured-before-scoping.md
kills the first component -- zero of eleven gallery booths have a subdirectory,
and the only two booths that do are reports -- and supplies a replacement:
stripping a trailing digit-run from the filename stem yields 5 to 16 sensible
groups on four of the five large galleries.

The degenerate fifth is carried as a first-class case rather than an edge: one
group must render NO rail, because a navigation affordance that cannot navigate
is worse than none.

Closes ROADMAP's outstanding U7 ordering question: groups order by the position
of their first member in sorted(rel), so the rail reads in the same direction
as the grid. Grouping and filtering are views and never reorder -- INV-2 exists
because sorting by (group, rel) looks right and silently changes what 'the
third one' means, which is the misfiled-judgment failure invariant 6 was
written for.

Blast radius checked before writing: Item gains one field beside the existing
section, build_gallery carries it, and image_chain is explicitly unchanged.
2026-09-22 14:40:37 -07:00
vh e15ee2c4ab memory: U7 re-measured before scoping — pre-work only, no unit started
The standing instruction is to re-count the booths before scoping U7. Done
against the live 19-booth set, so the scope call is a short read rather than an
investigation.

Two findings. Sections are worth zero and it is now measured twice: not one of
the eleven gallery booths has a subdirectory, and the only two booths that do
are both reports, the job where grid navigation matters least. And the grouping
signal is in the filename rather than the tree -- stripping a trailing digit-run
yields 5 to 16 sensible groups on four of the five large galleries and
degenerates to one group on the fifth, while the competing split-on-second-
hyphen heuristic is useless everywhere.

The sizing case has also moved: the unit was scoped against 270-item booths and
the largest gallery is now 81 items / 40 images.

No U7 code and no U7 contract. The scope direction is the operator's call.
2026-09-22 14:38:26 -07:00
vh 400e254da6 memory: reconcile the snapshot to v0.6.1
The snapshot was written at v0.6.0 and the marks-guard fix landed after it.
Updates the in-flight head commit, the test count, and the ahead-of-origin
count so a fresh session is not told a stale number.
2026-09-22 14:35:53 -07:00
19 changed files with 1516 additions and 83 deletions
+37 -21
View File
@@ -1,7 +1,12 @@
# The Booth — roadmap
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
Current version: `0.6.1` (U1 through U6 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
@@ -16,26 +21,29 @@ defect — not a wish. The measurements are in the IA doc.
| 4 | ~~**Derived lifetime**~~ — **landed `c3a97c1`, released `v0.4.0`** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 |
| 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 |
| 6 | ~~**Benches**~~ — **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} →
U7**, with **U6 independent** of all of them (different storage, different
surface) and therefore the safest thing to land first or in parallel.
**U1 through U6 are landed.** **U7 is the last unit before the 1.0 cut** — its
only dependency was `{U3, U4, U5}` and that closed with U3.
**ALL SEVEN UNITS ARE LANDED.** U7 closed last; its only dependency was
`{U3, U4, U5}` and that closed with U3.
⚠ **Before starting U7, read
`persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md`, and re-count
the booths first.** Half its premise is already known to be wrong — every booth
that actually needs navigation is FLAT — and the booth set churned again on
2026-09-22: the four large booths U7 was sized against (`pancake-v3-full` and
`pancake-v4-full` at 270 items, `sindra20-engines`, `sindra-finalists`) have all
been swept. The largest live booth is now `miranda-is` at 92 items, flat. Two of
23 booths have subfolders (`pewpew-ui-brief`, `dfa-concepts`) and **both are
reports** — the job where grid navigation matters least. Sections, one of U7's
four named components, buys close to nothing. The rail, the filters and the grid
keyboard are the unit.
⚠ **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
TWO predictions rather than one. The operator declined a fleetwide announcement
@@ -85,6 +93,8 @@ Where it already binds, and what the rule is in each case:
| legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them |
| link board rows | pinned first, then newest-first |
| a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) |
| **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) |
@@ -101,12 +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
ordering: one rule, one place, every surface reading it.
Where it is still to be decided, and must be before the unit ships: **U7's
section ordering and its compare pairing** (sections need a stated order among
themselves, not just within; pairing by filename needs a rule for what happens
to an unpaired file). **U6's bench listing is settled** — the row above.
Compare pairing is parked to v1.1 with compare mode itself, so U7 carries one
undecided rule, not two.
**U7's group ordering is SETTLED and SHIPPED** (operator, 2026-09-22): groups
order by the position of their first member in the rendered sequence, so the
rail reads in the same direction as the grid. Subfolder sections were dropped
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?*
If not, it does not have one yet.
+41 -2
View File
@@ -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()
+95 -3
View File
@@ -520,6 +520,8 @@ def build_gallery(child: Path) -> list[dict]:
"doc": it.doc,
"url": it.url,
"section": it.section,
# U7. Derived in the resolver (INV-1); this only carries it.
"group": it.group,
"caption": it.caption,
"rendered": rendered,
"rendered_html": rendered_html,
@@ -859,7 +861,7 @@ def create_app(
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=307)
@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)
# 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
@@ -902,6 +904,7 @@ def create_app(
held_marks, read_err = hold_read(booth) # ONE read; see list_booths
hold = hold_reason(held_marks, read_err)
marks = held_marks if read_err is None else marks_for(booth)
rail, shown = _rail(gallery, marks, filter)
return templates.TemplateResponse(
request,
"booth.html",
@@ -912,7 +915,16 @@ def create_app(
# The page could not previously tell keep from release, so it
# offered neither and you had to go back to the index.
"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
# its rows as real UI (link, provenance, pin, per-row + bulk
# remove) instead of a markdown blob you can only edit by hand.
@@ -951,7 +963,9 @@ def create_app(
"marks": marks,
"marks_open": len(open_marks(marks)),
# 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": {
it["name"]: marks_for_target(marks, it["name"]) for it in gallery
},
@@ -969,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]:
"""The link board's rows, or [] for a board that cannot be read.
+40
View File
@@ -15,6 +15,7 @@ See docs/contracts/u1_item_record.contract.md.
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
@@ -89,6 +90,7 @@ class Item:
url: str
kind: str
section: str | None
group: str | None
caption: str | None
blurred: bool
doc: str | None
@@ -115,6 +117,43 @@ def _section_of(rel: str) -> str | None:
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]]:
"""(caption-by-rel, rels consumed as sidecars).
@@ -203,6 +242,7 @@ def booth_items(booth: Path) -> list[Item]:
url=quote(rel, safe="/"),
kind=classify(p.name),
section=_section_of(rel),
group=_group_of(rel),
caption=caption.get(rel),
blurred=rel in blurred,
doc=doc_kind(p.name),
+19
View File
@@ -520,6 +520,25 @@
/* 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>
<body>
+84 -1
View File
@@ -243,7 +243,40 @@
{# `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
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 %}
{% if it.doc and it.rendered is not none %}
{# Docs render INLINE, collapsible, and closable — not a link to a
@@ -329,6 +362,56 @@
</div>
{% 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>
/* 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
+226
View File
@@ -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.
@@ -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,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,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]].
+67 -54
View File
@@ -19,65 +19,78 @@ loop it turned out to actually be.
_As of 2026-09-22:_
- **NOTHING IS IN FLIGHT.** U6 (benches) landed, all four review gates closed,
**released as `v0.6.0`** and deployed. Tree clean at `3296a86`, 607 tests
green, 19/19 booths 200 live. ⚠ **NOT PUSHED** — push is the operator's call
and he did not give it this session; `main` is ahead of `origin/main`.
- **U6 SHIPPED (`v0.6.0`) with a late fix (`v0.6.1`). PUSHED.** `main` and both
tags are on `origin` as of 2026-09-22 — the tree is no longer single-copy.
→ `persistent-memory.d/2026-09-22-u6-benches-released.md`
- **v1 is gated on seven units. SIX ARE LANDED. U7 IS THE LAST ONE.** U1
`ce598b3`; U2 → `v0.2.0`/`v0.2.1`/`v0.2.2`; U5 → `v0.3.0`; U4 → `v0.4.0`;
U3 → `v0.5.0`; U6 `1c3ce5d` → `v0.6.0`.
- ⚠ **Before starting U7, read
`persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md` AND
re-count the booths first.** Its premise has degraded twice over: every booth
that needs navigation is FLAT, and on 2026-09-22 the four large booths it was
sized against (`pancake-v3-full`/`pancake-v4-full` at 270 items,
`sindra20-engines`, `sindra-finalists`) had ALL been swept. Largest live booth
is `miranda-is` at 92 items. Two of 19 booths have subfolders and both are
reports. Sections buy close to nothing; the rail, filters and grid keyboard
are the unit.
- **THE LAST OPEN DEFECT IS CLOSED.** The wrong-shaped `answer` that 500'd the
gallery and marks pages (pre-existing, measured at `42ea67f`) is fixed at
`_hydrate` — the placement the session recommended three times and the
operator never ruled on, **taken under a stated assumption and cheap to move**
(one condition in one function) if he disagrees. Measured before/after: both
pages 500 → 200, error visible, the booth's other pick untouched. Two things
fell out of it that matter more than the fix — U3's `_safe_fragments` lost its
natural trigger and is now a synthetically-falsified backstop, and that guard's
own handler could not survive the failure it was handling. Read
`persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`
before touching marks rendering anywhere.
- ⚠ **TWO OPERATOR DECISIONS ARE OUTSTANDING AND BOTH ARE DELIBERATELY NOT
DONE.** (1) The single althing note to the 17 handles about `booth link`
refusing booth URLs — gated as multi-recipient, drafted nowhere, NOT SENT.
(2) Seeding the bench registry from the board — he said "no seeding yet", so
`booth bench import --apply` has NOT been run against live data and
`.benches.json` does not exist in `~/booth-data`.
- **THE OPERATOR RULED ON EVERYTHING OUTSTANDING (2026-09-22, "accept all
recs").** Four of five settled and executed; one blocked by the permission
layer. Nothing is waiting on him. →
`persistent-memory.d/2026-09-22-operator-ruled-on-the-open-five.md`
- ✅ **U7 IS LANDED — ALL SEVEN v1 UNITS ARE IN.** The fourth component
(filename-prefix groups) is built; `test_no_group_rail_is_shipped_yet` was
deleted in the same commit, as required.
→ `persistent-memory.d/2026-09-22-u7-landed-and-a-table-that-did-not-reproduce.md`
- 🔶 **THE 1.0 CUT IS NOW A DECISION, NOT A DEPENDENCY, AND IT IS HIS.** The v1
target is met. A major bump needs explicit operator approval; nothing in the
code is waiting on it. The open fork: cut `1.0`, or stage a `0.7.0` first.
**Not bumped — the work is committed as commits, which are not releases.**
- ⚠ **U7'S CONTRACT CARRIED A MEASUREMENT THAT DID NOT REPRODUCE**, and it was
the number the scope departure rested on. The stated rule gives 24 and 27
groups where the table claimed 5 and 1; the table was assembled from two
different heuristics. **A cold contract-review panel cannot catch this** — the
artifact is internally plausible. Re-run any measurement a contract's scope
rests on before implementing it. Same detail file.
- ⚠ **A MUTATION HARNESS NEEDS A GREEN BASELINE AND CACHE DEFEAT**, or it
certifies falsifiers without running them. Both defects bit in one session.
→ `persistent-memory.d/2026-09-22-a-mutation-harness-that-certified-a-broken-test.md`
- **639 tests green; 12/12 new falsifiers mutation-proved. Deployed; 21/21
booths 200.** ⚠ The set churned again mid-session (19 → 21).
- 🔶 **A bug-hunt panel is IN FLIGHT** — heid thread `01M368G2Y0JMTJ2T7M3JMTXV5Z`,
dispatched 2026-09-22 21:31 PDT over the U7-groups diff. If its reply has not
been consumed, drain `/althing:inbox` and triage before treating U7 as closed.
- 🛑 **STANDING RULING — NO ANNOUNCEMENTS OUT OF THIS REPO, AND THE OPERATOR
SENDS THE EVENTUAL ONE HIMSELF** (operator, 2026-09-22). Verbatim: *"no
announcements until the entire arc is done, and even then i'll do it myself."*
Two clauses, both binding: **(a)** no althing announcement of any kind ships
from booth-dev until the v1 arc is COMPLETE — not per-unit, not at the 1.0
tag, not "just the peers who consume it"; **(b)** when the arc IS done, the
announcement is HIS to send, not a thing to ask permission for. This is
STRICTER than `~/.claude/CLAUDE.md`'s broadcast gate, which merely requires
approval — here the send is not the agent's to make at all, so *asking* is
also out of scope. Do not offer, draft-and-await, or surface it as a pending
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"** — operator decision 2026-09-22, no broadcast, and it
still stands. **This CHANGES HOW THE 2026-10-06 RE-COUNT READS**: the hold
rides for free but not-pressing-`keep` has to be learned, so a flat `.forever`
rate does NOT falsify the diagnosis. Read its entry before measuring.
- **A remote exists and `main` is AHEAD of it.** `origin` is
`git@gitea.phasefinal.com:vh/booth.git`; the first push of this repo's history
was 2026-09-22 (26 commits, `v0.2.0`–`v0.5.0` in one motion). As of this
snapshot `main` is **8 commits ahead of `origin/main`** — the whole of U6
including `v0.6.0`. Pushing is the operator's call.
- **Two dated predictions are pending and must not be run early.** U5's adoption
re-measure on **2026-09-29**; the `.forever` re-count **on or after
2026-10-06**. Before the second, read
`persistent-memory.d/2026-09-22-no-notice-and-what-it-does-to-the-prediction.md`.
- **FIVE methodology proposals sit with the operator, untracked by his choice**
— four from earlier rounds plus Kimi's new one: promote "the falsifiable test
is weaker than the invariant it guards" to its own ambiguity class in
`/heid-contract-review`. It now has two data points in this repo (five of
seven U4 falsifiers vacuous; U6 shipped a tie-break falsifier that could not
fail). They are `/heid*` skill changes, not this repo's work.
- The booth set churns hard: 26 → 24 → 25 → 23 → **19** across five sessions.
Re-count rather than trusting any number written here.
"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
- `[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`
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "booth"
version = "0.6.1"
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."
requires-python = ">=3.11"
dependencies = [
+58
View File
@@ -258,3 +258,61 @@ def test_list_booths_counts_match_the_resolver(tmp_path):
got = list_booths(tmp_path, ttl_seconds=86400)[0]
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}
+31 -1
View File
@@ -7,6 +7,8 @@ See docs/contracts/u2_marks.contract.md.
"""
import ast
import json
import re
import tomllib
import pathlib
import sys
@@ -276,7 +278,7 @@ def test_as_dict_round_trips_through_json(tmp_path):
# ---- the stdlib-only invariant (INV-5) --------------------------------------
@pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest", "benches"])
@pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest", "benches", "__init__"])
def test_stdlib_only(module):
"""INV-5. scripts/booth imports these under the system python3 with NO venv,
through a `python3 -c` heredoc that no AST extractor can see — so nothing
@@ -1428,3 +1430,31 @@ def test_a_healthy_multi_answer_still_hydrates(tmp_path):
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"
+404
View File
@@ -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 "&lt;script&gt;" in nav and "<script" not in nav
assert "&#34;" in nav or "&quot;" 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)