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.
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). |
|
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. 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_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.
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.
declare_pickvalidates throughnormalize_askand raisesAskErrorbefore writing anything. A session cannot land a refused question. The function never returns an invalid mark.- A declaration that is invalid in the stored file — reachable via the
importer, or a hand-edit — is hydrated with
errorset 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_pickre-validates and raises. - A whole file that cannot be parsed is not a broken declaration.
marks_forreturns[]so the page loads; every WRITE refuses withMarksCorrupt.
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 / |