Files
booth/CLAUDE.md
T
vh c1108a1966 feat(blur): a booth can be fogged as a whole, composing with per-item blur
The operator ruled booth-level blur in and chose reading A for the reveal
("A is fine"). design-dev specced the semantics and owns the controls; this is
the storage half.

COMPOSES, NEVER OVERRIDES. An item is blurred iff the booth is blurred OR it is
in .blurred, so turning booth blur off leaves an agent's per-item choice exactly
as the poster left it. An override would need a per-item "unblurred" exception
list, which is state nobody can see.

Resolved in booth_items, so every surface inherits it for free — Desk strip,
tiles, flag tray, filmstrip, stage all already read Item.blurred and none of
them learns the booth flag exists (INV-1). Images and video only; audio has
nothing to hide from a glance.

A MARKER, deliberately not JSON. `.seen` is JSON because it holds rels that must
round-trip exactly; a boolean has nothing to round-trip, and matching `.forever`
means the two whole-booth flags read the same way. We told design-dev it would
be JSON and it should not be — said so rather than quietly shipping the other
thing.

is_booth_blurred mirrors is_kept's lstat shape WITH THE SAFETY INVERTED, and the
inversion is the point: is_kept fails toward keeping because a failed read must
not authorise a delete; this fails toward HIDING, because a failed read must not
reveal something a poster asked to fog. Both are "the failure does not cause the
loss".

Also records the operator's 2026-09-23 ruling that there is NO 1.0 yet, and adds
.blurbooth to CLAUDE.md's dotfile list. 766 green.
2026-09-23 17:06:37 -07:00

264 lines
13 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. `links.py`, `asks.py` and `marks.py` 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 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.
`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.
### 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
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
`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.
### 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.