feat(ai): capability loader, gates and refusal envelope

loadAiCapabilityInputs reads the switches, module access, provider
presence and active consent kinds for one record in parallel, memoised
per request, and fails closed to check_failed. requireAiCapability,
getAiCapability and aiCapabilityForJob resolve for the request's
record, masked to the active grant like moduleAccess, or for a job's
record under the worker's authority.

apiHandler renders AiUnavailableError as one envelope with errorCode,
capability, reason and module. Existing codes stay: the operator
family keeps assistant.disabled.<switch>, modules keep module.disabled,
consent keeps consent.ai.required. New codes are ai.record.notPermitted,
ai.provider.none and ai.unavailable.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 07:46:12 +02:00
1 parent 1be2392c81
commit 7c757520f1
8 files changed
+873 -28

No files matched your search

@@ -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<AiUnavailableReason, [number, string]> = {
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<Response> => {
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<string, unknown>;
};
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<Response> => {
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");
});
});
@@ -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<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: {} }));
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> = {},
): 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);
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",
});
});
});
+152
View File
@@ -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.<switch>`, 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<AiCapabilityScope | null> {
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<AiGateOptions, "recordId"> = {},
): Promise<AiCapabilityState> {
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<AiCapabilityState> {
let finding: ReturnType<typeof explainAiCapability>;
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<AiCapabilityState> {
try {
const inputs = await loadAiCapabilityInputs({
recordId: userId,
authority: providerWorkAuthorityForRecord(userId),
sections: null,
recordKind: "self",
});
return resolveAiCapability(key, inputs);
} catch {
return resolveAiCapability(key, null);
}
}
+125
View File
@@ -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<Set<string>> {
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<AiCapabilityInputs | null> {
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<AiCapabilities> {
return resolveAiBlock(await loadAiCapabilityInputs(scope));
}
+130
View File
@@ -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<AiUnavailableReason, string> = {
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;
}
}
+65 -24
View File
@@ -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<Record<ModuleKey, ModuleAccessState>>,
): 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`. */
+13 -1
View File
@@ -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<T extends (...args: any[]) => Promise<Response>>(
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.<surface>"`
// envelope is locked per
+6 -3
View File
@@ -22,9 +22,11 @@
*
* Not in this list, and deliberately:
*
* - `assistant.disabled.<surface>` 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.<switch>` 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<Record<string, readonly string[]>> =
{
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"],