Files
bookhoard/docs/contract_pins.md
T
John O'Keefe 28bd323505 refactor(progress): SaveProgressRequest becomes the canonical PUT payload; GET serializes from the row
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.
2026-10-16 17:22:32 -04:00

5.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).

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.