diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f1e6ce2fe..cd18fe97e 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.' @@ -8466,13 +8466,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 @@ -8511,16 +8511,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: @@ -8537,8 +8540,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: @@ -8551,15 +8557,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 @@ -8590,7 +8603,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 @@ -8632,8 +8645,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 @@ -8890,10 +8903,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. @@ -8909,7 +8923,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. @@ -8927,7 +8941,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`)." @@ -8943,12 +8957,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. @@ -8965,11 +8980,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. @@ -8985,10 +9000,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. @@ -9006,11 +9024,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: @@ -9853,9 +9871,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: @@ -9959,7 +9977,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. @@ -9975,37 +9997,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: @@ -10013,11 +10020,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). @@ -10026,7 +10033,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -10034,12 +10041,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: @@ -10048,7 +10058,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: @@ -10124,9 +10135,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 @@ -10137,12 +10150,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 @@ -10158,8 +10184,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: @@ -10176,8 +10203,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: @@ -10191,10 +10222,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: @@ -10209,8 +10240,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: @@ -10317,10 +10350,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 @@ -10337,7 +10370,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/seeded-question: @@ -10350,8 +10383,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. @@ -10360,7 +10395,6 @@ paths: schema: $ref: "#/components/schemas/CoachSeededQuestionEnvelope" "401": *a1 - "403": *a16 "422": *a4 "429": *a5 /api/insights/narrative: @@ -10374,9 +10408,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 @@ -10406,7 +10443,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -10457,8 +10494,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 @@ -10478,7 +10516,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/insights/settings: @@ -10602,8 +10640,11 @@ paths: - Insights summary: Blood-pressure assessment description: "Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache - miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via - cookie or Bearer." + miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note + is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the + answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the + reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the + AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: locale @@ -10631,8 +10672,11 @@ paths: - Insights summary: Pulse assessment description: "Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss - warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie - or Bearer." + warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is + model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the + answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the + reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the + AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: locale @@ -10660,8 +10704,11 @@ paths: - Insights summary: Weight assessment description: "Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a - generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or - Bearer." + generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: + it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 + with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is + read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis + opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: locale @@ -10689,8 +10736,11 @@ paths: - Insights summary: BMI assessment description: "Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a - generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or - Bearer." + generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: + it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 + with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is + read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis + opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: locale @@ -10718,8 +10768,11 @@ paths: - Insights summary: Mood assessment description: "Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a - generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or - Bearer." + generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: + it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 + with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is + read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis + opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: locale @@ -10748,7 +10801,11 @@ paths: summary: Medication-compliance assessment description: "Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good - envelope meanwhile (stale-while-revalidate). Auth via cookie or Bearer." + envelope meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the + `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, + `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never + refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. + Auth via cookie or Bearer." parameters: - in: query name: locale @@ -10779,7 +10836,10 @@ paths: sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed - registry enum. Auth via cookie or Bearer." + registry enum. The note is model text: it is served, and warmed, only while the `statusText` AI capability is + available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence + and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the + `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: metric @@ -10871,7 +10931,10 @@ paths: Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` - without an LLM call. Auth via cookie or Bearer." + without an LLM call. The note is model text: it is served, and warmed, only while the `statusText` AI capability + is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider + presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, + and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: biomarkerId @@ -10916,7 +10979,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 @@ -11022,7 +11088,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 @@ -11082,8 +11149,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). @@ -11092,7 +11158,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 post: @@ -11107,8 +11173,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: @@ -11130,7 +11196,6 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 - "403": *a16 "422": *a4 "429": *a5 /api/insights/ecg/{id}: @@ -11138,13 +11203,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 @@ -11170,7 +11235,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). @@ -11192,8 +11257,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). @@ -11202,7 +11267,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/insights/correlations: @@ -11210,11 +11275,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. @@ -11232,7 +11298,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. @@ -11241,7 +11307,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -11250,7 +11316,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 @@ -11271,7 +11337,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: @@ -11357,7 +11423,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a16 + "403": *a17 "422": *a4 "429": *a5 /api/mood/insights: @@ -11379,7 +11445,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a16 + "403": *a17 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -11512,7 +11578,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a17 + "404": &a18 description: Not found / not owned by the caller. content: application/json: @@ -11547,7 +11613,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a17 + "404": *a18 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -11583,7 +11649,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a17 + "404": *a18 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -11636,7 +11702,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a17 + "404": *a18 "422": *a4 "429": *a5 delete: @@ -11667,7 +11733,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a17 + "404": *a18 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -11832,7 +11898,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. @@ -11864,7 +11930,7 @@ paths: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a9 - "404": *a17 + "404": *a18 "422": *a4 "429": *a5 put: @@ -11899,8 +11965,8 @@ paths: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a9 - "404": *a17 - "409": *a18 + "404": *a18 + "409": *a19 "422": *a4 "429": *a5 delete: @@ -11926,7 +11992,7 @@ paths: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 "403": *a9 - "404": *a17 + "404": *a18 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -12076,7 +12142,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a19 + "400": &a20 description: Body is not parseable JSON. content: application/json: @@ -12089,13 +12155,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: @@ -12155,7 +12221,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 @@ -12170,8 +12236,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 @@ -12225,10 +12291,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` = @@ -12785,7 +12851,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`, `ai_extraction`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." @@ -12845,7 +12911,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a22 + "400": *a23 "401": *a1 "422": *a4 "429": *a5 @@ -13131,7 +13197,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 "403": *a9 - "404": &a23 + "404": &a24 description: Measurement reminder not found / not owned. content: application/json: @@ -13166,7 +13232,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 delete: @@ -13189,7 +13255,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -13214,7 +13280,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -13242,7 +13308,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -13272,7 +13338,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -13306,7 +13372,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -13347,7 +13413,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/labs: @@ -13471,7 +13537,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: @@ -13506,7 +13572,7 @@ paths: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 "403": *a9 - "404": *a24 + "404": *a25 "422": *a4 "429": *a5 delete: @@ -13529,7 +13595,7 @@ paths: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 "403": *a9 - "404": *a24 + "404": *a25 "422": *a4 "429": *a5 /api/labs/restore: @@ -13712,7 +13778,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: @@ -13741,7 +13807,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: @@ -13776,8 +13842,8 @@ paths: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 "403": *a9 - "404": *a25 - "409": *a26 + "404": *a26 + "409": *a27 "422": *a4 "429": *a5 delete: @@ -13800,7 +13866,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a25 + "404": *a26 "422": *a4 "429": *a5 /api/encounters: @@ -13939,7 +14005,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: @@ -13973,7 +14039,7 @@ paths: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -13998,7 +14064,7 @@ paths: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -14024,7 +14090,7 @@ paths: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -14054,7 +14120,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -14083,7 +14149,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 /api/practitioners: @@ -14162,7 +14228,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: @@ -14196,7 +14262,7 @@ paths: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 "403": *a9 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -14220,7 +14286,7 @@ paths: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 "403": *a9 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -14246,7 +14312,7 @@ paths: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 "403": *a9 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 /api/vaccinations: @@ -14341,7 +14407,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: @@ -14376,7 +14442,7 @@ paths: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14401,7 +14467,7 @@ paths: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -14426,7 +14492,7 @@ paths: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -14492,7 +14558,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -14550,7 +14616,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14579,7 +14645,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/illness/episodes: @@ -14663,7 +14729,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: @@ -14698,7 +14764,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -14722,7 +14788,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -14750,7 +14816,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "409": *a7 "422": *a4 "429": *a5 @@ -14783,7 +14849,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -14834,7 +14900,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 post: @@ -14871,7 +14937,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -14898,7 +14964,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/illness/insights: @@ -15114,7 +15180,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: @@ -15148,7 +15214,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a9 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 delete: @@ -15172,7 +15238,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a9 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 /api/family-history: @@ -15246,7 +15312,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: @@ -15280,7 +15346,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a9 - "404": *a32 + "404": *a33 "422": *a4 "429": *a5 delete: @@ -15304,7 +15370,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a9 - "404": *a32 + "404": *a33 "422": *a4 "429": *a5 /api/documents/inbound: @@ -15790,7 +15856,7 @@ paths: schema: $ref: "#/components/schemas/InboundExtractEnvelope" "401": *a1 - "403": &a33 + "403": &a34 description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master @@ -15814,7 +15880,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a34 + "503": &a35 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -15876,10 +15942,10 @@ paths: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" "401": *a1 - "403": *a33 + "403": *a34 "422": *a4 "429": *a5 - "503": *a34 + "503": *a35 /api/documents/inbound/{id}/summary: post: tags: @@ -15958,10 +16024,10 @@ paths: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" "401": *a1 - "403": *a33 + "403": *a34 "422": *a4 "429": *a5 - "503": *a34 + "503": *a35 /api/documents/inbound/{id}/index: post: tags: @@ -16018,10 +16084,10 @@ paths: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" "401": *a1 - "403": *a33 + "403": *a34 "422": *a4 "429": *a5 - "503": *a34 + "503": *a35 /api/documents/inbound/{id}/chat: post: tags: @@ -16061,10 +16127,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a33 + "403": *a34 "422": *a4 "429": *a5 - "503": *a34 + "503": *a35 get: tags: - Documents @@ -16127,10 +16193,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a33 + "403": *a34 "422": *a4 "429": *a5 - "503": *a34 + "503": *a35 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -16237,7 +16303,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a36 + "409": &a37 description: A custom metric with this name already exists for the caller. content: application/json: @@ -16266,7 +16332,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a9 - "404": &a35 + "404": &a36 description: Custom metric not found (or owned by another user). content: application/json: @@ -16300,8 +16366,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a35 - "409": *a36 + "404": *a36 + "409": *a37 "422": *a4 "429": *a5 delete: @@ -16324,7 +16390,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a35 + "404": *a36 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -16368,7 +16434,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a9 - "404": *a35 + "404": *a36 "422": *a4 "429": *a5 post: @@ -16397,7 +16463,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a35 + "404": *a36 "409": *a7 "422": *a4 "429": *a10 @@ -16433,7 +16499,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a37 + "404": &a38 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -16466,7 +16532,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a37 + "404": *a38 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -16875,7 +16941,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a38 + "403": &a39 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 @@ -16930,7 +16996,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a38 + "403": *a39 "422": *a4 "429": *a5 /api/nutrients/water: @@ -16959,7 +17025,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a38 + "403": *a39 "409": *a7 "422": *a4 "429": *a5 @@ -17461,26 +17527,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a39 + "400": &a40 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a40 + "413": &a41 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a41 + "415": &a42 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a42 + "422": &a43 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: @@ -17666,11 +17732,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a39 + "400": *a40 "401": *a1 - "413": *a40 - "415": *a41 - "422": *a42 + "413": *a41 + "415": *a42 + "422": *a43 "429": *a5 delete: tags: @@ -17813,11 +17879,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a39 + "400": *a40 "401": *a1 - "413": *a40 - "415": *a41 - "422": *a42 + "413": *a41 + "415": *a42 + "422": *a43 "429": *a5 delete: tags: @@ -17970,11 +18036,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a39 + "400": *a40 "401": *a1 - "413": *a40 - "415": *a41 - "422": *a42 + "413": *a41 + "415": *a42 + "422": *a43 "429": *a5 delete: tags: @@ -18050,21 +18116,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a43 + "422": &a44 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": &a44 + "429": &a45 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": &a45 + "502": &a46 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` @@ -18109,11 +18175,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a39 + "400": *a40 "401": *a1 - "413": *a40 - "415": *a41 - "422": *a42 + "413": *a41 + "415": *a42 + "422": *a43 "429": *a5 delete: tags: @@ -18189,9 +18255,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a43 - "429": *a44 - "502": *a45 + "422": *a44 + "429": *a45 + "502": *a46 /api/strava/credentials: get: tags: @@ -18227,11 +18293,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a39 + "400": *a40 "401": *a1 - "413": *a40 - "415": *a41 - "422": *a42 + "413": *a41 + "415": *a42 + "422": *a43 "429": *a5 delete: tags: @@ -18309,9 +18375,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a43 - "429": *a44 - "502": *a45 + "422": *a44 + "429": *a45 + "502": *a46 /api/integrations/status: get: tags: @@ -18347,7 +18413,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a44 + "429": *a45 /api/integrations/fitbit/test: post: tags: @@ -18365,9 +18431,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a43 - "429": *a44 - "502": *a45 + "422": *a44 + "429": *a45 + "502": *a46 /api/integrations/google-health/resume: post: tags: @@ -18384,7 +18450,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a44 + "429": *a45 /api/integrations/google-health/test: post: tags: @@ -18417,9 +18483,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a43 - "429": *a44 - "502": *a45 + "422": *a44 + "429": *a45 + "502": *a46 /api/integrations/whoop/resume: post: tags: @@ -18436,7 +18502,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a44 + "429": *a45 /api/integrations/whoop/test: post: tags: @@ -18453,9 +18519,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a43 - "429": *a44 - "502": *a45 + "422": *a44 + "429": *a45 + "502": *a46 /api/integrations/withings/resume: post: tags: @@ -18473,7 +18539,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a44 + "429": *a45 /api/integrations/withings/test: post: tags: @@ -18491,8 +18557,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a43 - "429": *a44 + "422": *a44 + "429": *a45 "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: @@ -18510,8 +18576,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. @@ -18520,7 +18587,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -18532,8 +18599,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: @@ -18556,7 +18622,6 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 - "403": *a16 "422": *a4 "429": *a5 /api/dashboard/chart-overlay-prefs: @@ -18729,7 +18794,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a46 + "403": &a47 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 @@ -18754,7 +18819,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a46 + "403": *a47 "422": *a4 "429": *a5 post: @@ -18849,7 +18914,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a46 + "403": *a47 "404": description: No such invitation for this account. content: @@ -18893,7 +18958,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a46 + "403": *a47 "404": description: No such grant on this account's record. content: @@ -18934,7 +18999,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a46 + "403": *a47 "404": description: No such grant held by this account. content: @@ -19316,7 +19381,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a46 + "403": *a47 "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 — @@ -19646,7 +19711,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a47 + "403": &a48 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. @@ -19679,7 +19744,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a47 + "403": *a48 "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. @@ -19706,7 +19771,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a47 + "403": *a48 "422": *a4 "429": *a5 post: @@ -19736,7 +19801,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a47 + "403": *a48 "413": description: Body exceeds 16 KiB. content: @@ -19779,7 +19844,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a47 + "403": *a48 "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. @@ -19837,7 +19902,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: - - &a48 + - &a49 name: family in: path required: true @@ -19881,7 +19946,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: - - *a48 + - *a49 requestBody: required: true content: @@ -20033,7 +20098,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a16 + "403": *a17 "422": *a4 "429": *a5 /api/environment/home: @@ -20070,7 +20135,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a16 + "403": *a17 "413": description: Body exceeds 4 KiB. content: @@ -20124,7 +20189,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`. @@ -20183,7 +20248,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`." @@ -20222,7 +20287,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a16 + "403": *a17 "413": description: Body exceeds 4 KiB. content: @@ -20275,7 +20340,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. @@ -20416,13 +20481,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a49 + "403": &a50 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": &a50 + "404": &a51 description: No stored copy with this id. content: application/json: @@ -20481,8 +20546,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a49 - "404": *a50 + "403": *a50 + "404": *a51 "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. @@ -40607,6 +40672,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: @@ -40638,6 +40707,7 @@ components: - splits - sportContext - aiInsight + - ai - canonicalId - previousWorkoutId additionalProperties: false @@ -40824,6 +40894,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: @@ -44720,7 +44835,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 @@ -44789,9 +44906,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 @@ -48708,10 +48827,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" @@ -48804,6 +48927,9 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + ai: + description: The `aboutMeQuestions` capability behind model-written questions. + $ref: "#/components/schemas/AiCapabilityState" required: - aboutMe - conditions @@ -48814,6 +48940,7 @@ components: - updatedAt - maxChars - fieldMaxChars + - ai additionalProperties: false error: type: "null" @@ -48878,6 +49005,9 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + ai: + description: The `aboutMeQuestions` capability the derivation ran under. + $ref: "#/components/schemas/AiCapabilityState" required: - aboutMe - conditions @@ -48888,6 +49018,7 @@ components: - updatedAt - maxChars - fieldMaxChars + - ai additionalProperties: false error: type: "null" @@ -51166,15 +51297,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: @@ -51464,14 +51603,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: @@ -51542,7 +51700,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 @@ -51577,19 +51736,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: @@ -53098,10 +53268,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. @@ -53152,18 +53326,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. @@ -53893,13 +54072,14 @@ components: properties: hasProvider: type: boolean - description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance. + description: "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never + false because the operator switched notes off or consent is missing; `ai` says that." text: anyOf: - type: string - type: "null" - description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is - preparing. + description: The assessment note (plain text, rendered as React text children). Null while a first generation is + preparing, and whenever the `statusText` capability is unavailable (see `ai`). cached: type: boolean description: True when `text` is served from cache (incl. last-good). @@ -53919,11 +54099,16 @@ components: flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount. type: boolean + ai: + description: The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is + false, the cache is not read and nothing is warmed. + $ref: "#/components/schemas/AiCapabilityState" required: - hasProvider - text - cached - updatedAt + - ai additionalProperties: false description: "Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + @@ -54018,12 +54203,14 @@ components: properties: hasProvider: type: boolean - description: False when the user has no usable AI provider — `summary` then carries the generic no-key guidance. + description: "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never + false because the operator switched notes off or consent is missing; `ai` says that." summary: anyOf: - type: string - type: "null" - description: The overall compliance narrative (plain text). Null while a first generation is preparing. + description: The overall compliance note (plain text). Null while a first generation is preparing, and whenever the + `statusText` capability is unavailable (see `ai`). medications: type: array items: @@ -54047,12 +54234,17 @@ components: description: True when the envelope is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded). type: boolean + ai: + description: The `statusText` capability behind the note. While it is unavailable `summary` is null, `medications` is + empty, `preparing` is false, the cache is not read and nothing is warmed. + $ref: "#/components/schemas/AiCapabilityState" required: - hasProvider - summary - medications - cached - updatedAt + - ai additionalProperties: false description: Medication-compliance assessment envelope. Unlike the other six specialised cards it carries a `summary` plus a per-medication `text` array rather than a single `text` field. Read-only + stale-while-revalidate. @@ -54091,13 +54283,15 @@ components: properties: hasProvider: type: boolean - description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance. + description: "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never + false because the operator switched notes off or consent is missing; `ai` says that." text: anyOf: - type: string - type: "null" - description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is - preparing, or when the metric has insufficient data. + description: The assessment note (plain text, rendered as React text children). Null while a first generation is + preparing, when the metric has insufficient data, and whenever the `statusText` capability is unavailable + (see `ai`). cached: type: boolean description: True when `text` is served from cache (incl. last-good). @@ -54119,13 +54313,18 @@ components: type: boolean insufficient: description: True when the metric has no readings; no assessment is generated (no LLM call). The card shows its - insufficient-data state. + insufficient-data state. Still computed while `ai` is unavailable. type: boolean + ai: + description: The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is + false, the cache is not read and nothing is warmed. + $ref: "#/components/schemas/AiCapabilityState" required: - hasProvider - text - cached - updatedAt + - ai additionalProperties: false description: "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation @@ -54152,13 +54351,15 @@ components: properties: hasProvider: type: boolean - description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance. + description: "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never + false because the operator switched notes off or consent is missing; `ai` says that." text: anyOf: - type: string - type: "null" - description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is - preparing, or when the marker has no numeric readings. + description: The assessment note (plain text, rendered as React text children). Null while a first generation is + preparing, when the marker has no numeric readings, and whenever the `statusText` capability is unavailable + (see `ai`). cached: type: boolean description: True when `text` is served from cache (incl. last-good). @@ -54182,11 +54383,16 @@ components: description: True when the marker has no numeric readings; no assessment is generated (no LLM call). The card is not rendered. type: boolean + ai: + description: The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is + false, the cache is not read and nothing is warmed. + $ref: "#/components/schemas/AiCapabilityState" required: - hasProvider - text - cached - updatedAt + - ai additionalProperties: false description: "Per-biomarker assessment envelope. Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation @@ -54276,7 +54482,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 @@ -59545,51 +59756,6 @@ components: else's, `available` is false with a null `reason` and `ai.reason` says why. A missing consent receipt leaves the scan offered (`ai.reason = \"consent_required\"`), because the scan is where the person is asked for it. No provider call is made." - 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." - 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." OcrExtractEnvelope: type: object properties: @@ -66685,12 +66851,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." @@ -66728,8 +66896,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 @@ -66741,6 +66927,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. 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 a18d03239..adfa10b47 100644 --- a/src/__tests__/ai-consent-enforcement-guard.test.ts +++ b/src/__tests__/ai-consent-enforcement-guard.test.ts @@ -141,8 +141,6 @@ const ALLOWLIST: Record = { // --- Capability / config probes: resolve a chain, never call it. --- "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..a5b256218 --- /dev/null +++ b/src/app/api/__tests__/ai-capability-route-inventory.test.ts @@ -0,0 +1,156 @@ +/** + * 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`. + */ +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+)"|(aiCapabilityToServe)\s*\([^,()]*,\s*"(\w+)")/g; +const ANY_GATE = + /\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\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] ?? m[3], + key: m[2] ?? m[4], + })); +} + +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); + expect( + gateCalls('aiCapabilityToServe(\n user.id,\n "statusText",\n )'), + ).toEqual([{ fn: "aiCapabilityToServe", key: "statusText" }]); + }); + + 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..2e49101b2 --- /dev/null +++ b/src/app/api/__tests__/ai-route-inventory.ts @@ -0,0 +1,274 @@ +/** + * 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)` (the viewer's view, for work + * they would start) or `aiCapabilityToServe(recordId, key)` (the record's + * own view, for stored model text they would show), 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> = { + // Reading documents, lab scans and medication text. + "src/app/api/documents/inbound/[id]/chat/route.ts": { + kind: "action", + capabilities: ["documentAi"], + why: "POST asks a model about one document; the history GET asks nothing.", + }, + "src/app/api/documents/inbound/[id]/extract/route.ts": { + kind: "action", + capabilities: ["documentAi"], + why: "Reads a document's text with a model.", + }, + "src/app/api/documents/inbound/[id]/index/route.ts": { + kind: "action", + capabilities: ["documentAi"], + why: "Indexes a document's content through a provider.", + }, + "src/app/api/documents/inbound/[id]/suggest/route.ts": { + kind: "action", + capabilities: ["documentAi"], + why: "Suggests a title and category from the document with a model.", + }, + "src/app/api/documents/inbound/[id]/summary/route.ts": { + kind: "action", + capabilities: ["documentAi"], + why: "Summarises a document with a model.", + }, + "src/app/api/documents/inbound/reindex/route.ts": { + kind: "action", + capabilities: ["documentAi"], + why: "Re-indexes the vault through a provider.", + }, + "src/app/api/documents/inbound/route.ts": { + kind: "mixed", + capabilities: ["documentAi"], + why: "The upload is data and always accepted; only the AI work queued after it follows the capability.", + }, + "src/app/api/auth/me/documents-auto-ai-read/route.ts": { + kind: "mixed", + capabilities: ["documentAi"], + why: "The preference is stored whatever AI says; the catch-up it queues follows the capability.", + }, + "src/app/api/insights/chat/fenced/route.ts": { + kind: "action", + capabilities: ["coach", "documentAi"], + why: "Document chat inside a Coach conversation.", + }, + "src/app/api/insights/chat/[id]/attachments/route.ts": { + kind: "action", + capabilities: ["coach", "documentAi"], + why: "Attaching a document to a Coach conversation for the model to read.", + }, + "src/app/api/labs/ocr/extract/route.ts": { + kind: "action", + capabilities: ["labsOcr"], + why: "Reads a lab report image with a model.", + }, + "src/app/api/medications/extract/route.ts": { + kind: "action", + capabilities: ["medicationExtract"], + why: "Turns typed medication text into a schedule with a model.", + }, + "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/insights/biomarker-assessment/route.ts": { + kind: "mixed", + capabilities: ["statusText"], + why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).", + }, + "src/app/api/insights/blood-pressure-status/route.ts": { + kind: "mixed", + capabilities: ["statusText"], + why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).", + }, + "src/app/api/insights/bmi-status/route.ts": { + kind: "mixed", + capabilities: ["statusText"], + why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).", + }, + "src/app/api/insights/medication-compliance-status/route.ts": { + kind: "mixed", + capabilities: ["statusText"], + why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).", + }, + "src/app/api/insights/metric-status/route.ts": { + kind: "mixed", + capabilities: ["statusText"], + why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).", + }, + "src/app/api/insights/mood-status/route.ts": { + kind: "mixed", + capabilities: ["statusText"], + why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).", + }, + "src/app/api/insights/pulse-status/route.ts": { + kind: "mixed", + capabilities: ["statusText"], + why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).", + }, + "src/app/api/insights/weight-status/route.ts": { + kind: "mixed", + capabilities: ["statusText"], + why: "Status family: the card is data, the note inside it follows the capability (200 with a null note otherwise).", + }, + "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/insights/chat/[id]/attachments/[documentId]/route.ts": + "Detaching a document: removal of one's own data.", + "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 still owns. Each 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`. + * Empty: every route in these trees is classified. + */ +export const PENDING_ROUTES: Readonly> = {}; + +/** 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 c7fdf64a8..ba12f419a 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,16 @@ 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. * * 3. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a * toggleable domain but are deliberately NOT gated, each with the @@ -147,9 +152,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 @@ -244,10 +250,9 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // affordance), not the row store. The extract route is read-only vision // assistance, the commit route writes the user's own confirmed lab rows, // and the capability probe is an infra availability check carrying no - // module data. All three are owner-scoped and AI-gated (consent / budget / - // rate); the module toggle hiding the surface does not need to wedge them. + // module data. The extract route is not listed: it names the `labsOcr` AI + // capability, which carries the labs module, so it counts as gated. "src/app/api/labs/ocr/capability/route.ts", - "src/app/api/labs/ocr/extract/route.ts", "src/app/api/labs/ocr/commit/route.ts", // ── DATA LAYER (medications) ────────────────────────────────────── // v1.18.1 (D3) — medications graduated from CORE to a toggleable module, @@ -268,12 +273,9 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // per-medication importer below: it writes intake rows and reads its own job. "src/app/api/medications/intake/dose-history-import/route.ts", "src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts", - // `medications/extract` is model work, not data: it answers under the - // `medicationExtract` AI capability (`requireAiCapability`), which no - // module owns, because medications is a core domain for extraction. The - // capability carries the operator switch, the record's authority and the - // consent rule; there is no module gate for it to carry. - "src/app/api/medications/extract/route.ts", + // `medications/extract` is not listed: it is model work under the + // `medicationExtract` AI capability (no owning module; medications is a + // core domain for extraction), and a capability call counts as the gate. "src/app/api/medications/[id]/route.ts", "src/app/api/medications/[id]/api-endpoint/route.ts", "src/app/api/medications/[id]/cadence/route.ts", @@ -302,12 +304,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 @@ -388,7 +417,10 @@ 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|aiCapabilityToServe)\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 @@ -470,6 +502,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 @@ -634,8 +675,8 @@ describe("module API route gate inventory", () => { if (fileHasCall(text, MODULE_GATE_NEEDLE)) continue; 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; @@ -755,8 +796,8 @@ describe("module API route gate inventory", () => { if ( 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/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts index bb675fc73..c1dd6de7d 100644 --- a/src/app/api/auth/me/modules/__tests__/route.test.ts +++ b/src/app/api/auth/me/modules/__tests__/route.test.ts @@ -43,7 +43,7 @@ vi.mock("@/lib/rate-limit", () => ({ })); // Operator master flag = on by default; coach delegation keys off it. -// Partial mock — api-handler imports `AssistantDisabledError` from here. +// Partial mock: the rest of the module stays real. vi.mock("@/lib/feature-flags", async (importOriginal) => { const actual = await importOriginal(); return { 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..46d0b746a 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/gate", () => ({ + aiCapabilityToServe: 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..8c303cbf5 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,120 @@ -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|aiCapabilityToServe)\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/biomarker-assessment/__tests__/route.test.ts b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts index 68aa38d33..d596a5f57 100644 --- a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts +++ b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts @@ -6,6 +6,12 @@ vi.mock("@/lib/db", () => ({ appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, }, })); +// The `statusText` capability decides whether the note is served; the +// unavailable body's provider-presence probe is stubbed. +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() })); +vi.mock("@/lib/ai/provider", () => ({ + probeProviderPresence: vi.fn(async () => true), +})); // The route resolves the `insights` module gate after `requireAuth()`. Mock // it default-enabled so the assertions ride through; the off → 403 coverage @@ -57,6 +63,7 @@ import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -80,6 +87,11 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(aiCapabilityToServe).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); vi.mocked(generateBiomarkerStatus).mockResolvedValue({ hasProvider: true, text: "ok", @@ -121,19 +133,22 @@ describe("GET /api/insights/biomarker-assessment", () => { expect(res.status).toBe(401); }); - it("403s + errorCode when insightStatus is disabled", async () => { + it("answers 200 with no note while statusText is unavailable, calling no generator", 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); + vi.mocked(aiCapabilityToServe).mockResolvedValue({ + available: false, + reason: "user_disabled", + onDeviceAllowed: false, + }); const res = await callGet(makeReq("bm-1")); - 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); + const body = (await res.json()) as { data: Record }; + expect(body.data).toMatchObject({ + text: null, + preparing: false, + ai: { available: false, reason: "user_disabled" }, + }); + expect(generateBiomarkerStatus).not.toHaveBeenCalled(); }); it("preserves the safe terminal fallback produced when a causal claim is screened", async () => { @@ -161,6 +176,7 @@ describe("GET /api/insights/biomarker-assessment", () => { text: "The assessment could not be completed safely. Your recorded measurements remain available.", cached: true, updatedAt: null, + ai: { available: true, reason: null, onDeviceAllowed: true }, }); expect(JSON.stringify(body)).not.toContain("medication caused"); expect(JSON.stringify(body)).not.toContain("causal_claim"); diff --git a/src/app/api/insights/biomarker-assessment/route.ts b/src/app/api/insights/biomarker-assessment/route.ts index 5345c1723..194acc3fb 100644 --- a/src/app/api/insights/biomarker-assessment/route.ts +++ b/src/app/api/insights/biomarker-assessment/route.ts @@ -12,6 +12,12 @@ * provider. The generator regenerates ONLY when the latest reading * fingerprint changes, so an idle marker re-stamps cached text without an * LLM round-trip. + * + * A mixed read: the note is served, and warmed, only while the `statusText` + * AI capability is available. Otherwise the route answers 200 with no note + * and an `ai` state saying why, without reading the cache or queueing + * anything. The `insights` module (the AI analysis opt-out) folds into the + * capability. */ import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -19,8 +25,8 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { requireAssistantSurface } from "@/lib/feature-flags"; -import { requireModuleEnabled } from "@/lib/modules/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; +import { unavailableStatusBody } from "@/lib/insights/status-unavailable"; import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status"; import { resolveMetricStatusLocale } from "@/lib/insights/metric-status"; @@ -35,10 +41,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 = querySchema.safeParse({ biomarkerId: request.nextUrl.searchParams.get("biomarkerId"), }); @@ -50,6 +52,15 @@ export const GET = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } + const ai = await aiCapabilityToServe(user.id, "statusText"); + if (!ai.available) { + annotate({ + action: { name: "insights.biomarker-status.unavailable" }, + meta: { reason: ai.reason }, + }); + return apiSuccess(await unavailableStatusBody(user.id, ai)); + } + const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ request, @@ -71,5 +82,5 @@ export const GET = apiHandler(async (request: NextRequest) => { meta: { biomarkerId: parsed.data.biomarkerId }, }); - return apiSuccess(result); + return apiSuccess({ ...result, ai }); }); diff --git a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts index 9f0ea1def..748572207 100644 --- a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts +++ b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts @@ -1,19 +1,20 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -vi.mock("@/lib/db", () => ({ - prisma: { - appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, - }, -})); +/** + * The status family is a mixed read. The note is model text: served only + * while the `statusText` capability is available. Otherwise the route + * answers 200 with `text: null`, `preparing: false`, `hasProvider` as provider + * presence and an `ai` state, and the generator (the cache read and the warm + * enqueue behind it) is never called. The `insights` module is folded into + * the capability and no longer refuses the route. + */ + +vi.mock("@/lib/db", () => ({ prisma: {} })); -// 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 }), + requireModuleEnabled: vi.fn(), resolveModuleMap: vi.fn().mockResolvedValue({}), })); @@ -38,22 +39,28 @@ vi.mock("next/headers", () => ({ })), })); -// Stub the heavy generator so the gate test stays isolated from the -// real status pipeline. vi.mock("@/lib/insights/blood-pressure-status", () => ({ - generateBloodPressureStatusForUser: vi.fn(async () => ({ - available: true, - locale: "en", - text: "ok", - })), + generateBloodPressureStatusForUser: vi.fn(), resolveBloodPressureStatusLocale: () => "en", })); +// The `statusText` capability decides whether the note is served; the +// unavailable body's provider-presence probe is stubbed. +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() })); +vi.mock("@/lib/ai/provider", () => ({ + probeProviderPresence: vi.fn(async () => true), +})); + import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; -import { prisma } from "@/lib/db"; import { requireModuleEnabled } from "@/lib/modules/gate"; -import { apiError } from "@/lib/api-response"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; +import { probeProviderPresence } from "@/lib/ai/provider"; +import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status"; +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,30 +79,63 @@ function makeReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); - vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); - vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE); + vi.mocked(probeProviderPresence).mockResolvedValue(true); + vi.mocked(generateBloodPressureStatusForUser).mockResolvedValue({ + hasProvider: true, + text: "ok", + cached: true, + updatedAt: "2026-09-20T06:00:00.000Z", + }); }); -describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () => { - it("returns 200 when insightStatus is enabled (default)", async () => { +describe("GET /api/insights/blood-pressure-status", () => { + it("serves the note and the state while statusText is available", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const res = await callGet(makeReq()); expect(res.status).toBe(200); + const body = (await res.json()) as { data: Record }; + expect(body.data.text).toBe("ok"); + expect(body.data.ai).toEqual(AI_AVAILABLE); + expect(aiCapabilityToServe).toHaveBeenCalledWith("user-1", "statusText"); + expect(requireModuleEnabled).not.toHaveBeenCalled(); }); - it("returns 403 + errorCode when insightStatus is off", async () => { + it.each([ + "operator_disabled", + "user_disabled", + "consent_required", + "check_failed", + ] as const)( + "answers 200 with no note for %s, reading and warming nothing", + async (reason) => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(aiCapabilityToServe).mockResolvedValue(aiUnavailable(reason)); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Record }; + expect(body.data).toEqual({ + hasProvider: true, + text: null, + cached: false, + updatedAt: null, + preparing: false, + ai: aiUnavailable(reason), + }); + expect(generateBloodPressureStatusForUser).not.toHaveBeenCalled(); + }, + ); + + it("reports hasProvider as presence only: false for a missing provider", 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); + vi.mocked(aiCapabilityToServe).mockResolvedValue( + aiUnavailable("no_provider"), + ); + vi.mocked(probeProviderPresence).mockResolvedValue(false); 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"); + const body = (await res.json()) as { data: Record }; + expect(body.data.hasProvider).toBe(false); + expect(body.data.text).toBeNull(); }); it("returns 401 when unauthenticated", async () => { @@ -104,33 +144,3 @@ describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () = expect(res.status).toBe(401); }); }); - -// v1.18.0 (B2) — the route now also requires the `insights` module. -// Representative coverage that a disabled module short-circuits with the -// 403 module.disabled envelope before any provider / cache work; the -// inventory test guards that EVERY insights AI route carries this gate. -describe("GET /api/insights/blood-pressure-status — insights module gate", () => { - it("returns 200 when insights is enabled (default)", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - const res = await callGet(makeReq()); - expect(res.status).toBe(200); - }); - - it("returns 403 + module.disabled when insights is off", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ - enabled: false, - response: apiError('Module "insights" is not enabled', 403, { - errorCode: "module.disabled", - module: "insights", - }), - }); - 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"); - }); -}); diff --git a/src/app/api/insights/blood-pressure-status/route.ts b/src/app/api/insights/blood-pressure-status/route.ts index 380a68ad2..9a0cec204 100644 --- a/src/app/api/insights/blood-pressure-status/route.ts +++ b/src/app/api/insights/blood-pressure-status/route.ts @@ -7,19 +7,34 @@ import { import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { requireAssistantSurface } from "@/lib/feature-flags"; -import { requireModuleEnabled } from "@/lib/modules/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; +import { unavailableStatusBody } from "@/lib/insights/status-unavailable"; export const dynamic = "force-dynamic"; +/** + * A mixed read: the card's frame is data, the note inside it is model text. + * The note is served, and warmed on a miss, only while the `statusText` AI + * capability is available. Otherwise the route answers 200 with no note + * (`text: null`, `preparing: false`, `hasProvider` as provider presence) and + * an `ai` state saying why, without reading the cache or queueing anything. + * The `insights` module is the AI analysis opt-out and is folded into the + * capability, so it no longer refuses the route. + */ + export const GET = apiHandler(async (request: NextRequest) => { // v1.37.0 — MANAGE-level read: a generated assessment over the whole // 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 ai = await aiCapabilityToServe(user.id, "statusText"); + if (!ai.available) { + annotate({ + action: { name: "insights.blood-pressure-status.unavailable" }, + meta: { reason: ai.reason }, + }); + return apiSuccess(await unavailableStatusBody(user.id, ai)); + } const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ @@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "insights.blood-pressure-status" } }); - return apiSuccess(result); + return apiSuccess({ ...result, ai }); }); diff --git a/src/app/api/insights/bmi-status/route.ts b/src/app/api/insights/bmi-status/route.ts index f4dff8de7..a78c2a57d 100644 --- a/src/app/api/insights/bmi-status/route.ts +++ b/src/app/api/insights/bmi-status/route.ts @@ -7,19 +7,34 @@ import { import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { requireAssistantSurface } from "@/lib/feature-flags"; -import { requireModuleEnabled } from "@/lib/modules/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; +import { unavailableStatusBody } from "@/lib/insights/status-unavailable"; export const dynamic = "force-dynamic"; +/** + * A mixed read: the card's frame is data, the note inside it is model text. + * The note is served, and warmed on a miss, only while the `statusText` AI + * capability is available. Otherwise the route answers 200 with no note + * (`text: null`, `preparing: false`, `hasProvider` as provider presence) and + * an `ai` state saying why, without reading the cache or queueing anything. + * The `insights` module is the AI analysis opt-out and is folded into the + * capability, so it no longer refuses the route. + */ + export const GET = apiHandler(async (request: NextRequest) => { // v1.37.0 — MANAGE-level read: a generated assessment over the whole // 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 ai = await aiCapabilityToServe(user.id, "statusText"); + if (!ai.available) { + annotate({ + action: { name: "insights.bmi-status.unavailable" }, + meta: { reason: ai.reason }, + }); + return apiSuccess(await unavailableStatusBody(user.id, ai)); + } const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ @@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "insights.bmi-status" } }); - return apiSuccess(result); + return apiSuccess({ ...result, ai }); }); 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 113b85fba..6ba4c1e46 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -15,9 +15,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 @@ -28,10 +27,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 }), @@ -80,7 +77,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 @@ -94,33 +90,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(); }); }); @@ -165,19 +157,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]/__tests__/route-patch.test.ts b/src/app/api/insights/chat/[id]/__tests__/route-patch.test.ts index d82efdffb..d1de6597b 100644 --- a/src/app/api/insights/chat/[id]/__tests__/route-patch.test.ts +++ b/src/app/api/insights/chat/[id]/__tests__/route-patch.test.ts @@ -2,14 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; import type * as ApiHandlerModule from "@/lib/api-handler"; -const { requireAuth, requireAssistantSurface, annotate, auditLog } = vi.hoisted( - () => ({ - requireAuth: vi.fn(), - requireAssistantSurface: vi.fn(), - annotate: vi.fn(), - auditLog: vi.fn(), - }), -); +const { requireAuth, annotate, auditLog } = vi.hoisted(() => ({ + requireAuth: vi.fn(), + annotate: vi.fn(), + auditLog: vi.fn(), +})); vi.mock("@/lib/api-handler", async (importOriginal) => { const actual = await importOriginal(); @@ -20,7 +17,6 @@ vi.mock("@/lib/api-handler", async (importOriginal) => { requireAuth, }; }); -vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface })); vi.mock("@/lib/logging/context", () => ({ annotate })); vi.mock("@/lib/auth/audit", () => ({ auditLog })); vi.mock("@/lib/ai/coach/persistence", () => ({ @@ -65,7 +61,6 @@ async function body(response: Response) { beforeEach(() => { vi.clearAllMocks(); requireAuth.mockResolvedValue({ user: { id: USER_ID, locale: "en" } }); - requireAssistantSurface.mockResolvedValue(undefined); auditLog.mockResolvedValue(undefined); }); @@ -77,7 +72,6 @@ describe("PATCH /api/insights/chat/[id]", () => { await expect(PATCH(request({ title: "Renamed" }), context())).rejects.toBe( unauthenticated, ); - expect(requireAssistantSurface).not.toHaveBeenCalled(); expect(renameConversation).not.toHaveBeenCalled(); }); 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/__tests__/route.test.ts b/src/app/api/insights/coach-read/__tests__/route.test.ts index 13d083137..0faf29c0d 100644 --- a/src/app/api/insights/coach-read/__tests__/route.test.ts +++ b/src/app/api/insights/coach-read/__tests__/route.test.ts @@ -33,9 +33,6 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => ({ requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), resolveModuleMap: vi.fn().mockResolvedValue({}), })); -vi.mock("@/lib/feature-flags", () => ({ - requireAssistantSurface: vi.fn().mockResolvedValue(undefined), -})); vi.mock("@/lib/rate-limit", () => ({ checkAnalyticsReadRateLimit: vi.fn(), })); 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..164aa9d9b 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, aiCapabilityToServe } = + 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, + }), + ), + aiCapabilityToServe: 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", () => ({ aiCapabilityToServe })); 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 }); + aiCapabilityToServe.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(aiCapabilityToServe).toHaveBeenCalledWith("u1", "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 }; + aiCapabilityToServe.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..d5908a363 100644 --- a/src/app/api/insights/coach/nudge-status/route.ts +++ b/src/app/api/insights/coach/nudge-status/route.ts @@ -21,10 +21,17 @@ * `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 record's `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 { aiCapabilityToServe } 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 +53,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 aiCapabilityToServe(user.id, "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 +80,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..d6673001e 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", () => ({ + aiCapabilityToServe: 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 { aiCapabilityToServe } 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(aiCapabilityToServe).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(aiCapabilityToServe).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(aiCapabilityToServe) + .mock.calls.map((c) => c[1]) + .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..be30df6e1 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 { aiCapabilityToServe } 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([ + aiCapabilityToServe(user.id, "briefing"), + aiCapabilityToServe(user.id, "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 a22642d60..47fa6a4a2 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -276,10 +276,11 @@ describe("GET /api/insights/correlations", () => { expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2); }); - // 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", @@ -287,13 +288,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 784d37d2f..eb4e4358e 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -23,11 +23,7 @@ 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 { - MODULE_KEYS, - requireModuleEnabled, - resolveModuleMap, -} from "@/lib/modules/gate"; +import { MODULE_KEYS, resolveModuleMap } from "@/lib/modules/gate"; import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { prisma } from "@/lib/db"; @@ -71,12 +67,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..2438fd636 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 { aiCapabilityToServe } 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 aiCapabilityToServe(user.id, "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/medication-compliance-status/route.ts b/src/app/api/insights/medication-compliance-status/route.ts index 691f6903f..38d8bade3 100644 --- a/src/app/api/insights/medication-compliance-status/route.ts +++ b/src/app/api/insights/medication-compliance-status/route.ts @@ -7,19 +7,34 @@ import { import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { requireAssistantSurface } from "@/lib/feature-flags"; -import { requireModuleEnabled } from "@/lib/modules/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; +import { unavailableComplianceStatusBody } from "@/lib/insights/status-unavailable"; export const dynamic = "force-dynamic"; +/** + * A mixed read: the card's frame is data, the note inside it is model text. + * The note is served, and warmed on a miss, only while the `statusText` AI + * capability is available. Otherwise the route answers 200 with no note + * (`summary: null`, no per-medication notes, `preparing: false`, `hasProvider` as provider presence) and + * an `ai` state saying why, without reading the cache or queueing anything. + * The `insights` module is the AI analysis opt-out and is folded into the + * capability, so it no longer refuses the route. + */ + export const GET = apiHandler(async (request: NextRequest) => { // v1.37.0 — MANAGE-level read: a generated assessment over the whole // 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 ai = await aiCapabilityToServe(user.id, "statusText"); + if (!ai.available) { + annotate({ + action: { name: "insights.medication-compliance-status.unavailable" }, + meta: { reason: ai.reason }, + }); + return apiSuccess(await unavailableComplianceStatusBody(user.id, ai)); + } const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ @@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "insights.medication-compliance-status" } }); - return apiSuccess(result); + return apiSuccess({ ...result, ai }); }); diff --git a/src/app/api/insights/metric-status/__tests__/route.test.ts b/src/app/api/insights/metric-status/__tests__/route.test.ts index 196d36573..393f1a7ec 100644 --- a/src/app/api/insights/metric-status/__tests__/route.test.ts +++ b/src/app/api/insights/metric-status/__tests__/route.test.ts @@ -4,8 +4,15 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + measurement: { count: vi.fn() }, }, })); +// The `statusText` capability decides whether the note is served; the +// unavailable body's provider-presence probe is stubbed. +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() })); +vi.mock("@/lib/ai/provider", () => ({ + probeProviderPresence: vi.fn(async () => true), +})); // v1.18.0 — the route now resolves the `insights` module gate after // `requireAuth()` (in addition to the existing per-metric sleep/glucose/ @@ -55,6 +62,7 @@ import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { generateMetricStatus } from "@/lib/insights/metric-status"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -77,6 +85,11 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(aiCapabilityToServe).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); }); describe("GET /api/insights/metric-status", () => { @@ -126,18 +139,29 @@ describe("GET /api/insights/metric-status", () => { expect(res.status).toBe(401); }); - 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 callGet(makeReq("SLEEP_DURATION")); - expect(res.status).toBe(403); - const body = (await res.json()) as { meta?: { errorCode?: string } }; - expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); - }); + it.each([ + [0, true], + [3, false], + ] as const)( + "answers 200 with no note while statusText is unavailable (%i readings → insufficient %s)", + async (readings, insufficient) => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(aiCapabilityToServe).mockResolvedValue({ + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + }); + vi.mocked(prisma.measurement.count).mockResolvedValue(readings as never); + const res = await callGet(makeReq("RESTING_HEART_RATE")); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Record }; + expect(body.data).toMatchObject({ + text: null, + preparing: false, + insufficient, + ai: { available: false, reason: "operator_disabled" }, + }); + expect(generateMetricStatus).not.toHaveBeenCalled(); + }, + ); }); diff --git a/src/app/api/insights/metric-status/route.ts b/src/app/api/insights/metric-status/route.ts index ac173fd2e..4c105e83f 100644 --- a/src/app/api/insights/metric-status/route.ts +++ b/src/app/api/insights/metric-status/route.ts @@ -13,6 +13,12 @@ * text, never blocking on the provider. An unknown `metric` 422s via the * closed registry enum. * + * A mixed read: the note is model text, served (and warmed on a miss) only + * while the `statusText` AI capability is available. Otherwise the route + * answers 200 with no note, `insufficient` still computed from the data, and + * an `ai` state saying why; the cache is not read and nothing is queued. The + * `insights` module (the AI analysis opt-out) folds into the capability. + * * Unlike the seven legacy `*-status` routes, this one carries OpenAPI * coverage (the route schema lives in `src/lib/openapi/routes.ts`), * establishing the pattern the legacy routes can be backfilled onto later. @@ -23,14 +29,17 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled, type ModuleKey } from "@/lib/modules/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; +import { unavailableStatusBody } from "@/lib/insights/status-unavailable"; +import { prisma } from "@/lib/db"; import { generateMetricStatus, resolveMetricStatusLocale, } from "@/lib/insights/metric-status"; import { METRIC_STATUS_IDS, + getMetricStatusMeta, type MetricStatusMetricId, } from "@/lib/insights/metric-status-registry"; @@ -72,10 +81,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 = metricQuerySchema.safeParse({ metric: request.nextUrl.searchParams.get("metric"), }); @@ -99,6 +104,30 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!gate.enabled) return gate.response; } + const ai = await aiCapabilityToServe(user.id, "statusText"); + if (!ai.available) { + // The note is not served, but whether there is anything to assess is + // data: the card keeps its insufficient-data state. + const meta = getMetricStatusMeta(metric); + const readings = meta + ? await prisma.measurement.count({ + where: { + userId: user.id, + type: meta.measurementType, + deletedAt: null, + }, + }) + : 0; + annotate({ + action: { name: "insights.metric-status.unavailable" }, + meta: { metric, reason: ai.reason }, + }); + return apiSuccess({ + ...(await unavailableStatusBody(user.id, ai)), + insufficient: readings === 0, + }); + } + const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ request, @@ -120,5 +149,5 @@ export const GET = apiHandler(async (request: NextRequest) => { meta: { metric }, }); - return apiSuccess(result); + return apiSuccess({ ...result, ai }); }); diff --git a/src/app/api/insights/mood-status/__tests__/route.test.ts b/src/app/api/insights/mood-status/__tests__/route.test.ts index 87bdda898..7f411003d 100644 --- a/src/app/api/insights/mood-status/__tests__/route.test.ts +++ b/src/app/api/insights/mood-status/__tests__/route.test.ts @@ -10,6 +10,12 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +// The `statusText` capability decides whether the note is served; the +// unavailable body's provider-presence probe is stubbed. +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() })); +vi.mock("@/lib/ai/provider", () => ({ + probeProviderPresence: vi.fn(async () => true), +})); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), @@ -45,6 +51,7 @@ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -74,6 +81,11 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); + vi.mocked(aiCapabilityToServe).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); }); describe("GET /api/insights/mood-status — module gate", () => { @@ -102,6 +114,30 @@ describe("GET /api/insights/mood-status — module gate", () => { expect(generateMoodStatusForUser).not.toHaveBeenCalled(); }); + it("keeps the mood module gate ahead of the capability", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow({ mood: false }) as never, + ); + await callGet(makeReq()); + expect(aiCapabilityToServe).not.toHaveBeenCalled(); + }); + + it("answers 200 with no note while statusText is unavailable", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow(null) as never); + vi.mocked(aiCapabilityToServe).mockResolvedValue({ + available: false, + reason: "no_provider", + onDeviceAllowed: true, + }); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Record }; + expect(body.data.text).toBeNull(); + expect(generateMoodStatusForUser).not.toHaveBeenCalled(); + }); + it("401s when unauthenticated", async () => { vi.mocked(getSession).mockResolvedValue(null); const res = await callGet(makeReq()); diff --git a/src/app/api/insights/mood-status/route.ts b/src/app/api/insights/mood-status/route.ts index f2ad24afc..1b7314d71 100644 --- a/src/app/api/insights/mood-status/route.ts +++ b/src/app/api/insights/mood-status/route.ts @@ -7,26 +7,41 @@ import { import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled } from "@/lib/modules/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; +import { unavailableStatusBody } from "@/lib/insights/status-unavailable"; export const dynamic = "force-dynamic"; +/** + * A mixed read: the card's frame is data, the note inside it is model text. + * The note is served, and warmed on a miss, only while the `statusText` AI + * capability is available. Otherwise the route answers 200 with no note + * (`text: null`, `preparing: false`, `hasProvider` as provider presence) and + * an `ai` state saying why, without reading the cache or queueing anything. + * The `insights` module is the AI analysis opt-out and is folded into the + * capability, so it no longer refuses the route. + */ + export const GET = apiHandler(async (request: NextRequest) => { // v1.37.0 — MANAGE-level read: a generated assessment over the whole // 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; - // Per-domain gate: the mood module must be enabled for this account // before the mood AI-status surface is served (mirrors the cycle gate). const gate = await requireModuleEnabled(user.id, "mood"); if (!gate.enabled) return gate.response; - await requireAssistantSurface("insightStatus"); + const ai = await aiCapabilityToServe(user.id, "statusText"); + if (!ai.available) { + annotate({ + action: { name: "insights.mood-status.unavailable" }, + meta: { reason: ai.reason }, + }); + return apiSuccess(await unavailableStatusBody(user.id, ai)); + } const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ @@ -47,5 +62,5 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "insights.mood-status" } }); - return apiSuccess(result); + return apiSuccess({ ...result, ai }); }); diff --git a/src/app/api/insights/narrative/route.ts b/src/app/api/insights/narrative/route.ts index 78c1a1f0a..f4314dd37 100644 --- a/src/app/api/insights/narrative/route.ts +++ b/src/app/api/insights/narrative/route.ts @@ -10,6 +10,17 @@ * * `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 record's `periodNarrative` capability is available (answered from + * the record's own state, so a delegate reads the owner's narrative exactly + * when the owner would); otherwise `narrative` is null and `ai` says why. The + * warm is enqueued only when the capability is available, or when no row + * exists at all (the worker then writes the deterministic narrative); never + * for a delegate. 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,8 +29,8 @@ 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 { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; +import { prisma } from "@/lib/db"; import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate"; import { PERIOD_DAYS, @@ -57,9 +68,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 +88,26 @@ export const GET = apiHandler(async (request: NextRequest) => { }); const locale = narrowLocale(resolved); + // Whether model-written text may be shown is the record's own answer, + // whoever is reading; the reader already applies it (a model-written row + // reads as absent while it may not be shown). Starting a warm for a + // delegate stays suppressed below. + const ai = await aiCapabilityToServe(user.id, "periodNarrative"); const existing = await readPeriodNarrative(user.id, period, locale); const isFresh = existing !== null && Date.now() - new Date(existing.updatedAt).getTime() < NARRATIVE_FRESH_MS; + // With AI unavailable a warm only helps when no row exists at all: the + // worker then writes the deterministic narrative. A hidden model-written + // row must not be overwritten by one (turning AI back on brings it back), + // and a stale deterministic row is served as it is. + const mayWarm = + ai.available || + (existing === null && + (await prisma.insightNarrative.findUnique({ + where: { userId_period_locale: { userId: user.id, period, locale } }, + select: { id: true }, + })) === 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 +122,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 +134,7 @@ export const GET = apiHandler(async (request: NextRequest) => { has_narrative: existing !== null, revalidating, generation_suppressed: generationSuppressed, + ai_reason: ai.reason, }, }); @@ -124,5 +149,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-status/route.ts b/src/app/api/insights/pulse-status/route.ts index 6366fcc71..f22b1b9fe 100644 --- a/src/app/api/insights/pulse-status/route.ts +++ b/src/app/api/insights/pulse-status/route.ts @@ -7,19 +7,34 @@ import { import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { requireAssistantSurface } from "@/lib/feature-flags"; -import { requireModuleEnabled } from "@/lib/modules/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; +import { unavailableStatusBody } from "@/lib/insights/status-unavailable"; export const dynamic = "force-dynamic"; +/** + * A mixed read: the card's frame is data, the note inside it is model text. + * The note is served, and warmed on a miss, only while the `statusText` AI + * capability is available. Otherwise the route answers 200 with no note + * (`text: null`, `preparing: false`, `hasProvider` as provider presence) and + * an `ai` state saying why, without reading the cache or queueing anything. + * The `insights` module is the AI analysis opt-out and is folded into the + * capability, so it no longer refuses the route. + */ + export const GET = apiHandler(async (request: NextRequest) => { // v1.37.0 — MANAGE-level read: a generated assessment over the whole // 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 ai = await aiCapabilityToServe(user.id, "statusText"); + if (!ai.available) { + annotate({ + action: { name: "insights.pulse-status.unavailable" }, + meta: { reason: ai.reason }, + }); + return apiSuccess(await unavailableStatusBody(user.id, ai)); + } const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ @@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "insights.pulse-status" } }); - return apiSuccess(result); + return apiSuccess({ ...result, 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/insights/weight-status/route.ts b/src/app/api/insights/weight-status/route.ts index 275dc3da7..bfa1e4a74 100644 --- a/src/app/api/insights/weight-status/route.ts +++ b/src/app/api/insights/weight-status/route.ts @@ -7,19 +7,34 @@ import { import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { requireAssistantSurface } from "@/lib/feature-flags"; -import { requireModuleEnabled } from "@/lib/modules/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; +import { unavailableStatusBody } from "@/lib/insights/status-unavailable"; export const dynamic = "force-dynamic"; +/** + * A mixed read: the card's frame is data, the note inside it is model text. + * The note is served, and warmed on a miss, only while the `statusText` AI + * capability is available. Otherwise the route answers 200 with no note + * (`text: null`, `preparing: false`, `hasProvider` as provider presence) and + * an `ai` state saying why, without reading the cache or queueing anything. + * The `insights` module is the AI analysis opt-out and is folded into the + * capability, so it no longer refuses the route. + */ + export const GET = apiHandler(async (request: NextRequest) => { // v1.37.0 — MANAGE-level read: a generated assessment over the whole // 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 ai = await aiCapabilityToServe(user.id, "statusText"); + if (!ai.available) { + annotate({ + action: { name: "insights.weight-status.unavailable" }, + meta: { reason: ai.reason }, + }); + return apiSuccess(await unavailableStatusBody(user.id, ai)); + } const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ @@ -40,5 +55,5 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "insights.weight-status" } }); - return apiSuccess(result); + return apiSuccess({ ...result, ai }); }); 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..05d7f2bc5 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 { aiCapabilityToServe } 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 aiCapabilityToServe(user.id, "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..3b3e280ab 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", () => ({ aiCapabilityToServe: 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 { aiCapabilityToServe } 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(aiCapabilityToServe).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(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ sourcePriorityJson: null, @@ -616,27 +624,42 @@ describe("GET /api/workouts/{id} — aiInsight", () => { paragraph: "A steady, aerobic-leaning run.", generatedAt: generatedAt.toISOString(), }); - }); - - 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); - - const body = await (await get()).json(); - expect(body.data.aiInsight).toBeNull(); - expect(prisma.workout.findUnique).toHaveBeenCalledWith( - expect.objectContaining({ - include: expect.objectContaining({ insight: false }), - }), + expect(aiCapabilityToServe).toHaveBeenCalledWith( + "user-1", + "workoutInsights", ); + expect(body.data.ai).toEqual(AI_AVAILABLE); }); + 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(aiCapabilityToServe).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(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 // whole workout-detail page into a 500 over a garnish field. 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/gate.ts b/src/lib/ai/capabilities/gate.ts index 6a70b43f4..d558c483b 100644 --- a/src/lib/ai/capabilities/gate.ts +++ b/src/lib/ai/capabilities/gate.ts @@ -185,3 +185,42 @@ export async function aiCapabilityForRecord( ? getAiCapability(key, { recordId }) : aiCapabilityForJob(recordId, key); } + +/** + * Whether text a model already wrote for a record may be SHOWN, answered from + * the record's own state, whoever is reading. + * + * Serving stored text and starting model work are different questions. A + * delegate inside somebody else's record may not start model work there + * (`not_permitted_for_record`: no generation, no warm, no chat on the owner's + * key), but reads the owner's stored briefing, narrative, status notes and + * workout notes exactly when the owner would. So this resolves the record's + * operator switches, its modules and AI opt-outs, its provider and its + * consent, under the record's own authority (`system`, as a job would, which + * also answers a managed profile correctly), unmasked by the viewer's grant. + * + * Use it only to decide whether stored model text is served. Anything that + * generates, warms or enqueues asks `getAiCapability` / `requireAiCapability` + * (the viewer's view) instead. Never throws. + */ +export async function aiCapabilityToServe( + recordId: string, + key: AiCapabilityKey, +): Promise { + try { + const inputs = await loadAiCapabilityInputs({ + recordId, + authority: { + origin: "system", + recordUserId: recordId, + actorUserId: null, + grantId: null, + }, + sections: null, + recordKind: "self", + }); + return resolveAiCapability(key, inputs); + } catch { + return resolveAiCapability(key, null); + } +} 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/ai/consent-guard.ts b/src/lib/ai/consent-guard.ts index 7774e836d..85b462a84 100644 --- a/src/lib/ai/consent-guard.ts +++ b/src/lib/ai/consent-guard.ts @@ -52,7 +52,7 @@ const SERVER_MANAGED_PROVIDER_TYPES: ReadonlySet = new Set([ /** * Error thrown when an external-LLM egress on a server-managed key is - * attempted without an active consent receipt. Mirrors `AssistantDisabledError` + * attempted without an active consent receipt. Mirrors the AI capability refusal * so the api-handler renders the same 403 envelope shape the iOS client * already branches on: * diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 884a6d6de..236d080ae 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -26,7 +26,6 @@ import { selectorNamesAnAccount, type ActingCarrier, } from "./auth/acting-carrier"; -import { AssistantDisabledError } from "./feature-flags"; import { AiUnavailableError } from "./ai/capabilities/refusal"; import { ConsentRequiredError } from "./ai/consent-guard"; import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config"; @@ -246,27 +245,10 @@ export function apiHandler Promise>( { data: null, error: error.message, meta: error.meta }, { status: error.status }, ); - } else if (error instanceof AssistantDisabledError) { - // v1.4.31 — operator has disabled the assistant surface. - // The 403 + `errorCode: "assistant.disabled."` - // envelope is locked per - // `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5. - // Older iOS clients that don't know the errorCode surface - // this as a generic 403; v1.4.31+ clients can branch on the - // errorCode to render an inline operator-disabled notice. - evt.setError(error); - response = NextResponse.json( - { - data: null, - error: error.message, - meta: { errorCode: error.errorCode }, - }, - { status: 403 }, - ); } else if (error instanceof ConsentRequiredError) { // v1.12.1 — server-side consent gate before external-LLM PHI - // egress on the operator's server-managed key. Mirrors the - // AssistantDisabledError envelope (403 + meta.errorCode) so the + // egress on the operator's server-managed key. 403 + + // meta.errorCode, like the capability refusal above, so the // iOS client renders an inline "grant consent" notice instead of // a generic failure. evt.setError(error); diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts index 5d411ee56..41b3533d5 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 }, @@ -80,7 +82,6 @@ function makeDeps( return { dispatch, loadDigest: async () => makeDigest(), - isModuleEnabled: async () => true, ...over, } as DailyBriefingDispatchDeps & { dispatch: ReturnType }; } @@ -251,17 +252,27 @@ describe("maybeDispatchDailyBriefing", () => { expect(deps.dispatch).not.toHaveBeenCalled(); }); - it("insights module off → no dispatch", async () => { + it("AI analysis off: the push still goes out with the deterministic line", async () => { + // The digest the loader returns when the briefing capability is + // unavailable: no lead, no top signal, the score floor as the line. const prisma = makePrisma({}); - const deps = makeDeps({ isModuleEnabled: async () => false }); + const deps = makeDeps({ + loadDigest: async () => + makeDigest({ + briefingLead: null, + topSignal: null, + line: "Your health score today is 82.", + }), + }); const result = await maybeDispatchDailyBriefing( prisma, "u1", IN_WINDOW, deps, ); - expect(result).toBe("module-off"); - expect(deps.dispatch).not.toHaveBeenCalled(); + expect(result).toBe("sent"); + const payload = JSON.stringify(deps.dispatch.mock.calls[0]); + expect(payload).toContain("Your health score today is 82."); }); it("frequency cap: an ok ledger row earlier the SAME local day suppresses the second push", async () => { 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..1b3ac8349 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -44,6 +44,15 @@ vi.mock("@/lib/analytics/intraday-pulse-io", () => ({ loadIntradayPulse: vi.fn(), })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(), + aiCapabilityToServe: 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 +66,16 @@ 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, + aiCapabilityToServe, +} from "@/lib/ai/capabilities/gate"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { + AI_AVAILABLE, + aiUnavailable, +} from "@/__tests__/helpers/ai-capability-fixtures"; const SNAPSHOT = { body: { @@ -75,6 +94,7 @@ const SNAPSHOT = { briefingState: "ready", briefingUpdatedAt: null, briefingStale: false, + briefingAi: AI_AVAILABLE, }, locale: "en", }; @@ -94,6 +114,8 @@ 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); + vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE); }); describe("loadDailyDigest — S11/S12 extras cache", () => { @@ -145,13 +167,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 +213,91 @@ 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(aiCapabilityToServe).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(aiCapabilityToServe).mockResolvedValue( + aiUnavailable("user_disabled"), + ); + 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).mockResolvedValue( + aiUnavailable("operator_disabled"), + ); + 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/daily-briefing-push.ts b/src/lib/daily/daily-briefing-push.ts index 9cef2f919..ef62c1f24 100644 --- a/src/lib/daily/daily-briefing-push.ts +++ b/src/lib/daily/daily-briefing-push.ts @@ -32,7 +32,6 @@ import type { Locale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { ServerTranslator } from "@/lib/i18n/server-translator"; -import { isModuleEnabled } from "@/lib/modules/gate"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { userDayKey } from "@/lib/tz/format"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; @@ -72,7 +71,6 @@ export type DailyBriefingDispatchResult = | "suppressed-frequency" | "no-digest" | "opted-out" - | "module-off" | "outside-window" | "no-channel" | "missing-user" @@ -81,7 +79,6 @@ export type DailyBriefingDispatchResult = export interface DailyBriefingDispatchDeps { dispatch?: typeof dispatchNotification; loadDigest?: (user: User, now: Date, locale: Locale) => Promise; - isModuleEnabled?: typeof isModuleEnabled; } function loadDailyBriefingDigest( @@ -137,10 +134,15 @@ export function buildDailyBriefingPush( * 1. user missing → `missing-user` * 2. no opt-in (default OFF) → `opted-out` (no enabled DAILY_BRIEFING pref) * 3. outside morning window → `outside-window` - * 4. insights module off → `module-off` - * 5. already pushed today → `suppressed-frequency` (push_attempts ledger) - * 6. digest not substantive → `no-digest` - * 7. dispatch; no channel → `no-channel`; else `sent`. + * 4. already pushed today → `suppressed-frequency` (push_attempts ledger) + * 5. digest not substantive → `no-digest` + * 6. dispatch; no channel → `no-channel`; else `sent`. + * + * No module gate. The `insights` module is the AI analysis opt-out: with it + * off (or AI unavailable for any other reason) the digest carries no briefing + * lead or top signal, and the push body falls to the digest's deterministic + * line (the score, or the all-clear). A digest with nothing deterministic to + * say is skipped as `no-digest`, like any other. */ export async function maybeDispatchDailyBriefing( prisma: PrismaClient, @@ -150,7 +152,6 @@ export async function maybeDispatchDailyBriefing( ): Promise { const dispatch = deps.dispatch ?? dispatchNotification; const loadDigest = deps.loadDigest ?? loadDailyBriefingDigest; - const moduleGate = deps.isModuleEnabled ?? isModuleEnabled; try { const user = await prisma.user.findUnique({ where: { id: userId } }); @@ -187,13 +188,6 @@ export async function maybeDispatchDailyBriefing( return "outside-window"; } - // Insights is the digest's home module; a user who turned it off gets no - // digest surfaces at all, so no morning push either. - if (!(await moduleGate(userId, "insights"))) { - annotate({ action: { name: "daily.briefing_push.module_off" } }); - return "module-off"; - } - // One per user per LOCAL day. Anchored on the `push_attempts` ledger the // senders already write (`ok` on a successful delivery), exactly like the // coach-nudge cap: whichever trigger fired first this morning left an `ok` 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 79d68db23..c2de4da55 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -21,7 +21,10 @@ import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; -import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; +import { + aiCapabilityForRecord, + aiCapabilityToServe, +} from "@/lib/ai/capabilities/gate"; import { userDayKey } from "@/lib/tz/format"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; @@ -30,6 +33,7 @@ import { isArrivalKind } from "@/lib/arrivals/types"; import { buildDailyDigest, type DailyDigest, + type DailyDigestAi, type DailyDigestArrival, type DailyDigestCoachPlan, type DailyDigestEcg, @@ -314,6 +318,7 @@ function toDigestArrival( lineEncrypted: Uint8Array | null; generatedAt: Date | null; }, + /** Whether the record's `reactionLines` text may be shown. */ linesServable: boolean, ): DailyDigestArrival | null { // A kind this build does not know about (a row written by a newer version) @@ -323,8 +328,8 @@ function toDigestArrival( 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. - // A model-written line is never served while `reactionLines` is - // unavailable for the record, whatever the reason; the marker still is. + // A model-written line is never served (nor decrypted) while + // `reactionLines` is unavailable for the record; the marker still is. if (linesServable && row.generatedAt !== null && row.lineEncrypted !== null) { try { line = decryptFromBytes(row.lineEncrypted); @@ -372,7 +377,6 @@ export async function loadDailyDigest( ); const [ - linesServable, { body: snapshot, locale }, modules, syncRows, @@ -381,10 +385,9 @@ export async function loadDailyDigest( ecgRow, arrivalRows, nextVisitRow, + coachAi, + reactionLinesAi, ] = await Promise.all([ - aiCapabilityForRecord(user.id, "reactionLines").then( - (capability) => capability.available, - ), readDashboardSnapshotCached(user, undefined, { locale: options.locale }), resolveModuleMap(user.id), prisma.integrationStatus.findMany({ @@ -478,8 +481,26 @@ export async function loadDailyDigest( practitioner: { select: { name: true } }, }, }), + // The check-in opens the Coach, so it asks as the person in front of + // the screen; the reaction line is stored text, shown whenever the + // record's own state allows it, whoever is reading. + aiCapabilityForRecord(user.id, "coach"), + aiCapabilityToServe(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, @@ -555,35 +576,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 @@ -625,6 +641,7 @@ export async function loadDailyDigest( const digest = buildDailyDigest( { now, + ai, todayEndExclusive, modules, enabledHeroItemKinds: @@ -647,7 +664,7 @@ export async function loadDailyDigest( todayLocalDate, dismissedItemKeys, arrivals: arrivalRows - .map((row) => toDigestArrival(row, linesServable)) + .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..0a2ab0f38 --- /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 aiCapabilityToServe = 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/gate", () => ({ + aiCapabilityToServe: (...args: unknown[]) => aiCapabilityToServe(...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(); + aiCapabilityToServe.mockReset(); +}); + +describe("readDashboardSnapshotCached — briefing capability per read", () => { + it("resolves the briefing capability for the record being read", async () => { + aiCapabilityToServe.mockResolvedValue(AI_AVAILABLE); + await readDashboardSnapshotCached(USER); + expect(aiCapabilityToServe).toHaveBeenCalledWith( + "user-briefing-gate", + "briefing", + ); + }); + + it("hides the cached briefing on the next read once the capability goes away", async () => { + aiCapabilityToServe.mockResolvedValue(AI_AVAILABLE); + const first = await readDashboardSnapshotCached(USER); + expect(first.body.briefing).not.toBeNull(); + expect(first.body.briefingAi).toEqual(AI_AVAILABLE); + + aiCapabilityToServe.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 () => { + aiCapabilityToServe.mockResolvedValue(aiUnavailable("operator_disabled")); + const hidden = await readDashboardSnapshotCached(USER); + expect(hidden.body.briefing).toBeNull(); + + aiCapabilityToServe.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..317455e16 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/gate", () => ({ + aiCapabilityToServe: 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 aa029bc35..25de8ee14 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([]); @@ -702,14 +695,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({ @@ -719,22 +705,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 () => { @@ -750,6 +723,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()); @@ -1265,7 +1298,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({ @@ -1280,8 +1313,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..41cfb684c 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 { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; 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,25 @@ 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, + ), + aiCapabilityToServe(user.id, "briefing"), + ]); + + // The briefing is model text: shown only while the record's `briefing` + // capability is available, decided on every read rather than baked into + // the cached body. The record's own state decides, whoever is reading, so a + // delegate sees the owner's briefing exactly when the owner would. + const body = applyBriefingCapability(cached, briefingAi); return { body, locale }; } diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 3efc8ca6a..d6d1e2baa 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -76,8 +76,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, @@ -502,6 +502,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; } @@ -516,7 +525,6 @@ export interface SnapshotUserInput { gender: string | null; glucoseUnit: string | null; onboardingTourCompleted: boolean; - disableCoach: boolean; insightsCachedText: string | null; insightsCachedAt: Date | null; /** @@ -891,6 +899,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 @@ -908,7 +941,6 @@ function parseCachedBriefing(cachedText: string | null): DailyBriefing | null { */ async function liftBriefing( user: SnapshotUserInput, - coachEnabled: boolean, hasProvider: () => Promise, readerLocale: Locale, ): Promise<{ @@ -917,14 +949,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 @@ -1262,7 +1289,6 @@ export async function buildDashboardSnapshot( slimRaw, moodRaw, extrasResult, - flags, medsToday, modules, scoreRings, @@ -1289,7 +1315,6 @@ export async function buildDashboardSnapshot( ), ) : Promise.resolve(null), - time("flags", () => getAssistantFlags()), medsTodayPromise, modulesPromise, // v1.27.7 — the selected hero score rings, resolved through the @@ -1350,27 +1375,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); @@ -1382,8 +1407,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", () => @@ -1396,7 +1420,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, @@ -1475,6 +1499,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__/index.test.ts b/src/lib/feature-flags/__tests__/index.test.ts index e01e3c970..67d146391 100644 --- a/src/lib/feature-flags/__tests__/index.test.ts +++ b/src/lib/feature-flags/__tests__/index.test.ts @@ -22,10 +22,8 @@ vi.mock("@/lib/logging/context", () => ({ import { prisma } from "@/lib/db"; import { ASSISTANT_FLAGS_DEFAULT, - AssistantDisabledError, getAssistantFlags, loadAssistantSwitches, - requireAssistantSurface, resolveAssistantFlags, } from "../index"; @@ -139,52 +137,6 @@ describe("loadAssistantSwitches", () => { }); }); -describe("requireAssistantSurface", () => { - it("returns the resolved flag set when the surface is enabled", async () => { - FIND.mockResolvedValue({ - assistantEnabled: true, - assistantCoachEnabled: true, - assistantBriefingEnabled: true, - assistantInsightStatusEnabled: true, - assistantDocumentAiEnabled: true, - }); - const flags = await requireAssistantSurface("coach"); - expect(flags.coach).toBe(true); - }); - - it("throws AssistantDisabledError when the sub-flag is off", async () => { - FIND.mockResolvedValue({ - assistantEnabled: true, - assistantCoachEnabled: false, - assistantBriefingEnabled: true, - assistantInsightStatusEnabled: true, - assistantDocumentAiEnabled: true, - }); - await expect(requireAssistantSurface("coach")).rejects.toThrow( - AssistantDisabledError, - ); - }); - - it("throws AssistantDisabledError when the master kills the surface", async () => { - FIND.mockResolvedValue({ - assistantEnabled: false, - assistantCoachEnabled: true, - assistantBriefingEnabled: true, - assistantInsightStatusEnabled: true, - assistantDocumentAiEnabled: true, - }); - await expect(requireAssistantSurface("briefing")).rejects.toThrow( - AssistantDisabledError, - ); - }); - - it("AssistantDisabledError carries the surface-tagged errorCode", () => { - const err = new AssistantDisabledError("insightStatus"); - expect(err.surface).toBe("insightStatus"); - expect(err.errorCode).toBe("assistant.disabled.insightStatus"); - }); -}); - describe("per-request memoisation", () => { // v1.4.33 — When the same request opens the Coach drawer, five gated // routes (`/api/insights/chat`, the rail list, each `-status` 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 c24743a57..1a6cdd615 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 @@ -1,6 +1,6 @@ /** * Every assistant switch an operator can flip has to move something, a - * switch that was retired stays retired, and the retired gate only shrinks. + * switch that was retired stays retired, and the retired gate stays gone. * * `healthScoreExplainer` did not move anything. It gated a caption beside the * Health-Score delta; the caption's component went when the health score was @@ -21,17 +21,15 @@ * it. A switch whose reader lands with the routes it covers is named in * `AWAITING_READER` with the reason, and the entry fails the moment a * reader appears, so it cannot outlive its reason. - * 3. `requireAssistantSurface` is the retired gate. It answers only the - * operator layer; `requireAiCapability` answers every layer. The routes - * that still call it are frozen below, in both directions: a new caller - * fails, and a caller that moved to the capability gate fails until its - * entry is removed. When the list is empty the function and - * `AssistantDisabledError` are deleted and this guard asserts zero. + * 3. `requireAssistantSurface` and `AssistantDisabledError` are gone. They + * answered only the operator layer; `requireAiCapability` answers every + * layer with the same `assistant.disabled.` code for the operator + * case. Nothing may define or call them again. * 4. The matchers find what they are meant to judge (non-zero counts), so a * matcher that stopped matching cannot agree with an empty world. * * Mutation checks: adding `await requireAssistantSurface("coach")` to any - * route outside the frozen list turns guard 3 red by file name; deleting the + * route turns guard 3 red by file name; deleting the * `requireAiCapability("documentAi"` calls from the document routes (the only * reader of that switch's capabilities in the route tree) turns guard 2 red; * putting `assistantCorrelationsEnabled` back on the schema turns guard 1 red. @@ -102,34 +100,6 @@ const PLUMBING = [ */ const AWAITING_READER: Record = {}; -/** - * Every file that still calls the retired gate. Only ever shrinks. - */ -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/coach/page.tsx", -]; - function sourceFiles(dir: string, acc: string[] = []): string[] { for (const entry of readdirSync(dir)) { if (entry === "generated" || entry === "__tests__") continue; @@ -241,7 +211,6 @@ describe("no assistant sub-switch is a dead switch", () => { .join("\n"); function hasReader(toggle: string): boolean { - if (corpus.includes(`requireAssistantSurface("${toggle}")`)) return true; if (new RegExp(`flags\\.${toggle}\\b`).test(corpus)) return true; const capabilities = AI_CAPABILITY_KEYS.filter( (key) => AI_CAPABILITIES[key].operatorSwitch === toggle, @@ -275,37 +244,28 @@ describe("no assistant sub-switch is a dead switch", () => { }); }); -describe("the retired gate only shrinks", () => { - const CALL = /(?:await|return|=)\s*requireAssistantSurface\s*\(/; - const callers = SOURCES.filter( - (file) => - rel(file) !== "src/lib/feature-flags/index.ts" && CALL.test(code(file)), - ) - .map(rel) - .sort(); +describe("the retired gate stays gone", () => { + const NAMES = /\b(?:requireAssistantSurface|AssistantDisabledError)\b/; - it("finds the callers it is meant to judge", () => { - // A matcher that stopped matching would agree with an empty list. - expect(callers.length).toBe(RETIRED_GATE_CALLERS.length); - }); - - it("gains no new caller", () => { - const added = callers.filter( - (file) => !RETIRED_GATE_CALLERS.includes(file), - ); + it("is neither defined nor called anywhere in the source tree", () => { + // The matcher is proven able to match before its empty answer counts. + expect(NAMES.test('await requireAssistantSurface("coach")')).toBe(true); + expect(NAMES.test("new AssistantDisabledError(s)")).toBe(true); + expect(SOURCES.length).toBeGreaterThan(100); + const offenders = SOURCES.filter((file) => NAMES.test(code(file))).map(rel); expect( - added, - "a new route calls requireAssistantSurface; use requireAiCapability", + offenders, + "the retired operator-only gate is back; use requireAiCapability", ).toEqual([]); }); - it("forgets a caller once it has moved to the capability gate", () => { - const moved = RETIRED_GATE_CALLERS.filter( - (file) => !callers.includes(file), + it("keeps the operator code family on the capability refusal", async () => { + const { aiRefusal } = await import("@/lib/ai/capabilities/refusal"); + expect(aiRefusal("coach", "operator_disabled").meta.errorCode).toBe( + "assistant.disabled.coach", + ); + expect(aiRefusal("statusText", "operator_disabled").meta.errorCode).toBe( + "assistant.disabled.insightStatus", ); - expect( - moved, - "these no longer call requireAssistantSurface; remove them from the list", - ).toEqual([]); }); }); diff --git a/src/lib/feature-flags/index.ts b/src/lib/feature-flags/index.ts index b8a628e85..a89e91587 100644 --- a/src/lib/feature-flags/index.ts +++ b/src/lib/feature-flags/index.ts @@ -2,10 +2,7 @@ import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; import { memoizePerRequest } from "@/lib/request-cache"; -import type { - AiOperatorSwitch, - AiOperatorSwitchSet, -} from "@/lib/ai/capabilities/types"; +import type { AiOperatorSwitchSet } from "@/lib/ai/capabilities/types"; /** * The operator's assistant switches: input one of the AI capability resolver @@ -21,9 +18,6 @@ import type { * a route that serves or calls a model asks the resolver, not this file. */ -/** A sub-switch name. Also the last segment of `assistant.disabled.`. */ -export type AssistantSurface = AiOperatorSwitch; - /** The resolved switch set, master applied. */ export type AssistantFlagSet = AiOperatorSwitchSet; @@ -104,41 +98,3 @@ export function resolveAssistantFlags(raw: AssistantFlagSet): AssistantFlagSet { if (!raw.enabled) return { ...ASSISTANT_FLAGS_OFF }; return { ...raw }; } - -/** - * The operator-switch-only refusal, `403 { meta: { errorCode: - * "assistant.disabled." } }`. - * - * Being retired. It answers only the operator layer, so a route gated on it - * refuses when a switch is off and says nothing about modules, consent or the - * provider; `requireAiCapability` (`src/lib/ai/capabilities/gate.ts`) answers - * all of them with the same code for the operator case. The routes that still - * call `requireAssistantSurface` are frozen by - * `src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts`: the list - * may only shrink, and this class goes with its last caller. - */ -export class AssistantDisabledError extends Error { - readonly surface: AssistantSurface; - readonly errorCode: string; - - constructor(surface: AssistantSurface) { - super(`Assistant surface "${surface}" is disabled on this server`); - this.name = "AssistantDisabledError"; - this.surface = surface; - this.errorCode = `assistant.disabled.${surface}`; - } -} - -/** - * Throws `AssistantDisabledError` when the switch (or the master) is off. - * Retired in favour of `requireAiCapability`; see the class above. - */ -export async function requireAssistantSurface( - surface: AssistantSurface, -): Promise { - const flags = await getAssistantFlags(); - if (!flags[surface]) { - throw new AssistantDisabledError(surface); - } - return flags; -} diff --git a/src/lib/insights/__tests__/biomarker-status.test.ts b/src/lib/insights/__tests__/biomarker-status.test.ts index 70c8acdef..50c586f06 100644 --- a/src/lib/insights/__tests__/biomarker-status.test.ts +++ b/src/lib/insights/__tests__/biomarker-status.test.ts @@ -31,6 +31,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); import { prisma } from "@/lib/db"; diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index 7e9ef8e87..191b5e50e 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -41,6 +41,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/bmi-status.test.ts b/src/lib/insights/__tests__/bmi-status.test.ts index b133c8df9..0a5122681 100644 --- a/src/lib/insights/__tests__/bmi-status.test.ts +++ b/src/lib/insights/__tests__/bmi-status.test.ts @@ -39,6 +39,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts index ba835d180..73cb4a4de 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts @@ -53,6 +53,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, })), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index 4bcad810e..e40fa860c 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -53,6 +53,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, })), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts index 9ca1ab0b9..ee2d27221 100644 --- a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts @@ -78,6 +78,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, })), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index eb101bd99..c9165b0c9 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -57,6 +57,8 @@ vi.mock("@/lib/ai/capabilities/egress", () => ({ })); vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a), + aiCapabilityToServe: (...args: unknown[]) => + (aiCapabilityForRecord as (...a: unknown[]) => unknown)(...args), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index 791557886..a667ad361 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -51,6 +51,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, })), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts index 92607581e..83d13f02f 100644 --- a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts +++ b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts @@ -50,6 +50,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, })), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts index 7cc7486a0..825d2a6e8 100644 --- a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts +++ b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts @@ -55,6 +55,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, })), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts index e6f58c204..4f43377bc 100644 --- a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts +++ b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts @@ -57,6 +57,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, })), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts index e5fd0f0c8..5dded1ca4 100644 --- a/src/lib/insights/__tests__/general-status.test.ts +++ b/src/lib/insights/__tests__/general-status.test.ts @@ -31,6 +31,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index 8ed1626a3..3a9e39463 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -44,6 +44,8 @@ vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a), + aiCapabilityToServe: (...args: unknown[]) => + (aiCapabilityForRecord as (...a: unknown[]) => unknown)(...args), aiCapabilityForJob: vi.fn(async () => ({ available: true, reason: null, diff --git a/src/lib/insights/__tests__/locale-pipeline.test.ts b/src/lib/insights/__tests__/locale-pipeline.test.ts index cb8452d11..4254c33be 100644 --- a/src/lib/insights/__tests__/locale-pipeline.test.ts +++ b/src/lib/insights/__tests__/locale-pipeline.test.ts @@ -98,6 +98,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index 7252318f9..858ad783d 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -29,6 +29,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index acec22802..091f2cf74 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -38,6 +38,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/mood-status.test.ts b/src/lib/insights/__tests__/mood-status.test.ts index f0dce0b46..ca7e09dcc 100644 --- a/src/lib/insights/__tests__/mood-status.test.ts +++ b/src/lib/insights/__tests__/mood-status.test.ts @@ -30,6 +30,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/payload-budget.test.ts b/src/lib/insights/__tests__/payload-budget.test.ts index a20504dbd..8b2286742 100644 --- a/src/lib/insights/__tests__/payload-budget.test.ts +++ b/src/lib/insights/__tests__/payload-budget.test.ts @@ -54,6 +54,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/pulse-status-timeout.test.ts b/src/lib/insights/__tests__/pulse-status-timeout.test.ts index 874b78cec..7a3ea488f 100644 --- a/src/lib/insights/__tests__/pulse-status-timeout.test.ts +++ b/src/lib/insights/__tests__/pulse-status-timeout.test.ts @@ -61,6 +61,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index 54825a867..0e3ef9cff 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -42,6 +42,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/status-cache-delegated.test.ts b/src/lib/insights/__tests__/status-cache-delegated.test.ts index 049fca9b4..b9018f420 100644 --- a/src/lib/insights/__tests__/status-cache-delegated.test.ts +++ b/src/lib/insights/__tests__/status-cache-delegated.test.ts @@ -38,6 +38,8 @@ vi.mock("@/lib/ai/provider", () => ({ vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: (...args: unknown[]) => aiCapabilityForRecord(...(args as [])), + aiCapabilityToServe: (...args: unknown[]) => + (aiCapabilityForRecord as (...a: unknown[]) => unknown)(...args), })); vi.mock("@/lib/sharing/delegated-generation", () => ({ diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index 210abb34e..a6a36064f 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -30,6 +30,8 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ const aiCapabilityForRecord = vi.fn(); vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a), + aiCapabilityToServe: (...args: unknown[]) => + (aiCapabilityForRecord as (...a: unknown[]) => unknown)(...args), })); const probeProviderPresence = vi.fn(); diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index b1bbad4f5..c50af944a 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -75,7 +75,10 @@ vi.mock("@/lib/ai/provider", () => ({ const { aiCapabilityForRecord } = vi.hoisted(() => ({ aiCapabilityForRecord: vi.fn(), })); -vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord, + aiCapabilityToServe: aiCapabilityForRecord, +})); const { runRawCompletionWithFallback } = vi.hoisted(() => ({ runRawCompletionWithFallback: vi.fn(), diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index 3e230f469..b07ef29e3 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -48,7 +48,10 @@ const { probeProviderPresence: vi.fn(), enqueueStatusGeneration: vi.fn(), })); -vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord, + aiCapabilityToServe: aiCapabilityForRecord, +})); vi.mock("@/lib/ai/provider", async (importOriginal) => ({ ...(await importOriginal()), probeProviderPresence, 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 89c895df1..7d1acb045 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/insights/narrative/__tests__/period-narrative-generate.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts index 21286fb9b..e44257ee4 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts @@ -20,7 +20,10 @@ vi.mock("@/lib/db", () => ({ prisma: {} })); const { aiCapabilityForRecord } = vi.hoisted(() => ({ aiCapabilityForRecord: vi.fn(), })); -vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord, + aiCapabilityToServe: aiCapabilityForRecord, +})); const UNAVAILABLE = { available: false, diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index 89d4209df..bb6cfc903 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -32,7 +32,7 @@ import { annotate } from "@/lib/logging/context"; import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets"; import { dayRotatedSeed } from "@/lib/ai/prompts/opener-archetype"; import { runStatusCompletion } from "@/lib/insights/status-provider"; -import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { buildPeriodNarrativeContext, type NarrativePeriod, @@ -565,14 +565,15 @@ export async function readPeriodNarrative( /** * Whether a stored narrative may be served: always for the deterministic * narrative, and for model-written prose only while `periodNarrative` is - * available for the record. + * available for the record, answered from the record's own state whoever is + * reading. */ async function narrativeServable( userId: string, providerType: string | null, ): Promise { if (providerType === DETERMINISTIC_PROVIDER_TYPE) return true; - return (await aiCapabilityForRecord(userId, "periodNarrative")).available; + return (await aiCapabilityToServe(userId, "periodNarrative")).available; } /** Prisma `Bytes` ↔ ciphertext, mirroring the CoachMessage helper. */ diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 126460113..71849bfb4 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -1,7 +1,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; -import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import type { AiUnavailableReason } from "@/lib/ai/capabilities/types"; import { probeProviderPresence } from "@/lib/ai/provider"; import { @@ -161,10 +161,12 @@ async function readStatusNote( /** * Whether stored status notes may be served, or re-dated, for this record. - * Every reader and both unchanged-data gates ask this first. + * Every reader and both unchanged-data gates ask this first. Answered from + * the record's own state, so a delegate reads the owner's notes exactly when + * the owner would; starting work is a separate question (see below). */ async function statusTextServable(userId: string): Promise { - return (await aiCapabilityForRecord(userId, "statusText")).available; + return (await aiCapabilityToServe(userId, "statusText")).available; } /** @@ -708,7 +710,9 @@ export async function resolveReadOnlyStatusMiss(args: { metric: InsightStatusScope; locale: SupportedLocale; }): Promise { - const capability = await aiCapabilityForRecord(args.userId, "statusText"); + // Serving view: whether the record's notes may be shown at all. Starting + // work for a delegate is refused by the suppression branch below. + const capability = await aiCapabilityToServe(args.userId, "statusText"); if (!capability.available) { const reason = capability.reason ?? "check_failed"; annotate({ diff --git a/src/lib/insights/status-unavailable.ts b/src/lib/insights/status-unavailable.ts new file mode 100644 index 000000000..a205a7743 --- /dev/null +++ b/src/lib/insights/status-unavailable.ts @@ -0,0 +1,70 @@ +/** + * What a status route answers while the `statusText` capability is + * unavailable. + * + * The status family (`*-status`, `metric-status`, `biomarker-assessment`) is + * a mixed read: the card around the note is data, the note is model output. + * With the capability unavailable, for any reason, the route answers 200 with + * no note and says why: + * + * - `text: null`: stored model text is never served, however fresh. + * - `preparing: false`: nothing is warming, because nothing was enqueued. + * - `hasProvider`: provider presence and nothing else. A shipped native + * client renders "no provider" copy for `false`, so it must not be false + * because the operator switched notes off or consent is missing. + * - `ai`: the resolved state, so a client can say why without guessing. + * + * The route builds this instead of calling the generator, so no cache row is + * read and no generation is queued. + */ +import { probeProviderPresence } from "@/lib/ai/provider"; +import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; + +export interface UnavailableStatusBody { + hasProvider: boolean; + text: null; + cached: false; + updatedAt: null; + preparing: false; + ai: AiCapabilityState; +} + +export async function unavailableStatusBody( + recordId: string, + ai: AiCapabilityState, +): Promise { + return { + hasProvider: await probeProviderPresence(recordId), + text: null, + cached: false, + updatedAt: null, + preparing: false, + ai, + }; +} + +/** The medication-compliance card's shape: a summary plus per-medication notes. */ +export interface UnavailableComplianceStatusBody { + hasProvider: boolean; + summary: null; + medications: []; + cached: false; + updatedAt: null; + preparing: false; + ai: AiCapabilityState; +} + +export async function unavailableComplianceStatusBody( + recordId: string, + ai: AiCapabilityState, +): Promise { + return { + hasProvider: await probeProviderPresence(recordId), + summary: null, + medications: [], + cached: false, + updatedAt: null, + preparing: false, + ai, + }; +} diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index 051f2b0a2..aed073f31 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 { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; +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/jobs/daily-briefing.ts b/src/lib/jobs/daily-briefing.ts index d91d0097a..5edc1a1d7 100644 --- a/src/lib/jobs/daily-briefing.ts +++ b/src/lib/jobs/daily-briefing.ts @@ -40,7 +40,6 @@ export interface DailyBriefingTickSummary { suppressedFrequency: number; noDigest: number; optedOut: number; - moduleOff: number; noChannel: number; outsideWindow: number; failed: number; @@ -74,7 +73,6 @@ export async function runDailyBriefingTick( suppressedFrequency: 0, noDigest: 0, optedOut: 0, - moduleOff: 0, noChannel: 0, outsideWindow: 0, failed: 0, @@ -123,9 +121,6 @@ export async function runDailyBriefingTick( case "opted-out": summary.optedOut += 1; break; - case "module-off": - summary.moduleOff += 1; - break; case "no-channel": summary.noChannel += 1; break; diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index 166dc7c14..70ece8c3d 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -663,7 +663,6 @@ export async function registerStatusQueues( suppressed_frequency: summary.suppressedFrequency, no_digest: summary.noDigest, opted_out: summary.optedOut, - module_off: summary.moduleOff, no_channel: summary.noChannel, outside_window: summary.outsideWindow, failed: summary.failed, 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 30e2cbab9..fbf9ba7be 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"; import { AI_EXTRACTION_UNAVAILABLE_DESCRIPTION, aiExtractionRefusalDescription, @@ -743,7 +745,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", @@ -788,7 +790,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: { @@ -810,14 +812,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, }, }, @@ -827,7 +829,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", @@ -860,7 +862,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", @@ -906,7 +908,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", @@ -1130,7 +1132,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.", @@ -1147,7 +1149,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.", @@ -1169,7 +1171,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: @@ -1192,7 +1194,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": { @@ -1218,6 +1220,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", ), @@ -1233,7 +1238,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.", @@ -1259,7 +1264,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: @@ -1277,6 +1282,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", ), @@ -1290,7 +1298,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: { @@ -1318,6 +1326,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", ), @@ -1929,7 +1940,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..79d2e749d 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`.", @@ -595,7 +596,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Blood-pressure assessment", description: - "Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + "Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.", requestParams: { query: insightStatusQuery, }, @@ -621,7 +622,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Pulse assessment", description: - "Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + "Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.", requestParams: { query: insightStatusQuery, }, @@ -647,7 +648,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Weight assessment", description: - "Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + "Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.", requestParams: { query: insightStatusQuery, }, @@ -673,7 +674,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "BMI assessment", description: - "Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + "Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.", requestParams: { query: insightStatusQuery, }, @@ -699,7 +700,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Mood assessment", description: - "Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + "Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.", requestParams: { query: insightStatusQuery, }, @@ -725,7 +726,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Medication-compliance assessment", description: - "Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good envelope meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + "Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good envelope meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.", requestParams: { query: insightStatusQuery, }, @@ -752,7 +753,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Generic per-HealthKit-metric assessment", description: - "v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate, sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed registry enum. Auth via cookie or Bearer.", + "v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate, sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed registry enum. The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.", requestParams: { query: metricStatusQuery, }, @@ -778,7 +779,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Per-biomarker assessment", description: - "Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history. Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` without an LLM call. Auth via cookie or Bearer.", + "Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history. Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` without an LLM call. The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer.", requestParams: { query: biomarkerAssessmentQuery, }, @@ -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 0abc60452..a629ac1e2 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 @@ -73,13 +88,13 @@ export const metricStatusResponse = z hasProvider: z .boolean() .describe( - "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", + "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never false because the operator switched notes off or consent is missing; `ai` says that.", ), text: z .string() .nullable() .describe( - "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the metric has insufficient data.", + "The assessment note (plain text, rendered as React text children). Null while a first generation is preparing, when the metric has insufficient data, and whenever the `statusText` capability is unavailable (see `ai`).", ), cached: z .boolean() @@ -104,8 +119,11 @@ export const metricStatusResponse = z .boolean() .optional() .describe( - "True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state.", + "True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state. Still computed while `ai` is unavailable.", ), + ai: aiCapabilityState.describe( + "The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is false, the cache is not read and nothing is warmed.", + ), }) .meta({ id: "MetricStatusResponse", @@ -391,7 +409,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({ @@ -708,13 +731,13 @@ export const insightStatusResponse = z hasProvider: z .boolean() .describe( - "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", + "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never false because the operator switched notes off or consent is missing; `ai` says that.", ), text: z .string() .nullable() .describe( - "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing.", + "The assessment note (plain text, rendered as React text children). Null while a first generation is preparing, and whenever the `statusText` capability is unavailable (see `ai`).", ), cached: z .boolean() @@ -735,6 +758,9 @@ export const insightStatusResponse = z .describe( "True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.", ), + ai: aiCapabilityState.describe( + "The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is false, the cache is not read and nothing is warmed.", + ), }) .meta({ id: "InsightStatusResponse", @@ -766,13 +792,13 @@ export const biomarkerAssessmentResponse = z hasProvider: z .boolean() .describe( - "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", + "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never false because the operator switched notes off or consent is missing; `ai` says that.", ), text: z .string() .nullable() .describe( - "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the marker has no numeric readings.", + "The assessment note (plain text, rendered as React text children). Null while a first generation is preparing, when the marker has no numeric readings, and whenever the `statusText` capability is unavailable (see `ai`).", ), cached: z .boolean() @@ -799,6 +825,9 @@ export const biomarkerAssessmentResponse = z .describe( "True when the marker has no numeric readings; no assessment is generated (no LLM call). The card is not rendered.", ), + ai: aiCapabilityState.describe( + "The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is false, the cache is not read and nothing is warmed.", + ), }) .meta({ id: "BiomarkerAssessmentResponse", @@ -814,13 +843,13 @@ export const medicationComplianceStatusResponse = z hasProvider: z .boolean() .describe( - "False when the user has no usable AI provider — `summary` then carries the generic no-key guidance.", + "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never false because the operator switched notes off or consent is missing; `ai` says that.", ), summary: z .string() .nullable() .describe( - "The overall compliance narrative (plain text). Null while a first generation is preparing.", + "The overall compliance note (plain text). Null while a first generation is preparing, and whenever the `statusText` capability is unavailable (see `ai`).", ), medications: z .array( @@ -859,6 +888,9 @@ export const medicationComplianceStatusResponse = z .describe( "True when the envelope is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded).", ), + ai: aiCapabilityState.describe( + "The `statusText` capability behind the note. While it is unavailable `summary` is null, `medications` is empty, `preparing` is false, the cache is not read and nothing is warmed.", + ), }) .meta({ id: "MedicationComplianceStatusResponse", @@ -946,15 +978,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 @@ -1407,12 +1449,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(), }) @@ -1424,8 +1476,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`)."), @@ -1455,7 +1507,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({ @@ -2675,8 +2727,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", @@ -2715,12 +2770,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 f34275a95..f6b1dba67 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({ diff --git a/src/lib/request-cache.ts b/src/lib/request-cache.ts index afb4ca83c..ec40f6a9a 100644 --- a/src/lib/request-cache.ts +++ b/src/lib/request-cache.ts @@ -5,7 +5,7 @@ * (api-handler wraps every handler in `eventStorage.run(builder, …)`), * yet the gate helpers and snapshot builders re-read the same row on * every call. The Coach drawer opening fires five fetches in parallel, - * each of which lands a `requireAssistantSurface("coach")` call — + * each of which resolves the AI capabilities — * five separate `SELECT` against `AppSettings.singleton` for the same * value within the same wall-clock millisecond. * @@ -44,7 +44,7 @@ function getCache(builder: WideEventBuilder): Map { * The cached value is the Promise itself, so concurrent callers within * the same request await one outstanding compute instead of starting * their own — important for the gate-cascade pattern where five - * mounts fire `requireAssistantSurface()` in parallel. + * mounts resolve the same capabilities in parallel. */ export function memoizePerRequest( key: string, diff --git a/tests/integration/ai-optional-actions.test.ts b/tests/integration/ai-optional-actions.test.ts new file mode 100644 index 000000000..5a29d565a --- /dev/null +++ b/tests/integration/ai-optional-actions.test.ts @@ -0,0 +1,233 @@ +/** + * AI actions refuse with the capability envelope, through the real handlers + * and the real `apiHandler` against real Postgres. + * + * Each case asserts the exact status, `meta.errorCode`, `meta.capability` + * and `meta.reason`, and that nothing downstream ran: no provider was asked + * (the runner and the chain resolver are spies), no cached text was written. + * The chat keeps its `coach.provider.none` SSE frame for a missing provider, + * now carrying `reason`. + * + * Mutation check: removing `requireAiCapability("briefing")` from the + * generate GET lets every state through to the cache read and turns all five + * briefing cases red. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + enterState, + makeUser, + resetWorld, + setSwitches, + signIn, + type StateName, +} from "./ai-optional-fixtures"; +import { getPrismaClient } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +// Provider spies: a refusal must never reach either. +const runner = vi.hoisted(() => ({ + raw: vi.fn(), + streaming: vi.fn(), + chain: vi.fn(), +})); +vi.mock("@/lib/ai/provider-runner", async (importOriginal) => ({ + ...(await importOriginal()), + runRawCompletionWithFallback: runner.raw, + runStreamingRawCompletionWithFallback: runner.streaming, +})); +vi.mock("@/lib/ai/provider", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveProviderChain: (...args: unknown[]) => { + runner.chain(...args); + return actual.resolveProviderChain( + ...(args as Parameters), + ); + }, + }; +}); + +interface Envelope { + data: unknown; + error: string | null; + meta?: { + errorCode?: string; + capability?: string; + reason?: string; + module?: string; + }; +} + +async function generateGet(): Promise<{ status: number; body: Envelope }> { + const { GET } = await import("@/app/api/insights/generate/route"); + const res = await GET( + new NextRequest("http://localhost/api/insights/generate"), + ); + return { status: res.status, body: (await res.json()) as Envelope }; +} + +async function generatePost(): Promise<{ status: number; body: Envelope }> { + const { POST } = await import("@/app/api/insights/generate/route"); + const res = await POST( + new NextRequest("http://localhost/api/insights/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ force: true }), + }), + ); + return { status: res.status, body: (await res.json()) as Envelope }; +} + +async function chatPost(): Promise { + const { POST } = await import("@/app/api/insights/chat/route"); + return POST( + new NextRequest("http://localhost/api/insights/chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "How was my week?" }), + }), + ); +} + +async function cachedAt(userId: string): Promise { + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: userId }, + select: { insightsCachedAt: true }, + }); + return row.insightsCachedAt; +} + +beforeEach(async () => { + await resetWorld(); + runner.raw.mockReset(); + runner.streaming.mockReset(); + runner.chain.mockReset(); +}); + +const BRIEFING_REFUSALS: ReadonlyArray< + [StateName, number, string, string, string | undefined] +> = [ + ["S1", 403, "assistant.disabled.briefing", "operator_disabled", undefined], + ["S2", 403, "assistant.disabled.briefing", "operator_disabled", undefined], + ["S3", 422, "ai.provider.none", "no_provider", undefined], + ["S4", 403, "consent.ai.required", "consent_required", undefined], + ["S6", 403, "module.disabled", "user_disabled", "insights"], +]; + +describe("insights/generate refuses by the briefing capability", () => { + it.each(BRIEFING_REFUSALS)( + "%s: GET and POST answer %i %s", + async (state, status, errorCode, reason, module) => { + const world = await enterState(state); + const before = await cachedAt(world.recordId); + for (const call of [generateGet, generatePost]) { + const { status: got, body } = await call(); + expect(got, `${state} ${call.name}`).toBe(status); + expect(body.data).toBeNull(); + expect(body.meta).toMatchObject({ + errorCode, + capability: "briefing", + reason, + ...(module ? { module } : {}), + }); + } + expect(runner.raw).not.toHaveBeenCalled(); + expect(runner.chain).not.toHaveBeenCalled(); + expect(await cachedAt(world.recordId)).toEqual(before); + }, + ); + + it("hiding the Coach does not refuse the briefing (S5)", async () => { + const world = await enterState("S5"); + const { encrypt } = await import("@/lib/crypto"); + await getPrismaClient().user.update({ + where: { id: world.recordId }, + data: { + // The read resolves (never calls) the chain, so the key must decrypt. + aiAnthropicKeyEncrypted: encrypt("sk-ant-integration"), + insightsCachedText: JSON.stringify({ + dailyBriefing: { paragraph: "Held text.", keyFindings: [] }, + }), + insightsCachedAt: new Date(), + insightsCachedLocale: "en", + }, + }); + const { status } = await generateGet(); + expect(status).toBe(200); + }); +}); + +describe("insights/chat refuses by the coach capability", () => { + it.each([ + ["S1", 403, "assistant.disabled.coach", "operator_disabled", undefined], + ["S5", 403, "module.disabled", "user_disabled", "coach"], + ["S4", 403, "consent.ai.required", "consent_required", undefined], + ] as const)( + "%s: POST answers %i %s before any stream opens", + async (state, status, errorCode, reason, module) => { + await enterState(state); + const res = await chatPost(); + expect(res.status).toBe(status); + const body = (await res.json()) as Envelope; + expect(body.meta).toMatchObject({ + errorCode, + capability: "coach", + reason, + ...(module ? { module } : {}), + }); + expect(runner.raw).not.toHaveBeenCalled(); + expect(runner.streaming).not.toHaveBeenCalled(); + expect(runner.chain).not.toHaveBeenCalled(); + const conversations = await getPrismaClient().coachConversation.count(); + expect(conversations).toBe(0); + }, + ); + + it("S3: keeps the coach.provider.none frame, now with the reason", async () => { + await enterState("S3"); + const res = await chatPost(); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + const text = await res.text(); + expect(text).toContain('"code":"coach.provider.none"'); + expect(text).toContain('"reason":"no_provider"'); + expect(runner.chain).not.toHaveBeenCalled(); + }); + + it("the Coach module off at the operator is the operator's switch", async () => { + const user = await makeUser("operator-coach-off"); + await setSwitches({ assistantCoachEnabled: false }); + await signIn(user.id); + const res = await chatPost(); + expect(res.status).toBe(403); + const body = (await res.json()) as Envelope; + expect(body.meta?.errorCode).toBe("assistant.disabled.coach"); + }); +}); diff --git a/tests/integration/ai-optional-data-routes.test.ts b/tests/integration/ai-optional-data-routes.test.ts new file mode 100644 index 000000000..f668f6636 --- /dev/null +++ b/tests/integration/ai-optional-data-routes.test.ts @@ -0,0 +1,350 @@ +/** + * Data never depends on AI: the formerly AI-gated data routes, through the + * real handlers against real Postgres, under every state of the matrix. + * + * For each state the same record is seeded and every data route is read. Each + * answers 200, and its body equals the all-on baseline's once the per-request + * AI state, the clock and row ids are set aside: switching AI off, never + * configuring a provider, withdrawing consent, hiding the Coach, opting out + * of AI analysis, or reading as a delegate changes no data. An ECG upload is + * stored with the master switch off and with AI analysis opted out. + * + * Mutation check: putting `requireModuleEnabled(user.id, "insights")` back + * into `insights/ecg/route.ts` turns S6 red by route name; putting + * `await requireAiCapability("statusText")` there turns S1, S2, S3, S4, S6 + * and S8 red. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + enterState, + resetWorld, + type StateName, + type World, +} from "./ai-optional-fixtures"; +import { getPrismaClient } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const DAY = 24 * 60 * 60 * 1000; +const NOW = Date.now(); + +/** One ECG recording, one device event, a week of pulse and a Coach fact. */ +async function seed(recordId: string): Promise { + const prisma = getPrismaClient(); + const { persistEcgRecording } = await import("@/lib/ecg/persist-recording"); + await persistEcgRecording( + { + userId: recordId, + source: "APPLE_HEALTH", + externalRecordingId: "aio-ecg-1", + recordedAt: new Date(NOW - 2 * DAY), + samples: [12, -7, 3, 240, -180, 60], + samplingFrequency: 512, + lead: "I", + averageHeartRate: 64, + rhythmClassification: "NOT_DETECTED", + }, + prisma, + ); + await prisma.measurement.create({ + data: { + userId: recordId, + type: "HIGH_HEART_RATE_EVENT", + value: 1, + unit: "event", + measuredAt: new Date(NOW - 3 * DAY), + source: "APPLE_HEALTH", + }, + }); + for (let i = 0; i < 7; i++) { + await prisma.measurement.create({ + data: { + userId: recordId, + type: "PULSE", + value: 60 + i, + unit: "bpm", + measuredAt: new Date(NOW - i * DAY), + source: "MANUAL", + }, + }); + } + const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); + await prisma.coachFact.create({ + data: { + userId: recordId, + factEncrypted: encryptToBytes("Prefers morning walks"), + category: "preference", + confidence: 80, + createdAt: new Date(NOW - DAY), + }, + }); +} + +type Handler = (req: NextRequest, ctx?: unknown) => Promise; + +interface DataRead { + name: string; + load: () => Promise; + url: string; + method?: "GET" | "POST"; + params?: (world: World) => Promise>; + /** Scoped to the caller rather than the record: not read in S8. */ + actorScoped?: boolean; +} + +async function firstEcgId(world: World): Promise> { + const row = await getPrismaClient().ecgRecording.findFirstOrThrow({ + where: { userId: world.recordId }, + }); + return { id: row.id }; +} + +const READS: DataRead[] = [ + { + name: "GET /api/insights/ecg", + load: async () => (await import("@/app/api/insights/ecg/route")).GET, + url: "/api/insights/ecg", + }, + { + name: "GET /api/insights/ecg/[id]", + load: async () => + (await import("@/app/api/insights/ecg/[id]/route")).GET as Handler, + url: "/api/insights/ecg/x", + params: firstEcgId, + }, + { + name: "GET /api/insights/rhythm-events", + load: async () => + (await import("@/app/api/insights/rhythm-events/route")).GET as Handler, + url: "/api/insights/rhythm-events", + }, + { + name: "GET /api/insights/derived/batch", + load: async () => + (await import("@/app/api/insights/derived/batch/route")).GET, + url: "/api/insights/derived/batch?metrics=VITALS_BASELINE:PULSE,READINESS", + }, + { + name: "GET /api/insights/cards", + load: async () => + (await import("@/app/api/insights/cards/route")).GET as Handler, + url: "/api/insights/cards", + }, + { + name: "GET /api/insights/correlations", + load: async () => + (await import("@/app/api/insights/correlations/route")).GET as Handler, + url: "/api/insights/correlations", + }, + { + name: "GET /api/insights/coach-read", + load: async () => (await import("@/app/api/insights/coach-read/route")).GET, + url: "/api/insights/coach-read?metric=PULSE", + }, + { + name: "GET /api/insights/health-status", + load: async () => + (await import("@/app/api/insights/health-status/route")).GET as Handler, + url: "/api/insights/health-status", + }, + { + name: "GET /api/insights/pulse/intraday", + load: async () => + (await import("@/app/api/insights/pulse/intraday/route")).GET as Handler, + url: "/api/insights/pulse/intraday", + }, + { + name: "GET /api/insights/patterns", + load: async () => + (await import("@/app/api/insights/patterns/route")).GET as Handler, + url: "/api/insights/patterns", + }, + { + name: "GET /api/insights/coach/facts", + load: async () => + (await import("@/app/api/insights/coach/facts/route")).GET as Handler, + url: "/api/insights/coach/facts", + actorScoped: true, + }, + { + name: "GET /api/insights/chat", + load: async () => (await import("@/app/api/insights/chat/route")).GET, + url: "/api/insights/chat", + actorScoped: true, + }, + { + name: "GET /api/daily/digest", + load: async () => + (await import("@/app/api/daily/digest/route")).GET as Handler, + url: "/api/daily/digest", + }, +]; + +/** Ids, the clock and the per-request AI state set aside. */ +function dataOnly(value: unknown): unknown { + if (Array.isArray(value)) return value.map(dataOnly); + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, inner] of Object.entries(value)) { + if ( + [ + "ai", + "id", + "generatedAt", + "revalidating", + "hasProvider", + "conversationId", + "computedAt", + ].includes(key) + ) { + continue; + } + out[key] = dataOnly(inner); + } + return out; + } + return value; +} + +async function readAll( + world: World, + state: StateName, +): Promise> { + const out: Record = {}; + for (const read of READS) { + if (state === "S8" && read.actorScoped) continue; + const handler = await read.load(); + const request = new NextRequest(`http://localhost${read.url}`, { + method: read.method ?? "GET", + }); + const res = read.params + ? await handler(request, { + params: Promise.resolve(await read.params(world)), + }) + : await handler(request); + const json = (await res.json()) as { data: unknown }; + out[read.name] = { status: res.status, body: dataOnly(json.data) }; + } + return out; +} + +let baseline: Record | null = null; + +beforeEach(async () => { + await resetWorld(); +}); + +describe("data routes under every AI state", () => { + it("S0 (all on) is the baseline, and every read answers", async () => { + const world = await enterState("S0", seed); + baseline = await readAll(world, "S0"); + for (const [name, { status }] of Object.entries(baseline)) { + expect(status, name).toBe(200); + } + // The seed is visible, so equality below compares something real. + const ecg = baseline["GET /api/insights/ecg"].body as { + hasRecordings: boolean; + }; + expect(ecg.hasRecordings).toBe(true); + const events = baseline["GET /api/insights/rhythm-events"].body as { + hasEvents: boolean; + }; + expect(events.hasEvents).toBe(true); + const facts = baseline["GET /api/insights/coach/facts"].body as { + facts: unknown[]; + }; + expect(facts.facts).toHaveLength(1); + }); + + it.each(["S1", "S2", "S3", "S4", "S5", "S6", "S8"] as const)( + "%s answers every read with the baseline's data", + async (state) => { + expect(baseline, "the S0 baseline runs first").not.toBeNull(); + const world = await enterState(state, seed); + const reads = await readAll(world, state); + for (const [name, { status, body }] of Object.entries(reads)) { + expect(status, `${state} ${name}`).toBe(200); + expect(body, `${state} ${name}`).toEqual(baseline![name].body); + } + }, + ); +}); + +describe("writes that AI never refuses", () => { + async function ingest(): Promise { + const { POST } = await import("@/app/api/insights/ecg/route"); + return POST( + new NextRequest("http://localhost/api/insights/ecg", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + externalRecordingId: "aio-ingest-1", + recordedAt: new Date(NOW - DAY).toISOString(), + samplingFrequency: 512, + samples: [1, 2, 3, 4], + classification: "NOT_DETECTED", + source: "APPLE_HEALTH", + }), + }), + ); + } + + it.each(["S1", "S6"] as const)( + "stores an ECG upload in %s", + async (state) => { + const world = await enterState(state); + const res = await ingest(); + expect(res.status).toBe(201); + const rows = await getPrismaClient().ecgRecording.count({ + where: { userId: world.recordId }, + }); + expect(rows).toBe(1); + }, + ); + + it.each(["S1", "S5"] as const)( + "stamps the Coach as seen and erases a fact in %s", + async (state) => { + const world = await enterState(state, seed); + const { POST: seen } = + await import("@/app/api/insights/coach/seen/route"); + expect((await seen()).status).toBe(200); + const { DELETE: forgetAll } = + await import("@/app/api/insights/coach/facts/route"); + const res = await forgetAll(); + expect(res.status).toBe(200); + expect( + ((await res.json()) as { data: { cleared: number } }).data, + ).toEqual({ cleared: 1 }); + const live = await getPrismaClient().coachFact.count({ + where: { userId: world.recordId, deletedAt: null }, + }); + expect(live).toBe(0); + }, + ); +}); diff --git a/tests/integration/ai-optional-fixtures.ts b/tests/integration/ai-optional-fixtures.ts new file mode 100644 index 000000000..fb426b526 --- /dev/null +++ b/tests/integration/ai-optional-fixtures.ts @@ -0,0 +1,202 @@ +/** + * Shared fixtures for the `ai-optional-*` integration files: one fully set-up + * account, and one function per state in the design's state matrix, each + * changing exactly one layer against that baseline. + * + * S0 all on: own key (presence only), every receipt, every switch on + * S1 the operator's master switch off + * S2 one operator sub-switch off + * S3 no provider configured anywhere + * S4 only the operator's key, and no consent receipt + * S5 the Coach hidden by the person (`disableCoach`) + * S6 the AI analysis opt-out (`insights` module off) + * S8 a delegate with MANAGE access inside somebody else's record + * + * Not a test file: the `vi.mock` blocks stay in each file, because Vitest + * hoists them per file. + */ +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; + +let counter = 0; + +export async function resetWorld(): Promise { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + counter = 0; +} + +/** A person with their own Anthropic key and the `ai_full` receipt: S0. */ +export async function makeUser( + label: string, + overrides: Record = {}, +) { + const suffix = `${label}-${counter++}`; + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: `aio-${suffix}`, + email: `aio-${suffix}@example.test`, + role: "USER", + timezone: "UTC", + locale: "en", + onboardingCompletedAt: new Date(), + aiProvider: "ANTHROPIC", + aiModel: "claude-sonnet-4-6", + // Presence only: the capability probe never decrypts. + aiAnthropicKeyEncrypted: "v1:presence-only", + ...overrides, + }, + }); + await prisma.consentReceipt.create({ + data: { + userId: user.id, + kind: "ai_full", + artefact: "test", + signedAt: new Date(), + }, + }); + return user; +} + +export async function setSwitches(values: Record) { + await getPrismaClient().appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", ...values }, + update: values, + }); +} + +export async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +export type StateName = "S0" | "S1" | "S2" | "S3" | "S4" | "S5" | "S6" | "S8"; + +export interface World { + /** The record the requests read and write. */ + recordId: string; + /** Who is signed in. */ + actorId: string; +} + +/** + * Build the world for one state and sign in. `seed` runs against the record + * before the session is opened, so every state reads the same data. + */ +export async function enterState( + state: StateName, + seed: (recordId: string) => Promise = async () => {}, +): Promise { + const prisma = getPrismaClient(); + switch (state) { + case "S0": { + const user = await makeUser("s0"); + await seed(user.id); + await signIn(user.id); + return { recordId: user.id, actorId: user.id }; + } + case "S1": { + const user = await makeUser("s1"); + await setSwitches({ assistantEnabled: false }); + await seed(user.id); + await signIn(user.id); + return { recordId: user.id, actorId: user.id }; + } + case "S2": { + const user = await makeUser("s2"); + await setSwitches({ + assistantInsightStatusEnabled: false, + assistantCoachEnabled: false, + assistantBriefingEnabled: false, + }); + await seed(user.id); + await signIn(user.id); + return { recordId: user.id, actorId: user.id }; + } + case "S3": { + const user = await makeUser("s3", { + aiProvider: null, + aiModel: null, + aiAnthropicKeyEncrypted: null, + }); + await seed(user.id); + await signIn(user.id); + return { recordId: user.id, actorId: user.id }; + } + case "S4": { + const user = await makeUser("s4", { + aiProvider: null, + aiModel: null, + aiAnthropicKeyEncrypted: null, + }); + await prisma.consentReceipt.deleteMany({ where: { userId: user.id } }); + await setSwitches({ + adminAiKeyEncrypted: "v1:presence-only", + adminAiModel: "gpt-4o", + }); + await seed(user.id); + await signIn(user.id); + return { recordId: user.id, actorId: user.id }; + } + case "S5": { + const user = await makeUser("s5", { disableCoach: true }); + await seed(user.id); + await signIn(user.id); + return { recordId: user.id, actorId: user.id }; + } + case "S6": { + const user = await makeUser("s6", { + modulePreferencesJson: { insights: false }, + }); + await seed(user.id); + await signIn(user.id); + return { recordId: user.id, actorId: user.id }; + } + case "S8": { + const owner = await makeUser("s8-owner"); + const delegate = await makeUser("s8-delegate"); + await prisma.accountGrant.create({ + data: { + grantorId: owner.id, + granteeId: delegate.id, + access: "MANAGE", + acceptedAt: new Date(), + }, + }); + await seed(owner.id); + const session = await signIn(delegate.id); + await switchSessionTo(session.id, owner.id); + return { recordId: owner.id, actorId: delegate.id }; + } + } +} + +/** + * The body with the per-request AI state and the clock stripped, so two + * states can be compared on their data alone. + */ +export function withoutAiAndClock(value: unknown): unknown { + if (Array.isArray(value)) return value.map(withoutAiAndClock); + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, inner] of Object.entries(value)) { + if ( + key === "ai" || + key === "generatedAt" || + key === "revalidating" || + key === "hasProvider" + ) { + continue; + } + out[key] = withoutAiAndClock(inner); + } + return out; + } + return value; +} diff --git a/tests/integration/ai-optional-mixed-routes.test.ts b/tests/integration/ai-optional-mixed-routes.test.ts new file mode 100644 index 000000000..e45966dc6 --- /dev/null +++ b/tests/integration/ai-optional-mixed-routes.test.ts @@ -0,0 +1,490 @@ +/** + * Mixed reads: data plus optional model text, through the real handlers + * against real Postgres. + * + * Each read answers 200 in every state. Model-written text is served only + * while its capability is available; otherwise the field is null (or the + * deterministic stand-in), `ai` names the reason, the stored text is not + * served however fresh, and nothing is enqueued to warm it. The baseline (S0) + * proves each assertion can see the text when it is allowed to. + * + * Mutation check: dropping the capability test from the narrative route + * (`existing = stored`) serves the provider row and turns all four hiding + * states red. The per-read snapshot gate is mutation-checked by + * `snapshot-read-briefing-capability.test.ts`. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + enterState, + resetWorld, + setSwitches, + type StateName, +} from "./ai-optional-fixtures"; +import { getPrismaClient } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +// The two enqueue seams a mixed read may reach. Spied, never run: the test +// asserts they are not called while a capability is unavailable. +const enqueueNarrativeWarm = vi.fn(async () => undefined); +vi.mock("@/lib/jobs/period-narrative-shared", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/jobs/period-narrative-shared") + >()), + enqueueNarrativeWarm: (...args: unknown[]) => + (enqueueNarrativeWarm as (...a: unknown[]) => Promise)(...args), +})); +const enqueueForceWarm = vi.fn(async () => undefined); +vi.mock("@/lib/jobs/insight-pregenerate-shared", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/jobs/insight-pregenerate-shared") + >()), + enqueueForceWarm: (...args: unknown[]) => + (enqueueForceWarm as (...a: unknown[]) => Promise)(...args), +})); + +const BRIEFING = { + dailyBriefing: { + paragraph: "Model prose about your week. It reads well.", + keyFindings: [], + }, +}; + +async function seed(recordId: string): Promise { + const prisma = getPrismaClient(); + const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); + // A model-written narrative, fresh. + await prisma.insightNarrative.create({ + data: { + userId: recordId, + period: "week", + locale: "en", + dateKey: "2026-09-20", + encryptedContent: encryptToBytes("A model wrote this retrospective."), + providerType: "anthropic", + }, + }); + // A fresh cached briefing in the reader's language. + await prisma.user.update({ + where: { id: recordId }, + data: { + insightsCachedText: JSON.stringify(BRIEFING), + insightsCachedAt: new Date(), + insightsCachedLocale: "en", + }, + }); + // A Coach thread whose newest message the person has not opened. + const conversation = await prisma.coachConversation.create({ + data: { userId: recordId, title: "Week" }, + }); + await prisma.coachMessage.create({ + data: { + conversationId: conversation.id, + role: "assistant", + encryptedContent: encryptToBytes("Hello from the Coach."), + }, + }); + // A workout with a stored Activity Insight paragraph. + const startedAt = new Date(Date.now() - 2 * 60 * 60 * 1000); + const workout = await prisma.workout.create({ + data: { + userId: recordId, + sportType: "RUNNING", + startedAt, + endedAt: new Date(startedAt.getTime() + 30 * 60 * 1000), + durationSec: 1800, + source: "APPLE_HEALTH", + }, + }); + await prisma.workoutInsight.create({ + data: { + userId: recordId, + workoutId: workout.id, + paragraphEncrypted: encryptToBytes("A steady run, model-written."), + inputHash: "h", + promptVersion: "p", + providerType: "anthropic", + locale: "en", + generatedAt: new Date(), + }, + }); +} + +async function json(res: Response): Promise<{ status: number; data: T }> { + return { + status: res.status, + data: ((await res.json()) as { data: T }).data, + }; +} + +async function readNarrative() { + const { GET } = await import("@/app/api/insights/narrative/route"); + return json<{ + narrative: { text: string } | null; + revalidating: boolean; + ai: { available: boolean; reason: string | null }; + }>( + await GET( + new NextRequest("http://localhost/api/insights/narrative?period=week"), + ), + ); +} + +async function readSnapshot() { + const { GET } = await import("@/app/api/dashboard/snapshot/route"); + return json<{ + briefing: unknown; + briefingState: string; + briefingUpdatedAt: string | null; + briefingAi: { available: boolean; reason: string | null }; + }>(await GET()); +} + +async function readDigest() { + const { GET } = await import("@/app/api/daily/digest/route"); + return json<{ + briefingLead: string | null; + ai: { briefing: { reason: string | null } }; + }>(await GET()); +} + +async function readNudge() { + const { GET } = await import("@/app/api/insights/coach/nudge-status/route"); + return json<{ + unread: boolean; + nudgedAt: string | null; + ai: { reason: string | null }; + }>(await GET()); +} + +async function readWorkout(recordId: string) { + const workout = await getPrismaClient().workout.findFirstOrThrow({ + where: { userId: recordId }, + }); + const { GET } = await import("@/app/api/workouts/[id]/route"); + return json<{ + aiInsight: { paragraph: string } | null; + ai: { reason: string | null }; + }>( + await GET( + new NextRequest(`http://localhost/api/workouts/${workout.id}?compact=1`), + { params: Promise.resolve({ id: workout.id }) }, + ), + ); +} + +async function readComprehensive() { + const { GET } = await import("@/app/api/insights/comprehensive/route"); + return json<{ + ai: { + briefing: { reason: string | null }; + statusText: { reason: string | null }; + }; + }>(await GET()); +} + +async function pregenerate() { + const { POST } = await import("@/app/api/insights/pregenerate/route"); + return json<{ queued: boolean; ai: unknown }>( + await POST( + new NextRequest("http://localhost/api/insights/pregenerate", { + method: "POST", + }), + ), + ); +} + +beforeEach(async () => { + await resetWorld(); + enqueueNarrativeWarm.mockClear(); + enqueueForceWarm.mockClear(); +}); + +describe("the baseline sees the model text", () => { + it("S0 serves every model-written field", async () => { + const world = await enterState("S0", seed); + + const narrative = await readNarrative(); + expect(narrative.status).toBe(200); + expect(narrative.data.narrative?.text).toBe( + "A model wrote this retrospective.", + ); + expect(narrative.data.ai.available).toBe(true); + + const snapshot = await readSnapshot(); + expect(snapshot.data.briefing).not.toBeNull(); + expect(snapshot.data.briefingAi.available).toBe(true); + + const digest = await readDigest(); + expect(digest.data.briefingLead).toBe("Model prose about your week."); + + const nudge = await readNudge(); + expect(nudge.data.unread).toBe(true); + + const workout = await readWorkout(world.recordId); + expect(workout.data.aiInsight?.paragraph).toBe( + "A steady run, model-written.", + ); + + const comprehensive = await readComprehensive(); + expect(comprehensive.status).toBe(200); + expect(comprehensive.data.ai.briefing.reason).toBeNull(); + + const warm = await pregenerate(); + expect(warm.data.queued).toBe(true); + expect(enqueueForceWarm).toHaveBeenCalledTimes(1); + }); +}); + +describe.each([ + ["S1", "operator_disabled"], + ["S3", "no_provider"], + ["S4", "consent_required"], + ["S6", "user_disabled"], +] as const)("%s hides the model text", (state: StateName, reason) => { + it("answers 200 with the text nulled, the reason named and nothing enqueued", async () => { + const world = await enterState(state, seed); + + const narrative = await readNarrative(); + expect(narrative.status).toBe(200); + expect(narrative.data.narrative).toBeNull(); + expect(narrative.data.ai.reason).toBe(reason); + // Nothing servable: the warm is still allowed, because the worker then + // writes the deterministic narrative. It is the only enqueue allowed. + + const snapshot = await readSnapshot(); + expect(snapshot.status).toBe(200); + expect(snapshot.data.briefing).toBeNull(); + expect(snapshot.data.briefingUpdatedAt).toBeNull(); + expect(snapshot.data.briefingAi.reason).toBe(reason); + expect(snapshot.data.briefingState).toBe( + reason === "no_provider" ? "no-provider" : "disabled", + ); + + const digest = await readDigest(); + expect(digest.status).toBe(200); + expect(digest.data.briefingLead).toBeNull(); + expect(digest.data.ai.briefing.reason).toBe(reason); + + const workout = await readWorkout(world.recordId); + expect(workout.status).toBe(200); + expect(workout.data.aiInsight).toBeNull(); + + const comprehensive = await readComprehensive(); + expect(comprehensive.status).toBe(200); + expect(comprehensive.data.ai.statusText.reason).toBe(reason); + + const warm = await pregenerate(); + expect(warm.status).toBe(200); + expect(warm.data.queued).toBe(false); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + }); +}); + +describe("the Coach half", () => { + it.each([ + ["S1", "operator_disabled"], + ["S5", "user_disabled"], + ["S3", "no_provider"], + ] as const)( + "nudge-status answers the quiet shape in %s", + async (state, reason) => { + await enterState(state, seed); + const nudge = await readNudge(); + expect(nudge.status).toBe(200); + expect(nudge.data.unread).toBe(false); + expect(nudge.data.nudgedAt).toBeNull(); + expect(nudge.data.ai.reason).toBe(reason); + }, + ); + + it("hiding the Coach hides the Coach, not the briefing", async () => { + await enterState("S5", seed); + const snapshot = await readSnapshot(); + expect(snapshot.data.briefing).not.toBeNull(); + const digest = await readDigest(); + expect(digest.data.briefingLead).toBe("Model prose about your week."); + }); +}); + +describe("the narrative's deterministic text is data", () => { + it("serves a deterministic row with the master switch off, and does not re-warm it", async () => { + await enterState("S1", async (recordId) => { + const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); + await getPrismaClient().insightNarrative.create({ + data: { + userId: recordId, + period: "week", + locale: "en", + dateKey: "2026-09-20", + encryptedContent: encryptToBytes("Your week, in numbers."), + providerType: "deterministic", + updatedAt: new Date(Date.now() - 48 * 60 * 60 * 1000), + }, + }); + }); + const narrative = await readNarrative(); + expect(narrative.data.narrative?.text).toBe("Your week, in numbers."); + expect(narrative.data.revalidating).toBe(false); + expect(enqueueNarrativeWarm).not.toHaveBeenCalled(); + }); +}); + +describe("the snapshot applies the capability per read", () => { + it("hides a cached briefing on the very next read after the operator switches it off", async () => { + await enterState("S0", seed); + const before = await readSnapshot(); + expect(before.data.briefing).not.toBeNull(); + + await setSwitches({ assistantBriefingEnabled: false }); + const after = await readSnapshot(); + expect(after.data.briefing).toBeNull(); + expect(after.data.briefingAi.reason).toBe("operator_disabled"); + }); +}); + +describe("the status family", () => { + async function readStatus(path: string) { + const route = + path === "weight" + ? await import("@/app/api/insights/weight-status/route") + : await import("@/app/api/insights/metric-status/route"); + const url = + path === "weight" + ? "http://localhost/api/insights/weight-status" + : "http://localhost/api/insights/metric-status?metric=RESTING_HEART_RATE"; + return json>(await route.GET(new NextRequest(url))); + } + + it("S0 publishes the capability beside the note", async () => { + const world = await enterState("S0"); + // The available path resolves (never calls) the provider chain, so the + // key has to decrypt. + const { encrypt } = await import("@/lib/crypto"); + await getPrismaClient().user.update({ + where: { id: world.recordId }, + data: { aiAnthropicKeyEncrypted: encrypt("sk-ant-integration") }, + }); + const status = await readStatus("weight"); + expect(status.status).toBe(200); + expect(status.data.ai).toMatchObject({ available: true }); + }); + + it.each([ + ["S1", "operator_disabled", true], + ["S3", "no_provider", false], + ["S6", "user_disabled", true], + ] as const)( + "%s answers 200 with no note and provider presence in hasProvider", + async (state, reason, hasProvider) => { + await enterState(state); + for (const path of ["weight", "metric"]) { + const status = await readStatus(path); + expect(status.status, path).toBe(200); + expect(status.data, path).toMatchObject({ + text: null, + preparing: false, + hasProvider, + ai: { available: false, reason }, + }); + } + const metric = await readStatus("metric"); + // No readings were seeded: the data half still says so. + expect(metric.data.insufficient).toBe(true); + }, + ); +}); + +describe("inside somebody else's record (S8, a MANAGE delegate)", () => { + async function seedOwnerText(recordId: string): Promise { + await seed(recordId); + const prisma = getPrismaClient(); + // The narrative is stale, so an owner's read would warm it. + await prisma.insightNarrative.updateMany({ + where: { userId: recordId }, + data: { updatedAt: new Date(Date.now() - 48 * 60 * 60 * 1000) }, + }); + const { writeStatusNote, statusCacheAction } = + await import("@/lib/insights/status-cache"); + const { userDayKey } = await import("@/lib/tz/format"); + await writeStatusNote({ + userId: recordId, + cacheAction: statusCacheAction("weight", "en"), + todayKey: userDayKey(new Date(), "UTC"), + text: "The owner's weight note.", + }); + } + + it("reads the owner's stored model text exactly as the owner would", async () => { + await enterState("S8", seedOwnerText); + + const narrative = await readNarrative(); + expect(narrative.data.narrative?.text).toBe( + "A model wrote this retrospective.", + ); + expect(narrative.data.ai.available).toBe(true); + + const snapshot = await readSnapshot(); + expect(snapshot.data.briefing).not.toBeNull(); + + const digest = await readDigest(); + expect(digest.data.briefingLead).toBe("Model prose about your week."); + + const { GET } = await import("@/app/api/insights/weight-status/route"); + const status = await json<{ text: string | null }>( + await GET(new NextRequest("http://localhost/api/insights/weight-status")), + ); + expect(status.status).toBe(200); + expect(status.data.text).toBe("The owner's weight note."); + }); + + it("starts no model work on the owner's record", async () => { + await enterState("S8", seedOwnerText); + + // A stale narrative the owner's read would warm: the delegate's does not. + const narrative = await readNarrative(); + expect(narrative.data.revalidating).toBe(false); + expect(enqueueNarrativeWarm).not.toHaveBeenCalled(); + + // Every generating route refuses a delegate before any work. + const { POST: chat } = await import("@/app/api/insights/chat/route"); + const chatRes = await chat( + new NextRequest("http://localhost/api/insights/chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "How was my week?" }), + }), + ); + expect(chatRes.status).toBe(403); + + const warm = await pregenerate(); + expect(warm.status).toBe(403); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + expect(await getPrismaClient().coachConversation.count()).toBe(1); + }); +}); diff --git a/tests/integration/briefing-cache-locale.test.ts b/tests/integration/briefing-cache-locale.test.ts index 56a930549..b1073e961 100644 --- a/tests/integration/briefing-cache-locale.test.ts +++ b/tests/integration/briefing-cache-locale.test.ts @@ -68,6 +68,14 @@ async function seedUserWithCachedBriefing(cachedLocale: string | null) { insightsCachedAt: new Date(), insightsCachedText: JSON.stringify({ dailyBriefing: BRIEFING }), insightsCachedLocale: cachedLocale, + // The briefing is shown only while its AI capability is available, so + // the account has its own provider (presence only) and a receipt. + aiProvider: "ANTHROPIC", + aiModel: "claude-sonnet-4-6", + aiAnthropicKeyEncrypted: "v1:presence-only", + consentReceipts: { + create: { kind: "ai_full", artefact: "test", signedAt: new Date() }, + }, }, }); } @@ -84,9 +92,8 @@ describe("dashboard snapshot — briefing cache written in another language", () expect(body.briefing).toBeNull(); expect(body.briefingStale).toBe(false); expect(body.briefingUpdatedAt).toBeNull(); - // No provider is configured on this account, so the honest empty state - // is "nothing will fill this"; with one it would be "preparing". - expect(["preparing", "no-provider"]).toContain(body.briefingState); + // A provider is configured, so a warm pass will refill the slot. + expect(body.briefingState).toBe("preparing"); }); it("serves the briefing when the tag matches the reader", async () => { diff --git a/tests/integration/coach-chat.test.ts b/tests/integration/coach-chat.test.ts index 3a26d5c22..9ae2ae8d9 100644 --- a/tests/integration/coach-chat.test.ts +++ b/tests/integration/coach-chat.test.ts @@ -81,7 +81,11 @@ vi.mock("@/lib/ai/provider-runner", async () => { }; }); -vi.mock("@/lib/ai/provider", () => ({ +// The chain the turn runs through is stubbed; provider PRESENCE is the real +// probe, which reads the credential the seeded user carries, because the +// `coach` capability is resolved from it before the turn starts. +vi.mock("@/lib/ai/provider", async (importOriginal) => ({ + ...(await importOriginal()), resolveProviderChain: vi.fn(async () => [ { providerType: "openai", @@ -109,6 +113,10 @@ async function seedUserWithSession(): Promise<{ userId: string }> { username: "coach-user", email: "coach@example.test", role: "USER", + // The person's own OpenAI key (presence only): the `coach` capability + // needs a provider, and a personal key needs no consent receipt. + aiProvider: "OPENAI", + aiOpenaiKeyEncrypted: "v1:presence-only", }, }); const session = await prisma.session.create({ diff --git a/tests/integration/insights-comprehensive-cache.test.ts b/tests/integration/insights-comprehensive-cache.test.ts index e7e1395b5..acd0cced9 100644 --- a/tests/integration/insights-comprehensive-cache.test.ts +++ b/tests/integration/insights-comprehensive-cache.test.ts @@ -48,7 +48,8 @@ vi.mock("@/lib/db-compat", () => ({ // `resolveProvider` reads `app_settings.default_locale` which trails the // production schema in this integration env. Mock the leaf: the route // only uses `provider.type !== "none"` to set the `hasProvider` flag. -vi.mock("@/lib/ai/provider", () => ({ +vi.mock("@/lib/ai/provider", async (importOriginal) => ({ + ...(await importOriginal()), resolveProvider: vi.fn().mockResolvedValue({ type: "none" }), })); diff --git a/tests/integration/sharing-front-door.test.ts b/tests/integration/sharing-front-door.test.ts index 36c71d62d..301a918da 100644 --- a/tests/integration/sharing-front-door.test.ts +++ b/tests/integration/sharing-front-door.test.ts @@ -261,6 +261,17 @@ frontDoorRead("GET /api/dashboard/snapshot", { read: (data: { user: { username: string } }) => data.user.username, }); +/** The person's own provider (presence only) and an AI consent receipt. */ +function ownProvider() { + return { + aiProvider: "ANTHROPIC", + aiAnthropicKeyEncrypted: "v1:presence-only", + consentReceipts: { + create: { kind: "ai_full", artefact: "test", signedAt: new Date() }, + }, + }; +} + /** A parseable cached briefing — the only thing the digest lifts prose from. */ function cachedBriefing(marker: string): string { return JSON.stringify({ @@ -272,12 +283,16 @@ function cachedBriefing(marker: string): string { } frontDoorRead("GET /api/daily/digest", { + // The briefing lead is stored model text: shown whenever the record's own + // `briefing` capability allows it, whoever reads. Each account therefore + // has its own provider (presence only) and consent. seed: async (userId, marker) => { await getPrismaClient().user.update({ where: { id: userId }, data: { insightsCachedText: cachedBriefing(marker), insightsCachedAt: new Date(), + ...ownProvider(), }, }); }, @@ -342,6 +357,10 @@ async function seedAssistantMessage(userId: string, at: string) { frontDoorRead("GET /api/insights/coach/nudge-status", { seed: async (userId, marker) => { + await getPrismaClient().user.update({ + where: { id: userId }, + data: ownProvider(), + }); await seedAssistantMessage( userId, marker === "owner" ? "2026-07-01T09:00:00Z" : "2026-07-02T09:00:00Z",