Pin-first batch for H3: freeze web annotation CRUD response shapes
(create 201 / update 200 / delete 204; full sqlc row models) before
the stack consolidation touches anything.
The new pins immediately caught a live server bug the 28 unit tests
could never see (annotations_test.go is pure-unit: keys and compare
logic, no DB): applyNoteLWW built UpdateMediaNoteForSyncParams without
UserID, so the query's WHERE id=$1 AND user_id=$13 compared against
zero — every dedup-hit note re-save (same content+position) returned
500 "update note: no rows in result set" and the update never landed.
The bookmark twin passes UserID and works; the note stack is the clone
that dropped the field.
Fix: pass UserID in the params (one line). Pins go green and become
the regression net; changelog records the new surfaces.
2026-10-17 lessons from the hardening session: most test/bruno failures
were harness/schema/environment drift (drifted body keys, truncated
payloads, container-only paths), found by triaging the test before the
server. Codified as: triage order (test schema -> environment -> cheap
probe), with the guard against "writing tests to work" — test/contract
edits are asymmetric with code fixes: only harness plumbing fixes,
additive pin widenings with a changelog entry, or deletions of tests of
removed behavior. Pins were green at a known commit; git history
arbitrates stale-vs-changed. A right-looking test that fails is prima
facie a server bug: stop and flag.
contract_pins.md rule 4: narrowing/removing a pinned key requires the
user's explicit sign-off — a pin freezing buggy behavior is a ledger,
not a license.
Batch H1 steps 1-2 of the hardening plan. Wire contracts frozen; the
pinned shapes are unchanged modulo the whitelisted additive GET keys
(docs/contract_pins.md).
1. One canonical request struct. The web PUT binds SaveProgressRequest
directly (identity/attribution fields are json:"-" so they stay
server-owned) and copies its server fields once — the hand-rolled DTO
and the 20-line field-copy block are gone. Parse set for existing
clients is IDENTICAL to the old DTO (verified in web/src: the ebook
reader sends percentage/current_page/total_pages/reading_mode/epubcfi/
context_text/chapter/zoom_level — reader.ts:3113-3143; the audiobook
reader sends percentage/chapter/audio_offset_seconds/elapsed_seconds/
reading_mode — audio.ts:518-520).
2. GET /api/media-items/:id/progress now serializes through one
row-struct-driven builder instead of a hand-map, so a row column
cannot be silently forgotten again (the audio_offset_seconds
omission). Existing keys are verbatim; the row already carried
audio_offset_seconds/reading_mode/viewport_x-y/zoom_level/
scroll_position_x-y/panel_number and they surface as WHITELISTED
additive keys. The web audiobook reader already reads
audio_offset_seconds (audio.ts:219) — with the key absent,
durationless books resumed at chapter start (offset always 0); they
now resume correctly. The conditional context_text bolt-on and the
server-derived css_selector/anchor_href block are unchanged —
restore precedence stays percentage-first, never re-derived.
Batch H0 of the hardening plan: freeze the JSON key SETS of every
load-bearing surface so H1-H7 can only change them additively.
- cmd/server/tests/contract_pins_test.go: 14 tests / ~30 subtests pinning
progress GET+PUT, the KOReader plugin surfaces (progress push/pull,
library, bookmarks), the Kobo adapter shapes (initialization, markup,
sync-from-server), collections detail all four branches + preview,
series JSON, search rows, dashboard sections, media list BOTH branches
(raw sqlc rows vs resolved 38-key map), and SSR smoke for
bookshelf/collections/series/tags.
- docs/contract_pins.md: the pinned-shape inventory + the rule that a
batch widening a shape must widen the pin and this doc in one commit.
Suite is green on the unmodified tree at b4ae423. KOReader/Kobo/progress
pins are the hard freeze (the plugin is an external client).