Merge branch 'fix/docai-governance-rebased' into release/v1.27.31

This commit is contained in:
Marc-André Bombeck committed 2026-07-09 01:28:07 +02:00
commit 7b1f077888
31 files changed
+786 -100

No files matched your search

@@ -7,8 +7,10 @@ vi.mock("@/lib/consent/receipts", () => ({
import {
ConsentRequiredError,
assertConsentForChain,
assertDocumentEgressConsent,
chainRequiresServerManagedConsent,
hasActiveConsentForSurface,
isExternalDocumentEgress,
} from "../consent-guard";
import { latestActiveReceipt } from "@/lib/consent/receipts";
import type { ProviderChainResolved } from "../provider-runner";
@@ -168,3 +170,67 @@ describe("assertConsentForChain", () => {
).rejects.toBeInstanceOf(ConsentRequiredError);
});
});
describe("isExternalDocumentEgress", () => {
it("treats only the self-hosted local provider as non-egress", () => {
expect(isExternalDocumentEgress("local")).toBe(false);
});
it("treats every external provider as document egress", () => {
for (const p of ["codex", "openai", "anthropic", "admin-openai"]) {
expect(isExternalDocumentEgress(p)).toBe(true);
}
});
});
describe("assertDocumentEgressConsent", () => {
beforeEach(() => mockedLatest.mockReset());
it("never reads consent and never throws for a LOCAL document pick", async () => {
grant([]);
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: "local",
surface: "insights",
}),
).resolves.toBeUndefined();
expect(mockedLatest).not.toHaveBeenCalled();
});
// The live gap the governance fix closes: codex was ungated for documents.
it("REQUIRES a receipt to send a document to codex (the closed gap)", async () => {
grant([]);
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: "codex",
surface: "insights",
}),
).rejects.toBeInstanceOf(ConsentRequiredError);
});
it("requires a receipt for BYOK document egress too (openai / anthropic)", async () => {
grant([]);
for (const p of ["openai", "anthropic", "admin-openai"]) {
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: p,
surface: "insights",
}),
).rejects.toBeInstanceOf(ConsentRequiredError);
}
});
it("proceeds for codex WITH an active document-class receipt", async () => {
grant(["ai_insights_only"]);
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: "codex",
surface: "insights",
}),
).resolves.toBeUndefined();
});
});
+47
View File
@@ -118,3 +118,50 @@ export async function assertConsentForChain(args: {
if (await hasActiveConsentForSurface(args.userId, args.surface)) return;
throw new ConsentRequiredError(args.surface);
}
/**
* The DOCUMENT-class consent gate (governance fix, oauth-investigation
* SYNTHESIS §2).
*
* The self-snapshot gate above leaves BYOK / codex / local UNGATED on the
* theory that "configuring the provider was the consent act." That theory holds
* for a metrics snapshot the account owner sends about themselves. It is too
* thin for an uploaded medical DOCUMENT: sending a scanned discharge letter to
* ANY third-party AI is the egress data-protection law cares about, and the
* codex (ChatGPT-subscription) backend trains on consumer content by default.
*
* So for the document surfaces the gate is stricter and PICK-based: the vault
* AI routes call the single resolved provider directly (no runner cascade), so
* the exact egress is the picked provider. A `local` pick never leaves the
* machine and stays ungated; EVERY external pick (codex, BYOK openai/anthropic,
* the operator's admin key) requires an active document-class consent receipt.
*/
const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet<string> = new Set(["local"]);
/**
* True when reading a document through this provider egresses it OFF the machine
* to a third-party AI service. Only the self-hosted `local` provider keeps the
* document on the operator's own infrastructure.
*/
export function isExternalDocumentEgress(providerType: string): boolean {
return !LOCAL_ONLY_PROVIDER_TYPES.has(providerType);
}
/**
* Enforce the document-class consent precondition for the provider that will
* actually receive the document. No-op for a `local` pick (nothing leaves the
* machine); for any external pick, throw `ConsentRequiredError` unless an active
* receipt of the surface's mapped kind (or `ai_full`) is on file.
*
* Call this AFTER the document provider is picked and BEFORE the first
* `generateCompletion` on it.
*/
export async function assertDocumentEgressConsent(args: {
userId: string;
providerType: string;
surface: ConsentSurface;
}): Promise<void> {
if (!isExternalDocumentEgress(args.providerType)) return;
if (await hasActiveConsentForSurface(args.userId, args.surface)) return;
throw new ConsentRequiredError(args.surface);
}
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Document-class provider order (governance fix, oauth-investigation
* SYNTHESIS §1). Pins: for a DOCUMENT read the chain is reprioritised
* local-first with codex (ChatGPT-subscription OAuth) LAST, and the egress
* class the vault notice reads is vendor-blind local/external. Coach / insights
* do not use these helpers, so their app-wide order is untouched.
*/
vi.mock("@/lib/labs/ocr-capability", () => ({
resolveVisionProvider: vi.fn(),
resolveTextProvider: vi.fn(),
}));
import {
documentEgressClass,
reorderChainForDocumentClass,
resolveDocumentVisionProvider,
} from "../provider-order";
import {
resolveVisionProvider,
type VisionProviderPick,
} from "@/lib/labs/ocr-capability";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
function entry(providerType: string): ProviderChainResolved {
return {
providerType: providerType as ProviderChainResolved["providerType"],
instance: {
type: providerType,
generateCompletion: vi.fn(),
} as unknown as ProviderChainResolved["instance"],
};
}
const order = (chain: ProviderChainResolved[]) =>
reorderChainForDocumentClass(chain).map((e) => e.providerType);
describe("reorderChainForDocumentClass", () => {
it("demotes codex last and lifts local first for the default chain", () => {
// The persisted default chain the audit flagged: codex at priority 1.
const chain = [
entry("codex"),
entry("openai"),
entry("anthropic"),
entry("local"),
entry("admin-openai"),
];
expect(order(chain)).toEqual([
"local",
"openai",
"anthropic",
"admin-openai",
"codex",
]);
});
it("keeps codex behind local even when codex is the only cheap option", () => {
expect(order([entry("codex"), entry("local")])).toEqual(["local", "codex"]);
});
it("is stable within a rank tier (preserves user order for BYOK keys)", () => {
expect(order([entry("anthropic"), entry("openai")])).toEqual([
"anthropic",
"openai",
]);
expect(order([entry("openai"), entry("anthropic")])).toEqual([
"openai",
"anthropic",
]);
});
it("does not mutate the input array", () => {
const chain = [entry("codex"), entry("local")];
reorderChainForDocumentClass(chain);
expect(chain.map((e) => e.providerType)).toEqual(["codex", "local"]);
});
});
describe("documentEgressClass", () => {
it("classifies local as on-machine and everything else as external", () => {
expect(documentEgressClass("local")).toBe("local");
expect(documentEgressClass("codex")).toBe("external");
expect(documentEgressClass("openai")).toBe("external");
expect(documentEgressClass("anthropic")).toBe("external");
expect(documentEgressClass("admin-openai")).toBe("external");
});
});
describe("resolveDocumentVisionProvider", () => {
beforeEach(() => vi.mocked(resolveVisionProvider).mockReset());
it("invokes the shared resolver with the document reorder that demotes codex", async () => {
vi.mocked(resolveVisionProvider).mockResolvedValue({
chain: [],
localOcrEnabled: false,
pick: null,
} as VisionProviderPick);
await resolveDocumentVisionProvider("u1");
const call = vi.mocked(resolveVisionProvider).mock.calls[0]!;
expect(call[0]).toBe("u1");
const reorder = call[1]!.reorder!;
// The reorder the document resolver hands down puts a codex-first chain
// local-first — proving documents never default to the subscription path.
expect(
reorder([entry("codex"), entry("local")]).map((e) => e.providerType),
).toEqual(["local", "codex"]);
});
});
+164
View File
@@ -0,0 +1,164 @@
/**
* Document-class AI provider resolution (governance fix, oauth-investigation
* SYNTHESIS §1).
*
* A three-audit investigation found that for uploaded medical DOCUMENTS the
* app-wide provider chain is the wrong default: `codex` (the
* ChatGPT-subscription OAuth backend) sits at chain priority 1, ahead of BYOK
* and local, and OpenAI's consumer policy allows training on that content by
* default. HealthLog is privacy-first, so a scanned discharge letter must NOT
* default to the train-by-default backend.
*
* The fix is scoped to the DOCUMENT class only — Coach / insights keep the
* cost-first app-wide order untouched. This module reprioritises the resolved
* provider chain for the vault's AI surfaces (suggest / summary / extract /
* index / reindex / backfill):
*
* local (no egress) → BYOK no-train API (openai / anthropic) → operator's
* admin key → codex (ChatGPT-subscription OAuth) LAST, as an explicit,
* 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.
*/
import { isExternalDocumentEgress } from "@/lib/ai/consent-guard";
import {
resolveTextProvider,
resolveVisionProvider,
type ChainReorder,
type VisionProviderPick,
} from "@/lib/labs/ocr-capability";
import type {
DocumentAiCapabilityDto,
DocumentEgressClass,
} from "@/lib/validations/inbound-documents";
/**
* Preference rank for a provider when the payload is a DOCUMENT. Lower wins.
* Local keeps the document on the machine (rank 0); BYOK no-train API keys are
* next (rank 1); the operator's shared no-train key follows (rank 2); the
* ChatGPT-subscription OAuth path (`codex`) is LAST (rank 3) because it trains
* on consumer content by default and cannot be verified opted-out from here.
*/
function documentProviderRank(providerType: string): number {
switch (providerType) {
case "local":
return 0;
case "openai":
case "anthropic":
case "admin-key":
return 1;
case "admin-openai":
return 2;
case "codex":
return 3;
default:
return 2;
}
}
/**
* Reorder a resolved chain for the document class: stable sort by
* `documentProviderRank`, so within a rank tier the user's own chain order is
* preserved (a stable sort keeps insertion order on ties). Pure — returns a new
* array, never mutates the input.
*/
export const reorderChainForDocumentClass: ChainReorder = (chain) => {
return [...chain].sort(
(a, b) =>
documentProviderRank(a.providerType) -
documentProviderRank(b.providerType),
);
};
/**
* 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.
*/
export function resolveDocumentVisionProvider(
userId: string,
): Promise<VisionProviderPick> {
return resolveVisionProvider(userId, {
reorder: reorderChainForDocumentClass,
});
}
/**
* 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.
*/
export function resolveDocumentTextProvider(userId: string) {
return resolveTextProvider(userId, {
reorder: reorderChainForDocumentClass,
});
}
/**
* Classify a picked provider's egress for the per-egress UI notice. Vendor-blind
* by design — "local" (stays on the machine) vs "external" (a third-party AI
* service); the copy never names a vendor.
*/
export function documentEgressClass(providerType: string): DocumentEgressClass {
return isExternalDocumentEgress(providerType) ? "external" : "local";
}
/**
* 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.
*/
export async function resolveDocumentAiCapability(
userId: string,
): Promise<DocumentAiCapabilityDto> {
const { chain, pick, localOcrEnabled } =
await resolveDocumentVisionProvider(userId);
// A vision-capable provider is available — the read runs directly over the
// stored original. Egress follows the picked provider.
if (pick) {
return {
available: true,
mode: "vision",
reason: null,
pdfSupported: pick.pdfSupported,
egress: documentEgressClass(pick.providerType),
};
}
// Nothing configured at all — no read, regardless of the local-OCR toggle.
if (chain.length === 0) {
return {
available: false,
mode: null,
reason: "no-provider",
pdfSupported: false,
egress: null,
};
}
// A text-only provider is configured. Local OCR runs in the browser and only
// the extracted text is structured by the first provider in document order.
if (localOcrEnabled) {
return {
available: true,
mode: "text",
reason: null,
pdfSupported: false,
egress: documentEgressClass(chain[0]!.providerType),
};
}
// A text-only provider is configured but local OCR is not enabled.
return {
available: false,
mode: null,
reason: "enable-local-ocr",
pdfSupported: false,
egress: null,
};
}
@@ -25,11 +25,11 @@ vi.mock("@/lib/documents/describe", () => ({
vi.mock("@/lib/documents/content-index", () => ({
upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 3 }),
}));
vi.mock("@/lib/labs/ocr-capability", () => ({
resolveVisionProvider: vi.fn(),
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
}));
vi.mock("@/lib/ai/coach/budget", () => ({
@@ -42,9 +42,9 @@ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
import { runContentIndexBackfillForUser } from "../document-content-index-backfill";
import { prisma } from "@/lib/db";
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import {
assertConsentForChain,
assertDocumentEgressConsent,
ConsentRequiredError,
} from "@/lib/ai/consent-guard";
import { reserveBudget } from "@/lib/ai/coach/budget";
@@ -81,7 +81,7 @@ beforeEach(() => {
describe("runContentIndexBackfillForUser", () => {
it("no-ops with no provider", async () => {
vi.mocked(resolveVisionProvider).mockResolvedValue({
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
chain: [],
pick: null,
} as never);
@@ -91,8 +91,8 @@ describe("runContentIndexBackfillForUser", () => {
});
it("no-ops when consent is missing", async () => {
vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never);
vi.mocked(assertConsentForChain).mockRejectedValueOnce(
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
vi.mocked(assertDocumentEgressConsent).mockRejectedValueOnce(
new ConsentRequiredError("insights" as never),
);
const result = await runContentIndexBackfillForUser("user-1");
@@ -101,7 +101,7 @@ describe("runContentIndexBackfillForUser", () => {
});
it("indexes the not-yet-indexed documents", async () => {
vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never);
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
// One short page (< PAGE_SIZE) — the walk breaks after it, so a single
// findMany return is enough (a trailing once would leak to the next test).
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
@@ -115,7 +115,7 @@ describe("runContentIndexBackfillForUser", () => {
});
it("stops when the daily budget is reached (resumable)", async () => {
vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never);
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValueOnce([
{ id: "d1" },
{ id: "d2" },
@@ -8,9 +8,10 @@
* indexing egresses to a provider under the user's key + budget, so it must be
* a deliberate, consented act, exactly like the per-document index route.
*
* Consent: gated on the EXISTING AI consent (`assertConsentForChain`), the same
* gate the extract / index routes use — there is no separate per-user toggle
* (maintainer decision, 2026-07-07).
* Consent: gated on the document-egress consent (`assertDocumentEgressConsent`),
* the same gate the extract / index routes use — any external provider needs an
* active receipt; a local pick stays ungated. There is no separate per-user
* toggle (maintainer decision, 2026-07-07).
*
* Bounded + resumable: an id-cursor forward walk over the not-yet-indexed set,
* capped at `MAX_DOCS_PER_RUN` provider calls per job and stopped the moment the
@@ -24,7 +25,7 @@
*/
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import {
assertConsentForChain,
assertDocumentEgressConsent,
ConsentRequiredError,
} from "@/lib/ai/consent-guard";
import {
@@ -41,7 +42,7 @@ import {
import { upsertContentIndex } from "@/lib/documents/content-index";
import { transcribeDocument } from "@/lib/documents/describe";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
export const CONTENT_INDEX_BACKFILL_QUEUE = "document-content-index-backfill";
@@ -76,10 +77,14 @@ export interface ContentIndexBackfillSummary {
export async function runContentIndexBackfillForUser(
userId: string,
): Promise<ContentIndexBackfillSummary> {
const { chain, pick } = await resolveVisionProvider(userId);
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) return { indexed: 0, reason: "no-provider" };
try {
await assertConsentForChain({ userId, chain, surface: "insights" });
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
} catch (err) {
if (err instanceof ConsentRequiredError) {
return { indexed: 0, reason: "no-consent" };
+40 -8
View File
@@ -39,6 +39,21 @@ function modelForEntry(
return providerType === "admin-openai" ? ctx.adminModel : ctx.userModel;
}
/**
* A transform applied to the resolved provider chain before the vision/text
* pick is made. Identity by default (labs behaviour unchanged); the document
* class passes a reorder that demotes external/train-by-default providers below
* local (see `@/lib/documents/provider-order`).
*/
export type ChainReorder = (
chain: ProviderChainResolved[],
) => ProviderChainResolved[];
export interface ResolveProviderOptions {
/** Reorder the resolved chain before picking. Defaults to identity. */
reorder?: ChainReorder;
}
/** A vision-capable provider pick: its instance, logical tag, and model. */
export interface VisionProviderPick {
/** The full resolved chain (for `assertConsentForChain`). */
@@ -61,6 +76,7 @@ export interface VisionProviderPick {
*/
export async function resolveVisionProvider(
userId: string,
options?: ResolveProviderOptions,
): Promise<VisionProviderPick> {
const userRow = await prisma.user.findUnique({
where: { id: userId },
@@ -84,16 +100,22 @@ export async function resolveVisionProvider(
adminModel: settings?.adminAiModel ?? null,
};
const chain = ocr.chain;
if (chain.length === 0) {
const resolvedChain = ocr.chain;
if (resolvedChain.length === 0) {
// Mirror the Coach: fall back to the legacy single-provider resolution and
// tag it as the admin-managed entry the consent gate recognises.
const legacy = await resolveProvider(userId);
if (legacy.type !== "none") {
chain.push({ providerType: "admin-openai", instance: legacy });
resolvedChain.push({ providerType: "admin-openai", instance: legacy });
}
}
// Apply the caller's reorder (document class demotes external providers below
// local) before picking; labs passes no reorder → identity.
const chain = options?.reorder
? options.reorder(resolvedChain)
: resolvedChain;
for (const entry of chain) {
const providerType = entry.providerType as VisionProviderType;
const model = modelForEntry(entry.providerType, ctx);
@@ -119,19 +141,25 @@ export async function resolveVisionProvider(
* so this returns the whole chain plus the first entry to drive the structuring
* call. Returns `null` when nothing is configured.
*/
export async function resolveTextProvider(userId: string): Promise<{
export async function resolveTextProvider(
userId: string,
options?: ResolveProviderOptions,
): Promise<{
chain: ProviderChainResolved[];
pick: { entry: ProviderChainResolved; providerType: string } | null;
}> {
// v1.22 (#90) — use the dedicated document-scan provider when enabled, else
// the main chain (the text-mode structuring pass needs no vision).
const chain = (await resolveOcrProviderChain(userId)).chain;
if (chain.length === 0) {
const resolvedChain = (await resolveOcrProviderChain(userId)).chain;
if (resolvedChain.length === 0) {
const legacy = await resolveProvider(userId);
if (legacy.type !== "none") {
chain.push({ providerType: "admin-openai", instance: legacy });
resolvedChain.push({ providerType: "admin-openai", instance: legacy });
}
}
const chain = options?.reorder
? options.reorder(resolvedChain)
: resolvedChain;
const entry = chain[0];
return {
chain,
@@ -147,8 +175,12 @@ export async function resolveTextProvider(userId: string): Promise<{
*/
export async function resolveOcrCapability(
userId: string,
options?: ResolveProviderOptions,
): Promise<OcrCapabilityDto> {
const { chain, pick, localOcrEnabled } = await resolveVisionProvider(userId);
const { chain, pick, localOcrEnabled } = await resolveVisionProvider(
userId,
options,
);
// Prefer the native vision path whenever it is available — it is more
// accurate and the client does not download the OCR WASM.
+32
View File
@@ -334,6 +334,38 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/documents/inbound/capability": {
get: {
tags: ["Documents"],
summary: "Probe document-AI availability + egress",
description:
'Cheap probe (no provider call) the vault uses to decide the AI transport and whether to warn before a read leaves the machine. Resolved over the DOCUMENT provider order (local-first, ChatGPT-subscription OAuth last), so `mode` / `pdfSupported` / `egress` match exactly what the document AI routes do. `egress` is vendor-blind: `"local"` (a self-hosted model — the document never leaves the operator\'s machine) or `"external"` (a third-party AI service). The vault shows a per-egress notice before any external document read; sending a document to any external provider also requires an active AI-consent receipt.',
responses: {
"200": {
description: "Document-AI capability flags + egress class.",
content: {
"application/json": {
schema: dataEnvelope(
z
.object({
available: z.boolean(),
mode: z.enum(["vision", "text"]).nullable(),
reason: z
.enum(["no-provider", "enable-local-ocr"])
.nullable(),
pdfSupported: z.boolean(),
egress: z.enum(["local", "external"]).nullable(),
})
.meta({ id: "DocumentAiCapability" }),
"DocumentAiCapabilityEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/documents/inbound/usage": {
get: {
tags: ["Documents"],
+8
View File
@@ -58,4 +58,12 @@ export const documentKeys = {
* delete so the quota bar tracks reality.
*/
inboundDocumentUsage: () => ["documents", "inbound", "usage"] as const,
/**
* Document-scoped AI capability probe
* (`GET /api/documents/inbound/capability`). Distinct from the labs
* `ocrCapability` key because it resolves over the DOCUMENT provider order
* (local-first) and carries the per-egress class the vault notice reads.
*/
inboundDocumentAiCapability: () =>
["documents", "inbound", "ai-capability"] as const,
};
+24
View File
@@ -594,6 +594,30 @@ export interface DocumentUsageDto {
};
}
/** Where a document read egresses, vendor-blind. */
export type DocumentEgressClass = "local" | "external";
/**
* The document-scoped AI capability probe response
* (`GET /api/documents/inbound/capability`). Resolved over the DOCUMENT
* provider order (local-first, codex last), so `mode` / `pdfSupported` /
* `egress` match exactly what the document AI routes will do. `egress` drives
* the vault's per-egress "this leaves your machine to a third-party AI" notice.
*/
export interface DocumentAiCapabilityDto {
available: boolean;
mode: "vision" | "text" | null;
reason: "no-provider" | "enable-local-ocr" | null;
pdfSupported: boolean;
/**
* Where a document read will egress with the current provider order:
* - "local": stays on the operator's machine (self-hosted model).
* - "external": leaves the machine to a third-party AI service.
* - null: no read is available (see `reason`).
*/
egress: DocumentEgressClass | null;
}
// ─── AI assist / summary / index (Document vault P2) ────────────────────────
/**