mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user