fix(blur): fold the heid bug-hunt: two file names, a reader-judged writer, one predicate

The heid bug-hunt panel on 4cfbce5 (hulda, regin, kimi; groa timed out) found
four real defects in the round-trip fix, and three of its arms converged on the
worst: it re-created the bug it existed to fix.

- Two names, never a sniffed file (3/3). JSON went into the OLD `.blurred`, and
  the reader guessed the format from the bytes, so a legacy file whose one line
  is an item named `["a.png"]` read as {"a.png"} and blurred the neighbour. The
  set now lives in `.blurred.json`, JSON only. The legacy `.blurred` is read as
  lines only, and only while `.blurred.json` is absent; the first write retires
  it, after the new file is in place.
- A planted directory is a 409, not a 500 (2/3 plus a third angle, executed by
  the seat). The reader was hardened against it and the writer was not:
  os.replace and unlink raised IsADirectoryError through the route. Now the
  writer is judged by its reader: set_blurred re-reads after writing and raises
  BlurUnwritable unless the set on disk is the set asked for. That one check
  covers a directory at either name, a permission and a race.
- A lone surrogate is dropped on read (hulda, executed). `"\ud800"` is a valid
  JSON string that no filename can produce, and the UTF-8 encode raised on it
  at every later write.
- The writer respects the reader's size cap (2/3). Nothing capped the write,
  and the reader reads an oversized file as EMPTY, which reveals everything.
- One predicate, check_rel, for the route and the CLI (2/3). The CLI's `*..*`
  substring guard refused `a..b.png`, which the route accepts. It also refuses
  an empty path now (regin, kimi), and every item is checked before any is
  written.
- `booth blur` fails closed, with a message and exit 3, when its package is
  missing (kimi), as `link` already does.

Declined, with reasons: the Item positional-constructor break (booth_items is
the only constructor, INV-1), the fdopen fd leak and the short read (not
constructible on a local filesystem, and the `.seen` shape), and
unreadable-reads-as-revealed (blur is cosmetic; the `.seen` posture).
blur_storage.toml: 20/20 proved. One row came back VACUOUS on its first run,
because `set() or X` is X, and was rewritten before counting.
This commit is contained in:
vh
2026-09-23 23:01:18 -07:00
parent 4cfbce5109
commit c1f5543b77
7 changed files with 494 additions and 134 deletions
+26 -15
View File
@@ -66,9 +66,9 @@ that list in the same commit.
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` (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
`.viewed` (last deliberate look — U4's "viewing is activity"), `.blurred.json`
(the per-item blur set, a JSON ARRAY — see below; the legacy `.blurred` is
read-only), `.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
@@ -79,19 +79,30 @@ inventing a sidecar-per-item.
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.** `.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.
`.seen` was written as JSON for exactly that reason (design-dev, R2), and the
blur set now matches it in `.blurred.json` (`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.**
**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.
⚠ **A format change gets a NEW NAME, never a sniffed file.** The first cut of
the blur fix wrote JSON into `.blurred` and guessed the format from the bytes;
a legacy file whose one line is an item literally named `["a.png"]` parses as
JSON and blurs the neighbour, the bug being fixed (heid bug-hunt, 3 of 3). So
`.blurred.json` is JSON only, the legacy `.blurred` is lines only and read only
while `.blurred.json` is absent, and the first write retires it. Do not remove
that legacy read while a line-format file can still exist.
**A writer is judged by its reader.** `set_blurred` re-reads after writing and
raises `BlurUnwritable` unless the reader returns exactly the set asked for.
One postcondition covers a planted directory, a permission and a race without
a branch per way the disk can be wrong; the route answers it 409, never 500.
**A dotfile with two writers has ONE implementation of the writer, and one
predicate for its keys.** The blur set is written by the service and by `booth
blur`; both call `booth.blur.set_blurred`, and both ask `check_rel` what an
item path is. The CLI used to keep a grep/printf writer and a `*..*` guard of
its own, which refused `a..b.png` where the route accepted it.
### 3. One resolver for item facts