Landing a cross-repo change
The server defines the HTTP/JSON contract; the frontend - and the manager's
internal/serverapi - mirror it by hand. There is no codegen, so a wire
change is never a one-repo change: both repos' CI can be green while the seam is
broken. This page is the procedure for landing such a change safely. The full map
of every seam is the cross-repo contract
(normatively: the workspace CROSS-REPO.md).
First: does your change cross the seam?
Decide which repo(s) a task belongs to before starting:
- Won't play, wrong
Content-Type, scope leak, scanner behavior → server. - Looks wrong, navigation, timeline math, offline → frontend.
- Pairing, media auth, a new wire field, transcode, capabilities, path semantics → both - read the contract first.
- If the JSON shape you're changing is one the manager reads (pairing,
GET /server, libraries,fs, books/search, item, progress, scan, enrichment) → the manager too (internal/serverapihand-mirrors those shapes under the same rule).
The wire-change checklist
Do these together, in order. Same rule everywhere: a field rename is a multi-repo edit.
- Server - handler. Add or modify the handler in
internal/api/handlers_*.go. Keep the handler transport-only: business logic goes inauth/catalog/library/media. - Server - route wiring. Register the route in
internal/api/api.gowith the right middleware (requireAuth/requireAdmin/requireMediaAuth). - Server - test. Add a Go test (
internal/api/*_test.go, using thenewTestEnvharness) asserting the emitted shape. Security-critical paths need an allowed and a denied case. - Frontend - types. Mirror the shape in
src/api/types.ts. Mirror every field, including ones the client doesn't read yet - that's the no-codegen convention's safety net. - Frontend - client. Add/extend the method in
src/api/client.ts(unwrapping the envelope: lists are wrapped like{ books },{ history }; errors are{ error }thrown asApiError). - Frontend - hook. Expose a React Query hook in
src/api/hooks.ts(query key + invalidation). - Frontend - screen. Consume the hook in a screen/component under
src/app/**orsrc/components/**- keep the logic in the testable modules, not the screen. - Frontend - test. Add a
src/api/client.test.tscase (and unit tests for any new pure logic). - Manager (when applicable). Mirror the shape in
internal/serverapiand test it there too. - Update
CROSS-REPO.md(workspace root) if the seam's behavior changed - it is the normative contract and is updated first. - Update the docs - this site's API and seam pages follow the contract; see writing the docs.
- Run every touched repo's full gate (gates and CI), then ship.
Worked example: listening history
Listening history (a frontend milestone feature that drove a server change) is the canonical shape of a cross-repo change. What actually landed, file by file:
Server
-
Routes in
internal/api/api.go:mux.Handle("GET /api/v1/me/history", a.requireAuth(http.HandlerFunc(a.handleListAllHistory)))mux.Handle("GET /api/v1/libraries/{id}/history", a.requireAuth(http.HandlerFunc(a.handleListHistory)))mux.Handle("POST /api/v1/libraries/{id}/history", a.requireAuth(http.HandlerFunc(a.handleAddHistory))) -
Handlers
handleListAllHistory/handleListHistory/handleAddHistoryininternal/api/handlers_me.go- thin transport over the data layer. -
Data layer in
internal/catalog/listening.go(AddHistory,ListHistory,ListAllHistory), backed by the durable, path-keyedlistening_historytable ((user_id, library_id, rel_path)- no FK to the rebuildable book index, per the invariants). The cross-library listing is scope-filtered so users only see history for paths they can access. -
Tests in
internal/api/handlers_me_test.go.
Frontend
Historytype insrc/api/types.ts- the{ history }envelope mirrored field-for-field.history()andaddHistory()methods insrc/api/client.ts, unwrapping{ history: History[] | null }and tolerating anullarray.useHistoryhook insrc/api/hooks.ts(query keyqk.history(lib, path)).- UI in
src/components/library/history-section.tsx, consumed from the book detail screen and the player view. - The client's envelope/unwrap conventions are covered in
src/api/client.test.ts.
That is the shape of essentially every cross-repo change: server endpoint + data layer + Go test, then type + client method + hook + screen + test, each side gated by its own CI.
The capability-flag pattern (new features)
Any shipped app build must be able to talk to any server version, so features
are negotiated, never assumed. GET /api/v1/server advertises capability flags -
admin_ui, web_player, upload, transcode, websocket - plus the server
version.
Adding a feature that isn't universally available is a two-repo pattern:
- Server: add (or flip) the capability flag when the feature lands, and make
it reflect reality - e.g.
transcodereflects whether ffmpeg is configured,web_playerreflects whetherweb_diris populated. - Frontend: mirror the flag in the
ServerInfotype and gate the new UI on it. Never assume a capability is present; a client may be talking to an older server or one with the feature disabled - degrade gracefully.
Pitfalls - the recurring failure modes
These come straight from the workspace CODE-HEALTH.md (distilled from a full
health review, where each was a pattern, not a one-off):
- Wire-contract drift. The server emits a field the frontend's
types.tsomits or mistypes (this happened withdirect_playable,codec,web_player), or the frontend carries a phantom field the server never sends (a removedlayoutknob lingered for months). Root cause: hand-mirroring with no parity test - both repos' gates pass independently. Countermeasure: mirror every field, test the emitted shape on both sides, in the same logical change. - Dead code left behind. A superseded hook or client method survives because TypeScript doesn't error on unused exports and Go only catches unused unexported symbols. When your change replaces something, delete the old thing in the same change - search for it first.
- Stale docs. Nothing checks a prose claim against the code it describes.
Grep the symbol/flag/route you changed across
*.md(workspace docs, repo CLAUDE.mds, and this site) and fix what you contradicted, in the same commit.
Branch and PR conventions
- Branch in the right repo. All repos push to GitHub under
KodeStar/…. The server works onmain; the frontend often carries in-flight feature branches - checkgit branchbefore assumingmain. - One PR per repo, and mention the pair. A cross-repo change ships as one PR in each affected repo; cross-reference them in the PR descriptions so a reviewer (and future archaeologist) can find the other half.
- Run each touched repo's gate - the CIs are independent; there is no workspace-level CI that checks the seam for you.
- Releases pin the pair. The deployable server image bakes in a pinned web build, so a wire change reaches users as a known-compatible pair - but only if you release in the right order. See releasing and the release pipeline.