From a19900c8543351defd1fc9acd54d98f7e919236b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 08:44:49 +0200 Subject: [PATCH 1/7] feat(ai): re-check a capability at the wire requireAiCapability answers at the top of a route, before a provider is picked, and a job enqueued before an operator switch flipped runs long after any route answered. aiEgressRefusal asks again about the providers a caller is about to send to: the capability for the record (the request's own resolution, or the job's for the record it names) and, for exactly those providers, whether a consent receipt of the kinds the capability table declares is active. A pickDecides gate option leaves no_provider and consent_required to that pick, for routes that read the chain in their own order. --- .../ai/capabilities/__tests__/egress.test.ts | 254 ++++++++++++++++++ src/lib/ai/capabilities/egress.ts | 96 +++++++ src/lib/ai/capabilities/gate.ts | 20 +- src/lib/ai/capabilities/resolve.ts | 23 ++ src/lib/ai/capabilities/types.ts | 9 + 5 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 src/lib/ai/capabilities/__tests__/egress.test.ts create mode 100644 src/lib/ai/capabilities/egress.ts 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 From bd8ea9d86b87278c04daa7bd5d2b5d97c78ecc2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 08:44:57 +0200 Subject: [PATCH 2/7] feat(ai): gate document, lab and medication extraction on their capabilities Reading a stored document (suggest, summary, extract, index, chat, reindex), scanning a lab report and turning medication text into a schedule now answer under documentAi, labsOcr and medicationExtract. The operator's master switch and the new reading-documents switch stop the work and the egress; a module off for the record, a delegate inside somebody else's record, and a missing receipt refuse with the capability envelope. Existing no-provider codes keep their status. The document pick and the labs pick re-check the capability for the provider they picked, immediately before anything is sent, so a job enqueued before a switch flipped finds no provider and falls back to the provider-free text layer. Consent follows the document rule for all three: any provider that leaves the machine needs an ai_extraction or ai_full receipt, answered for the provider actually picked. The fenced Coach chat and attachments answer under coach and documentAi. Uploads are always stored and indexed; the background summary is only enqueued while documentAi is open. The capability probes report the capability beside the provider answer and keep a read offered when only the receipt is missing. medications/extract keeps its 503 for no provider and adds ai.provider.none, and its budget refusal gains the coach.budget.exceeded code. --- .../ai-consent-enforcement-guard.test.ts | 20 ++- src/__tests__/helpers/provider-order-mock.ts | 67 +++++++ .../module-route-gate-inventory.test.ts | 9 +- .../inbound/[id]/chat/__tests__/route.test.ts | 71 +++++--- .../api/documents/inbound/[id]/chat/route.ts | 7 + .../[id]/extract/__tests__/route.test.ts | 54 +++++- .../documents/inbound/[id]/extract/route.ts | 54 ++---- .../[id]/index/__tests__/route.test.ts | 15 +- .../api/documents/inbound/[id]/index/route.ts | 21 +-- .../[id]/suggest/__tests__/route.test.ts | 16 +- .../documents/inbound/[id]/suggest/route.ts | 36 +--- .../[id]/summary/__tests__/route.test.ts | 16 +- .../documents/inbound/[id]/summary/route.ts | 36 ++-- .../documents/inbound/__tests__/route.test.ts | 34 ++++ .../api/documents/inbound/capability/route.ts | 1 + .../inbound/reindex/__tests__/route.test.ts | 53 +++++- .../api/documents/inbound/reindex/route.ts | 25 ++- src/app/api/documents/inbound/route.ts | 16 +- src/app/api/documents/inbound/usage/route.ts | 8 +- .../[id]/attachments/__tests__/route.test.ts | 55 ++++++ .../insights/chat/[id]/attachments/route.ts | 7 + .../chat/fenced/__tests__/route.test.ts | 54 ++++++ src/app/api/insights/chat/fenced/route.ts | 11 +- src/app/api/labs/ocr/capability/route.ts | 5 +- .../labs/ocr/extract/__tests__/route.test.ts | 94 +++++++--- src/app/api/labs/ocr/extract/route.ts | 53 ++---- .../extract/__tests__/route.test.ts | 92 ++++++++-- src/app/api/medications/extract/route.ts | 67 +++++-- .../__tests__/fenced-chat-pipeline.test.ts | 92 +++++++--- .../__tests__/index-document.test.ts | 21 +++ .../__tests__/provider-order.test.ts | 169 +++++++++++++++++- src/lib/documents/fenced-chat.ts | 56 +++--- src/lib/documents/index-document.ts | 19 +- src/lib/documents/provider-order.ts | 155 +++++++++++++--- .../__tests__/no-dead-assistant-flag.test.ts | 16 +- src/lib/labs/__tests__/ocr-capability.test.ts | 163 +++++++++++++++++ src/lib/labs/ocr-capability.ts | 93 +++++++++- src/lib/validations/inbound-documents.ts | 8 + src/lib/validations/labs-ocr.ts | 7 + 39 files changed, 1420 insertions(+), 376 deletions(-) create mode 100644 src/__tests__/helpers/provider-order-mock.ts create mode 100644 src/lib/labs/__tests__/ocr-capability.test.ts 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/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/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/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; } /** From d239d313110464f9bbda0ace38daa7606271df1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 08:45:03 +0200 Subject: [PATCH 3/7] feat(ai): narrow the auto-read consent and refuse the connection test with AI off Turning document auto-read on now mints an ai_extraction receipt instead of ai_full, so it covers reading documents, lab reports and medication text and no longer opens the Coach or the AI analysis. Receipts minted as ai_full by earlier releases stay valid. The catch-up over stored documents is only scheduled while documentAi is open for the record. The AI connection test is an AI call, so it is refused with assistant.disabled.enabled while the operator's master switch is off, and fails closed with ai.unavailable when the switches cannot be read (403, since the route never answers 5xx). A missing provider carries ai.provider.none and a spent daily budget ai.budget.exceeded. --- src/app/api/ai/test/__tests__/route.test.ts | 58 ++++++++++++ src/app/api/ai/test/route.ts | 47 +++++++++- .../__tests__/route.test.ts | 90 ++++++++++++++++--- .../auth/me/documents-auto-ai-read/route.ts | 90 ++++++++++++++++--- src/lib/openapi/error-codes.ts | 8 +- 5 files changed, 263 insertions(+), 30 deletions(-) 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/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", From 005f645b8bc04f1b064b426b07c3378f37e7f832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 08:45:06 +0200 Subject: [PATCH 4/7] docs(api): describe the extraction capability refusals The document, labs-scan, medication-extraction, fenced-chat, attachment, connection-test and auto-read operations document the capability they answer under, the refusal envelope and codes, and the consent rule. The two capability probes publish the capability beside the provider answer. --- docs/api/openapi.yaml | 604 +++++++++++------- .../openapi/routes/ai-extraction-refusals.ts | 84 +++ src/lib/openapi/routes/coach.ts | 17 +- src/lib/openapi/routes/documents.ts | 36 +- src/lib/openapi/routes/medications/paths.ts | 10 +- src/lib/openapi/routes/ocr.ts | 8 +- src/lib/openapi/routes/profile.ts | 13 +- 7 files changed, 524 insertions(+), 248 deletions(-) create mode 100644 src/lib/openapi/routes/ai-extraction-refusals.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3930a4101..ccd1197d7 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: @@ -6357,6 +6375,12 @@ paths: Two ceilings: 5 per minute and 50 per day, per user. + + + With the operator's master AI switch off the probe is refused (403 `assistant.disabled.enabled`, `meta.reason = + "operator_disabled"`): no AI call leaves the server then, and this is an AI call. When the switches cannot be + read the answer fails closed with 403 `ai.unavailable` (`meta.reason = "check_failed"`) rather than the usual + 503, for the no-5xx reason above. requestBody: required: false content: @@ -6372,6 +6396,13 @@ paths: schema: $ref: "#/components/schemas/AiTestEnvelope" "401": *a1 + "403": + description: The operator's master AI switch is off (`meta.errorCode = "assistant.disabled.enabled"`), or the switches + could not be read (`ai.unavailable`). Nothing was sent. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 64 KiB. content: @@ -6387,14 +6418,16 @@ paths: "422": description: Nothing could be probed. Either the override body failed validation, or the resolved configuration is not usable — no provider selected, a required key or base URL missing, a model name missing for the gateway, - ChatGPT OAuth not connected, or a base URL pointing at an internal host. The message names which. + ChatGPT OAuth not connected, or a base URL pointing at an internal host. The message names which; a resolved + configuration with no provider at all carries `meta.errorCode = "ai.provider.none"`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: "One of three ceilings: 5 probes per minute, 50 per day, or the account's daily AI token budget is - exhausted. The message distinguishes them." + exhausted. The message distinguishes them; the budget refusal also carries `meta.errorCode = + \"ai.budget.exceeded\"`." content: application/json: schema: @@ -7949,11 +7982,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 @@ -8628,12 +8664,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: @@ -8650,8 +8689,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: @@ -8660,6 +8709,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: @@ -8669,7 +8725,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 @@ -8691,6 +8749,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: @@ -8699,6 +8773,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: @@ -13491,12 +13572,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: @@ -13521,8 +13606,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: @@ -15404,7 +15510,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. @@ -15636,14 +15746,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 @@ -15664,6 +15776,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 @@ -15674,6 +15800,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: @@ -15681,13 +15814,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 @@ -15729,8 +15862,10 @@ paths: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" "401": *a1 + "403": *a33 "422": *a4 "429": *a5 + "503": *a34 /api/documents/inbound/{id}/summary: post: tags: @@ -15741,7 +15876,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 @@ -15807,22 +15944,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 @@ -15864,8 +16004,10 @@ paths: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" "401": *a1 + "403": *a33 "422": *a4 "429": *a5 + "503": *a34 /api/documents/inbound/{id}/chat: post: tags: @@ -15878,10 +16020,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 @@ -15903,14 +16047,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 @@ -15959,9 +16099,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 @@ -15971,8 +16113,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: @@ -16079,7 +16223,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: @@ -16108,7 +16252,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: @@ -16142,8 +16286,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a33 - "409": *a34 + "404": *a35 + "409": *a36 "422": *a4 "429": *a5 delete: @@ -16166,7 +16310,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a33 + "404": *a35 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -16210,7 +16354,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a9 - "404": *a33 + "404": *a35 "422": *a4 "429": *a5 post: @@ -16239,7 +16383,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a33 + "404": *a35 "409": *a7 "422": *a4 "429": *a10 @@ -16275,7 +16419,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: @@ -16308,7 +16452,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a35 + "404": *a37 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -16717,7 +16861,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 @@ -16772,7 +16916,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a36 + "403": *a38 "422": *a4 "429": *a5 /api/nutrients/water: @@ -16801,7 +16945,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a36 + "403": *a38 "409": *a7 "422": *a4 "429": *a5 @@ -17303,26 +17447,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: @@ -17508,11 +17652,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: @@ -17655,11 +17799,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: @@ -17812,11 +17956,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: @@ -17892,21 +18036,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` @@ -17951,11 +18095,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: @@ -18031,9 +18175,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: @@ -18069,11 +18213,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: @@ -18151,9 +18295,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: @@ -18189,7 +18333,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a42 + "429": *a44 /api/integrations/fitbit/test: post: tags: @@ -18207,9 +18351,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: @@ -18226,7 +18370,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a42 + "429": *a44 /api/integrations/google-health/test: post: tags: @@ -18259,9 +18403,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: @@ -18278,7 +18422,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a42 + "429": *a44 /api/integrations/whoop/test: post: tags: @@ -18295,9 +18439,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: @@ -18315,7 +18459,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a42 + "429": *a44 /api/integrations/withings/test: post: tags: @@ -18333,8 +18477,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: @@ -18571,7 +18715,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 @@ -18596,7 +18740,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "422": *a4 "429": *a5 post: @@ -18691,7 +18835,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "404": description: No such invitation for this account. content: @@ -18735,7 +18879,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "404": description: No such grant on this account's record. content: @@ -18776,7 +18920,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "404": description: No such grant held by this account. content: @@ -19158,7 +19302,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 — @@ -19488,7 +19632,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. @@ -19521,7 +19665,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. @@ -19548,7 +19692,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a45 + "403": *a47 "422": *a4 "429": *a5 post: @@ -19578,7 +19722,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a45 + "403": *a47 "413": description: Body exceeds 16 KiB. content: @@ -19621,7 +19765,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. @@ -19679,7 +19823,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 @@ -19723,7 +19867,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: @@ -20258,13 +20402,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: @@ -20323,8 +20467,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. @@ -30032,36 +30176,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 — @@ -59304,17 +59448,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: @@ -62318,12 +62514,15 @@ components: - local - external - type: "null" + ai: + $ref: "#/components/schemas/AiCapabilityState" required: - available - mode - reason - pdfSupported - egress + - ai additionalProperties: false DocumentUsageEnvelope: type: object @@ -62370,6 +62569,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: @@ -62608,12 +62810,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: @@ -67797,51 +68000,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/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, From 660351be3acd420d2085575b976f6a11528c4eae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 08:47:35 +0200 Subject: [PATCH 5/7] test(ai): prove the extraction capabilities against Postgres with a model spy Each refusal is checked twice, at the response and at a spy standing in for every model call, beside an all-open twin in which the spy is called. Two background cases run the index and summary job handlers directly and show that a job enqueued before document reading was turned off finds no provider when it runs. --- .../ai-extraction-capability.test.ts | 542 ++++++++++++++++++ 1 file changed, 542 insertions(+) create mode 100644 tests/integration/ai-extraction-capability.test.ts diff --git a/tests/integration/ai-extraction-capability.test.ts b/tests/integration/ai-extraction-capability.test.ts new file mode 100644 index 000000000..2384d659e --- /dev/null +++ b/tests/integration/ai-extraction-capability.test.ts @@ -0,0 +1,542 @@ +/** + * 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 (recorded with the package): deleting the + * `requireAiCapability("documentAi"` line from the suggest route leaves the + * wire re-check to refuse, so the switch case still answers 403 but through + * the pick; deleting the `atTheWire` re-check from `provider-order.ts` turns + * both job cases and the receipt cases red; returning `ai_full` from the + * auto-read mint turns the receipt-kind case red. + */ +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); + }); +}); From 059da3ceb03b44785182ca28bb27af04cd154d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 08:50:17 +0200 Subject: [PATCH 6/7] test(ai): state the mutation results the extraction suite was run against --- tests/integration/ai-extraction-capability.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/integration/ai-extraction-capability.test.ts b/tests/integration/ai-extraction-capability.test.ts index 2384d659e..46f98579a 100644 --- a/tests/integration/ai-extraction-capability.test.ts +++ b/tests/integration/ai-extraction-capability.test.ts @@ -17,12 +17,14 @@ * 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 (recorded with the package): deleting the - * `requireAiCapability("documentAi"` line from the suggest route leaves the - * wire re-check to refuse, so the switch case still answers 403 but through - * the pick; deleting the `atTheWire` re-check from `provider-order.ts` turns - * both job cases and the receipt cases red; returning `ai_full` from the - * auto-read mint turns the receipt-kind case red. + * 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"; From b0bbd344a3bf787c8fdd900bab176a40ebc6ccae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 08:51:52 +0200 Subject: [PATCH 7/7] style: format the extraction capability suite --- .../ai-extraction-capability.test.ts | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/tests/integration/ai-extraction-capability.test.ts b/tests/integration/ai-extraction-capability.test.ts index 46f98579a..2732b453b 100644 --- a/tests/integration/ai-extraction-capability.test.ts +++ b/tests/integration/ai-extraction-capability.test.ts @@ -187,9 +187,8 @@ interface Envelope { } async function suggestText(documentId: string) { - const { POST } = await import( - "@/app/api/documents/inbound/[id]/suggest/route" - ); + const { POST } = + await import("@/app/api/documents/inbound/[id]/suggest/route"); const { NextRequest } = await import("next/server"); const res = await POST( new NextRequest( @@ -457,9 +456,8 @@ 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 { 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", { @@ -483,9 +481,8 @@ describe("background reads stop at the wire", () => { 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" - ); + 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); @@ -507,9 +504,8 @@ describe("background reads stop at the wire", () => { await grantConsent(user.id, "ai_extraction"); const document = await storeDocument(user.id); await setSwitches({ assistantDocumentAiEnabled: false }); - const { runDocumentSummaryJob } = await import( - "@/lib/jobs/document-summary" - ); + const { runDocumentSummaryJob } = + await import("@/lib/jobs/document-summary"); await runDocumentSummaryJob({ userId: user.id, @@ -529,9 +525,8 @@ describe("background reads stop at the wire", () => { 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" - ); + const { runDocumentSummaryJob } = + await import("@/lib/jobs/document-summary"); await runDocumentSummaryJob({ userId: user.id,