docs(openapi): give rhythm-events its own six-value classification enum

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-30 16:06:53 +02:00
1 parent d7badf1512
commit 9a5206ee5f
3 files changed
+236 -1

No files matched your search

+138
View File
@@ -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:
+24
View File
@@ -39,6 +39,7 @@ import {
ecgListResponse,
ecgDetailQuery,
ecgDetailResponse,
rhythmEventsResponse,
} from "./schemas";
export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
@@ -492,6 +493,29 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],
+74 -1
View File
@@ -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.",
});