feat(ai): gate document, lab and medication extraction on their capabilities

Reading a stored document (suggest, summary, extract, index, chat,
reindex), scanning a lab report and turning medication text into a
schedule now answer under documentAi, labsOcr and medicationExtract. The
operator's master switch and the new reading-documents switch stop the
work and the egress; a module off for the record, a delegate inside
somebody else's record, and a missing receipt refuse with the capability
envelope. Existing no-provider codes keep their status.

The document pick and the labs pick re-check the capability for the
provider they picked, immediately before anything is sent, so a job
enqueued before a switch flipped finds no provider and falls back to the
provider-free text layer. Consent follows the document rule for all
three: any provider that leaves the machine needs an ai_extraction or
ai_full receipt, answered for the provider actually picked.

The fenced Coach chat and attachments answer under coach and documentAi.
Uploads are always stored and indexed; the background summary is only
enqueued while documentAi is open. The capability probes report the
capability beside the provider answer and keep a read offered when only
the receipt is missing. medications/extract keeps its 503 for no provider
and adds ai.provider.none, and its budget refusal gains the
coach.budget.exceeded code.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 08:44:57 +02:00
1 parent a19900c854
commit bd8ea9d86b
39 files changed
+1420 -376

No files matched your search

@@ -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")] }),
);
@@ -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);
@@ -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" },
});
});
});
+33 -23
View File
@@ -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 });
+16 -3
View File
@@ -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<ResolvedIndexProvider> {
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)) {
+134 -21
View File
@@ -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> = T & {
withheld: AiUnavailableError | null;
};
async function atTheWire<T extends { pick: { providerType: string } | null }>(
userId: string,
): Promise<VisionProviderPick> {
return resolveVisionProvider(userId, {
reorder: reorderChainForDocumentClass,
});
resolved: T,
): Promise<DocumentProviderPick<T>> {
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<DocumentProviderPick<VisionProviderPick>> {
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<T>(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<DocumentAiCapabilityDto> {
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,
};
}
@@ -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<string, string> = {
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<string, string> = {};
/**
* 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",
];
@@ -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" });
});
});
+88 -5
View File
@@ -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<NonNullable<VisionProviderPick["pick"]>>;
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<OcrCapabilityDto> {
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,
};
}
+8
View File
@@ -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) ────────────────────────
+7
View File
@@ -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;
}
/**