Merge branch 'worktree-agent-ad9c547e230ce64bd' into release/v1.30.19

This commit is contained in:
Marc-André Bombeck committed 2026-07-19 01:53:18 +02:00
commit d2656a2d90
5 files changed
+746 -24

No files matched your search

@@ -0,0 +1,192 @@
/**
* Structural guard on the AI consent boundary.
*
* Two surfaces shipped without a consent check — the self-context clarifying
* questions and the AI-composed proactive nudge — for the same reason: nothing
* connected "I am holding a live provider instance" to "I must have checked a
* receipt first". Behavioural tests close the two we found. This closes the
* class, so the third one cannot appear quietly.
*
* The invariant: a module that resolves a provider INSTANCE for a user
* (`resolveProvider` / `resolveProviderChain` / `resolveProviderForTest`) holds
* something it can call `generateCompletion` on. Every such module must also
* pull in a consent helper — or sit on the allowlist below with a written
* reason a reviewer signed off.
*
* WHAT THIS CANNOT PROVE — read this before trusting it:
*
* - It is an IMPORT-level check. It proves a consent helper is in the
* module's scope, NOT that the gate is reached on every path through the
* file. A module could import `hasActiveConsentForSurface`, call it on one
* branch, and egress ungated on another; this guard stays green.
* - It cannot prove ORDER. A gate called after the provider call, or after
* the snapshot is built, passes here and is still wrong.
* - It cannot prove the SURFACE is right. Passing `"insights"` where the data
* is a Coach snapshot passes here.
* - It cannot prove an allowlist entry is HONEST. A reviewer who waves
* through a new entry with a plausible-sounding reason defeats it entirely.
* The allowlist is a review checkpoint, not a proof.
*
* What it does buy: a NEW ungated provider call site cannot land silently. The
* author must either wire a consent helper or edit this file, and editing this
* file puts the decision in front of a reviewer. That is the whole claim.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join, sep } from "node:path";
import { globSync } from "node:fs";
const SRC = join(process.cwd(), "src");
/** Every non-test `.ts` / `.tsx` under `src/`, minus the generated client. */
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.filter(
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
/**
* The helpers that hand back a live, callable provider for a user. Deliberately
* NOT including the presence-only probes (`hasAnyConfiguredProvider`,
* `resolveProviderAvailability`, `userRowHasProviderCredential`): those never
* decrypt a key or construct a client, so they cannot egress anything.
*/
const RESOLUTION_HELPERS = [
"resolveProvider",
"resolveProviderChain",
"resolveProviderForTest",
] as const;
/** The two sanctioned consent APIs. Either satisfies the guard. */
const CONSENT_HELPERS = [
// Chain-shaped: throw-form and predicate-form.
"assertConsentForChain",
"chainRequiresServerManagedConsent",
"hasActiveConsentForSurface",
// Document-class, pick-shaped.
"assertDocumentEgressConsent",
"isExternalDocumentEgress",
] as const;
/**
* Extract the named bindings a file imports from a given module specifier.
* Handles the multi-line `import { a, b } from "…"` form the codebase uses.
* Comments mentioning a helper are NOT import statements and do not count —
* that distinction is the whole point of parsing the import rather than
* grepping the file.
*/
function importedNamesFrom(source: string, moduleSpecifier: string): string[] {
const re = new RegExp(
`import\\s+(?:type\\s+)?\\{([^}]*)\\}\\s*from\\s*["']${moduleSpecifier.replace(
/[/\\^$*+?.()|[\]{}]/g,
"\\$&",
)}["']`,
"g",
);
const names: string[] = [];
for (const match of source.matchAll(re)) {
for (const raw of match[1].split(",")) {
const name = raw
.trim()
.replace(/^type\s+/, "")
.split(/\s+as\s+/)[0];
if (name) names.push(name.trim());
}
}
return names;
}
function resolvesAProvider(source: string): boolean {
const imported = importedNamesFrom(source, "@/lib/ai/provider");
return RESOLUTION_HELPERS.some((h) => imported.includes(h));
}
function importsAConsentHelper(source: string): boolean {
const imported = importedNamesFrom(source, "@/lib/ai/consent-guard");
return CONSENT_HELPERS.some((h) => imported.includes(h));
}
/**
* Modules that resolve a provider and deliberately carry no consent helper.
* Every entry needs a reason that survives a reviewer asking "what PHI does
* this send?" — the answer here is "none" in every case.
*
* `lib/ai/provider.ts` needs no entry: it DEFINES the resolution helpers rather
* than importing them, so the detector never flags it. That is correct — the
* resolver constructs clients and never sends a prompt, and gating it would
* invert the layering.
*/
const ALLOWLIST: Record<string, string> = {
// --- Capability / config probes: resolve a chain, never call it. ---
"lib/labs/ocr-capability.ts":
"Resolves the chain to REPORT which entry could do vision/text OCR (mode, reason, pdfSupported). Makes no completion call. Every consumer that then egresses a document is gated at its own call site with assertDocumentEgressConsent.",
"app/api/insights/provider-chain/route.ts":
"Lists the user's configured chain and which entry is active, for the settings UI. Constructs clients but never calls generateCompletion. No health data in the request or the response.",
"app/api/insights/comprehensive/route.ts":
"Uses the resolver only as a boolean probe ((await resolveProvider(userId)).type !== 'none') to set hasProvider on the response. The generation path itself lives in lib/insights/comprehensive-generate.ts, which is gated.",
// --- Connection test: a real provider call, but with no user data. ---
"app/api/ai/test/route.ts":
"Sends a fixed synthetic prompt to verify credentials resolve and the endpoint answers. No health data is read, so there is no PHI egress for a receipt to authorise. Bounded by its own 5/min rate limit.",
};
describe("every provider-resolving module carries a consent helper", () => {
const resolvers = sourceFiles().filter((rel) => resolvesAProvider(read(rel)));
it("finds the provider-resolving modules at all (the guard is wired up)", () => {
// A refactor that renames the resolver or moves the module would otherwise
// silently empty the set and make every assertion below vacuous.
expect(resolvers.length).toBeGreaterThanOrEqual(8);
expect(resolvers).toContain("lib/ai/coach/self-context-questions.ts");
expect(resolvers).toContain("lib/jobs/coach-nudge-ai.ts");
});
it("gates every provider-resolving module that is not explicitly allowlisted", () => {
const ungated = resolvers.filter(
(rel) => !importsAConsentHelper(read(rel)) && !(rel in ALLOWLIST),
);
expect(ungated).toEqual([]);
});
it("keeps the allowlist free of stale entries", () => {
// An entry that no longer resolves a provider is dead weight that hides
// the next real one. Delete it rather than letting it rot.
const stale = Object.keys(ALLOWLIST).filter(
(rel) => !resolvers.includes(rel),
);
expect(stale).toEqual([]);
});
it("requires a substantive reason on every allowlist entry", () => {
const thin = Object.entries(ALLOWLIST)
.filter(([, reason]) => reason.trim().length < 40)
.map(([rel]) => rel);
expect(thin).toEqual([]);
});
});
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"');
const copies = sourceFiles().filter(
(rel) =>
rel !== "lib/ai/consent-guard.ts" &&
/SERVER_MANAGED_PROVIDER_TYPES/.test(read(rel)),
);
expect(copies).toEqual([]);
});
});
@@ -0,0 +1,251 @@
/**
* Consent + budget contract for the self-context clarifying questions.
*
* The questions prompt ships the COMPLETE Coach snapshot, so it is a real PHI
* egress and carries the same receipt requirement as the Coach chat. These
* tests pin the two halves of that contract:
*
* - a chain that could reach the operator's server-managed credential
* (`admin-openai` / `admin-codex`) refuses without an active receipt and
* lands on the deterministic hints — the provider is never called, and the
* snapshot is never even built;
* - the same chain proceeds once a receipt is on file;
* - a BYOK / local chain stays ungated (the user's own egress);
* - the budget runs through the ATOMIC reserve/reconcile pair, not a
* read-then-write check.
*
* `@/lib/consent/receipts` is mocked rather than the consent guard itself, so
* the real `chainRequiresServerManagedConsent` + `hasActiveConsentForSurface`
* logic is what these tests exercise.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
const latestActiveReceipt = vi.hoisted(() => vi.fn());
vi.mock("@/lib/consent/receipts", () => ({ latestActiveReceipt }));
vi.mock("@/lib/documents/document-settings", () => ({
documentAutoReadEnabled: vi.fn(async () => false),
}));
const providerMocks = vi.hoisted(() => ({
hasAnyConfiguredProvider: vi.fn(async () => true),
resolveProvider: vi.fn(async () => ({ type: "none" })),
resolveProviderChain: vi.fn(async () => [] as unknown[]),
}));
vi.mock("@/lib/ai/provider", () => providerMocks);
const budgetMocks = vi.hoisted(() => ({
buildDateKey: vi.fn(() => "2026-07-18"),
reserveBudget: vi.fn(async () => ({
allowed: true,
reserved: 300,
totalAfter: 300,
})),
reconcileSpend: vi.fn(async () => {}),
resolveDailyCap: vi.fn(() => 200_000),
}));
vi.mock("@/lib/ai/coach/budget", () => budgetMocks);
const buildCoachSnapshot = vi.hoisted(() => vi.fn());
vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { deriveClarifyingQuestions } from "../self-context-questions";
import type { SelfContext } from "../about-me";
const ctx: SelfContext = {
aboutMe: "Shift work, half-marathon training.",
conditions: "Hypertension",
allergies: null,
coachFocus: null,
};
/** A provider whose completion returns three usable questions. */
function makeProvider() {
return {
type: "openai",
generateCompletion: vi.fn(async () => ({
content: '["Question one?","Question two?"]',
model: "gpt-4o",
tokensUsed: 120,
cachedInputTokens: 0,
})),
};
}
let provider: ReturnType<typeof makeProvider>;
beforeEach(() => {
vi.clearAllMocks();
provider = makeProvider();
latestActiveReceipt.mockResolvedValue(null);
providerMocks.hasAnyConfiguredProvider.mockResolvedValue(true);
providerMocks.resolveProvider.mockResolvedValue({ type: "none" });
budgetMocks.reserveBudget.mockResolvedValue({
allowed: true,
reserved: 300,
totalAfter: 300,
});
buildCoachSnapshot.mockResolvedValue({ snapshotJson: '{"weight":[]}' });
});
describe("deriveClarifyingQuestions — server-managed consent gate", () => {
it("refuses the AI path on an operator-managed chain with no receipt", async () => {
providerMocks.resolveProviderChain.mockResolvedValue([
{ providerType: "admin-openai", instance: provider },
]);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
expect(out.source).toBe("fallback");
// The snapshot is the PHI: it must never even be built, let alone sent.
expect(provider.generateCompletion).not.toHaveBeenCalled();
expect(buildCoachSnapshot).not.toHaveBeenCalled();
// And no budget is consumed by a refused request.
expect(budgetMocks.reserveBudget).not.toHaveBeenCalled();
});
it("refuses on the operator-shared central Codex with no receipt", async () => {
providerMocks.resolveProviderChain.mockResolvedValue([
{ providerType: "admin-codex", instance: provider },
]);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
expect(out.source).toBe("fallback");
expect(provider.generateCompletion).not.toHaveBeenCalled();
});
it("refuses when the legacy single-provider fallback serves the request", async () => {
// Empty chain → `resolveProvider` fallback, which is tagged `admin-openai`
// because an admin-key `OpenAIClient` is indistinguishable from a BYOK one.
providerMocks.resolveProviderChain.mockResolvedValue([]);
providerMocks.resolveProvider.mockResolvedValue(provider);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
expect(out.source).toBe("fallback");
expect(provider.generateCompletion).not.toHaveBeenCalled();
});
it("proceeds on an operator-managed chain once an ai_coach receipt is active", async () => {
providerMocks.resolveProviderChain.mockResolvedValue([
{ providerType: "admin-openai", instance: provider },
]);
latestActiveReceipt.mockImplementation(async (_userId, kind) =>
kind === "ai_coach" ? { id: "receipt-1" } : null,
);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
expect(out.source).toBe("ai");
expect(out.questions).toEqual(["Question one?", "Question two?"]);
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
});
it("accepts the master ai_full grant for the coach surface", async () => {
providerMocks.resolveProviderChain.mockResolvedValue([
{ providerType: "admin-openai", instance: provider },
]);
latestActiveReceipt.mockImplementation(async (_userId, kind) =>
kind === "ai_full" ? { id: "receipt-2" } : null,
);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
expect(out.source).toBe("ai");
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
});
it("leaves a BYOK chain ungated — the user's own egress needs no receipt", async () => {
providerMocks.resolveProviderChain.mockResolvedValue([
{ providerType: "openai", instance: provider },
]);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
expect(out.source).toBe("ai");
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
expect(latestActiveReceipt).not.toHaveBeenCalled();
});
it("leaves a local chain ungated", async () => {
providerMocks.resolveProviderChain.mockResolvedValue([
{ providerType: "local", instance: provider },
]);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
expect(out.source).toBe("ai");
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
});
it("fails closed when a server-managed entry sits BEHIND a BYOK primary", async () => {
// The runner may cascade to the admin key on a primary failure, so the
// whole chain is what the gate judges — not just its head.
providerMocks.resolveProviderChain.mockResolvedValue([
{ providerType: "openai", instance: provider },
{ providerType: "admin-openai", instance: provider },
]);
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
expect(out.source).toBe("fallback");
expect(provider.generateCompletion).not.toHaveBeenCalled();
});
});
describe("deriveClarifyingQuestions — atomic budget", () => {
beforeEach(() => {
providerMocks.resolveProviderChain.mockResolvedValue([
{ providerType: "openai", instance: provider },
]);
});
it("reserves before the call and reconciles the actual spend after", async () => {
await deriveClarifyingQuestions("user-1", ctx, "en");
expect(budgetMocks.reserveBudget).toHaveBeenCalledWith(
"user-1",
300,
"2026-07-18",
200_000,
);
expect(budgetMocks.reconcileSpend).toHaveBeenCalledWith(
"user-1",
300,
120,
"2026-07-18",
0,
);
});
it("falls back without calling the provider when the reservation is refused", async () => {
budgetMocks.reserveBudget.mockResolvedValue({
allowed: false,
reserved: 300,
totalAfter: 200_000,
});
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
expect(out.source).toBe("fallback");
expect(provider.generateCompletion).not.toHaveBeenCalled();
});
it("reconciles the reservation to zero when the provider throws", async () => {
provider.generateCompletion.mockRejectedValue(new Error("upstream down"));
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
expect(out.source).toBe("fallback");
expect(budgetMocks.reconcileSpend).toHaveBeenCalledWith(
"user-1",
300,
0,
"2026-07-18",
);
});
});
+109 -24
View File
@@ -10,24 +10,42 @@
*
* Two paths, gated in order:
* 1. AI path — only when the user has a working provider
* (`hasAnyConfiguredProvider`) AND the daily Coach token budget
* is not exhausted. One single-shot completion (no fallback
* chain — this is a nicety, not a core flow); spend is recorded
* against the same per-day ledger the Coach uses.
* (`hasAnyConfiguredProvider`), an active consent receipt covers
* the egress, AND the daily Coach token budget is not exhausted.
* One single-shot completion (no fallback chain — this is a
* nicety, not a core flow); spend rides the same per-day ledger
* the Coach uses.
* 2. Deterministic path — two static completion hints for the first
* two unanswered fields, localised through the server translator.
* Also the landing spot for every AI-path failure (no provider,
* budget gone, model error, unparseable output).
* consent missing, budget gone, model error, unparseable output).
*
* The prompt carries the FULL Coach snapshot, so this is a genuine PHI
* egress and it is gated exactly like every other Coach surface: a chain
* that could reach the operator's server-managed credential requires an
* active `ai_coach` / `ai_full` receipt. The gate is SKIP-shaped, not
* throw-shaped — a missing receipt lands on the deterministic hints, which
* is what the caller (`PUT /api/coach/about-me`) already expects for every
* other AI-path miss.
*
* Server-only — reads `@/lib/db` through the provider resolver.
*/
import { hasAnyConfiguredProvider, resolveProvider } from "@/lib/ai/provider";
import {
hasAnyConfiguredProvider,
resolveProvider,
resolveProviderChain,
} from "@/lib/ai/provider";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import {
chainRequiresServerManagedConsent,
hasActiveConsentForSurface,
} from "@/lib/ai/consent-guard";
import { singleUserTurn } from "@/lib/ai/types";
import {
buildDateKey,
getDailyTokenSpend,
OPERATOR_COST_CAP,
recordSpend,
reconcileSpend,
reserveBudget,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import {
clampPendingQuestions,
@@ -116,6 +134,27 @@ export function buildPrompts(
return { systemPrompt, userPrompt };
}
/**
* Resolve the chain that will serve this generation, so the consent gate and
* the daily cap both see the SAME provider set the call will actually use.
* Mirrors `resolveStatusChain` in `@/lib/insights/status-provider`: the chain
* first, then the legacy single provider tagged `admin-openai` — the
* conservative tag, because `resolveProvider`'s admin fallback returns a bare
* `OpenAIClient` that is indistinguishable from a BYOK key at the instance
* level. Tagging it as server-managed makes the gate fail closed. Returns null
* when nothing can serve.
*/
async function resolveQuestionsChain(
userId: string,
): Promise<ProviderChainResolved[] | null> {
const chain = await resolveProviderChain(userId);
if (chain.length > 0) return chain;
const legacy = await resolveProvider(userId);
if (legacy.type === "none") return null;
return [{ providerType: "admin-openai", instance: legacy }];
}
/**
* Derive the pending questions for a freshly saved self-context.
* Never throws — every failure path lands on the deterministic
@@ -134,12 +173,42 @@ export async function deriveClarifyingQuestions(
try {
if (!(await hasAnyConfiguredProvider(userId))) return fallback();
// Budget gate — same per-day ledger the Coach chat enforces.
const dateKey = buildDateKey();
const spent = await getDailyTokenSpend(userId, dateKey);
if (spent >= OPERATOR_COST_CAP) return fallback();
const chain = await resolveQuestionsChain(userId);
if (chain === null) return fallback();
const provider = await resolveProvider(userId);
// Consent gate — BEFORE the snapshot is built, let alone sent. This
// prompt ships the complete Coach snapshot, so a chain that could egress
// via the operator's server-managed credential needs an active
// `ai_coach` / `ai_full` receipt. BYOK / local / ChatGPT-OAuth chains are
// the user's own egress and stay ungated, matching every other surface.
if (
chainRequiresServerManagedConsent(chain) &&
!(await hasActiveConsentForSurface(userId, "coach"))
) {
annotate({
action: { name: "coach.self_context.consent_required" },
meta: { self_context_questions_source: "fallback" },
});
return fallback();
}
// Budget gate — the same per-day ledger the Coach chat enforces, via the
// ATOMIC reserve/reconcile pair. The prior read-then-write
// (`getDailyTokenSpend` then `recordSpend`) let two concurrent saves both
// observe a sub-cap spend and both call the provider; the upsert-increment
// serialises them on the row's unique key instead. The cap follows the
// chain's cost owner, so a user on their own plan is not held to the
// operator-cost ceiling.
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
QUESTIONS_MAX_TOKENS,
dateKey,
resolveDailyCap(chain),
);
if (!reservation.allowed) return fallback();
const provider = chain[0].instance;
// v1.16.6 — same snapshot the Coach chat rides; lets the model
// ask about what the user actually tracks. Best-effort: a snapshot
// failure must not cost the AI path, the prompt just stays
@@ -152,18 +221,34 @@ export async function deriveClarifyingQuestions(
resolveLocale(locale),
snapshotJson,
);
const result = await provider.generateCompletion(
singleUserTurn({
system: systemPrompt,
user: userPrompt,
temperature: 0.4,
maxTokens: QUESTIONS_MAX_TOKENS,
}),
);
if (typeof result.tokensUsed === "number" && result.tokensUsed > 0) {
await recordSpend({ userId, tokens: result.tokensUsed, dateKey });
let result;
try {
result = await provider.generateCompletion(
singleUserTurn({
system: systemPrompt,
user: userPrompt,
temperature: 0.4,
maxTokens: QUESTIONS_MAX_TOKENS,
}),
);
} catch (err) {
// Reconcile the reservation down to zero actual spend before the
// failure lands on the deterministic fallback.
await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch(
() => {},
);
throw err;
}
await reconcileSpend(
userId,
reservation.reserved,
result.tokensUsed ?? 0,
dateKey,
result.cachedInputTokens ?? 0,
).catch(() => {});
const questions = parseQuestionsReply(result.content);
if (questions.length === 0) return fallback();
@@ -0,0 +1,170 @@
/**
* Consent contract for the AI-composed proactive Coach nudge.
*
* The nudge sends less PHI than the Coach chat (an abstract trigger topic plus
* the deterministic template body — never the user's own words or figures), but
* it is still the user's health situation leaving the server, it runs
* unattended on the 05:15 tick, and the chain can end at the operator's
* server-managed credential. So it carries the same receipt requirement.
*
* The gate is skip-shaped: no receipt → `null` → the caller ships the
* deterministic template, so the nudge itself is never lost.
*
* `@/lib/consent/receipts` is mocked rather than the consent guard, so the real
* `chainRequiresServerManagedConsent` + `hasActiveConsentForSurface` logic runs.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
const latestActiveReceipt = vi.hoisted(() => vi.fn());
vi.mock("@/lib/consent/receipts", () => ({ latestActiveReceipt }));
vi.mock("@/lib/documents/document-settings", () => ({
documentAutoReadEnabled: vi.fn(async () => false),
}));
const resolveProviderChain = vi.hoisted(() => vi.fn());
vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain }));
const budgetMocks = vi.hoisted(() => ({
buildDateKey: vi.fn(() => "2026-07-18"),
reserveBudget: vi.fn(async () => ({
allowed: true,
reserved: 160,
totalAfter: 160,
})),
reconcileSpend: vi.fn(async () => {}),
resolveDailyCap: vi.fn(() => 200_000),
}));
vi.mock("@/lib/ai/coach/budget", () => budgetMocks);
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import {
composeNudgeWithAI,
createNudgeAiTickBudget,
type ComposeNudgeParams,
} from "../coach-nudge-ai";
function makeProvider() {
return {
type: "openai",
generateCompletion: vi.fn(async () => ({
content: "Your rhythm has shifted a little this week — worth a look?",
model: "gpt-4o",
tokensUsed: 90,
cachedInputTokens: 0,
})),
};
}
let provider: ReturnType<typeof makeProvider>;
function params(): ComposeNudgeParams {
return {
userId: "user-1",
trigger: "compliance",
locale: "en",
name: "A",
hasCoachFocus: false,
template: { title: "Morning", body: "A gentle deterministic body." },
tickBudget: createNudgeAiTickBudget(),
};
}
beforeEach(() => {
vi.clearAllMocks();
provider = makeProvider();
latestActiveReceipt.mockResolvedValue(null);
budgetMocks.reserveBudget.mockResolvedValue({
allowed: true,
reserved: 160,
totalAfter: 160,
});
});
describe("composeNudgeWithAI — server-managed consent gate", () => {
it("skips AI composition on an operator-managed chain with no receipt", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "admin-openai", instance: provider },
]);
const out = await composeNudgeWithAI(params());
// null = the caller keeps the deterministic template.
expect(out).toBeNull();
expect(provider.generateCompletion).not.toHaveBeenCalled();
});
it("burns neither a budget reservation nor a per-tick slot when refused", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "admin-openai", instance: provider },
]);
const p = params();
await composeNudgeWithAI(p);
expect(budgetMocks.reserveBudget).not.toHaveBeenCalled();
expect(p.tickBudget.remainingCount).toBe(
createNudgeAiTickBudget().remainingCount,
);
});
it("skips on the operator-shared central Codex with no receipt", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "admin-codex", instance: provider },
]);
expect(await composeNudgeWithAI(params())).toBeNull();
expect(provider.generateCompletion).not.toHaveBeenCalled();
});
it("composes once an ai_coach receipt is active", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "admin-openai", instance: provider },
]);
latestActiveReceipt.mockImplementation(async (_userId, kind) =>
kind === "ai_coach" ? { id: "receipt-1" } : null,
);
const out = await composeNudgeWithAI(params());
expect(out).not.toBeNull();
expect(out?.body).toBe(
"Your rhythm has shifted a little this week — worth a look?",
);
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
});
it("accepts the master ai_full grant", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "admin-openai", instance: provider },
]);
latestActiveReceipt.mockImplementation(async (_userId, kind) =>
kind === "ai_full" ? { id: "receipt-2" } : null,
);
expect(await composeNudgeWithAI(params())).not.toBeNull();
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
});
it("leaves a BYOK chain ungated — the user's own egress needs no receipt", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "openai", instance: provider },
]);
expect(await composeNudgeWithAI(params())).not.toBeNull();
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
expect(latestActiveReceipt).not.toHaveBeenCalled();
});
it("fails closed when a server-managed entry sits BEHIND a BYOK primary", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "openai", instance: provider },
{ providerType: "admin-openai", instance: provider },
]);
expect(await composeNudgeWithAI(params())).toBeNull();
expect(provider.generateCompletion).not.toHaveBeenCalled();
});
});
+24
View File
@@ -9,6 +9,13 @@
* model's output.
*
* Hard guards (non-negotiable):
* - a CONSENT gate: a chain that could egress via the operator's
* server-managed credential requires an active `ai_coach` / `ai_full`
* receipt. The PHI volume here is small (an abstract trigger topic plus
* the deterministic template body) but it is still the user's health
* situation leaving the server on a credential they did not contract, and
* this tick runs unattended — so it carries the same receipt requirement
* as the interactive Coach;
* - per-user daily BUDGET gate (the same reserve/reconcile ledger the Coach
* chat uses, with the chain-aware daily cap);
* - a tight per-call TIMEOUT (≤ ~9 s) on both the provider `timeoutMs` and a
@@ -28,6 +35,10 @@
import type { CoachNudgeTrigger } from "@/lib/jobs/coach-nudge";
import type { Locale } from "@/lib/i18n/config";
import { resolveProviderChain } from "@/lib/ai/provider";
import {
chainRequiresServerManagedConsent,
hasActiveConsentForSurface,
} from "@/lib/ai/consent-guard";
import {
buildDateKey,
reconcileSpend,
@@ -156,6 +167,19 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => {
const chain = await resolveProviderChain(params.userId);
if (chain.length === 0) return null;
// Consent gate — before the budget reservation, so a user without a
// receipt never spends a slot or a token. Skip-shaped like every other
// guard here: no receipt → return null and the caller ships the
// deterministic template, so the nudge itself is never lost. BYOK / local
// / ChatGPT-OAuth chains are the user's own egress and stay ungated.
if (
chainRequiresServerManagedConsent(chain) &&
!(await hasActiveConsentForSurface(params.userId, "coach"))
) {
annotate({ action: { name: "coach.nudge.ai.consent_required" } });
return null;
}
const budget = AI_BUDGETS.coachNudge;
const maxTokens = budget.maxTokens ?? 160;
const dailyCap = resolveDailyCap(chain);