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:
@@ -123,6 +123,7 @@ from booth.asks import ( # noqa: E402
|
||||
)
|
||||
from booth.marks import ( # noqa: E402
|
||||
MARKS_FILE,
|
||||
MarksCorrupt,
|
||||
answer_pick,
|
||||
as_dict,
|
||||
declare_pick,
|
||||
@@ -578,6 +579,25 @@ def create_app(
|
||||
# test needs a handle on the env that the app actually renders with.
|
||||
app.state.templates = templates
|
||||
|
||||
@app.exception_handler(MarksCorrupt)
|
||||
async def _marks_corrupt(request: Request, exc: MarksCorrupt):
|
||||
"""A write was refused because the booth's mark file is damaged.
|
||||
|
||||
409, not 500: the service is fine and the request was well-formed — the
|
||||
state on disk is not, and the refusal is deliberate. Says what to do,
|
||||
because the alternative the operator will otherwise reach for is
|
||||
deleting the file, which is the thing being protected.
|
||||
"""
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"error": "this booth's .marks.json cannot be read, so nothing was written",
|
||||
"detail": str(exc),
|
||||
"why": "writing would replace every mark in the booth with just this one",
|
||||
"fix": "repair or move the file by hand; the marks panel still renders as empty",
|
||||
},
|
||||
)
|
||||
|
||||
ttl_display = int(ttl_hours) if float(ttl_hours).is_integer() else ttl_hours
|
||||
base_ctx = {
|
||||
"ttl_hours": ttl_display,
|
||||
|
||||
+83
-8
@@ -59,6 +59,20 @@ from booth.asks import (
|
||||
valid_stem,
|
||||
)
|
||||
|
||||
class MarksCorrupt(RuntimeError):
|
||||
"""The mark file exists but cannot be parsed, and a WRITE was attempted.
|
||||
|
||||
The read path is deliberately lenient — `marks_for` returns [] so a review
|
||||
page still loads. The write path must not inherit that leniency: reading a
|
||||
damaged file as "no marks" and then atomically replacing it destroys every
|
||||
judgment in the booth from one click, silently. Shipped in v0.2.0 and found
|
||||
by a cross-frontier contract panel, not by the suite.
|
||||
|
||||
A page that renders without an annotation is recoverable. A file that
|
||||
overwrote the operator's judgment is not.
|
||||
"""
|
||||
|
||||
|
||||
MARKS_FILE = ".marks.json"
|
||||
MARKS_LOCK = ".marks.lock"
|
||||
SCHEMA_VERSION = 1
|
||||
@@ -176,6 +190,34 @@ def _fingerprint(entries: list[dict]) -> str:
|
||||
return json.dumps(entries, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
def _read_raw_strict(booth: Path) -> list[dict]:
|
||||
"""Like `_read_raw`, but RAISES `MarksCorrupt` on a file it cannot parse.
|
||||
|
||||
Absent, empty and valid-but-empty are all "no marks yet" and are fine — the
|
||||
distinction that matters is bytes-present-but-unreadable, because that is the
|
||||
case where writing would destroy something.
|
||||
"""
|
||||
path = Path(booth) / MARKS_FILE
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise MarksCorrupt(f"{path} cannot be read: {exc}") from exc
|
||||
if not text.strip():
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise MarksCorrupt(f"{path} is not valid JSON: {exc}") from exc
|
||||
if not isinstance(raw, dict) or not isinstance(raw.get("marks"), list):
|
||||
raise MarksCorrupt(f"{path} is not a marks document")
|
||||
entries = [e for e in raw["marks"] if isinstance(e, dict) and isinstance(e.get("id"), str)]
|
||||
if len(entries) != len(raw["marks"]):
|
||||
raise MarksCorrupt(f"{path} holds entries this version cannot read")
|
||||
return entries
|
||||
|
||||
|
||||
def _write_raw(booth: Path, entries: list[dict]) -> None:
|
||||
"""Atomic replace, so a reader never sees a half-written document and a
|
||||
crash mid-write cannot truncate the file into a shorter — and therefore
|
||||
@@ -215,7 +257,16 @@ class _Locked:
|
||||
self._made_lock = True
|
||||
self._lf = lock.open("r+")
|
||||
fcntl.flock(self._lf, fcntl.LOCK_EX)
|
||||
self.entries = _read_raw(self.booth)
|
||||
try:
|
||||
# STRICT here, lenient in marks_for — see MarksCorrupt.
|
||||
self.entries = _read_raw_strict(self.booth)
|
||||
except MarksCorrupt:
|
||||
fcntl.flock(self._lf, fcntl.LOCK_UN)
|
||||
self._lf.close()
|
||||
self._lf = None
|
||||
if self._made_lock:
|
||||
lock.unlink(missing_ok=True)
|
||||
raise
|
||||
self._before = _fingerprint(self.entries)
|
||||
return self
|
||||
|
||||
@@ -357,16 +408,25 @@ def as_dict(mark: Mark) -> dict:
|
||||
# ---- write ------------------------------------------------------------------
|
||||
|
||||
|
||||
def declare_pick(booth: Path, mark_id: str, doc: dict) -> Mark:
|
||||
"""A session poses a pick.
|
||||
def declare_pick(booth: Path, mark_id: str, doc: dict, target: str | None = None) -> Mark:
|
||||
"""A session poses a pick, about the booth or about ONE item in it.
|
||||
|
||||
Validated through `normalize_ask` BEFORE anything is written, so a session
|
||||
cannot land a question the renderer would refuse. Re-declaring an existing
|
||||
id replaces the declaration and CLEARS its answer: the question changed, so
|
||||
the old judgment is not an answer to it.
|
||||
the old judgment is not an answer to it — and it may move the target, since
|
||||
a re-declaration is a new question.
|
||||
|
||||
`target` is an `Item.rel`, or None for the booth. It exists because the
|
||||
2026-09-09 ruling is that a question belongs WITH the artifact it is about: a
|
||||
four-voice audition wants the radio group under that voice. The record and
|
||||
the renderer both supported it before this parameter did, which meant a
|
||||
session could not actually produce one.
|
||||
"""
|
||||
if not valid_stem(mark_id):
|
||||
raise AskError("bad mark id: letters, digits, . _ - only")
|
||||
if not _valid_target(target):
|
||||
raise AskError("a pick's target must be a path inside the booth")
|
||||
normalize_ask(doc, mark_id) # raises AskError; nothing written yet
|
||||
with _Locked(booth) as lk:
|
||||
existing = lk.find(mark_id)
|
||||
@@ -375,7 +435,7 @@ def declare_pick(booth: Path, mark_id: str, doc: dict) -> Mark:
|
||||
entry = {
|
||||
"id": mark_id,
|
||||
"shape": PICK,
|
||||
"target": existing.get("target") if existing else None,
|
||||
"target": target,
|
||||
"created": existing.get("created") if existing else now_stamp(),
|
||||
"declaration": doc,
|
||||
"answer": None,
|
||||
@@ -551,10 +611,8 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
|
||||
created: list[dict] = []
|
||||
with _Locked(booth) as lk:
|
||||
have = {e.get("id") for e in lk.entries}
|
||||
by_id = {e.get("id"): e for e in lk.entries}
|
||||
for mtime, stem, decl, err in found:
|
||||
if stem in have:
|
||||
continue
|
||||
answer = None
|
||||
ap = booth / f"{stem}{ANSWER_SUFFIX}"
|
||||
try:
|
||||
@@ -563,6 +621,23 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
answer = loaded
|
||||
except (OSError, ValueError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
prior = by_id.get(stem)
|
||||
if prior is not None:
|
||||
# The stem is already a mark, so the DECLARATION is not imported
|
||||
# — that is the idempotence rule, and a mark declared since the
|
||||
# sidecar outranks it. But a legacy ANSWER must not be stranded:
|
||||
# if the existing mark is an unanswered pick and the sidecar
|
||||
# holds the operator's choice, adopt it. Ordinary reads are
|
||||
# forbidden from looking at sidecars, so a skip here would lose
|
||||
# that judgment permanently.
|
||||
if (answer is not None
|
||||
and prior.get("shape") == PICK
|
||||
and prior.get("answer") is None):
|
||||
prior["answer"] = answer
|
||||
created.append(prior)
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"id": stem,
|
||||
"shape": PICK,
|
||||
|
||||
Reference in New Issue
Block a user