• v0.2.1 5e41108cd3

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

    vh released this 2026-09-21 23:54:42 -07:00 | 128 commits to main since this release

    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 " — 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.

    Downloads