diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c0de7bf46..a2de22e9a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -10997,8 +10997,8 @@ paths: NEVER decrypts or returns the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Reflects only the recording device's certified on-device classification, verbatim; HealthLog never re-classifies an ECG or produces a diagnosis. Data-availability-gated: an empty account returns - `hasRecordings: false`. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM - call. Auth via cookie or Bearer." + `hasRecordings: false`. Module-gated on `insights`; no assistant-surface gate and no LLM call. Auth via cookie + or Bearer." responses: "200": description: The ECG recording list (possibly empty). @@ -11022,8 +11022,8 @@ paths: or revise one. `source` accepts `APPLE_HEALTH` only; `userId` comes from the session and is never a body field. Unknown body keys are rejected with a 422 naming them. No `Idempotency-Key` is needed: the recording carries its own identity, so a retry resolves to the same row by construction — see `status` for what a re-post reports. - Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights` and the - operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer." + Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights`; no + assistant-surface gate and no LLM call. Auth via cookie or Bearer." requestBody: required: true content: @@ -11058,8 +11058,8 @@ paths: foreign or unknown id 404s (existence sealed). The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. By default the ~9000-sample strip is min/max-decimated to ~2500 display points so R-wave peaks survive; `?full=1` returns the raw array. HealthLog does not interpret the trace, measure intervals, - annotate beats, or emit a verdict of its own. Module-gated on `insights` and the operator `insightStatus` - assistant surface; no LLM call. `no-store`. Auth via cookie or Bearer. + annotate beats, or emit a verdict of its own. Module-gated on `insights`; no assistant-surface gate and no LLM + call. `no-store`. Auth via cookie or Bearer. parameters: - in: path name: id @@ -11107,8 +11107,8 @@ paths: 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." + with no event rows returns `hasEvents: false`. Module-gated on `insights`; no assistant-surface gate and no LLM + call. Auth via cookie or Bearer." responses: "200": description: The device-flagged event timeline (possibly empty). diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 6c0d67024..8e822ae5d 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -42,13 +42,6 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ "src/app/api/insights/bmi-status/route.ts", "src/app/api/insights/cards/route.ts", "src/app/api/insights/correlations/route.ts", - // v1.10.0 — generic derived-wellness-metric route. Pure compute over - // the rollup tier; gates on the same `insightStatus` sub-flag as the - // assessment routes (no Coach prose). - "src/app/api/insights/derived/route.ts", - // v1.10.0 — batched derived-metric route (the dashboard fan-out fix). - // Same pure compute + `insightStatus` sub-flag as the single route. - "src/app/api/insights/derived/batch/route.ts", "src/app/api/insights/medication-compliance-status/route.ts", // v1.8.7.1 — generic per-HealthKit-metric assessment. Gated on the // same `insightStatus` sub-flag as the seven specialised status routes. @@ -62,17 +55,6 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ // `coach`: a user with assessments enabled but Coach disabled can warm. "src/app/api/insights/pregenerate/route.ts", "src/app/api/insights/pulse-status/route.ts", - // v1.10.0 — device-flagged event awareness timeline (categorical - // events, WX-B). Pure DB read of the device's own verdicts; gates on - // the same `insightStatus` sub-flag as the assessment routes (no Coach - // prose). - "src/app/api/insights/rhythm-events/route.ts", - // v1.28.50 — ECG recording surface (list + per-recording waveform). Pure - // DB read of the device's own recordings + verdicts; gates on the same - // `insightStatus` sub-flag as the assessment routes (no Coach prose — the - // waveform is never interpreted). - "src/app/api/insights/ecg/route.ts", - "src/app/api/insights/ecg/[id]/route.ts", "src/app/api/insights/weight-status/route.ts", ]; @@ -133,6 +115,18 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray = [ "src/app/api/insights/chat/fenced/route.ts", "src/app/api/insights/chat/[id]/attachments/route.ts", "src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts", + // Deterministic reads behind the Insights overview: the comprehensive + // overview query, the derived-metric tiles, the ECG list, strip and live + // ingest, and the device-flagged rhythm events. None of them carries + // assistant prose or calls a provider; they gate on the `insights` module + // only. Switching the assistant off, the master or a sub-flag, must not + // refuse them, or the overview fails to load. + "src/app/api/insights/comprehensive/route.ts", + "src/app/api/insights/derived/route.ts", + "src/app/api/insights/derived/batch/route.ts", + "src/app/api/insights/ecg/route.ts", + "src/app/api/insights/ecg/[id]/route.ts", + "src/app/api/insights/rhythm-events/route.ts", ]; const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")'; diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index e6dde951a..082cc649e 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -88,8 +88,18 @@ vi.mock("@/lib/medication-category", () => ({ getMedicationCategories: vi.fn(async () => ({})), })); +// The route gates on the `insights` module. Mock it default-enabled so the +// envelope assertions ride through; the module-off coverage lives in the +// module route-gate inventory test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; @@ -113,6 +123,8 @@ function makeReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); + // resetAllMocks drops the module gate's default; restore it. + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); __resetAllCachesForTests(); // v1.15.20 — default to an allowing analytics-read budget. vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ @@ -121,7 +133,7 @@ beforeEach(() => { remaining: 119, resetAt: Date.now() + 60_000, }); - // Default to assistant-on so the gate doesn't 403 every test. + // No stored settings row: the provider probe falls back to defaults. (prisma.appSettings.findUnique as ReturnType).mockResolvedValue( null, ); @@ -210,6 +222,34 @@ describe("GET /api/insights/comprehensive — envelope shape", () => { expect(body.data.dataSpanDays).toBe(0); }); + it("answers with the assistant switched off on the server", async () => { + // The overview's main read carries no assistant prose, so an operator who + // switches the assistant off (master flag) must still get the overview. + ( + prisma.appSettings.findUnique as ReturnType + ).mockResolvedValue({ + assistantEnabled: false, + assistantCoachEnabled: false, + assistantBriefingEnabled: false, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: false, + }); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + (buildComprehensiveAggregate as ReturnType).mockResolvedValue( + { + summaries: {}, + bpRawRows: { sys: [], dia: [] }, + weightRawRows: [], + dailyByType: {}, + firstMeasurementAt: null, + totalMeasurements: 0, + }, + ); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + }); + it("computes BMI from aggregate WEIGHT.latest and user heightCm", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); (buildComprehensiveAggregate as ReturnType).mockResolvedValue( diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index a063ea1c5..607537676 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -28,8 +28,8 @@ import { requireRecordAuth, type AuthContext, } from "@/lib/api-handler"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { cachedSwrWithMeta, @@ -49,6 +49,8 @@ export const GET = apiHandler(async () => { // v1.37.0 — MANAGE-level read: computed over the whole record, with no // provider anywhere on the path. const { user } = await requireRecordAuth("manage", "record"); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). const rl = await checkAnalyticsReadRateLimit(user.id); @@ -56,9 +58,10 @@ export const GET = apiHandler(async () => { return apiError("Too many analytics requests. Please retry later.", 429); } - // v1.4.31 — comprehensive feeds the hero strip narration and the - // recommendations grid that share the Coach gate. - await requireAssistantSurface("coach"); + // No assistant-surface gate. Every field is computed from the record + // (the provider chain is only probed for `hasProvider`), and this is the + // main read of the Insights overview: an operator who switches the + // assistant off still gets the overview. // v1.4.35 — read-through the analytics cache keyed on // (userId, "comprehensive"). The /insights page mount routinely diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index 54cd3b294..e7d45d745 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -28,7 +28,6 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { prisma } from "@/lib/db"; import { MeasurementType } from "@/generated/prisma/client"; import { @@ -120,7 +119,8 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user, actor } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; - await requireAssistantSurface("insightStatus"); + // Same deterministic compute as the single route, so no + // assistant-surface gate. // Per-caller limiter, same posture as the compliance routes: the cold // build fans out up to 24 rollup walks, so an unthrottled caller could diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index 4d904aa4c..d51926c9e 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -24,7 +24,6 @@ import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled, type ModuleKey } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { @@ -98,7 +97,8 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiError("Too many analytics requests. Please retry later.", 429); } - await requireAssistantSurface("insightStatus"); + // Pure compute over the rollup tier, so no assistant-surface gate: + // switching the assistant off does not blank the derived tiles. const parsed = derivedQuerySchema.safeParse({ metric: request.nextUrl.searchParams.get("metric"), diff --git a/src/app/api/insights/ecg/[id]/route.ts b/src/app/api/insights/ecg/[id]/route.ts index 1be6a5351..7b05217be 100644 --- a/src/app/api/insights/ecg/[id]/route.ts +++ b/src/app/api/insights/ecg/[id]/route.ts @@ -28,7 +28,6 @@ import { NextRequest } from "next/server"; import { apiError, apiSuccess } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { decryptWaveformFromBytes } from "@/lib/withings/ecg-waveform-codec"; @@ -48,7 +47,7 @@ export const GET = apiHandler( const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; - await requireAssistantSurface("insightStatus"); + // The waveform is never interpreted, so no assistant-surface gate. const { id } = await params; const full = request.nextUrl.searchParams.get("full") === "1"; diff --git a/src/app/api/insights/ecg/route.ts b/src/app/api/insights/ecg/route.ts index 7cdf4a65f..18c28e6fa 100644 --- a/src/app/api/insights/ecg/route.ts +++ b/src/app/api/insights/ecg/route.ts @@ -42,7 +42,6 @@ import { } from "@/lib/api-response"; import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; @@ -113,7 +112,8 @@ export const GET = apiHandler(async () => { const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; - await requireAssistantSurface("insightStatus"); + // A pure read of the device's own recordings, so no assistant-surface + // gate. const rows = await prisma.ecgRecording.findMany({ where: { userId: user.id }, @@ -181,7 +181,8 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; - await requireAssistantSurface("insightStatus"); + // A device ingest carries no assistant prose, so switching the assistant + // off must not refuse a recording. const rl = await checkRateLimit( `insights:ecg:ingest:${user.id}`, diff --git a/src/app/api/insights/rhythm-events/route.ts b/src/app/api/insights/rhythm-events/route.ts index 8ea59813a..db7901301 100644 --- a/src/app/api/insights/rhythm-events/route.ts +++ b/src/app/api/insights/rhythm-events/route.ts @@ -27,7 +27,6 @@ import { apiSuccess } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement"; @@ -49,7 +48,7 @@ export const GET = apiHandler(async () => { const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; - await requireAssistantSurface("insightStatus"); + // A pure read of the device's own verdicts, so no assistant-surface gate. const rows = await prisma.measurement.findMany({ where: { diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 936a33c09..d161b00e6 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -74,6 +74,9 @@ function BlockSkeleton({ ); } +/** Stable reference so the trends row does not re-select on every render. */ +const HIDDEN_MOOD = ["mood"] as const; + const DailyBriefing = dynamic( () => import("@/components/insights/daily-briefing").then((mod) => ({ @@ -513,6 +516,9 @@ export default function InsightsPageClient() { trends: ( diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 7ed4e976d..85d8255a8 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -13,6 +13,7 @@ import { selectTrendCharts, type TrendAnnotationKey, type TrendChartConfig, + type SelectTrendChartsOptions, } from "@/lib/insights/trend-chart-select"; import { TrendAnnotation, @@ -65,6 +66,11 @@ const MoodChart = dynamic( ); interface TrendsRowProps { + /** + * Metrics to leave out because their module is switched off (for + * example `mood`). Passed straight to `selectTrendCharts`. + */ + hiddenMetrics?: SelectTrendChartsOptions["hiddenMetrics"]; /** * Daily briefing payload. Drives the chart set: the row charts the * metrics the briefing flags, in order, deduped + capped. `null` / @@ -132,13 +138,14 @@ export function TrendsRow({ annotations, confidence, loading = false, + hiddenMetrics, }: TrendsRowProps) { const { t } = useTranslations(); // v1.8.5 — derive the chart set from the briefing. No new fetch: the // briefing payload is already on the page (advisor cache), so this is // a pure read that respects the v1.8.3 anti-freeze contract. - const charts = selectTrendCharts(briefing); + const charts = selectTrendCharts(briefing, { hiddenMetrics }); // v1.4.36 W2 T3 — derive the tri-state status per metric from the // advisor's loading flag + the annotation presence. Pending wins diff --git a/src/lib/insights/__tests__/trend-chart-select.test.ts b/src/lib/insights/__tests__/trend-chart-select.test.ts index 209422697..eb812dddb 100644 --- a/src/lib/insights/__tests__/trend-chart-select.test.ts +++ b/src/lib/insights/__tests__/trend-chart-select.test.ts @@ -157,3 +157,24 @@ describe("selectTrendCharts", () => { expect(TREND_CHART_CONFIG.steps?.detailHref).toBe("/insights/steps"); }); }); + +describe("selectTrendCharts — hidden metrics", () => { + it("drops mood from the fallback triple when the mood module is off", () => { + const charts = selectTrendCharts(null, { hiddenMetrics: ["mood"] }); + expect(charts.map((c) => c.metric)).not.toContain("mood"); + expect(charts).toHaveLength(2); + }); + + it("skips a hidden metric the briefing flags", () => { + const charts = selectTrendCharts(briefing(["mood", "weight"]), { + hiddenMetrics: ["mood"], + }); + expect(charts.map((c) => c.metric)).not.toContain("mood"); + expect(charts).toHaveLength(1); + }); + + it("keeps mood when nothing is hidden", () => { + const charts = selectTrendCharts(null, { hiddenMetrics: [] }); + expect(charts.map((c) => c.metric)).toContain("mood"); + }); +}); diff --git a/src/lib/insights/trend-chart-select.ts b/src/lib/insights/trend-chart-select.ts index 31e04a6aa..2c3114ecb 100644 --- a/src/lib/insights/trend-chart-select.ts +++ b/src/lib/insights/trend-chart-select.ts @@ -267,16 +267,24 @@ export const DEFAULT_TREND_CHART_CAP = 3; export interface SelectTrendChartsOptions { /** Max number of charts to return. Defaults to {@link DEFAULT_TREND_CHART_CAP}. */ cap?: number; + /** + * Metrics whose module is switched off. They are skipped on both the + * briefing path and the fallback triple, so a disabled module never + * charts on the overview. + */ + hiddenMetrics?: ReadonlyArray; } function configsFor( metrics: ReadonlyArray, cap: number, + hidden: ReadonlySet, ): TrendChartConfig[] { const seen = new Set(); const out: TrendChartConfig[] = []; for (const metric of metrics) { if (out.length >= cap) break; + if (hidden.has(metric)) continue; // module switched off const config = TREND_CHART_CONFIG[metric]; if (!config) continue; // metric has no standalone trend chart if (seen.has(config.metric)) continue; // dedupe @@ -301,14 +309,15 @@ export function selectTrendCharts( options: SelectTrendChartsOptions = {}, ): TrendChartConfig[] { const cap = Math.max(1, options.cap ?? DEFAULT_TREND_CHART_CAP); + const hidden = new Set(options.hiddenMetrics ?? []); const findingMetrics = briefing?.keyFindings?.map((f) => f.sourceMetric) ?? []; - const fromBriefing = configsFor(findingMetrics, cap); + const fromBriefing = configsFor(findingMetrics, cap, hidden); if (fromBriefing.length > 0) { return fromBriefing; } - return configsFor(DEFAULT_TREND_METRICS, cap); + return configsFor(DEFAULT_TREND_METRICS, cap, hidden); } diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index 4d4f0d0f5..5f1705961 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -879,7 +879,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "ECG recording list (metadata only)", description: - "v1.28.50 — the authenticated user's ECG recordings as a cheap, index-covered metadata list (recorded time, duration, sampling rate, sample count, average heart rate, lead, and the DEVICE's own rhythm classification). NEVER decrypts or returns the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Reflects only the recording device's certified on-device classification, verbatim; HealthLog never re-classifies an ECG or produces a diagnosis. Data-availability-gated: an empty account returns `hasRecordings: false`. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer.", + "v1.28.50 — the authenticated user's ECG recordings as a cheap, index-covered metadata list (recorded time, duration, sampling rate, sample count, average heart rate, lead, and the DEVICE's own rhythm classification). NEVER decrypts or returns the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Reflects only the recording device's certified on-device classification, verbatim; HealthLog never re-classifies an ECG or produces a diagnosis. Data-availability-gated: an empty account returns `hasRecordings: false`. Module-gated on `insights`; no assistant-surface gate and no LLM call. Auth via cookie or Bearer.", responses: { "200": { description: "The ECG recording list (possibly empty).", @@ -897,7 +897,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Ingest one ECG recording", description: - "The live ECG ingest: one Apple Watch recording per request, for a client draining its HealthKit ECG observer. Before this existed, a watch ECG could only reach HealthLog inside a full `export.zip`. One recording per request because a 30 s / 512 Hz strip is ~15 360 samples. Samples are INTEGER MICRO-VOLTS (convert from HealthKit's Volts), stored AES-256-GCM encrypted; `sampleCount` and `durationSeconds` are derived server-side. The `classification` is the device's own verdict stored verbatim — HealthLog never reads the waveform to produce or revise one. `source` accepts `APPLE_HEALTH` only; `userId` comes from the session and is never a body field. Unknown body keys are rejected with a 422 naming them. No `Idempotency-Key` is needed: the recording carries its own identity, so a retry resolves to the same row by construction — see `status` for what a re-post reports. Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer.", + "The live ECG ingest: one Apple Watch recording per request, for a client draining its HealthKit ECG observer. Before this existed, a watch ECG could only reach HealthLog inside a full `export.zip`. One recording per request because a 30 s / 512 Hz strip is ~15 360 samples. Samples are INTEGER MICRO-VOLTS (convert from HealthKit's Volts), stored AES-256-GCM encrypted; `sampleCount` and `durationSeconds` are derived server-side. The `classification` is the device's own verdict stored verbatim — HealthLog never reads the waveform to produce or revise one. `source` accepts `APPLE_HEALTH` only; `userId` comes from the session and is never a body field. Unknown body keys are rejected with a 422 naming them. No `Idempotency-Key` is needed: the recording carries its own identity, so a retry resolves to the same row by construction — see `status` for what a re-post reports. Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Module-gated on `insights`; no assistant-surface gate and no LLM call. Auth via cookie or Bearer.", requestBody: { required: true, content: { @@ -938,7 +938,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "One ECG recording with waveform", description: - "v1.28.50 — one recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is narrowed in the query where (`{ id, userId }`) so a cross-user read is structurally impossible; a foreign or unknown id 404s (existence sealed). The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. By default the ~9000-sample strip is min/max-decimated to ~2500 display points so R-wave peaks survive; `?full=1` returns the raw array. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. `no-store`. Auth via cookie or Bearer.", + "v1.28.50 — one recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is narrowed in the query where (`{ id, userId }`) so a cross-user read is structurally impossible; a foreign or unknown id 404s (existence sealed). The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. By default the ~9000-sample strip is min/max-decimated to ~2500 display points so R-wave peaks survive; `?full=1` returns the raw array. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. Module-gated on `insights`; no assistant-surface gate and no LLM call. `no-store`. Auth via cookie or Bearer.", requestParams: { path: z.object({ id: z.string().describe("The ECG recording id (cuid)."), @@ -973,7 +973,7 @@ export const insightsPaths: NonNullable = { 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.", + "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`; no assistant-surface gate and no LLM call. Auth via cookie or Bearer.", responses: { "200": { description: "The device-flagged event timeline (possibly empty).",