fix(blur): .blurred round-trips any rel, and one writer serves both surfaces

The heid bug-hunt on r2b merge 1 found the /blur route stripping `f` before
writing, so the form for " a.png" blurred its neighbour "a.png". The route was
only half of it: `.blurred` was one stripped rel per line, so no writer could
store a rel with a leading space or a newline, whatever the route did.
Operator-ruled 2026-09-23 ("fix the blur").

- booth/blur.py (new, stdlib-only): read_blurred / set_blurred / BLUR_FILE.
  `.blurred` is now a JSON array in sorted order, the `.seen` shape: opened
  O_NOFOLLOW | O_NONBLOCK with an S_ISREG check and a 1 MiB cap, so a planted
  symlink is refused and a FIFO can no longer hang every Desk render (the old
  read_text() blocked on one). Writes go through mkstemp + os.replace. The
  legacy line format is still READ, so the 6 live line-format files keep their
  blur until their next write upgrades them. Measured before the change: 42
  live rels, none with edge whitespace, so the defect had no live victims.
- The route no longer strips `f`.
- scripts/booth `blur`/`unblur` go through booth.blur.set_blurred instead of
  their own grep/printf line writer. Two writers of one format is how the
  formats drift, and after this change the shell writer would have appended a
  line to a JSON array. Every path is checked before anything is written.
- Item.blurred_self (appended to the record): the item's own blur, resolved in
  booth_items from the same read as `blurred`. It replaces build_gallery's
  second read_blurred, which a write between the two reads could split
  (invariant 3). app.py no longer reads blur state at all, and a test asserts
  it.

Names stay importable from booth.app and booth.items (invariant 4). blur joins
test_stdlib_only. test_cli's per-item-survives test now reads through the reader
rather than asserting the old byte format. The r2b contract and its mutation
row follow blurred_self onto the record. tests/mutations/blur_storage.toml
proves 12 falsifiers by running the change each forbids.

Not in this change, and still ours: the "off"-means-ON idiom drift between
/blur, /blurbooth and /flag (forms only ever send 0/1), and the CLI's
`.blurbooth` touch following a symlink where the service no longer does.
This commit is contained in:
vh
2026-09-23 22:05:18 -07:00
parent cce6a20abe
commit 4cfbce5109
12 changed files with 580 additions and 89 deletions
+31 -20
View File
@@ -39,7 +39,7 @@ lags the code defeats its own purpose.
These are the ones a casual change breaks silently. Each has a test. These are the ones a casual change breaks silently. Each has a test.
### 1. `links.py`, `asks.py` and `marks.py` are stdlib-only, on purpose ### 1. The modules `scripts/booth` imports are stdlib-only, on purpose
`scripts/booth` — the CLI every fleet session uses — imports them directly: `scripts/booth` — the CLI every fleet session uses — imports them directly:
@@ -48,39 +48,50 @@ BOOTH_SRC=… python3 -c 'import sys; sys.path.insert(0, …); from booth.marks
``` ```
It runs under the system `python3` with **no venv**. A single third-party It runs under the system `python3` with **no venv**. A single third-party
import in any of the three breaks `booth ask` / `booth marks` / `booth answer` / import in any of them breaks `booth ask` / `booth marks` / `booth answer` /
`booth unlink` on every host, and the failure surfaces in an agent's session, `booth unlink` / `booth blur` on every host, and the failure surfaces in an
not in ours. agent's session, not in ours.
`items.py` and `app.py` are free to import what they like. Those three are not. The set is `marks`, `asks`, `links`, `manifest`, `benches`, `blur` and
`test_stdlib_only` walks each module's AST imports and asserts it — the CLI `__init__` (which runs before every one of them). **The list of record is
imports through a `python3 -c` heredoc that no AST extractor can see, so that `test_stdlib_only`'s parametrize in `tests/test_marks.py`**, not this
test is the only thing standing here. paragraph. `items.py` and `app.py` are free to import what they like; those are
not. `test_stdlib_only` walks each module's AST imports and asserts it — the
CLI imports through a `python3 -c` heredoc that no AST extractor can see, so
that test is the only thing standing here. A new module the CLI imports goes on
that list in the same commit.
### 2. The filesystem is the state ### 2. The filesystem is the state
No database. `ls ~/booth-data` tells you everything the service knows. No database. `ls ~/booth-data` tells you everything the service knows.
Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep), Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep),
`.viewed` (last deliberate look — U4's "viewing is activity"), `.blurred` (one `.viewed` (last deliberate look — U4's "viewing is activity"), `.blurred` (the
rel per line — ⚠ see below), `.seen` (R2: rels looked at full size, a JSON per-item blur set, a JSON ARRAY — see below), `.seen` (R2: rels looked at full
ARRAY), `.blurbooth` (the whole booth fogged — a MARKER like `.forever`, not size, a JSON ARRAY), `.blurbooth` (the whole booth fogged — a MARKER like `.forever`, not
JSON, because a boolean has no rels to round-trip), `.marks.json` + `.marks.lock` (judgment), `.pins` (link-board pin JSON, because a boolean has no rels to round-trip), `.marks.json` + `.marks.lock` (judgment), `.pins` (link-board pin
ids), `.uploaded` (upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new ids), `.uploaded` (upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new
dotfile costs nothing in item counts, galleries or zips. That skip is why the dotfile costs nothing in item counts, galleries or zips. That skip is why the
dotfile is the right shape for new operator state — use it rather than dotfile is the right shape for new operator state — use it rather than
inventing a sidecar-per-item. inventing a sidecar-per-item.
⚠ **`.seen` is a JSON array where `.blurred` is one stripped rel per line, and ⚠ **A dotfile that holds rels is a JSON array, opened `O_NOFOLLOW |
the difference is a latent bug in the older one.** A rel may carry a leading O_NONBLOCK` with an `S_ISREG` check and a size cap.** A rel may carry a leading
space or a newline; line-stripped storage does not round-trip it, so blurring space or a newline, and line-stripped storage does not round-trip it: `.blurred`
`" a.png"` can blur `a.png` instead. `.seen` was written as JSON for exactly was one stripped rel per line, and blurring `" a.png"` blurred `a.png` instead.
that reason (design-dev, R2), and it also opens `O_NOFOLLOW | O_NONBLOCK` with `.seen` was written as JSON for exactly that reason (design-dev, R2), and
an `S_ISREG` check — a planted symlink is refused and a FIFO cannot hang the `.blurred` now matches it (`booth/blur.py`). The open flags mean a planted
read, which is the outage in symlink is refused and a FIFO cannot hang the read, which is the outage in
`persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md`. **Any new dotfile `persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md`. **Any new dotfile
inherits that shape, not `.blurred`'s.** `.blurred` itself is unfixed and inherits that shape.** `.blurred`'s reader still accepts the old line format,
pre-existing. so a booth written before the change keeps its blur until its next write
upgrades the file. Do not remove that fallback while a line-format file can
still exist.
**A dotfile with two writers has ONE implementation of the writer.** `.blurred`
is written by the service and by `booth blur`, and both call
`booth.blur.set_blurred`; the CLI used to keep a grep/printf writer of its own,
and two writers of one format is how the formats drift apart.
### 3. One resolver for item facts ### 3. One resolver for item facts
+10 -25
View File
@@ -102,6 +102,9 @@ from booth.items import ( # noqa: E402,F401
render_doc, render_doc,
render_doc_body, render_doc_body,
) )
# The per-item blur writer lives with its reader in booth/blur.py, stdlib-only so
# `scripts/booth blur` shares both. Re-exported: tests import it from here.
from booth.blur import set_blurred # noqa: E402,F401
# Sentinel dotfile that exempts a booth from the TTL sweep. A dotfile because # Sentinel dotfile that exempts a booth from the TTL sweep. A dotfile because
# the existing listing code already skips dotfiles, so it costs nothing in item # the existing listing code already skips dotfiles, so it costs nothing in item
@@ -163,24 +166,6 @@ def set_booth_blurred(booth: Path, on: bool) -> bool:
return False return False
def set_blurred(booth: Path, rel: str, on: bool) -> set[str]:
"""Add or remove one item from the blur set. Atomic replace, so a crash
mid-write cannot leave a half-file that read_blurred would parse as a
shorter — and therefore more revealing — set. Returns the new set."""
current = read_blurred(booth)
if on:
current.add(rel)
else:
current.discard(rel)
path = booth / BLUR_FILE
if not current:
path.unlink(missing_ok=True)
return current
tmp = path.with_suffix(".tmp")
tmp.write_text("".join(f"{r}\n" for r in sorted(current)))
tmp.replace(path)
return current
# The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI # The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI
# can use it without pulling FastAPI in. Re-exported here because call sites and # can use it without pulling FastAPI in. Re-exported here because call sites and
# tests already reference these names through app. # tests already reference these names through app.
@@ -818,10 +803,6 @@ def build_gallery(child: Path) -> list[dict]:
suite reaches for it by name in nine places. suite reaches for it by name in nine places.
""" """
out = [] out = []
# r2b D2b: the item's OWN blur, apart from the booth's. `blurred` is the
# composed fact the surfaces render; the per-item control changes only
# this, and must not claim an un-blur the booth flag would override.
own_blur = read_blurred(child)
for it in booth_items(child): for it in booth_items(child):
body = render_doc_body(child, it) body = render_doc_body(child, it)
rendered, rendered_html = body if body is not None else (None, False) rendered, rendered_html = body if body is not None else (None, False)
@@ -844,7 +825,9 @@ def build_gallery(child: Path) -> list[dict]:
"rendered": rendered, "rendered": rendered,
"rendered_html": rendered_html, "rendered_html": rendered_html,
"blurred": it.blurred, "blurred": it.blurred,
"blurred_self": it.rel in own_blur, # r2b D2b: the item's OWN blur, apart from the booth's — off the
# record, from the one read `blurred` came from (invariant 3).
"blurred_self": it.blurred_self,
} }
) )
return out return out
@@ -2234,8 +2217,10 @@ def create_app(
See BLUR_FILE: this hides an item from a glance, it does not protect it.""" See BLUR_FILE: this hides an item from a glance, it does not protect it."""
booth = resolve_booth(name) booth = resolve_booth(name)
# Guard the path the same way the file route must: a blur entry is only # Guard the path the same way the file route must: a blur entry is only
# ever a booth-relative path, never an escape. # ever a booth-relative path, never an escape. NEVER STRIPPED: " a.png"
rel = f.strip().lstrip("/") # and "a.png" are two items, and a stripped `f` blurred the neighbour
# (heid bug-hunt on r2b merge 1). A leading "/" is never part of a rel.
rel = f.lstrip("/")
if ".." in Path(rel).parts: if ".." in Path(rel).parts:
raise HTTPException(status_code=400, detail="bad item path") raise HTTPException(status_code=400, detail="bad item path")
set_blurred(booth, rel, on not in ("0", "false", "")) set_blurred(booth, rel, on not in ("0", "false", ""))
+118
View File
@@ -0,0 +1,118 @@
"""Per-item blur storage — `.blurred`, one JSON array of booth-relative paths.
⚠ STDLIB ONLY (CLAUDE.md invariant 1). `scripts/booth blur` imports this under
the system python3 with no venv, so the service and the CLI share ONE reader and
ONE writer. The CLI used to keep its own grep/printf line writer, and two
writers of one file is how the formats would drift apart.
⚠ COSMETIC ONLY. A blurred item is still served, still in the zip, still on
disk. The Booth has no auth: if a thing must not be SEEN, it must not be in a
booth.
The `.seen` shape, for `.seen`'s reason: a rel may carry a leading space or a
newline, and the old one-stripped-rel-per-line format could not round-trip it.
Blurring " a.png" stored "a.png" and blurred the neighbour instead. The line
format is still READ, so a booth written before this change keeps its blur
until its next write upgrades the file.
"""
from __future__ import annotations
import json
import os
import stat
import tempfile
from pathlib import Path
BLUR_FILE = ".blurred"
# A blur set bigger than this is not one this service or the CLI wrote: a JSON
# array of every rel in a 270-item booth is a few KB. Same bound as `.seen`.
BLUR_MAX_BYTES = 1 << 20
def read_blurred(booth: Path) -> set[str]:
"""Blurred rels for a booth. Missing, unreadable or malformed -> empty set.
NEVER RAISES and NEVER BLOCKS. `booth_items` calls this for every booth the
Desk renders, and any fleet session can write into a booth, so the file may
be planted: it is opened without following a link and without blocking (a
FIFO with no writer), and refused unless it is a regular file of sane size.
Reads BOTH formats. A JSON array of strings is the current one; anything
that does not parse as a JSON array is the legacy one-rel-per-line format,
read exactly as before (stripped, blank lines dropped). Legacy rels starting
with "[" still read, because they fail the JSON parse and fall through.
Non-string members of an array are skipped, not fatal.
"""
try:
fd = os.open(booth / BLUR_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except OSError:
return set()
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_size > BLUR_MAX_BYTES:
return set()
raw = os.read(fd, BLUR_MAX_BYTES + 1)
except OSError:
return set()
finally:
os.close(fd)
try:
text = raw.decode("utf-8", "surrogateescape")
except UnicodeDecodeError: # pragma: no cover - surrogateescape cannot fail
return set()
try:
data = json.loads(text)
except (ValueError, RecursionError):
# RecursionError: a deeply nested array blows the parser's stack, and
# it is neither a ValueError nor an OSError (the `.seen` hole).
data = None
if isinstance(data, list):
return {r for r in data if isinstance(r, str)}
return {ln.strip() for ln in text.splitlines() if ln.strip()}
def set_blurred(booth: Path, rel: str, on: bool) -> set[str]:
"""Add or remove one rel from the blur set, and return the new set.
`rel` is stored EXACTLY as given; callers must not strip it. Written as a
JSON array in sorted order (CLAUDE.md invariant 6), so the same set is the
same bytes. An empty set removes the file: an empty marker is a lie by
omission.
Atomic replace (CLAUDE.md invariant 5) through a temp file created with
O_EXCL, so a crash mid-write cannot leave a shorter, and therefore more
revealing, set; a planted `.blurred.*.tmp` symlink cannot redirect the
write, and `os.replace` swaps a planted `.blurred` symlink out rather than
writing through it.
NOT locked. Two writers racing (the operator's click and a session's
`booth blur`) can lose one toggle; both are rare, deliberate and visible on
the next render, so this matches what the line format did.
"""
current = read_blurred(booth)
if on:
current.add(rel)
else:
current.discard(rel)
path = booth / BLUR_FILE
if not current:
try:
path.unlink()
except FileNotFoundError:
pass
return current
body = json.dumps(sorted(current), ensure_ascii=False).encode("utf-8", "surrogateescape")
fd, tmp = tempfile.mkstemp(prefix=".blurred.", suffix=".tmp", dir=booth)
try:
with os.fdopen(fd, "wb") as fh:
fh.write(body)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
return current
+10 -10
View File
@@ -30,6 +30,7 @@ except ImportError: # pragma: no cover
_markdown = None _markdown = None
from booth.asks import is_answer_file, is_ask_file from booth.asks import is_answer_file, is_ask_file
from booth.blur import BLUR_FILE, read_blurred # noqa: F401 (re-exported)
from booth.thumbs import wants_thumb from booth.thumbs import wants_thumb
# Browser-playable media buckets. Anything else renders as a download link. # Browser-playable media buckets. Anything else renders as a download link.
@@ -44,7 +45,8 @@ TEXT_EXTS = {".txt", ".text", ".log"}
CAPTION_MAX = 800 # chars of a sidecar .txt caption we render CAPTION_MAX = 800 # chars of a sidecar .txt caption we render
DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not rendered DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not rendered
BLUR_FILE = ".blurred" # `BLUR_FILE` and `read_blurred` live in booth/blur.py (stdlib-only, so the CLI
# shares the reader and the writer) and are re-exported from here.
# Booth-level blur: the whole booth is fogged, agent-set at post time or # Booth-level blur: the whole booth is fogged, agent-set at post time or
# toggled by the operator. A MARKER, deliberately not JSON like `.seen` — # toggled by the operator. A MARKER, deliberately not JSON like `.seen` —
@@ -119,6 +121,12 @@ class Item:
# Derived HERE so no template reasons about `kind` to decide — INV-1, which # Derived HERE so no template reasons about `kind` to decide — INV-1, which
# is the caption bug in a new field. # is the caption bug in a new field.
thumb: str | None thumb: str | None
# The item's OWN per-item blur, apart from the booth's fog: the per-item
# control toggles only this, so it must not offer an un-blur the booth flag
# would override (r2b D2b). From the SAME read as `blurred` — it used to be
# a second `read_blurred` in build_gallery, and a write between the two
# reads could split them (invariant 3). APPENDED, like `ordinal`.
blurred_self: bool
# R2 C2: which items have been looked at full size. UI state, not judgment — # R2 C2: which items have been looked at full size. UI state, not judgment —
@@ -167,15 +175,6 @@ def read_seen(booth: Path) -> set[str]:
return {r for r in data if isinstance(r, str)} return {r for r in data if isinstance(r, str)}
def read_blurred(booth: Path) -> set[str]:
"""Blurred item paths for a booth. Missing file -> empty set."""
try:
text = (booth / BLUR_FILE).read_text()
except (OSError, UnicodeDecodeError):
return set()
return {ln.strip() for ln in text.splitlines() if ln.strip()}
def is_booth_blurred(booth: Path) -> bool: def is_booth_blurred(booth: Path) -> bool:
"""Whether the WHOLE booth is blurred. """Whether the WHOLE booth is blurred.
@@ -399,6 +398,7 @@ def booth_items(booth: Path) -> list[Item]:
# stay contiguous over what the operator can see. # stay contiguous over what the operator can see.
ordinal=len(items) + 1, ordinal=len(items) + 1,
thumb=(quote(rel, safe='/') + '?thumb=1') if wants_thumb(rel) else None, thumb=(quote(rel, safe='/') + '?thumb=1') if wants_thumb(rel) else None,
blurred_self=rel in blurred,
) )
) )
return items return items
@@ -17,7 +17,7 @@ estimated_loc: 350
confidence: 0.7 confidence: 0.7
touches: touches:
- "booth/templates/index.html (the row: facts line, lifetime pill, the hover cluster, the `blurred` badge; the confirm script unchanged)" - "booth/templates/index.html (the row: facts line, lifetime pill, the hover cluster, the `blurred` badge; the confirm script unchanged)"
- "booth/app.py (READS only, no new route: `booth_blurred` in the booth_view and booth_view_file contexts and on each list_booths row; `blurred_self` on each gallery dict from build_gallery's one `read_blurred`)" - "booth/app.py (READS only, no new route: `booth_blurred` in the booth_view and booth_view_file contexts and on each list_booths row; `blurred_self` on each gallery dict, read off `Item.blurred_self` — moved onto the item record by booth-dev 2026-09-23 so blur state has one reader, invariant 3)"
- "booth/templates/base.html (Desk row CSS; reveal-all CSS; the theme toggle markup in the top bar; the early <head> script; the toggle script)" - "booth/templates/base.html (Desk row CSS; reveal-all CSS; the theme toggle markup in the top bar; the early <head> script; the toggle script)"
- "booth/templates/booth.html (Reveal all in the booth header; per-tile reveal defers to it)" - "booth/templates/booth.html (Reveal all in the booth header; per-tile reveal defers to it)"
- "booth/templates/view.html (Reveal all in the review; the stage reveal defers to it)" - "booth/templates/view.html (Reveal all in the review; the stage reveal defers to it)"
@@ -214,7 +214,7 @@ reading; this is the control the operator uses, which the blur ruling assumed.
- **Each item's own blur control tells the truth under a fogged booth.** - **Each item's own blur control tells the truth under a fogged booth.**
`Item.blurred` is the COMPOSED fact (own OR booth). The per-item form changes `Item.blurred` is the COMPOSED fact (own OR booth). The per-item form changes
only the item's own entry in `.blurred`, so the gallery also carries only the item's own entry in `.blurred`, so the gallery also carries
`blurred_self`, read from the same single `read_blurred`. `blurred_self`, from `Item.blurred_self` (the same single read `booth_items` makes for `blurred`).
- An item blurred only because the booth is shows "◉ booth", a label with no - An item blurred only because the booth is shows "◉ booth", a label with no
form, pointing at the header. A per-item un-blur there would be overridden form, pointing at the header. A per-item un-blur there would be overridden
by the booth flag and visibly do nothing. by the booth flag and visibly do nothing.
@@ -283,7 +283,7 @@ reading; this is the control the operator uses, which the blur ruling assumed.
- **INV-1 — nothing new on the server beyond READS.** No route and no file are - **INV-1 — nothing new on the server beyond READS.** No route and no file are
added: `booth_blurred` in two contexts and on the Desk row (`is_booth_blurred`), added: `booth_blurred` in two contexts and on the Desk row (`is_booth_blurred`),
and `blurred_self` per gallery item (`read_blurred`, once per page). D2 and D3 are per-browser state; D1 is markup and CSS. and `blurred_self` per gallery item (`Item.blurred_self`, from `booth_items`' one read). D2 and D3 are per-browser state; D1 is markup and CSS.
- **INV-2 — JS-off parity (r2 INV-3).** Every control on the row works with - **INV-2 — JS-off parity (r2 INV-3).** Every control on the row works with
scripts off. Reveal all and the toggle do not render without JS. The page scripts off. Reveal all and the toggle do not render without JS. The page
follows the OS. follows the OS.
+30 -16
View File
@@ -19,22 +19,36 @@ loop it turned out to actually be.
_As of 2026-09-23:_ _As of 2026-09-23:_
- 🛑 **THE ONE THING BLOCKING: design-dev's blur merge is HELD, awaiting his - ✅ **BOTH r2b MERGES LANDED AND ARE LIVE** (operator-approved 2026-09-23):
ping.** `design-dev/svos-retheme` @ `5ded5ff` (Reveal all + the booth-blur `b92b002` (Reveal all + the booth-blur control, design-dev `ca0641f`) and
control) is fetched, merge-tree against `091f4b5` is CLEAN, and his commit `cce6a20` (the Desk row, booth dates, the theme toggle, `1558a7f`). Each got a
records the contract panel 4/4 folded, 14/14 mutations, 765 passing — full suite, a restart and a sweep: 25 live booths, 19 review pages and every
**but the heid code-review and bug-hunt panels were dispatched 17:53 and are marks page at 200. ⚠ **A peer's "merge it" is not the operator's approval
NOT folded.** He said explicitly: *"Hold… I will ping 'merge it' when both here.** The permission layer refused the merge on design-dev's word alone, and
are folded; whoever picks it up after a clear should look for that ping before that was right: put the merge to the operator.
merging."* **DO NOT MERGE IT WITHOUT THAT PING.** The operator's "merge - 🔶 **NEXT, design-dev's: r2c, the review stage.** Fit/1:1 always shown; **Fit
everything" predates this and was not about overriding his gate. may enlarge** (operator, 2026-09-23); the arrows hug the image; drag-pan in
- 🔶 **After that ping: merge, restart, verify 30 booths.** Then his SECOND 1:1 with native image drag killed; the mode is remembered per viewer. Pan
merge (Desk row revisions + the dark/light/system theme toggle) follows the offset across items is parked to r3 (compare). Then **r3, compare mode**:
same way. ours is only the `booth_items` support he asks for.
- ⚠ **THE BROWSER SUITE IS FLAKY UNDER LOAD AND IT IS NOT FIXED.** Three - ✅ **`.blurred` ROUND-TRIPS ANY REL** (operator: "fix the blur"). A JSON array
different tests, one failure each, all passing in isolation. Two real defects via stdlib-only `booth/blur.py`, the one writer for both the service and
were fixed chasing it and NEITHER is proven to be the cause. **Do not read a `booth blur`. The legacy line format is still read, and a write upgrades it.
green suite as proof.** Operator ruled design-dev diagnoses it properly. The bug design-dev's bug-hunt found (a stripped rel blurring its neighbour)
had no live victims: 6 `.blurred` files, 42 rels, 0 with edge whitespace.
`Item.blurred_self` came along, so blur state has one reader (invariant 3).
**Still open and ours, not done:** the "off"-means-ON idiom drift between
/blur, /blurbooth and /flag (forms only send 0/1), and the CLI's
`.blurbooth` `touch` still follows a symlink where the service no longer does.
- ⚠ **THE BROWSER SUITE WAS FLAKY UNDER LOAD, AND THE CAUSE IS STILL
UNCONFIRMED.** design-dev's suspect: Google Fonts stalling "networkidle". He
reproduced the exact error with a stalled font request (sufficiency only).
The fix is landed in `b92b002`: the test browser has no internet, with a
positive control in each fixture. Since then, **0 reds in 24** untraced runs
against a pre-fix rate of about 1 in 8. That rate is itself 1 red in 8 runs
(95% CI roughly 0.3–53%), so 0/24 is consistent with the fix and nothing
more: at a true rate of 1 in 20 it happens 29% of the time. No trace ever
caught the stalled request. **Do not read a green suite as proof.**
→ `persistent-memory.d/2026-09-23-the-browser-suite-is-flaky-under-load.md` → `persistent-memory.d/2026-09-23-the-browser-suite-is-flaky-under-load.md`
- ✅ **THE REDESIGN IS LIVE.** R2 (the Desk, the lightbox, the reel) merged and - ✅ **THE REDESIGN IS LIVE.** R2 (the Desk, the lightbox, the reel) merged and
deployed; release/wipe moved onto the facts line. 30 booths at 200. deployed; release/wipe moved onto the facts line. 30 booths at 200.
+20 -12
View File
@@ -144,7 +144,6 @@ set -euo pipefail
DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}" DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}"
URL="${BOOTH_URL:-http://10.100.10.50:8090}" URL="${BOOTH_URL:-http://10.100.10.50:8090}"
KEEP=".forever" # must match KEEP_MARKER in booth/app.py KEEP=".forever" # must match KEEP_MARKER in booth/app.py
BLUR=".blurred" # one booth-relative item path per line; see `blur` below
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}" LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
# `--why` / `--title` for `new` and `add`. Pulled out of "$@" wherever they # `--why` / `--title` for `new` and `add`. Pulled out of "$@" wherever they
@@ -306,7 +305,6 @@ case "$cmd" in
[ $# -ge 1 ] || usage [ $# -ge 1 ] || usage
b="$1"; shift b="$1"; shift
[ -d "$DATA/$b" ] || { echo "no such booth: $b" >&2; exit 1; } [ -d "$DATA/$b" ] || { echo "no such booth: $b" >&2; exit 1; }
f="$DATA/$b/$BLUR"
# NO FILES NAMED = THE WHOLE BOOTH. The Desk shows up to four images from # NO FILES NAMED = THE WHOLE BOOTH. The Desk shows up to four images from
# every booth on the page the operator opens first, so a booth that should # every booth on the page the operator opens first, so a booth that should
@@ -327,23 +325,33 @@ case "$cmd" in
exit 0 exit 0
fi fi
# Every item is checked BEFORE anything is written, so a refused path
# leaves the blur set exactly as it was.
items=()
for item in "$@"; do for item in "$@"; do
item="${item#"$DATA/$b/"}"; item="${item#/}" item="${item#"$DATA/$b/"}"; item="${item#/}"
case "$item" in case "$item" in
*..*) echo "refusing path with '..': $item" >&2; exit 2 ;; *..*) echo "refusing path with '..': $item" >&2; exit 2 ;;
esac esac
[ -e "$DATA/$b/$item" ] || echo "warning: no such item in $b: $item" >&2 [ -e "$DATA/$b/$item" ] || echo "warning: no such item in $b: $item" >&2
touch "$f" items+=("$item")
if [ "$cmd" = blur ]; then
grep -qxF -- "$item" "$f" || printf '%s\n' "$item" >> "$f"
else
grep -vxF -- "$item" "$f" > "$f.tmp" || true
mv -- "$f.tmp" "$f"
fi
done done
# An empty marker is a lie by omission — `ls -a` should say whether # ONE WRITER. `.blurred` is a JSON array now (a rel may carry a leading
# anything here is blurred at all. # space or a newline, and the old line format could not round-trip it), and
[ -s "$f" ] || rm -f -- "$f" # the service writes it too — so the CLI goes through the same stdlib-only
# booth.blur the service does, never a grep/printf of its own. Items travel
# as argv, which carries any byte but NUL; an env var or a line would not.
# An emptied set removes the file (booth.blur), so `ls -a` still says
# whether anything here is blurred at all.
BOOTH_SRC="$(booth_src)" BOOTH_DIR="$DATA/$b" python3 -c '
import os, sys
from pathlib import Path
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.blur import set_blurred # stdlib only — no venv needed
on = sys.argv[1] == "blur"
for rel in sys.argv[2:]:
set_blurred(Path(os.environ["BOOTH_DIR"]), rel, on)
' "$cmd" "${items[@]}"
if [ "$cmd" = blur ]; then if [ "$cmd" = blur ]; then
echo "blurred (cosmetic — still served): $URL/b/$b/" echo "blurred (cosmetic — still served): $URL/b/$b/"
else else
+122
View File
@@ -0,0 +1,122 @@
# Per-item blur storage: `.blurred` round-trips any rel, whoever writes it.
# The fix for the wrong-item write the heid bug-hunt found through r2b merge 1
# (a stripped rel blurred its neighbour), operator-ruled 2026-09-23. Every row
# is a change tests/test_blur.py claims to forbid.
#
# NOT here, on purpose: the S_ISREG guard in read_blurred. With O_NONBLOCK a
# FIFO opens and reads as EOF, a symlink is already refused by O_NOFOLLOW, and a
# device node needs root to plant, so no test here can see that guard go. It
# stays as the `.seen` shape, and it is not claimed as a proven falsifier.
unit = "blur storage round-trip"
[[mutation]]
label = "the writer strips the rel (the old line format's loss)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_leading_space_rel_round_trips"
old = '''
current.add(rel)'''
new = '''
current.add(rel.strip())'''
[[mutation]]
label = "the route strips `f` before writing (the reported wrong-item write)"
file = "booth/app.py"
test = "tests/test_blur.py::test_the_blur_route_blurs_exactly_the_item_it_names"
old = '''
rel = f.lstrip("/")'''
new = '''
rel = f.strip().lstrip("/")'''
[[mutation]]
label = "a JSON-only reader: every live line-format file un-blurs on deploy"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_legacy_line_format_still_reads"
old = '''
return {ln.strip() for ln in text.splitlines() if ln.strip()}'''
new = '''
return set()'''
[[mutation]]
label = "a legacy file that is not JSON reads as nothing instead of falling back"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_legacy_rel_that_starts_with_a_bracket_still_reads"
old = '''
data = None'''
new = '''
return set()'''
[[mutation]]
label = "a FIFO blocks the read (no O_NONBLOCK)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_fifo_blur_file_does_not_block_the_read"
old = '''
fd = os.open(booth / BLUR_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)'''
new = '''
fd = os.open(booth / BLUR_FILE, os.O_RDONLY | os.O_NOFOLLOW)'''
[[mutation]]
label = "the read follows a planted symlink (no O_NOFOLLOW)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_symlinked_blur_file_is_not_followed_on_read"
old = '''
fd = os.open(booth / BLUR_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)'''
new = '''
fd = os.open(booth / BLUR_FILE, os.O_RDONLY | os.O_NONBLOCK)'''
[[mutation]]
label = "the write goes through a planted symlink instead of replacing it"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_write_replaces_a_planted_symlink_rather_than_writing_through_it"
old = '''
os.replace(tmp, path)'''
new = '''
path.write_bytes(Path(tmp).read_bytes()); os.unlink(tmp)'''
[[mutation]]
label = "the stored order is not the stated one (invariant 6)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_file_is_a_json_array_in_sorted_order"
old = '''
body = json.dumps(sorted(current), ensure_ascii=False)'''
new = '''
body = json.dumps(sorted(current, reverse=True), ensure_ascii=False)'''
[[mutation]]
label = "the CLI ignores the verb: `unblur` blurs"
file = "scripts/booth"
test = "tests/test_blur.py::test_the_cli_writes_the_format_the_service_reads"
old = '''
on = sys.argv[1] == "blur"'''
new = '''
on = True'''
[[mutation]]
label = "the CLI writes past a refused '..' path"
file = "scripts/booth"
test = "tests/test_blur.py::test_the_cli_still_refuses_a_dotdot_path"
old = '''
*..*) echo "refusing path with '..': $item" >&2; exit 2 ;;'''
new = '''
*..*) echo "refusing path with '..': $item" >&2 ;;'''
[[mutation]]
label = "the item's own blur is the composed one (booth fog leaks into it)"
file = "booth/items.py"
test = "tests/test_blur.py::test_the_item_record_carries_its_own_blur_apart_from_the_booths"
old = '''
blurred_self=rel in blurred,'''
new = '''
blurred_self=rel in blurred or booth_blur,'''
[[mutation]]
label = "app.py reads the blur file a second time (invariant 3)"
file = "booth/app.py"
test = "tests/test_blur.py::test_app_py_never_reads_the_blur_file_itself"
old = '''
out = []
for it in booth_items(child):'''
new = '''
out = []
read_blurred(child)
for it in booth_items(child):'''
+1 -1
View File
@@ -134,7 +134,7 @@ label = "D2b the per-item control reads the composed blur, not the item's own"
file = "booth/app.py" file = "booth/app.py"
test = "tests/test_flow.py::test_under_a_fogged_booth_each_items_blur_control_tells_the_truth" test = "tests/test_flow.py::test_under_a_fogged_booth_each_items_blur_control_tells_the_truth"
old = ''' old = '''
"blurred_self": it.rel in own_blur,''' "blurred_self": it.blurred_self,'''
new = ''' new = '''
"blurred_self": it.blurred,''' "blurred_self": it.blurred,'''
+227
View File
@@ -0,0 +1,227 @@
"""Per-item blur storage — `.blurred` round-trips any rel, whoever writes it.
`.blurred` was one stripped rel per line, so a rel with a leading space could
not survive a write: blurring " a.png" stored "a.png", and toggled the
neighbour instead (heid bug-hunt on r2b merge 1, reported to booth-dev). It is
now a JSON array, the `.seen` shape, read without following a link or blocking
on a FIFO. The old line format is still READ, so nothing live changes until the
next write upgrades it.
Two writers share the file: the service (the operator's per-item control) and
`scripts/booth blur` (a session at post time). Both go through `booth.blur`,
which is stdlib-only so the CLI can import it under the system python3.
"""
from __future__ import annotations
import json
import os
import pathlib
import subprocess
import sys
import threading
from fastapi.testclient import TestClient
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.app import BLUR_FILE, create_app, read_blurred, set_blurred # noqa: E402
from booth.items import booth_items # noqa: E402
PNG = b"\x89PNG\r\n\x1a\n"
SCRIPT = pathlib.Path(__file__).parent.parent / "scripts" / "booth"
def _booth(root: pathlib.Path, name: str, files: dict[str, bytes]) -> pathlib.Path:
b = root / name
b.mkdir()
for rel, data in files.items():
(b / rel).write_bytes(data)
return b
def _within(seconds: float, fn):
"""Run fn in a thread and fail, rather than hang the suite, if it blocks."""
out: dict = {}
t = threading.Thread(target=lambda: out.setdefault("v", fn()), daemon=True)
t.start()
t.join(seconds)
assert not t.is_alive(), f"{fn} blocked for over {seconds}s"
return out["v"]
# ---- the round-trip: the defect ---------------------------------------------
def test_a_leading_space_rel_round_trips(tmp_path):
"""Defeating change: storing rels line-stripped (the old format)."""
set_blurred(tmp_path, " a.png", True)
assert read_blurred(tmp_path) == {" a.png"}
def test_unblurring_a_leading_space_rel_leaves_its_neighbour_blurred(tmp_path):
"""The reported wrong-item write: " a.png" and "a.png" are two items, and
toggling one must never move the other. (Unblurring the SPACED one would
pass under the old format too — it was a no-op there — so this unblurs the
plain one and asks whether the spaced one survived.)"""
set_blurred(tmp_path, "a.png", True)
set_blurred(tmp_path, " a.png", True)
set_blurred(tmp_path, "a.png", False)
assert read_blurred(tmp_path) == {" a.png"}
def test_a_newline_in_a_rel_round_trips(tmp_path):
"""A line format cannot hold one at all."""
set_blurred(tmp_path, "two\nlines.png", True)
assert read_blurred(tmp_path) == {"two\nlines.png"}
def test_the_file_is_a_json_array_in_sorted_order(tmp_path):
"""The `.seen` shape, and a stated order (invariant 6) so two writes of the
same set are byte-identical."""
set_blurred(tmp_path, "b.png", True)
set_blurred(tmp_path, "a.png", True)
assert json.loads((tmp_path / BLUR_FILE).read_text("utf-8")) == ["a.png", "b.png"]
def test_emptying_the_set_removes_the_file(tmp_path):
"""Unchanged: an empty marker is a lie by omission."""
set_blurred(tmp_path, "a.png", True)
set_blurred(tmp_path, "a.png", False)
assert not (tmp_path / BLUR_FILE).exists()
# ---- the legacy format: nothing live changes until it is written ------------
def test_the_legacy_line_format_still_reads(tmp_path):
"""Six live booths hold line-format files. Defeating change: a JSON-only
reader, which would un-blur every one of them on deploy."""
(tmp_path / BLUR_FILE).write_text("a.png\nsub/b.png\n\n")
assert read_blurred(tmp_path) == {"a.png", "sub/b.png"}
def test_a_legacy_rel_that_starts_with_a_bracket_still_reads(tmp_path):
"""A line-format file whose first rel happens to begin with "[" is not
JSON, and must fall back to lines rather than read as nothing."""
(tmp_path / BLUR_FILE).write_text("[draft] a.png\nb.png\n")
assert read_blurred(tmp_path) == {"[draft] a.png", "b.png"}
def test_a_write_upgrades_a_legacy_file_and_keeps_its_rels(tmp_path):
(tmp_path / BLUR_FILE).write_text("a.png\n")
set_blurred(tmp_path, "b.png", True)
assert json.loads((tmp_path / BLUR_FILE).read_text("utf-8")) == ["a.png", "b.png"]
# ---- a planted file: never blocks, never follows ----------------------------
def test_a_fifo_blur_file_does_not_block_the_read(tmp_path):
"""read_blurred runs for every booth the Desk renders; a FIFO with no writer
used to hang it — the outage class `.seen` was built against."""
os.mkfifo(tmp_path / BLUR_FILE)
assert _within(5, lambda: read_blurred(tmp_path)) == set()
def test_a_symlinked_blur_file_is_not_followed_on_read(tmp_path):
outside = tmp_path / "outside.json"
outside.write_text('["a.png"]')
b = tmp_path / "b"
b.mkdir()
(b / BLUR_FILE).symlink_to(outside)
assert read_blurred(b) == set()
def test_a_write_replaces_a_planted_symlink_rather_than_writing_through_it(tmp_path):
outside = tmp_path / "outside.txt"
outside.write_text("untouched")
b = tmp_path / "b"
b.mkdir()
(b / BLUR_FILE).symlink_to(outside)
set_blurred(b, "a.png", True)
assert outside.read_text() == "untouched"
assert not (b / BLUR_FILE).is_symlink()
assert read_blurred(b) == {"a.png"}
def test_malformed_json_array_contents_are_skipped_not_fatal(tmp_path):
(tmp_path / BLUR_FILE).write_text('["a.png", 3, null, ["x"]]')
assert read_blurred(tmp_path) == {"a.png"}
# ---- the route: the operator's per-item control -----------------------------
def test_the_blur_route_blurs_exactly_the_item_it_names(tmp_path):
"""The route stripped `f` before writing, so the form for " a.png" blurred
"a.png". Defeating change: `f.strip()` back in the route."""
b = _booth(tmp_path, "g", {" a.png": PNG, "a.png": PNG})
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
r = c.post("/b/g/blur", data={"f": " a.png", "on": "1"}, follow_redirects=False)
assert r.status_code == 303
blurred = {it.rel: it.blurred for it in booth_items(b)}
assert blurred == {" a.png": True, "a.png": False}
# ---- the CLI: the other writer ----------------------------------------------
def _cli(data: pathlib.Path, *args: str) -> subprocess.CompletedProcess:
env = {**os.environ, "BOOTH_DATA_DIR": str(data), "BOOTH_URL": "http://booth.invalid"}
return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env, timeout=30)
def test_the_cli_writes_the_format_the_service_reads(tmp_path):
"""Both writers, one format. Defeating change: the CLI keeping its own
grep/printf line writer, which appends a line to a JSON array."""
b = _booth(tmp_path, "g", {" a.png": PNG, "a.png": PNG})
set_blurred(b, "a.png", True) # the service wrote first
r = _cli(tmp_path, "blur", "g", " a.png")
assert r.returncode == 0, r.stderr
assert read_blurred(b) == {"a.png", " a.png"}
r = _cli(tmp_path, "unblur", "g", " a.png")
assert r.returncode == 0, r.stderr
assert read_blurred(b) == {"a.png"}
def test_the_cli_unblurring_the_last_item_removes_the_file(tmp_path):
b = _booth(tmp_path, "g", {"a.png": PNG})
assert _cli(tmp_path, "blur", "g", "a.png").returncode == 0
assert _cli(tmp_path, "unblur", "g", "a.png").returncode == 0
assert not (b / BLUR_FILE).exists()
def test_the_cli_still_refuses_a_dotdot_path(tmp_path):
_booth(tmp_path, "g", {"a.png": PNG})
r = _cli(tmp_path, "blur", "g", "../escape.png")
assert r.returncode == 2
assert not (tmp_path / "g" / BLUR_FILE).exists()
# ---- one read of blur state per render (invariant 3) ------------------------
def test_the_item_record_carries_its_own_blur_apart_from_the_booths(tmp_path):
"""r2b's per-item control needs the item's OWN blur as well as the composed
one. It came from a second `read_blurred` in build_gallery — a second reader
of one file, which a write between the two could split. It is now resolved
in `booth_items`, from the one read the composed fact already uses."""
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
set_blurred(b, "a.png", True)
(b / ".blurbooth").write_bytes(b"")
got = {it.rel: (it.blurred, it.blurred_self) for it in booth_items(b)}
assert got == {"a.png": (True, True), "b.png": (True, False)}
def test_app_py_never_reads_the_blur_file_itself():
"""Invariant 3, extended from route bodies to the whole module: blur state
is read in `booth_items` and nowhere in app.py. Defeating change: the
second `read_blurred` in build_gallery."""
import ast
src = pathlib.Path(__file__).parent.parent / "booth" / "app.py"
calls = [
n for n in ast.walk(ast.parse(src.read_text()))
if isinstance(n, ast.Call) and getattr(n.func, "id", getattr(n.func, "attr", None)) == "read_blurred"
]
assert calls == []
+7 -1
View File
@@ -695,4 +695,10 @@ def test_unblurring_the_booth_keeps_per_item_choices(booth):
run(data, "unblur", "b") run(data, "unblur", "b")
assert not (b / ".blurbooth").exists() assert not (b / ".blurbooth").exists()
assert (b / ".blurred").read_text().strip() == "a.png" # Read through the reader, not the bytes: `.blurred` became a JSON array
# (the round-trip fix, operator-ruled 2026-09-23), and this test is about
# the per-item choice surviving, not about the file's format.
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.blur import read_blurred
assert read_blurred(b) == {"a.png"}
+1 -1
View File
@@ -278,7 +278,7 @@ def test_as_dict_round_trips_through_json(tmp_path):
# ---- the stdlib-only invariant (INV-5) -------------------------------------- # ---- the stdlib-only invariant (INV-5) --------------------------------------
@pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest", "benches", "__init__"]) @pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest", "benches", "blur", "__init__"])
def test_stdlib_only(module): def test_stdlib_only(module):
"""INV-5. scripts/booth imports these under the system python3 with NO venv, """INV-5. scripts/booth imports these under the system python3 with NO venv,
through a `python3 -c` heredoc that no AST extractor can see — so nothing through a `python3 -c` heredoc that no AST extractor can see — so nothing