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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 08:44:49 +02:00
1 parent f434ba7d29
commit a19900c854
5 files changed
+400 -2

No files matched your search

@@ -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<string, unknown> | undefined;
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ getAuth: () => auth, addWarning: vi.fn() }),
}));
vi.mock("@/lib/sharing/grants", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/sharing/grants")>()),
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> = {},
): AiCapabilityInputs {
const moduleAccess = {} as Record<ModuleKey, ModuleAccessState>;
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();
});
});
+96
View File
@@ -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<boolean> {
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<AiUnavailableError | null> {
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<void> {
const refusal = await aiEgressRefusal(key, recordId, providerTypes);
if (refusal) throw refusal;
}
+18 -2
View File
@@ -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,
+23
View File
@@ -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,
+9
View File
@@ -84,6 +84,15 @@ export interface AiCapabilityState {
export const ON_DEVICE_ALLOWED_REASONS: ReadonlySet<AiUnavailableReason> =
new Set<AiUnavailableReason>(["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<AiUnavailableReason> =
new Set<AiUnavailableReason>(["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