refactor(ai): define the operator-held providers once

The list of provider tags that send data out through a credential the
operator holds (admin-openai, admin-codex) existed four times: in the
consent gate, in the capability resolver, in the shared-provider health
read and in the admin provider-health ordering. A tag added to one and not
the others would be consented for in one place and not the other. The list
and the local-provider test now live in src/lib/ai/provider-egress.ts and
every reader imports them.

The guard meant to keep one copy matched a variable name, so the copy in
the resolver, under another name, never tripped it. It now matches the
list itself in either order and names the one file allowed to hold it.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 20:27:53 +02:00
1 parent 63e69e98ea
commit 5fd858e0af
6 files changed
+79 -47

No files matched your search

@@ -183,18 +183,22 @@ describe("every provider-resolving module carries a consent helper", () => {
});
describe("the consent guard itself stays the single source of the policy", () => {
it("keeps the server-managed provider set defined in exactly one place", () => {
// Both operator-credential tags must be classified in consent-guard.ts and
// nowhere else — a second copy of this list is how the policy drifts.
const guard = read("lib/ai/consent-guard.ts");
expect(guard).toContain('"admin-openai"');
expect(guard).toContain('"admin-codex"');
it("keeps the operator-held provider set defined in exactly one place", () => {
// The two operator-credential tags are listed in provider-egress.ts and
// nowhere else, and the consent guard reads them from there. A second copy
// is how the policy drifts. This guard used to match one variable NAME, so
// a copy under another name in the capability resolver went unseen for as
// long as it existed; it now matches the list itself, in either order.
const pair =
/\[\s*"admin-(?:openai|codex)"\s*,\s*"admin-(?:openai|codex)"\s*,?\s*\]/;
const holders = sourceFiles().filter((rel) => pair.test(read(rel)));
expect(holders).toEqual(["lib/ai/provider-egress.ts"]);
const copies = sourceFiles().filter(
(rel) =>
rel !== "lib/ai/consent-guard.ts" &&
/SERVER_MANAGED_PROVIDER_TYPES/.test(read(rel)),
expect(read("lib/ai/consent-guard.ts")).toMatch(
/from "@\/lib\/ai\/provider-egress"/,
);
expect(read("lib/ai/capabilities/resolve.ts")).toMatch(
/from "@\/lib\/ai\/provider-egress"/,
);
expect(copies).toEqual([]);
});
});
+6 -2
View File
@@ -3,6 +3,7 @@ import { buildDateKey } from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { OPERATOR_HELD_PROVIDER_TYPES } from "@/lib/ai/provider-egress";
export const dynamic = "force-dynamic";
@@ -43,8 +44,11 @@ interface ProviderHealthSummary {
lastFailureStatus: number | null;
}
/** The two operator-managed tags sort first — they affect every user on the chain. */
const CENTRAL_TYPES = ["admin-openai", "admin-codex"];
/**
* The operator-managed tags sort first — they affect every user on the chain.
* Read from the one definition, in its order (the OpenAI key, then Codex).
*/
const CENTRAL_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES];
export const GET = apiHandler(async () => {
await requireAdmin();
+10 -12
View File
@@ -13,6 +13,10 @@
* a client acted on it. They stay a per-call refusal with their own codes.
*/
import { documentProviderRank } from "@/lib/documents/provider-rank";
import {
isExternalProvider,
isOperatorHeldProvider,
} from "@/lib/ai/provider-egress";
import type { ModuleKey } from "@/lib/modules/registry";
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
@@ -70,14 +74,8 @@ export interface AiCapabilityInputs {
recordKind: AiRecordKind;
}
/** Provider tags that egress through a credential the operator holds. */
const OPERATOR_HELD_PROVIDER_TYPES: ReadonlySet<string> = new Set([
"admin-openai",
"admin-codex",
]);
/** The one provider tag that keeps its input on the machine. */
const LOCAL_PROVIDER_TYPE = "local";
// Which tags are operator-held and which stays local: `@/lib/ai/provider-egress`,
// the list the consent gate reads too.
const REASON_RANK: ReadonlyMap<AiUnavailableReason, number> = new Map(
AI_UNAVAILABLE_REASONS.map((reason, index) => [reason, index]),
@@ -208,18 +206,18 @@ function consentNeeded(
// A cascade may fall through to any entry, so one operator-held entry
// anywhere in the chain is enough to need the receipt.
return provider.entries.some((entry) =>
OPERATOR_HELD_PROVIDER_TYPES.has(entry.providerType),
isOperatorHeldProvider(entry.providerType),
);
}
if (def.modality === "document") {
// Document reads call the one picked provider, no cascade.
const pick = documentPick(def, provider);
return pick !== null && pick.providerType !== LOCAL_PROVIDER_TYPE;
return pick !== null && isExternalProvider(pick.providerType);
}
// Text under the document rule cascades like a snapshot does, so any entry
// that leaves the machine needs the receipt.
return provider.entries.some(
(entry) => entry.providerType !== LOCAL_PROVIDER_TYPE,
return provider.entries.some((entry) =>
isExternalProvider(entry.providerType),
);
}
+6 -17
View File
@@ -30,6 +30,10 @@
import { latestActiveReceipt } from "@/lib/consent/receipts";
import type { ConsentKind } from "@/lib/validations/consent";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import {
isExternalProvider,
isOperatorHeldProvider,
} from "@/lib/ai/provider-egress";
/**
* The two AI surfaces that egress PHI through a chain. Each maps to the consent
@@ -39,17 +43,6 @@ import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
*/
export type ConsentSurface = "coach" | "insights";
/**
* Provider tags that egress via an operator-managed credential the user did not
* personally contract: the operator's global OpenAI key (`admin-openai`) and the
* operator's shared central Codex / ChatGPT-subscription account (`admin-codex`).
* Both require an active consent receipt before any PHI leaves for them.
*/
const SERVER_MANAGED_PROVIDER_TYPES: ReadonlySet<string> = new Set([
"admin-openai",
"admin-codex",
]);
/**
* Error thrown when an external-LLM egress on a server-managed key is
* attempted without an active consent receipt. Mirrors the AI capability refusal
@@ -79,9 +72,7 @@ export class ConsentRequiredError extends Error {
export function chainRequiresServerManagedConsent(
chain: ReadonlyArray<ProviderChainResolved>,
): boolean {
return chain.some((entry) =>
SERVER_MANAGED_PROVIDER_TYPES.has(entry.providerType),
);
return chain.some((entry) => isOperatorHeldProvider(entry.providerType));
}
/** The consent kinds that satisfy a given surface (the specific + the master). */
@@ -148,13 +139,11 @@ export async function assertConsentForChain(args: {
* reads the receipt kinds from the capability table; the auto-read toggle is a
* trigger, never the consent, so a revoked receipt wins over a toggle left on.
*/
const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet<string> = new Set(["local"]);
/**
* True when reading a document through this provider egresses it OFF the machine
* to a third-party AI service. Only the self-hosted `local` provider keeps the
* document on the operator's own infrastructure.
*/
export function isExternalDocumentEgress(providerType: string): boolean {
return !LOCAL_ONLY_PROVIDER_TYPES.has(providerType);
return isExternalProvider(providerType);
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Which AI provider tags send a record off the machine, and through whose
* credential. One definition, read by the consent gate
* (`src/lib/ai/consent-guard.ts`) and the capability resolver
* (`src/lib/ai/capabilities/resolve.ts`). They each kept their own copy
* before, and a provider added to one list but not the other would have been
* consented for in one place and not the other.
*/
/**
* Provider tags that egress through a credential the operator holds, which
* the user did not personally contract: the operator's global OpenAI key
* (`admin-openai`) and the operator's shared Codex / ChatGPT-subscription
* account (`admin-codex`). Both need an active consent receipt before any
* health data leaves for them.
*/
export const OPERATOR_HELD_PROVIDER_TYPES: ReadonlySet<string> = new Set([
"admin-openai",
"admin-codex",
]);
/** The one provider tag that keeps its input on the operator's machine. */
export const LOCAL_PROVIDER_TYPE = "local";
export function isOperatorHeldProvider(providerType: string): boolean {
return OPERATOR_HELD_PROVIDER_TYPES.has(providerType);
}
/**
* True when this provider sends its input OFF the machine to a third-party AI
* service. Only the self-hosted `local` provider keeps it on the operator's
* own infrastructure.
*/
export function isExternalProvider(providerType: string): boolean {
return providerType !== LOCAL_PROVIDER_TYPE;
}
+6 -5
View File
@@ -40,6 +40,7 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { OPERATOR_HELD_PROVIDER_TYPES } from "@/lib/ai/provider-egress";
/**
* The whole contract. Three words, no payload.
@@ -68,11 +69,11 @@ export const SERVER_PROVIDER_FRESH_WINDOW_MS = 24 * 60 * 60 * 1000;
/**
* The provider tags the operator pays for: the instance-wide OpenAI key and
* the shared central Codex account. These are exactly the two tags
* `consent-guard.ts` gates on, and that is not a coincidence — the offer
* this feeds is the consent for these two and for nothing else.
* the shared central Codex account. The same set `consent-guard.ts` gates on,
* read from the one definition, because the offer this feeds is the consent
* for these and for nothing else.
*/
const SHARED_PROVIDER_TYPES = ["admin-openai", "admin-codex"] as const;
const SHARED_PROVIDER_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES];
export async function readServerProviderHealth(
now: Date = new Date(),
@@ -84,7 +85,7 @@ export async function readServerProviderHealth(
}>;
try {
rows = await prisma.providerHealth.findMany({
where: { providerType: { in: [...SHARED_PROVIDER_TYPES] } },
where: { providerType: { in: SHARED_PROVIDER_TYPES } },
// Three columns, none of which identifies an account. The absence of
// `userId` here is load-bearing, not tidiness.
select: { lastResult: true, lastOkAt: true, nextRetryAt: true },