From a19900c8543351defd1fc9acd54d98f7e919236b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 08:44:49 +0200 Subject: [PATCH] feat(ai): re-check a capability at the wire requireAiCapability answers at the top of a route, before a provider is picked, and a job enqueued before an operator switch flipped runs long after any route answered. aiEgressRefusal asks again about the providers a caller is about to send to: the capability for the record (the request's own resolution, or the job's for the record it names) and, for exactly those providers, whether a consent receipt of the kinds the capability table declares is active. A pickDecides gate option leaves no_provider and consent_required to that pick, for routes that read the chain in their own order. --- .../ai/capabilities/__tests__/egress.test.ts | 254 ++++++++++++++++++ src/lib/ai/capabilities/egress.ts | 96 +++++++ src/lib/ai/capabilities/gate.ts | 20 +- src/lib/ai/capabilities/resolve.ts | 23 ++ src/lib/ai/capabilities/types.ts | 9 + 5 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 src/lib/ai/capabilities/__tests__/egress.test.ts create mode 100644 src/lib/ai/capabilities/egress.ts diff --git a/src/lib/ai/capabilities/__tests__/egress.test.ts b/src/lib/ai/capabilities/__tests__/egress.test.ts new file mode 100644 index 000000000..6b81d3d2f --- /dev/null +++ b/src/lib/ai/capabilities/__tests__/egress.test.ts @@ -0,0 +1,254 @@ +/** + * The capability re-check at the wire, and the gate option that leaves the + * provider questions to the route's own pick. + * + * The loader is replaced so each case hands the gate exactly the inputs it + * needs; the receipt read is replaced so the exact-pick consent answer can be + * steered independently of the presence-based one. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let auth: Record | undefined; +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ getAuth: () => auth, addWarning: vi.fn() }), +})); +vi.mock("@/lib/sharing/grants", async (importOriginal) => ({ + ...(await importOriginal()), + findActiveGrant: vi.fn(), +})); +vi.mock("../load", () => ({ loadAiCapabilityInputs: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { consentReceipt: { findFirst: vi.fn() } }, +})); + +import { prisma } from "@/lib/db"; +import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; +import type { ModuleAccessState } from "@/lib/sharing/module-disclosure"; + +import { aiEgressRefusal, assertAiEgress } from "../egress"; +import { requireAiCapability } from "../gate"; +import { loadAiCapabilityInputs } from "../load"; +import { AiUnavailableError } from "../refusal"; +import type { AiCapabilityInputs } from "../resolve"; + +function inputs( + overrides: Partial = {}, +): AiCapabilityInputs { + const moduleAccess = {} as Record; + for (const key of MODULE_KEYS) moduleAccess[key] = "enabled"; + return { + switches: { + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + documentAi: true, + }, + moduleAccess, + providerWorkAdmitted: true, + provider: { + entries: [{ providerType: "local", vision: true }], + localOcrEnabled: false, + managedBy: "local", + }, + activeConsentKinds: new Set(), + recordKind: "self", + ...overrides, + }; +} + +const LOAD = vi.mocked(loadAiCapabilityInputs); +const RECEIPT = vi.mocked(prisma.consentReceipt.findFirst); + +beforeEach(() => { + LOAD.mockReset(); + RECEIPT.mockReset(); + RECEIPT.mockResolvedValue(null); + auth = { user_id: "user-1" }; +}); + +describe("requireAiCapability with pickDecides", () => { + it("leaves no_provider to the route's pick", async () => { + LOAD.mockResolvedValue( + inputs({ + provider: { entries: [], localOcrEnabled: false, managedBy: null }, + }), + ); + await expect( + requireAiCapability("documentAi", { pickDecides: true }), + ).resolves.toMatchObject({ available: true }); + }); + + it("leaves consent_required to the route's pick", async () => { + LOAD.mockResolvedValue( + inputs({ + provider: { + entries: [{ providerType: "openai", vision: true }], + localOcrEnabled: false, + managedBy: "user", + }, + }), + ); + await expect( + requireAiCapability("documentAi", { pickDecides: true }), + ).resolves.toMatchObject({ available: true }); + }); + + it("still refuses a layer the pick cannot answer", async () => { + LOAD.mockResolvedValue( + inputs({ switches: { ...inputs().switches, documentAi: false } }), + ); + const error = await requireAiCapability("documentAi", { + pickDecides: true, + }).catch((e) => e); + expect(error).toBeInstanceOf(AiUnavailableError); + expect(error.meta.errorCode).toBe("assistant.disabled.documentAi"); + }); +}); + +describe("aiEgressRefusal inside a request", () => { + it("refuses when the operator switch is off, whatever the pick", async () => { + LOAD.mockResolvedValue( + inputs({ switches: { ...inputs().switches, documentAi: false } }), + ); + const refusal = await aiEgressRefusal("documentAi", "user-1", ["local"]); + expect(refusal).toBeInstanceOf(AiUnavailableError); + expect(refusal?.meta).toEqual({ + errorCode: "assistant.disabled.documentAi", + capability: "documentAi", + reason: "operator_disabled", + }); + }); + + it("refuses the master switch through every extraction capability", async () => { + LOAD.mockResolvedValue( + inputs({ switches: { ...inputs().switches, enabled: false } }), + ); + for (const key of ["documentAi", "labsOcr", "medicationExtract"] as const) { + const refusal = await aiEgressRefusal(key, "user-1", ["local"]); + expect(refusal?.reason, key).toBe("operator_disabled"); + } + }); + + it("asks for a receipt when the actual pick leaves the machine", async () => { + // Presence says a local model would read it; the route picked an external + // provider anyway (text mode reads the chain head). The pick decides. + LOAD.mockResolvedValue(inputs()); + const refusal = await aiEgressRefusal("documentAi", "user-1", ["openai"]); + expect(refusal?.meta).toEqual({ + errorCode: "consent.ai.required", + capability: "documentAi", + reason: "consent_required", + }); + expect(RECEIPT).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: "user-1", + revokedAt: null, + kind: { in: ["ai_extraction", "ai_full"] }, + }), + }), + ); + }); + + it("admits an external pick with an extraction receipt", async () => { + LOAD.mockResolvedValue(inputs()); + RECEIPT.mockResolvedValue({ id: "r1" } as never); + await expect( + aiEgressRefusal("documentAi", "user-1", ["openai"]), + ).resolves.toBeNull(); + }); + + it("needs no receipt for a local pick, even when presence says one is needed", async () => { + LOAD.mockResolvedValue( + inputs({ + provider: { + entries: [{ providerType: "openai", vision: true }], + localOcrEnabled: false, + managedBy: "user", + }, + }), + ); + await expect( + aiEgressRefusal("documentAi", "user-1", ["local"]), + ).resolves.toBeNull(); + expect(RECEIPT).not.toHaveBeenCalled(); + }); + + it("checks every entry of a cascading chain under the document rule", async () => { + LOAD.mockResolvedValue(inputs()); + const refusal = await aiEgressRefusal("medicationExtract", "user-1", [ + "local", + "admin-openai", + ]); + expect(refusal?.reason).toBe("consent_required"); + await expect( + aiEgressRefusal("medicationExtract", "user-1", ["local"]), + ).resolves.toBeNull(); + }); + + it("uses the self-snapshot rule for the Coach: a person's own key needs nothing", async () => { + LOAD.mockResolvedValue(inputs()); + await expect( + aiEgressRefusal("coach", "user-1", ["openai"]), + ).resolves.toBeNull(); + const refusal = await aiEgressRefusal("coach", "user-1", ["admin-openai"]); + expect(refusal?.reason).toBe("consent_required"); + expect(RECEIPT).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + kind: { in: ["ai_coach", "ai_full"] }, + }), + }), + ); + }); + + it("fails closed when the inputs cannot be loaded", async () => { + LOAD.mockResolvedValue(null); + const refusal = await aiEgressRefusal("labsOcr", "user-1", ["local"]); + expect(refusal?.reason).toBe("check_failed"); + expect(refusal?.status).toBe(503); + }); +}); + +describe("aiEgressRefusal outside a request (a job)", () => { + beforeEach(() => { + auth = undefined; + }); + + it("resolves for the record the job names", async () => { + LOAD.mockResolvedValue( + inputs({ switches: { ...inputs().switches, documentAi: false } }), + ); + const refusal = await aiEgressRefusal("documentAi", "user-9", ["local"]); + expect(refusal?.reason).toBe("operator_disabled"); + expect(LOAD).toHaveBeenCalledWith( + expect.objectContaining({ recordId: "user-9" }), + ); + }); + + it("admits the wire when every layer is open", async () => { + LOAD.mockResolvedValue(inputs()); + await expect( + aiEgressRefusal("documentAi", "user-9", ["local"]), + ).resolves.toBeNull(); + }); +}); + +describe("assertAiEgress", () => { + it("throws the refusal", async () => { + LOAD.mockResolvedValue( + inputs({ switches: { ...inputs().switches, documentAi: false } }), + ); + await expect( + assertAiEgress("medicationExtract", "user-1", ["local"]), + ).rejects.toBeInstanceOf(AiUnavailableError); + }); + + it("returns quietly when the wire is open", async () => { + LOAD.mockResolvedValue(inputs()); + await expect( + assertAiEgress("labsOcr", "user-1", ["local"]), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/ai/capabilities/egress.ts b/src/lib/ai/capabilities/egress.ts new file mode 100644 index 000000000..de276c578 --- /dev/null +++ b/src/lib/ai/capabilities/egress.ts @@ -0,0 +1,96 @@ +/** + * The capability re-check at the wire. + * + * `requireAiCapability` answers at the top of a route, before anything is + * picked. That is the refusal a client sees, but it is not the last word: a + * route may pick its provider in its own order, and a job enqueued before the + * operator turned a switch off runs long after any route answered. So every + * place that hands input to a model (the document provider pick, the labs + * scan, the medication extractor, the fenced document chat) asks again here, + * with the provider it is about to send to, immediately before it sends. + * + * Two questions, both about the actual wire: + * + * 1. Is the capability still open for this record? Inside a request this is + * the request's own resolution (memoised per request, so the answer + * matches the one the route gave); outside a request it is the job's + * resolution for the record it names. + * 2. Does sending to exactly these providers need a consent receipt, and is + * one active? Presence cannot answer this for a route that reads the + * chain in its own order, so the pick answers it here, from the same + * rule and the same receipt kinds the capability table declares. + * + * `no_provider` is never answered here: a caller that has a pick has a + * provider, and one without a pick refuses with its own typed code. + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; + +import { aiCapabilityForJob, requireAiCapability } from "./gate"; +import { AiUnavailableError } from "./refusal"; +import { consentNeededForProviders } from "./resolve"; +import { + AI_CAPABILITIES, + PICK_DECIDED_REASONS, + type AiCapabilityKey, +} from "./types"; + +/** Whether the record holds an active receipt of one of these kinds. */ +async function hasActiveReceipt( + recordId: string, + kinds: readonly string[], +): Promise { + const row = await prisma.consentReceipt.findFirst({ + where: { userId: recordId, revokedAt: null, kind: { in: [...kinds] } }, + select: { id: true }, + }); + return row !== null; +} + +/** + * Why input may not leave for these providers right now, or `null` when it + * may. Never throws for a refusal; a database error propagates, which is the + * closed direction for a caller about to send. + * + * @param recordId The record the input belongs to. Inside a request the + * request's own record is used, which is the record the route acts on. + * @param providerTypes The providers the input is about to reach: the one + * picked provider for a single-pick read, the whole chain for a cascade. + */ +export async function aiEgressRefusal( + key: AiCapabilityKey, + recordId: string, + providerTypes: readonly string[], +): Promise { + if (getEvent()?.getAuth()?.user_id) { + try { + await requireAiCapability(key, { pickDecides: true }); + } catch (error) { + if (error instanceof AiUnavailableError) return error; + throw error; + } + } else { + const state = await aiCapabilityForJob(recordId, key); + if (state.reason !== null && !PICK_DECIDED_REASONS.has(state.reason)) { + return new AiUnavailableError(key, state.reason); + } + } + + if ( + consentNeededForProviders(key, providerTypes) && + !(await hasActiveReceipt(recordId, AI_CAPABILITIES[key].consent.kinds)) + ) { + return new AiUnavailableError(key, "consent_required"); + } + return null; +} + +/** {@link aiEgressRefusal}, thrown. For routes, where `apiHandler` renders it. */ +export async function assertAiEgress( + key: AiCapabilityKey, + recordId: string, + providerTypes: readonly string[], +): Promise { + const refusal = await aiEgressRefusal(key, recordId, providerTypes); + if (refusal) throw refusal; +} diff --git a/src/lib/ai/capabilities/gate.ts b/src/lib/ai/capabilities/gate.ts index 254a4d794..76fc70463 100644 --- a/src/lib/ai/capabilities/gate.ts +++ b/src/lib/ai/capabilities/gate.ts @@ -40,7 +40,11 @@ import { providerWorkAuthorityForRecord } from "@/lib/sharing/provider-work-auth import { loadAiCapabilityInputs, type AiCapabilityScope } from "./load"; import { explainAiCapability, resolveAiCapability } from "./resolve"; import { AiUnavailableError, type NoProviderRefusal } from "./refusal"; -import type { AiCapabilityKey, AiCapabilityState } from "./types"; +import { + PICK_DECIDED_REASONS, + type AiCapabilityKey, + type AiCapabilityState, +} from "./types"; export interface AiGateOptions { /** @@ -50,6 +54,15 @@ export interface AiGateOptions { recordId?: string; /** The route's own `no_provider` refusal, where a client already reads it. */ noProvider?: NoProviderRefusal; + /** + * Leave `no_provider` and `consent_required` to the provider the route + * actually picks. The resolver answers both from presence, which is right + * for the published payload but can differ from a route that reads the + * chain in its own order (a text-mode document read takes the chain head, + * not the first vision entry). A route that sets this must run the pick + * through `assertAiEgress` / `aiEgressRefusal`, which answers both exactly. + */ + pickDecides?: boolean; } /** @@ -118,7 +131,10 @@ export async function requireAiCapability( } catch { finding = { reason: "check_failed", module: null }; } - if (finding.reason !== null) { + if ( + finding.reason !== null && + !(options.pickDecides && PICK_DECIDED_REASONS.has(finding.reason)) + ) { throw new AiUnavailableError( key, finding.reason, diff --git a/src/lib/ai/capabilities/resolve.ts b/src/lib/ai/capabilities/resolve.ts index b814ba86a..c661d78d3 100644 --- a/src/lib/ai/capabilities/resolve.ts +++ b/src/lib/ai/capabilities/resolve.ts @@ -223,6 +223,29 @@ function consentNeeded( ); } +/** + * Whether sending to exactly these providers needs a receipt under the + * capability's consent rule. The egress re-check asks it about the providers a + * route actually picked, so the answer at the wire comes from the same rule the + * published capability does. Pass the one picked provider for a single-pick + * read, or the whole chain for a cascade. + */ +export function consentNeededForProviders( + key: AiCapabilityKey, + providerTypes: readonly string[], +): boolean { + return consentNeeded(AI_CAPABILITIES[key], { + // Every entry named here is one the route will send to, so each counts as + // able to read the input. + entries: providerTypes.map((providerType) => ({ + providerType, + vision: true, + })), + localOcrEnabled: false, + managedBy: null, + }); +} + function consentReason( def: AiCapabilityDefinition, provider: ProviderPresence, diff --git a/src/lib/ai/capabilities/types.ts b/src/lib/ai/capabilities/types.ts index 049b7350f..13adfceca 100644 --- a/src/lib/ai/capabilities/types.ts +++ b/src/lib/ai/capabilities/types.ts @@ -84,6 +84,15 @@ export interface AiCapabilityState { export const ON_DEVICE_ALLOWED_REASONS: ReadonlySet = new Set(["no_provider", "consent_required"]); +/** + * The reasons a route's own provider pick answers exactly: whether there is a + * provider, and whether sending to it needs a receipt. The resolver answers + * both from presence, which is right for the published payload; a route that + * reads the chain in its own order asks again about the provider it picked. + */ +export const PICK_DECIDED_REASONS: ReadonlySet = + new Set(["no_provider", "consent_required"]); + /** * The operator's assistant switches below the master. Each maps to a cost or * egress profile an operator can reason about: the Coach, the daily briefing