Files
booth/docs/contracts/u2_marks.contract.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

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).
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. 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_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.

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 /