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; } /**