feat(marks): one primitive for operator judgment, so the loop stops running through chat

Five mechanisms existed to get one question next to one artifact. Three of
them were the same thing wearing different clothes, and the third of the three
had no code at all: the operator picked winners out of a 270-image set and
told the session in conversation. `sindra-finalists` is 86 items, every one
captioned, with the selection encoded in the booth's NAME.

A MARK is operator judgment attached to a target — the booth, or one item in
it, addressed by the `rel` U1 established as item identity. Three shapes:

  pick — one of N options a session declared in advance   (was: an ask)
  note — free text the operator volunteered               (had nothing)
  flag — this one                                         (had nothing)

One file per booth, one read path, one place openness is computed, one slot
beside the artifact. The storage shape is the operator's call (2026-09-21) and
follows from U4: "does this booth still owe an answer?" gets asked per booth
per sweep tick and per card per index render, so it has to be one read and not
a walk of a booth holding 270 files. Marks are also not links.md — that is an
O_APPEND content-hash log because 17 handles write it concurrently, whereas a
booth's marks see one session and one operator, so locking the common path
costs nothing.

The 2026-09-09 pick semantics are preserved by NOT rewriting them: partial
answers legal, a blank question lands in `unanswered`, `complete` false until
every question has a pick, the only refusal a submission carrying nothing.
`write_answer` split into the pure `build_answer` plus the storage that went
away with the sidecar; `normalize_ask` untouched.

Three findings worth naming, because each was caught by a gate rather than by
reading the diff again:

  * The seam review found `inline.place` indexes asks by SUBSCRIPT — the only
    consumer in the service that does — so a frozen dataclass breaks it, and
    `inline.py` had been missing from the contract's scope entirely.
  * A retargeted test found a regression in the legacy importer: a malformed
    sidecar that renders "broken" today would have silently vanished on
    migration. It now imports carrying its reason.
  * A partially-answered pick counted as CLOSED on the index while the panel
    beside it rendered it "partial" — the two disagreed about one booth. Open
    is the reading U4 needs, and it is declared rather than smuggled in.

`GET /b/<n>/marks.json` is new and load-bearing: sessions on other hosts polled
`<stem>.answer.json` over HTTP, so removing the sidecar without it would have
taken that capability away. `/b/<n>/asks` 308s to `/marks`. Legacy sidecars are
imported, never deleted — four are live and unanswered.

Also records the operator's deterministic-order directive as a cross-cutting v1
invariant, in ROADMAP.md with the per-collection rule table and as CLAUDE.md
invariant 6. The Booth's job is comparison; an order that moves between renders
does not crash, it misfiles the judgment.

242 tests. No version bump — a release tier for this is the operator's call.
This commit is contained in:
vh
2026-09-21 23:38:27 -07:00
parent 9272c9872e
commit c7f9437a64
23 changed files with 2677 additions and 650 deletions
+37
View File
@@ -22,6 +22,43 @@ Ordering is dependency-driven, not priority-driven: **U1 → U2 → {U3, U4, U5}
U7**, with **U6 independent** of all of them (different storage, different
surface) and therefore the safest thing to land first or in parallel.
### Cross-cutting invariant — deterministic order, everywhere
**Every ordered collection the Booth renders must have a stated, deterministic
order.** Not "usually stable", not "whatever the filesystem yields" — a rule
someone can name, that produces the same sequence on every render of the same
state. The rule itself is free to be anything defensible: byte order over a
path, creation time, an explicit number, even an arbitrary-but-recorded
sequence. What is forbidden is *no rule*.
This matters more here than in most services because the Booth's whole job is
**comparison**. The operator is judging `pancake-v3-full` against
`pancake-v4-full`, tile 47 against tile 47. If the order shifts between two page
loads — or differs between the gallery, the zoom ring, the zip manifest and the
`marks` read — then every positional reference the operator makes ("the third
one from the left", "the one after the banded one") is silently wrong, and a
flag or a note lands on the wrong artifact. Non-determinism does not present as
a bug report; it presents as the operator's judgment being quietly misfiled.
Where it already binds, and what the rule is in each case:
| collection | rule |
|---|---|
| items in a booth | `sorted(rel)` — byte order over the booth-relative path (U1 INV-3) |
| the zoom prev/next ring | the item order, filtered to images — same sequence, one source |
| caption sidecar resolution | sorted scan, so two media files sharing a stem resolve the same way every time (a real non-determinism U1 removed) |
| marks in a booth | `(created, id)` — time, with the id as tie-break so two marks written in the same second cannot swap |
| legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them |
| link board rows | pinned first, then newest-first |
Where it is still to be decided, and must be before the unit ships: **U7's
section ordering and its compare pairing** (sections need a stated order among
themselves, not just within; pairing by filename needs a rule for what happens
to an unpaired file), and **U6's bench listing**.
The test for any new ordered surface: *can you write the rule down in one line?*
If not, it does not have one yet.
### Explicitly NOT in v1
- **Backward compatibility with the `ask` CLI verbs.** Pre-1.0, and `ask` /