v1.11.0 — WHOOP, a longitudinal coach, and a clinician-grade record#251
Merged
Conversation
Owner-minted, scoped, time-limited, revocable read view of the health record for a clinician holding the raw hls_ token. The token is stored only as an HMAC-SHA256 hash; the scope (range start, sections, resource types) is frozen at creation and never widened. expiresAt is mandatory (capped at SHARE_LINK_MAX_DAYS, default 90); only revokedAt, lastAccessAt and accessCount mutate after mint. Cascades on user delete. Schema + migration only; no behaviour yet.
Lift the Observation / MedicationStatement / MedicationAdministration / Patient / Coverage builders — with their LOINC/ATC/SNOMED/UCUM codings and the survey-category wellness split — out of build-bundle.ts into a shared src/lib/fhir/resources.ts. buildFhirDocumentBundle now composes the emitters so the upcoming FHIR REST search routes reuse the exact same coding logic instead of a second copy of the mapping. Pure refactor: the document-bundle output stays byte-identical (the existing FHIR + export tests pass unchanged). The shared coding constants and option types re-export from build-bundle so existing import paths keep working.
…priority ladders Add the WHOOP integration data layer (migration 0111): seven new MeasurementType values (HRV_RMSSD, DAY_STRAIN, WORKOUT_STRAIN, SLEEP_PERFORMANCE, SLEEP_EFFICIENCY, SLEEP_CONSISTENCY, SLEEP_NEED, ENERGY_EXPENDITURE_KJ), a WHOOP MeasurementSource, the WhoopConnection and WhoopOAuthState models, and the per-user encrypted BYO-key columns on User. Day-strain takes DAY_STRAIN (not the COMPUTED STRAIN_SCORE) and RMSSD takes HRV_RMSSD (not the SDNN HEART_RATE_VARIABILITY) so a native value never shares a bucket with a derived proxy. Carry the new types through the completeness walls (units, value ranges, chart tokens, PR direction, categories, list-meta colours/icons/labels, HealthKit-mapping exclusions) and feed the cross-source picker: recovery ladder (WHOOP native above the COMPUTED proxy), WHOOP inserted into the overlapping vital ladders and the workout ladder, plus skinTemperature and respiratoryRate metric keys.
…cing Promote the volatile per-worker last-working cache to a Postgres provider-health ledger so the fallback chain shares one health signal across every instance, modelled on the atomic-upsert rate limiter. The ledger holds a durable negative cache: an auth-class failure (401/403) benches that provider for a cooldown instead of re-burning the dead round-trip on every generation; a sustained hard failure backs off briefly. The local model is never benched — it stays the guaranteed floor of the chain. Every ledger interaction fails open, so a DB error falls back to today's chain walk exactly. When the user's primary provider fails auth-class, the Coach emits a distinct credential_expired frame that deep-links to reconnect rather than a generic "try again later". Adds the provider_health table (migration 0112) and the errorCredentialExpired copy across all locales.
Assemble the structured, provenance-carrying data the period narrative narrates, without any LLM call. buildPeriodNarrativeContext reads one bounded window covering the current and prior period of equal length and delegates to a pure core that produces metric deltas, derived-band transitions, FDR-surviving correlation drivers, and coincident-deviation flags as label+number+source beats. Reuse the rollup-backed daily series, the median ± k·MAD personal band, and the existing Benjamini-Hochberg correlation engine, so the descriptive-never-causal contract carries through verbatim — a driver never enters without clearing the FDR target, and each keeps its conservative interpretation string unchanged. Gate on data availability: too little history returns an insufficient shape, never a fabricated story. Pure core is unit-tested over seeded fixtures.
…olution Add the WHOOP integration data layer on copy-of-Withings rails: - client.ts: hand-rolled fetch over safeFetch (no SDK) — OAuth authorize URL / code exchange / rotating-refresh, the four v2 collection fetchers (recovery, sleep, cycle, workout) with next_token pagination, single body-measurement + profile reads, and the field->Measurement mappers (RECOVERY_SCORE/HRV_RMSSD/RESTING_HEART_RATE/OXYGEN_SATURATION/ SKIN_TEMPERATURE, per-stage SLEEP_DURATION ms->min, SLEEP_NEED/SLEEP_*, DAY_STRAIN/ENERGY_EXPENDITURE_KJ, source=WHOOP). Day strain maps to DAY_STRAIN and RMSSD to HRV_RMSSD so the device-native values never share a bucket with the COMPUTED STRAIN_SCORE / SDNN proxies. - response-classifier.ts: HTTP-status-driven success/transient/ reauth_required/persistent verdicts + WhoopApiError, mirroring the Withings reason-tag classifier. - credentials.ts: per-user BYO-key resolution, decrypting the client id/secret from the User columns. - oauth-state.ts: opaque-nonce mint helper + cookie/TTL constants. - mapping.md: field->Measurement reference, kept in sync with the mappers. Unit tests cover the classifier, pagination, every fetcher, the mappers (ms->min, kJ->kcal factor, stage mapping, unscored-record handling), and credential resolution against mocked WHOOP JSON. No routes / OAuth flow / sync workers / migration yet.
Authenticated owner routes to create, list, and revoke a ClinicianShareLink. Create mints an hls_ token (192-bit), stores only its HMAC hash, and returns the raw token exactly once; the scope columns (window, sections, FHIR resource types, API toggle) are frozen write-once. expiresAt is required and capped at SHARE_LINK_MAX_DAYS (90). Revoke sets revokedAt and seals a cross-user id as 404. userId is narrowed from auth; rate-limited; strict Zod rejects unknown keys. This is the owner lifecycle only — the public resolver/view is separate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/fhir/{metadata,Patient,Observation,MedicationStatement,
MedicationAdministration} and $everything, returning type:"searchset"
Bundles (total + self/next links, offset paging via _count clamped to 200)
as application/fhir+json, with an OperationOutcome on error. The per-resource
emitters are reused from src/lib/fhir/resources.ts so the document export and
the REST face share one coding source and can never drift. Auth is a narrow
fhir:read scope via requireAuth (cookie sessions also pass); userId is
narrowed from auth. Read-only — no write handlers. Rate-limited per user.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
resolveShareToken hashes a raw hls_ token, looks up the ClinicianShareLink, enforces not-revoked and not-expired, and returns a ShareContext carrying only the owner userId plus the frozen scope. It is not an auth primitive: it never reads a session or sets a cookie, so a share token can authenticate only the share surface. Unknown / revoked / expired / malformed tokens resolve to null for a blunt 404. Access counters bump fire-and-forget on a hit. A token presented on a normal authed route is rejected by construction — requireAuth reads only the cookie session and Authorization: Bearer (against ApiToken), never the share header, and an hls_ token has no ApiToken row. The invariant test locks that boundary.
A standalone read-only RSC that resolves the path token through the share-token resolver and renders an owner-scoped clinical summary: provenance header, clinical vitals and labs, medications with adherence, and a fenced muted card for the descriptive wellness scores carrying the not-a-clinical-assessment, not-a-diagnosis disclaimer. KVNR is default off. No app chrome, no coach, no AI, no session, no markdown — every value renders as escaped text. force-dynamic and noindex; the page returns a flat 404 for any null resolve. The route joins the proxy public-path allowlist and the AuthShell standalone list so it reaches the page bare without a sign-in redirect. New clinicianView strings land in all six locales.
Build the WHOOP sync layer on the W2 client, mirroring the Withings queues. A per-resource sync (recovery/sleep/cycle/workout) pulls since the last cursor with a 24h overlap on recovery/sleep to absorb WHOOP's after-the-fact re-scoring, maps via the W2 mappers, and upserts Measurements with source=WHOOP keyed on (userId,type,source,externalId) so a re-post overwrites in place. Workouts upsert into Workout with per-workout strain + HR zones in metadata and kJ->kcal energy. Token refresh persists BOTH rotated tokens (WHOOP invalidates the prior access and refresh token on every refresh). A self-converging boot backfill enqueues one full-history sync per un-backfilled connection and stamps backfillCompletedAt; the discovery query drops completed accounts, idempotent across reboots. Every new queue is registered in the worker's allQueues + cron schedule (recovery/sleep/workout poll catch-all, cycle poll-only, backfill, oauth-state sweep). IntegrationStatus gains the whoop key + label. Tests: rotating-refresh persistence, idempotent measurement upsert, incremental-window overlap, backfill self-convergence, and a queue-registration inventory guard.
Add a calm "your week / month in review" summary to the Insights overview, generated from the period-narrative context (deltas, personal bands, FDR-controlled associations, coincident days). - New insight_narratives table (migration 0113): one durable row per (user, period, locale); generated prose AES-256-GCM at rest following the coach-message precedent; labels-only provenance stays plaintext. - generatePeriodNarrative feeds the context into the provider chain with a tight, descriptive-never-causal prompt; an insufficient context yields no narrative, a missing provider no-ops, neither fabricates. - Nightly warm cron on week (Mon) / month (1st) boundaries, budget-gated per user; the read-only GET enqueues a single-user warm on a cold read. - Read route is stale-while-revalidate and never blocks on the provider; the card renders plain text (no markdown), a single provenance disclosure, and a fixed footprint (no CLS). i18n in all six locales.
…edge Register the `/c/` prefix in `PATH_SECRET_PATHS` so the raw `hls_` clinician share token, which rides as the trailing path segment, is rewritten to [REDACTED] in `http.path` / `http.route` before any Wide Event or GlitchTip report leaves the process — the same posture as the Withings webhook path secret. The resolve path never logs, throws, or annotates the raw token. Defend the share surface at the edge irrespective of what the RSC emits: the proxy now sets `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow`, and `Referrer-Policy: no-referrer` on `/c/` responses, so no downstream proxy caches a scoped record, no crawler indexes the token, and the token-bearing URL is not leaked in an outbound `Referer`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Sharing section under Settings (between API and Export) where the owner creates a read-only clinician share link, sees their active and inactive links, and revokes one. Create wires to the C4 lifecycle API: it sends the label, a rolling history window, an expiry (capped at 90 days, validated before the round trip), an optional scoped FHIR-API toggle, and the FHIR resource types. The raw token is revealed exactly once on create as a copy-able `/c/<token>` URL; the list only ever carries the server's safe projection (no token, since only its hash is stored). Revoke confirms through an alert dialog and invalidates the list. RSC page mounts a client island; reads unwrap the envelope and key off the centralised query factory. New `settings.sharing` / `settings.sections.sharing` strings land in all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Connect mints the state nonce into the WhoopOAuthState ledger and redirects to the WHOOP authorize page; callback verifies the nonce (atomic delete-first consume, four distinguished reason tags), exchanges the code, fetches the WHOOP profile for the user id, persists the encrypted WhoopConnection, and enqueues the self-converging history backfill. The webhook handler rate-limits per source first, verifies the WHOOP_WEBHOOK_SECRET path segment, then verifies the HMAC-SHA256 body signature over the raw bytes (timestamp + body, timingSafeEqual, stale timestamp reject) before any work. Valid *.updated enqueues the matching per-resource sync; *.deleted soft-deletes (workouts hard-delete, no soft-delete column). Unknown users return 200 to stop retries. CSP gates api.prod.whoop.com to the WHOOP settings surface + /api/whoop/*; the webhook path joins the public-path allowlist and the path-secret redact registry. Env wiring adds WHOOP_WEBHOOK_SECRET + WHOOP_REDIRECT_URI to the compose whitelist and .env.production.example.
… band Add a short-horizon OLS projection over the DAY-native per-day mean series, extending the existing trendSlope. The fit, residual standard error and prediction interval are computed at DAY granularity only — the rollup invariant forbids composing a slope/sd from WEEK/MONTH buckets. The projection is gated on fit quality (R squared), in-window history and staleness: below any floor the engine returns insufficient rather than a weak line, so noise is never extrapolated. The prediction-interval band widens with the horizon distance from the data centre, so the fan visibly communicates uncertainty. Register TRAJECTORY in the derived registry and dispatch (additive, iOS-forward-compatible; the capabilities endpoint and drift-guard pick it up automatically). Surface it on the weight detail page as a forecast band beneath the chart, labelled a projection with the cited method and a conditional, range-only headline — never a prediction or a dated event. i18n across all six locales.
The body-content secret guard matched hlk_ / hlr_ / sk- but not the new hls_ clinician share-link prefix, so a response echoing a freshly minted share token could be persisted to the idempotency cache. Add hls_ to SECRET_PATTERN alongside the existing token prefixes.
Add a single canonical FHIR_REST_RESOURCE_TYPES catalogue (plus the $everything operation and honoured search params) to fhir/rest.ts; the metadata CapabilityStatement and the share-link resource-type enum now derive from it so the advertised set can never drift from what is routed. Extend GET /api/meta/capabilities with the FHIR REST descriptor (restBaseUrl, readScope, resourceTypes, operations, searchParams) and a clinician-share descriptor (supported, maxDays, resourceTypes, scopeable sections) — every list sourced from its canonical registry, guarded by the existing drift assertions. Grow the OpenAPI CapabilitiesResponse to match. Fill the clinician test-matrix gap: assert the clinician view never requests a KVNR / identifier opt-in from the aggregator (default-OFF by construction) and resolves a rolling window without reaching data older than the frozen start.
… routes + connect card Fill the WHOOP route set beside the OAuth connect/callback: status reports connection + token expiry + backfill progress, disconnect revokes the stored tokens and parks the integration, credentials saves/clears the per-user BYO client id/secret encrypted at rest, sync triggers a manual incremental or full pull. Add a WHOOP card to Settings -> Integrations mirroring the Withings card (BYO-keys form, connect/disconnect, status pill, last-sync, backfill notice) and surface whoop rows in the combined integrations-status envelope so the pill reflects real failure state. WHOOP lives under the existing integrations section -- no new slug.
…time picker Prove a WHOOP run and the same Apple-Health run inside the 5-minute clustering window collapse to the APPLE_HEALTH canonical row (richer GPS + HR record), and a lone WHOOP run survives when no richer source logged the session. The measurement-side WHOOP/Apple/Withings oracle already lives in source-priority-whoop-applehealth.test.ts; this closes the workout axis.
Assemble a zero-LLM longitudinal memory block from artefacts already persisted: the latest period-narrative headline and a per-metric prior-vs-current band recall. The Coach can now reference what was true last period instead of re-deriving a cold snapshot every turn. The block is machine-derived (labels plus numbers only), so it is unencrypted and adds no model cost. Each sub-source is fault-isolated — a failing narrative read or context build drops that sub-block and never sinks the turn. It registers against the lowest-signal cluster so the budget degrader sheds it first, before any clinical data.
Fold the deterministic short-horizon forecasting engine into the Coach prompt as a compact, lowest-signal snapshot block. For each in-scope metric the block carries direction + slope + the projected horizon end with its widening prediction band, the last observed value, and the fit's R² / server-computed confidence — read straight from the existing projection engine, never recomputed. A metric below the fit/history/ staleness floor is omitted; the block is dropped entirely when none qualify, and it sheds first under prompt-budget pressure. Add a conditional-projection ground rule so the Coach narrates a trajectory only when the block is present and only for the metric it covers: direction and range, always conditional, never a certainty, a risk score, a diagnosis, or a dated event; absent block means no projection. The rule lands in the safety-contract matrix across all six locales and inline in the EN + DE system prompts, with an overclaim probe asserting a projection is never upgraded. Bump PROMPT_VERSION.
… connect card When WHOOP and another source both supply a standard vital (resting heart rate, blood oxygen, body temperature, respiratory rate, sleep stages), both values can appear until a future update settles on a single preferred source. Show this as a calm muted note on the WHOOP card so the known limitation reads as documented behaviour rather than a bug. New whoopOverlapNote key across all six locales.
…ot wired The capabilities share descriptor exposed scopeable FHIR resourceTypes, but no /api/fhir/* route honours a share token — those routes require an authenticated fhir:read Bearer. The share serves the rendered record view only. Drop the unrouted resourceTypes from the advertised share scope and add an explicit fhirApi:false flag so an integrator knows the share to FHIR face is not live.
… view The clinician view rendered metric names through an English-only humaniser while the rest of the page was translated, leaving a mixed-language summary for non-English clinicians. Reuse the doctor-report type-label keys (already present in every locale) for the per-type label, falling back to the humaniser only for a type with no key.
Replace the incomplete tablist/tab markup on the week/month toggle with plain buttons carrying aria-pressed, give them a 44px minimum tap target, and add a focus-visible ring. The visual treatment is unchanged.
The rotation registry missed the per-user WHOOP credential columns and the WhoopConnection token columns, so a key rotation would skip those rows and leave them undecryptable once the operator drops the old key. Add whoopClientIdEncrypted / whoopClientSecretEncrypted to the User field list and a WhoopConnection accessToken / refreshToken pass.
…nician-grade FHIR record
Add WHOOP as a synced provider (per-user BYO-keys OAuth2 + HMAC-signed webhooks), the longitudinal coach (narrative + per-metric trajectory), and the read-only FHIR R4 REST API plus the scoped, time-limited clinician share-link record across README, the self-hosting and ops runbooks, and a new WHOOP integration page. Note the two optional WHOOP instance env vars, that migrations 0110-0114 are additive, and the two known limitations (dual-source standard-vital dedup deferred; coach durable-memory deferred). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The WHOOP integration card adds a third status pill on /settings/integrations. Update the Pixel-5 layout spec to expect three pills and assert the two connected ones (Withings + moodLog) order- independently, since WHOOP stays a dormant BYO-keys card until an operator adds credentials.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Multi-feature milestone across three fronts.
Added
/api/fhir/*) + scoped, time-limited, revocable share links opening a clean read-only clinician view (no account); wellness fenced off from clinical with a not-a-diagnosis note.Migrations
0110 ClinicianShareLink · 0111 WHOOP (types/source/connection/oauth-state/BYO-keys) · 0112 ProviderHealth ledger · 0113 InsightNarrative · 0114 coach conversation-summary columns. All additive, multi-tenant-safe.
Known limitations (documented, planned follow-ups)
Verification
typecheck/lint/knip/openapi:check green; full unit suite + WHOOP/coach/clinician integration tests pass. Whole-app final audit across six lenses (QoL, QoS, senior-dev, product-lead, senior-architect, pentester): 0 Critical; the 1 High + 5 Medium findings are all fixed in this branch; lows + the two refinements above are documented deferrals.