Files
booth/persistent-memory.md
T
vh bb1e3cfcd7 fix(booth): templates were hot-reloading into a live service running older Python
19 of 25 live booths returned 500 with `UndefinedError: 'item_marks' is
undefined`. Neither the old code nor the new code was broken — the service was
running both at once.

`booth.service` sets WorkingDirectory to this repo, so the repo IS the
deployment root: no build step, no staging copy, the live service imports these
files. Python is read once when the process starts. Jinja's FileSystemLoader
re-reads a template on EVERY render. So the two halves of the service had
different staleness rules, and editing booth.html deployed it instantly against
Python from 22:03 that had never heard of the context the new markup wanted.

The failure mode is worth naming precisely, because it is invisible to the
suite by construction: the skew exists between a running process and the disk
underneath it, so every test can pass against a tree that is simultaneously
serving 500s. No amount of green catches this. The operator found it.

Fixed at the source rather than with a reminder to restart. The template
Environment is built here with auto_reload=False, so templates are cached at
startup exactly like the Python, and there is ONE rule: nothing takes effect
until you restart. The price is that template work needs a restart to see —
that price is the entire point, and it is cheaper than a page of 500s while
someone is reviewing.

Building the Environment by hand means autoescape no longer comes from the
Jinja2Templates constructor, so it is explicit and load-bearing: booth names,
item names and mark text are all agent- or operator-authored strings that land
in HTML. Verified escaped, not merely configured.

Two tests hold the line — one on the snapshot property, one on the `dur` filter
that is no longer incidental to the constructor. The environment is reachable at
app.state.templates because a promise about the deployed service needs an
assertion, and an assertion needs the env the app actually renders with.

Also records the foot-gun in CLAUDE.md and persistent-memory: anyone editing
this repo while the operator may be using the service is editing production.

244 tests. No version bump — the release tier for U2 is still the operator's
call, and this rides with it.
2026-09-21 23:44:37 -07:00

207 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Persistent memory — booth
_Last updated: 2026-09-21_
> **Always check for `/tmp/booth-dev-handoff.md`** — if it exists and its
> `Written:` stamp is under 8 hours old, read it (it carries the in-flight
> handoff from the previous session), then delete it. Older than 8 hours:
> stale — delete it unread.
## Repo purpose
The Booth is the fleet's **operator-review surface**: agents post work by
making a folder under `~/booth-data`, the operator looks at it and judges it in
the browser, and the judgment gets back to the agent that posted it. It was
built as a file-shuttle and is being converged, unit by unit, onto the review
loop it turned out to actually be.
## Current state / in-flight
_As of 2026-09-21:_
- **v1 is gated on seven units** in `ROADMAP.md`, ordered by dependency:
**U1 → U2 → {U3, U4, U5} → U7**, with **U6 independent** of all of them.
- **U1 (one item record) has landed** at `ce598b3` and is verified against its
own invariants, not just its commit message: INV-1 holds (no `classify` /
`doc_kind` / `read_blurred` / `render_doc` call survives in a route body),
the zoom and doc templates render the caption they now receive, the
re-exports are asserted by a test. 192 tests green, `0.1.15`.
- **U2 (marks) has landed** — `booth/marks.py`, contract at
`docs/contracts/u2_marks.contract.md`, 242 tests green. Not yet deployed.
- **U2 is DEPLOYED and the migration is done.** The service was restarted
2026-09-21 23:41 and again after the `auto_reload` fix; all four legacy
sidecars imported (`dfa-concepts/dfa`, `run07-decisions/decisions`,
`sc-iso-spread/spread`, `sindra-voice-1/anchor`, all still open) with the
sidecars left on disk. Verified live: index + 25 booths x {booth page, marks
page, marks.json} all 200, plus zoom views on five booths.
- **Still needs the operator: the release tier.** U2 changes the CLI surface for
17 consuming handles (`booth asks` -> `booth marks`, new `marks-import`) and is
a v1 unit, so it reads minor-worthy — which needs explicit approval per the
SemVer rule. Nothing is bumped or tagged; the work is committed as SHAs.
- **`/heid-contract-review` on the U2 contract is still in flight** (panel mode,
posted 2026-09-21, redacted copy at
`/tmp/heid-contract-review/booth-20260922-061015/`). Triage it when it lands —
the code is written, so findings land as follow-up fixes rather than contract
edits. The seam review ran in-session and its nine findings are already folded
into the contract and the code.
- **Open, operator's call:** whether U6 (benches) runs in parallel with U2 or
strictly after it. Nothing blocks on the answer; U6 touches different storage
and a different surface, so it cannot be broken by U2.
- Live service is `active` on `:8090` (systemd `--user`), 25 booths.
## Recent decisions
- `[2026-09-21]` **Deterministic order is a cross-cutting v1 invariant** —
operator directive, mid-implementation. Every ordered collection the Booth
renders must have a *stated* rule producing the same sequence on every render
of the same state; the rule can be anything defensible (byte order, time, an
explicit number, an arbitrary-but-recorded sequence), but no rule at all is
forbidden. It binds harder here than elsewhere because the Booth's job is
**comparison** — the operator judges tile 47 against tile 47 and refers to
artifacts positionally, so an order that moves between renders misfiles a flag
or a note rather than crashing. Recorded as `ROADMAP.md` § "Cross-cutting
invariant" (with the per-collection table) and `CLAUDE.md` invariant 6, and
tested. Still undecided and must be settled before those units ship: **U7's
section ordering and compare pairing**, and **U6's bench listing**.
- `[2026-09-21]` **U2 (marks) landed.** One primitive replacing three
mechanisms. `pick` / `note` / `flag` in one `.marks.json` per booth, one read
path (`marks_for`), one openness predicate (`open_marks`), rendered beside the
artifact on the tile, at full size in the zoom, and in the panel. `flag` and
`note` had no write path at all before this — the selection loop
(`golden-candidates`, `sindra-finalists`, the `pancake-*` ladders) was running
through chat. 242 tests. Details worth carrying: `asks.py` kept `normalize_ask`
and gained `build_answer` (the 2026-09-09 partial-answer semantics preserved by
moving, not rewriting) and LOST its five sidecar-storage functions;
`GET /b/<n>/marks.json` was added because remote sessions polled
`<stem>.answer.json` over HTTP and the sidecar's removal would have taken that
capability with it; `/b/<n>/asks` 308s to `/marks`.
- `[2026-09-21]` **A partially-answered pick now counts as OPEN** — declared, not
smuggled. The old index badge tested `answer is None`, so a half-answered
four-question ask read as closed on the index while the panel beside it
rendered `◐ partial`: the two disagreed about the same booth. Open is the
reading that makes U4 correct — a lifetime rule that unpinned a booth on the
first radio click would sweep a review in flight.
- `[2026-09-21]` **The U2 seam review earned its place, and the record should
say how.** Nine findings against the real `booth.asks` / `booth.items` /
`booth.inline` surfaces, two of which changed scope or behaviour: `inline.py`
was missing from `touches` entirely (its `place()` indexes asks by
**subscript**, which a frozen dataclass refuses — nothing else in the service
does that), and the partial-answer inconsistency above. The cold
`/heid-contract-review` pass is artifact-only by design and structurally
cannot see a sibling module, so neither it nor a same-model self-review would
have found either. Two more surfaced later and are worth the same note: a
SECOND subscript in `inline.place` the seam review undercounted, and a
regression in my own legacy importer that a retargeted test caught — a
malformed sidecar that renders `⚠ broken` today would have silently vanished
on migration.
- `[2026-09-21]` **Marks are stored as one `.marks.json` per booth**, atomic
temp-file + `os.replace`, `fcntl` lock on the read-modify-write — operator
decision, this session. Two alternatives were weighed and lost: a sidecar
per item (`<rel>.marks.json`) and extending the existing `<stem>.ask.json`
shape. Rationale, and the reason it is not `links.md`-shaped: **(a)** U4
makes *"does this booth owe an answer?"* a hot question — the sweep asks it
per booth per tick and the index asks it per card per page load, so per-item
sidecars turn it into a full walk of all 25 booths, one of which holds 270
files; **(b)** `links.md` is an `O_APPEND` content-hash log because **17
agent handles write it concurrently**, whereas marks have exactly one writer
(the operator, in one browser) and many readers — a different problem that
must not inherit the append-log design; **(c)** `.blurred` / `.pins` /
`.forever` already establish the per-booth dotfile as the house shape for
operator state, and `booth_items()`'s dotfile skip means it costs nothing in
counts, galleries or zips. Accepted cost: a corrupt `.marks.json` loses that
booth's marks rather than one item's. Implementation deferred to U2 —
tracked at `ROADMAP.md` U2 and by this entry.
- `[2026-09-21]` **U7's section premise is half wrong, and it is the half that
matters** — found by re-measuring `~/booth-data` rather than trusting the IA
doc. The IA says sections come from subfolders that already exist on disk;
true, but **every booth that actually needs navigation is flat**:
`pancake-v3-full` (270 items, 0 subfolders), `pancake-v4-full` (270, 0),
`sindra20-engines` (98 items + 99 caption sidecars, 0), `sindra-finalists`
(86 + 87, 0). Subfolders exist on exactly two booths — `pewpew-ui-brief` (7,
nested to `_ds/powerpellet-design-system-<uuid>/preview`) and `dfa-concepts`
(1) — and **both are reports**, the job where grid navigation matters least.
So sections stay worth shipping and `Item.section` stays right, but they are
**not** "most of the navigation fix": the rail, the filters and grid keyboard
are all of it. Worth noting for whoever writes U7: `sindra20-engines` encodes
its structure in the **filename prefix** (`b2-s1-<subject>-<seed>`), which is
where a grouping heuristic would actually pay. The IA doc's claim about what
sections buy needs a line struck — not yet edited.
- `[2026-09-21]` **`sindra-finalists` is U2's `flag` motivation caught in the
act** — 86 items, every one captioned, and the booth's entire name is "the
ones the operator picked." That loop currently runs through chat, which is
the defect `flag` closes. Evidence, not argument.
- `[2026-09-21]` **The information architecture and the v1 gate landed**
(`726822b`): `docs/design/information-architecture.md` names the single
defect — *one lifetime (24h from last touch) and one shape (a folder),
serving five jobs with different lifetimes and different shapes* — and
`ROADMAP.md` gates v1 on seven units, each closing a **measured** defect
rather than a wish. Both were written after a measurement pass over the live
service, and the measurements are the load-bearing part.
- `[2026-09-21]` **The `.forever` diagnosis is a stated, falsifiable
prediction.** U4 (derived lifetime) predicts the kept-rate falls to the
genuinely-durable booths. Re-measured today: **14 of 25 booths kept (56%)**,
against the 54% the IA doc recorded. **Re-count a fortnight after U4 lands.**
If it does not move, the diagnosis was wrong and the boolean was doing
something else. Tracked in the IA doc's Booth section and by this entry.
- `[2026-09-21]` **Extracted from `eshpfi` into its own repo.** The accreted
service came over whole, tests included, so `tests/test_booth.py` (1581 lines)
is the regression net the v1 rewrite is checked against.
## Tried and abandoned
- `[2026-09-21]` **Letting Jinja hot-reload templates while the repo is the
deployment root** — the cause of a live outage the same day U2 landed, and the
sharpest foot-gun in the repo. `booth.service` sets `WorkingDirectory` to this
repo, so the running service imports these files with no build step and no
staging copy. Python is read once at process start; Jinja's `FileSystemLoader`
re-reads a template **on every render**. Editing `booth.html` therefore
deployed it instantly against Python from 22:03 that knew nothing about
`item_marks`, and **19 of 25 live booths returned 500** with
`UndefinedError: 'item_marks' is undefined`. Neither the old code nor the new
code was broken — the service was running both at once.
**The lesson that generalises:** a skew between a process and the disk under it
is invisible to the test suite by construction, so no amount of green tests
would have caught it; the operator found it. Fixed at the source rather than
with a reminder — the `Environment` is hand-built with `auto_reload=False`, so
there is now ONE staleness rule (nothing takes effect until you restart) and
the running process is always a coherent snapshot of one commit. Asserted by
`test_templates_do_not_hot_reload_from_disk`. Watch the second-order risk the
fix introduces: a hand-built `Environment` does not inherit `autoescape` from
the `Jinja2Templates` constructor, and booth names, item names and mark text
are all agent-authored strings landing in HTML.
- `[2026-09-21]` **Five separate mechanisms to get one question next to one
artifact** — `.forever`, the link board, `inline.py`'s placeholder DSL,
`wrap_verbatim_html`'s six regexes, and the floating amber asks chip plus
`/b/<n>/asks`. Every one is a *correct local fix* to the same global
mismatch, which is exactly why they accumulated without anyone making a bad
call. **The foot-gun is the sixth one:** the next "just add a small thing for
this case" reads as reasonable and is the pattern. The git log carries the
signature — every feature ships, then takes 2–5 patches for cases the single
shape did not anticipate. Check the ROADMAP gate before adding a mechanism.
- `[2026-09-21]` **Regex-injecting chrome into arbitrary author HTML**
(`wrap_verbatim_html` + `_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`,
`_BODY_CLOSE_RE`, `_HTML_CLOSE_RE`, `_ICON_RE`, and the doctype/charset
ordering constraints they thread). It works today and is **still live** —
but it is the single most fragile thing in the service and it is load-bearing
for the operator's most important workflow. Slated for deletion at U3 in
favour of a declared seam (`/_booth/embed.js`, mounted through a real DOM
API), which costs an author one line and removes the whole class. Do not
extend the regex set in the meantime; if a verbatim page breaks, that is an
argument for U3, not for a seventh pattern.
- `[2026-09-21]` **A boolean escape hatch as the lifetime mechanism.**
`.forever` was added because a 24h TTL genuinely did not fit some booths —
and then 56% of live booths ended up on it, which means it is not "ephemeral
with an exception", it is two lifetimes wearing one lifetime's clothes, with
the operator doing the sorting by hand. Replaced at U4 by lifetime derived
from state (an open mark pins; viewing is activity; `keep` survives as an
explicit reasoned pin rather than the only way to say "not yet").
- `[2026-09-21]` **Letting the link board absorb the announce job.** `booth
link` is an `O_APPEND` write with no identity and no stated rule, so
re-announcing a bench appends a row instead of updating one, and a booth URL
rots the moment its booth is swept — **145 of 211 rows (69%) pointed at
nothing**, and 22 were the same target re-posted (talk 5×, peedlar 4×). The
rot is **structural, not drift**. The lesson that cost the most: enforcing
the link rule without first giving the announce job a home (`.booth.json`
provenance on the index, U5) just makes it homeless.