ADR-0017 — Post-match review & replay: one renderer, history read off the live loop¶
Status: Implemented (FE Phase 3, 2026-06-15) · Date: 2026-06-15
Built to the frozen Phase 3 contract (§3). Of the two sanctioned read paths below, the single-threaded keyset-paging option was chosen (worker thread deferred):
server/src/history.tspages the existingidx_telemetry_session_tsindex inHISTORY_SCAN_CHUNK-row (default 1000) batches andawait-yields between them — likeretention.ts— using the existing WALdbhandle (no separate read-only connection).GET /sessions/:id/historyinserver/src/server.tscarries the auth posture + DoS controls (span-cap, per-principal rate-limit, inflight cap, audit log,Cache-Control: no-store); the client isclient/src/useHistory.ts+ReviewView.tsxreusing the live homography. The off-loop SLO is verified — not asserted — byft_history_read_secondsplus thetest/history.tsSLO case (≥270k pre-seeded rows; a concurrent aggregate query must not freeze the liveft_ws_messages_sent_totalrate). Thehistory.tsboundary keeps the worker-thread swap local if the SLO ever fails.
Context¶
Workstream (c) adds a review / replay mode. Raw 10 Hz fixes already persist in bun:sqlite under 30-day
retention (ADR-0010); there is no client history API. Everything runs
on one Bun event loop and bun:sqlite is synchronous — server/src/retention.ts
already chunks its deletes to avoid stalling live ingest. The FE panel's adversarial pass confirmed a naive
synchronous history read (~540k rows for a 90-min, 10-player match) would freeze every live tablet mid-
match, and that a history endpoint is a new bulk-export attack surface for children's location.
Decision¶
- One renderer, two modes. Reuse
PitchCanvas+ the homography for both live and review; a mode-aware shell switches the data source (live WS vs history fetch). No second rendering stack. - History reads off the live loop. Serve
GET /sessions/{id}/historyfrom a worker thread / dedicated read-onlyDatabasehandle (WAL already permits concurrent readers without blocking the writer), or, if single-threaded, page it (keyset onserver_ts, bounded chunks, yield between) exactly likepurgeOlderThan— never materialise a whole match with.all(). - Verified as an SLO, not asserted. Add
ft_history_read_secondsand an e2e assertion that a review query issued while the simulator ramps to 50 players does not introduce a gap in the liveft_ws_*rate. - Auth + minimisation. The endpoint reuses
/live's auth posture (principal + Origin, ADR-0015), is session-scoped (ADR-0010), and never lives on a public unauthenticated interface. Prefer a session-aggregate / down-sampled store for replay where the coaching question allows (smaller result sets; aligns with ADR-0010 minimisation). - Reuse record/replay for tests. The simulator's
--record/--replay(server/test/simulate.ts) drives deterministic review-mode tests.
Consequences¶
- + Review mode without a second renderer; the live feed stays smooth during a review query.
- + The history endpoint can't become an unauthenticated bulk location export.
- − A worker-thread (or careful paging) read path is more work than a naive
SELECT. - − Adds a server history endpoint + a new SLO/test.
Alternatives considered¶
- Naive synchronous
SELECT … .all()— rejected: blocks the shared event loop, freezes every live tablet. - A separate review renderer — rejected: duplicates the homography/canvas; more code, drift risk.
- Replay only from the raw 10 Hz trace — kept secondary; default to per-player aggregates + position heatmap (smaller, age-appropriate, ADR-0010-aligned), raw replay on demand.