Phase 4 — coaching polish · FROZEN BUILD CONTRACT¶
Status: FROZEN 2026-06-15 (pre-mortem folds into §9). Drivers: security #1 → performance #2 → cost #3. Implements ADR-0019 within the youth-football domain spec metric-definitions.md. The last phase of improvement-plan.md. Source of truth the parallel build agents code against.
§0 — Scope + standing invariants¶
In scope (GPS-derivable coaching, per the plan): age-banded speed-zone colouring + distance/min live; an isolated-player cue live; post-match aggregates server-side (review-only): per-zone distance breakdown, sprint efforts (count/distance/max), accel/decel effort counts. Per-session age band via a server config store (ADR-0019). Explicitly DEFERRED (not Phase 4): IMU PlayerLoad (§5.1, no hardware), metabolic power / HMLD (§5.2), sRPE (§5.3), ACWR / monotony (§6, cross-session), individualized MSS/MAS thresholds (§3.3). Do not build these.
Inherited invariants (unchanged from Phase 1–3, NON-NEGOTIABLE):
1. No child name in the telemetry DB, any history row, any Prometheus label/HELP, any log line, or any
client persistence. The age band + thresholds are NOT names/location → not sensitive, but their config
endpoint still uses the session-scoped authz posture for uniformity.
2. Load metrics stay OFF the live path. Sprint/accel/decel counts are computed server-side, review-only
(§3/plan). Live carries only: zone colour, distance + distance/min, the isolated cue. (Distance/min is a
volume metric, allowed live; it is a best-effort live glance — see §3.)
3. One Bun event loop. The aggregate extension folds into the EXISTING paged history scan (§3.1 Phase-3) —
no new scan, no new synchronous hold; the per-player effort state machines are O(1) per row.
4. Reuse, don't drift. Reuse the roster store pattern for the config store; the Phase-3 GET authz gate
(sessionGetGate) for the config + (extended) history routes; the homography/rAF/useRef-Map render model.
5. Firmware is owned elsewhere — do NOT touch firmware/src/main.cpp. No firmware change is needed.
§1 — Domain constants (from metric-definitions.md — single source of truth, do NOT invent)¶
- Bands:
U12 | U14 | U16 | U19. Default when a session is unconfigured: U14 (documented). - Zones (m/s), zones 1–3 fixed, HSR+Sprint per band (§3.1/§3.2):
- Z1 Walking
[0, 2.0), Z2 Jogging[2.0, 4.0), Z3 Running[4.0, HSR), Z4 HSR[HSR, Sprint), Z5 Sprint[Sprint, ∞). Half-open intervals: a speed EXACTLY at a threshold lands in the HIGHER zone. Implement as a descending>=cascade (v≥sprint→5; v≥hsr→4; v≥run→3; v≥jog→2; else 1) — clientspeedZoneand the server zone breakdown MUST use this identical cascade so live colour and review breakdown never disagree at a boundary. - HSR/Sprint thresholds (m/s): U12
4.44 / 5.28, U144.86 / 5.83, U165.28 / 6.39, U195.50 / 6.94. - Distance gating (§2.1): accumulate a per-segment distance ONLY when
v ≥ 0.4 m/s(walking floor) ANDfix ≥ 2ANDpdop ≤ 5; else the segment contributes 0 (GNSS jitter otherwise manufactures phantom distance). This gate applies to the basedistanceM, everyzoneDistanceMbin, ANDsprintDistanceM— all from the SAME filtered base. Server reality: every STORED row already hasfix ≥ 2(ingest dropsfix<2before persist), so the server gate reduces tov≥0.4 AND pdop≤5— butpdopis NOT inFixRow/the read SELECT today and MUST be added (see §4.1). Client:Telemetryalready carriesfix+pdop, so the live accumulator applies all three — the live and server gates are therefore EQUIVALENT (a hard requirement: the two distances must not disagree). - Sprint = an effort (§3.4): count one when
v≥ the band's Sprint threshold for ≥ 1.0 s (≥10 samples), and efforts are separated by a ≥ 1.0 s dip below threshold (else one effort). Report count, distance (accumulated while above threshold), max speed. (Stricter entry-accel variant: NOT in scope.) - Accel/decel efforts (§4): on smoothed
v— a trailing causal 5-sample moving averageṽ_i = mean(v_{i-4..i})(a centred filter needs 2-sample lookahead the forward-only keyset scan cannot provide; the trailing average is the standard GPS-streaming substitute, ~0.2 s lag) — thena = Δṽ/Δtusing the REALΔt = (serverTs_i − serverTs_{i-1})/1000(variable; fixes drop), clamp|a| ≤ 8. Bands: Moderate|a| ≥ 2.0, High|a| ≥ 3.0m/s². An effort counts whenacrosses the band and is sustained ≥ 0.3 s (≥3 samples); efforts within 0.3 s merge. Count per band, per direction (accel vs decel). GPS-derived → label as a trend estimate, not lab-grade. The per-player velocity ring + effort state live in the player'sAcc(keyed by playerId), so the global interleaved scan order never mixes one player's samples into another's.
§2 — Session-config store (age band) — server¶
§2.1 server/src/sessionConfig.ts (NEW) — modelled EXACTLY on roster.ts¶
- File
SESSION_CONFIG_FILE(default./session-config.json), shape:{ "sessions": { "<sessionId>": { "ageBand": "U14" } } }. CLI writes 0o600. loadSessionConfig(file?): Promise<Map<string, AgeBand>>— async fail-closed: missing→empty (default band applies); size > cap (1_000_000)→empty WARN; malformed/nosessions→empty WARN (content-free error, no file snippet — though this file has no names, keep the roster-loader hygiene); per-entry:ageBandmust be one of the 4 bands else drop that entry WARN. Periodic reload (SESSION_CONFIG_RELOAD_SECONDS, default 15, re-entrancy guarded),initSessionConfig()awaited by server.ts before serving.ageBandFor(sessionId): AgeBand— configured band or the U14 default (so zones always resolve).thresholdsFor(band): { jogMps:2.0, runMps:4.0, hsrMps:number, sprintMps:number }— the §1 table (the ONE place the band→threshold mapping lives server-side).thresholdsForSession(id) = thresholdsFor(ageBandFor(id)).- The band→threshold table is shared with the client ONLY via the API (§2.3) — the client never re-implements it.
§2.2 CLI server/roster-user.ts-style — server/src/sessionConfig.ts + server/session-config.ts CLI (NEW)¶
set <sessionId> <ageBand>(validate band ∈ the 4),remove <sessionId>,list. 0o600. Mirrorroster-user.ts. package.json scriptconfig. (Age band is not a name → CLI may print it freely; no value-redaction needed.)
§2.3 Endpoint — GET /sessions/:id/config (server.ts, integrator)¶
- Authz: the EXISTING
sessionGetGate(Origin-lenient → currentPrincipal → validSessionId → authorizedFor), same as/roster. Body:{ sessionId, ageBand, thresholds: { jogMps, runMps, hsrMps, sprintMps } }. - No
Cache-Control: no-storeand no rate-limit needed (not name/location data; it's a tiny static config) — but it IS session-scoped + authed for uniformity. metric:ft_config_requests_total{result}(bounded labels). On a gate rejection,log.warn('config rejected', {reason, session, username})(mirrors the roster handler; no success-audit needed — the band is not sensitive). The age band carries no name/location, so no name-leak risk.
§3 — Live path (client) — zone colour, distance/min, isolated cue¶
§3.1 Shared spine (integrator-owned, edited first)¶
client/src/types.ts:AgeBand;ZoneThresholds { jogMps, runMps, hsrMps, sprintMps };SessionConfig { ageBand, thresholds }.client/src/zones.ts(NEW): purespeedZone(v, t: ZoneThresholds): 1|2|3|4|5+ZONE_LABEL/ZONE_COLOR(5 distinct, colour-blind-safe-ish, by shape+text not colour alone in the mirror). NO thresholds table here — thresholds come from the server config; this module only classifies a speed against given thresholds + names zones.client/src/config.ts:ISOLATION_M(default 15) +ISOLATION_MS(sustained, default 3000) for the cue.
§3.2 client/src/useSessionConfig.ts (NEW) — mirror useRoster¶
useSessionConfig(sessionId): SessionConfig | null— fetchGET /sessions/:id/config(credentials:'same-origin', AbortController + reset-on-session-change, exactly likeuseRoster). null until first load; the caller falls back to U14 default thresholds client-side only while null (zones still render — graceful degradation). Never persisted. Transient-failure retention: keep the last successfully-fetched config for THIS session in a ref and return it on a later non-abort fetch error, so a configured session that has a network blip keeps its real band rather than silently snapping live zones to U14 while review still uses the real band (provenance gap). On session CHANGE the ref resets first (no cross-session bleed, mirroring the roster guard).
§3.3 client/src/useLiveTelemetry.ts (EDIT) — per-player live distance accumulator¶
- Add a third bounded ref
dist: Map<playerId, { distM, firstTs, lastLat, lastLon, lastTs }>, updated on each ACCEPTED fix inupsert: addhaversine(last, cur)only if the §1 gating holds (v≥0.4, fix≥2, pdop≤5) and the serverTs advanced. Bound + GC + clear on session change exactly likestore/health. Returndiston theLiveTelemetrycontract. (Best-effort live glance: a reconnect/eviction resets a player's running distance — acceptable; the authoritative distance is the server review aggregate. Document this.)
§3.4 Render (integrator-owned, serialise last)¶
PitchCanvas.tsx: colour the dot (or its speed readout) byspeedZone(spd, thresholds); draw an isolated cue (a distinct dashed ring) when a fresh player's nearest fresh teammate is >ISOLATION_Mfor ≥ISOLATION_MS(O(n²) over ≤MAX_TRACKED — fine). Thresholds come from a prop (the fetched config, mirrored into a ref like roster). Keep the hot loop allocation-free.A11yMirror.tsx: add Zone (word: walk/jog/run/HSR/sprint), Distance (m), Dist/min columns; isolated flagged by word, not colour alone. Distance/dist-min read from thedistref; zone fromspeedZone.App.tsx/LiveView: calluseSessionConfig(session), passconfig?.thresholds ?? defaultThresholds+distdown to canvas + mirror (alongsideroster/health). Pass thresholds toReviewViewtoo.
§4 — Review path (server aggregates) — zone breakdown + sprint + accel/decel¶
§4.1 server/src/history.ts + server/src/db.ts (EDIT — extend the EXISTING aggregate scan; no new scan)¶
One new read column (db.ts): add pdop: number | null to FixRow and the readFixesPage SELECT (it is
already a stored column; its omission was scoped to the raw-replay surface, not analytics). fix is NOT added —
ingest drops fix<2 before persisting, so every stored row already satisfies fix ≥ 2. The heatmap (2nd) pass
is unchanged. Effort detection needs only server_ts, spd, lat, lon (already scanned) + this pdop for the gate.
Fix the pre-existing ungated distance FIRST: the current foldRow adds stepMetres(...) to distanceM
UNCONDITIONALLY — no gate — so distanceM (and thus the Phase-3 aggregate + the new distancePerMin) inflates
with GNSS jitter while a player stands still. Apply the §1 gate to the base distanceM too, computing ONE gated
step per row reused by both distanceM and the zone bin (so distancePerMin and Σ zoneDistanceM agree).
Extend the per-player Acc + foldRow (rows arrive ordered by (server_ts,rowid), so per-player order holds):
- gated step: step = (prev set AND r.spd≥0.4 AND (r.pdop==null||r.pdop≤5)) ? stepMetres(prev,cur) : 0.
Add step to distanceM AND to zoneDistanceM[zone(r.spd)] (zone via the §1 >= cascade with
thresholdsForSession(sessionId)).
- sprint efforts (per-player state machine, §3.4): track a run of consecutive samples with spd ≥ Sprint;
accumulate its above-threshold duration (sum of Δt) + distance + max speed; close the run when spd drops
below Sprint, and count it as a sprint IFF its duration ≥ 1.0 s (a sub-threshold sample is the ≥1.0 s separator).
Scan-end flush (REQUIRED, post-loop): after the scan, iterate every Acc and flush an in-progress run whose
accumulated duration ≥ 1.0 s (a player sprinting through the window's end). Report sprintCount,
sprintDistanceM, maxSprintSpeedMps.
- accel/decel efforts (§4): per-player trailing velocity ring (≤5) in the Acc (NOT centred — see §1),
smoothed ṽ = mean(last≤5), a = (ṽ−prevṽ)/Δt with real Δt, clamp |a|≤8; state machine for ≥0.3 s
sustained band crossings, merge within 0.3 s; counts {accelMod, accelHigh, decelMod, decelHigh}.
- distancePerMin = distanceM / max(1, (lastTs-firstTs)/60000) — over the gated distanceM.
- Per-player response gains: zoneDistanceM:number[5], sprint:{count,distanceM,maxSpeedMps},
effort:{accelMod,accelHigh,decelMod,decelHigh}, distancePerMin. Top-level gains ageBand (provenance, from
ageBandFor). NO names anywhere. NB the module test test/history.ts distance assertion changes once the
gate lands (seed rows below 0.4 m/s no longer count) — update it with the gate.
§4.2 Client review display — ReviewView.tsx (integrator)¶
- Per-player aggregate row gains: a small zone-breakdown bar (5 segments,
ZONE_COLOR, % of distance), the sprint count/distance/max, accel/decel counts (labelled "GPS estimate"), and dist/min. Names via the roster join (render-only, unchanged). Show theageBandprovenance in the panel header. - The client
AggregatePlayer/AggregateResulttypes (useHistory.ts) MUST gain the new fields as OPTIONAL (zoneDistanceM?,sprint?,effort?,distancePerMin?, top-levelageBand?) so the UI degrades gracefully against a pre-Phase-4 server response, andReviewViewrenders each new cell only when present.
§5 — Wire/type contracts (binding shapes)¶
| Surface | Shape |
|---|---|
GET /sessions/:id/config |
{ sessionId, ageBand, thresholds:{jogMps,runMps,hsrMps,sprintMps} } |
| history aggregate per-player (extended) | …prev fields…, zoneDistanceM:number[5], sprint:{count,distanceM,maxSpeedMps}, effort:{accelMod,accelHigh,decelMod,decelHigh}, distancePerMin |
| history aggregate top-level (extended) | …prev fields…, ageBand |
| session-config file at rest | {sessions:{<id>:{ageBand}}}, 0o600 |
LiveTelemetry (extended) |
…store, health…, dist: RefObject<Map<string, LiveDist>> |
AgeBand/ZoneThresholds/SessionConfig defined once in server/src/types.ts and mirrored in |
|
client/src/types.ts (the established mirror convention). |
§6 — File-ownership map (disjoint; repo is NOT git → strict no-overlap for parallel agents)¶
Server: A-config = sessionConfig.ts(new) + session-config.ts CLI(new) + tests session-config-loader.ts/
session-config-cli.ts(new). B-aggregates = history.ts(edit: Acc/foldRow + zone/sprint/effort) + test
history.ts(extend) / new history-metrics.ts test. Integrator-serialised: server/src/types.ts,
server/src/server.ts (the /config route), metrics.ts, package.json, server/test/config-e2e.ts,
server/test/simulate.ts (write a dev session-config), server/test/history-e2e.ts (assert new aggregate fields).
Client: spine (integrator first) client/src/types.ts, zones.ts(new), config.ts. C-live =
useSessionConfig.ts(new) + useLiveTelemetry.ts(edit: dist). Integrator-serialised: PitchCanvas.tsx,
A11yMirror.tsx, App.tsx, ReviewView.tsx, client/src/zones.test.ts(new unit), e2e/live.spec.ts +
e2e/review.spec.ts(extend).
Build order: spine (server+client) → frozen → fan out disjoint slices (A-config, B-aggregates, C-live + their
tests) in parallel → integrator serialises shared files → verify → post-build security/correctness review.
§7 — Acceptance / definition of done¶
tsc+lint clean (server+client); all server tests + client units green; Playwright live+review green.- Zone colouring uses the SESSION'S band (config endpoint), live and review agree; an unconfigured session defaults to U14 and still renders. Sprint/accel/decel appear ONLY in review, never on the live wire.
- A known recorded run yields expected zone-distance / sprint-count / accel-count (correctness test).
- Name-leak guard still holds (no name in config/aggregate responses, labels, logs).
- ADR-0019 Accepted→Implemented; improvement-plan Phase 4 shipped; observability gains the config metric; metric-definitions cross-checked (no invented thresholds).
§8 — Pre-mortem inputs (the adversarial pass must answer before code)¶
- Effort state machines: off-by-one / boundary correctness (exactly-1.0 s sprint, merging within gaps, scan-end flush); do they stay O(1) per row and not stall the loop at max span?
- Smoothing/accel: real
Δtfromserver_ts(variable, fixes drop) — does the centred average + clamp avoid garbage; is a 5-sample ring correct as rows stream interleaved across players? - Distance gating consistency: live (client) vs review (server) use the SAME gate (v≥0.4, fix≥2, pdop≤5) — any divergence makes the two distances disagree confusingly.
- Config endpoint: any way it leaks more than {band,thresholds}? Correct authz reuse? Default-band fallback safe?
- Live distance accumulator: bounded + cleared on session switch + GC'd; reconnect/eviction reset documented & not misread as "player stopped"?
- Isolated cue: O(n²) bound, the sustained-window state, no false "isolated" for a lone fresh player; perf in the hot loop (no per-frame allocation).
- Any name/PII leak via the new aggregate fields, the config endpoint, or the review display.
§9 — Pre-mortem disposition (4 lenses, 25 findings; 12 confirmed must/should folded above, 10 refuted, 3 nits)¶
- Distance gating is real + the existing aggregate is already wrong (the biggest cluster — 6 findings):
foldRowcurrently sumsstepMetresUNGATED → phantom distance while standing still. Fixed: gate the basedistanceM+ everyzoneDistanceM/sprintDistanceMbyv≥0.4 AND pdop≤5(fix≥2 guaranteed at ingest); addpdoptoFixRow+the read SELECT (the "no new columns" claim was wrong); update thetest/history.tsdistance assertion. Live + server gates are equivalent (client has fix+pdop onTelemetry). - "Centred" smoothing is impossible in a forward-only scan (2 findings): a centred window needs lookahead.
Switched to a trailing causal 5-sample average (the standard GPS-streaming substitute), per-player ring in
Acckeyed by playerId so interleaved rows never mix players. - Sprint scan-end flush (REQUIRED post-loop): an in-progress ≥1.0 s sprint at the window edge must be counted.
- Client provenance:
useSessionConfigretains the last-good config on a transient failure (don't snap a configured session's live zones to U14 while review uses the real band). - Client aggregate types gain the new fields as OPTIONAL (graceful degradation vs a pre-Phase-4 server).
- nits folded: zone-boundary
>=cascade (exactly-at-threshold → higher zone); a config reject-audit log; a doc note that the live distance is haversine-on-last-known (best-effort, resets on reconnect — not "stopped"). Refuted (not changed): the deferred metrics stay deferred; the O(n²) isolated cue at ≤64 players (≤4096 cheap distance calcs/frame) is within budget; the config endpoint needs no no-store/rate-limit (band is not sensitive).
Post-build review disposition (4 lenses on the SHIPPED code; 7 confirmed fixed, 1 refuted, 6 nits)¶
The post-build pass caught real metric bugs the unit tests missed, all fixed + regression-tested:
- Sprint separator (§3.4 cl.3) was not implemented — a brief sub-threshold dip closed the run, overcounting
efforts. Rewrote the sprint state machine to mirror feedBand: a dip < SPRINT_MIN_SECONDS MERGES; only a
≥1.0 s dip separates. Tests SM (merge→1), SS2 (separate→2).
- Sprint duration off-by-one — the entry interval wasn't counted, so 10 samples measured 0.9 s and the exact
1.0 s boundary never counted. Fixed (dur = dt ?? 0 on entry). Test SB (10 samples → 1).
- Float-sum drift (found while fixing the above): Σ of 0.1 s Δt drifts below 1.0 s, so the exact boundary +
the gap separator need a SECONDS_EPS tolerance on every ≥ MIN_SECONDS compare (sprint + accel).
- Stale-player isolation false positive — isoSince wasn't cleared when a player went stale, so recovery
within ISOLATION_MS drew an instant cue. Cleared in the stale branch.
- readRaw now yields after a page-aligned final page; the live distance gate constants are shared (named in
config, matching the server); useHistory depends on primitive query fields (not the object) so an
equal-valued query rebuild can't loop. Nits (per-frame string alloc, shift() on a 5-element array, aria
verbosity) skipped as negligible/pre-existing.