From 4ad65a5b7db2b90fceeb93ecfc9540f28a1dba65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 20:01:42 +0200 Subject: [PATCH] feat(insights): add GET /api/insights/derived + OpenAPI contract Generic route mirroring the v1.8.7.1 metric-status precedent: GET /api/insights/derived?metric=[&type=]. apiHandler + Zod safeParse on the query, closed registry enum (unknown id 422s via returnAllZodIssues), cookie-or-Bearer auth, userId narrowed from the session (never a query field). The profile is read once and passed into the pure compute function. Returns the flat Derived union in the standard { data, error, meta } envelope so the native client decodes one stable shape and combines values across metrics. Register the query + response schemas (DerivedMetricQuery, DerivedMetricResponse + DerivedCoverage/Confidence/Provenance) and the path operation in the OpenAPI registry; regenerate docs/api/openapi.yaml. Add the route to the insights gate inventory (insightStatus-gated, no Coach prose). Route tests cover the 200 flat-envelope shape, session-narrowed userId, 422 on an unknown / missing metric, 401 unauthenticated, and the insufficient flattening (null value + confidence, populated reason). --- docs/api/openapi.yaml | 216 ++++++++++++++++++ .../coach-route-gate-inventory.test.ts | 4 + .../insights/derived/__tests__/route.test.ts | 148 ++++++++++++ src/app/api/insights/derived/route.ts | 103 +++++++++ src/lib/openapi/routes.ts | 132 +++++++++++ 5 files changed, 603 insertions(+) create mode 100644 src/app/api/insights/derived/__tests__/route.test.ts create mode 100644 src/app/api/insights/derived/route.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ffda5e6e9..6f5607bbf 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2307,6 +2307,68 @@ paths: "401": *a1 "422": *a2 "429": *a3 + /api/insights/derived: + get: + tags: + - Insights + summary: Derived wellness metric (compute-once) + description: v1.10.0 — the compute-once `Derived` value for any registered derived wellness metric (personal + typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, + coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure + compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no + cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine + values across metrics. Auth via cookie or Bearer. + parameters: + - in: query + name: metric + schema: + type: string + enum: + - VITALS_BASELINE + - FITNESS_AGE + - VASCULAR_AGE_DELTA + - SLEEP_SCORE + - READINESS + - COINCIDENT_DEVIATION + description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, READINESS). Closed enum: an unknown id + 422s. Wave 1 implements VITALS_BASELINE end-to-end; not-yet-implemented metrics return an `insufficient` + value with reason `not_implemented`." + required: true + description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, READINESS). Closed enum: an unknown id + 422s. Wave 1 implements VITALS_BASELINE end-to-end; not-yet-implemented metrics return an `insufficient` + value with reason `not_implemented`." + - in: query + name: type + schema: + description: For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by + composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric + combinations stay forgiving. + type: string + enum: + - RESTING_HEART_RATE + - HEART_RATE_VARIABILITY + - RESPIRATORY_RATE + - OXYGEN_SATURATION + - BODY_TEMPERATURE + - SKIN_TEMPERATURE + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - BLOOD_GLUCOSE + - PULSE + - WEIGHT + description: For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by + composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric combinations + stay forgiving. + responses: + "200": + description: The flat derived-metric value (ok or insufficient). + content: + application/json: + schema: + $ref: "#/components/schemas/DerivedMetricResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 components: schemas: PasskeyLoginVerifyRequest: @@ -7241,6 +7303,160 @@ components: description: "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile." + DerivedMetricResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DerivedMetricResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DerivedMetricResponse: + type: object + properties: + metric: + type: string + enum: + - VITALS_BASELINE + - FITNESS_AGE + - VASCULAR_AGE_DELTA + - SLEEP_SCORE + - READINESS + - COINCIDENT_DEVIATION + description: Echoes the requested derived-metric id (tags the union). + status: + type: string + enum: + - ok + - insufficient + description: "'ok' carries `value` + `confidence`; 'insufficient' carries `reason` and no value, but still carries + `coverage` + `provenance` so the surface renders the same gating UI." + value: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: {} + - type: "null" + description: Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k } + for VITALS_BASELINE); null when 'insufficient'. + coverage: + $ref: "#/components/schemas/DerivedCoverage" + confidence: + anyOf: + - $ref: "#/components/schemas/DerivedConfidence" + - type: "null" + description: Present when status is 'ok'; null when 'insufficient'. + provenance: + $ref: "#/components/schemas/DerivedProvenance" + reason: + anyOf: + - type: string + - type: "null" + description: Why the value could not be produced; null when status is 'ok'. + required: + - metric + - status + - value + - coverage + - confidence + - provenance + - reason + additionalProperties: false + description: Flat `Derived` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no + narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let + it render the same honesty chips. + DerivedCoverage: + type: object + properties: + requiredInputs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Inputs the metric wants (its full input set). + presentInputs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Inputs actually present in the user's data. + historyDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Distinct days of history backing the value (the gating floor). + missing: + type: array + items: + type: string + description: Named inputs still missing — drives the 'track N more' nudge. + required: + - requiredInputs + - presentInputs + - historyDays + - missing + additionalProperties: false + DerivedConfidence: + type: object + properties: + score: + type: number + description: 0..100 confidence; feeds the shared coverage meter unchanged. + band: + type: string + enum: + - high + - medium + - low + - draft + description: Confidence band the meter renders. + required: + - score + - band + additionalProperties: false + DerivedProvenance: + type: object + properties: + inputs: + type: array + items: + type: string + description: Named inputs that actually backed the value. + source: + type: string + enum: + - DAY + - WEEK + - MONTH + - YEAR + - live + - none + description: Granularity the dominant read resolved against. 'live' = a coverage-miss live-SQL fallback; 'none' = no + data backed the value. + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Trailing window the value summarises, in days. + computedAt: + 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: Compute time (for cache-staleness + the 'as of' chip). + required: + - inputs + - source + - windowDays + - computedAt + additionalProperties: false MedicationScheduleOutput: type: object properties: 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 8b4b61ac7..a8c5a8145 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 @@ -39,6 +39,10 @@ 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", "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. diff --git a/src/app/api/insights/derived/__tests__/route.test.ts b/src/app/api/insights/derived/__tests__/route.test.ts new file mode 100644 index 000000000..7711d0fcf --- /dev/null +++ b/src/app/api/insights/derived/__tests__/route.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + user: { + findUnique: vi.fn().mockResolvedValue({ + dateOfBirth: new Date("1986-01-01"), + gender: "MALE", + }), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Stub the compute layer so the route test stays isolated. +vi.mock("@/lib/insights/derived", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + computeDerivedMetric: vi.fn(async () => ({ + status: "ok", + value: { type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 }, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] }, + confidence: { score: 100, band: "high" }, + provenance: { inputs: ["RESTING_HEART_RATE"], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" }, + })), + }; +}); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { computeDerivedMetric } from "@/lib/insights/derived"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(metric?: string, type?: string): NextRequest { + const url = new URL("http://localhost/api/insights/derived"); + if (metric !== undefined) url.searchParams.set("metric", metric); + if (type !== undefined) url.searchParams.set("type", type); + return new NextRequest(url); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: new Date("1986-01-01"), + gender: "MALE", + } as never); +}); + +describe("GET /api/insights/derived", () => { + it("returns 200 + a flat Derived envelope for a valid metric", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("VITALS_BASELINE", "RESTING_HEART_RATE")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + metric: string; + status: string; + value: { center: number } | null; + coverage: { historyDays: number }; + confidence: { band: string } | null; + provenance: { source: string }; + reason: string | null; + } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.metric).toBe("VITALS_BASELINE"); + expect(body.data?.status).toBe("ok"); + expect(body.data?.value?.center).toBe(55); + expect(body.data?.confidence?.band).toBe("high"); + expect(body.data?.provenance.source).toBe("DAY"); + expect(body.data?.reason).toBeNull(); + }); + + it("narrows userId from the session into the compute call (never a query field)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq("VITALS_BASELINE")); + expect(computeDerivedMetric).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", metric: "VITALS_BASELINE" }), + ); + }); + + it("422s on an unknown metric without calling compute", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("NOT_A_METRIC")); + expect(res.status).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + }); + + it("422s on a missing metric param", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + }); + + it("401s when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq("VITALS_BASELINE")); + expect(res.status).toBe(401); + }); + + it("flattens an insufficient result with a null value + reason", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(computeDerivedMetric).mockResolvedValueOnce({ + status: "insufficient", + coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: ["RESTING_HEART_RATE"] }, + provenance: { inputs: ["RESTING_HEART_RATE"], source: "none", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" }, + reason: "no_readings_in_window", + } as never); + const res = await callGet(makeReq("VITALS_BASELINE")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { status: string; value: unknown; confidence: unknown; reason: string } | null; + }; + expect(body.data?.status).toBe("insufficient"); + expect(body.data?.value).toBeNull(); + expect(body.data?.confidence).toBeNull(); + expect(body.data?.reason).toBe("no_readings_in_window"); + }); +}); diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts new file mode 100644 index 000000000..afceaec9f --- /dev/null +++ b/src/app/api/insights/derived/route.ts @@ -0,0 +1,103 @@ +/** + * v1.10.0 — generic derived-wellness-metric route. + * + * `GET /api/insights/derived?metric=[&type=]` + * serves the compute-once `Derived` value for any metric registered + * in `derived/registry.ts`. Mirrors the v1.8.7.1 `metric-status` route + * precedent: `apiHandler` wrapper, Zod `safeParse` on the query, a closed + * registry enum (unknown id → 422 via `returnAllZodIssues`), cookie OR + * Bearer auth, `userId` narrowed from the session/Bearer (never a query + * field). + * + * The `data` payload is the flat `Derived` union so iOS can decode + + * combine values across metrics: `{ metric, status, value?, coverage, + * confidence?, provenance, reason? }`. Numbers are pure compute over the + * rollup tier (Tier-1, no narrative) — no provider call, no cache table. + * + * Wave 1 implements `VITALS_BASELINE` end-to-end; other registered ids + * return a `not_implemented` insufficient until their compute lands in a + * later wave (never a 500, never a fabricated value). + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { prisma } from "@/lib/db"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { + computeDerivedMetric, + DERIVED_METRIC_IDS, + type DerivedMetricId, +} from "@/lib/insights/derived"; + +export const dynamic = "force-dynamic"; + +// Closed enum derived from the registry so the route + registry cannot +// drift — an id added to the registry is accepted automatically, an +// unknown id 422s rather than silently resolving to no metric. +const derivedQuerySchema = z.object({ + metric: z.enum(DERIVED_METRIC_IDS as [string, ...string[]]), + // Optional sub-target for metrics that baseline a single chosen vital + // (VITALS_BASELINE). Validated against the engine's supported set in + // the dispatcher; an unsupported value yields an `insufficient`, not a + // 422, so the contract stays forgiving for iOS combinations. + type: z.string().optional(), +}); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + await requireAssistantSurface("insightStatus"); + + const parsed = derivedQuerySchema.safeParse({ + metric: request.nextUrl.searchParams.get("metric"), + type: request.nextUrl.searchParams.get("type") ?? undefined, + }); + if (!parsed.success) { + annotate({ + action: { name: "insights.derived.invalid-metric" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + const metric = parsed.data.metric as DerivedMetricId; + + // Profile read once, passed into the pure compute function (never + // re-fetched per metric — the pool-contention mitigation). + const profile = await prisma.user.findUnique({ + where: { id: user.id }, + select: { dateOfBirth: true, gender: true }, + }); + const sex = + profile?.gender === "MALE" || profile?.gender === "FEMALE" + ? profile.gender + : null; + + const derived = await computeDerivedMetric({ + metric, + userId: user.id, + profile: { + ageYears: getAgeFromDateOfBirth(profile?.dateOfBirth ?? null), + sex, + }, + type: parsed.data.type ?? null, + }); + + annotate({ + action: { name: "insights.derived" }, + meta: { metric, status: derived.status }, + }); + + // Flatten the discriminated union for the wire: `metric` tags it, + // `value`/`reason` are nullable so iOS decodes one stable shape. + return apiSuccess({ + metric, + status: derived.status, + value: derived.status === "ok" ? derived.value : null, + coverage: derived.coverage, + confidence: derived.status === "ok" ? derived.confidence : null, + provenance: derived.provenance, + reason: derived.status === "insufficient" ? derived.reason : null, + }); +}); diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index a0e29069b..f2e299523 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -47,6 +47,10 @@ import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-pr import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout"; import { exportSelectionSchema } from "@/lib/validations/health-record-export"; import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; +import { + DERIVED_METRIC_IDS, + VITALS_BASELINE_TYPES, +} from "@/lib/insights/derived/registry"; import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; /** @@ -1278,6 +1282,109 @@ const metricStatusResponse = z "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", }); +// v1.10.0 — generic derived-wellness-metric route. The query enum is +// derived from the same registry the route validates against, so spec + +// route + cache scope cannot drift. `type` sub-targets the single vital +// a baseline metric (VITALS_BASELINE) bands over. +const derivedMetricQuery = z + .object({ + metric: z + .enum(DERIVED_METRIC_IDS as [string, ...string[]]) + .describe( + "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, READINESS). Closed enum: an unknown id 422s. Wave 1 implements VITALS_BASELINE end-to-end; not-yet-implemented metrics return an `insufficient` value with reason `not_implemented`.", + ), + type: z + .enum(VITALS_BASELINE_TYPES as [string, ...string[]]) + .optional() + .describe( + "For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric combinations stay forgiving.", + ), + }) + .meta({ id: "DerivedMetricQuery" }); + +const derivedCoverage = z + .object({ + requiredInputs: z + .number() + .int() + .describe("Inputs the metric wants (its full input set)."), + presentInputs: z + .number() + .int() + .describe("Inputs actually present in the user's data."), + historyDays: z + .number() + .int() + .describe("Distinct days of history backing the value (the gating floor)."), + missing: z + .array(z.string()) + .describe("Named inputs still missing — drives the 'track N more' nudge."), + }) + .meta({ id: "DerivedCoverage" }); + +const derivedConfidence = z + .object({ + score: z + .number() + .describe("0..100 confidence; feeds the shared coverage meter unchanged."), + band: z + .enum(["high", "medium", "low", "draft"]) + .describe("Confidence band the meter renders."), + }) + .meta({ id: "DerivedConfidence" }); + +const derivedProvenance = z + .object({ + inputs: z + .array(z.string()) + .describe("Named inputs that actually backed the value."), + source: z + .enum(["DAY", "WEEK", "MONTH", "YEAR", "live", "none"]) + .describe( + "Granularity the dominant read resolved against. 'live' = a coverage-miss live-SQL fallback; 'none' = no data backed the value.", + ), + windowDays: z + .number() + .int() + .describe("Trailing window the value summarises, in days."), + computedAt: z.iso + .datetime({ offset: true }) + .describe("Compute time (for cache-staleness + the 'as of' chip)."), + }) + .meta({ id: "DerivedProvenance" }); + +const derivedMetricResponse = z + .object({ + metric: z + .enum(DERIVED_METRIC_IDS as [string, ...string[]]) + .describe("Echoes the requested derived-metric id (tags the union)."), + status: z + .enum(["ok", "insufficient"]) + .describe( + "'ok' carries `value` + `confidence`; 'insufficient' carries `reason` and no value, but still carries `coverage` + `provenance` so the surface renders the same gating UI.", + ), + value: z + .record(z.string(), z.unknown()) + .nullable() + .describe( + "Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k } for VITALS_BASELINE); null when 'insufficient'.", + ), + coverage: derivedCoverage, + confidence: derivedConfidence + .nullable() + .describe("Present when status is 'ok'; null when 'insufficient'."), + provenance: derivedProvenance, + reason: z + .string() + .nullable() + .describe("Why the value could not be produced; null when status is 'ok'."), + }) + .meta({ + id: "DerivedMetricResponse", + description: + "Flat `Derived` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let it render the same honesty chips.", + }); + // The seven specialised `*-status` routes accept an optional locale // override (the metric is fixed by the route path, unlike the generic // metric-status route which carries it as a query field). @@ -2996,6 +3103,31 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/insights/derived": { + get: { + tags: ["Insights"], + summary: "Derived wellness metric (compute-once)", + description: + "v1.10.0 — the compute-once `Derived` value for any registered derived wellness metric (personal typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine values across metrics. Auth via cookie or Bearer.", + requestParams: { + query: derivedMetricQuery, + }, + responses: { + "200": { + description: "The flat derived-metric value (ok or insufficient).", + content: { + "application/json": { + schema: dataEnvelope( + derivedMetricResponse, + "DerivedMetricResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, }; export const openApiComponents: NonNullable = {