feat(documents): per-egress notice + document-AI capability probe

Add GET /api/documents/inbound/capability (document provider order + vendor-blind
egress class), repoint the vault capability hook to it, and show a per-egress
notice in the detail sheet before a document read leaves the machine. Cover the
document provider order, the egress consent gate, and the notice with tests.
i18n x6 + OpenAPI regenerated.

Co-Authored-By: Marc-André Bombeck
This commit is contained in:
Marc-André BombeckandMarc-André Bombeck committed 2026-07-08 23:59:56 +02:00
1 parent c1e11e284a
commit c825d4b501
18 files changed
+427 -32

No files matched your search

+73
View File
@@ -8905,6 +8905,27 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/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:
$ref: "#/components/schemas/DocumentAiCapabilityEnvelope"
"401": *a1
"422": *a3
"429": *a4
/api/documents/inbound/usage:
get:
tags:
@@ -26253,6 +26274,58 @@ components:
- data
- error
additionalProperties: false
DocumentAiCapabilityEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/DocumentAiCapability"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
DocumentAiCapability:
type: object
properties:
available:
type: boolean
mode:
anyOf:
- type: string
enum:
- vision
- text
- type: "null"
reason:
anyOf:
- type: string
enum:
- no-provider
- enable-local-ocr
- type: "null"
pdfSupported:
type: boolean
egress:
anyOf:
- type: string
enum:
- local
- external
- type: "null"
required:
- available
- mode
- reason
- pdfSupported
- egress
additionalProperties: false
DocumentUsageEnvelope:
type: object
properties:
+3
View File
@@ -8172,6 +8172,9 @@
"searchPlaceholder": "Nach Titel oder Dateiname suchen",
"empty": "Keine Dokumente gefunden."
},
"ai": {
"egressNotice": "Dies sendet den Inhalt des Dokuments an deinen konfigurierten KI-Anbieter — es verlässt dieses Gerät zu einem KI-Dienst eines Drittanbieters. Ist der Anbieter ein persönliches Abonnement, gelten dessen eigene Datenschutz-Einstellungen, einschließlich Modelltraining, sofern du es dort nicht deaktiviert hast."
},
"assist": {
"suggest": "Details vorschlagen",
"suggesting": "Wird gelesen…",
+3
View File
@@ -8172,6 +8172,9 @@
"searchPlaceholder": "Search by title or filename",
"empty": "No documents found."
},
"ai": {
"egressNotice": "This sends the document's contents to your configured AI provider — it leaves this machine to a third-party AI service. If that provider is a personal subscription plan, its own data-use settings apply, including model training unless you have turned it off there."
},
"assist": {
"suggest": "Suggest details",
"suggesting": "Reading…",
+3
View File
@@ -8172,6 +8172,9 @@
"searchPlaceholder": "Buscar por título o nombre de archivo",
"empty": "No se encontraron documentos."
},
"ai": {
"egressNotice": "Esto envía el contenido del documento a tu proveedor de IA configurado — sale de esta máquina hacia un servicio de IA de terceros. Si ese proveedor es una suscripción personal, se aplican sus propios ajustes de uso de datos, incluido el entrenamiento del modelo salvo que lo hayas desactivado allí."
},
"assist": {
"suggest": "Sugerir detalles",
"suggesting": "Leyendo…",
+3
View File
@@ -8172,6 +8172,9 @@
"searchPlaceholder": "Rechercher par titre ou nom de fichier",
"empty": "Aucun document trouvé."
},
"ai": {
"egressNotice": "Ceci envoie le contenu du document à votre fournisseur d'IA configuré — il quitte cette machine vers un service d'IA tiers. Si ce fournisseur est un abonnement personnel, ses propres paramètres d'utilisation des données s'appliquent, y compris l'entraînement du modèle sauf si vous l'avez désactivé là-bas."
},
"assist": {
"suggest": "Suggérer des détails",
"suggesting": "Lecture…",
+3
View File
@@ -8172,6 +8172,9 @@
"searchPlaceholder": "Cerca per titolo o nome del file",
"empty": "Nessun documento trovato."
},
"ai": {
"egressNotice": "Questo invia il contenuto del documento al provider IA configurato — lascia questa macchina verso un servizio IA di terze parti. Se quel provider è un abbonamento personale, si applicano le sue impostazioni sull'uso dei dati, incluso l'addestramento del modello a meno che tu non l'abbia disattivato lì."
},
"assist": {
"suggest": "Suggerisci dettagli",
"suggesting": "Lettura…",
+3
View File
@@ -8172,6 +8172,9 @@
"searchPlaceholder": "Szukaj po tytule lub nazwie pliku",
"empty": "Nie znaleziono dokumentów."
},
"ai": {
"egressNotice": "To wysyła treść dokumentu do skonfigurowanego dostawcy AI — opuszcza ona to urządzenie i trafia do zewnętrznej usługi AI. Jeśli ten dostawca to subskrypcja osobista, obowiązują jego własne ustawienia wykorzystania danych, w tym trenowanie modelu, o ile nie wyłączysz go tam."
},
"assist": {
"suggest": "Zaproponuj szczegóły",
"suggesting": "Odczytywanie…",
@@ -0,0 +1,42 @@
/**
* GET /api/documents/inbound/capability
*
* Document-scoped AI capability probe (no provider call). Mirrors the labs
* `/api/labs/ocr/capability` probe but resolves over the DOCUMENT provider
* order (local-first, codex last — governance fix, oauth-investigation
* SYNTHESIS §1) and adds the vendor-blind `egress` class so the vault UI can
* show the "this leaves your machine to a third-party AI" notice BEFORE a read.
*
* The `mode` / `pdfSupported` / `egress` here reflect exactly what the document
* routes (suggest / summary / extract / index) will do, so the affordance the
* UI offers never diverges from what the endpoint accepts.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { resolveDocumentAiCapability } from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
const capability = await resolveDocumentAiCapability(user.id);
annotate({
action: { name: "documents.ai.capability" },
meta: {
available: capability.available,
mode: capability.mode,
reason: capability.reason,
pdfSupported: capability.pdfSupported,
egress: capability.egress,
},
});
return apiSuccess(capability);
});
@@ -33,6 +33,7 @@ function base(
aiEnabled: true,
indexEnabled: true,
unavailableReason: null,
egressExternal: false,
actionsDisabled: false,
onSuggest: noop,
suggestPending: false,
@@ -106,6 +107,22 @@ describe("<DocumentAiSection>", () => {
expect(html).toContain("Not saved");
});
it("shows the vendor-blind egress notice before an external document read", () => {
const html = render(base({ aiEnabled: true, egressExternal: true }));
expect(html).toContain('data-slot="document-ai-egress-notice"');
expect(html).toContain("third-party AI service");
// Vendor-blind: never names a specific AI vendor in the document surface.
expect(html).not.toContain("OpenAI");
expect(html).not.toContain("ChatGPT");
// The actions are still offered — the notice informs, it does not block.
expect(html).toContain('data-slot="assist-suggest"');
});
it("omits the egress notice when the read stays on the local machine", () => {
const html = render(base({ aiEnabled: true, egressExternal: false }));
expect(html).not.toContain('data-slot="document-ai-egress-notice"');
});
it("hides the content-index row when indexing is unavailable", () => {
const html = render(base({ aiEnabled: true, indexEnabled: false }));
expect(html).toContain('data-slot="assist-suggest"');
@@ -11,7 +11,7 @@
* calm "set up an AI provider" pointer — never an error, and the manual form
* below stays fully usable.
*/
import { FileText, Sparkles } from "lucide-react";
import { FileText, ShieldAlert, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
@@ -32,6 +32,7 @@ export function DocumentAiSection({
aiEnabled,
indexEnabled,
unavailableReason,
egressExternal,
actionsDisabled,
onSuggest,
suggestPending,
@@ -57,6 +58,12 @@ export function DocumentAiSection({
aiEnabled: boolean;
indexEnabled: boolean;
unavailableReason: "no-provider" | "enable-local-ocr" | null;
/**
* True when a document read will egress to a third-party AI service (any
* provider other than a self-hosted local model). Drives the vendor-blind
* "this leaves your machine" notice shown before the AI actions.
*/
egressExternal: boolean;
/** Capability still resolving — the transport mode isn't known yet. */
actionsDisabled: boolean;
onSuggest: () => void;
@@ -92,6 +99,17 @@ export function DocumentAiSection({
return (
<div className="space-y-3" data-slot="document-ai-section">
{egressExternal ? (
<div
data-slot="document-ai-egress-notice"
role="note"
className="border-border text-muted-foreground flex items-start gap-2 rounded-lg border border-dashed px-3 py-2.5 text-xs"
>
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<p className="min-w-0">{t("documents.ai.egressNotice")}</p>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
@@ -505,6 +505,7 @@ export function DocumentDetailSheet({
aiEnabled={aiEnabled}
indexEnabled={indexEnabled}
unavailableReason={capability.data?.reason ?? null}
egressExternal={capability.data?.egress === "external"}
actionsDisabled={capability.isPending}
onSuggest={runSuggest}
suggestPending={suggest.isPending}
@@ -17,8 +17,8 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { apiGet, ApiError } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { OcrCapabilityDto } from "@/lib/validations/labs-ocr";
import type {
DocumentAiCapabilityDto,
DocumentSuggestionDto,
DocumentSummaryMode,
} from "@/lib/validations/inbound-documents";
@@ -34,16 +34,18 @@ import {
export type DocumentDescribeResult = { summary: string } | { text: string };
/**
* The shared OCR capability probe (`/api/labs/ocr/capability`) reused verbatim:
* the same server resolution both the lab-OCR and the document AI routes gate
* on, so the client picks the transport the endpoint accepts. Availability of
* the affordance itself is gated on `usage.assistAvailable`; this read only
* resolves the transport `mode` + PDF support.
* The document-scoped AI capability probe (`/api/documents/inbound/capability`).
* Resolved over the DOCUMENT provider order (local-first, codex last), so the
* transport `mode` + PDF support match what the document routes do, and it
* carries the vendor-blind `egress` class the detail sheet uses to warn before a
* read leaves the machine. Availability of the affordance itself is still gated
* on `usage.assistAvailable`.
*/
export function useDocumentAiCapability(enabled: boolean) {
return useQuery<OcrCapabilityDto>({
queryKey: queryKeys.ocrCapability(),
queryFn: () => apiGet<OcrCapabilityDto>("/api/labs/ocr/capability"),
return useQuery<DocumentAiCapabilityDto>({
queryKey: queryKeys.inboundDocumentAiCapability(),
queryFn: () =>
apiGet<DocumentAiCapabilityDto>("/api/documents/inbound/capability"),
enabled,
staleTime: 60_000,
});
@@ -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();
});
});
@@ -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"]);
});
});
+4 -22
View File
@@ -32,6 +32,10 @@ import {
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.
@@ -95,9 +99,6 @@ export function resolveDocumentTextProvider(userId: string) {
});
}
/** Where a document read egresses, vendor-blind. */
export type DocumentEgressClass = "local" | "external";
/**
* 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
@@ -107,25 +108,6 @@ export function documentEgressClass(providerType: string): DocumentEgressClass {
return isExternalDocumentEgress(providerType) ? "external" : "local";
}
/**
* The document-scoped capability probe. Mirrors `resolveOcrCapability` but over
* the document provider order, and adds the `egress` class so the vault UI can
* show the "this leaves your machine to a third-party AI" notice BEFORE a read.
*/
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;
}
/**
* Resolve the document AI capability for the vault UI. The `mode` /
* `pdfSupported` / `egress` reflect the DOCUMENT provider order (local-first),
+32
View File
@@ -331,6 +331,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
@@ -548,6 +548,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) ────────────────────────
/**