fix(ai): require consent before server-managed egress on two coach surfaces

The self-context clarifying questions and the AI-composed proactive nudge
both reached a provider without a consent check of any kind. Either could
resolve to the operator's server-managed credential (admin-openai or the
shared central Codex), so a user with no AI credentials and no receipt on
file had health data leave the server on the operator's key.

The questions path is the sharper of the two: its prompt carries the
complete Coach snapshot. Gate both with the chain predicate pair the status
path already uses, on the coach surface. The gate is skip-shaped on both —
the questions fall back to the deterministic hints, the nudge keeps its
deterministic template — so a missing receipt costs the user nothing but the
model's phrasing. BYOK, local and ChatGPT-OAuth chains stay ungated; that
egress is the user's own act.

Move the questions path off the read-then-write budget check onto the atomic
reserve/reconcile pair, so two concurrent saves can no longer both observe a
sub-cap spend and both call the provider. The cap now follows the chain's
cost owner instead of always charging the operator ceiling.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 01:43:27 +02:00
1 parent 40ad2dfc96
commit 5211771e8d
4 files changed
+554 -24

No files matched your search

@@ -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);