Files
booth/docs/contracts/u2_marks.contract.md
vh 5e41108cd3 fix(marks): a write over a damaged mark file was wiping the booth's judgment
Three defects and a missing test, all surfaced by the cross-frontier contract
panel dispatched before implementation and triaged after it (heid, four arms,
artifact-only, thread 01M33VSNFER4N1554G0Y0VC9C8). v0.2.0 was already tagged and
announced to fifteen handles when they landed, which is the argument for running
the gate at all.

DATA LOSS. `marks_for` is deliberately lenient — an unparseable `.marks.json`
reads as "no marks" so a review page still loads. The write path inherited that
leniency through the same reader, so one flag click appended a single entry to an
empty list and atomically replaced the file: every mark in the booth gone,
silently, from a click. Reproduced first, then fixed.

The fix is an asymmetry, not a retreat from leniency. Reads stay lenient; writes
go strict through `_read_raw_strict`, which distinguishes bytes-present-but-
unreadable from absent and valid-but-empty, and raises `MarksCorrupt`. The
damaged bytes are left on disk. Routes answer 409 rather than 500 — the service
is fine and the request was well-formed, the state on disk is not — and the body
says what to do, because the alternative the operator reaches for otherwise is
deleting the file, which is the thing being protected. The CLI says it in one
line instead of a traceback.

A PICK COULD NOT TARGET AN ITEM. `Mark.target` carried one, `marks_for_target`
retrieved by it, and the panel already rendered "on <item>" — but `declare_pick`
had no parameter for it, so no session could produce one. A question about one
artifact is the whole point of the 2026-09-09 inline-placement ruling; the door
was simply missing.

THE IMPORTER STRANDED AN ANSWER. A stem already present as a mark was skipped
wholesale. If a session had re-declared that stem through marks while the
operator's choice sat in the legacy sidecar, that choice was lost permanently —
reads are forbidden from looking at sidecars. The declaration is still skipped
(idempotence holds) but a legacy answer is now adopted when the existing mark is
an unanswered pick, and an answer made through marks is never overwritten.

INV-3 NAMED A SURFACE NOTHING TESTED. All four arms converged on it: the rule
protects gallery tile, zoom view and doc view; the falsifiable check covered one.
The doc view was implemented and untested, so shipping it unmarked would have
passed. Three tests now, one per surface.

The contract carries the full triage, including two findings accepted and NOT
closed: INV-2's and INV-5's checks comply in letter — openness can be re-derived
without spelling the grepped pattern, and importlib inside a function defeats the
AST walk. Both describe a future careless change, and the honest statement is
that these checks raise the cost of drifting rather than making it impossible.
Recorded rather than papered over.

Also pins the three prose ambiguities the panel found, normatively and once each:
what counts as open, the three distinct broken-declaration cases, and INV-6,
which had named a helper that does not exist and forbidden the calls that helper
must make.

253 tests.
2026-09-21 23:54:42 -07:00

34 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).
booth.asks (normalize_ask, build_answer, AskError, is_ask_file, is_answer_file -- the pick DECLARATION validator and the ANSWER builder. Their semantics are operator-settled 2026-09-09 and are preserved by NOT rewriting them; this unit extracts the pure answer-building out of write_answer's I/O and otherwise leaves the validator alone.)
booth.items (Item.rel -- a mark's target for an item-scoped mark IS the rel U1 established as item identity; no second addressing scheme)
booth.inline (place, form_id -- the verbatim-booth placement engine. `place` indexes its input by SUBSCRIPT (`{a["stem"]: a}`), which is why it is in `touches`: a frozen dataclass raises TypeError there. See the seam-review note SR-1.)
python high 620 0.75
booth.app.list_booths (the index card's open-mark count -- was `asks_open`, one read of one file instead of a per-booth walk of ask sidecars)
booth.app.booth_view (the marks panel + per-item marks on the gallery)
booth.app.booth_view_file (the zoom view's marks slot -- an item's notes and flag state at the size where the judgment is actually made)
booth.app.booth_answer -> booth_mark_pick (the operator's pick submit)
booth.app.booth_mark_note / booth_mark_flag (NEW routes -- note and flag have no write path today)
booth.app.inject_asks (verbatim-booth injection: reads marks instead of list_asks; DELETED at U3, not here)
scripts/booth (`marks` verb + `ask` / `asks` / `answer` as thin aliases over it; `marks import` for the legacy sidecars)
booth.app.list_booths open-mark count is ALSO U4's pin input -- U4 reads open_marks, it does not re-derive openness
booth/marks.py (new -- the record, the storage, the read path)
booth/asks.py (write_answer split: the pure `build_answer(ask, choice, notes, who, qnotes) -> dict` keeps every line of the 2026-09-09 semantics, the os.replace half goes away with the sidecar. `write_ask` / `load_ask` / `read_answer` / `list_asks` DELETED -- sidecar storage, replaced. `normalize_ask` and its helpers UNTOUCHED.)
booth/inline.py (ONE line: `place`'s `by_stem = {a["stem"]: a for a in asks}` becomes `{m.id: m for m in marks}`. Missed in the first draft of this contract and caught by the seam review -- `place` is the only consumer that indexes an ask by subscript rather than attribute, so it is the only one a dataclass breaks. The module is otherwise untouched and is DELETED at U3.)
booth/app.py (imports; list_booths open-mark count; booth_view passes marks; booth_view_file gains the marks slot; booth_answer becomes the pick route over marks; TWO new routes for note and flag; inject_asks reads marks)
booth/templates/_marks.html (new -- replaces _asks.html; the panel, all three shapes)
booth/templates/_ask_inline.html (the injected pick fragment: reads a Mark, not an ask dict. Kept because U3 deletes it, not this unit.)
booth/templates/booth.html (the panel include; per-item flag + note controls on each tile)
booth/templates/view.html (the zoom view's marks slot -- notes and flag state for THIS item)
booth/templates/index.html (the open-mark badge: `marks_open` replaces `asks_open`)
booth/templates/asks.html -> marks.html (the standalone page; DELETED at U3, renamed here so nothing dangles)
booth/templates/base.html (the .mark-* CSS; the .ask-* block is renamed, not extended)
scripts/booth (the `marks` verb, the three aliases, `marks import`; usage line; the header doc block)
tests/test_marks.py (new)
tests/test_asks.py (the sidecar-storage tests retarget to marks storage; the NORMALIZER and ANSWER-BUILDER tests keep asserting the same semantics against the same functions)
tests/test_booth.py (asks_open -> marks_open on the index; the gallery-hides-sidecars assertions)
ONE FILE HOLDS BOTH THE DECLARATION AND THE JUDGMENT, and this is a derived consequence of the operator's 2026-09-21 storage decision rather than a fresh choice. A pick's OPTIONS are declared by the session; the CHOICE is made by the operator. If the declaration lived in its own file, `is anything open?` would again be a directory walk -- the exact cost the per-booth decision was made to avoid, since U4 asks it per booth per sweep tick and the index asks it per card per page load. So a declared pick with no answer IS the open mark, and one read of `.marks.json` answers the question for the whole booth.
THE 2026-09-09 PICK SEMANTICS ARE PRESERVED BY NOT REWRITING THEM. Partial answers legal; a blank question lands in `unanswered` and is absent from `answers` unless it carried a note; `complete` is false until every question has a pick; the ONLY refusal is a submission with no choice anywhere AND no notes; an offered-but-invalid option is still an error (a broken form, not a skipped question); single and multi shapes both normalize to a `questions` list. This unit MOVES that code; it does not improve it. Any behaviour change inside the answer-shaping logic is a separate unit.
THE `build_answer` EXTRACTION IS THREE EDITS, NOT ONE -- corrected by the seam review (SR-3), which is the whole reason that gate exists. The first draft said `write_answer` minus its last four lines. It is also minus its FIRST line (`ask = load_ask(booth, stem)`), plus an `ask: dict` parameter in place of `(booth, stem)`, and `stem` sourced from `ask["stem"]` -- which `normalize_ask` does emit, so no new plumbing. Everything between those edits is byte-identical. The consequence that is easy to miss: `load_ask` was the thing that raised `AskError` for a missing or invalid ask, so THAT ERROR PATH MOVES TO THE CALLER. `answer_pick` must raise `AskError` when `mark_id` names no live pick, or a stale form POST becomes a silent no-op instead of a 400.
MARKS ARE SINGLE-WRITER-ROLE, NOT SINGLE-PROCESS. The operator's browser writes judgments; a session writes pick declarations. That is two roles on one file, so the fcntl read-modify-write lock is load-bearing and not ceremony -- but it is NOT `links.md`'s problem. links.md is an O_APPEND content-hash log because 17 handles write it concurrently and a lock on the common path would serialize them; a booth's marks see one session and one operator, so locking the common path costs nothing. Reuse the flock pattern from `links.remove_link_entry`; do NOT reuse the append-log shape.
STDLIB ONLY. `booth/marks.py` is imported by `scripts/booth` under the system python3 with no venv, exactly like `links.py` and `asks.py`. json, os, fcntl, re, dataclasses, datetime, pathlib. This is the invariant graphify cannot see (the CLI imports through a `python3 -c` heredoc, invisible to AST extraction) and therefore the one most likely to be broken by a later change that looks safe.
A FLAG IS AN UPSERT KEYED BY TARGET; A NOTE IS NOT. One item has at most one flag state, so setting a flag replaces it and clearing it removes the mark rather than storing `false` (an absent flag and a false flag are the same judgment, and storing both makes two representations of one state). An item may carry several notes, so each gets a generated id. A pick's id is the session-supplied stem, validated by the existing `valid_stem` -- which keeps today's `#ask-<stem>` anchors, the inline placeholder specs and the CLI's argument shape working unchanged.
THE LEGACY SIDECARS ARE IMPORTED, NEVER DELETED. Four `*.ask.json` files are live and unanswered right now (dfa-concepts, sc-iso-spread, sindra-voice-1, run07-decisions; zero `*.answer.json`). `marks import <booth>` is an explicit, idempotent one-shot that reads them into `.marks.json` and LEAVES THEM ON DISK -- per the ROADMAP's `a migration that deletes anything` non-goal. The read path does NOT know about legacy files: a read that writes would fire on every index page load, which is the wrong trade for four files. `booth_items` keeps excluding `*.ask.json` / `*.answer.json` from the item list so an imported-but-not-deleted sidecar does not appear as a tile.
NOTE AND FLAG SHIP WITH CONTROLS, NOT WITH KEYBOARD. U2 gives every item a flag toggle and a note field, and the booth itself a note field. The grid keyboard (`f` flags, `n` opens a note, arrows move, Enter zooms) is U7's navigation work and is NOT in this unit -- U2 makes the judgment writable, U7 makes it fast at 270 items. Stated because `flag` is the capability that makes a 270-image booth tractable and it is tempting to pull U7's keyboard forward with it.
U3's DELETIONS DO NOT HAPPEN HERE. `inline.py`, `wrap_verbatim_html` and its six regexes, both floating chips and the standalone page all survive this unit, reading marks instead of asks. U2 changes the primitive underneath them; U3 deletes the mechanism. Doing both at once would mean a rewrite whose failures cannot be attributed to either change.
Whether a `note` on the booth (target None) should also render on the index card, or only inside the booth. Deferred to U5, which is the unit that redesigns the index card and can weigh it against `.booth.json` provenance.
Whether `flag` needs a negative state (`not this one`) distinct from absent. The IA names the shape `this one / not this one`, but every live use is positive selection (golden-candidates, sindra-finalists, the pancake ladders). Shipping positive-only, with the storage shape able to carry a value later; revisit if the operator asks for a reject pass.

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.json except import_legacy_asks. Falsifiable: ASK_SUFFIX and ANSWER_SUFFIX appear in marks.py only inside the importer; in app.py only as the re-export; and in items.py only as the tile exclusion, which imports is_ask_file from booth.asks directly rather than through app (SR-9 — the first draft's wording said "nowhere in app.py" and would have read items.py as a violation).
  • INV-2 — one place openness is computed. The predicate shape == "pick" and not answered-completely exists exactly once, in open_marks. Falsifiable: grep -c "selectattr('answer'" booth/templates/ is 0 and grep -c 'answer.*is None' booth/app.py is 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:750 and app.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. Falsifiable, once per surface — the first draft named three surfaces and checked one, which all four panel arms flagged as the document's strongest ambiguity: (a) the gallery tile shows the flag control in its current state and the item's notes; (b) the zoom view /b/<n>/view?f=<img> carries the flag state and the note text; (c) the doc view /b/<n>/view?f=<doc> carries the note text. Three tests, not one.
  • INV-4 — the pick semantics are byte-identical. build_answer produces, for every input, the document write_answer produced. Falsifiable: the existing test_asks.py answer assertions pass against build_answer with their expectations unchanged.
  • INV-5 — stdlib only. booth/marks.py imports nothing outside the standard library. Falsifiable: a test that walks marks.py's AST imports and asserts every root module is in sys.stdlib_module_names — the same guard is added for asks.py and links.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.lock across read, mutate and atomic replace. Falsifiable: no write function calls _read_unlocked or os.replace directly.
  • INV-7 — nothing deletes a legacy sidecar. Falsifiable: after import_legacy_asks, the *.ask.json file 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.

Cross-frontier contract panel — 2026-09-22, four arms, artifact-only

/heid-contract-review panel (Gróa / Hulda / Regin / Kimi), thread 01M33VSNFER4N1554G0Y0VC9C8, dispatched before implementation and triaged after it. Every quoted passage was verified verbatim by Heid; no arm fabricated an identifier. Triaged per the five-category rule — what follows is the disposition, not the reply.

Three of these were defects in shipped code, not ambiguities in prose. v0.2.0 was already tagged and announced to 15 handles when they landed.

finding arms category disposition
A write over a corrupt .marks.json silently replaced every mark in the booth. The read path is deliberately lenient (unparseable → [] so the page loads); the write path inherited that through the same reader, so one flag click appended to an empty list and atomically replaced the file. Kimi F2, Hulda F3 1 — genuine add FIXED. MarksCorrupt, raised by a strict _read_raw_strict used only by the write path. Read stays lenient, write goes strict; the damaged bytes are left on disk. Routes return 409, not 500. Reproduced first, then fixed.
declare_pick had no target, so a pick could not be attached to an item — though Mark.target carried one, marks_for_target retrieved it, and _marks.html already rendered "on <item>". Hulda F1, Regin 1 — genuine add FIXED. declare_pick(..., target=None), validated like every other target. A re-declaration may move it.
The importer stranded a legacy answer. A stem already present as a mark was skipped wholesale, so a re-declared-but-unanswered pick with the operator's choice sitting in <stem>.answer.json lost that choice permanently — reads are forbidden from looking at sidecars. Gróa F10 1 — genuine add FIXED. The declaration is still skipped (idempotence), but a legacy answer is ADOPTED when the existing mark is an unanswered pick. An answer made through marks is never overwritten.
INV-3 names "doc view" as a protected surface; nothing tested it. Shipping the doc view unmarked would have passed. 4/4 — the panel's strongest convergence 1 — genuine add TEST ADDED. The behaviour was already implemented; the gate caught that nothing held it. INV-3's falsifiable below now covers all three surfaces.
The broken-declaration path is three different doors and none is written: validate-before-write, stored-raw-with-read-time-error, and unparseable-file-yields-[]. 4/4 1 — genuine add, prose only PINNED below. All three are real and distinct cases; the code always handled them separately. The contract conflated them.
"What counts as open" is defined three ways across assumptions, the signature comment and a test row. Gróa F1, Regin F5, Hulda F4 1 — genuine add, prose only PINNED below. Code and tests were already correct (partial = open).
INV-2 and INV-5's checks comply in letter: openness can be re-derived as (answer or {}).get("complete") with the grep still green; importlib inside a function defeats the AST walk. Gróa F5/F7, Kimi F4/F5 4 — out of place Accepted as true and NOT closed. Both describe a future careless change, and the honest statement is that these checks raise the cost of drifting rather than making it impossible. Recorded rather than papered over.
INV-6 named _with_marks(booth); the code has _Locked. And its falsifiable makes the mandated helper unimplementable, since the helper must itself call os.replace. Gróa F8, Kimi F8 2 — sharpening FIXED below — the name and the exemption.
set_flag's annotation forbids a booth-level flag; never stated as a decision. Regin F6 2 — sharpening It IS a decision: a flag means this one, so it needs an item. Stated in the signature.
"Cleaning" note text is defined by example only. Kimi F6, Hulda F6 2 — sharpening _clean_text is CRLF-normalize, strip, truncate at TEXT_MAX. Documented at the function.
INV-1 self-conflict: the rule allows one function, the check and assumptions exempt booth_items' name check. Gróa F6 3 — settled prior Already resolved by the seam review (SR-9): the exemption is a NAME check, never a content read.

The methodology note the arms volunteered, which is worth more than any single flag: this contract's own frontmatter carries a plain-language narrative, so the paraphrase half was partly re-reading the author's framing back to him. Regin and Kimi both said the stronger shape for a narrative-heavy contract is the ambiguity pass with the paraphrase cut to a drift-check. That is a finding about the mechanism, not this document, and it belongs in the skill rather than here.

The three pinnings

Openness, normatively, once. A mark is open when shape == "pick" and it has no error and (answer is None or answer["complete"] is false). A partially answered pick is OPEN. Every other sentence in this document about openness is descriptive; this one governs, and open_marks is its only implementation.

A broken declaration, normatively — three distinct cases, not one.

  1. declare_pick validates through normalize_ask and raises AskError before writing anything. A session cannot land a refused question. The function never returns an invalid mark.
  2. A declaration that is invalid in the stored file — reachable via the importer, or a hand-edit — is hydrated with error set and is rendered, so a question the session believes it posted is never silently hidden. It is not open (it can never be answered), and it cannot be answered: answer_pick re-validates and raises.
  3. A whole file that cannot be parsed is not a broken declaration. marks_for returns [] so the page loads; every WRITE refuses with MarksCorrupt.

INV-6, corrected. Every writer goes through the one _Locked(booth) context manager, which holds an exclusive flock on <booth>/.marks.lock across read, mutate and atomic replace. Falsifiable: no function outside _Locked calls _write_raw or os.replace on the mark file. (The first draft named a _with_marks helper that does not exist, and forbade the very calls the helper must make.)

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 /