Files
bookhoard/docs/contract_pins.md
T
John O'Keefe ff029f1ad5 fix(annotations): note LWW re-save 500'd — UserID missing from the sync update params
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 15:38:47 -04:00

6.1 KiB

Wire-contract pins (hardening batches)

These are the response SHAPES (JSON key sets, not values) frozen by the TestContractPin_* suite in cmd/server/tests/contract_pins_test.go (batch H0). The plan they serve is ~/.opencode/plan/hardening-canonical-types.md.

Rules

  1. Frozen: no existing response key may be RENAMED or REMOVED on any endpoint below (JSON or KOReader/Kobo adapters). Requests are frozen too — no parsing changes.
  2. Additive keys are allowed and expected — but a batch must widen the allowed slice in the test AND record the addition in the changelog below, in the same commit.
  3. KOReader / Kobo / progress surfaces are the hard freeze: the KOReader plugin is an external client whose handlers keep response shapes byte-identical (only their internals may route through shared funnels).
  4. A behavior fix that would require narrowing or REMOVING a pinned key needs the user's explicit sign-off before the pin is touched — a pin freezing buggy behavior is a known-bugs ledger, not a license.

Pinned surfaces

Test Surface Shape
ProgressWeb GET /api/media-items/:id/progress (percentage-only write) flat map: id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, chapter, chapter_progress, format_group, total_characters, chapter_count, last_sync_device, last_sync_source, last_sync_timestamp
… conditional bolt-ons context_text (when stored valid); css_selector, anchor_href, char_offset, rewritten epubcfi (when the anchor verify path fires)
PUT /api/media-items/:id/progress response database.ReadingProgress JSON (25 keys incl. audio_offset_seconds, viewport/zoom/scroll/panel/reading_mode)
GET with no rows exactly {current_page, total_pages}
KOReaderProgressPush POST /api/sync/koreader/progress (immediate, 202) sync_status, books_synced, book_results[]?{sha256, book_uuid, synced}, conflicts[]?{book_uuid, conflict_type, device_progress, server_progress, resolution}, timestamp, device_updated
KOReaderProgressPull GET /api/sync/koreader/metadata/:uuid uuid, sha256?, title, authors, progress{percentage + optional character/epubcfi/koreader_xpointer/chapter/chapter_progress/page/total_pages}, annotations{highlights?, notes?, bookmarks?, deleted_highlights?, deleted_bookmarks?}, last_sync
KOReaderLibrary GET /api/sync/koreader/library library_sync[]{uuid, sha256?, title, author, content_type, percent_read, pages_remaining?, bookmark_count, last_modified}, total_books, last_sync
KOReaderBookmarksPush POST /api/sync/koreader/bookmarks sync_status, bookmarks_synced, notes_synced, highlights_synced, total_synced, timestamp
KoboInitialization GET /api/sync/kobo/:token/v1/initialization library_sync[]{ContentId, ContentType, Title, Author, PercentRead, PagesRemaining?, BookmarkCount, LastModified, EntitlementId, Revision, MimeType, FileSize, Categories?, BookhoardUUID?}, total_books, last_sync
KoboMarkupSync POST /api/sync/kobo/:token/markup Status, MarkupsSynced, BookmarksSynced, DeletedAnnotations?
KoboSyncFromServer POST /api/sync/kobo/:token/sync-from-server BooksSynced, BookmarksSent, HighlightsSent
CollectionsJSON GET /api/collections/:id (all 4 branches) envelope id, user_id, name, description, color, icon, auto_assign_rules, view_settings, books[], book_count, is_smart, created_at; books = BookInfo
POST /api/collections/preview bare {items: BookInfo[]}
SeriesJSON GET /api/series/books?name=&library_id= {name, library_id, books: BookInfo[], total}
SearchJSON GET /api/media-items/search?q=&library_id= top-level array of SearchMediaItemsUnifiedRow (47 keys)
DashboardSections GET /api/dashboard/sections {sections[]: {id, collection_id, is_system, title, description, icon, items: BookInfo[], view_all_url, priority}}
MediaListBothBranches GET /api/media-items (unscoped) {data: [38-key resolved map]} (intentionally divergent from the scoped branch until the row-shape collapse batch)
GET /api/media-items?library_id=&sort= {data: [raw ListMediaItemsSortedRow, 70 sqlc keys]}
SSRPages /bookshelf, /collections, /collections/:id, /series, /series/detail, /tags/detail 200 + stable title/substring markers

BookInfo (every card surface): media_item_id, title, author, cover_image_path, has_conflict, format_group, badge.

Changelog (additive keys per batch)

  • H0 (initial pin) — the shapes above, captured on the unmodified tree at commit b4ae423.
  • H1 (progress canonicalization) — GET /api/media-items/:id/progress Whitelisted ADDITIVE keys (fields the GetUniversalProgressRow already carries, now surfaced by the shared row serializer): audio_offset_seconds, reading_mode, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number. No key removed or renamed; the no-rows and PUT-response shapes are untouched. The web PUT binds SaveProgressRequest directly — its parse set for existing clients is IDENTICAL to the old DTO (verified against web/src reader.ts:3113-3143 and audio.ts:518-520); keys the web never sent (viewport_x/y, panel_number) merely become honor-able. All other pins unchanged.
  • H3-prep (web annotation CRUD freeze) — NEW pinned surfaces, captured pre-refactor per the pin-first doctrine: POST/PUT/DELETE /api/media-items/:id/{notes,highlights,bookmarks} — create 201 / update 200 / delete 204, response bodies are the full sqlc row models (MediaNotes 19 keys, MediaBookmarks 20 keys, MediaHighlights 27 keys — listed in contract_pins_test.go). No keys removed; nothing was pinned here before. Capturing these immediately caught a LIVE server bug: applyNoteLWW omitted UserID from its UpdateMediaNoteForSync params, so every dedup-hit note re-save (same content+position ⇒ same key) returned 500 ("update note: no rows in result set") — the LWW update could never land. Fixed in the same commit (server-side fix, freely allowed); the pins are its regression net.