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.
275 lines
14 KiB
Markdown
275 lines
14 KiB
Markdown
# 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:
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
.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
|
|
```
|
|
|
|
```sh
|
|
.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.
|