From 8e5f5f04ae0510d9aafb3bac66cda08ece9e9c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:49:30 +0200 Subject: [PATCH] feat(documents): route vault AI surfaces through document provider order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repoint suggest/summary/extract/index/reindex routes and the content-index backfill job to the document-scoped resolvers (local-first, codex last) and the pick-based document-egress consent gate. Labs OCR and Coach/insights untouched. Co-Authored-By: Marc-André Bombeck --- .../documents/inbound/[id]/extract/route.ts | 24 ++++++++++++------- .../api/documents/inbound/[id]/index/route.ts | 17 ++++++++----- .../documents/inbound/[id]/suggest/route.ts | 24 ++++++++++++------- .../documents/inbound/[id]/summary/route.ts | 24 ++++++++++++------- .../api/documents/inbound/reindex/route.ts | 12 ++++++---- .../jobs/document-content-index-backfill.ts | 19 +++++++++------ 6 files changed, 79 insertions(+), 41 deletions(-) diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 765f556c7..2b54f6785 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -31,7 +31,7 @@ import { safeJson, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; import { buildDateKey, reconcileSpend, @@ -52,9 +52,9 @@ import { serialiseDocumentDetail, } from "@/lib/documents/store"; import { - resolveTextProvider, - resolveVisionProvider, -} from "@/lib/labs/ocr-capability"; + resolveDocumentTextProvider, + resolveDocumentVisionProvider, +} from "@/lib/documents/provider-order"; import { detectOcrMimeType } from "@/lib/labs/ocr-upload"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -200,14 +200,18 @@ async function handleTextExtract( }); } - const { chain, pick } = await resolveTextProvider(userId); + const { pick } = await resolveDocumentTextProvider(userId); if (!pick) { return apiError("No AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); const rl = await checkRateLimit( `documents-inbound:${userId}`, @@ -298,14 +302,18 @@ async function handleVisionExtract( userId: string, document: LoadedDocument, ): Promise { - const { chain, pick } = await resolveVisionProvider(userId); + const { pick } = await resolveDocumentVisionProvider(userId); if (!pick) { return apiError("No vision-capable AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); const rl = await checkRateLimit( `documents-inbound:${userId}`, diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index 7faad4a29..0d318a235 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -14,8 +14,9 @@ * Decision (maintainer, 2026-07-07): content indexing is gated on the EXISTING * AI consent / provider gate — there is NO separate `documentsContentIndexEnabled` * toggle (the plan's P2-D8 opt-in was refused to avoid toggle sprawl). The vision - * path runs `assertConsentForChain`; the text path rides the local-OCR opt-in the - * lab / extract text mode already uses. + * path runs `assertDocumentEgressConsent` (any external provider needs an active + * receipt; a local pick stays ungated); the text path rides the local-OCR opt-in + * the lab / extract text mode already uses — no provider egress at all. * * Persists ONLY AES-256-GCM ciphertext text + opaque HMAC token hashes (A4). */ @@ -29,7 +30,7 @@ import { safeJson, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; import { buildDateKey, reconcileSpend, @@ -51,7 +52,7 @@ import { DocumentDescribeError, transcribeDocument, } from "@/lib/documents/describe"; -import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; @@ -164,13 +165,17 @@ async function handleVisionIndex( userId: string, document: LoadedDocument, ): Promise { - const { chain, pick } = await resolveVisionProvider(userId); + const { pick } = await resolveDocumentVisionProvider(userId); if (!pick) { return apiError("No vision-capable AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); const rl = await checkRateLimit( `${DOCUMENT_AI_BUCKET}:${userId}`, diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 354b69701..e0670d3f7 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -22,7 +22,7 @@ import { safeJson, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; import { buildDateKey, reconcileSpend, @@ -45,9 +45,9 @@ import { type LoadedDocument, } from "@/lib/documents/ai-route-support"; import { - resolveTextProvider, - resolveVisionProvider, -} from "@/lib/labs/ocr-capability"; + resolveDocumentTextProvider, + resolveDocumentVisionProvider, +} from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; @@ -133,14 +133,18 @@ async function handleTextSuggest( }); } - const { chain, pick } = await resolveTextProvider(userId); + const { pick } = await resolveDocumentTextProvider(userId); if (!pick) { return apiError("No AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); const rl = await checkRateLimit( `${DOCUMENT_AI_BUCKET}:${userId}`, @@ -209,14 +213,18 @@ async function handleVisionSuggest( userId: string, document: LoadedDocument, ): Promise { - const { chain, pick } = await resolveVisionProvider(userId); + const { pick } = await resolveDocumentVisionProvider(userId); if (!pick) { return apiError("No vision-capable AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); const rl = await checkRateLimit( `${DOCUMENT_AI_BUCKET}:${userId}`, diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index f8acd0d80..6d09219cb 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -23,7 +23,7 @@ import { safeJson, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; import { buildDateKey, reconcileSpend, @@ -47,9 +47,9 @@ import { type DescribeInput, } from "@/lib/documents/describe"; import { - resolveTextProvider, - resolveVisionProvider, -} from "@/lib/labs/ocr-capability"; + resolveDocumentTextProvider, + resolveDocumentVisionProvider, +} from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; @@ -188,13 +188,17 @@ async function handleTextSummary( return finishSummary(request, userId, document.id, "text", mode, result); } - const { chain, pick } = await resolveTextProvider(userId); + const { pick } = await resolveDocumentTextProvider(userId); if (!pick) { return apiError("No AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); const dateKey = buildDateKey(); const reservation = await reserveBudget( @@ -235,13 +239,17 @@ async function handleVisionSummary( document: LoadedDocument, mode: DocumentSummaryMode, ): Promise { - const { chain, pick } = await resolveVisionProvider(userId); + const { pick } = await resolveDocumentVisionProvider(userId); if (!pick) { return apiError("No vision-capable AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); const rl = await checkRateLimit( `${DOCUMENT_AI_BUCKET}:${userId}`, diff --git a/src/app/api/documents/inbound/reindex/route.ts b/src/app/api/documents/inbound/reindex/route.ts index 0b396e09c..c8f97cf9b 100644 --- a/src/app/api/documents/inbound/reindex/route.ts +++ b/src/app/api/documents/inbound/reindex/route.ts @@ -9,10 +9,10 @@ */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; import { auditLog } from "@/lib/auth/audit"; import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill"; -import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; +import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -46,13 +46,17 @@ 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 { chain, pick } = await resolveVisionProvider(user.id); + const { pick } = await resolveDocumentVisionProvider(user.id); if (!pick) { return apiError("No vision-capable AI provider is configured", 422, { errorCode: "documents.inbound.providerUnsupported", }); } - await assertConsentForChain({ userId: user.id, chain, surface: "insights" }); + await assertDocumentEgressConsent({ + userId: user.id, + providerType: pick.providerType, + surface: "insights", + }); const { enqueued } = await enqueueContentIndexBackfill(user.id); diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index 6bba8b7e3..a9e78f413 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -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 { - 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" };