fix(marks): a write over a damaged mark file was wiping the booth's judgment

Three defects and a missing test, all surfaced by the cross-frontier contract
panel dispatched before implementation and triaged after it (heid, four arms,
artifact-only, thread 01M33VSNFER4N1554G0Y0VC9C8). v0.2.0 was already tagged and
announced to fifteen handles when they landed, which is the argument for running
the gate at all.

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

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

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

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

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

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

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

253 tests.
This commit is contained in:
vh
2026-09-21 23:54:42 -07:00
parent 54c1e7c60f
commit 5e41108cd3
8 changed files with 359 additions and 15 deletions
+67 -4
View File
@@ -208,10 +208,13 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
`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.
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
@@ -275,6 +278,66 @@ 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.