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.
27 KiB
contract_version, module, purpose, depends_on, language, complexity, estimated_loc, confidence, used_by, touches, assumptions, open_questions
| contract_version | module | purpose | depends_on | language | complexity | estimated_loc | confidence | used_by | touches | assumptions | open_questions | |||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1.0 | booth.marks | ONE primitive for operator judgment attached to an artifact, replacing three tacked-on mechanisms. A MARK has a target (the booth, or one item in it) and a shape -- `pick` (one of N options the session declared in advance; was: an ask), `note` (free text the operator volunteered; was: a comment), `flag` (this one / not this one; was: a vote). All three are the same thing -- the operator judging something and the session reading the judgment -- and today they are three storage models, three read paths and, for `flag`, no code at all: the operator picks winners from a 270-image set and tells the session IN CHAT. One storage model (`.marks.json` per booth), one read path (`marks_for`), one place `is anything still open?` is computed (`open_marks`), one rendering slot (beside the artifact). |
|
python | high | 620 | 0.75 |
|
|
|
|
U2 — marks
The defect, stated precisely
Five mechanisms exist to get one question next to one artifact. Three of them are the same primitive wearing different clothes, and the third of the three does not exist in code at all:
| job | today | storage | read path |
|---|---|---|---|
| the session asks the operator | asks | <stem>.ask.json + <stem>.answer.json, two files per question |
list_asks(booth) walks the booth, load_ask + read_answer per ask |
| the operator tells the session | — | nothing | nothing |
| the operator points at the good ones | — | nothing | a chat message |
The third row is the expensive one. golden-candidates, sindra-finalists and
the pancake-* ladders are all the operator selecting winners from a set and
then telling the session in conversation — and sindra-finalists is 86 items,
every one captioned, with the selection encoded in the booth's name. The
session that posted the set cannot read the judgment it asked for.
asks_open on the index card is the same defect from the other end: computing
"does this booth owe an answer?" means walking every booth and parsing two JSON
files per ask, on every index page load. U4 makes that question load-bearing —
an open mark pins its booth — so it has to be one read.
The record
@dataclass(frozen=True)
class Mark:
id: str # pick: the session-supplied stem. note: generated. flag: derived from target.
shape: str # "pick" | "note" | "flag"
target: str | None # an Item.rel, or None for the booth itself
created: str # ISO8601 local, seconds
# --- pick: the session's declaration, normalized on READ (as load_ask does today) ---
declaration: dict | None # the raw posted document; None for note/flag
prompt: str | None # normalized: the prompt, or the multi-form title
title: str
multi: bool
questions: list[dict] # normalized; a single-question pick is a 1-list with key None
options: list[dict] # single-question picks only, as normalize_ask emits
notes_enabled: bool # <- normalize_ask emits this as `notes` (a bool). SR-2.
notes_label: str
# --- the operator's judgment ---
answer: dict | None # pick: build_answer's output. None while OPEN.
text: str # note: the body. "" otherwise.
flagged: bool # flag: always True (see open_questions). False otherwise.
by: str # who recorded it (request client host), "" for a declaration
error: str | None # a malformed pick declaration, SURFACED not hidden
error is not defensive decoration: today a broken *.ask.json is returned by
list_asks with error set precisely so the page can say so, rather than
silently hiding a question the session believes it posted. Preserved.
The stored document
<booth>/.marks.json — a dotfile, so booth_items' existing
name.startswith(".") skip keeps it out of item counts, galleries and zips
with no new exclusion rule.
{
"version": 1,
"marks": [
{"id": "decisions", "shape": "pick", "target": null,
"created": "2026-09-21T14:02:11-07:00",
"declaration": {"prompt": "Which run ships?", "options": ["run07", "run08"]},
"answer": null},
{"id": "n-4f3a91", "shape": "note", "target": "v3/DSC03389.jpg",
"created": "...", "text": "banding in the gradient", "by": "10.100.10.20"},
{"id": "flag:DSC03403.jpg", "shape": "flag", "target": "DSC03403.jpg",
"created": "...", "by": "10.100.10.20"}
]
}
The declaration is stored raw and normalized at read time — exactly what
write_ask + load_ask do today, and the reason a broken declaration surfaces
as error at render rather than being unrepresentable on disk.
Signatures
# ---- read -------------------------------------------------------------------
def marks_for(booth: Path) -> list[Mark]:
"""Every mark in a booth, oldest first by `created`, declarations normalized
and answers folded in. ONE file read. A missing or unparseable `.marks.json`
returns [] -- a booth with no marks and a booth whose mark file is corrupt
both render as "no marks", and neither is a 500."""
def open_marks(marks: Sequence[Mark]) -> list[Mark]:
"""The marks still owed an answer: shape == "pick" and (answer is None or
not answer["complete"]). THE one place openness is computed -- the index
badge, the booth header, the panel filter and U4's pin rule all call this
rather than re-deriving the predicate."""
def marks_for_target(marks: Sequence[Mark], rel: str | None) -> list[Mark]:
"""The marks attached to one item (or to the booth, for None). The gallery
tile, the zoom view and the injected fragment all slot by this."""
def as_dict(mark: Mark) -> dict:
"""The JSON boundary -- `booth marks` output and nothing else (SR-8). Python
consumers take the dataclass; Jinja takes the dataclass too, since every
template accesses marks by attribute. This exists so the CLI has one
serialization and not a hand-rolled dict per verb."""
# ---- write (each takes the lock, rewrites atomically, returns the new Mark) --
def declare_pick(booth: Path, stem: str, doc: dict) -> Mark:
"""A session poses a pick. Validated through `normalize_ask` BEFORE the
write, so a session cannot land a question the renderer would refuse.
Re-declaring the same stem replaces the declaration and CLEARS its answer
-- the question changed, so the old judgment is not an answer to it."""
def answer_pick(booth: Path, mark_id: str, choice, notes: str = "",
who: str = "", qnotes: dict | None = None) -> Mark:
"""Record the operator's pick. Delegates every semantic to
`asks.build_answer`; this function owns storage and nothing else.
Re-answering overwrites -- the mark is the CURRENT judgment, not a log."""
def write_note(booth: Path, target: str | None, text: str, who: str = "") -> Mark:
"""Attach free text to an item, or to the booth. Empty text after cleaning
is refused (nothing to record), same posture as an empty pick submission."""
def set_flag(booth: Path, target: str, on: bool, who: str = "") -> Mark | None:
"""Flag or unflag one item. Upsert keyed by target: flagging twice is
idempotent, unflagging REMOVES the mark and returns None (an absent flag and
a false flag are the same judgment; two representations of one state is how
`.forever` became a problem)."""
def delete_mark(booth: Path, mark_id: str) -> bool:
"""Remove one mark by id. True if it was there. The operator's undo."""
# ---- migration --------------------------------------------------------------
def import_legacy_asks(booth: Path) -> list[Mark]:
"""Read every `*.ask.json` / `*.answer.json` into `.marks.json` as picks and
their answers. Idempotent: a stem already present as a mark is skipped, so
running it twice is a no-op and never clobbers a newer judgment. LEAVES THE
SIDECARS ON DISK -- nothing here deletes the operator's data."""
Invariants
- INV-1 — one storage model. No module reads or writes a
*.ask.json/*.answer.jsonexceptimport_legacy_asks. Falsifiable:ASK_SUFFIXandANSWER_SUFFIXappear inmarks.pyonly inside the importer; inapp.pyonly as the re-export; and initems.pyonly as the tile exclusion, which importsis_ask_filefrombooth.asksdirectly rather than throughapp(SR-9 — the first draft's wording said "nowhere inapp.py" and would have readitems.pyas a violation). - INV-2 — one place openness is computed. The predicate
shape == "pick" and not answered-completelyexists exactly once, inopen_marks. Falsifiable:grep -c "selectattr('answer'" booth/templates/is 0 andgrep -c 'answer.*is None' booth/app.pyis 0. Today the predicate exists in five places, not two (SR-6): two in Jinja —booth.html:21,asks.html:11— and three in Python —app.py:271(the index badge),app.py:750andapp.py:751(the verbatim-booth chip). - INV-3 — the judgment travels, like the caption. U1's rule, extended:
every surface that renders an item renders that item's marks. Gallery tile,
zoom view, doc view. Falsifiable: fetch
/b/<n>/view?f=<img>for a flagged item carrying a note and assert both the flag state and the note text are in the served HTML. - INV-4 — the pick semantics are byte-identical.
build_answerproduces, for every input, the documentwrite_answerproduced. Falsifiable: the existingtest_asks.pyanswer assertions pass againstbuild_answerwith their expectations unchanged. - INV-5 — stdlib only.
booth/marks.pyimports nothing outside the standard library. Falsifiable: a test that walksmarks.py's AST imports and asserts every root module is insys.stdlib_module_names— the same guard is added forasks.pyandlinks.py, since the constraint has been convention-only until now and graphify structurally cannot see the CLI consumer that depends on it. - INV-6 — the lock is held for the whole read-modify-write. Every writer
goes through one
_with_marks(booth)helper holding an exclusive flock on.marks.lockacross read, mutate and atomic replace. Falsifiable: no write function calls_read_unlockedoros.replacedirectly. - INV-7 — nothing deletes a legacy sidecar. Falsifiable: after
import_legacy_asks, the*.ask.jsonfile still exists.
Seam review — 2026-09-21, caller-side, against the real module surfaces
/heid-contract-review is artifact-only by design: the arms read this file and
are forbidden the depends_on siblings, so the cold pass structurally cannot
check a single cross-module seam. This is that check — every symbol, field and
comparand this contract borrows from booth.asks, booth.items and
booth.inline, read against the actual .py rather than against prose.
Nine findings. Two changed the contract's scope or its stated behaviour; the
rest are field-level corrections that would each have been a mid-implementation
KeyError or TypeError.
| id | finding | disposition |
|---|---|---|
| SR-1 | inline.place indexes its input with by_stem = {a["stem"]: a for a in asks} — a subscript. Every other consumer, templates included, uses attribute access. A frozen dataclass raises TypeError there. |
Scope miss. booth/inline.py added to touches and depends_on. One line. |
| SR-2 | normalize_ask emits notes (bool) and stem; this contract's record calls them notes_enabled and id. |
Mapping stated inline in the record. |
| SR-3 | "build_answer is write_answer minus the last four lines" is false — it also loses its first line, gains an ask parameter, and sources stem from ask["stem"]. |
Assumption rewritten; see the build_answer assumption. |
| SR-4 | load_ask was what raised AskError for a missing/invalid ask. Extracting it moves that error path to the caller. |
answer_pick must raise AskError for an unknown mark_id; a test asserts it. |
| SR-5 | list_asks orders by file mtime; marks_for orders by the stored created string. For the four live sidecars those are different orderings. |
import_legacy_asks seeds created from the sidecar's mtime. Test added. |
| SR-6 | Openness is re-derived in five places, not the two this contract claimed: 2 Jinja + 3 Python (app.py:271, :750, :751). |
INV-2's falsifiable now covers app.py as well as templates. |
| SR-7 | The three Python re-derivations test answer is None, so a partially-answered pick counts as closed — while _asks.html renders that same pick as ◐ partial. The index badge and the panel disagree about the same booth today. |
Declared behaviour change, below. Not smuggled in. |
| SR-8 | The CLI's asks verb reads a["stem"], a["answer"]["label"], a["answer"]["answers"] — dict subscripts — and booth marks must emit JSON. |
marks.as_dict(mark) (over dataclasses.asdict) is the CLI/JSON boundary; the dataclass is the Python boundary. |
| SR-9 | items.py imports is_ask_file from booth.asks directly, not via app. |
INV-1's falsifiable reworded to name it. |
Good news worth recording so nobody defensively rewrites it: every Jinja
template accesses asks by attribute (a.stem, a.answer.complete) and not one
uses subscript syntax — verified by grep across booth/templates/*.html. So
Mark dataclasses drop into the templates unchanged, and answer staying a
plain dict is fine because Jinja's dot access falls back to __getitem__.
inline.py is the single exception, which is SR-1.
The one declared behaviour change (SR-7)
open_marks counts a partially-answered pick as still open. Today's index
badge does not, and that is an inconsistency inside the live service rather than
a decision: the panel already renders a partial answer as ◐ partial with an
n/m counter, while the badge that is supposed to say "this booth owes you
something" reports zero.
Counting partial as open is the reading that makes U4 correct — a booth with a half-answered four-question pick still owes an answer, and a lifetime rule that unpins it on the first radio click would sweep a review mid-flight. It is called out here because it is a visible change to what the index shows, it is the kind of thing that looks like a bug when it lands, and the operator should get to veto it rather than discover it.
Slices
Vertical, each one shippable and green before the next starts.
| # | slice | tracer |
|---|---|---|
| 1 | marks.py storage + Mark + marks_for / open_marks + the lock helper; asks.build_answer extracted |
a declared pick round-trips through .marks.json and reads back normalized |
| 2 | flag end to end — set_flag, the route, the tile control, the zoom slot |
the operator flags an item in the browser and booth marks shows it |
| 3 | note end to end — write_note, the route, the item + booth fields |
same, for text |
| 4 | pick migrated — declare_pick / answer_pick, the panel, the submit route, import_legacy_asks |
the four live asks import and answer through marks |
| 5 | the CLI — booth marks [--wait], the three aliases, marks import |
a session poses and reads back without touching a sidecar |
| 6 | the seams — index badge, booth header, inject_asks reading marks, template rename |
192 existing tests green, no Jinja re-derives openness |
Slice 2 is the tracer bullet deliberately: flag is the shape with no existing
code, the simplest payload, and the one that closes the loop currently running
through chat. If the storage seam is wrong, flag finds it cheapest.
Tests
| test | asserts |
|---|---|
pick_round_trips_through_marks_json |
declare → read → normalized questions, answer None |
open_marks_is_the_only_openness_predicate |
INV-2, incl. a partial answer counting as open |
build_answer_matches_write_answer_for_every_shape |
INV-4 — single, multi, partial, notes-only, bad-option |
partial_answer_is_recorded_not_refused |
the 2026-09-09 ruling, as a regression test |
nothing_to_record_is_still_refused |
no choice and no notes anywhere → AskError |
redeclaring_a_pick_clears_its_answer |
the question changed; the old judgment is not an answer |
flag_is_an_upsert_and_unflag_removes |
idempotent set; clear deletes the mark, not value: false |
note_allows_several_per_target |
generated ids, both survive |
booth_level_mark_has_target_none |
the booth itself is a legal target |
zoom_carries_the_marks |
INV-3 — flag state + note text in /view?f=… HTML |
marks_json_is_not_an_item |
the dotfile skip already covers it; asserted, not assumed |
corrupt_marks_json_renders_as_empty |
marks_for returns [], the page is a 200 |
broken_pick_declaration_surfaces_error |
error set, question not silently hidden |
concurrent_writes_do_not_lose_a_mark |
INV-6 — two interleaved writers, both marks present |
import_is_idempotent_and_keeps_the_sidecar |
INV-7; second run is a no-op |
import_does_not_clobber_a_newer_answer |
a stem already marked is skipped |
import_preserves_mtime_ordering |
SR-5 — created seeded from the sidecar mtime, so the four live asks keep the order list_asks gave them |
answering_an_unknown_pick_raises |
SR-4 — the AskError path load_ask used to own now lives in answer_pick |
a_partial_answer_counts_as_open |
SR-7 — the declared behaviour change, asserted at the index badge and in open_marks |
as_dict_round_trips_through_json |
SR-8 — the CLI boundary serializes without a hand-rolled dict |
inline_place_indexes_marks_by_id |
SR-1 — the one subscript that a dataclass breaks |
marks_py_is_stdlib_only |
INV-5, over marks.py, asks.py, links.py |
index_badge_counts_open_marks |
marks_open replaces asks_open, one file read |
index_does_not_walk_ask_sidecars |
monkeypatch the importer to raise, load / |