diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index dded8bf92..570c1b76e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5756,8 +5756,10 @@ paths: description: Runs the user's free-text description through the Coach provider chain and returns a citation-guarded partial payload the wizard merges onto whatever the user already typed. `name` and `dose` are dropped when not substring-matched in the original text so the wizard cannot land a hallucinated brand or dose. `cadenceKind` / - `doseUnit` / `weekdays` are closed enums; numeric fields are clamped. Rate-limited 10 requests / 5 minutes / - user, gated against the daily Coach token budget. + `doseUnit` / `weekdays` are closed enums; numeric fields are clamped. Answers under the `medicationExtract` + capability (the operator's reading-documents switch; free-text medication input is health data, so any provider + in the chain that leaves the machine needs an active `ai_extraction` or `ai_full` consent receipt). Rate-limited + 10 requests / 5 minutes / user, gated against the daily Coach token budget (429 `coach.budget.exceeded`). requestBody: required: true content: @@ -5772,6 +5774,19 @@ paths: schema: $ref: "#/components/schemas/MedicationExtractResponse" "401": *a1 + "403": + description: "AI refusal: the `medicationExtract` 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 + switch). `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in + somebody else's record). `consent.ai.required` — the provider this request would reach leaves the machine + and the record holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The + question is answered for the provider actually picked, so it can differ from the published capability when a + text-mode read takes a different provider than a vision read would." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 "502": @@ -5781,7 +5796,10 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "503": - description: No AI provider configured for the calling user (or operator). + description: 'No answer could be produced. `meta.errorCode = "ai.provider.none"` (with `meta.capability` and + `meta.reason = "no_provider"`): no AI provider is configured for the calling user or the operator — the + status predates the capability envelope and is kept for clients that branch on it. `ai.unavailable`: the + capability could not be resolved and failed closed. No code: every provider in the chain failed.' content: application/json: schema: @@ -5965,21 +5983,23 @@ paths: - in: query name: from schema: - description: Window start (inclusive). Defaults to 90 days before `to`; clamped to the medication's `createdAt` and a - 366-day span floor. + description: Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses + are returned from this instant; expected slots are not minted before the medication's `createdAt`. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - description: Window start (inclusive). Defaults to 90 days before `to`; clamped to the medication's `createdAt` and a - 366-day span floor. + description: Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses + are returned from this instant; expected slots are not minted before the medication's `createdAt`. - in: query name: to schema: - description: Window end (inclusive). Defaults to now. Must be ≥ `from`. + description: Window end (inclusive). Defaults to now. Must be ≥ `from`. A dose recorded ahead of its slot (the slot's + time within a day after `to`) is still returned. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - description: Window end (inclusive). Defaults to now. Must be ≥ `from`. + description: Window end (inclusive). Defaults to now. Must be ≥ `from`. A dose recorded ahead of its slot (the slot's + time within a day after `to`) is still returned. responses: "200": description: Dose-history ledger. @@ -6357,6 +6377,12 @@ paths: Two ceilings: 5 per minute and 50 per day, per user. + + + With the operator's master AI switch off the probe is refused (403 `assistant.disabled.enabled`, `meta.reason = + "operator_disabled"`): no AI call leaves the server then, and this is an AI call. When the switches cannot be + read the answer fails closed with 403 `ai.unavailable` (`meta.reason = "check_failed"`) rather than the usual + 503, for the no-5xx reason above. requestBody: required: false content: @@ -6372,6 +6398,13 @@ paths: schema: $ref: "#/components/schemas/AiTestEnvelope" "401": *a1 + "403": + description: The operator's master AI switch is off (`meta.errorCode = "assistant.disabled.enabled"`), or the switches + could not be read (`ai.unavailable`). Nothing was sent. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 64 KiB. content: @@ -6387,14 +6420,16 @@ paths: "422": description: Nothing could be probed. Either the override body failed validation, or the resolved configuration is not usable — no provider selected, a required key or base URL missing, a model name missing for the gateway, - ChatGPT OAuth not connected, or a base URL pointing at an internal host. The message names which. + ChatGPT OAuth not connected, or a base URL pointing at an internal host. The message names which; a resolved + configuration with no provider at all carries `meta.errorCode = "ai.provider.none"`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: "One of three ceilings: 5 probes per minute, 50 per day, or the account's daily AI token budget is - exhausted. The message distinguishes them." + exhausted. The message distinguishes them; the budget refusal also carries `meta.errorCode = + \"ai.budget.exceeded\"`." content: application/json: schema: @@ -7949,11 +7984,14 @@ paths: Turning it ON does two further things a caller should expect. It is itself the standing consent act, so the - write appends an `ai_full` `ConsentReceipt` — the same receipt POST /api/consent/ai/web mints, and one DELETE - /api/consent/ai/latest revokes without touching this flag. And a genuine OFF→ON flip schedules a bounded - catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would - otherwise only ever apply to future uploads. The catch-up is fire-and-forget and re-runs every consent and - budget gate per document. + write appends an `ai_extraction` `ConsentReceipt` unless one that covers document reads (`ai_extraction` or + `ai_full`) is already active. The receipt covers reading documents, lab reports and medication text and nothing + else: the Coach and the AI analysis keep asking for their own consent (earlier releases minted `ai_full` here, + and those receipts stay valid). DELETE /api/consent/ai/latest?kind=ai_extraction revokes it without touching + this flag. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, + because the summary job is enqueued at upload time and would otherwise only ever apply to future uploads; it is + skipped while the `documentAi` capability is closed for the record. The catch-up is fire-and-forget and re-runs + every consent and budget gate per document. The body cap here is 1 KB, far tighter than the 64 KB its siblings allow — a payload above it is refused with @@ -8641,12 +8679,15 @@ paths: coach conversation, as Server-Sent Events (`text/event-stream`: one `data: \\n\\n` frame per event; `token` / `done` / `error`, HTTP 200 even for a provider/refusal outcome). FENCED by construction: NO tools, NO health snapshot; every attached document is fenced as untrusted DATA (per-document header fields - marker-scrubbed); the single picked provider is egress-consent-checked once per attached document (403 - `consent.ai.required` if any fails); the reply is numerically grounded against the LIVE attachments' figures - only. `conversationId` continues an existing fenced thread; `attachmentIds` (first-turn ONLY, min 1, max 5) - creates a fresh fenced thread — supplying BOTH is a 422. The body is `.strict()`: `scope` / `guidedQuestion` / - `prefill` / `userId` are rejected. A plain tool conversation 404s here. Module-gated on `inboundDocuments`; - rate-limited (shared `document-chat` bucket). Renders as plain text (no markdown). Auth via cookie or Bearer." + marker-scrubbed); answers under the `coach` and `documentAi` capabilities, both re-checked for the single picked + provider immediately before anything is sent (403 with the capability envelope when either is closed; + `consent.ai.required` when that provider leaves the machine without an `ai_extraction` / `ai_full` receipt; no + provider is the `documents.chat.provider.none` error frame); the reply is numerically grounded against the LIVE + attachments' figures only. `conversationId` continues an existing fenced thread; `attachmentIds` (first-turn + ONLY, min 1, max 5) creates a fresh fenced thread — supplying BOTH is a 422. The body is `.strict()`: `scope` / + `guidedQuestion` / `prefill` / `userId` are rejected. A plain tool conversation 404s here. Module-gated on + `inboundDocuments`; rate-limited (shared `document-chat` bucket). Renders as plain text (no markdown). Auth via + cookie or Bearer." requestBody: required: true content: @@ -8663,8 +8704,18 @@ paths: description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 "403": - description: "AI consent required for an external provider (`errorCode: consent.ai.required`). A 422 (`stdResponses`) - covers an un-indexed / unavailable attachment (`coach.fenced.attachmentUnavailable`), the attachment cap + 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 + switch). `module.disabled` with `meta.module = \"inboundDocuments\"` — the module is off for this record. + `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's + record). `consent.ai.required` — the provider this request would reach leaves the machine and the record + holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is + answered for the provider actually picked, so it can differ from the published capability when a text-mode + read takes a different provider than a vision read would. The route also answers under `coach`, with the + same envelope and that capability's own codes (`assistant.disabled.coach`, `module.disabled` with + `meta.module = \"coach\"` when the Coach is off for the record). A 422 (`stdResponses`) covers an un-indexed + / unavailable attachment (`coach.fenced.attachmentUnavailable`), the attachment cap (`coach.fenced.attachmentLimit`), or `attachmentIds` sent with a `conversationId` (`coach.fenced.attachmentConflict`)." content: @@ -8673,6 +8724,13 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + "503": + description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. + Nothing was sent. Retry later." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/insights/chat/{id}/attachments: post: tags: @@ -8682,7 +8740,9 @@ paths: its sticky `documentScoped` flag TRUE (the one legal, privilege-reducing tool→fenced transition; audit-logged when a flip occurs). Validates the document is owned + live + indexed + within the 5-document cap. Idempotent: attaching an already-attached document is a 200. A foreign / unknown conversation or document maps to 404. - Module-gated on `inboundDocuments`; rate-limited. Auth via cookie or Bearer." + Module-gated on `inboundDocuments`; answers under the `coach` and `documentAi` capabilities (nothing is sent to + a provider here, so a missing provider or consent receipt is left to the next turn); rate-limited. Detaching + (DELETE) is never refused for AI reasons: it removes the person's own data. Auth via cookie or Bearer." parameters: - name: id in: path @@ -8704,6 +8764,22 @@ paths: schema: $ref: "#/components/schemas/CoachAttachmentsResponse" "401": *a1 + "403": + 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 + switch). `module.disabled` with `meta.module = \"inboundDocuments\"` — the module is off for this record. + `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's + record). `consent.ai.required` — the provider this request would reach leaves the machine and the record + holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is + answered for the provider actually picked, so it can differ from the published capability when a text-mode + read takes a different provider than a vision read would. The route also answers under `coach`, with the + same envelope and that capability's own codes (`assistant.disabled.coach`, `module.disabled` with + `meta.module = \"coach\"` when the Coach is off for the record)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "404": description: Conversation or document not found / not owned. content: @@ -8712,6 +8788,13 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + "503": + description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. + Nothing was sent. Retry later." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/insights/chat/{id}/attachments/{documentId}: delete: tags: @@ -12757,6 +12840,7 @@ paths: - ai_full - ai_insights_only - ai_coach + - ai_extraction description: Narrow to one consent kind. Omit for the full keyspace. responses: "200": @@ -12768,8 +12852,9 @@ paths: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" "400": &a23 - description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope; - nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." + 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." content: application/json: schema: @@ -12792,9 +12877,18 @@ paths: each revocation is audit-logged. - Revoking `ai_full` does NOT switch off `documentsAutoAiRead`; that flag is written through PATCH - /api/auth/me/documents-auto-ai-read and turning it on mints a fresh receipt. A client offering both controls - should show them as the two separate things they are. + Revoking `ai_full` or `ai_extraction` does NOT switch off `documentsAutoAiRead`, but it does stop document + reading: the document gate reads the receipt, not the switch, so a revocation wins over a switch left on. + Turning the switch on again mints a fresh `ai_extraction` receipt through PATCH + /api/auth/me/documents-auto-ai-read. A client offering both controls should show them as the two separate things + they are. + + + When a revocation leaves no receipt covering the analysis (`ai_insights_only` or `ai_full`), the same + transaction deletes the text a model wrote under it that can be written again: per-metric status notes, the + cached briefing, model-written period narratives, reaction lines and workout paragraphs. Coach conversations, + facts and plans and document summaries are the person's records and stay. A deletion is audit-logged with counts + only (`consent.ai.purge`). parameters: - in: query name: kind @@ -12805,6 +12899,7 @@ paths: - ai_full - ai_insights_only - ai_coach + - ai_extraction description: Revoke one kind. Omit to revoke the latest active receipt across every kind. responses: "200": @@ -13557,12 +13652,16 @@ paths: provider; ≤ 12 MiB) is run through the user's vision-capable provider; the upload lives in memory only and is never persisted or logged. TEXT (`application/json`, opt-in local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted text reaches the server, so a text-only - provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are gated by AI consent, a - per-user hourly rate bucket (default 6, operator-tunable via `LABS_OCR_LIMIT_PER_HOUR`; a slot is only consumed - when the scan reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-*` triple and - `meta.retryAt`), and the per-day token budget, and return proposed rows for the mandatory human review screen — - nothing is written. Extracted content is treated as untrusted (prompt-injection); the review step is the safety - boundary." + provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are model work and answer under + the `labsOcr` capability (the operator's reading-documents switch, the labs module, and — because a lab report + is a document — an active `ai_extraction` or `ai_full` consent receipt for any provider that leaves the machine, + checked for the provider actually picked: the first vision-capable entry in vision mode, the chain head in text + mode). No provider is 422 `labs.ocr.providerUnsupported`; text mode without the local-OCR opt-in is 422 + `labs.ocr.localOcrDisabled`. Then both pass a per-user hourly rate bucket (default 6, operator-tunable via + `LABS_OCR_LIMIT_PER_HOUR`; a slot is only consumed when the scan reaches the provider, and the 429 carries + `Retry-After`, the `X-RateLimit-*` triple and `meta.retryAt`) and the per-day token budget, and return proposed + rows for the mandatory human review screen — nothing is written. Extracted content is treated as untrusted + (prompt-injection); the review step is the safety boundary." requestBody: required: true content: @@ -13587,8 +13686,29 @@ paths: schema: $ref: "#/components/schemas/OcrExtractEnvelope" "401": *a1 + "403": + description: "AI refusal: the `labsOcr` 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 + switch). `module.disabled` with `meta.module = \"labs\"` — the module is off for this record. + `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's + record). `consent.ai.required` — the provider this request would reach leaves the machine and the record + holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is + answered for the provider actually picked, so it can differ from the published capability when a text-mode + read takes a different provider than a vision read would." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + "503": + description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. + Nothing was sent. Retry later." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/labs/ocr/commit: post: tags: @@ -15470,7 +15590,11 @@ paths: `mode` / `pdfSupported` / `egress` match exactly what the document AI routes do. `egress` is vendor-blind: `\"local\"` (a self-hosted model — the document never leaves the operator's machine) or `\"external\"` (a third-party AI service). The vault shows a per-egress notice before any external document read; sending a - document to any external provider also requires an active AI-consent receipt." + document to any external provider also requires an active `ai_extraction` or `ai_full` consent receipt. `ai` is + the `documentAi` capability for the record: when the operator turned reading documents off, the module is off, + or the record is somebody else's, `available` is false with a null `reason` and `ai.reason` says why. A missing + consent receipt leaves the read offered (`ai.reason = \"consent_required\"`), because the read is where the + person is asked for it." responses: "200": description: Document-AI capability flags + egress class. @@ -15702,14 +15826,16 @@ paths: - Documents summary: Extract facts from a stored document description: "Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the - stored original and stages STRUCTURED FACTS for review. AI-consent / rate / budget gated. With no provider - configured this returns 422 (`documents.inbound.providerUnsupported`) — the stored document is untouched, only - the enhancement fails. Three modes: VISION (empty body) decrypts and scans the stored original (PDF needs an - Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: \"text\", text }` structures - browser-OCR'd text; STORED (`application/json`) `{ mode: \"stored\" }` structures the document's own stored - extracted text (its content index) — the manual recovery for a skipped/failed automatic staging run, 422 - `documents.inbound.notIndexed` when no stored text exists. Extraction reproduces what the document states — it - never interprets. Nothing reaches the structured stores here; the confirm route is the only write path." + stored original and stages STRUCTURED FACTS for review. Answers under the `documentAi` capability (403 with the + capability envelope when it is closed), then rate / budget gated. With no provider configured this returns 422 + (`documents.inbound.providerUnsupported`, with `meta.capability` and `meta.reason = \"no_provider\"`) — the + stored document is untouched, only the enhancement fails. Three modes: VISION (empty body) decrypts and scans + the stored original (PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ + mode: \"text\", text }` structures browser-OCR'd text; STORED (`application/json`) `{ mode: \"stored\" }` + structures the document's own stored extracted text (its content index) — the manual recovery for a + skipped/failed automatic staging run, 422 `documents.inbound.notIndexed` when no stored text exists. Extraction + reproduces what the document states — it never interprets. Nothing reaches the structured stores here; the + confirm route is the only write path." parameters: - name: id in: path @@ -15730,6 +15856,20 @@ paths: schema: $ref: "#/components/schemas/InboundExtractEnvelope" "401": *a1 + "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 + switch). `module.disabled` with `meta.module = \"inboundDocuments\"` — the module is off for this record. + `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's + record). `consent.ai.required` — the provider this request would reach leaves the machine and the record + holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is + answered for the provider actually picked, so it can differ from the published capability when a text-mode + read takes a different provider than a vision read would." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "409": description: "Re-extraction refused: at least one fact on this document is already APPROVED. `meta.errorCode` = `documents.inbound.alreadyPartlyConfirmed`. Re-extracting would sever committed-record provenance and @@ -15740,6 +15880,13 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + "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: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/documents/inbound/{id}/suggest: post: tags: @@ -15747,13 +15894,13 @@ paths: summary: Suggest filing metadata (drafts only) description: "Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR'd text (TEXT, `application/json` `{ mode: \"text\", text }`, opt-in local OCR) and - returns a `{ title, kind, documentDate }` DRAFT for the edit form. AI-consent / rate / budget gated (shares the - per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via - `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries - `Retry-After`, the `X-RateLimit-Limit` / `-Remaining` / `-Reset` triple, and `meta.retryAt`). WRITES NOTHING — - never stages facts, never flips status; the user reviews and saves. 422 - (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title - is a neutral filing label." + returns a `{ title, kind, documentDate }` DRAFT for the edit form. Answers under the `documentAi` capability + (403 with the capability envelope when it is closed), then rate / budget gated (shares the per-user document-AI + bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is + only consumed when the request reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-Limit` + / `-Remaining` / `-Reset` triple, and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; + the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never + interprets or diagnoses; the title is a neutral filing label." parameters: - name: id in: path @@ -15795,8 +15942,10 @@ paths: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" "401": *a1 + "403": *a34 "422": *a4 "429": *a5 + "503": *a35 /api/documents/inbound/{id}/summary: post: tags: @@ -15807,7 +15956,9 @@ paths: while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: - "text", text }`, opt-in local OCR) dispatch as extract. AI-consent / rate / budget gated. 422 with no provider.' + "text", text }`, opt-in local OCR) dispatch as extract. Answers under the `documentAi` capability (403 with the + capability envelope when it is closed), then rate / budget gated. 422 `documents.inbound.providerUnsupported` + with no provider.' parameters: - name: id in: path @@ -15873,22 +16024,25 @@ paths: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" "401": *a1 + "403": *a34 "422": *a4 "429": *a5 + "503": *a35 /api/documents/inbound/{id}/index: post: tags: - Documents summary: Build / refresh the content-search index description: "Populates or refreshes one document's content-search index so search matches INSIDE its body. VISION - (empty body) decrypts the stored original and runs one provider transcription (AI-consent / rate / budget - gated); TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with - no provider egress. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes — no - plaintext body, no plaintext token. Gated on the existing AI consent (no separate per-user toggle). Idempotent; - re-indexing overwrites in place. A successful index continues into the same lab auto-staging the background - worker performs (still-STORED lab-looking document with no facts, both modules on, provider + consent eligible); - `labFactsStaged` reports how many facts were staged PENDING for review — confirmation stays the only write into - Labs." + (empty body) decrypts the stored original and runs one provider transcription (answers under the `documentAi` + capability — 403 with the capability envelope when it is closed, including a missing `ai_extraction` / `ai_full` + receipt for a provider that leaves the machine — then rate / budget gated); TEXT (`application/json` `{ mode: + \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with no provider egress and stays available with + AI off, so search keeps working. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes + — no plaintext body, no plaintext token. Idempotent; re-indexing overwrites in place. A successful index + continues into the same lab auto-staging the background worker performs (still-STORED lab-looking document with + no facts, both modules on, provider + consent eligible); `labFactsStaged` reports how many facts were staged + PENDING for review — confirmation stays the only write into Labs." parameters: - name: id in: path @@ -15930,8 +16084,10 @@ paths: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" "401": *a1 + "403": *a34 "422": *a4 "429": *a5 + "503": *a35 /api/documents/inbound/{id}/chat: post: tags: @@ -15944,10 +16100,12 @@ paths: grounded ONLY in the document's indexed text — NO health snapshot, NO tools, NO other document. The document text is fenced as untrusted DATA (prompt-injection defence); the inbound message + every prior turn are injection-screened and the reply is dose/risk-screened + numerically grounded against the document's own - figures. Available only for a content-indexed document (422 `documents.inbound.notIndexed` otherwise). - AI-consent-gated (403 `consent.ai.required` for an external provider), budget- and rate-limited. Omitting - `conversationId` starts a new thread. Renders as plain text on the client (no markdown). Auth via cookie or - Bearer." + figures. Available only for a content-indexed document (422 `documents.inbound.notIndexed` otherwise). Answers + under the `documentAi` capability (403 with the capability envelope when it is closed; `consent.ai.required` for + a provider that leaves the machine without an `ai_extraction` / `ai_full` receipt), budget- and rate-limited; no + provider is the `documents.chat.provider.none` error frame. Reading the history (GET) stays available whatever + the capability says. Omitting `conversationId` starts a new thread. Renders as plain text on the client (no + markdown). Auth via cookie or Bearer." parameters: - name: id in: path @@ -15969,14 +16127,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": - description: "AI consent required for an external provider (`errorCode: consent.ai.required`)." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "403": *a34 "422": *a4 "429": *a5 + "503": *a35 get: tags: - Documents @@ -16025,9 +16179,11 @@ paths: - Documents summary: Index all documents for content search description: Enqueues a background job that content-indexes the caller's not-yet-indexed documents (one provider - transcription each, bounded + resumable). Gated on the module, a configured vision provider (422 - `documents.inbound.providerUnsupported` otherwise), and the existing AI consent (403 otherwise). Returns - immediately; the work runs off-request on the queue. + transcription each, bounded + resumable). Gated on the module, the `documentAi` capability (403 with the + capability envelope), a configured vision provider (422 `documents.inbound.providerUnsupported` otherwise), and + an `ai_extraction` / `ai_full` consent receipt when that provider leaves the machine (403 + `consent.ai.required`). Returns immediately; the work runs off-request on the queue, which re-checks the + capability before every read. responses: "200": description: How many live documents the enqueued backfill still has to index (0 when everything is indexed or no job @@ -16037,8 +16193,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 + "403": *a34 "422": *a4 "429": *a5 + "503": *a35 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -16145,7 +16303,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a35 + "409": &a37 description: A custom metric with this name already exists for the caller. content: application/json: @@ -16174,7 +16332,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a9 - "404": &a34 + "404": &a36 description: Custom metric not found (or owned by another user). content: application/json: @@ -16208,8 +16366,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a34 - "409": *a35 + "404": *a36 + "409": *a37 "422": *a4 "429": *a5 delete: @@ -16232,7 +16390,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a34 + "404": *a36 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -16276,7 +16434,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a9 - "404": *a34 + "404": *a36 "422": *a4 "429": *a5 post: @@ -16305,7 +16463,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a34 + "404": *a36 "409": *a7 "422": *a4 "429": *a10 @@ -16341,7 +16499,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a36 + "404": &a38 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -16374,7 +16532,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a36 + "404": *a38 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -16783,7 +16941,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a37 + "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 @@ -16838,7 +16996,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a37 + "403": *a39 "422": *a4 "429": *a5 /api/nutrients/water: @@ -16867,7 +17025,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a37 + "403": *a39 "409": *a7 "422": *a4 "429": *a5 @@ -17369,26 +17527,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a38 + "400": &a40 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a39 + "413": &a41 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a40 + "415": &a42 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a41 + "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: @@ -17574,11 +17732,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a38 + "400": *a40 "401": *a1 - "413": *a39 - "415": *a40 - "422": *a41 + "413": *a41 + "415": *a42 + "422": *a43 "429": *a5 delete: tags: @@ -17721,11 +17879,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a38 + "400": *a40 "401": *a1 - "413": *a39 - "415": *a40 - "422": *a41 + "413": *a41 + "415": *a42 + "422": *a43 "429": *a5 delete: tags: @@ -17878,11 +18036,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a38 + "400": *a40 "401": *a1 - "413": *a39 - "415": *a40 - "422": *a41 + "413": *a41 + "415": *a42 + "422": *a43 "429": *a5 delete: tags: @@ -17958,21 +18116,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a42 + "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": &a43 + "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": &a44 + "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` @@ -18017,11 +18175,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a38 + "400": *a40 "401": *a1 - "413": *a39 - "415": *a40 - "422": *a41 + "413": *a41 + "415": *a42 + "422": *a43 "429": *a5 delete: tags: @@ -18097,9 +18255,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a42 - "429": *a43 - "502": *a44 + "422": *a44 + "429": *a45 + "502": *a46 /api/strava/credentials: get: tags: @@ -18135,11 +18293,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a38 + "400": *a40 "401": *a1 - "413": *a39 - "415": *a40 - "422": *a41 + "413": *a41 + "415": *a42 + "422": *a43 "429": *a5 delete: tags: @@ -18217,9 +18375,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a42 - "429": *a43 - "502": *a44 + "422": *a44 + "429": *a45 + "502": *a46 /api/integrations/status: get: tags: @@ -18255,7 +18413,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a43 + "429": *a45 /api/integrations/fitbit/test: post: tags: @@ -18273,9 +18431,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a42 - "429": *a43 - "502": *a44 + "422": *a44 + "429": *a45 + "502": *a46 /api/integrations/google-health/resume: post: tags: @@ -18292,7 +18450,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a43 + "429": *a45 /api/integrations/google-health/test: post: tags: @@ -18325,9 +18483,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a42 - "429": *a43 - "502": *a44 + "422": *a44 + "429": *a45 + "502": *a46 /api/integrations/whoop/resume: post: tags: @@ -18344,7 +18502,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a43 + "429": *a45 /api/integrations/whoop/test: post: tags: @@ -18361,9 +18519,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a42 - "429": *a43 - "502": *a44 + "422": *a44 + "429": *a45 + "502": *a46 /api/integrations/withings/resume: post: tags: @@ -18381,7 +18539,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a43 + "429": *a45 /api/integrations/withings/test: post: tags: @@ -18399,8 +18557,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a42 - "429": *a43 + "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: @@ -18636,7 +18794,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a45 + "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 @@ -18661,7 +18819,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a45 + "403": *a47 "422": *a4 "429": *a5 post: @@ -18756,7 +18914,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a45 + "403": *a47 "404": description: No such invitation for this account. content: @@ -18800,7 +18958,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a45 + "403": *a47 "404": description: No such grant on this account's record. content: @@ -18841,7 +18999,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a45 + "403": *a47 "404": description: No such grant held by this account. content: @@ -19223,7 +19381,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a45 + "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 — @@ -19553,7 +19711,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a46 + "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. @@ -19586,7 +19744,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a46 + "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. @@ -19613,7 +19771,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a46 + "403": *a48 "422": *a4 "429": *a5 post: @@ -19643,7 +19801,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a46 + "403": *a48 "413": description: Body exceeds 16 KiB. content: @@ -19686,7 +19844,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a46 + "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. @@ -19744,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: - - &a47 + - &a49 name: family in: path required: true @@ -19788,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: - - *a47 + - *a49 requestBody: required: true content: @@ -20323,13 +20481,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a48 + "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": &a49 + "404": &a51 description: No stored copy with this id. content: application/json: @@ -20388,8 +20546,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a48 - "404": *a49 + "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. @@ -23911,8 +24069,8 @@ components: type: boolean required: - documentsAutoAiRead - description: Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_full` - consent receipt and schedules a catch-up over the existing vault. + description: Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an + `ai_extraction` consent receipt and schedules a catch-up over the existing vault. InjectionSitePrefsPatchRequest: type: object properties: @@ -26070,6 +26228,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction artefact: type: string minLength: 1 @@ -30097,36 +30256,36 @@ components: (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a credential of its own may carry no code at all. ai — - ai.provider.none, ai.record.notPermitted, ai.unavailable; ai_provider — ai_provider.invalid, - ai_provider.no_fields; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — - anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue; auth — - auth.admin.required, auth.already_authenticated, auth.mfa.code_invalid, auth.missing, - auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, auth.scope.insufficient, - auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.payload.undecryptable, - backup.section.missing; coach — coach.conversation.invalidTitle, coach.conversation.notFound, - coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, - coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, - coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, - coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — cycle.bulk.invalid, - cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid, - cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid, - cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, - cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; documents — - documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, - documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, - documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, - documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, - documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, - documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, - documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed, - documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, - documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, - documents.inbound.restoreGone, documents.inbound.uploadBusy, documents.inbound.uploadTimeout; encounter - — encounter.invalid, encounter.link.invalid, encounter.practitioner-not-found, - encounter.reminder-conflict, encounter.reminder-not-found; environment — environment.invalid, - environment.no_home, environment.range_too_large, environment.travel.not_found; export — - export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — - feedback.body.invalid; health_score_config — health_score_config.invalid, + ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable; ai_provider — + ai_provider.invalid, ai_provider.no_fields; allergy — allergy.invalid; analytics — + analytics.invalid_query; anamnesis — anamnesis.fact.conflict, anamnesis.fact.currentExists, + anamnesis.fact.invalidValue; auth — auth.admin.required, auth.already_authenticated, + auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, + auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — + backup.payload.undecryptable, backup.section.missing; coach — coach.budget.exceeded, + coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, + coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, + coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, + coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; + consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, + cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, + cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; + cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — + disable-coach.body.invalid_json; documents — documents.chat.conversationNotFound, + documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, + documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, + documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, + documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, + documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, + documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound, + documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, + documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, + documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.uploadBusy, + documents.inbound.uploadTimeout; encounter — encounter.invalid, encounter.link.invalid, + encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; environment + — environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found; + export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; + feedback — feedback.body.invalid; health_score_config — health_score_config.invalid, health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import — import.write_failed; ingest — @@ -30774,6 +30933,19 @@ components: isCycleStart: type: boolean description: Whether a logged cycle opens on this day. Deleting this day's log removes that cycle start with it. + cycleDay: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: The 1-based day of the logged cycle this date belongs to. Null before the first logged start, after today, + or once an open cycle has run past the point where the verdict stops counting. For today it equals + verdict.dayOfCycle. + periodEndable: + type: boolean + description: Whether a one-tap period end (POST /api/cycle/period, action end) on this date lands inside the first days + of a logged cycle. Resolved per date, independent of today's phase. flow: anyOf: - $ref: "#/components/schemas/FlowLevel" @@ -30825,6 +30997,8 @@ components: - isPredictedOvulation - isPeriodLogged - isCycleStart + - cycleDay + - periodEndable - flow - hasSymptoms - confidence @@ -43808,10 +43982,13 @@ components: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: "Start of the window recorded doses were read over: the requested `from` after the span clamp." to: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: "End of the window recorded doses were read over: the requested `to`, or the slot time of a dose recorded + ahead of its slot when that lies later (at most one day after `to`)." family: type: string enum: @@ -58230,6 +58407,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction signedAt: type: string revokedAt: @@ -58326,6 +58504,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction receipt: anyOf: - $ref: "#/components/schemas/SerialisedConsentReceipt" @@ -58348,6 +58527,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction signedAt: type: string format: date-time @@ -58404,13 +58584,18 @@ components: anyOf: - $ref: "#/components/schemas/SerialisedConsentReceipt" - type: "null" + ai_extraction: + anyOf: + - $ref: "#/components/schemas/SerialisedConsentReceipt" + - type: "null" required: - ai_full - ai_insights_only - ai_coach + - ai_extraction additionalProperties: false - description: "The latest active receipt for every kind. All three keys are always present: a null value means the kind - is not granted, so a client never has to read absence as ambiguity." + description: "The latest active receipt for every kind. Every key is always present: a null value means the kind is not + granted, so a client never has to read absence as ambiguity." ConsentRevokeForKindEnvelope: type: object properties: @@ -58459,6 +58644,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction receipt: $ref: "#/components/schemas/SerialisedConsentReceipt" required: @@ -59515,17 +59701,24 @@ components: - type: "null" pdfSupported: type: boolean + ai: + $ref: "#/components/schemas/AiCapabilityState" required: - available - mode - reason - pdfSupported + - ai additionalProperties: false - description: Whether the caller's configured AI provider can ingest a lab report (drives the UI's scan affordance). + description: "Whether the caller's configured AI provider can ingest a lab report (drives the UI's scan affordance). `mode` is `vision` when the provider reads the image directly, `text` when the image is OCR'd in-browser and only the extracted text is sent (opt-in local OCR for text-only providers), or null when unavailable. `reason` explains an unavailable state; `pdfSupported` is true when the provider reads PDFs natively (Anthropic) or the - server-side rasterizer can render the pages for any other vision provider. No provider call is made. + server-side rasterizer can render the pages for any other vision provider. `ai` is the `labsOcr` capability for + the record: when the operator turned reading documents off, the labs module is off, or the record is somebody + 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." OcrExtractEnvelope: type: object properties: @@ -62529,12 +62722,15 @@ components: - local - external - type: "null" + ai: + $ref: "#/components/schemas/AiCapabilityState" required: - available - mode - reason - pdfSupported - egress + - ai additionalProperties: false DocumentUsageEnvelope: type: object @@ -62581,6 +62777,9 @@ components: additionalProperties: false assistAvailable: type: boolean + description: "Whether a document read can run for this record now: a provider can serve it and the `documentAi` + capability is not closed by the operator, the module or the sharing grant. The same answer as `available` on + GET /api/documents/inbound/capability." contentIndex: type: object properties: @@ -62819,12 +63018,13 @@ components: WHAT the document is, generated once and then served from storage; it is descriptive only, never a diagnosis. `summaryGeneratedAt` is when it was generated (null until then). `summaryState` says what became of it, because a null `summary` on its own is ambiguous: NONE = never attempted (the `documentsAutoAiRead` opt-in was off at - upload, or the document predates it — no backfill reaches these), PENDING = a job is enqueued or running, READY - = stored and returned in `summary`, WITHHELD = generated but blocked by the outbound safety screen and therefore - never returned as text, UNAVAILABLE = attempted and could not produce one (no vision provider, spent budget, - withdrawn consent, unreadable file, provider error). Only PENDING may be presented as "being generated"; - WITHHELD and UNAVAILABLE are both re-attemptable via POST `/api/documents/inbound/{id}/summary`. Treat an - unknown value as UNAVAILABLE when decoding.' + upload, reading documents with AI was closed for the record at upload, or the document predates it — no backfill + reaches these), PENDING = a job is enqueued or running, READY = stored and returned in `summary`, WITHHELD = + generated but blocked by the outbound safety screen and therefore never returned as text, UNAVAILABLE = + attempted and could not produce one (no vision provider, spent budget, withdrawn consent, unreadable file, + provider error). Only PENDING may be presented as "being generated"; WITHHELD and UNAVAILABLE are both + re-attemptable via POST `/api/documents/inbound/{id}/summary`. Treat an unknown value as UNAVAILABLE when + decoding.' ExtractedFact: type: object properties: diff --git a/messages/de.json b/messages/de.json index ef7fb3863..0edd57f47 100644 --- a/messages/de.json +++ b/messages/de.json @@ -760,6 +760,7 @@ "typeWaistToHeight": "Taille-Größe-Verhältnis", "unitScore": "Pkt.", "unitRatio": "Verhältnis", + "unitRating": "Stufe", "typeWho5Score": "WHO-5-Wert", "typeSciScore": "SCI-Wert" }, @@ -8256,6 +8257,7 @@ "saving": "Speichern…", "cancel": "Abbrechen", "saveError": "Konnte nicht gespeichert werden. Versuch es erneut.", + "emptyHint": "Wähle eine Stärke oder ein Symptom, um diesen Tag zu speichern, oder nutze oben den Knopf für den Periodenbeginn.", "intermenstrualBleeding": "Schmierblutung zwischen Perioden", "pregnancyTest": "Schwangerschaftstest", "progesteroneTest": "Progesterontest", diff --git a/messages/en.json b/messages/en.json index 238396831..fc22621e5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -760,6 +760,7 @@ "typeWaistToHeight": "Waist-to-height ratio", "unitScore": "pts", "unitRatio": "ratio", + "unitRating": "rating", "typeWho5Score": "WHO-5 score", "typeSciScore": "SCI score" }, @@ -8266,6 +8267,7 @@ "saving": "Saving…", "cancel": "Cancel", "saveError": "Could not save. Try again.", + "emptyHint": "Pick a flow or a symptom to save this day, or use the period start button above.", "temperatureSection": "Temperature & ovulation", "intimacySection": "Intimacy & contraception", "testsSection": "Tests", diff --git a/messages/es.json b/messages/es.json index 477047e58..865f35997 100644 --- a/messages/es.json +++ b/messages/es.json @@ -760,6 +760,7 @@ "typeWaistToHeight": "Índice cintura-altura", "unitScore": "pts", "unitRatio": "ratio", + "unitRating": "nivel", "typeWho5Score": "Puntuación WHO-5", "typeSciScore": "Puntuación SCI" }, @@ -8256,6 +8257,7 @@ "saving": "Guardando…", "cancel": "Cancelar", "saveError": "No se pudo guardar. Inténtalo de nuevo.", + "emptyHint": "Elige un flujo o un síntoma para guardar este día, o usa arriba el botón de inicio del periodo.", "intermenstrualBleeding": "Manchado entre periodos", "pregnancyTest": "Prueba de embarazo", "progesteroneTest": "Prueba de progesterona", diff --git a/messages/fr.json b/messages/fr.json index 3ab4ed449..8fb4f9d19 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -619,7 +619,7 @@ "typeBpSys": "Sys", "typeBpDia": "Dia", "typePulse": "Pouls", - "typeBodyFat": "Masse grasse", + "typeBodyFat": "Graisse corporelle", "typeSleep": "Sommeil", "typeSteps": "Pas", "typeBloodGlucose": "Glycémie", @@ -633,7 +633,7 @@ "typeWalkingRunningDistance": "Distance à pied et en course", "typeVo2Max": "VO₂ max", "typeBodyTemperature": "Température corporelle", - "typeFatFreeMass": "Masse maigre", + "typeFatFreeMass": "Masse non grasse", "typeFatMass": "Masse grasse", "typeMuscleMass": "Masse musculaire", "typeSkinTemperature": "Température cutanée", @@ -760,6 +760,7 @@ "typeWaistToHeight": "Rapport taille-taille", "unitScore": "pts", "unitRatio": "ratio", + "unitRating": "niveau", "typeWho5Score": "Score WHO-5", "typeSciScore": "Score SCI" }, @@ -8256,6 +8257,7 @@ "saving": "Enregistrement…", "cancel": "Annuler", "saveError": "Enregistrement impossible. Réessaie.", + "emptyHint": "Choisis un flux ou un symptôme pour enregistrer cette journée, ou utilise le bouton de début des règles ci-dessus.", "intermenstrualBleeding": "Saignements entre les règles", "pregnancyTest": "Test de grossesse", "progesteroneTest": "Test de progestérone", diff --git a/messages/it.json b/messages/it.json index 6d2a00f08..3f1777ff9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -619,7 +619,7 @@ "typeBpSys": "Sis", "typeBpDia": "Dia", "typePulse": "Polso", - "typeBodyFat": "Massa grassa", + "typeBodyFat": "Grasso corporeo", "typeSleep": "Sonno", "typeSteps": "Passi", "typeBloodGlucose": "Glicemia", @@ -633,7 +633,7 @@ "typeWalkingRunningDistance": "Distanza a piedi e di corsa", "typeVo2Max": "VO₂ max", "typeBodyTemperature": "Temperatura corporea", - "typeFatFreeMass": "Massa magra", + "typeFatFreeMass": "Massa libera da grasso", "typeFatMass": "Massa grassa", "typeMuscleMass": "Massa muscolare", "typeSkinTemperature": "Temperatura cutanea", @@ -760,6 +760,7 @@ "typeWaistToHeight": "Rapporto vita-altezza", "unitScore": "pti", "unitRatio": "rapporto", + "unitRating": "livello", "typeWho5Score": "Punteggio WHO-5", "typeSciScore": "Punteggio SCI" }, @@ -8256,6 +8257,7 @@ "saving": "Salvataggio…", "cancel": "Annulla", "saveError": "Impossibile salvare. Riprova.", + "emptyHint": "Scegli un flusso o un sintomo per salvare questo giorno, oppure usa in alto il pulsante di inizio mestruazioni.", "intermenstrualBleeding": "Spotting tra i cicli", "pregnancyTest": "Test di gravidanza", "progesteroneTest": "Test del progesterone", diff --git a/messages/ko.json b/messages/ko.json index 347493505..30f388897 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -760,6 +760,7 @@ "typeWaistToHeight": "허리-키 비율", "unitScore": "점", "unitRatio": "비율", + "unitRating": "단계", "typeWho5Score": "WHO-5 점수", "typeSciScore": "SCI 점수" }, @@ -8266,6 +8267,7 @@ "saving": "저장 중…", "cancel": "취소", "saveError": "저장하지 못했어요. 다시 시도해 주세요.", + "emptyHint": "이 날을 저장하려면 생리량이나 증상을 고르거나, 위의 생리 시작 버튼을 눌러 주세요.", "temperatureSection": "체온 및 배란", "intimacySection": "성관계 및 피임", "testsSection": "검사", diff --git a/messages/pl.json b/messages/pl.json index 126af9283..69afeac71 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -647,7 +647,7 @@ "typeAudioExposureEvent": "Zdarzenie głośnego słuchania", "typeRespiratoryRate": "Częstość oddechów", "typeBodyMassIndex": "Wskaźnik masy ciała", - "typeLeanBodyMass": "Masa beztłuszczowa", + "typeLeanBodyMass": "Szczupła masa ciała", "typeWalkingHeartRateAverage": "Tętno przy chodzeniu (śr.)", "typeWalkingAsymmetry": "Asymetria chodu", "typeWalkingDoubleSupport": "Podwójna podpora chodu", @@ -760,6 +760,7 @@ "typeWaistToHeight": "Stosunek talii do wzrostu", "unitScore": "pkt", "unitRatio": "stosunek", + "unitRating": "poziom", "typeWho5Score": "Wynik WHO-5", "typeSciScore": "Wynik SCI" }, @@ -8256,6 +8257,7 @@ "saving": "Zapisywanie…", "cancel": "Anuluj", "saveError": "Nie udało się zapisać. Spróbuj ponownie.", + "emptyHint": "Wybierz krwawienie lub objaw, aby zapisać ten dzień, albo użyj przycisku początku miesiączki powyżej.", "intermenstrualBleeding": "Plamienie między okresami", "pregnancyTest": "Test ciążowy", "progesteroneTest": "Test progesteronu", diff --git a/prisma/migrations/0344_insight_status_cache/migration.sql b/prisma/migrations/0344_insight_status_cache/migration.sql new file mode 100644 index 000000000..9571572c1 --- /dev/null +++ b/prisma/migrations/0344_insight_status_cache/migration.sql @@ -0,0 +1,44 @@ +-- Status notes move out of the audit log. +-- +-- The per-metric status notes used to be appended to `audit_logs` as plaintext +-- JSON, one row per metric per day, next to the security trail. They now live +-- in their own table, encrypted at rest, one row per (user, metric, locale), +-- upserted in place. +-- +-- 1. The new table. +-- CreateTable +CREATE TABLE "insight_status_caches" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "metric" TEXT NOT NULL, + "locale" TEXT NOT NULL, + "text_encrypted" BYTEA, + "items_encrypted" BYTEA, + "input_hash" TEXT, + "snapshot_hash" TEXT, + "date_key" TEXT NOT NULL, + "generated_at" TIMESTAMP(3), + "retry_at" TIMESTAMP(3), + "negative_reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "insight_status_caches_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "insight_status_caches_user_id_metric_locale_key" ON "insight_status_caches"("user_id", "metric", "locale"); + +-- AddForeignKey +ALTER TABLE "insight_status_caches" ADD CONSTRAINT "insight_status_caches_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- 2. The old rows go. DESTRUCTIVE by design: the notes are a regenerable +-- cache, nothing reads them from `audit_logs` any more, and leaving them +-- would keep plaintext health text in a table documented as holding none. +-- Nothing is copied across, because the old rows are plaintext and a +-- migration cannot encrypt; the first nightly run after the upgrade writes +-- every note again. Take a database backup before applying, as for 0339. +-- Matches exactly the cache keys `insights.-status.` (the +-- seven specialised scopes and the `metric:` / `derived-score:` / +-- `biomarker:` generic ones); no security-trail action has this shape. +DELETE FROM "audit_logs" WHERE "action" LIKE 'insights.%-status.%'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1745fce4b..66075b211 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -860,6 +860,9 @@ model User { // v1.11.0 W3 — one durable period-narrative row per (period, locale). // Delete/regenerate-clean; the generated prose is AES-256-GCM at rest. insightNarratives InsightNarrative[] + // v1.39.0 — the per-metric status notes, one encrypted row per + // (metric, locale). A regenerable cache; it used to live in `audit_logs`. + insightStatusCaches InsightStatusCache[] // v1.31.0 — the data-arrival spine's durable per-day reaction marker. One // row per (kind, local day); the unique index IS the once-per-kind-per-day // claim every reaction surface reads. Retained 14 days. @@ -6722,6 +6725,55 @@ model InsightNarrative { @@map("insight_narratives") } +// v1.39.0 — the per-metric status notes ("Status notes"): what a model wrote +// about one metric for one person in one language, and the fingerprints that +// let the next run skip the model when nothing changed. +// +// One row per (user, metric, locale), upserted in place. Until v1.39.0 these +// notes were appended to `audit_logs` as plaintext JSON, one row per metric per +// day, mixed with the security trail; migration 0344 dropped those rows, so the +// first night after the upgrade regenerates every note once. +// +// A regenerable cache, not a record: excluded from backups, deleted by "Delete +// All Data", purged when the person withdraws AI consent, and never served +// while the `statusText` capability is unavailable. +model InsightStatusCache { + id String @id @default(cuid()) + userId String @map("user_id") + /// The status scope: `weight`, `blood-pressure`, `general`, …, or a prefixed + /// generic scope (`metric:`, `derived-score:`, `biomarker:`). + metric String + /// The UI locale the note was written in. + locale String + /// AES-256-GCM ciphertext of the note. Null while the row only carries a + /// negative-cache window (a generation that stalled before any note existed). + textEncrypted Bytes? @map("text_encrypted") + /// AES-256-GCM ciphertext of per-item notes as JSON, for a card that carries + /// them (medication compliance: one line per medication). Null otherwise. + itemsEncrypted Bytes? @map("items_encrypted") + /// Cheap fingerprint of the salient inputs (count + newest reading). + inputHash String? @map("input_hash") + /// Fingerprint of the full data snapshot the note was written from. + snapshotHash String? @map("snapshot_hash") + /// The person's local YYYY-MM-DD the note counts as current for. + dateKey String @map("date_key") + /// When the note was written, or last re-dated by an unchanged-data run. + generatedAt DateTime? @map("generated_at") + /// Negative cache: while in the future, a read does not enqueue another + /// generation. Cleared when a note is written. + retryAt DateTime? @map("retry_at") + /// Why the last generation served the deterministic line instead of a note: + /// `timeout` | `error` | `screened`. Set together with `retryAt`. + negativeReason String? @map("negative_reason") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, metric, locale]) + @@map("insight_status_caches") +} + // ─── Data-arrival spine (v1.31.0) ───────────────────────────── // // One row per (user, arrival kind, local day). The row is written by the diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index ff2367ff7..1546d0e90 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -454,6 +454,25 @@ async function main() { ), ); + // ───── v1.39.0 InsightStatusCache."textEncrypted" / "itemsEncrypted" ───── + // The per-metric status notes. Both nullable (a row may carry only a + // negative-cache window; only one card stores per-item lines), and + // `rotateBytesColumn` skips a NULL payload. + results.push( + await rotateBytesColumn( + "InsightStatusCache", + "textEncrypted", + prisma.insightStatusCache, + ), + ); + results.push( + await rotateBytesColumn( + "InsightStatusCache", + "itemsEncrypted", + prisma.insightStatusCache, + ), + ); + // ───── v1.31.0 ArrivalReaction."lineEncrypted" (Bytes, nullable) ───── // The data-arrival spine's generated reaction line. Nullable by design — a // provider-less install writes markers with no line at all — and diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index ed24d0515..33167d40a 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -211,6 +211,7 @@ async function seed() { await client.query("DELETE FROM coach_conversations"); await client.query("DELETE FROM consent_receipts"); await client.query("DELETE FROM insight_narratives"); + await client.query("DELETE FROM insight_status_caches"); await client.query("DELETE FROM illness_symptom_links"); await client.query("DELETE FROM illness_day_logs"); await client.query("DELETE FROM illness_episodes"); @@ -1826,11 +1827,10 @@ async function seed() { console.log("Creating baked AI insight texts..."); const todayKey = berlinDayKey(); - // A real-looking provider/model pair so the read path treats the row as a - // genuine assessment (NOT a `model: "timeout-stub"`, which the cache-read - // rejects). The text is what renders; provider/model are provenance only. + // A real-looking provider tag for the rows that record one (the cached + // briefing, the narratives). The text is what renders; the tag is + // provenance only. const bakedProvider = "anthropic"; - const bakedModel = "claude-3-5-sonnet"; // ── Comprehensive insight + daily briefing ── // Stored on users.insights_cached_text (JSON). The GET read parses it and @@ -2037,13 +2037,12 @@ async function seed() { ); // ── Per-metric status cards ── - // Each card is an audit_logs row keyed `insights.-status.` - // whose details JSON is { dateKey, locale, text, providerType, model, - // tokensUsed, snapshotHash }. The read serves a row only when its dateKey - // equals today's Berlin key and the model is not the timeout-stub - // sentinel. We stamp today's key + a real model name so every card reads - // as a current assessment. `text` is the rendered field. 2-4 sentences - // each, grounded in the seeded data, in the base-system advisor voice. + // Each card is an insight_status_caches row keyed (user, metric, locale), + // the note encrypted with the app's codec. The read serves a note only + // when its date_key equals today's key, so every card is stamped with + // today's key and reads as a current assessment. The medication card also + // carries its (empty) per-medication list. 2-4 sentences each, grounded in + // the seeded data, in the base-system advisor voice. const statusCards: Array<{ scope: string; text: string }> = [ { scope: "blood-pressure", @@ -2108,23 +2107,30 @@ async function seed() { ]; for (const card of statusCards) { - const action = `insights.${card.scope}-status.en`; await client.query( - `INSERT INTO audit_logs (id, user_id, action, details, created_at) - VALUES ($1, $2, $3, $4, NOW())`, + `INSERT INTO insight_status_caches + (id, user_id, metric, locale, text_encrypted, items_encrypted, + snapshot_hash, date_key, generated_at, created_at, updated_at) + VALUES ($1, $2, $3, 'en', $4, $5, $6, $7, NOW(), NOW(), NOW()) + ON CONFLICT (user_id, metric, locale) DO UPDATE SET + text_encrypted = EXCLUDED.text_encrypted, + items_encrypted = EXCLUDED.items_encrypted, + snapshot_hash = EXCLUDED.snapshot_hash, + date_key = EXCLUDED.date_key, + generated_at = EXCLUDED.generated_at, + retry_at = NULL, + negative_reason = NULL, + updated_at = NOW()`, [ cuid(), userId, - action, - JSON.stringify({ - dateKey: todayKey, - locale: "en", - text: card.text, - providerType: bakedProvider, - model: bakedModel, - tokensUsed: null, - snapshotHash: `demo-baked-${card.scope}`, - }), + card.scope, + Buffer.from(encryptToBytes(card.text)), + card.scope === "medication-compliance" + ? Buffer.from(encryptToBytes("[]")) + : null, + `demo-baked-${card.scope}`, + todayKey, ], ); } diff --git a/src/__tests__/ai-consent-enforcement-guard.test.ts b/src/__tests__/ai-consent-enforcement-guard.test.ts index 025083705..adfa10b47 100644 --- a/src/__tests__/ai-consent-enforcement-guard.test.ts +++ b/src/__tests__/ai-consent-enforcement-guard.test.ts @@ -64,14 +64,14 @@ const RESOLUTION_HELPERS = [ "resolveProviderForTest", ] as const; -/** The two sanctioned consent APIs. Either satisfies the guard. */ +/** The sanctioned consent APIs in the consent guard. Any satisfies the guard. */ const CONSENT_HELPERS = [ // Chain-shaped: throw-form and predicate-form. "assertConsentForChain", "chainRequiresServerManagedConsent", "hasActiveConsentForSurface", - // Document-class, pick-shaped. - "assertDocumentEgressConsent", + // Document-class, pick-shaped (the receipt itself is read by the capability + // egress re-check below). "isExternalDocumentEgress", ] as const; @@ -108,9 +108,23 @@ function resolvesAProvider(source: string): boolean { return RESOLUTION_HELPERS.some((h) => imported.includes(h)); } +/** + * The capability re-check at the wire. It answers the consent question for the + * providers a caller is about to send to, from the receipt kinds the capability + * table declares, so it is a consent helper in its own right. + */ +const CAPABILITY_EGRESS_HELPERS = [ + "assertAiEgress", + "aiEgressRefusal", +] as const; + function importsAConsentHelper(source: string): boolean { const imported = importedNamesFrom(source, "@/lib/ai/consent-guard"); - return CONSENT_HELPERS.some((h) => imported.includes(h)); + const egress = importedNamesFrom(source, "@/lib/ai/capabilities/egress"); + return ( + CONSENT_HELPERS.some((h) => imported.includes(h)) || + CAPABILITY_EGRESS_HELPERS.some((h) => egress.includes(h)) + ); } /** @@ -125,8 +139,6 @@ function importsAConsentHelper(source: string): boolean { */ const ALLOWLIST: Record = { // --- Capability / config probes: resolve a chain, never call it. --- - "lib/labs/ocr-capability.ts": - "Resolves the chain to REPORT which entry could do vision/text OCR (mode, reason, pdfSupported). Makes no completion call. Every consumer that then egresses a document is gated at its own call site with assertDocumentEgressConsent.", "app/api/insights/provider-chain/route.ts": "Lists the user's configured chain and which entry is active, for the settings UI. Constructs clients but never calls generateCompletion. No health data in the request or the response.", diff --git a/src/__tests__/ai-egress-capability-guard.test.ts b/src/__tests__/ai-egress-capability-guard.test.ts new file mode 100644 index 000000000..123c56808 --- /dev/null +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -0,0 +1,199 @@ +/** + * Structural guard on the places a model is actually called. + * + * A capability is only as good as the last check before the wire. A job + * enqueued before an operator flipped a switch, or before the person withdrew + * consent or turned AI analysis off, must still stop there. So every file that + * hands a prompt to a provider (`.generateCompletion(`, the chain runners) is + * named here with the reason its call is admitted, and the ones that ARE a + * chokepoint must re-check a capability in the same file. + * + * Mirrors `ai-consent-enforcement-guard.test.ts` in shape and in its limits: it + * proves a reference exists, not that it sits on every branch or before the + * call. The integration suite with a provider spy + * (`tests/integration/ai-optional-jobs.test.ts`) is what proves behaviour. + * A future egress helper under a new name would slip the matcher; a reviewer + * adding one should add it here. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { runStatusCompletion } from "@/lib/insights/status-provider"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** A provider call: a client's completion (plain or streamed), or a chain runner. */ +const EGRESS_CALL = + /\.generateCompletion(?:Stream)?\s*\(|\brunRawCompletionWithFallback\s*\(|\brunWithFallback\s*\(/; + +/** + * A capability read in the same file: one of the gates, or the wire re-check + * (`aiEgressRefusal` / `assertAiEgress`), which also answers the consent a + * picked provider needs. + */ +const CAPABILITY_CHECK = + /\b(?:aiCapabilityForJob|aiCapabilityForRecord|requireAiCapability|getAiCapability|aiEgressRefusal|assertAiEgress)\s*\(/; + +/** Comments name the helpers too; only code counts. */ +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +function code(rel: string): string { + return stripComments(readFileSync(join(SRC, rel), "utf8")); +} + +interface EgressSite { + /** True when this file is a chokepoint and must re-check a capability. */ + recheck: boolean; + reason: string; +} + +/** + * Every file that calls a provider, and why the call is admitted. + */ +const EGRESS_SITES: Record = { + // ── Chokepoints: re-check at the wire ────────────────────────────────── + "lib/insights/status-provider.ts": { + recheck: true, + reason: + "runStatusCompletion: status notes, narratives, workout paragraphs, derived and biomarker assessments, Coach memory. Takes the capability as a required argument and re-checks it before the chain is resolved.", + }, + "lib/jobs/reaction-line.ts": { + recheck: true, + reason: + "Reaction lines call the first chain entry directly; the worker resolves `reactionLines` before the digest and re-checks the chain at the wire (`aiEgressRefusal`).", + }, + "lib/jobs/coach-nudge-ai.ts": { + recheck: true, + reason: + "AI-composed nudges call the first chain entry directly; the composer re-checks `coach` before the chain.", + }, + + // ── Admitted by the one caller that reaches them ─────────────────────── + "lib/insights/briefing-provider.ts": { + recheck: false, + reason: + "Reached only through generateComprehensiveInsight, which re-checks `briefing` before the chain is resolved.", + }, + "lib/ai/coach/tools/loop.ts": { + recheck: false, + reason: + "The Coach tool loop runs inside the chat stream, which the chat route gates on `coach`.", + }, + "lib/ai/coach/self-context-questions.ts": { + recheck: false, + reason: + "About-me follow-up questions; reached from the about-me route, which gates `aboutMeQuestions`.", + }, + "lib/documents/assist.ts": { + recheck: false, + reason: + "Document suggest/chat helpers; reached from the document routes and jobs, which resolve `documentAi` before a provider is picked.", + }, + "lib/documents/describe.ts": { + recheck: false, + reason: + "Document summary; reached from the summary route and the summary job, which resolve `documentAi` first.", + }, + "lib/documents/extract.ts": { + recheck: false, + reason: + "Document extraction/indexing; reached from the document routes and the index jobs, which resolve `documentAi` first.", + }, + "lib/labs/ocr-extract.ts": { + recheck: false, + reason: + "Lab report scan; reached from the labs OCR route, which gates `labsOcr`.", + }, + "app/api/medications/extract/route.ts": { + recheck: false, + reason: + "Medication text extraction; the route is the chokepoint for `medicationExtract`.", + }, + "app/api/ai/test/route.ts": { + recheck: false, + reason: + "The connection test a person runs on a provider they are configuring; it sends a fixed probe prompt, no health data.", + }, + + // ── The machinery itself ──────────────────────────────────────────────── + "lib/ai/provider-runner.ts": { + recheck: false, + reason: "The chain runners; every caller above is what admits a run.", + }, + "lib/ai/openai-client.ts": { + recheck: false, + reason: "A wire client delegating to its own completion method.", + }, + "lib/ai/local-client.ts": { + recheck: false, + reason: "A wire client delegating to its own completion method.", + }, + "lib/ai/coach/eval/judge.ts": { + recheck: false, + reason: "The offline evaluation harness; never on a request or job path.", + }, +}; + +describe("AI egress call sites", () => { + const found = sourceFiles().filter((rel) => EGRESS_CALL.test(code(rel))); + + it("finds provider calls at all (an empty matcher is a failure, not a pass)", () => { + expect(found.length).toBeGreaterThan(5); + }); + + it("every provider call site is named, with a reason", () => { + const unlisted = found.filter((rel) => !(rel in EGRESS_SITES)); + expect( + unlisted, + "A new file calls a provider. Add it to EGRESS_SITES with the reason its call is admitted, and re-check a capability in it if it is a chokepoint.", + ).toEqual([]); + }); + + it("the list carries no file that no longer calls a provider", () => { + const stale = Object.keys(EGRESS_SITES).filter( + (rel) => !found.includes(rel), + ); + expect(stale).toEqual([]); + }); + + it("every chokepoint re-checks a capability in the same file", () => { + const missing = Object.entries(EGRESS_SITES) + .filter(([, site]) => site.recheck) + .filter(([rel]) => !CAPABILITY_CHECK.test(code(rel))) + .map(([rel]) => rel); + expect(missing).toEqual([]); + }); +}); + +describe("the status chokepoint's capability argument", () => { + it("is required by the type (the @ts-expect-error below fails typecheck if it is not)", () => { + type Args = Parameters[0]; + // @ts-expect-error — `capability` is required: a caller that omits it does not compile. + const withoutCapability: Args = { + userId: "u", + cacheAction: "insights.weight-status.en", + systemPrompt: "s", + userPrompt: "p", + }; + const withCapability: Args = { + ...withoutCapability, + capability: "statusText", + }; + expect(withCapability.capability).toBe("statusText"); + }); +}); diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts new file mode 100644 index 000000000..8414051a4 --- /dev/null +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -0,0 +1,175 @@ +/** + * Structural guard: background and insight readers must not materialise a + * dense measurement stream. + * + * #1023: a single account syncing heart rate from a watch took the worker + * down within half an hour. The status-card read was bounded in time + * (`measuredAt >= now − 91 days`) but not in rows, and a stream sampled every + * minute or faster turns 91 days into six or seven figures of objects. The + * read also ran into the statement timeout. The cure was to fold per day in + * SQL (`readDayAggregates`, `readLiveBuckets`), so the reader's size tracks + * the number of days, not the sampling rate. + * + * What this guard pins, in the directories that run on the worker or feed an + * insight: every `measurement.findMany` without a `take` reads a type that is + * named as a literal and is not one a device streams. A read whose type is a + * variable could be handed a dense type by the next caller, so it must be + * listed below with the reason it is safe. The list is frozen both ways: a new + * unlisted read fails, and an entry whose read is gone fails too. + * + * It is a tripwire, not a proof: the matcher reads source text, and a read + * written some other way (a raw SELECT of rows, a helper in another + * directory) passes it. The floor below fails the guard if the matcher stops + * finding reads at all. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; +import { + CUMULATIVE_HK_TYPES, + HIGH_FREQUENCY_MEAN_TYPES, +} from "@/lib/measurements/apple-health-mapping"; + +const SRC = join(process.cwd(), "src"); + +const SCOPED_DIRS = [ + "lib/insights/", + "lib/jobs/", + "lib/rollups/", + "lib/ai/", + "lib/analytics/", + "lib/dashboard/", + "lib/daily/", +]; + +/** Types a device writes at sampling rate rather than a few times a day. */ +const DENSE_TYPES: ReadonlySet = new Set([ + "PULSE", + "BLOOD_GLUCOSE", + "HEART_RATE_VARIABILITY", + "OXYGEN_SATURATION", + ...CUMULATIVE_HK_TYPES, + ...HIGH_FREQUENCY_MEAN_TYPES, +]); + +/** + * Untaken reads whose type is not a sparse literal, keyed + * `::`, with why each stays bounded. + */ +const ALLOWED: Record = { + "lib/insights/comprehensive-generate.ts::(shorthand)": + 'Only reached for SLEEP_DURATION (guarded by `type === "SLEEP_DURATION"`); a few stage rows per night.', + "lib/insights/derived/vascular-age.ts::VASCULAR_AGE_TYPE": + "A constant for a type written at most a few times a day.", + "lib/insights/derived/fitness-age.ts::VO2_MAX_TYPE": + "A constant for VO2 max, written at most daily.", + "lib/insights/derived/six-minute-walk.ts::SIX_MINUTE_WALK_TYPE": + "A constant for six-minute-walk distance, written at most daily.", + "lib/insights/derived/wellness-scores.ts::measurementType": + "Daily computed / provider scores (recovery, strain, readiness), one row per day.", + "lib/jobs/reaction-line.ts::{ in: [...types]": + "Exact-instant match (`measuredAt: row.occurredAt`), not a window.", + "lib/jobs/step-consolidation-repair.ts::STEP_TYPE": + "Repair job over one user's step rows for a bounded day range; runs once per repair, not per status refresh.", + 'lib/ai/coach/snapshot.ts::"BLOOD_GLUCOSE" as never': + "Clinical CGM metrics (time in range, SD / CV, J-index, LBGI / HBGI, reading span) are functions of the individual readings, which a per-day aggregate cannot reproduce. 30 days at a sensor's fixed rate (at most one a minute) stays below 45 000 rows.", + 'lib/dashboard/snapshot.ts::"BLOOD_GLUCOSE"': + "Same 30-day clinical glucose panel as the coach snapshot (per-reading metrics), grouped by meal context.", + "lib/analytics/score/reader.ts::{ in: types": + "Health-score inputs: steps (drained to one row per day nightly), sleep, waist, weight, blood pressure, fasting glucose only.", +}; + +interface UntakenRead { + file: string; + line: number; + typeExpr: string; +} + +function scopedFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => SCOPED_DIRS.some((d) => p.startsWith(d))) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +function untakenReads(): { all: number; untaken: UntakenRead[] } { + let all = 0; + const untaken: UntakenRead[] = []; + for (const file of scopedFiles()) { + const src = readFileSync(join(SRC, file), "utf8"); + // Whitespace-tolerant: a call split across lines still matches. + const re = /\.measurement\s*\.\s*findMany\s*\(/g; + for (let m = re.exec(src); m; m = re.exec(src)) { + all += 1; + let i = m.index + m[0].length; + let depth = 1; + while (depth > 0 && i < src.length) { + if (src[i] === "(") depth += 1; + else if (src[i] === ")") depth -= 1; + i += 1; + } + const call = src.slice(m.index, i); + if (/\btake\b/.test(call)) continue; + const typed = /\btype\s*:\s*([^,\n}]+)/.exec(call); + const typeExpr = typed + ? typed[1].trim() + : /\btype\s*,/.test(call) + ? "(shorthand)" + : "(none)"; + untaken.push({ + file, + line: src.slice(0, m.index).split("\n").length, + typeExpr, + }); + } + } + return { all, untaken }; +} + +function sparseLiteral(typeExpr: string): boolean { + const lit = /^"([A-Z_0-9]+)"/.exec(typeExpr); + return lit !== null && !DENSE_TYPES.has(lit[1]); +} + +describe("dense measurement reads stay bounded (#1023)", () => { + const { all, untaken } = untakenReads(); + + it("finds the reads it is meant to police", () => { + // Guard against a matcher that silently matches nothing. + expect(all).toBeGreaterThanOrEqual(40); + expect(untaken.length).toBeGreaterThanOrEqual(15); + }); + + it("every untaken read names a sparse literal type or is listed with its reason", () => { + const offenders = untaken + .filter((r) => !sparseLiteral(r.typeExpr)) + .filter((r) => !(`${r.file}::${r.typeExpr}` in ALLOWED)) + .map((r) => `${r.file}:${r.line} type ${r.typeExpr}`); + expect(offenders).toEqual([]); + }); + + it("every listed exception still exists", () => { + const present = new Set(untaken.map((r) => `${r.file}::${r.typeExpr}`)); + expect(Object.keys(ALLOWED).filter((k) => !present.has(k))).toEqual([]); + }); + + it("the readers fixed for #1023 fold in SQL rather than reading rows", () => { + for (const file of [ + "lib/insights/graded-series.ts", + "lib/rollups/tiered-context.ts", + "lib/jobs/coach-plan-review.ts", + "lib/measurements/daily-series-read.ts", + ]) { + const src = readFileSync(join(SRC, file), "utf8"); + expect(src, file).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/); + } + const baseline = readFileSync( + join(SRC, "lib/insights/derived/baseline.ts"), + "utf8", + ); + expect(baseline).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/); + }); +}); diff --git a/src/__tests__/helpers/provider-order-mock.ts b/src/__tests__/helpers/provider-order-mock.ts new file mode 100644 index 000000000..d3bf28cf6 --- /dev/null +++ b/src/__tests__/helpers/provider-order-mock.ts @@ -0,0 +1,67 @@ +/** + * A `@/lib/documents/provider-order` mock for the vault route suites. + * + * The suites steer the document pick through `resolveDocumentVisionProvider` / + * `resolveDocumentTextProvider` (a `{ chain, pick }` per case). The routes call + * the `require*` forms, which throw instead of returning an empty pick; these + * are built on the two mocked resolvers so a case that returns no pick sees + * the route answer the vault's own `documents.inbound.providerUnsupported` + * 422, and a case that returns a `withheld` refusal sees it thrown. + * + * Use inside a factory, where only a dynamic import is allowed: + * + * vi.mock("@/lib/documents/provider-order", async () => + * (await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(), + * ); + */ +import { vi } from "vitest"; + +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; + +interface MockPick { + pick: unknown; + withheld?: AiUnavailableError | null; +} + +async function required(resolved: Promise | MockPick) { + const result = await resolved; + if (result?.withheld) throw result.withheld; + if (!result?.pick) { + throw new AiUnavailableError("documentAi", "no_provider", null, { + errorCode: "documents.inbound.providerUnsupported", + status: 422, + }); + } + return result.pick; +} + +export function providerOrderMock() { + const resolveDocumentVisionProvider = vi.fn(); + const resolveDocumentTextProvider = vi.fn(); + return { + resolveDocumentVisionProvider, + resolveDocumentTextProvider, + requireDocumentVisionProvider: vi.fn((userId: string) => + required(resolveDocumentVisionProvider(userId)), + ), + requireDocumentTextProvider: vi.fn((userId: string) => + required(resolveDocumentTextProvider(userId)), + ), + }; +} + +/** The capability gate, open. Pair with `vi.mock("@/lib/ai/capabilities/gate", …)`. */ +export function openCapabilityGateMock() { + return { + requireAiCapability: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), + getAiCapability: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), + }; +} diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index cb5a0d316..759d4b43c 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -74,7 +74,7 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [ */ const EXEMPT_TEMPLATES: Record = { "insights.${scope}-status.${locale}": - "auditLog action name for the per-metric status cache (statusCacheAction), not an i18n key", + "the per-metric status note name (statusCacheAction), not an i18n key", "insights.${scope}.${locale}": "auditLog action name for the previous-insight memory read (getPreviousInsightContext), not an i18n key", "apiErrors.${errorCode}": diff --git a/src/app/api/__tests__/ai-capability-route-inventory.test.ts b/src/app/api/__tests__/ai-capability-route-inventory.test.ts index 30fd05cf6..a5b256218 100644 --- a/src/app/api/__tests__/ai-capability-route-inventory.test.ts +++ b/src/app/api/__tests__/ai-capability-route-inventory.test.ts @@ -45,13 +45,18 @@ function code(path: string): string { } /** Whitespace-tolerant: a call split across lines still matches. */ -const GATE_CALL = /\b(requireAiCapability|getAiCapability)\s*\(\s*"(\w+)"/g; -const ANY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/; +const 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], key: m[2] })); + return [...text.matchAll(GATE_CALL)].map((m) => ({ + fn: m[1] ?? m[3], + key: m[2] ?? m[4], + })); } function routeFiles(): string[] { @@ -81,6 +86,9 @@ describe("AI capability route inventory", () => { { 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", () => { diff --git a/src/app/api/__tests__/ai-route-inventory.ts b/src/app/api/__tests__/ai-route-inventory.ts index 8b619bfbe..2e49101b2 100644 --- a/src/app/api/__tests__/ai-route-inventory.ts +++ b/src/app/api/__tests__/ai-route-inventory.ts @@ -11,9 +11,11 @@ * * - `action` routes call `requireAiCapability(key)` and refuse with the * capability envelope; - * - `mixed` reads call `getAiCapability(key)`, answer 200, null the model - * text and say why in `ai`. They never refuse for an AI reason, so they - * never call `requireAiCapability`. + * - `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 @@ -29,6 +31,67 @@ export interface AiRouteEntry { } 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"], @@ -189,23 +252,19 @@ export const DATA_ROUTES: Readonly> = { "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 owns. Each still has + * 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> = { - "src/app/api/insights/chat/fenced/route.ts": - "Document chat inside a conversation: moves to `coach` and `documentAi` with the document-extraction routes.", - "src/app/api/insights/chat/[id]/attachments/route.ts": - "Attaching a document to a conversation: moves to `coach` and `documentAi` with the document-extraction routes.", - "src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts": - "Detaching a document: removal of one's own data, stays ungated once the document routes move.", -}; +export const PENDING_ROUTES: Readonly> = {}; /** The trees in which every route has to be classified above. */ export const AI_ROUTE_TREES = [ 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 5b9556f47..ba12f419a 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -42,9 +42,6 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; * module is the AI analysis opt-out and gates only AI parts; * the data routes that happen to live under * `/api/insights` are EXEMPT below. - * - coach → `requireAssistantSurface("coach")`, the retired gate, - * still recognised for the one route outside these trees - * that has not moved to its capability yet. * * 3. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a * toggleable domain but are deliberately NOT gated, each with the @@ -253,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, @@ -277,9 +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", - // NB: `medications/extract` is NOT exempt — it gates on - // `requireAssistantSurface("coach")` (the NL-extraction is an assistant - // surface), so the inventory already counts it as a delegated gate. + // `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", @@ -421,10 +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)\s*\(/; +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 @@ -679,7 +675,6 @@ 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; @@ -801,7 +796,6 @@ 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) || fileHasCapabilityGate(text) ) { diff --git a/src/app/api/ai/test/__tests__/route.test.ts b/src/app/api/ai/test/__tests__/route.test.ts index 840c64368..dcef82ea6 100644 --- a/src/app/api/ai/test/__tests__/route.test.ts +++ b/src/app/api/ai/test/__tests__/route.test.ts @@ -43,8 +43,19 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); +vi.mock("@/lib/feature-flags", () => ({ + loadAssistantSwitches: vi.fn(async () => ({ + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + documentAi: true, + })), +})); + import { POST } from "../route"; import { resolveProviderForTest } from "@/lib/ai/provider"; +import { loadAssistantSwitches } from "@/lib/feature-flags"; beforeEach(() => { vi.resetAllMocks(); @@ -367,6 +378,8 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => { expect(response.status).toBe(429); expect(generateCompletion).not.toHaveBeenCalled(); + const body = (await response.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("ai.budget.exceeded"); }); it("records a successful probe on the ledger", async () => { @@ -385,3 +398,48 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => { ); }); }); + +describe("POST /api/ai/test — the operator's master switch and typed refusals", () => { + it("refuses with the master's code when AI is off on the server, before any provider resolves", async () => { + vi.mocked(loadAssistantSwitches).mockResolvedValueOnce({ + enabled: false, + coach: false, + briefing: false, + insightStatus: false, + documentAi: false, + }); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(403); + const body = (await response.json()) as { meta: Record }; + expect(body.meta).toEqual({ + errorCode: "assistant.disabled.enabled", + reason: "operator_disabled", + }); + expect(resolveProviderForTest).not.toHaveBeenCalled(); + }); + + it("fails closed, without a 5xx, when the switches cannot be read", async () => { + vi.mocked(loadAssistantSwitches).mockResolvedValueOnce(null); + const response = await POST(emptyRequest() as never); + expect(response.status).toBeLessThan(500); + const body = (await response.json()) as { meta: Record }; + expect(body.meta).toEqual({ + errorCode: "ai.unavailable", + reason: "check_failed", + }); + expect(resolveProviderForTest).not.toHaveBeenCalled(); + }); + + it("names a missing provider", async () => { + vi.mocked(resolveProviderForTest).mockResolvedValue({ + type: "none", + } as never); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(422); + const body = (await response.json()) as { meta: Record }; + expect(body.meta).toEqual({ + errorCode: "ai.provider.none", + reason: "no_provider", + }); + }); +}); diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 27265df91..1c6c53cca 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -23,6 +23,23 @@ import { resolveDailyCap, } from "@/lib/ai/coach/budget"; import { aiTestOverrideSchema } from "@/lib/validations/ai-provider"; +import { + AI_PROVIDER_NONE_ERROR_CODE, + AI_UNAVAILABLE_ERROR_CODE, +} from "@/lib/ai/capabilities/types"; +import { loadAssistantSwitches } from "@/lib/feature-flags"; + +/** + * The master switch's refusal code. The connection test sends a fixed prompt + * and no health data, so it belongs to no capability; it answers to the + * operator's master switch alone, under the `assistant.disabled.` + * template with the master's own name (`enabled`) as the last segment. + */ +const MASTER_SWITCH = "enabled"; +const AI_TEST_MASTER_DISABLED_CODE = `assistant.disabled.${MASTER_SWITCH}`; + +/** The daily AI budget refusal's code. */ +const AI_TEST_BUDGET_CODE = "ai.budget.exceeded"; /** * Output ceiling of the probe below. Mirrors the `maxTokens` on the completion @@ -50,6 +67,27 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "ai.test" } }); + // With the operator's master switch off no AI call leaves this server, and + // the connection test is an AI call. A switch set that could not be read + // fails closed, like every capability does. + // (403, not the usual 503 for `ai.unavailable`: this route never answers + // 5xx, because a proxy rewrites a 5xx into an HTML page the settings form + // cannot parse. See the catch below.) + const switches = await loadAssistantSwitches(); + if (switches === null) { + return apiError("AI is unavailable right now", 403, { + errorCode: AI_UNAVAILABLE_ERROR_CODE, + reason: "check_failed", + }); + } + if (!switches.enabled) { + annotate({ action: { name: "ai.test.operator_disabled" } }); + return apiError("AI is turned off on this server", 403, { + errorCode: AI_TEST_MASTER_DISABLED_CODE, + reason: "operator_disabled", + }); + } + const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000); if (!rl.allowed) return apiError("Too many test requests", 429); @@ -96,7 +134,10 @@ export const POST = apiHandler(async (request: NextRequest) => { } if (provider.type === "none") { - return apiError("No AI provider configured", 422); + return apiError("No AI provider configured", 422, { + errorCode: AI_PROVIDER_NONE_ERROR_CODE, + reason: "no_provider", + }); } // Meter the probe on the same daily ledger every other AI surface writes to, @@ -123,7 +164,9 @@ export const POST = apiHandler(async (request: NextRequest) => { ); if (!reservation.allowed) { annotate({ action: { name: "ai.test.budget_exceeded" } }); - return apiError("Daily AI budget exhausted", 429); + return apiError("Daily AI budget exhausted", 429, { + errorCode: AI_TEST_BUDGET_CODE, + }); } try { diff --git a/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts b/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts index 0fa555dd3..656fb617f 100644 --- a/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts +++ b/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts @@ -11,7 +11,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; */ vi.mock("@/lib/db", () => ({ - prisma: { user: { findUnique: vi.fn(), update: vi.fn() } }, + prisma: { + user: { findUnique: vi.fn(), update: vi.fn() }, + consentReceipt: { findFirst: vi.fn(), create: vi.fn() }, + }, })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -21,8 +24,8 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/consent/web-grant", () => ({ - ensureWebAiConsentReceipt: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/ai/capabilities/gate", () => ({ + getAiCapability: vi.fn(), })); vi.mock("@/lib/jobs/document-summary-catchup", () => ({ enqueueSummaryCatchUp: vi.fn().mockResolvedValue({ enqueued: true }), @@ -47,7 +50,7 @@ vi.mock("next/headers", () => ({ import { PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup"; const SESSION_OK = { @@ -74,6 +77,13 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.update).mockResolvedValue({} as never); + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + vi.mocked(prisma.consentReceipt.create).mockResolvedValue({} as never); + vi.mocked(getAiCapability).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); }); describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () => { @@ -103,23 +113,75 @@ describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () await PATCH(mkPatch(false)); expect(enqueueSummaryCatchUp).not.toHaveBeenCalled(); - expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled(); + expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); }); - it("still mints the consent receipt on the flip that schedules the pass", async () => { - // The catch-up rides the same act of consent, never around it. The mint - // is marked `affirmative` because switching the toggle on IS the consent - // act: unlike the settings-mount heal, it may lift an earlier - // withdrawal, which is the user deciding again rather than the page - // deciding for them. + it("mints the extraction receipt on the flip that schedules the pass", async () => { + // The catch-up rides the same act of consent, never around it. The toggle + // grants document reading only: `ai_extraction`, not the master `ai_full` + // that would also open the Coach and the AI analysis. withPrevious(false); await PATCH(mkPatch(true)); - expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith( - "user-1", - "affirmative", + expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1); + const data = vi.mocked(prisma.consentReceipt.create).mock.calls[0]![0] + .data as { userId: string; kind: string }; + expect(data.userId).toBe("user-1"); + expect(data.kind).toBe("ai_extraction"); + // The capability is read after the mint, so the fresh receipt counts. + const mintOrder = vi.mocked(prisma.consentReceipt.create).mock + .invocationCallOrder[0]!; + const readOrder = vi.mocked(getAiCapability).mock.invocationCallOrder[0]!; + expect(mintOrder).toBeLessThan(readOrder); + }); + + it("does not mint a second receipt when one that covers document reads is active", async () => { + withPrevious(false); + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({ + id: "r1", + } as never); + + await PATCH(mkPatch(true)); + + expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: "user-1", + revokedAt: null, + kind: { in: ["ai_extraction", "ai_full"] }, + }), + }), ); + expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); + }); + + it("treats a concurrent mint as success", async () => { + withPrevious(false); + vi.mocked(prisma.consentReceipt.create).mockRejectedValue({ + code: "P2002", + }); + + const res = await PATCH(mkPatch(true)); + + expect(res.status).toBe(200); + }); + + it("saves the preference but schedules no catch-up while document reading is closed", async () => { + // The operator turned document reading off: every summary the pass would + // enqueue could only be refused, so it is not enqueued at all. + withPrevious(false); + vi.mocked(getAiCapability).mockResolvedValue({ + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + }); + + const res = await PATCH(mkPatch(true)); + + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalled(); + expect(enqueueSummaryCatchUp).not.toHaveBeenCalled(); }); it("persists the flag field-by-field", async () => { diff --git a/src/app/api/auth/me/documents-auto-ai-read/route.ts b/src/app/api/auth/me/documents-auto-ai-read/route.ts index 8fef3da24..ee1161184 100644 --- a/src/app/api/auth/me/documents-auto-ai-read/route.ts +++ b/src/app/api/auth/me/documents-auto-ai-read/route.ts @@ -10,12 +10,17 @@ * through the user's configured external provider with no per-document tap. * * Flipping it ON is itself the standing consent act, so the write also mints an - * append-only `ai_full` consent receipt (`ensureWebAiConsentReceipt`) — the - * durable audit record that sits alongside the runtime short-circuit the - * document consent gate reads. An OFF→ON flip additionally schedules a bounded - * catch-up over the documents already stored (`enqueueSummaryCatchUp`), because - * the summary job is enqueued at upload time and would otherwise never revisit - * a vault filled before the opt-in. Mirrors `auth/me/labs-local-ocr`: 60/min rate + * append-only `ai_extraction` consent receipt — the durable record the document + * capability reads. It covers reading documents, lab reports and medication + * text and nothing else: the Coach and the AI analysis keep asking for their + * own consent. (Before this release the toggle minted `ai_full`, which also + * opened those; receipts minted that way stay valid, because nothing tells + * them apart from a deliberate master grant.) An OFF→ON flip additionally + * schedules a bounded catch-up over the documents already stored + * (`enqueueSummaryCatchUp`), because the summary job is enqueued at upload time + * and would otherwise never revisit a vault filled before the opt-in; it is + * skipped when the `documentAi` capability is closed for the record, since + * every summary it would enqueue could only be refused. Mirrors `auth/me/labs-local-ocr`: 60/min rate * limit, Zod `safeParse` → 422 via `returnAllZodIssues`, audit-log row, * field-by-field write (no mass assignment). Idempotent — always returns the * resolved next state so the client can hard-set the optimistic update. @@ -30,8 +35,11 @@ import { } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { AI_CAPABILITIES } from "@/lib/ai/capabilities/types"; +import { AI_EXTRACTION_CONSENT_KIND } from "@/lib/consent/receipts"; import { prisma } from "@/lib/db"; +import { isP2002 } from "@/lib/prisma-errors"; import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { documentsAutoAiReadPatchSchema } from "@/lib/validations/user-prefs"; @@ -45,6 +53,55 @@ type DocumentsAutoAiReadResponse = { documentsAutoAiRead: boolean; }; +/** + * The receipt kind the toggle mints: the extraction grant, narrower than the + * master `ai_full`. The capability table lists it (with `ai_full`) as what + * satisfies the document consent rule. + */ +const AUTO_READ_CONSENT_KIND = AI_EXTRACTION_CONSENT_KIND; + +/** + * Mint the extraction receipt unless one that satisfies document reads is + * already active. The toggle is an affirmative act, so an earlier revocation + * does not block it, exactly as a first grant would not be blocked. The + * partial unique index on active receipts is the backstop for two concurrent + * flips; losing that race means the other flip already minted, which is the + * outcome wanted. Returns whether a receipt was written. + */ +async function ensureExtractionReceipt( + userId: string, + now: Date = new Date(), +): Promise { + const active = await prisma.consentReceipt.findFirst({ + where: { + userId, + revokedAt: null, + kind: { in: [...AI_CAPABILITIES.documentAi.consent.kinds] }, + }, + select: { id: true }, + }); + if (active) return false; + try { + await prisma.consentReceipt.create({ + data: { + userId, + kind: AUTO_READ_CONSENT_KIND, + artefact: JSON.stringify({ + source: "web", + kind: AUTO_READ_CONSENT_KIND, + grantedAt: now.toISOString(), + note: "Documents read automatically with AI, turned on in the app.", + }), + signedAt: now, + }, + }); + return true; + } catch (err) { + if (isP2002(err)) return false; + throw err; + } +} + export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "auth.me.documentsAutoAiRead.get" } }); @@ -103,11 +160,9 @@ export const PATCH = apiHandler(async (req: Request) => { }); // Turning the toggle ON is the standing consent act — mint an append-only - // `ai_full` receipt (idempotent) so the durable audit trail records it, in - // addition to the runtime short-circuit the document consent gate reads. - if (next) { - await ensureWebAiConsentReceipt(user.id, "affirmative"); - } + // extraction receipt (idempotent) so the durable audit trail records it and + // the document capability can read it. + const consentMinted = next ? await ensureExtractionReceipt(user.id) : false; // A genuine OFF→ON flip schedules a catch-up over the documents already in // the vault. Without it the switch only ever applied to FUTURE uploads: the @@ -117,7 +172,16 @@ export const PATCH = apiHandler(async (req: Request) => { // budget gate still runs per document inside the summary job itself. const wasEnabled = previous?.documentsAutoAiRead ?? false; if (next && !wasEnabled) { - void enqueueSummaryCatchUp(user.id); + // Read after the mint, so a receipt this request just wrote counts. + const documentAi = await getAiCapability("documentAi"); + if (documentAi.available) { + void enqueueSummaryCatchUp(user.id); + } else { + annotate({ + action: { name: "auth.me.documentsAutoAiRead.catchUpSkipped" }, + meta: { reason: documentAi.reason }, + }); + } } await auditLog("user.documentsAutoAiRead.update", { @@ -126,6 +190,7 @@ export const PATCH = apiHandler(async (req: Request) => { details: { previous: previous?.documentsAutoAiRead ?? false, next, + consentMinted, }, }); 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/auth/me/route.ts b/src/app/api/auth/me/route.ts index 3b22f9c41..0c7ac8244 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -49,6 +49,10 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; import { buildAvatarUrl } from "@/lib/avatar"; import { decrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; +import { + isValidTimezone, + resolveServerDefaultTimezone, +} from "@/lib/tz/resolver"; import { isCycleEnabled } from "@/lib/cycle/gate"; import { resolveModuleMap, @@ -272,7 +276,12 @@ export const GET = apiHandler(async () => { heightCm: user.heightCm, dateOfBirth: user.dateOfBirth, gender: user.gender, - timezone: user.timezone, + // The zone the server cuts this account's days in: the stored zone, or + // the instance default when the stored one is unusable. Clients bucket + // by this value, so it must be the resolved one, not the raw column. + timezone: isValidTimezone(user.timezone) + ? user.timezone + : await resolveServerDefaultTimezone(), onboardingCompletedAt: user.onboardingCompletedAt, onboardingTourCompleted: user.onboardingTourCompleted, // v1.39 (C1) — the needs-based setup flow, published additively: diff --git a/src/app/api/consent/ai/latest/__tests__/route.test.ts b/src/app/api/consent/ai/latest/__tests__/route.test.ts index b486eb6b7..356be50e0 100644 --- a/src/app/api/consent/ai/latest/__tests__/route.test.ts +++ b/src/app/api/consent/ai/latest/__tests__/route.test.ts @@ -1,24 +1,30 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -// v1.16.16 — `createReceipt` + `revokeLatest` run inside a transaction; the -// mock runs the callback against the same proxy. +// v1.16.16 — `createReceipt` and the withdrawal run inside a transaction; +// the mock runs the callback against the same proxy. The withdrawal also +// purges the regenerable AI text, so the proxy carries those delegates too. type TxFn = (tx: unknown) => unknown; vi.mock("@/lib/db", () => { - const consentReceipt = { - create: vi.fn(), - findFirst: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), - updateMany: vi.fn(), - }; - return { - prisma: { - consentReceipt, - $transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })), + const prisma = { + consentReceipt: { + create: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + count: vi.fn(), }, + insightStatusCache: { deleteMany: vi.fn() }, + user: { updateMany: vi.fn() }, + insightNarrative: { deleteMany: vi.fn() }, + arrivalReaction: { updateMany: vi.fn() }, + workoutInsight: { deleteMany: vi.fn() }, + $transaction: vi.fn(), }; + prisma.$transaction.mockImplementation((fn: TxFn) => fn(prisma)); + return { prisma }; }); vi.mock("@/lib/rate-limit", () => ({ @@ -78,9 +84,21 @@ beforeEach(() => { vi.mocked(auditLog).mockResolvedValue(undefined); // Re-arm rate-limit + transaction pass-throughs cleared by reset. vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK); - $transaction.mockImplementation((fn: TxFn) => - fn({ consentReceipt: prisma.consentReceipt }), - ); + $transaction.mockImplementation((fn: TxFn) => fn(prisma)); + // Default: another receipt still covers the analysis, so no purge runs. + // The purge cases below set this to 0. + vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never); + for (const deleteMany of [ + prisma.insightStatusCache.deleteMany, + prisma.insightNarrative.deleteMany, + prisma.workoutInsight.deleteMany, + ]) { + vi.mocked(deleteMany).mockResolvedValue({ count: 0 } as never); + } + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never); + vi.mocked(prisma.arrivalReaction.updateMany).mockResolvedValue({ + count: 0, + } as never); }); describe("GET /api/consent/ai/latest", () => { @@ -278,14 +296,15 @@ describe("DELETE /api/consent/ai/latest", () => { const createdAt = new Date("2026-05-18T10:00:01.000Z"); const revokedAt = new Date("2026-05-18T11:00:00.000Z"); - // v1.16.16 — `revokeLatest` runs per kind in `consentKindEnum.options` - // order: ai_full → ai_insights_only → ai_coach. The `updateMany` count - // gates the follow-up re-read. ai_full + ai_coach revoke a row; - // ai_insights_only has none (count 0, no re-read). + // The withdrawal runs per kind in `consentKindEnum.options` order: + // ai_full → ai_insights_only → ai_coach → ai_extraction. The + // `updateMany` count gates the follow-up re-read. ai_full + ai_coach + // revoke a row; the others have none (count 0, no re-read). vi.mocked(prisma.consentReceipt.updateMany) .mockResolvedValueOnce({ count: 1 } as never) // ai_full .mockResolvedValueOnce({ count: 0 } as never) // ai_insights_only - .mockResolvedValueOnce({ count: 1 } as never); // ai_coach + .mockResolvedValueOnce({ count: 1 } as never) // ai_coach + .mockResolvedValueOnce({ count: 0 } as never); // ai_extraction vi.mocked(prisma.consentReceipt.findFirst) .mockResolvedValueOnce({ id: "rcpt-full", @@ -323,6 +342,80 @@ describe("DELETE /api/consent/ai/latest", () => { }); }); +describe("DELETE /api/consent/ai/latest — purge of regenerable AI text", () => { + const revokedRow = { + id: "rcpt-1", + userId: "user-1", + kind: "ai_insights_only", + artefact: "PDF", + signedAt: new Date("2026-05-18T10:00:00.000Z"), + revokedAt: new Date("2026-05-18T11:00:00.000Z"), + createdAt: new Date("2026-05-18T10:00:01.000Z"), + }; + + it("deletes the regenerable text and writes a counts-only purge audit row when nothing covers the analysis any more", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue( + revokedRow as never, + ); + vi.mocked(prisma.consentReceipt.count).mockResolvedValue(0 as never); + vi.mocked(prisma.insightStatusCache.deleteMany).mockResolvedValue({ + count: 4, + } as never); + + const res = await DELETE( + new NextRequest( + "http://localhost/api/consent/ai/latest?kind=ai_insights_only", + { method: "DELETE" }, + ), + ); + expect(res.status).toBe(200); + expect(prisma.insightStatusCache.deleteMany).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + }); + expect(prisma.workoutInsight.deleteMany).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + }); + expect(auditLog).toHaveBeenCalledWith("consent.ai.purge", { + userId: "user-1", + details: { + statusNotes: 4, + briefing: 0, + narratives: 0, + reactionLines: 0, + workoutParagraphs: 0, + }, + }); + }); + + it("keeps the text while another receipt still covers the analysis", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue( + revokedRow as never, + ); + vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never); + + const res = await DELETE( + new NextRequest( + "http://localhost/api/consent/ai/latest?kind=ai_insights_only", + { method: "DELETE" }, + ), + ); + expect(res.status).toBe(200); + expect(prisma.insightStatusCache.deleteMany).not.toHaveBeenCalled(); + expect(auditLog).not.toHaveBeenCalledWith( + "consent.ai.purge", + expect.anything(), + ); + }); +}); + describe("append-only invariant", () => { /** * v1.4.40 SB-10 — the receipts table is the legal audit trail. diff --git a/src/app/api/consent/ai/latest/route.ts b/src/app/api/consent/ai/latest/route.ts index a316f5adb..babdba8ac 100644 --- a/src/app/api/consent/ai/latest/route.ts +++ b/src/app/api/consent/ai/latest/route.ts @@ -13,6 +13,11 @@ * DELETE /api/consent/ai/latest * Master toggle — revokes the latest active receipt across every * consent kind. Returns the list of revoked rows. + * + * Either DELETE also deletes the regenerable model-written text (status + * notes, the cached briefing, model-written narratives, reaction lines, + * workout paragraphs) when it leaves no receipt covering the analysis, in the + * same transaction as the revoke. Coach memory and document summaries stay. */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; @@ -28,8 +33,8 @@ import { import { latestActiveReceipt, latestActiveReceiptsByKind, - revokeLatest, } from "@/lib/consent/receipts"; +import { withdrawConsent } from "@/lib/consent/withdrawal"; import { serialiseReceipt } from "../route"; export const GET = apiHandler(async (request: NextRequest) => { @@ -81,6 +86,9 @@ export const GET = apiHandler(async (request: NextRequest) => { ? serialiseReceipt(byKind.ai_insights_only) : null, ai_coach: byKind.ai_coach ? serialiseReceipt(byKind.ai_coach) : null, + ai_extraction: byKind.ai_extraction + ? serialiseReceipt(byKind.ai_extraction) + : null, }; annotate({ @@ -119,53 +127,50 @@ export const DELETE = apiHandler(async (request: NextRequest) => { const { kind } = parsed.data; - if (kind) { - const revoked = await revokeLatest(user.id, kind); - if (!revoked) { - // Idempotent: "no active receipt to revoke" is success-shaped. - // The iOS toggle hammers this on every flip; returning 404 - // would surface as a spurious error in the client. - annotate({ - action: { name: "consent.ai.revoke" }, - meta: { kind, revoked: false }, - }); - return apiSuccess({ kind, receipt: null }); - } + // One kind, or every kind for the master "turn AI off" toggle. The revoke + // and the purge of the regenerable text the consent covered commit + // together (`withdrawConsent`). + const kinds = kind ? [kind] : consentKindEnum.options; + const { revoked, purged } = await withdrawConsent(user.id, kinds); + + for (const { kind: k, receipt } of revoked) { auditLog("consent.ai.revoke", { userId: user.id, - details: { kind, receiptId: revoked.id }, + details: { kind: k, receiptId: receipt.id }, }).catch(() => {}); - annotate({ - action: { name: "consent.ai.revoke" }, - meta: { kind, receiptId: revoked.id }, - }); - return apiSuccess({ kind, receipt: serialiseReceipt(revoked) }); } - - // No kind specified — master "AI deaktivieren" toggle. Revoke the - // latest active row for every kind that currently has one. - const revokedRows: Array<{ - kind: ConsentKind; - receipt: ReturnType; - }> = []; - for (const k of consentKindEnum.options) { - const r = await revokeLatest(user.id, k); - if (r) { - revokedRows.push({ kind: k, receipt: serialiseReceipt(r) }); - auditLog("consent.ai.revoke", { - userId: user.id, - details: { kind: k, receiptId: r.id }, - }).catch(() => {}); - } + if (purged) { + // Counts only, never content. + auditLog("consent.ai.purge", { + userId: user.id, + details: { ...purged }, + }).catch(() => {}); } annotate({ action: { name: "consent.ai.revoke" }, meta: { - kinds_revoked: revokedRows.map((r) => r.kind), - count: revokedRows.length, + kinds_revoked: revoked.map((r) => r.kind), + count: revoked.length, + purged: purged !== null, }, }); - return apiSuccess({ revoked: revokedRows }); + if (kind) { + // Idempotent: "no active receipt to revoke" is success-shaped. The iOS + // toggle hammers this on every flip; returning 404 would surface as a + // spurious error in the client. + const receipt = revoked[0]?.receipt ?? null; + return apiSuccess({ + kind, + receipt: receipt ? serialiseReceipt(receipt) : null, + }); + } + + return apiSuccess({ + revoked: revoked.map((r) => ({ + kind: r.kind, + receipt: serialiseReceipt(r.receipt), + })), + }); }); diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 26eca5a6d..4fc37c6a8 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -180,6 +180,14 @@ async function postPeriod(request: NextRequest): Promise { ...(txResult.moved.priorCycleId ? { priorCycleId: txResult.moved.priorCycleId } : {}), + // A later start folded into this one (the same period, recorded in + // the other order): name the row that stopped existing and its date. + ...(txResult.moved.absorbedCycleId + ? { + absorbedCycleId: txResult.moved.absorbedCycleId, + absorbedStartDate: txResult.moved.absorbedStartDate, + } + : {}), ...overwriteDetails({ before: { priorEndDate: txResult.moved.priorEndDateBefore, diff --git a/src/app/api/dashboard/snapshot/__tests__/route.test.ts b/src/app/api/dashboard/snapshot/__tests__/route.test.ts index 9e5290ed4..46d0b746a 100644 --- a/src/app/api/dashboard/snapshot/__tests__/route.test.ts +++ b/src/app/api/dashboard/snapshot/__tests__/route.test.ts @@ -44,8 +44,8 @@ vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({ })); // The per-read briefing capability; its own behaviour is pinned in // `snapshot-read-briefing-capability.test.ts`. -vi.mock("@/lib/ai/capabilities/record", () => ({ - aiCapabilityForRecord: async () => ({ +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityToServe: async () => ({ available: true, reason: null, onDeviceAllowed: true, diff --git a/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts index 913b29da0..c04152cc4 100644 --- a/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts @@ -34,17 +34,13 @@ vi.mock("@/lib/documents/content-index", () => ({ vi.mock("@/lib/documents/provider-order", () => ({ resolveDocumentTextProvider: vi.fn(), })); -vi.mock("@/lib/ai/consent-guard", () => ({ - assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), - ConsentRequiredError: class ConsentRequiredError extends Error { - errorCode = "consent.ai.required" as const; - surface: string; - constructor(surface: string) { - super("consent required"); - this.surface = surface; - this.name = "ConsentRequiredError"; - } - }, +vi.mock("@/lib/ai/capabilities/gate", async () => + ( + await import("@/__tests__/helpers/provider-order-mock") + ).openCapabilityGateMock(), +); +vi.mock("@/lib/ai/capabilities/egress", () => ({ + assertAiEgress: vi.fn(), })); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-07-07"), @@ -94,10 +90,9 @@ import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { loadDocumentChatText } from "@/lib/documents/content-index"; import { resolveDocumentTextProvider } from "@/lib/documents/provider-order"; -import { - assertDocumentEgressConsent, - ConsentRequiredError, -} from "@/lib/ai/consent-guard"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; +import { assertAiEgress } from "@/lib/ai/capabilities/egress"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { reserveBudget } from "@/lib/ai/coach/budget"; import { runStreamingRawCompletionWithFallback } from "@/lib/ai/provider-runner"; import { @@ -162,7 +157,7 @@ beforeEach(() => { allowed: true, reserved: 600, } as never); - vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined); + vi.mocked(assertAiEgress).mockResolvedValue(undefined); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ id: "doc-1", @@ -413,9 +408,44 @@ describe("POST — numeric grounding", () => { }); describe("POST — consent / budget gating", () => { - it("403s when document egress consent is missing", async () => { - vi.mocked(assertDocumentEgressConsent).mockRejectedValue( - new ConsentRequiredError("insights"), + it("403s when the pick needs a consent receipt the record does not hold", async () => { + vi.mocked(resolveDocumentTextProvider).mockResolvedValue({ + chain: [], + pick: null, + withheld: new AiUnavailableError("documentAi", "consent_required"), + } as never); + const res = await POST( + req("doc-1", { message: "hi" }) as never, + ctx("doc-1") as never, + ); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.meta).toEqual({ + errorCode: "consent.ai.required", + capability: "documentAi", + reason: "consent_required", + }); + expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled(); + expect(appendMessage).not.toHaveBeenCalled(); + }); + + it("answers under documentAi alone: the vault's chat asks nothing of the Coach", async () => { + await frames( + await POST( + req("doc-1", { message: "hi" }) as never, + ctx("doc-1") as never, + ), + ); + expect(requireAiCapability).toHaveBeenCalledTimes(1); + expect(requireAiCapability).toHaveBeenCalledWith("documentAi", { + pickDecides: true, + }); + expect(assertAiEgress).not.toHaveBeenCalled(); + }); + + it("refuses before loading anything when document reading is off", async () => { + vi.mocked(requireAiCapability).mockRejectedValueOnce( + new AiUnavailableError("documentAi", "operator_disabled"), ); const res = await POST( req("doc-1", { message: "hi" }) as never, @@ -423,7 +453,8 @@ describe("POST — consent / budget gating", () => { ); expect(res.status).toBe(403); const body = await res.json(); - expect(body.meta?.errorCode ?? body.errorCode).toBe("consent.ai.required"); + expect(body.meta.errorCode).toBe("assistant.disabled.documentAi"); + expect(loadDocumentChatText).not.toHaveBeenCalled(); expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled(); }); diff --git a/src/app/api/documents/inbound/[id]/chat/route.ts b/src/app/api/documents/inbound/[id]/chat/route.ts index b0559de97..d37ff416e 100644 --- a/src/app/api/documents/inbound/[id]/chat/route.ts +++ b/src/app/api/documents/inbound/[id]/chat/route.ts @@ -23,6 +23,7 @@ */ import { type NextRequest } from "next/server"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; import { apiError, @@ -76,6 +77,11 @@ export const POST = apiHandler( const gate = await requireModuleEnabled(userId, "inboundDocuments"); if (!gate.enabled) return gate.response; + // A turn sends the document's text to a model. The provider and the + // consent receipt are answered by the pick, for the provider actually used. + // Reading the history (GET) is the person's own data and stays open. + await requireAiCapability("documentAi", { pickDecides: true }); + const { id } = await params; const document = await loadOwnedDocument(userId, id); if (!document) { @@ -213,6 +219,7 @@ export const POST = apiHandler( contractLocale, locale, signal: request.signal, + alsoRequires: [], }); }, ); diff --git a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts index 4975c6f65..267c366bd 100644 --- a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts @@ -56,14 +56,14 @@ vi.mock("@/lib/labs/ocr-upload", () => ({ detectOcrMimeType: vi.fn(() => "image/png"), })); -vi.mock("@/lib/documents/provider-order", () => ({ - resolveDocumentVisionProvider: vi.fn(), - resolveDocumentTextProvider: vi.fn(), -})); -vi.mock("@/lib/ai/consent-guard", () => ({ - assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), - ConsentRequiredError: class ConsentRequiredError extends Error {}, -})); +vi.mock("@/lib/documents/provider-order", async () => + (await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(), +); +vi.mock("@/lib/ai/capabilities/gate", async () => + ( + await import("@/__tests__/helpers/provider-order-mock") + ).openCapabilityGateMock(), +); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-06-27"), reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }), @@ -117,6 +117,8 @@ import { resolveDocumentTextProvider, resolveDocumentVisionProvider, } from "@/lib/documents/provider-order"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; const tx = ( prisma as unknown as { @@ -323,3 +325,39 @@ describe("POST /api/documents/inbound/[id]/extract — stored mode", () => { expect(runInboundExtraction).not.toHaveBeenCalled(); }); }); + +describe("POST /api/documents/inbound/[id]/extract — the documentAi capability", () => { + it("refuses with the operator's code before the document is loaded", async () => { + vi.mocked(requireAiCapability).mockRejectedValueOnce( + new AiUnavailableError("documentAi", "operator_disabled"), + ); + const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.meta).toEqual({ + errorCode: "assistant.disabled.documentAi", + capability: "documentAi", + reason: "operator_disabled", + }); + expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled(); + expect(resolveDocumentVisionProvider).not.toHaveBeenCalled(); + expect(runInboundExtraction).not.toHaveBeenCalled(); + }); + + it("stops at the wire when the picked provider needs a receipt, before any slot is charged", async () => { + vi.mocked(resolveDocumentTextProvider).mockResolvedValue({ + chain: [], + pick: null, + withheld: new AiUnavailableError("documentAi", "consent_required"), + } as never); + vi.mocked(loadDocumentChatText).mockResolvedValue({ + text: "Glucose 95 mg/dL", + } as never); + const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.meta.errorCode).toBe("consent.ai.required"); + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(runInboundExtraction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 118717268..f6bd96a3b 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -4,8 +4,9 @@ * Extraction is no longer part of upload: a document is stored first * (provider-free), and THIS route is the explicit, user-triggered enhancement. * It carries the entire ingest gauntlet that upload used to run inline — - * resolve document provider (local-first, codex last) → assertDocumentEgressConsent - * → rate-limit → reserveBudget → runInboundExtraction → reconcileSpend → stage + * requireAiCapability("documentAi") → resolve document provider (local-first, + * codex last) and re-check the capability for that pick, consent receipt + * included → rate-limit → reserveBudget → runInboundExtraction → reconcileSpend → stage * facts — but now against a row that already exists. Absent a provider this 422s * the ENHANCEMENT only; the stored document is untouched and remains filed. * @@ -36,7 +37,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { buildDateKey, reconcileSpend, @@ -58,8 +59,8 @@ import { serialiseDocumentDetail, } from "@/lib/documents/store"; import { - resolveDocumentTextProvider, - resolveDocumentVisionProvider, + requireDocumentTextProvider, + requireDocumentVisionProvider, } from "@/lib/documents/provider-order"; import { checkDocumentAiRateLimit, @@ -139,6 +140,10 @@ export const POST = apiHandler( const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; + // Reading a document is model work. The provider and the consent receipt + // are answered by the pick below, for the provider actually used. + await requireAiCapability("documentAi", { pickDecides: true }); + const { id } = await params; const document = await prisma.inboundDocument.findFirst({ where: { id, userId: user.id, deletedAt: null }, @@ -220,18 +225,7 @@ async function handleTextExtract( }); } - const { pick } = await resolveDocumentTextProvider(userId); - if (!pick) { - return apiError("No AI provider is configured", 422, { - errorCode: "documents.inbound.providerUnsupported", - }); - } - - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - surface: "insights", - }); + const pick = await requireDocumentTextProvider(userId); const rl = await checkDocumentAiRateLimit(userId); if (!rl.allowed) return documentAiRateLimited(rl); @@ -337,18 +331,7 @@ async function handleStoredExtract( }); } - const { pick } = await resolveDocumentTextProvider(userId); - if (!pick) { - return apiError("No AI provider is configured", 422, { - errorCode: "documents.inbound.providerUnsupported", - }); - } - - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - surface: "insights", - }); + const pick = await requireDocumentTextProvider(userId); const rl = await checkDocumentAiRateLimit(userId); if (!rl.allowed) return documentAiRateLimited(rl); @@ -423,18 +406,7 @@ async function handleVisionExtract( userId: string, document: LoadedDocument, ): Promise { - const { pick } = await resolveDocumentVisionProvider(userId); - if (!pick) { - return apiError("No vision-capable AI provider is configured", 422, { - errorCode: "documents.inbound.providerUnsupported", - }); - } - - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - surface: "insights", - }); + const pick = await requireDocumentVisionProvider(userId); const rl = await checkDocumentAiRateLimit(userId); if (!rl.allowed) return documentAiRateLimited(rl); diff --git a/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts index 671c68bd5..39ffd1d66 100644 --- a/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts @@ -35,18 +35,19 @@ vi.mock("@/lib/documents/describe", () => ({ vi.mock("@/lib/documents/content-index", () => ({ upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 7 }), })); -vi.mock("@/lib/documents/provider-order", () => ({ - resolveDocumentVisionProvider: vi.fn(), -})); +vi.mock("@/lib/documents/provider-order", async () => + (await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(), +); +vi.mock("@/lib/ai/capabilities/gate", async () => + ( + await import("@/__tests__/helpers/provider-order-mock") + ).openCapabilityGateMock(), +); vi.mock("@/lib/documents/auto-stage-labs", () => ({ maybeAutoStageLabFacts: vi .fn() .mockResolvedValue({ staged: false, reason: "not-lab" }), })); -vi.mock("@/lib/ai/consent-guard", () => ({ - assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), - ConsentRequiredError: class ConsentRequiredError extends Error {}, -})); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-07-07"), reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }), diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index 04530af0c..bbedb829d 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -32,7 +32,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { buildDateKey, reconcileSpend, @@ -57,7 +57,7 @@ import { DocumentDescribeError, transcribeDocument, } from "@/lib/documents/describe"; -import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; +import { requireDocumentVisionProvider } from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; @@ -183,17 +183,12 @@ async function handleVisionIndex( userId: string, document: LoadedDocument, ): Promise { - const { pick } = await resolveDocumentVisionProvider(userId); - if (!pick) { - return apiError("No vision-capable AI provider is configured", 422, { - errorCode: "documents.inbound.providerUnsupported", - }); - } - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - surface: "insights", - }); + // Transcribing the stored original is model work; indexing text the browser + // already read (the text mode above) is not, and stays open with AI off so + // search keeps working. The provider and the consent receipt are answered + // by the pick, for the provider actually used. + await requireAiCapability("documentAi", { pickDecides: true }); + const pick = await requireDocumentVisionProvider(userId); const rl = await checkDocumentAiRateLimit(userId); if (!rl.allowed) return documentAiRateLimited(rl); diff --git a/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts index 30bf21b6d..bde68062a 100644 --- a/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts @@ -27,14 +27,14 @@ vi.mock("@/lib/documents/assist", () => ({ runDocumentAssist: vi.fn(), DocumentAssistError: class DocumentAssistError extends Error {}, })); -vi.mock("@/lib/documents/provider-order", () => ({ - resolveDocumentVisionProvider: vi.fn(), - resolveDocumentTextProvider: vi.fn(), -})); -vi.mock("@/lib/ai/consent-guard", () => ({ - assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), - ConsentRequiredError: class ConsentRequiredError extends Error {}, -})); +vi.mock("@/lib/documents/provider-order", async () => + (await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(), +); +vi.mock("@/lib/ai/capabilities/gate", async () => + ( + await import("@/__tests__/helpers/provider-order-mock") + ).openCapabilityGateMock(), +); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-07-07"), reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }), diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 0fe5d57a7..c361d2929 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -24,7 +24,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { buildDateKey, reconcileSpend, @@ -48,8 +48,8 @@ import { type LoadedDocument, } from "@/lib/documents/ai-route-support"; import { - resolveDocumentTextProvider, - resolveDocumentVisionProvider, + requireDocumentTextProvider, + requireDocumentVisionProvider, } from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -67,6 +67,10 @@ export const POST = apiHandler( const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; + // Reading a document is model work. The provider and the consent receipt + // are answered by the pick below, for the provider actually used. + await requireAiCapability("documentAi", { pickDecides: true }); + const { id } = await params; const document = await loadOwnedDocument(user.id, id); if (!document) { @@ -125,18 +129,7 @@ async function handleTextSuggest( }); } - const { pick } = await resolveDocumentTextProvider(userId); - if (!pick) { - return apiError("No AI provider is configured", 422, { - errorCode: "documents.inbound.providerUnsupported", - }); - } - - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - surface: "insights", - }); + const pick = await requireDocumentTextProvider(userId); const rl = await checkDocumentAiRateLimit(userId); if (!rl.allowed) return documentAiRateLimited(rl); @@ -218,18 +211,7 @@ async function handleVisionSuggest( userId: string, document: LoadedDocument, ): Promise { - const { pick } = await resolveDocumentVisionProvider(userId); - if (!pick) { - return apiError("No vision-capable AI provider is configured", 422, { - errorCode: "documents.inbound.providerUnsupported", - }); - } - - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - surface: "insights", - }); + const pick = await requireDocumentVisionProvider(userId); const rl = await checkDocumentAiRateLimit(userId); if (!rl.allowed) return documentAiRateLimited(rl); diff --git a/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts index a1926710d..60e7088a3 100644 --- a/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/summary/__tests__/route.test.ts @@ -40,14 +40,14 @@ vi.mock("@/lib/documents/describe", () => ({ documentSummaryBlockedCopy: () => "The summary was withheld.", DocumentDescribeError: class DocumentDescribeError extends Error {}, })); -vi.mock("@/lib/documents/provider-order", () => ({ - resolveDocumentVisionProvider: vi.fn(), - resolveDocumentTextProvider: vi.fn(), -})); -vi.mock("@/lib/ai/consent-guard", () => ({ - assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), - ConsentRequiredError: class ConsentRequiredError extends Error {}, -})); +vi.mock("@/lib/documents/provider-order", async () => + (await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(), +); +vi.mock("@/lib/ai/capabilities/gate", async () => + ( + await import("@/__tests__/helpers/provider-order-mock") + ).openCapabilityGateMock(), +); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-07-07"), reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }), diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index f207faf12..54a39b9d4 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -31,7 +31,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { buildDateKey, reconcileSpend, @@ -59,8 +59,8 @@ import { import { encryptDocumentSummary } from "@/lib/documents/store"; import type { OutboundReason } from "@/lib/ai/safety/outbound-screen"; import { - resolveDocumentTextProvider, - resolveDocumentVisionProvider, + requireDocumentTextProvider, + requireDocumentVisionProvider, } from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -203,6 +203,10 @@ export const POST = apiHandler( const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; + // Reading a document is model work. The provider and the consent receipt + // are answered by the pick below, for the provider actually used. + await requireAiCapability("documentAi", { pickDecides: true }); + const { id } = await params; const document = await loadOwnedDocument(user.id, id); if (!document) { @@ -321,19 +325,9 @@ async function handleTextSummary( return finishSummary(request, userId, document.id, "text", mode, result); } - const { pick } = await resolveDocumentTextProvider(userId); - if (!pick) { - await refundDocumentAiSlot(userId); - return apiError("No AI provider is configured", 422, { - errorCode: "documents.inbound.providerUnsupported", - }); - } + let pick: Awaited>; try { - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - surface: "insights", - }); + pick = await requireDocumentTextProvider(userId); } catch (err) { // Refused before any dispatch — the slot goes back with the refusal. await refundDocumentAiSlot(userId); @@ -394,17 +388,7 @@ async function handleVisionSummary( locale: Locale, persist: SummaryPersistenceTarget | null, ): Promise { - const { pick } = await resolveDocumentVisionProvider(userId); - if (!pick) { - return apiError("No vision-capable AI provider is configured", 422, { - errorCode: "documents.inbound.providerUnsupported", - }); - } - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - surface: "insights", - }); + const pick = await requireDocumentVisionProvider(userId); const rl = await checkDocumentAiRateLimit(userId); if (!rl.allowed) return documentAiRateLimited(rl); diff --git a/src/app/api/documents/inbound/__tests__/route.test.ts b/src/app/api/documents/inbound/__tests__/route.test.ts index dceb607f3..265bc6e6d 100644 --- a/src/app/api/documents/inbound/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/__tests__/route.test.ts @@ -92,6 +92,14 @@ vi.mock("@/lib/jobs/document-index", () => ({ enqueueDocumentIndex: vi.fn().mockResolvedValue({ enqueued: true }), })); +vi.mock("@/lib/jobs/document-summary", () => ({ + enqueueDocumentSummary: vi.fn().mockResolvedValue({ enqueued: true }), +})); +vi.mock("@/lib/ai/capabilities/gate", async () => + ( + await import("@/__tests__/helpers/provider-order-mock") + ).openCapabilityGateMock(), +); vi.mock("@/lib/jobs/document-thumbnail", () => ({ enqueueDocumentThumbnail: vi.fn().mockResolvedValue({ enqueued: true }), })); @@ -129,6 +137,8 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { enqueueDocumentIndex } from "@/lib/jobs/document-index"; +import { enqueueDocumentSummary } from "@/lib/jobs/document-summary"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { acquireDocumentUploadSlot, DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY, @@ -547,3 +557,27 @@ describe("GET /api/documents/inbound (list)", () => { expect(res.status).toBe(422); }); }); + +describe("POST /api/documents/inbound — AI work on upload follows documentAi", () => { + it("enqueues the background summary when document reading is open", async () => { + const res = await post(mkUpload()); + expect(res.status).toBe(201); + expect(getAiCapability).toHaveBeenCalledWith("documentAi"); + expect(enqueueDocumentSummary).toHaveBeenCalledWith("user-1", "doc-1"); + }); + + it("stores the document and indexes it, but enqueues no summary, with document reading closed", async () => { + vi.mocked(getAiCapability).mockResolvedValueOnce({ + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + }); + const res = await post(mkUpload()); + // The upload is data: accepted whatever AI says. + expect(res.status).toBe(201); + expect(txCreate).toHaveBeenCalled(); + // The index job still runs its provider-free text-layer path. + expect(enqueueDocumentIndex).toHaveBeenCalledWith("user-1", "doc-1"); + expect(enqueueDocumentSummary).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/documents/inbound/capability/route.ts b/src/app/api/documents/inbound/capability/route.ts index 54e694882..b4aa5e796 100644 --- a/src/app/api/documents/inbound/capability/route.ts +++ b/src/app/api/documents/inbound/capability/route.ts @@ -35,6 +35,7 @@ export const GET = apiHandler(async () => { reason: capability.reason, pdfSupported: capability.pdfSupported, egress: capability.egress, + aiReason: capability.ai.reason, }, }); diff --git a/src/app/api/documents/inbound/reindex/__tests__/route.test.ts b/src/app/api/documents/inbound/reindex/__tests__/route.test.ts index ddcf1900b..d975d002c 100644 --- a/src/app/api/documents/inbound/reindex/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/reindex/__tests__/route.test.ts @@ -20,11 +20,10 @@ vi.mock("@/lib/jobs/document-content-index-backfill", () => ({ enqueueContentIndexBackfill: vi.fn(), })); vi.mock("@/lib/documents/provider-order", () => ({ - resolveDocumentVisionProvider: vi.fn(), + requireDocumentVisionProvider: vi.fn(), })); -vi.mock("@/lib/ai/consent-guard", () => ({ - assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), - ConsentRequiredError: class ConsentRequiredError extends Error {}, +vi.mock("@/lib/ai/capabilities/gate", () => ({ + requireAiCapability: vi.fn(), })); vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), @@ -54,7 +53,9 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill"; -import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; +import { requireDocumentVisionProvider } from "@/lib/documents/provider-order"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; const SESSION_OK = { session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -78,7 +79,14 @@ const req = () => beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); + vi.mocked(requireAiCapability).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + vi.mocked(requireDocumentVisionProvider).mockResolvedValue( + PICK.pick as never, + ); vi.mocked(enqueueContentIndexBackfill).mockResolvedValue({ enqueued: true }); // 7 live documents, 4 already indexed → 3 remain. vi.mocked(prisma.inboundDocument.count).mockResolvedValue(7 as never); @@ -114,3 +122,36 @@ describe("POST /api/documents/inbound/reindex", () => { }); }); }); + +describe("POST /api/documents/inbound/reindex — the documentAi capability", () => { + it("answers the capability envelope and enqueues nothing when document reading is off", async () => { + vi.mocked(requireAiCapability).mockRejectedValue( + new AiUnavailableError("documentAi", "operator_disabled"), + ); + const res = await POST(req() as never); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.meta).toEqual({ + errorCode: "assistant.disabled.documentAi", + capability: "documentAi", + reason: "operator_disabled", + }); + expect(requireAiCapability).toHaveBeenCalledWith("documentAi", { + pickDecides: true, + }); + expect(enqueueContentIndexBackfill).not.toHaveBeenCalled(); + }); + + it("keeps the vault's own code when no provider can read documents", async () => { + vi.mocked(requireDocumentVisionProvider).mockRejectedValue( + new AiUnavailableError("documentAi", "no_provider", null, { + errorCode: "documents.inbound.providerUnsupported", + }), + ); + const res = await POST(req() as never); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.meta.errorCode).toBe("documents.inbound.providerUnsupported"); + expect(enqueueContentIndexBackfill).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/documents/inbound/reindex/route.ts b/src/app/api/documents/inbound/reindex/route.ts index de2a13ff8..b5a60d403 100644 --- a/src/app/api/documents/inbound/reindex/route.ts +++ b/src/app/api/documents/inbound/reindex/route.ts @@ -3,17 +3,17 @@ * * Enqueues a per-user job that indexes the caller's not-yet-indexed documents * (one provider transcription each, bounded + resumable). Gated on the module, - * a configured vision provider, and the EXISTING AI consent (the worker - * re-checks all three). The work runs off-request on pg-boss; this route only + * the `documentAi` capability, a configured vision provider, and a document + * consent receipt for that provider (the worker re-checks all of it). The work runs off-request on pg-boss; this route only * enqueues and returns immediately so the vault stays responsive. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; -import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill"; -import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; +import { requireDocumentVisionProvider } from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -30,6 +30,10 @@ export const POST = apiHandler(async (request) => { const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; + // The backfill transcribes each document through a model. The provider and + // the consent receipt are answered by the pick below. + await requireAiCapability("documentAi", { pickDecides: true }); + const rl = await checkRateLimit( `documents-reindex:${user.id}`, REINDEX_LIMIT_PER_HOUR, @@ -47,17 +51,8 @@ export const POST = apiHandler(async (request) => { // Fail fast when the precondition is not met so the UI gets immediate // feedback rather than a silently no-op'd job. - const { pick } = await resolveDocumentVisionProvider(user.id); - if (!pick) { - return apiError("No vision-capable AI provider is configured", 422, { - errorCode: "documents.inbound.providerUnsupported", - }); - } - await assertDocumentEgressConsent({ - userId: user.id, - providerType: pick.providerType, - surface: "insights", - }); + // The worker re-checks all of it per run. + await requireDocumentVisionProvider(user.id); const { enqueued: jobCreated } = await enqueueContentIndexBackfill(user.id); diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index 15e79f51b..a10dcc3ba 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -40,6 +40,7 @@ import { getClientIp, sanitiseZodIssues, } from "@/lib/api-response"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { hashQueryTokens } from "@/lib/documents/content-index"; @@ -446,10 +447,21 @@ async function processUpload( // Summarise the freshly stored document in the background — but ONLY when the // `documentsAutoAiRead` opt-in is ON (the job re-checks it and the egress - // consent, and no-ops otherwise). The persisted summary shows on the detail + // consent, and no-ops otherwise) and the `documentAi` capability is open for + // this record. The upload itself never depends on AI: the document is stored + // above whatever the capability says, and the index job still runs its + // provider-free text-layer path. The persisted summary shows on the detail // view. Same fire-and-forget contract: the upload never blocks on or fails // because of it. Only fresh inserts reach here (a duplicate returns early). - void enqueueDocumentSummary(user.id, document.id); + const documentAi = await getAiCapability("documentAi"); + if (documentAi.available) { + void enqueueDocumentSummary(user.id, document.id); + } else { + annotate({ + action: { name: "documents.summary.enqueueSkipped" }, + meta: { documentId: document.id, reason: documentAi.reason }, + }); + } const [links, visitLinks] = await Promise.all([ loadConditionLinks(user.id, [document.id]), diff --git a/src/app/api/documents/inbound/usage/route.ts b/src/app/api/documents/inbound/usage/route.ts index 5aee8c7ba..ca2524bb8 100644 --- a/src/app/api/documents/inbound/usage/route.ts +++ b/src/app/api/documents/inbound/usage/route.ts @@ -14,7 +14,7 @@ import { DOCUMENT_ACCEPTED_EXTENSIONS, resolveDocumentLimits, } from "@/lib/documents/upload-policy"; -import { resolveOcrCapability } from "@/lib/labs/ocr-capability"; +import { resolveDocumentAiCapability } from "@/lib/documents/provider-order"; import { listDistinctTargets } from "@/lib/links"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -58,8 +58,10 @@ export const GET = apiHandler(async () => { }), // Whether an AI action can run for this caller (assist + indexing share // the same provider precondition). Honest availability so the UI never - // offers what the endpoint would 422. - resolveOcrCapability(user.id), + // offers what the endpoint would refuse: the document provider order and + // the `documentAi` capability for this record, the same answer the + // capability probe gives. + resolveDocumentAiCapability(user.id), // Content-index coverage: how many live documents are indexed … // Scope the count to LIVE documents: a soft-deleted document keeps its // index row (cascade fires on hard purge only), so an unscoped count 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 89f3cda16..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 @@ -59,7 +59,8 @@ function routeFiles(tree: string): string[] { const ROUTES = AI_ROUTE_TREES.flatMap(routeFiles).sort(); -const ANY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/; +const ANY_GATE = + /\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/; function code(path: string): string { return readFileSync(resolve(repoRoot, path), "utf8") diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts index 0b6c6880a..6ba4c1e46 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -1,6 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +// Day-mean reads fold per-day aggregates in SQL; the fake folds the mocked +// `measurement.findMany` rows with the same rules. +vi.mock("@/lib/measurements/day-aggregates", async () => ({ + readDayAggregates: ( + await import("@/lib/measurements/__tests__/fake-day-aggregates") + ).fakeReadDayAggregates, +})); + vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, 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]/attachments/__tests__/route.test.ts b/src/app/api/insights/chat/[id]/attachments/__tests__/route.test.ts index 03dfd5bb2..a715ba6c3 100644 --- a/src/app/api/insights/chat/[id]/attachments/__tests__/route.test.ts +++ b/src/app/api/insights/chat/[id]/attachments/__tests__/route.test.ts @@ -8,6 +8,7 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + MODULE_DISABLED_ERROR_CODE: "module.disabled", })); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), @@ -28,6 +29,11 @@ vi.mock("@/lib/auth/audit", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/ai/capabilities/gate", async () => + ( + await import("@/__tests__/helpers/provider-order-mock") + ).openCapabilityGateMock(), +); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); @@ -43,6 +49,8 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { DELETE } from "../[documentId]/route"; import { getSession } from "@/lib/auth/session"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { attachDocument, detachDocument, @@ -211,3 +219,50 @@ describe("DELETE detach", () => { expect(body.data.fenced).toBe(true); }); }); + +describe("attachments — the coach and documentAi capabilities", () => { + it("refuses an attach when the Coach is off for the record", async () => { + vi.mocked(requireAiCapability).mockRejectedValueOnce( + new AiUnavailableError("coach", "user_disabled", "coach"), + ); + const res = await POST( + postReq({ documentId: "doc-a" }) as never, + postCtx() as never, + ); + expect(res.status).toBe(403); + expect(await code(res)).toBe("module.disabled"); + expect(attachDocument).not.toHaveBeenCalled(); + }); + + it("checks both capabilities on attach", async () => { + vi.mocked(fetchConversationAttachmentState).mockResolvedValue(null); + await POST(postReq({ documentId: "doc-a" }) as never, postCtx() as never); + expect(requireAiCapability).toHaveBeenCalledWith("coach", { + pickDecides: true, + }); + expect(requireAiCapability).toHaveBeenCalledWith("documentAi", { + pickDecides: true, + }); + }); + + it("lets a detach through with every capability closed: it removes the person's own data", async () => { + vi.mocked(requireAiCapability).mockRejectedValue( + new AiUnavailableError("coach", "operator_disabled"), + ); + vi.mocked(fetchConversationAttachmentState).mockResolvedValue({ + id: "conv-1", + documentScoped: true, + messageCount: 0, + attachmentIds: ["doc-a"], + }); + const res = await DELETE( + new NextRequest( + new URL("http://localhost/api/insights/chat/conv-1/attachments/doc-a"), + { method: "DELETE" }, + ) as never, + delCtx() as never, + ); + expect(res.status).not.toBe(403); + expect(requireAiCapability).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/insights/chat/[id]/attachments/route.ts b/src/app/api/insights/chat/[id]/attachments/route.ts index a6c66fb75..895e61608 100644 --- a/src/app/api/insights/chat/[id]/attachments/route.ts +++ b/src/app/api/insights/chat/[id]/attachments/route.ts @@ -9,6 +9,7 @@ */ import { type NextRequest } from "next/server"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, @@ -48,6 +49,12 @@ export const POST = apiHandler( const gate = await requireModuleEnabled(userId, "inboundDocuments"); if (!gate.enabled) return gate.response; + // Attaching prepares a fenced turn, which is Coach work over documents. + // Nothing is sent here, so the provider and the consent receipt wait for + // the turn. Detaching (DELETE) removes the person's own data and stays open. + await requireAiCapability("coach", { pickDecides: true }); + await requireAiCapability("documentAi", { pickDecides: true }); + const { id: conversationId } = await params; const rl = await checkRateLimit( diff --git a/src/app/api/insights/chat/fenced/__tests__/route.test.ts b/src/app/api/insights/chat/fenced/__tests__/route.test.ts index ce56b9594..52b4a1720 100644 --- a/src/app/api/insights/chat/fenced/__tests__/route.test.ts +++ b/src/app/api/insights/chat/fenced/__tests__/route.test.ts @@ -10,6 +10,11 @@ import { NextRequest } from "next/server"; */ vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/ai/capabilities/gate", async () => + ( + await import("@/__tests__/helpers/provider-order-mock") + ).openCapabilityGateMock(), +); vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), })); @@ -54,6 +59,8 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { getSession } from "@/lib/auth/session"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { createConversation, fetchConversationWithMessages, @@ -260,3 +267,50 @@ describe("fenced route — refusal", () => { expect(streamFencedReply).not.toHaveBeenCalled(); }); }); + +describe("fenced route — the coach and documentAi capabilities", () => { + it("answers under both, leaving provider and consent to the pick", async () => { + await POST(req({ message: "hi", attachmentIds: ["doc-a"] }) as never); + expect(requireAiCapability).toHaveBeenCalledWith("coach", { + pickDecides: true, + }); + expect(requireAiCapability).toHaveBeenCalledWith("documentAi", { + pickDecides: true, + }); + // The turn re-checks the Coach for the picked provider too. + expect(streamFencedReply).toHaveBeenCalledWith( + expect.objectContaining({ alsoRequires: ["coach"] }), + ); + }); + + it("refuses with the Coach's code before any conversation is created", async () => { + vi.mocked(requireAiCapability).mockRejectedValueOnce( + new AiUnavailableError("coach", "operator_disabled"), + ); + const res = await POST( + req({ message: "hi", attachmentIds: ["doc-a"] }) as never, + ); + expect(res.status).toBe(403); + expect(await errorCode(res)).toBe("assistant.disabled.coach"); + expect(createConversation).not.toHaveBeenCalled(); + expect(streamFencedReply).not.toHaveBeenCalled(); + }); + + it("refuses when document reading is off even with the Coach on", async () => { + vi.mocked(requireAiCapability) + .mockResolvedValueOnce({ + available: true, + reason: null, + onDeviceAllowed: true, + }) + .mockRejectedValueOnce( + new AiUnavailableError("documentAi", "operator_disabled"), + ); + const res = await POST( + req({ message: "hi", attachmentIds: ["doc-a"] }) as never, + ); + expect(res.status).toBe(403); + expect(await errorCode(res)).toBe("assistant.disabled.documentAi"); + expect(streamFencedReply).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/insights/chat/fenced/route.ts b/src/app/api/insights/chat/fenced/route.ts index 1c7da325a..1276a96de 100644 --- a/src/app/api/insights/chat/fenced/route.ts +++ b/src/app/api/insights/chat/fenced/route.ts @@ -7,13 +7,15 @@ * route via `@/lib/documents/fenced-chat` so the two cannot drift. * * THE FENCE (see `fenced-chat.ts` for the full posture): NO tools, NO health - * snapshot; every document fenced as untrusted DATA; per-document egress consent; + * snapshot; every document fenced as untrusted DATA; the `coach` and + * `documentAi` capabilities, re-checked for the picked provider; * numeric grounding over the LIVE attachment union only. A plain tool * conversation 404s here (`documentScoped: true` in the fetch), so a fenced turn * can never be appended into a tool thread's history. */ import { type NextRequest } from "next/server"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; import { apiError, @@ -56,6 +58,12 @@ export const POST = apiHandler(async (request: NextRequest) => { const gate = await requireModuleEnabled(userId, "inboundDocuments"); if (!gate.enabled) return gate.response; + // A fenced turn is a Coach turn that reads documents: both capabilities must + // be open. The provider and the consent receipt are answered by the pick, + // for the provider actually used (a missing provider stays an SSE frame). + await requireAiCapability("coach", { pickDecides: true }); + await requireAiCapability("documentAi", { pickDecides: true }); + const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); @@ -224,5 +232,6 @@ export const POST = apiHandler(async (request: NextRequest) => { contractLocale, locale, signal: request.signal, + alsoRequires: ["coach"], }); }); 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/labs/ocr/capability/route.ts b/src/app/api/labs/ocr/capability/route.ts index 87df4115c..190d69b16 100644 --- a/src/app/api/labs/ocr/capability/route.ts +++ b/src/app/api/labs/ocr/capability/route.ts @@ -4,7 +4,9 @@ * Cheap probe (no provider call) the Labs UI uses to decide whether to show * the "Scan a report" affordance. Returns `{ available, reason, pdfSupported }` * for the calling user's configured AI provider. The surface stays dark for - * users with no provider configured and no local-OCR opt-in. + * users with no provider configured and no local-OCR opt-in, and whenever the + * `labsOcr` capability is closed (operator switch, labs module, sharing grant); + * `ai` says which. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; @@ -25,6 +27,7 @@ export const GET = apiHandler(async () => { mode: capability.mode, reason: capability.reason, pdfSupported: capability.pdfSupported, + aiReason: capability.ai.reason, }, }); diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts index b5f9f03bc..66b874e13 100644 --- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts @@ -27,15 +27,14 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/labs/ocr-capability", () => ({ - resolveTextProvider: vi.fn(), - resolveVisionProvider: vi.fn(), + requireLabsOcrProvider: vi.fn(), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + requireAiCapability: vi.fn(), })); vi.mock("@/lib/documents/rasterize-pdf", () => ({ rasterizePdf: vi.fn(), })); -vi.mock("@/lib/ai/consent-guard", () => ({ - assertConsentForChain: vi.fn().mockResolvedValue(undefined), -})); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), refundRateLimit: vi.fn().mockResolvedValue(undefined), @@ -60,10 +59,9 @@ import { POST } from "../route"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { requireAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; -import { - resolveTextProvider, - resolveVisionProvider, -} from "@/lib/labs/ocr-capability"; +import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { rasterizePdf } from "@/lib/documents/rasterize-pdf"; import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget"; import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit"; @@ -94,12 +92,14 @@ beforeEach(() => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ labsLocalOcrEnabled: true, } as never); - vi.mocked(resolveTextProvider).mockResolvedValue({ - chain: [{ providerType: "openai", instance: {} as never }], - pick: { - entry: { providerType: "openai", instance: {} as never }, - providerType: "openai", - }, + vi.mocked(requireAiCapability).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + vi.mocked(requireLabsOcrProvider).mockResolvedValue({ + entry: { providerType: "openai", instance: {} as never }, + providerType: "openai", } as never); vi.mocked(reserveBudget).mockResolvedValue({ allowed: true, @@ -187,14 +187,10 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => { beforeEach(() => { // A non-Anthropic vision provider (codex): no native PDF block, so the // route must rasterize. - vi.mocked(resolveVisionProvider).mockResolvedValue({ - chain: [{ providerType: "codex", instance: {} as never }], - localOcrEnabled: false, - pick: { - entry: { providerType: "codex", instance: {} as never }, - providerType: "codex", - pdfSupported: false, - }, + vi.mocked(requireLabsOcrProvider).mockResolvedValue({ + entry: { providerType: "codex", instance: {} as never }, + providerType: "codex", + pdfSupported: false, } as never); vi.mocked(reserveBudget).mockResolvedValue({ allowed: true, @@ -334,3 +330,55 @@ describe("POST /api/labs/ocr/extract — the hourly scan bucket", () => { expect(refundRateLimit).not.toHaveBeenCalled(); }); }); + +describe("POST /api/labs/ocr/extract — the labsOcr capability", () => { + it("refuses before anything is read when the operator turned document reading off", async () => { + vi.mocked(requireAiCapability).mockRejectedValue( + new AiUnavailableError("labsOcr", "operator_disabled"), + ); + const error = await POST(textReq()).catch((e) => e); + expect(error).toBeInstanceOf(AiUnavailableError); + expect(error.meta.errorCode).toBe("assistant.disabled.documentAi"); + expect(requireAiCapability).toHaveBeenCalledWith("labsOcr", { + pickDecides: true, + }); + expect(requireLabsOcrProvider).not.toHaveBeenCalled(); + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(runOcrExtraction).not.toHaveBeenCalled(); + }); + + it("stops at the wire when the pick is refused, before any slot or budget is spent", async () => { + vi.mocked(requireLabsOcrProvider).mockRejectedValue( + new AiUnavailableError("labsOcr", "consent_required"), + ); + const error = await POST(textReq()).catch((e) => e); + expect(error.meta).toEqual({ + errorCode: "consent.ai.required", + capability: "labsOcr", + reason: "consent_required", + }); + expect(requireLabsOcrProvider).toHaveBeenCalledWith("user-1", "text"); + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(reserveBudget).not.toHaveBeenCalled(); + expect(runOcrExtraction).not.toHaveBeenCalled(); + }); + + it("asks for the vision pick on a multipart upload", async () => { + vi.mocked(requireLabsOcrProvider).mockRejectedValue( + new AiUnavailableError("labsOcr", "no_provider", null, { + errorCode: "labs.ocr.providerUnsupported", + }), + ); + const form = new FormData(); + form.append("file", new File([new Uint8Array([1])], "a.png")); + const error = await POST( + new Request("http://localhost/api/labs/ocr/extract", { + method: "POST", + body: form, + }), + ).catch((e) => e); + expect(requireLabsOcrProvider).toHaveBeenCalledWith("user-1", "vision"); + expect(error.status).toBe(422); + expect(error.meta.errorCode).toBe("labs.ocr.providerUnsupported"); + }); +}); diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index 36a7f9139..c6b1db112 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -18,7 +18,10 @@ * `labsLocalOcrEnabled` preference. * * Guards mirror the Coach's discipline in both modes: - * requireAuth → resolve provider → assertConsentForChain → rate-limit + * requireAuth → requireAiCapability("labsOcr") → resolve provider and + * re-check the capability for that pick (`requireLabsOcrProvider`: the + * operator's switch, the labs module, and a document consent receipt for a + * pick that leaves the machine) → rate-limit * (`LABS_OCR_LIMIT_PER_HOUR`, default 6/h) → reserveBudget → run extraction * → reconcile budget. A slot is charged early so a 429 stays cheap, and it is * handed back when the scan fails before the provider is called. @@ -38,7 +41,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { buildDateKey, reconcileSpend, @@ -48,10 +51,7 @@ import { } from "@/lib/ai/coach/budget"; import { prisma } from "@/lib/db"; import { rasterizePdf } from "@/lib/documents/rasterize-pdf"; -import { - resolveTextProvider, - resolveVisionProvider, -} from "@/lib/labs/ocr-capability"; +import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability"; import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract"; import { BodyTooLargeError, @@ -94,6 +94,11 @@ function providerFailureMeta(error: unknown, mode?: "text") { export const POST = apiHandler(async (request: Request) => { const { user } = await requireAuth(); + // Reading a lab report is model work in both modes: vision sends the image, + // text mode sends what the browser read off it. The provider and the consent + // receipt are answered by the pick below, for the provider actually used. + await requireAiCapability("labsOcr", { pickDecides: true }); + // Dispatch on the body shape: a JSON body is the local-OCR text mode; a // multipart body is the native-vision image/PDF mode. const contentType = request.headers.get("content-type") ?? ""; @@ -124,21 +129,10 @@ async function handleTextExtract( }); } - // Any configured provider can structure the text. - const { chain, pick } = await resolveTextProvider(userId); - if (!pick) { - annotate({ - action: { name: "labs.ocr.providerUnsupported" }, - meta: { chainLength: chain.length, mode: "text" }, - }); - return apiError("No AI provider is configured", 422, { - errorCode: "labs.ocr.providerUnsupported", - }); - } - - // Consent gate — only the server-managed key trips this; BYOK / local / codex - // egress is the user's own act (the toggle is the consent for local OCR). - await assertConsentForChain({ userId, chain, surface: "insights" }); + // Any configured provider can structure the text. The pick is re-checked + // against `labsOcr` for exactly that provider; one that leaves the machine + // needs a document consent receipt. + const pick = await requireLabsOcrProvider(userId, "text"); const rl = await checkLabsOcrRateLimit(userId); if (!rl.allowed) { @@ -244,20 +238,9 @@ async function handleVisionExtract( request: Request, userId: string, ): Promise { - // 1. Resolve a vision-capable provider. 422 when none is configured. - const { chain, pick } = await resolveVisionProvider(userId); - if (!pick) { - annotate({ - action: { name: "labs.ocr.providerUnsupported" }, - meta: { chainLength: chain.length }, - }); - return apiError("No vision-capable AI provider is configured", 422, { - errorCode: "labs.ocr.providerUnsupported", - }); - } - - // 2. Consent gate — server-managed egress of health data requires a receipt. - await assertConsentForChain({ userId, chain, surface: "insights" }); + // 1-2. Resolve a vision-capable provider (422 when none is configured) and + // re-check `labsOcr` for it, consent receipt included. + const pick = await requireLabsOcrProvider(userId, "vision"); // 3. Rate-limit (vision calls are costly). const rl = await checkLabsOcrRateLimit(userId); diff --git a/src/app/api/measurements/__tests__/range-aggregation-route.test.ts b/src/app/api/measurements/__tests__/range-aggregation-route.test.ts index 8f0245114..968963986 100644 --- a/src/app/api/measurements/__tests__/range-aggregation-route.test.ts +++ b/src/app/api/measurements/__tests__/range-aggregation-route.test.ts @@ -32,9 +32,6 @@ vi.mock("@/lib/db", () => ({ }, })); -// v1.4.39.2 — the rollup branch's coverage-mismatch fallback may call -// `recomputeUserRollups` inline. Mock it so tests can assert whether -// the inline fold fired without exercising the real populator. vi.mock("@/lib/rollups/measurement-rollups", async () => { const actual = await vi.importActual< typeof import("@/lib/rollups/measurement-rollups") @@ -66,7 +63,7 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -221,318 +218,50 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => { expect(months.size).toBe(24); }); - it("source=rollup + aggregate=daily reads measurement_rollups without firing the heavy date_trunc query", async () => { - // v1.4.36 W1 — Insights trends row routes the three daily chart - // fetches through the persistent DAY buckets via `source=rollup`. - // The heavy `date_trunc` $queryRaw must NOT fire on the happy - // path where the rollup has rows for the requested window. - const buckets = Array.from({ length: 30 }, (_, i) => ({ - type: "WEIGHT", - bucketStart: new Date(Date.UTC(2026, 3, 16 + i, 0, 0, 0)), - mean: 81 + i * 0.05, - count: 5, - // v1.11.1 — single source per day → collapse is identity. - source: "APPLE_HEALTH", - })); - vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue( - buckets as never, - ); + it("source=rollup + aggregate=daily folds live in the user's zone, never the UTC-day rollup (#1026)", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Asia/Kolkata", + sourcePriorityJson: null, + } as never); + // The resolver caches per user; drop what earlier cases left behind. + invalidateUserTimezone("user-1"); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { + type: "ACTIVITY_STEPS", + bucket_start: new Date("2026-05-09T18:30:00.000Z"), + avg: 8123, + cnt: 96, + min_value: 0, + max_value: 400, + }, + ] as never); const res = await GET( getRequest( - "type=WEIGHT&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000", + "type=ACTIVITY_STEPS&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000", ), ); + expect(res.status).toBe(200); const json = (await res.json()) as { data: { - measurements: Array<{ type: string; value: number; count: number }>; - meta: { aggregate?: string }; + measurements: Array<{ + measuredAt: string; + value: number; + minValue?: number; + }>; }; }; - expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1); - expect(prisma.$queryRaw).not.toHaveBeenCalled(); - expect(json.data.measurements.length).toBe(30); - // First bucket reflects the rollup mean — round-trip-stable. - expect(json.data.measurements[0].type).toBe("WEIGHT"); - expect(json.data.measurements[0].value).toBeCloseTo(81, 5); - expect(json.data.measurements[0].count).toBe(5); - expect(json.data.meta.aggregate).toBe("daily"); - }); - - it("source=rollup cumulative path reads sum_value directly (v1.4.39 W-SUM)", async () => { - // ACTIVITY_STEPS rollup row with sum_value populated. The route - // must consume sumValue directly — NOT reconstruct from mean * - // count. Use a sum that is NOT mean × count so the test - // distinguishes the two paths. - const buckets = Array.from({ length: 3 }, (_, i) => ({ - type: "ACTIVITY_STEPS", - bucketStart: new Date(Date.UTC(2026, 4, 1 + i, 0, 0, 0)), - mean: 2000, // would yield 10000 if multiplied by count=5 - count: 5, - sumValue: 11000 + i * 250, // distinct from mean × count - source: "APPLE_HEALTH", - })); - vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue( - buckets as never, - ); - - const res = await GET( - getRequest( - "type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000", - ), - ); - expect(res.status).toBe(200); - const json = (await res.json()) as { - data: { measurements: Array<{ value: number }> }; - }; - expect(prisma.$queryRaw).not.toHaveBeenCalled(); - expect(json.data.measurements.map((m) => m.value)).toEqual([ - 11000, 11250, 11500, - ]); - }); - - it("source=rollup cumulative path falls back to mean*count when sum_value is NULL (v1.4.39 W-SUM)", async () => { - // Pre-v1.4.39 row — boot-backfill hasn't converged yet. The - // route falls back to mean × count so the chart never paints - // a hole during the convergence window. - const buckets = [ - { - type: "ACTIVITY_STEPS", - bucketStart: new Date(Date.UTC(2026, 4, 1)), - mean: 2000, - count: 5, - sumValue: null, - source: "APPLE_HEALTH", - }, - ]; - vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue( - buckets as never, - ); - - const res = await GET( - getRequest( - "type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000", - ), - ); - const json = (await res.json()) as { - data: { measurements: Array<{ value: number }> }; - }; - expect(json.data.measurements[0].value).toBe(10000); - }); - - it("falls back to live date_trunc when source=rollup returns zero buckets", async () => { - // Empty rollup ⇒ heavy aggregate runs so brand-new accounts still - // see a correct chart on their first render. - vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); - const liveBuckets = Array.from({ length: 7 }, (_, i) => ({ - type: "WEIGHT", - bucket_start: new Date(Date.UTC(2026, 4, 8 + i, 0, 0, 0)), - avg: 81 + i * 0.05, - cnt: 2, - })); - vi.mocked(prisma.$queryRaw).mockResolvedValue(liveBuckets as never); - - const res = await GET( - getRequest( - "type=WEIGHT&from=2026-05-08T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000", - ), - ); - expect(res.status).toBe(200); - expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1); - expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); - const json = (await res.json()) as { - data: { measurements: Array }; - }; - expect(json.data.measurements.length).toBe(7); - }); - - it("source=rollup folds the partition inline and re-reads when the rollup is under-converged (v1.4.39.2)", async () => { - // The maintainer's pre-fix production trace: 30-day BP_SYS window returned - // `total: 2` from the rollup branch even though `measurements` - // carried ~30 distinct days. The chart tripped its `< 3 daily - // points` empty-state because the route short-circuited on - // `rollupRows.length > 0` and never reconciled with the live - // table. The coverage-mismatch fallback runs an inline - // `recomputeUserRollups` for the (user, type, DAY, window) - // partition and re-reads so the request returns the converged - // rows. - const sparseRollup = [ - { - type: "BLOOD_PRESSURE_SYS", - bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)), - mean: 128, - count: 1, - sumValue: null, - source: "APPLE_HEALTH", - }, - { - type: "BLOOD_PRESSURE_SYS", - bucketStart: new Date(Date.UTC(2026, 4, 10, 0, 0, 0)), - mean: 132, - count: 1, - sumValue: null, - source: "APPLE_HEALTH", - }, - ]; - const convergedRollup = Array.from({ length: 28 }, (_, i) => ({ - type: "BLOOD_PRESSURE_SYS", - bucketStart: new Date(Date.UTC(2026, 4, 1 + i, 0, 0, 0)), - mean: 128 + (i % 5), - count: 2, - sumValue: null, - source: "APPLE_HEALTH", - })); - // First read returns the sparse rows; second (post-fold) read - // returns the converged rows. - vi.mocked(prisma.measurementRollup.findMany) - .mockResolvedValueOnce(sparseRollup as never) - .mockResolvedValueOnce(convergedRollup as never); - // Coverage probe — live `measurements` has 28 distinct days. - vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([ - { days: BigInt(28) }, - ] as never); - - const res = await GET( - getRequest( - "type=BLOOD_PRESSURE_SYS&from=2026-04-30T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=daily&source=rollup&limit=5000", - ), - ); - expect(res.status).toBe(200); - const json = (await res.json()) as { - data: { measurements: Array }; - }; - // The inline fold fired exactly once with the (type, DAY, window) - // bound so the partition write stays small. - expect(recomputeUserRollups).toHaveBeenCalledTimes(1); - expect(recomputeUserRollups).toHaveBeenCalledWith( - "user-1", - expect.objectContaining({ - types: ["BLOOD_PRESSURE_SYS"], - granularities: ["DAY"], - }), - ); - // The route re-read the rollup after the fold so the response - // carries the converged 28 buckets — well above the chart's - // `< 3 daily points` empty-state threshold. - expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(2); - expect(json.data.measurements.length).toBe(28); - }); - - it("source=rollup skips the coverage probe on a covered window (v1.4.39.2 hot-path)", async () => { - // Happy case: 30-day window, 28 rollup rows present. The route - // must short-circuit on the first read and skip the probe so the - // covered-tenant hot path stays a single indexed read. - const buckets = Array.from({ length: 28 }, (_, i) => ({ - type: "WEIGHT", - bucketStart: new Date(Date.UTC(2026, 3, 16 + i, 0, 0, 0)), - mean: 81 + i * 0.05, - count: 2, - sumValue: null, - source: "APPLE_HEALTH", - })); - vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue( - buckets as never, - ); - - const res = await GET( - getRequest( - "type=WEIGHT&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000", - ), - ); - expect(res.status).toBe(200); - expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1); - expect(recomputeUserRollups).not.toHaveBeenCalled(); - expect(prisma.$queryRaw).not.toHaveBeenCalled(); - }); - - it("source=rollup skips the coverage fallback on short windows (v1.4.39.2)", async () => { - // A 5-day window with 2 rollup rows is plausibly genuine sparsity - // for a user who measures BP every other day. The coverage - // fallback gates on `windowDays >= 7` so the probe + inline fold - // never fire on these short windows. - const sparseRollup = [ - { - type: "BLOOD_PRESSURE_SYS", - bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)), - mean: 128, - count: 1, - sumValue: null, - source: "APPLE_HEALTH", - }, - { - type: "BLOOD_PRESSURE_SYS", - bucketStart: new Date(Date.UTC(2026, 4, 3, 0, 0, 0)), - mean: 132, - count: 1, - sumValue: null, - source: "APPLE_HEALTH", - }, - ]; - vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue( - sparseRollup as never, - ); - - const res = await GET( - getRequest( - "type=BLOOD_PRESSURE_SYS&from=2026-05-01T00:00:00Z&to=2026-05-06T00:00:00Z&aggregate=daily&source=rollup&limit=5000", - ), - ); - expect(res.status).toBe(200); - expect(recomputeUserRollups).not.toHaveBeenCalled(); - expect(prisma.$queryRaw).not.toHaveBeenCalled(); - const json = (await res.json()) as { - data: { measurements: Array }; - }; - expect(json.data.measurements.length).toBe(2); - }); - - it("source=rollup keeps the sparse rollup when live `measurements` agrees (v1.4.39.2)", async () => { - // Genuine sparsity: the user measured BP twice in 30 days. The - // probe finds the same 2 distinct days in `measurements` so the - // route returns the 2 rollup rows without firing the inline - // fold. The chart's `< 3 daily points` empty-state is the right - // surface for this case — the data really is sparse. - const sparseRollup = [ - { - type: "BLOOD_PRESSURE_SYS", - bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)), - mean: 128, - count: 1, - sumValue: null, - source: "APPLE_HEALTH", - }, - { - type: "BLOOD_PRESSURE_SYS", - bucketStart: new Date(Date.UTC(2026, 4, 10, 0, 0, 0)), - mean: 132, - count: 1, - sumValue: null, - source: "APPLE_HEALTH", - }, - ]; - vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue( - sparseRollup as never, - ); - // Probe agrees — 2 distinct days in `measurements` too. - vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([ - { days: BigInt(2) }, - ] as never); - - const res = await GET( - getRequest( - "type=BLOOD_PRESSURE_SYS&from=2026-04-30T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=daily&source=rollup&limit=5000", - ), - ); - expect(res.status).toBe(200); - // Probe fired (suspicious-low rollup) but the inline fold did - // not (live and rollup agree). - expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); - expect(recomputeUserRollups).not.toHaveBeenCalled(); - const json = (await res.json()) as { - data: { measurements: Array }; - }; - expect(json.data.measurements.length).toBe(2); + expect(prisma.measurementRollup.findMany).not.toHaveBeenCalled(); + // The zone travels as a bound parameter of the fold. + const call = vi.mocked(prisma.$queryRaw).mock.calls[0] as unknown[]; + expect(call.slice(1)).toContain("Asia/Kolkata"); + // Local midnight of 10 May in Kolkata; a SUM carries no spread band. + expect(json.data.measurements[0]).toMatchObject({ + measuredAt: "2026-05-09T18:30:00.000Z", + value: 8123, + }); + expect(json.data.measurements[0].minValue).toBeUndefined(); }); it("returns a weekly series when the all-time window is under two years", async () => { diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index ca5e59685..a1eb337a1 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -45,20 +45,21 @@ import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { runSafetyFloorCheck } from "@/lib/illness/safety-floor-check"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; -import { readDailySeries } from "@/lib/measurements/daily-series-read"; +import { + readDailySeries, + readLiveBuckets, +} from "@/lib/measurements/daily-series-read"; import { reconstructSleepSessions, pickMainNightAndNaps, } from "@/lib/analytics/sleep-night"; import { resolveUserTimezone } from "@/lib/tz/resolver"; -import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql"; import { NextRequest } from "next/server"; import type { MeasurementType, MeasurementSource, GlucoseContext, } from "@/generated/prisma/client"; -import { Prisma } from "@/generated/prisma/client"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "measurements"); @@ -393,36 +394,12 @@ export const GET = apiHandler(async (request: NextRequest) => { // The route's response is bounded by the `BUCKET_CAP` ceiling per // grain (monthly: 24, weekly: 105, daily: 365) so a multi-decade // account never paints an unbounded series. - // v1.4.36 W1 — rollup-table read for daily aggregates. - // - // The Insights trends row fires three parallel - // `GET /api/measurements?type=…&aggregate=daily&limit=5000` requests - // (BP_SYS / BP_DIA / WEIGHT). The legacy `date_trunc('day', …)` - // path scanned the measurements table each time — a full year window - // on the maintainer's account hit 3 × ~3 s on the v1.4.35 HAR. Reading from - // the persistent `measurement_rollups` DAY buckets drops each call - // to a small indexed read against the ~5 k-row rollup table. - // - // Gating: - // - `source=rollup` is an explicit client opt-in (`@/lib/validations/measurement` - // constrains the enum to `["rollup"]`). - // - We require `aggregate=daily` because the rollup populator only - // keeps DAY buckets on the synchronous write hook; the WEEK / - // MONTH paths still depend on the async pg-boss recomputes which - // may lag behind a recent write. - // - `type` is mandatory so the response shape mirrors the per-type - // ask the chart code makes. - // - When the rollup window returns zero rows we fall through to - // the live `date_trunc` path so a brand-new account whose - // populator hasn't caught up still sees a correct chart on its - // first render. + // `source=rollup` + `aggregate=daily` — the chart's series read. Windows up + // to the DAY cap fold live into the user's local days (#1026); wider + // windows step up to the WEEK / MONTH / YEAR rollup tier. `type` is + // mandatory so the response mirrors the per-type ask the chart makes. if (source === "rollup" && aggregate === "daily" && type && from && to) { - // v1.18.6 — the rollup-daily read (per-source collapse + coverage - // probe + inline fold + live-SQL fallback) moved to the shared - // `readDailySeries` reader so the batched series endpoint - // (`/api/measurements/series-batch`) reads through the SAME path - // instead of duplicating this SQL. Behaviour is unchanged: the rows - // returned here are byte-identical with the pre-extraction branch. + // Shared with the batched series endpoint, so both read the same rows. const cap = Math.min(limit, BUCKET_CAP.daily); const priorityJson = await loadUserSourcePriority(user.id); const measurements = await readDailySeries({ @@ -444,94 +421,29 @@ export const GET = apiHandler(async (request: NextRequest) => { }, }); } - // `readDailySeries` already falls back to the live `date_trunc` - // aggregate on a rollup-coverage miss, so an empty result here means - // the window genuinely holds no rows. Fall through to the shared - // live aggregate branch below to keep the response shape identical - // for the truly-empty case. + // An empty result means the window genuinely holds no rows. Fall + // through to the shared live aggregate branch below to keep the + // response shape identical for the truly-empty case. } if (aggregate && aggregate !== "raw" && from && to) { - const grain: AggregateGrain = aggregate; + const grain: Exclude = aggregate; const cap = Math.min(limit, BUCKET_CAP[grain]); - // Postgres `date_trunc` requires the unit argument to be a SQL - // literal — it cannot be supplied as a prepared-statement parameter. - // The previous `${truncUnit}` (bound) interpolation 500'd in - // production for every grain; the grain string is restricted to - // the `AggregateGrain` enum upstream via the Zod schema, so - // injecting the mapped unit via `Prisma.raw` is safe. The mapping - // also fixes the "weekly"/"monthly" passthrough — Postgres expects - // the singular forms `week`/`month`. - const TRUNC_UNIT: Record, string> = { - daily: "day", - weekly: "week", - monthly: "month", - }; - const truncUnit = TRUNC_UNIT[grain]; - const truncUnitLiteral = Prisma.raw(`'${truncUnit}'`); - // Cumulative HK types (steps, active energy, flights, distance, - // daylight) are partial-day increments — averaging across the day - // understates the daily total by the per-bucket sample count. - // SUM for those; AVG for spot metrics (BP, weight, pulse, BG, - // body fat, mood, sleep). See R-A finding 2. - const useSum = - type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType); - const aggregator = useSum - ? Prisma.raw(`SUM(m."value")::double precision`) - : Prisma.raw(`AVG(m."value")::double precision`); - // v1.11.1 — collapse overlapping sources to the ladder-canonical reading - // per (type, bucket) before aggregating, so this cold/uncovered fallback - // agrees with the warm rollup path. `canon` picks the winning source for - // each (type, bucket) via the per-user rank; the join keeps only its rows. + // Buckets are cut at the user's own calendar boundaries (#1026), the + // same day the raw list and the chart's short range use. Cumulative + // types SUM per bucket, every other type averages; overlapping sources + // collapse to the ladder-canonical one first. const priorityJson = await loadUserSourcePriority(user.id); - const rankRaw = Prisma.raw( - buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'), - ); - const typeFilter = type - ? Prisma.sql`AND m."type" = ${type}::measurement_type` - : Prisma.empty; - const buckets = await prisma.$queryRaw< - Array<{ type: string; bucket_start: Date; avg: number; cnt: number }> - >` - WITH canon AS ( - SELECT DISTINCT ON (m."type", date_trunc(${truncUnitLiteral}, m."measured_at")) - m."type" AS t, - date_trunc(${truncUnitLiteral}, m."measured_at") AS d, - m."source" AS canon - FROM measurements m - WHERE m."user_id" = ${user.id} - AND m."measured_at" >= ${from} - AND m."measured_at" <= ${to} - AND m."deleted_at" IS NULL - ${typeFilter} - ORDER BY m."type", date_trunc(${truncUnitLiteral}, m."measured_at"), (${rankRaw}), m."source" - ) - SELECT - m."type"::text AS type, - date_trunc(${truncUnitLiteral}, m."measured_at") AS bucket_start, - ${aggregator} AS avg, - COUNT(*)::int AS cnt - FROM measurements m - JOIN canon c - ON c.t = m."type" - AND c.d = date_trunc(${truncUnitLiteral}, m."measured_at") - AND c.canon = m."source" - WHERE m."user_id" = ${user.id} - AND m."measured_at" >= ${from} - AND m."measured_at" <= ${to} - AND m."deleted_at" IS NULL - ${typeFilter} - GROUP BY m."type", bucket_start - ORDER BY bucket_start ASC - LIMIT ${cap} - `; - - const measurements = buckets.map((b) => ({ - type: b.type, - value: Number(b.avg), - measuredAt: b.bucket_start.toISOString(), - count: Number(b.cnt), - })); + const measurements = await readLiveBuckets({ + userId: user.id, + type: (type as MeasurementType | undefined) ?? null, + from, + to, + cap, + priorityJson, + grain, + timeZone: await resolveUserTimezone(user.id), + }); annotate({ action: { name: "measurement.list" }, meta: { total: measurements.length, type, aggregate: grain }, diff --git a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts index 4a5c8fed8..1cccc72ad 100644 --- a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts @@ -264,3 +264,165 @@ describe("GET /api/medications/[id]/dose-history", () => { expect(res.status).toBe(422); }); }); + +/** + * Issue #1028 — a dose logged for earlier today on a medication created later + * the same day vanished from the trailing-window ledger while the full + * history (which has no window) showed it. The window floor was clamped to + * the medication's creation instant and the same clamped floor filtered the + * intakes, so any dose recorded with a time before the moment the medication + * was added fell out of "Last 90 days". The creation floor exists to keep + * the ledger from minting phantom slots before the medication existed; it + * says nothing about which recorded doses belong in the window. + * + * Pinned in a positive and a negative UTC offset so a zone-dependent floor + * cannot hide behind one of them. + */ +describe("GET /api/medications/[id]/dose-history — intakes recorded before creation (#1028)", () => { + for (const tz of ["Asia/Kolkata", "America/Los_Angeles"]) { + it(`keeps a same-day dose logged before the medication was added (${tz})`, async () => { + const today = new Date("2026-09-22T12:00:00Z"); + const now = localHmAsUtc(today, tz, 16, 30); + const createdAt = localHmAsUtc(today, tz, 16, 20); + const takenAt = localHmAsUtc(today, tz, 14, 0); + + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(now); + try { + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, timezone: tz }, + } as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt, + scheduleRevisions: [], + schedules: [ + { + id: "sched-1", + windowStart: "08:00", + windowEnd: "08:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + id: "evt-earlier-today", + scheduledFor: takenAt, + takenAt, + skipped: false, + autoMissed: false, + attributionSource: "AUTO", + doseTaken: null, + source: "WEB", + }, + ] as never); + + // Exactly what the Verlauf tab sends: the trailing 90 days to now. + const from = new Date(now.getTime() - 90 * 86_400_000).toISOString(); + const to = now.toISOString(); + const res = await GET( + getReq( + `?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, + ), + ROUTE_PARAMS, + ); + expect(res.status).toBe(200); + const json = await res.json(); + const ids = ( + json.data.rows as Array<{ intake: { id: string | null } | null }> + ).map((r) => r.intake?.id); + expect(ids).toContain("evt-earlier-today"); + // The response reports the window the recorded doses were read + // over: the requested start, not the medication's creation. + expect(json.data.from).toBe(from); + } finally { + vi.useRealTimers(); + } + }); + } +}); + +describe("GET /api/medications/[id]/dose-history — early take (#1028 class)", () => { + it("keeps a dose taken inside its window before the slot's own time", async () => { + const tz = "Asia/Kolkata"; + const today = new Date("2026-09-22T12:00:00Z"); + const now = localHmAsUtc(today, tz, 19, 30); + const slot = localHmAsUtc(today, tz, 20, 0); + const takenAt = localHmAsUtc(today, tz, 19, 15); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(now); + try { + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, timezone: tz }, + } as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + scheduleRevisions: [], + schedules: [ + { + id: "sched-1", + windowStart: "20:00", + windowEnd: "20:00", + daysOfWeek: null, + timesOfDay: ["20:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + id: "evt-early", + scheduledFor: slot, + takenAt, + skipped: false, + autoMissed: false, + attributionSource: "AUTO", + doseTaken: null, + source: "WEB", + }, + ] as never); + const from = new Date(now.getTime() - 90 * 86_400_000).toISOString(); + const res = await GET( + getReq(`?from=${encodeURIComponent(from)}`), + ROUTE_PARAMS, + ); + const json = await res.json(); + const rows = json.data.rows as Array<{ + status: string; + intake: { id: string | null } | null; + }>; + // `to` reports how far recorded doses were read: up to the early + // dose's slot, past the request's own now. + expect(json.data.to).toBe(slot.toISOString()); + const early = rows.find((r) => r.intake?.id === "evt-early"); + expect(early?.status).toBe("taken_on_time"); + // The read horizon stretches only to the recorded anchor: no later + // slot is minted as upcoming. + expect(rows.filter((r) => r.status === "upcoming")).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 11c60c9a0..8b1dae088 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -142,12 +142,18 @@ export const GET = apiHandler( const requestedFrom = parsed.data.from ?? new Date(to.getTime() - 90 * DAY_MS); const spanFloor = new Date(to.getTime() - MAX_WINDOW_DAYS * DAY_MS); + // The window the caller asked for, capped only by the span limit. This is + // the floor for recorded doses: a dose logged with a time before the + // medication was added (created at 16:20, "taken at 14:00") is a real + // record and belongs in the window, exactly as the unwindowed full + // history shows it (#1028). + const intakeFrom = new Date( + Math.max(requestedFrom.getTime(), spanFloor.getTime()), + ); + // The floor for minted slots additionally stops at the medication's + // creation, so pre-existence days never mint phantom missed slots. const from = new Date( - Math.max( - requestedFrom.getTime(), - medication.createdAt.getTime(), - spanFloor.getTime(), - ), + Math.max(intakeFrom.getTime(), medication.createdAt.getTime()), ); const events = await prisma.medicationIntakeEvent.findMany({ @@ -182,6 +188,23 @@ export const GET = apiHandler( const lastIntakeAt = lastNonSkippedTakenAt(mapped); + // A dose may be recorded before its slot's own time: the dose window opens + // up to two hours early, and a skip can be logged ahead. Such a row is + // stored on the slot's anchor, which still lies after `to`, so a window + // that ends at `to` dropped a dose the user had just recorded until the + // slot's clock time passed (#1028). Extend the read horizon to the latest + // anchor of a recorded row (taken or skipped) within a day of `to`, and no + // further: pending reminder rows record nothing and never stretch it. + const horizon = new Date( + mapped.reduce((latest, e) => { + const t = e.scheduledFor.getTime(); + const recorded = e.skipped || (e.takenAt !== null && e.takenAt <= to); + return recorded && t > latest && t <= to.getTime() + DAY_MS + ? t + : latest; + }, to.getTime()), + ); + const bandMedication: BandMinterMedication = { id: medication.id, startsOn: medication.startsOn, @@ -223,7 +246,7 @@ export const GET = apiHandler( revisions: medication.scheduleRevisions ?? [], ctx, userTz, - range: { from, to }, + range: { from, to: horizon }, now, intakeInstants, }); @@ -241,7 +264,7 @@ export const GET = apiHandler( // partition, so filter on the stored anchor (a take snapped to an // out-of-window slot is excluded, matching the compliance read). const historyIntakes: HistoryIntake[] = mapped - .filter((e) => e.scheduledFor >= from && e.scheduledFor <= to) + .filter((e) => e.scheduledFor >= intakeFrom && e.scheduledFor <= horizon) .map((e) => ({ id: e.id, scheduledFor: e.scheduledFor, @@ -290,9 +313,13 @@ export const GET = apiHandler( meta: { row_count: serialized.length, family }, }); + // The window the recorded doses were read over (#1028): from the + // requested start (slots additionally start at the medication's + // creation) to the read horizon, which reaches past `to` only for a dose + // recorded ahead of its slot. return apiSuccess({ - from: from.toISOString(), - to: to.toISOString(), + from: intakeFrom.toISOString(), + to: horizon.toISOString(), family, hasExpectedSlots: bands.length > 0, rows: serialized, diff --git a/src/app/api/medications/extract/__tests__/route.test.ts b/src/app/api/medications/extract/__tests__/route.test.ts index d80ee7565..f13877232 100644 --- a/src/app/api/medications/extract/__tests__/route.test.ts +++ b/src/app/api/medications/extract/__tests__/route.test.ts @@ -22,9 +22,11 @@ vi.mock("@/lib/api-handler", async () => { }; }); -vi.mock("@/lib/feature-flags", () => ({ - requireAssistantSurface: vi.fn(async () => undefined), - AssistantDisabledError: class extends Error {}, +vi.mock("@/lib/ai/capabilities/gate", () => ({ + requireAiCapability: vi.fn(), +})); +vi.mock("@/lib/ai/capabilities/egress", () => ({ + assertAiEgress: vi.fn(), })); vi.mock("@/lib/rate-limit", () => ({ @@ -53,14 +55,6 @@ vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: vi.fn(async () => []), })); -// v1.12.1 — free-text medication extraction egresses PHI to the operator's -// server-managed key, so it now passes through the consent gate. Mock it to a -// no-op here; the gate's own fail-closed behaviour is covered by -// `consent-guard.test.ts`. -vi.mock("@/lib/ai/consent-guard", () => ({ - assertConsentForChain: vi.fn(async () => undefined), -})); - vi.mock("@/lib/ai/provider-runner", async () => { const actual = await vi.importActual< typeof import("@/lib/ai/provider-runner") @@ -93,7 +87,9 @@ import { runRawCompletionWithFallback, AllProvidersFailedError, } from "@/lib/ai/provider-runner"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; +import { assertAiEgress } from "@/lib/ai/capabilities/egress"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; function postReq(body: unknown): NextRequest { return new NextRequest("http://localhost/api/medications/extract", { @@ -110,6 +106,12 @@ const AUTH_OK = { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(requireAiCapability).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + vi.mocked(assertAiEgress).mockResolvedValue(undefined); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, limit: 60, @@ -208,10 +210,15 @@ describe("POST /api/medications/extract — happy path", () => { doseUnit: "mg", cadenceKind: "everyNWeeks", }); - // The PHI free-text egress is gated on an active consent receipt for the - // coach surface before the provider chain runs. - expect(assertConsentForChain).toHaveBeenCalledWith( - expect.objectContaining({ surface: "coach" }), + // The PHI free-text egress is re-checked against the capability for the + // whole chain the runner may cascade through, before it runs. + expect(assertAiEgress).toHaveBeenCalledWith( + "medicationExtract", + "user-1", + expect.any(Array), + ); + expect(vi.mocked(assertAiEgress).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(runRawCompletionWithFallback).mock.invocationCallOrder[0]!, ); }); @@ -300,6 +307,8 @@ describe("POST /api/medications/extract — daily budget", () => { expect(res.status).toBe(429); const body = await res.json(); expect(body.error).toMatch(/budget/i); + // The code a client branches on, the same string the body always carried. + expect(body.meta).toEqual({ errorCode: "coach.budget.exceeded" }); // The refusal happens BEFORE any upstream spend. expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); }); @@ -374,8 +383,19 @@ describe("POST /api/medications/extract — validation + provider errors", () => type: "none", } as never); - const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never); - expect(res.status).toBe(503); + // The status stays 503 (a shipped client may branch on it) and gains the + // code that says what it means. + const error = await POST( + postReq({ text: "Mounjaro 5mg weekly" }) as never, + ).catch((e) => e); + expect(error).toBeInstanceOf(AiUnavailableError); + expect(error.status).toBe(503); + expect(error.meta).toEqual({ + errorCode: "ai.provider.none", + capability: "medicationExtract", + reason: "no_provider", + }); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); }); it("returns 502 when the provider returns unparseable JSON", async () => { @@ -395,3 +415,39 @@ describe("POST /api/medications/extract — validation + provider errors", () => expect(res.status).toBe(502); }); }); + +describe("POST /api/medications/extract — the medicationExtract capability", () => { + it("answers under medicationExtract, keeping 503 for no provider", async () => { + vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never); + await POST(postReq({ text: "5mg weekly" }) as never).catch(() => null); + expect(requireAiCapability).toHaveBeenCalledWith("medicationExtract", { + noProvider: { errorCode: "ai.provider.none", status: 503 }, + }); + }); + + it("refuses before the body is read when the operator turned it off", async () => { + vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never); + vi.mocked(requireAiCapability).mockRejectedValueOnce( + new AiUnavailableError("medicationExtract", "operator_disabled"), + ); + const error = await POST(postReq({ text: "5mg weekly" }) as never).catch( + (e) => e, + ); + expect(error.meta.errorCode).toBe("assistant.disabled.documentAi"); + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + }); + + it("stops at the wire without reserving budget or calling a provider", async () => { + vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never); + vi.mocked(assertAiEgress).mockRejectedValueOnce( + new AiUnavailableError("medicationExtract", "consent_required"), + ); + const error = await POST(postReq({ text: "5mg weekly" }) as never).catch( + (e) => e, + ); + expect(error.meta.errorCode).toBe("consent.ai.required"); + expect(reserveBudget).not.toHaveBeenCalled(); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts index 4fe35ca8a..c8bee510e 100644 --- a/src/app/api/medications/extract/route.ts +++ b/src/app/api/medications/extract/route.ts @@ -24,9 +24,16 @@ * Behaviour: * * 1. requireAuth() — cookie OR Bearer. - * 2. requireAssistantSurface("coach") — operator can disable. + * 2. requireAiCapability("medicationExtract") — the operator's "Reading + * documents" switch (and the master), provider-work authority for the + * record, a configured provider (503 `ai.provider.none` otherwise; the + * status predates the envelope and stays until no shipped client reads + * it), and a document-class consent receipt: free-text medication input + * is health data, and any provider that leaves the machine needs one. * 3. checkRateLimit(...) — 10 requests / 5 min / user. - * 4. resolveProviderChain() — fall back through providers. + * 4. resolveProviderChain() — fall back through providers, + * then `assertAiEgress` re-checks the capability for exactly that chain + * immediately before anything is sent. * 5. reserveBudget(resolveDailyCap(chain)) — atomic daily token ceiling, * capped against the COST OWNER: a self-hoster on their own key or a * local model is measured against the user-plan ceiling, not the @@ -56,7 +63,13 @@ import { } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -import { requireAssistantSurface } from "@/lib/feature-flags"; +import { assertAiEgress } from "@/lib/ai/capabilities/egress"; +import { requireAiCapability } from "@/lib/ai/capabilities/gate"; +import { + AiUnavailableError, + type NoProviderRefusal, +} from "@/lib/ai/capabilities/refusal"; +import { AI_PROVIDER_NONE_ERROR_CODE } from "@/lib/ai/capabilities/types"; import { AllProvidersFailedError, @@ -64,7 +77,6 @@ import { } from "@/lib/ai/provider-runner"; import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; import { singleUserTurn } from "@/lib/ai/types"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; import { buildDateKey, @@ -80,6 +92,19 @@ import { type MedicationExtractionResult, } from "@/lib/ai/coach/medication-extract-prompt"; +/** + * How the extractor refuses with no provider. 503 predates the capability + * envelope; it stays until no shipped client is known to branch on it, and the + * code says what it means either way. + */ +const MEDICATION_EXTRACT_NO_PROVIDER: NoProviderRefusal = { + errorCode: AI_PROVIDER_NONE_ERROR_CODE, + status: 503, +}; + +/** The budget refusal's code; the same string the body always carried. */ +const MEDICATION_EXTRACT_BUDGET_CODE = "coach.budget.exceeded"; + /** Hard cap on the free-text payload; mirrors `coachChatRequestSchema.message`. */ const MAX_TEXT_LENGTH = 2000; @@ -128,10 +153,12 @@ function parseModelJson(content: string): unknown | null { async function handleExtract(request: NextRequest): Promise { const auth = await requireAuth(); - // Same operator-disable gate the Coach SSE route honours — when - // "coach" is off, the overlay also disappears so the wizard simply - // falls back to the manual path. - await requireAssistantSurface("coach"); + // Medication text extraction is its own capability under the operator's + // "Reading documents" switch. When it is closed the wizard keeps its + // manual path; nothing here is data the wizard needs. + await requireAiCapability("medicationExtract", { + noProvider: MEDICATION_EXTRACT_NO_PROVIDER, + }); const userId = auth.user.id; @@ -178,15 +205,25 @@ async function handleExtract(request: NextRequest): Promise { const legacy = await resolveProvider(userId); if (legacy.type === "none") { annotate({ action: { name: "medications.extract.no-provider" } }); - return apiError("No AI provider configured", 503); + throw new AiUnavailableError( + "medicationExtract", + "no_provider", + null, + MEDICATION_EXTRACT_NO_PROVIDER, + ); } chain.push({ providerType: "admin-openai", instance: legacy }); } - // Free-text medication input is PHI. If the resolved chain could egress - // via the operator's server-managed key, an active consent receipt is - // required first — same gate as the Coach (which shares this chain). - await assertConsentForChain({ userId, chain, surface: "coach" }); + // The wire. The runner may cascade through every entry, so each one is + // named: any entry that leaves the machine needs a document-class consent + // receipt, and a switch turned off since the gate above answered stops the + // call here. + await assertAiEgress( + "medicationExtract", + userId, + chain.map((entry) => entry.providerType), + ); const today = todayOverride ?? buildDateKey(); const { systemPrompt, userPrompt } = buildMedicationExtractionPrompt({ @@ -219,7 +256,9 @@ async function handleExtract(request: NextRequest): Promise { action: { name: "medications.extract.budget-exceeded" }, meta: { totalAfter: reservation.totalAfter }, }); - return apiError("coach.budget.exceeded", 429); + return apiError(MEDICATION_EXTRACT_BUDGET_CODE, 429, { + errorCode: MEDICATION_EXTRACT_BUDGET_CODE, + }); } let completion; diff --git a/src/components/charts/__tests__/health-chart-range.test.tsx b/src/components/charts/__tests__/health-chart-range.test.tsx index 9cbed264c..3db9cb2f6 100644 --- a/src/components/charts/__tests__/health-chart-range.test.tsx +++ b/src/components/charts/__tests__/health-chart-range.test.tsx @@ -93,9 +93,10 @@ describe(" — bounded range fetch", () => { expect(measurementsUrl!).toMatch(/aggregate=daily/); }); - it("omits aggregate=daily on windows of 7 days or fewer", async () => { - // v1.4.29 C3 — short windows keep raw fetching so the user can - // see hour-by-hour detail on the 7-day view. + it("asks for daily rows on the 7-day window too", async () => { + // The chart folds every range into days. The 7-day window used to read + // raw rows oldest-first under the 5000-row cap, which on a per-minute + // stream cut off the newest half of the week. const { I18nProvider } = await import("@/lib/i18n/context"); const { HealthChart } = await import("../health-chart"); @@ -116,7 +117,7 @@ describe(" — bounded range fetch", () => { u.includes("/api/measurements"), ); expect(measurementsUrl).toBeDefined(); - expect(measurementsUrl!).not.toMatch(/aggregate=daily/); + expect(measurementsUrl!).toMatch(/aggregate=daily/); }); it("re-keys the query when the range window changes", async () => { diff --git a/src/components/charts/__tests__/health-chart-timezone.test.tsx b/src/components/charts/__tests__/health-chart-timezone.test.tsx new file mode 100644 index 000000000..4dd513ae7 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-timezone.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * A chart mount that passes no `userTimezone` must still cut days in the + * signed-in user's zone. Several mounts (the medications page, the coach + * chat, the recovery section) pass none, and the component used to fall + * back to a fixed Europe/Berlin, so a user in Asia/Kolkata saw those charts + * bucketed and labelled in Berlin days while every other chart used their + * own. The zone now comes from the account, which the server resolves. + */ + +let lastKey: unknown[] | undefined; + +vi.mock("@tanstack/react-query", () => ({ + keepPreviousData: (previous: unknown) => previous, + useQuery: ({ queryKey }: { queryKey: unknown[] }) => { + lastKey = queryKey; + return { data: [], isLoading: false }; + }, + useQueryClient: () => ({ + cancelQueries: () => Promise.resolve(), + getQueryData: () => undefined, + setQueryData: () => undefined, + invalidateQueries: () => Promise.resolve(), + }), + useMutation: () => ({ mutate: () => undefined, isPending: false }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: { id: "u1", timezone: "Asia/Kolkata" }, + isLoading: false, + }), +})); + +describe(" timezone", () => { + it("uses the account's zone when the mount passes none", async () => { + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + renderToStaticMarkup( + + + , + ); + + expect(lastKey).toBeDefined(); + expect(lastKey).toContain("Asia/Kolkata"); + expect(lastKey).not.toContain("Europe/Berlin"); + }); +}); diff --git a/src/components/charts/chart-series-request.ts b/src/components/charts/chart-series-request.ts new file mode 100644 index 000000000..3656f6956 --- /dev/null +++ b/src/components/charts/chart-series-request.ts @@ -0,0 +1,27 @@ +/** + * Query string for one type's chart series (`GET /api/measurements`). + * + * Every range asks for one row per local calendar day. The 7-day range used + * to ask for raw rows oldest-first under a 5 000-row limit; a per-minute + * heart-rate stream holds 10 080 rows in seven days, so the answer stopped + * half-way through the week and the newest days were silently missing. The + * chart folds rows into days either way, so the daily answer draws the same + * points while its size depends only on the number of days. + * + * Pure so the request the chart makes can be tested against the real route. + */ +export function chartSeriesParams( + type: string, + window: { from: string; to: string }, +): URLSearchParams { + const params = new URLSearchParams(); + params.set("type", type); + params.set("sortBy", "measuredAt"); + params.set("sortDir", "asc"); + params.set("from", window.from); + params.set("to", window.to); + params.set("limit", "5000"); + params.set("aggregate", "daily"); + params.set("source", "rollup"); + return params; +} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 6948494cf..d2d766ce8 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1,5 +1,7 @@ "use client"; +import { chartSeriesParams } from "@/components/charts/chart-series-request"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; @@ -213,10 +215,11 @@ interface HealthChartProps { /** * v1.4.25 W7b — per-user display timezone. When passed (mount sites * thread `useAuth().user?.timezone`), x-axis tick labels and the - * per-day bucket keys both render in the user's zone instead of the - * legacy Europe/Berlin pin. Defaults to "Europe/Berlin" so older - * callers that haven't yet adopted the prop keep their previous - * behaviour bit-for-bit. + * per-day bucket keys both render in the user's zone. When a mount + * passes none, the chart uses the signed-in account's zone (which + * `/api/auth/me` reports already resolved against the server default), + * and only before that has loaded the shared `DEFAULT_TIMEZONE` the + * server itself falls back to. */ userTimezone?: string; /** @@ -327,8 +330,8 @@ interface MeasurementApiRow { value: number; // v1.8.5 — per-day min / max emitted by the rollup daily-aggregate // path (non-cumulative metrics only). Drive the Apple-Health-style - // range band shaded around the mean line. Absent on the raw-row path - // (windows ≤ 7 days) and on cumulative metrics. + // range band shaded around the mean line. Not drawn on the 7-day range + // and absent on cumulative metrics. // v1.18.6 — `null` accepted alongside `undefined` so the batched // series endpoint's row shape (which carries `number | null`) feeds // `preloadedSeries` directly. The bucketing loop already guards each @@ -340,8 +343,7 @@ interface MeasurementApiRow { // the underlying raw-row count per bucket. Used downstream to gate // the "more days needed" empty-state copy on the actual measurement // count rather than the number of distinct calendar days. Optional - // because the short-window raw-row path does not populate it (each - // row already represents one measurement, count = 1). + // because the per-night sleep series does not carry it. count?: number; } @@ -614,7 +616,7 @@ export function HealthChart({ chartKey, annotations, verticalMarkers, - userTimezone = "Europe/Berlin", + userTimezone: userTimezoneProp, valueScale = 1, valueOffset = 0, onVisibleStats, @@ -625,6 +627,9 @@ export function HealthChart({ showDataTable = false, }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); + // A mount without the prop used to pin Europe/Berlin, so those charts + // cut days in Berlin for every user whatever their profile said. + const userTimezone = userTimezoneProp || user?.timezone || DEFAULT_TIMEZONE; const { t, locale } = useTranslations(); const fmt = useFormatters(); // Issue #490 — x-axis tick + tooltip date labels. Every timestamp this @@ -764,12 +769,11 @@ export function HealthChart({ }; }, [rangePoints, effectiveCompareBaseline]); - // v1.18.6 — honour the dashboard's batched slice ONLY for the - // daily-aggregate window (`windowDays > 7`, the rollup path) and ONLY - // when EVERY non-sleep type the chart renders is present in the slice. - // SLEEP_DURATION is never batched (per-night `/series`), so a chart - // that includes it always self-fetches. A range-tab change to a ≤7-day - // window (raw rows) drops batched coverage and self-fetches. + // v1.18.6 — honour the dashboard's batched slice ONLY for windows over + // 7 days and ONLY when EVERY non-sleep type the chart renders is present + // in the slice. SLEEP_DURATION is never batched (per-night `/series`), + // so a chart that includes it always self-fetches. The 7-day tab + // self-fetches its own window. // // v1.19.0 — also require that the requested window FITS within the // batch's actual coverage. The dashboard batches only a ~30-day slice; @@ -916,41 +920,7 @@ export function HealthChart({ await fetchSleepNights(); return; } - const typeParams = new URLSearchParams(); - typeParams.set("type", type); - typeParams.set("sortBy", "measuredAt"); - typeParams.set("sortDir", "asc"); - typeParams.set("from", fetchWindow.from); - typeParams.set("to", fetchWindow.to); - typeParams.set("limit", "5000"); - // v1.4.29 C3 — windows over 7 days ask the server to bucket - // daily. Caps the chart's per-type payload at ~365 rows - // instead of ~5 000 for high-density types (pulse), and drops - // Recharts paint cost ~50× on continuous-monitoring accounts. - // Short windows keep raw fetching so hour-by-hour detail - // stays visible. - // - // v1.4.36 W1 — also route the daily aggregate through the - // persistent rollup buckets via `source=rollup`. The route - // reads from `measurement_rollups` instead of running a live - // `date_trunc` scan over the raw measurements table; the - // three parallel daily fetches on the Insights trends row - // (BP_SYS / BP_DIA / WEIGHT) drop from ~3 s each to a small - // indexed read against the ~5 k-row rollup table. The server - // falls back to live SQL when the rollup is empty for the - // requested window so brand-new accounts still see a chart - // on their first render. - // - // v1.19.2 — the `aggregate=daily` ask still holds, but for windows - // wider than the DAY bucket cap the server steps the rollup tier - // up to WEEK / MONTH and returns whole-history coverage at that - // tier. Each returned row is one coarse bucket; the daily fold + - // `bucketTimeSeries` below downsample it to the visible range, so - // the request contract is unchanged. - if (fetchWindow.windowDays > 7) { - typeParams.set("aggregate", "daily"); - typeParams.set("source", "rollup"); - } + const typeParams = chartSeriesParams(type, fetchWindow); // v1.18.6 — when the dashboard handed this chart a batched slice // for the type, fold those rows instead of a per-type fetch. The @@ -1011,8 +981,11 @@ export function HealthChart({ // same scale to the spread so the band tracks the line. The // BMI view drops the range tuple in its `select` callback (it // has never rendered the band), so the raw cache always - // carries the spread. + // carries the spread. The 7-day range never drew the band (it + // used to read raw rows, which carry no spread) and still does + // not, so its picture is unchanged now that it reads days too. if ( + fetchWindow.windowDays > 7 && typeof measurement.minValue === "number" && typeof measurement.maxValue === "number" ) { @@ -1044,10 +1017,9 @@ export function HealthChart({ for (const [type, stats] of Object.entries(bucket.values)) { // v1.4.29.1 — cumulative HealthKit types (steps, active energy, // distance, flights, daylight) must reduce with sum, not the - // per-sample average. The server already does this when the - // chart fetches with `aggregate=daily` (windows over 7 days); - // the 7-day path still pulls raw rows and aggregates here, - // so the same distinction must hold client-side. + // per-sample average. The server already returns one row per + // day; a day that arrives as several rows (the coarser tiers + // of the "All" range) must still add up rather than average. const isCumulative = CUMULATIVE_HK_TYPES.has( type as MeasurementType, ); diff --git a/src/components/cycle/__tests__/bbt-chart.test.tsx b/src/components/cycle/__tests__/bbt-chart.test.tsx index 36437b9f7..a07a707f2 100644 --- a/src/components/cycle/__tests__/bbt-chart.test.tsx +++ b/src/components/cycle/__tests__/bbt-chart.test.tsx @@ -20,6 +20,8 @@ function day(date: string, overrides: Partial = {}): CalendarDay { isPredictedOvulation: false, isPeriodLogged: false, isCycleStart: false, + cycleDay: null, + periodEndable: false, flow: null, hasSymptoms: false, confidence: 1, diff --git a/src/components/cycle/__tests__/cycle-calendar.test.tsx b/src/components/cycle/__tests__/cycle-calendar.test.tsx index f57f782f2..800802a61 100644 --- a/src/components/cycle/__tests__/cycle-calendar.test.tsx +++ b/src/components/cycle/__tests__/cycle-calendar.test.tsx @@ -20,6 +20,8 @@ function dayBase(date: string): CalendarDay { isPredictedOvulation: false, isPeriodLogged: false, isCycleStart: false, + cycleDay: null, + periodEndable: false, flow: null, hasSymptoms: false, confidence: 1, diff --git a/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx b/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx new file mode 100644 index 000000000..e32bb1c3a --- /dev/null +++ b/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx @@ -0,0 +1,137 @@ +/** + * The log sheet speaks about the date it opened on, not about today (#1004). + * + * It used to take its header count and its "period ended" button from the + * verdict, which is the server's answer about TODAY. A January date opened + * in September therefore read "Day 2", and a back-dated period could not be + * closed unless today happened to be a bleeding day. The fixture below makes + * the two sources disagree on purpose: the verdict says day 2 of a period, + * the grid day for the open date says day 20 with no period end possible. + * The sheet must follow the grid. + */ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { CalendarDay } from "../types"; + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ + canManage: true, + canWriteDomain: () => true, + canManageDomain: () => true, + }), +})); + +const TODAY = "2026-07-01"; + +function gridDay(date: string, over: Partial = {}): CalendarDay { + return { + date, + phase: null, + isPredictedPeriod: false, + isFertileWindow: false, + isPredictedOvulation: false, + isPeriodLogged: false, + isCycleStart: false, + cycleDay: null, + periodEndable: false, + flow: null, + hasSymptoms: false, + confidence: 0, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, + ...over, + }; +} + +const calendarQuery = { + data: { + profile: { + goal: "GENERAL_HEALTH", + rawChartMode: false, + predictionEnabled: true, + cyclesObserved: 4, + }, + prediction: null, + verdict: { + state: "IN_CYCLE", + dayOfCycle: 2, + cycleLength: 28, + phase: "MENSTRUAL", + spans: [], + cycleStartDate: "2026-06-30", + overdueDays: null, + daysUntilNext: null, + fertileWindow: { start: null, end: null, active: false }, + }, + stillLearning: false, + days: [gridDay(TODAY, { cycleDay: 20, phase: "LUTEAL" })], + meta: { generatedAt: "2026-07-01T00:00:00Z" }, + }, + isError: false, + isLoading: false, + refetch: vi.fn(), +}; +const idleQuery = { + data: undefined, + isError: false, + isLoading: false, + refetch: vi.fn(), +}; +const mutationStub = { mutate: vi.fn(), isPending: false, isError: false }; + +vi.mock("../use-cycle", () => ({ + localYmd: () => TODAY, + useCycleCalendar: () => calendarQuery, + useCycleHistory: () => idleQuery, + useCycleInsights: () => idleQuery, + useCycleProfile: () => idleQuery, + useCycleDayLog: () => idleQuery, + useCustomSymptoms: () => idleQuery, + useLogDay: () => mutationStub, + usePatchDayLog: () => mutationStub, + useStartPeriod: () => mutationStub, + useEndPeriod: () => mutationStub, + useDeleteDayLog: () => mutationStub, + useCreateCustomSymptom: () => mutationStub, + useDeleteCustomSymptom: () => mutationStub, + CUSTOM_SYMPTOM_LIMIT_ERROR_CODE: "cycle.symptom.custom.limit", + CustomSymptomError: class extends Error {}, +})); + +const sheetProps: Record[] = []; +vi.mock("../log-day-sheet", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + LogDaySheet: (props: Record) => { + sheetProps.push(props); + return null; + }, + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { CycleView } from "../cycle-view"; + +describe(" — the log sheet describes the date it opened on", () => { + it("feeds the sheet the grid day's count and period-end answer, not today's verdict", () => { + sheetProps.length = 0; + renderToStaticMarkup( + + + , + ); + const props = sheetProps.at(-1); + expect(props).toBeDefined(); + expect(props?.date).toBe(TODAY); + expect(props?.dayOfCycle).toBe(20); + expect(props?.phase).toBe("LUTEAL"); + expect(props?.periodEndable).toBe(false); + }); +}); diff --git a/src/components/cycle/__tests__/log-day-sheet.test.ts b/src/components/cycle/__tests__/log-day-sheet.test.ts index e7b281c00..9f0a1e56a 100644 --- a/src/components/cycle/__tests__/log-day-sheet.test.ts +++ b/src/components/cycle/__tests__/log-day-sheet.test.ts @@ -8,8 +8,12 @@ import { intimacyCount, testsCount, noteCount, + isBlankDayLog, + dayLogSaveState, + sheetDayContext, type DayLogFormState, } from "../log-day-sheet"; +import type { CalendarDay } from "../types"; function blank(): DayLogFormState { return { @@ -204,3 +208,116 @@ describe("section summary-badge counts (v1.17.0)", () => { expect(noteCount({ ...blank(), note: "tired today" })).toBe(1); }); }); + +describe("saving a day (#1004)", () => { + it("treats a form with nothing picked as blank", () => { + expect(isBlankDayLog(blank())).toBe(true); + // A disturbed-flag or protection toggle alone records nothing: both only + // mean something next to the value they qualify. + expect(isBlankDayLog({ ...blank(), bbtDisturbed: true })).toBe(true); + expect(isBlankDayLog({ ...blank(), protectedSex: true })).toBe(true); + expect(isBlankDayLog({ ...blank(), note: " " })).toBe(true); + }); + + it("treats any recorded sign as content", () => { + expect(isBlankDayLog({ ...blank(), flow: "LIGHT" })).toBe(false); + expect(isBlankDayLog({ ...blank(), intermenstrual: true })).toBe(false); + expect(isBlankDayLog({ ...blank(), bbt: "36.5" })).toBe(false); + expect(isBlankDayLog({ ...blank(), mucus: "DRY" })).toBe(false); + expect(isBlankDayLog({ ...blank(), intercourse: true })).toBe(false); + expect(isBlankDayLog({ ...blank(), note: "headache" })).toBe(false); + expect( + isBlankDayLog({ ...blank(), symptoms: new Map([["cramps", null]]) }), + ).toBe(false); + }); + + it("does not save an empty new entry, so no empty row lands unseen", () => { + const state = dayLogSaveState({ + hydrated: true, + rowId: null, + form: blank(), + }); + expect(state.canSave).toBe(false); + expect(state.blankNewEntry).toBe(true); + }); + + it("still saves an emptied existing entry, because clearing is an edit", () => { + const state = dayLogSaveState({ + hydrated: true, + rowId: "row-1", + form: blank(), + }); + expect(state.canSave).toBe(true); + expect(state.rowId).toBe("row-1"); + }); + + it("neither saves nor names a row before the date's own entry has loaded", () => { + // The form still holds the previously opened date. A Save here used to + // PATCH that other date's row. + const state = dayLogSaveState({ + hydrated: false, + rowId: "row-of-another-date", + form: { ...blank(), flow: "HEAVY" }, + }); + expect(state.canSave).toBe(false); + expect(state.rowId).toBeNull(); + }); +}); + +function gridDay(date: string, over: Partial = {}): CalendarDay { + return { + date, + phase: null, + isPredictedPeriod: false, + isFertileWindow: false, + isPredictedOvulation: false, + isPeriodLogged: false, + isCycleStart: false, + cycleDay: null, + periodEndable: false, + flow: null, + hasSymptoms: false, + confidence: 0, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, + ...over, + }; +} + +describe("sheetDayContext (#1004)", () => { + it("describes the selected date from its own grid day", () => { + const ctx = sheetDayContext("2026-01-27", [ + gridDay("2026-01-26", { isCycleStart: true, cycleDay: 1 }), + gridDay("2026-01-27", { + cycleDay: 2, + periodEndable: true, + phase: "MENSTRUAL", + }), + ]); + expect(ctx).toEqual({ + startsCycle: false, + periodEndable: true, + phase: "MENSTRUAL", + dayOfCycle: 2, + }); + }); + + it("makes no claim about a date the grid does not hold", () => { + expect(sheetDayContext("2025-12-01", [gridDay("2026-01-26")])).toEqual({ + startsCycle: false, + periodEndable: false, + phase: null, + dayOfCycle: null, + }); + }); + + it("takes the later of two overlapping grid reads, as the calendar does", () => { + const ctx = sheetDayContext("2026-04-01", [ + gridDay("2026-04-01", { cycleDay: null }), + gridDay("2026-04-01", { cycleDay: 4, periodEndable: true }), + ]); + expect(ctx.dayOfCycle).toBe(4); + expect(ctx.periodEndable).toBe(true); + }); +}); diff --git a/src/components/cycle/__tests__/symptom-row.test.tsx b/src/components/cycle/__tests__/symptom-row.test.tsx new file mode 100644 index 000000000..fb84de6a5 --- /dev/null +++ b/src/components/cycle/__tests__/symptom-row.test.tsx @@ -0,0 +1,75 @@ +/** + * Selecting a symptom must not move anything on the sheet (#1001 follow-up). + * + * The intensity selector used to appear inline beside a symptom chip in a + * wrapping row, so a selection widened the chip and pushed the chips after it + * along, sometimes out from under the finger reaching for the intensity. A + * symptom is now one row with its intensity beside it, and the intensity's + * space is laid out whether or not it is shown. What the test pins is exactly + * that: the selected and unselected row render the same boxes, the intensity + * group is present in both, and only its visibility and the colours change. + */ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Droplet } from "lucide-react"; + +vi.mock("../use-cycle", () => ({ + useDeleteCustomSymptom: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { SymptomRow } from "../log-day-sheet"; + +function render(active: boolean, severity: number | null = null): string { + return renderToStaticMarkup( + + {}} + onSeverity={() => {}} + /> + , + ); +} + +/** The markup with every class and state attribute removed: the box tree. */ +function skeleton(html: string): string { + return html + .replace(/ class="[^"]*"/g, "") + .replace(/ (aria-[a-z]+|data-active|disabled|tabindex)(="[^"]*")?/g, ""); +} + +function severityGroup(html: string): string { + const m = /]*data-slot="cycle-symptom-severity"[^>]*>/.exec(html); + if (!m) throw new Error("no severity group rendered"); + return m[0]; +} + +describe("", () => { + it("lays out the same boxes whether or not the symptom is selected", () => { + expect(skeleton(render(true, 2))).toBe(skeleton(render(false))); + }); + + it("reserves the intensity's space while hidden, and reveals it in place", () => { + const idle = severityGroup(render(false)); + const picked = severityGroup(render(true)); + expect(idle).toContain("invisible"); + expect(idle).toContain('aria-hidden="true"'); + expect(picked).not.toContain("invisible"); + expect(picked).not.toContain("aria-hidden"); + }); + + it("keeps the intensity optional and out of reach until the symptom is picked", () => { + const idle = render(false); + // Four levels exist in both states, none of them focusable while hidden. + expect(idle.match(/aria-label="Severity \d of 4"/g)).toHaveLength(4); + expect(idle.match(/tabindex="-1"/g)).toHaveLength(4); + const picked = render(true); + expect(picked).not.toContain('tabindex="-1"'); + expect(picked.match(/aria-pressed="true"/g)).toHaveLength(1); + }); +}); diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index b8c084508..7bb5e2876 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -18,7 +18,7 @@ import { cn } from "@/lib/utils"; import { CycleRing } from "./cycle-ring"; import { CycleCalendar } from "./cycle-calendar"; import { CycleHistoryChart } from "./cycle-history-chart"; -import { LogDaySheet } from "./log-day-sheet"; +import { LogDaySheet, sheetDayContext } from "./log-day-sheet"; import { PhaseEducationCard } from "./phase-education-card"; import { PredictionsPanel } from "./predictions-panel"; import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab"; @@ -469,13 +469,10 @@ export function CycleView() { onOpenChange={setSheetOpen} date={selectedDate} today={today} - startsCycle={ - calendarDays.find((d) => d.date === selectedDate)?.isCycleStart ?? - false - } - activePeriod={verdict?.phase === "MENSTRUAL"} - phase={verdict?.phase ?? null} - dayOfCycle={verdict?.dayOfCycle ?? null} + // Everything the sheet says about the day is about THAT day: its own + // cycle day, its own phase, whether a period end can land on it. The + // verdict answers for today and only ever labelled today correctly. + {...sheetDayContext(selectedDate, calendarDays)} goal={goal} predictionEnabled={calProfile?.predictionEnabled ?? false} rawChartMode={calProfile?.rawChartMode ?? false} diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 815b77df7..8d7c5411f 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -51,7 +51,7 @@ import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-share import { FieldInfo } from "./field-info"; import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog"; import { FLOW_HUE, PHASE_HUE } from "./phase-tokens"; -import type { CyclePhase, CycleGoal } from "./types"; +import type { CalendarDay, CyclePhase, CycleGoal } from "./types"; import { useCreateCustomSymptom, useCustomSymptoms, @@ -88,9 +88,9 @@ import type { * detailed form posts a single day-log (flow, symptoms, BBT, OPK, mucus, * intercourse + protection, free-text note). The write is optimistic-ish * (the mutation invalidates the cycle keys on success so the calendar + - * wheel repaint); the symptom chips reuse the mood-tag chip styling. + * wheel repaint); symptoms list one per line with their intensity beside them. * - * a11y: every chip is a real ` - - {activePeriod ? ( + {periodEndable ? ( + {custom ? ( + <> - ) : null} - {custom ? ( - ) : null} - - {active ? ( -
- {SEVERITY_LEVELS.map((lvl) => ( - - ))} -
+ ) : null} + {/* Always laid out, only shown while the symptom is selected: the + reserved width is what keeps the row still when it appears. */} +
+ {SEVERITY_LEVELS.map((lvl) => ( + + ))} +
); } /** - * The dashed ghost-chip in the symptom grid that opens a compact label + icon - * popover to mint a new custom symptom. Matches the chip grid (same rounded- - * full pill, same size) so it reads as part of the grid, not a foreign button. + * The dashed ghost row at the end of the custom symptoms that opens a compact + * label + icon popover to mint a new custom symptom. Same height and shape as + * a symptom row so it reads as part of the list, not a foreign button. */ function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) { const { t } = useTranslations(); @@ -1380,9 +1512,9 @@ function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) { diff --git a/src/components/cycle/types.ts b/src/components/cycle/types.ts index 983b9d5a3..8667b61b8 100644 --- a/src/components/cycle/types.ts +++ b/src/components/cycle/types.ts @@ -31,6 +31,13 @@ export interface CalendarDay { isPeriodLogged: boolean; /** Whether a logged cycle opens on this day. */ isCycleStart: boolean; + /** + * The 1-based day of the logged cycle this date belongs to, resolved by the + * server for THIS date (not today). Null when the record cannot say. + */ + cycleDay: number | null; + /** Whether the one-tap period end can land on this date. */ + periodEndable: boolean; flow: string | null; hasSymptoms: boolean; confidence: number; diff --git a/src/components/labs/__tests__/lab-form-keep-open.test.ts b/src/components/labs/__tests__/lab-form-keep-open.test.ts new file mode 100644 index 000000000..389df8ecc --- /dev/null +++ b/src/components/labs/__tests__/lab-form-keep-open.test.ts @@ -0,0 +1,44 @@ +/** + * "Save & add another" keeps the report and the marker, clears the reading + * (#1029). + * + * It used to clear the biomarker too. The picker then fell out of its + * controlled state and went on showing the marker it last held while the + * form's own value was empty, so the second save answered "Pick a biomarker + * first" under a visibly picked marker. Keeping the marker removes the reset + * that caused the disagreement; the picker itself is now always controlled, + * so an empty pick shows the placeholder instead of a stale marker. + */ +import { describe, it, expect } from "vitest"; + +import { nextEntryAfterKeepOpen, type LabEntryDraft } from "../lab-form"; + +const saved: LabEntryDraft = { + biomarkerId: "bm-vitamin-d", + value: "42", + valueText: "", + takenAt: "2026-09-01T08:30", + note: "fasting", + sourceRange: "30 - 100", + visitId: "visit-1", +}; + +describe("nextEntryAfterKeepOpen", () => { + it("keeps the picked biomarker so the next save does not ask for it again", () => { + expect(nextEntryAfterKeepOpen(saved).biomarkerId).toBe("bm-vitamin-d"); + }); + + it("keeps what the report shares: its draw date and its visit", () => { + const next = nextEntryAfterKeepOpen(saved); + expect(next.takenAt).toBe("2026-09-01T08:30"); + expect(next.visitId).toBe("visit-1"); + }); + + it("clears the reading itself and its printed window", () => { + const next = nextEntryAfterKeepOpen({ ...saved, valueText: "negative" }); + expect(next.value).toBe(""); + expect(next.valueText).toBe(""); + expect(next.note).toBe(""); + expect(next.sourceRange).toBe(""); + }); +}); diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index f22d1efe8..a27babb65 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -1,7 +1,7 @@ "use client"; import { useActiveRecordName } from "@/hooks/use-record-capabilities"; -import { useId, useMemo, useRef, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; @@ -57,6 +57,40 @@ function parseDecimal(raw: string): number | null { return Number.isFinite(n) ? n : null; } +/** The per-reading fields "Save & add another" decides to keep or clear. */ +export interface LabEntryDraft { + biomarkerId: string; + value: string; + valueText: string; + takenAt: string; + note: string; + sourceRange: string; + visitId: string | null; +} + +/** + * The form after "Save & add another": the reading is cleared, everything that + * describes the report it came from stays. The draw date and the visit are + * shared by every value on one report, and the biomarker stays because a run + * of entries is as often one marker over several dates as several markers on + * one date; re-picking it each time is what the button exists to avoid. + * + * Clearing the biomarker here used to drop the picker out of its controlled + * state. It kept showing the marker it last held while the form value was + * empty, so the next save refused with "Pick a biomarker first" under a + * visibly picked marker. + */ +export function nextEntryAfterKeepOpen(draft: LabEntryDraft): LabEntryDraft { + return { + ...draft, + value: "", + valueText: "", + note: "", + // The next reading has its own printed window. + sourceRange: "", + }; +} + interface LabFormProps { /** When set, the biomarker is pre-selected and locked (add-from-detail). */ lockedBiomarkerId?: string; @@ -157,7 +191,6 @@ export function LabForm({ const [defineFooterEl, setDefineFooterEl] = useState( null, ); - const biomarkerTriggerRef = useRef(null); const allMarkers = catalog?.biomarkers ?? []; const selected = allMarkers.find((m) => m.id === biomarkerId); @@ -256,30 +289,31 @@ export function LabForm({ : undefined, ); if (keepOpen) { - // A real lab report shares one blood-draw date across every - // analyte — clear the reading but deliberately KEEP `takenAt` so - // the next row doesn't need it re-set away from "now" again. - // The biomarker resets too (unless locked to one detail page) - // since the whole point is entering the NEXT analyte. - if (!lockedBiomarkerId) setBiomarkerId(""); - setValue(""); - setValueText(""); - setNote(""); - // The next analyte off the same report has its own printed window. - setSourceRange(""); + const next = nextEntryAfterKeepOpen({ + biomarkerId, + value, + valueText, + takenAt, + note, + sourceRange, + visitId, + }); + setBiomarkerId(next.biomarkerId); + setValue(next.value); + setValueText(next.valueText); + setTakenAt(next.takenAt); + setNote(next.note); + setSourceRange(next.sourceRange); + setVisitId(next.visitId); setError(null); onSavedKeepOpen?.(created); - // Return focus to wherever the next entry starts so the flow - // stays keyboard/screen-reader friendly across repeated saves. - if (!lockedBiomarkerId) { - biomarkerTriggerRef.current?.focus(); - } else { - document - .getElementById( - resultType === "numeric" ? "lab-value" : "lab-valueText", - ) - ?.focus(); - } + // The next entry starts at the value, so focus goes there and keeps + // the flow keyboard/screen-reader friendly across repeated saves. + document + .getElementById( + resultType === "numeric" ? "lab-value" : "lab-valueText", + ) + ?.focus(); } else { onSuccess?.(created); } @@ -355,16 +389,16 @@ export function LabForm({
+ {/* Always controlled: `""` shows the placeholder. Passing + `undefined` for an empty pick switched the picker to its own + internal state, which kept displaying the last marker while the + form held none. */}