mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'worktree-agent-ad9c547e230ce64bd' into release/v1.30.19
This commit is contained in:
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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