From 5211771e8d36836991ff7cd8524b58279b7e4a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 01:43:27 +0200 Subject: [PATCH] fix(ai): require consent before server-managed egress on two coach surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../self-context-questions-consent.test.ts | 251 ++++++++++++++++++ src/lib/ai/coach/self-context-questions.ts | 133 ++++++++-- .../__tests__/coach-nudge-ai-consent.test.ts | 170 ++++++++++++ src/lib/jobs/coach-nudge-ai.ts | 24 ++ 4 files changed, 554 insertions(+), 24 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts create mode 100644 src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts diff --git a/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts b/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts new file mode 100644 index 000000000..9acd08c5e --- /dev/null +++ b/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts @@ -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; + +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", + ); + }); +}); diff --git a/src/lib/ai/coach/self-context-questions.ts b/src/lib/ai/coach/self-context-questions.ts index d6a4074cc..351915ae2 100644 --- a/src/lib/ai/coach/self-context-questions.ts +++ b/src/lib/ai/coach/self-context-questions.ts @@ -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 { + 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(); diff --git a/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts b/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts new file mode 100644 index 000000000..b267ff776 --- /dev/null +++ b/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts @@ -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; + +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(); + }); +}); diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index 49944c59b..6e45aa434 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -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);