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.
386 lines
34 KiB
Markdown
386 lines
34 KiB
Markdown
---
|
|
contract_version: "1.0"
|
|
module: "booth.marks"
|
|
purpose: "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)."
|
|
depends_on:
|
|
- "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.)"
|
|
language: "python"
|
|
complexity: "high"
|
|
estimated_loc: 620
|
|
confidence: 0.75
|
|
used_by:
|
|
- "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"
|
|
touches:
|
|
- "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)"
|
|
assumptions:
|
|
- "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."
|
|
open_questions:
|
|
- "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
|
|
|
|
```python
|
|
@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.
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```python
|
|
# ---- 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 `/` |
|