docs: the conventions a fresh session can't infer, and the state it can't reconstruct

The repo came out of eshpfi one day ago with neither piece of its house
furniture, so everything non-obvious about it lived in docstrings inside a
998-line app.py — which is a bad place to keep an invariant that breaks
silently on every fleet host when someone violates it.

CLAUDE.md carries the five that do exactly that:

  * links.py and asks.py are stdlib-only because scripts/booth imports them
    under the system python3 with no venv, so one third-party import breaks
    `booth ask` everywhere and fails in an agent's session, not in ours
  * the filesystem is the state, and booth_items()'s dotfile skip is why a
    per-booth dotfile is the right shape for new operator state
  * booth_items() is the only thing that classifies a file or resolves a
    caption (U1's INV-1) — the zoom-loses-the-annotation bug was three
    readers of one truth, not a rendering bug
  * moved names stay importable from booth.app, asserted by a test
  * sidecar writes are atomic; render_doc returns raw text on purpose

Plus the distinction that decided this session's storage call: links.md is
an append log because 17 handles write it concurrently, and marks have one
writer. Different problem, different shape — ask which you have first.

persistent-memory.md carries what CLAUDE.md is structurally unable to: the
dated decisions, the `.forever` prediction and its re-measure date, and the
foot-gun log. Two entries are load-bearing for the next unit — the settled
mark storage shape with the reasoning that picked it, and a measured
correction to U7's premise: every booth that actually needs navigation is
flat, so subfolder sections are worth shipping but are not the nav fix.

No version bump — docs and memory, both on the SemVer skip list.
This commit is contained in:
vh
2026-09-21 22:58:00 -07:00
parent ce598b3cf6
commit 9272c9872e
2 changed files with 271 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
# 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 five invariants
These are the ones a casual change breaks silently. Each has a test.
### 1. `links.py` and `asks.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.asks import write_ask'
```
It runs under the system `python3` with **no venv**. A single third-party
import in either module breaks `booth ask` / `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 two are not.
### 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),
`.blurred` (one rel per line), `.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.
### 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.
### 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 `asks.write_answer`). A reader never sees a partial file.
`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.
## 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.
## 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
```
`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.