Merge pull request #982 from MBombeck/fix/insights-without-assistant

fix(insights): keep the overview reads open with the assistant switched off
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-16 13:39:54 +02:00
commit c79cd3a0ed
14 files changed
+128 -49

No files matched your search

+8 -8
View File
@@ -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).
@@ -42,13 +42,6 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
"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<string> = [
// `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<string> = [
"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")';
@@ -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<typeof import("@/lib/modules/gate")>()),
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<typeof vi.fn>).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<typeof vi.fn>
).mockResolvedValue({
assistantEnabled: false,
assistantCoachEnabled: false,
assistantBriefingEnabled: false,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: false,
});
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(
+7 -4
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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"),
+1 -2
View File
@@ -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";
+4 -3
View File
@@ -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}`,
+1 -2
View File
@@ -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: {
+6
View File
@@ -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: (
<TrendsRow
briefing={briefingPayload}
// Same module gate the tab strip applies to the Mood pill: with
// the mood module off, the trends row does not chart mood either.
hiddenMetrics={user?.modules?.mood === false ? HIDDEN_MOOD : undefined}
annotations={advisor.payload?.trendAnnotations ?? null}
loading={advisor.isLoading || advisor.isRegenerating}
/>
+8 -1
View File
@@ -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
@@ -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");
});
});
+11 -2
View File
@@ -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<DailyBriefingKeyFinding["sourceMetric"]>;
}
function configsFor(
metrics: ReadonlyArray<DailyBriefingKeyFinding["sourceMetric"]>,
cap: number,
hidden: ReadonlySet<string>,
): TrendChartConfig[] {
const seen = new Set<string>();
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<string>(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);
}
+4 -4
View File
@@ -879,7 +879,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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).",