Files
booth/CLAUDE.md
T
vh 4cfbce5109 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.
2026-09-23 22:05:18 -07:00

14 KiB

CLAUDE.md — the Booth

Mechanical conventions for this repo: how the code here is written. Timeless by design — temporal state and dated decisions live in persistent-memory.md, the v1 gate lives in ROADMAP.md, and the model the code is converging on lives in docs/design/information-architecture.md.

House-wide rules (SemVer cadence, attribution, roadmap gate, the House Code Discipline) come from ~/.claude/CLAUDE.md and are not restated here.

What this is

A standing FastAPI service on :8090 that scans ~/booth-data for drop-folders and renders each as a review surface. Agents post work by making a folder; the operator looks at it, judges it, and the judgment gets back to the agent.

Read docs/design/information-architecture.md before any structural change. It supersedes README.md, which still describes the accreted file-shuttle model and is rewritten as the v1 units land.

Persistent memory

persistent-memory.md at the repo root captures durable intent and supporting evidence (goals, decisions, foot-gun warnings, in-flight state) across context resets. Read it at session start; treat it as one input alongside this file and the auto-memory system, not as the single source of truth.

It is a lean index: the dated log sections keep each over-threshold entry's full body in persistent-memory.d/<slug>.md. Pull a detail file only when its index line is relevant to your work — never bulk-read persistent-memory.d/.

When you commit, include any pending persistent-memory.md and persistent-memory.d/ updates in the same commit. Never leave them as a floating uncommitted change while shipping other work — durable memory that lags the code defeats its own purpose.

The six invariants

These are the ones a casual change breaks silently. Each has a test.

1. The modules scripts/booth imports are stdlib-only, on purpose

scripts/booth — the CLI every fleet session uses — imports them directly:

BOOTH_SRC=… python3 -c 'import sys; sys.path.insert(0, …); from booth.marks import declare_pick'

It runs under the system python3 with no venv. A single third-party 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.

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 (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.

⚠ 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. .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

booth.items.booth_items(booth) is the only place a file is classified, a caption resolved, a section derived or blur state read. No route body derives an item fact. Every surface — gallery, zoom, doc view, index card, zip manifest — reads the Item record.

Contract: docs/contracts/u1_item_record.contract.md, INV-1. Falsifiable and tested: no call to classify / doc_kind / read_blurred / render_doc survives inside create_app.

This exists because the zoom view once re-derived the item from scratch and, in doing so, never resolved the caption — the operator's "zoomed images lose their annotations" bug. It was not a rendering bug; it was three readers of one truth.

U3 extended this to the verbatim path. A booth's own index.html now gets its chrome from /_booth/embed.js, which places server-rendered fragments and never builds one. The fragments come from the same _ask_inline.html macros the gallery page uses, handed over /b/<name>/embed.json. A second renderer in JavaScript would be the same bug in a new language.

4. Re-export, don't move-and-break

Names that moved from app.py to items.py (classify, doc_kind, render_doc, CAPTION_MAX, the extension sets) stay importable from booth.app. 20+ test sites import them by name from there. The re-export is load-bearing and asserted by a test, not left to convention — a silent drop would be found by a consumer, not by us.

5. Sidecar writes are atomic; text bodies stay raw

Anything a session may read while the browser writes it goes through temp file

  • os.replace (see marks._write_raw). A reader never sees a partial file, and a crash mid-write cannot truncate a file into a shorter — and therefore quieter — set of marks or a more revealing blur set.

render_doc returns raw text for the non-markdown case on purpose: the template escapes it inside <pre>, and pre-escaping here double-encodes under Jinja autoescape.

6. Every ordered collection has a stated, deterministic order

Operator directive, 2026-09-21. Not "usually stable" and not "whatever rglob yields" — a rule you can write down in one line, producing the same sequence on every render of the same state. Any defensible rule qualifies: byte order over a path, creation time, an explicit number, an arbitrary-but-recorded sequence. No rule at all does not.

The Booth's job is comparison, which makes this load-bearing rather than tidy. The operator judges tile 47 of pancake-v3-full against tile 47 of pancake-v4-full, and refers to artifacts positionally — "the third one", "the one after the banded one". If the order moves between renders, or differs between the gallery, the zoom ring, the zip and the marks read, a flag or a note lands on the wrong artifact. It never shows up as a crash; it shows up as the operator's judgment being quietly misfiled.

Current rules: items sorted(rel); the zoom ring is that order filtered to images; captions resolve over a sorted scan; marks (created, id); legacy import (mtime, name); link rows pinned-then-newest; a verbatim report's embed anchors in document order, its tail in payload order, its questions in declaration order. ROADMAP.md carries the table and the two places still undecided (U7 sections and compare pairing, U6 bench listing).

U3's rows are the first that bind across a language boundary — decided in Python, honoured in JavaScript. A string assertion cannot see that, which is why tests/test_embed_browser.py exists.

When you add an ordered surface, state its rule in the docstring. If you cannot state it in one line, it does not have one.

Multi-writer vs single-writer — don't inherit the wrong shape

links.md is a multi-writer append log: 17 agent handles post to it concurrently, so it is an O_APPEND write with content-hash row identity and an fcntl lock only on the rewrite path. Index-based removal would delete a neighbour's row when another session appends mid-operation.

Marks, blur and keep state are single-writer — the operator, in one browser — with many readers (sessions polling). That is a different problem and must not inherit the append-log design. Per-booth file, atomic replace, lock the read-modify-write.

Ask which one you have before choosing a storage shape.

Non-goals, standing

  • No auth. LAN/mesh-internal. Blur is cosmetic and the UI says so. Anything that must not be seen by whoever can reach :8090 must not be in a booth.
  • No database. See invariant 2.
  • No upload API for booths. A session makes a folder. That is the whole API, and it is why every agent family can use this without a client.
  • Nothing deletes the operator's data on a timer beyond the documented 24h TTL. Liveness is flagged, not enforced.

⚠ The repo IS the deployment root

booth.service runs uvicorn with WorkingDirectory=/home/lkraven/development/booth. There is no build step, no staging copy, and no separate deploy artifact: the live service on :8090 imports these files. Two consequences, and the second one caused an outage.

  1. A Python edit does nothing until you restart. Expected, and documented below.

  2. A template edit used to take effect INSTANTLY. Jinja's FileSystemLoader re-reads a template from disk on every render. So the two halves of the service had different staleness rules, and editing booth.html deployed it immediately against Python that had never heard of the context it wanted.

    On 2026-09-21 that put 19 of 25 live booths at 500 — UndefinedError: 'item_marks' is undefined — with the Python from 22:03 and the templates from 23:40. Neither version was broken; the service was running both. The operator found it, not the suite, because no test can see a skew that only exists between a process and the disk under it.

    Fixed at the source: the template Environment is now built with auto_reload=False, so templates are cached at startup exactly like the Python. One rule now — nothing takes effect until you restart. The price is that template work needs a restart to see, and that price is the point. test_templates_do_not_hot_reload_from_disk holds the line.

  3. booth/static/embed.js is the third thing that would have hot-reloaded, and it does not. U3 gave the service a static asset living in the deployment root; it is read ONCE in create_app and served from memory with an ETag over its content, for exactly the reason above. Same rule, same test shape (test_embed_js_does_not_hot_reload_from_disk). Anything else this repo learns to serve from disk inherits the rule — read it at startup.

So: after ANY edit here — Python, template or static asset — the live service is stale until you restart it. If you are touching this repo while the operator may be using the service, either restart promptly or expect him to be looking at the old version. Never leave the tree in a state where a restart would 500.

⚠ The Environment is hand-built now, which means autoescape is explicit rather than inherited from the Jinja2Templates constructor. It is on (select_autoescape(["html", "xml"])) and it is load-bearing: booth names, item names and mark text are all agent- or operator-authored and land in HTML.

Working in here

.venv/bin/python -m pytest -q          # the suite; keep it green
curl -s localhost:8090/healthz         # the live service (systemd --user)
systemctl --user restart booth.service # after a code change, to see it live
.venv/bin/python scripts/mutation_check.py          # prove the falsifiers still falsify

A green test is not evidence. A test that has never seen its own defeating change may pass under it too — forbidding nothing while reading as though it forbids something. This repo shipped that three times before the tool existed (twice in one session, once an hour after writing the entry about it). Tables live in tests/mutations/*.toml, one per unit, committed so a unit's proofs are an artifact rather than scrollback; adding a unit means adding a file, never editing the script. tests/test_mutation_check.py holds the tool's own positive and negative controls, because an instrument that only ever sees unknowns cannot tell "nothing wrong" from "I am blind".

When you add a *Falsifiable:* line to a contract, add its row to the table and run it. A falsifier nobody has run is a claim, not a test.

tests/test_embed_browser.py drives a real Chromium against a real uvicorn on an ephemeral port — the only place U3's placement and form= binding can be observed at all. Browsers are NOT downloaded per project; they live box-wide in /opt/ms-playwright. The file skips rather than fails when playwright or a usable browser is missing, so the suite stays green anywhere. If those tests start skipping on this box, the pinned playwright>=1.60,<1.63 in pyproject.toml has drifted past the shared store — read the comment there before raising the bound.

booth.service is a user unit installed to ~/.config/systemd/user/. The repo copy is the source; edits there need a daemon-reload.

Tests are the spec for behaviour the contracts don't cover yet — test_booth.py carries the accreted service's behaviour and is the regression net for the v1 rewrite. Don't edit an existing assertion to make a change pass; if the behaviour genuinely changes, the contract says so first.