diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3930a4101..6b564e0a9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4209,10 +4209,10 @@ paths: resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`) are computed server-side. `aiInsight` is a pure read of a per-workout paragraph written by a background job when the workout landed; it is null for every - workout that predates the feature or was re-synced, and reading this endpoint never generates one. `compact=1` - (sent by the web client) drops the raw `samples.samples` HR blob and `route.sampleTimestamps` array from the - response; without it the payload is byte-identical to the v1.4.32 contract. Cross-user rows surface as 404 - (existence channel sealed). + workout that predates the feature or was re-synced, and while the `workoutInsights` AI capability is unavailable + (`ai` says why), and reading this endpoint never generates one. `compact=1` (sent by the web client) drops the + raw `samples.samples` HR blob and `route.sampleTimestamps` array from the response; without it the payload is + byte-identical to the v1.4.32 contract. Cross-user rows surface as 404 (existence channel sealed). parameters: - in: path name: id @@ -4233,7 +4233,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a16 + "403": &a17 description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' @@ -8428,13 +8428,13 @@ paths: tags: - Insights summary: List the caller's Coach conversations - description: v1.18.0 — cursor-paginated list of the caller's Coach conversations for the history rail, most-recent + description: "v1.18.0 — cursor-paginated list of the caller's Coach conversations for the history rail, most-recent activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here. `limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at the end). v1.30.2 — optional `q` narrows the page to conversations whose TITLE contains the text (case-insensitive substring, capped at 200 chars); message bodies are encrypted at rest and are not searched. - Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner - is narrowed from the session. + Never AI-gated: stored conversations are the person's data and stay listable, readable and deletable while the + Coach is unavailable for any reason. Auth via cookie or Bearer; the owner is narrowed from the session." parameters: - name: cursor in: query @@ -8473,16 +8473,19 @@ paths: tags: - Insights summary: Send a Coach turn (streaming reply) - description: "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is - `text/event-stream`, not JSON: one `data: \\n\\n` frame per event. Frame `type` is one of `token` (a chunk + description: 'v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is + `text/event-stream`, not JSON: one `data: \n\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, - code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` - frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts - a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer." + code, message, reason? }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the + `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting + `conversationId` starts a new conversation. Requires the `coach` AI capability, checked right after auth: an + unavailable Coach is refused with the capability envelope before the stream opens, except a missing provider, + which keeps its `coach.provider.none` error frame and adds `reason: "no_provider"`. Budget- and rate-limited. + Auth via cookie or Bearer.' requestBody: required: true content: @@ -8499,8 +8502,11 @@ paths: description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 "403": - description: "Coach surface disabled (`errorCode: assistant.disabled.coach`) or AI consent required (`errorCode: - consent.ai.required`)." + description: "The `coach` AI capability is unavailable for this record. `meta.capability` = `coach` and `meta.reason` + says which layer refused: the operator's switch (`assistant.disabled.coach`, or `assistant.disabled.enabled` + for the master switch), an owning module or the person's own AI opt-out (`module.disabled` with + `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), or a missing consent + receipt (`consent.ai.required`). Nothing was generated, read from a stored model answer, or enqueued." content: application/json: schema: @@ -8513,15 +8519,22 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + "503": &a16 + description: "The AI capability could not be resolved (its inputs could not be read) and the answer failed closed: + `meta.errorCode` = `ai.unavailable`, `meta.reason` = `check_failed`. Retry later; no data depends on it." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/insights/chat/{id}: get: tags: - Insights summary: Read one Coach conversation with all messages - description: v1.18.0 — returns one conversation with every message decrypted server-side and ordered oldest-first, plus + description: "v1.18.0 — returns one conversation with every message decrypted server-side and ordered oldest-first, plus the rolling `summary` when one is on file. The client renders the bodies directly; no decryption key is involved. A foreign / unknown id maps to 404 (never 403) so the existence channel does not leak across accounts. - Coach-gated. Auth via cookie or Bearer. + Never AI-gated: a stored conversation stays readable while the Coach is unavailable. Auth via cookie or Bearer." parameters: - name: id in: path @@ -8552,7 +8565,7 @@ paths: description: Sets the conversation's title. The only field this verb writes — the body is `.strict()`, so an unrecognised key is REFUSED rather than ignored, unlike most partial updates on this surface. The title is trimmed and must be 1..80 characters after trimming. A foreign or unknown id maps to 404 (never 403), so the - existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer; the caller is always + existence channel does not leak across accounts. Never AI-gated. Auth via cookie or Bearer; the caller is always resolved as themselves, so a conversation in a shared record is not reachable here. parameters: - name: id @@ -8594,8 +8607,8 @@ paths: tags: - Insights summary: Delete one Coach conversation - description: v1.18.0 — hard-deletes a conversation and every message under it. A foreign / unknown id maps to 404 (never - 403). Coach-gated. Auth via cookie or Bearer. + description: "v1.18.0 — hard-deletes a conversation and every message under it. A foreign / unknown id maps to 404 + (never 403). Never AI-gated: erasure works while the Coach is unavailable. Auth via cookie or Bearer." parameters: - name: id in: path @@ -8807,10 +8820,11 @@ paths: tags: - Insights summary: List the caller's durable Coach facts - description: v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then newest - first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Coach-gated - (`requireAssistantSurface("coach")`). Auth via cookie or Bearer; the owner is always narrowed from the session, - never the body. Undecryptable rows are omitted rather than failing the read. + description: "v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then + newest first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Never AI-gated: the + facts stay readable and erasable while the Coach is unavailable for any reason. Auth via cookie or Bearer; the + owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing + the read." responses: "200": description: The caller's active facts. @@ -8826,7 +8840,7 @@ paths: - Insights summary: Forget all of the caller's Coach facts description: "v1.11.1 — bulk 'forget what you know about me': soft-deletes every active fact for the caller and returns - the count cleared. Idempotent (a second call clears 0). Coach-gated. Auth via cookie or Bearer." + the count cleared. Idempotent (a second call clears 0). Never AI-gated. Auth via cookie or Bearer." responses: "200": description: All active facts cleared; the count is returned. @@ -8844,7 +8858,7 @@ paths: summary: Forget one Coach fact description: "v1.11.1 — soft-deletes a single fact owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. - Coach-gated. Auth via cookie or Bearer." + Never AI-gated. Auth via cookie or Bearer." responses: "200": description: "The fact was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`)." @@ -8860,12 +8874,13 @@ paths: tags: - Insights summary: Whether an unopened Coach message is waiting - description: v1.18.6 (CCH-03) — server-authoritative unread signal for the Coach FAB. `unread` is true when the caller's - newest Coach ASSISTANT message (a proactive nudge or any reply) is newer than `User.coachLastSeenAt`; a user who - has never opened the Coach reads an existing nudge as unread exactly once. `nudgedAt` carries that newest - assistant-message timestamp (null when none exists) so the client can key a local seen-mirror on a stable value. - Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner - is narrowed from the session. + description: "v1.18.6 (CCH-03) — server-authoritative unread signal for the Coach FAB. `unread` is true when the + caller's newest Coach ASSISTANT message (a proactive nudge or any reply) is newer than `User.coachLastSeenAt`; a + user who has never opened the Coach reads an existing nudge as unread exactly once. `nudgedAt` carries that + newest assistant-message timestamp (null when none exists) so the client can key a local seen-mirror on a stable + value. Never refused for an AI reason: while the `coach` capability is unavailable the answer is the quiet `{ + unread: false, nudgedAt: null, conversationId: null }` with `ai` saying why. Auth via cookie or Bearer; the + owner is narrowed from the session." responses: "200": description: The current unread state. @@ -8882,11 +8897,11 @@ paths: tags: - Insights summary: Mark the Coach as opened (clear the unread dot) - description: v1.18.6 (CCH-03) — opening the Coach (drawer or full page) stamps `User.coachLastSeenAt = now()`, so `GET + description: "v1.18.6 (CCH-03) — opening the Coach (drawer or full page) stamps `User.coachLastSeenAt = now()`, so `GET /api/insights/coach/nudge-status` then reports no assistant message newer than the stamp and the FAB drops the unread dot. Server-authoritative, so the cleared state follows the caller across web + iOS rather than just the opening device. No request body — the timestamp is server-minted, so a client can never backdate the stamp to - suppress a future nudge. Coach-gated; a disabled surface 403s. Auth via cookie or Bearer. + suppress a future nudge. Never AI-gated: a timestamp on the caller's own row. Auth via cookie or Bearer." responses: "200": description: The Coach was marked opened; the stamp is echoed back. @@ -8902,10 +8917,13 @@ paths: tags: - Insights summary: Read the caller's self-context - description: v1.16.0 — returns the structured self-context (free text plus chronic conditions, allergies, coach focus) + description: "v1.16.0 — returns the structured self-context (free text plus chronic conditions, allergies, coach focus) the Coach system prompt and the daily briefing inject as a delimited, user-provided context block, alongside any pending clarifying questions. Every field is stored encrypted at rest; an undecryptable payload reads as null - (fail closed). Auth via cookie or Bearer; the owner is always narrowed from the session. + (fail closed). The questions are the one AI part: stored questions cannot be told apart by origin, so while the + `aboutMeQuestions` capability is unavailable an outstanding set is replaced by the deterministic completion + hints for the same profile, and `ai` says why. Auth via cookie or Bearer; the owner is always narrowed from the + session." responses: "200": description: The stored fields (null when never written / cleared) plus pending clarifying questions. @@ -8923,11 +8941,11 @@ paths: description: "v1.16.0 — persists the free text (4 000-char cap) and the three structured fields (500-char cap each) encrypted at rest; caps are enforced before encryption. Structured fields are optional: omitted leaves the stored value untouched, an empty string clears it. After a non-empty save the server derives up to 3 clarifying - questions (AI when a provider and the daily Coach token budget allow, deterministic completion hints otherwise) - and returns them as `pendingQuestions`. Rate-limited per user. Optimistic concurrency (v1.32.21): send - `baseUpdatedAt` (the `updatedAt` from a prior read) and the write 409s if the stored self-context changed since; - omit it for the legacy unconditional write. The token is per-surface (`UserHealthProfile.updatedAt`), so it is - not perturbed by unrelated account writes." + questions (AI when the `aboutMeQuestions` capability is available and the daily Coach token budget allows, + deterministic completion hints otherwise, with no provider call) and returns them as `pendingQuestions`. + Rate-limited per user. Optimistic concurrency (v1.32.21): send `baseUpdatedAt` (the `updatedAt` from a prior + read) and the write 409s if the stored self-context changed since; omit it for the legacy unconditional write. + The token is per-surface (`UserHealthProfile.updatedAt`), so it is not perturbed by unrelated account writes." requestBody: required: true content: @@ -9770,9 +9788,9 @@ paths: tags: - Insights summary: Rate a Coach assistant message - description: Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback + description: "Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone, - verbosity). + verbosity). Never AI-gated: a rating on a message that already exists calls no model." requestBody: required: true content: @@ -9876,7 +9894,11 @@ paths: summary: Unified dashboard first-paint snapshot description: "Assembles every above-the-fold tile field in one round-trip from the rollup / mood / widget helpers plus a read-only lift of the pre-generated daily briefing. Two-phase: `tiles` always present, `extras` nullable on a - rollup-coverage miss. No LLM is reachable from this path. Cookie or Bearer auth." + rollup-coverage miss. No LLM is reachable from this path. The briefing is model text: it, its recall + (`briefingMemory`) and its timestamp are served only while the `briefing` AI capability is available for the + record, decided on every read rather than cached with the body; `briefingAi` carries the state and + `briefingState` reads `no-provider` or `disabled` otherwise. Every other field is data and never depends on AI. + Cookie or Bearer auth." responses: "200": description: Dashboard snapshot. @@ -9892,37 +9914,22 @@ paths: get: tags: - Insights - summary: Comprehensive AI insights bundle - description: Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard - annotations. Strict-schema validated server-side. Requires an active ConsentReceipt when the resolved provider - chain egresses via the operator's server-managed key (see POST /api/consent/ai). + summary: The Insights overview's computed read + description: "The main read of the Insights overview: summaries, classifications, correlations, medication compliance + and rule alerts, all computed from the record. No provider is called and no AI switch, opt-out, provider or + consent refuses it; `hasProvider` is provider presence only. The `ai` block beside the body says whether the + briefing and the status notes that decorate the overview can be shown, resolved per request. Served + stale-while-revalidate; `revalidating` marks a stale body. Delegable at MANAGE level over the whole record. + Cookie or Bearer auth." responses: "200": - description: Insights bundle. + description: The overview read. content: application/json: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 - "403": - description: >- - AI consent required: no active ConsentReceipt for the server-managed provider. `meta.errorCode` = - `consent.ai.required`. Mint a receipt via POST /api/consent/ai before retrying. - - - Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). - Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections - do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it - is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's - audit trail instead. On the cookie transport the same code answers one further case: a session that has - entered a shared record and sends a request without its record-context assertion, which is a client older - than the fence — leave the record and reload. A request that names no record but the caller's own never - reaches this response, so a client that never switches and never sends the per-request account selector will - not see it. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "422": *a4 "429": *a5 /api/insights/cards: @@ -9930,11 +9937,11 @@ paths: tags: - Insights summary: Insight cards (iOS adapter) - description: "v1.4.31 — the native-client adapter over the same alert rule engine the web comprehensive surface + description: 'v1.4.31 — the native-client adapter over the same alert rule engine the web comprehensive surface consumes: measurements, BP-in-target, weight trend, pulse, and cadence-aware medication compliance are fed through `generateAlerts()` and each resulting `HealthAlert` is re-shaped to the iOS Insight card model. - Deterministic — no LLM call on this path. Module-gated on `insights` and the operator `insightStatus` assistant - surface. Auth via cookie or Bearer." + Deterministic — no LLM call on this path, so no AI switch, opt-out or module refuses it. Every card is a rule + alert: `provider` is `"rules"` and `summary` is never null. Auth via cookie or Bearer.' responses: "200": description: The list of insight cards (possibly empty). @@ -9943,7 +9950,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -9951,12 +9958,15 @@ paths: tags: - Insights summary: Warm all AI assessments for the calling user - description: v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + + description: "v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + the seven specialised status cards + every data-bearing generic metric assessment) in the active locale, so the read-only status GETs serve cached text instantly. Returns immediately; the generation runs out of band on the - worker. Empty metrics and provider-less accounts never trigger an LLM call. Short anti-spam bucket - (`insights-warm:`, one warm per 3 minutes) → 429 on a tight loop. Auth via cookie or Bearer; `userId` is - taken from the session, never the body. + worker. Covers two AI capabilities, `briefing` and `statusText`, and never refuses: with neither available it + answers `queued: false` and enqueues nothing (and spends no anti-spam bucket); with one available it enqueues + and the worker checks each half before it builds anything. The `ai` block says which half can run. Empty metrics + and provider-less accounts never trigger an LLM call. Short anti-spam bucket (`insights-warm:`, one warm + per 3 minutes) → 429 on a tight loop. Auth via cookie or Bearer; `userId` is taken from the session, never the + body." requestBody: required: false content: @@ -9965,7 +9975,8 @@ paths: $ref: "#/components/schemas/InsightsPregenerateRequest" responses: "200": - description: Warm accepted and enqueued. The work runs on the worker; poll the read-only status routes for the text. + description: "`queued: true`: the warm was enqueued; the work runs on the worker, so poll the read-only status routes + for the text. `queued: false`: neither capability is available and nothing was enqueued; `ai` says why." content: application/json: schema: @@ -10041,9 +10052,11 @@ paths: point of it: `hasProvider: false` means no warm pass can ever refresh what is shown, so stop polling and offer to connect one; `generationFailed` means the last attempt failed and the text is HELD rather than fresh; `generationFailureClass` says which lever to point at; and `briefingOmittedReason: "ungrounded"` means a - generation SUCCEEDED and the grounding gate withheld the briefing, which is a different state from a failure. - Gated on the Coach assistant surface — an operator who turns Coach off empties every advisor consumer. Cookie or - Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record.' + generation SUCCEEDED and the grounding gate withheld the briefing, which is a different state from a failure. A + pure AI read: the payload is the model-written briefing and nothing else, so it requires the `briefing` AI + capability and refuses with the capability envelope before the cache is read; stored briefing text is never + served, and no warm is enqueued, while the capability is unavailable. Cookie or Bearer auth; the caller is + always resolved as themselves, so this read cannot be delegated to a shared record.' responses: "200": description: The cached payload, or the empty shape when nothing is cached. Always 200 — an absent briefing is a normal @@ -10054,12 +10067,25 @@ paths: $ref: "#/components/schemas/InsightsGenerateReadEnvelope" "401": *a1 "403": - description: The operator has switched the Coach assistant surface off (`meta.errorCode` = `assistant.disabled.coach`). + description: "The `briefing` AI capability is unavailable for this record. `meta.capability` = `briefing` and + `meta.reason` says which layer refused: the operator's switch (`assistant.disabled.briefing`, or + `assistant.disabled.enabled` for the master switch), an owning module or the person's own AI opt-out + (`module.disabled` with `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), + or a missing consent receipt (`consent.ai.required`). Nothing was generated, read from a stored model + answer, or enqueued." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "No configured AI provider can serve the `briefing` capability: `meta.errorCode` = `ai.provider.none`, + `meta.reason` = `no_provider`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 + "503": *a16 post: tags: - Insights @@ -10075,8 +10101,9 @@ paths: true`, and so does an exhausted quota when the fresh cache merely lacked a briefing — and a request that TIMES OUT client-side may still complete on the server, writing the briefing to the cache or a dated failure marker, which is exactly the pair the read GET reports so a client can settle an aborted regenerate by polling rather - than guessing. Gated on the Coach assistant surface, consent-gated when the resolved chain egresses through the - operator's server-managed key. Cookie or Bearer auth; not delegable." + than guessing. Requires the `briefing` AI capability (the operator's briefing switch, the AI analysis opt-out, a + provider, and consent when the chain egresses through the operator's server-managed key), checked before any + read or provider work. Cookie or Bearer auth; not delegable." requestBody: required: false content: @@ -10093,8 +10120,12 @@ paths: $ref: "#/components/schemas/InsightsGeneratePostEnvelope" "401": *a1 "403": - description: The Coach assistant surface is off (`assistant.disabled.coach`), or the resolved chain egresses through the - operator's key and no consent receipt is on file (`consent.ai.required`). + description: "The `briefing` AI capability is unavailable for this record. `meta.capability` = `briefing` and + `meta.reason` says which layer refused: the operator's switch (`assistant.disabled.briefing`, or + `assistant.disabled.enabled` for the master switch), an owning module or the person's own AI opt-out + (`module.disabled` with `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), + or a missing consent receipt (`consent.ai.required`). Nothing was generated, read from a stored model + answer, or enqueued." content: application/json: schema: @@ -10108,10 +10139,10 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": - description: No AI provider is configured anywhere for this account, or the provider answered something that was not - valid JSON (`meta.errorCode` = `ai_response_truncated` when the answer was cut off mid-stream rather than - malformed), or the finished prose was withheld by the outbound safety screen - (`insights.generate.outboundScreened`). + description: No AI provider can serve the `briefing` capability (`meta.errorCode` = `ai.provider.none`, `meta.reason` = + `no_provider`), or the provider answered something that was not valid JSON (`meta.errorCode` = + `ai_response_truncated` when the answer was cut off mid-stream rather than malformed), or the finished prose + was withheld by the outbound safety screen (`insights.generate.outboundScreened`). content: application/json: schema: @@ -10126,8 +10157,10 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "503": - description: Every provider in the chain failed for a reason that is not a rate limit — auth, timeout or an upstream + description: "Every provider in the chain failed for a reason that is not a rate limit — auth, timeout or an upstream 5xx. A dated failure marker is written, so the read GET reports the briefing as held rather than fresh. + Also: the `briefing` capability could not be resolved (`meta.errorCode` = `ai.unavailable`, `meta.reason` = + `check_failed`); nothing was generated." content: application/json: schema: @@ -10234,10 +10267,10 @@ paths: the two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the baseline and correlation engines: no provider call and no cache table, so web and native decode the same DTO rather than each deriving one. Line two arrives as a finished sentence in the reader's language and is printed verbatim, so the - resolved locale decides the row; a correlation failure degrades it to null and never sinks line one. - Module-gated on `insights` and gated on the Coach assistant surface, because the strip is the ambient Coach - presence on that page. Shared analytics-read budget. Delegable at MANAGE level over the whole record: it is - computed across the record with no provider on the path. Cookie or Bearer auth." + resolved locale decides the row; a correlation failure degrades it to null and never sinks line one. Both lines + are computed, so no AI switch, opt-out or module refuses it (the name is historical). Shared analytics-read + budget. Delegable at MANAGE level over the whole record: it is computed across the record with no provider on + the path. Cookie or Bearer auth." parameters: - in: query name: metric @@ -10254,7 +10287,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/seeded-question: @@ -10267,8 +10300,10 @@ paths: SERVER-side and the client renders it rather than recomputing. `signal: null` is the ordinary answer and means the hero keeps its neutral greeting — never a fabricated opener. Two different causes produce that null and are indistinguishable on the wire: nothing crossed the notability gate, or the account has turned proactive - suggestions off, in which case the detector does not run at all. Module-gated on `insights` and on the Coach - assistant surface. Shared analytics-read budget. Cookie or Bearer auth; not delegable." + suggestions off, in which case the detector does not run at all. The opener exists only to open the Coach, so it + is served only while the `coach` AI capability is available; otherwise the answer is the same neutral `signal: + null` and `ai` says why. Never refused for an AI reason. Shared analytics-read budget. Cookie or Bearer auth; + not delegable." responses: "200": description: The opener, or the neutral null. @@ -10277,7 +10312,6 @@ paths: schema: $ref: "#/components/schemas/CoachSeededQuestionEnvelope" "401": *a1 - "403": *a16 "422": *a4 "429": *a5 /api/insights/narrative: @@ -10291,9 +10325,12 @@ paths: the no-key demo gets a retrospective on the next read. `revalidating` reports whether this read enqueued a warm, and is always FALSE on a delegated request: the route warms unconditionally rather than on a miss, so without that suppression a manager's first navigation here would be an egress of the owner's record the owner never - asked for. Rows are keyed per locale, so `locale` selects a different stored row rather than translating one. - Module-gated on `insights` and on the `insightStatus` assistant surface. Delegable at MANAGE level over the - whole record. Cookie or Bearer auth." + asked for. Rows are keyed per locale, so `locale` selects a different stored row rather than translating one. A + mixed read: a narrative the deterministic composer wrote is data and always served; one a model wrote is served + only while the `periodNarrative` AI capability is available, otherwise `narrative` is null and `ai` says why. + While the capability is unavailable a warm is enqueued only when nothing servable exists (the worker then writes + the deterministic narrative). Never refused for an AI reason. Delegable at MANAGE level over the whole record. + Cookie or Bearer auth." parameters: - in: query name: period @@ -10323,7 +10360,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -10374,8 +10411,9 @@ paths: grain instead of empty — `resolution` says which, and `tension` is always null on an hourly day because that read needs per-sample resolution. The bar for a tension window is deliberately high; under-flagging is the intended failure mode, and it is awareness, never a diagnosis. `date` is STRICT here, unlike the forgiving date - filters elsewhere on the surface: anything but a `YYYY-MM-DD` literal is a 422. Module-gated on `insights`. - Delegable at MANAGE level over the whole record. Cookie or Bearer auth." + filters elsewhere on the surface: anything but a `YYYY-MM-DD` literal is a 422. Pulse is a core vital, so no + module gates it and no AI switch refuses it. Delegable at MANAGE level over the whole record. Cookie or Bearer + auth." parameters: - in: query name: date @@ -10395,7 +10433,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/insights/settings: @@ -10833,7 +10871,10 @@ paths: cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine values across metrics. `windowDays` widens or narrows the trailing window a metric summarises; `provenance.windowDays` always reports the window actually used and `coverage.historyDays` the days that - actually backed it. Auth via cookie or Bearer. + actually backed it. Never refused for an AI reason and not gated on the `insights` module (the AI analysis + opt-out). The one AI part is the warmer model-written `assessment` text, served (and warmed) only while the + `statusText` capability is available; otherwise the deterministic assessment stands and `ai` says why. Auth via + cookie or Bearer. parameters: - in: query name: metric @@ -10939,7 +10980,8 @@ paths: `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The - single-metric route stays for the per-score detail pages. Auth via cookie or Bearer. + single-metric route stays for the per-score detail pages. Deterministic assessments only, so no AI gate and no + `insights` module gate; each metric keeps its own owning module. Auth via cookie or Bearer. parameters: - in: query name: metrics @@ -10999,8 +11041,7 @@ 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`; no assistant-surface gate and no LLM call. Auth via cookie - or Bearer." + `hasRecordings: false`. Device data: no module gate, no AI gate and no LLM call. Auth via cookie or Bearer." responses: "200": description: The ECG recording list (possibly empty). @@ -11009,7 +11050,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 post: @@ -11024,8 +11065,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`; no - assistant-surface gate and no LLM call. Auth via cookie or Bearer." + Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Device data: never refused because AI is + off or the AI analysis opt-out is on; no module gate and no LLM call. Auth via cookie or Bearer." requestBody: required: true content: @@ -11047,7 +11088,6 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 - "403": *a16 "422": *a4 "429": *a5 /api/insights/ecg/{id}: @@ -11055,13 +11095,13 @@ 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. + 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`; no assistant-surface gate and no LLM - call. `no-store`. Auth via cookie or Bearer. + annotate beats, or emit a verdict of its own. Device data: no module gate, no AI gate and no LLM call. + `no-store`. Auth via cookie or Bearer." parameters: - in: path name: id @@ -11087,7 +11127,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -11109,8 +11149,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`; no assistant-surface gate and no LLM - call. Auth via cookie or Bearer." + with no event rows returns `hasEvents: false`. Device data: no module gate, no AI gate and no LLM call. Auth via + cookie or Bearer." responses: "200": description: The device-flagged event timeline (possibly empty). @@ -11119,7 +11159,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/insights/correlations: @@ -11127,11 +11167,12 @@ paths: tags: - Insights summary: Correlation discovery (FDR-controlled) - description: v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV + description: "v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. - Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer. + Statistics, not model output: no AI switch, opt-out or `insights` module gate refuses them. Auth via cookie or + Bearer." responses: "200": description: The discovered correlations + the tested-pair count. @@ -11149,7 +11190,7 @@ paths: - Insights summary: List current correlation patterns description: Returns the authenticated account's currently accepted persisted correlation identities, evidence, and - dismissal timestamps. + dismissal timestamps. Statistics, not model output, so no AI or `insights` module gate applies. responses: "200": description: Current persisted correlation patterns. @@ -11158,7 +11199,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -11167,7 +11208,7 @@ paths: - Insights summary: Update correlation pattern dismissal description: Dismisses or restores one current account-owned pattern. A dismissal remains effective until the accepted - evidence changes materially. + evidence changes materially. No AI or `insights` module gate applies. parameters: - in: path name: id @@ -11188,7 +11229,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "404": description: No current pattern with that id for this account. content: @@ -11274,7 +11315,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a16 + "403": *a17 "422": *a4 "429": *a5 /api/mood/insights: @@ -11296,7 +11337,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a16 + "403": *a17 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -11429,7 +11470,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a17 + "404": &a18 description: Not found / not owned by the caller. content: application/json: @@ -11464,7 +11505,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a17 + "404": *a18 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -11500,7 +11541,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a17 + "404": *a18 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -11553,7 +11594,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a17 + "404": *a18 "422": *a4 "429": *a5 delete: @@ -11584,7 +11625,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a17 + "404": *a18 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -11749,7 +11790,7 @@ paths: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a9 - "409": &a18 + "409": &a19 description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place. @@ -11781,7 +11822,7 @@ paths: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a9 - "404": *a17 + "404": *a18 "422": *a4 "429": *a5 put: @@ -11816,8 +11857,8 @@ paths: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a9 - "404": *a17 - "409": *a18 + "404": *a18 + "409": *a19 "422": *a4 "429": *a5 delete: @@ -11843,7 +11884,7 @@ paths: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 "403": *a9 - "404": *a17 + "404": *a18 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -11993,7 +12034,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a19 + "400": &a20 description: Body is not parseable JSON. content: application/json: @@ -12006,13 +12047,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a20 + "413": &a21 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a21 + "415": &a22 description: Content-Type is not `application/json`. content: application/json: @@ -12072,7 +12113,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a19 + "400": *a20 "401": *a1 "403": description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was @@ -12087,8 +12128,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a20 - "415": *a21 + "413": *a21 + "415": *a22 "422": description: Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook @@ -12142,10 +12183,10 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a19 + "400": *a20 "401": *a1 - "413": *a20 - "415": *a21 + "413": *a21 + "415": *a22 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = @@ -12701,7 +12742,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a22 + "400": &a23 description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." content: @@ -12750,7 +12791,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a22 + "400": *a23 "401": *a1 "422": *a4 "429": *a5 @@ -13036,7 +13077,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 "403": *a9 - "404": &a23 + "404": &a24 description: Measurement reminder not found / not owned. content: application/json: @@ -13071,7 +13112,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 delete: @@ -13094,7 +13135,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -13119,7 +13160,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -13147,7 +13188,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -13177,7 +13218,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -13211,7 +13252,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -13252,7 +13293,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/labs: @@ -13376,7 +13417,7 @@ paths: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 "403": *a9 - "404": &a24 + "404": &a25 description: Lab result not found (or owned by another user). content: application/json: @@ -13411,7 +13452,7 @@ paths: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 "403": *a9 - "404": *a24 + "404": *a25 "422": *a4 "429": *a5 delete: @@ -13434,7 +13475,7 @@ paths: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 "403": *a9 - "404": *a24 + "404": *a25 "422": *a4 "429": *a5 /api/labs/restore: @@ -13592,7 +13633,7 @@ paths: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 "403": *a9 - "409": &a26 + "409": &a27 description: A biomarker with this name already exists for the caller. content: application/json: @@ -13621,7 +13662,7 @@ paths: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 "403": *a9 - "404": &a25 + "404": &a26 description: Biomarker not found (or owned by another user). content: application/json: @@ -13656,8 +13697,8 @@ paths: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 "403": *a9 - "404": *a25 - "409": *a26 + "404": *a26 + "409": *a27 "422": *a4 "429": *a5 delete: @@ -13680,7 +13721,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a25 + "404": *a26 "422": *a4 "429": *a5 /api/encounters: @@ -13819,7 +13860,7 @@ paths: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 "403": *a9 - "404": &a27 + "404": &a28 description: Visit not found (or owned by another user). content: application/json: @@ -13853,7 +13894,7 @@ paths: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -13878,7 +13919,7 @@ paths: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -13904,7 +13945,7 @@ paths: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -13934,7 +13975,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -13963,7 +14004,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 /api/practitioners: @@ -14042,7 +14083,7 @@ paths: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 "403": *a9 - "404": &a28 + "404": &a29 description: Practitioner not found (or owned by another user). content: application/json: @@ -14076,7 +14117,7 @@ paths: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 "403": *a9 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -14100,7 +14141,7 @@ paths: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 "403": *a9 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -14126,7 +14167,7 @@ paths: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 "403": *a9 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 /api/vaccinations: @@ -14221,7 +14262,7 @@ paths: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 "403": *a9 - "404": &a29 + "404": &a30 description: Vaccination not found (or owned by another user). content: application/json: @@ -14256,7 +14297,7 @@ paths: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14281,7 +14322,7 @@ paths: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -14306,7 +14347,7 @@ paths: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -14372,7 +14413,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -14430,7 +14471,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14459,7 +14500,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/illness/episodes: @@ -14543,7 +14584,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": &a30 + "404": &a31 description: Illness episode not found (or owned by another user). content: application/json: @@ -14578,7 +14619,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -14602,7 +14643,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -14630,7 +14671,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "409": *a7 "422": *a4 "429": *a5 @@ -14663,7 +14704,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -14714,7 +14755,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 post: @@ -14751,7 +14792,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -14778,7 +14819,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/illness/insights: @@ -14994,7 +15035,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a9 - "404": &a31 + "404": &a32 description: Allergy not found (or owned by another user). content: application/json: @@ -15028,7 +15069,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a9 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 delete: @@ -15052,7 +15093,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a9 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 /api/family-history: @@ -15126,7 +15167,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a9 - "404": &a32 + "404": &a33 description: Family history entry not found (or owned by another user). content: application/json: @@ -15160,7 +15201,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a9 - "404": *a32 + "404": *a33 "422": *a4 "429": *a5 delete: @@ -15184,7 +15225,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a9 - "404": *a32 + "404": *a33 "422": *a4 "429": *a5 /api/documents/inbound: @@ -16079,7 +16120,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a34 + "409": &a35 description: A custom metric with this name already exists for the caller. content: application/json: @@ -16108,7 +16149,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a9 - "404": &a33 + "404": &a34 description: Custom metric not found (or owned by another user). content: application/json: @@ -16142,8 +16183,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a33 - "409": *a34 + "404": *a34 + "409": *a35 "422": *a4 "429": *a5 delete: @@ -16166,7 +16207,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -16210,7 +16251,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a9 - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 post: @@ -16239,7 +16280,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a33 + "404": *a34 "409": *a7 "422": *a4 "429": *a10 @@ -16275,7 +16316,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a35 + "404": &a36 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -16308,7 +16349,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a35 + "404": *a36 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -16717,7 +16758,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a36 + "403": &a37 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -16772,7 +16813,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a36 + "403": *a37 "422": *a4 "429": *a5 /api/nutrients/water: @@ -16801,7 +16842,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a36 + "403": *a37 "409": *a7 "422": *a4 "429": *a5 @@ -17303,26 +17344,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a37 + "400": &a38 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a38 + "413": &a39 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a39 + "415": &a40 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a40 + "422": &a41 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -17508,11 +17549,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a37 + "400": *a38 "401": *a1 - "413": *a38 - "415": *a39 - "422": *a40 + "413": *a39 + "415": *a40 + "422": *a41 "429": *a5 delete: tags: @@ -17655,11 +17696,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a37 + "400": *a38 "401": *a1 - "413": *a38 - "415": *a39 - "422": *a40 + "413": *a39 + "415": *a40 + "422": *a41 "429": *a5 delete: tags: @@ -17812,11 +17853,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a37 + "400": *a38 "401": *a1 - "413": *a38 - "415": *a39 - "422": *a40 + "413": *a39 + "415": *a40 + "422": *a41 "429": *a5 delete: tags: @@ -17892,21 +17933,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a41 + "422": &a42 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a42 + "429": &a43 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a43 + "502": &a44 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -17951,11 +17992,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a37 + "400": *a38 "401": *a1 - "413": *a38 - "415": *a39 - "422": *a40 + "413": *a39 + "415": *a40 + "422": *a41 "429": *a5 delete: tags: @@ -18031,9 +18072,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a41 - "429": *a42 - "502": *a43 + "422": *a42 + "429": *a43 + "502": *a44 /api/strava/credentials: get: tags: @@ -18069,11 +18110,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a37 + "400": *a38 "401": *a1 - "413": *a38 - "415": *a39 - "422": *a40 + "413": *a39 + "415": *a40 + "422": *a41 "429": *a5 delete: tags: @@ -18151,9 +18192,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a41 - "429": *a42 - "502": *a43 + "422": *a42 + "429": *a43 + "502": *a44 /api/integrations/status: get: tags: @@ -18189,7 +18230,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a42 + "429": *a43 /api/integrations/fitbit/test: post: tags: @@ -18207,9 +18248,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a41 - "429": *a42 - "502": *a43 + "422": *a42 + "429": *a43 + "502": *a44 /api/integrations/google-health/resume: post: tags: @@ -18226,7 +18267,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a42 + "429": *a43 /api/integrations/google-health/test: post: tags: @@ -18259,9 +18300,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a41 - "429": *a42 - "502": *a43 + "422": *a42 + "429": *a43 + "502": *a44 /api/integrations/whoop/resume: post: tags: @@ -18278,7 +18319,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a42 + "429": *a43 /api/integrations/whoop/test: post: tags: @@ -18295,9 +18336,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a41 - "429": *a42 - "502": *a43 + "422": *a42 + "429": *a43 + "502": *a44 /api/integrations/withings/resume: post: tags: @@ -18315,7 +18356,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a42 + "429": *a43 /api/integrations/withings/test: post: tags: @@ -18333,8 +18374,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a41 - "429": *a42 + "422": *a42 + "429": *a43 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -18352,8 +18393,9 @@ paths: summary: The unified daily digest description: "Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status - and Vorsorge reads for the 'worth a look' rail. No provider call, no warm-on-mount. Requires the insights - module. Cookie or Bearer auth." + and Vorsorge reads for the 'worth a look' rail. No provider call, no warm-on-mount. No module gate and no AI + gate: the digest is data, and the `insights` module is the AI analysis opt-out. Its AI parts follow their + capabilities, published in `ai`. Cookie or Bearer auth." responses: "200": description: The daily digest. @@ -18362,7 +18404,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -18374,8 +18416,7 @@ paths: ecg_new_recording, tension_window. The ACTIONABLE kinds (dose_window, sync_issue, preventive_care, coach_checkin) are structurally unreachable: itemKey must be namespaced under one of the three dismissible kinds or the request 422s before any lookup runs. Persisted server-side so the dismissal survives reload / a second - device; an upsert, so a repeat dismiss of the same instance is a no-op. Requires the insights module. Cookie or - Bearer auth." + device; an upsert, so a repeat dismiss of the same instance is a no-op. No module gate. Cookie or Bearer auth." requestBody: required: true content: @@ -18398,7 +18439,6 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 - "403": *a16 "422": *a4 "429": *a5 /api/dashboard/chart-overlay-prefs: @@ -18571,7 +18611,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a44 + "403": &a45 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -18596,7 +18636,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "422": *a4 "429": *a5 post: @@ -18691,7 +18731,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "404": description: No such invitation for this account. content: @@ -18735,7 +18775,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "404": description: No such grant on this account's record. content: @@ -18776,7 +18816,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "404": description: No such grant held by this account. content: @@ -19158,7 +19198,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -19488,7 +19528,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a45 + "403": &a46 description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help. @@ -19521,7 +19561,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a45 + "403": *a46 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -19548,7 +19588,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a45 + "403": *a46 "422": *a4 "429": *a5 post: @@ -19578,7 +19618,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a45 + "403": *a46 "413": description: Body exceeds 16 KiB. content: @@ -19621,7 +19661,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a45 + "403": *a46 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -19679,7 +19719,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a46 + - &a47 name: family in: path required: true @@ -19723,7 +19763,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a46 + - *a47 requestBody: required: true content: @@ -19875,7 +19915,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a16 + "403": *a17 "422": *a4 "429": *a5 /api/environment/home: @@ -19912,7 +19952,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a16 + "403": *a17 "413": description: Body exceeds 4 KiB. content: @@ -19966,7 +20006,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a16 + "403": *a17 "409": description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = `environment.no_home`. @@ -20025,7 +20065,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a16 + "403": *a17 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -20064,7 +20104,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a16 + "403": *a17 "413": description: Body exceeds 4 KiB. content: @@ -20117,7 +20157,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a16 + "403": *a17 "404": description: No such override for this account. `meta.errorCode` = `environment.travel.not_found`. Also the answer for an id owned by somebody else — existence is not leaked. @@ -20258,13 +20298,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a47 + "403": &a48 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a48 + "404": &a49 description: No stored copy with this id. content: application/json: @@ -20323,8 +20363,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a47 - "404": *a48 + "403": *a48 + "404": *a49 "409": description: Either the payload declares a different owner than the stored row, or a request under the same `Idempotency-Key` is still in flight. Nothing was changed. @@ -40425,6 +40465,10 @@ components: anyOf: - $ref: "#/components/schemas/WorkoutActivityInsight" - type: "null" + ai: + description: The `workoutInsights` AI capability. While it is unavailable `aiInsight` is null and the stored paragraph + is not even read. + $ref: "#/components/schemas/AiCapabilityState" canonicalId: type: string previousWorkoutId: @@ -40456,6 +40500,7 @@ components: - splits - sportContext - aiInsight + - ai - canonicalId - previousWorkoutId additionalProperties: false @@ -40642,6 +40687,51 @@ components: - paragraph - generatedAt additionalProperties: false + AiUnavailableReason: + type: string + enum: + - check_failed + - operator_disabled + - not_permitted_for_record + - module_disabled + - user_disabled + - no_provider + - consent_required + description: "Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is + reported, because it names the layer that would have to change first. `check_failed` — an input could not be + read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own + switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not + admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off + for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI + opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. + `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider + chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does + not know treats the capability as unavailable." + AiCapabilityState: + type: object + properties: + available: + type: boolean + reason: + anyOf: + - $ref: "#/components/schemas/AiUnavailableReason" + - type: "null" + description: "`null` exactly when `available` is true." + onDeviceAllowed: + type: boolean + description: "Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, + `module_disabled`, `user_disabled` and `check_failed`: the operator's and the person's decisions hold on the + device too. True otherwise, since a missing server provider or a missing consent for server egress do not + concern a model that runs on the device. Resolved here so the client never decides which reasons apply off + the server." + required: + - available + - reason + - onDeviceAllowed + additionalProperties: false + description: "One AI capability, resolved for one record from every layer that can say no. Render from it; never + recompute it. Data never depends on it: measurements, scores, statistics and device records load whatever it + says." BatchWorkoutsResponse: type: object properties: @@ -44535,7 +44625,9 @@ components: aiAvailable: type: boolean description: True when ANY provider can serve this user — including the operator's server-managed key when the user set - none. iOS keys Coach visibility off this. + none — AND the operator's master AI switch is on. With every AI capability switched off there is no AI + available, whatever is configured. iOS keys Coach visibility off this. The per-capability answer is `ai` on + `/api/auth/me`. managedBy: anyOf: - type: string @@ -44604,9 +44696,11 @@ components: type: boolean description: "Whether the shared provider may honestly be offered to this caller in one tap. True only when all of: the operator's provider is the one that would serve them (`managedBy: \"server\"`), `serverProviderHealth` is - `healthy`, the operator's assistant master + coach flags are on, the acting record holds its own credentials - (a managed profile never does), the caller holds no receipt yet, and the instance is not running in demo - mode (where the grant the tap makes is refused at the edge). Anything unknown makes it false." + `healthy`, the operator keeps at least one capability the consent would unlock switched on (the Coach, the + daily briefing, or reading documents; the master switch off turns all three off), the acting record holds + its own credentials (a managed profile never does), the caller holds no receipt yet, and the instance is not + running in demo mode (where the grant the tap makes is refused at the edge). Anything unknown makes it + false." serverProviderConsent: type: boolean description: Whether this caller already holds an active `ai_full` / `ai_coach` receipt — the grant `POST @@ -48523,10 +48617,14 @@ components: - type: "null" description: Conversation holding the newest assistant message; null when none exists. The client deep-links into it (`/coach?c=`) on the unread path. + ai: + description: The `coach` capability. While unavailable the three fields above are the quiet empty values. + $ref: "#/components/schemas/AiCapabilityState" required: - nudgedAt - unread - conversationId + - ai additionalProperties: false error: type: "null" @@ -48619,6 +48717,9 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + ai: + description: The `aboutMeQuestions` capability behind model-written questions. + $ref: "#/components/schemas/AiCapabilityState" required: - aboutMe - conditions @@ -48629,6 +48730,7 @@ components: - updatedAt - maxChars - fieldMaxChars + - ai additionalProperties: false error: type: "null" @@ -48693,6 +48795,9 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + ai: + description: The `aboutMeQuestions` capability the derivation ran under. + $ref: "#/components/schemas/AiCapabilityState" required: - aboutMe - conditions @@ -48703,6 +48808,7 @@ components: - updatedAt - maxChars - fieldMaxChars + - ai additionalProperties: false error: type: "null" @@ -50952,15 +51058,23 @@ components: - preparing - disabled - no-provider + description: "`disabled` whenever the `briefing` AI capability is unavailable for a reason other than a missing provider + (then `briefingAi` names it); `no-provider` when no provider can serve it." briefingUpdatedAt: anyOf: - type: string - type: "null" briefingStale: type: boolean - description: True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`) - or impossible (`no-provider`). Render the stale content with its `briefingUpdatedAt` timestamp instead of a - blank tile. + description: True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`). + Render the stale content with its `briefingUpdatedAt` timestamp instead of a blank tile. + briefingAi: + description: "The `briefing` AI capability for this record, applied on every read after the cache: while it is + unavailable `briefing`, `briefingMemory` and `briefingUpdatedAt` are null, whatever the cache holds. + Nullable and optional so older cached bodies stay valid." + anyOf: + - $ref: "#/components/schemas/AiCapabilityState" + - type: "null" generatedAt: type: string required: @@ -51250,14 +51364,33 @@ components: in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body. type: boolean + hasProvider: + description: "Whether a configured AI provider exists for the record: provider presence and nothing else. False never + means the operator switched AI off or consent is missing; `ai` says that." + type: boolean + ai: + type: object + properties: + briefing: + $ref: "#/components/schemas/AiCapabilityState" + statusText: + $ref: "#/components/schemas/AiCapabilityState" + required: + - briefing + - statusText + additionalProperties: false + description: "Whether the AI parts that decorate the overview can be shown: the daily briefing and the per-metric status + notes. Resolved per request, never cached with the body. The overview itself is computed data and loads + whatever these say." required: - summary - recommendations - citations - warnings + - ai additionalProperties: false - description: AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface - needs day-level grounding. + description: The Insights overview's computed read. No provider is called on this path; every figure is computed from + the record. InsightsCardsEnvelope: type: object properties: @@ -51328,7 +51461,8 @@ components: 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)))$ provider: type: string - description: Lower-cased AI provider label for the account (e.g. `claude`). + description: "Who wrote the card: always `rules`, the threshold engine. It used to echo the account's AI provider + although no model wrote any card." required: - id - title @@ -51363,19 +51497,30 @@ components: properties: queued: type: boolean - description: True when the full warm was accepted and enqueued. + description: True when the warm was accepted and enqueued; false when neither AI capability it covers is available, in + which case nothing was enqueued. locale: type: string - enum: - - de - - en description: The locale the assessments are being warmed in. + ai: + type: object + properties: + briefing: + $ref: "#/components/schemas/AiCapabilityState" + statusText: + $ref: "#/components/schemas/AiCapabilityState" + required: + - briefing + - statusText + additionalProperties: false + description: The two capabilities the warm covers. The worker skips the half whose capability is unavailable. required: - queued - locale + - ai additionalProperties: false - description: Acknowledgement that a full assessment warm was enqueued for the calling user. The generation runs out of - band; the text lands in the read-only status routes. + description: Acknowledgement of a full assessment warm for the calling user, or of why none was enqueued. The generation + runs out of band; the text lands in the read-only status routes. AnalyticsEnvelope: type: object properties: @@ -52884,10 +53029,14 @@ components: additionalProperties: false - type: "null" description: Null whenever nothing crossed the detector's confidence and notability gate, AND whenever the account has - turned proactive suggestions off — the two are indistinguishable on the wire, and both mean the hero keeps - its neutral greeting rather than showing a fabricated opener. + turned proactive suggestions off, AND whenever the `coach` capability is unavailable (then `ai` says why) — + all mean the hero keeps its neutral greeting rather than showing a fabricated opener. + ai: + description: The `coach` capability the opener exists for. + $ref: "#/components/schemas/AiCapabilityState" required: - signal + - ai additionalProperties: false description: Today's single most notable derived wellness signal, resolved server-side into a tappable opener for the Coach's blank-chat hero. The client renders the resolved DTO and never recomputes the selection. @@ -52938,18 +53087,23 @@ components: - updatedAt additionalProperties: false - type: "null" - description: Null when no row exists for this period and locale yet. + description: Null when no row exists for this period and locale yet, and when the stored row is model-written while the + `periodNarrative` capability is unavailable (a deterministic row is always served). revalidating: type: boolean description: "True when this read enqueued a warm. Always FALSE on a delegated request even when the row is stale: this route warms unconditionally rather than on a miss, so a manager's first navigation here would otherwise be an egress of the owner's record the owner never asked for. The stale row is still served; only the warm is - withheld." + withheld. Also false while `periodNarrative` is unavailable and a servable row exists." + ai: + description: The `periodNarrative` capability behind model-written narratives. + $ref: "#/components/schemas/AiCapabilityState" required: - period - locale - narrative - revalidating + - ai additionalProperties: false description: The latest generated period retrospective. Read-only by construction — it never blocks on a provider, serves the last good row immediately, and warms out of band. @@ -54062,7 +54216,12 @@ components: description: "v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less - accounts + the demo always get one) and warmer AI prose overrides it once cached." + accounts + the demo always get one) and warmer AI prose overrides it once cached, but only while the + `statusText` AI capability is available; otherwise the deterministic text is served and nothing is warmed." + ai: + description: The `statusText` capability behind the model-written assessment. Present on the single-metric route; absent + on the batch route, whose assessments are always the deterministic text. + $ref: "#/components/schemas/AiCapabilityState" required: - metric - status @@ -66326,12 +66485,14 @@ components: - delta additionalProperties: false - type: "null" - description: Clinical-priority top signal, lifted from the cached briefing. + description: Clinical-priority top signal, lifted from the cached briefing. Null while the `briefing` capability is + unavailable. briefingLead: anyOf: - type: string - type: "null" - description: First sentence of the cached briefing paragraph. + description: First sentence of the cached briefing paragraph. Null while the `briefing` capability is unavailable; + `line` then falls to its deterministic floor. line: type: string description: "Push / lock-screen line: cached-AI lead with a deterministic floor." @@ -66369,8 +66530,26 @@ components: - type: string - type: "null" description: One-sentence generated reaction to that arrival, standing for the rest of the local day. Null whenever no - line was generated (no provider, no consent, budget exhausted, or generation failed) — consumers fall back - to `briefingLead` / `line`. Additive since v1.31.0. + line was generated (no provider, no consent, budget exhausted, or generation failed), and whenever the + `reactionLines` capability is unavailable, however recent the stored line — consumers fall back to + `briefingLead` / `line`. Additive since v1.31.0. + ai: + type: object + properties: + briefing: + $ref: "#/components/schemas/AiCapabilityState" + coach: + $ref: "#/components/schemas/AiCapabilityState" + reactionLines: + $ref: "#/components/schemas/AiCapabilityState" + required: + - briefing + - coach + - reactionLines + additionalProperties: false + description: "The three AI capabilities the digest carries text or a card for: `briefing` (the lead and top signal), + `coach` (the Coach check-in card, which opens the Coach) and `reactionLines` (the reaction line). Everything + else in the digest is data." required: - generatedAt - phase @@ -66382,6 +66561,7 @@ components: - worthALook - justIn - reactionLine + - ai additionalProperties: false description: The one data spine of the daily-value system — composed from already-cached data, never a fresh AI call. Consumed by the Today surface, the daily push, and a future iOS widget. @@ -67797,51 +67977,6 @@ components: model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. `medicationExtract` — turning a typed medication description into a schedule. - AiUnavailableReason: - type: string - enum: - - check_failed - - operator_disabled - - not_permitted_for_record - - module_disabled - - user_disabled - - no_provider - - consent_required - description: "Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is - reported, because it names the layer that would have to change first. `check_failed` — an input could not be - read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own - switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not - admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off - for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI - opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. - `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider - chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does - not know treats the capability as unavailable." - AiCapabilityState: - type: object - properties: - available: - type: boolean - reason: - anyOf: - - $ref: "#/components/schemas/AiUnavailableReason" - - type: "null" - description: "`null` exactly when `available` is true." - onDeviceAllowed: - type: boolean - description: "Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, - `module_disabled`, `user_disabled` and `check_failed`: the operator's and the person's decisions hold on the - device too. True otherwise, since a missing server provider or a missing consent for server egress do not - concern a model that runs on the device. Resolved here so the client never decides which reasons apply off - the server." - required: - - available - - reason - - onDeviceAllowed - additionalProperties: false - description: "One AI capability, resolved for one record from every layer that can say no. Render from it; never - recompute it. Data never depends on it: measurements, scores, statistics and device records load whatever it - says." AiProviderState: type: object properties: diff --git a/e2e/v1341-ui-evidence.spec.ts b/e2e/v1341-ui-evidence.spec.ts index bbfccb930..ef9e150f9 100644 --- a/e2e/v1341-ui-evidence.spec.ts +++ b/e2e/v1341-ui-evidence.spec.ts @@ -12,11 +12,13 @@ import { } from "./utils/mock-dashboard-snapshot"; import { mockPopulatedInsights } from "./utils/mock-populated-insights"; import { settleBeforeMeasure, waitForFonts } from "./utils/settle"; +import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; const EVIDENCE_DIR = join(process.cwd(), "test-results", "v1341"); const NARRATIVE_DIGEST: DailyDigest = { generatedAt: "2026-07-29T08:30:00.000Z", + ai: DIGEST_AI_AVAILABLE, phase: "final", sleepPending: false, score: { diff --git a/src/__tests__/ai-consent-enforcement-guard.test.ts b/src/__tests__/ai-consent-enforcement-guard.test.ts index 25b5173ab..025083705 100644 --- a/src/__tests__/ai-consent-enforcement-guard.test.ts +++ b/src/__tests__/ai-consent-enforcement-guard.test.ts @@ -129,8 +129,6 @@ const ALLOWLIST: Record = { "Resolves the chain to REPORT which entry could do vision/text OCR (mode, reason, pdfSupported). Makes no completion call. Every consumer that then egresses a document is gated at its own call site with assertDocumentEgressConsent.", "app/api/insights/provider-chain/route.ts": "Lists the user's configured chain and which entry is active, for the settings UI. Constructs clients but never calls generateCompletion. No health data in the request or the response.", - "app/api/insights/comprehensive/route.ts": - "Uses the resolver only as a boolean probe ((await resolveProvider(userId)).type !== 'none') to set hasProvider on the response. The generation path itself lives in lib/insights/comprehensive-generate.ts, which is gated.", // --- Connection test: a real provider call, but with no user data. --- "app/api/ai/test/route.ts": diff --git a/src/__tests__/derived-response-consumer-guard.test.ts b/src/__tests__/derived-response-consumer-guard.test.ts index 08e33261b..e219c8b9a 100644 --- a/src/__tests__/derived-response-consumer-guard.test.ts +++ b/src/__tests__/derived-response-consumer-guard.test.ts @@ -18,6 +18,9 @@ import { describe, expect, it } from "vitest"; const ROOT = process.cwd(); const SCHEMA_FILE = join(ROOT, "src/lib/openapi/routes/insights/schemas.ts"); +// Shared component schemas the insights schemas import by name (the AI +// capability state). Resolved from their own file, never guessed. +const IMPORTED_SCHEMA_FILES = [join(ROOT, "src/lib/openapi/routes/profile.ts")]; const PATH_FILE = join(ROOT, "src/lib/openapi/routes/insights/paths.ts"); const ROUTE_FILE = join(ROOT, "src/app/api/insights/derived/route.ts"); const BATCH_ROUTE_FILE = join( @@ -132,11 +135,19 @@ function zodObjectShape(path: string, schemaName: string): ZodObjectShape { const file = parse(path); const initializers = new Map(); const arrays = new Set(); - for (const statement of file.statements) { - if (!ts.isVariableStatement(statement)) continue; - for (const declaration of statement.declarationList.declarations) { - if (ts.isIdentifier(declaration.name) && declaration.initializer) { - initializers.set(declaration.name.text, declaration.initializer); + // The schema file's own declarations win; an imported component fills in + // only a name the file does not declare itself. + for (const source of [file, ...IMPORTED_SCHEMA_FILES.map(parse)]) { + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + declaration.initializer && + !initializers.has(declaration.name.text) + ) { + initializers.set(declaration.name.text, declaration.initializer); + } } } } @@ -168,7 +179,8 @@ function zodObjectShape(path: string, schemaName: string): ZodObjectShape { `${prefix || schemaName} contains unsupported Zod object syntax: ${property.getText(file)}`, ).toBe(true); if (!ts.isPropertyAssignment(property)) return []; - const name = propertyName(property.name, file); + // The node may come from an imported schema file. + const name = propertyName(property.name, property.getSourceFile()); expect( name, `${prefix || schemaName} contains a computed property name`, @@ -416,7 +428,8 @@ function apiSuccessLineageBranches( `${path} contains unsupported apiSuccess syntax: ${property.getText(file)}`, ).toBe(true); if (ts.isPropertyAssignment(property)) { - const name = propertyName(property.name, file); + // The node may come from an imported schema file. + const name = propertyName(property.name, property.getSourceFile()); expect( name, `${path} has a computed response field`, @@ -892,7 +905,13 @@ const WIRE_ONLY_PROVENANCE = const WIRE_ONLY_ASSESSMENT_SOURCE = "The public response intentionally identifies deterministic versus cached assessment prose for programmatic and native callers. The web presents the prose and timestamp without exposing its implementation source."; +const WIRE_ONLY_AI = + "The web reads AI availability from the account payload (`ai` on `/api/auth/me`) for every surface at once; the per-response state is for native and programmatic callers that read one route."; + const EXCEPTIONS: Readonly> = { + "ai.available": WIRE_ONLY_AI, + "ai.reason": WIRE_ONLY_AI, + "ai.onDeviceAllowed": WIRE_ONLY_AI, metric: WIRE_ONLY_METRIC, "provenance.inputs": WIRE_ONLY_PROVENANCE, "provenance.source": WIRE_ONLY_PROVENANCE, @@ -943,6 +962,7 @@ describe("derived response consumer guard", () => { provenance: ["derived.provenance"], reason: ["derived.reason", "derived.status"], assessment: ["assessment"], + ai: ["ai"], }; for (const [index, lineage] of apiSuccessLineageBranches( ROUTE_FILE, @@ -987,7 +1007,14 @@ describe("derived response consumer guard", () => { }, ); assertBatchSchemaReferencesSingleShape(SCHEMA_FILE); - assertBatchProducerLineage(BATCH_ROUTE_FILE, responseFields); + // `ai` rides the single-metric route only: the batch serves the + // deterministic assessment and resolves no capability. The schema marks + // the field optional for exactly this reason. + const singleRouteOnly = ["ai"]; + assertBatchProducerLineage( + BATCH_ROUTE_FILE, + responseFields.filter((field) => !singleRouteOnly.includes(field)), + ); assertBatchClientHandoff(DERIVED_BATCH_CLIENT, VITALS_DASHBOARD); }); diff --git a/src/__tests__/helpers/ai-capability-fixtures.ts b/src/__tests__/helpers/ai-capability-fixtures.ts new file mode 100644 index 000000000..63260382b --- /dev/null +++ b/src/__tests__/helpers/ai-capability-fixtures.ts @@ -0,0 +1,31 @@ +/** + * Capability states for test fixtures, so a fixture names the state it means + * rather than spelling the object out. + */ +import { + ON_DEVICE_ALLOWED_REASONS, + type AiCapabilityState, + type AiUnavailableReason, +} from "@/lib/ai/capabilities/types"; +import type { DailyDigestAi } from "@/lib/daily/digest"; + +export const AI_AVAILABLE: AiCapabilityState = Object.freeze({ + available: true, + reason: null, + onDeviceAllowed: true, +}); + +export function aiUnavailable(reason: AiUnavailableReason): AiCapabilityState { + return { + available: false, + reason, + onDeviceAllowed: ON_DEVICE_ALLOWED_REASONS.has(reason), + }; +} + +/** Every AI part of the daily digest available. */ +export const DIGEST_AI_AVAILABLE: DailyDigestAi = Object.freeze({ + briefing: AI_AVAILABLE, + coach: AI_AVAILABLE, + reactionLines: AI_AVAILABLE, +}); diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 4e87fbaae..2a4c03615 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -2943,13 +2943,20 @@ describe("(g) the MANAGE route set is frozen", () => { expect(narrativeDispatch).toContain("mayDispatchProviderWork"); expect(narrativeDispatch).toContain("withProviderWorkAuthority"); - // A shared comprehensive read calls the resolver even before it decides - // whether there is enough context to generate. Credential policy belongs - // inside every resolver helper so a delegate or managed system job cannot - // select a personal BYOK key or custom base URL first. + // A shared comprehensive read asks for provider presence on every read. + // It asks the presence probe, never the resolver: a read builds no client + // and refreshes no token. Credential policy belongs inside every resolver + // and presence helper so a delegate or managed system job cannot select a + // personal BYOK key or custom base URL first. + expect( + callsSymbol( + "app/api/insights/comprehensive/route.ts", + "probeProviderPresence", + ), + ).toBe(true); expect( callsSymbol("app/api/insights/comprehensive/route.ts", "resolveProvider"), - ).toBe(true); + ).toBe(false); for (const name of [ "resolveProvider", "resolveProviderChain", diff --git a/src/app/api/__tests__/ai-capability-route-inventory.test.ts b/src/app/api/__tests__/ai-capability-route-inventory.test.ts new file mode 100644 index 000000000..6a4d85038 --- /dev/null +++ b/src/app/api/__tests__/ai-capability-route-inventory.test.ts @@ -0,0 +1,149 @@ +/** + * AI capability route inventory, across the whole API tree. + * + * Proves, from source text, the route half of "data never depends on AI": + * + * 1. Every `route.ts` under `src/app/api` that asks an AI capability + * (`requireAiCapability` / `getAiCapability`) is on `AI_ROUTES`, which + * names the capability and whether it refuses (`action`) or answers 200 + * with the model text nulled (`mixed`). + * 2. Each `AI_ROUTES` entry asks exactly the capabilities it declares, by + * literal key; an `action` route calls `requireAiCapability`, a `mixed` + * read never does (a mixed read never refuses for an AI reason). + * 3. Every `DATA_ROUTES` entry asks neither gate, nor the retired + * `requireAssistantSurface`. + * 4. The matchers find what they judge: non-zero counts, and a call split + * across lines still matches. + * + * What it does NOT prove: that a gate sits before the work it guards, or that + * a mixed read really nulls the text. The route tests and the integration + * files (`tests/integration/ai-optional-*.test.ts`) prove behaviour; this + * proves no route escapes classification and no data route grows a gate. + * + * Mutation checks (each turned this file red by name): adding + * `await requireAiCapability("statusText")` to `insights/ecg/route.ts`; + * removing the capability call from `insights/chat/route.ts`; swapping + * `getAiCapability` for `requireAiCapability` in `insights/narrative`; + * breaking the call matcher. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { AI_CAPABILITY_KEYS } from "@/lib/ai/capabilities/types"; + +import { AI_ROUTES, DATA_ROUTES } from "./ai-route-inventory"; + +const repoRoot = resolve(__dirname, "..", "..", "..", ".."); + +/** Comment lines out, so prose naming a gate is not read as a call. */ +function code(path: string): string { + return readFileSync(resolve(repoRoot, path), "utf8") + .split("\n") + .filter((line) => !/^\s*(\*|\/\/|\/\*)/.test(line)) + .join("\n"); +} + +/** Whitespace-tolerant: a call split across lines still matches. */ +const GATE_CALL = /\b(requireAiCapability|getAiCapability)\s*\(\s*"(\w+)"/g; +const ANY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/; +const REQUIRE_GATE = /\brequireAiCapability\s*\(/; +const RETIRED_GATE = /\brequireAssistantSurface\s*\(/; + +function gateCalls(text: string): Array<{ fn: string; key: string }> { + return [...text.matchAll(GATE_CALL)].map((m) => ({ fn: m[1], key: m[2] })); +} + +function routeFiles(): string[] { + const root = resolve(repoRoot, "src/app/api"); + const hits: string[] = []; + function walk(dir: string): void { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + if (entry === "__tests__" || entry === "node_modules") continue; + walk(full); + } else if (entry === "route.ts") { + hits.push(relative(repoRoot, full).split(/[\\/]/).join("/")); + } + } + } + walk(root); + return hits.sort(); +} + +const ROUTES = routeFiles(); + +describe("AI capability route inventory", () => { + it("the matcher reads a call split across lines", () => { + const split = 'await requireAiCapability(\n "coach",\n );'; + expect(gateCalls(split)).toEqual([ + { fn: "requireAiCapability", key: "coach" }, + ]); + expect(ANY_GATE.test("getAiCapability (\n")).toBe(true); + }); + + it("finds the routes it is meant to judge", () => { + expect(ROUTES.length).toBeGreaterThan(100); + const asking = ROUTES.filter((path) => ANY_GATE.test(code(path))); + // A matcher that stopped matching would agree with an empty world. + expect(asking.length).toBeGreaterThan(0); + expect(asking.length).toBe(Object.keys(AI_ROUTES).length); + expect(Object.keys(DATA_ROUTES).length).toBeGreaterThan(0); + }); + + it("every route that asks an AI capability is on AI_ROUTES", () => { + const unlisted = ROUTES.filter( + (path) => ANY_GATE.test(code(path)) && !(path in AI_ROUTES), + ); + expect( + unlisted, + "these routes ask an AI capability but are not on AI_ROUTES — add them with their capability, or remove the gate from a data route", + ).toEqual([]); + }); + + describe.each(Object.entries(AI_ROUTES))("%s", (path, entry) => { + it("exists and asks exactly the capabilities it declares", () => { + expect(ROUTES, `${path} no longer exists`).toContain(path); + const calls = gateCalls(code(path)); + expect(calls.length, `${path} asks no capability`).toBeGreaterThan(0); + for (const { key } of calls) { + expect(AI_CAPABILITY_KEYS as readonly string[]).toContain(key); + } + expect([...new Set(calls.map((c) => c.key))].sort()).toEqual( + [...entry.capabilities].sort(), + ); + }); + + it(`is ${entry.kind === "action" ? "an action that refuses" : "a mixed read that never refuses"}`, () => { + const refuses = REQUIRE_GATE.test(code(path)); + expect( + refuses, + entry.kind === "action" + ? `${path} is an action but never calls requireAiCapability` + : `${path} is a mixed read but calls requireAiCapability — a mixed read answers 200 and nulls the model text`, + ).toBe(entry.kind === "action"); + }); + }); + + it("no data route asks an AI capability or the retired gate", () => { + const offenders = Object.keys(DATA_ROUTES).filter((path) => { + const text = code(path); + return ANY_GATE.test(text) || RETIRED_GATE.test(text); + }); + expect( + offenders, + "data routes never depend on AI: remove the gate, or move the route to AI_ROUTES if it now serves model text", + ).toEqual([]); + }); + + it("no data route is stale or listed twice", () => { + const missing = Object.keys(DATA_ROUTES).filter( + (path) => !ROUTES.includes(path), + ); + expect(missing, "DATA_ROUTES names files that do not exist").toEqual([]); + const both = Object.keys(DATA_ROUTES).filter((path) => path in AI_ROUTES); + expect(both).toEqual([]); + }); +}); diff --git a/src/app/api/__tests__/ai-route-inventory.ts b/src/app/api/__tests__/ai-route-inventory.ts new file mode 100644 index 000000000..7f3082664 --- /dev/null +++ b/src/app/api/__tests__/ai-route-inventory.ts @@ -0,0 +1,191 @@ +/** + * The AI route inventory: which API routes ask an AI capability, and which + * are data and must never ask. + * + * The rule it pins (the AI-optional design): data never depends on AI. A + * route that serves measurements, device records, computed scores, + * statistics or template sentences imports no AI gate and is never refused + * because a switch is off, a provider is missing, consent is absent or the + * person opted out of AI analysis. A route that calls a model, or serves text + * a model wrote, names its capability: + * + * - `action` routes call `requireAiCapability(key)` and refuse with the + * capability envelope; + * - `mixed` reads call `getAiCapability(key)`, answer 200, null the model + * text and say why in `ai`. They never refuse for an AI reason, so they + * never call `requireAiCapability`. + * + * Read by two guards: `ai-capability-route-inventory.test.ts` (the whole API + * tree) and `insights/__tests__/coach-route-gate-inventory.test.ts` (every + * route in the trees where AI routes live is classified). Not a test file, so + * importing it registers nothing. + */ +import type { AiCapabilityKey } from "@/lib/ai/capabilities/types"; + +export interface AiRouteEntry { + kind: "action" | "mixed"; + capabilities: readonly AiCapabilityKey[]; + why: string; +} + +export const AI_ROUTES: Readonly> = { + "src/app/api/insights/chat/route.ts": { + kind: "action", + capabilities: ["coach"], + why: "POST streams a Coach reply from a model. The history GET in the same file asks nothing.", + }, + "src/app/api/insights/generate/route.ts": { + kind: "action", + capabilities: ["briefing"], + why: "POST generates the briefing; GET is a pure AI read (the payload is the model text and nothing else), so both refuse.", + }, + "src/app/api/insights/comprehensive/route.ts": { + kind: "mixed", + capabilities: ["briefing", "statusText"], + why: "Computed overview data; the `ai` block says whether the briefing and status notes around it can be shown.", + }, + "src/app/api/insights/derived/route.ts": { + kind: "mixed", + capabilities: ["statusText"], + why: "Computed score; only the model-written assessment override follows the capability.", + }, + "src/app/api/insights/narrative/route.ts": { + kind: "mixed", + capabilities: ["periodNarrative"], + why: "A deterministic narrative is data; a model-written one is served only while the capability is available.", + }, + "src/app/api/insights/pregenerate/route.ts": { + kind: "mixed", + capabilities: ["briefing", "statusText"], + why: "Enqueue-only warm that returns no model text: `queued: false` when neither half can run.", + }, + "src/app/api/insights/coach/nudge-status/route.ts": { + kind: "mixed", + capabilities: ["coach"], + why: "The unread signal of a Coach that is unavailable is the quiet empty shape, never a refusal.", + }, + "src/app/api/insights/coach/seeded-question/route.ts": { + kind: "mixed", + capabilities: ["coach"], + why: "The opener exists only to open the Coach: `signal: null` while it is unavailable.", + }, + "src/app/api/coach/about-me/route.ts": { + kind: "mixed", + capabilities: ["aboutMeQuestions"], + why: "The profile is data; only model-written follow-up questions follow the capability.", + }, + "src/app/api/workouts/[id]/route.ts": { + kind: "mixed", + capabilities: ["workoutInsights"], + why: "The workout is data; only the stored Activity Insight paragraph follows the capability.", + }, +}; + +/** + * Routes that are data and must never import an AI gate. Every entry is a + * claim the guard checks against the file. + */ +export const DATA_ROUTES: Readonly> = { + // The design's list of formerly AI-gated data reads and writes. + "src/app/api/insights/derived/batch/route.ts": + "Deterministic scores and assessments for the whole grid.", + "src/app/api/insights/ecg/route.ts": + "Device recordings, list and live ingest: an upload is never refused because AI is off.", + "src/app/api/insights/ecg/[id]/route.ts": "One device recording's waveform.", + "src/app/api/insights/rhythm-events/route.ts": + "The device's own flagged events, verbatim.", + "src/app/api/insights/cards/route.ts": + "Rule alerts from the threshold engine; `provider` is `rules`.", + "src/app/api/insights/correlations/route.ts": + "Statistics; the retired Correlations switch gated no model output.", + "src/app/api/insights/coach-read/route.ts": + "Baseline placement and the strongest association, both computed.", + "src/app/api/insights/coach/seen/route.ts": + "A timestamp on the caller's own row.", + "src/app/api/insights/coach/facts/route.ts": + "Stored Coach facts: readable and erasable while the Coach is unavailable.", + "src/app/api/insights/coach/facts/[id]/route.ts": "Erasing one stored fact.", + "src/app/api/insights/chat/[id]/route.ts": + "One stored conversation: read, rename, delete.", + "src/app/api/insights/chat/messages/[id]/feedback/route.ts": + "A rating on a message that already exists.", + "src/app/api/insights/breathing-screening/route.ts": + "Device breathing data, owned by the sleep module.", + "src/app/api/insights/labs-changes/route.ts": + "Lab deltas, owned by the labs module.", + "src/app/api/insights/health-status/route.ts": + "Baseline drift, computed over core vitals.", + "src/app/api/insights/pulse/intraday/route.ts": + "The day's pulse shape, a core vital.", + "src/app/api/insights/patterns/route.ts": "Stored correlation statistics.", + "src/app/api/insights/patterns/[id]/route.ts": + "Dismissing a stored correlation.", + "src/app/api/daily/digest/route.ts": + "The Today digest is data; its AI parts are masked by the loader through the capability resolver, never refused.", + "src/app/api/daily/digest/dismiss/route.ts": "Dismissing a Today rail item.", + "src/app/api/dashboard/snapshot/route.ts": + "The dashboard is data; the briefing inside it is masked per read by the snapshot loader, never refused.", + // Configuration and other non-AI routes in the insights tree. + "src/app/api/insights/feedback/route.ts": + "Feedback on a recommendation that already exists.", + "src/app/api/insights/glp1-plateau/route.ts": + "A deterministic plateau detector over weight and dose history.", + "src/app/api/insights/glp1-timeline/route.ts": + "A merge of medication events.", + "src/app/api/insights/layout/route.ts": "The person's tile layout.", + "src/app/api/insights/provider-chain/route.ts": + "AI configuration: editable while AI is unavailable, so it can be set up.", + "src/app/api/insights/settings/route.ts": + "AI configuration: editable while AI is unavailable, so it can be set up.", + "src/app/api/insights/targets/route.ts": "Threshold reference values.", + // The Coach tree's own data: gated on the `coach` module, never on AI. + "src/app/api/coach/about-me/adopt/route.ts": + "Folds a typed answer into the stored profile; no model call.", + "src/app/api/coach/about-me/questions/route.ts": + "Reads or dismisses stored questions; no model call.", + "src/app/api/coach/plans/route.ts": "Stored Coach plans.", + "src/app/api/coach/plans/[id]/route.ts": "One stored Coach plan.", + "src/app/api/coach/reminders/route.ts": "Stored Coach reminders.", + "src/app/api/coach/reminders/[id]/route.ts": "One stored Coach reminder.", + "src/app/api/coach/reminder-suggestions/route.ts": + "Deterministic reminder suggestions.", + "src/app/api/coach/suggested-actions/route.ts": + "Confirms a proposed action from a closed allowlist; no model call.", +}; + +/** + * Routes in the AI trees whose AI gating another change owns. Each still has + * to be classified; the entry names who moves it. An entry that starts + * calling a capability gate fails the guard until it moves to `AI_ROUTES`. + */ +export const PENDING_ROUTES: Readonly> = { + "src/app/api/insights/chat/fenced/route.ts": + "Document chat inside a conversation: moves to `coach` and `documentAi` with the document-extraction routes.", + "src/app/api/insights/chat/[id]/attachments/route.ts": + "Attaching a document to a conversation: moves to `coach` and `documentAi` with the document-extraction routes.", + "src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts": + "Detaching a document: removal of one's own data, stays ungated once the document routes move.", + "src/app/api/insights/biomarker-assessment/route.ts": + "Status family: moves to `statusText` (200 with null text) in its own change.", + "src/app/api/insights/blood-pressure-status/route.ts": + "Status family: moves to `statusText` in its own change.", + "src/app/api/insights/bmi-status/route.ts": + "Status family: moves to `statusText` in its own change.", + "src/app/api/insights/medication-compliance-status/route.ts": + "Status family: moves to `statusText` in its own change.", + "src/app/api/insights/metric-status/route.ts": + "Status family: moves to `statusText` in its own change.", + "src/app/api/insights/mood-status/route.ts": + "Status family: moves to `statusText` in its own change.", + "src/app/api/insights/pulse-status/route.ts": + "Status family: moves to `statusText` in its own change.", + "src/app/api/insights/weight-status/route.ts": + "Status family: moves to `statusText` in its own change.", +}; + +/** The trees in which every route has to be classified above. */ +export const AI_ROUTE_TREES = [ + "src/app/api/insights", + "src/app/api/coach", + "src/app/api/daily", +] as const; diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index da860c1da..5b9556f47 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -32,11 +32,19 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; * `delegatesTo`): * - cycle → `requireCycleEnabled(...)` (the cycle gate the * `cycle` ModuleKey delegates to). - * - coach → `requireAssistantSurface("coach")` (the assistant - * master flag + per-user opt-out the `coach` ModuleKey - * delegates to). Covered in depth by the sibling - * `coach-route-gate-inventory.test.ts`; listed here so - * coach-bearing routes are not flagged as ungated. + * - an AI capability → `requireAiCapability(key)` / + * `getAiCapability(key)`. A capability resolves its owning + * modules and the AI opt-out (the `coach` module and + * `disableCoach` for the Coach, the `insights` module for + * every other capability), so an AI route that names one is + * gated on those modules. Covered in depth by + * `ai-capability-route-inventory.test.ts`. The `insights` + * module is the AI analysis opt-out and gates only AI parts; + * the data routes that happen to live under + * `/api/insights` are EXEMPT below. + * - coach → `requireAssistantSurface("coach")`, the retired gate, + * still recognised for the one route outside these trees + * that has not moved to its capability yet. * * 3. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a * toggleable domain but are deliberately NOT gated, each with the @@ -147,9 +155,10 @@ const MODULE_ROUTE_TREES_BY_KEY: Readonly< // counted as directly gated here, and the sibling // `coach-route-gate-inventory.test.ts` covers the surface in depth. coach: ["src/app/api/coach"], - // v1.18.0 (B2) — the AI-narrative insights tree, plus v1.28's unified - // daily-digest read (`GET /api/daily/digest`), which is the AI-narrative - // daily layer and gates on `insights` directly. + // The `insights` module is the AI analysis opt-out. It owns no data route: + // the AI routes under these trees name a capability that folds it in, and + // the data routes that live here (ECG, scores, statistics, the Today + // digest) are EXEMPT below because a computation never depends on AI. insights: ["src/app/api/insights", "src/app/api/daily"], // v1.18.1 (D3) — medications graduated from CORE to a toggleable module. // SURFACE-gated (nav entry, dashboard widget, the dedicated Medikamente @@ -299,12 +308,39 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // ── INFRA / UI-ONLY ─────────────────────────────────────────────── // Static FHIR CapabilityStatement — server metadata, no user data. "src/app/api/fhir/metadata/route.ts", + // ── DATA (insights tree) ────────────────────────────────────────── + // The `insights` module is the AI analysis opt-out, so it gates AI parts + // only, through the capability. These routes serve or accept data that no + // model wrote: device recordings and events, computed scores, statistics, + // rule alerts, the person's stored Coach data and the Today digest. Turning + // AI analysis off must leave every one of them working. + "src/app/api/insights/ecg/route.ts", + "src/app/api/insights/ecg/[id]/route.ts", + "src/app/api/insights/rhythm-events/route.ts", + "src/app/api/insights/derived/batch/route.ts", + "src/app/api/insights/cards/route.ts", + "src/app/api/insights/correlations/route.ts", + "src/app/api/insights/coach-read/route.ts", + "src/app/api/insights/health-status/route.ts", + "src/app/api/insights/pulse/intraday/route.ts", + "src/app/api/insights/patterns/route.ts", + "src/app/api/insights/patterns/[id]/route.ts", + // Stored Coach data: viewing and erasing it never depends on the Coach + // being available (the GDPR reason), and a timestamp on one's own row is + // data too. + "src/app/api/insights/coach/seen/route.ts", + "src/app/api/insights/coach/facts/route.ts", + "src/app/api/insights/coach/facts/[id]/route.ts", + "src/app/api/insights/chat/[id]/route.ts", + "src/app/api/insights/chat/messages/[id]/feedback/route.ts", + // The Today digest and its dismiss: data, with the AI parts masked by the + // loader through the capability resolver. + "src/app/api/daily/digest/route.ts", + "src/app/api/daily/digest/dismiss/route.ts", // ── INFRA / CONFIG (insights) ───────────────────────────────────── - // v1.18.0 (B2) — the insights tree's non-narrative routes. The - // `insights` module gates the AI-narrative SURFACES (status cards, - // correlations, derived scores, period narrative, the rhythm-event - // timeline); these six carry no narrative payload, so gating them would - // only break configuration / settings reads while the module is off. + // The insights tree's configuration routes carry no narrative payload, + // so gating them would only break configuration / settings reads while + // the module is off. // // AI provider + privacy settings and the read-only chain summary — pure // configuration of the assistant, surfaced under Settings → AI, not an @@ -386,6 +422,9 @@ const EXEMPT_ROUTES: ReadonlyArray = [ const MODULE_GATE_NEEDLE = "requireModuleEnabled("; const CYCLE_GATE_NEEDLE = "requireCycleEnabled("; const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")'; +// An AI capability resolves its owning modules and the AI opt-out, so a route +// that names one is gated on them. +const AI_CAPABILITY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/; // v1.18.1 — the illness journal's thin gate wrapper. `requireIllnessEnabled` // delegates to `requireModuleEnabled(userId, "illness")` and re-stamps the // illness-specific errorCode, exactly mirroring how `cycle` delegates to @@ -467,6 +506,15 @@ function fileHasCall(text: string, needle: string): boolean { }); } +/** True when a non-comment line calls an AI capability gate. */ +function fileHasCapabilityGate(text: string): boolean { + return text.split("\n").some((line) => { + const trimmed = line.trim(); + if (trimmed.startsWith("//") || trimmed.startsWith("*")) return false; + return AI_CAPABILITY_GATE.test(line); + }); +} + /** * Extract the module key from a `requireModuleEnabled(user.id, "")` * call. Returns the literal key, or null when the key is computed (the @@ -633,6 +681,7 @@ describe("module API route gate inventory", () => { if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue; if (fileHasCall(text, COACH_GATE_NEEDLE)) continue; if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue; + if (fileHasCapabilityGate(text)) continue; if (exempt.has(path)) continue; @@ -753,7 +802,8 @@ describe("module API route gate inventory", () => { fileHasCall(text, MODULE_GATE_NEEDLE) || fileHasCall(text, CYCLE_GATE_NEEDLE) || fileHasCall(text, COACH_GATE_NEEDLE) || - fileHasCall(text, ILLNESS_GATE_NEEDLE) + fileHasCall(text, ILLNESS_GATE_NEEDLE) || + fileHasCapabilityGate(text) ) { stillGated.push(path); } diff --git a/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts index da8647672..402286c7d 100644 --- a/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts +++ b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts @@ -108,7 +108,10 @@ vi.mock("@/lib/ai/coach/self-context-questions", () => ({ questions: [], source: "none", })), + buildFallbackQuestions: vi.fn(() => ["deterministic hint"]), })); +// The follow-up questions are the route's one AI part: `aboutMeQuestions`. +vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() })); import { GET, PUT } from "../route"; import { @@ -118,6 +121,12 @@ import { } from "@/lib/ai/coach/about-me"; import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions"; import { auditLog } from "@/lib/auth/audit"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { buildFallbackQuestions } from "@/lib/ai/coach/self-context-questions"; +import { + AI_AVAILABLE, + aiUnavailable, +} from "@/__tests__/helpers/ai-capability-fixtures"; type Envelope = { data: unknown; @@ -151,6 +160,7 @@ function putReq( describe("about-me module gate", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(getAiCapability).mockResolvedValue(AI_AVAILABLE); profileFindUnique.mockResolvedValue({ updatedAt: new Date(0), aiIncludedSections: ["ABOUT_ME"], @@ -284,6 +294,7 @@ describe("about-me module gate", () => { }, "en", ["ABOUT_ME"], + { aiAvailable: true }, ); expect(setPendingQuestionsForUser).toHaveBeenCalledWith( "u1", @@ -292,6 +303,71 @@ describe("about-me module gate", () => { ); }); + it("derives without the model while aboutMeQuestions is unavailable", async () => { + setGates(true, false); + vi.mocked(getAiCapability).mockResolvedValue( + aiUnavailable("operator_disabled"), + ); + vi.mocked(getSelfContextForUser).mockResolvedValueOnce({ + aboutMe: "I run daily", + conditions: null, + allergies: null, + coachFocus: null, + }); + + const res = await put(putReq({ aiIncludedSections: ["ABOUT_ME"] })); + + expect(res.status).toBe(200); + expect(getAiCapability).toHaveBeenCalledWith("aboutMeQuestions"); + expect(vi.mocked(deriveClarifyingQuestions).mock.calls[0]?.[4]).toEqual({ + aiAvailable: false, + }); + expect((res.data as { ai: unknown }).ai).toEqual( + aiUnavailable("operator_disabled"), + ); + }); + + it("GET swaps an outstanding (possibly model-written) set for the deterministic hints while unavailable", async () => { + setGates(true, false); + vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable("no_provider")); + vi.mocked(getPendingQuestionsForUser).mockResolvedValueOnce([ + "a question a model may have written", + ]); + + const res = await get(); + + expect(res.status).toBe(200); + const data = res.data as { pendingQuestions: string[]; ai: unknown }; + expect(data.pendingQuestions).toEqual(["deterministic hint"]); + expect(data.ai).toEqual(aiUnavailable("no_provider")); + }); + + it("GET keeps an answered (empty) set empty while unavailable", async () => { + setGates(true, false); + vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable("no_provider")); + vi.mocked(getPendingQuestionsForUser).mockResolvedValueOnce([]); + + const res = await get(); + + expect( + (res.data as { pendingQuestions: string[] }).pendingQuestions, + ).toEqual([]); + expect(buildFallbackQuestions).not.toHaveBeenCalled(); + }); + + it("GET serves the stored set while available", async () => { + setGates(true, false); + vi.mocked(getPendingQuestionsForUser).mockResolvedValueOnce([ + "stored question", + ]); + + const res = await get(); + + expect( + (res.data as { pendingQuestions: string[] }).pendingQuestions, + ).toEqual(["stored question"]); + }); + it("clears pending questions when inclusion removes every text field", async () => { setGates(true, false); profileFindUnique.mockResolvedValue({ diff --git a/src/app/api/coach/about-me/route.ts b/src/app/api/coach/about-me/route.ts index 2e05ab307..f6891bd25 100644 --- a/src/app/api/coach/about-me/route.ts +++ b/src/app/api/coach/about-me/route.ts @@ -19,6 +19,13 @@ * text child only — no markdown library exists in the tree and none * may be added (XSS posture, see the contributor notes). * + * The follow-up questions are the one AI part. Model-written questions are + * derived and served only while the `aboutMeQuestions` capability is + * available; otherwise the deterministic completion hints stand in, no + * provider is called, and the `ai` field says why. Stored questions cannot be + * told apart by origin, so while the capability is unavailable an outstanding + * set is replaced on read by the deterministic hints for the same profile. + * * Ownership: the user id always comes from `requireAuth()`; the body * carries only the text. Audit rows never contain the text itself — * only per-field lengths — because it is free-form health prose. @@ -44,7 +51,11 @@ import { filterSelfContextForAi, setPendingQuestionsForUser, } from "@/lib/ai/coach/about-me"; -import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions"; +import { + buildFallbackQuestions, + deriveClarifyingQuestions, +} from "@/lib/ai/coach/self-context-questions"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { ABOUT_ME_FIELD_MAX_CHARS, @@ -79,7 +90,7 @@ export const GET = apiHandler(async () => { const gate = await requireSelfContextConsumer(user.id); if (!gate.enabled) return gate.response; - const [ctx, pendingQuestions, row] = await Promise.all([ + const [ctx, storedQuestions, row, ai] = await Promise.all([ getSelfContextForUser(user.id), gate.coachEnabled ? getPendingQuestionsForUser(user.id) @@ -88,7 +99,22 @@ export const GET = apiHandler(async () => { where: { userId: user.id }, select: { updatedAt: true, aiIncludedSections: true }, }), + getAiCapability("aboutMeQuestions"), ]); + const aiIncludedSections = + (row?.aiIncludedSections as HealthProfileAiSection[] | undefined) ?? + DEFAULT_HEALTH_PROFILE_AI_SECTIONS; + // A stored set may be model-written. While the capability is unavailable + // an outstanding set is swapped for the deterministic hints; an answered + // (empty) set stays empty. + const pendingQuestions = + ai.available || storedQuestions.length === 0 + ? storedQuestions + : buildFallbackQuestions( + filterSelfContextForAi(ctx, aiIncludedSections), + user.locale, + aiIncludedSections, + ); annotate({ action: { name: "coach.about_me.get" }, @@ -107,12 +133,11 @@ export const GET = apiHandler(async () => { allergies: ctx.allergies, coachFocus: ctx.coachFocus, pendingQuestions, - aiIncludedSections: - (row?.aiIncludedSections as HealthProfileAiSection[] | undefined) ?? - DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + aiIncludedSections, updatedAt: row?.updatedAt?.toISOString() ?? null, maxChars: ABOUT_ME_MAX_CHARS, fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS, + ai, }); }); @@ -325,6 +350,7 @@ export const PUT = apiHandler(async (req: Request) => { ctx.allergies === null && ctx.coachFocus === null; + const ai = await getAiCapability("aboutMeQuestions"); let pendingQuestions: string[] = []; let questionsToPersist: string[] | null | undefined; let questionsSource: "ai" | "fallback" | "none" = "none"; @@ -342,6 +368,7 @@ export const PUT = apiHandler(async (req: Request) => { aiContext, user.locale, aiIncludedSections, + { aiAvailable: ai.available }, ); pendingQuestions = derived.questions; questionsSource = derived.source; @@ -404,6 +431,7 @@ export const PUT = apiHandler(async (req: Request) => { updatedAt: updatedAtIso, maxChars: ABOUT_ME_MAX_CHARS, fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS, + ai, }); }); diff --git a/src/app/api/daily/digest/__tests__/route.test.ts b/src/app/api/daily/digest/__tests__/route.test.ts index 4ae9ddb1a..14f636b64 100644 --- a/src/app/api/daily/digest/__tests__/route.test.ts +++ b/src/app/api/daily/digest/__tests__/route.test.ts @@ -8,9 +8,9 @@ import { SCORE_VERSION } from "@/lib/analytics/score/types"; /** * `GET /api/daily/digest` — the P3 read seam. * - * Under test: cookie/Bearer auth narrows the user, the `insights` module gate - * returns a 403 `module.disabled` envelope when off (even for a valid - * session), and the happy path returns the `DailyDigest` DTO shape. The digest + * Under test: cookie/Bearer auth narrows the user, the `insights` module (the + * AI analysis opt-out) no longer refuses the digest, and the happy path + * returns the `DailyDigest` DTO shape. The digest * itself is composed by `loadDailyDigest`, mocked here — the route must reach * no provider (there is nothing AI-shaped in this module graph). */ @@ -44,6 +44,7 @@ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { loadDailyDigest } from "@/lib/daily/load-digest"; +import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -57,6 +58,7 @@ const SESSION_OK = { const DIGEST: DailyDigest = { generatedAt: "2026-07-16T09:00:00.000Z", + ai: DIGEST_AI_AVAILABLE, phase: "final", sleepPending: false, score: { @@ -95,7 +97,7 @@ describe("GET /api/daily/digest", () => { expect(vi.mocked(loadDailyDigest)).not.toHaveBeenCalled(); }); - it("returns the 403 module.disabled envelope when insights is off", async () => { + it("serves the digest with the insights module (the AI analysis opt-out) off", async () => { vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: false, response: apiError('Module "insights" is not enabled', 403, { @@ -104,18 +106,9 @@ describe("GET /api/daily/digest", () => { }), }); const res = await callGet(makeReq()); - expect(res.status).toBe(403); - const body = await res.json(); - expect(body.meta?.errorCode).toBe("module.disabled"); - expect(vi.mocked(loadDailyDigest)).not.toHaveBeenCalled(); - }); - - it("gates on the insights module key", async () => { - await callGet(makeReq()); - expect(vi.mocked(requireModuleEnabled)).toHaveBeenCalledWith( - "user-1", - "insights", - ); + expect(res.status).toBe(200); + expect(vi.mocked(requireModuleEnabled)).not.toHaveBeenCalled(); + expect(vi.mocked(loadDailyDigest)).toHaveBeenCalled(); }); it("returns 200 with the DailyDigest DTO on the happy path", async () => { diff --git a/src/app/api/daily/digest/dismiss/__tests__/route.test.ts b/src/app/api/daily/digest/dismiss/__tests__/route.test.ts index 795f7ef8c..71b989788 100644 --- a/src/app/api/daily/digest/dismiss/__tests__/route.test.ts +++ b/src/app/api/daily/digest/dismiss/__tests__/route.test.ts @@ -1,8 +1,8 @@ /** * `POST /api/daily/digest/dismiss` — the Today rail dismiss surface. * - * Under test: cookie/Bearer auth narrows the user, the `insights` module gate - * returns a 403 `module.disabled` envelope when off, an `itemKey` that isn't + * Under test: cookie/Bearer auth narrows the user, the `insights` module (the + * AI analysis opt-out) no longer refuses a dismissal, an `itemKey` that isn't * namespaced under a dismissible kind 422s BEFORE any DB write, and the happy * path upserts the `DismissedPriorityItem` row scoped to the caller. */ @@ -98,7 +98,7 @@ describe("POST /api/daily/digest/dismiss", () => { expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled(); }); - it("returns the 403 module.disabled envelope when insights is off", async () => { + it("accepts a dismissal with the insights module (the AI analysis opt-out) off", async () => { vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: false, response: apiError('Module "insights" is not enabled', 403, { @@ -109,8 +109,9 @@ describe("POST /api/daily/digest/dismiss", () => { const res = await callPost( makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }), ); - expect(res.status).toBe(403); - expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + expect(requireModuleEnabled).not.toHaveBeenCalled(); + expect(prisma.dismissedPriorityItem.upsert).toHaveBeenCalled(); }); it("422s an itemKey that isn't namespaced under a dismissible kind — an actionable item can never be dismissed", async () => { diff --git a/src/app/api/daily/digest/dismiss/route.ts b/src/app/api/daily/digest/dismiss/route.ts index 96b8c7d40..05a8d1f24 100644 --- a/src/app/api/daily/digest/dismiss/route.ts +++ b/src/app/api/daily/digest/dismiss/route.ts @@ -12,8 +12,8 @@ * an upsert, so a repeat dismiss of the same instance is a no-op rather than * a conflict. `userId` is narrowed from `requireAuth()` only; the body never * carries one. Cookie OR Bearer, mirroring the digest GET route's own auth - * policy, and gated on the same `insights` module (the daily rail's host - * surface). + * policy. Like the digest GET it carries no module gate: the rail is data, + * and the `insights` module is the AI analysis opt-out. */ import type { NextRequest } from "next/server"; @@ -28,7 +28,6 @@ import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { isScoreNoticeItemKey } from "@/lib/daily/priority-item"; -import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { dismissPriorityItemSchema } from "@/lib/validations/daily"; @@ -40,9 +39,6 @@ const DISMISS_WINDOW_MS = 60_000; export const POST = apiHandler(async (req: NextRequest) => { const { user } = await requireAuth(); - const gate = await requireModuleEnabled(user.id, "insights"); - if (!gate.enabled) return gate.response; - const rl = await checkRateLimit( `daily-digest-dismiss:${user.id}`, DISMISS_RATE_LIMIT, diff --git a/src/app/api/daily/digest/route.ts b/src/app/api/daily/digest/route.ts index 78b09aebe..db05d9ea8 100644 --- a/src/app/api/daily/digest/route.ts +++ b/src/app/api/daily/digest/route.ts @@ -10,14 +10,15 @@ * provider call is reachable from this path, and nothing warms on mount. * * Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the - * resolved session — never a body field. Gated on the `insights` module (the - * daily digest is the AI-narrative daily layer); a disabled account gets the - * standard 403 `module.disabled` envelope even over a Bearer token. The rail's - * data-tile inputs inherit their own module gates via the snapshot builder. + * resolved session — never a body field. No module gate and no AI gate: the + * digest is data (score, doses, syncs, checkups, the day's markers). Its AI + * parts, the briefing lead, the reaction line and the Coach check-in, are + * served only while their capabilities are available, and the `ai` block says + * which. The rail's data-tile inputs inherit their own module gates via the + * snapshot builder. */ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; -import { requireModuleEnabled } from "@/lib/modules/gate"; import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; import { loadDailyDigest } from "@/lib/daily/load-digest"; @@ -27,13 +28,10 @@ export const GET = apiHandler(async () => { // The record's Today hero. Same family as the dashboard snapshot and // admitted on the same argument: an aggregate over the record's own data, // assembled from already-cached values with no provider on the path. The - // module gate below now runs against the RECORD, which is the behaviour that - // matters — a delegate gets the hero only where the owner switched insights - // on, never where the delegate did. Re-examine with the snapshot when + // AI parts resolve against the RECORD, so a delegate sees the owner's + // capabilities, never their own. Re-examine with the snapshot when // per-module scope lands; a digest is a summary of several modules at once. const { user } = await requireRecordAuth("read", "record"); - const m = await requireModuleEnabled(user.id, "insights"); - if (!m.enabled) return m.response; const digest = await loadDailyDigest(user); diff --git a/src/app/api/dashboard/snapshot/__tests__/route.test.ts b/src/app/api/dashboard/snapshot/__tests__/route.test.ts index 7d68a97f1..9e5290ed4 100644 --- a/src/app/api/dashboard/snapshot/__tests__/route.test.ts +++ b/src/app/api/dashboard/snapshot/__tests__/route.test.ts @@ -38,9 +38,19 @@ vi.mock("next/headers", () => ({ })); const buildDashboardSnapshot = vi.fn(); -vi.mock("@/lib/dashboard/snapshot", () => ({ +vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({ + ...(await importOriginal()), buildDashboardSnapshot: (...a: unknown[]) => buildDashboardSnapshot(...a), })); +// The per-read briefing capability; its own behaviour is pinned in +// `snapshot-read-briefing-capability.test.ts`. +vi.mock("@/lib/ai/capabilities/record", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), +})); import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; 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 c35baf017..89f3cda16 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 @@ -1,254 +1,119 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { describe, expect, it } from "vitest"; -/** - * v1.4.38 W-C M6 — Coach API route gate inventory. - * - * The web surfaces are protected by the `flags.coach` short-circuit in - * every Coach-bearing component (see - * `src/lib/feature-flags/__tests__/coach-cascade.test.tsx`). The - * server-side mirror is `requireAssistantSurface("coach")` in every - * Coach-only route handler. Without a discovery test, a future - * contributor who lands a new `/api/insights//route.ts` whose body - * touches the Coach stack would have to remember the gate by hand — a - * silent miss leaks the surface even when the operator turned the - * Coach matrix off. - * - * The walk below scans every `route.ts` under `src/app/api/insights/` - * and groups each handler into one of three buckets: - * - * 1. Coach-gated — file imports + invokes - * `requireAssistantSurface("coach")`. - * 2. Other-gated — file invokes `requireAssistantSurface()` with a - * different surface (`insightStatus`, - * `correlations`, `briefing`). - * 3. Allowlisted — non-Coach Insights routes that don't gate on - * the matrix at all (provider chain, settings, - * targets, glp1-timeline, feedback). Allowlist - * lives alongside the test for easy review. - * - * Anything that doesn't fit one of the three buckets is an orphan and - * fails the test by name so the fix is one search-and-add. - */ - -const NON_COACH_GATED_ROUTES: ReadonlyArray = [ - // Non-Coach assistant surfaces — gated on a sibling sub-flag. - // Per-biomarker assessment. Gated on the same `insightStatus` sub-flag as - // the metric-status + specialised status routes (no Coach prose). - "src/app/api/insights/biomarker-assessment/route.ts", - "src/app/api/insights/blood-pressure-status/route.ts", - "src/app/api/insights/bmi-status/route.ts", - "src/app/api/insights/cards/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. - "src/app/api/insights/metric-status/route.ts", - "src/app/api/insights/mood-status/route.ts", - // v1.11.0 — period-narrative read route. Gates on the same `insightStatus` - // sub-flag as the assessment routes (no Coach prose). - "src/app/api/insights/narrative/route.ts", - // v1.9.0 — on-demand full assessment warm. Warms the same assessment - // cards the status routes serve, so it gates on `insightStatus`, not - // `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", - "src/app/api/insights/weight-status/route.ts", -]; - -const NOT_COACH_OWNED_ROUTES: ReadonlyArray = [ - // Settings + chain inventory — surface-agnostic infra reads. The - // settings UI itself is server-rendered and the chain page hides - // when no provider is configured; neither one carries assistant - // prose. Disabling Coach must not break these reads — the user can - // still inspect and reconfigure the chain that gets re-enabled. - "src/app/api/insights/provider-chain/route.ts", - // Correlation statistics: computed, never written by a model. The switch - // that used to gate them was retired because it stopped no AI work. - "src/app/api/insights/correlations/route.ts", - "src/app/api/insights/settings/route.ts", - // Read-only data feeds — `/targets` is a non-assistant Zielwerte - // page driven by classifications, not LLM prose; the GLP-1 timeline - // is the same data the medication page paints whether the assistant - // is on or off. Recommendation feedback (`/feedback`) records a - // thumbs-up/-down on a NON-Coach recommendation row that was - // produced before the operator flipped the flag; gating it would - // mean the operator's mid-flight toggle erases the user's - // ability to dispose of stale recommendations. - "src/app/api/insights/feedback/route.ts", - "src/app/api/insights/glp1-timeline/route.ts", - // Deterministic GLP-1 plateau detector read (no assistant prose) — same - // medications-domain posture as glp1-timeline directly above. - "src/app/api/insights/glp1-plateau/route.ts", - // v1.5.5 — per-user tile layout for the `/insights` surface. The - // endpoint persists tile visibility + ordering only; it carries no - // assistant prose and is the mirror of `/api/dashboard/widgets`, - // which also sits outside the Coach gate. Disabling Coach must not - // wedge the user's ability to reorder the insights tile strip. - "src/app/api/insights/layout/route.ts", - "src/app/api/insights/targets/route.ts", - // v1.25 — read-only awareness cards for the overview. Each is pure compute - // over the rollup / lab tier (baseline-drift, sleep-breathing screening, - // last-panel lab deltas) and carries no assistant prose; they gate on the - // `insights` module, not on the Coach surface. Disabling Coach must not - // wedge these factual reads. - "src/app/api/insights/health-status/route.ts", - "src/app/api/insights/breathing-screening/route.ts", - "src/app/api/insights/labs-changes/route.ts", - // S11 — the intraday pulse / tension read. Deterministic compute over the - // day's raw PULSE / steps / workouts (10-minute mean shape + the cautious - // elevated-at-rest window); it carries no assistant prose and gates on the - // `insights` module, not the Coach surface. Same posture as health-status / - // labs-changes directly above — disabling Coach must not wedge this read. - "src/app/api/insights/pulse/intraday/route.ts", - // Persisted correlation decisions are deterministic Insights data. They - // gate on the Insights module and remain usable when Coach is disabled. - "src/app/api/insights/patterns/route.ts", - "src/app/api/insights/patterns/[id]/route.ts", - // v1.29.x (S7) — the FENCED multi-document coach chat + its attach/detach - // mutations. These live under /insights/chat for URL grouping but are - // DOCUMENT-surface routes: they gate on the `inboundDocuments` module - // (`requireModuleEnabled`), NOT on the Coach assistant surface — parity with - // the document-sheet chat (`/api/documents/inbound/[id]/chat`, outside this - // scan). The security boundary here is the fence (no tools, no snapshot), - // not the Coach gate; disabling Coach must not wedge a fenced document chat - // any more than it wedges the document sheet. See the S7 design §3.2 step 1. - "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")'; -const ANY_GATE_NEEDLE = "requireAssistantSurface("; +import { + AI_ROUTES, + AI_ROUTE_TREES, + DATA_ROUTES, + PENDING_ROUTES, +} from "../../__tests__/ai-route-inventory"; /** - * Return true when the file contains the needle on a line that is NOT - * a pure comment. A documentation comment that mentions the gate - * (`// requireAssistantSurface("coach") protects this surface`) must - * not satisfy the gate-presence check — otherwise a contributor who - * deletes the actual call but leaves the docstring would slip through. + * Every route in the trees where AI routes live is classified. + * + * This file used to sort the `/api/insights` routes into "Coach-gated", + * "other-gated" and "not Coach-owned" by the operator switch each one read. + * That sorting encoded the defect the AI-optional design removes: ECG uploads, + * rhythm events, scores and correlation statistics sat behind AI switches, so + * turning AI off took data down with it. The rule now is the capability rule: + * + * - a data route asks no AI capability and is never refused for an AI + * reason; + * - an AI route names its capability (`requireAiCapability` for an action, + * `getAiCapability` for a mixed read). + * + * The lists live in `src/app/api/__tests__/ai-route-inventory.ts`; the whole + * API tree is checked against them by `ai-capability-route-inventory.test.ts`. + * This file walks the insights, Coach and daily trees and fails by name on any + * route that none of the lists classifies, so a new route there has to decide + * which kind it is. `PENDING_ROUTES` names the few whose AI gating another + * change owns; an entry there that starts asking a capability fails until it + * moves to `AI_ROUTES`. + * + * Mutation checks: deleting `insights/ecg/route.ts` from `DATA_ROUTES` fails + * the orphan test by name; adding `getAiCapability("coach")` to a pending + * route fails the pending test; pointing the walk at an empty tree fails the + * count. */ -function fileHasGateCall(text: string, needle: string): boolean { - return text.split("\n").some((line) => { - if (!line.includes(needle)) return false; - const trimmed = line.trim(); - if (trimmed.startsWith("//")) return false; - if (trimmed.startsWith("*")) return false; - return true; - }); -} -/** - * Walk every `route.ts` under `src/app/api/insights/` (including - * dynamic segments under `[id]`). Returns POSIX-style paths relative - * to the repo root. - */ -function findInsightsRouteFiles(): string[] { - // __dirname here is `src/app/api/insights/__tests__/`, so the - // insights root is one directory up. - const insightsRoot = resolve(__dirname, ".."); - const repoRoot = resolve(insightsRoot, "..", "..", "..", ".."); +const repoRoot = resolve(__dirname, "..", "..", "..", "..", ".."); + +function routeFiles(tree: string): string[] { const hits: string[] = []; - function walk(dir: string): void { - const entries = readdirSync(dir); - for (const entry of entries) { + for (const entry of readdirSync(dir)) { const full = join(dir, entry); - const stat = statSync(full); - if (stat.isDirectory()) { - if (entry === "__tests__") continue; - if (entry === "node_modules") continue; + if (statSync(full).isDirectory()) { + if (entry === "__tests__" || entry === "node_modules") continue; walk(full); - continue; + } else if (entry === "route.ts") { + hits.push(relative(repoRoot, full).split(/[\\/]/).join("/")); } - if (entry !== "route.ts") continue; - hits.push(relative(repoRoot, full).split(/[\\/]/).join("/")); } } - - walk(insightsRoot); - return hits.sort(); + walk(resolve(repoRoot, tree)); + return hits; } -describe("Coach API route gate inventory", () => { - it("every Coach-bearing insights route imports requireAssistantSurface('coach')", () => { - const repoRoot = resolve(__dirname, "..", "..", "..", "..", ".."); - const routes = findInsightsRouteFiles(); - expect(routes.length).toBeGreaterThan(0); +const ROUTES = AI_ROUTE_TREES.flatMap(routeFiles).sort(); - const allowlistOther = new Set(NON_COACH_GATED_ROUTES); - const allowlistNone = new Set(NOT_COACH_OWNED_ROUTES); +const ANY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/; - const orphans: Array<{ path: string; reason: string }> = []; +function code(path: string): string { + return readFileSync(resolve(repoRoot, path), "utf8") + .split("\n") + .filter((line) => !/^\s*(\*|\/\/|\/\*)/.test(line)) + .join("\n"); +} - for (const path of routes) { - const full = resolve(repoRoot, path); - const text = readFileSync(full, "utf8"); - const hasCoachGate = fileHasGateCall(text, COACH_GATE_NEEDLE); - const hasAnyGate = fileHasGateCall(text, ANY_GATE_NEEDLE); - - if (hasCoachGate) continue; - - if (allowlistOther.has(path)) { - // Sanity — the file should still gate on SOME assistant - // surface. A stale entry on this allowlist is misleading - // documentation; flip the failure mode so the orphan is - // reported with the right diagnosis. - if (!hasAnyGate) { - orphans.push({ - path, - reason: - "listed in NON_COACH_GATED_ROUTES but has no requireAssistantSurface() call at all", - }); - } - continue; - } - - if (allowlistNone.has(path)) continue; - - orphans.push({ - path, - reason: `missing requireAssistantSurface("coach"); add the gate or move the route onto an allowlist (NON_COACH_GATED_ROUTES / NOT_COACH_OWNED_ROUTES)`, - }); +describe("insights, Coach and daily route classification", () => { + it("walks trees that exist and finds routes in them", () => { + for (const tree of AI_ROUTE_TREES) { + expect(existsSync(resolve(repoRoot, tree)), tree).toBe(true); } + expect(ROUTES.length).toBeGreaterThan(40); + }); + it("classifies every route as AI, data or pending", () => { + const orphans = ROUTES.filter( + (path) => + !(path in AI_ROUTES) && + !(path in DATA_ROUTES) && + !(path in PENDING_ROUTES), + ); expect( orphans, [ - "Coach API route gate inventory found unaccounted-for handler(s):", - ...orphans.map((o) => ` - ${o.path}: ${o.reason}`), + "Unclassified routes. Decide what each one is and add it to", + "src/app/api/__tests__/ai-route-inventory.ts:", + " AI_ROUTES — it calls a model or serves model text (name the capability);", + " DATA_ROUTES — it serves or accepts data and must never ask AI.", ].join("\n"), ).toEqual([]); }); - it("allowlists do not reference deleted route files", () => { - const known = new Set(findInsightsRouteFiles()); - - const stale = [...NON_COACH_GATED_ROUTES, ...NOT_COACH_OWNED_ROUTES].filter( - (path) => !known.has(path), + it("classifies no route twice", () => { + const lists = [AI_ROUTES, DATA_ROUTES, PENDING_ROUTES].map((list) => + Object.keys(list), ); + const seen = new Map(); + for (const list of lists) { + for (const path of list) seen.set(path, (seen.get(path) ?? 0) + 1); + } + expect([...seen].filter(([, n]) => n > 1).map(([path]) => path)).toEqual( + [], + ); + }); - expect( - stale, - [ - "Allowlist entries point to files that no longer exist —", - "delete the stale entries from", - "`src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts`:", - ...stale.map((p) => ` - ${p}`), - ].join("\n"), - ).toEqual([]); + it("keeps PENDING_ROUTES honest: existing files, none of them asking yet", () => { + for (const path of Object.keys(PENDING_ROUTES)) { + expect(ROUTES, `${path} no longer exists`).toContain(path); + expect( + ANY_GATE.test(code(path)), + `${path} now asks an AI capability — move it to AI_ROUTES`, + ).toBe(false); + } }); }); diff --git a/src/app/api/insights/breathing-screening/route.ts b/src/app/api/insights/breathing-screening/route.ts index c0c3cd287..1a0cf88c6 100644 --- a/src/app/api/insights/breathing-screening/route.ts +++ b/src/app/api/insights/breathing-screening/route.ts @@ -10,7 +10,7 @@ * classification). SCREENING SIGNAL ONLY — never a HealthLog diagnosis; the * card states this explicitly. * - * Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights` + * Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `sleep` * module gate, the shared analytics-read budget. `userId` is narrowed from the * session. */ @@ -31,7 +31,10 @@ export const GET = apiHandler(async () => { // provider anywhere on the path. const { user } = await requireRecordAuth("manage", "record"); - const m = await requireModuleEnabled(user.id, "insights"); + // The screening belongs to sleep: with the sleep module off it hides like + // every other sleep surface. Not an AI read, so the `insights` module (the + // AI analysis opt-out) does not gate it. + const m = await requireModuleEnabled(user.id, "sleep"); if (!m.enabled) return m.response; const rl = await checkAnalyticsReadRateLimit(user.id); diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts index 5954961ac..0b6c6880a 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -7,9 +7,8 @@ vi.mock("@/lib/db", () => ({ measurement: { findMany: vi.fn() }, medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, - // v1.4.31 — gated on `assistant.insightStatus`; null row falls - // back to the all-on default so existing assertions ride - // through unchanged. + // The operator switches are read by nothing on this route any more; the + // mock stays so a test can prove a switch-off changes nothing. appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, // v1.28 perf — PULSE now reads through the DAY-rollup read-swap // (`probeRollupCoverage` + `readDayMeanSeries`). An empty coverage @@ -20,10 +19,8 @@ vi.mock("@/lib/db", () => ({ }, })); -// v1.18.0 — the route now resolves the `insights` module gate after -// `requireAuth()`. Mock it default-enabled so the existing assertions -// (data shape + assistant-flag gating) ride through; the dedicated -// off → 403 coverage lives in the route-gate inventory test. +// The route no longer asks the `insights` module (the AI analysis opt-out); +// the mock stays so a test can prove it is never consulted. vi.mock("@/lib/modules/gate", async (importOriginal) => ({ ...(await importOriginal()), requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), @@ -72,7 +69,6 @@ beforeEach(() => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: null, dateOfBirth: null, - aiProvider: null, } as never); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); // v1.28 perf — empty coverage probe routes PULSE through the @@ -86,33 +82,29 @@ function makeReq(): NextRequest { return new NextRequest("http://localhost/api/insights/cards"); } -describe("GET /api/insights/cards — assistant-flag gate", () => { - it("returns 403 + errorCode when insightStatus is disabled", async () => { +describe("GET /api/insights/cards — rule alerts are data", () => { + it("answers 200 with every AI switch off", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ - assistantEnabled: true, - assistantCoachEnabled: true, - assistantBriefingEnabled: true, + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + assistantEnabled: false, + assistantCoachEnabled: false, + assistantBriefingEnabled: false, assistantInsightStatusEnabled: false, - assistantDocumentAiEnabled: true, + assistantDocumentAiEnabled: false, } as never); const res = await callGet(makeReq()); - expect(res.status).toBe(403); - const body = (await res.json()) as { meta?: { errorCode?: string } }; - expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); + expect(res.status).toBe(200); }); - it("returns 403 when the master flag is off", async () => { + it("answers 200 with the AI analysis opt-out on, without asking the module", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ - assistantEnabled: false, - assistantCoachEnabled: true, - assistantBriefingEnabled: true, - assistantInsightStatusEnabled: true, - assistantDocumentAiEnabled: true, + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), } as never); const res = await callGet(makeReq()); - expect(res.status).toBe(403); + expect(res.status).toBe(200); + expect(requireModuleEnabled).not.toHaveBeenCalled(); }); }); @@ -157,19 +149,23 @@ describe("GET /api/insights/cards", () => { vi.mocked(prisma.measurement.findMany).mockResolvedValue( measurements as never, ); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - heightCm: null, - dateOfBirth: null, - aiProvider: "ANTHROPIC", - } as never); - const res = await callGet(makeReq()); expect(res.status).toBe(200); const body = (await res.json()) as { - data: Array<{ severity: string; provider: string; title: string }>; + data: Array<{ + severity: string; + provider: string; + title: string; + summary: string | null; + }>; }; expect(body.data.length).toBeGreaterThan(0); - expect(body.data[0].provider).toBe("anthropic"); + // The threshold engine wrote these, never a model: the provider says so + // and no card ever carries null text (a native decoder needs both). + for (const card of body.data) { + expect(card.provider).toBe("rules"); + expect(typeof card.summary).toBe("string"); + } expect(body.data.some((c) => c.severity === "alert")).toBe(true); }); diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 23dc79ae7..197bfeb97 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -5,10 +5,14 @@ * shared `generateAlerts()` rule engine, then re-shapes each `HealthAlert` * to the iOS Insight model (id, title, summary, severity, recommendations, * provider). + * + * Every card is a rule alert: deterministic text from the threshold engine, + * never model output. So the route carries no AI gate and no `insights` + * module gate (the AI analysis opt-out), `summary` is never null, and + * `provider` is `"rules"`. It used to echo the account's chosen AI provider + * (or `"claude"`), which claimed a model wrote text that none did. */ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; -import { requireAssistantSurface } from "@/lib/feature-flags"; -import { requireModuleEnabled } from "@/lib/modules/gate"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; @@ -68,12 +72,6 @@ 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.4.31 — the iOS cards adapter feeds the same per-metric - // insight surfaces the web `` mounts on each - // /insights/ sub-page. Both share the operator gate. - await requireAssistantSurface("insightStatus"); annotate({ action: { name: "insights.cards" } }); const NINETY_DAY_WINDOW = 90; @@ -83,7 +81,7 @@ export const GET = apiHandler(async () => { await Promise.all([ prisma.user.findUnique({ where: { id: user.id }, - select: { heightCm: true, dateOfBirth: true, aiProvider: true }, + select: { heightCm: true, dateOfBirth: true }, }), // WEIGHT + BP are manual entries (low volume, and BP needs exact- // timestamp pairing below for `bpPctInTarget`), so a bounded raw read @@ -257,7 +255,8 @@ export const GET = apiHandler(async () => { medications: medicationCompliance, }); - const provider = dbUser?.aiProvider?.toLowerCase() ?? "claude"; + // The rule engine wrote these, not a model. + const provider = "rules"; const generatedAt = new Date().toISOString(); const cards: InsightCard[] = alerts.map((alert, idx) => ({ diff --git a/src/app/api/insights/chat/[id]/route.ts b/src/app/api/insights/chat/[id]/route.ts index 1bb5577c4..ff1371c15 100644 --- a/src/app/api/insights/chat/[id]/route.ts +++ b/src/app/api/insights/chat/[id]/route.ts @@ -21,7 +21,6 @@ import { safeJson, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { auditLog } from "@/lib/auth/audit"; import { COACH_CONVERSATION_TITLE_MAX } from "@/lib/ai/coach/types"; @@ -43,10 +42,9 @@ const renameConversationSchema = z export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => { const auth = await requireAuth(); - // v1.4.38 W-C M6 — operator can hide the Coach surface app-wide; - // the conversation reader is part of the Coach stack (encrypted - // assistant prose), so a disabled surface must 403 here too. - await requireAssistantSurface("coach"); + // Never AI-gated: a stored conversation is the person's own data. Reading, + // renaming and deleting it keep working while the Coach is unavailable for + // any reason, so a thread can always be reviewed and erased. const { id } = await ctx.params; if (!id) return apiError("coach.conversation.notFound", 404); @@ -74,7 +72,6 @@ export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => { export const PATCH = apiHandler(async (request: NextRequest, ctx: RouteCtx) => { const auth = await requireAuth(); - await requireAssistantSurface("coach"); const { id } = await ctx.params; if (!id) return apiError("coach.conversation.notFound", 404); @@ -114,10 +111,8 @@ export const PATCH = apiHandler(async (request: NextRequest, ctx: RouteCtx) => { export const DELETE = apiHandler( async (_request: NextRequest, ctx: RouteCtx) => { const auth = await requireAuth(); - // v1.4.38 W-C M6 — same gate as GET; a disabled Coach surface - // means the user can't reach the conversation list anyway, so the - // delete affordance must stay behind the same kill-switch. - await requireAssistantSurface("coach"); + // Not AI-gated, like GET: deleting one's own thread never waits on the + // Coach being available. const { id } = await ctx.params; if (!id) return apiError("coach.conversation.notFound", 404); diff --git a/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts b/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts index 7849a4827..1ad9881f1 100644 --- a/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts +++ b/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts @@ -26,11 +26,14 @@ vi.mock("@/lib/api-handler", () => ({ }, })); vi.mock("@/lib/modules/gate", () => ({ - requireModuleEnabled: vi.fn(async () => ({ enabled: true })), isModuleEnabled: vi.fn(async () => true), })); -vi.mock("@/lib/feature-flags", () => ({ - requireAssistantSurface: vi.fn(async () => undefined), +vi.mock("@/lib/ai/capabilities/gate", () => ({ + requireAiCapability: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), })); vi.mock("@/lib/api-response", () => ({ apiError: (error: string, status: number) => ({ data: null, error, status }), diff --git a/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts b/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts new file mode 100644 index 000000000..0ef7fb98f --- /dev/null +++ b/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; + +/** + * The Coach chat POST (SSE) asks for the `coach` AI capability right after + * auth, before the body is parsed or anything else runs. The capability folds + * in what the Coach module gate, the operator switch and the `disableCoach` + * opt-out used to answer separately, plus provider presence and consent. + * + * - Unavailable for any reason but a missing provider: the capability + * envelope is thrown (apiHandler renders it) and nothing else runs. + * - `no_provider`: the existing `coach.provider.none` SSE frame, now with + * `reason`, because the streaming clients already read that frame. + * + * The conversation-list GET is the person's stored data: it never asks. + */ + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), + HttpError: class HttpError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } + }, +})); + +const { requireAiCapability } = vi.hoisted(() => ({ + requireAiCapability: vi.fn(), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ requireAiCapability })); +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), + MODULE_DISABLED_ERROR_CODE: "module.disabled", +})); + +vi.mock("@/lib/api-response", () => ({ + apiError: (error: string, status: number, meta?: unknown) => ({ + data: null, + error, + status, + meta, + }), + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + apiValidationError: (error: string, _issues: unknown, status: number) => + new Response(JSON.stringify({ data: null, error }), { status }), + sanitiseZodIssues: () => [], +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: vi.fn(async () => "en"), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback: vi.fn(), +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: vi.fn(), + resolveProvider: vi.fn(), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() })); +vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" })); +vi.mock("@/lib/ai/coach/types", () => ({ + coachChatRequestSchema: { + safeParse: () => ({ success: false, error: { issues: [] } }), + }, +})); +// v1.20.0 (F1) — the route statically imports the tool barrel; this suite +// never reaches tool code (it asserts the 403 module-gate short-circuit), so a +// thin stub keeps the import graph satisfied without pulling the real schemas. +vi.mock("@/lib/ai/coach/tools", () => ({ + COACH_TOOL_DEFS: [], + MAX_ROUNDS: 3, + buildCoachDataInventory: vi.fn(), + renderDataInventory: vi.fn(), + renderFocusHint: vi.fn(() => ""), + buildToolModeAddendum: vi.fn(), + runCoachToolLoop: vi.fn(), +})); +vi.mock("@/lib/ai/coach/persistence", () => ({ + appendMessage: vi.fn(), + createConversation: vi.fn(), + fetchConversationWithMessages: vi.fn(), + listConversations: vi.fn(async () => ({ + conversations: [], + nextCursor: null, + })), +})); +vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({ + enqueueCoachMemoryRefresh: vi.fn(), +})); +vi.mock("@/lib/ai/coach/facts", () => ({ storeDeterministicFacts: vi.fn() })); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(), + reserveBudget: vi.fn(async () => ({ + allowed: true, + reserved: 0, + totalAfter: 0, + })), + reconcileSpend: vi.fn(), + resolveDailyCap: vi.fn(() => 200_000), + resolveDailyCapFor: vi.fn(() => 200_000), + resolveCostOwner: vi.fn(() => "operator" as const), +})); +vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() })); +vi.mock("@/lib/ai/coach/system-prompt", () => ({ + getCoachSystemPrompt: vi.fn(), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(), +})); +vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() })); +vi.mock("@/lib/ai/coach/keyvalues", () => ({ + parseKeyValuesSentinel: vi.fn(), +})); +vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() })); +// Collect the frames a stream would carry, so a refusal frame is observable. +vi.mock("@/lib/sse/create-stream", () => ({ + createSseStream: vi.fn( + (start: (c: { enqueue: (chunk: Uint8Array) => void }) => void) => { + const chunks: string[] = []; + start({ + enqueue: (chunk) => chunks.push(new TextDecoder().decode(chunk)), + }); + return chunks.join(""); + }, + ), +})); + +import { POST, GET } from "../route"; + +type Envelope = { data: unknown; error: string | null; status: number }; +const post = POST as unknown as (req: Request) => Promise; +const get = GET as unknown as (req: Request) => Promise; + +function chatPostReq(): Request { + return new Request("http://localhost/api/insights/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi" }), + }); +} + +function listReq(): Request { + return new Request("http://localhost/api/insights/chat", { method: "GET" }); +} + +describe("coach chat capability gate", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + ["operator_disabled", 403, "assistant.disabled.coach"], + ["module_disabled", 403, "module.disabled"], + ["user_disabled", 403, "module.disabled"], + ["consent_required", 403, "consent.ai.required"], + ["not_permitted_for_record", 403, "ai.record.notPermitted"], + ["check_failed", 503, "ai.unavailable"], + ] as const)( + "POST refuses with the capability envelope for %s", + async (reason, status, errorCode) => { + requireAiCapability.mockRejectedValue( + new AiUnavailableError("coach", reason), + ); + + const refused = await post(chatPostReq()).catch((err: unknown) => err); + + expect(refused).toBeInstanceOf(AiUnavailableError); + const error = refused as AiUnavailableError; + expect(error.status).toBe(status); + expect(error.meta).toMatchObject({ + errorCode, + capability: "coach", + reason, + }); + expect(requireAiCapability).toHaveBeenCalledWith("coach"); + }, + ); + + it("POST keeps the coach.provider.none SSE frame for a missing provider, with the reason", async () => { + requireAiCapability.mockRejectedValue( + new AiUnavailableError("coach", "no_provider"), + ); + + const res = await post(chatPostReq()); + + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toContain('"code":"coach.provider.none"'); + expect(body).toContain('"reason":"no_provider"'); + }); + + it("POST continues past the gate when the Coach is available", async () => { + requireAiCapability.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + + const res = await post(chatPostReq()); + + // The stubbed schema refuses the body, so reaching validation proves the + // gate let the request through. + expect(res.status).toBe(422); + }); + + it("GET never asks for the capability: stored conversations stay readable", async () => { + requireAiCapability.mockRejectedValue( + new AiUnavailableError("coach", "operator_disabled"), + ); + + const res = await get(listReq()); + + expect(res.status).toBe(200); + expect(requireAiCapability).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts index b3476a8af..fe02ba885 100644 --- a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts +++ b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts @@ -24,10 +24,14 @@ vi.mock("@/lib/api-handler", () => ({ }, })); vi.mock("@/lib/modules/gate", () => ({ - requireModuleEnabled: vi.fn(async () => ({ enabled: true })), + isModuleEnabled: vi.fn(async () => true), })); -vi.mock("@/lib/feature-flags", () => ({ - requireAssistantSurface: vi.fn(async () => undefined), +vi.mock("@/lib/ai/capabilities/gate", () => ({ + requireAiCapability: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), })); vi.mock("@/lib/api-response", () => ({ apiError: (error: string, status: number) => ({ data: null, error, status }), diff --git a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts index 3923f76b3..740f2be72 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts @@ -37,11 +37,14 @@ vi.mock("@/lib/api-handler", () => ({ }, })); vi.mock("@/lib/modules/gate", () => ({ - requireModuleEnabled: vi.fn(async () => ({ enabled: true })), isModuleEnabled: vi.fn(async () => true), })); -vi.mock("@/lib/feature-flags", () => ({ - requireAssistantSurface: vi.fn(async () => undefined), +vi.mock("@/lib/ai/capabilities/gate", () => ({ + requireAiCapability: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), })); vi.mock("@/lib/api-response", () => ({ apiError: (error: string, status: number) => ({ data: null, error, status }), diff --git a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts index 14adc66aa..f47849ff3 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts @@ -30,11 +30,14 @@ vi.mock("@/lib/api-handler", () => ({ }, })); vi.mock("@/lib/modules/gate", () => ({ - requireModuleEnabled: vi.fn(async () => ({ enabled: true })), isModuleEnabled: vi.fn(async () => true), })); -vi.mock("@/lib/feature-flags", () => ({ - requireAssistantSurface: vi.fn(async () => undefined), +vi.mock("@/lib/ai/capabilities/gate", () => ({ + requireAiCapability: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), })); vi.mock("@/lib/api-response", () => ({ apiError: (error: string, status: number) => ({ data: null, error, status }), diff --git a/src/app/api/insights/chat/__tests__/route-guard.test.ts b/src/app/api/insights/chat/__tests__/route-guard.test.ts index 2803076b7..762518cf6 100644 --- a/src/app/api/insights/chat/__tests__/route-guard.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard.test.ts @@ -27,11 +27,14 @@ vi.mock("@/lib/api-handler", () => ({ }, })); vi.mock("@/lib/modules/gate", () => ({ - requireModuleEnabled: vi.fn(async () => ({ enabled: true })), isModuleEnabled: vi.fn(async () => true), })); -vi.mock("@/lib/feature-flags", () => ({ - requireAssistantSurface: vi.fn(async () => undefined), +vi.mock("@/lib/ai/capabilities/gate", () => ({ + requireAiCapability: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), })); vi.mock("@/lib/api-response", () => ({ apiError: (error: string, status: number) => ({ data: null, error, status }), diff --git a/src/app/api/insights/chat/__tests__/route-list-query.test.ts b/src/app/api/insights/chat/__tests__/route-list-query.test.ts index 4fc5d3488..b151a2a1b 100644 --- a/src/app/api/insights/chat/__tests__/route-list-query.test.ts +++ b/src/app/api/insights/chat/__tests__/route-list-query.test.ts @@ -22,12 +22,9 @@ vi.mock("@/lib/api-handler", () => ({ }, })); -const { requireModuleEnabled, requireAssistantSurface } = vi.hoisted(() => ({ - requireModuleEnabled: vi.fn(async () => ({ enabled: true })), - requireAssistantSurface: vi.fn(async () => undefined), +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), })); -vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled })); -vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface })); vi.mock("@/lib/api-response", () => ({ apiError: (error: string, status: number, meta?: unknown) => ({ @@ -132,8 +129,6 @@ function listReq(qs = ""): Request { describe("GET /api/insights/chat — q search param", () => { beforeEach(() => { vi.clearAllMocks(); - requireModuleEnabled.mockResolvedValue({ enabled: true }); - requireAssistantSurface.mockResolvedValue(undefined); listConversations.mockResolvedValue({ conversations: [], nextCursor: null, diff --git a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts deleted file mode 100644 index 07e52d0c9..000000000 --- a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -/** - * v1.18.0 — the Coach chat POST (SSE) and the conversation-list GET both - * enforce the two-layer module gate (operator availability AND the - * per-user `disableCoach` opt-out) right after auth. A disabled module - * returns the 403 `module.disabled` envelope verbatim before any other - * Coach dependency runs; an enabled module falls through to the existing - * assistant-flag check. The suite pins both branches so a refactor can't - * quietly drop the gate. - */ - -vi.mock("@/lib/api-handler", () => ({ - apiHandler: unknown>(fn: T) => fn, - requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), - HttpError: class HttpError extends Error { - status: number; - constructor(status: number, message: string) { - super(message); - this.status = status; - } - }, -})); - -// The legacy assistant-flag gate the handler reaches AFTER the module -// gate. We throw a sentinel so an enabled module is observable by the -// control reaching this call. -class AssistantSentinel extends Error {} - -const { requireModuleEnabled, requireAssistantSurface } = vi.hoisted(() => ({ - requireModuleEnabled: vi.fn(), - requireAssistantSurface: vi.fn(), -})); -vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled })); -vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface })); - -// Everything else the module imports — stubbed harmlessly; none of it is -// reached on the disabled branch, and the enabled branch stops at the -// assistant sentinel above. -vi.mock("@/lib/api-response", () => ({ - apiError: (error: string, status: number, meta?: unknown) => ({ - data: null, - error, - status, - meta, - }), - apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), -})); -vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); -vi.mock("@/lib/db", () => ({ prisma: {} })); -vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); -vi.mock("@/lib/i18n/server-locale", () => ({ - resolveServerLocale: vi.fn(async () => "en"), -})); -vi.mock("@/lib/ai/provider-runner", () => ({ - AllProvidersFailedError: class extends Error {}, - runRawCompletionWithFallback: vi.fn(), -})); -vi.mock("@/lib/ai/provider", () => ({ - resolveProviderChain: vi.fn(), - resolveProvider: vi.fn(), -})); -vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() })); -vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" })); -vi.mock("@/lib/ai/coach/types", () => ({ - coachChatRequestSchema: { safeParse: () => ({ success: false }) }, -})); -// v1.20.0 (F1) — the route statically imports the tool barrel; this suite -// never reaches tool code (it asserts the 403 module-gate short-circuit), so a -// thin stub keeps the import graph satisfied without pulling the real schemas. -vi.mock("@/lib/ai/coach/tools", () => ({ - COACH_TOOL_DEFS: [], - MAX_ROUNDS: 3, - buildCoachDataInventory: vi.fn(), - renderDataInventory: vi.fn(), - renderFocusHint: vi.fn(() => ""), - buildToolModeAddendum: vi.fn(), - runCoachToolLoop: vi.fn(), -})); -vi.mock("@/lib/ai/coach/persistence", () => ({ - appendMessage: vi.fn(), - createConversation: vi.fn(), - fetchConversationWithMessages: vi.fn(), - listConversations: vi.fn(async () => ({ - conversations: [], - nextCursor: null, - })), -})); -vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({ - enqueueCoachMemoryRefresh: vi.fn(), -})); -vi.mock("@/lib/ai/coach/facts", () => ({ storeDeterministicFacts: vi.fn() })); -vi.mock("@/lib/ai/coach/budget", () => ({ - buildDateKey: vi.fn(), - reserveBudget: vi.fn(async () => ({ - allowed: true, - reserved: 0, - totalAfter: 0, - })), - reconcileSpend: vi.fn(), - resolveDailyCap: vi.fn(() => 200_000), - resolveDailyCapFor: vi.fn(() => 200_000), - resolveCostOwner: vi.fn(() => "operator" as const), -})); -vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() })); -vi.mock("@/lib/ai/coach/system-prompt", () => ({ - getCoachSystemPrompt: vi.fn(), -})); -vi.mock("@/lib/ai/coach/about-me", () => ({ - getSelfContextTextForUser: vi.fn(), -})); -vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() })); -vi.mock("@/lib/ai/coach/keyvalues", () => ({ - parseKeyValuesSentinel: vi.fn(), -})); -vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() })); -vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: vi.fn() })); - -import { POST, GET } from "../route"; - -type Envelope = { - data: unknown; - error: string | null; - status: number; - meta?: { errorCode?: string; module?: string }; -}; -const post = POST as unknown as (req: Request) => Promise; -const get = GET as unknown as (req: Request) => Promise; - -const disabledResponse: Envelope = { - data: null, - error: 'Module "coach" is not enabled', - status: 403, - meta: { errorCode: "module.disabled", module: "coach" }, -}; - -function chatPostReq(): Request { - return new Request("http://localhost/api/insights/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi" }), - }); -} - -function listReq(): Request { - return new Request("http://localhost/api/insights/chat", { method: "GET" }); -} - -describe("coach chat module gate", () => { - beforeEach(() => { - vi.clearAllMocks(); - requireAssistantSurface.mockImplementation(async () => { - throw new AssistantSentinel("reached assistant surface"); - }); - }); - - it("POST returns 403 module.disabled when Coach is disabled", async () => { - requireModuleEnabled.mockResolvedValue({ - enabled: false, - response: disabledResponse, - }); - - const res = await post(chatPostReq()); - - expect(res.status).toBe(403); - expect(res.meta?.errorCode).toBe("module.disabled"); - expect(res.meta?.module).toBe("coach"); - expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach"); - // The gate short-circuits before the legacy assistant flag. - expect(requireAssistantSurface).not.toHaveBeenCalled(); - }); - - it("POST falls through to the assistant flag when Coach is enabled", async () => { - requireModuleEnabled.mockResolvedValue({ enabled: true }); - - await expect(post(chatPostReq())).rejects.toBeInstanceOf(AssistantSentinel); - expect(requireAssistantSurface).toHaveBeenCalledWith("coach"); - }); - - it("GET returns 403 module.disabled when Coach is disabled", async () => { - requireModuleEnabled.mockResolvedValue({ - enabled: false, - response: disabledResponse, - }); - - const res = await get(listReq()); - - expect(res.status).toBe(403); - expect(res.meta?.errorCode).toBe("module.disabled"); - expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach"); - expect(requireAssistantSurface).not.toHaveBeenCalled(); - }); - - it("GET reaches 200 when Coach is enabled", async () => { - requireModuleEnabled.mockResolvedValue({ enabled: true }); - requireAssistantSurface.mockResolvedValue(undefined); - - const res = await get(listReq()); - - expect(res.status).toBe(200); - expect(requireAssistantSurface).toHaveBeenCalledWith("coach"); - }); -}); diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts index bcb917964..524273dc1 100644 --- a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts +++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts @@ -28,10 +28,14 @@ vi.mock("@/lib/api-handler", () => ({ })); vi.mock("@/lib/modules/gate", () => ({ - requireModuleEnabled: vi.fn(async () => ({ enabled: true })), + isModuleEnabled: vi.fn(async () => true), })); -vi.mock("@/lib/feature-flags", () => ({ - requireAssistantSurface: vi.fn(async () => undefined), +vi.mock("@/lib/ai/capabilities/gate", () => ({ + requireAiCapability: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), })); vi.mock("@/lib/api-response", () => ({ apiError: (error: string, status: number) => ({ data: null, error, status }), diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index 2dac8489d..3437fce2b 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -26,11 +26,14 @@ vi.mock("@/lib/api-handler", () => ({ }, })); vi.mock("@/lib/modules/gate", () => ({ - requireModuleEnabled: vi.fn(async () => ({ enabled: true })), isModuleEnabled: vi.fn(async () => true), })); -vi.mock("@/lib/feature-flags", () => ({ - requireAssistantSurface: vi.fn(async () => undefined), +vi.mock("@/lib/ai/capabilities/gate", () => ({ + requireAiCapability: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), })); vi.mock("@/lib/api-response", () => ({ apiError: (error: string, status: number) => ({ data: null, error, status }), diff --git a/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts b/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts index 37567bbef..6ee64e7e1 100644 --- a/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts +++ b/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts @@ -41,11 +41,14 @@ const { isModuleEnabled } = vi.hoisted(() => ({ isModuleEnabled: vi.fn(async () => true), })); vi.mock("@/lib/modules/gate", () => ({ - requireModuleEnabled: vi.fn(async () => ({ enabled: true })), isModuleEnabled, })); -vi.mock("@/lib/feature-flags", () => ({ - requireAssistantSurface: vi.fn(async () => undefined), +vi.mock("@/lib/ai/capabilities/gate", () => ({ + requireAiCapability: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), })); vi.mock("@/lib/api-response", () => ({ apiError: (error: string, status: number) => ({ data: null, error, status }), diff --git a/src/app/api/insights/chat/messages/[id]/feedback/route.ts b/src/app/api/insights/chat/messages/[id]/feedback/route.ts index 4056d7eb9..bb760e7d3 100644 --- a/src/app/api/insights/chat/messages/[id]/feedback/route.ts +++ b/src/app/api/insights/chat/messages/[id]/feedback/route.ts @@ -43,7 +43,6 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; import { resolveCoachFeedbackAttribution } from "@/lib/ai/feedback-attribution"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -60,11 +59,8 @@ interface RouteContext { async function handlePost(request: NextRequest, ctx: RouteContext) { const { user } = await requireAuth(); - // v1.4.38 W-C M6 — Coach message feedback is a Coach surface - // affordance; gate it on the same operator kill-switch so a - // disabled Coach matrix can't be skirted through the feedback - // endpoint. - await requireAssistantSurface("coach"); + // Not AI-gated: a rating on a message that already exists is data about + // the person's own thread and calls no model. const { id: messageId } = await ctx.params; // Per-user rate limit — same shape as the recommendation feedback diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index c2e6315e4..ab0a26722 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -7,7 +7,11 @@ * and a closing `done` frame carrying the persisted message ids. * * Behaviour: - * 1. requireAuth() — cookie session OR bearer token (iOS app). + * 1. requireAuth() — cookie session OR bearer token (iOS app), then the + * `coach` AI capability (operator switch, the Coach module and the + * `disableCoach` opt-out, provider presence, consent). Refused with the + * capability envelope, except `no_provider`, which keeps its + * `coach.provider.none` SSE frame (now with `reason`). * 2. Validate body with `coachChatRequestSchema`. * 3. reserveBudget(resolveDailyCap(chain)) — 429 with * `coach.budget.exceeded` when the day's token cap for the @@ -41,8 +45,10 @@ import { redactOptional, redactSecrets } from "@/lib/logging/redact"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { checkRateLimit } from "@/lib/rate-limit"; -import { requireAssistantSurface } from "@/lib/feature-flags"; -import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; +import type { AiUnavailableReason } from "@/lib/ai/capabilities/types"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { getServerTranslator } from "@/lib/i18n/server-translator"; @@ -187,16 +193,38 @@ function flushTick(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } +/** + * The `coach` capability gate for a turn. Every unavailable reason throws the + * capability envelope (`assistant.disabled.coach`, `module.disabled`, + * `consent.ai.required`, `ai.record.notPermitted`, `ai.unavailable`), except + * `no_provider`: the streaming clients already read that one as the + * `coach.provider.none` SSE frame, so it keeps that wire and gains `reason`. + * Returns the frame to send, or null when the Coach is available. + */ +async function coachCapabilityRefusal(): Promise { + try { + await requireAiCapability("coach"); + return null; + } catch (err) { + if (err instanceof AiUnavailableError && err.reason === "no_provider") { + annotate({ action: { name: "insights.coach.noProvider" } }); + return streamProviderError({ + code: "coach.provider.none", + reason: "no_provider", + }); + } + throw err; + } +} + async function handleChatRequest(request: NextRequest): Promise { const auth = await requireAuth(); - // v1.18.0 — two-layer module gate (operator availability + per-user - // disableCoach) on top of the legacy assistant flag. 403 module.disabled. - const gate = await requireModuleEnabled(auth.user.id, "coach"); - if (!gate.enabled) return gate.response; - // v1.4.31 — operator can disable the Coach surface app-wide. - // Throws AssistantDisabledError → apiHandler returns 403 + - // `errorCode: "assistant.disabled.coach"` per the iOS contract. - await requireAssistantSurface("coach"); + // The `coach` capability, right after auth and before the rate limit and + // the body parse. It folds in what the Coach module gate, the operator + // switch and the `disableCoach` opt-out used to answer separately, plus + // provider presence and consent. + const refused = await coachCapabilityRefusal(); + if (refused) return refused; const userId = auth.user.id; let body: unknown; @@ -560,7 +588,10 @@ async function handleChatRequest(request: NextRequest): Promise { annotate({ action: { name: "insights.coach.noProvider" }, }); - return streamProviderError({ code: "coach.provider.none" }); + return streamProviderError({ + code: "coach.provider.none", + reason: "no_provider", + }); } chain.push({ providerType: "admin-openai", instance: legacy }); } @@ -579,6 +610,15 @@ async function handleChatRequest(request: NextRequest): Promise { // as before — the snapshot builder stays alive as the no-tools floor. const toolMode = chain.every((c) => c.instance.supportsTools !== false); + // The last check before the budget reservation and the provider call: the + // `coach` capability again, at the egress site, so the call site names its + // capability whatever happens to the gate at the top. Inside one request + // the capability inputs are memoised, so this answers from the same + // resolution as the top gate; a fresh read at the wire belongs to the + // provider chokepoints, not to this route. + const refusedAtEgress = await coachCapabilityRefusal(); + if (refusedAtEgress) return refusedAtEgress; + // v1.18.7 (SENIOR-DEV HIGH) — atomically RESERVE the day's budget before // the provider call. The reservation increments the day's total by the // per-call ceiling (`maxTokens`) in one upsert and returns the new total; @@ -1613,13 +1653,18 @@ async function reportCoachStreamDefect(err: unknown): Promise { } } -function streamProviderError(args: { code: string }): Response { +function streamProviderError(args: { + code: string; + /** The capability reason, when the refusal came from one. */ + reason?: AiUnavailableReason; +}): Response { const stream = createSseStream((controller) => { controller.enqueue( encodeFrame({ type: "error", code: args.code, message: args.code, + ...(args.reason ? { reason: args.reason } : {}), }), ); }); @@ -1662,13 +1707,9 @@ const LIST_QUERY_MAX_LEN = 200; */ export const GET = apiHandler(async (request: NextRequest) => { const auth = await requireAuth(); - // v1.18.0 — same two-layer module gate as the SSE POST. - const gate = await requireModuleEnabled(auth.user.id, "coach"); - if (!gate.enabled) return gate.response; - // v1.4.31 — same gate as the SSE POST. Hiding the rail when - // the operator has disabled Coach matches the FAB suppression - // on the client. - await requireAssistantSurface("coach"); + // Never AI-gated and never module-gated: the conversation list is the + // person's stored data. It stays readable while the Coach is unavailable + // for any reason, so every thread can be found and deleted. const url = new URL(request.url); const cursor = url.searchParams.get("cursor"); const limitRaw = url.searchParams.get("limit"); diff --git a/src/app/api/insights/coach-read/route.ts b/src/app/api/insights/coach-read/route.ts index 5064aef78..e7917ed51 100644 --- a/src/app/api/insights/coach-read/route.ts +++ b/src/app/api/insights/coach-read/route.ts @@ -11,7 +11,9 @@ * Mirrors the `/api/insights/derived` precedent: `apiHandler` wrapper, Zod * `safeParse` on the query (unknown `metric` → 422 via `returnAllZodIssues`), * cookie OR Bearer auth, `userId` narrowed from the session (never a query - * field), insights-module gate, and the shared analytics-read budget. + * field), and the shared analytics-read budget. No AI gate and no module + * gate: both lines are computed, and the `insights` module is the AI analysis + * opt-out. */ import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -19,8 +21,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 { requireModuleEnabled } from "@/lib/modules/gate"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -37,13 +37,9 @@ export const GET = apiHandler(async (request: NextRequest) => { // provider anywhere on the path. const { user } = await requireRecordAuth("manage", "record"); - const m = await requireModuleEnabled(user.id, "insights"); - if (!m.enabled) return m.response; - - // The strip is the ambient Coach presence on the metric page, so it - // gates on the Coach assistant matrix too: an operator who turns the - // Coach off must not see a "Coach read" line linger. - await requireAssistantSurface("coach"); + // Pure compute over the baseline and correlation engines. The name is + // historical: no model writes either line, so neither the Coach switch nor + // the AI analysis opt-out refuses it. // Shared analytics-read budget — generous; caps a runaway navigation loop. const rl = await checkAnalyticsReadRateLimit(user.id); diff --git a/src/app/api/insights/coach/facts/[id]/route.ts b/src/app/api/insights/coach/facts/[id]/route.ts index e28dd04c7..debe5963b 100644 --- a/src/app/api/insights/coach/facts/[id]/route.ts +++ b/src/app/api/insights/coach/facts/[id]/route.ts @@ -12,8 +12,8 @@ * convention is the idempotent-delete one used elsewhere in the tree * (a not-found delete is a successful no-op, not a 404). * - * Coach-gated: same `requireAssistantSurface("coach")` kill-switch as the - * collection route. + * Never AI-gated, like the collection route: deleting a stored fact keeps + * working while the Coach is unavailable. */ import type { NextRequest } from "next/server"; @@ -21,7 +21,6 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; -import { requireAssistantSurface } from "@/lib/feature-flags"; interface RouteCtx { params: Promise<{ id: string }>; @@ -30,7 +29,6 @@ interface RouteCtx { export const DELETE = apiHandler( async (_request: NextRequest, ctx: RouteCtx) => { const { user } = await requireAuth(); - await requireAssistantSurface("coach"); const { id } = await ctx.params; diff --git a/src/app/api/insights/coach/facts/__tests__/route.test.ts b/src/app/api/insights/coach/facts/__tests__/route.test.ts index 3b448c2e2..4ce52362c 100644 --- a/src/app/api/insights/coach/facts/__tests__/route.test.ts +++ b/src/app/api/insights/coach/facts/__tests__/route.test.ts @@ -10,7 +10,8 @@ * returns the count. * - [id] DELETE soft-deletes the caller's own fact, and a cross-user / * unknown id is a 0-count no-op that returns `{ deleted: false }`. - * - the `requireAssistantSurface("coach")` gate is present on every verb. + * - no verb asks for the `coach` capability: the facts are the person's + * data, readable and erasable while the Coach is unavailable. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -28,12 +29,17 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(), })); -// v1.4.31 — `requireAssistantSurface()` gates near the top of each -// handler. Mock the module boundary so flag reads are deterministic; the -// gate-presence assertions below verify the call is actually made. -vi.mock("@/lib/feature-flags", () => ({ - requireAssistantSurface: vi.fn(async () => undefined), - AssistantDisabledError: class extends Error {}, +// The capability gate answers "unavailable" throughout: every verb must +// still work, and must not even ask. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + requireAiCapability: vi.fn(async () => { + throw new Error("the coach capability was consulted"); + }), + getAiCapability: vi.fn(async () => ({ + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + })), })); // Mock the codec so the test never needs an encryption key. The "row" @@ -78,7 +84,10 @@ import { GET, DELETE } from "../route"; import { DELETE as DELETE_ONE } from "../[id]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { requireAssistantSurface } from "@/lib/feature-flags"; +import { + getAiCapability, + requireAiCapability, +} from "@/lib/ai/capabilities/gate"; import { annotate } from "@/lib/logging/context"; const SESSION_OK = { @@ -111,7 +120,6 @@ function deleteReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(requireAssistantSurface).mockResolvedValue(undefined as never); }); describe("GET /api/insights/coach/facts", () => { @@ -223,10 +231,12 @@ describe("GET /api/insights/coach/facts", () => { }); }); - it("invokes the coach assistant-surface gate", async () => { + it("lists facts while the Coach is unavailable, without asking", async () => { vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never); - await callGet(); - expect(requireAssistantSurface).toHaveBeenCalledWith("coach"); + const res = await callGet(); + expect(res.status).toBe(200); + expect(requireAiCapability).not.toHaveBeenCalled(); + expect(getAiCapability).not.toHaveBeenCalled(); }); }); @@ -261,12 +271,14 @@ describe("DELETE /api/insights/coach/facts — forget all", () => { expect(body.data.cleared).toBe(0); }); - it("invokes the coach assistant-surface gate", async () => { + it("forgets everything while the Coach is unavailable, without asking", async () => { vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({ count: 0, } as never); - await callDeleteAll(); - expect(requireAssistantSurface).toHaveBeenCalledWith("coach"); + const res = await callDeleteAll(); + expect(res.status).toBe(200); + expect(requireAiCapability).not.toHaveBeenCalled(); + expect(getAiCapability).not.toHaveBeenCalled(); }); }); @@ -316,13 +328,15 @@ describe("DELETE /api/insights/coach/facts/[id] — forget one", () => { expect(arg.where.userId).toBe("user-1"); }); - it("invokes the coach assistant-surface gate", async () => { + it("forgets one fact while the Coach is unavailable, without asking", async () => { vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({ count: 0, } as never); - await callDeleteOne(deleteReq(), { + const res = await callDeleteOne(deleteReq(), { params: Promise.resolve({ id: "f1" }), }); - expect(requireAssistantSurface).toHaveBeenCalledWith("coach"); + expect(res.status).toBe(200); + expect(requireAiCapability).not.toHaveBeenCalled(); + expect(getAiCapability).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/insights/coach/facts/route.ts b/src/app/api/insights/coach/facts/route.ts index 4a37ce2ef..8a0057149 100644 --- a/src/app/api/insights/coach/facts/route.ts +++ b/src/app/api/insights/coach/facts/route.ts @@ -14,20 +14,19 @@ * skipped rather than 500ing the whole list — the surface stays available * for the rows that DO decrypt. * - * Coach-gated: a fact only exists because the Coach extracted it, so the - * management surface sits behind the same `requireAssistantSurface("coach")` - * kill-switch as the rest of the Coach stack. + * Never AI-gated. The facts are the person's stored data: viewing and + * deleting them keeps working while the Coach is off for any reason (the + * operator's switch, the person's own opt-out, no provider, no consent). + * Erasure that depends on the feature being on is no erasure. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); - await requireAssistantSurface("coach"); const rows = await prisma.coachFact.findMany({ where: { userId: user.id, deletedAt: null }, @@ -80,7 +79,6 @@ export const GET = apiHandler(async () => { export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); - await requireAssistantSurface("coach"); // Soft-delete every active fact — keeps the rows for audit while // hiding them from injection. `updateMany` scoped to the caller can diff --git a/src/app/api/insights/coach/nudge-status/__tests__/route.test.ts b/src/app/api/insights/coach/nudge-status/__tests__/route.test.ts index cb3b24c75..04ab9baa7 100644 --- a/src/app/api/insights/coach/nudge-status/__tests__/route.test.ts +++ b/src/app/api/insights/coach/nudge-status/__tests__/route.test.ts @@ -11,19 +11,26 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -const { requireRecordAuth, evaluateContext, readStatus } = vi.hoisted(() => ({ - requireRecordAuth: vi.fn(), - evaluateContext: vi.fn(async () => ({ surfaced: 0, errored: 0 })), - readStatus: vi.fn(async () => ({ unread: false, nudgedAt: null })), -})); +const { requireRecordAuth, evaluateContext, readStatus, getAiCapability } = + vi.hoisted(() => ({ + requireRecordAuth: vi.fn(), + evaluateContext: vi.fn(async () => ({ surfaced: 0, errored: 0 })), + readStatus: vi.fn( + async (): Promise<{ unread: boolean; nudgedAt: string | null }> => ({ + unread: false, + nudgedAt: null, + }), + ), + getAiCapability: vi.fn(), + })); + +const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true }; vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, requireRecordAuth, })); -vi.mock("@/lib/feature-flags", () => ({ - requireAssistantSurface: vi.fn(async () => undefined), -})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability })); vi.mock("@/lib/ai/coach/nudge-status", () => ({ readCoachNudgeStatus: readStatus, })); @@ -40,6 +47,7 @@ import { GET } from "../route"; beforeEach(() => { vi.clearAllMocks(); readStatus.mockResolvedValue({ unread: false, nudgedAt: null }); + getAiCapability.mockResolvedValue(AVAILABLE); }); describe("nudge-status — NEXT_APP_OPEN hook", () => { @@ -93,3 +101,51 @@ describe("nudge-status — NEXT_APP_OPEN hook", () => { } }); }); + +describe("nudge-status — the coach capability", () => { + it("adds the resolved state beside the unread signal while available", async () => { + requireRecordAuth.mockResolvedValue({ + user: { id: "u1" }, + actor: { id: "u1" }, + }); + readStatus.mockResolvedValue({ unread: true, nudgedAt: "2026-07-18" }); + + const res = (await (GET as unknown as () => Promise)()) as { + data: Record; + }; + + expect(getAiCapability).toHaveBeenCalledWith("coach"); + expect(res.data).toEqual({ + unread: true, + nudgedAt: "2026-07-18", + ai: AVAILABLE, + }); + }); + + it.each(["operator_disabled", "user_disabled", "no_provider"] as const)( + "answers the quiet 200 shape for %s, reading and evaluating nothing", + async (reason) => { + requireRecordAuth.mockResolvedValue({ + user: { id: "u1" }, + actor: { id: "u1" }, + }); + const ai = { available: false, reason, onDeviceAllowed: false }; + getAiCapability.mockResolvedValue(ai); + + const res = (await (GET as unknown as () => Promise)()) as { + data: Record; + error: null; + }; + + expect(res.error).toBeNull(); + expect(res.data).toEqual({ + nudgedAt: null, + unread: false, + conversationId: null, + ai, + }); + expect(readStatus).not.toHaveBeenCalled(); + expect(evaluateContext).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/src/app/api/insights/coach/nudge-status/route.ts b/src/app/api/insights/coach/nudge-status/route.ts index 5c3d92fa7..d4f778d0f 100644 --- a/src/app/api/insights/coach/nudge-status/route.ts +++ b/src/app/api/insights/coach/nudge-status/route.ts @@ -21,10 +21,16 @@ * `nudgedAt` carries the newest assistant-message timestamp so the FAB's * local seen-stamp keys on a stable value (kept for the existing client * contract). + * + * A mixed read: while the `coach` capability is unavailable (operator switch, + * opt-out, no provider, no consent) the route still answers 200, with the + * quiet shape `{ unread: false, nudgedAt: null, conversationId: null }` and + * an `ai` state saying why. There is no Coach to open, so there is nothing + * unread, and no app-open reminder is evaluated. */ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; -import { requireAssistantSurface } from "@/lib/feature-flags"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status"; import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders"; import { prisma } from "@/lib/db"; @@ -46,9 +52,15 @@ export const GET = apiHandler(async () => { // budget and writes into their conversation, and reading whether the thread // has something unopened does neither. const { user, actor } = await requireRecordAuth("read", "record"); - // Operator-level flag — an `AppSettings` singleton, unaffected by whose - // record is open. - await requireAssistantSurface("coach"); + const ai = await getAiCapability("coach"); + if (!ai.available) { + return apiSuccess({ + nudgedAt: null, + unread: false, + conversationId: null, + ai, + }); + } // NEXT_APP_OPEN Coach reminders resolve here: this poll mounts with the // app chrome, which makes it the app-open signal itself. Owner-only — a @@ -67,7 +79,7 @@ export const GET = apiHandler(async () => { // Shared with the `/coach` RSC prefetch (`src/app/coach/page.tsx`) so both // readers compute the unread signal identically. - return apiSuccess(await readCoachNudgeStatus(user.id)); + return apiSuccess({ ...(await readCoachNudgeStatus(user.id)), ai }); }); export const dynamic = "force-dynamic"; diff --git a/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts b/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts index 4e65a2cc1..06b0c2aaf 100644 --- a/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts +++ b/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts @@ -12,16 +12,15 @@ const findUniqueMock = vi.fn().mockResolvedValue({ notificationPrefs: null }); vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: (...args: unknown[]) => findUniqueMock(...args) }, - // No settings row: every assistant switch at its default (on). A switch - // read that fails now fails closed, so the row has to be readable. - appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, }, })); -vi.mock("@/lib/modules/gate", async (importOriginal) => ({ - ...(await importOriginal()), - requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), - resolveModuleMap: vi.fn().mockResolvedValue({}), +// The `coach` capability decides whether an opener is served at all; pinned +// here so the route test does not depend on the capability loader's reads. +const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true }; +const getAiCapability = vi.fn(); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + getAiCapability: (...args: unknown[]) => getAiCapability(...args), })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); @@ -84,6 +83,7 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); findUniqueMock.mockResolvedValue({ notificationPrefs: null }); + getAiCapability.mockResolvedValue(AVAILABLE); }); describe("GET /api/insights/coach/seeded-question", () => { @@ -120,8 +120,25 @@ describe("GET /api/insights/coach/seeded-question", () => { score: 58, band: "yellow", }); + expect(getAiCapability).toHaveBeenCalledWith("coach"); + expect((body.data as { ai?: unknown }).ai).toEqual(AVAILABLE); }); + it.each(["operator_disabled", "user_disabled", "no_provider"] as const)( + "answers { signal: null, ai } for %s without running the detector", + async (reason) => { + const ai = { available: false, reason, onDeviceAllowed: false }; + getAiCapability.mockResolvedValue(ai); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { signal: unknown; ai: unknown }; + }; + expect(body.data).toEqual({ signal: null, ai }); + expect(detectDerivedBriefingSignals).not.toHaveBeenCalled(); + }, + ); + it("returns { signal: null } when nothing crosses the gate (neutral fallback)", async () => { vi.mocked(detectDerivedBriefingSignals).mockResolvedValue(null); const res = await callGet(makeReq()); diff --git a/src/app/api/insights/coach/seeded-question/route.ts b/src/app/api/insights/coach/seeded-question/route.ts index b8986bc1f..83efeacd2 100644 --- a/src/app/api/insights/coach/seeded-question/route.ts +++ b/src/app/api/insights/coach/seeded-question/route.ts @@ -17,14 +17,19 @@ * * Mirrors the sibling derived routes: `apiHandler` wrapper, `requireAuth` * (userId narrowed from the session/Bearer, never a body/query field), - * analytics-read rate limit, `insights` module gate. + * analytics-read rate limit. + * + * A mixed read: the opener only exists to open the Coach, so it is served + * only while the `coach` capability is available. Otherwise the route answers + * 200 `{ signal: null, ai }`, the same neutral shape as "nothing notable", + * with the reason beside it. */ import { apiError, apiSuccess } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { requireModuleEnabled } from "@/lib/modules/gate"; -import { requireAssistantSurface } from "@/lib/feature-flags"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; import { prisma } from "@/lib/db"; import { loadBaselineProfile } from "@/lib/insights/derived"; import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing"; @@ -46,17 +51,19 @@ export interface CoachSeededQuestionDTO { /** Band — `yellow` / `red` (green never surfaces as notable). */ band: string; } | null; + /** Whether the Coach this opener feeds is available, and why not. */ + ai: AiCapabilityState; } export const GET = apiHandler(async () => { const { user } = await requireAuth(); - const m = await requireModuleEnabled(user.id, "insights"); - if (!m.enabled) return m.response; - - // The opener feeds the Coach hero, so it gates on the Coach assistant - // matrix: with the Coach off there is no hero to seed. - await requireAssistantSurface("coach"); + // The opener feeds the Coach hero: with the Coach unavailable there is no + // hero to seed, so the neutral shape answers and says why. + const ai = await getAiCapability("coach"); + if (!ai.available) { + return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO); + } const rl = await checkAnalyticsReadRateLimit(user.id); if (!rl.allowed) { @@ -77,7 +84,7 @@ export const GET = apiHandler(async () => { action: { name: "coach.seeded-question.resolve" }, meta: { has_signal: false, source_metric: "none", band: "none" }, }); - return apiSuccess({ signal: null } satisfies CoachSeededQuestionDTO); + return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO); } // Profile read once via the shared loader; passed into the detector, @@ -102,5 +109,5 @@ export const GET = apiHandler(async () => { }, }); - return apiSuccess({ signal } satisfies CoachSeededQuestionDTO); + return apiSuccess({ signal, ai } satisfies CoachSeededQuestionDTO); }); diff --git a/src/app/api/insights/coach/seen/route.ts b/src/app/api/insights/coach/seen/route.ts index 603edafb7..5ad1989b0 100644 --- a/src/app/api/insights/coach/seen/route.ts +++ b/src/app/api/insights/coach/seen/route.ts @@ -10,15 +10,16 @@ * * No request body — the timestamp is server-minted from `now()` so a * client can never backdate the stamp to suppress a future nudge. + * + * No AI gate: a timestamp on the caller's own row is data, whatever the + * Coach's availability. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { prisma } from "@/lib/db"; -import { requireAssistantSurface } from "@/lib/feature-flags"; export const POST = apiHandler(async () => { const { user } = await requireAuth(); - await requireAssistantSurface("coach"); const seenAt = new Date(); await prisma.user.update({ diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 57adde4ab..51c11427f 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -81,16 +81,21 @@ vi.mock("@/lib/insights/comprehensive-aggregator", () => ({ })); vi.mock("@/lib/ai/provider", () => ({ - resolveProvider: vi.fn(async () => ({ type: "none" })), + probeProviderPresence: vi.fn(async () => false), +})); + +// The per-request `ai` block beside the body. Pinned here so the envelope +// assertions do not depend on the capability loader's own reads. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + getAiCapability: vi.fn(), })); 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. +// The route no longer gates on the `insights` module (the AI analysis +// opt-out); the mock stays so a test can prove the route never asks it. vi.mock("@/lib/modules/gate", async (importOriginal) => ({ ...(await importOriginal()), requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), @@ -100,6 +105,8 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => ({ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { requireModuleEnabled } from "@/lib/modules/gate"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { probeProviderPresence } from "@/lib/ai/provider"; import { prisma } from "@/lib/db"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; @@ -125,6 +132,12 @@ beforeEach(() => { vi.resetAllMocks(); // resetAllMocks drops the module gate's default; restore it. vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(getAiCapability).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + vi.mocked(probeProviderPresence).mockResolvedValue(false); __resetAllCachesForTests(); // v1.15.20 — default to an allowing analytics-read budget. vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ @@ -250,6 +263,64 @@ describe("GET /api/insights/comprehensive — envelope shape", () => { expect(res.status).toBe(200); }); + it("answers with the AI analysis opt-out on, and publishes why the AI parts are absent", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + } as never); + vi.mocked(getAiCapability).mockResolvedValue({ + available: false, + reason: "user_disabled", + onDeviceAllowed: 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); + expect(requireModuleEnabled).not.toHaveBeenCalled(); + const body = (await res.json()) as { + data: { ai: Record }; + }; + expect(body.data.ai.briefing.reason).toBe("user_disabled"); + expect(body.data.ai.statusText.reason).toBe("user_disabled"); + expect( + vi + .mocked(getAiCapability) + .mock.calls.map((c) => c[0]) + .sort(), + ).toEqual(["briefing", "statusText"]); + }); + + it("reports hasProvider from the presence probe, never from building a client", async () => { + vi.mocked(probeProviderPresence).mockResolvedValue(true); + 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()); + const body = (await res.json()) as { data: { hasProvider: boolean } }; + expect(body.data.hasProvider).toBe(true); + expect(probeProviderPresence).toHaveBeenCalledWith("user-comp-1"); + }); + 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 607537676..b816bb2d1 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db"; -import { resolveProvider } from "@/lib/ai/provider"; +import { probeProviderPresence } from "@/lib/ai/provider"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { apiError, apiSuccess } from "@/lib/api-response"; import type { DataPoint, DataSummary } from "@/lib/analytics/trends"; import { summarize } from "@/lib/analytics/trends"; @@ -28,7 +29,6 @@ import { requireRecordAuth, type AuthContext, } from "@/lib/api-handler"; -import { requireModuleEnabled } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { @@ -49,19 +49,19 @@ 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); if (!rl.allowed) { return apiError("Too many analytics requests. Please retry later.", 429); } - // 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. + // No AI gate, and no `insights` module gate (that module is the AI + // analysis opt-out). Every field is computed from the record (the provider + // chain is only probed for presence, for `hasProvider`), and this is the + // main read of the Insights overview: switching AI off, or never setting a + // provider up, still gets the overview. The `ai` block beside the body says + // whether the briefing and status notes that decorate the overview can be + // shown, resolved per request and never cached with the body. // v1.4.35 — read-through the analytics cache keyed on // (userId, "comprehensive"). The /insights page mount routinely @@ -94,7 +94,16 @@ export const GET = apiHandler(async () => { annotate, ); - return apiSuccess({ ...body, revalidating: outcome === "stale" }); + const [briefing, statusText] = await Promise.all([ + getAiCapability("briefing"), + getAiCapability("statusText"), + ]); + + return apiSuccess({ + ...body, + revalidating: outcome === "stale", + ai: { briefing, statusText }, + }); }); type AuthedUser = AuthContext["user"]; @@ -599,7 +608,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) { moodPulseScatterData, medications: medCompliance, alerts, - hasProvider: (await resolveProvider(userId)).type !== "none", + // Presence only: no client is built and no token is refreshed on a read. + hasProvider: await probeProviderPresence(userId), dataSpanDays, totalMeasurements: aggregate.totalMeasurements, }; diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index c22a3e35b..82a276744 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -249,10 +249,11 @@ describe("GET /api/insights/correlations", () => { ); }); - // v1.18.0 (B2) — the route now also requires the `insights` module. - it("returns 403 + module.disabled when the insights module is off", async () => { + // The `insights` module is the AI analysis opt-out. Correlations are + // statistics, so turning AI analysis off must not take them away. + it("serves the statistics with the insights module (the AI analysis opt-out) off", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: false, response: apiError('Module "insights" is not enabled', 403, { errorCode: "module.disabled", @@ -260,13 +261,7 @@ describe("GET /api/insights/correlations", () => { }), }); const res = await callGet(makeReq()); - expect(res.status).toBe(403); - const body = (await res.json()) as { - meta?: { errorCode?: string; module?: string }; - }; - expect(body.meta?.errorCode).toBe("module.disabled"); - expect(body.meta?.module).toBe("insights"); - // The disabled-module request never reaches the series reads. - expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + expect(requireModuleEnabled).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 20eab84b8..756137ed7 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -23,7 +23,6 @@ import { apiError, apiSuccess } from "@/lib/api-response"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { requireModuleEnabled } from "@/lib/modules/gate"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { prisma } from "@/lib/db"; import { wallClockInTz } from "@/lib/tz/wall-clock"; @@ -66,12 +65,10 @@ export const GET = apiHandler(async () => { return apiError("Too many analytics requests. Please retry later.", 429); } - const m = await requireModuleEnabled(user.id, "insights"); - if (!m.enabled) return m.response; - - // No assistant switch: this is statistics, not model output. The retired - // Correlations switch gated it and nothing model-written; an operator's AI - // switches never take a computation down. + // No AI gate and no `insights` module gate: this is statistics, not model + // output. The retired Correlations switch gated it and nothing model-written; + // an operator's AI switches, and the person's AI analysis opt-out, never + // take a computation down. // Reader's locale for the narrated `interpretation` — the correlation cards // render this string verbatim, so it MUST be localised (cookie / User.locale / diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index e7d45d745..e66aa8fe2 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -39,7 +39,7 @@ import { import { resolveDeterministicAssessment } from "@/lib/insights/derived/derived-assessment"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { instructionLocale } from "@/lib/ai/prompts/output-language"; -import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; +import { resolveModuleMap } from "@/lib/modules/gate"; import { DERIVED_MODULE } from "../route"; export const dynamic = "force-dynamic"; @@ -117,10 +117,9 @@ export const GET = apiHandler(async (request: NextRequest) => { // v1.37.0 — MANAGE-level read: the deterministic batch form, no provider on // the path. const { user, actor } = await requireRecordAuth("manage", "record"); - const m = await requireModuleEnabled(user.id, "insights"); - if (!m.enabled) return m.response; - // Same deterministic compute as the single route, so no - // assistant-surface gate. + // Same deterministic compute as the single route: no AI gate, and no + // `insights` module gate (that module is the AI analysis opt-out). Per-metric + // module ownership still applies below through `DERIVED_MODULE`. // 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 d51926c9e..d60034261 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -35,6 +35,7 @@ import { import { DERIVED_MAX_WINDOW_DAYS } from "@/lib/insights/derived/types"; import { resolveDerivedAssessment } from "@/lib/insights/derived/derived-assessment-ai"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; export const dynamic = "force-dynamic"; @@ -88,17 +89,15 @@ export const GET = apiHandler(async (request: NextRequest) => { // enqueues nothing while a delegate is holding the request. 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); if (!rl.allowed) { return apiError("Too many analytics requests. Please retry later.", 429); } - // Pure compute over the rollup tier, so no assistant-surface gate: - // switching the assistant off does not blank the derived tiles. + // Pure compute over the rollup tier, so no AI gate and no `insights` + // module gate (the AI analysis opt-out): switching AI off does not blank the + // derived tiles. Only the model-written assessment below depends on AI. const parsed = derivedQuerySchema.safeParse({ metric: request.nextUrl.searchParams.get("metric"), @@ -159,11 +158,16 @@ export const GET = apiHandler(async (request: NextRequest) => { userLocale: user.locale ?? null, override: localeParam, }); + // The model-written override is served, and warmed, only while + // `statusText` is available. Otherwise the deterministic text stands and + // `ai` says why there is no warmer prose. + const ai = await getAiCapability("statusText"); const assessment = await resolveDerivedAssessment({ metric, userId: user.id, derived, locale: resolvedLocale, + aiAvailable: ai.available, }); // Flatten the discriminated union for the wire: `metric` tags it, @@ -177,5 +181,6 @@ export const GET = apiHandler(async (request: NextRequest) => { provenance: derived.provenance, reason: derived.status === "insufficient" ? derived.reason : null, assessment, + ai, }); }); diff --git a/src/app/api/insights/ecg/[id]/route.ts b/src/app/api/insights/ecg/[id]/route.ts index 7b05217be..a2fec40cc 100644 --- a/src/app/api/insights/ecg/[id]/route.ts +++ b/src/app/api/insights/ecg/[id]/route.ts @@ -20,15 +20,14 @@ * a verdict of its own. * * Mirrors the `rhythm-events` gating: `apiHandler`, cookie OR Bearer auth, - * `userId` from the session, the `insights` module gate, and the - * `insightStatus` assistant-surface gate. No AI provider call. + * `userId` from the session. No AI gate and no module gate: the strip is + * device data. No AI provider call. */ 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 { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { decryptWaveformFromBytes } from "@/lib/withings/ecg-waveform-codec"; import { @@ -45,9 +44,8 @@ export const GET = apiHandler( // 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; - // The waveform is never interpreted, so no assistant-surface gate. + // The waveform is never interpreted, so neither an AI gate nor the AI + // analysis opt-out applies. 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 18c28e6fa..63e497784 100644 --- a/src/app/api/insights/ecg/route.ts +++ b/src/app/api/insights/ecg/route.ts @@ -28,8 +28,9 @@ * * Both verbs mirror the `rhythm-events` route gating exactly: `apiHandler` * wrapper, cookie OR Bearer auth, `userId` narrowed from the session (never a - * body or query field), the `insights` module gate, and the `insightStatus` - * assistant-surface gate. No AI provider call on either path. + * body or query field). No AI gate and no module gate: a recording is device + * data, and the `insights` module is the AI analysis opt-out. An ingest is + * never refused because AI is off; the POST keeps its own rate limit. */ import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -42,7 +43,6 @@ import { } from "@/lib/api-response"; import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { persistEcgRecording } from "@/lib/ecg/persist-recording"; @@ -110,10 +110,6 @@ export const GET = apiHandler(async () => { // keeps `requireAuth()`: a delegate's phone must never move somebody else's // sync. const { user } = await requireRecordAuth("manage", "record"); - const m = await requireModuleEnabled(user.id, "insights"); - if (!m.enabled) return m.response; - // A pure read of the device's own recordings, so no assistant-surface - // gate. const rows = await prisma.ecgRecording.findMany({ where: { userId: user.id }, @@ -179,10 +175,8 @@ export const GET = apiHandler(async () => { */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const m = await requireModuleEnabled(user.id, "insights"); - if (!m.enabled) return m.response; - // A device ingest carries no assistant prose, so switching the assistant - // off must not refuse a recording. + // A device ingest carries no model text: switching AI off, or the AI + // analysis opt-out, must never refuse a recording. const rl = await checkRateLimit( `insights:ecg:ingest:${user.id}`, diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index fe320041c..c81ed66ff 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -23,13 +23,12 @@ vi.mock("@/lib/api-handler", () => ({ })), })); -// v1.4.31 — `requireAssistantSurface()` is gated near the top of -// the handler. The test mocks the apiHandler wrapper itself out, so -// flag reads need a deterministic mock at the module boundary. -vi.mock("@/lib/feature-flags", () => ({ - requireAssistantSurface: vi.fn(async () => undefined), - AssistantDisabledError: class extends Error {}, -})); +// The `briefing` capability is required near the top of both handlers. +// The test mocks the apiHandler wrapper itself out, so the gate needs a +// deterministic mock at the module boundary: available by default, and a +// refusal where a test says so. +const requireAiCapability = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/ai/capabilities/gate", () => ({ requireAiCapability })); const selfContextText = vi.hoisted(() => vi.fn<() => Promise>()); @@ -172,9 +171,15 @@ import { FeaturesPayloadTooLargeError, } from "@/lib/insights/features"; import { annotate } from "@/lib/logging/context"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; beforeEach(() => { vi.clearAllMocks(); + requireAiCapability.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); // The B5b fallback runner caches "last working provider" per user // across calls; tests that assert single-provider error mapping // need a fresh cache so the previous test's success doesn't reorder @@ -982,3 +987,48 @@ describe("GET /api/insights/generate — read-only advisor read", () => { expect(body.data.hasProvider).toBe(true); }); }); + +describe("the briefing capability", () => { + it.each([ + ["operator_disabled", 403, "assistant.disabled.briefing"], + ["user_disabled", 403, "module.disabled"], + ["consent_required", 403, "consent.ai.required"], + ["no_provider", 422, "ai.provider.none"], + ] as const)( + "GET refuses with the envelope for %s before reading the cache or warming", + async (reason, status, errorCode) => { + requireAiCapability.mockRejectedValue( + new AiUnavailableError("briefing", reason), + ); + + const refused = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/insights/generate")).catch( + (err: unknown) => err, + ); + + expect(refused).toBeInstanceOf(AiUnavailableError); + expect((refused as AiUnavailableError).status).toBe(status); + expect((refused as AiUnavailableError).meta.errorCode).toBe(errorCode); + expect(requireAiCapability).toHaveBeenCalledWith("briefing"); + // Stored briefing text is never read, and nothing is warmed. + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + }, + ); + + it("POST refuses before any read or provider work", async () => { + requireAiCapability.mockRejectedValue( + new AiUnavailableError("briefing", "operator_disabled"), + ); + + const refused = await POST(jsonRequest({ force: true }) as never).catch( + (err: unknown) => err, + ); + + expect(refused).toBeInstanceOf(AiUnavailableError); + expect(requireAiCapability).toHaveBeenCalledWith("briefing"); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(prisma.user.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 8de86d480..b5706dd8b 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -78,7 +78,8 @@ import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload"; import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; +import { AI_PROVIDER_NONE_ERROR_CODE } from "@/lib/ai/capabilities/types"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { toProfileSex } from "@/lib/profile/sex"; @@ -139,14 +140,22 @@ export function resolveInsightsRateLimit(): number { * cron and the "prepare assessments" button use). The next read reflects * the fresh briefing. User-initiated regeneration stays on the POST path. * + * A pure AI read: the payload is the model-written briefing and nothing + * else, so it keeps a typed refusal rather than answering 200 with nothing. + * The `briefing` capability is required (operator switch, the AI analysis + * opt-out, provider presence, consent); an unavailable one refuses with the + * capability envelope (`assistant.disabled.briefing`, `module.disabled`, + * `consent.ai.required`, `ai.provider.none` 422, …) before the cache is read, + * so stored briefing text is never served and no warm is enqueued. + * * `userId` is narrowed from the session / Bearer — never a body field. */ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - // Same surface gate as the POST + the read-only status routes: a user - // with assessments enabled but Coach disabled still reads the cached - // briefing. - await requireAssistantSurface("coach"); + // The briefing is its own capability now, not the Coach's: a user with the + // Coach hidden still reads their briefing, and one who opted out of AI + // analysis does not. + await requireAiCapability("briefing"); const userId = user.id; const dbUser = await prisma.user.findUnique({ @@ -304,11 +313,10 @@ export const POST = apiHandler((request: NextRequest) => // sibling consumer) for this request. withFeatureCacheScope(async () => { const { user } = await requireAuth(); - // v1.4.31 — the advisor + daily briefing surfaces ride this - // endpoint. Operator disables Coach, every advisor consumer - // (briefing card, recommendations grid, hero strip narration) - // empties out. - await requireAssistantSurface("coach"); + // The `briefing` capability, before any read or provider work. Refused + // with the capability envelope; no provider is `ai.provider.none` on the + // 422 this route has always used for it. + await requireAiCapability("briefing"); const userId = user.id; const dbUser = await prisma.user.findUnique({ @@ -490,6 +498,7 @@ export const POST = apiHandler((request: NextRequest) => return apiError( "No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.", 422, + { errorCode: AI_PROVIDER_NONE_ERROR_CODE }, ); } chain.push({ providerType: "admin-openai", instance: legacy }); diff --git a/src/app/api/insights/health-status/route.ts b/src/app/api/insights/health-status/route.ts index 99ba3fd15..2b0bcd0be 100644 --- a/src/app/api/insights/health-status/route.ts +++ b/src/app/api/insights/health-status/route.ts @@ -10,7 +10,9 @@ * the rollup tier: no provider call, no fabricated value, never a diagnosis. * * Mirrors `/api/insights/derived`: `apiHandler` wrapper, `requireAuth`, the - * `insights` module gate, the shared analytics-read budget. `userId` is always + * shared analytics-read budget. No module gate: the drift read spans every + * vital, and the `insights` module is the AI analysis opt-out, which a + * computation does not depend on. `userId` is always * narrowed from the session. The labels are mapped client-side from the * MeasurementType, so the wire carries the type tokens only. */ @@ -18,7 +20,6 @@ import { apiError, apiSuccess } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { loadBaselineProfile } from "@/lib/insights/derived"; import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation"; @@ -32,9 +33,6 @@ export const GET = apiHandler(async () => { // provider anywhere on the path. const { user } = await requireRecordAuth("manage", "record"); - const m = await requireModuleEnabled(user.id, "insights"); - if (!m.enabled) return m.response; - const rl = await checkAnalyticsReadRateLimit(user.id); if (!rl.allowed) { return apiError("Too many analytics requests. Please retry later.", 429); diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts index ddfde9972..6969ac69d 100644 --- a/src/app/api/insights/labs-changes/route.ts +++ b/src/app/api/insights/labs-changes/route.ts @@ -11,7 +11,7 @@ * about recent news, and it stops being made rather than being made about a * years-old draw. Neutral framing only — a delta is not a diagnosis. * - * Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights` + * Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `labs` * module gate, the shared analytics-read budget. `userId` is narrowed from the * session; soft-deleted rows (`deletedAt`) are filtered out. */ @@ -30,7 +30,10 @@ export const GET = apiHandler(async () => { // provider anywhere on the path. const { user } = await requireRecordAuth("manage", "record"); - const m = await requireModuleEnabled(user.id, "insights"); + // Lab deltas belong to labs: with the labs module off the card hides like + // every other labs surface. Not an AI read, so the `insights` module (the + // AI analysis opt-out) does not gate it. + const m = await requireModuleEnabled(user.id, "labs"); if (!m.enabled) return m.response; const rl = await checkAnalyticsReadRateLimit(user.id); diff --git a/src/app/api/insights/narrative/route.ts b/src/app/api/insights/narrative/route.ts index 78c1a1f0a..3a9fa8d76 100644 --- a/src/app/api/insights/narrative/route.ts +++ b/src/app/api/insights/narrative/route.ts @@ -10,6 +10,15 @@ * * `userId` is narrowed from the session/Bearer (never a query field); an * unknown `period` 422s via the closed enum. + * + * A mixed read. The narrative is data when the deterministic composer wrote + * it and model output when a provider did. Model-written text is served only + * while the `periodNarrative` capability is available; otherwise `narrative` + * is null and `ai` says why. The warm is enqueued only when the capability is + * available, or when there is no servable text at all (the worker then writes + * the deterministic narrative). The `insights` module is the AI analysis + * opt-out and is folded into the capability, so it no longer refuses the + * route. */ import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -18,9 +27,9 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { locales, defaultLocale, type Locale } from "@/lib/i18n/config"; -import { requireAssistantSurface } from "@/lib/feature-flags"; -import { requireModuleEnabled } from "@/lib/modules/gate"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate"; +import { DETERMINISTIC_PROVIDER_TYPE } from "@/lib/insights/narrative/period-narrative-deterministic"; import { PERIOD_DAYS, type NarrativePeriod, @@ -57,9 +66,6 @@ export const GET = apiHandler(async (request: NextRequest) => { // record, which is not a section a scoped grant can name. The miss behind it // enqueues nothing while a delegate is holding the request. const { user } = await requireRecordAuth("manage", "record"); - const m = await requireModuleEnabled(user.id, "insights"); - if (!m.enabled) return m.response; - await requireAssistantSurface("insightStatus"); const parsed = narrativeQuerySchema.safeParse({ period: request.nextUrl.searchParams.get("period"), @@ -80,10 +86,21 @@ export const GET = apiHandler(async (request: NextRequest) => { }); const locale = narrowLocale(resolved); - const existing = await readPeriodNarrative(user.id, period, locale); + const ai = await getAiCapability("periodNarrative"); + const stored = await readPeriodNarrative(user.id, period, locale); + // A row without a recorded provider is treated as model text: failing + // closed hides at most a deterministic line the next warm rewrites. + const modelWritten = + stored !== null && stored.providerType !== DETERMINISTIC_PROVIDER_TYPE; + const existing = + stored !== null && (!modelWritten || ai.available) ? stored : null; const isFresh = existing !== null && Date.now() - new Date(existing.updatedAt).getTime() < NARRATIVE_FRESH_MS; + // With AI unavailable a warm is still useful when nothing servable exists: + // it produces the deterministic narrative. A stale deterministic row is + // served as it is rather than re-warmed. + const mayWarm = ai.available || existing === null; // Read-only: never block on the provider. Warm out of band whenever the row // is stale / missing. The generator produces AI prose when a provider is @@ -98,7 +115,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // The stale row is still served; only the warm is withheld. let revalidating = false; const generationSuppressed = delegatedGenerationSuppressed(); - if (!isFresh && !generationSuppressed) { + if (!isFresh && mayWarm && !generationSuppressed) { void enqueueNarrativeWarm({ userId: user.id, period, locale }); revalidating = true; } @@ -110,6 +127,7 @@ export const GET = apiHandler(async (request: NextRequest) => { has_narrative: existing !== null, revalidating, generation_suppressed: generationSuppressed, + model_text_hidden: modelWritten && !ai.available, }, }); @@ -124,5 +142,6 @@ export const GET = apiHandler(async (request: NextRequest) => { } : null, revalidating, + ai, }); }); diff --git a/src/app/api/insights/patterns/[id]/__tests__/route.test.ts b/src/app/api/insights/patterns/[id]/__tests__/route.test.ts index 19ecfa7eb..dca313586 100644 --- a/src/app/api/insights/patterns/[id]/__tests__/route.test.ts +++ b/src/app/api/insights/patterns/[id]/__tests__/route.test.ts @@ -69,16 +69,19 @@ describe("PATCH /api/insights/patterns/[id]", () => { vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); }); - it("refuses a dismissal when Insights is disabled", async () => { - vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ + it("accepts a dismissal whatever the AI analysis opt-out says (statistics are data)", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: false, response: apiError('Module "insights" is not enabled', 403), }); const response = await PATCH(request({ dismissed: true }), params); - expect(response.status).toBe(403); - expect(prisma.correlationPattern.findFirst).not.toHaveBeenCalled(); + // No such pattern in this fixture: reaching the lookup proves no module + // gate stood in front of it. + expect(response.status).toBe(404); + expect(requireModuleEnabled).not.toHaveBeenCalled(); + expect(prisma.correlationPattern.findFirst).toHaveBeenCalled(); }); it("stores the current evidence as the account-scoped dismissal baseline", async () => { diff --git a/src/app/api/insights/patterns/[id]/route.ts b/src/app/api/insights/patterns/[id]/route.ts index f4827a1cf..84c2ea72f 100644 --- a/src/app/api/insights/patterns/[id]/route.ts +++ b/src/app/api/insights/patterns/[id]/route.ts @@ -13,7 +13,6 @@ import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; -import { requireModuleEnabled } from "@/lib/modules/gate"; import { updateCorrelationPatternSchema } from "@/lib/validations/correlation-patterns"; type RouteParams = { params: Promise<{ id: string }> }; @@ -26,8 +25,6 @@ export const PATCH = apiHandler( // at no level below. The dismissal stores its own evidence hash and effect // size, so it reads back and reverses. const { user } = await requireRecordAuth("manage", "record"); - const gate = await requireModuleEnabled(user.id, "insights"); - if (!gate.enabled) return gate.response; const parsedId = patternIdSchema.safeParse((await params).id); if (!parsedId.success) return returnAllZodIssues(parsedId.error, 422); const id = parsedId.data; diff --git a/src/app/api/insights/patterns/__tests__/route.test.ts b/src/app/api/insights/patterns/__tests__/route.test.ts index 7dceee097..a1ca0dee5 100644 --- a/src/app/api/insights/patterns/__tests__/route.test.ts +++ b/src/app/api/insights/patterns/__tests__/route.test.ts @@ -54,15 +54,16 @@ describe("GET /api/insights/patterns", () => { ); }); - it("refuses the list when Insights is disabled", async () => { - vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ + it("serves the list whatever the AI analysis opt-out says (statistics are data)", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: false, response: apiError('Module "insights" is not enabled', 403), }); const response = await GET(); - expect(response.status).toBe(403); - expect(prisma.correlationPattern.findMany).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(requireModuleEnabled).not.toHaveBeenCalled(); + expect(prisma.correlationPattern.findMany).toHaveBeenCalled(); }); }); diff --git a/src/app/api/insights/patterns/route.ts b/src/app/api/insights/patterns/route.ts index 828f70d47..82524b71f 100644 --- a/src/app/api/insights/patterns/route.ts +++ b/src/app/api/insights/patterns/route.ts @@ -2,7 +2,6 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; -import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; @@ -10,8 +9,9 @@ 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 gate = await requireModuleEnabled(user.id, "insights"); - if (!gate.enabled) return gate.response; + // Correlation patterns are statistics over the record's own rows. No model + // writes them, so the `insights` module (the AI analysis opt-out) does not + // gate them. const patterns = await prisma.correlationPattern.findMany({ where: { userId: user.id, isCurrent: true }, orderBy: [{ lastComputedAt: "desc" }, { canonicalKey: "asc" }], diff --git a/src/app/api/insights/pregenerate/__tests__/route.test.ts b/src/app/api/insights/pregenerate/__tests__/route.test.ts index f95eacf20..e85491006 100644 --- a/src/app/api/insights/pregenerate/__tests__/route.test.ts +++ b/src/app/api/insights/pregenerate/__tests__/route.test.ts @@ -1,21 +1,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -vi.mock("@/lib/db", () => ({ - prisma: { - appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, - }, -})); +/** + * `POST /api/insights/pregenerate` warms two AI capabilities, `briefing` and + * `statusText`. It returns no model output, so it never refuses: with + * neither available it answers 200 `{ queued: false }` and enqueues nothing; + * with either available it enqueues (the worker checks each half). The `ai` + * block always says which half can run. + */ -// v1.18.0 — the route now resolves the `insights` module gate after -// `requireAuth()`. Mock it default-enabled so the existing assertions -// ride through; the off → 403 coverage lives in the route-gate inventory -// test. -vi.mock("@/lib/modules/gate", async (importOriginal) => ({ - ...(await importOriginal()), - requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), - resolveModuleMap: vi.fn().mockResolvedValue({}), -})); +vi.mock("@/lib/db", () => ({ prisma: {} })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); @@ -38,8 +32,6 @@ vi.mock("next/headers", () => ({ })), })); -// Always allow the anti-spam bucket so the surface gate is what the test -// exercises. vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(async () => ({ allowed: true })), })); @@ -49,11 +41,18 @@ vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({ enqueueForceWarm: vi.fn(async () => undefined), })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() })); + import { POST } from "../route"; import { getSession } from "@/lib/auth/session"; -import { prisma } from "@/lib/db"; -import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit } from "@/lib/rate-limit"; import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import type { AiCapabilityKey } from "@/lib/ai/capabilities/types"; +import { + AI_AVAILABLE, + aiUnavailable, +} from "@/__tests__/helpers/ai-capability-fixtures"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -72,57 +71,73 @@ function makeReq(): NextRequest { }); } +function capabilities( + states: Partial>>, +) { + vi.mocked(getAiCapability).mockImplementation( + async (key) => states[key] ?? AI_AVAILABLE, + ); +} + beforeEach(() => { vi.resetAllMocks(); - vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); - vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + capabilities({}); }); describe("POST /api/insights/pregenerate", () => { - it("enqueues a warm when insightStatus is enabled", async () => { + it("enqueues a warm when both halves are available", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const res = await callPost(makeReq()); expect(res.status).toBe(200); const body = (await res.json()) as { - data: { queued: boolean } | null; + data: { queued: boolean; ai: Record } | null; error: string | null; }; expect(body.error).toBeNull(); expect(body.data?.queued).toBe(true); + expect(body.data?.ai).toEqual({ + briefing: AI_AVAILABLE, + statusText: AI_AVAILABLE, + }); expect(enqueueForceWarm).toHaveBeenCalledWith( expect.objectContaining({ userId: "user-1" }), ); }); - it("gates on insightStatus, not coach — a coach-disabled user can still warm", async () => { + it("enqueues when only one half is available (the worker skips the other)", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ - assistantEnabled: true, - assistantCoachEnabled: false, - assistantBriefingEnabled: true, - assistantInsightStatusEnabled: true, - assistantDocumentAiEnabled: true, - } as never); + capabilities({ briefing: aiUnavailable("operator_disabled") }); const res = await callPost(makeReq()); expect(res.status).toBe(200); expect(enqueueForceWarm).toHaveBeenCalledTimes(1); }); - it("403s + errorCode when insightStatus is disabled", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ - assistantEnabled: true, - assistantCoachEnabled: true, - assistantBriefingEnabled: true, - assistantInsightStatusEnabled: false, - assistantDocumentAiEnabled: true, - } as never); - const res = await callPost(makeReq()); - expect(res.status).toBe(403); - const body = (await res.json()) as { meta?: { errorCode?: string } }; - expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); - expect(enqueueForceWarm).not.toHaveBeenCalled(); - }); + it.each([ + "operator_disabled", + "user_disabled", + "no_provider", + "consent_required", + ] as const)( + "answers 200 { queued: false } and enqueues nothing when neither half is available (%s)", + async (reason) => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + capabilities({ + briefing: aiUnavailable(reason), + statusText: aiUnavailable(reason), + }); + const res = await callPost(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { queued: boolean; ai: { briefing: { reason: string } } }; + }; + expect(body.data.queued).toBe(false); + expect(body.data.ai.briefing.reason).toBe(reason); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + // Nothing to warm, so no anti-spam bucket is spent either. + expect(checkRateLimit).not.toHaveBeenCalled(); + }, + ); it("401s when unauthenticated", async () => { vi.mocked(getSession).mockResolvedValue(null); diff --git a/src/app/api/insights/pregenerate/route.ts b/src/app/api/insights/pregenerate/route.ts index ad3b06759..5a3c8595b 100644 --- a/src/app/api/insights/pregenerate/route.ts +++ b/src/app/api/insights/pregenerate/route.ts @@ -21,12 +21,18 @@ * `userId` is always narrowed from the session / Bearer — never a body * field. Both web and the iOS client can call it; they read the same * cached routes afterward. + * + * The warm covers two AI capabilities: `briefing` (the comprehensive + * insight) and `statusText` (the assessment cards). It returns no model + * output, so it never refuses: with neither capability available it answers + * 200 `{ queued: false }` and enqueues nothing; with one available it + * enqueues, and the worker checks each half before it builds anything. The + * `ai` block says which half can run. */ import { NextRequest } from "next/server"; import { apiSuccess, apiError } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { requireAssistantSurface } from "@/lib/feature-flags"; -import { requireModuleEnabled } from "@/lib/modules/gate"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -40,21 +46,12 @@ const WARM_WINDOW_MS = 3 * 60 * 1000; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const m = await requireModuleEnabled(user.id, "insights"); - if (!m.enabled) return m.response; - // Gate on the same surface as the read-only status routes: this warms - // the assessment cards (the `insightStatus` surface), not the Coach. - // A user with assessments enabled but Coach disabled can still warm. - await requireAssistantSurface("insightStatus"); const userId = user.id; - - // Short per-user bucket so the bypassed nightly budget can't be abused - // into a provider-cost amplifier by a tight POST loop. A blocked call - // is harmless — the caches are already being warmed by the prior call. - const rl = await checkRateLimit(`insights-warm:${userId}`, 1, WARM_WINDOW_MS); - if (!rl.allowed) { - return apiError("A warm is already in progress. Try again shortly.", 429); - } + const [briefing, statusText] = await Promise.all([ + getAiCapability("briefing"), + getAiCapability("statusText"), + ]); + const ai = { briefing, statusText }; const resolved = await resolveServerLocale({ request, @@ -65,6 +62,27 @@ export const POST = apiHandler(async (request: NextRequest) => { // German warm, so the whole warmed cache family was in the wrong language. const locale = normalizeLocale(resolved); + // Nothing this warm covers can run: enqueue nothing, spend no bucket. + if (!briefing.available && !statusText.available) { + annotate({ + action: { name: "insights.pregenerate.skipped" }, + meta: { + locale, + briefing_reason: briefing.reason, + status_reason: statusText.reason, + }, + }); + return apiSuccess({ queued: false, locale, ai }); + } + + // Short per-user bucket so the bypassed nightly budget can't be abused + // into a provider-cost amplifier by a tight POST loop. A blocked call + // is harmless — the caches are already being warmed by the prior call. + const rl = await checkRateLimit(`insights-warm:${userId}`, 1, WARM_WINDOW_MS); + if (!rl.allowed) { + return apiError("A warm is already in progress. Try again shortly.", 429); + } + await enqueueForceWarm({ userId, locale }); annotate({ @@ -74,5 +92,5 @@ export const POST = apiHandler(async (request: NextRequest) => { // The work runs on the worker; report that it was accepted, not that it // is done. The client polls the read-only status GETs for the text. - return apiSuccess({ queued: true, locale }); + return apiSuccess({ queued: true, locale, ai }); }); diff --git a/src/app/api/insights/pulse/intraday/route.ts b/src/app/api/insights/pulse/intraday/route.ts index e8e4501f4..44ae4d868 100644 --- a/src/app/api/insights/pulse/intraday/route.ts +++ b/src/app/api/insights/pulse/intraday/route.ts @@ -8,7 +8,9 @@ * all history. * * Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the - * resolved session — never a body field. Gated on the `insights` module. The + * resolved session — never a body field. Pulse is a core vital, so no module + * gates it; the `insights` module is the AI analysis opt-out, and this read + * calls no model. The * `date` defaults to the user's local today; anything but a `YYYY-MM-DD` * literal is a 422 via `returnAllZodIssues`. The frontend day navigator * pages this same route backward through prior days — a day outside @@ -20,7 +22,6 @@ import { z } from "zod"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; -import { requireModuleEnabled } from "@/lib/modules/gate"; import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { userDayKey } from "@/lib/tz/format"; @@ -40,8 +41,6 @@ export const GET = apiHandler(async (req: Request) => { // 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; const url = new URL(req.url); const parsed = querySchema.safeParse({ diff --git a/src/app/api/insights/rhythm-events/route.ts b/src/app/api/insights/rhythm-events/route.ts index db7901301..d23a09bb8 100644 --- a/src/app/api/insights/rhythm-events/route.ts +++ b/src/app/api/insights/rhythm-events/route.ts @@ -21,13 +21,12 @@ * * Follows the `metric-status` / `derived` route precedent: `apiHandler` * wrapper, cookie OR Bearer auth, `userId` narrowed from the session - * (never a query field), the `insightStatus` assistant-surface gate (no AI - * provider call — this is a pure DB read). + * (never a query field). No AI gate and no module gate: this is a pure DB + * read of device data. */ import { apiSuccess } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -46,9 +45,9 @@ 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; - // A pure read of the device's own verdicts, so no assistant-surface gate. + // A pure read of the device's own verdicts: no AI gate and no module gate. + // The `insights` module is the AI analysis opt-out, and nothing here was + // written by a model. const rows = await prisma.measurement.findMany({ where: { diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts index 5d813d452..2a0803b10 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -505,15 +505,15 @@ describe("GET /api/user/ai-provider — the shared-provider offer", () => { }, ); - it("never offers when the operator switched the assistant surfaces off", async () => { + it("never offers when the operator switched off everything the consent would unlock", async () => { serverManaged(); vi.mocked(readServerProviderHealth).mockResolvedValue("healthy"); vi.mocked(getAssistantFlags).mockResolvedValue({ enabled: true, coach: false, - briefing: true, + briefing: false, insightStatus: true, - documentAi: true, + documentAi: false, }); await expect(read()).resolves.toMatchObject({ serverProviderHealth: "healthy", @@ -521,6 +521,41 @@ describe("GET /api/user/ai-provider — the shared-provider offer", () => { }); }); + it.each(["coach", "briefing", "documentAi"] as const)( + "still offers while the operator keeps %s on", + async (open) => { + serverManaged(); + vi.mocked(readServerProviderHealth).mockResolvedValue("healthy"); + vi.mocked(getAssistantFlags).mockResolvedValue({ + enabled: true, + coach: false, + briefing: false, + insightStatus: false, + documentAi: false, + [open]: true, + }); + await expect(read()).resolves.toMatchObject({ + serverProviderOffer: true, + }); + }, + ); + + it("reports no AI available while the operator's master switch is off, whatever is configured", async () => { + serverManaged(); + vi.mocked(getAssistantFlags).mockResolvedValue({ + enabled: false, + coach: false, + briefing: false, + insightStatus: false, + documentAi: false, + }); + await expect(read()).resolves.toMatchObject({ + aiAvailable: false, + managedBy: "server", + serverProviderOffer: false, + }); + }); + it("never offers to a managed profile — a child's record does not consent for itself", async () => { serverManaged(); vi.mocked(readServerProviderHealth).mockResolvedValue("healthy"); diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 21ef5c88e..3aa4edf38 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -53,7 +53,17 @@ export const GET = apiHandler(async () => { // no personal provider. iOS keys its Coach visibility off `aiAvailable` so // a server-managed provider is no longer invisible to the client. // `managedBy` reports the origin only; no admin keys/endpoints are leaked. - const { aiAvailable, managedBy } = await resolveProviderAvailability(user.id); + // + // The operator's master switch is part of the answer: with every AI + // capability switched off there is no AI available, whatever is + // configured, so a native client that keys on this field hides its AI + // surfaces. The switches fail closed (a read error reads as all off). + const [presence, assistantFlags] = await Promise.all([ + resolveProviderAvailability(user.id), + getAssistantFlags(), + ]); + const aiAvailable = presence.aiAvailable && assistantFlags.enabled; + const { managedBy } = presence; // ── v1.38.19 — the shared provider, honestly ──────────────── // `managedBy: "server"` says the operator configured a key. It has never @@ -77,7 +87,6 @@ export const GET = apiHandler(async () => { user.id, "coach", ); - const assistantFlags = await getAssistantFlags(); // `personal`, not merely "not deny": a guardian's own record resolves to // `personal`, while a managed profile resolves to `operator-default` — // its provider is the operator's by definition, and a child's record does @@ -93,13 +102,20 @@ export const GET = apiHandler(async () => { // one visitor minted would turn the operator's provider on for every later // one. A button that can only 403 into a generic toast is precisely the // misleading failure this surface exists to remove, so there is no button. + // + // The offer is worth making while any capability it would unlock is + // switched on by the operator: the Coach, the briefing, or reading + // documents. With all three off there is nothing for the consent to open. const demoInstance = process.env.DEMO_MODE === "true"; + const offerUnlocksSomething = + assistantFlags.coach || + assistantFlags.briefing || + assistantFlags.documentAi; const serverProviderOffer = !demoInstance && managedBy === "server" && serverProviderHealth === "healthy" && - assistantFlags.enabled && - assistantFlags.coach && + offerUnlocksSomething && credentialPolicy === "personal" && !serverProviderConsent; diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index d6c348171..2cf0e2322 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -29,7 +29,8 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; -import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series"; @@ -60,7 +61,11 @@ export const GET = apiHandler( // v1.18.0 B1 — gate the detail surface behind the workouts module. const gate = await requireModuleEnabled(user.id, "workouts"); if (!gate.enabled) return gate.response; - const insightsEnabled = await isModuleEnabled(user.id, "insights"); + // The stored Activity Insight paragraph is model text: it is read and + // served only while `workoutInsights` is available (operator switch, the + // AI analysis opt-out, provider presence, consent). Otherwise the row is + // not even loaded, `aiInsight` is null, and `ai` says why. + const ai = await getAiCapability("workoutInsights"); const row = await prisma.workout.findUnique({ where: { id }, @@ -79,7 +84,7 @@ export const GET = apiHandler( sampleCount: true, }, }, - insight: insightsEnabled + insight: ai.available ? { select: { paragraphEncrypted: true, generatedAt: true }, } @@ -282,7 +287,7 @@ export const GET = apiHandler( // undecryptable paragraph degrades to no card, exactly like a workout that // never had one. let aiInsight: { paragraph: string; generatedAt: string } | null = null; - if (insightsEnabled && row.insight) { + if (ai.available && row.insight) { try { aiInsight = { paragraph: decryptFromBytes(row.insight.paragraphEncrypted), @@ -331,6 +336,8 @@ export const GET = apiHandler( // re-synced one, every one on a provider-less install) serves null and // the page's `{aiInsight ? : null}` renders nothing. aiInsight, + // Whether the Activity Insight can be shown, and why not. + ai, // v1.4.32 — when the requested id is a non-canonical twin the // caller can redirect to `canonicalId` to land on the cluster // winner. `canonicalId === id` when the requested row already diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts index 1e15b7c67..41ea55ac1 100644 --- a/src/app/api/workouts/__tests__/detail.test.ts +++ b/src/app/api/workouts/__tests__/detail.test.ts @@ -60,14 +60,21 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => { ...actual, resolveModuleMap: vi.fn(async () => ({})), requireModuleEnabled: vi.fn(async () => ({ enabled: true })), - isModuleEnabled: vi.fn(async () => true), }; }); +// The Activity Insight paragraph follows the `workoutInsights` capability. +vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() })); + import { GET } from "../[id]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { + AI_AVAILABLE, + aiUnavailable, +} from "@/__tests__/helpers/ai-capability-fixtures"; import { invalidateUserTimezone } from "@/lib/tz/resolver"; const SESSION_OK = { @@ -139,7 +146,7 @@ describe("GET /api/workouts/{id}", () => { vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true, } as never); - vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(getAiCapability).mockResolvedValue(AI_AVAILABLE); // The timezone resolver caches per user for 60 s in process; drop the // entry so a test that pins a zone is not served the previous one. invalidateUserTimezone(SESSION_OK.user.id); @@ -565,6 +572,7 @@ describe("GET /api/workouts/{id} — aiInsight", () => { vi.clearAllMocks(); vi.stubEnv("ENCRYPTION_KEY", TEST_KEY); _resetCryptoCacheForTests(); + vi.mocked(getAiCapability).mockResolvedValue(AI_AVAILABLE); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ sourcePriorityJson: null, @@ -616,26 +624,38 @@ describe("GET /api/workouts/{id} — aiInsight", () => { paragraph: "A steady, aerobic-leaning run.", generatedAt: generatedAt.toISOString(), }); + expect(getAiCapability).toHaveBeenCalledWith("workoutInsights"); + expect(body.data.ai).toEqual(AI_AVAILABLE); }); - it("does not read or expose the stored paragraph when Insights is disabled", async () => { - vi.mocked(isModuleEnabled).mockResolvedValueOnce(false); - vi.mocked(prisma.workout.findUnique).mockResolvedValue({ - ...BASE_ROW, - insight: { - paragraphEncrypted: new Uint8Array([1, 2, 3]), - generatedAt: new Date(`${SESSION_DAY}T07:35:00Z`), - }, - } as never); + it.each([ + "operator_disabled", + "user_disabled", + "module_disabled", + "consent_required", + "no_provider", + ] as const)( + "does not read or expose the stored paragraph while workoutInsights is %s", + async (reason) => { + vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable(reason)); + vi.mocked(prisma.workout.findUnique).mockResolvedValue({ + ...BASE_ROW, + insight: { + paragraphEncrypted: new Uint8Array([1, 2, 3]), + generatedAt: new Date(`${SESSION_DAY}T07:35:00Z`), + }, + } as never); - const body = await (await get()).json(); - expect(body.data.aiInsight).toBeNull(); - expect(prisma.workout.findUnique).toHaveBeenCalledWith( - expect.objectContaining({ - include: expect.objectContaining({ insight: false }), - }), - ); - }); + const body = await (await get()).json(); + expect(body.data.aiInsight).toBeNull(); + expect(body.data.ai).toEqual(aiUnavailable(reason)); + expect(prisma.workout.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + include: expect.objectContaining({ insight: false }), + }), + ); + }, + ); it("degrades to no card rather than failing the page on an undecryptable row", async () => { // `decrypt` is fail-closed by design. A key rotated away must not turn the diff --git a/src/app/coach/__tests__/page-prefetch.test.tsx b/src/app/coach/__tests__/page-prefetch.test.tsx index 3912f4f11..d0fc4b7ca 100644 --- a/src/app/coach/__tests__/page-prefetch.test.tsx +++ b/src/app/coach/__tests__/page-prefetch.test.tsx @@ -10,20 +10,25 @@ import { queryKeys } from "@/lib/query-keys"; * The RSC wrapper (`src/app/coach/page.tsx`) dehydrates the coach nudge status * under `queryKeys.coachNudgeStatus()` so the auto-open-most-recent decision is * available at hydrate (collapsing the nudge → auto-open waterfall). These - * tests pin: the exact client key; the read is only run when the Coach surface - * is reachable (operator flag ON + not user-disabled); and every unreachable / + * tests pin: the exact client key; the read is only run when the `coach` + * capability is available for the caller's own record; and every unavailable / * error path fails soft. The streaming conversation is never prefetched here. */ const getUnswitchedSession = vi.fn(); -const requireAssistantSurface = vi.fn(); +const loadAiCapabilityInputs = vi.fn(); +const resolveAiCapability = vi.fn(); const readCoachNudgeStatus = vi.fn(); vi.mock("@/lib/auth/acting-carrier", () => ({ getUnswitchedSession: () => getUnswitchedSession(), })); -vi.mock("@/lib/feature-flags", () => ({ - requireAssistantSurface: (s: string) => requireAssistantSurface(s), +vi.mock("@/lib/ai/capabilities/load", () => ({ + loadAiCapabilityInputs: (scope: unknown) => loadAiCapabilityInputs(scope), +})); +vi.mock("@/lib/ai/capabilities/resolve", () => ({ + resolveAiCapability: (key: string, inputs: unknown) => + resolveAiCapability(key, inputs), })); vi.mock("@/lib/ai/coach/nudge-status", () => ({ readCoachNudgeStatus: (id: string) => readCoachNudgeStatus(id), @@ -38,6 +43,18 @@ const NUDGE = { conversationId: "c1", }; +const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true }; +const USER_DISABLED = { + available: false, + reason: "user_disabled", + onDeviceAllowed: false, +}; +const OPERATOR_DISABLED = { + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, +}; + afterEach(() => { vi.clearAllMocks(); delete process.env.DASHBOARD_SSR_PREFETCH; @@ -59,31 +76,65 @@ describe("/coach RSC prefetch", () => { getUnswitchedSession.mockResolvedValue({ user: { id: "u1", disableCoach: false }, }); - requireAssistantSurface.mockResolvedValue({}); + resolveAiCapability.mockReturnValue(AVAILABLE); readCoachNudgeStatus.mockResolvedValue(NUDGE); const el = (await CoachPage()) as ReactElement; const q = dehydratedQuery(el); expect(q).not.toBeNull(); expect(q!.queryHash).toBe(hashKey(queryKeys.coachNudgeStatus())); - expect(q!.state.data).toEqual(NUDGE); + // The seeded value equals the route's wire shape, `ai` included. + expect(q!.state.data).toEqual({ ...NUDGE, ai: AVAILABLE }); + }); + + it("resolves the Coach for the caller's own record with the owner's authority", async () => { + getUnswitchedSession.mockResolvedValue({ + user: { id: "u1", disableCoach: false }, + }); + resolveAiCapability.mockReturnValue(AVAILABLE); + readCoachNudgeStatus.mockResolvedValue(NUDGE); + + await CoachPage(); + expect(loadAiCapabilityInputs).toHaveBeenCalledWith({ + recordId: "u1", + authority: { + origin: "owner", + recordUserId: "u1", + actorUserId: "u1", + grantId: null, + }, + sections: null, + recordKind: "self", + }); + expect(resolveAiCapability.mock.calls[0]?.[0]).toBe("coach"); }); it("skips the prefetch when the user opted out of the Coach", async () => { getUnswitchedSession.mockResolvedValue({ user: { id: "u1", disableCoach: true }, }); + resolveAiCapability.mockReturnValue(USER_DISABLED); const el = (await CoachPage()) as ReactElement; expect(el.type).not.toBe(HydrationBoundary); - expect(requireAssistantSurface).not.toHaveBeenCalled(); expect(readCoachNudgeStatus).not.toHaveBeenCalled(); }); - it("fails soft when the operator Coach flag is off (surface throws)", async () => { + it("skips the prefetch when the operator turned the Coach off", async () => { getUnswitchedSession.mockResolvedValue({ user: { id: "u1", disableCoach: false }, }); - requireAssistantSurface.mockRejectedValue(new Error("assistant disabled")); + resolveAiCapability.mockReturnValue(OPERATOR_DISABLED); + + const el = (await CoachPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(readCoachNudgeStatus).not.toHaveBeenCalled(); + }); + + it("fails soft when the capability load throws", async () => { + getUnswitchedSession.mockResolvedValue({ + user: { id: "u1", disableCoach: false }, + }); + loadAiCapabilityInputs.mockRejectedValueOnce(new Error("db blip")); const el = (await CoachPage()) as ReactElement; expect(el.type).not.toBe(HydrationBoundary); @@ -94,7 +145,7 @@ describe("/coach RSC prefetch", () => { getUnswitchedSession.mockResolvedValue({ user: { id: "u1", disableCoach: false }, }); - requireAssistantSurface.mockResolvedValue({}); + resolveAiCapability.mockReturnValue(AVAILABLE); readCoachNudgeStatus.mockRejectedValue(new Error("db blip")); const el = (await CoachPage()) as ReactElement; diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 5a1655500..40848fb69 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -5,8 +5,10 @@ import { } from "@tanstack/react-query"; import { getUnswitchedSession } from "@/lib/auth/acting-carrier"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status"; +import { loadAiCapabilityInputs } from "@/lib/ai/capabilities/load"; +import { resolveAiCapability } from "@/lib/ai/capabilities/resolve"; +import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; import { queryKeys } from "@/lib/query-keys"; import CoachPageClient from "./page-client"; @@ -35,11 +37,12 @@ import CoachPageClient from "./page-client"; * immediate refetch. When the client query is disabled (a `?c=`/`?doc=`/ * `?scope=` deep-link makes it `enabled: false`) the seeded value simply * sits unused — harmless. - * - Availability parity: prefetch only when the Coach surface is actually - * reachable — the operator `coach` assistant flag is ON - * (`requireAssistantSurface` throws otherwise) AND the user has not opted - * out (`disableCoach`). Mirrors the client's `!flags.coach || disableCoach` - * redirect gate, so a disabled account does no wasted work. + * - Availability parity: prefetch only when the `coach` capability is + * available for the caller's own record, resolved from every layer (the + * operator switch, the `disableCoach` opt-out, provider presence and + * consent) by the same resolver the API routes use. An unavailable Coach + * does no wasted work. The seeded value carries the same `ai` state the + * nudge-status route returns, so the hydrated shape equals the wire. * - Left CLIENT-LAZY on purpose: the streaming conversation itself (the SSE * thread, snapshot, any provider-touching path) is NOT prefetched or warmed * — it depends on URL params + the nudge outcome and must never be triggered @@ -52,6 +55,28 @@ import CoachPageClient from "./page-client"; * - Fail-soft: no session, the Coach being off, or a DB blip renders the page * exactly as before this wrapper existed — the client cell owns the fetch. */ +/** + * The `coach` capability for the caller's own record. An RSC has no request + * event, so the route gate cannot resolve the record from it; the unswitched + * session is always the caller's own record, read with the owner's authority. + */ +async function coachCapabilityForOwnRecord( + userId: string, +): Promise { + const inputs = await loadAiCapabilityInputs({ + recordId: userId, + authority: { + origin: "owner", + recordUserId: userId, + actorUserId: userId, + grantId: null, + }, + sections: null, + recordKind: "self", + }); + return resolveAiCapability("coach", inputs); +} + export default async function CoachPage() { // Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e // server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which @@ -63,15 +88,17 @@ export default async function CoachPage() { let dehydratedState = null; try { const session = await getUnswitchedSession(); - if (session && !session.user.disableCoach) { - // Throws `AssistantDisabledError` when the operator flag is off → caught - // below, prefetch skipped, client path stands (and redirects to /insights). - await requireAssistantSurface("coach"); + const ai = session + ? await coachCapabilityForOwnRecord(session.user.id) + : null; + // An unavailable Coach skips the prefetch; the client path stands (and + // redirects to /insights). + if (session && ai?.available) { const nudge = await readCoachNudgeStatus(session.user.id); const queryClient = new QueryClient(); queryClient.setQueryData( queryKeys.coachNudgeStatus(), - JSON.parse(JSON.stringify(nudge)), + JSON.parse(JSON.stringify({ ...nudge, ai })), ); dehydratedState = dehydrate(queryClient); } diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index de7a48835..d7fa8c1d7 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -188,6 +188,7 @@ import type { EncounterDTO } from "@/lib/encounters/dto"; import { queryKeys } from "@/lib/query-keys"; import type { DailyDigest } from "@/lib/daily/digest"; import type { MeasurementReminder } from "@/hooks/use-measurement-reminders"; +import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; function render( access: AccountAccess, @@ -344,6 +345,7 @@ describe("the medication card menu", () => { const DIGEST: DailyDigest = { generatedAt: "2026-08-03T06:00:00.000Z", + ai: DIGEST_AI_AVAILABLE, phase: "final", sleepPending: false, score: null, diff --git a/src/components/daily/__tests__/today-hero-just-in.test.tsx b/src/components/daily/__tests__/today-hero-just-in.test.tsx index 12759331f..d22129481 100644 --- a/src/components/daily/__tests__/today-hero-just-in.test.tsx +++ b/src/components/daily/__tests__/today-hero-just-in.test.tsx @@ -20,6 +20,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { TodayHero } from "../today-hero"; import type { DailyDigest } from "@/lib/daily/digest"; +import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; function render(node: React.ReactNode, locale: "en" | "de" = "en") { const client = new QueryClient(); @@ -35,6 +36,7 @@ const ARRIVED_AT = "2026-07-16T05:41:00.000Z"; function digest(over: Partial = {}): DailyDigest { return { generatedAt: "2026-07-16T06:00:00.000Z", + ai: DIGEST_AI_AVAILABLE, phase: "final", sleepPending: false, score: { value: 82, band: "green", delta: 3 }, diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 7f794ff2e..5c8842fb1 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -7,6 +7,7 @@ import { TodayHero } from "../today-hero"; import type { DailyDigest } from "@/lib/daily/digest"; import type { PriorityItem } from "@/lib/daily/priority-item"; import { SCORE_VERSION } from "@/lib/analytics/score/types"; +import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; // The hero now wires the coach check-in card's keep / let-go taps through // `useCoachCheckinAction`, so it needs a QueryClient in the tree. @@ -51,6 +52,7 @@ const syncItem: PriorityItem = { function digest(over: Partial = {}): DailyDigest { return { generatedAt: "2026-07-16T06:00:00.000Z", + ai: DIGEST_AI_AVAILABLE, phase: "final", sleepPending: false, score: { diff --git a/src/lib/ai/capabilities/record.ts b/src/lib/ai/capabilities/record.ts new file mode 100644 index 000000000..bc37fb46f --- /dev/null +++ b/src/lib/ai/capabilities/record.ts @@ -0,0 +1,26 @@ +/** + * One capability for one record, from whichever context is asking. + * + * Some readers run both inside an API request and outside one: the dashboard + * snapshot and the daily digest are read by their routes, by the server + * components that prefetch them, and by the morning push. Inside a request + * the route gate answers, and it honours the request's grant (a delegate + * inside somebody else's record gets that record's masked answer). Outside a + * request there is no caller to mask for, so the worker path answers with + * the record's own authority, exactly as a job would. + * + * Never throws; an input that cannot be loaded reads `check_failed`. + */ +import { getEvent } from "@/lib/logging/context"; + +import { aiCapabilityForJob, getAiCapability } from "./gate"; +import type { AiCapabilityKey, AiCapabilityState } from "./types"; + +export function aiCapabilityForRecord( + recordId: string, + key: AiCapabilityKey, +): Promise { + return getEvent()?.getAuth()?.user_id + ? getAiCapability(key, { recordId }) + : aiCapabilityForJob(recordId, key); +} diff --git a/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts b/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts index 5bc67d264..7c2ca4a71 100644 --- a/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts +++ b/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts @@ -58,6 +58,7 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); import { deriveClarifyingQuestions } from "../self-context-questions"; import type { SelfContext } from "../about-me"; +import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS } from "@/lib/validations/health-profile-facts"; const ctx: SelfContext = { aboutMe: "Shift work, half-marathon training.", @@ -104,6 +105,7 @@ describe("deriveClarifyingQuestions — included sections", () => { { ...ctx, aboutMe: null }, "en", ["ABOUT_ME"], + { aiAvailable: true }, ); expect(out.questions).toEqual([ @@ -114,9 +116,13 @@ describe("deriveClarifyingQuestions — included sections", () => { }); it("does no question work when no question-bearing field is included", async () => { - const out = await deriveClarifyingQuestions("user-1", ctx, "en", [ - "FAMILY_HISTORY", - ]); + const out = await deriveClarifyingQuestions( + "user-1", + ctx, + "en", + ["FAMILY_HISTORY"], + { aiAvailable: true }, + ); expect(out.questions).toEqual([]); expect(providerMocks.hasAnyConfiguredProvider).not.toHaveBeenCalled(); @@ -124,13 +130,43 @@ describe("deriveClarifyingQuestions — included sections", () => { }); }); +describe("deriveClarifyingQuestions — capability unavailable", () => { + it("serves the deterministic hints and never touches a provider, budget or snapshot", async () => { + providerMocks.resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: provider }, + ]); + + const out = await deriveClarifyingQuestions( + "user-1", + { ...ctx, aboutMe: null }, + "en", + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + { aiAvailable: false }, + ); + + expect(out.source).toBe("fallback"); + expect(out.questions.length).toBeGreaterThan(0); + expect(providerMocks.hasAnyConfiguredProvider).not.toHaveBeenCalled(); + expect(providerMocks.resolveProviderChain).not.toHaveBeenCalled(); + expect(budgetMocks.reserveBudget).not.toHaveBeenCalled(); + expect(buildCoachSnapshot).not.toHaveBeenCalled(); + expect(provider.generateCompletion).not.toHaveBeenCalled(); + }); +}); + describe("deriveClarifyingQuestions — server-managed consent gate", () => { it("refuses the AI path on an operator-managed chain with no receipt", async () => { providerMocks.resolveProviderChain.mockResolvedValue([ { providerType: "admin-openai", instance: provider }, ]); - const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + const out = await deriveClarifyingQuestions( + "user-1", + ctx, + "en", + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + { aiAvailable: true }, + ); expect(out.source).toBe("fallback"); // The snapshot is the PHI: it must never even be built, let alone sent. @@ -145,7 +181,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => { { providerType: "admin-codex", instance: provider }, ]); - const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + const out = await deriveClarifyingQuestions( + "user-1", + ctx, + "en", + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + { aiAvailable: true }, + ); expect(out.source).toBe("fallback"); expect(provider.generateCompletion).not.toHaveBeenCalled(); @@ -157,7 +199,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => { providerMocks.resolveProviderChain.mockResolvedValue([]); providerMocks.resolveProvider.mockResolvedValue(provider); - const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + const out = await deriveClarifyingQuestions( + "user-1", + ctx, + "en", + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + { aiAvailable: true }, + ); expect(out.source).toBe("fallback"); expect(provider.generateCompletion).not.toHaveBeenCalled(); @@ -171,7 +219,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => { kind === "ai_coach" ? { id: "receipt-1" } : null, ); - const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + const out = await deriveClarifyingQuestions( + "user-1", + ctx, + "en", + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + { aiAvailable: true }, + ); expect(out.source).toBe("ai"); expect(out.questions).toEqual(["Question one?", "Question two?"]); @@ -186,7 +240,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => { kind === "ai_full" ? { id: "receipt-2" } : null, ); - const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + const out = await deriveClarifyingQuestions( + "user-1", + ctx, + "en", + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + { aiAvailable: true }, + ); expect(out.source).toBe("ai"); expect(provider.generateCompletion).toHaveBeenCalledTimes(1); @@ -197,7 +257,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => { { providerType: "openai", instance: provider }, ]); - const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + const out = await deriveClarifyingQuestions( + "user-1", + ctx, + "en", + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + { aiAvailable: true }, + ); expect(out.source).toBe("ai"); expect(provider.generateCompletion).toHaveBeenCalledTimes(1); @@ -209,7 +275,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => { { providerType: "local", instance: provider }, ]); - const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + const out = await deriveClarifyingQuestions( + "user-1", + ctx, + "en", + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + { aiAvailable: true }, + ); expect(out.source).toBe("ai"); expect(provider.generateCompletion).toHaveBeenCalledTimes(1); @@ -223,7 +295,13 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => { { providerType: "admin-openai", instance: provider }, ]); - const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + const out = await deriveClarifyingQuestions( + "user-1", + ctx, + "en", + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + { aiAvailable: true }, + ); expect(out.source).toBe("fallback"); expect(provider.generateCompletion).not.toHaveBeenCalled(); @@ -238,7 +316,13 @@ describe("deriveClarifyingQuestions — atomic budget", () => { }); it("reserves before the call and reconciles the actual spend after", async () => { - await deriveClarifyingQuestions("user-1", ctx, "en"); + await deriveClarifyingQuestions( + "user-1", + ctx, + "en", + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + { aiAvailable: true }, + ); expect(budgetMocks.reserveBudget).toHaveBeenCalledWith( "user-1", @@ -267,7 +351,13 @@ describe("deriveClarifyingQuestions — atomic budget", () => { operatorAfter: 200_000, }); - const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + const out = await deriveClarifyingQuestions( + "user-1", + ctx, + "en", + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + { aiAvailable: true }, + ); expect(out.source).toBe("fallback"); expect(provider.generateCompletion).not.toHaveBeenCalled(); @@ -276,7 +366,13 @@ describe("deriveClarifyingQuestions — atomic budget", () => { it("reconciles the reservation to zero when the provider throws", async () => { provider.generateCompletion.mockRejectedValue(new Error("upstream down")); - const out = await deriveClarifyingQuestions("user-1", ctx, "en"); + const out = await deriveClarifyingQuestions( + "user-1", + ctx, + "en", + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + { aiAvailable: true }, + ); expect(out.source).toBe("fallback"); expect(budgetMocks.reconcileSpend).toHaveBeenCalledWith( diff --git a/src/lib/ai/coach/self-context-questions.ts b/src/lib/ai/coach/self-context-questions.ts index 34cd3b0e2..4b8b175af 100644 --- a/src/lib/ai/coach/self-context-questions.ts +++ b/src/lib/ai/coach/self-context-questions.ts @@ -181,17 +181,23 @@ async function resolveQuestionsChain( * Derive the pending questions for a freshly saved self-context. * Never throws — every failure path lands on the deterministic * fallback so the PUT route stays robust. + * + * `aiAvailable` is the caller's resolved `aboutMeQuestions` capability. When + * it is false the deterministic fallback is the whole answer: no provider is + * resolved, no budget reserved, nothing leaves the server. */ export async function deriveClarifyingQuestions( userId: string, ctx: SelfContext, locale: string | null | undefined, - includedSections: readonly HealthProfileAiSection[] = DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + includedSections: readonly HealthProfileAiSection[], + options: { aiAvailable: boolean }, ): Promise<{ questions: string[]; source: "ai" | "fallback" }> { const fallback = () => ({ questions: buildFallbackQuestions(ctx, locale, includedSections), source: "fallback" as const, }); + if (!options.aiAvailable) return fallback(); const included = new Set(includedSections); // A free-form model reply cannot be proven not to cross into an omitted // field. Partial inclusion therefore stays on the closed deterministic diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index ef02d12c4..ca2282dd4 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -8,6 +8,7 @@ */ import { z } from "zod/v4"; +import type { AiUnavailableReason } from "@/lib/ai/capabilities/types"; import type { CoachSuggestedAction } from "./suggest-action"; /** @@ -227,7 +228,17 @@ export type CoachStreamEvent = messageId: string; usage?: CoachUsage; } - | { type: "error"; code: string; message: string }; + | { + type: "error"; + code: string; + message: string; + /** + * The AI capability reason, when the refusal came from one (today only + * `no_provider` on `coach.provider.none`). Additive: older clients + * ignore it. + */ + reason?: AiUnavailableReason; + }; /** * v1.4.22 — Zod schema for one entry inside the Coach's evidence diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts index 5d411ee56..151acdc14 100644 --- a/src/lib/daily/__tests__/daily-briefing-push.test.ts +++ b/src/lib/daily/__tests__/daily-briefing-push.test.ts @@ -21,6 +21,7 @@ import { isUrgentPayload } from "@/lib/notifications/types"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { loadDailyDigest } from "@/lib/daily/load-digest"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; +import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; // 06:00Z → 08:00 in Europe/Berlin (summer) → inside the morning window AND the // fixed fallback hour. The tz-window cases move only this instant. @@ -31,6 +32,7 @@ const AFTER_WINDOW = new Date("2026-07-16T12:30:00Z"); // 14:30 Berlin function makeDigest(over: Partial = {}): DailyDigest { return { generatedAt: "2026-07-16T06:00:00.000Z", + ai: DIGEST_AI_AVAILABLE, phase: "final", sleepPending: false, score: { value: 82, band: "good", delta: 1 }, diff --git a/src/lib/daily/__tests__/digest-just-in.test.ts b/src/lib/daily/__tests__/digest-just-in.test.ts index ba5b9e9a3..0a4c7dd5b 100644 --- a/src/lib/daily/__tests__/digest-just-in.test.ts +++ b/src/lib/daily/__tests__/digest-just-in.test.ts @@ -18,6 +18,7 @@ import { type DailyDigestInput, } from "@/lib/daily/digest"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; +import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; const t = getServerTranslator("en").t; const NOW = new Date("2026-07-16T09:00:00.000Z"); @@ -38,6 +39,7 @@ function meds(): MedsTodayBlock { function input(over: Partial = {}): DailyDigestInput { return { now: NOW, + ai: DIGEST_AI_AVAILABLE, todayEndExclusive: new Date("2026-07-17T00:00:00.000Z"), modules: {}, enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index e6dde7696..acfa1e89c 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -28,6 +28,10 @@ import { sameTimeBaselineItemKey, tensionWindowItemKey, } from "@/lib/daily/priority-item-key"; +import { + aiUnavailable, + DIGEST_AI_AVAILABLE, +} from "@/__tests__/helpers/ai-capability-fixtures"; const t = getServerTranslator("en").t; const NOW = new Date("2026-07-16T09:00:00.000Z"); @@ -64,6 +68,7 @@ const briefing: DailyBriefing = { function input(over: Partial = {}): DailyDigestInput { return { now: NOW, + ai: DIGEST_AI_AVAILABLE, // NOW is 09:00Z on 2026-07-16; UTC profile day ends at next midnight. todayEndExclusive: new Date("2026-07-17T00:00:00.000Z"), modules: {}, @@ -766,13 +771,25 @@ describe("buildDailyDigest — coach check-in (S3)", () => { expect(checkin(d)).toBeUndefined(); }); - it("does NOT emit a check-in when the coach module is off", () => { - const d = buildDailyDigest( - input({ modules: { coach: false }, coachPlans: [plan()] }), - t, - ); - expect(checkin(d)).toBeUndefined(); - }); + it.each([ + "operator_disabled", + "module_disabled", + "user_disabled", + "no_provider", + "consent_required", + ] as const)( + "does NOT emit a check-in while the coach capability is %s", + (reason) => { + const d = buildDailyDigest( + input({ + ai: { ...DIGEST_AI_AVAILABLE, coach: aiUnavailable(reason) }, + coachPlans: [plan()], + }), + t, + ); + expect(checkin(d)).toBeUndefined(); + }, + ); it("emits none when there are no standing plans", () => { const d = buildDailyDigest(input({ coachPlans: [] }), t); @@ -862,12 +879,13 @@ describe("S12 — the milestone reward card", () => { expect(milestone(buildDailyDigest(input(), t))).toBeUndefined(); }); - it("is suppressed when the insights module is off (module-gated)", () => { + it("is data: the AI analysis opt-out (insights module off) does not hide it", () => { const d = buildDailyDigest( input({ milestone: RECORD_MILESTONE, modules: { insights: false } }), t, ); - expect(milestone(d)).toBeUndefined(); + expect(milestone(d)).toBeDefined(); + expect(milestone(d)?.moduleKey).toBeUndefined(); }); it("sits just below an overdue dose and above ambient items", () => { @@ -936,7 +954,7 @@ describe("buildDailyDigest — S11 tension_window item", () => { expect(tension(d)).toBeUndefined(); }); - it("stays silent when the insights module is off", () => { + it("is data: the AI analysis opt-out (insights module off) does not hide it", () => { const d = buildDailyDigest( input({ modules: { insights: false }, @@ -944,7 +962,7 @@ describe("buildDailyDigest — S11 tension_window item", () => { }), t, ); - expect(tension(d)).toBeUndefined(); + expect(tension(d)).toBeDefined(); }); it("yields the bounded rail to time-sensitive actions first", () => { @@ -1042,12 +1060,12 @@ describe("buildDailyDigest — same_time_baseline item", () => { expect(sameTimeItem(buildDailyDigest(input({}), t))).toBeUndefined(); }); - it("stays silent when the insights module is off", () => { + it("is data: the AI analysis opt-out (insights module off) does not hide it", () => { const d = buildDailyDigest( input({ modules: { insights: false }, sameTime: sameTime() }), t, ); - expect(sameTimeItem(d)).toBeUndefined(); + expect(sameTimeItem(d)).toBeDefined(); }); it("uses a different sentence when today is ahead", () => { @@ -1131,7 +1149,8 @@ describe("buildDailyDigest — ecg_new_recording (S10)", () => { const item = ecgItem(d); expect(item).toBeDefined(); expect(item?.status).toBe("info"); - expect(item?.moduleKey).toBe("insights"); + // Device data: no module owns it. + expect(item?.moduleKey).toBeUndefined(); // Single action, deep-linking the ECG viewer. expect(item?.actions).toHaveLength(1); expect(item?.actions[0].intent).toBe("ecg.view"); @@ -1182,7 +1201,7 @@ describe("buildDailyDigest — ecg_new_recording (S10)", () => { expect(ecgItem(d)).toBeUndefined(); }); - it("does not emit when the insights module is off", () => { + it("is device data: the AI analysis opt-out (insights module off) does not hide it", () => { const d = buildDailyDigest( input({ modules: { insights: false }, @@ -1193,7 +1212,7 @@ describe("buildDailyDigest — ecg_new_recording (S10)", () => { }), t, ); - expect(ecgItem(d)).toBeUndefined(); + expect(ecgItem(d)).toBeDefined(); }); it("emits nothing when there is no recent recording", () => { @@ -1274,3 +1293,27 @@ describe("buildDailyDigest — dismiss filtering never touches actionable kinds" ).toBeUndefined(); }); }); + +describe("buildDailyDigest — AI text follows its capability", () => { + it.each([ + "operator_disabled", + "user_disabled", + "no_provider", + "consent_required", + ] as const)( + "drops the briefing lead and top signal while briefing is %s", + (reason) => { + const d = buildDailyDigest( + input({ + ai: { ...DIGEST_AI_AVAILABLE, briefing: aiUnavailable(reason) }, + }), + t, + ); + expect(d.briefingLead).toBeNull(); + expect(d.topSignal).toBeNull(); + // The push line falls to the deterministic floor, never to model text. + expect(d.line).toBe("Your health score today is 82."); + expect(d.ai.briefing.reason).toBe(reason); + }, + ); +}); diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index 2756a6dec..3ca427c57 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -44,6 +44,14 @@ vi.mock("@/lib/analytics/intraday-pulse-io", () => ({ loadIntradayPulse: vi.fn(), })); +vi.mock("@/lib/ai/capabilities/record", () => ({ + aiCapabilityForRecord: vi.fn(), +})); + +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: vi.fn(() => "a reaction line"), +})); + vi.mock("@/lib/i18n/server-translator", () => ({ getServerTranslator: vi.fn().mockReturnValue({ t: (key: string) => key }), })); @@ -57,6 +65,13 @@ import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; +import { prisma } from "@/lib/db"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/record"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { + AI_AVAILABLE, + aiUnavailable, +} from "@/__tests__/helpers/ai-capability-fixtures"; const SNAPSHOT = { body: { @@ -75,6 +90,7 @@ const SNAPSHOT = { briefingState: "ready", briefingUpdatedAt: null, briefingStale: false, + briefingAi: AI_AVAILABLE, }, locale: "en", }; @@ -94,6 +110,7 @@ beforeEach(() => { vi.mocked(resolveModuleMap).mockResolvedValue({} as never); vi.mocked(probeRollupCoverage).mockResolvedValue(new Map()); vi.mocked(loadIntradayPulse).mockResolvedValue({ tension: null } as never); + vi.mocked(aiCapabilityForRecord).mockResolvedValue(AI_AVAILABLE); }); describe("loadDailyDigest — S11/S12 extras cache", () => { @@ -145,13 +162,13 @@ describe("loadDailyDigest — S11/S12 extras cache", () => { expect(loadIntradayPulse).toHaveBeenCalledTimes(2); }); - it("never gathers the extras when the insights module is disabled", async () => { + it("gathers the extras even with the insights module (the AI analysis opt-out) off", async () => { vi.mocked(resolveModuleMap).mockResolvedValue({ insights: false } as never); await loadDailyDigest(USER, NOW); - expect(probeRollupCoverage).not.toHaveBeenCalled(); - expect(loadIntradayPulse).not.toHaveBeenCalled(); + expect(probeRollupCoverage).toHaveBeenCalled(); + expect(loadIntradayPulse).toHaveBeenCalled(); }); it("threads the resolved hero-item visibility into composition", async () => { vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({ @@ -191,3 +208,94 @@ describe("loadDailyDigest — S11/S12 extras cache", () => { expect(digest.worthALook.map((item) => item.kind)).toContain("dose_window"); }); }); + +describe("loadDailyDigest — AI parts", () => { + const ARRIVAL = { + kind: "sleep_night", + occurredAt: new Date("2026-07-17T06:00:00.000Z"), + arrivedAt: new Date("2026-07-17T08:55:00.000Z"), + lineEncrypted: new Uint8Array([1, 2, 3]), + generatedAt: new Date("2026-07-17T08:56:00.000Z"), + }; + + it("resolves the coach and reaction-line capabilities for the record and publishes them", async () => { + const digest = await loadDailyDigest(USER, NOW); + + expect(aiCapabilityForRecord).toHaveBeenCalledWith("user-1", "coach"); + expect(aiCapabilityForRecord).toHaveBeenCalledWith( + "user-1", + "reactionLines", + ); + expect(digest.ai).toEqual({ + briefing: AI_AVAILABLE, + coach: AI_AVAILABLE, + reactionLines: AI_AVAILABLE, + }); + }); + + it("serves the stored reaction line while the capability is available", async () => { + vi.mocked(prisma.arrivalReaction.findMany).mockResolvedValueOnce([ + ARRIVAL, + ] as never); + + const digest = await loadDailyDigest(USER, NOW); + + expect(digest.reactionLine).toBe("a reaction line"); + }); + + it("neither decrypts nor serves a stored reaction line while the capability is unavailable", async () => { + vi.mocked(aiCapabilityForRecord).mockImplementation(async (_id, key) => + key === "reactionLines" ? aiUnavailable("user_disabled") : AI_AVAILABLE, + ); + vi.mocked(prisma.arrivalReaction.findMany).mockResolvedValueOnce([ + ARRIVAL, + ] as never); + + const digest = await loadDailyDigest(USER, NOW); + + expect(digest.reactionLine).toBeNull(); + expect(decryptFromBytes).not.toHaveBeenCalled(); + // The marker itself is data and still drives the "just in" chip. + expect(digest.justIn?.kind).toBe("sleep_night"); + }); + + it("does not decrypt plan prose while the coach capability is unavailable", async () => { + vi.mocked(aiCapabilityForRecord).mockImplementation(async (_id, key) => + key === "coach" ? aiUnavailable("operator_disabled") : AI_AVAILABLE, + ); + vi.mocked(prisma.coachPlan.findMany).mockResolvedValueOnce([ + { + id: "p1", + status: "active", + reviewDate: null, + createdAt: new Date("2026-07-01T00:00:00.000Z"), + updatedAt: new Date("2026-07-01T00:00:00.000Z"), + ifCueEncrypted: new Uint8Array([1]), + thenActionEncrypted: new Uint8Array([2]), + }, + ] as never); + + const digest = await loadDailyDigest(USER, NOW); + + expect(decryptFromBytes).not.toHaveBeenCalled(); + expect(digest.worthALook.some((i) => i.kind === "coach_checkin")).toBe( + false, + ); + }); + + it("fails closed on the briefing when a cached body predates the published state", async () => { + const { briefingAi: _dropped, ...older } = SNAPSHOT.body; + vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({ + ...SNAPSHOT, + body: older, + } as never); + + const digest = await loadDailyDigest(USER, NOW); + + expect(digest.ai.briefing).toEqual({ + available: false, + reason: "check_failed", + onDeviceAllowed: false, + }); + }); +}); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 267754164..212fdc20f 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -21,6 +21,7 @@ */ import type { EncounterKind } from "@/generated/prisma/client"; import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema"; +import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; import type { ArrivalKind } from "@/lib/arrivals/types"; import { encounterKindLabelKey } from "@/lib/encounters/kind-label"; import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; @@ -254,8 +255,22 @@ export interface DailyDigestArrival { export const JUST_IN_WINDOW_MS = 3 * 60 * 60 * 1000; /** The fully-resolved, IO-free input the composer folds into a digest. */ +/** + * The three AI capabilities the digest carries text or a card for, resolved + * for this record by the IO seam. The builder serves the briefing lead and + * top signal only while `briefing` is available, the reaction line only while + * `reactionLines` is, and the Coach check-in only while `coach` is; the rest + * of the digest is data and never depends on them. + */ +export interface DailyDigestAi { + briefing: AiCapabilityState; + coach: AiCapabilityState; + reactionLines: AiCapabilityState; +} + export interface DailyDigestInput { now: Date; + ai: DailyDigestAi; /** * First instant of the user's NEXT local day (profile tz), resolved by the * IO seam via `getUserTodayBounds`. Bounds the dose-window rail to TODAY'S @@ -374,6 +389,8 @@ export interface DailyDigest { * sentence is garnish; its absence degrades nothing. */ reactionLine: string | null; + /** Why the briefing lead, the reaction line or the check-in are absent. */ + ai: DailyDigestAi; } /** First sentence of a paragraph, trimmed; null when empty. */ @@ -598,8 +615,8 @@ function buildUpcomingVisitItem( /** * S12 — the calm reward card. Emits ONE `milestone` PriorityItem when a durable * state was reached TODAY (the IO seam already applied the reached-once gate). - * Gated on the `insights` module — the daily narrative layer that hosts it. - * Celebratory-but-quiet: `success` status, a single "view" action into the + * Data, not AI: no module gate beyond the metric's own (applied by the IO + * seam). Celebratory-but-quiet: `success` status, a single "view" action into the * metric's insight, and copy that marks arrival at a state, never a maintained * count. Shown the day reached and never again — never a "you broke it" note. */ @@ -609,7 +626,6 @@ function buildMilestoneItem( t: Translate, ): PriorityItem | null { if (!milestone) return null; - if (!moduleEnabled(modules, "insights")) return null; const { title, body } = milestoneCopy(milestone, t); return { kind: "milestone", @@ -624,15 +640,14 @@ function buildMilestoneItem( href: milestoneHref(milestone), }, ], - moduleKey: "insights", }; } /** * S11 — the elevated-at-rest ("tension") card. Emitted at most once per day * (the window is already the day's single most confident stretch), gated on - * the `insights` module and on a non-null window (the analytics layer stays - * silent unless every confidence gate holds). Cautious, non-diagnostic copy: + * a non-null window (the analytics layer stays silent unless every confidence + * gate holds). Pulse is a core vital, so no module gates it. Cautious, non-diagnostic copy: * "possible tension", never a clinical stress verdict. The one action deep- * links into the pulse insight where the intraday shape is charted. */ @@ -642,7 +657,6 @@ function buildTensionWindowItem( todayLocalDate: string, t: Translate, ): PriorityItem | null { - if (!moduleEnabled(modules, "insights")) return null; if (!window) return null; return { kind: "tension_window", @@ -657,7 +671,6 @@ function buildTensionWindowItem( href: "/insights/pulse", }, ], - moduleKey: "insights", }; } @@ -681,7 +694,6 @@ function buildSameTimeBaselineItem( todayLocalDate: string, t: Translate, ): PriorityItem | null { - if (!moduleEnabled(modules, "insights")) return null; if (!sameTime) return null; if (sameTime.band === "within") return null; // The comparison is against everything accumulated through the END of @@ -704,7 +716,6 @@ function buildSameTimeBaselineItem( href: "/insights/steps", }, ], - moduleKey: "insights", }; } @@ -735,7 +746,10 @@ function isCheckinDue(plan: DailyDigestCoachPlan, now: number): boolean { } /** - * The one coach check-in card (§2.3). Gated on the `coach` module. Capped at + * The one coach check-in card (§2.3). Served only while the `coach` AI + * capability is available: the card's "Adjust" action opens the Coach, and a + * Coach that is off, opted out or without a provider has nothing to open. + * The capability folds in the `coach` module. Capped at * ONE per day across every plan: the earliest-due check-in wins, the rest wait * for a following day. Reads existing plan state only — never a fresh AI call. * Three one-tap actions map to the plan lifecycle: keep (re-arm), adjust @@ -743,11 +757,11 @@ function isCheckinDue(plan: DailyDigestCoachPlan, now: number): boolean { */ function buildCoachCheckinItem( plans: DailyDigestCoachPlan[], - modules: DigestModuleMap, + coach: AiCapabilityState, now: Date, t: Translate, ): PriorityItem | null { - if (!moduleEnabled(modules, "coach")) return null; + if (!coach.available) return null; const nowMs = now.getTime(); const due = plans .filter((p) => isCheckinDue(p, nowMs)) @@ -820,8 +834,8 @@ const ECG_VERDICT_KEYS: Record< }; /** - * The one ECG "new recording" item (§3.5.3). Gated on the `insights` module - * (the ECG viewer's own gate). Fires ONLY when the freshest recording landed + * The one ECG "new recording" item (§3.5.3). Device data: no module gates + * it, as none gates the ECG viewer. Fires ONLY when the freshest recording landed * within the last day — a calm "a new ECG recording is ready to view" pointer * into the viewer. NON-DIAGNOSTIC: the body echoes ONLY the RECORDING DEVICE's * verdict, attributed to the device; HealthLog never interprets the trace (the @@ -834,7 +848,6 @@ function buildEcgNewRecordingItem( now: Date, t: Translate, ): PriorityItem | null { - if (!moduleEnabled(modules, "insights")) return null; if (!ecg) return null; const age = now.getTime() - ecg.recordedAt.getTime(); // Skip a future-dated row (clock skew) and anything older than the window. @@ -860,7 +873,6 @@ function buildEcgNewRecordingItem( href: "/insights#ecg", }, ], - moduleKey: "insights", }; } @@ -932,8 +944,12 @@ export function buildDailyDigest( const sleepPending = sleepEnabled && sleepExpected && !isFinal; const phase: DailyDigest["phase"] = isFinal ? "final" : "provisional"; - const topSignal = input.briefing?.signalsOfDay?.[0] ?? null; - const briefingLead = firstSentence(input.briefing?.paragraph); + // The briefing is model text: its lead and top signal are served only while + // the `briefing` capability is available. The IO seam already hides it; this + // is the builder's own backstop, so the DTO cannot carry it by accident. + const briefing = input.ai.briefing.available ? input.briefing : null; + const topSignal = briefing?.signalsOfDay?.[0] ?? null; + const briefingLead = firstSentence(briefing?.paragraph); // The day's most recently LANDED arrival drives both reaction fields. The // sample timestamp may be hours old (sleep synced after waking, an offline @@ -969,7 +985,10 @@ export function buildDailyDigest( : null; // An empty or whitespace-only line is treated as absent rather than shipped // as a blank lead — a degraded generation must fall through to the floor. - const reactionLine = newestArrival?.line?.trim() || null; + // Model text too: served only while `reactionLines` is available. + const reactionLine = input.ai.reactionLines.available + ? newestArrival?.line?.trim() || null + : null; // Priority order: a due or overdue dose is the most time-sensitive daily // action, a broken sync next, then the calm coach check-in, a preventive @@ -993,7 +1012,7 @@ export function buildDailyDigest( worthALook.push(...buildSyncIssueItems(input.syncIssues, t)); const checkin = buildCoachCheckinItem( input.coachPlans, - input.modules, + input.ai.coach, input.now, t, ); @@ -1070,5 +1089,6 @@ export function buildDailyDigest( worthALook: visible.slice(0, MAX_WORTH_A_LOOK), justIn, reactionLine, + ai: input.ai, }; } diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index a7e14b030..a7d061d47 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -17,6 +17,7 @@ import type { User } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { resolveModuleMap } from "@/lib/modules/gate"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/record"; import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; @@ -29,6 +30,7 @@ import { isArrivalKind } from "@/lib/arrivals/types"; import { buildDailyDigest, type DailyDigest, + type DailyDigestAi, type DailyDigestArrival, type DailyDigestCoachPlan, type DailyDigestEcg, @@ -305,13 +307,17 @@ function toCoachPlanCandidate(row: CoachPlanRow): DailyDigestCoachPlan { * marker itself — which is what actually drives the "just in" chip — survives * a lost sentence intact. Same discipline as `toCoachPlanCandidate` above. */ -function toDigestArrival(row: { - kind: string; - occurredAt: Date; - arrivedAt: Date; - lineEncrypted: Uint8Array | null; - generatedAt: Date | null; -}): DailyDigestArrival | null { +function toDigestArrival( + row: { + kind: string; + occurredAt: Date; + arrivedAt: Date; + lineEncrypted: Uint8Array | null; + generatedAt: Date | null; + }, + /** The `reactionLines` capability: the line is model text. */ + linesAvailable: boolean, +): DailyDigestArrival | null { // A kind this build does not know about (a row written by a newer version) // is dropped rather than widened — the DTO's kind union is closed. if (!isArrivalKind(row.kind)) return null; @@ -319,7 +325,12 @@ function toDigestArrival(row: { let line: string | null = null; // The ciphertext rides only once the generation actually COMMITTED. A row // mid-generation carries no `generatedAt`, and its line must not surface. - if (row.generatedAt !== null && row.lineEncrypted !== null) { + // Nor while the capability is unavailable: it is not even decrypted. + if ( + linesAvailable && + row.generatedAt !== null && + row.lineEncrypted !== null + ) { try { line = decryptFromBytes(row.lineEncrypted); } catch { @@ -374,6 +385,8 @@ export async function loadDailyDigest( ecgRow, arrivalRows, nextVisitRow, + coachAi, + reactionLinesAi, ] = await Promise.all([ readDashboardSnapshotCached(user, undefined, { locale: options.locale }), resolveModuleMap(user.id), @@ -468,8 +481,23 @@ export async function loadDailyDigest( practitioner: { select: { name: true } }, }, }), + aiCapabilityForRecord(user.id, "coach"), + aiCapabilityForRecord(user.id, "reactionLines"), ]); + // The snapshot read has already applied the `briefing` capability and + // publishes the state it applied; the fallback only covers an older cached + // body shape and fails closed. + const ai: DailyDigestAi = { + briefing: snapshot.briefingAi ?? { + available: false, + reason: "check_failed", + onDeviceAllowed: false, + }, + coach: coachAi, + reactionLines: reactionLinesAi, + }; + const score: DailyDigestScore | null = snapshot.healthScore ? { value: snapshot.healthScore.score, @@ -545,35 +573,30 @@ export async function loadDailyDigest( } : null; - // Only decrypt plan prose for a coach-enabled account; the builder gates on - // the module too, so a disabled coach never surfaces a check-in either way. - const coachEnabled = modules.coach !== false; - const coachPlans: DailyDigestCoachPlan[] = coachEnabled + // Only decrypt plan prose while the Coach is available; the builder gates + // the check-in on the same capability, so it never surfaces otherwise. + const coachPlans: DailyDigestCoachPlan[] = coachAi.available ? planRows.map((row) => toCoachPlanCandidate(row as CoachPlanRow)) : []; - // S12 — the calm reward layer. Only gather when the insights module (the - // narrative layer that hosts the milestone card) is on; the builder gates on - // it too, so a disabled account never surfaces one either way. - // - // S11 — the day's elevated-at-rest window, computed on demand from raw for - // TODAY only (read-swap, one bounded day-read; never persisted). Gated on the - // insights module and fault-isolated: a tension-read failure must never break - // the digest (a hot, must-not-fail path), it just omits the calm marker. + // S12 — the calm reward layer. S11 — the day's elevated-at-rest window, + // computed on demand from raw for TODAY only (read-swap, one bounded + // day-read; never persisted), fault-isolated: a tension-read failure must + // never break the digest (a hot, must-not-fail path), it just omits the calm + // marker. Both are computations over the record's own data, so neither + // depends on the `insights` module, which is the AI analysis opt-out. // // Both ride the cached, single-flight `loadDailyDigestExtrasCached` cell so // a 120 s poll of every open tab doesn't re-run the ~8.5k-row streak + // per-sample tension reads on every request — see its docblock for the // cache/invalidation contract. - const insightsEnabled = modules.insights !== false; - const { milestone, tensionWindow, sameTime } = insightsEnabled - ? await loadDailyDigestExtrasCached( - user.id, - user.timezone, - todayLocalDate, - now, - ) - : { milestone: null, tensionWindow: null, sameTime: null }; + const { milestone, tensionWindow, sameTime } = + await loadDailyDigestExtrasCached( + user.id, + user.timezone, + todayLocalDate, + now, + ); // Dismiss ledger (P — Today rail dismiss). Only the OBSERVATIONAL kinds // (milestone / ecg_new_recording / tension_window) can ever be dismissed, so @@ -615,6 +638,7 @@ export async function loadDailyDigest( const digest = buildDailyDigest( { now, + ai, todayEndExclusive, modules, enabledHeroItemKinds: @@ -637,7 +661,7 @@ export async function loadDailyDigest( todayLocalDate, dismissedItemKeys, arrivals: arrivalRows - .map(toDigestArrival) + .map((row) => toDigestArrival(row, reactionLinesAi.available)) .filter((a): a is DailyDigestArrival => a !== null), }, t, diff --git a/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts b/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts new file mode 100644 index 000000000..43fecf728 --- /dev/null +++ b/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts @@ -0,0 +1,91 @@ +/** + * The briefing capability is applied on every read of the cached snapshot, + * never baked into the cached body. + * + * The body is cached for minutes and rebuilt in the background, where no + * request exists to resolve a capability for. If the decision rode inside + * the cached body, a switch flip, an AI opt-out or a withdrawn consent would + * keep showing model text until the entry aged out. So: warm the cell while + * the briefing is available, flip the capability, and require the very next + * read (served from the same cell, no rebuild) to hide the text. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { User } from "@/generated/prisma/client"; +import { + AI_AVAILABLE, + aiUnavailable, +} from "@/__tests__/helpers/ai-capability-fixtures"; + +const briefingBody = { + tiles: {}, + briefing: { greeting: "Hi", paragraph: "Model prose.", keyFindings: [] }, + briefingMemory: null, + briefingState: "ready", + briefingUpdatedAt: "2026-07-17T06:00:00.000Z", + briefingStale: false, + briefingAi: null, +}; +const buildDashboardSnapshot = vi.fn(async () => briefingBody); +const aiCapabilityForRecord = vi.fn(); + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({ + ...(await importOriginal()), + buildDashboardSnapshot: () => buildDashboardSnapshot(), +})); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: async () => "en", +})); +vi.mock("@/lib/ai/capabilities/record", () => ({ + aiCapabilityForRecord: (...args: unknown[]) => aiCapabilityForRecord(...args), +})); + +const { readDashboardSnapshotCached } = await import("../snapshot-read"); +const { __resetAllCachesForTests } = await import("@/lib/cache/server-cache"); + +const USER = { id: "user-briefing-gate", locale: "en" } as unknown as User; + +beforeEach(() => { + __resetAllCachesForTests(); + buildDashboardSnapshot.mockClear(); + aiCapabilityForRecord.mockReset(); +}); + +describe("readDashboardSnapshotCached — briefing capability per read", () => { + it("resolves the briefing capability for the record being read", async () => { + aiCapabilityForRecord.mockResolvedValue(AI_AVAILABLE); + await readDashboardSnapshotCached(USER); + expect(aiCapabilityForRecord).toHaveBeenCalledWith( + "user-briefing-gate", + "briefing", + ); + }); + + it("hides the cached briefing on the next read once the capability goes away", async () => { + aiCapabilityForRecord.mockResolvedValue(AI_AVAILABLE); + const first = await readDashboardSnapshotCached(USER); + expect(first.body.briefing).not.toBeNull(); + expect(first.body.briefingAi).toEqual(AI_AVAILABLE); + + aiCapabilityForRecord.mockResolvedValue(aiUnavailable("consent_required")); + const second = await readDashboardSnapshotCached(USER); + + // Served from the same cell: the builder ran once. + expect(buildDashboardSnapshot).toHaveBeenCalledTimes(1); + expect(second.body.briefing).toBeNull(); + expect(second.body.briefingState).toBe("disabled"); + expect(second.body.briefingAi).toEqual(aiUnavailable("consent_required")); + }); + + it("shows it again when the capability comes back, without a rebuild", async () => { + aiCapabilityForRecord.mockResolvedValue(aiUnavailable("operator_disabled")); + const hidden = await readDashboardSnapshotCached(USER); + expect(hidden.body.briefing).toBeNull(); + + aiCapabilityForRecord.mockResolvedValue(AI_AVAILABLE); + const shown = await readDashboardSnapshotCached(USER); + expect(buildDashboardSnapshot).toHaveBeenCalledTimes(1); + expect(shown.body.briefing).not.toBeNull(); + }); +}); diff --git a/src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts b/src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts index 5184a0b05..be5862d9d 100644 --- a/src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts +++ b/src/lib/dashboard/__tests__/snapshot-read-cache-key.test.ts @@ -23,6 +23,14 @@ vi.mock("@/lib/db", () => ({ prisma: {} })); vi.mock("@/lib/dashboard/snapshot", () => ({ buildDashboardSnapshot: (...args: unknown[]) => buildDashboardSnapshot(...(args as [])), + applyBriefingCapability: (body: unknown) => body, +})); +vi.mock("@/lib/ai/capabilities/record", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: (...args: unknown[]) => diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 6c658260c..bda6fec0f 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -19,7 +19,6 @@ const isFullyCovered = vi.fn(); const readMoodDayRollups = vi.fn(); const ensureUserMoodRollupsFresh = vi.fn(); const computeBpInTargetFastPath = vi.fn(); -const getAssistantFlags = vi.fn(); const hasAnyConfiguredProvider = vi.fn(); const buildMedsTodayBlock = vi.fn(); const computeUserHealthScoreMock = vi.fn(); @@ -40,9 +39,6 @@ vi.mock("@/lib/analytics/bp-in-target-fast-path", () => ({ computeBpInTargetFastPath: (...a: unknown[]) => computeBpInTargetFastPath(...a), })); -vi.mock("@/lib/feature-flags", () => ({ - getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a), -})); // The builder imports only the credential-PRESENCE check; mock it so the // test never touches the real provider module (prisma / crypto imports). vi.mock("@/lib/ai/provider", () => ({ @@ -148,6 +144,7 @@ function moduleMap( } import { + applyBriefingCapability, buildDashboardSnapshot, WIDGET_MODULE_BY_ID, SUMMARY_TYPE_MODULE, @@ -156,6 +153,10 @@ import { import type { ModuleKey } from "@/lib/modules/gate"; import { MODULE_KEYS } from "@/lib/modules/registry"; import { SCORE_VERSION } from "@/lib/analytics/score/types"; +import { + AI_AVAILABLE, + aiUnavailable, +} from "@/__tests__/helpers/ai-capability-fixtures"; const emptySummary = { count: 0, @@ -191,7 +192,6 @@ function baseUser( gender: "MALE", glucoseUnit: "mg/dL", onboardingTourCompleted: true, - disableCoach: false, insightsCachedText: null, insightsCachedAt: null, insightsCachedLocale: null, @@ -212,13 +212,6 @@ beforeEach(() => { bmi: null, }); readMoodDayRollups.mockResolvedValue([]); - getAssistantFlags.mockResolvedValue({ - enabled: true, - coach: true, - briefing: true, - insightStatus: true, - documentAi: true, - }); fakePrisma.measurement.findMany.mockResolvedValue([]); fakePrisma.moodEntry.findMany.mockResolvedValue([]); fakePrisma.nutrientIntakeDay.findMany.mockResolvedValue([]); @@ -649,14 +642,7 @@ describe("buildDashboardSnapshot — briefingState matrix", () => { expect(hasAnyConfiguredProvider).not.toHaveBeenCalled(); }); - it("disabled — coach surface off (flag)", async () => { - getAssistantFlags.mockResolvedValue({ - enabled: false, - coach: false, - briefing: false, - insightStatus: false, - documentAi: false, - }); + it("the builder lifts the cache and leaves the capability to the read", async () => { const snap = await buildDashboardSnapshot( fakePrisma, baseUser({ @@ -666,22 +652,9 @@ describe("buildDashboardSnapshot — briefingState matrix", () => { }), }), ); - expect(snap.briefingState).toBe("disabled"); - expect(snap.briefing).toBeNull(); - }); - - it("disabled — per-user disableCoach", async () => { - const snap = await buildDashboardSnapshot( - fakePrisma, - baseUser({ - disableCoach: true, - insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify({ - dailyBriefing: { greeting: "x", paragraph: "y", keyFindings: [] }, - }), - }), - ); - expect(snap.briefingState).toBe("disabled"); + expect(snap.briefingState).toBe("ready"); + expect(snap.briefing).not.toBeNull(); + expect(snap.briefingAi).toBeNull(); }); it("preparing — malformed cached briefing (invalid shape)", async () => { @@ -697,6 +670,66 @@ describe("buildDashboardSnapshot — briefingState matrix", () => { }); }); +describe("applyBriefingCapability — the per-read briefing gate", () => { + beforeEach(() => { + probeRollupCoverage.mockResolvedValue(new Map()); + isFullyCovered.mockReturnValue(false); + }); + + async function readySnapshot() { + return buildDashboardSnapshot( + fakePrisma, + baseUser({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify({ + dailyBriefing: { greeting: "x", paragraph: "y", keyFindings: [] }, + }), + }), + ); + } + + it("keeps the briefing and publishes the state while available", async () => { + const snap = applyBriefingCapability(await readySnapshot(), AI_AVAILABLE); + expect(snap.briefing).not.toBeNull(); + expect(snap.briefingState).toBe("ready"); + expect(snap.briefingAi).toEqual(AI_AVAILABLE); + }); + + it.each([ + "operator_disabled", + "user_disabled", + "consent_required", + "not_permitted_for_record", + "check_failed", + ] as const)( + "hides the briefing, its recall and its timestamp for %s", + async (reason) => { + const snap = applyBriefingCapability( + { + ...(await readySnapshot()), + briefingMemory: { recall: "r" } as never, + }, + aiUnavailable(reason), + ); + expect(snap.briefing).toBeNull(); + expect(snap.briefingMemory).toBeNull(); + expect(snap.briefingUpdatedAt).toBeNull(); + expect(snap.briefingStale).toBe(false); + expect(snap.briefingState).toBe("disabled"); + expect(snap.briefingAi).toEqual(aiUnavailable(reason)); + }, + ); + + it("keeps the published no-provider state for a missing provider", async () => { + const snap = applyBriefingCapability( + await readySnapshot(), + aiUnavailable("no_provider"), + ); + expect(snap.briefing).toBeNull(); + expect(snap.briefingState).toBe("no-provider"); + }); +}); + describe("buildDashboardSnapshot — briefing locale tag", () => { beforeEach(() => { probeRollupCoverage.mockResolvedValue(new Map()); @@ -1212,7 +1245,7 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => { expect(snap.tiles.lastSeenByType.NUTRIENT_WATER).toBeUndefined(); }); - it("insights disabled: Daily Briefing surface goes to disabled state", async () => { + it("insights disabled: the builder no longer decides the briefing (the read applies the capability)", async () => { const snap = await buildDashboardSnapshot( fakePrisma, baseUser({ @@ -1227,8 +1260,9 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => { }), { modules: () => Promise.resolve(moduleMap({ insights: false })) }, ); - expect(snap.briefingState).toBe("disabled"); - expect(snap.briefing).toBeNull(); + // The `insights` module is the AI analysis opt-out, folded into the + // `briefing` capability that `applyBriefingCapability` applies per read. + expect(snap.briefingAi).toBeNull(); }); it("defaults to resolveModuleMap when no override is injected", async () => { diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index bcedf987d..4b3bcd10f 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -29,10 +29,12 @@ import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { dashboardSnapshotCacheKey } from "@/lib/cache/invalidate"; import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; import { + applyBriefingCapability, buildDashboardSnapshot, type DashboardSnapshot, type SnapshotUserInput, } from "@/lib/dashboard/snapshot"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/record"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import type { Locale } from "@/lib/i18n/config"; @@ -84,7 +86,6 @@ export async function readDashboardSnapshotCached( gender: user.gender, glucoseUnit: user.glucoseUnit, onboardingTourCompleted: user.onboardingTourCompleted, - disableCoach: user.disableCoach, insightsCachedText: user.insightsCachedText, insightsCachedAt: user.insightsCachedAt, insightsCachedLocale: user.insightsCachedLocale, @@ -111,16 +112,24 @@ export async function readDashboardSnapshotCached( // — the foreground request never pays the cold rebuild. Hard-evicting // writes (widget reorder) still drop the key outright, forcing a clean // miss + synchronous rebuild here. - const body = await cachedSwr( - caches.analytics as ServerCache, - // The invalidators sweep by `dashboardSnapshotCacheKey(userId)` prefix, so - // the read has to build its key from that same function. Spelling the - // string out here worked only for as long as nobody edited one side. - `${dashboardSnapshotCacheKey(user.id)}|${locale}`, - () => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }), - annotate, - SNAPSHOT_CACHE_TTL_MS, - ); + const [cached, briefingAi] = await Promise.all([ + cachedSwr( + caches.analytics as ServerCache, + // The invalidators sweep by `dashboardSnapshotCacheKey(userId)` prefix, so + // the read has to build its key from that same function. Spelling the + // string out here worked only for as long as nobody edited one side. + `${dashboardSnapshotCacheKey(user.id)}|${locale}`, + () => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }), + annotate, + SNAPSHOT_CACHE_TTL_MS, + ), + aiCapabilityForRecord(user.id, "briefing"), + ]); + + // The briefing is model text: shown only while the `briefing` capability is + // available for this record, decided on every read rather than baked into + // the cached body. + const body = applyBriefingCapability(cached, briefingAi); return { body, locale }; } diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index da3f1f631..0a50a7172 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -71,8 +71,8 @@ import { type DashboardWidgetCatalogueId, } from "@/lib/dashboard-layout"; import { dailyBriefingSchema, type DailyBriefing } from "@/lib/ai/schema"; -import { getAssistantFlags } from "@/lib/feature-flags"; import { hasAnyConfiguredProvider } from "@/lib/ai/provider"; +import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { buildMedsTodayBlock, @@ -488,6 +488,15 @@ export interface DashboardSnapshot { * stale-while-revalidate honesty the insights page uses. */ briefingStale: boolean; + /** + * Whether the daily briefing can be shown for this record, and why not. + * The builder lifts whatever the cache holds and leaves this null; the + * cached read applies the `briefing` capability on every request + * (`applyBriefingCapability`), so a switch flip, an opt-out or a withdrawn + * consent hides the text on the next read rather than when the cached body + * ages out. Optional so older cached bodies and fixtures stay valid. + */ + briefingAi?: AiCapabilityState | null; generatedAt: string; } @@ -502,7 +511,6 @@ export interface SnapshotUserInput { gender: string | null; glucoseUnit: string | null; onboardingTourCompleted: boolean; - disableCoach: boolean; insightsCachedText: string | null; insightsCachedAt: Date | null; /** @@ -877,6 +885,31 @@ function parseCachedBriefing(cachedText: string | null): DailyBriefing | null { } } +/** + * Hide the briefing (and the recall that rides it) unless the `briefing` + * capability is available for this record, and publish the state beside it. + * + * Applied on every read, after the cache, so a body cached while the + * capability was available never outlives it. `briefingState` keeps its + * published values: `no-provider` when that is the reason, `disabled` for + * every other one. + */ +export function applyBriefingCapability( + body: DashboardSnapshot, + ai: AiCapabilityState, +): DashboardSnapshot { + if (ai.available) return { ...body, briefingAi: ai }; + return { + ...body, + briefing: null, + briefingMemory: null, + briefingState: ai.reason === "no_provider" ? "no-provider" : "disabled", + briefingUpdatedAt: null, + briefingStale: false, + briefingAi: ai, + }; +} + /** * Read-only briefing lift. Parses `User.insightsCachedText`, validates * the `dailyBriefing` block, and reports a four-state. NEVER calls the @@ -894,7 +927,6 @@ function parseCachedBriefing(cachedText: string | null): DailyBriefing | null { */ async function liftBriefing( user: SnapshotUserInput, - coachEnabled: boolean, hasProvider: () => Promise, readerLocale: Locale, ): Promise<{ @@ -903,14 +935,9 @@ async function liftBriefing( briefingUpdatedAt: string | null; briefingStale: boolean; }> { - if (!coachEnabled || user.disableCoach) { - return { - briefing: null, - briefingState: "disabled", - briefingUpdatedAt: null, - briefingStale: false, - }; - } + // No capability decision here: the body is cached and rebuilt in the + // background, where there is no request to resolve one for. Whether the + // lifted text may be shown is decided per read by `applyBriefingCapability`. // The cache is one slot per user with no per-language copy. Prose written // in another language is never the reader's briefing, stale or not, so a @@ -1264,7 +1291,6 @@ export async function buildDashboardSnapshot( slimRaw, moodRaw, extrasResult, - flags, medsToday, modules, scoreRings, @@ -1291,7 +1317,6 @@ export async function buildDashboardSnapshot( ), ) : Promise.resolve(null), - time("flags", () => getAssistantFlags()), medsTodayPromise, modulesPromise, // v1.27.7 — the selected hero score rings, resolved through the @@ -1352,27 +1377,27 @@ export async function buildDashboardSnapshot( } const layout = gateLayoutByModules(storedLayout, modules, slim.summaries); - // v1.18.0 — the Daily Briefing is the dashboard's AI-narrative surface, - // so the `insights` module gates it alongside the operator briefing - // flag + per-user coach opt-out. Disabling `insights` yields the same - // `briefingState: "disabled"` empty surface the existing flags produce - // (the raw weight/BP/pulse data stays untouched — `insights` is the - // narrative layer, not the data layer). + // The Daily Briefing is the dashboard's one AI surface. The builder lifts + // it from the cache unconditionally; the `briefing` capability (operator + // switch, the AI analysis opt-out, provider, consent) is applied per read + // by `applyBriefingCapability`, after the cache. The Coach opt-out no + // longer hides it: hiding the Coach hides the Coach. // The reader's locale: the route resolves it from the request; the builder // defaults to English. It decides which cached briefing counts as the // reader's and which narrative row the briefing memory reads. const locale = options.locale ?? "en"; const briefing = await liftBriefing( user, - flags.briefing && modules.insights !== false, options.hasProvider ?? (() => hasAnyConfiguredProvider(user.id)), locale, ); // v1.21.2 (A4 / A5 / A6) — the score-card narrative (Tension Verdict + - // return-to-baseline) and the briefing recall + forward-look. Gated like the - // briefing on the `insights` module so a narrative-off account carries none. - // Both are fail-soft — a transient derived-engine read resolves to null and + // return-to-baseline) and the briefing recall + forward-look. The score + // narrative is computed from the derived engines, no model involved, so no + // AI switch and no AI opt-out gates it. The recall reads the period + // narrative, which a model may have written, so it rides the briefing and + // is hidden with it per read. Both are fail-soft — a transient derived-engine read resolves to null and // never sinks the snapshot. The narrative block rides the warm phase's // healthScore; memory rides the briefing card. const narrativeSex = binaryReferenceSex(user.gender); @@ -1384,8 +1409,7 @@ export async function buildDashboardSnapshot( // The Tension Verdict only adds signal once a health score actually rendered // (a coherent ring with no contributors is nothing to reconcile); memory only // matters once a briefing is shown. - const narrativeEnabled = - modules.insights !== false && extrasResult?.healthScore != null; + const narrativeEnabled = extrasResult?.healthScore != null; const [scoreNarrative, coachMemoryBlock] = await Promise.all([ narrativeEnabled ? time("scoreNarrative", () => @@ -1398,7 +1422,7 @@ export async function buildDashboardSnapshot( ), ).catch(() => null) : Promise.resolve(null), - briefing.briefing !== null && modules.insights !== false + briefing.briefing !== null ? time("coachMemory", () => (options.coachMemory ?? buildCoachMemoryBlock)( user.id, @@ -1469,6 +1493,8 @@ export async function buildDashboardSnapshot( briefingState: briefing.briefingState, briefingUpdatedAt: briefing.briefingUpdatedAt, briefingStale: briefing.briefingStale, + // Applied per read, after the cache (`applyBriefingCapability`). + briefingAi: null, generatedAt: now.toISOString(), }; } diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index db6ddb379..360ded7c4 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -294,11 +294,10 @@ describe("Coach per-user disableCoach invariant", () => { // FAB gate: operator master flag OR per-user opt-out redirects the // page back to `/insights` instead of painting a dead chat shell. // v1.18.0 — moved to the standalone top-level `/coach` route. - // v1.30.x — split into an RSC prefetch wrapper (`page.tsx`, which reads - // `session.user.disableCoach` to skip the nudge prefetch for an opted-out - // account) + the client leaf (`page-client.tsx`, the `useDisableCoach` - // render-path gate). Both legitimately read the per-user flag. - "src/app/coach/page.tsx", + // v1.30.x — split into an RSC prefetch wrapper (`page.tsx`) + the client + // leaf (`page-client.tsx`, the `useDisableCoach` render-path gate). The + // RSC wrapper now asks the `coach` capability, which folds the opt-out in, + // so only the client leaf reads the flag directly. "src/app/coach/page-client.tsx", // v1.21.4 — the dedicated conversation-history page mirrors the // Coach route gate: operator master flag OR per-user opt-out marks @@ -322,15 +321,9 @@ describe("Coach per-user disableCoach invariant", () => { // dedicated Coach settings entry. "src/components/settings/ai/disable-coach-card.tsx", "src/components/settings/coach-section.tsx", - // v1.7.0 W6 — the unified dashboard snapshot builder reads - // `user.disableCoach` to gate the embedded daily briefing to - // `briefingState: "disabled"`. Covered by the briefingState matrix - // in `src/lib/dashboard/__tests__/snapshot.test.ts`. The shared - // cached-read helper (used by BOTH the API route and the dashboard - // RSC prefetch) maps the session user's flag into the builder - // input; the route itself no longer touches the flag. - "src/lib/dashboard/snapshot.ts", - "src/lib/dashboard/snapshot-read.ts", + // The dashboard snapshot no longer reads the flag: hiding the Coach + // hides the Coach, and the daily briefing follows its own `briefing` + // capability, applied per read in `snapshot-read.ts`. // v1.18.0 — the module enable/disable gate delegates the `coach` // module to the SAME two-layer source of truth (`user.disableCoach` // AND the operator assistant master flag) rather than owning a diff --git a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts index fbb554f3b..8f643998b 100644 --- a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts +++ b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts @@ -114,26 +114,12 @@ const RETIRED_GATE_CALLERS = [ "src/app/api/insights/biomarker-assessment/route.ts", "src/app/api/insights/blood-pressure-status/route.ts", "src/app/api/insights/bmi-status/route.ts", - "src/app/api/insights/cards/route.ts", - "src/app/api/insights/chat/[id]/route.ts", - "src/app/api/insights/chat/messages/[id]/feedback/route.ts", - "src/app/api/insights/chat/route.ts", - "src/app/api/insights/coach-read/route.ts", - "src/app/api/insights/coach/facts/[id]/route.ts", - "src/app/api/insights/coach/facts/route.ts", - "src/app/api/insights/coach/nudge-status/route.ts", - "src/app/api/insights/coach/seeded-question/route.ts", - "src/app/api/insights/coach/seen/route.ts", - "src/app/api/insights/generate/route.ts", "src/app/api/insights/medication-compliance-status/route.ts", "src/app/api/insights/metric-status/route.ts", "src/app/api/insights/mood-status/route.ts", - "src/app/api/insights/narrative/route.ts", - "src/app/api/insights/pregenerate/route.ts", "src/app/api/insights/pulse-status/route.ts", "src/app/api/insights/weight-status/route.ts", "src/app/api/medications/extract/route.ts", - "src/app/coach/page.tsx", ]; function sourceFiles(dir: string, acc: string[] = []): string[] { diff --git a/src/lib/insights/derived/__tests__/derived-assessment-ai.test.ts b/src/lib/insights/derived/__tests__/derived-assessment-ai.test.ts index 6a1f28629..534fa4d6e 100644 --- a/src/lib/insights/derived/__tests__/derived-assessment-ai.test.ts +++ b/src/lib/insights/derived/__tests__/derived-assessment-ai.test.ts @@ -23,7 +23,10 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: (a: unknown) => findUnique(a) } }, })); -import { readFreshStatusText } from "@/lib/insights/status-cache"; +import { + readFreshStatusText, + resolveReadOnlyStatusMiss, +} from "@/lib/insights/status-cache"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { resolveDerivedAssessment, @@ -74,6 +77,7 @@ describe("resolveDerivedAssessment — per-user tz (QA F5)", () => { userId: "u-ny", derived: okDerived(), locale: "en", + aiAvailable: true, now: new Date("2026-06-20T23:30:00.000Z"), // Berlin: 06-21 already }); @@ -89,6 +93,49 @@ describe("resolveDerivedAssessment — per-user tz (QA F5)", () => { }); }); +describe("resolveDerivedAssessment — AI unavailable", () => { + it("serves the deterministic text and neither reads stored model text nor warms", async () => { + // A fresh cached AI assessment exists: it must still not be served. + vi.mocked(readFreshStatusText).mockResolvedValue({ + text: "model prose", + updatedAt: "2026-06-20T08:00:00.000Z", + } as never); + + const result = await resolveDerivedAssessment({ + metric: "RECOVERY_SCORE", + userId: "u-ny", + derived: okDerived(), + locale: "en", + aiAvailable: false, + now: new Date("2026-06-20T12:00:00.000Z"), + }); + + expect(result).not.toBeNull(); + expect(result?.source).not.toBe("ai"); + expect(result?.text).not.toBe("model prose"); + expect(readFreshStatusText).not.toHaveBeenCalled(); + expect(resolveReadOnlyStatusMiss).not.toHaveBeenCalled(); + }); + + it("serves the stored model text while AI is available", async () => { + vi.mocked(readFreshStatusText).mockResolvedValue({ + text: "model prose", + updatedAt: "2026-06-20T08:00:00.000Z", + } as never); + + const result = await resolveDerivedAssessment({ + metric: "RECOVERY_SCORE", + userId: "u-ny", + derived: okDerived(), + locale: "en", + aiAvailable: true, + now: new Date("2026-06-20T12:00:00.000Z"), + }); + + expect(result).toMatchObject({ text: "model prose", source: "ai" }); + }); +}); + describe("generateDerivedScoreAssessment — per-user tz label (QA F5)", () => { it("labels the prompt's date line with the user's actual tz, not a hardcoded Berlin", async () => { let capturedPrompt = ""; diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts index 82bb9c24b..55b28c6a7 100644 --- a/src/lib/insights/derived/derived-assessment-ai.ts +++ b/src/lib/insights/derived/derived-assessment-ai.ts @@ -165,6 +165,13 @@ export async function resolveDerivedAssessment(args: { userId: string; derived: Derived; locale: string | null | undefined; + /** + * Whether the `statusText` capability is available for this record. When + * it is not, stored model text is never served and nothing is warmed: the + * deterministic text is the whole answer. Required, so a caller cannot + * forget the question. + */ + aiAvailable: boolean; now?: Date; }): Promise { const now = args.now ?? new Date(); @@ -179,6 +186,8 @@ export async function resolveDerivedAssessment(args: { ); // Not assessable, or status !== ok → no field (the locked contract). if (!deterministic) return null; + // AI unavailable for this record: no cached model text, no warm. + if (!args.aiAvailable) return deterministic; const scope = derivedScoreScope(args.metric); const cacheAction = statusCacheAction(scope, locale); diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index ac9403751..f0f9a9dde 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -105,6 +105,7 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ })); import { runReactionLine } from "@/lib/jobs/reaction-line"; +import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; const JOB = { userId: "u1", @@ -124,6 +125,7 @@ const NOW = new Date("2026-07-16T09:00:00.000Z"); function degradedDigest() { const input: DailyDigestInput = { now: NOW, + ai: DIGEST_AI_AVAILABLE, todayEndExclusive: new Date("2026-07-17T00:00:00.000Z"), modules: {}, enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index e410d51b6..30c134d92 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -16,17 +16,6 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn(async () => true), })); -// v1.30 (G3) — the operator-level assistant surface gate `get_ecg_recordings` -// consults on top of the module gate. -vi.mock("@/lib/feature-flags", () => ({ - getAssistantFlags: vi.fn(async () => ({ - enabled: true, - coach: true, - briefing: true, - insightStatus: true, - documentAi: true, - })), -})); // v1.22.0 — `search` reads the record directly via Prisma; stub it so the // registry-wide loops never reach a DB. vi.mock("@/lib/db", () => ({ @@ -151,7 +140,6 @@ import { computeDisplayDue } from "@/lib/medications/scheduling/next-due"; import { getIntegrationStatus } from "@/lib/integrations/status"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; import { isModuleEnabled } from "@/lib/modules/gate"; -import { getAssistantFlags } from "@/lib/feature-flags"; import { getNutrients } from "@/lib/mcp/nutrients-read"; import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; import { listTargetsBySource } from "@/lib/links"; @@ -1331,14 +1319,11 @@ describe("get_intraday_pulse — v1.30 coverage review (G2)", () => { expect(result.reason).toBe("no_data"); }); - it("returns { present: false, reason: module_disabled } when the `insights` module is off", async () => { - vi.mocked(isModuleEnabled).mockResolvedValueOnce(false); - const result = (await tool("get_intraday_pulse").run(CTX, {})) as { - present: boolean; - reason?: string; - }; - expect(result).toEqual({ present: false, reason: "module_disabled" }); - expect(loadIntradayPulse).not.toHaveBeenCalled(); + it("reads the pulse whatever the AI analysis opt-out (insights module) says", async () => { + vi.mocked(isModuleEnabled).mockResolvedValue(false); + await tool("get_intraday_pulse").run(CTX, {}); + expect(loadIntradayPulse).toHaveBeenCalled(); + expect(isModuleEnabled).not.toHaveBeenCalledWith(CTX.userId, "insights"); }); }); @@ -1394,29 +1379,11 @@ describe("get_ecg_recordings — v1.30 coverage review (G3)", () => { expect(result).toEqual({ present: false, reason: "no_data" }); }); - it("returns { present: false, reason: module_disabled } when the `insights` module is off", async () => { - vi.mocked(isModuleEnabled).mockResolvedValueOnce(false); - const result = (await tool("get_ecg_recordings").run(CTX, {})) as { - present: boolean; - reason?: string; - }; - expect(result).toEqual({ present: false, reason: "module_disabled" }); - expect(prisma.ecgRecording.findMany).not.toHaveBeenCalled(); - }); - - it("returns { present: false, reason: module_disabled } when the operator-level insightStatus surface is off", async () => { - vi.mocked(getAssistantFlags).mockResolvedValueOnce({ - enabled: true, - coach: true, - briefing: true, - insightStatus: false, - documentAi: true, - } as never); - const result = (await tool("get_ecg_recordings").run(CTX, {})) as { - present: boolean; - reason?: string; - }; - expect(result).toEqual({ present: false, reason: "module_disabled" }); - expect(prisma.ecgRecording.findMany).not.toHaveBeenCalled(); + it("reads the recordings whatever the AI analysis opt-out (insights module) says", async () => { + vi.mocked(isModuleEnabled).mockResolvedValue(false); + vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never); + await tool("get_ecg_recordings").run(CTX, {}); + expect(prisma.ecgRecording.findMany).toHaveBeenCalled(); + expect(isModuleEnabled).not.toHaveBeenCalledWith(CTX.userId, "insights"); }); }); diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 2c7281413..a6b6449b8 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -28,7 +28,6 @@ import { coachScopeWindowSchema, } from "@/lib/ai/coach/types"; import { isModuleEnabled } from "@/lib/modules/gate"; -import { getAssistantFlags } from "@/lib/feature-flags"; import { resolveBaseOrigin } from "@/lib/mcp/oauth/config"; import { getCorrelation, @@ -1783,7 +1782,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ name: "get_intraday_pulse", title: "Get the intraday pulse shape", description: - "Fetch the user's own 10-minute heart-rate shape for ONE local day — 'what happened this afternoon?', 'was I tense during the meeting?'. When every confidence gate holds, also carries a single cautious elevated-at-rest ('tension') window: a DESCRIPTIVE pattern only, NEVER a stress diagnosis. Falls back to an hourly-grain shape for a day outside the dense-retention window — see the `resolution` field ('tenMin' vs 'hourly'); `tension` is only ever computed at the dense grain. Gated on the `insights` module. Returns { present: false, reason: \"module_disabled\" } when the module is off, or { present: false } when the day has no pulse data.", + "Fetch the user's own 10-minute heart-rate shape for ONE local day — 'what happened this afternoon?', 'was I tense during the meeting?'. When every confidence gate holds, also carries a single cautious elevated-at-rest ('tension') window: a DESCRIPTIVE pattern only, NEVER a stress diagnosis. Falls back to an hourly-grain shape for a day outside the dense-retention window — see the `resolution` field ('tenMin' vs 'hourly'); `tension` is only ever computed at the dense grain. Pulse is a core vital: no AI switch and no module gate apply. Returns { present: false } when the day has no pulse data.", inputShape: { date: z .string() @@ -1796,15 +1795,8 @@ export const MCP_TOOLS: McpToolDefinition[] = [ annotations: READ_ONLY_ANNOTATIONS, outputShape: getIntradayPulseOutput, async run(ctx, args) { - const enabled = await isModuleEnabled(ctx.userId, "insights"); - if (!enabled) { - annotate({ - action: { name: "mcp.tool.invoked" }, - meta: { tool: "get_intraday_pulse", present: false }, - }); - return { present: false, reason: "module_disabled" }; - } - + // Mirrors `GET /api/insights/pulse/intraday`: a computation over core + // vitals, served whatever the AI analysis opt-out says. const user = await prisma.user.findUnique({ where: { id: ctx.userId }, select: { timezone: true }, @@ -1841,23 +1833,13 @@ export const MCP_TOOLS: McpToolDefinition[] = [ name: "get_ecg_recordings", title: "Get ECG recordings", description: - "Fetch METADATA for the user's own ECG recordings — recorded time, duration, sampling rate, sample count, average heart rate, lead, and the DEVICE's own rhythm classification. NEVER reads or returns the waveform (that stays app-only). Non-diagnostic: this reflects ONLY the classification result the recording device's own certified on-device algorithm produced — HealthLog never re-classifies an ECG and never forms a diagnosis from it; `classificationSource` is always the fixed literal 'device'. Gated on the `insights` module. Returns { present: false, reason: \"module_disabled\" } when the module (or the assistant surface) is off, or { present: false } when no recordings exist.", + "Fetch METADATA for the user's own ECG recordings — recorded time, duration, sampling rate, sample count, average heart rate, lead, and the DEVICE's own rhythm classification. NEVER reads or returns the waveform (that stays app-only). Non-diagnostic: this reflects ONLY the classification result the recording device's own certified on-device algorithm produced — HealthLog never re-classifies an ECG and never forms a diagnosis from it; `classificationSource` is always the fixed literal 'device'. Device data: no AI switch and no module gate apply. Returns { present: false } when no recordings exist.", inputShape: {}, annotations: READ_ONLY_ANNOTATIONS, outputShape: getEcgRecordingsOutput, async run(ctx) { - const moduleEnabled = await isModuleEnabled(ctx.userId, "insights"); - const insightStatusEnabled = moduleEnabled - ? (await getAssistantFlags()).insightStatus - : false; - if (!moduleEnabled || !insightStatusEnabled) { - annotate({ - action: { name: "mcp.tool.invoked" }, - meta: { tool: "get_ecg_recordings", present: false }, - }); - return { present: false, reason: "module_disabled" }; - } - + // Device data, served whatever the AI switches or the AI analysis + // opt-out say, like `GET /api/insights/ecg`. const rows = await prisma.ecgRecording.findMany({ where: { userId: ctx.userId }, // Everything EXCEPT `waveformEncrypted` — mirrors `GET diff --git a/src/lib/openapi/__tests__/insights-pattern-contract-drift.test.ts b/src/lib/openapi/__tests__/insights-pattern-contract-drift.test.ts index 1ea63f97d..da995a8d8 100644 --- a/src/lib/openapi/__tests__/insights-pattern-contract-drift.test.ts +++ b/src/lib/openapi/__tests__/insights-pattern-contract-drift.test.ts @@ -2,8 +2,14 @@ import { describe, expect, it } from "vitest"; import { insightsPaths } from "../routes/insights/paths"; -describe("insight patterns module gate OpenAPI contract", () => { - it("documents module.disabled for list and dismissal updates", () => { +/** + * Correlation patterns are statistics, not model output. The `insights` + * module is the AI analysis opt-out, so it no longer gates them, and the + * contract must not promise a `module.disabled` refusal the routes never + * send. The 403 that remains is the sharing fence. + */ +describe("insight patterns OpenAPI contract", () => { + it("documents the sharing refusal and no module refusal for list and dismissal updates", () => { const listResponses = insightsPaths["/api/insights/patterns"]?.get?.responses; const updateResponses = @@ -11,7 +17,9 @@ describe("insight patterns module gate OpenAPI contract", () => { for (const responses of [listResponses, updateResponses]) { expect(Object.keys(responses ?? {})).toContain("403"); - expect(responses?.["403"]?.description).toContain("module.disabled"); + const description = responses?.["403"]?.description ?? ""; + expect(description).toContain("sharing.access.denied"); + expect(description).not.toContain("module.disabled"); } }); }); diff --git a/src/lib/openapi/routes/ai-refusal.ts b/src/lib/openapi/routes/ai-refusal.ts new file mode 100644 index 000000000..f0e39b815 --- /dev/null +++ b/src/lib/openapi/routes/ai-refusal.ts @@ -0,0 +1,37 @@ +/** + * The published wording for an AI capability refusal, shared by every + * operation that asks `requireAiCapability`. + * + * One envelope, three statuses: 403 for the operator, record, module, opt-out + * and consent reasons; 422 for a missing provider (unless the route keeps a + * typed code of its own); 503 when the capability could not be resolved. The + * mapping from reason to `meta.errorCode` is written once on `ErrorEnvelope` + * (`meta.reason`); these strings name the capability and point there. + */ +import { + AI_CAPABILITIES, + type AiCapabilityKey, +} from "@/lib/ai/capabilities/types"; + +import { errorEnvelope } from "./shared"; + +/** The 403 sentence for one capability. */ +export function aiRefusal403Description(key: AiCapabilityKey): string { + const operatorSwitch = AI_CAPABILITIES[key].operatorSwitch; + return `The \`${key}\` AI capability is unavailable for this record. \`meta.capability\` = \`${key}\` and \`meta.reason\` says which layer refused: the operator's switch (\`assistant.disabled.${operatorSwitch}\`, or \`assistant.disabled.enabled\` for the master switch), an owning module or the person's own AI opt-out (\`module.disabled\` with \`meta.module\`), a delegate inside somebody else's record (\`ai.record.notPermitted\`), or a missing consent receipt (\`consent.ai.required\`). Nothing was generated, read from a stored model answer, or enqueued.`; +} + +/** The 422 a capability answers when no provider can serve it. */ +export function aiNoProviderResponse(key: AiCapabilityKey) { + return { + description: `No configured AI provider can serve the \`${key}\` capability: \`meta.errorCode\` = \`ai.provider.none\`, \`meta.reason\` = \`no_provider\`.`, + content: { "application/json": { schema: errorEnvelope } }, + }; +} + +/** The 503 every capability answers when its inputs could not be read. */ +export const aiCheckFailedResponse = { + description: + "The AI capability could not be resolved (its inputs could not be read) and the answer failed closed: `meta.errorCode` = `ai.unavailable`, `meta.reason` = `check_failed`. Retry later; no data depends on it.", + content: { "application/json": { schema: errorEnvelope } }, +}; diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index d870e1385..61d4f0d6e 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -58,6 +58,8 @@ import { recordRefusal, stdResponses, } from "./shared"; +import { aiCheckFailedResponse, aiRefusal403Description } from "./ai-refusal"; +import { aiCapabilityState } from "./profile"; // The assigning form, deliberately: `schema.meta({...})` as a bare statement // returns a clone and registers nothing, so the component id would never @@ -738,7 +740,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "List the caller's Coach conversations", description: - 'v1.18.0 — cursor-paginated list of the caller\'s Coach conversations for the history rail, most-recent activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here. `limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at the end). v1.30.2 — optional `q` narrows the page to conversations whose TITLE contains the text (case-insensitive substring, capped at 200 chars); message bodies are encrypted at rest and are not searched. Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is narrowed from the session.', + "v1.18.0 — cursor-paginated list of the caller's Coach conversations for the history rail, most-recent activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here. `limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at the end). v1.30.2 — optional `q` narrows the page to conversations whose TITLE contains the text (case-insensitive substring, capped at 200 chars); message bodies are encrypted at rest and are not searched. Never AI-gated: stored conversations are the person's data and stay listable, readable and deletable while the Coach is unavailable for any reason. Auth via cookie or Bearer; the owner is narrowed from the session.", parameters: [ { name: "cursor", @@ -783,7 +785,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Send a Coach turn (streaming reply)", description: - "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: \\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer.", + 'v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: \\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, code, message, reason? }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Requires the `coach` AI capability, checked right after auth: an unavailable Coach is refused with the capability envelope before the stream opens, except a missing provider, which keeps its `coach.provider.none` error frame and adds `reason: "no_provider"`. Budget- and rate-limited. Auth via cookie or Bearer.', requestBody: { required: true, content: { @@ -805,14 +807,14 @@ export const coachPaths: NonNullable = { }, }, "403": { - description: - "Coach surface disabled (`errorCode: assistant.disabled.coach`) or AI consent required (`errorCode: consent.ai.required`).", + description: aiRefusal403Description("coach"), content: { "application/json": { schema: errorEnvelope } }, }, "413": { description: "Request body exceeds the 64 KB cap.", content: { "application/json": { schema: errorEnvelope } }, }, + "503": aiCheckFailedResponse, ...stdResponses, }, }, @@ -822,7 +824,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Read one Coach conversation with all messages", description: - "v1.18.0 — returns one conversation with every message decrypted server-side and ordered oldest-first, plus the rolling `summary` when one is on file. The client renders the bodies directly; no decryption key is involved. A foreign / unknown id maps to 404 (never 403) so the existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer.", + "v1.18.0 — returns one conversation with every message decrypted server-side and ordered oldest-first, plus the rolling `summary` when one is on file. The client renders the bodies directly; no decryption key is involved. A foreign / unknown id maps to 404 (never 403) so the existence channel does not leak across accounts. Never AI-gated: a stored conversation stays readable while the Coach is unavailable. Auth via cookie or Bearer.", parameters: [ { name: "id", @@ -855,7 +857,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Rename one Coach conversation", description: - "Sets the conversation's title. The only field this verb writes — the body is `.strict()`, so an unrecognised key is REFUSED rather than ignored, unlike most partial updates on this surface. The title is trimmed and must be 1..80 characters after trimming. A foreign or unknown id maps to 404 (never 403), so the existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer; the caller is always resolved as themselves, so a conversation in a shared record is not reachable here.", + "Sets the conversation's title. The only field this verb writes — the body is `.strict()`, so an unrecognised key is REFUSED rather than ignored, unlike most partial updates on this surface. The title is trimmed and must be 1..80 characters after trimming. A foreign or unknown id maps to 404 (never 403), so the existence channel does not leak across accounts. Never AI-gated. Auth via cookie or Bearer; the caller is always resolved as themselves, so a conversation in a shared record is not reachable here.", parameters: [ { name: "id", @@ -901,7 +903,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Delete one Coach conversation", description: - "v1.18.0 — hard-deletes a conversation and every message under it. A foreign / unknown id maps to 404 (never 403). Coach-gated. Auth via cookie or Bearer.", + "v1.18.0 — hard-deletes a conversation and every message under it. A foreign / unknown id maps to 404 (never 403). Never AI-gated: erasure works while the Coach is unavailable. Auth via cookie or Bearer.", parameters: [ { name: "id", @@ -1119,7 +1121,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "List the caller's durable Coach facts", description: - "v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then newest first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Coach-gated (`requireAssistantSurface(\"coach\")`). Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.", + "v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then newest first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Never AI-gated: the facts stay readable and erasable while the Coach is unavailable for any reason. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.", responses: { "200": { description: "The caller's active facts.", @@ -1136,7 +1138,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Forget all of the caller's Coach facts", description: - "v1.11.1 — bulk 'forget what you know about me': soft-deletes every active fact for the caller and returns the count cleared. Idempotent (a second call clears 0). Coach-gated. Auth via cookie or Bearer.", + "v1.11.1 — bulk 'forget what you know about me': soft-deletes every active fact for the caller and returns the count cleared. Idempotent (a second call clears 0). Never AI-gated. Auth via cookie or Bearer.", responses: { "200": { description: "All active facts cleared; the count is returned.", @@ -1158,7 +1160,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Forget one Coach fact", description: - "v1.11.1 — soft-deletes a single fact owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Coach-gated. Auth via cookie or Bearer.", + "v1.11.1 — soft-deletes a single fact owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Never AI-gated. Auth via cookie or Bearer.", responses: { "200": { description: @@ -1181,7 +1183,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Whether an unopened Coach message is waiting", description: - 'v1.18.6 (CCH-03) — server-authoritative unread signal for the Coach FAB. `unread` is true when the caller\'s newest Coach ASSISTANT message (a proactive nudge or any reply) is newer than `User.coachLastSeenAt`; a user who has never opened the Coach reads an existing nudge as unread exactly once. `nudgedAt` carries that newest assistant-message timestamp (null when none exists) so the client can key a local seen-mirror on a stable value. Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is narrowed from the session.', + "v1.18.6 (CCH-03) — server-authoritative unread signal for the Coach FAB. `unread` is true when the caller's newest Coach ASSISTANT message (a proactive nudge or any reply) is newer than `User.coachLastSeenAt`; a user who has never opened the Coach reads an existing nudge as unread exactly once. `nudgedAt` carries that newest assistant-message timestamp (null when none exists) so the client can key a local seen-mirror on a stable value. Never refused for an AI reason: while the `coach` capability is unavailable the answer is the quiet `{ unread: false, nudgedAt: null, conversationId: null }` with `ai` saying why. Auth via cookie or Bearer; the owner is narrowed from the session.", responses: { ...recordRefusal(), "200": { @@ -1207,6 +1209,9 @@ export const coachPaths: NonNullable = { .describe( "Conversation holding the newest assistant message; null when none exists. The client deep-links into it (`/coach?c=`) on the unread path.", ), + ai: aiCapabilityState.describe( + "The `coach` capability. While unavailable the three fields above are the quiet empty values.", + ), }), "CoachNudgeStatus", ), @@ -1222,7 +1227,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Mark the Coach as opened (clear the unread dot)", description: - "v1.18.6 (CCH-03) — opening the Coach (drawer or full page) stamps `User.coachLastSeenAt = now()`, so `GET /api/insights/coach/nudge-status` then reports no assistant message newer than the stamp and the FAB drops the unread dot. Server-authoritative, so the cleared state follows the caller across web + iOS rather than just the opening device. No request body — the timestamp is server-minted, so a client can never backdate the stamp to suppress a future nudge. Coach-gated; a disabled surface 403s. Auth via cookie or Bearer.", + "v1.18.6 (CCH-03) — opening the Coach (drawer or full page) stamps `User.coachLastSeenAt = now()`, so `GET /api/insights/coach/nudge-status` then reports no assistant message newer than the stamp and the FAB drops the unread dot. Server-authoritative, so the cleared state follows the caller across web + iOS rather than just the opening device. No request body — the timestamp is server-minted, so a client can never backdate the stamp to suppress a future nudge. Never AI-gated: a timestamp on the caller's own row. Auth via cookie or Bearer.", responses: { "200": { description: "The Coach was marked opened; the stamp is echoed back.", @@ -1248,7 +1253,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Read the caller's self-context", description: - "v1.16.0 — returns the structured self-context (free text plus chronic conditions, allergies, coach focus) the Coach system prompt and the daily briefing inject as a delimited, user-provided context block, alongside any pending clarifying questions. Every field is stored encrypted at rest; an undecryptable payload reads as null (fail closed). Auth via cookie or Bearer; the owner is always narrowed from the session.", + "v1.16.0 — returns the structured self-context (free text plus chronic conditions, allergies, coach focus) the Coach system prompt and the daily briefing inject as a delimited, user-provided context block, alongside any pending clarifying questions. Every field is stored encrypted at rest; an undecryptable payload reads as null (fail closed). The questions are the one AI part: stored questions cannot be told apart by origin, so while the `aboutMeQuestions` capability is unavailable an outstanding set is replaced by the deterministic completion hints for the same profile, and `ai` says why. Auth via cookie or Bearer; the owner is always narrowed from the session.", responses: { "200": { description: @@ -1266,6 +1271,9 @@ export const coachPaths: NonNullable = { updatedAt: z.iso.datetime({ offset: true }).nullable(), maxChars: z.number().int(), fieldMaxChars: z.number().int(), + ai: aiCapabilityState.describe( + "The `aboutMeQuestions` capability behind model-written questions.", + ), }), "GetCoachAboutMeResponse", ), @@ -1279,7 +1287,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Write (or clear) the caller's self-context", description: - "v1.16.0 — persists the free text (4 000-char cap) and the three structured fields (500-char cap each) encrypted at rest; caps are enforced before encryption. Structured fields are optional: omitted leaves the stored value untouched, an empty string clears it. After a non-empty save the server derives up to 3 clarifying questions (AI when a provider and the daily Coach token budget allow, deterministic completion hints otherwise) and returns them as `pendingQuestions`. Rate-limited per user. Optimistic concurrency (v1.32.21): send `baseUpdatedAt` (the `updatedAt` from a prior read) and the write 409s if the stored self-context changed since; omit it for the legacy unconditional write. The token is per-surface (`UserHealthProfile.updatedAt`), so it is not perturbed by unrelated account writes.", + "v1.16.0 — persists the free text (4 000-char cap) and the three structured fields (500-char cap each) encrypted at rest; caps are enforced before encryption. Structured fields are optional: omitted leaves the stored value untouched, an empty string clears it. After a non-empty save the server derives up to 3 clarifying questions (AI when the `aboutMeQuestions` capability is available and the daily Coach token budget allows, deterministic completion hints otherwise, with no provider call) and returns them as `pendingQuestions`. Rate-limited per user. Optimistic concurrency (v1.32.21): send `baseUpdatedAt` (the `updatedAt` from a prior read) and the write 409s if the stored self-context changed since; omit it for the legacy unconditional write. The token is per-surface (`UserHealthProfile.updatedAt`), so it is not perturbed by unrelated account writes.", requestBody: { required: true, content: { @@ -1307,6 +1315,9 @@ export const coachPaths: NonNullable = { updatedAt: z.iso.datetime({ offset: true }), maxChars: z.number().int(), fieldMaxChars: z.number().int(), + ai: aiCapabilityState.describe( + "The `aboutMeQuestions` capability the derivation ran under.", + ), }), "PutCoachAboutMeResponse", ), @@ -1918,7 +1929,7 @@ export const coachFeedbackPaths: NonNullable = { tags: ["Insights"], summary: "Rate a Coach assistant message", description: - "Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone, verbosity).", + "Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone, verbosity). Never AI-gated: a rating on a message that already exists calls no model.", requestBody: { required: true, content: { diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index e48f8674e..ad3657d37 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -4,9 +4,9 @@ * GET /api/daily/digest returns the `DailyDigest` DTO the Today surface, the * daily push, and a future iOS widget all consume. It is a pure read of * already-cached data (nightly briefing lift + dashboard-snapshot ingredients - * + two light deterministic reads) — no provider call, no warm-on-mount. Gated - * on the `insights` module. Part of the OpenAPI route table; aggregated in - * `./index.ts`. + * + two light deterministic reads) — no provider call, no warm-on-mount. No + * module or AI gate; its AI parts follow their capabilities. Part of the + * OpenAPI route table; aggregated in `./index.ts`. */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; @@ -14,14 +14,9 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { ARRIVAL_KINDS } from "@/lib/arrivals/types"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; import { dismissPriorityItemSchema } from "@/lib/validations/daily"; -import { - MODULE_DISABLED_DESCRIPTION, - dataEnvelope, - moduleDisabledResponse, - recordRefusal, - stdResponses, -} from "./shared"; +import { dataEnvelope, recordRefusal, stdResponses } from "./shared"; import { healthScoreBasis } from "./insights/schemas"; +import { aiCapabilityState } from "./profile"; const priorityItemSchema = z .object({ @@ -123,12 +118,14 @@ const dailyDigestResponse = z }) .nullable() .describe( - "Clinical-priority top signal, lifted from the cached briefing.", + "Clinical-priority top signal, lifted from the cached briefing. Null while the `briefing` capability is unavailable.", ), briefingLead: z .string() .nullable() - .describe("First sentence of the cached briefing paragraph."), + .describe( + "First sentence of the cached briefing paragraph. Null while the `briefing` capability is unavailable; `line` then falls to its deterministic floor.", + ), line: z .string() .describe( @@ -156,7 +153,16 @@ const dailyDigestResponse = z .string() .nullable() .describe( - "One-sentence generated reaction to that arrival, standing for the rest of the local day. Null whenever no line was generated (no provider, no consent, budget exhausted, or generation failed) — consumers fall back to `briefingLead` / `line`. Additive since v1.31.0.", + "One-sentence generated reaction to that arrival, standing for the rest of the local day. Null whenever no line was generated (no provider, no consent, budget exhausted, or generation failed), and whenever the `reactionLines` capability is unavailable, however recent the stored line — consumers fall back to `briefingLead` / `line`. Additive since v1.31.0.", + ), + ai: z + .object({ + briefing: aiCapabilityState, + coach: aiCapabilityState, + reactionLines: aiCapabilityState, + }) + .describe( + "The three AI capabilities the digest carries text or a card for: `briefing` (the lead and top signal), `coach` (the Coach check-in card, which opens the Coach) and `reactionLines` (the reaction line). Everything else in the digest is data.", ), }) .meta({ @@ -171,7 +177,7 @@ export const dailyPaths: NonNullable = { tags: ["Insights"], summary: "The unified daily digest", description: - "Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status and Vorsorge reads for the 'worth a look' rail. No provider call, no warm-on-mount. Requires the insights module. Cookie or Bearer auth.", + "Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status and Vorsorge reads for the 'worth a look' rail. No provider call, no warm-on-mount. No module gate and no AI gate: the digest is data, and the `insights` module is the AI analysis opt-out. Its AI parts follow their capabilities, published in `ai`. Cookie or Bearer auth.", responses: { "200": { description: "The daily digest.", @@ -181,7 +187,7 @@ export const dailyPaths: NonNullable = { }, }, }, - ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + ...recordRefusal(), ...stdResponses, }, }, @@ -191,7 +197,7 @@ export const dailyPaths: NonNullable = { tags: ["Insights"], summary: "Dismiss a Today rail item", description: - '"Dismiss / mark seen" for the Today rail\'s OBSERVATIONAL PriorityItem kinds only — milestone, ecg_new_recording, tension_window. The ACTIONABLE kinds (dose_window, sync_issue, preventive_care, coach_checkin) are structurally unreachable: itemKey must be namespaced under one of the three dismissible kinds or the request 422s before any lookup runs. Persisted server-side so the dismissal survives reload / a second device; an upsert, so a repeat dismiss of the same instance is a no-op. Requires the insights module. Cookie or Bearer auth.', + '"Dismiss / mark seen" for the Today rail\'s OBSERVATIONAL PriorityItem kinds only — milestone, ecg_new_recording, tension_window. The ACTIONABLE kinds (dose_window, sync_issue, preventive_care, coach_checkin) are structurally unreachable: itemKey must be namespaced under one of the three dismissible kinds or the request 422s before any lookup runs. Persisted server-side so the dismissal survives reload / a second device; an upsert, so a repeat dismiss of the same instance is a no-op. No module gate. Cookie or Bearer auth.', requestBody: { required: true, content: { @@ -210,7 +216,6 @@ export const dailyPaths: NonNullable = { }, }, }, - ...moduleDisabledResponse, ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index 5f1705961..e5d3aa103 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -6,16 +6,18 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; import { - AI_CONSENT_REQUIRED_DESCRIPTION, - MODULE_DISABLED_DESCRIPTION, dataEnvelope, errorEnvelope, idempotencyKeyParameter, idempotentWrite, - moduleDisabledResponse, recordRefusal, stdResponses, } from "../shared"; +import { + aiCheckFailedResponse, + aiNoProviderResponse, + aiRefusal403Description, +} from "../ai-refusal"; import { insightsCardsResponse, insightsComprehensiveResponse, @@ -80,7 +82,7 @@ export const insightsPaths: NonNullable = { tags: ["Dashboard"], summary: "Unified dashboard first-paint snapshot", description: - "Assembles every above-the-fold tile field in one round-trip from the rollup / mood / widget helpers plus a read-only lift of the pre-generated daily briefing. Two-phase: `tiles` always present, `extras` nullable on a rollup-coverage miss. No LLM is reachable from this path. Cookie or Bearer auth.", + "Assembles every above-the-fold tile field in one round-trip from the rollup / mood / widget helpers plus a read-only lift of the pre-generated daily briefing. Two-phase: `tiles` always present, `extras` nullable on a rollup-coverage miss. No LLM is reachable from this path. The briefing is model text: it, its recall (`briefingMemory`) and its timestamp are served only while the `briefing` AI capability is available for the record, decided on every read rather than cached with the body; `briefingAi` carries the state and `briefingState` reads `no-provider` or `disabled` otherwise. Every other field is data and never depends on AI. Cookie or Bearer auth.", responses: { ...recordRefusal(), "200": { @@ -101,12 +103,12 @@ export const insightsPaths: NonNullable = { "/api/insights/comprehensive": { get: { tags: ["Insights"], - summary: "Comprehensive AI insights bundle", + summary: "The Insights overview's computed read", description: - "Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side. Requires an active ConsentReceipt when the resolved provider chain egresses via the operator's server-managed key (see POST /api/consent/ai).", + "The main read of the Insights overview: summaries, classifications, correlations, medication compliance and rule alerts, all computed from the record. No provider is called and no AI switch, opt-out, provider or consent refuses it; `hasProvider` is provider presence only. The `ai` block beside the body says whether the briefing and the status notes that decorate the overview can be shown, resolved per request. Served stale-while-revalidate; `revalidating` marks a stale body. Delegable at MANAGE level over the whole record. Cookie or Bearer auth.", responses: { "200": { - description: "Insights bundle.", + description: "The overview read.", content: { "application/json": { schema: dataEnvelope( @@ -117,7 +119,7 @@ export const insightsPaths: NonNullable = { }, }, ...stdResponses, - ...recordRefusal(AI_CONSENT_REQUIRED_DESCRIPTION), + ...recordRefusal(), }, }, }, @@ -126,7 +128,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Insight cards (iOS adapter)", description: - "v1.4.31 — the native-client adapter over the same alert rule engine the web comprehensive surface consumes: measurements, BP-in-target, weight trend, pulse, and cadence-aware medication compliance are fed through `generateAlerts()` and each resulting `HealthAlert` is re-shaped to the iOS Insight card model. Deterministic — no LLM call on this path. Module-gated on `insights` and the operator `insightStatus` assistant surface. Auth via cookie or Bearer.", + 'v1.4.31 — the native-client adapter over the same alert rule engine the web comprehensive surface consumes: measurements, BP-in-target, weight trend, pulse, and cadence-aware medication compliance are fed through `generateAlerts()` and each resulting `HealthAlert` is re-shaped to the iOS Insight card model. Deterministic — no LLM call on this path, so no AI switch, opt-out or module refuses it. Every card is a rule alert: `provider` is `"rules"` and `summary` is never null. Auth via cookie or Bearer.', responses: { "200": { description: "The list of insight cards (possibly empty).", @@ -139,7 +141,7 @@ export const insightsPaths: NonNullable = { }, }, }, - ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + ...recordRefusal(), ...stdResponses, }, }, @@ -149,7 +151,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Warm all AI assessments for the calling user", description: - "v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + the seven specialised status cards + every data-bearing generic metric assessment) in the active locale, so the read-only status GETs serve cached text instantly. Returns immediately; the generation runs out of band on the worker. Empty metrics and provider-less accounts never trigger an LLM call. Short anti-spam bucket (`insights-warm:`, one warm per 3 minutes) → 429 on a tight loop. Auth via cookie or Bearer; `userId` is taken from the session, never the body.", + "v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + the seven specialised status cards + every data-bearing generic metric assessment) in the active locale, so the read-only status GETs serve cached text instantly. Returns immediately; the generation runs out of band on the worker. Covers two AI capabilities, `briefing` and `statusText`, and never refuses: with neither available it answers `queued: false` and enqueues nothing (and spends no anti-spam bucket); with one available it enqueues and the worker checks each half before it builds anything. The `ai` block says which half can run. Empty metrics and provider-less accounts never trigger an LLM call. Short anti-spam bucket (`insights-warm:`, one warm per 3 minutes) → 429 on a tight loop. Auth via cookie or Bearer; `userId` is taken from the session, never the body.", requestBody: { required: false, content: { @@ -159,7 +161,7 @@ export const insightsPaths: NonNullable = { responses: { "200": { description: - "Warm accepted and enqueued. The work runs on the worker; poll the read-only status routes for the text.", + "`queued: true`: the warm was enqueued; the work runs on the worker, so poll the read-only status routes for the text. `queued: false`: neither capability is available and nothing was enqueued; `ai` says why.", content: { "application/json": { schema: dataEnvelope( @@ -199,7 +201,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Read the cached advisor briefing (never generates)", description: - 'The read-only advisor. It serves the cached payload and NEVER calls a provider — the surfaces that mount on page load used to POST here, which blocked first paint on the whole provider chain. When the cache is stale or missing AND a provider exists it enqueues an out-of-band warm and says so in `revalidating`; the next read reflects it. User-initiated regeneration is the POST. Four honesty signals ride the payload and are the whole point of it: `hasProvider: false` means no warm pass can ever refresh what is shown, so stop polling and offer to connect one; `generationFailed` means the last attempt failed and the text is HELD rather than fresh; `generationFailureClass` says which lever to point at; and `briefingOmittedReason: "ungrounded"` means a generation SUCCEEDED and the grounding gate withheld the briefing, which is a different state from a failure. Gated on the Coach assistant surface — an operator who turns Coach off empties every advisor consumer. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record.', + 'The read-only advisor. It serves the cached payload and NEVER calls a provider — the surfaces that mount on page load used to POST here, which blocked first paint on the whole provider chain. When the cache is stale or missing AND a provider exists it enqueues an out-of-band warm and says so in `revalidating`; the next read reflects it. User-initiated regeneration is the POST. Four honesty signals ride the payload and are the whole point of it: `hasProvider: false` means no warm pass can ever refresh what is shown, so stop polling and offer to connect one; `generationFailed` means the last attempt failed and the text is HELD rather than fresh; `generationFailureClass` says which lever to point at; and `briefingOmittedReason: "ungrounded"` means a generation SUCCEEDED and the grounding gate withheld the briefing, which is a different state from a failure. A pure AI read: the payload is the model-written briefing and nothing else, so it requires the `briefing` AI capability and refuses with the capability envelope before the cache is read; stored briefing text is never served, and no warm is enqueued, while the capability is unavailable. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record.', responses: { "200": { description: @@ -214,10 +216,11 @@ export const insightsPaths: NonNullable = { }, }, "403": { - description: - "The operator has switched the Coach assistant surface off (`meta.errorCode` = `assistant.disabled.coach`).", + description: aiRefusal403Description("briefing"), content: { "application/json": { schema: errorEnvelope } }, }, + "422": aiNoProviderResponse("briefing"), + "503": aiCheckFailedResponse, "401": stdResponses["401"], "429": stdResponses["429"], }, @@ -226,7 +229,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Generate the advisor briefing inline (costs tokens)", description: - "The only path in the advisor family that calls a provider, and it does so INLINE. Two gates bound it, and they bound different things. The RATE gate is `insights:`, ten generations per hour by default and configurable by the operator through `INSIGHTS_RATE_LIMIT_PER_HOUR` (a value below 1 falls back to the default); it is checked AFTER the cache short-circuit, so a cache hit never spends a token. The BUDGET gate is the day's token ceiling, refused at reservation time before any provider is contacted — so it is not a provider failure, records no failure marker, and answers 429 with `meta.errorCode` = `insights.generate.budgetExceeded`, which is how a client tells 'you asked too often' from 'the day's spend is gone'. Two more things a caller should know before integrating: a plain 200 does NOT mean a provider ran — a fresh cache inside 24 hours answers `cached: true`, and so does an exhausted quota when the fresh cache merely lacked a briefing — and a request that TIMES OUT client-side may still complete on the server, writing the briefing to the cache or a dated failure marker, which is exactly the pair the read GET reports so a client can settle an aborted regenerate by polling rather than guessing. Gated on the Coach assistant surface, consent-gated when the resolved chain egresses through the operator's server-managed key. Cookie or Bearer auth; not delegable.", + "The only path in the advisor family that calls a provider, and it does so INLINE. Two gates bound it, and they bound different things. The RATE gate is `insights:`, ten generations per hour by default and configurable by the operator through `INSIGHTS_RATE_LIMIT_PER_HOUR` (a value below 1 falls back to the default); it is checked AFTER the cache short-circuit, so a cache hit never spends a token. The BUDGET gate is the day's token ceiling, refused at reservation time before any provider is contacted — so it is not a provider failure, records no failure marker, and answers 429 with `meta.errorCode` = `insights.generate.budgetExceeded`, which is how a client tells 'you asked too often' from 'the day's spend is gone'. Two more things a caller should know before integrating: a plain 200 does NOT mean a provider ran — a fresh cache inside 24 hours answers `cached: true`, and so does an exhausted quota when the fresh cache merely lacked a briefing — and a request that TIMES OUT client-side may still complete on the server, writing the briefing to the cache or a dated failure marker, which is exactly the pair the read GET reports so a client can settle an aborted regenerate by polling rather than guessing. Requires the `briefing` AI capability (the operator's briefing switch, the AI analysis opt-out, a provider, and consent when the chain egresses through the operator's server-managed key), checked before any read or provider work. Cookie or Bearer auth; not delegable.", requestBody: { required: false, content: { @@ -247,8 +250,7 @@ export const insightsPaths: NonNullable = { }, }, "403": { - description: - "The Coach assistant surface is off (`assistant.disabled.coach`), or the resolved chain egresses through the operator's key and no consent receipt is on file (`consent.ai.required`).", + description: aiRefusal403Description("briefing"), content: { "application/json": { schema: errorEnvelope } }, }, "409": { @@ -258,7 +260,7 @@ export const insightsPaths: NonNullable = { }, "422": { description: - "No AI provider is configured anywhere for this account, or the provider answered something that was not valid JSON (`meta.errorCode` = `ai_response_truncated` when the answer was cut off mid-stream rather than malformed), or the finished prose was withheld by the outbound safety screen (`insights.generate.outboundScreened`).", + "No AI provider can serve the `briefing` capability (`meta.errorCode` = `ai.provider.none`, `meta.reason` = `no_provider`), or the provider answered something that was not valid JSON (`meta.errorCode` = `ai_response_truncated` when the answer was cut off mid-stream rather than malformed), or the finished prose was withheld by the outbound safety screen (`insights.generate.outboundScreened`).", content: { "application/json": { schema: errorEnvelope } }, }, "429": { @@ -268,7 +270,7 @@ export const insightsPaths: NonNullable = { }, "503": { description: - "Every provider in the chain failed for a reason that is not a rate limit — auth, timeout or an upstream 5xx. A dated failure marker is written, so the read GET reports the briefing as held rather than fresh.", + "Every provider in the chain failed for a reason that is not a rate limit — auth, timeout or an upstream 5xx. A dated failure marker is written, so the read GET reports the briefing as held rather than fresh. Also: the `briefing` capability could not be resolved (`meta.errorCode` = `ai.unavailable`, `meta.reason` = `check_failed`); nothing was generated.", content: { "application/json": { schema: errorEnvelope } }, }, "401": stdResponses["401"], @@ -378,10 +380,10 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "The two-line Coach read for one metric", description: - "The own-baseline placement and the single strongest lagged association whose outcome is the named metric — the two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the baseline and correlation engines: no provider call and no cache table, so web and native decode the same DTO rather than each deriving one. Line two arrives as a finished sentence in the reader's language and is printed verbatim, so the resolved locale decides the row; a correlation failure degrades it to null and never sinks line one. Module-gated on `insights` and gated on the Coach assistant surface, because the strip is the ambient Coach presence on that page. Shared analytics-read budget. Delegable at MANAGE level over the whole record: it is computed across the record with no provider on the path. Cookie or Bearer auth.", + "The own-baseline placement and the single strongest lagged association whose outcome is the named metric — the two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the baseline and correlation engines: no provider call and no cache table, so web and native decode the same DTO rather than each deriving one. Line two arrives as a finished sentence in the reader's language and is printed verbatim, so the resolved locale decides the row; a correlation failure degrades it to null and never sinks line one. Both lines are computed, so no AI switch, opt-out or module refuses it (the name is historical). Shared analytics-read budget. Delegable at MANAGE level over the whole record: it is computed across the record with no provider on the path. Cookie or Bearer auth.", requestParams: { query: coachReadQuery }, responses: { - ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + ...recordRefusal(), "200": { description: "The strip.", content: { @@ -402,7 +404,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Today's suggested Coach opener", description: - "Resolves today's single most notable derived wellness signal into a tappable opener for the Coach's blank-chat hero, using the same confidence-gated detector the daily briefing uses. The selection happens SERVER-side and the client renders it rather than recomputing. `signal: null` is the ordinary answer and means the hero keeps its neutral greeting — never a fabricated opener. Two different causes produce that null and are indistinguishable on the wire: nothing crossed the notability gate, or the account has turned proactive suggestions off, in which case the detector does not run at all. Module-gated on `insights` and on the Coach assistant surface. Shared analytics-read budget. Cookie or Bearer auth; not delegable.", + "Resolves today's single most notable derived wellness signal into a tappable opener for the Coach's blank-chat hero, using the same confidence-gated detector the daily briefing uses. The selection happens SERVER-side and the client renders it rather than recomputing. `signal: null` is the ordinary answer and means the hero keeps its neutral greeting — never a fabricated opener. Two different causes produce that null and are indistinguishable on the wire: nothing crossed the notability gate, or the account has turned proactive suggestions off, in which case the detector does not run at all. The opener exists only to open the Coach, so it is served only while the `coach` AI capability is available; otherwise the answer is the same neutral `signal: null` and `ai` says why. Never refused for an AI reason. Shared analytics-read budget. Cookie or Bearer auth; not delegable.", responses: { "200": { description: "The opener, or the neutral null.", @@ -415,7 +417,6 @@ export const insightsPaths: NonNullable = { }, }, }, - ...moduleDisabledResponse, ...stdResponses, }, }, @@ -425,10 +426,10 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "The latest week or month retrospective", description: - "The last generated period summary for the record. Read-only by construction: it never blocks on a provider, serves the last good row immediately, and warms out of band when the row is stale or missing. A provider-less account is not left empty — the generator falls back to deterministic, non-causal prose, so even the no-key demo gets a retrospective on the next read. `revalidating` reports whether this read enqueued a warm, and is always FALSE on a delegated request: the route warms unconditionally rather than on a miss, so without that suppression a manager's first navigation here would be an egress of the owner's record the owner never asked for. Rows are keyed per locale, so `locale` selects a different stored row rather than translating one. Module-gated on `insights` and on the `insightStatus` assistant surface. Delegable at MANAGE level over the whole record. Cookie or Bearer auth.", + "The last generated period summary for the record. Read-only by construction: it never blocks on a provider, serves the last good row immediately, and warms out of band when the row is stale or missing. A provider-less account is not left empty — the generator falls back to deterministic, non-causal prose, so even the no-key demo gets a retrospective on the next read. `revalidating` reports whether this read enqueued a warm, and is always FALSE on a delegated request: the route warms unconditionally rather than on a miss, so without that suppression a manager's first navigation here would be an egress of the owner's record the owner never asked for. Rows are keyed per locale, so `locale` selects a different stored row rather than translating one. A mixed read: a narrative the deterministic composer wrote is data and always served; one a model wrote is served only while the `periodNarrative` AI capability is available, otherwise `narrative` is null and `ai` says why. While the capability is unavailable a warm is enqueued only when nothing servable exists (the worker then writes the deterministic narrative). Never refused for an AI reason. Delegable at MANAGE level over the whole record. Cookie or Bearer auth.", requestParams: { query: narrativeQuery }, responses: { - ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + ...recordRefusal(), "200": { description: "The retrospective, or `narrative: null` when none is on file yet. Always 200 — absence is a normal answer.", @@ -471,10 +472,10 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "One day's intraday heart-rate shape", description: - "The day's ten-minute mean heart-rate series plus, when every confidence gate holds, at most ONE cautious elevated-at-rest window. Computed from raw samples through the read-swap pattern rather than persisted as ten-minute rollups for all history, so a day outside the dense-retention window reads back at the coarser hourly grain instead of empty — `resolution` says which, and `tension` is always null on an hourly day because that read needs per-sample resolution. The bar for a tension window is deliberately high; under-flagging is the intended failure mode, and it is awareness, never a diagnosis. `date` is STRICT here, unlike the forgiving date filters elsewhere on the surface: anything but a `YYYY-MM-DD` literal is a 422. Module-gated on `insights`. Delegable at MANAGE level over the whole record. Cookie or Bearer auth.", + "The day's ten-minute mean heart-rate series plus, when every confidence gate holds, at most ONE cautious elevated-at-rest window. Computed from raw samples through the read-swap pattern rather than persisted as ten-minute rollups for all history, so a day outside the dense-retention window reads back at the coarser hourly grain instead of empty — `resolution` says which, and `tension` is always null on an hourly day because that read needs per-sample resolution. The bar for a tension window is deliberately high; under-flagging is the intended failure mode, and it is awareness, never a diagnosis. `date` is STRICT here, unlike the forgiving date filters elsewhere on the surface: anything but a `YYYY-MM-DD` literal is a 422. Pulse is a core vital, so no module gates it and no AI switch refuses it. Delegable at MANAGE level over the whole record. Cookie or Bearer auth.", requestParams: { query: intradayPulseQuery }, responses: { - ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + ...recordRefusal(), "200": { description: "The day's series. `Cache-Control` is the bfcache-friendly `private, max-age=0, must-revalidate` rather than `no-store`.", @@ -804,7 +805,7 @@ export const insightsPaths: NonNullable = { 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. `windowDays` widens or narrows the trailing window a metric summarises; `provenance.windowDays` always reports the window actually used and `coverage.historyDays` the days that actually backed it. Auth via cookie or Bearer.", + "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. `windowDays` widens or narrows the trailing window a metric summarises; `provenance.windowDays` always reports the window actually used and `coverage.historyDays` the days that actually backed it. Never refused for an AI reason and not gated on the `insights` module (the AI analysis opt-out). The one AI part is the warmer model-written `assessment` text, served (and warmed) only while the `statusText` capability is available; otherwise the deterministic assessment stands and `ai` says why. Auth via cookie or Bearer.", requestParams: { query: derivedMetricQuery, }, @@ -830,7 +831,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Derived wellness metrics (batched compute-once)", description: - "v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The single-metric route stays for the per-score detail pages. Auth via cookie or Bearer.", + "v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The single-metric route stays for the per-score detail pages. Deterministic assessments only, so no AI gate and no `insights` module gate; each metric keeps its own owning module. Auth via cookie or Bearer.", requestParams: { query: derivedBatchQuery, }, @@ -879,7 +880,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`; no assistant-surface gate and 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`. Device data: no module gate, no AI gate and no LLM call. Auth via cookie or Bearer.", responses: { "200": { description: "The ECG recording list (possibly empty).", @@ -889,7 +890,7 @@ export const insightsPaths: NonNullable = { }, }, }, - ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + ...recordRefusal(), ...stdResponses, }, }, @@ -897,7 +898,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`; no assistant-surface gate and 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. Device data: never refused because AI is off or the AI analysis opt-out is on; no module gate and no LLM call. Auth via cookie or Bearer.", requestBody: { required: true, content: { @@ -928,7 +929,6 @@ export const insightsPaths: NonNullable = { }, }, }, - ...moduleDisabledResponse, ...stdResponses, }, }, @@ -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`; no assistant-surface gate and 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. Device data: no module gate, no AI 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)."), @@ -963,7 +963,7 @@ export const insightsPaths: NonNullable = { "No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one).", content: { "application/json": { schema: errorEnvelope } }, }, - ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + ...recordRefusal(), ...stdResponses, }, }, @@ -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`; no assistant-surface gate and 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`. Device data: no module gate, no AI gate and no LLM call. Auth via cookie or Bearer.", responses: { "200": { description: "The device-flagged event timeline (possibly empty).", @@ -986,7 +986,7 @@ export const insightsPaths: NonNullable = { }, }, }, - ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + ...recordRefusal(), ...stdResponses, }, }, @@ -996,7 +996,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Correlation discovery (FDR-controlled)", description: - "v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer.", + "v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. Statistics, not model output: no AI switch, opt-out or `insights` module gate refuses them. Auth via cookie or Bearer.", responses: { ...recordRefusal(), "200": { @@ -1019,7 +1019,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "List current correlation patterns", description: - "Returns the authenticated account's currently accepted persisted correlation identities, evidence, and dismissal timestamps.", + "Returns the authenticated account's currently accepted persisted correlation identities, evidence, and dismissal timestamps. Statistics, not model output, so no AI or `insights` module gate applies.", responses: { "200": { description: "Current persisted correlation patterns.", @@ -1032,7 +1032,7 @@ export const insightsPaths: NonNullable = { }, }, }, - ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + ...recordRefusal(), ...stdResponses, }, }, @@ -1042,7 +1042,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Update correlation pattern dismissal", description: - "Dismisses or restores one current account-owned pattern. A dismissal remains effective until the accepted evidence changes materially.", + "Dismisses or restores one current account-owned pattern. A dismissal remains effective until the accepted evidence changes materially. No AI or `insights` module gate applies.", requestParams: { path: z.object({ id: z.string() }) }, requestBody: { required: true, @@ -1066,7 +1066,7 @@ export const insightsPaths: NonNullable = { description: "No current pattern with that id for this account.", content: { "application/json": { schema: errorEnvelope } }, }, - ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + ...recordRefusal(), ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 2fa522b70..ebbc08c88 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -20,6 +20,7 @@ import { import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; import { PROVIDER_CHAIN_TYPES } from "@/lib/ai/provider-chain"; import { PERIOD_DAYS } from "@/lib/insights/narrative/period-narrative"; +import { aiCapabilityState } from "../profile"; /** The retrospective periods the narrative route accepts, read off the engine. */ const NARRATIVE_PERIOD_VALUES = Object.keys(PERIOD_DAYS); @@ -42,11 +43,25 @@ export const insightsComprehensiveResponse = z .describe( "True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.", ), + hasProvider: z + .boolean() + .optional() + .describe( + "Whether a configured AI provider exists for the record: provider presence and nothing else. False never means the operator switched AI off or consent is missing; `ai` says that.", + ), + ai: z + .object({ + briefing: aiCapabilityState, + statusText: aiCapabilityState, + }) + .describe( + "Whether the AI parts that decorate the overview can be shown: the daily briefing and the per-metric status notes. Resolved per request, never cached with the body. The overview itself is computed data and loads whatever these say.", + ), }) .meta({ id: "InsightsComprehensiveResponse", description: - "AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface needs day-level grounding.", + "The Insights overview's computed read. No provider is called on this path; every figure is computed from the record.", }); // v1.8.7.1 — generic per-HealthKit-metric assessment. The query enum is @@ -391,7 +406,12 @@ export const derivedMetricResponse = z assessment: derivedAssessment .nullable() .describe( - "v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less accounts + the demo always get one) and warmer AI prose overrides it once cached.", + "v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less accounts + the demo always get one) and warmer AI prose overrides it once cached, but only while the `statusText` AI capability is available; otherwise the deterministic text is served and nothing is warmed.", + ), + ai: aiCapabilityState + .optional() + .describe( + "The `statusText` capability behind the model-written assessment. Present on the single-metric route; absent on the batch route, whose assessments are always the deterministic text.", ), }) .meta({ @@ -946,15 +966,25 @@ export const insightsPregenerateResponse = z .object({ queued: z .boolean() - .describe("True when the full warm was accepted and enqueued."), + .describe( + "True when the warm was accepted and enqueued; false when neither AI capability it covers is available, in which case nothing was enqueued.", + ), locale: z - .enum(["de", "en"]) + .string() .describe("The locale the assessments are being warmed in."), + ai: z + .object({ + briefing: aiCapabilityState, + statusText: aiCapabilityState, + }) + .describe( + "The two capabilities the warm covers. The worker skips the half whose capability is unavailable.", + ), }) .meta({ id: "InsightsPregenerateResponse", description: - "Acknowledgement that a full assessment warm was enqueued for the calling user. The generation runs out of band; the text lands in the read-only status routes.", + "Acknowledgement of a full assessment warm for the calling user, or of why none was enqueued. The generation runs out of band; the text lands in the read-only status routes.", }); // v1.7.0 — unified dashboard first-paint snapshot. One GET that @@ -1389,12 +1419,22 @@ export const dashboardSnapshotResponse = z .describe( "The briefing recall + forward-look: `recall` is the prior period's narrative headline, `forward` points ahead to the most salient trend drift (or a calm 'holding steady' line). Both are already-localised prose the client renders verbatim. Null when no prior narrative is on file or no briefing is shown. Optional so older cached snapshots without the field stay valid.", ), - briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]), + briefingState: z + .enum(["ready", "preparing", "disabled", "no-provider"]) + .describe( + "`disabled` whenever the `briefing` AI capability is unavailable for a reason other than a missing provider (then `briefingAi` names it); `no-provider` when no provider can serve it.", + ), briefingUpdatedAt: z.string().nullable(), briefingStale: z .boolean() .describe( - "True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`) or impossible (`no-provider`). Render the stale content with its `briefingUpdatedAt` timestamp instead of a blank tile.", + "True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`). Render the stale content with its `briefingUpdatedAt` timestamp instead of a blank tile.", + ), + briefingAi: aiCapabilityState + .nullable() + .optional() + .describe( + "The `briefing` AI capability for this record, applied on every read after the cache: while it is unavailable `briefing`, `briefingMemory` and `briefingUpdatedAt` are null, whatever the cache holds. Nullable and optional so older cached bodies stay valid.", ), generatedAt: z.string(), }) @@ -1406,8 +1446,8 @@ export const dashboardSnapshotResponse = z // v1.4.31 — the iOS "cards" adapter over the same alert rule engine the // web comprehensive surface consumes. Each card is one `HealthAlert` -// re-shaped to the iOS Insight model. Module-gated on `insights` and the -// operator `insightStatus` assistant surface. +// re-shaped to the iOS Insight model. Rule output, not model output, so no +// AI or module gate applies. const insightCard = z .object({ id: z.string().describe("Stable per-card id (e.g. `alert-1`)."), @@ -1437,7 +1477,7 @@ const insightCard = z provider: z .string() .describe( - "Lower-cased AI provider label for the account (e.g. `claude`).", + "Who wrote the card: always `rules`, the threshold engine. It used to echo the account's AI provider although no model wrote any card.", ), }) .meta({ @@ -2657,8 +2697,11 @@ export const coachSeededQuestionResponse = z }) .nullable() .describe( - "Null whenever nothing crossed the detector's confidence and notability gate, AND whenever the account has turned proactive suggestions off — the two are indistinguishable on the wire, and both mean the hero keeps its neutral greeting rather than showing a fabricated opener.", + "Null whenever nothing crossed the detector's confidence and notability gate, AND whenever the account has turned proactive suggestions off, AND whenever the `coach` capability is unavailable (then `ai` says why) — all mean the hero keeps its neutral greeting rather than showing a fabricated opener.", ), + ai: aiCapabilityState.describe( + "The `coach` capability the opener exists for.", + ), }) .meta({ id: "CoachSeededQuestion", @@ -2697,12 +2740,17 @@ export const narrativeResponse = z updatedAt: z.iso.datetime({ offset: true }), }) .nullable() - .describe("Null when no row exists for this period and locale yet."), + .describe( + "Null when no row exists for this period and locale yet, and when the stored row is model-written while the `periodNarrative` capability is unavailable (a deterministic row is always served).", + ), revalidating: z .boolean() .describe( - "True when this read enqueued a warm. Always FALSE on a delegated request even when the row is stale: this route warms unconditionally rather than on a miss, so a manager's first navigation here would otherwise be an egress of the owner's record the owner never asked for. The stale row is still served; only the warm is withheld.", + "True when this read enqueued a warm. Always FALSE on a delegated request even when the row is stale: this route warms unconditionally rather than on a miss, so a manager's first navigation here would otherwise be an egress of the owner's record the owner never asked for. The stale row is still served; only the warm is withheld. Also false while `periodNarrative` is unavailable and a servable row exists.", ), + ai: aiCapabilityState.describe( + "The `periodNarrative` capability behind model-written narratives.", + ), }) .meta({ id: "NarrativeResponse", diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index f4a6cae45..47f1179ad 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -609,7 +609,7 @@ const aiProviderResponse = z aiAvailable: z .boolean() .describe( - "True when ANY provider can serve this user — including the operator's server-managed key when the user set none. iOS keys Coach visibility off this.", + "True when ANY provider can serve this user — including the operator's server-managed key when the user set none — AND the operator's master AI switch is on. With every AI capability switched off there is no AI available, whatever is configured. iOS keys Coach visibility off this. The per-capability answer is `ai` on `/api/auth/me`.", ), managedBy: z .enum(["user", "local", "server"]) @@ -665,7 +665,7 @@ const aiProviderResponse = z serverProviderOffer: z .boolean() .describe( - "Whether the shared provider may honestly be offered to this caller in one tap. True only when all of: the operator's provider is the one that would serve them (`managedBy: \"server\"`), `serverProviderHealth` is `healthy`, the operator's assistant master + coach flags are on, the acting record holds its own credentials (a managed profile never does), the caller holds no receipt yet, and the instance is not running in demo mode (where the grant the tap makes is refused at the edge). Anything unknown makes it false.", + 'Whether the shared provider may honestly be offered to this caller in one tap. True only when all of: the operator\'s provider is the one that would serve them (`managedBy: "server"`), `serverProviderHealth` is `healthy`, the operator keeps at least one capability the consent would unlock switched on (the Coach, the daily briefing, or reading documents; the master switch off turns all three off), the acting record holds its own credentials (a managed profile never does), the caller holds no receipt yet, and the instance is not running in demo mode (where the grant the tap makes is refused at the edge). Anything unknown makes it false.', ), serverProviderConsent: z .boolean() diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 1cac9cb47..82b608d1c 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -532,23 +532,12 @@ export function recordRefusal(...alsoRefusesFor: string[]): RefusalResponse { return response; } -// ── AI-consent precondition (v1.16.13) ─────────────────────────────── -// The server-managed AI-egress gate requires an active ConsentReceipt -// (`ai_full`, or the surface-specific `ai_insights_only` / `ai_coach`) -// before any health snapshot leaves for the operator's global LLM key. -// Interactive routes surface this as a 403 with -// `meta.errorCode = "consent.ai.required"`; clients render an inline -// grant-consent notice and call POST /api/consent/ai (or, on web, POST -// /api/consent/ai/web) to mint the receipt. BYOK / local / ChatGPT-OAuth -// chains are the user's own egress and never trip this gate. -// -// There is no `consentRequiredResponse` beside this description any more, and -// the absence is deliberate. Every route that could answer it is also a route -// the sharing fence can refuse, and OpenAPI allows one response per status, so -// the two 403s share a single description built by `recordRefusal(...)`. A -// second exported 403 body would be a second way to write the same operation. -export const AI_CONSENT_REQUIRED_DESCRIPTION = - "AI consent required: no active ConsentReceipt for the server-managed provider. `meta.errorCode` = `consent.ai.required`. Mint a receipt via POST /api/consent/ai before retrying."; +// ── AI-consent precondition ────────────────────────────────────────── +// A missing consent receipt is one reason an AI capability is unavailable: +// the refusal is the capability envelope (`meta.errorCode` = +// `consent.ai.required`, `meta.reason` = `consent_required`), described once on +// `ErrorEnvelope` and per capability in `./ai-refusal.ts`. Data routes never +// answer it. // ── Module-disabled gate (v1.18.0) ─────────────────────────────────── // Every module-scoped route runs `requireModuleEnabled(userId, key)`, diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index ceec2dcbb..9420d7fbd 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -19,6 +19,7 @@ import { recordRefusal, stdResponses, } from "./shared"; +import { aiCapabilityState } from "./profile"; // v1.4.25 W16b — typed workout batch ingest response envelope. Mirrors // the measurements batch shape but reports the `workouts` count rather @@ -239,6 +240,9 @@ const workoutDetailResponse = z // feature was live, over the duration floor, under the day's cap and with // a provider reachable. Reading this endpoint never generates one. aiInsight: workoutActivityInsight.nullable(), + ai: aiCapabilityState.describe( + "The `workoutInsights` AI capability. While it is unavailable `aiInsight` is null and the stored paragraph is not even read.", + ), canonicalId: z.string(), /** * The canonical session before this one in the same sport, or null when @@ -284,7 +288,7 @@ export const workoutPaths: NonNullable = { tags: ["Measurements"], summary: "Workout detail (v1.4.32)", description: - "Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`) are computed server-side. `aiInsight` is a pure read of a per-workout paragraph written by a background job when the workout landed; it is null for every workout that predates the feature or was re-synced, and reading this endpoint never generates one. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and `route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32 contract. Cross-user rows surface as 404 (existence channel sealed).", + "Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`) are computed server-side. `aiInsight` is a pure read of a per-workout paragraph written by a background job when the workout landed; it is null for every workout that predates the feature or was re-synced, and while the `workoutInsights` AI capability is unavailable (`ai` says why), and reading this endpoint never generates one. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and `route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32 contract. Cross-user rows surface as 404 (existence channel sealed).", requestParams: { path: z.object({ id: z.string() }), query: z.object({