diff --git a/src/lib/__tests__/ai-refusal-envelope.test.ts b/src/lib/__tests__/ai-refusal-envelope.test.ts new file mode 100644 index 000000000..a21e8f390 --- /dev/null +++ b/src/lib/__tests__/ai-refusal-envelope.test.ts @@ -0,0 +1,160 @@ +/** + * The AI refusal envelope: one shape for every reason, and every code a + * shipped client branches on kept as it was. + * + * The table below is the contract. A change to it is a change a native client + * has to be told about, so it is spelled out literally rather than derived + * from the code under test; renaming a code, or moving a reason to a + * different status, turns this red by name. + * + * Mutation check: changing the `check_failed` status to 500 in + * `src/lib/ai/capabilities/refusal.ts`, or renaming `ai.record.notPermitted` + * in `types.ts`, fails the table; dropping the `AiUnavailableError` branch + * from `apiHandler` fails the envelope case with a 500. + */ +import { NextRequest } from "next/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { apiHandler } from "@/lib/api-handler"; +import { AiUnavailableError, aiRefusal } from "@/lib/ai/capabilities/refusal"; +import { + AI_CAPABILITIES, + AI_CAPABILITY_KEYS, + AI_UNAVAILABLE_REASONS, + type AiUnavailableReason, +} from "@/lib/ai/capabilities/types"; + +/** reason → [status, errorCode] for a capability on the `briefing` switch. */ +const CONTRACT: Record = { + check_failed: [503, "ai.unavailable"], + operator_disabled: [403, "assistant.disabled.briefing"], + not_permitted_for_record: [403, "ai.record.notPermitted"], + module_disabled: [403, "module.disabled"], + user_disabled: [403, "module.disabled"], + no_provider: [422, "ai.provider.none"], + consent_required: [403, "consent.ai.required"], +}; + +describe("the refusal table", () => { + it("covers every reason", () => { + expect(Object.keys(CONTRACT).sort()).toEqual( + [...AI_UNAVAILABLE_REASONS].sort(), + ); + }); + + for (const reason of AI_UNAVAILABLE_REASONS) { + it(`${reason} → ${CONTRACT[reason].join(" ")}`, () => { + const { status, meta } = aiRefusal("briefing", reason); + expect([status, meta.errorCode]).toEqual(CONTRACT[reason]); + expect(meta.capability).toBe("briefing"); + expect(meta.reason).toBe(reason); + }); + } + + it("names each capability's own switch in the operator code", () => { + for (const key of AI_CAPABILITY_KEYS) { + expect(aiRefusal(key, "operator_disabled").meta.errorCode).toBe( + `assistant.disabled.${AI_CAPABILITIES[key].operatorSwitch}`, + ); + } + }); + + it("keeps the code shipped clients know for the Coach", () => { + expect(aiRefusal("coach", "operator_disabled").meta.errorCode).toBe( + "assistant.disabled.coach", + ); + }); + + it("reports an operator-disabled module as module.disabled, naming it", () => { + expect( + aiRefusal("workoutInsights", "operator_disabled", "workouts"), + ).toEqual({ + status: 403, + meta: { + errorCode: "module.disabled", + capability: "workoutInsights", + reason: "operator_disabled", + module: "workouts", + }, + }); + }); + + it("names the module behind a module or opt-out refusal", () => { + expect(aiRefusal("statusText", "user_disabled", "insights").meta).toEqual({ + errorCode: "module.disabled", + capability: "statusText", + reason: "user_disabled", + module: "insights", + }); + }); + + it("lets a route keep its own typed no-provider code and status", () => { + const { status, meta } = aiRefusal("documentAi", "no_provider", null, { + errorCode: "documents.inbound.providerUnsupported", + }); + expect(status).toBe(422); + expect(meta.errorCode).toBe("documents.inbound.providerUnsupported"); + const custom = aiRefusal("medicationExtract", "no_provider", null, { + errorCode: "ai.provider.none", + status: 503, + }); + expect(custom.status).toBe(503); + }); +}); + +describe("apiHandler renders it", () => { + it("as { data: null, error, meta } with the refusal's status", async () => { + const route = apiHandler( + async (_request: NextRequest): Promise => { + throw new AiUnavailableError("coach", "consent_required"); + }, + ); + const res = await route( + new NextRequest("http://localhost/api/test", { method: "POST" }), + ); + expect(res.status).toBe(403); + const body = (await res.json()) as { + data: unknown; + error: string; + meta: Record; + }; + expect(body.data).toBeNull(); + expect(typeof body.error).toBe("string"); + expect(body.meta).toEqual({ + errorCode: "consent.ai.required", + capability: "coach", + reason: "consent_required", + }); + }); + + it("with a 503 when the capability could not be resolved", async () => { + const route = apiHandler( + async (_request: NextRequest): Promise => { + throw new AiUnavailableError("briefing", "check_failed"); + }, + ); + const res = await route( + new NextRequest("http://localhost/api/test", { method: "GET" }), + ); + expect(res.status).toBe(503); + expect( + ((await res.json()) as { meta: { errorCode: string } }).meta.errorCode, + ).toBe("ai.unavailable"); + }); +}); diff --git a/src/lib/ai/capabilities/__tests__/gate.test.ts b/src/lib/ai/capabilities/__tests__/gate.test.ts new file mode 100644 index 000000000..e05add81a --- /dev/null +++ b/src/lib/ai/capabilities/__tests__/gate.test.ts @@ -0,0 +1,222 @@ +/** + * The capability gates: which record and scope they resolve for, and how they + * fail. The resolver itself is covered in `resolve.test.ts`; here the loader + * is replaced so each case can hand the gate exactly the inputs it needs. + */ +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: {} })); + +import { findActiveGrant } from "@/lib/sharing/grants"; +import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; +import type { ModuleAccessState } from "@/lib/sharing/module-disclosure"; + +import { + aiCapabilityForJob, + getAiCapability, + 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); + +beforeEach(() => { + LOAD.mockReset(); + vi.mocked(findActiveGrant).mockReset(); + auth = { user_id: "actor" }; +}); + +describe("requireAiCapability", () => { + it("passes an available capability", async () => { + LOAD.mockResolvedValue(inputs()); + await expect(requireAiCapability("briefing")).resolves.toEqual({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + }); + + it("throws the outermost reason with its code", async () => { + LOAD.mockResolvedValue( + inputs({ + switches: { ...inputs().switches, briefing: false }, + providerWorkAdmitted: false, + }), + ); + const error = await requireAiCapability("briefing").catch((e) => e); + expect(error).toBeInstanceOf(AiUnavailableError); + expect(error.meta).toEqual({ + errorCode: "assistant.disabled.briefing", + capability: "briefing", + reason: "operator_disabled", + }); + }); + + it("names the module behind a module-layer refusal", async () => { + LOAD.mockResolvedValue( + inputs({ + moduleAccess: { ...inputs().moduleAccess, workouts: "disabled" }, + }), + ); + const error = await requireAiCapability("workoutInsights").catch((e) => e); + expect(error.meta).toMatchObject({ + errorCode: "module.disabled", + reason: "module_disabled", + module: "workouts", + }); + }); + + it("applies a route's own no-provider refusal", async () => { + LOAD.mockResolvedValue( + inputs({ + provider: { entries: [], localOcrEnabled: false, managedBy: null }, + }), + ); + const error = await requireAiCapability("documentAi", { + noProvider: { errorCode: "documents.inbound.providerUnsupported" }, + }).catch((e) => e); + expect(error.status).toBe(422); + expect(error.meta.errorCode).toBe("documents.inbound.providerUnsupported"); + }); + + it("fails closed when the loader could not read its inputs", async () => { + LOAD.mockResolvedValue(null); + const error = await requireAiCapability("coach").catch((e) => e); + expect(error.status).toBe(503); + expect(error.meta.reason).toBe("check_failed"); + }); + + it("fails closed when the loader throws", async () => { + LOAD.mockRejectedValue(new Error("boom")); + const error = await requireAiCapability("coach").catch((e) => e); + expect(error.meta.reason).toBe("check_failed"); + }); + + it("fails closed without an authenticated caller", async () => { + auth = undefined; + const error = await requireAiCapability("coach").catch((e) => e); + expect(error.meta.reason).toBe("check_failed"); + expect(LOAD).not.toHaveBeenCalled(); + }); +}); + +describe("the request scope", () => { + it("resolves the caller's own record with the whole record in view", async () => { + LOAD.mockResolvedValue(inputs()); + await getAiCapability("coach"); + expect(LOAD).toHaveBeenCalledWith( + expect.objectContaining({ + recordId: "actor", + sections: null, + recordKind: "self", + authority: expect.objectContaining({ origin: "owner" }), + }), + ); + }); + + it("resolves a switched record through the grant's sections", async () => { + auth = { + user_id: "actor", + acting_as: "owner", + provider_work_authority: { + origin: "guardian", + recordUserId: "owner", + actorUserId: "actor", + grantId: "g1", + }, + }; + vi.mocked(findActiveGrant).mockResolvedValue({ + scopeJson: ["documents"], + } as never); + LOAD.mockResolvedValue(inputs()); + await getAiCapability("documentAi"); + const scope = LOAD.mock.calls[0]![0]; + expect(scope.recordId).toBe("owner"); + expect(scope.sections).toEqual(["documents"]); + expect(scope.recordKind).toBe("managed"); + expect(scope.authority?.origin).toBe("guardian"); + expect(findActiveGrant).toHaveBeenCalledWith({ + grantorId: "owner", + granteeId: "actor", + }); + }); + + it("opens nothing when the grant is gone", async () => { + auth = { user_id: "actor", acting_as: "owner" }; + vi.mocked(findActiveGrant).mockResolvedValue(null); + LOAD.mockResolvedValue(inputs()); + await getAiCapability("coach"); + expect(LOAD.mock.calls[0]![0].sections).toEqual([]); + expect(LOAD.mock.calls[0]![0].recordKind).toBe("shared"); + }); +}); + +describe("getAiCapability", () => { + it("returns the state and never throws", async () => { + LOAD.mockRejectedValue(new Error("boom")); + await expect(getAiCapability("statusText")).resolves.toEqual({ + available: false, + reason: "check_failed", + onDeviceAllowed: false, + }); + }); +}); + +describe("aiCapabilityForJob", () => { + it("resolves for the named record with the whole record in view", async () => { + auth = undefined; + LOAD.mockResolvedValue(inputs()); + await expect(aiCapabilityForJob("u9", "reactionLines")).resolves.toEqual({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + expect(LOAD).toHaveBeenCalledWith( + expect.objectContaining({ recordId: "u9", sections: null }), + ); + }); + + it("fails closed and never throws", async () => { + LOAD.mockRejectedValue(new Error("boom")); + await expect(aiCapabilityForJob("u9", "coach")).resolves.toMatchObject({ + reason: "check_failed", + }); + }); +}); diff --git a/src/lib/ai/capabilities/gate.ts b/src/lib/ai/capabilities/gate.ts new file mode 100644 index 000000000..254a4d794 --- /dev/null +++ b/src/lib/ai/capabilities/gate.ts @@ -0,0 +1,152 @@ +/** + * The AI capability gates: how a route or a job asks whether it may call a + * model, or serve text a model wrote. + * + * - `requireAiCapability(key)`: AI action routes (they call a model or + * enqueue a job that will). Call it right after auth, before rate limits + * and body parsing; it throws `AiUnavailableError`, which `apiHandler` + * turns into the refusal envelope. + * - `getAiCapability(key)`: mixed reads that serve data plus optional model + * text. Never throws; returns the state so the route can null the model + * field and say why. + * - `aiCapabilityForJob(userId, key)`: workers, before they build a + * snapshot. No request context, so the record comes in as an argument and + * the authority is the worker's own. + * + * Data routes never import this file. A measurement, a score, a statistic or + * a device record does not depend on AI. + * + * The refusal envelope keeps every code a shipped client branches on: + * + * 403 { data: null, error, meta: { errorCode, capability, reason, module? } } + * + * operator_disabled → `assistant.disabled.`, or + * `module.disabled` + `meta.module` when the + * operator's module availability closed it + * not_permitted_for_record → `ai.record.notPermitted` + * module_disabled → `module.disabled` + `meta.module` + * user_disabled → `module.disabled` + `meta.module` + * no_provider → the route's own typed code where a client + * already reads one, otherwise + * `ai.provider.none`; 422 + * consent_required → `consent.ai.required` + * check_failed → `ai.unavailable`; 503 + */ +import { findActiveGrant } from "@/lib/sharing/grants"; +import { resolveGrantSections } from "@/lib/sharing/grant-view"; +import { getEvent } from "@/lib/logging/context"; +import { providerWorkAuthorityForRecord } from "@/lib/sharing/provider-work-authority"; + +import { loadAiCapabilityInputs, type AiCapabilityScope } from "./load"; +import { explainAiCapability, resolveAiCapability } from "./resolve"; +import { AiUnavailableError, type NoProviderRefusal } from "./refusal"; +import type { AiCapabilityKey, AiCapabilityState } from "./types"; + +export interface AiGateOptions { + /** + * The record to resolve for. Defaults to the record this request resolved + * (`acting_as`, else the caller). + */ + recordId?: string; + /** The route's own `no_provider` refusal, where a client already reads it. */ + noProvider?: NoProviderRefusal; +} + +/** + * The scope of the current request: the record it resolved, the authority + * stamped for it, and the sections the grant opens. `null` outside a request + * that authenticated anybody. + */ +async function requestScope( + recordOverride?: string, +): Promise { + const auth = getEvent()?.getAuth(); + const actorId = auth?.user_id; + if (!actorId) return null; + const recordId = recordOverride ?? auth.acting_as ?? actorId; + const authority = providerWorkAuthorityForRecord(recordId); + if (recordId === actorId) { + return { recordId, authority, sections: null, recordKind: "self" }; + } + // Inside somebody else's record: mask modules to what the grant opens, as + // the account payload does. `null` sections are an unscoped grant; a grant + // that cannot be found opens nothing. + const grant = await findActiveGrant({ + grantorId: recordId, + granteeId: actorId, + }); + return { + recordId, + authority, + sections: grant ? resolveGrantSections(grant.scopeJson) : [], + recordKind: authority?.origin === "guardian" ? "managed" : "shared", + }; +} + +/** + * A mixed read's question: may this request serve this capability? Never + * throws; a request with no authenticated caller reads `check_failed`. + */ +export async function getAiCapability( + key: AiCapabilityKey, + options: Pick = {}, +): Promise { + try { + const scope = await requestScope(options.recordId); + if (scope === null) return resolveAiCapability(key, null); + return resolveAiCapability(key, await loadAiCapabilityInputs(scope)); + } catch { + return resolveAiCapability(key, null); + } +} + +/** + * An AI action's gate. Returns the (available) state, or throws + * `AiUnavailableError` with the outermost reason. + */ +export async function requireAiCapability( + key: AiCapabilityKey, + options: AiGateOptions = {}, +): Promise { + let finding: ReturnType; + try { + const scope = await requestScope(options.recordId); + finding = explainAiCapability( + key, + scope === null ? null : await loadAiCapabilityInputs(scope), + ); + } catch { + finding = { reason: "check_failed", module: null }; + } + if (finding.reason !== null) { + throw new AiUnavailableError( + key, + finding.reason, + finding.module, + options.noProvider, + ); + } + return { available: true, reason: null, onDeviceAllowed: true }; +} + +/** + * A worker's gate. Resolves for `userId` under the worker's own authority + * (the authority the job was enqueued with, or `system`), with the whole + * record in view. Never throws. + */ +export async function aiCapabilityForJob( + userId: string, + key: AiCapabilityKey, +): Promise { + try { + const inputs = await loadAiCapabilityInputs({ + recordId: userId, + authority: providerWorkAuthorityForRecord(userId), + sections: null, + recordKind: "self", + }); + return resolveAiCapability(key, inputs); + } catch { + return resolveAiCapability(key, null); + } +} diff --git a/src/lib/ai/capabilities/load.ts b/src/lib/ai/capabilities/load.ts new file mode 100644 index 000000000..7a940a76b --- /dev/null +++ b/src/lib/ai/capabilities/load.ts @@ -0,0 +1,125 @@ +/** + * The AI capability loader: gathers the resolver's inputs for one record. + * + * One read per input, all of them in parallel, all of them memoised per + * request, and most of them shared with reads the request makes anyway: + * + * - operator switches: the `AppSettings` singleton (`loadAssistantSwitches`); + * - the record's module map and the operator's module availability: the same + * memoised reads the account payload and every module gate use; + * - provider presence: the record's credential row and the operator's + * provider columns (`probeProviderChain`), presence only; + * - consent: the kinds of the record's active receipts, one indexed read. + * + * Any failure resolves to `null`, which the resolver turns into + * `check_failed` for every capability. Failing closed is safe because no data + * depends on a capability; failing open would switch AI work on during a + * database blip. + */ +import { prisma } from "@/lib/db"; +import { loadAssistantSwitches } from "@/lib/feature-flags"; +import { getEvent } from "@/lib/logging/context"; +import { + getOperatorModuleAvailability, + resolveModuleMap, +} from "@/lib/modules/gate"; +import { probeProviderChain } from "@/lib/ai/provider"; +import { memoizePerRequest } from "@/lib/request-cache"; +import { buildModuleDisclosure } from "@/lib/sharing/module-disclosure"; +import { + mayEnqueueProviderWork, + type ProviderWorkAuthority, +} from "@/lib/sharing/provider-work-authority"; +import type { ShareDomain } from "@/lib/sharing/scope"; + +import { + resolveAiBlock, + type AiCapabilityInputs, + type AiRecordKind, +} from "./resolve"; +import type { AiCapabilities } from "./types"; + +/** Whose record, seen by whom, through which grant. */ +export interface AiCapabilityScope { + /** The record the capability is resolved for. */ + recordId: string; + /** The provider-work authority for that record; `null` admits nothing. */ + authority: ProviderWorkAuthority | null; + /** + * The sections the active grant opens, `null` for the whole record (one's + * own record, an unscoped grant, a background job). Masks module access + * exactly as the account payload masks `moduleAccess`. + */ + sections: readonly ShareDomain[] | null; + recordKind: AiRecordKind; +} + +/** The kinds of the record's active consent receipts. */ +function loadActiveConsentKinds(recordId: string): Promise> { + return memoizePerRequest(`ai-consent-kinds:${recordId}`, async () => { + const rows = await prisma.consentReceipt.findMany({ + where: { userId: recordId, revokedAt: null }, + select: { kind: true }, + }); + return new Set(rows.map((row) => row.kind)); + }); +} + +function scopeKey(scope: AiCapabilityScope): string { + return [ + scope.recordId, + scope.authority?.origin ?? "none", + scope.sections === null ? "*" : [...scope.sections].sort().join(","), + scope.recordKind, + ].join(":"); +} + +/** + * Load the resolver's inputs for one scope, or `null` when any of them could + * not be read. + */ +export function loadAiCapabilityInputs( + scope: AiCapabilityScope, +): Promise { + return memoizePerRequest( + `ai-capability-inputs:${scopeKey(scope)}`, + async () => { + try { + const [switches, modules, availability, provider, activeConsentKinds] = + await Promise.all([ + loadAssistantSwitches(), + resolveModuleMap(scope.recordId), + getOperatorModuleAvailability(), + probeProviderChain(scope.recordId, scope.authority), + loadActiveConsentKinds(scope.recordId), + ]); + if (switches === null) return null; + const { moduleAccess } = buildModuleDisclosure( + modules, + availability, + scope.sections, + ); + return { + switches, + moduleAccess, + providerWorkAdmitted: mayEnqueueProviderWork(scope.authority), + provider, + activeConsentKinds, + recordKind: scope.recordKind, + }; + } catch { + getEvent()?.addWarning( + "Failed to load AI capability inputs; every AI capability is off for this request", + ); + return null; + } + }, + ); +} + +/** The whole `ai` block for one scope. */ +export async function loadAiCapabilities( + scope: AiCapabilityScope, +): Promise { + return resolveAiBlock(await loadAiCapabilityInputs(scope)); +} diff --git a/src/lib/ai/capabilities/refusal.ts b/src/lib/ai/capabilities/refusal.ts new file mode 100644 index 000000000..0f6a44639 --- /dev/null +++ b/src/lib/ai/capabilities/refusal.ts @@ -0,0 +1,130 @@ +/** + * The refusal an AI action answers with when its capability is unavailable. + * + * Kept apart from the gates so `apiHandler` can render the envelope without + * importing the loader and the provider resolution behind it. The mapping + * keeps every code a shipped client already branches on; see + * `./gate.ts` for the table. + */ +import { MODULE_DISABLED_ERROR_CODE } from "@/lib/modules/gate"; +import type { ModuleKey } from "@/lib/modules/registry"; + +import { + AI_CAPABILITIES, + AI_PROVIDER_NONE_ERROR_CODE, + AI_RECORD_NOT_PERMITTED_ERROR_CODE, + AI_UNAVAILABLE_ERROR_CODE, + type AiCapabilityKey, + type AiUnavailableReason, +} from "./types"; + +/** How a route refuses `no_provider` when it already has a typed code. */ +export interface NoProviderRefusal { + errorCode: string; + status?: number; +} + +/** The consent code, shared with `ConsentRequiredError`. */ +const CONSENT_REQUIRED_CODE = "consent.ai.required"; + +/** What the envelope carries in `meta`. */ +export interface AiRefusalMeta { + errorCode: string; + capability: AiCapabilityKey; + reason: AiUnavailableReason; + module?: ModuleKey; +} + +/** The HTTP shape of one refusal. Pure; exported for the envelope test. */ +export function aiRefusal( + capability: AiCapabilityKey, + reason: AiUnavailableReason, + module: ModuleKey | null = null, + noProvider?: NoProviderRefusal, +): { status: number; meta: AiRefusalMeta } { + const base = { capability, reason }; + switch (reason) { + case "operator_disabled": + return module === null + ? { + status: 403, + meta: { + ...base, + errorCode: `assistant.disabled.${AI_CAPABILITIES[capability].operatorSwitch}`, + }, + } + : { + status: 403, + meta: { ...base, errorCode: MODULE_DISABLED_ERROR_CODE, module }, + }; + case "not_permitted_for_record": + return { + status: 403, + meta: { ...base, errorCode: AI_RECORD_NOT_PERMITTED_ERROR_CODE }, + }; + case "module_disabled": + case "user_disabled": + return { + status: 403, + meta: { + ...base, + errorCode: MODULE_DISABLED_ERROR_CODE, + ...(module === null ? {} : { module }), + }, + }; + case "no_provider": + return { + status: noProvider?.status ?? 422, + meta: { + ...base, + errorCode: noProvider?.errorCode ?? AI_PROVIDER_NONE_ERROR_CODE, + }, + }; + case "consent_required": + return { + status: 403, + meta: { ...base, errorCode: CONSENT_REQUIRED_CODE }, + }; + case "check_failed": + return { + status: 503, + meta: { ...base, errorCode: AI_UNAVAILABLE_ERROR_CODE }, + }; + } +} + +const REFUSAL_MESSAGE: Record = { + check_failed: "AI is unavailable right now", + operator_disabled: "This AI feature is turned off on this server", + not_permitted_for_record: "AI work is not available for this record", + module_disabled: "This AI feature's module is turned off for this record", + user_disabled: "This AI feature is turned off in your settings", + no_provider: "No AI provider is set up for this feature", + consent_required: "AI consent is required for this feature", +}; + +/** + * Thrown by `requireAiCapability`; `apiHandler` renders it as + * `{ data: null, error, meta }` with its status. + */ +export class AiUnavailableError extends Error { + readonly capability: AiCapabilityKey; + readonly reason: AiUnavailableReason; + readonly status: number; + readonly meta: AiRefusalMeta; + + constructor( + capability: AiCapabilityKey, + reason: AiUnavailableReason, + module: ModuleKey | null = null, + noProvider?: NoProviderRefusal, + ) { + super(REFUSAL_MESSAGE[reason]); + this.name = "AiUnavailableError"; + this.capability = capability; + this.reason = reason; + const refusal = aiRefusal(capability, reason, module, noProvider); + this.status = refusal.status; + this.meta = refusal.meta; + } +} diff --git a/src/lib/ai/capabilities/resolve.ts b/src/lib/ai/capabilities/resolve.ts index 737f2d4de..b814ba86a 100644 --- a/src/lib/ai/capabilities/resolve.ts +++ b/src/lib/ai/capabilities/resolve.ts @@ -97,10 +97,11 @@ function outermost( } function innermost( - reasons: readonly AiUnavailableReason[], + reasons: readonly (AiUnavailableReason | null)[], ): AiUnavailableReason | null { let best: AiUnavailableReason | null = null; for (const reason of reasons) { + if (reason === null) continue; if (best === null || REASON_RANK.get(reason)! > REASON_RANK.get(best)!) { best = reason; } @@ -127,24 +128,35 @@ function moduleReason( } } +interface ModuleFinding { + reason: AiUnavailableReason | null; + /** The owning module the reason is about, when it is about one. */ + module: ModuleKey | null; +} + /** * The module layer for one capability. `all` needs every owning module and * reports the outermost reason among those that are off. `any` needs one: it * is available as soon as one path is, and otherwise reports the INNERMOST * reason, because moving that one layer is enough to open the capability. */ -function modulesReason( +function modulesFinding( def: AiCapabilityDefinition, moduleAccess: Readonly>, -): AiUnavailableReason | null { +): ModuleFinding { const { mode, keys } = def.modules; - if (keys.length === 0) return null; - const reasons = keys.map((key) => - moduleReason(key, moduleAccess[key] ?? "unavailable"), - ); - if (mode === "all") return outermost(reasons); - if (reasons.some((reason) => reason === null)) return null; - return innermost(reasons as AiUnavailableReason[]); + const findings: ModuleFinding[] = keys.map((key) => ({ + reason: moduleReason(key, moduleAccess[key] ?? "unavailable"), + module: key, + })); + const closed = findings.filter((finding) => finding.reason !== null); + if (closed.length === 0) return { reason: null, module: null }; + if (mode === "any" && closed.length < findings.length) { + return { reason: null, module: null }; + } + const pick = mode === "all" ? outermost : innermost; + const reason = pick(closed.map((finding) => finding.reason!)); + return closed.find((finding) => finding.reason === reason)!; } /** The entries in the order this capability would pick from. */ @@ -233,25 +245,54 @@ function stateFor(reason: AiUnavailableReason | null): AiCapabilityState { }; } +/** Why one capability is unavailable, with the module it is about. */ +export interface AiCapabilityFinding { + reason: AiUnavailableReason | null; + /** + * The owning module behind a module-layer reason (`module_disabled`, + * `user_disabled`, or an `operator_disabled` that came from the operator's + * module availability rather than a switch). `null` otherwise. + */ + module: ModuleKey | null; +} + +/** + * The full finding for one capability. `null` inputs mean the loader failed. + * The refusal envelope reads the module to name `module.disabled` correctly; + * everything else only needs the state. + */ +export function explainAiCapability( + key: AiCapabilityKey, + inputs: AiCapabilityInputs | null, +): AiCapabilityFinding { + if (inputs === null) return { reason: "check_failed", module: null }; + const def = AI_CAPABILITIES[key]; + const { switches } = inputs; + const switchReason: AiUnavailableReason | null = + !switches.enabled || !switches[def.operatorSwitch] + ? "operator_disabled" + : null; + const modules = modulesFinding(def, inputs.moduleAccess); + const reason = outermost([ + switchReason, + inputs.providerWorkAdmitted ? null : "not_permitted_for_record", + modules.reason, + providerReason(def, inputs.provider), + consentReason(def, inputs.provider, inputs.activeConsentKinds), + ]); + // The switch outranks a module the operator also turned off: name the + // module only when the switch is not what closed it. + const aboutModule = + reason !== null && reason === modules.reason && switchReason === null; + return { reason, module: aboutModule ? modules.module : null }; +} + /** Resolve one capability. `null` inputs mean the loader failed. */ export function resolveAiCapability( key: AiCapabilityKey, inputs: AiCapabilityInputs | null, ): AiCapabilityState { - if (inputs === null) return stateFor("check_failed"); - const def = AI_CAPABILITIES[key]; - const { switches } = inputs; - return stateFor( - outermost([ - !switches.enabled || !switches[def.operatorSwitch] - ? "operator_disabled" - : null, - inputs.providerWorkAdmitted ? null : "not_permitted_for_record", - modulesReason(def, inputs.moduleAccess), - providerReason(def, inputs.provider), - consentReason(def, inputs.provider, inputs.activeConsentKinds), - ]), - ); + return stateFor(explainAiCapability(key, inputs).reason); } /** Resolve every capability. `null` inputs resolve all to `check_failed`. */ diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 5478c3a88..884a6d6de 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -27,6 +27,7 @@ import { type ActingCarrier, } from "./auth/acting-carrier"; import { AssistantDisabledError } from "./feature-flags"; +import { AiUnavailableError } from "./ai/capabilities/refusal"; import { ConsentRequiredError } from "./ai/consent-guard"; import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config"; import { @@ -234,7 +235,18 @@ export function apiHandler Promise>( try { response = await handler(...args); } catch (error) { - if (error instanceof AssistantDisabledError) { + if (error instanceof AiUnavailableError) { + // An AI capability is unavailable for this record. One envelope + // for every reason, keeping every code a shipped client already + // branches on: `meta.errorCode` plus the `capability` and the + // outermost `reason`, and `module` where the reason is about one. + // See `src/lib/ai/capabilities/gate.ts` for the table. + evt.setError(error); + response = NextResponse.json( + { data: null, error: error.message, meta: error.meta }, + { status: error.status }, + ); + } else if (error instanceof AssistantDisabledError) { // v1.4.31 — operator has disabled the assistant surface. // The 403 + `errorCode: "assistant.disabled."` // envelope is locked per diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 7b1f82e55..3e4462c4d 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -22,9 +22,11 @@ * * Not in this list, and deliberately: * - * - `assistant.disabled.` is built from a template at runtime - * (`src/lib/feature-flags/index.ts`), so the surface segment is open. - * `assistant.disabled.coach` is the one the iOS contract names. + * - `assistant.disabled.` is built from a template at runtime + * (`src/lib/ai/capabilities/refusal.ts`, and the retired + * `src/lib/feature-flags/index.ts` gate), so the last segment is open. It + * is one of `coach`, `briefing`, `insightStatus`, `documentAi` and + * `enabled`; `assistant.disabled.coach` is the one the iOS contract names. * - The integration-probe classes — `credentials_rejected`, `rate_limited`, * `upstream_error`, `timeout`, `connection_failed`, and the per-provider * additions beside them — are chosen by each `/test` route's own @@ -36,6 +38,7 @@ */ export const ERROR_CODE_CATALOGUE: Readonly> = { + ai: ["ai.provider.none", "ai.record.notPermitted", "ai.unavailable"], ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"], allergy: ["allergy.invalid"], analytics: ["analytics.invalid_query"],