From 9a5206ee5fb21de434bb44340e2aed7d56f669b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 30 Jul 2026 15:51:59 +0200 Subject: [PATCH] docs(openapi): give rhythm-events its own six-value classification enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/insights/rhythm-events was never in the spec, so a client had nothing to generate a type from but the three-value ECG classification next door — reusing it would drop VERY_LOW / FIRED. Register the route with its own RhythmEventClassification carrying all six RhythmClassification values; the ECG enum stays untouched at three. --- docs/api/openapi.yaml | 138 +++++++++++++++++++++ src/lib/openapi/routes/insights/paths.ts | 24 ++++ src/lib/openapi/routes/insights/schemas.ts | 75 ++++++++++- 3 files changed, 236 insertions(+), 1 deletion(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5938e2f0b..6c5d33755 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -7743,6 +7743,31 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/insights/rhythm-events: + get: + tags: + - Insights + summary: Device-flagged rhythm/HR/steadiness event timeline + description: "v1.10.0 (WX-B) — the authenticated user's timeline of device-flagged EVENT rows: irregular-rhythm / + high-HR / low-HR / walking-steadiness / breathing-disturbance notifications the user's wearable (Apple Watch / + Withings ScanWatch) already produced and synced. AWARENESS / SCREENING of the DEVICE's own decision — HealthLog + stores and reflects ONLY the classification result the device's certified on-device algorithm emitted, verbatim; + it never re-classifies and never produces a HealthLog diagnosis. `classification` carries the full six-value + verdict set (the three ECG verdicts plus the two walking-steadiness severities plus the neutral FIRED verdict) — + a distinct, wider enum than the three-value one on GET /api/insights/ecg. Data-availability-gated: an account + with no event rows returns `hasEvents: false`. Module-gated on `insights` and the operator `insightStatus` + assistant surface; no LLM call. Auth via cookie or Bearer." + responses: + "200": + description: The device-flagged event timeline (possibly empty). + content: + application/json: + schema: + $ref: "#/components/schemas/RhythmEventsResponseEnvelope" + "401": *a1 + "403": *a5 + "422": *a3 + "429": *a4 /api/insights/correlations: get: tags: @@ -28508,6 +28533,119 @@ components: narrowed in the query where — a foreign / unknown id 404s. The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. no-store. + RhythmEventsResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/RhythmEventsResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + RhythmEventsResponse: + type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/RhythmEventListItem" + description: The user's device-flagged events, newest first (capped at 200). + hasEvents: + type: boolean + description: False for an account with no event rows — the client un-mounts the whole surface (data-availability floor). + required: + - events + - hasEvents + additionalProperties: false + description: Timeline of device-flagged EVENT rows (irregular-rhythm / high-HR / low-HR / walking-steadiness / + breathing-disturbance) the user's wearable (Apple Watch / Withings ScanWatch) already produced and synced. + AWARENESS / SCREENING of the DEVICE's own decision — HealthLog stores and reflects the classification result + verbatim, never a raw waveform, and never re-classifies. Read-only; no LLM call. + RhythmEventListItem: + type: object + properties: + id: + type: string + description: Measurement row id (cuid). + type: + type: string + enum: + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + description: The HealthKit category the device flagged. IRREGULAR_RHYTHM_NOTIFICATION + (`HKCategoryTypeIdentifierIrregularHeartRhythmEvent` + ScanWatch AFib screening), HIGH_HEART_RATE_EVENT / + LOW_HEART_RATE_EVENT (a sustained heart rate above / below the user's configured threshold while apparently + inactive), WALKING_STEADINESS_EVENT (`HKCategoryTypeIdentifierAppleWalkingSteadinessEvent`; the severity + rides in `classification`), BREATHING_DISTURBANCE_EVENT (`HKCategoryTypeIdentifierSleepApneaEvent` + + `AppleSleepingBreathingDisturbances`, an elevated breathing-disturbance / possible sleep-apnea screening + signal). + classification: + anyOf: + - type: string + enum: + - IRREGULAR + - NOT_DETECTED + - INCONCLUSIVE + - LOW + - VERY_LOW + - FIRED + - type: "null" + description: The RECORDING DEVICE's own verdict, surfaced verbatim. IRREGULAR / NOT_DETECTED / INCONCLUSIVE are the + ECG-style rhythm verdicts (device flagged a possible irregular rhythm / algorithm ran and found nothing / + signal too poor to classify) and occur on an `IRREGULAR_RHYTHM_NOTIFICATION` row. LOW / VERY_LOW are + walking-steadiness severities and occur on a `WALKING_STEADINESS_EVENT` row. FIRED is the neutral "event + occurred" verdict with no severity gradient, and occurs on a `HIGH_HEART_RATE_EVENT` / + `LOW_HEART_RATE_EVENT` / `BREATHING_DISTURBANCE_EVENT` row. Null when the source reported none. HealthLog + never re-classifies — this is the device's certified on-device result. Distinct from + `EcgRecordingListItem.classification` / `EcgDetailResponse.classification` above, which are scoped to the + three ECG-only values. + occurredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Device-reported event time (`measuredAt`). + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + deviceType: + anyOf: + - type: string + - type: "null" + description: Device class that reported the event (`watch | band | ring | phone | scale | other | unknown`); null when + the source omitted it (treated as `unknown`). + required: + - id + - type + - classification + - occurredAt + - source + - deviceType + additionalProperties: false CorrelationDiscoveryResponseEnvelope: type: object properties: diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index 2d7ef8170..07f5185b2 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -39,6 +39,7 @@ import { ecgListResponse, ecgDetailQuery, ecgDetailResponse, + rhythmEventsResponse, } from "./schemas"; export const insightsPaths: NonNullable = { @@ -492,6 +493,29 @@ export const insightsPaths: NonNullable = { }, }, }, + "/api/insights/rhythm-events": { + get: { + tags: ["Insights"], + summary: "Device-flagged rhythm/HR/steadiness event timeline", + description: + "v1.10.0 (WX-B) — the authenticated user's timeline of device-flagged EVENT rows: irregular-rhythm / high-HR / low-HR / walking-steadiness / breathing-disturbance notifications the user's wearable (Apple Watch / Withings ScanWatch) already produced and synced. AWARENESS / SCREENING of the DEVICE's own decision — HealthLog stores and reflects ONLY the classification result the device's certified on-device algorithm emitted, verbatim; it never re-classifies and never produces a HealthLog diagnosis. `classification` carries the full six-value verdict set (the three ECG verdicts plus the two walking-steadiness severities plus the neutral FIRED verdict) — a distinct, wider enum than the three-value one on GET /api/insights/ecg. Data-availability-gated: an account with no event rows returns `hasEvents: false`. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The device-flagged event timeline (possibly empty).", + content: { + "application/json": { + schema: dataEnvelope( + rhythmEventsResponse, + "RhythmEventsResponseEnvelope", + ), + }, + }, + }, + ...moduleDisabledResponse, + ...stdResponses, + }, + }, + }, "/api/insights/correlations": { get: { tags: ["Insights"], diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 6c160083e..8bb3deb0a 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -6,7 +6,10 @@ * runtime request parsing, so the wire contract stays single-source. */ import { z } from "zod/v4"; -import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { + measurementTypeEnum, + measurementSourceEnum, +} from "@/lib/validations/measurement"; import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; import { DERIVED_METRIC_IDS, @@ -1250,3 +1253,73 @@ export const ecgDetailResponse = z description: "One ECG recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is narrowed in the query where — a foreign / unknown id 404s. The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. no-store.", }); + +// v1.10.0 — device-flagged event timeline (rhythm-events, WX-B). Deliberately +// a SEPARATE enum from `ecgClassification` above: an EVENT row's +// `rhythmClassification` carries the FULL RhythmClassification set (the +// three ECG verdicts + the two walking-steadiness severities + the neutral +// "event occurred" verdict), not just the three ECG-only values. Reusing +// `ecgClassification` here would silently drop LOW / VERY_LOW / FIRED for +// any client that generates its type from this schema. +const rhythmEventType = z + .enum([ + "IRREGULAR_RHYTHM_NOTIFICATION", + "HIGH_HEART_RATE_EVENT", + "LOW_HEART_RATE_EVENT", + "WALKING_STEADINESS_EVENT", + "BREATHING_DISTURBANCE_EVENT", + ]) + .describe( + "The HealthKit category the device flagged. IRREGULAR_RHYTHM_NOTIFICATION (`HKCategoryTypeIdentifierIrregularHeartRhythmEvent` + ScanWatch AFib screening), HIGH_HEART_RATE_EVENT / LOW_HEART_RATE_EVENT (a sustained heart rate above / below the user's configured threshold while apparently inactive), WALKING_STEADINESS_EVENT (`HKCategoryTypeIdentifierAppleWalkingSteadinessEvent`; the severity rides in `classification`), BREATHING_DISTURBANCE_EVENT (`HKCategoryTypeIdentifierSleepApneaEvent` + `AppleSleepingBreathingDisturbances`, an elevated breathing-disturbance / possible sleep-apnea screening signal).", + ); + +const rhythmEventClassification = z + .enum([ + "IRREGULAR", + "NOT_DETECTED", + "INCONCLUSIVE", + "LOW", + "VERY_LOW", + "FIRED", + ]) + .nullable() + .describe( + "The RECORDING DEVICE's own verdict, surfaced verbatim. IRREGULAR / NOT_DETECTED / INCONCLUSIVE are the ECG-style rhythm verdicts (device flagged a possible irregular rhythm / algorithm ran and found nothing / signal too poor to classify) and occur on an `IRREGULAR_RHYTHM_NOTIFICATION` row. LOW / VERY_LOW are walking-steadiness severities and occur on a `WALKING_STEADINESS_EVENT` row. FIRED is the neutral \"event occurred\" verdict with no severity gradient, and occurs on a `HIGH_HEART_RATE_EVENT` / `LOW_HEART_RATE_EVENT` / `BREATHING_DISTURBANCE_EVENT` row. Null when the source reported none. HealthLog never re-classifies — this is the device's certified on-device result. Distinct from `EcgRecordingListItem.classification` / `EcgDetailResponse.classification` above, which are scoped to the three ECG-only values.", + ); + +const rhythmEventListItem = z + .object({ + id: z.string().describe("Measurement row id (cuid)."), + type: rhythmEventType, + classification: rhythmEventClassification, + occurredAt: z.iso + .datetime({ offset: true }) + .describe("Device-reported event time (`measuredAt`)."), + source: measurementSourceEnum, + deviceType: z + .string() + .nullable() + .describe( + "Device class that reported the event (`watch | band | ring | phone | scale | other | unknown`); null when the source omitted it (treated as `unknown`).", + ), + }) + .meta({ id: "RhythmEventListItem" }); + +export const rhythmEventsResponse = z + .object({ + events: z + .array(rhythmEventListItem) + .describe( + "The user's device-flagged events, newest first (capped at 200).", + ), + hasEvents: z + .boolean() + .describe( + "False for an account with no event rows — the client un-mounts the whole surface (data-availability floor).", + ), + }) + .meta({ + id: "RhythmEventsResponse", + description: + "Timeline of device-flagged EVENT rows (irregular-rhythm / high-HR / low-HR / walking-steadiness / breathing-disturbance) the user's wearable (Apple Watch / Withings ScanWatch) already produced and synced. AWARENESS / SCREENING of the DEVICE's own decision — HealthLog stores and reflects the classification result verbatim, never a raw waveform, and never re-classifies. Read-only; no LLM call.", + });