`.forever` was the only way to say three different things — "this is durable",
"I have not answered yet", "I am still looking" — and the census said it was
carrying all three: 17 of 24 live booths (70%, up from 54% the day before).
Three of the four booths in the fleet awaiting an answer had been pinned by
hand as well, and 10 of the 17 were younger than the TTL, so the sentinel had
bought them nothing and was pressed pre-emptively.
Only the first meaning is what `keep` means. The other two are facts the
service already held and did not consult.
KEPT `.forever` present never swept (unchanged)
HELD an open pick, or marks we cannot read never swept (new)
EPHEMERAL everything else 24h (unchanged)
Viewing is activity: a deliberately-served response from a booth's own page
route writes `.viewed`, which is a dotfile and not a `.lock` dotfile, so
`_newest_mtime` already counts it. There is no new arithmetic — `booth_age_seconds`,
`is_expired` and `expires_in` are unchanged. Machine reads are excluded on
purpose: an agent must not be able to hold its own booth open by polling for
the answer it is waiting on.
The hold is unbounded, and what makes that safe is visibility plus two exits
that already existed. Every surface whose chrome the Booth owns says
`held until answered` where the countdown was, and `booth rm` / the UI x /
`DELETE /b/<n>` take a held booth exactly as they take a kept one. A hold is
protection from the timer, never from the operator.
Three cross-frontier panels ran and each found a class the others could not:
* the paraphrase panel found that two reads of one file are not one read of
one state — the contract's `is_held(marks_for(c), read_error(c))` could
resolve to `([], None)`, the pair that deletes. `hold_read` is one read.
* the code-review panel found, 4-of-4, that the booth header's board branch
rendered no lifetime at all; and that five of seven invariant tests passed
under the change that defeats them.
* the bug-hunt panel found four more paths where a failed read still
authorized a delete, and a `record_view` that followed a planted symlink.
`is_held` became `hold_reason`, which returns the reason rather than a bool
beside a string that can disagree with it.
Prediction, to re-count on or after 2026-10-06: the `.forever` rate falls to
the booths that are genuinely durable references. Only 4 booths carry marks at
all, so this rests on both halves of the unit; a null result cannot distinguish
a wrong diagnosis from a habit that outlived its need.
406 tests (341 before). Contract: docs/contracts/u4_derived_lifetime.contract.md
9.9 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. links.py, asks.py and marks.py 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 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), .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.
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(seemarks._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. ROADMAP.md carries the
table and the two places still undecided (U7 sections and compare pairing, U6
bench listing).
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
:8090must 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.
-
A Python edit does nothing until you restart. Expected, and documented below.
-
A template edit used to take effect INSTANTLY. Jinja's
FileSystemLoaderre-reads a template from disk on every render. So the two halves of the service had different staleness rules, and editingbooth.htmldeployed 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
Environmentis now built withauto_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_diskholds the line.
So: after ANY edit here — Python or template — 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
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.