diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a494e55a8..bce702fa5 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: @@ -6359,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: @@ -6374,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: @@ -6389,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: @@ -7951,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 @@ -8630,12 +8666,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: @@ -8652,8 +8691,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: @@ -8662,6 +8711,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: @@ -8671,7 +8727,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 @@ -8693,6 +8751,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: @@ -8701,6 +8775,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: @@ -13493,12 +13574,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: @@ -13523,8 +13608,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: @@ -15406,7 +15512,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. @@ -15638,14 +15748,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 @@ -15666,6 +15778,20 @@ paths: schema: $ref: "#/components/schemas/InboundExtractEnvelope" "401": *a1 + "403": &a33 + 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 @@ -15676,6 +15802,13 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + "503": &a34 + 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: @@ -15683,13 +15816,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 @@ -15731,8 +15864,10 @@ paths: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" "401": *a1 + "403": *a33 "422": *a4 "429": *a5 + "503": *a34 /api/documents/inbound/{id}/summary: post: tags: @@ -15743,7 +15878,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 @@ -15809,22 +15946,25 @@ paths: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" "401": *a1 + "403": *a33 "422": *a4 "429": *a5 + "503": *a34 /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 @@ -15866,8 +16006,10 @@ paths: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" "401": *a1 + "403": *a33 "422": *a4 "429": *a5 + "503": *a34 /api/documents/inbound/{id}/chat: post: tags: @@ -15880,10 +16022,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 @@ -15905,14 +16049,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": *a33 "422": *a4 "429": *a5 + "503": *a34 get: tags: - Documents @@ -15961,9 +16101,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 @@ -15973,8 +16115,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 + "403": *a33 "422": *a4 "429": *a5 + "503": *a34 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -16081,7 +16225,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a34 + "409": &a36 description: A custom metric with this name already exists for the caller. content: application/json: @@ -16110,7 +16254,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a9 - "404": &a33 + "404": &a35 description: Custom metric not found (or owned by another user). content: application/json: @@ -16144,8 +16288,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a33 - "409": *a34 + "404": *a35 + "409": *a36 "422": *a4 "429": *a5 delete: @@ -16168,7 +16312,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a33 + "404": *a35 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -16212,7 +16356,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a9 - "404": *a33 + "404": *a35 "422": *a4 "429": *a5 post: @@ -16241,7 +16385,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a33 + "404": *a35 "409": *a7 "422": *a4 "429": *a10 @@ -16277,7 +16421,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a35 + "404": &a37 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -16310,7 +16454,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a35 + "404": *a37 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -16719,7 +16863,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a36 + "403": &a38 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 @@ -16774,7 +16918,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a36 + "403": *a38 "422": *a4 "429": *a5 /api/nutrients/water: @@ -16803,7 +16947,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a36 + "403": *a38 "409": *a7 "422": *a4 "429": *a5 @@ -17305,26 +17449,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a37 + "400": &a39 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a38 + "413": &a40 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a39 + "415": &a41 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a40 + "422": &a42 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: @@ -17510,11 +17654,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a37 + "400": *a39 "401": *a1 - "413": *a38 - "415": *a39 - "422": *a40 + "413": *a40 + "415": *a41 + "422": *a42 "429": *a5 delete: tags: @@ -17657,11 +17801,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a37 + "400": *a39 "401": *a1 - "413": *a38 - "415": *a39 - "422": *a40 + "413": *a40 + "415": *a41 + "422": *a42 "429": *a5 delete: tags: @@ -17814,11 +17958,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a37 + "400": *a39 "401": *a1 - "413": *a38 - "415": *a39 - "422": *a40 + "413": *a40 + "415": *a41 + "422": *a42 "429": *a5 delete: tags: @@ -17894,21 +18038,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a41 + "422": &a43 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a42 + "429": &a44 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a43 + "502": &a45 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` @@ -17953,11 +18097,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a37 + "400": *a39 "401": *a1 - "413": *a38 - "415": *a39 - "422": *a40 + "413": *a40 + "415": *a41 + "422": *a42 "429": *a5 delete: tags: @@ -18033,9 +18177,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a41 - "429": *a42 - "502": *a43 + "422": *a43 + "429": *a44 + "502": *a45 /api/strava/credentials: get: tags: @@ -18071,11 +18215,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a37 + "400": *a39 "401": *a1 - "413": *a38 - "415": *a39 - "422": *a40 + "413": *a40 + "415": *a41 + "422": *a42 "429": *a5 delete: tags: @@ -18153,9 +18297,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a41 - "429": *a42 - "502": *a43 + "422": *a43 + "429": *a44 + "502": *a45 /api/integrations/status: get: tags: @@ -18191,7 +18335,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a42 + "429": *a44 /api/integrations/fitbit/test: post: tags: @@ -18209,9 +18353,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a41 - "429": *a42 - "502": *a43 + "422": *a43 + "429": *a44 + "502": *a45 /api/integrations/google-health/resume: post: tags: @@ -18228,7 +18372,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a42 + "429": *a44 /api/integrations/google-health/test: post: tags: @@ -18261,9 +18405,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a41 - "429": *a42 - "502": *a43 + "422": *a43 + "429": *a44 + "502": *a45 /api/integrations/whoop/resume: post: tags: @@ -18280,7 +18424,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a42 + "429": *a44 /api/integrations/whoop/test: post: tags: @@ -18297,9 +18441,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a41 - "429": *a42 - "502": *a43 + "422": *a43 + "429": *a44 + "502": *a45 /api/integrations/withings/resume: post: tags: @@ -18317,7 +18461,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a42 + "429": *a44 /api/integrations/withings/test: post: tags: @@ -18335,8 +18479,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a41 - "429": *a42 + "422": *a43 + "429": *a44 "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: @@ -18573,7 +18717,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a44 + "403": &a46 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 @@ -18598,7 +18742,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "422": *a4 "429": *a5 post: @@ -18693,7 +18837,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "404": description: No such invitation for this account. content: @@ -18737,7 +18881,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "404": description: No such grant on this account's record. content: @@ -18778,7 +18922,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "404": description: No such grant held by this account. content: @@ -19160,7 +19304,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "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 — @@ -19490,7 +19634,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a45 + "403": &a47 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. @@ -19523,7 +19667,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a45 + "403": *a47 "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. @@ -19550,7 +19694,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a45 + "403": *a47 "422": *a4 "429": *a5 post: @@ -19580,7 +19724,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a45 + "403": *a47 "413": description: Body exceeds 16 KiB. content: @@ -19623,7 +19767,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a45 + "403": *a47 "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. @@ -19681,7 +19825,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a46 + - &a48 name: family in: path required: true @@ -19725,7 +19869,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a46 + - *a48 requestBody: required: true content: @@ -20260,13 +20404,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a47 + "403": &a49 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a48 + "404": &a50 description: No stored copy with this id. content: application/json: @@ -20325,8 +20469,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a47 - "404": *a48 + "403": *a49 + "404": *a50 "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. @@ -30034,36 +30178,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 — @@ -59324,17 +59468,69 @@ 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." + AiCapabilityState: + type: object + properties: + available: + type: boolean + reason: + anyOf: + - $ref: "#/components/schemas/AiUnavailableReason" + - type: "null" + description: "`null` exactly when `available` is true." + onDeviceAllowed: + type: boolean + description: "Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, + `module_disabled`, `user_disabled` and `check_failed`: the operator's and the person's decisions hold on the + device too. True otherwise, since a missing server provider or a missing consent for server egress do not + concern a model that runs on the device. Resolved here so the client never decides which reasons apply off + the server." + required: + - available + - reason + - onDeviceAllowed + additionalProperties: false + description: "One AI capability, resolved for one record from every layer that can say no. Render from it; never + recompute it. Data never depends on it: measurements, scores, statistics and device records load whatever it + says." + AiUnavailableReason: + type: string + enum: + - check_failed + - operator_disabled + - not_permitted_for_record + - module_disabled + - user_disabled + - no_provider + - consent_required + description: "Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is + reported, because it names the layer that would have to change first. `check_failed` — an input could not be + read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own + switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not + admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off + for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI + opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. + `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider + chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does + not know treats the capability as unavailable." OcrExtractEnvelope: type: object properties: @@ -62338,12 +62534,15 @@ components: - local - external - type: "null" + ai: + $ref: "#/components/schemas/AiCapabilityState" required: - available - mode - reason - pdfSupported - egress + - ai additionalProperties: false DocumentUsageEnvelope: type: object @@ -62390,6 +62589,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: @@ -62628,12 +62830,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: @@ -67817,51 +68020,6 @@ components: model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. `medicationExtract` — turning a typed medication description into a schedule. - AiUnavailableReason: - type: string - enum: - - check_failed - - operator_disabled - - not_permitted_for_record - - module_disabled - - user_disabled - - no_provider - - consent_required - description: "Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is - reported, because it names the layer that would have to change first. `check_failed` — an input could not be - read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own - switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not - admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off - for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI - opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. - `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider - chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does - not know treats the capability as unavailable." - AiCapabilityState: - type: object - properties: - available: - type: boolean - reason: - anyOf: - - $ref: "#/components/schemas/AiUnavailableReason" - - type: "null" - description: "`null` exactly when `available` is true." - onDeviceAllowed: - type: boolean - description: "Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, - `module_disabled`, `user_disabled` and `check_failed`: the operator's and the person's decisions hold on the - device too. True otherwise, since a missing server provider or a missing consent for server egress do not - concern a model that runs on the device. Resolved here so the client never decides which reasons apply off - the server." - required: - - available - - reason - - onDeviceAllowed - additionalProperties: false - description: "One AI capability, resolved for one record from every layer that can say no. Render from it; never - recompute it. Data never depends on it: measurements, scores, statistics and device records load whatever it - says." AiProviderState: type: object properties: diff --git a/src/__tests__/ai-consent-enforcement-guard.test.ts b/src/__tests__/ai-consent-enforcement-guard.test.ts index 25b5173ab..874012310 100644 --- a/src/__tests__/ai-consent-enforcement-guard.test.ts +++ b/src/__tests__/ai-consent-enforcement-guard.test.ts @@ -64,7 +64,7 @@ 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", @@ -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.", "app/api/insights/comprehensive/route.ts": 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/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index da860c1da..c7fdf64a8 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -268,9 +268,12 @@ 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 model work, not data: it answers under the + // `medicationExtract` AI capability (`requireAiCapability`), which no + // module owns, because medications is a core domain for extraction. The + // capability carries the operator switch, the record's authority and the + // consent rule; there is no module gate for it to carry. + "src/app/api/medications/extract/route.ts", "src/app/api/medications/[id]/route.ts", "src/app/api/medications/[id]/api-endpoint/route.ts", "src/app/api/medications/[id]/cadence/route.ts", 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..c5e175fea 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,10 @@ 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 { 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 +52,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"; + +/** + * 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 +159,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 +171,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 +189,7 @@ export const PATCH = apiHandler(async (req: Request) => { details: { previous: previous?.documentsAutoAiRead ?? false, next, + consentMinted, }, }); 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/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/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/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/lib/ai/capabilities/__tests__/egress.test.ts b/src/lib/ai/capabilities/__tests__/egress.test.ts new file mode 100644 index 000000000..6b81d3d2f --- /dev/null +++ b/src/lib/ai/capabilities/__tests__/egress.test.ts @@ -0,0 +1,254 @@ +/** + * The capability re-check at the wire, and the gate option that leaves the + * provider questions to the route's own pick. + * + * The loader is replaced so each case hands the gate exactly the inputs it + * needs; the receipt read is replaced so the exact-pick consent answer can be + * steered independently of the presence-based one. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let auth: Record | undefined; +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ getAuth: () => auth, addWarning: vi.fn() }), +})); +vi.mock("@/lib/sharing/grants", async (importOriginal) => ({ + ...(await importOriginal()), + findActiveGrant: vi.fn(), +})); +vi.mock("../load", () => ({ loadAiCapabilityInputs: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { consentReceipt: { findFirst: vi.fn() } }, +})); + +import { prisma } from "@/lib/db"; +import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; +import type { ModuleAccessState } from "@/lib/sharing/module-disclosure"; + +import { aiEgressRefusal, assertAiEgress } from "../egress"; +import { requireAiCapability } from "../gate"; +import { loadAiCapabilityInputs } from "../load"; +import { AiUnavailableError } from "../refusal"; +import type { AiCapabilityInputs } from "../resolve"; + +function inputs( + overrides: Partial = {}, +): AiCapabilityInputs { + const moduleAccess = {} as Record; + for (const key of MODULE_KEYS) moduleAccess[key] = "enabled"; + return { + switches: { + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + documentAi: true, + }, + moduleAccess, + providerWorkAdmitted: true, + provider: { + entries: [{ providerType: "local", vision: true }], + localOcrEnabled: false, + managedBy: "local", + }, + activeConsentKinds: new Set(), + recordKind: "self", + ...overrides, + }; +} + +const LOAD = vi.mocked(loadAiCapabilityInputs); +const RECEIPT = vi.mocked(prisma.consentReceipt.findFirst); + +beforeEach(() => { + LOAD.mockReset(); + RECEIPT.mockReset(); + RECEIPT.mockResolvedValue(null); + auth = { user_id: "user-1" }; +}); + +describe("requireAiCapability with pickDecides", () => { + it("leaves no_provider to the route's pick", async () => { + LOAD.mockResolvedValue( + inputs({ + provider: { entries: [], localOcrEnabled: false, managedBy: null }, + }), + ); + await expect( + requireAiCapability("documentAi", { pickDecides: true }), + ).resolves.toMatchObject({ available: true }); + }); + + it("leaves consent_required to the route's pick", async () => { + LOAD.mockResolvedValue( + inputs({ + provider: { + entries: [{ providerType: "openai", vision: true }], + localOcrEnabled: false, + managedBy: "user", + }, + }), + ); + await expect( + requireAiCapability("documentAi", { pickDecides: true }), + ).resolves.toMatchObject({ available: true }); + }); + + it("still refuses a layer the pick cannot answer", async () => { + LOAD.mockResolvedValue( + inputs({ switches: { ...inputs().switches, documentAi: false } }), + ); + const error = await requireAiCapability("documentAi", { + pickDecides: true, + }).catch((e) => e); + expect(error).toBeInstanceOf(AiUnavailableError); + expect(error.meta.errorCode).toBe("assistant.disabled.documentAi"); + }); +}); + +describe("aiEgressRefusal inside a request", () => { + it("refuses when the operator switch is off, whatever the pick", async () => { + LOAD.mockResolvedValue( + inputs({ switches: { ...inputs().switches, documentAi: false } }), + ); + const refusal = await aiEgressRefusal("documentAi", "user-1", ["local"]); + expect(refusal).toBeInstanceOf(AiUnavailableError); + expect(refusal?.meta).toEqual({ + errorCode: "assistant.disabled.documentAi", + capability: "documentAi", + reason: "operator_disabled", + }); + }); + + it("refuses the master switch through every extraction capability", async () => { + LOAD.mockResolvedValue( + inputs({ switches: { ...inputs().switches, enabled: false } }), + ); + for (const key of ["documentAi", "labsOcr", "medicationExtract"] as const) { + const refusal = await aiEgressRefusal(key, "user-1", ["local"]); + expect(refusal?.reason, key).toBe("operator_disabled"); + } + }); + + it("asks for a receipt when the actual pick leaves the machine", async () => { + // Presence says a local model would read it; the route picked an external + // provider anyway (text mode reads the chain head). The pick decides. + LOAD.mockResolvedValue(inputs()); + const refusal = await aiEgressRefusal("documentAi", "user-1", ["openai"]); + expect(refusal?.meta).toEqual({ + errorCode: "consent.ai.required", + capability: "documentAi", + reason: "consent_required", + }); + expect(RECEIPT).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: "user-1", + revokedAt: null, + kind: { in: ["ai_extraction", "ai_full"] }, + }), + }), + ); + }); + + it("admits an external pick with an extraction receipt", async () => { + LOAD.mockResolvedValue(inputs()); + RECEIPT.mockResolvedValue({ id: "r1" } as never); + await expect( + aiEgressRefusal("documentAi", "user-1", ["openai"]), + ).resolves.toBeNull(); + }); + + it("needs no receipt for a local pick, even when presence says one is needed", async () => { + LOAD.mockResolvedValue( + inputs({ + provider: { + entries: [{ providerType: "openai", vision: true }], + localOcrEnabled: false, + managedBy: "user", + }, + }), + ); + await expect( + aiEgressRefusal("documentAi", "user-1", ["local"]), + ).resolves.toBeNull(); + expect(RECEIPT).not.toHaveBeenCalled(); + }); + + it("checks every entry of a cascading chain under the document rule", async () => { + LOAD.mockResolvedValue(inputs()); + const refusal = await aiEgressRefusal("medicationExtract", "user-1", [ + "local", + "admin-openai", + ]); + expect(refusal?.reason).toBe("consent_required"); + await expect( + aiEgressRefusal("medicationExtract", "user-1", ["local"]), + ).resolves.toBeNull(); + }); + + it("uses the self-snapshot rule for the Coach: a person's own key needs nothing", async () => { + LOAD.mockResolvedValue(inputs()); + await expect( + aiEgressRefusal("coach", "user-1", ["openai"]), + ).resolves.toBeNull(); + const refusal = await aiEgressRefusal("coach", "user-1", ["admin-openai"]); + expect(refusal?.reason).toBe("consent_required"); + expect(RECEIPT).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + kind: { in: ["ai_coach", "ai_full"] }, + }), + }), + ); + }); + + it("fails closed when the inputs cannot be loaded", async () => { + LOAD.mockResolvedValue(null); + const refusal = await aiEgressRefusal("labsOcr", "user-1", ["local"]); + expect(refusal?.reason).toBe("check_failed"); + expect(refusal?.status).toBe(503); + }); +}); + +describe("aiEgressRefusal outside a request (a job)", () => { + beforeEach(() => { + auth = undefined; + }); + + it("resolves for the record the job names", async () => { + LOAD.mockResolvedValue( + inputs({ switches: { ...inputs().switches, documentAi: false } }), + ); + const refusal = await aiEgressRefusal("documentAi", "user-9", ["local"]); + expect(refusal?.reason).toBe("operator_disabled"); + expect(LOAD).toHaveBeenCalledWith( + expect.objectContaining({ recordId: "user-9" }), + ); + }); + + it("admits the wire when every layer is open", async () => { + LOAD.mockResolvedValue(inputs()); + await expect( + aiEgressRefusal("documentAi", "user-9", ["local"]), + ).resolves.toBeNull(); + }); +}); + +describe("assertAiEgress", () => { + it("throws the refusal", async () => { + LOAD.mockResolvedValue( + inputs({ switches: { ...inputs().switches, documentAi: false } }), + ); + await expect( + assertAiEgress("medicationExtract", "user-1", ["local"]), + ).rejects.toBeInstanceOf(AiUnavailableError); + }); + + it("returns quietly when the wire is open", async () => { + LOAD.mockResolvedValue(inputs()); + await expect( + assertAiEgress("labsOcr", "user-1", ["local"]), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/ai/capabilities/egress.ts b/src/lib/ai/capabilities/egress.ts new file mode 100644 index 000000000..de276c578 --- /dev/null +++ b/src/lib/ai/capabilities/egress.ts @@ -0,0 +1,96 @@ +/** + * The capability re-check at the wire. + * + * `requireAiCapability` answers at the top of a route, before anything is + * picked. That is the refusal a client sees, but it is not the last word: a + * route may pick its provider in its own order, and a job enqueued before the + * operator turned a switch off runs long after any route answered. So every + * place that hands input to a model (the document provider pick, the labs + * scan, the medication extractor, the fenced document chat) asks again here, + * with the provider it is about to send to, immediately before it sends. + * + * Two questions, both about the actual wire: + * + * 1. Is the capability still open for this record? Inside a request this is + * the request's own resolution (memoised per request, so the answer + * matches the one the route gave); outside a request it is the job's + * resolution for the record it names. + * 2. Does sending to exactly these providers need a consent receipt, and is + * one active? Presence cannot answer this for a route that reads the + * chain in its own order, so the pick answers it here, from the same + * rule and the same receipt kinds the capability table declares. + * + * `no_provider` is never answered here: a caller that has a pick has a + * provider, and one without a pick refuses with its own typed code. + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; + +import { aiCapabilityForJob, requireAiCapability } from "./gate"; +import { AiUnavailableError } from "./refusal"; +import { consentNeededForProviders } from "./resolve"; +import { + AI_CAPABILITIES, + PICK_DECIDED_REASONS, + type AiCapabilityKey, +} from "./types"; + +/** Whether the record holds an active receipt of one of these kinds. */ +async function hasActiveReceipt( + recordId: string, + kinds: readonly string[], +): Promise { + const row = await prisma.consentReceipt.findFirst({ + where: { userId: recordId, revokedAt: null, kind: { in: [...kinds] } }, + select: { id: true }, + }); + return row !== null; +} + +/** + * Why input may not leave for these providers right now, or `null` when it + * may. Never throws for a refusal; a database error propagates, which is the + * closed direction for a caller about to send. + * + * @param recordId The record the input belongs to. Inside a request the + * request's own record is used, which is the record the route acts on. + * @param providerTypes The providers the input is about to reach: the one + * picked provider for a single-pick read, the whole chain for a cascade. + */ +export async function aiEgressRefusal( + key: AiCapabilityKey, + recordId: string, + providerTypes: readonly string[], +): Promise { + if (getEvent()?.getAuth()?.user_id) { + try { + await requireAiCapability(key, { pickDecides: true }); + } catch (error) { + if (error instanceof AiUnavailableError) return error; + throw error; + } + } else { + const state = await aiCapabilityForJob(recordId, key); + if (state.reason !== null && !PICK_DECIDED_REASONS.has(state.reason)) { + return new AiUnavailableError(key, state.reason); + } + } + + if ( + consentNeededForProviders(key, providerTypes) && + !(await hasActiveReceipt(recordId, AI_CAPABILITIES[key].consent.kinds)) + ) { + return new AiUnavailableError(key, "consent_required"); + } + return null; +} + +/** {@link aiEgressRefusal}, thrown. For routes, where `apiHandler` renders it. */ +export async function assertAiEgress( + key: AiCapabilityKey, + recordId: string, + providerTypes: readonly string[], +): Promise { + const refusal = await aiEgressRefusal(key, recordId, providerTypes); + if (refusal) throw refusal; +} diff --git a/src/lib/ai/capabilities/gate.ts b/src/lib/ai/capabilities/gate.ts index 254a4d794..76fc70463 100644 --- a/src/lib/ai/capabilities/gate.ts +++ b/src/lib/ai/capabilities/gate.ts @@ -40,7 +40,11 @@ import { providerWorkAuthorityForRecord } from "@/lib/sharing/provider-work-auth import { loadAiCapabilityInputs, type AiCapabilityScope } from "./load"; import { explainAiCapability, resolveAiCapability } from "./resolve"; import { AiUnavailableError, type NoProviderRefusal } from "./refusal"; -import type { AiCapabilityKey, AiCapabilityState } from "./types"; +import { + PICK_DECIDED_REASONS, + type AiCapabilityKey, + type AiCapabilityState, +} from "./types"; export interface AiGateOptions { /** @@ -50,6 +54,15 @@ export interface AiGateOptions { recordId?: string; /** The route's own `no_provider` refusal, where a client already reads it. */ noProvider?: NoProviderRefusal; + /** + * Leave `no_provider` and `consent_required` to the provider the route + * actually picks. The resolver answers both from presence, which is right + * for the published payload but can differ from a route that reads the + * chain in its own order (a text-mode document read takes the chain head, + * not the first vision entry). A route that sets this must run the pick + * through `assertAiEgress` / `aiEgressRefusal`, which answers both exactly. + */ + pickDecides?: boolean; } /** @@ -118,7 +131,10 @@ export async function requireAiCapability( } catch { finding = { reason: "check_failed", module: null }; } - if (finding.reason !== null) { + if ( + finding.reason !== null && + !(options.pickDecides && PICK_DECIDED_REASONS.has(finding.reason)) + ) { throw new AiUnavailableError( key, finding.reason, diff --git a/src/lib/ai/capabilities/resolve.ts b/src/lib/ai/capabilities/resolve.ts index b814ba86a..c661d78d3 100644 --- a/src/lib/ai/capabilities/resolve.ts +++ b/src/lib/ai/capabilities/resolve.ts @@ -223,6 +223,29 @@ function consentNeeded( ); } +/** + * Whether sending to exactly these providers needs a receipt under the + * capability's consent rule. The egress re-check asks it about the providers a + * route actually picked, so the answer at the wire comes from the same rule the + * published capability does. Pass the one picked provider for a single-pick + * read, or the whole chain for a cascade. + */ +export function consentNeededForProviders( + key: AiCapabilityKey, + providerTypes: readonly string[], +): boolean { + return consentNeeded(AI_CAPABILITIES[key], { + // Every entry named here is one the route will send to, so each counts as + // able to read the input. + entries: providerTypes.map((providerType) => ({ + providerType, + vision: true, + })), + localOcrEnabled: false, + managedBy: null, + }); +} + function consentReason( def: AiCapabilityDefinition, provider: ProviderPresence, diff --git a/src/lib/ai/capabilities/types.ts b/src/lib/ai/capabilities/types.ts index 049b7350f..13adfceca 100644 --- a/src/lib/ai/capabilities/types.ts +++ b/src/lib/ai/capabilities/types.ts @@ -84,6 +84,15 @@ export interface AiCapabilityState { export const ON_DEVICE_ALLOWED_REASONS: ReadonlySet = new Set(["no_provider", "consent_required"]); +/** + * The reasons a route's own provider pick answers exactly: whether there is a + * provider, and whether sending to it needs a receipt. The resolver answers + * both from presence, which is right for the published payload; a route that + * reads the chain in its own order asks again about the provider it picked. + */ +export const PICK_DECIDED_REASONS: ReadonlySet = + new Set(["no_provider", "consent_required"]); + /** * The operator's assistant switches below the master. Each maps to a cost or * egress profile an operator can reason about: the Coach, the daily briefing diff --git a/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts b/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts index 95dda88f1..facf860c6 100644 --- a/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts +++ b/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts @@ -2,9 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; /** * v1.29.x (S7) — the shared fenced turn pipeline (design §3, adversarial tests - * 12, 14, 16, 17, 18, 19). Pins: per-document egress consent fan-out (ALL clear - * or refuse, zero egress + no user turn persisted on partial consent), the SINGLE - * consent-checked provider (no cascade), numeric grounding over the LIVE + * 12, 14, 16, 17, 18, 19). Pins: the capability re-check at the wire (the + * document pick answers `documentAi`, each extra capability the entry point + * names is asked for the same provider; any refusal means zero egress and no + * user turn persisted), the SINGLE checked provider (no cascade), numeric grounding over the LIVE * attachment UNION, and the owner-scoped context loader (a corrupted/foreign join * row yields null → the turn refuses, never foreign text). */ @@ -21,8 +22,8 @@ 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), +vi.mock("@/lib/ai/capabilities/egress", () => ({ + assertAiEgress: vi.fn(), })); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-07-16"), @@ -49,7 +50,8 @@ import { } from "../fenced-chat"; import { loadDocumentChatText } from "@/lib/documents/content-index"; import { resolveDocumentTextProvider } from "@/lib/documents/provider-order"; -import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; +import { assertAiEgress } from "@/lib/ai/capabilities/egress"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { runStreamingRawCompletionWithFallback } from "@/lib/ai/provider-runner"; import { appendMessage } from "@/lib/ai/coach/persistence"; @@ -90,12 +92,13 @@ const baseArgs = { contractLocale: "en" as const, locale: "en" as const, signal: new AbortController().signal, + alsoRequires: [], }; beforeEach(() => { vi.clearAllMocks(); vi.mocked(resolveDocumentTextProvider).mockResolvedValue(PICK as never); - vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined); + vi.mocked(assertAiEgress).mockResolvedValue(undefined); vi.mocked(appendMessage).mockImplementation( async (p) => ({ id: p.role === "assistant" ? "a" : "u", ...p }) as never, ); @@ -106,36 +109,67 @@ beforeEach(() => { } as never); }); -describe("streamFencedReply — consent fan-out", () => { - it("checks egress consent ONCE PER attached document", async () => { - await streamFencedReply({ - ...baseArgs, - docs: [doc("a", "x"), doc("b", "y"), doc("c", "z")], - }); - expect(assertDocumentEgressConsent).toHaveBeenCalledTimes(3); - expect(assertDocumentEgressConsent).toHaveBeenCalledWith( - expect.objectContaining({ - userId: "user-1", - providerType: "anthropic", - surface: "insights", - }), - ); - }); - - it("refuses (throws) with ZERO egress + no user turn persisted when ANY document's consent fails", async () => { - vi.mocked(assertDocumentEgressConsent) - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error("ConsentRequiredError")); +describe("streamFencedReply — the capability at the wire", () => { + it("throws the document pick's refusal with ZERO egress and no user turn persisted", async () => { + const refusal = new AiUnavailableError("documentAi", "consent_required"); + vi.mocked(resolveDocumentTextProvider).mockResolvedValue({ + chain: [], + pick: null, + withheld: refusal, + } as never); await expect( streamFencedReply({ ...baseArgs, docs: [doc("a", "x"), doc("b", "y")] }), - ).rejects.toThrow(); + ).rejects.toBe(refusal); expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled(); expect(appendMessage).not.toHaveBeenCalled(); }); + + it("asks every extra capability about the picked provider", async () => { + await drain( + await streamFencedReply({ + ...baseArgs, + alsoRequires: ["coach"], + docs: [doc("a", "x"), doc("b", "y")], + }), + ); + expect(assertAiEgress).toHaveBeenCalledTimes(1); + expect(assertAiEgress).toHaveBeenCalledWith("coach", "user-1", [ + "anthropic", + ]); + }); + + it("refuses with ZERO egress when an extra capability closed", async () => { + vi.mocked(assertAiEgress).mockRejectedValueOnce( + new AiUnavailableError("coach", "operator_disabled"), + ); + await expect( + streamFencedReply({ + ...baseArgs, + alsoRequires: ["coach"], + docs: [doc("a", "x")], + }), + ).rejects.toBeInstanceOf(AiUnavailableError); + expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled(); + expect(appendMessage).not.toHaveBeenCalled(); + }); + + it("keeps a missing provider an SSE frame, not a refusal", async () => { + vi.mocked(resolveDocumentTextProvider).mockResolvedValue({ + chain: [], + pick: null, + withheld: null, + } as never); + const events = await drain( + await streamFencedReply({ ...baseArgs, docs: [doc("a", "x")] }), + ); + expect(events).toContainEqual( + expect.objectContaining({ code: "documents.chat.provider.none" }), + ); + }); }); describe("streamFencedReply — single provider, no cascade", () => { - it("passes exactly the ONE consent-checked provider entry to the runner", async () => { + it("passes exactly the ONE checked provider entry to the runner", async () => { await drain( await streamFencedReply({ ...baseArgs, docs: [doc("a", "x")] }), ); diff --git a/src/lib/documents/__tests__/index-document.test.ts b/src/lib/documents/__tests__/index-document.test.ts index c6de6c93b..c9fab4ae9 100644 --- a/src/lib/documents/__tests__/index-document.test.ts +++ b/src/lib/documents/__tests__/index-document.test.ts @@ -53,6 +53,7 @@ vi.mock("@/lib/ai/ai-budgets", () => ({ })); import { indexDocumentContent } from "../index-document"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { loadOwnedDocument, prepareVisionInput, @@ -259,6 +260,26 @@ describe("indexDocumentContent — local fallback path", () => { }); }); +describe("indexDocumentContent — the documentAi capability", () => { + it("indexes through the provider-free text layer when the pick was withheld", async () => { + // The operator turned document reading off after the upload enqueued this + // job: the pick comes back empty with the refusal beside it, no model is + // called, and the text layer still makes the document searchable. + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ + chain: [], + pick: null, + withheld: new AiUnavailableError("documentAi", "operator_disabled"), + } as never); + + const outcome = await indexDocumentContent("user-1", "doc-1"); + + expect(transcribeDocument).not.toHaveBeenCalled(); + expect(reserveBudget).not.toHaveBeenCalled(); + expect(localExtractText).toHaveBeenCalledTimes(1); + expect(outcome).toMatchObject({ indexed: true }); + }); +}); + describe("indexDocumentContent — fail-closed", () => { it("returns not-found for an unknown / non-owned document", async () => { vi.mocked(loadOwnedDocument).mockResolvedValue(null as never); diff --git a/src/lib/documents/__tests__/provider-order.test.ts b/src/lib/documents/__tests__/provider-order.test.ts index 29adca1bf..42c6c9c37 100644 --- a/src/lib/documents/__tests__/provider-order.test.ts +++ b/src/lib/documents/__tests__/provider-order.test.ts @@ -12,13 +12,27 @@ vi.mock("@/lib/labs/ocr-capability", () => ({ resolveVisionProvider: vi.fn(), resolveTextProvider: vi.fn(), })); +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + getAiCapability: vi.fn(), +})); import { documentEgressClass, reorderChainForDocumentClass, + requireDocumentTextProvider, + requireDocumentVisionProvider, + resolveDocumentAiCapability, + resolveDocumentTextProvider, resolveDocumentVisionProvider, } from "../provider-order"; +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { + resolveTextProvider, resolveVisionProvider, type VisionProviderPick, } from "@/lib/labs/ocr-capability"; @@ -103,7 +117,11 @@ describe("documentEgressClass", () => { }); describe("resolveDocumentVisionProvider", () => { - beforeEach(() => vi.mocked(resolveVisionProvider).mockReset()); + beforeEach(() => { + vi.mocked(resolveVisionProvider).mockReset(); + vi.mocked(aiEgressRefusal).mockReset(); + vi.mocked(aiEgressRefusal).mockResolvedValue(null); + }); it("invokes the shared resolver with the document reorder that demotes codex", async () => { vi.mocked(resolveVisionProvider).mockResolvedValue({ @@ -124,3 +142,152 @@ describe("resolveDocumentVisionProvider", () => { ).toEqual(["local", "codex"]); }); }); + +function visionPick(providerType: string): VisionProviderPick { + return { + chain: [entry(providerType)], + localOcrEnabled: false, + pick: { + entry: entry(providerType), + providerType: providerType as never, + pdfSupported: false, + }, + }; +} + +describe("the document pick re-checks documentAi at the wire", () => { + beforeEach(() => { + vi.mocked(resolveVisionProvider).mockReset(); + vi.mocked(resolveTextProvider).mockReset(); + vi.mocked(aiEgressRefusal).mockReset(); + vi.mocked(aiEgressRefusal).mockResolvedValue(null); + }); + + it("asks about the picked provider, under documentAi", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("openai")); + const result = await resolveDocumentVisionProvider("u1"); + expect(aiEgressRefusal).toHaveBeenCalledWith("documentAi", "u1", [ + "openai", + ]); + expect(result.pick?.providerType).toBe("openai"); + expect(result.withheld).toBeNull(); + }); + + it("withholds the pick when the capability closed after the route answered", async () => { + // A job enqueued before the operator turned the switch off: the pick is + // resolved, and the wire says no. The caller sees no provider to call. + const refusal = new AiUnavailableError("documentAi", "operator_disabled"); + vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local")); + vi.mocked(aiEgressRefusal).mockResolvedValue(refusal); + const result = await resolveDocumentVisionProvider("u1"); + expect(result.pick).toBeNull(); + expect(result.withheld).toBe(refusal); + }); + + it("withholds a text-mode pick the same way", async () => { + const refusal = new AiUnavailableError("documentAi", "consent_required"); + vi.mocked(resolveTextProvider).mockResolvedValue({ + chain: [entry("codex")], + pick: { entry: entry("codex"), providerType: "codex" }, + }); + vi.mocked(aiEgressRefusal).mockResolvedValue(refusal); + const result = await resolveDocumentTextProvider("u1"); + expect(aiEgressRefusal).toHaveBeenCalledWith("documentAi", "u1", ["codex"]); + expect(result.pick).toBeNull(); + expect(result.withheld).toBe(refusal); + }); + + it("does not ask when nothing was picked", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue({ + chain: [], + localOcrEnabled: false, + pick: null, + }); + const result = await resolveDocumentVisionProvider("u1"); + expect(aiEgressRefusal).not.toHaveBeenCalled(); + expect(result.withheld).toBeNull(); + }); + + it("the route form throws the refusal", async () => { + const refusal = new AiUnavailableError("documentAi", "operator_disabled"); + vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local")); + vi.mocked(aiEgressRefusal).mockResolvedValue(refusal); + await expect(requireDocumentVisionProvider("u1")).rejects.toBe(refusal); + }); + + it("the route form refuses a missing provider with the vault's own code", async () => { + vi.mocked(resolveTextProvider).mockResolvedValue({ chain: [], pick: null }); + const error = await requireDocumentTextProvider("u1").catch((e) => e); + expect(error).toBeInstanceOf(AiUnavailableError); + expect(error.status).toBe(422); + expect(error.meta).toEqual({ + errorCode: "documents.inbound.providerUnsupported", + capability: "documentAi", + reason: "no_provider", + }); + }); +}); + +describe("resolveDocumentAiCapability", () => { + beforeEach(() => { + vi.mocked(resolveVisionProvider).mockReset(); + vi.mocked(aiEgressRefusal).mockReset(); + vi.mocked(getAiCapability).mockReset(); + }); + + it("reports the capability beside the provider answer", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local")); + vi.mocked(getAiCapability).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + const dto = await resolveDocumentAiCapability("u1"); + expect(dto).toMatchObject({ + available: true, + mode: "vision", + egress: "local", + ai: { available: true, reason: null }, + }); + // A probe never runs the wire re-check: it sends nothing. + expect(aiEgressRefusal).not.toHaveBeenCalled(); + }); + + it("is unavailable when the operator turned document reading off", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local")); + vi.mocked(getAiCapability).mockResolvedValue({ + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + }); + const dto = await resolveDocumentAiCapability("u1"); + expect(dto).toEqual({ + available: false, + mode: null, + reason: null, + pdfSupported: false, + egress: null, + ai: { + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + }, + }); + }); + + it("stays available when only a consent receipt is missing, so the read can ask for it", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("openai")); + vi.mocked(getAiCapability).mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: true, + }); + const dto = await resolveDocumentAiCapability("u1"); + expect(dto).toMatchObject({ + available: true, + mode: "vision", + egress: "external", + ai: { reason: "consent_required" }, + }); + }); +}); diff --git a/src/lib/documents/fenced-chat.ts b/src/lib/documents/fenced-chat.ts index 9b8e63e04..1af3eb30a 100644 --- a/src/lib/documents/fenced-chat.ts +++ b/src/lib/documents/fenced-chat.ts @@ -19,9 +19,12 @@ * - NO TOOLS, NO SNAPSHOT — one completion per turn. * - PROMPT-INJECTION FENCING — every document fenced as DATA (`fenceDocument`), * per-doc header fields marker-scrubbed (`buildFencedChatSystemPrompt`). - * - PER-DOCUMENT EGRESS CONSENT — the single picked provider (no cascade) is - * consent-checked once per attached document; ALL must clear or the turn - * refuses (403) BEFORE any egress and BEFORE the user turn is persisted. + * - CAPABILITY AT THE WIRE — the single picked provider (no cascade) is + * re-checked against `documentAi` (the operator's switch, the module, the + * sharing grant, and a document consent receipt for a pick that leaves the + * machine) and against every other capability the entry point names (the + * Coach, for a turn inside a Coach conversation). Any refusal is a 403 + * BEFORE any egress and BEFORE the user turn is persisted. * - NUMERIC GROUNDING over the LIVE attachment union only — recomputed per turn * from the join rows, never history / snapshot. * - OWNER-SCOPED TEXT LOADER as a second ownership layer — a corrupted/foreign @@ -33,7 +36,8 @@ import { prisma } from "@/lib/db"; import { createSseStream } from "@/lib/sse/create-stream"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; +import { assertAiEgress } from "@/lib/ai/capabilities/egress"; +import type { AiCapabilityKey } from "@/lib/ai/capabilities/types"; import { AllProvidersFailedError, runStreamingRawCompletionWithFallback, @@ -260,12 +264,19 @@ export interface StreamFencedReplyArgs { /** i18n locale for outbound-guard fallbacks. */ locale: Locale; signal: AbortSignal; + /** + * The capabilities this turn is answered under, beside `documentAi`, which + * every fenced turn needs because it reads documents. The Coach's fenced + * chat adds `coach`; the vault's single-document chat adds nothing. + */ + alsoRequires: readonly AiCapabilityKey[]; } /** * Run ONE fenced turn against a resolved conversation and its live documents, and - * return the SSE response. May throw `ConsentRequiredError` (403, rendered as - * JSON by the api-handler) BEFORE any egress — the consent fan-out runs first. + * return the SSE response. May throw `AiUnavailableError` (403, rendered as + * JSON by the api-handler) BEFORE any egress — the capability re-check runs + * first. */ export async function streamFencedReply( args: StreamFencedReplyArgs, @@ -274,29 +285,28 @@ export async function streamFencedReply( args; // ── Provider resolution: document order (local-first, codex last), SINGLE - // pick, NO cascade — so the exact egress equals the consent-checked target. ── - const { pick } = await resolveDocumentTextProvider(userId); + // pick, NO cascade — so the exact egress equals the checked target. The pick + // re-checks `documentAi` for that provider, consent receipt included; a + // refusal is thrown (403) before any egress. ── + const { pick, withheld } = await resolveDocumentTextProvider(userId); + if (withheld) throw withheld; if (!pick) { annotate({ action: { name: "documents.chat.noProvider" } }); return streamFencedError("documents.chat.provider.none"); } - // ── Per-document egress consent fan-out. Structured as a loop over the live - // attachments (annotated per doc id) so a future per-document consent - // dimension slots into this seam. ALL must clear or the first throws - // ConsentRequiredError (403) — zero egress on partial consent, BEFORE budget - // and BEFORE the user turn is persisted. ── - for (const doc of docs) { - annotate({ - action: { name: "documents.chat.consent_check" }, - meta: { documentId: doc.documentId, provider: pick.providerType }, - }); - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - surface: "insights", - }); + // ── Every other capability the entry point answers under, for the same + // provider, BEFORE budget and BEFORE the user turn is persisted. ── + for (const key of args.alsoRequires) { + await assertAiEgress(key, userId, [pick.providerType]); } + annotate({ + action: { name: "documents.chat.consent_check" }, + meta: { + documentIds: docs.map((doc) => doc.documentId), + provider: pick.providerType, + }, + }); // Persist the user's turn first so it's on disk regardless of the outcome. await appendMessage({ conversationId, role: "user", content: message }); diff --git a/src/lib/documents/index-document.ts b/src/lib/documents/index-document.ts index e512aee6e..a35e24ca7 100644 --- a/src/lib/documents/index-document.ts +++ b/src/lib/documents/index-document.ts @@ -3,8 +3,13 @@ * both the per-document auto-index-on-upload job and the corpus backfill. * * Provider resolution follows the DOCUMENT order (local-first, codex last — - * `resolveDocumentVisionProvider`), NOT the cost-first app-wide chain, and the - * external egress is governed by the per-user `documentsAutoAiRead` opt-in: + * `resolveDocumentVisionProvider`), NOT the cost-first app-wide chain. That + * pick re-checks the `documentAi` capability for the provider it picked: with + * the operator's switch off, the module off, or (for a pick that leaves the + * machine) no extraction consent receipt, there is no pick and the tree goes + * straight to the provider-free text-layer path below, so search keeps working + * with AI off. The external egress is further governed by the per-user + * `documentsAutoAiRead` opt-in: * * 1. LOCAL PROVIDER (a self-hosted vision model) — never egresses, so it runs * whenever it is the document-order pick, toggle-independent. @@ -149,7 +154,15 @@ export interface ResolvedIndexProvider { export async function resolveIndexProvider( userId: string, ): Promise { - const { chain, pick } = await resolveDocumentVisionProvider(userId); + // A pick the `documentAi` capability refused comes back null, which lands on + // the local text-layer path exactly like having no provider at all. + const { chain, pick, withheld } = await resolveDocumentVisionProvider(userId); + if (withheld) { + annotate({ + action: { name: "documents.contentIndex.providerWithheld" }, + meta: { reason: withheld.reason }, + }); + } let consentOk = false; if (pick) { if (!isExternalDocumentEgress(pick.providerType)) { diff --git a/src/lib/documents/provider-order.ts b/src/lib/documents/provider-order.ts index 5027c490e..97f0e2921 100644 --- a/src/lib/documents/provider-order.ts +++ b/src/lib/documents/provider-order.ts @@ -19,12 +19,28 @@ * consented opt-in. * * The reorder only decides WHICH configured provider is preferred; it never - * invents a provider. Egress consent (`assertDocumentEgressConsent`) and the - * per-egress vendor-blind UI notice sit on top of this order — the reorder - * keeps codex from being the silent default, the consent gate keeps any - * external egress from happening without an active receipt. + * invents a provider. The per-egress vendor-blind UI notice sits on top of + * this order — the reorder keeps codex from being the silent default. + * + * The pick is also the document class's wire: every read of a stored document + * resolves its provider here, the vault routes and the background jobs alike, + * so this is where the `documentAi` capability is asked again, about the + * provider actually picked, immediately before anything is sent. A switch the + * operator turned off after a job was enqueued stops the job here, and an + * external pick without an extraction consent receipt never reaches its + * provider. The document class is the `documentAi` capability; there is no + * other key a caller could pass, so the check is built in rather than taken + * as an argument. */ import { isExternalDocumentEgress } from "@/lib/ai/consent-guard"; +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types"; +import { + AiUnavailableError, + type NoProviderRefusal, +} from "@/lib/ai/capabilities/refusal"; +import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; import { documentProviderRank } from "@/lib/documents/provider-rank"; import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf"; import { @@ -38,6 +54,15 @@ import type { DocumentEgressClass, } from "@/lib/validations/inbound-documents"; +/** + * How the vault refuses a read with no provider that can serve it. The code + * predates the capability envelope and a client already branches on it. + */ +export const DOCUMENT_NO_PROVIDER: NoProviderRefusal = { + errorCode: "documents.inbound.providerUnsupported", + status: 422, +}; + /** * Reorder a resolved chain for the document class: stable sort by * `documentProviderRank`, so within a rank tier the user's own chain order is @@ -53,27 +78,92 @@ export const reorderChainForDocumentClass: ChainReorder = (chain) => { }; /** - * Resolve the vision provider for a DOCUMENT read — local-first, codex last. - * Same shape as `resolveVisionProvider`; the returned `chain` is already in - * document order and the `pick` is the first vision-capable entry in it. + * A document pick after the wire re-check. `pick` is null when nothing can + * serve the read OR when the capability refused it; `withheld` says which. + * A job reads `pick` and skips; a route throws `withheld` so the person sees + * the real reason. */ -export function resolveDocumentVisionProvider( +export type DocumentProviderPick = T & { + withheld: AiUnavailableError | null; +}; + +async function atTheWire( userId: string, -): Promise { - return resolveVisionProvider(userId, { - reorder: reorderChainForDocumentClass, - }); + resolved: T, +): Promise> { + if (!resolved.pick) return { ...resolved, withheld: null }; + const withheld = await aiEgressRefusal("documentAi", userId, [ + resolved.pick.providerType, + ]); + return withheld + ? { ...resolved, pick: null, withheld } + : { ...resolved, withheld: null }; } /** - * Resolve the text-mode (browser-OCR) structuring provider for a DOCUMENT — - * local-first, codex last. The `pick` is the first entry of the reordered - * chain. + * Resolve the vision provider for a DOCUMENT read — local-first, codex last. + * Same shape as `resolveVisionProvider`; the returned `chain` is already in + * document order and the `pick` is the first vision-capable entry in it, or + * null when `documentAi` refuses the wire. */ -export function resolveDocumentTextProvider(userId: string) { - return resolveTextProvider(userId, { - reorder: reorderChainForDocumentClass, - }); +export async function resolveDocumentVisionProvider( + userId: string, +): Promise> { + return atTheWire( + userId, + await resolveVisionProvider(userId, { + reorder: reorderChainForDocumentClass, + }), + ); +} + +/** + * Resolve the text-mode structuring provider for a DOCUMENT — local-first, + * codex last. The `pick` is the first entry of the reordered chain, or null + * when `documentAi` refuses the wire. + */ +export async function resolveDocumentTextProvider(userId: string): Promise< + DocumentProviderPick<{ + chain: ProviderChainResolved[]; + pick: { entry: ProviderChainResolved; providerType: string } | null; + }> +> { + return atTheWire( + userId, + await resolveTextProvider(userId, { + reorder: reorderChainForDocumentClass, + }), + ); +} + +function required(result: { + pick: T | null; + withheld: AiUnavailableError | null; +}): T { + if (result.withheld) throw result.withheld; + if (!result.pick) { + throw new AiUnavailableError( + "documentAi", + "no_provider", + null, + DOCUMENT_NO_PROVIDER, + ); + } + return result.pick; +} + +/** + * The route form of {@link resolveDocumentVisionProvider}: the pick, or the + * refusal thrown for `apiHandler` to render (`documents.inbound.providerUnsupported` + * when nothing can read the document, the capability envelope otherwise). + */ +export async function requireDocumentVisionProvider(userId: string) { + return required(await resolveDocumentVisionProvider(userId)); +} + +/** The route form of {@link resolveDocumentTextProvider}. */ +export async function requireDocumentTextProvider(userId: string) { + return required(await resolveDocumentTextProvider(userId)); } /** @@ -89,12 +179,31 @@ export function documentEgressClass(providerType: string): DocumentEgressClass { * Resolve the document AI capability for the vault UI. The `mode` / * `pdfSupported` / `egress` reflect the DOCUMENT provider order (local-first), * so the affordance the UI offers matches exactly what the document routes do. + * + * `ai` is the `documentAi` capability for the request's record. A read the + * operator, the record's modules or the sharing grant close is not offered + * (`available: false`, no mode). A missing consent receipt keeps the read + * offered: the read itself is where the person is asked for the receipt, and + * hiding the button would leave them no way to give it. */ export async function resolveDocumentAiCapability( userId: string, ): Promise { - const { chain, pick, localOcrEnabled } = - await resolveDocumentVisionProvider(userId); + const [{ chain, pick, localOcrEnabled }, ai] = await Promise.all([ + resolveVisionProvider(userId, { reorder: reorderChainForDocumentClass }), + getAiCapability("documentAi"), + ]); + + if (ai.reason !== null && !PICK_DECIDED_REASONS.has(ai.reason)) { + return { + available: false, + mode: null, + reason: null, + pdfSupported: false, + egress: null, + ai, + }; + } // A vision-capable provider is available — the read runs directly over the // stored original. Egress follows the picked provider. PDFs are readable @@ -108,6 +217,7 @@ export async function resolveDocumentAiCapability( reason: null, pdfSupported: pick.pdfSupported || RASTERIZATION_AVAILABLE, egress: documentEgressClass(pick.providerType), + ai, }; } @@ -119,6 +229,7 @@ export async function resolveDocumentAiCapability( reason: "no-provider", pdfSupported: false, egress: null, + ai, }; } @@ -131,6 +242,7 @@ export async function resolveDocumentAiCapability( reason: null, pdfSupported: false, egress: documentEgressClass(chain[0]!.providerType), + ai, }; } @@ -141,5 +253,6 @@ export async function resolveDocumentAiCapability( reason: "enable-local-ocr", pdfSupported: false, egress: null, + ai, }; } diff --git a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts index fbb554f3b..c24743a57 100644 --- a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts +++ b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts @@ -31,9 +31,10 @@ * matcher that stopped matching cannot agree with an empty world. * * Mutation checks: adding `await requireAssistantSurface("coach")` to any - * route outside the frozen list turns guard 3 red by file name; removing the - * `documentAi` entry from `AWAITING_READER` turns guard 2 red; putting - * `assistantCorrelationsEnabled` back on the schema turns guard 1 red. + * route outside the frozen list turns guard 3 red by file name; deleting the + * `requireAiCapability("documentAi"` calls from the document routes (the only + * reader of that switch's capabilities in the route tree) turns guard 2 red; + * putting `assistantCorrelationsEnabled` back on the schema turns guard 1 red. */ import { readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; @@ -99,13 +100,7 @@ const PLUMBING = [ * Switches with no reader yet, and why that is correct for now. An entry is * a claim with an expiry: the test below fails as soon as a reader appears. */ -const AWAITING_READER: Record = { - documentAi: - "The capabilities it covers (documentAi, labsOcr, medicationExtract) are " + - "resolved and published, but the document, lab-scan and medication " + - "extraction routes move onto requireAiCapability in the same release; " + - "until then the master switch is what stops them.", -}; +const AWAITING_READER: Record = {}; /** * Every file that still calls the retired gate. Only ever shrinks. @@ -132,7 +127,6 @@ const RETIRED_GATE_CALLERS = [ "src/app/api/insights/pregenerate/route.ts", "src/app/api/insights/pulse-status/route.ts", "src/app/api/insights/weight-status/route.ts", - "src/app/api/medications/extract/route.ts", "src/app/coach/page.tsx", ]; diff --git a/src/lib/labs/__tests__/ocr-capability.test.ts b/src/lib/labs/__tests__/ocr-capability.test.ts new file mode 100644 index 000000000..3945f39a9 --- /dev/null +++ b/src/lib/labs/__tests__/ocr-capability.test.ts @@ -0,0 +1,163 @@ +/** + * The labs scan's wire and its probe. + * + * `requireLabsOcrProvider` is where a lab report meets a provider: it picks + * the provider for the scan mode and asks the `labsOcr` capability again about + * exactly that provider. `resolveOcrCapability` is the probe the Labs page + * reads to decide whether to offer a scan at all. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + appSettings: { findUnique: vi.fn() }, + }, +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: vi.fn(), + resolveProvider: vi.fn(), +})); +vi.mock("@/lib/ai/codex-client", () => ({ + resolveCodexVisionSlug: vi.fn(() => "gpt-5"), +})); +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + getAiCapability: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/documents/rasterize-pdf", () => ({ + RASTERIZATION_AVAILABLE: true, +})); + +import { prisma } from "@/lib/db"; +import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; + +import { + requireLabsOcrProvider, + resolveOcrCapability, +} from "../ocr-capability"; + +function entry(providerType: string) { + return { providerType, instance: { type: providerType } } as never; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiModel: "claude-sonnet-4-5", + labsLocalOcrEnabled: true, + } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + adminAiModel: null, + } as never); + vi.mocked(resolveProvider).mockResolvedValue({ type: "none" } as never); + vi.mocked(aiEgressRefusal).mockResolvedValue(null); + vi.mocked(getAiCapability).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); +}); + +describe("requireLabsOcrProvider", () => { + it("re-checks labsOcr for the vision pick", async () => { + vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]); + const pick = await requireLabsOcrProvider("u1", "vision"); + expect(pick.providerType).toBe("anthropic"); + expect(aiEgressRefusal).toHaveBeenCalledWith("labsOcr", "u1", [ + "anthropic", + ]); + }); + + it("re-checks labsOcr for the text-mode chain head, not the first vision entry", async () => { + vi.mocked(resolveProviderChain).mockResolvedValue([ + entry("codex"), + entry("local"), + ]); + const pick = await requireLabsOcrProvider("u1", "text"); + expect(pick.providerType).toBe("codex"); + expect(aiEgressRefusal).toHaveBeenCalledWith("labsOcr", "u1", ["codex"]); + }); + + it("throws the wire's refusal and hands back no provider", async () => { + vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]); + const refusal = new AiUnavailableError("labsOcr", "operator_disabled"); + vi.mocked(aiEgressRefusal).mockResolvedValue(refusal); + await expect(requireLabsOcrProvider("u1", "vision")).rejects.toBe(refusal); + }); + + it("keeps the scan's own code when nothing can read the report", async () => { + vi.mocked(resolveProviderChain).mockResolvedValue([]); + const error = await requireLabsOcrProvider("u1", "vision").catch((e) => e); + expect(error).toBeInstanceOf(AiUnavailableError); + expect(error.status).toBe(422); + expect(error.meta).toEqual({ + errorCode: "labs.ocr.providerUnsupported", + capability: "labsOcr", + reason: "no_provider", + }); + expect(aiEgressRefusal).not.toHaveBeenCalled(); + }); +}); + +describe("resolveOcrCapability", () => { + it("offers the scan and reports the capability beside it", async () => { + vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]); + const dto = await resolveOcrCapability("u1"); + expect(dto).toMatchObject({ + available: true, + mode: "vision", + ai: { available: true }, + }); + expect(getAiCapability).toHaveBeenCalledWith("labsOcr"); + }); + + it("offers nothing when the operator turned document reading off", async () => { + vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]); + vi.mocked(getAiCapability).mockResolvedValue({ + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + }); + await expect(resolveOcrCapability("u1")).resolves.toEqual({ + available: false, + mode: null, + reason: null, + pdfSupported: false, + ai: { + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + }, + }); + }); + + it("offers nothing with the labs module off", async () => { + vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]); + vi.mocked(getAiCapability).mockResolvedValue({ + available: false, + reason: "module_disabled", + onDeviceAllowed: false, + }); + const dto = await resolveOcrCapability("u1"); + expect(dto.available).toBe(false); + expect(dto.mode).toBeNull(); + }); + + it("keeps the scan offered when only a consent receipt is missing", async () => { + vi.mocked(resolveProviderChain).mockResolvedValue([entry("anthropic")]); + vi.mocked(getAiCapability).mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: true, + }); + const dto = await resolveOcrCapability("u1"); + expect(dto).toMatchObject({ available: true, mode: "vision" }); + }); +}); diff --git a/src/lib/labs/ocr-capability.ts b/src/lib/labs/ocr-capability.ts index c0a22652e..b2df0d8ef 100644 --- a/src/lib/labs/ocr-capability.ts +++ b/src/lib/labs/ocr-capability.ts @@ -9,11 +9,19 @@ * one place) and pair each surviving entry with the model that drives its * vision allowlist. */ +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; +import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { + AiUnavailableError, + type NoProviderRefusal, +} from "@/lib/ai/capabilities/refusal"; +import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; import { resolveCodexVisionSlug } from "@/lib/ai/codex-client"; import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf"; import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; import { supportsPdfForProvider, supportsVisionForConfig, @@ -167,20 +175,91 @@ export async function resolveTextProvider( }; } +/** + * How a lab scan refuses with no provider that can serve it. The code predates + * the capability envelope and a client already branches on it. + */ +export const LABS_OCR_NO_PROVIDER: NoProviderRefusal = { + errorCode: "labs.ocr.providerUnsupported", + status: 422, +}; + +/** + * The labs scan's wire. Resolve the provider for one scan mode and ask the + * `labsOcr` capability again about exactly that provider, immediately before + * the report is sent: the operator's switch, the labs module, the sharing + * grant, and a document-class consent receipt for any pick that leaves the + * machine (a lab report is a document). Throws the refusal for `apiHandler` + * to render; a missing provider keeps its `labs.ocr.providerUnsupported` code. + * + * Vision mode picks the first vision-capable entry in chain order; text mode + * (the person's in-browser OCR) takes the chain head, which is why the pick, + * not the published capability, answers the consent question. + */ +export async function requireLabsOcrProvider( + userId: string, + mode: "vision", +): Promise>; +export async function requireLabsOcrProvider( + userId: string, + mode: "text", +): Promise<{ entry: ProviderChainResolved; providerType: string }>; +export async function requireLabsOcrProvider( + userId: string, + mode: "vision" | "text", +) { + const { chain, pick } = + mode === "vision" + ? await resolveVisionProvider(userId) + : await resolveTextProvider(userId); + if (!pick) { + annotate({ + action: { name: "labs.ocr.providerUnsupported" }, + meta: { + chainLength: chain.length, + ...(mode === "text" ? { mode } : {}), + }, + }); + throw new AiUnavailableError( + "labsOcr", + "no_provider", + null, + LABS_OCR_NO_PROVIDER, + ); + } + const refusal = await aiEgressRefusal("labsOcr", userId, [pick.providerType]); + if (refusal) throw refusal; + return pick; +} + /** * The cheap capability DTO for the probe endpoint: whether scanning is * available + why not + whether PDFs are accepted. Built from the same * resolution the extract route uses, so the UI never shows an entry the * extract route would 422. + * + * `ai` is the `labsOcr` capability for the request's record. A scan the + * operator, the labs module or the sharing grant close is not offered + * (`available: false`, no mode). A missing consent receipt keeps the scan + * offered: the scan is where the person is asked for the receipt. */ export async function resolveOcrCapability( userId: string, - options?: ResolveProviderOptions, ): Promise { - const { chain, pick, localOcrEnabled } = await resolveVisionProvider( - userId, - options, - ); + const [{ chain, pick, localOcrEnabled }, ai] = await Promise.all([ + resolveVisionProvider(userId), + getAiCapability("labsOcr"), + ]); + + if (ai.reason !== null && !PICK_DECIDED_REASONS.has(ai.reason)) { + return { + available: false, + mode: null, + reason: null, + pdfSupported: false, + ai, + }; + } // Prefer the native vision path whenever it is available — it is more // accurate and the client does not download the OCR WASM. PDFs are readable @@ -195,6 +274,7 @@ export async function resolveOcrCapability( mode: "vision", reason: null, pdfSupported: pick.pdfSupported || RASTERIZATION_AVAILABLE, + ai, }; } @@ -206,6 +286,7 @@ export async function resolveOcrCapability( mode: null, reason: "no-provider", pdfSupported: false, + ai, }; } @@ -219,6 +300,7 @@ export async function resolveOcrCapability( mode: "text", reason: null, pdfSupported: false, + ai, }; } @@ -229,5 +311,6 @@ export async function resolveOcrCapability( mode: null, reason: "enable-local-ocr", pdfSupported: false, + ai, }; } diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 3e4462c4d..daef007e4 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -38,7 +38,12 @@ */ export const ERROR_CODE_CATALOGUE: Readonly> = { - ai: ["ai.provider.none", "ai.record.notPermitted", "ai.unavailable"], + ai: [ + "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"], @@ -62,6 +67,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = ], backup: ["backup.payload.undecryptable", "backup.section.missing"], coach: [ + "coach.budget.exceeded", "coach.conversation.invalidTitle", "coach.conversation.notFound", "coach.fenced.attachmentConflict", diff --git a/src/lib/openapi/routes/ai-extraction-refusals.ts b/src/lib/openapi/routes/ai-extraction-refusals.ts new file mode 100644 index 000000000..fecf28243 --- /dev/null +++ b/src/lib/openapi/routes/ai-extraction-refusals.ts @@ -0,0 +1,84 @@ +/** + * The refusals the extraction group answers with: reading a stored document, + * scanning a lab report, and turning typed medication text into a schedule. + * + * Three capabilities under one operator switch ("Reading documents", + * `documentAi`), one consent rule (any provider that leaves the machine needs + * an `ai_extraction` or `ai_full` receipt), and one envelope. Written once so + * the vault, the labs scan and the medication extractor describe the same + * refusal in the same words. + */ +import type { AiCapabilityKey } from "@/lib/ai/capabilities/types"; + +import { errorEnvelope } from "./shared"; + +const MODULE_BY_CAPABILITY: Partial> = { + documentAi: "inboundDocuments", + labsOcr: "labs", +}; + +/** + * The 403 sentence for one extraction capability (plus any capability the + * route also answers under, such as `coach` for a fenced Coach turn). + */ +export function aiExtractionRefusalDescription( + capability: AiCapabilityKey, + ...alsoUnder: AiCapabilityKey[] +): string { + const owningModule = MODULE_BY_CAPABILITY[capability]; + const also = + alsoUnder.length === 0 + ? "" + : ` The route also answers under ${alsoUnder.map((key) => `\`${key}\``).join(", ")}, 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).`; + return ( + `AI refusal: the \`${capability}\` 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). " + + (owningModule + ? `\`module.disabled\` with \`meta.module = "${owningModule}"\` — 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." + + also + ); +} + +/** The 503 an extraction route answers when the capability could not be resolved. */ +export const AI_EXTRACTION_UNAVAILABLE_DESCRIPTION = + "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later."; + +type AiExtractionRefusals = { + "403": { + description: string; + content: { "application/json": { schema: typeof errorEnvelope } }; + }; + "503": { + description: string; + content: { "application/json": { schema: typeof errorEnvelope } }; + }; +}; + +// Memoised so identical responses are one object: the YAML emitter aliases by +// identity, and a fresh object per operation would print the paragraph again. +const cache = new Map(); + +/** `{ "403", "503" }` for an extraction route. Spread before `stdResponses`. */ +export function aiExtractionRefusals( + capability: AiCapabilityKey, + ...alsoUnder: AiCapabilityKey[] +): AiExtractionRefusals { + const key = [capability, ...alsoUnder].join(","); + const cached = cache.get(key); + if (cached) return cached; + const refusals: AiExtractionRefusals = { + "403": { + description: aiExtractionRefusalDescription(capability, ...alsoUnder), + content: { "application/json": { schema: errorEnvelope } }, + }, + "503": { + description: AI_EXTRACTION_UNAVAILABLE_DESCRIPTION, + content: { "application/json": { schema: errorEnvelope } }, + }, + }; + cache.set(key, refusals); + return refusals; +} diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index d870e1385..30e2cbab9 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -58,6 +58,11 @@ import { recordRefusal, stdResponses, } from "./shared"; +import { + AI_EXTRACTION_UNAVAILABLE_DESCRIPTION, + aiExtractionRefusalDescription, + aiExtractionRefusals, +} from "./ai-extraction-refusals"; // The assigning form, deliberately: `schema.meta({...})` as a bare statement // returns a clone and registers nothing, so the component id would never @@ -936,7 +941,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Send a FENCED multi-document coach turn (streaming reply)", description: - "v1.29.x (S7) — sends a user turn and streams a grounded prose reply about the documents attached to a 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.", + "v1.29.x (S7) — sends a user turn and streams a grounded prose reply about the documents attached to a 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); 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: { @@ -959,7 +964,12 @@ export const coachPaths: NonNullable = { }, "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 (`coach.fenced.attachmentLimit`), or `attachmentIds` sent with a `conversationId` (`coach.fenced.attachmentConflict`).", + aiExtractionRefusalDescription("documentAi", "coach") + + " 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: { "application/json": { schema: errorEnvelope } }, + }, + "503": { + description: AI_EXTRACTION_UNAVAILABLE_DESCRIPTION, content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, @@ -971,7 +981,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "Attach a document to a coach conversation", description: - "v1.29.x (S7) — attaches one already-stored, content-indexed document to an existing conversation and sets 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.", + "v1.29.x (S7) — attaches one already-stored, content-indexed document to an existing conversation and sets 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`; 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", @@ -1003,6 +1013,7 @@ export const coachPaths: NonNullable = { description: "Conversation or document not found / not owned.", content: { "application/json": { schema: errorEnvelope } }, }, + ...aiExtractionRefusals("documentAi", "coach"), ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 95d5d20d8..c421df1db 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -28,6 +28,8 @@ import { INBOUND_DOCUMENT_STATUSES, } from "@/lib/validations/inbound-documents"; +import { aiExtractionRefusals } from "./ai-extraction-refusals"; +import { aiCapabilityState } from "./profile"; import { dataEnvelope, errorEnvelope, @@ -208,7 +210,7 @@ const inboundDocumentDetail = inboundDocument .meta({ id: "InboundDocumentDetail", description: - 'A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of 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.', + 'A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of 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, 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.', }); const listResponse = z @@ -440,7 +442,7 @@ export const inboundDocumentPaths: NonNullable = { tags: ["Documents"], summary: "Probe document-AI availability + egress", description: - 'Cheap probe (no provider call) the vault uses to decide the AI transport and whether to warn before a read leaves the machine. Resolved over the DOCUMENT provider order (local-first, ChatGPT-subscription OAuth last), so `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.', + 'Cheap probe (no provider call) the vault uses to decide the AI transport and whether to warn before a read leaves the machine. Resolved over the DOCUMENT provider order (local-first, ChatGPT-subscription OAuth last), so `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_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.", @@ -456,6 +458,7 @@ export const inboundDocumentPaths: NonNullable = { .nullable(), pdfSupported: z.boolean(), egress: z.enum(["local", "external"]).nullable(), + ai: aiCapabilityState, }) .meta({ id: "DocumentAiCapability" }), "DocumentAiCapabilityEnvelope", @@ -492,7 +495,11 @@ export const inboundDocumentPaths: NonNullable = { name: z.string(), }), ), - assistAvailable: z.boolean(), + assistAvailable: z + .boolean() + .describe( + "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: z.object({ enabled: z.boolean(), indexedCount: z.number(), @@ -739,7 +746,7 @@ export const inboundDocumentPaths: NonNullable = { tags: ["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.', + 'Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the 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", @@ -784,6 +791,7 @@ export const inboundDocumentPaths: NonNullable = { "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 duplicate committed records, so the user must finish reviewing or discard the document first.", content: { "application/json": { schema: errorEnvelope } }, }, + ...aiExtractionRefusals("documentAi"), ...stdResponses, }, }, @@ -793,7 +801,7 @@ export const inboundDocumentPaths: NonNullable = { tags: ["Documents"], 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.', + '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. 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", required: true, schema: { type: "string" } }, ], @@ -829,6 +837,7 @@ export const inboundDocumentPaths: NonNullable = { }, }, }, + ...aiExtractionRefusals("documentAi"), ...stdResponses, }, }, @@ -838,7 +847,7 @@ export const inboundDocumentPaths: NonNullable = { tags: ["Documents"], summary: "Summarise or transcribe a document", description: - 'On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, 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.', + 'On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, 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. 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", required: true, schema: { type: "string" } }, { @@ -903,6 +912,7 @@ export const inboundDocumentPaths: NonNullable = { }, }, }, + ...aiExtractionRefusals("documentAi"), ...stdResponses, }, }, @@ -912,7 +922,7 @@ export const inboundDocumentPaths: NonNullable = { 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.", + "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 (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", required: true, schema: { type: "string" } }, ], @@ -951,6 +961,7 @@ export const inboundDocumentPaths: NonNullable = { }, }, }, + ...aiExtractionRefusals("documentAi"), ...stdResponses, }, }, @@ -960,7 +971,7 @@ export const inboundDocumentPaths: NonNullable = { tags: ["Documents"], summary: "Chat about a document (streaming reply)", description: - "v1.27.33 — sends a user turn and streams a grounded prose reply about ONE stored document as Server-Sent Events (`text/event-stream`, not JSON: one `data: \\n\\n` frame per event). Frame `type` is `token` (a chunk of reply text), `done` (`{ type, conversationId, messageId, usage? }`), or `error` (`{ type, code, message }`); HTTP status is 200 even for a provider/refusal outcome (dispatch on the `error` frame). The reply is 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.", + "v1.27.33 — sends a user turn and streams a grounded prose reply about ONE stored document as Server-Sent Events (`text/event-stream`, not JSON: one `data: \\n\\n` frame per event). Frame `type` is `token` (a chunk of reply text), `done` (`{ type, conversationId, messageId, usage? }`), or `error` (`{ type, code, message }`); HTTP status is 200 even for a provider/refusal outcome (dispatch on the `error` frame). The reply is 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). 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", required: true, schema: { type: "string" } }, ], @@ -984,11 +995,7 @@ export const inboundDocumentPaths: NonNullable = { }, }, }, - "403": { - description: - "AI consent required for an external provider (`errorCode: consent.ai.required`).", - content: { "application/json": { schema: errorEnvelope } }, - }, + ...aiExtractionRefusals("documentAi"), ...stdResponses, }, }, @@ -1044,7 +1051,7 @@ export const inboundDocumentPaths: NonNullable = { tags: ["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.", + "Enqueues a background job that content-indexes the caller's not-yet-indexed documents (one provider 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: @@ -1060,6 +1067,7 @@ export const inboundDocumentPaths: NonNullable = { }, }, }, + ...aiExtractionRefusals("documentAi"), ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 17c8f8d9f..bdfc6ae33 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -5,6 +5,8 @@ */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; + +import { aiExtractionRefusalDescription } from "../ai-extraction-refusals"; import { createMedicationSchema, updateMedicationSchema, @@ -1336,7 +1338,7 @@ export const medicationPaths: NonNullable = { summary: "Extract scheduling fields from a free-text medication description", 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.", + "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. 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: { @@ -1360,9 +1362,13 @@ export const medicationPaths: NonNullable = { "Upstream provider returned an empty, unparseable, or off-schema reply.", content: { "application/json": { schema: errorEnvelope } }, }, + "403": { + description: aiExtractionRefusalDescription("medicationExtract"), + content: { "application/json": { schema: errorEnvelope } }, + }, "503": { description: - "No AI provider configured for the calling user (or operator).", + '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: errorEnvelope } }, }, ...stdResponses, diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index d4c9a280a..82ded9d09 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -17,6 +17,8 @@ import { z } from "zod/v4"; import { ocrCommitSchema } from "@/lib/validations/labs-ocr"; +import { aiExtractionRefusals } from "./ai-extraction-refusals"; +import { aiCapabilityState } from "./profile"; import { dataEnvelope, idempotencyKeyParameter, @@ -40,11 +42,12 @@ const capabilityResponse = z mode: z.enum(["vision", "text"]).nullable(), reason: z.enum(["no-provider", "enable-local-ocr"]).nullable(), pdfSupported: z.boolean(), + ai: aiCapabilityState, }) .meta({ id: "OcrCapabilityResponse", 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.", + "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. `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.", }); const extractConfidence = z.object({ @@ -165,7 +168,7 @@ export const ocrPaths: NonNullable = { tags: ["Labs"], summary: "Extract lab readings from a photo, PDF, or OCR'd text", description: - "Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF — read natively on Anthropic, else rasterized to page images for any other vision 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.", + "Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF — read natively on Anthropic, else rasterized to page images for any other vision 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 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: { @@ -200,6 +203,7 @@ export const ocrPaths: NonNullable = { }, }, }, + ...aiExtractionRefusals("labsOcr"), ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index f4a6cae45..ccf50488f 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -1044,7 +1044,7 @@ export const profilePaths: NonNullable = { tags: ["Auth"], summary: "Probe the AI provider connection", description: - "Sends a tiny fixed prompt to the resolved provider and reports whether it answered. The body is OPTIONAL: with no body the saved configuration is tested; with one, the unsaved selection is tested without touching the user row, so the settings surface can verify a credential before persisting it. Plaintext keys sent this way are never stored.\n\n**This route never returns 5xx, and that is deliberate.** A 5xx from the origin is rewritten to an HTML error page by a reverse proxy or CDN, and the client's `res.json()` then dies on ` = { }, }, }, + "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: errorEnvelope } }, + }, "413": { description: "Body exceeds 64 KiB.", content: { "application/json": { schema: errorEnvelope } }, @@ -1071,12 +1076,12 @@ export const profilePaths: NonNullable = { ...stdResponses, "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.", + '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; a resolved configuration with no provider at all carries `meta.errorCode = "ai.provider.none"`.', content: { "application/json": { schema: 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.", + '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; the budget refusal also carries `meta.errorCode = "ai.budget.exceeded"`.', content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -1820,7 +1825,7 @@ export const profilePaths: NonNullable = { summary: "Set the automatic document-reading opt-in", description: "Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" + - "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.\n\n" + + "Turning it ON does two further things a caller should expect. It is itself the standing consent act, so the 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.\n\n" + "The body cap here is 1 KB, far tighter than the 64 KB its siblings allow — a payload above it is refused with 413 rather than parsed.", requestBody: { required: true, diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 00b44f940..29acd9af5 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -26,6 +26,8 @@ */ import { z } from "zod/v4"; +import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; + /** Max facts a single document may stage / confirm. A dense letter is ~40. */ export const INBOUND_MAX_FACTS = 120; @@ -840,6 +842,12 @@ export interface DocumentAiCapabilityDto { * - null: no read is available (see `reason`). */ egress: DocumentEgressClass | null; + /** + * The `documentAi` capability for this record. When it is closed by + * anything other than a missing provider or a missing consent receipt, + * `available` is false with a null `reason`, and this says why. + */ + ai: AiCapabilityState; } // ─── AI assist / summary / index (Document vault P2) ──────────────────────── diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index e06a70443..aa18dd7e6 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -16,6 +16,7 @@ * The DTO types the routes return + the UI consumes are derived here so the * three layers agree on one shape. */ +import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; import { z } from "zod/v4"; import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; @@ -258,6 +259,12 @@ export interface OcrCapabilityDto { reason: "no-provider" | "enable-local-ocr" | null; /** Whether PDF uploads are accepted (Anthropic vision provider only). */ pdfSupported: boolean; + /** + * The `labsOcr` capability for this record. When it is closed by anything + * other than a missing provider or a missing consent receipt, `available` + * is false with a null `reason`, and this says why. + */ + ai: AiCapabilityState; } /** diff --git a/tests/integration/ai-extraction-capability.test.ts b/tests/integration/ai-extraction-capability.test.ts new file mode 100644 index 000000000..2732b453b --- /dev/null +++ b/tests/integration/ai-extraction-capability.test.ts @@ -0,0 +1,539 @@ +/** + * The extraction group's capabilities, through the real routes and the real + * document pick against real Postgres, with a spy standing in for every model + * call. + * + * The unit suites prove each gate is asked. What only this file can prove is + * that the answer comes off the rows it should: the operator's switches on + * `AppSettings`, the record's module preferences, the receipts on + * `ConsentReceipt`, and the provider built from the person's own credential. + * Every state is checked twice: the response a client sees, and the spy that + * says whether anything reached a model. Each file-level state has an all-open + * twin in which the spy IS called, so a spy that could not see a call would + * turn those red. + * + * The two background paths at the end run the job handlers directly. They + * prove the wire re-check in the document pick: a job enqueued before the + * operator turned document reading off finds no provider when it runs, so + * nothing is sent even though the job itself asks nothing of the capability. + * + * Mutation checks, run against this file: replacing the `atTheWire` re-check + * in `provider-order.ts` with "nothing withheld" turns four cases red (both + * receipt cases on the vault route and both background cases). Deleting the + * `requireAiCapability("documentAi"` line from the suggest route leaves all + * seventeen green, and that is the design rather than a blind spot: the pick + * re-checks the same capability, so the switch still answers 403 with the + * same envelope. The route gate's own presence and order are pinned by the + * route's unit suite instead. + */ +import { Buffer } from "node:buffer"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +// ── The spies: every place a model would be called ───────────────────────── + +vi.mock("@/lib/documents/assist", async (importOriginal) => ({ + ...(await importOriginal()), + runDocumentAssist: vi.fn(async () => ({ + title: "Letter", + kind: null, + documentDate: null, + })), +})); +vi.mock("@/lib/labs/ocr-extract", async (importOriginal) => ({ + ...(await importOriginal()), + runOcrExtraction: vi.fn(async () => ({ rows: [], reportDate: null })), +})); +vi.mock("@/lib/documents/describe", async (importOriginal) => ({ + ...(await importOriginal()), + transcribeDocument: vi.fn(async () => ({ text: "Glucose 95 mg/dL" })), + runDocumentSummary: vi.fn(async () => ({ + summary: "A laboratory report.", + blocked: false, + })), +})); +vi.mock("@/lib/ai/provider-runner", async (importOriginal) => ({ + ...(await importOriginal()), + runRawCompletionWithFallback: vi.fn(async () => ({ + result: { + content: JSON.stringify({ name: "Metformin" }), + tokensUsed: 20, + model: "m", + }, + workingProvider: { providerType: "anthropic" }, + fallbackHops: [], + })), +})); + +import { runDocumentAssist } from "@/lib/documents/assist"; +import { runOcrExtraction } from "@/lib/labs/ocr-extract"; +import { + runDocumentSummary, + transcribeDocument, +} from "@/lib/documents/describe"; +import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner"; + +const SPIES = [ + runDocumentAssist, + runOcrExtraction, + transcribeDocument, + runDocumentSummary, + runRawCompletionWithFallback, +] as const; + +/** A 1x1 PNG, so the vision path has a real image to prepare. */ +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==", + "base64", +); + +let counter = 0; + +/** A person on their own Anthropic key: every extraction leaves the machine. */ +async function makeUser(overrides: Record = {}) { + const suffix = `x-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `extract-${suffix}`, + email: `extract-${suffix}@example.test`, + role: "USER", + timezone: "UTC", + locale: "en", + onboardingCompletedAt: new Date(), + aiProvider: "ANTHROPIC", + aiModel: "claude-sonnet-4-6", + aiAnthropicKeyEncrypted: encrypt("sk-ant-integration-test"), + labsLocalOcrEnabled: true, + ...overrides, + }, + }); +} + +async function grantConsent(userId: string, kind: string) { + await getPrismaClient().consentReceipt.create({ + data: { userId, kind, artefact: "test", signedAt: new Date() }, + }); +} + +async function setSwitches(values: Record) { + await getPrismaClient().appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", ...values }, + update: values, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +async function storeDocument(userId: string) { + const { encryptDocumentContent } = await import("@/lib/documents/store"); + const { content, codec } = encryptDocumentContent(PNG); + return getPrismaClient().inboundDocument.create({ + data: { + userId, + kind: "OTHER", + filename: "letter.png", + mimeType: "image/png", + byteSize: PNG.byteLength, + status: "STORED", + contentEncrypted: content, + contentCodec: codec, + }, + }); +} + +interface Envelope { + data: unknown; + error: string | null; + meta?: Record; +} + +async function suggestText(documentId: string) { + const { POST } = + await import("@/app/api/documents/inbound/[id]/suggest/route"); + const { NextRequest } = await import("next/server"); + const res = await POST( + new NextRequest( + `http://localhost/api/documents/inbound/${documentId}/suggest`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "text", text: "Discharge letter" }), + }, + ) as never, + { params: Promise.resolve({ id: documentId }) } as never, + ); + return { status: res.status, body: (await res.json()) as Envelope }; +} + +async function labsScanText() { + const { POST } = await import("@/app/api/labs/ocr/extract/route"); + const res = await POST( + new Request("http://localhost/api/labs/ocr/extract", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "text", text: "Glucose 95 mg/dL" }), + }) as never, + ); + return { status: res.status, body: (await res.json()) as Envelope }; +} + +async function extractMedication() { + const { POST } = await import("@/app/api/medications/extract/route"); + const { NextRequest } = await import("next/server"); + const res = await POST( + new NextRequest("http://localhost/api/medications/extract", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: "Metformin 500 mg twice daily" }), + }) as never, + ); + return { status: res.status, body: (await res.json()) as Envelope }; +} + +function expectNoModelCall() { + for (const spy of SPIES) { + expect(vi.mocked(spy)).not.toHaveBeenCalled(); + } +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + counter = 0; + for (const spy of SPIES) vi.mocked(spy).mockClear(); +}); + +describe("reading a stored document — POST /api/documents/inbound/[id]/suggest", () => { + it("reads the document when every layer is open (the spy can see a call)", async () => { + const user = await makeUser(); + await grantConsent(user.id, "ai_extraction"); + const document = await storeDocument(user.id); + await signIn(user.id); + + const { status } = await suggestText(document.id); + + expect(status).toBe(200); + expect(runDocumentAssist).toHaveBeenCalledTimes(1); + }); + + it("refuses with the reading-documents code and sends nothing when the operator turned it off", async () => { + const user = await makeUser(); + await grantConsent(user.id, "ai_full"); + await setSwitches({ assistantDocumentAiEnabled: false }); + const document = await storeDocument(user.id); + await signIn(user.id); + + const { status, body } = await suggestText(document.id); + + expect(status).toBe(403); + expect(body.meta).toEqual({ + errorCode: "assistant.disabled.documentAi", + capability: "documentAi", + reason: "operator_disabled", + }); + expectNoModelCall(); + }); + + it("refuses under the master switch too", async () => { + const user = await makeUser(); + await grantConsent(user.id, "ai_full"); + await setSwitches({ assistantEnabled: false }); + const document = await storeDocument(user.id); + await signIn(user.id); + + const { status, body } = await suggestText(document.id); + + expect(status).toBe(403); + expect(body.meta?.reason).toBe("operator_disabled"); + expectNoModelCall(); + }); + + it("asks for an extraction receipt before the document leaves for the person's own key", async () => { + const user = await makeUser(); + const document = await storeDocument(user.id); + await signIn(user.id); + + const { status, body } = await suggestText(document.id); + + expect(status).toBe(403); + expect(body.meta).toEqual({ + errorCode: "consent.ai.required", + capability: "documentAi", + reason: "consent_required", + }); + expectNoModelCall(); + }); + + it("does not take the AI-analysis receipt as consent to read documents", async () => { + const user = await makeUser(); + await grantConsent(user.id, "ai_insights_only"); + const document = await storeDocument(user.id); + await signIn(user.id); + + const { status, body } = await suggestText(document.id); + + expect(status).toBe(403); + expect(body.meta?.errorCode).toBe("consent.ai.required"); + expectNoModelCall(); + }); + + it("keeps the vault's own code when no provider is configured", async () => { + const user = await makeUser({ + aiProvider: null, + aiModel: null, + aiAnthropicKeyEncrypted: null, + }); + const document = await storeDocument(user.id); + await signIn(user.id); + + const { status, body } = await suggestText(document.id); + + expect(status).toBe(422); + expect(body.meta).toEqual({ + errorCode: "documents.inbound.providerUnsupported", + capability: "documentAi", + reason: "no_provider", + }); + expectNoModelCall(); + }); +}); + +describe("scanning a lab report — POST /api/labs/ocr/extract", () => { + it("scans when every layer is open (the spy can see a call)", async () => { + const user = await makeUser(); + await grantConsent(user.id, "ai_extraction"); + await signIn(user.id); + + const { status } = await labsScanText(); + + expect(status).toBe(200); + expect(runOcrExtraction).toHaveBeenCalledTimes(1); + }); + + it("stops under the reading-documents switch", async () => { + const user = await makeUser(); + await grantConsent(user.id, "ai_full"); + await setSwitches({ assistantDocumentAiEnabled: false }); + await signIn(user.id); + + const { status, body } = await labsScanText(); + + expect(status).toBe(403); + expect(body.meta).toMatchObject({ + errorCode: "assistant.disabled.documentAi", + capability: "labsOcr", + }); + expectNoModelCall(); + }); + + it("names the labs module when it is off for the record", async () => { + const user = await makeUser({ + modulePreferencesJson: { labs: false }, + }); + await grantConsent(user.id, "ai_full"); + await signIn(user.id); + + const { status, body } = await labsScanText(); + + expect(status).toBe(403); + expect(body.meta).toEqual({ + errorCode: "module.disabled", + capability: "labsOcr", + reason: "module_disabled", + module: "labs", + }); + expectNoModelCall(); + }); + + it("puts a lab report under the document consent rule", async () => { + const user = await makeUser(); + // The snapshot-rule receipt that used to cover a scan no longer does: a + // lab report is a document. + await grantConsent(user.id, "ai_insights_only"); + await signIn(user.id); + + const { status, body } = await labsScanText(); + + expect(status).toBe(403); + expect(body.meta?.errorCode).toBe("consent.ai.required"); + expectNoModelCall(); + }); +}); + +describe("turning medication text into a schedule — POST /api/medications/extract", () => { + it("extracts when every layer is open (the spy can see a call)", async () => { + const user = await makeUser(); + await grantConsent(user.id, "ai_extraction"); + await signIn(user.id); + + const { status } = await extractMedication(); + + expect(status).toBe(200); + expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); + }); + + it("stops under the reading-documents switch, whatever the Coach switch says", async () => { + const user = await makeUser(); + await grantConsent(user.id, "ai_full"); + await setSwitches({ + assistantDocumentAiEnabled: false, + assistantCoachEnabled: true, + }); + await signIn(user.id); + + const { status, body } = await extractMedication(); + + expect(status).toBe(403); + expect(body.meta).toEqual({ + errorCode: "assistant.disabled.documentAi", + capability: "medicationExtract", + reason: "operator_disabled", + }); + expectNoModelCall(); + }); + + it("keeps 503 for no provider and names it", async () => { + const user = await makeUser({ + aiProvider: null, + aiModel: null, + aiAnthropicKeyEncrypted: null, + }); + await signIn(user.id); + + const { status, body } = await extractMedication(); + + expect(status).toBe(503); + expect(body.meta).toEqual({ + errorCode: "ai.provider.none", + capability: "medicationExtract", + reason: "no_provider", + }); + expectNoModelCall(); + }); +}); + +describe("document auto-read mints the extraction receipt", () => { + it("writes ai_extraction, not ai_full", async () => { + const user = await makeUser(); + await signIn(user.id); + const { PATCH } = + await import("@/app/api/auth/me/documents-auto-ai-read/route"); + + const res = await PATCH( + new Request("http://localhost/api/auth/me/documents-auto-ai-read", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ documentsAutoAiRead: true }), + }), + ); + + expect(res.status).toBe(200); + const receipts = await getPrismaClient().consentReceipt.findMany({ + where: { userId: user.id, revokedAt: null }, + select: { kind: true }, + }); + expect(receipts.map((r) => r.kind)).toEqual(["ai_extraction"]); + }); +}); + +describe("background reads stop at the wire", () => { + it("indexes through the text layer, not a model, once document reading is off", async () => { + const user = await makeUser({ documentsAutoAiRead: true }); + await grantConsent(user.id, "ai_extraction"); + const document = await storeDocument(user.id); + const { indexDocumentContent } = + await import("@/lib/documents/index-document"); + + // Open: the provider path is taken (the spy can see a call). + await indexDocumentContent(user.id, document.id); + expect(transcribeDocument).toHaveBeenCalledTimes(1); + + // The operator turns document reading off; the same job runs again. + vi.mocked(transcribeDocument).mockClear(); + await getPrismaClient().documentContentIndex.deleteMany({ + where: { documentId: document.id }, + }); + await setSwitches({ assistantDocumentAiEnabled: false }); + await indexDocumentContent(user.id, document.id); + + expectNoModelCall(); + }); + + it("leaves a summary job enqueued before the switch flipped with no provider to call", async () => { + const user = await makeUser({ documentsAutoAiRead: true }); + await grantConsent(user.id, "ai_extraction"); + const document = await storeDocument(user.id); + await setSwitches({ assistantDocumentAiEnabled: false }); + const { runDocumentSummaryJob } = + await import("@/lib/jobs/document-summary"); + + await runDocumentSummaryJob({ + userId: user.id, + documentId: document.id, + enqueuedAt: new Date().toISOString(), + }); + + expectNoModelCall(); + const row = await getPrismaClient().inboundDocument.findUniqueOrThrow({ + where: { id: document.id }, + select: { summaryEncrypted: true }, + }); + expect(row.summaryEncrypted).toBeNull(); + }); + + it("summarises the same document with the switch on (the spy can see a call)", async () => { + const user = await makeUser({ documentsAutoAiRead: true }); + await grantConsent(user.id, "ai_extraction"); + const document = await storeDocument(user.id); + const { runDocumentSummaryJob } = + await import("@/lib/jobs/document-summary"); + + await runDocumentSummaryJob({ + userId: user.id, + documentId: document.id, + enqueuedAt: new Date().toISOString(), + }); + + expect(runDocumentSummary).toHaveBeenCalledTimes(1); + }); +});