diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ccd1197d7..c21ebb4e7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12772,6 +12772,7 @@ paths: - ai_full - ai_insights_only - ai_coach + - ai_extraction description: Narrow to one consent kind. Omit for the full keyspace. responses: "200": @@ -12783,8 +12784,9 @@ paths: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" "400": &a22 - description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope; - nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." + description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`. + Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the + API answers 422." content: application/json: schema: @@ -12807,9 +12809,18 @@ paths: each revocation is audit-logged. - Revoking `ai_full` does NOT switch off `documentsAutoAiRead`; that flag is written through PATCH - /api/auth/me/documents-auto-ai-read and turning it on mints a fresh receipt. A client offering both controls - should show them as the two separate things they are. + Revoking `ai_full` or `ai_extraction` does NOT switch off `documentsAutoAiRead`, but it does stop document + reading: the document gate reads the receipt, not the switch, so a revocation wins over a switch left on. + Turning the switch on again mints a fresh `ai_extraction` receipt through PATCH + /api/auth/me/documents-auto-ai-read. A client offering both controls should show them as the two separate things + they are. + + + When a revocation leaves no receipt covering the analysis (`ai_insights_only` or `ai_full`), the same + transaction deletes the text a model wrote under it that can be written again: per-metric status notes, the + cached briefing, model-written period narratives, reaction lines and workout paragraphs. Coach conversations, + facts and plans and document summaries are the person's records and stay. A deletion is audit-logged with counts + only (`consent.ai.purge`). parameters: - in: query name: kind @@ -12820,6 +12831,7 @@ paths: - ai_full - ai_insights_only - ai_coach + - ai_extraction description: Revoke one kind. Omit to revoke the latest active receipt across every kind. responses: "200": @@ -23990,8 +24002,8 @@ components: type: boolean required: - documentsAutoAiRead - description: Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_full` - consent receipt and schedules a catch-up over the existing vault. + description: Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an + `ai_extraction` consent receipt and schedules a catch-up over the existing vault. InjectionSitePrefsPatchRequest: type: object properties: @@ -26149,6 +26161,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction artefact: type: string minLength: 1 @@ -58163,6 +58176,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction signedAt: type: string revokedAt: @@ -58259,6 +58273,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction receipt: anyOf: - $ref: "#/components/schemas/SerialisedConsentReceipt" @@ -58281,6 +58296,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction signedAt: type: string format: date-time @@ -58337,13 +58353,18 @@ components: anyOf: - $ref: "#/components/schemas/SerialisedConsentReceipt" - type: "null" + ai_extraction: + anyOf: + - $ref: "#/components/schemas/SerialisedConsentReceipt" + - type: "null" required: - ai_full - ai_insights_only - ai_coach + - ai_extraction additionalProperties: false - description: "The latest active receipt for every kind. All three keys are always present: a null value means the kind - is not granted, so a client never has to read absence as ambiguity." + description: "The latest active receipt for every kind. Every key is always present: a null value means the kind is not + granted, so a client never has to read absence as ambiguity." ConsentRevokeForKindEnvelope: type: object properties: @@ -58392,6 +58413,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction receipt: $ref: "#/components/schemas/SerialisedConsentReceipt" required: diff --git a/src/__tests__/ai-consent-enforcement-guard.test.ts b/src/__tests__/ai-consent-enforcement-guard.test.ts index 874012310..a18d03239 100644 --- a/src/__tests__/ai-consent-enforcement-guard.test.ts +++ b/src/__tests__/ai-consent-enforcement-guard.test.ts @@ -70,8 +70,8 @@ const CONSENT_HELPERS = [ "assertConsentForChain", "chainRequiresServerManagedConsent", "hasActiveConsentForSurface", - // Document-class, pick-shaped. - "assertDocumentEgressConsent", + // Document-class, pick-shaped (the receipt itself is read by the capability + // egress re-check below). "isExternalDocumentEgress", ] as const; diff --git a/src/__tests__/ai-egress-capability-guard.test.ts b/src/__tests__/ai-egress-capability-guard.test.ts index a8c7acce9..123c56808 100644 --- a/src/__tests__/ai-egress-capability-guard.test.ts +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -29,9 +29,13 @@ const SRC = join(process.cwd(), "src"); const EGRESS_CALL = /\.generateCompletion(?:Stream)?\s*\(|\brunRawCompletionWithFallback\s*\(|\brunWithFallback\s*\(/; -/** A capability read in the same file. */ +/** + * A capability read in the same file: one of the gates, or the wire re-check + * (`aiEgressRefusal` / `assertAiEgress`), which also answers the consent a + * picked provider needs. + */ const CAPABILITY_CHECK = - /\b(?:aiCapabilityForJob|aiCapabilityForRecord|requireAiCapability|getAiCapability)\s*\(/; + /\b(?:aiCapabilityForJob|aiCapabilityForRecord|requireAiCapability|getAiCapability|aiEgressRefusal|assertAiEgress)\s*\(/; /** Comments name the helpers too; only code counts. */ function stripComments(source: string): string { @@ -71,7 +75,7 @@ const EGRESS_SITES: Record = { "lib/jobs/reaction-line.ts": { recheck: true, reason: - "Reaction lines call the first chain entry directly; the worker resolves `reactionLines` before the digest or the chain.", + "Reaction lines call the first chain entry directly; the worker resolves `reactionLines` before the digest and re-checks the chain at the wire (`aiEgressRefusal`).", }, "lib/jobs/coach-nudge-ai.ts": { recheck: true, diff --git a/src/app/api/auth/me/documents-auto-ai-read/route.ts b/src/app/api/auth/me/documents-auto-ai-read/route.ts index c5e175fea..ee1161184 100644 --- a/src/app/api/auth/me/documents-auto-ai-read/route.ts +++ b/src/app/api/auth/me/documents-auto-ai-read/route.ts @@ -37,6 +37,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { AI_CAPABILITIES } from "@/lib/ai/capabilities/types"; +import { AI_EXTRACTION_CONSENT_KIND } from "@/lib/consent/receipts"; import { prisma } from "@/lib/db"; import { isP2002 } from "@/lib/prisma-errors"; import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup"; @@ -57,7 +58,7 @@ type DocumentsAutoAiReadResponse = { * master `ai_full`. The capability table lists it (with `ai_full`) as what * satisfies the document consent rule. */ -const AUTO_READ_CONSENT_KIND = "ai_extraction"; +const AUTO_READ_CONSENT_KIND = AI_EXTRACTION_CONSENT_KIND; /** * Mint the extraction receipt unless one that satisfies document reads is diff --git a/src/lib/ai/__tests__/consent-guard.test.ts b/src/lib/ai/__tests__/consent-guard.test.ts index 939856a24..57fcf0495 100644 --- a/src/lib/ai/__tests__/consent-guard.test.ts +++ b/src/lib/ai/__tests__/consent-guard.test.ts @@ -1,15 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/consent/receipts", () => ({ - AI_EXTRACTION_CONSENT_KIND: "ai_extraction", latestActiveReceipt: vi.fn(), })); import { ConsentRequiredError, assertConsentForChain, - assertDocumentEgressConsent, - assertExtractionConsentForChain, chainRequiresServerManagedConsent, hasActiveConsentForSurface, isExternalDocumentEgress, @@ -200,73 +197,14 @@ describe("isExternalDocumentEgress", () => { }); }); -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" }), - ).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" }), - ).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 }), - ).rejects.toBeInstanceOf(ConsentRequiredError); - } - }); - - it("proceeds for an external pick with an active extraction receipt", async () => { - grant(["ai_extraction"]); - await expect( - assertDocumentEgressConsent({ userId: "u1", providerType: "codex" }), - ).resolves.toBeUndefined(); - }); - - it("proceeds for an external pick with the master ai_full receipt", async () => { - grant(["ai_full"]); - await expect( - assertDocumentEgressConsent({ userId: "u1", providerType: "openai" }), - ).resolves.toBeUndefined(); - }); - - // K1: the analysis and the Coach consents no longer cover documents. - it("refuses an external pick when only the analysis or Coach consent is active", async () => { - for (const kinds of [ - ["ai_insights_only"], - ["ai_coach"], - ] as ConsentKind[][]) { - grant(kinds); - await expect( - assertDocumentEgressConsent({ userId: "u1", providerType: "codex" }), - ).rejects.toBeInstanceOf(ConsentRequiredError); - } - }); - - it("names the extraction group on the refusal", async () => { - grant([]); - await expect( - assertDocumentEgressConsent({ userId: "u1", providerType: "codex" }), - ).rejects.toMatchObject({ surface: "extraction" }); - }); -}); - -describe("extraction consent does not satisfy the other groups", () => { - it("an ai_extraction receipt alone satisfies neither the Coach nor the analysis", async () => { +// The document-class receipt (`ai_extraction` or `ai_full`) is read by the +// capability egress re-check, not here: see +// `src/lib/ai/capabilities/__tests__/egress.test.ts` and +// `tests/integration/consent-withdrawal-purge.test.ts`. What this module still +// owes the document surfaces is the local/external split above, and that the +// extraction kind satisfies neither chain surface. +describe("an extraction receipt does not satisfy the chain surfaces", () => { + it("satisfies neither the Coach nor the analysis", async () => { grant(["ai_extraction"]); await expect(hasActiveConsentForSurface("u1", "coach")).resolves.toBe( false, @@ -274,51 +212,5 @@ describe("extraction consent does not satisfy the other groups", () => { await expect(hasActiveConsentForSurface("u1", "insights")).resolves.toBe( false, ); - await expect(hasActiveConsentForSurface("u1", "extraction")).resolves.toBe( - true, - ); - }); -}); - -describe("assertExtractionConsentForChain (lab report scans)", () => { - beforeEach(() => mockedLatest.mockReset()); - - it("is a no-op for a chain of local entries only", async () => { - grant([]); - await expect( - assertExtractionConsentForChain({ - userId: "u1", - chain: [entry("local")], - }), - ).resolves.toBeUndefined(); - expect(mockedLatest).not.toHaveBeenCalled(); - }); - - it("requires an extraction receipt for a BYOK chain, unlike the self-snapshot rule", async () => { - grant([]); - await expect( - assertExtractionConsentForChain({ - userId: "u1", - chain: [entry("local"), entry("anthropic")], - }), - ).rejects.toBeInstanceOf(ConsentRequiredError); - // The self-snapshot rule would have admitted the same chain. - await expect( - assertConsentForChain({ - userId: "u1", - chain: [entry("anthropic")], - surface: "insights", - }), - ).resolves.toBeUndefined(); - }); - - it("admits the chain with an extraction receipt", async () => { - grant(["ai_extraction"]); - await expect( - assertExtractionConsentForChain({ - userId: "u1", - chain: [entry("openai")], - }), - ).resolves.toBeUndefined(); }); }); diff --git a/src/lib/ai/consent-guard.ts b/src/lib/ai/consent-guard.ts index bd243b2cc..7774e836d 100644 --- a/src/lib/ai/consent-guard.ts +++ b/src/lib/ai/consent-guard.ts @@ -27,26 +27,17 @@ * failure and we must not race that decision. A receipt of the surface's * mapped kind OR the superset `ai_full` grant satisfies the gate. */ -import { - AI_EXTRACTION_CONSENT_KIND, - latestActiveReceipt, -} from "@/lib/consent/receipts"; +import { latestActiveReceipt } from "@/lib/consent/receipts"; import type { ConsentKind } from "@/lib/validations/consent"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; /** - * The consent groups that gate PHI egress. Each maps to the consent kind - * collected for it; `ai_full` (the master grant) satisfies every one. - * - * - `coach`: the Coach (`ai_coach`). - * - `insights`: model-written analysis over the person's own readings - * (`ai_insights_only`). - * - `extraction`: reading a document, a lab report scan or typed medication - * text (`ai_extraction`, the narrower grant the document auto-read toggle - * mints). It no longer satisfies the Coach or the analysis, and neither of - * those satisfies it. + * The two AI surfaces that egress PHI through a chain. Each maps to the consent + * kind the iOS client collects for it; `ai_full` (the master grant) satisfies + * either. Reading documents has its own kind (`ai_extraction`), checked by the + * capability egress re-check rather than here. */ -export type ConsentSurface = "coach" | "insights" | "extraction"; +export type ConsentSurface = "coach" | "insights"; /** * Provider tags that egress via an operator-managed credential the user did not @@ -95,14 +86,9 @@ export function chainRequiresServerManagedConsent( /** The consent kinds that satisfy a given surface (the specific + the master). */ function acceptableKinds(surface: ConsentSurface): ConsentKind[] { - switch (surface) { - case "coach": - return ["ai_coach", "ai_full"]; - case "insights": - return ["ai_insights_only", "ai_full"]; - case "extraction": - return [AI_EXTRACTION_CONSENT_KIND, "ai_full"]; - } + return surface === "coach" + ? ["ai_coach", "ai_full"] + : ["ai_insights_only", "ai_full"]; } /** @@ -156,7 +142,11 @@ export async function assertConsentForChain(args: { * 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. + * the operator's admin key) requires an active extraction receipt + * (`ai_extraction` or `ai_full`). The check itself runs through the capability + * egress re-check (`aiEgressRefusal` in `@/lib/ai/capabilities/egress`), which + * reads the receipt kinds from the capability table; the auto-read toggle is a + * trigger, never the consent, so a revoked receipt wins over a toggle left on. */ const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet = new Set(["local"]); @@ -168,49 +158,3 @@ const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet = new Set(["local"]); 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 extraction receipt (`ai_extraction`, or the master `ai_full`) is on - * file. - * - * The document auto-read toggle is NOT a shortcut here any more. Switching it - * on mints an `ai_extraction` receipt, and that receipt is what this reads, so - * a receipt the person later revokes wins over a toggle still left on: the - * withdrawal is honoured at the next egress, not at the next toggle flip. - * - * Call this AFTER the document provider is picked and BEFORE the first - * `generateCompletion` on it. - */ -export async function assertDocumentEgressConsent(args: { - userId: string; - providerType: string; -}): Promise { - if (!isExternalDocumentEgress(args.providerType)) return; - if (await hasActiveConsentForSurface(args.userId, "extraction")) return; - throw new ConsentRequiredError("extraction"); -} - -/** - * The document rule for a provider CHAIN (lab report scans, which cascade - * across the chain rather than calling one picked provider). A lab report is a - * document, so any entry that would send it off the machine needs an active - * extraction receipt, whoever holds the credential; a chain of local entries - * only is ungated. Fails closed like `assertConsentForChain`: an external - * entry anywhere in the chain requires the receipt, because the runner may - * cascade to it. - */ -export async function assertExtractionConsentForChain(args: { - userId: string; - chain: ReadonlyArray; -}): Promise { - if ( - !args.chain.some((entry) => isExternalDocumentEgress(entry.providerType)) - ) { - return; - } - if (await hasActiveConsentForSurface(args.userId, "extraction")) return; - throw new ConsentRequiredError("extraction"); -} diff --git a/src/lib/consent/__tests__/web-grant.test.ts b/src/lib/consent/__tests__/web-grant.test.ts index e9650c7fc..285bc8984 100644 --- a/src/lib/consent/__tests__/web-grant.test.ts +++ b/src/lib/consent/__tests__/web-grant.test.ts @@ -21,10 +21,7 @@ vi.mock("@/lib/db", () => { }); import { prisma } from "@/lib/db"; -import { - ensureExtractionConsentReceipt, - ensureWebAiConsentReceipt, -} from "../web-grant"; +import { ensureWebAiConsentReceipt } from "../web-grant"; const $transaction = vi.mocked(prisma.$transaction) as unknown as { mockImplementation: (impl: (fn: TxFn) => unknown) => void; @@ -249,44 +246,3 @@ describe("ensureWebAiConsentReceipt — a revocation is a standing decision", () expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); }); }); - -describe("ensureExtractionConsentReceipt", () => { - it("mints an ai_extraction receipt, never the master grant", async () => { - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); - vi.mocked(prisma.consentReceipt.create).mockResolvedValue( - row({ kind: "ai_extraction" }) as never, - ); - - const result = await ensureExtractionConsentReceipt("user-1"); - - expect(result.minted).toBe(true); - const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0]; - expect(createArg.data.kind).toBe("ai_extraction"); - expect(JSON.parse(createArg.data.artefact as string).kind).toBe( - "ai_extraction", - ); - for (const [args] of vi.mocked(prisma.consentReceipt.findFirst).mock - .calls) { - expect((args as { where: { kind: string } }).where.kind).toBe( - "ai_extraction", - ); - } - }); - - it("supersedes an earlier revocation of the same kind (it is the person's own act)", async () => { - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); - vi.mocked(prisma.consentReceipt.create).mockResolvedValue( - row({ kind: "ai_extraction" }) as never, - ); - - await ensureExtractionConsentReceipt("user-1"); - - // An affirmative mint never looks for a past revocation. - for (const [args] of vi.mocked(prisma.consentReceipt.findFirst).mock - .calls) { - expect( - (args as { where: { revokedAt: unknown } }).where.revokedAt, - ).toBeNull(); - } - }); -}); diff --git a/src/lib/consent/web-grant.ts b/src/lib/consent/web-grant.ts index 292230e9b..2a122207d 100644 --- a/src/lib/consent/web-grant.ts +++ b/src/lib/consent/web-grant.ts @@ -35,10 +35,7 @@ import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; import { isP2002 } from "@/lib/prisma-errors"; -import { - AI_EXTRACTION_CONSENT_KIND, - latestActiveReceipt, -} from "@/lib/consent/receipts"; +import { latestActiveReceipt } from "@/lib/consent/receipts"; import type { ConsentReceipt } from "@/lib/consent/receipts"; /** @@ -66,55 +63,24 @@ export type WebConsentGrantResult = */ export type WebConsentGrantIntent = "heal" | "affirmative"; -/** The receipt kinds the web mints: the master grant, or extraction alone. */ -type WebConsentKind = "ai_full" | typeof AI_EXTRACTION_CONSENT_KIND; - /** * Ensure the web user has an active `ai_full` consent receipt, minting one * if absent. Idempotent: returns `{ minted: false }` when an active master * grant already exists, so callers can invoke it freely on mount. * * Mirrors the iOS `ai_full` grant. `ai_full` is the master kind that - * satisfies every surface gate (insights + coach + extraction), matching the - * iOS app's single master toggle rather than per-surface web grants. + * satisfies every surface gate (insights + coach), matching the iOS app's + * single master toggle rather than per-surface web grants. */ export async function ensureWebAiConsentReceipt( userId: string, intent: WebConsentGrantIntent, now: Date = new Date(), -): Promise { - return ensureWebConsentReceipt(userId, "ai_full", intent, now); -} - -/** - * Ensure an active `ai_extraction` receipt: the consent to read documents, - * lab report scans and typed medication text with an external model. Minted - * by switching the document auto-read on, which is the person's own - * affirmative act, so it supersedes an earlier revocation of the same kind. - * It does not satisfy the Coach or the analysis. - */ -export async function ensureExtractionConsentReceipt( - userId: string, - now: Date = new Date(), -): Promise { - return ensureWebConsentReceipt( - userId, - AI_EXTRACTION_CONSENT_KIND, - "affirmative", - now, - ); -} - -async function ensureWebConsentReceipt( - userId: string, - kind: WebConsentKind, - intent: WebConsentGrantIntent, - now: Date, ): Promise { // Fast path: a cheap read outside the transaction short-circuits the // common already-granted case (every mount after the first) without // opening a transaction. - const existing = await latestActiveReceipt(userId, kind); + const existing = await latestActiveReceipt(userId, "ai_full"); if (existing) return { minted: false, reason: "already_active" }; // A revoked receipt anywhere in this account's history means the user made @@ -122,7 +88,7 @@ async function ensureWebConsentReceipt( // affirmative act is the user deciding again, so it may proceed. if (intent === "heal") { const revoked = await prisma.consentReceipt.findFirst({ - where: { userId, kind, revokedAt: { not: null } }, + where: { userId, kind: "ai_full", revokedAt: { not: null } }, select: { id: true }, }); if (revoked) return { minted: false, reason: "previously_revoked" }; @@ -130,7 +96,7 @@ async function ensureWebConsentReceipt( const artefact = JSON.stringify({ source: "web", - kind, + kind: "ai_full", grantedAt: now.toISOString(), note: "In-app affirmative AI consent granted via the web client.", }); @@ -146,7 +112,7 @@ async function ensureWebConsentReceipt( return await prisma.$transaction( async (tx) => { const inTx = await tx.consentReceipt.findFirst({ - where: { userId, kind, revokedAt: null }, + where: { userId, kind: "ai_full", revokedAt: null }, orderBy: { createdAt: "desc" }, }); if (inTx) return { minted: false, reason: "already_active" } as const; @@ -156,7 +122,7 @@ async function ensureWebConsentReceipt( // mint would overwrite a decision made a moment earlier. if (intent === "heal") { const revokedInTx = await tx.consentReceipt.findFirst({ - where: { userId, kind, revokedAt: { not: null } }, + where: { userId, kind: "ai_full", revokedAt: { not: null } }, select: { id: true }, }); if (revokedInTx) @@ -164,7 +130,7 @@ async function ensureWebConsentReceipt( } const receipt = await tx.consentReceipt.create({ - data: { userId, kind, artefact, signedAt: now }, + data: { userId, kind: "ai_full", artefact, signedAt: now }, }); return { minted: true, receipt } as const; }, diff --git a/src/lib/documents/document-settings.ts b/src/lib/documents/document-settings.ts index 348bd504f..e07aeafd5 100644 --- a/src/lib/documents/document-settings.ts +++ b/src/lib/documents/document-settings.ts @@ -6,9 +6,10 @@ * uploaded documents. OFF by default: the vault stays local-first and every * external egress needs an explicit per-document action + consent receipt. * - * Two gates read this flag: - * - the document consent gate (`assertDocumentEgressConsent`) short-circuits an - * external pick when it is ON (the toggle IS the standing consent); + * It is a trigger, not a consent. Switching it on mints an `ai_extraction` + * receipt, and that receipt is what the document egress re-check reads, so a + * revoked receipt stops external reading even with the flag still on. Readers: + * - the summary job and its catch-up pass run only while it is ON; * - the auto-index-on-upload job stays strictly local when it is OFF and uses * the document-order external pick when it is ON. */ diff --git a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts index 39a6ffeb1..ba835d180 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts @@ -42,6 +42,11 @@ vi.mock("@/lib/db", () => ({ })); // The `briefing` capability is available in these fixtures; the refusal // path has its own test in comprehensive-generate-force.test.ts. +// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal +// arm has its own test in comprehensive-budget-refusal.test.ts. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: vi.fn(async () => ({ available: true, diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index cf4c94793..4bcad810e 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -42,6 +42,11 @@ vi.mock("@/lib/db", () => ({ })); // The `briefing` capability is available in these fixtures; the refusal // path has its own test in comprehensive-generate-force.test.ts. +// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal +// arm has its own test in comprehensive-budget-refusal.test.ts. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: vi.fn(async () => ({ available: true, diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts index 1a50d530f..9ca1ab0b9 100644 --- a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts @@ -25,7 +25,8 @@ const resolveProvider = vi.fn(); const runRawCompletionWithFallback = vi.fn(); const extractFeatures = vi.fn(); const recordBriefingFailure = vi.fn(); -const hasActiveConsentForSurface = vi.fn(); +const hasReceipt = vi.fn(); +const aiEgressRefusal = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { @@ -113,18 +114,16 @@ vi.mock("@/lib/ai/coach/about-me", () => ({ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserInsights: vi.fn(), })); -// Modelled truthfully rather than stubbed open: only an operator-credential -// chain needs a receipt, which is what lets the consent-ordering test below -// assert the real gate rather than a bypass. -vi.mock("@/lib/ai/consent-guard", () => ({ - chainRequiresServerManagedConsent: (chain: { providerType: string }[] = []) => - chain.some((c) => c.providerType.startsWith("admin-")), - hasActiveConsentForSurface: (...a: unknown[]) => - hasActiveConsentForSurface(...a), +// The wire re-check, modelled truthfully rather than stubbed open: only an +// operator-credential entry needs a receipt, which is what lets the +// consent-ordering test below assert the real gate rather than a bypass. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: (...a: unknown[]) => aiEgressRefusal(...a), })); import { generateComprehensiveInsight } from "../comprehensive-generate"; import { OPERATOR_COST_CAP, USER_PLAN_CAP } from "@/lib/ai/coach/budget"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; const VALID = JSON.stringify({ dailyBriefing: { paragraph: "ok" } }); @@ -148,7 +147,13 @@ beforeEach(() => { insightsSnapshotHash: null, insightsBriefingRerollDate: null, }); - hasActiveConsentForSurface.mockResolvedValue(true); + hasReceipt.mockResolvedValue(true); + aiEgressRefusal.mockImplementation( + async (key: never, _userId: string, providerTypes: string[]) => + providerTypes.some((t) => t.startsWith("admin-")) && !(await hasReceipt()) + ? new AiUnavailableError(key, "consent_required") + : null, + ); runRawCompletionWithFallback.mockResolvedValue({ result: { content: VALID, tokensUsed: 900, model: "m" }, workingProvider: { providerType: "admin-openai" }, @@ -224,11 +229,28 @@ describe("generateComprehensiveInsight — daily token ceiling", () => { // Reserving first would bill an operator-key user for a snapshot that is // never sent anywhere, and could lock them out of a tier they are not // even permitted to use. - hasActiveConsentForSurface.mockResolvedValue(false); + hasReceipt.mockResolvedValue(false); const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); expect(outcome).toEqual({ status: "skipped", reason: "no-consent" }); + expect(aiEgressRefusal).toHaveBeenCalledWith("briefing", "u1", [ + "admin-openai", + ]); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + expect(ledgerTotal).toBe(0); + }); + + it("reports any other wire refusal as `skipped: unavailable`, before any spend", async () => { + // A switch the operator turned off after the run was scheduled: not a + // consent question, so not reported as one. + aiEgressRefusal.mockResolvedValue( + new AiUnavailableError("briefing", "operator_disabled"), + ); + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome).toEqual({ status: "skipped", reason: "unavailable" }); expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); expect(ledgerTotal).toBe(0); }); diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 180a8a612..eb101bd99 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -50,6 +50,11 @@ vi.mock("@/lib/db", () => ({ }, }, })); +// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal +// arm has its own test in comprehensive-budget-refusal.test.ts. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a), })); diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index a3f42c45a..791557886 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -40,6 +40,11 @@ vi.mock("@/lib/db", () => ({ })); // The `briefing` capability is available in these fixtures; the refusal // path has its own test in comprehensive-generate-force.test.ts. +// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal +// arm has its own test in comprehensive-budget-refusal.test.ts. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: vi.fn(async () => ({ available: true, diff --git a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts index 4b70cab43..92607581e 100644 --- a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts +++ b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts @@ -39,6 +39,11 @@ vi.mock("@/lib/db", () => ({ })); // The `briefing` capability is available in these fixtures; the refusal // path has its own test in comprehensive-generate-force.test.ts. +// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal +// arm has its own test in comprehensive-budget-refusal.test.ts. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: vi.fn(async () => ({ available: true, diff --git a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts index 5d9fdbcee..7cc7486a0 100644 --- a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts +++ b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts @@ -44,6 +44,11 @@ vi.mock("@/lib/db", () => ({ })); // The `briefing` capability is available in these fixtures; the refusal // path has its own test in comprehensive-generate-force.test.ts. +// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal +// arm has its own test in comprehensive-budget-refusal.test.ts. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: vi.fn(async () => ({ available: true, diff --git a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts index 15304c0d1..e6f58c204 100644 --- a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts +++ b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts @@ -46,6 +46,11 @@ vi.mock("@/lib/db", () => ({ })); // The `briefing` capability is available in these fixtures; the refusal // path has its own test in comprehensive-generate-force.test.ts. +// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal +// arm has its own test in comprehensive-budget-refusal.test.ts. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: vi.fn(async () => ({ available: true, diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index 05b44961b..b1bbad4f5 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -85,15 +85,16 @@ vi.mock("@/lib/ai/provider-runner", () => ({ runRawCompletionWithFallback, })); -// Consent gate is exercised by its own suite; keep it open here so the budget -// behaviour is what these tests isolate. -vi.mock("@/lib/ai/consent-guard", () => ({ - chainRequiresServerManagedConsent: vi.fn(() => false), - hasActiveConsentForSurface: vi.fn(async () => true), -})); +// The wire re-check for the resolved chain (`aiEgressRefusal`). Its own rule +// (capability + receipt for an operator-held entry) is pinned in egress.test.ts; +// here it passes by default so the budget is what these tests isolate, and +// the refusal test below flips it. +const { aiEgressRefusal } = vi.hoisted(() => ({ aiEgressRefusal: vi.fn() })); +vi.mock("@/lib/ai/capabilities/egress", () => ({ aiEgressRefusal })); import { runStatusCompletion } from "../status-provider"; import { annotate } from "@/lib/logging/context"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { OPERATOR_COST_CAP, USER_PLAN_CAP } from "@/lib/ai/coach/budget"; const OPERATOR_CHAIN = [{ providerType: "admin-openai", instance: {} }]; @@ -129,6 +130,7 @@ beforeEach(() => { reason: null, onDeviceAllowed: true, }); + aiEgressRefusal.mockResolvedValue(null); }); describe("runStatusCompletion — capability at the wire", () => { @@ -152,6 +154,37 @@ describe("runStatusCompletion — capability at the wire", () => { expect(ledgerTotal).toBe(0); }); + it("re-checks exactly the resolved chain and refuses before any budget or provider work", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + { providerType: "admin-openai", instance: {} }, + ]); + aiEgressRefusal.mockResolvedValue( + new AiUnavailableError("coach", "consent_required"), + ); + + const outcome = await runStatusCompletion( + completionArgs({ capability: "coach" }) as never, + ); + + expect(outcome).toEqual({ kind: "none", reason: "consent_required" }); + expect(aiEgressRefusal).toHaveBeenCalledWith("coach", "u1", [ + "anthropic", + "admin-openai", + ]); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + expect(ledgerTotal).toBe(0); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "insights.status.capability_unavailable" }, + meta: expect.objectContaining({ + reason: "consent_required", + at: "wire", + }), + }), + ); + }); + it("returns none with reason no_provider when the chain is empty", async () => { resolveProviderChain.mockResolvedValue([]); const outcome = await runStatusCompletion(completionArgs() as never); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 2d89f3cdb..cc29fcfa8 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -79,10 +79,7 @@ import { runBriefingCompletion, } from "@/lib/insights/briefing-provider"; import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; -import { - chainRequiresServerManagedConsent, - hasActiveConsentForSurface, -} from "@/lib/ai/consent-guard"; +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { stripJsonFences, @@ -690,16 +687,23 @@ export async function generateComprehensiveInsight( chain.push({ providerType: "admin-openai", instance: legacy }); } - // v1.12.1 — consent gate before any server-managed external egress. This + // The wire re-check for exactly this chain (`aiEgressRefusal`): the + // capability again, and the consent an operator-held entry needs. This // pipeline runs off-request (nightly cron + on-demand force-warm), so a - // missing receipt is a typed `skipped` outcome rather than a throw — the - // cron batch continues to the next user. BYOK / local / ChatGPT-OAuth - // chains never trip the check. - if ( - chainRequiresServerManagedConsent(chain) && - !(await hasActiveConsentForSurface(userId, "insights")) - ) { - return { status: "skipped", reason: "no-consent" }; + // refusal is a typed `skipped` outcome rather than a throw — the cron batch + // continues to the next user. A person's own key, their ChatGPT account or + // a local model need no receipt. + const refusal = await aiEgressRefusal( + "briefing", + userId, + chain.map((entry) => entry.providerType), + ); + if (refusal) { + return { + status: "skipped", + reason: + refusal.reason === "consent_required" ? "no-consent" : "unavailable", + }; } const includeRaw = dbUser?.insightsPrivacyMode === "raw"; diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index 0d7185b4c..bb0342bca 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -12,11 +12,7 @@ import { runRawCompletionWithFallback, type ProviderChainResolved, } from "@/lib/ai/provider-runner"; -import { - chainRequiresServerManagedConsent, - hasActiveConsentForSurface, - type ConsentSurface, -} from "@/lib/ai/consent-guard"; +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; import { annotate } from "@/lib/logging/context"; import { buildDateKey, @@ -93,16 +89,11 @@ interface RunStatusCompletionArgs { * immediately before anything leaves the machine, so a job enqueued before * an operator flipped a switch, or before the person withdrew consent or * turned AI analysis off, still stops here. The capability also names the - * consent kinds the server-managed chain needs. + * consent kinds an operator-held chain entry needs. */ capability: AiCapabilityKey; } -/** The consent surface a capability's server-managed egress is gated on. */ -function consentSurfaceFor(capability: AiCapabilityKey): ConsentSurface { - return capability === "coach" ? "coach" : "insights"; -} - /** * Resolve the provider chain for `userId`, falling back to the legacy * single provider. Returns `null` when the user has no usable provider @@ -162,25 +153,28 @@ export async function runStatusCompletion( return { kind: "none", reason: "no_provider" }; } - // v1.12.1 — consent gate before server-managed external egress. A chain - // that could egress via the operator's global key requires an active - // receipt of the surface's mapped kind (or master `ai_full`). Without one, - // surface the no-key fallback (`none`) rather than egress the snapshot — - // identical to a missing provider from the caller's perspective, so no - // generator branch needs to change. BYOK / local / ChatGPT-OAuth chains - // never trip this. - if ( - chainRequiresServerManagedConsent(chain) && - !(await hasActiveConsentForSurface( - userId, - consentSurfaceFor(args.capability), - )) - ) { + // The wire re-check for exactly this chain (`aiEgressRefusal`, the one + // helper every chokepoint uses): the capability again, and whether sending + // to these providers needs a consent receipt under the capability's rule + // (the operator's key or the shared central Codex does; a person's own key + // or a local model does not). Fails closed like the rest: a chain that + // COULD cascade to an operator-held entry needs the receipt. + const refusal = await aiEgressRefusal( + args.capability, + userId, + chain.map((entry) => entry.providerType), + ); + if (refusal) { annotate({ - action: { name: "insights.status.consent_required" }, - meta: { cacheAction, capability: args.capability }, + action: { name: "insights.status.capability_unavailable" }, + meta: { + cacheAction, + capability: args.capability, + reason: refusal.reason, + at: "wire", + }, }); - return { kind: "none", reason: "consent_required" }; + return { kind: "none", reason: refusal.reason }; } // Honour the per-user response-timeout setting the operator dials in for a diff --git a/src/lib/jobs/__tests__/async-state-contract.test.ts b/src/lib/jobs/__tests__/async-state-contract.test.ts index 505fe6caf..76f7a55f0 100644 --- a/src/lib/jobs/__tests__/async-state-contract.test.ts +++ b/src/lib/jobs/__tests__/async-state-contract.test.ts @@ -62,15 +62,9 @@ vi.mock("@/lib/documents/auto-stage-labs", () => ({ vi.mock("@/lib/labs/ocr-upload", () => ({ detectOcrMimeType: vi.fn(() => "application/pdf"), })); -vi.mock("@/lib/ai/consent-guard", () => { - class ConsentRequiredError extends Error {} - return { - ConsentRequiredError, - assertDocumentEgressConsent: vi.fn(), - isExternalDocumentEgress: (providerType: string) => - providerType !== "local", - }; -}); +vi.mock("@/lib/ai/consent-guard", () => ({ + isExternalDocumentEgress: (providerType: string) => providerType !== "local", +})); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-08-13"), reserveBudget: vi.fn(), @@ -108,10 +102,7 @@ import { transcribeDocument, } from "@/lib/documents/describe"; import { localExtractText } from "@/lib/documents/local-extract"; -import { - assertDocumentEgressConsent, - ConsentRequiredError, -} from "@/lib/ai/consent-guard"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { reserveBudget } from "@/lib/ai/coach/budget"; import { aiCapabilityForJob, @@ -129,6 +120,7 @@ const DOC = { const PICK = { chain: [{ providerType: "anthropic", instance: {} }], + withheld: null, pick: { entry: { providerType: "anthropic", instance: {} }, providerType: "anthropic", @@ -179,7 +171,6 @@ beforeEach(() => { }); } vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); - vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined); vi.mocked(loadOwnedDocument).mockResolvedValue(DOC as never); vi.mocked(prepareVisionInput).mockResolvedValue({ ok: true, @@ -220,18 +211,27 @@ describe("contract (a): every runDocumentSummaryJob branch writes a terminal sta state: "NONE", }, { - name: "documentAi unavailable for want of a provider", + // Presence says no provider, but that is the pick's to decide; with no + // pick either, the attempt is recorded as one that could not run. + name: "documentAi unavailable for want of a provider, no pick", setup: () => { vi.mocked(aiCapabilityForJob).mockResolvedValue({ available: false, reason: "no_provider", onDeviceAllowed: true, }); + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ + chain: [], + pick: null, + withheld: null, + } as never); }, state: "UNAVAILABLE", }, { - name: "documentAi unavailable for want of a consent receipt", + // Presence says a receipt is missing, but the document order lands on a + // pick that needs none: the pick decides, and the summary is written. + name: "documentAi presence wants a receipt the pick does not need", setup: () => { vi.mocked(aiCapabilityForJob).mockResolvedValue({ available: false, @@ -239,7 +239,7 @@ describe("contract (a): every runDocumentSummaryJob branch writes a terminal sta onDeviceAllowed: true, }); }, - state: "UNAVAILABLE", + state: "READY", }, { name: "documentAi switched off by the operator", @@ -258,16 +258,19 @@ describe("contract (a): every runDocumentSummaryJob branch writes a terminal sta vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], pick: null, + withheld: null, } as never); }, state: "UNAVAILABLE", }, { - name: "consent refused", + name: "consent refused at the pick's wire re-check", setup: () => { - vi.mocked(assertDocumentEgressConsent).mockRejectedValue( - new ConsentRequiredError("insights"), - ); + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ + chain: PICK.chain, + pick: null, + withheld: new AiUnavailableError("documentAi", "consent_required"), + } as never); }, state: "UNAVAILABLE", }, @@ -357,6 +360,7 @@ describe("contract (a): every runDocumentIndex branch records the attempt", () = vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], pick: null, + withheld: null, } as never); }, outcome: null, @@ -367,6 +371,7 @@ describe("contract (a): every runDocumentIndex branch records the attempt", () = vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], pick: null, + withheld: null, } as never); vi.mocked(localExtractText).mockResolvedValue({ ok: false, @@ -452,7 +457,25 @@ describe("contract (b): enqueue never claims a pending state its job cannot reso expect(summaryStateWrites()).toEqual([]); }); - it("enqueueDocumentSummary with documentAi unavailable claims nothing and mints no job", async () => { + it("enqueueDocumentSummary with documentAi switched off claims nothing and mints no job", async () => { + vi.mocked(aiCapabilityForRecord).mockResolvedValue({ + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + }); + const send = vi.fn().mockResolvedValue("job-1"); + vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); + + const result = await enqueueDocumentSummary("user-1", "doc-1"); + expect(result).toEqual({ enqueued: false }); + expect(send).not.toHaveBeenCalled(); + expect(summaryStateWrites()).toEqual([]); + }); + + it("enqueueDocumentSummary leaves a missing receipt to the job's pick, whose every branch is terminal", async () => { + // Presence cannot say whether the document-order pick needs a receipt, so + // the enqueue does not refuse on it; contract (a) pins that the job then + // writes a terminal state for both a withheld pick and a served one. vi.mocked(aiCapabilityForRecord).mockResolvedValue({ available: false, reason: "consent_required", @@ -462,9 +485,9 @@ describe("contract (b): enqueue never claims a pending state its job cannot reso vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); const result = await enqueueDocumentSummary("user-1", "doc-1"); - expect(result).toEqual({ enqueued: false }); - expect(send).not.toHaveBeenCalled(); - expect(summaryStateWrites()).toEqual([]); + expect(result).toEqual({ enqueued: true }); + expect(send).toHaveBeenCalledTimes(1); + expect(summaryStateWrites()).toContain("PENDING"); }); it("enqueueDocumentSummary claims PENDING only once a job id exists", async () => { diff --git a/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts b/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts index 430f0faed..ab941a0cc 100644 --- a/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts @@ -10,15 +10,25 @@ * The gate is skip-shaped: no receipt → `null` → the caller ships the * deterministic template, so the nudge itself is never lost. * - * `@/lib/consent/receipts` is mocked rather than the consent guard, so the real - * `chainRequiresServerManagedConsent` + `hasActiveConsentForSurface` logic runs. + * Only the receipt table (`prisma.consentReceipt`) is mocked, not the wire + * re-check, so the real `aiEgressRefusal` rule runs: the capability again (a + * job, so `aiCapabilityForJob`), then whether exactly this chain needs a + * receipt under the `coach` capability's rule, then whether one is active. */ import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("@/lib/db", () => ({ prisma: {} })); - -const latestActiveReceipt = vi.hoisted(() => vi.fn()); -vi.mock("@/lib/consent/receipts", () => ({ latestActiveReceipt })); +/** The active receipt kinds the record holds; `findFirst` answers from them. */ +const activeKinds = vi.hoisted(() => ({ current: [] as string[] })); +const consentReceiptFindFirst = vi.hoisted(() => + vi.fn(async (args: { where: { kind: { in: string[] } } }) => + args.where.kind.in.some((kind) => activeKinds.current.includes(kind)) + ? { id: "receipt-1" } + : null, + ), +); +vi.mock("@/lib/db", () => ({ + prisma: { consentReceipt: { findFirst: consentReceiptFindFirst } }, +})); vi.mock("@/lib/documents/document-settings", () => ({ documentAutoReadEnabled: vi.fn(async () => false), })); @@ -40,7 +50,12 @@ const budgetMocks = vi.hoisted(() => ({ })); vi.mock("@/lib/ai/coach/budget", () => budgetMocks); -vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +// No request event: the nudge runs off-request, which is the arm of +// `aiEgressRefusal` that resolves the capability for the job's record. +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => undefined), +})); // The composer resolves the `coach` capability before the chain. Available // by default so the consent logic below is what each case exercises. @@ -55,6 +70,7 @@ import { createNudgeAiTickBudget, type ComposeNudgeParams, } from "../coach-nudge-ai"; +import { annotate } from "@/lib/logging/context"; function makeProvider() { return { @@ -85,7 +101,7 @@ function params(): ComposeNudgeParams { beforeEach(() => { vi.clearAllMocks(); provider = makeProvider(); - latestActiveReceipt.mockResolvedValue(null); + activeKinds.current = []; aiCapabilityForJob.mockResolvedValue({ available: true, reason: null, @@ -138,9 +154,7 @@ describe("composeNudgeWithAI — server-managed consent gate", () => { resolveProviderChain.mockResolvedValue([ { providerType: "admin-openai", instance: provider }, ]); - latestActiveReceipt.mockImplementation(async (_userId, kind) => - kind === "ai_coach" ? { id: "receipt-1" } : null, - ); + activeKinds.current = ["ai_coach"]; const out = await composeNudgeWithAI(params()); @@ -155,14 +169,26 @@ describe("composeNudgeWithAI — server-managed consent gate", () => { resolveProviderChain.mockResolvedValue([ { providerType: "admin-openai", instance: provider }, ]); - latestActiveReceipt.mockImplementation(async (_userId, kind) => - kind === "ai_full" ? { id: "receipt-2" } : null, - ); + activeKinds.current = ["ai_full"]; expect(await composeNudgeWithAI(params())).not.toBeNull(); expect(provider.generateCompletion).toHaveBeenCalledTimes(1); }); + it("does not count a receipt of an unrelated kind", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "admin-openai", instance: provider }, + ]); + activeKinds.current = ["ai_extraction"]; + + expect(await composeNudgeWithAI(params())).toBeNull(); + expect(provider.generateCompletion).not.toHaveBeenCalled(); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "coach.nudge.ai.refused" }, + meta: { reason: "consent_required" }, + }); + }); + it("leaves a BYOK chain ungated — the user's own egress needs no receipt", async () => { resolveProviderChain.mockResolvedValue([ { providerType: "openai", instance: provider }, @@ -170,7 +196,7 @@ describe("composeNudgeWithAI — server-managed consent gate", () => { expect(await composeNudgeWithAI(params())).not.toBeNull(); expect(provider.generateCompletion).toHaveBeenCalledTimes(1); - expect(latestActiveReceipt).not.toHaveBeenCalled(); + expect(consentReceiptFindFirst).not.toHaveBeenCalled(); }); it("fails closed when a server-managed entry sits BEHIND a BYOK primary", async () => { diff --git a/src/lib/jobs/__tests__/document-content-index-backfill.test.ts b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts index 420b58a3a..ca33cee73 100644 --- a/src/lib/jobs/__tests__/document-content-index-backfill.test.ts +++ b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; /** - * Content-index backfill (Document vault P2). Pins: no provider / no consent → - * clean no-op; indexes not-yet-indexed docs; stops when the budget is reached + * Content-index backfill (Document vault P2). Pins: an operator / module / + * personal switch off → refused before the pick; no provider or a pick the + * wire re-check withholds (no consent, a switch) → clean no-op; indexes not-yet-indexed docs; stops when the budget is reached * (resumable); skips are bounded by the MIME-filtered candidate set; rows on a * stale `tokenizerVersion` are re-tokenised locally (the column's consumer) * while current-version rows are left alone. @@ -40,15 +41,6 @@ vi.mock("@/lib/documents/content-index", () => ({ vi.mock("@/lib/documents/provider-order", () => ({ resolveDocumentVisionProvider: vi.fn(), })); -vi.mock("@/lib/ai/consent-guard", () => ({ - assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), - // Mirrors the real rule: only the self-hosted `local` provider stays on the - // machine. - isExternalDocumentEgress: vi.fn( - (providerType: string) => providerType !== "local", - ), - ConsentRequiredError: class ConsentRequiredError extends Error {}, -})); vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForJob: vi.fn(), aiCapabilityForRecord: vi.fn(), @@ -69,10 +61,7 @@ import { detectOcrMimeType } from "@/lib/labs/ocr-upload"; import { rasterizePdf } from "@/lib/documents/rasterize-pdf"; import { transcribeDocument } from "@/lib/documents/describe"; import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; -import { - assertDocumentEgressConsent, - ConsentRequiredError, -} from "@/lib/ai/consent-guard"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { reserveBudget } from "@/lib/ai/coach/budget"; import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { @@ -82,6 +71,7 @@ import { const PICK = { chain: [{ providerType: "anthropic", instance: {} }], + withheld: null, pick: { entry: { providerType: "anthropic", instance: {} }, providerType: "anthropic", @@ -151,6 +141,7 @@ describe("runContentIndexBackfillForUser", () => { it("no-ops with no provider", async () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], + withheld: null, pick: null, } as never); const result = await runContentIndexBackfillForUser("user-1"); @@ -164,30 +155,43 @@ describe("runContentIndexBackfillForUser", () => { expect(upsertContentIndex).not.toHaveBeenCalled(); }); - it("no-ops when consent is missing", async () => { - vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); - vi.mocked(assertDocumentEgressConsent).mockRejectedValueOnce( - new ConsentRequiredError("insights" as never), - ); - const result = await runContentIndexBackfillForUser("user-1"); - expect(result).toEqual({ - indexed: 0, - retokenised: 0, - skipped: 0, - failed: 0, - reason: "no-consent", - }); - expect(upsertContentIndex).not.toHaveBeenCalled(); - }); - it.each([ ["consent_required", "no-consent"], ["operator_disabled", "unavailable"], ["module_disabled", "unavailable"], - ["no_provider", "no-provider"], ] as const)( - "refuses an external pick before any document is read when documentAi is unavailable (%s → %s)", + "no-ops when the wire re-check withholds the pick (%s → %s)", async (reason, summaryReason) => { + // The pick's own re-check answers for exactly the provider picked: an + // external pick without an extraction receipt, or a switch turned off + // between the pass-level check and the pick. + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ + chain: PICK.chain, + pick: null, + withheld: new AiUnavailableError("documentAi", reason), + } as never); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + { id: "d1" }, + ] as never); + + const result = await runContentIndexBackfillForUser("user-1"); + + expect(result).toEqual({ + indexed: 0, + retokenised: 0, + skipped: 0, + failed: 0, + reason: summaryReason, + }); + expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled(); + expect(reserveBudget).not.toHaveBeenCalled(); + expect(upsertContentIndex).not.toHaveBeenCalled(); + }, + ); + + it.each(["operator_disabled", "module_disabled", "user_disabled"] as const)( + "refuses before the pick and before any document is read when documentAi is switched off (%s)", + async (reason) => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); vi.mocked(aiCapabilityForJob).mockResolvedValue({ available: false, @@ -206,9 +210,9 @@ describe("runContentIndexBackfillForUser", () => { retokenised: 0, skipped: 0, failed: 0, - reason: summaryReason, + reason: "unavailable", }); - expect(assertDocumentEgressConsent).not.toHaveBeenCalled(); + expect(resolveDocumentVisionProvider).not.toHaveBeenCalled(); expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled(); expect(reserveBudget).not.toHaveBeenCalled(); expect(transcribeDocument).not.toHaveBeenCalled(); @@ -216,30 +220,37 @@ describe("runContentIndexBackfillForUser", () => { }, ); - it("does not ask the documentAi capability for a local pick", async () => { - vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ - chain: [{ providerType: "local", instance: {} }], - pick: { - entry: { providerType: "local", instance: {} }, - providerType: "local", - pdfSupported: false, - }, - } as never); - vi.mocked(aiCapabilityForJob).mockResolvedValue({ - available: false, - reason: "consent_required", - onDeviceAllowed: true, - }); - vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ - { id: "d1" }, - ] as never); + it.each(["no_provider", "consent_required"] as const)( + "leaves %s to the pick: a local pick the presence answer would refuse still indexes", + async (reason) => { + // Presence may see an external entry without a receipt, or no server + // provider at all; the document order can still land on the local + // model, which needs neither. The pick decides, not the presence answer. + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ + chain: [{ providerType: "local", instance: {} }], + withheld: null, + pick: { + entry: { providerType: "local", instance: {} }, + providerType: "local", + pdfSupported: false, + }, + } as never); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: true, + }); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + { id: "d1" }, + ] as never); - const result = await runContentIndexBackfillForUser("user-1"); + const result = await runContentIndexBackfillForUser("user-1"); - expect(aiCapabilityForJob).not.toHaveBeenCalled(); - expect(result.reason).toBe("ok"); - expect(result.indexed).toBe(1); - }); + expect(resolveDocumentVisionProvider).toHaveBeenCalledWith("user-1"); + expect(result.reason).toBe("ok"); + expect(result.indexed).toBe(1); + }, + ); it("indexes the not-yet-indexed documents", async () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); @@ -268,6 +279,7 @@ describe("runContentIndexBackfillForUser", () => { // silently leaves every PDF out for exactly those providers. vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [{ providerType: "codex", instance: {} }], + withheld: null, pick: { entry: { providerType: "codex", instance: {} }, providerType: "codex", @@ -310,6 +322,7 @@ describe("runContentIndexBackfillForUser", () => { it("counts a PDF the rasteriser cannot render as skipped, not failed", async () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [{ providerType: "codex", instance: {} }], + withheld: null, pick: { entry: { providerType: "codex", instance: {} }, providerType: "codex", @@ -365,6 +378,7 @@ describe("runContentIndexBackfillForUser", () => { // No provider at all: the sweep is local work and must run anyway. vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], + withheld: null, pick: null, } as never); seedIndexRows([ @@ -409,6 +423,7 @@ describe("runContentIndexBackfillForUser", () => { it("touches nothing when every row already carries the current tokenizerVersion", async () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], + withheld: null, pick: null, } as never); seedIndexRows([ @@ -434,6 +449,7 @@ describe("runContentIndexBackfillForUser", () => { it("counts an undecryptable stale row as failed and keeps walking", async () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], + withheld: null, pick: null, } as never); seedIndexRows([ diff --git a/src/lib/jobs/__tests__/document-summary-catchup.test.ts b/src/lib/jobs/__tests__/document-summary-catchup.test.ts index 8c7af8749..9a803b316 100644 --- a/src/lib/jobs/__tests__/document-summary-catchup.test.ts +++ b/src/lib/jobs/__tests__/document-summary-catchup.test.ts @@ -122,7 +122,7 @@ describe("runSummaryCatchUpForUser", () => { expect(findMany).not.toHaveBeenCalled(); }); - it.each(["operator_disabled", "no_provider", "consent_required"] as const)( + it.each(["operator_disabled", "module_disabled", "user_disabled"] as const)( "does nothing when the documentAi capability is unavailable (%s)", async (reason) => { serveDocuments(5); @@ -146,6 +146,31 @@ describe("runSummaryCatchUpForUser", () => { }, ); + it.each(["no_provider", "consent_required"] as const)( + "leaves %s to each job's pick: still walks and enqueues", + async (reason) => { + // Whether there is a provider and whether sending to it needs a receipt + // depends on the document-order pick, which the per-document job + // re-checks at the wire. The pass-level presence answer does not decide. + serveDocuments(2); + mockCapability.mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: true, + }); + + const result = await runSummaryCatchUpForUser("user-1"); + + expect(result).toEqual({ enqueued: 2, capped: false }); + expect(mockEnqueueSummary).toHaveBeenCalledTimes(2); + expect(annotate).not.toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "documents.autoRead.catchUpSkipped" }, + }), + ); + }, + ); + it("routes work through the ordinary summary job, granting nothing itself", async () => { // The per-document job is what re-asserts egress consent and reserves the // daily budget. The catch-up must not have its own provider or budget path. diff --git a/src/lib/jobs/__tests__/document-summary.test.ts b/src/lib/jobs/__tests__/document-summary.test.ts index f1d0837e7..61f27dcd2 100644 --- a/src/lib/jobs/__tests__/document-summary.test.ts +++ b/src/lib/jobs/__tests__/document-summary.test.ts @@ -38,13 +38,6 @@ vi.mock("@/lib/documents/describe", () => ({ vi.mock("@/lib/documents/store", () => ({ encryptDocumentSummary: vi.fn(() => new Uint8Array([9, 9, 9])), })); -vi.mock("@/lib/ai/consent-guard", () => { - class ConsentRequiredError extends Error {} - return { - ConsentRequiredError, - assertDocumentEgressConsent: vi.fn(), - }; -}); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-07-17"), reserveBudget: vi.fn(), @@ -75,7 +68,6 @@ import { } from "@/lib/documents/ai-route-support"; import { runDocumentSummary } from "@/lib/documents/describe"; import { encryptDocumentSummary } from "@/lib/documents/store"; -import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget"; import { aiCapabilityForJob, @@ -106,6 +98,7 @@ const PICK = { providerType: "anthropic", pdfSupported: true, }, + withheld: null, }; function visionOk() { @@ -133,7 +126,6 @@ beforeEach(() => { vi.mocked(aiCapabilityForJob).mockResolvedValue(AVAILABLE); vi.mocked(aiCapabilityForRecord).mockResolvedValue(AVAILABLE); vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); - vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined); vi.mocked(loadOwnedDocument).mockResolvedValue(DOC as never); visionOk(); vi.mocked(runDocumentSummary).mockResolvedValue({ @@ -168,12 +160,8 @@ describe("runDocumentSummaryJob — gating", () => { expect(runDocumentSummary).toHaveBeenCalledTimes(1); expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "documentAi"); - // The egress consent is re-asserted for the picked provider; the auto-read - // toggle is not a consent and no `surface` rides along any more. - expect(assertDocumentEgressConsent).toHaveBeenCalledWith({ - userId: "user-1", - providerType: "anthropic", - }); + // The pick is re-checked at the wire inside the provider order. + expect(resolveDocumentVisionProvider).toHaveBeenCalledWith("user-1"); expect(encryptDocumentSummary).toHaveBeenCalledWith( "A lab report from a clinic listing routine blood values.", ); @@ -227,15 +215,9 @@ describe("runDocumentSummaryJob — gating", () => { expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled(); }); - it.each([ - ["no_provider", "UNAVAILABLE"], - ["consent_required", "UNAVAILABLE"], - ["operator_disabled", "NONE"], - ["module_disabled", "NONE"], - ["check_failed", "NONE"], - ] as const)( - "refuses before any provider is picked when documentAi is unavailable (%s → %s)", - async (reason, state) => { + it.each(["operator_disabled", "module_disabled", "check_failed"] as const)( + "refuses before any provider is picked when documentAi is closed (%s → only PENDING heals to NONE)", + async (reason) => { vi.mocked(aiCapabilityForJob).mockResolvedValue({ available: false, reason, @@ -245,29 +227,71 @@ describe("runDocumentSummaryJob — gating", () => { await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); expect(resolveDocumentVisionProvider).not.toHaveBeenCalled(); - expect(assertDocumentEgressConsent).not.toHaveBeenCalled(); expect(loadOwnedDocument).not.toHaveBeenCalled(); expect(reserveBudget).not.toHaveBeenCalled(); expect(runDocumentSummary).not.toHaveBeenCalled(); - // An attempt that could not run is recorded as UNAVAILABLE; a switch - // turned off only heals the PENDING this job was meant to resolve, so an - // earlier UNAVAILABLE / WITHHELD is never overwritten. + // A switch turned off only heals the PENDING this job was meant to + // resolve, so an earlier UNAVAILABLE / WITHHELD is never overwritten. expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({ where: { id: "doc-1", userId: "user-1", deletedAt: null, - summaryState: state === "NONE" ? "PENDING" : { not: "READY" }, + summaryState: "PENDING", }, - data: { summaryState: state }, + data: { summaryState: "NONE" }, }); expect(annotate).toHaveBeenCalledWith({ action: { name: "documents.summary.autoSkipped" }, - meta: { documentId: "doc-1", reason }, + meta: { + documentId: "doc-1", + reason: "unavailable", + capability_reason: reason, + }, }); }, ); + it.each(["no_provider", "consent_required"] as const)( + "leaves %s to the pick: the provider order decides", + async (reason) => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: true, + }); + + await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); + + expect(resolveDocumentVisionProvider).toHaveBeenCalledWith("user-1"); + expect(runDocumentSummary).toHaveBeenCalledTimes(1); + }, + ); + + it("records UNAVAILABLE with the reason when the pick is withheld at the wire", async () => { + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ + chain: PICK.chain, + pick: null, + withheld: { reason: "consent_required" }, + } as never); + + await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); + + expect(loadOwnedDocument).not.toHaveBeenCalled(); + expect(runDocumentSummary).not.toHaveBeenCalled(); + expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ data: { summaryState: "UNAVAILABLE" } }), + ); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "documents.summary.autoSkipped" }, + meta: { + documentId: "doc-1", + reason: "consent", + capability_reason: "consent_required", + }, + }); + }); + it("does not consult the capability when the opt-in is OFF", async () => { vi.mocked(documentAutoReadEnabled).mockResolvedValue(false); @@ -280,6 +304,7 @@ describe("runDocumentSummaryJob — gating", () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], pick: null, + withheld: null, } as never); await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); @@ -398,8 +423,8 @@ describe("enqueueDocumentSummary — the pending contract", () => { ); }); - it.each(["no_provider", "consent_required", "operator_disabled"] as const)( - "does not enqueue and does not claim PENDING when documentAi is unavailable (%s)", + it.each(["operator_disabled", "module_disabled", "check_failed"] as const)( + "does not enqueue and does not claim PENDING when documentAi is closed (%s)", async (reason) => { vi.mocked(aiCapabilityForRecord).mockResolvedValue({ available: false, @@ -421,6 +446,23 @@ describe("enqueueDocumentSummary — the pending contract", () => { }, ); + it.each(["no_provider", "consent_required"] as const)( + "still enqueues on %s: the job's own pick decides", + async (reason) => { + vi.mocked(aiCapabilityForRecord).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: true, + }); + const send = vi.fn().mockResolvedValue("job-1"); + vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); + + const result = await enqueueDocumentSummary("user-1", "doc-1"); + + expect(result).toEqual({ enqueued: true }); + }, + ); + it("claims nothing when the boss dropped the send (no job id)", async () => { const send = vi.fn().mockResolvedValue(null); vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index 8782179eb..051f2b0a2 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -73,13 +73,11 @@ vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), })); -const chainRequiresServerManagedConsent = vi.fn(); -const hasActiveConsentForSurface = vi.fn(); -vi.mock("@/lib/ai/consent-guard", () => ({ - chainRequiresServerManagedConsent: (...a: unknown[]) => - chainRequiresServerManagedConsent(...a), - hasActiveConsentForSurface: (...a: unknown[]) => - hasActiveConsentForSurface(...a), +// The wire re-check for the resolved chain. Its rule is pinned in +// egress.test.ts; here it passes unless a case refuses. +const aiEgressRefusal = vi.fn(); +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: (...a: unknown[]) => aiEgressRefusal(...a), })); const reserveBudget = vi.fn(); @@ -106,6 +104,7 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ })); import { runReactionLine } from "@/lib/jobs/reaction-line"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; const JOB = { userId: "u1", @@ -202,8 +201,7 @@ beforeEach(async () => { reason: null, onDeviceAllowed: true, }); - chainRequiresServerManagedConsent.mockReturnValue(false); - hasActiveConsentForSurface.mockResolvedValue(true); + aiEgressRefusal.mockResolvedValue(null); // v1.38.19 — the claim reservation is no longer a // second copy of the upsert; it calls the shared `reserveBudget` with the // transaction client. The spy therefore runs the REAL gate against the @@ -512,14 +510,21 @@ describe("reaction line — degradation", () => { it("no consent on a server-managed chain: refuses before reserving", async () => { resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: {} }, { providerType: "admin-openai", instance: {} }, ]); - chainRequiresServerManagedConsent.mockReturnValue(true); - hasActiveConsentForSurface.mockResolvedValue(false); + aiEgressRefusal.mockResolvedValue( + new AiUnavailableError("reactionLines", "consent_required"), + ); const outcome = await runReactionLine(JOB); expect(outcome).toEqual({ status: "skipped", reason: "consent_required" }); + // Asked about exactly the chain it would cascade through. + expect(aiEgressRefusal).toHaveBeenCalledWith("reactionLines", "u1", [ + "openai", + "admin-openai", + ]); // A user without a receipt must not spend a token OR a ledger slot. expect(reserveBudget).not.toHaveBeenCalled(); expect(update).not.toHaveBeenCalled(); diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index 95b786789..ed3c3a374 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -36,10 +36,7 @@ import type { CoachNudgeTrigger } from "@/lib/jobs/coach-nudge"; import type { Locale } from "@/lib/i18n/config"; import { resolveProviderChain } from "@/lib/ai/provider"; import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; -import { - chainRequiresServerManagedConsent, - hasActiveConsentForSurface, -} from "@/lib/ai/consent-guard"; +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; import { buildDateKey, reconcileSpend, @@ -189,16 +186,23 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => { const chain = await resolveProviderChain(params.userId); if (chain.length === 0) return null; - // Consent gate — before the budget reservation, so a user without a - // receipt never spends a slot or a token. Skip-shaped like every other - // guard here: no receipt → return null and the caller ships the - // deterministic template, so the nudge itself is never lost. BYOK / local - // / ChatGPT-OAuth chains are the user's own egress and stay ungated. - if ( - chainRequiresServerManagedConsent(chain) && - !(await hasActiveConsentForSurface(params.userId, "coach")) - ) { - annotate({ action: { name: "coach.nudge.ai.consent_required" } }); + // The wire re-check for exactly this chain, before the budget + // reservation so a user without a receipt never spends a slot or a token: + // the capability again, and the consent an operator-held entry needs. + // Skip-shaped like every other guard here: a refusal returns null and the + // caller ships the deterministic template, so the nudge itself is never + // lost. A person's own key, their ChatGPT account or a local model need no + // receipt. + const refusal = await aiEgressRefusal( + "coach", + params.userId, + chain.map((entry) => entry.providerType), + ); + if (refusal) { + annotate({ + action: { name: "coach.nudge.ai.refused" }, + meta: { reason: refusal.reason }, + }); return null; } diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index a7c1368da..17b616348 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -12,9 +12,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 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 + * Consent: gated on the `documentAi` capability and the wire re-check of the + * document pick (`resolveDocumentVisionProvider` → `aiEgressRefusal`), the same + * gate the extract / index routes use — any external provider needs an active + * extraction receipt; a local pick needs none. 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, @@ -31,11 +32,7 @@ */ import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; -import { - assertDocumentEgressConsent, - isExternalDocumentEgress, - ConsentRequiredError, -} from "@/lib/ai/consent-guard"; +import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types"; import { buildDateKey, reconcileSpend, @@ -167,55 +164,45 @@ export async function runContentIndexBackfillForUser( // provider still gets their existing index moved to the current rules. const stale = await retokeniseStaleRows(userId); - const { pick } = await resolveDocumentVisionProvider(userId); - if (!pick) + // The `documentAi` capability before any document is listed: the + // operator's switches (the master and "Reading documents") and the vault + // module. Whether there is a provider and whether sending to it needs a + // receipt is the pick's to answer, just below. + const capability = await aiCapabilityForJob(userId, "documentAi"); + if ( + capability.reason !== null && + !PICK_DECIDED_REASONS.has(capability.reason) + ) { + annotate({ + action: { name: "documents.contentIndex.backfillSkipped" }, + meta: { reason: capability.reason }, + }); return { indexed: 0, retokenised: stale.retokenised, skipped: 0, failed: stale.failed, - reason: "no-provider", + reason: "unavailable", }; - // An external pick is document AI and needs the `documentAi` capability - // (the operator's switches, the vault module, the extraction consent). A - // local pick keeps the document on the machine and indexes as before. - if (isExternalDocumentEgress(pick.providerType)) { - const capability = await aiCapabilityForJob(userId, "documentAi"); - if (!capability.available) { - annotate({ - action: { name: "documents.contentIndex.backfillSkipped" }, - meta: { reason: capability.reason }, - }); - return { - indexed: 0, - retokenised: stale.retokenised, - skipped: 0, - failed: stale.failed, - reason: - capability.reason === "consent_required" - ? "no-consent" - : capability.reason === "no_provider" - ? "no-provider" - : "unavailable", - }; - } } - try { - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - }); - } catch (err) { - if (err instanceof ConsentRequiredError) { - return { - indexed: 0, - retokenised: stale.retokenised, - skipped: 0, - failed: stale.failed, - reason: "no-consent", - }; - } - throw err; + + // The document-order pick, re-checked at the wire for exactly that + // provider: the capability again, and the extraction receipt an external + // pick needs (`ai_extraction` or `ai_full`). A local pick needs none. + const { pick, withheld } = await resolveDocumentVisionProvider(userId); + if (!pick) { + return { + indexed: 0, + retokenised: stale.retokenised, + skipped: 0, + failed: stale.failed, + reason: + withheld === null + ? "no-provider" + : withheld.reason === "consent_required" + ? "no-consent" + : "unavailable", + }; } // PDFs are always candidates: `prepareVisionInput` passes them natively diff --git a/src/lib/jobs/document-summary-catchup.ts b/src/lib/jobs/document-summary-catchup.ts index 7a16ffe33..65ca18df6 100644 --- a/src/lib/jobs/document-summary-catchup.ts +++ b/src/lib/jobs/document-summary-catchup.ts @@ -34,6 +34,7 @@ */ import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; +import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types"; import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { enqueueDocumentSummary } from "@/lib/jobs/document-summary"; @@ -94,10 +95,14 @@ export async function runSummaryCatchUpForUser( } // The `documentAi` capability, once for the whole pass, before any document - // is listed: a switch off, no provider or no extraction consent means there - // is nothing the per-document jobs could do. + // is listed: a switch or the vault module off means there is nothing the + // per-document jobs could do. A missing provider or receipt is each job's + // pick to decide. const capability = await aiCapabilityForJob(userId, "documentAi"); - if (!capability.available) { + if ( + capability.reason !== null && + !PICK_DECIDED_REASONS.has(capability.reason) + ) { annotate({ action: { name: "documents.autoRead.catchUpSkipped" }, meta: { reason: capability.reason }, diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index 55940bbe3..feec890ba 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -26,10 +26,7 @@ import { aiCapabilityForJob, aiCapabilityForRecord, } from "@/lib/ai/capabilities/gate"; -import { - assertDocumentEgressConsent, - ConsentRequiredError, -} from "@/lib/ai/consent-guard"; +import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types"; import { buildDateKey, reconcileSpend, @@ -140,68 +137,58 @@ export async function runDocumentSummaryJob( } // The `documentAi` capability before a provider is picked or the document is - // decrypted: the operator's switches (the master and "Reading documents"), - // the vault module, provider presence and the extraction consent. The - // auto-read toggle above is the trigger, not the consent. + // decrypted: the operator's switches (the master and "Reading documents") + // and the vault module. A switch turned off means this job was never to run + // at all, so only the PENDING it was meant to resolve goes back to NONE; a + // recorded UNAVAILABLE or WITHHELD is an earlier real attempt and stays. + // Whether there is a provider and whether sending to it needs a receipt is + // the pick's to answer (the document order differs from the chain order the + // presence probe reads), so those two reasons wait for the pick below. const capability = await aiCapabilityForJob(userId, "documentAi"); - if (!capability.available) { - if ( - capability.reason === "no_provider" || - capability.reason === "consent_required" - ) { - // An attempt that could not run, recorded as such. - await markSummaryState(userId, documentId, "UNAVAILABLE"); - } else { - // A switch turned off means this job was never to run at all. Only the - // PENDING this job was meant to resolve goes back to NONE; a recorded - // UNAVAILABLE or WITHHELD is an earlier real attempt and stays. - await prisma.inboundDocument.updateMany({ - where: { - id: documentId, - userId, - deletedAt: null, - summaryState: "PENDING", - }, - data: { summaryState: "NONE" }, - }); - } + if ( + capability.reason !== null && + !PICK_DECIDED_REASONS.has(capability.reason) + ) { + await prisma.inboundDocument.updateMany({ + where: { + id: documentId, + userId, + deletedAt: null, + summaryState: "PENDING", + }, + data: { summaryState: "NONE" }, + }); annotate({ action: { name: "documents.summary.autoSkipped" }, - meta: { documentId, reason: capability.reason }, + meta: { + documentId, + reason: "unavailable", + capability_reason: capability.reason, + }, }); return; } - // Resolve the DOCUMENT-order vision provider (local-first, codex last). No - // vision-capable provider configured → graceful no-op (unlike the index job, - // there is no local text-layer fallback for a descriptive summary). - const { pick } = await resolveDocumentVisionProvider(userId); + // Resolve the DOCUMENT-order vision provider (local-first, codex last) and + // re-check the wire for exactly that pick: the capability again and the + // extraction receipt an external pick needs (`ai_extraction` or `ai_full`; + // the auto-read toggle mints one but is not one, so a revoke mid-flight wins). + // No pick → an attempt that could not run, recorded as UNAVAILABLE (unlike + // the index job, there is no local text-layer fallback for a summary). + const { pick, withheld } = await resolveDocumentVisionProvider(userId); if (!pick) { - await markSummaryState(userId, documentId, "UNAVAILABLE"); - annotate({ - action: { name: "documents.summary.autoSkipped" }, - meta: { documentId, reason: "no-provider" }, - }); - return; - } - - // Re-assert egress consent for the picked provider. A local pick is ungated; - // an external pick needs an active extraction receipt (`ai_extraction` or - // `ai_full`); the auto-read toggle mints one but is not one. A consent race - // (a revoke mid-flight) resolves to a no-op, never an egress. - try { - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - }); - } catch (err) { await markSummaryState(userId, documentId, "UNAVAILABLE"); annotate({ action: { name: "documents.summary.autoSkipped" }, meta: { documentId, reason: - err instanceof ConsentRequiredError ? "consent" : "consent-error", + withheld === null + ? "no-provider" + : withheld.reason === "consent_required" + ? "consent" + : "unavailable", + capability_reason: withheld?.reason ?? null, }, }); return; @@ -355,11 +342,15 @@ export async function enqueueDocumentSummary( }); return { enqueued: false }; } - // Nothing is queued that the capability already refuses (a switch off, - // no provider, no extraction consent): the document stays NONE and the - // view offers the manual action, exactly as with the opt-in off. + // Nothing is queued that the capability already refuses (a switch or the + // vault module off): the document stays NONE and the view offers the + // manual action, exactly as with the opt-in off. A missing provider or + // receipt is the job's pick to decide. const capability = await aiCapabilityForRecord(userId, "documentAi"); - if (!capability.available) { + if ( + capability.reason !== null && + !PICK_DECIDED_REASONS.has(capability.reason) + ) { annotate({ action: { name: "documents.summary.autoSkipped" }, meta: { documentId, reason: capability.reason }, diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index 35a4a6493..a036f0025 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -33,10 +33,7 @@ import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; import { annotate } from "@/lib/logging/context"; import { withBackgroundEvent } from "@/lib/logging/background"; import { resolveProviderChain } from "@/lib/ai/provider"; -import { - chainRequiresServerManagedConsent, - hasActiveConsentForSurface, -} from "@/lib/ai/consent-guard"; +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { buildDateKey, @@ -490,12 +487,14 @@ export async function runReactionLine( const chain = await resolveProviderChain(job.userId); if (chain.length === 0) return { status: "skipped", reason: "no_provider" }; - if ( - chainRequiresServerManagedConsent(chain) && - !(await hasActiveConsentForSurface(job.userId, "insights")) - ) { - return { status: "skipped", reason: "consent_required" }; - } + // The wire re-check for exactly this chain: the capability again, and the + // consent an operator-held entry needs (`aiEgressRefusal`). + const refusal = await aiEgressRefusal( + "reactionLines", + job.userId, + chain.map((entry) => entry.providerType), + ); + if (refusal) return { status: "skipped", reason: refusal.reason }; const generationClaimId = randomUUID(); const claimedAt = new Date(); diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index ccf50488f..f34275a95 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -709,7 +709,7 @@ const glucoseUnitResponse = z const documentsAutoAiReadPatchRequest = documentsAutoAiReadPatchSchema.meta({ id: "DocumentsAutoAiReadPatchRequest", description: - "Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_full` consent receipt and schedules a catch-up over the existing vault.", + "Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_extraction` consent receipt and schedules a catch-up over the existing vault.", }); const documentsAutoAiReadResponse = z diff --git a/tests/integration/consent-withdrawal-purge.test.ts b/tests/integration/consent-withdrawal-purge.test.ts index 4823be731..e80e75790 100644 --- a/tests/integration/consent-withdrawal-purge.test.ts +++ b/tests/integration/consent-withdrawal-purge.test.ts @@ -307,21 +307,15 @@ describe("a revoked extraction receipt wins over the auto-read toggle", () => { signedAt: new Date(), }, }); - const { assertDocumentEgressConsent, ConsentRequiredError } = - await import("@/lib/ai/consent-guard"); + const { aiEgressRefusal } = await import("@/lib/ai/capabilities/egress"); - await expect( - assertDocumentEgressConsent({ userId: user.id, providerType: "codex" }), - ).resolves.toBeUndefined(); + expect(await aiEgressRefusal("documentAi", user.id, ["codex"])).toBeNull(); await withdrawConsent(user.id, ["ai_extraction"]); - await expect( - assertDocumentEgressConsent({ userId: user.id, providerType: "codex" }), - ).rejects.toBeInstanceOf(ConsentRequiredError); + const refusal = await aiEgressRefusal("documentAi", user.id, ["codex"]); + expect(refusal?.reason).toBe("consent_required"); // A local pick never leaves the machine and stays admitted. - await expect( - assertDocumentEgressConsent({ userId: user.id, providerType: "local" }), - ).resolves.toBeUndefined(); + expect(await aiEgressRefusal("documentAi", user.id, ["local"])).toBeNull(); }); });