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.
### 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:
@@ -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
import in any of the three breaks `booth ask` / `booth marks` / `booth answer` /
`booth unlink` on every host, and the failure surfaces in an agent's session,
not in ours.
import in any of them breaks `booth ask` / `booth marks` / `booth answer` /
`booth unlink` / `booth blur` on every host, and the failure surfaces in an
agent's session, not in ours.
`items.py` and `app.py` are free to import what they like. Those three 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.
The set is `marks`, `asks`, `links`, `manifest`, `benches`, `blur` and
`__init__` (which runs before every one of them). **The list of record is
`test_stdlib_only`'s parametrize in `tests/test_marks.py`**, not this
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
No database. `ls ~/booth-data` tells you everything the service knows.
Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep),
`.viewed` (last deliberate look — U4's "viewing is activity"), `.blurred` (one
rel per line — ⚠ see below), `.seen` (R2: rels looked at full size, a JSON
ARRAY), `.blurbooth` (the whole booth fogged — a MARKER like `.forever`, not
`.viewed` (last deliberate look — U4's "viewing is activity"), `.blurred` (the
per-item blur set, a JSON ARRAY — see below), `.seen` (R2: rels looked at full
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
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 is the right shape for new operator state — use it rather than
inventing a sidecar-per-item.
⚠ **`.seen` is a JSON array where `.blurred` is one stripped rel per line, and
the difference is a latent bug in the older one.** A rel may carry a leading
space or a newline; line-stripped storage does not round-trip it, so blurring
`" a.png"` can blur `a.png` instead. `.seen` was written as JSON for exactly
that reason (design-dev, R2), and it also opens `O_NOFOLLOW | O_NONBLOCK` with
an `S_ISREG` check — a planted symlink is refused and a FIFO cannot hang the
read, which is the outage in
⚠ **A dotfile that holds rels is a JSON array, opened `O_NOFOLLOW |
O_NONBLOCK` with an `S_ISREG` check and a size cap.** A rel may carry a leading
space or a newline, and line-stripped storage does not round-trip it: `.blurred`
was one stripped rel per line, and blurring `" a.png"` blurred `a.png` instead.
`.seen` was written as JSON for exactly that reason (design-dev, R2), and
`.blurred` now matches it (`booth/blur.py`). The open flags mean a planted
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
inherits that shape, not `.blurred`'s.** `.blurred` itself is unfixed and
pre-existing.
inherits that shape.** `.blurred`'s reader still accepts the old line format,
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