mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user