Files
booth/persistent-memory.md
T
vh c7f9437a64 feat(marks): one primitive for operator judgment, so the loop stops running through chat
Five mechanisms existed to get one question next to one artifact. Three of
them were the same thing wearing different clothes, and the third of the three
had no code at all: the operator picked winners out of a 270-image set and
told the session in conversation. `sindra-finalists` is 86 items, every one
captioned, with the selection encoded in the booth's NAME.

A MARK is operator judgment attached to a target — the booth, or one item in
it, addressed by the `rel` U1 established as item identity. Three shapes:

  pick — one of N options a session declared in advance   (was: an ask)
  note — free text the operator volunteered               (had nothing)
  flag — this one                                         (had nothing)

One file per booth, one read path, one place openness is computed, one slot
beside the artifact. The storage shape is the operator's call (2026-09-21) and
follows from U4: "does this booth still owe an answer?" gets asked per booth
per sweep tick and per card per index render, so it has to be one read and not
a walk of a booth holding 270 files. Marks are also not links.md — that is an
O_APPEND content-hash log because 17 handles write it concurrently, whereas a
booth's marks see one session and one operator, so locking the common path
costs nothing.

The 2026-09-09 pick semantics are preserved by NOT rewriting them: partial
answers legal, a blank question lands in `unanswered`, `complete` false until
every question has a pick, the only refusal a submission carrying nothing.
`write_answer` split into the pure `build_answer` plus the storage that went
away with the sidecar; `normalize_ask` untouched.

Three findings worth naming, because each was caught by a gate rather than by
reading the diff again:

  * The seam review found `inline.place` indexes asks by SUBSCRIPT — the only
    consumer in the service that does — so a frozen dataclass breaks it, and
    `inline.py` had been missing from the contract's scope entirely.
  * A retargeted test found a regression in the legacy importer: a malformed
    sidecar that renders "broken" today would have silently vanished on
    migration. It now imports carrying its reason.
  * A partially-answered pick counted as CLOSED on the index while the panel
    beside it rendered it "partial" — the two disagreed about one booth. Open
    is the reading U4 needs, and it is declared rather than smuggled in.

`GET /b/<n>/marks.json` is new and load-bearing: sessions on other hosts polled
`<stem>.answer.json` over HTTP, so removing the sidecar without it would have
taken that capability away. `/b/<n>/asks` 308s to `/marks`. Legacy sidecars are
imported, never deleted — four are live and unanswered.

Also records the operator's deterministic-order directive as a cross-cutting v1
invariant, in ROADMAP.md with the per-collection rule table and as CLAUDE.md
invariant 6. The Booth's job is comparison; an order that moves between renders
does not crash, it misfiles the judgment.

242 tests. No version bump — a release tier for this is the operator's call.
2026-09-21 23:38:27 -07:00

12 KiB
Raw Blame History

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.
  • Two things are outstanding on U2 and both need the operator:
    1. Deploy + migrate, in that order. The live service still runs the old code, and four unanswered *.ask.json sidecars are live (dfa-concepts, sc-iso-spread, sindra-voice-1, run07-decisions). Migrating BEFORE deploying is the hazard: the old code would keep serving the sidecar, an answer written there would land in the sidecar, and import_legacy_asks skips a stem it has already imported — so that answer would be lost. Restart the service first, then booth marks-import <name> on each of the four.
    2. 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 operator 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] 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.