Library Views Specification
F9 Phase 2 surface. See index.md for the architecture; this doc defines the lightweight engagement ingest for library-overlay watches.
The Patient Portal's exercise library lets a patient browse the catalog and watch individual exercises (or audio files) through a lightweight overlay player — pure curiosity, not part of any session run. These watches have no session_runs row, no clinical record, and no QoS rollup. They feed exactly one product surface: F9's library curiosity tile on the clinic patient-detail stats tab ("what library content does this patient engage with").
Why a separate surface
/v1/media/events is a streaming session-scoped surface: many events per run, aggregation in memory, terminal flush on session_end, run identity in the JWT, per-(run_id, media_id) rows. None of that fits a library overlay:
- No run — no
session_runsto attach to. - No QoS analytics need — the tile only asks "what / how completely / when."
- Single overlay open → single record; streaming aggregation is overkill.
- Different read pattern — per-patient over a 30-day window, not per-run.
- Different retention horizon — library views aren't clinical history; lifecycle policy lives independently.
So library views ride their own narrow endpoint with their own table and their own JWT audience.
Endpoint
| Endpoint | Purpose | Auth |
|---|---|---|
POST /v1/library/views | One library-overlay close → one row | Library token (audience: restartix-telemetry-library); no consent claim — engagement is GDPR Art. 6(1)(f) legitimate interest |
Fire once per overlay close, with the totals accumulated client-side over the watch. The server validates, computes the authoritative completion_pct, and writes one row.
Request
POST /v1/library/views
Authorization: Bearer <library-jwt>
Content-Type: application/json
{
"media_id": "content-file-uuid",
"media_kind": "video",
"started_at": "2026-05-21T10:00:00.000Z",
"ended_at": "2026-05-21T10:01:30.000Z",
"watched_seconds": 85.4,
"total_duration_seconds": 120.0
}| Field | Required | Notes |
|---|---|---|
media_id | yes | content_files.id per F9 Phase 1. Opaque to telemetry. |
media_kind | no | "video" (default) or "audio". Missing / unknown → "video". |
started_at | yes | RFC3339. |
ended_at | yes | RFC3339; must be ≥ started_at. |
watched_seconds | no | Default 0. Must be ≥ 0. |
total_duration_seconds | no | When > 0, server derives completion_pct = clamp(watched / total * 100, 0, 100). When 0 / missing, completion_pct stored as 0. |
Identity (org_id, patient_id) comes from the verified JWT — payload-supplied identity fields, if any, are ignored.
Response
202 Accepted
Content-Type: application/json
{ "status": "accepted", "media_id": "content-file-uuid" }Error envelope
400 Bad Request for malformed JSON, missing media_id / timestamps, ended_at before started_at, or negative durations. 401 Unauthorized for missing / invalid / wrong-audience tokens. 500 Internal Server Error for storage failures. No 403 on this route — engagement runs under legitimate interest, not consent.
JWT
Library tokens share the session token's signing material (same kid, same HS256 secret at restartix/{env}/telemetry-jwt-signing-key) but carry a distinct audience and omit run_id:
{
"iss": "restartix-api",
"aud": "restartix-telemetry-library",
"sub": "<patient-uuid>",
"iat": 1747816800,
"exp": 1747822200,
"org_id": "<org-uuid>",
"patient_id": "<patient-uuid>",
"consents": { "pose": false }
}Minted by API on portal load when a patient enters the exercise library (telemetrytoken.Signer.MintLibrary). Same 90-min TTL as the session token.
The Telemetry service verifies signature + issuer + expiry; the audience is enforced by the per-route auth.RequireAudience middleware (the verifier itself is audience-agnostic so one shared key serves both surfaces). A session token replayed against /v1/library/views (or vice versa) is rejected with 401.
Storage
Single table, range-partitioned monthly per P41:
CREATE TABLE media_library_views (
id UUID NOT NULL DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
patient_id UUID NOT NULL,
media_id TEXT NOT NULL,
media_kind TEXT NOT NULL DEFAULT 'video'
CHECK (media_kind IN ('video','audio')),
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ NOT NULL,
watched_seconds REAL NOT NULL DEFAULT 0,
total_duration_seconds REAL,
completion_pct REAL NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, started_at)
) PARTITION BY RANGE (started_at);
CREATE INDEX idx_media_library_views_patient ON media_library_views (patient_id, started_at DESC);
CREATE INDEX idx_media_library_views_org_started ON media_library_views (organization_id, started_at);
CREATE INDEX idx_media_library_views_media ON media_library_views (media_id);Append-only (no ON CONFLICT clause) — a rapid open-close-open-close is signal, not noise. Partition runway is extended by cmd/telemetry-partition-roll, which covers both media_buffering_events and media_library_views in one pass.
The (patient_id, started_at DESC) index is the curiosity-tile access pattern. The (organization_id, started_at) index serves clinic-level rollups (top library content per clinic).
Reads
All reads go through API. Telemetry exposes no per-tenant authenticated query path — the post-launch read endpoint (API → telemetry PG, filtered by (organization_id, patient_id)) is owned by F9's stats lane.
What this surface intentionally excludes
- Buffering / quality / dropped-frame events. Library overlays are short, low-stakes watches; QoS noise doesn't earn its keep here.
- Watch threshold events (
watch_pct_25/_50/ etc.). The singlecompletion_pctvalue is enough for the curiosity tile. - Pose / biometric. Pose ingest is session-only. The library player runs without pose tracking.
- Idempotency / dedup. Each overlay close is one row. Duplicate POSTs from a flaky client appear as duplicate rows — harmless to the curiosity tile (which counts engagements, not unique opens).