diff --git a/src/__tests__/ai-egress-capability-guard.test.ts b/src/__tests__/ai-egress-capability-guard.test.ts new file mode 100644 index 000000000..a8c7acce9 --- /dev/null +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -0,0 +1,195 @@ +/** + * Structural guard on the places a model is actually called. + * + * A capability is only as good as the last check before the wire. A job + * enqueued before an operator flipped a switch, or before the person withdrew + * consent or turned AI analysis off, must still stop there. So every file that + * hands a prompt to a provider (`.generateCompletion(`, the chain runners) is + * named here with the reason its call is admitted, and the ones that ARE a + * chokepoint must re-check a capability in the same file. + * + * Mirrors `ai-consent-enforcement-guard.test.ts` in shape and in its limits: it + * proves a reference exists, not that it sits on every branch or before the + * call. The integration suite with a provider spy + * (`tests/integration/ai-optional-jobs.test.ts`) is what proves behaviour. + * A future egress helper under a new name would slip the matcher; a reviewer + * adding one should add it here. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { runStatusCompletion } from "@/lib/insights/status-provider"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** A provider call: a client's completion (plain or streamed), or a chain runner. */ +const EGRESS_CALL = + /\.generateCompletion(?:Stream)?\s*\(|\brunRawCompletionWithFallback\s*\(|\brunWithFallback\s*\(/; + +/** A capability read in the same file. */ +const CAPABILITY_CHECK = + /\b(?:aiCapabilityForJob|aiCapabilityForRecord|requireAiCapability|getAiCapability)\s*\(/; + +/** Comments name the helpers too; only code counts. */ +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +function code(rel: string): string { + return stripComments(readFileSync(join(SRC, rel), "utf8")); +} + +interface EgressSite { + /** True when this file is a chokepoint and must re-check a capability. */ + recheck: boolean; + reason: string; +} + +/** + * Every file that calls a provider, and why the call is admitted. + */ +const EGRESS_SITES: Record = { + // ── Chokepoints: re-check at the wire ────────────────────────────────── + "lib/insights/status-provider.ts": { + recheck: true, + reason: + "runStatusCompletion: status notes, narratives, workout paragraphs, derived and biomarker assessments, Coach memory. Takes the capability as a required argument and re-checks it before the chain is resolved.", + }, + "lib/jobs/reaction-line.ts": { + recheck: true, + reason: + "Reaction lines call the first chain entry directly; the worker resolves `reactionLines` before the digest or the chain.", + }, + "lib/jobs/coach-nudge-ai.ts": { + recheck: true, + reason: + "AI-composed nudges call the first chain entry directly; the composer re-checks `coach` before the chain.", + }, + + // ── Admitted by the one caller that reaches them ─────────────────────── + "lib/insights/briefing-provider.ts": { + recheck: false, + reason: + "Reached only through generateComprehensiveInsight, which re-checks `briefing` before the chain is resolved.", + }, + "lib/ai/coach/tools/loop.ts": { + recheck: false, + reason: + "The Coach tool loop runs inside the chat stream, which the chat route gates on `coach`.", + }, + "lib/ai/coach/self-context-questions.ts": { + recheck: false, + reason: + "About-me follow-up questions; reached from the about-me route, which gates `aboutMeQuestions`.", + }, + "lib/documents/assist.ts": { + recheck: false, + reason: + "Document suggest/chat helpers; reached from the document routes and jobs, which resolve `documentAi` before a provider is picked.", + }, + "lib/documents/describe.ts": { + recheck: false, + reason: + "Document summary; reached from the summary route and the summary job, which resolve `documentAi` first.", + }, + "lib/documents/extract.ts": { + recheck: false, + reason: + "Document extraction/indexing; reached from the document routes and the index jobs, which resolve `documentAi` first.", + }, + "lib/labs/ocr-extract.ts": { + recheck: false, + reason: + "Lab report scan; reached from the labs OCR route, which gates `labsOcr`.", + }, + "app/api/medications/extract/route.ts": { + recheck: false, + reason: + "Medication text extraction; the route is the chokepoint for `medicationExtract`.", + }, + "app/api/ai/test/route.ts": { + recheck: false, + reason: + "The connection test a person runs on a provider they are configuring; it sends a fixed probe prompt, no health data.", + }, + + // ── The machinery itself ──────────────────────────────────────────────── + "lib/ai/provider-runner.ts": { + recheck: false, + reason: "The chain runners; every caller above is what admits a run.", + }, + "lib/ai/openai-client.ts": { + recheck: false, + reason: "A wire client delegating to its own completion method.", + }, + "lib/ai/local-client.ts": { + recheck: false, + reason: "A wire client delegating to its own completion method.", + }, + "lib/ai/coach/eval/judge.ts": { + recheck: false, + reason: "The offline evaluation harness; never on a request or job path.", + }, +}; + +describe("AI egress call sites", () => { + const found = sourceFiles().filter((rel) => EGRESS_CALL.test(code(rel))); + + it("finds provider calls at all (an empty matcher is a failure, not a pass)", () => { + expect(found.length).toBeGreaterThan(5); + }); + + it("every provider call site is named, with a reason", () => { + const unlisted = found.filter((rel) => !(rel in EGRESS_SITES)); + expect( + unlisted, + "A new file calls a provider. Add it to EGRESS_SITES with the reason its call is admitted, and re-check a capability in it if it is a chokepoint.", + ).toEqual([]); + }); + + it("the list carries no file that no longer calls a provider", () => { + const stale = Object.keys(EGRESS_SITES).filter( + (rel) => !found.includes(rel), + ); + expect(stale).toEqual([]); + }); + + it("every chokepoint re-checks a capability in the same file", () => { + const missing = Object.entries(EGRESS_SITES) + .filter(([, site]) => site.recheck) + .filter(([rel]) => !CAPABILITY_CHECK.test(code(rel))) + .map(([rel]) => rel); + expect(missing).toEqual([]); + }); +}); + +describe("the status chokepoint's capability argument", () => { + it("is required by the type (the @ts-expect-error below fails typecheck if it is not)", () => { + type Args = Parameters[0]; + // @ts-expect-error — `capability` is required: a caller that omits it does not compile. + const withoutCapability: Args = { + userId: "u", + cacheAction: "insights.weight-status.en", + systemPrompt: "s", + userPrompt: "p", + }; + const withCapability: Args = { + ...withoutCapability, + capability: "statusText", + }; + expect(withCapability.capability).toBe("statusText"); + }); +}); diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 118717268..a7655f3fb 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -230,7 +230,6 @@ async function handleTextExtract( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); const rl = await checkDocumentAiRateLimit(userId); @@ -347,7 +346,6 @@ async function handleStoredExtract( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); const rl = await checkDocumentAiRateLimit(userId); @@ -433,7 +431,6 @@ async function handleVisionExtract( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); const rl = await checkDocumentAiRateLimit(userId); diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index 04530af0c..21b77495b 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -192,7 +192,6 @@ async function handleVisionIndex( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); const rl = await checkDocumentAiRateLimit(userId); diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 0fe5d57a7..f7c0dd98b 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -135,7 +135,6 @@ async function handleTextSuggest( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); const rl = await checkDocumentAiRateLimit(userId); @@ -228,7 +227,6 @@ async function handleVisionSuggest( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); const rl = await checkDocumentAiRateLimit(userId); diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index f207faf12..9dc18d6d8 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -332,7 +332,6 @@ async function handleTextSummary( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); } catch (err) { // Refused before any dispatch — the slot goes back with the refusal. @@ -403,7 +402,6 @@ async function handleVisionSummary( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); const rl = await checkDocumentAiRateLimit(userId); diff --git a/src/app/api/documents/inbound/reindex/route.ts b/src/app/api/documents/inbound/reindex/route.ts index de2a13ff8..052efc043 100644 --- a/src/app/api/documents/inbound/reindex/route.ts +++ b/src/app/api/documents/inbound/reindex/route.ts @@ -56,7 +56,6 @@ export const POST = apiHandler(async (request) => { await assertDocumentEgressConsent({ userId: user.id, providerType: pick.providerType, - surface: "insights", }); const { enqueued: jobCreated } = await enqueueContentIndexBackfill(user.id); diff --git a/src/lib/ai/__tests__/consent-guard.test.ts b/src/lib/ai/__tests__/consent-guard.test.ts index a7e5a2e0f..939856a24 100644 --- a/src/lib/ai/__tests__/consent-guard.test.ts +++ b/src/lib/ai/__tests__/consent-guard.test.ts @@ -1,27 +1,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/consent/receipts", () => ({ + AI_EXTRACTION_CONSENT_KIND: "ai_extraction", latestActiveReceipt: vi.fn(), })); -vi.mock("@/lib/documents/document-settings", () => ({ - documentAutoReadEnabled: vi.fn().mockResolvedValue(false), -})); import { ConsentRequiredError, assertConsentForChain, assertDocumentEgressConsent, + assertExtractionConsentForChain, chainRequiresServerManagedConsent, hasActiveConsentForSurface, isExternalDocumentEgress, } from "../consent-guard"; import { latestActiveReceipt } from "@/lib/consent/receipts"; -import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; import type { ProviderChainResolved } from "../provider-runner"; import type { ConsentKind } from "@/lib/validations/consent"; const mockedLatest = vi.mocked(latestActiveReceipt); -const mockedAutoRead = vi.mocked(documentAutoReadEnabled); /** Minimal chain entry — the gate only inspects `providerType`. */ function entry(providerType: string): ProviderChainResolved { @@ -206,34 +203,21 @@ describe("isExternalDocumentEgress", () => { describe("assertDocumentEgressConsent", () => { beforeEach(() => { mockedLatest.mockReset(); - mockedAutoRead.mockReset(); - // Default: the auto-read toggle is OFF (the shipped privacy posture). - mockedAutoRead.mockResolvedValue(false); }); it("never reads consent and never throws for a LOCAL document pick", async () => { grant([]); await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: "local", - surface: "insights", - }), + assertDocumentEgressConsent({ userId: "u1", providerType: "local" }), ).resolves.toBeUndefined(); expect(mockedLatest).not.toHaveBeenCalled(); - // A local pick short-circuits before the toggle is even consulted. - expect(mockedAutoRead).not.toHaveBeenCalled(); }); // The live gap the governance fix closes: codex was ungated for documents. it("REQUIRES a receipt to send a document to codex (the closed gap)", async () => { grant([]); await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: "codex", - surface: "insights", - }), + assertDocumentEgressConsent({ userId: "u1", providerType: "codex" }), ).rejects.toBeInstanceOf(ConsentRequiredError); }); @@ -241,65 +225,100 @@ describe("assertDocumentEgressConsent", () => { grant([]); for (const p of ["openai", "anthropic", "admin-openai"]) { await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: p, - surface: "insights", - }), + assertDocumentEgressConsent({ userId: "u1", providerType: p }), ).rejects.toBeInstanceOf(ConsentRequiredError); } }); - it("proceeds for codex WITH an active document-class receipt", async () => { - grant(["ai_insights_only"]); + it("proceeds for an external pick with an active extraction receipt", async () => { + grant(["ai_extraction"]); await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: "codex", - surface: "insights", - }), + assertDocumentEgressConsent({ userId: "u1", providerType: "codex" }), ).resolves.toBeUndefined(); }); - // Gate A: the documentsAutoAiRead opt-in short-circuits an external pick. - it("proceeds for an external pick when documentsAutoAiRead is ON, without a receipt", async () => { - grant([]); - mockedAutoRead.mockResolvedValue(true); - for (const p of ["codex", "openai", "anthropic", "admin-openai"]) { + it("proceeds for an external pick with the master ai_full receipt", async () => { + grant(["ai_full"]); + await expect( + assertDocumentEgressConsent({ userId: "u1", providerType: "openai" }), + ).resolves.toBeUndefined(); + }); + + // K1: the analysis and the Coach consents no longer cover documents. + it("refuses an external pick when only the analysis or Coach consent is active", async () => { + for (const kinds of [ + ["ai_insights_only"], + ["ai_coach"], + ] as ConsentKind[][]) { + grant(kinds); await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: p, - surface: "insights", - }), - ).resolves.toBeUndefined(); + assertDocumentEgressConsent({ userId: "u1", providerType: "codex" }), + ).rejects.toBeInstanceOf(ConsentRequiredError); } - // The toggle short-circuits BEFORE the receipt read. + }); + + it("names the extraction group on the refusal", async () => { + grant([]); + await expect( + assertDocumentEgressConsent({ userId: "u1", providerType: "codex" }), + ).rejects.toMatchObject({ surface: "extraction" }); + }); +}); + +describe("extraction consent does not satisfy the other groups", () => { + it("an ai_extraction receipt alone satisfies neither the Coach nor the analysis", async () => { + grant(["ai_extraction"]); + await expect(hasActiveConsentForSurface("u1", "coach")).resolves.toBe( + false, + ); + await expect(hasActiveConsentForSurface("u1", "insights")).resolves.toBe( + false, + ); + await expect(hasActiveConsentForSurface("u1", "extraction")).resolves.toBe( + true, + ); + }); +}); + +describe("assertExtractionConsentForChain (lab report scans)", () => { + beforeEach(() => mockedLatest.mockReset()); + + it("is a no-op for a chain of local entries only", async () => { + grant([]); + await expect( + assertExtractionConsentForChain({ + userId: "u1", + chain: [entry("local")], + }), + ).resolves.toBeUndefined(); expect(mockedLatest).not.toHaveBeenCalled(); }); - it("STILL requires a receipt for an external pick when documentsAutoAiRead is OFF", async () => { + it("requires an extraction receipt for a BYOK chain, unlike the self-snapshot rule", async () => { grant([]); - mockedAutoRead.mockResolvedValue(false); await expect( - assertDocumentEgressConsent({ + assertExtractionConsentForChain({ userId: "u1", - providerType: "codex", - surface: "insights", + chain: [entry("local"), entry("anthropic")], }), ).rejects.toBeInstanceOf(ConsentRequiredError); - }); - - it("does NOT consult the toggle for a LOCAL pick even when it would be ON", async () => { - grant([]); - mockedAutoRead.mockResolvedValue(true); + // The self-snapshot rule would have admitted the same chain. await expect( - assertDocumentEgressConsent({ + assertConsentForChain({ userId: "u1", - providerType: "local", + chain: [entry("anthropic")], surface: "insights", }), ).resolves.toBeUndefined(); - expect(mockedAutoRead).not.toHaveBeenCalled(); + }); + + it("admits the chain with an extraction receipt", async () => { + grant(["ai_extraction"]); + await expect( + assertExtractionConsentForChain({ + userId: "u1", + chain: [entry("openai")], + }), + ).resolves.toBeUndefined(); }); }); diff --git a/src/lib/ai/coach/__tests__/coach-memory-refresh-worker.test.ts b/src/lib/ai/coach/__tests__/coach-memory-refresh-worker.test.ts new file mode 100644 index 000000000..322afb932 --- /dev/null +++ b/src/lib/ai/coach/__tests__/coach-memory-refresh-worker.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("../conversation-summary", () => ({ + refreshConversationSummary: vi.fn(async () => ({ status: "refreshed" })), +})); +vi.mock("../facts", () => ({ + extractAndStoreFacts: vi.fn(async () => ({ status: "stored", count: 1 })), +})); +vi.mock("../plans", () => ({ + extractAndStorePlanProposals: vi.fn(async () => ({ + status: "stored", + count: 0, + })), +})); + +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; +import { annotate } from "@/lib/logging/context"; +import { runCoachMemoryRefresh } from "../coach-memory-refresh-worker"; +import { refreshConversationSummary } from "../conversation-summary"; +import { extractAndStoreFacts } from "../facts"; +import { extractAndStorePlanProposals } from "../plans"; + +const payload = { conversationId: "c1", userId: "u1", locale: "en" as const }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runCoachMemoryRefresh", () => { + it("runs all three steps when the Coach is available", async () => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + await runCoachMemoryRefresh(payload); + expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "coach"); + expect(refreshConversationSummary).toHaveBeenCalled(); + expect(extractAndStoreFacts).toHaveBeenCalled(); + expect(extractAndStorePlanProposals).toHaveBeenCalled(); + }); + + it.each(["operator_disabled", "user_disabled", "consent_required"] as const)( + "reads no transcript and runs no step when the Coach is unavailable (%s)", + async (reason) => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + await runCoachMemoryRefresh(payload); + expect(refreshConversationSummary).not.toHaveBeenCalled(); + expect(extractAndStoreFacts).not.toHaveBeenCalled(); + expect(extractAndStorePlanProposals).not.toHaveBeenCalled(); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "coach.memory.refresh.skipped" }, + meta: { reason }, + }); + }, + ); +}); diff --git a/src/lib/ai/coach/coach-memory-refresh-worker.ts b/src/lib/ai/coach/coach-memory-refresh-worker.ts index c303e49f0..295a9212d 100644 --- a/src/lib/ai/coach/coach-memory-refresh-worker.ts +++ b/src/lib/ai/coach/coach-memory-refresh-worker.ts @@ -15,6 +15,7 @@ * chokepoint did no budget work at all, so every generator behind it, not just * these two, spent unmetered. */ +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { annotate } from "@/lib/logging/context"; import type { CoachMemoryRefreshPayload } from "./coach-memory-shared"; @@ -31,6 +32,19 @@ export async function runCoachMemoryRefresh( // fallback body for every locale that has no reviewed one. const locale = payload.locale ?? "en"; + // The `coach` capability before any transcript is read. The chat route that + // enqueued this admitted the Coach, but the operator, the person's Coach + // switch or a consent can change while the job waits. Stored memory stays + // as it is; only new model work stops. The chokepoint re-checks per step. + const capability = await aiCapabilityForJob(userId, "coach"); + if (!capability.available) { + annotate({ + action: { name: "coach.memory.refresh.skipped" }, + meta: { reason: capability.reason }, + }); + return; + } + let summaryStatus = "error"; try { const result = await refreshConversationSummary(conversationId, userId, { diff --git a/src/lib/ai/consent-guard.ts b/src/lib/ai/consent-guard.ts index 213c5a37c..bd243b2cc 100644 --- a/src/lib/ai/consent-guard.ts +++ b/src/lib/ai/consent-guard.ts @@ -27,16 +27,26 @@ * failure and we must not race that decision. A receipt of the surface's * mapped kind OR the superset `ai_full` grant satisfies the gate. */ -import { latestActiveReceipt } from "@/lib/consent/receipts"; -import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; +import { + AI_EXTRACTION_CONSENT_KIND, + latestActiveReceipt, +} from "@/lib/consent/receipts"; import type { ConsentKind } from "@/lib/validations/consent"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; /** - * The two AI surfaces that egress PHI. Each maps to the consent kind the iOS - * client collects for it; `ai_full` (the master grant) satisfies either. + * The consent groups that gate PHI egress. Each maps to the consent kind + * collected for it; `ai_full` (the master grant) satisfies every one. + * + * - `coach`: the Coach (`ai_coach`). + * - `insights`: model-written analysis over the person's own readings + * (`ai_insights_only`). + * - `extraction`: reading a document, a lab report scan or typed medication + * text (`ai_extraction`, the narrower grant the document auto-read toggle + * mints). It no longer satisfies the Coach or the analysis, and neither of + * those satisfies it. */ -export type ConsentSurface = "coach" | "insights"; +export type ConsentSurface = "coach" | "insights" | "extraction"; /** * Provider tags that egress via an operator-managed credential the user did not @@ -85,9 +95,14 @@ export function chainRequiresServerManagedConsent( /** The consent kinds that satisfy a given surface (the specific + the master). */ function acceptableKinds(surface: ConsentSurface): ConsentKind[] { - return surface === "coach" - ? ["ai_coach", "ai_full"] - : ["ai_insights_only", "ai_full"]; + switch (surface) { + case "coach": + return ["ai_coach", "ai_full"]; + case "insights": + return ["ai_insights_only", "ai_full"]; + case "extraction": + return [AI_EXTRACTION_CONSENT_KIND, "ai_full"]; + } } /** @@ -157,18 +172,14 @@ export function isExternalDocumentEgress(providerType: string): boolean { /** * Enforce the document-class consent precondition for the provider that will * actually receive the document. No-op for a `local` pick (nothing leaves the - * machine); for any external pick, throw `ConsentRequiredError` unless an active - * receipt of the surface's mapped kind (or `ai_full`) is on file. + * machine); for any external pick, throw `ConsentRequiredError` unless an + * active extraction receipt (`ai_extraction`, or the master `ai_full`) is on + * file. * - * ONE relaxation: the per-user `documentsAutoAiRead` opt-in. Flipping it ON is - * itself the standing consent act (the toggle write also mints an `ai_full` - * receipt for the audit trail), so the gate short-circuits an external pick when - * it is ON — that is what removes the per-document friction ("upload and the AI - * just reads it, no 80 switches"). When it is OFF the branch is inert and the - * gate behaves exactly as shipped: no external egress without an explicit - * receipt. The short-circuit is checked BEFORE the receipt read and ONLY for an - * external pick — a `local` pick is already ungated above, so the toggle can - * never widen egress that was not already external. + * The document auto-read toggle is NOT a shortcut here any more. Switching it + * on mints an `ai_extraction` receipt, and that receipt is what this reads, so + * a receipt the person later revokes wins over a toggle still left on: the + * withdrawal is honoured at the next egress, not at the next toggle flip. * * Call this AFTER the document provider is picked and BEFORE the first * `generateCompletion` on it. @@ -176,10 +187,30 @@ export function isExternalDocumentEgress(providerType: string): boolean { export async function assertDocumentEgressConsent(args: { userId: string; providerType: string; - surface: ConsentSurface; }): Promise { if (!isExternalDocumentEgress(args.providerType)) return; - if (await documentAutoReadEnabled(args.userId)) return; - if (await hasActiveConsentForSurface(args.userId, args.surface)) return; - throw new ConsentRequiredError(args.surface); + if (await hasActiveConsentForSurface(args.userId, "extraction")) return; + throw new ConsentRequiredError("extraction"); +} + +/** + * The document rule for a provider CHAIN (lab report scans, which cascade + * across the chain rather than calling one picked provider). A lab report is a + * document, so any entry that would send it off the machine needs an active + * extraction receipt, whoever holds the credential; a chain of local entries + * only is ungated. Fails closed like `assertConsentForChain`: an external + * entry anywhere in the chain requires the receipt, because the runner may + * cascade to it. + */ +export async function assertExtractionConsentForChain(args: { + userId: string; + chain: ReadonlyArray; +}): Promise { + if ( + !args.chain.some((entry) => isExternalDocumentEgress(entry.providerType)) + ) { + return; + } + if (await hasActiveConsentForSurface(args.userId, "extraction")) return; + throw new ConsentRequiredError("extraction"); } diff --git a/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts b/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts index 95dda88f1..9fe8961c7 100644 --- a/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts +++ b/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts @@ -113,13 +113,12 @@ describe("streamFencedReply — consent fan-out", () => { docs: [doc("a", "x"), doc("b", "y"), doc("c", "z")], }); expect(assertDocumentEgressConsent).toHaveBeenCalledTimes(3); - expect(assertDocumentEgressConsent).toHaveBeenCalledWith( - expect.objectContaining({ - userId: "user-1", - providerType: "anthropic", - surface: "insights", - }), - ); + // The document rule is the extraction receipt for the picked provider; + // no chat surface rides along to widen it. + expect(assertDocumentEgressConsent).toHaveBeenCalledWith({ + userId: "user-1", + providerType: "anthropic", + }); }); it("refuses (throws) with ZERO egress + no user turn persisted when ANY document's consent fails", async () => { diff --git a/src/lib/documents/fenced-chat.ts b/src/lib/documents/fenced-chat.ts index 9b8e63e04..20147139d 100644 --- a/src/lib/documents/fenced-chat.ts +++ b/src/lib/documents/fenced-chat.ts @@ -294,7 +294,6 @@ export async function streamFencedReply( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); } diff --git a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts index fbb554f3b..3fb7b7ae5 100644 --- a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts +++ b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts @@ -32,7 +32,8 @@ * * Mutation checks: adding `await requireAssistantSurface("coach")` to any * route outside the frozen list turns guard 3 red by file name; removing the - * `documentAi` entry from `AWAITING_READER` turns guard 2 red; putting + * `aiCapabilityForJob(userId, "documentAi")` calls from the document jobs + * turns guard 2 red for `documentAi`; putting * `assistantCorrelationsEnabled` back on the schema turns guard 1 red. */ import { readdirSync, readFileSync, statSync } from "node:fs"; @@ -99,13 +100,10 @@ const PLUMBING = [ * Switches with no reader yet, and why that is correct for now. An entry is * a claim with an expiry: the test below fails as soon as a reader appears. */ -const AWAITING_READER: Record = { - documentAi: - "The capabilities it covers (documentAi, labsOcr, medicationExtract) are " + - "resolved and published, but the document, lab-scan and medication " + - "extraction routes move onto requireAiCapability in the same release; " + - "until then the master switch is what stops them.", -}; +// Empty since the document jobs resolve `documentAi` (the summary job, its +// catch-up pass and the index backfill). A new switch that ships before its +// reader is listed here with the reason. +const AWAITING_READER: Record = {}; /** * Every file that still calls the retired gate. Only ever shrinks. diff --git a/src/lib/jobs/__tests__/ai-job-capability-inventory.test.ts b/src/lib/jobs/__tests__/ai-job-capability-inventory.test.ts new file mode 100644 index 000000000..00f6886bc --- /dev/null +++ b/src/lib/jobs/__tests__/ai-job-capability-inventory.test.ts @@ -0,0 +1,124 @@ +/** + * Every background job that writes model text resolves its AI capability. + * + * The rule for jobs (design: "AI optional"): a worker resolves its capability + * for the user before it builds a snapshot and skips with a `.skipped` + * annotation when the answer is no; the provider chokepoint re-checks at the + * wire. This freezes the first half as a reference: each file below must call + * the named gate with the named capability key. + * + * It proves a reference exists, not that it sits before the snapshot or on + * every branch. The integration suite (`tests/integration/ai-optional-jobs.test.ts`) + * runs each worker under the refusing states with a provider spy and is what + * proves behaviour. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + AI_CAPABILITY_KEYS, + type AiCapabilityKey, +} from "@/lib/ai/capabilities/types"; + +const ROOT = join(process.cwd(), "src"); + +type Gate = + "aiCapabilityForJob" | "aiCapabilityForRecord" | "aiWorkNotRuledOut"; + +interface JobGate { + gate: Gate; + keys: readonly AiCapabilityKey[]; +} + +/** Every AI job (and the enqueue sides that decide whether one is queued). */ +const AI_JOBS: Record = { + "lib/jobs/insight-pregenerate.ts": { + gate: "aiCapabilityForJob", + keys: ["briefing", "statusText"], + }, + "lib/insights/status-batch.ts": { + gate: "aiCapabilityForJob", + keys: ["statusText"], + }, + "lib/jobs/reminder/insights-handlers.ts": { + gate: "aiCapabilityForJob", + keys: ["statusText"], + }, + "lib/jobs/insight-status-generate.ts": { + gate: "aiCapabilityForJob", + keys: ["statusText"], + }, + "lib/jobs/period-narrative-warm.ts": { + gate: "aiCapabilityForJob", + keys: ["periodNarrative"], + }, + "lib/jobs/coach-nudge.ts": { gate: "aiCapabilityForJob", keys: ["coach"] }, + "lib/jobs/coach-nudge-ai.ts": { gate: "aiCapabilityForJob", keys: ["coach"] }, + "lib/ai/coach/coach-memory-refresh-worker.ts": { + gate: "aiCapabilityForJob", + keys: ["coach"], + }, + "lib/jobs/reaction-line.ts": { + gate: "aiCapabilityForJob", + keys: ["reactionLines"], + }, + "lib/jobs/workout-insight-generate.ts": { + gate: "aiCapabilityForJob", + keys: ["workoutInsights"], + }, + "lib/jobs/document-summary.ts": { + gate: "aiCapabilityForJob", + keys: ["documentAi"], + }, + "lib/jobs/document-summary-catchup.ts": { + gate: "aiCapabilityForJob", + keys: ["documentAi"], + }, + "lib/jobs/document-content-index-backfill.ts": { + gate: "aiCapabilityForJob", + keys: ["documentAi"], + }, + // The arrival spine may not import the provider machinery, so it asks the + // provider-free half before it enqueues; the jobs resolve the rest. + "lib/jobs/data-arrival.ts": { + gate: "aiWorkNotRuledOut", + keys: ["workoutInsights", "reactionLines"], + }, + // Enqueue sides reached from requests and jobs alike. + "lib/insights/status-invalidation.ts": { + gate: "aiCapabilityForRecord", + keys: ["statusText"], + }, +}; + +function code(rel: string): string { + return readFileSync(join(ROOT, rel), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +describe("AI job capability inventory", () => { + const entries = Object.entries(AI_JOBS); + + it("is not empty", () => { + expect(entries.length).toBeGreaterThan(10); + }); + + it("names only capabilities that exist", () => { + for (const [, { keys }] of entries) { + for (const key of keys) expect(AI_CAPABILITY_KEYS).toContain(key); + } + }); + + it.each(entries)("%s resolves its capability", (rel, { gate, keys }) => { + const source = code(rel); + for (const key of keys) { + const call = new RegExp(`\\b${gate}\\s*\\([^)]*?["']${key}["']`); + expect( + call.test(source), + `${rel} must call ${gate}(…, "${key}") before it does model work`, + ).toBe(true); + } + }); +}); diff --git a/src/lib/jobs/__tests__/async-state-contract.test.ts b/src/lib/jobs/__tests__/async-state-contract.test.ts index 5f70f8a95..505fe6caf 100644 --- a/src/lib/jobs/__tests__/async-state-contract.test.ts +++ b/src/lib/jobs/__tests__/async-state-contract.test.ts @@ -79,6 +79,10 @@ vi.mock("@/lib/ai/coach/budget", () => ({ resolveDailyCapFor: vi.fn(() => 1000), resolveCostOwner: vi.fn(() => "operator" as const), })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), + aiCapabilityForRecord: vi.fn(), +})); vi.mock("@/lib/ai/ai-budgets", () => ({ AI_BUDGETS: { documentSummary: { temperature: 0.3, maxTokens: 600 }, @@ -109,6 +113,10 @@ import { ConsentRequiredError, } from "@/lib/ai/consent-guard"; import { reserveBudget } from "@/lib/ai/coach/budget"; +import { + aiCapabilityForJob, + aiCapabilityForRecord, +} from "@/lib/ai/capabilities/gate"; const DOC = { id: "doc-1", @@ -163,6 +171,13 @@ beforeEach(() => { locale: "en", } as never); vi.mocked(documentAutoReadEnabled).mockResolvedValue(true); + for (const resolve of [aiCapabilityForJob, aiCapabilityForRecord]) { + vi.mocked(resolve).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + } vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined); vi.mocked(loadOwnedDocument).mockResolvedValue(DOC as never); @@ -204,6 +219,39 @@ describe("contract (a): every runDocumentSummaryJob branch writes a terminal sta }, state: "NONE", }, + { + name: "documentAi unavailable for want of a provider", + setup: () => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "no_provider", + onDeviceAllowed: true, + }); + }, + state: "UNAVAILABLE", + }, + { + name: "documentAi unavailable for want of a consent receipt", + setup: () => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: true, + }); + }, + state: "UNAVAILABLE", + }, + { + name: "documentAi switched off by the operator", + setup: () => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + }); + }, + state: "NONE", + }, { name: "no provider configured", setup: () => { @@ -404,6 +452,21 @@ describe("contract (b): enqueue never claims a pending state its job cannot reso expect(summaryStateWrites()).toEqual([]); }); + it("enqueueDocumentSummary with documentAi unavailable claims nothing and mints no job", async () => { + vi.mocked(aiCapabilityForRecord).mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: true, + }); + const send = vi.fn().mockResolvedValue("job-1"); + vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); + + const result = await enqueueDocumentSummary("user-1", "doc-1"); + expect(result).toEqual({ enqueued: false }); + expect(send).not.toHaveBeenCalled(); + expect(summaryStateWrites()).toEqual([]); + }); + it("enqueueDocumentSummary claims PENDING only once a job id exists", async () => { const send = vi.fn().mockResolvedValue(null); vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); diff --git a/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts b/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts index 8923120a3..430f0faed 100644 --- a/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts @@ -42,6 +42,14 @@ vi.mock("@/lib/ai/coach/budget", () => budgetMocks); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +// The composer resolves the `coach` capability before the chain. Available +// by default so the consent logic below is what each case exercises. +const aiCapabilityForJob = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob, + aiCapabilityForRecord: vi.fn(), +})); + import { composeNudgeWithAI, createNudgeAiTickBudget, @@ -78,6 +86,11 @@ beforeEach(() => { vi.clearAllMocks(); provider = makeProvider(); latestActiveReceipt.mockResolvedValue(null); + aiCapabilityForJob.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); budgetMocks.reserveBudget.mockResolvedValue({ allowed: true, reserved: 160, @@ -169,4 +182,25 @@ describe("composeNudgeWithAI — server-managed consent gate", () => { expect(await composeNudgeWithAI(params())).toBeNull(); expect(provider.generateCompletion).not.toHaveBeenCalled(); }); + + it("returns the template before resolving any chain when the coach capability is unavailable", async () => { + aiCapabilityForJob.mockResolvedValue({ + available: false, + reason: "user_disabled", + onDeviceAllowed: false, + }); + resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: provider }, + ]); + const p = params(); + + expect(await composeNudgeWithAI(p)).toBeNull(); + expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "coach"); + expect(resolveProviderChain).not.toHaveBeenCalled(); + expect(budgetMocks.reserveBudget).not.toHaveBeenCalled(); + expect(provider.generateCompletion).not.toHaveBeenCalled(); + expect(p.tickBudget.remainingCount).toBe( + createNudgeAiTickBudget().remainingCount, + ); + }); }); diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts index f34c6e920..bb702bb17 100644 --- a/src/lib/jobs/__tests__/coach-nudge.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge.test.ts @@ -33,7 +33,7 @@ import { runCoachNudgeTick, } from "../coach-nudge"; import { getAssistantFlags } from "@/lib/feature-flags"; -import { userRowHasProviderCredential } from "@/lib/ai/provider"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS, type HealthProfileAiSection, @@ -42,8 +42,10 @@ import { vi.mock("@/lib/feature-flags", () => ({ getAssistantFlags: vi.fn(async () => ({ coach: true })), })); -vi.mock("@/lib/ai/provider", () => ({ - userRowHasProviderCredential: vi.fn(() => true), +// Gate 3 resolves the `coach` capability per user; never the real resolver. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), + aiCapabilityForRecord: vi.fn(), })); vi.mock("@/lib/ai/coach/bytes-codec", () => ({ decryptFromBytes: vi.fn(() => "morning blood pressure"), @@ -602,9 +604,6 @@ describe("runCoachNudgeTick — gates and prefs", () => { user: { findMany: vi.fn(async () => overrides.users ?? []), }, - appSettings: { - findUnique: vi.fn(async () => ({ adminAiKeyEncrypted: "k" })), - }, pushAttempt: { findFirst: vi.fn(async (args?: unknown) => { void args; @@ -661,7 +660,12 @@ describe("runCoachNudgeTick — gates and prefs", () => { vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true, } as Awaited>); - vi.mocked(userRowHasProviderCredential).mockReturnValue(true); + vi.mocked(aiCapabilityForJob).mockReset(); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); restModeMock.resolveRestMode.mockResolvedValue({ active: false, since: null, @@ -713,7 +717,7 @@ describe("runCoachNudgeTick — gates and prefs", () => { { dispatch: vi.fn() }, ); expect(summary.skippedOptedOut).toBe(1); - expect(userRowHasProviderCredential).not.toHaveBeenCalled(); + expect(aiCapabilityForJob).not.toHaveBeenCalled(); expect(prisma.pushAttempt.findFirst).not.toHaveBeenCalled(); }); @@ -741,15 +745,75 @@ describe("runCoachNudgeTick — gates and prefs", () => { }); it("checks the provider gate before the frequency cap", async () => { - vi.mocked(userRowHasProviderCredential).mockReturnValue(false); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "no_provider", + onDeviceAllowed: true, + }); const prisma = prismaMock({ users: [userRow(null)] }); + const dispatch = vi.fn(); const summary = await runCoachNudgeTick( prisma as unknown as PrismaClient, now, - { dispatch: vi.fn() }, + { dispatch }, ); + expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "coach"); expect(summary.skippedNoProvider).toBe(1); + expect(summary.skippedUnavailable).toBe(0); expect(prisma.pushAttempt.findFirst).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it.each([ + "operator_disabled", + "user_disabled", + "not_permitted_for_record", + "check_failed", + ] as const)( + "counts an unavailable coach capability (%s) as skippedUnavailable before the cap", + async (reason) => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + const prisma = prismaMock({ + users: [userRow(null)], + intakeRows: failingIntakes, + }); + const dispatch = vi.fn(); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch }, + ); + expect(summary.skippedUnavailable).toBe(1); + expect(summary.skippedNoProvider).toBe(0); + expect(prisma.pushAttempt.findFirst).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }, + ); + + it("still sends the template nudge when only a consent receipt is missing", async () => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: true, + }); + const prisma = prismaMock({ + users: [userRow(null)], + intakeRows: failingIntakes, + }); + const dispatch = vi.fn(async () => ({ dispatched: true })); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: dispatch as never }, + ); + expect(summary.skippedNoProvider).toBe(0); + expect(summary.skippedUnavailable).toBe(0); + expect(summary.dispatched).toBe(1); }); it("widens the cap cutoff to 14 days for a biweekly user", async () => { diff --git a/src/lib/jobs/__tests__/data-arrival-worker.test.ts b/src/lib/jobs/__tests__/data-arrival-worker.test.ts index 86f9d4ca9..5e5b3656e 100644 --- a/src/lib/jobs/__tests__/data-arrival-worker.test.ts +++ b/src/lib/jobs/__tests__/data-arrival-worker.test.ts @@ -37,6 +37,11 @@ vi.mock("@/lib/jobs/workout-insight-generate-shared", () => ({ vi.mock("@/lib/arrivals/reaction-line-shared", () => ({ enqueueReactionLine: vi.fn(async () => undefined), })); +// The provider-free prefilter the spine asks before enqueueing model work. +// Not ruled out by default; the job itself resolves the full capability. +vi.mock("@/lib/jobs/ai-job-candidates", () => ({ + aiWorkNotRuledOut: vi.fn(async () => true), +})); type ExistingReaction = { id: string; @@ -83,6 +88,7 @@ const { enqueueWorkoutInsight } = await import("@/lib/jobs/workout-insight-generate-shared"); const { enqueueReactionLine } = await import("@/lib/arrivals/reaction-line-shared"); +const { aiWorkNotRuledOut } = await import("@/lib/jobs/ai-job-candidates"); type Runnable = Parameters[1]; @@ -115,6 +121,7 @@ beforeEach(() => { transaction.mockClear(); vi.mocked(enqueueReactionLine).mockClear(); vi.mocked(enqueueWorkoutInsight).mockClear(); + vi.mocked(aiWorkNotRuledOut).mockReset().mockResolvedValue(true); }); describe("data-arrival worker", () => { @@ -286,4 +293,42 @@ describe("data-arrival worker", () => { runDataArrival(fakePrisma as never, arrival()), ).rejects.toThrow("connection terminated"); }); + + it("stores the marker but enqueues no workout paragraph when the work is ruled out", async () => { + vi.mocked(aiWorkNotRuledOut).mockImplementation( + async (_userId, key) => key !== "workoutInsights", + ); + const result = await runDataArrival( + fakePrisma as never, + arrival({ kind: "workout", refId: "w-1" }), + ); + if (result.status !== "processed") throw new Error("unreachable"); + expect(aiWorkNotRuledOut).toHaveBeenCalledWith("user-1", "workoutInsights"); + expect(createMany).toHaveBeenCalledTimes(1); + expect(result.actions).toContain("workout_insight_unavailable"); + expect(result.actions).not.toContain("workout_insight_enqueued"); + expect(enqueueWorkoutInsight).not.toHaveBeenCalled(); + // The reaction line is its own capability and still goes out. + expect(result.actions).toContain("line_pending"); + expect(enqueueReactionLine).toHaveBeenCalledTimes(1); + }); + + it("claims the marker but enqueues no reaction line when the line is ruled out", async () => { + vi.mocked(aiWorkNotRuledOut).mockImplementation( + async (_userId, key) => key !== "reactionLines", + ); + const result = await runDataArrival(fakePrisma as never, arrival()); + if (result.status !== "processed") throw new Error("unreachable"); + expect(aiWorkNotRuledOut).toHaveBeenCalledWith("user-1", "reactionLines"); + expect(createMany).toHaveBeenCalledTimes(1); + expect(result.actions).toContain("line_unavailable"); + expect(result.actions).not.toContain("line_pending"); + expect(enqueueReactionLine).not.toHaveBeenCalled(); + }); + + it("does not ask the prefilter for a line when the claim was a no-op", async () => { + createMany.mockResolvedValue({ count: 0 }); + await runDataArrival(fakePrisma as never, arrival()); + expect(aiWorkNotRuledOut).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/jobs/__tests__/document-content-index-backfill.test.ts b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts index e048cd61f..420b58a3a 100644 --- a/src/lib/jobs/__tests__/document-content-index-backfill.test.ts +++ b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts @@ -42,8 +42,17 @@ vi.mock("@/lib/documents/provider-order", () => ({ })); vi.mock("@/lib/ai/consent-guard", () => ({ assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), + // Mirrors the real rule: only the self-hosted `local` provider stays on the + // machine. + isExternalDocumentEgress: vi.fn( + (providerType: string) => providerType !== "local", + ), ConsentRequiredError: class ConsentRequiredError extends Error {}, })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), + aiCapabilityForRecord: vi.fn(), +})); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-07-07"), reserveBudget: vi.fn(), @@ -65,6 +74,7 @@ import { ConsentRequiredError, } from "@/lib/ai/consent-guard"; import { reserveBudget } from "@/lib/ai/coach/budget"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { decryptIndexText, upsertContentIndex, @@ -90,6 +100,11 @@ const doc = (id: string) => ({ beforeEach(() => { vi.clearAllMocks(); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); vi.mocked(reserveBudget).mockResolvedValue({ allowed: true, reserved: 1, @@ -165,6 +180,67 @@ describe("runContentIndexBackfillForUser", () => { expect(upsertContentIndex).not.toHaveBeenCalled(); }); + it.each([ + ["consent_required", "no-consent"], + ["operator_disabled", "unavailable"], + ["module_disabled", "unavailable"], + ["no_provider", "no-provider"], + ] as const)( + "refuses an external pick before any document is read when documentAi is unavailable (%s → %s)", + async (reason, summaryReason) => { + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + { id: "d1" }, + ] as never); + + const result = await runContentIndexBackfillForUser("user-1"); + + expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "documentAi"); + expect(result).toEqual({ + indexed: 0, + retokenised: 0, + skipped: 0, + failed: 0, + reason: summaryReason, + }); + expect(assertDocumentEgressConsent).not.toHaveBeenCalled(); + expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled(); + expect(reserveBudget).not.toHaveBeenCalled(); + expect(transcribeDocument).not.toHaveBeenCalled(); + expect(upsertContentIndex).not.toHaveBeenCalled(); + }, + ); + + it("does not ask the documentAi capability for a local pick", async () => { + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ + chain: [{ providerType: "local", instance: {} }], + pick: { + entry: { providerType: "local", instance: {} }, + providerType: "local", + pdfSupported: false, + }, + } as never); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: true, + }); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + { id: "d1" }, + ] as never); + + const result = await runContentIndexBackfillForUser("user-1"); + + expect(aiCapabilityForJob).not.toHaveBeenCalled(); + expect(result.reason).toBe("ok"); + expect(result.indexed).toBe(1); + }); + it("indexes the not-yet-indexed documents", async () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); // One short page (< PAGE_SIZE) — the walk breaks after it, so a single diff --git a/src/lib/jobs/__tests__/document-summary-catchup.test.ts b/src/lib/jobs/__tests__/document-summary-catchup.test.ts index c546feea9..8c7af8749 100644 --- a/src/lib/jobs/__tests__/document-summary-catchup.test.ts +++ b/src/lib/jobs/__tests__/document-summary-catchup.test.ts @@ -23,6 +23,10 @@ vi.mock("@/lib/documents/document-settings", () => ({ vi.mock("@/lib/jobs/document-summary", () => ({ enqueueDocumentSummary: vi.fn(), })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), + aiCapabilityForRecord: vi.fn(), +})); import { enqueueSummaryCatchUp, @@ -35,10 +39,12 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; import { enqueueDocumentSummary } from "@/lib/jobs/document-summary"; import { annotate } from "@/lib/logging/context"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; const findMany = vi.mocked(prisma.inboundDocument.findMany); const mockEnqueueSummary = vi.mocked(enqueueDocumentSummary); const mockAutoRead = vi.mocked(documentAutoReadEnabled); +const mockCapability = vi.mocked(aiCapabilityForJob); /** Serve `total` document ids across the job's id-cursor paged walk. */ function serveDocuments(total: number) { @@ -59,6 +65,11 @@ function serveDocuments(total: number) { beforeEach(() => { vi.clearAllMocks(); mockAutoRead.mockResolvedValue(true); + mockCapability.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); mockEnqueueSummary.mockResolvedValue({ enqueued: true }); }); @@ -111,6 +122,30 @@ describe("runSummaryCatchUpForUser", () => { expect(findMany).not.toHaveBeenCalled(); }); + it.each(["operator_disabled", "no_provider", "consent_required"] as const)( + "does nothing when the documentAi capability is unavailable (%s)", + async (reason) => { + serveDocuments(5); + mockCapability.mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + + const result = await runSummaryCatchUpForUser("user-1"); + + expect(mockCapability).toHaveBeenCalledWith("user-1", "documentAi"); + expect(result).toEqual({ enqueued: 0, capped: false }); + // Refused before a single document is listed. + expect(findMany).not.toHaveBeenCalled(); + expect(mockEnqueueSummary).not.toHaveBeenCalled(); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "documents.autoRead.catchUpSkipped" }, + meta: { reason }, + }); + }, + ); + it("routes work through the ordinary summary job, granting nothing itself", async () => { // The per-document job is what re-asserts egress consent and reserves the // daily budget. The catch-up must not have its own provider or budget path. diff --git a/src/lib/jobs/__tests__/document-summary.test.ts b/src/lib/jobs/__tests__/document-summary.test.ts index 9a4c84a3f..f1d0837e7 100644 --- a/src/lib/jobs/__tests__/document-summary.test.ts +++ b/src/lib/jobs/__tests__/document-summary.test.ts @@ -53,6 +53,10 @@ vi.mock("@/lib/ai/coach/budget", () => ({ resolveDailyCapFor: vi.fn(() => 1000), resolveCostOwner: vi.fn(() => "operator" as const), })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), + aiCapabilityForRecord: vi.fn(), +})); vi.mock("@/lib/ai/ai-budgets", () => ({ AI_BUDGETS: { documentSummary: { temperature: 0.3, maxTokens: 600 } }, })); @@ -73,6 +77,17 @@ import { runDocumentSummary } from "@/lib/documents/describe"; import { encryptDocumentSummary } from "@/lib/documents/store"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget"; +import { + aiCapabilityForJob, + aiCapabilityForRecord, +} from "@/lib/ai/capabilities/gate"; +import { annotate } from "@/lib/logging/context"; + +const AVAILABLE = { + available: true, + reason: null, + onDeviceAllowed: true, +} as const; const DOC = { id: "doc-1", @@ -115,6 +130,8 @@ beforeEach(() => { locale: "en", } as never); vi.mocked(documentAutoReadEnabled).mockResolvedValue(true); + vi.mocked(aiCapabilityForJob).mockResolvedValue(AVAILABLE); + vi.mocked(aiCapabilityForRecord).mockResolvedValue(AVAILABLE); vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined); vi.mocked(loadOwnedDocument).mockResolvedValue(DOC as never); @@ -150,6 +167,13 @@ describe("runDocumentSummaryJob — gating", () => { await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); expect(runDocumentSummary).toHaveBeenCalledTimes(1); + expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "documentAi"); + // The egress consent is re-asserted for the picked provider; the auto-read + // toggle is not a consent and no `surface` rides along any more. + expect(assertDocumentEgressConsent).toHaveBeenCalledWith({ + userId: "user-1", + providerType: "anthropic", + }); expect(encryptDocumentSummary).toHaveBeenCalledWith( "A lab report from a clinic listing routine blood values.", ); @@ -203,6 +227,55 @@ describe("runDocumentSummaryJob — gating", () => { expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled(); }); + it.each([ + ["no_provider", "UNAVAILABLE"], + ["consent_required", "UNAVAILABLE"], + ["operator_disabled", "NONE"], + ["module_disabled", "NONE"], + ["check_failed", "NONE"], + ] as const)( + "refuses before any provider is picked when documentAi is unavailable (%s → %s)", + async (reason, state) => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + + await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); + + expect(resolveDocumentVisionProvider).not.toHaveBeenCalled(); + expect(assertDocumentEgressConsent).not.toHaveBeenCalled(); + expect(loadOwnedDocument).not.toHaveBeenCalled(); + expect(reserveBudget).not.toHaveBeenCalled(); + expect(runDocumentSummary).not.toHaveBeenCalled(); + // An attempt that could not run is recorded as UNAVAILABLE; a switch + // turned off only heals the PENDING this job was meant to resolve, so an + // earlier UNAVAILABLE / WITHHELD is never overwritten. + expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({ + where: { + id: "doc-1", + userId: "user-1", + deletedAt: null, + summaryState: state === "NONE" ? "PENDING" : { not: "READY" }, + }, + data: { summaryState: state }, + }); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "documents.summary.autoSkipped" }, + meta: { documentId: "doc-1", reason }, + }); + }, + ); + + it("does not consult the capability when the opt-in is OFF", async () => { + vi.mocked(documentAutoReadEnabled).mockResolvedValue(false); + + await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); + + expect(aiCapabilityForJob).not.toHaveBeenCalled(); + }); + it("is a graceful no-op when NO provider is configured", async () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], @@ -325,6 +398,29 @@ describe("enqueueDocumentSummary — the pending contract", () => { ); }); + it.each(["no_provider", "consent_required", "operator_disabled"] as const)( + "does not enqueue and does not claim PENDING when documentAi is unavailable (%s)", + async (reason) => { + vi.mocked(aiCapabilityForRecord).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + const send = vi.fn().mockResolvedValue("job-1"); + vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); + + const result = await enqueueDocumentSummary("user-1", "doc-1"); + + expect(aiCapabilityForRecord).toHaveBeenCalledWith( + "user-1", + "documentAi", + ); + expect(result).toEqual({ enqueued: false }); + expect(send).not.toHaveBeenCalled(); + expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled(); + }, + ); + it("claims nothing when the boss dropped the send (no job id)", async () => { const send = vi.fn().mockResolvedValue(null); vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 38c92003e..abfb2aac3 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -2,7 +2,11 @@ * v1.7.0 W6 — unit tests for the nightly insight pre-generation cron. * * Covers: - * - the discovery query selects only coach-enabled, stale-cache users; + * - the discovery query selects stale-cache users who kept AI analysis + * (the `insights` module) on; + * - each half of the pass runs on its own capability (`briefing` for the + * comprehensive, `statusText` for the warm), resolved before any budget + * write or generation; * - the per-user budget gate blocks a user already generated today; * - the master assistant kill-switch short-circuits the whole run; * - the generator outcomes tally correctly (generated / cached / @@ -39,6 +43,29 @@ vi.mock("@/lib/logging/context", async () => { vi.mock("@/lib/feature-flags", () => ({ getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a), })); +// Per-user capabilities, keyed by capability; every test starts with both +// halves available and overrides one or both. +type CapabilityState = { + available: boolean; + reason: string | null; + onDeviceAllowed: boolean; +}; +const AVAILABLE: CapabilityState = { + available: true, + reason: null, + onDeviceAllowed: true, +}; +function unavailable(reason: string): CapabilityState { + return { available: false, reason, onDeviceAllowed: false }; +} +let capabilities: Record = {}; +const aiCapabilityForJob = vi.fn( + async (_userId: string, key: string) => capabilities[key] ?? AVAILABLE, +); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: (...a: [string, string]) => aiCapabilityForJob(...a), + aiCapabilityForRecord: vi.fn(), +})); // Never reach the real generator (which would import the provider chain). vi.mock("@/lib/insights/comprehensive-generate", () => ({ generateComprehensiveInsight: vi.fn(), @@ -109,8 +136,10 @@ function makePrisma( locale: string | null; aiResponseTimeoutSeconds?: number | null; }>, + optedOut: string[] = [], ) { const findMany = vi.fn().mockResolvedValue(users); + const queryRaw = vi.fn().mockResolvedValue(optedOut.map((id) => ({ id }))); // forceWarmUser reads `insightsCachedAt` / `insightsWarmFailedAt` at job // start and maintains the failure marker; default: never warmed, never // failed. @@ -120,10 +149,11 @@ function makePrisma( }); const update = vi.fn().mockResolvedValue({}); return { - prisma: { user: { findMany, findUnique, update } }, + prisma: { user: { findMany, findUnique, update }, $queryRaw: queryRaw }, findMany, findUnique, update, + queryRaw, }; } @@ -135,18 +165,26 @@ beforeEach(() => { insightStatus: true, }); checkRateLimit.mockResolvedValue({ allowed: true }); + capabilities = {}; }); describe("findPregenerateCandidates", () => { - it("filters on disableCoach=false + stale-or-null cache, oldest-first, capped", async () => { - const { prisma, findMany } = makePrisma([{ id: "u1", locale: "de" }]); + it("filters on the insights opt-out + stale-or-null cache, oldest-first, capped", async () => { + const { prisma, findMany, queryRaw } = makePrisma( + [{ id: "u1", locale: "de" }], + ["opted-out"], + ); const now = new Date("2026-05-31T04:30:00.000Z"); // eslint-disable-next-line @typescript-eslint/no-explicit-any await findPregenerateCandidates(prisma as any, now, 50); + expect(queryRaw).toHaveBeenCalledTimes(1); + expect(queryRaw.mock.calls[0].slice(1)).toContain("insights"); expect(findMany).toHaveBeenCalledTimes(1); const arg = findMany.mock.calls[0][0]; - expect(arg.where.disableCoach).toBe(false); + // Hiding the Coach is not an AI opt-out any more. + expect(arg.where.disableCoach).toBeUndefined(); + expect(arg.where.id).toEqual({ notIn: ["opted-out"] }); expect(arg.where.OR).toEqual([ { insightsCachedAt: null }, { @@ -158,6 +196,98 @@ describe("findPregenerateCandidates", () => { expect(arg.orderBy).toEqual({ insightsCachedAt: "asc" }); expect(arg.take).toBe(50); }); + + it("adds no id filter when nobody opted out", async () => { + const { prisma, findMany } = makePrisma([{ id: "u1", locale: "de" }]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await findPregenerateCandidates(prisma as any, new Date(), 50); + expect(findMany.mock.calls[0][0].where.id).toBeUndefined(); + }); +}); + +describe("runInsightPregenerate — per-user capabilities", () => { + it("skips a user with neither capability before any budget write or generation", async () => { + capabilities = { + briefing: unavailable("user_disabled"), + statusText: unavailable("user_disabled"), + }; + const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); + const generate = vi.fn(); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "briefing"); + expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "statusText"); + expect(result).toMatchObject({ total: 1, skipped: 1, budgetBlocked: 0 }); + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(generate).not.toHaveBeenCalled(); + for (const g of statusGenerators) expect(g).not.toHaveBeenCalled(); + expect(warmGenericMetrics).not.toHaveBeenCalled(); + expect(annotateSpy).toHaveBeenCalledWith({ + action: { name: "insights.pregenerate.skipped" }, + meta: { briefing: "user_disabled", status_text: "user_disabled" }, + }); + }); + + it("skips the comprehensive without a budget write when only briefing is unavailable, and still warms", async () => { + capabilities = { briefing: unavailable("operator_disabled") }; + const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); + const generate = vi.fn(); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(4); + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(generate).not.toHaveBeenCalled(); + expect(result.skipped).toBe(1); + expect(result.budgetBlocked).toBe(0); + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); + } + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); + expect(result.assessmentsWarmed).toBe(7); + expect(result.metricAssessmentsWarmed).toBe(4); + }); + + it("generates the comprehensive but warms nothing when only statusText is unavailable", async () => { + capabilities = { statusText: unavailable("consent_required") }; + const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(4); + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(generate).toHaveBeenCalledTimes(1); + expect(result.generated).toBe(1); + for (const g of statusGenerators) expect(g).not.toHaveBeenCalled(); + expect(warmGenericMetrics).not.toHaveBeenCalled(); + expect(result.assessmentsWarmed).toBe(0); + expect(result.metricAssessmentsWarmed).toBe(0); + }); }); describe("runInsightPregenerate — kill switch", () => { @@ -591,7 +721,10 @@ describe("runInsightPregenerate — per-metric warm pass", () => { expect(result.assessmentsWarmed).toBe(7); }); - it("does NOT warm when the comprehensive pass skipped (no provider)", async () => { + it("does NOT warm when the statusText capability has no provider", async () => { + // The warm is gated on its own capability now, not on the comprehensive + // outcome: no provider for the status notes → nothing to warm. + capabilities = { statusText: unavailable("no_provider") }; const { prisma } = makePrisma([{ id: "u1", locale: "en" }]); const generate = vi .fn() @@ -714,7 +847,8 @@ describe("runInsightPregenerate — generic metric warm pass (v1.8.7.1)", () => expect(result.metricAssessmentsWarmed).toBe(9); }); - it("does NOT warm the generic metric caches when the comprehensive pass skipped (no provider)", async () => { + it("does NOT warm the generic metric caches when the statusText capability has no provider", async () => { + capabilities = { statusText: unavailable("no_provider") }; const { prisma } = makePrisma([{ id: "u1", locale: "en" }]); const generate = vi .fn() @@ -1047,13 +1181,12 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { } }); - it("short-circuits to a no-op when the whole assistant is disabled globally", async () => { - getAssistantFlags.mockResolvedValue({ - enabled: false, - briefing: false, - insightStatus: false, - }); - const { prisma } = makePrisma([]); + it("short-circuits to a no-op when neither capability is available", async () => { + capabilities = { + briefing: unavailable("operator_disabled"), + statusText: unavailable("operator_disabled"), + }; + const { prisma, findUnique } = makePrisma([]); const generate = vi.fn(); const statusGenerators = Array.from({ length: 7 }, () => vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), @@ -1070,18 +1203,16 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { } expect(result.comprehensive).toBe("skipped"); expect(result.assessmentsWarmed).toBe(0); + // Nothing read, nothing counted against the forced-warm bucket. + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(findUnique).not.toHaveBeenCalled(); }); - it("warms the per-status + generic caches when `briefing` is off but `insightStatus` is on (L1)", async () => { - // v1.9.0 — the route admits the job on the per-user `insightStatus` - // surface. A global `briefing` kill-switch must not suppress the - // assessment cards the user has enabled; only the comprehensive briefing - // belongs to the `briefing` surface. - getAssistantFlags.mockResolvedValue({ - enabled: true, - briefing: false, - insightStatus: true, - }); + it("warms the per-status + generic caches when `briefing` is unavailable but `statusText` is available (L1)", async () => { + // v1.9.0 — the route admits the job on the per-user status surface. An + // unavailable briefing must not suppress the assessment cards; only the + // comprehensive briefing belongs to the `briefing` capability. + capabilities = { briefing: unavailable("operator_disabled") }; const { prisma } = makePrisma([]); const generate = vi.fn(); const statusGenerators = Array.from({ length: 7 }, () => @@ -1109,6 +1240,33 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { }); }); + it("forces the comprehensive but warms no card when `statusText` is unavailable", async () => { + capabilities = { statusText: unavailable("user_disabled") }; + const { prisma } = makePrisma([]); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(2); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(generate).toHaveBeenCalledTimes(1); + for (const g of statusGenerators) expect(g).not.toHaveBeenCalled(); + expect(warmGenericMetrics).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + comprehensive: "generated", + assessmentsWarmed: 0, + metricAssessmentsWarmed: 0, + }); + }); + it("aborts the comprehensive on timeout so its late resolve cannot write a stale cache row (M-1)", async () => { // v1.9.0 race fix: `withTimeout` cannot cancel the detached generation, // so a comprehensive that resolves AFTER the timeout would still write diff --git a/src/lib/jobs/__tests__/insight-status-generate.test.ts b/src/lib/jobs/__tests__/insight-status-generate.test.ts index 6a494fbf1..b46fb90bd 100644 --- a/src/lib/jobs/__tests__/insight-status-generate.test.ts +++ b/src/lib/jobs/__tests__/insight-status-generate.test.ts @@ -41,6 +41,17 @@ vi.mock("@/lib/insights/mood-status", () => ({ vi.mock("@/lib/insights/medication-compliance-status", () => ({ generateMedicationComplianceStatusForUser: vi.fn(), })); +// The job resolves the `statusText` capability before any generator runs. +const aiCapabilityForJob = vi.fn(); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: (...a: unknown[]) => aiCapabilityForJob(...a), + aiCapabilityForRecord: vi.fn(), +})); +const annotate = vi.fn(); +vi.mock("@/lib/logging/context", async (importOriginal) => ({ + ...(await importOriginal()), + annotate: (...a: unknown[]) => annotate(...a), +})); import { runInsightStatusGenerate, @@ -51,8 +62,32 @@ import { beforeEach(() => { vi.clearAllMocks(); + aiCapabilityForJob.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); }); +const OWNER_AUTHORITY = { + origin: "owner" as const, + recordUserId: "u1", + actorUserId: "u1", + grantId: null, +}; + +function allGenerators() { + return { + general: vi.fn(), + "blood-pressure": vi.fn(), + weight: vi.fn(), + pulse: vi.fn(), + bmi: vi.fn(), + mood: vi.fn(), + "medication-compliance": vi.fn(), + }; +} + describe("runInsightStatusGenerate", () => { it("forces the matching generator with the payload locale", async () => { const weight = vi.fn().mockResolvedValue(undefined); @@ -82,8 +117,37 @@ describe("runInsightStatusGenerate", () => { expect(weight).toHaveBeenCalledWith("u1", { locale: "en", force: true }); // No other generator ran. expect(generators.pulse).not.toHaveBeenCalled(); + expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "statusText"); }); + it.each(["user_disabled", "no_provider", "consent_required"] as const)( + "skips before any generator runs when statusText is unavailable (%s)", + async (reason) => { + aiCapabilityForJob.mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + const generators = allGenerators(); + await runInsightStatusGenerate( + { + userId: "u1", + metric: "weight", + locale: "en", + authority: OWNER_AUTHORITY, + }, + generators, + ); + for (const generator of Object.values(generators)) { + expect(generator).not.toHaveBeenCalled(); + } + expect(annotate).toHaveBeenCalledWith({ + action: { name: "insights.status.generate.skipped" }, + meta: { metric: "weight", reason }, + }); + }, + ); + it("skips (does not throw) an unknown metric", async () => { await expect( runInsightStatusGenerate( diff --git a/src/lib/jobs/__tests__/period-narrative-warm.test.ts b/src/lib/jobs/__tests__/period-narrative-warm.test.ts index 5c9e6ea83..6f11e6f0d 100644 --- a/src/lib/jobs/__tests__/period-narrative-warm.test.ts +++ b/src/lib/jobs/__tests__/period-narrative-warm.test.ts @@ -15,7 +15,15 @@ vi.mock("@/lib/feature-flags", () => ({ vi.mock("@/lib/insights/narrative/period-narrative-generate", () => ({ generatePeriodNarrative: vi.fn(), })); -vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +const annotate = vi.fn(); +vi.mock("@/lib/logging/context", () => ({ + annotate: (...a: unknown[]) => annotate(...a), +})); +const aiCapabilityForJob = vi.fn(); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: (...a: unknown[]) => aiCapabilityForJob(...a), + aiCapabilityForRecord: vi.fn(), +})); import { runPeriodNarrativeWarm, @@ -28,8 +36,10 @@ import { function makePrisma( users: Array<{ id: string; locale: string | null }>, narrativeWrites: Array<{ userId: string; updatedAt: Date }> = [], + optedOut: string[] = [], ) { const findMany = vi.fn().mockResolvedValue(users); + const queryRaw = vi.fn().mockResolvedValue(optedOut.map((id) => ({ id }))); const groupBy = vi.fn().mockResolvedValue( narrativeWrites.map((row) => ({ userId: row.userId, @@ -37,9 +47,14 @@ function makePrisma( })), ); return { - prisma: { user: { findMany }, insightNarrative: { groupBy } }, + prisma: { + user: { findMany }, + insightNarrative: { groupBy }, + $queryRaw: queryRaw, + }, findMany, groupBy, + queryRaw, }; } @@ -58,6 +73,11 @@ beforeEach(() => { insightStatus: true, }); checkRateLimit.mockResolvedValue({ allowed: true }); + aiCapabilityForJob.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); }); describe("periodsForDay — boundary gate", () => { @@ -74,14 +94,18 @@ describe("periodsForDay — boundary gate", () => { }); describe("findNarrativeCandidates", () => { - it("filters coach-enabled users", async () => { - const { prisma, findMany, groupBy } = makePrisma([ - { id: "u1", locale: "de" }, - ]); + it("drops the users who switched AI analysis off, not the Coach-hidden ones", async () => { + const { prisma, findMany, groupBy, queryRaw } = makePrisma( + [{ id: "u1", locale: "de" }], + [], + ["opted-out"], + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await findNarrativeCandidates(prisma as any, 50); + expect(queryRaw.mock.calls[0].slice(1)).toContain("insights"); const arg = findMany.mock.calls[0][0]; - expect(arg.where.disableCoach).toBe(false); + expect(arg.where).toEqual({ id: { notIn: ["opted-out"] } }); + expect(arg.where.disableCoach).toBeUndefined(); expect(result).toEqual([{ id: "u1", locale: "de" }]); // Below the cap the ordering aggregate never runs. expect(groupBy).not.toHaveBeenCalled(); @@ -156,6 +180,38 @@ describe("runPeriodNarrativeWarm", () => { expect(generate).not.toHaveBeenCalled(); }); + it("skips a user whose periodNarrative capability is unavailable before the budget write", async () => { + const { prisma } = makePrisma([ + { id: "off", locale: "de" }, + { id: "on", locale: "de" }, + ]); + aiCapabilityForJob.mockImplementation(async (userId: string) => + userId === "off" + ? { available: false, reason: "no_provider", onDeviceAllowed: true } + : { available: true, reason: null, onDeviceAllowed: true }, + ); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "openai" }); + const result = await runPeriodNarrativeWarm( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma as any, + { now: MON_MID, generate }, + ); + expect(aiCapabilityForJob).toHaveBeenCalledWith("off", "periodNarrative"); + expect(result.skipped).toBe(1); + expect(result.budgetBlocked).toBe(0); + // Only the available user reaches the budget bucket and the generator. + expect(checkRateLimit).toHaveBeenCalledTimes(1); + expect(checkRateLimit.mock.calls[0][0]).toBe("period-narrative:on"); + expect(generate).toHaveBeenCalledTimes(1); + expect(generate.mock.calls[0][0]).toBe("on"); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "insights.narrative.warm.skipped" }, + meta: { reason: "no_provider" }, + }); + }); + it("short-circuits when the briefing surface is disabled", async () => { getAssistantFlags.mockResolvedValueOnce({ enabled: false, diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index ac9403751..8782179eb 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -63,9 +63,10 @@ vi.mock("@/lib/db", () => ({ }, })); -const isModuleEnabled = vi.fn(); -vi.mock("@/lib/modules/gate", () => ({ - isModuleEnabled: (...a: unknown[]) => isModuleEnabled(...a), +const aiCapabilityForJob = vi.fn(); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: (...a: unknown[]) => aiCapabilityForJob(...a), + aiCapabilityForRecord: vi.fn(), })); const resolveProviderChain = vi.fn(); vi.mock("@/lib/ai/provider", () => ({ @@ -196,7 +197,11 @@ beforeEach(async () => { labResultFindMany.mockResolvedValue([]); queryRaw.mockResolvedValue([{ total_tokens: 1_400, operator_tokens: 1_400 }]); executeRaw.mockResolvedValue(1); - isModuleEnabled.mockResolvedValue(true); + aiCapabilityForJob.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); chainRequiresServerManagedConsent.mockReturnValue(false); hasActiveConsentForSurface.mockResolvedValue(true); // v1.38.19 — the claim reservation is no longer a @@ -219,18 +224,32 @@ beforeEach(async () => { }); describe("reaction line — degradation", () => { - it("insights opt-out refuses before provider resolution or spend", async () => { - isModuleEnabled.mockResolvedValue(false); + it.each([ + "user_disabled", + "module_disabled", + "operator_disabled", + "no_provider", + "consent_required", + ] as const)( + "an unavailable reactionLines capability (%s) refuses before the digest, provider resolution or spend", + async (reason) => { + aiCapabilityForJob.mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); - const outcome = await runReactionLine(JOB); + const outcome = await runReactionLine(JOB); - expect(outcome).toEqual({ - status: "skipped", - reason: "module_disabled", - }); - expect(resolveProviderChain).not.toHaveBeenCalled(); - expect(reserveBudget).not.toHaveBeenCalled(); - }); + expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "reactionLines"); + expect(outcome).toEqual({ status: "skipped", reason }); + expect(userFindUnique).not.toHaveBeenCalled(); + expect(loadDailyDigest).not.toHaveBeenCalled(); + expect(resolveProviderChain).not.toHaveBeenCalled(); + expect(reserveBudget).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }, + ); it("grounds the prompt in the exact reading that triggered the arrival", async () => { measurementFindMany.mockResolvedValue([ diff --git a/src/lib/jobs/__tests__/status-cron-candidates.test.ts b/src/lib/jobs/__tests__/status-cron-candidates.test.ts index 4f2690a1f..8ede8ce2d 100644 --- a/src/lib/jobs/__tests__/status-cron-candidates.test.ts +++ b/src/lib/jobs/__tests__/status-cron-candidates.test.ts @@ -3,10 +3,12 @@ * * Pins the three gates the discovery applies: * 1. the operator assistant kill-switch (`insightStatus` flag), - * 2. the per-user `disableCoach: false` filter, - * 3. the pregenerate-candidate skip (configured provider + stale - * comprehensive cache → the 04:30 pass owns the user; the 02:xx - * crons keep fresh-cache and provider-less accounts), + * 2. the per-user AI-analysis opt-out (the `insights` module switched off + * drops the account from the candidate query; `disableCoach` plays no + * part any more), + * 3. the pregenerate-candidate skip (a stale or missing comprehensive + * cache → the 04:30 pass owns the user; the 02:xx crons keep the + * fresh-cache accounts), applied only while `briefing` is on, * plus the mood-status queue registration in the worker source (the * v1.4.37 dead-queue class guard). */ @@ -30,15 +32,6 @@ interface FakeUserRow { id: string; locale: string | null; insightsCachedAt: Date | null; - aiProvider: string | null; - aiProviderChain: unknown; - aiAnthropicKeyEncrypted: string | null; - aiLocalKeyEncrypted: string | null; - aiOpenaiKeyEncrypted: string | null; - aiBaseUrl: string | null; - codexConnectionStatus: string | null; - codexAccessTokenEncrypted: string | null; - codexRefreshTokenEncrypted: string | null; } function userRow(overrides: Partial = {}): FakeUserRow { @@ -46,32 +39,23 @@ function userRow(overrides: Partial = {}): FakeUserRow { id: "u1", locale: "de", insightsCachedAt: null, - aiProvider: null, - aiProviderChain: null, - aiAnthropicKeyEncrypted: null, - aiLocalKeyEncrypted: null, - aiOpenaiKeyEncrypted: null, - aiBaseUrl: null, - codexConnectionStatus: null, - codexAccessTokenEncrypted: null, - codexRefreshTokenEncrypted: null, ...overrides, }; } -function makePrisma( - users: FakeUserRow[], - adminAiKeyEncrypted: string | null = null, -) { +function makePrisma(users: FakeUserRow[], optedOut: string[] = []) { const findMany = vi.fn().mockResolvedValue(users); - const findUnique = vi.fn().mockResolvedValue({ adminAiKeyEncrypted }); + const queryRaw = vi.fn().mockResolvedValue(optedOut.map((id) => ({ id }))); + const findUnique = vi.fn(); return { prisma: { user: { findMany }, appSettings: { findUnique }, + $queryRaw: queryRaw, }, findMany, findUnique, + queryRaw, }; } @@ -93,65 +77,82 @@ describe("findStatusCronCandidates — gates", () => { briefing: false, insightStatus: false, }); - const { prisma, findMany } = makePrisma([userRow()]); + const { prisma, findMany, queryRaw } = makePrisma([userRow()]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await findStatusCronCandidates(prisma as any, NOW); expect(result).toEqual([]); expect(findMany).not.toHaveBeenCalled(); + expect(queryRaw).not.toHaveBeenCalled(); }); - it("filters discovery to disableCoach: false", async () => { + it("does not filter on disableCoach: hiding the Coach is not an AI opt-out", async () => { const { prisma, findMany } = makePrisma([userRow()]); // eslint-disable-next-line @typescript-eslint/no-explicit-any await findStatusCronCandidates(prisma as any, NOW); - expect(findMany.mock.calls[0][0].where).toEqual({ disableCoach: false }); + expect(findMany.mock.calls[0][0].where).toEqual({}); + expect(JSON.stringify(findMany.mock.calls[0][0])).not.toContain( + "disableCoach", + ); }); - it("skips a pregenerate candidate (configured provider + stale cache)", async () => { - const { prisma } = makePrisma([ - userRow({ - id: "stale-with-provider", - insightsCachedAt: STALE_AT, - aiAnthropicKeyEncrypted: "enc", - }), - ]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await findStatusCronCandidates(prisma as any, NOW); - expect(result).toEqual([]); - }); - - it("keeps a provider user whose comprehensive cache is still fresh", async () => { - const { prisma } = makePrisma([ - userRow({ - id: "fresh-with-provider", - insightsCachedAt: FRESH_AT, - aiAnthropicKeyEncrypted: "enc", - }), - ]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await findStatusCronCandidates(prisma as any, NOW); - expect(result).toEqual([{ id: "fresh-with-provider", locale: "de" }]); - }); - - it("keeps a provider-less user even with a stale cache (no pregenerate claim)", async () => { - const { prisma } = makePrisma([ - userRow({ id: "no-provider", insightsCachedAt: STALE_AT }), - ]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await findStatusCronCandidates(prisma as any, NOW); - expect(result).toEqual([{ id: "no-provider", locale: "de" }]); - }); - - it("counts the operator's shared admin key as a configured provider", async () => { - const { prisma } = makePrisma( - [userRow({ id: "admin-covered", insightsCachedAt: STALE_AT })], - "admin-enc", + it("drops the accounts that switched the insights module off", async () => { + const { prisma, findMany, queryRaw } = makePrisma( + [userRow({ id: "kept", insightsCachedAt: FRESH_AT })], + ["opted-out-a", "opted-out-b"], ); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await findStatusCronCandidates(prisma as any, NOW); + expect(queryRaw).toHaveBeenCalledTimes(1); + // The module key rides as a bound parameter, never spliced. + expect(queryRaw.mock.calls[0].slice(1)).toContain("insights"); + expect(findMany.mock.calls[0][0].where).toEqual({ + id: { notIn: ["opted-out-a", "opted-out-b"] }, + }); + expect(result).toEqual([{ id: "kept", locale: "de" }]); + }); + + it("skips a pregenerate candidate (stale cache)", async () => { + const { prisma } = makePrisma([ + userRow({ id: "stale", insightsCachedAt: STALE_AT }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); expect(result).toEqual([]); }); + it("skips a never-warmed user (null cache belongs to pregenerate)", async () => { + const { prisma } = makePrisma([ + userRow({ id: "never", insightsCachedAt: null }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([]); + }); + + it("keeps a user whose comprehensive cache is still fresh", async () => { + const { prisma } = makePrisma([ + userRow({ id: "fresh", insightsCachedAt: FRESH_AT }), + userRow({ id: "stale", insightsCachedAt: STALE_AT }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([{ id: "fresh", locale: "de" }]); + }); + + it("reads no provider credentials and no operator admin key", async () => { + const { prisma, findMany, findUnique } = makePrisma([ + userRow({ id: "fresh", insightsCachedAt: FRESH_AT }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await findStatusCronCandidates(prisma as any, NOW); + expect(findMany.mock.calls[0][0].select).toEqual({ + id: true, + locale: true, + insightsCachedAt: true, + }); + expect(findUnique).not.toHaveBeenCalled(); + }); + it("keeps every user when the briefing surface is off (the 04:30 pass no-ops)", async () => { getAssistantFlags.mockResolvedValue({ enabled: true, @@ -159,18 +160,16 @@ describe("findStatusCronCandidates — gates", () => { briefing: false, insightStatus: true, }); - const { prisma, findUnique } = makePrisma([ - userRow({ - id: "stale-with-provider", - insightsCachedAt: STALE_AT, - aiAnthropicKeyEncrypted: "enc", - }), + const { prisma } = makePrisma([ + userRow({ id: "stale", insightsCachedAt: STALE_AT }), + userRow({ id: "never", insightsCachedAt: null }), ]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await findStatusCronCandidates(prisma as any, NOW); - expect(result).toEqual([{ id: "stale-with-provider", locale: "de" }]); - // No admin-key read needed when the skip never applies. - expect(findUnique).not.toHaveBeenCalled(); + expect(result).toEqual([ + { id: "stale", locale: "de" }, + { id: "never", locale: "de" }, + ]); }); }); diff --git a/src/lib/jobs/ai-job-candidates.ts b/src/lib/jobs/ai-job-candidates.ts new file mode 100644 index 000000000..b11a21756 --- /dev/null +++ b/src/lib/jobs/ai-job-candidates.ts @@ -0,0 +1,62 @@ +/** + * Cheap SQL prefilters for the AI crons. + * + * Every AI job resolves its capability per user (`aiCapabilityForJob`) before + * it builds a snapshot, and the provider chokepoint re-checks it at the wire. + * Those are the answers that count. What this file adds is the cheap part in + * front of them: people who switched a module off are dropped from the + * candidate query itself, so a nightly pass over the instance does not load + * their capability inputs one by one only to be told no. + * + * `modulePreferencesJson` is a DISABLED allowlist: a module is off only when + * its key holds JSON `false`. Selecting the "off" set is NULL-safe by + * construction (an absent key, a NULL column or a non-object value never + * compares equal to `false`), which a negated JSON-path filter would not be. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { + AI_CAPABILITIES, + type AiCapabilityKey, +} from "@/lib/ai/capabilities/types"; +import { loadAssistantSwitches } from "@/lib/feature-flags"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import type { ModuleKey } from "@/lib/modules/registry"; + +/** Ids of the accounts that switched `module` off for themselves. */ +export async function userIdsWithModuleOff( + prisma: Pick, + module: ModuleKey, +): Promise { + const rows = await prisma.$queryRaw>` + SELECT "id" FROM "users" + WHERE "module_preferences_json" -> ${module}::text = 'false'::jsonb`; + return rows.map((row) => row.id); +} + +/** + * The provider-free half of a capability: the operator's switches and the + * owning modules, and nothing that needs the provider machinery. + * + * For the one place that must not import that machinery at all, the + * data-arrival spine, whose cost claim is that its module graph reaches no + * provider (`data-arrival-provider-isolation.test.ts`). It decides whether a + * follow-up job is worth enqueueing; the job itself then resolves the full + * capability (`aiCapabilityForJob`) before it does anything, so this can only + * ever say "no" early, never "yes" for good. + */ +export async function aiWorkNotRuledOut( + userId: string, + key: AiCapabilityKey, +): Promise { + const definition = AI_CAPABILITIES[key]; + const [switches, modules] = await Promise.all([ + loadAssistantSwitches(), + resolveModuleMap(userId), + ]); + if (switches === null || !switches[definition.operatorSwitch]) return false; + const owners = definition.modules.keys; + if (owners.length === 0) return true; + return definition.modules.mode === "all" + ? owners.every((module) => modules[module]) + : owners.some((module) => modules[module]); +} diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index a2916d907..95b786789 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -35,6 +35,7 @@ import type { CoachNudgeTrigger } from "@/lib/jobs/coach-nudge"; import type { Locale } from "@/lib/i18n/config"; import { resolveProviderChain } from "@/lib/ai/provider"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { chainRequiresServerManagedConsent, hasActiveConsentForSurface, @@ -173,6 +174,18 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => { } try { + // The capability at the wire, before the chain is resolved (a Codex + // chain may refresh a token on resolve). The cron checked it for the + // candidate already; a switch can flip within the tick. + const coach = await aiCapabilityForJob(params.userId, "coach"); + if (!coach.available) { + annotate({ + action: { name: "coach.nudge.ai.unavailable" }, + meta: { reason: coach.reason }, + }); + return null; + } + const chain = await resolveProviderChain(params.userId); if (chain.length === 0) return null; diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index d8de29c13..52c8b698e 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -13,9 +13,14 @@ * 1. Operator kill-switch via `getAssistantFlags().coach` — the * master assistant switch forces this off too. * 2. Per-user `disableCoach: false` — no Coach surface, no nudge. - * 3. A working provider (`userRowHasProviderCredential`, including - * the operator's shared key) — a nudge into a Coach that cannot - * answer is worse than silence. + * 3. The `coach` capability for the user (`aiCapabilityForJob`): the + * operator's switches, the person's Coach switch, provider-work + * authority and one provider-presence definition (the operator's + * shared key and the central Codex included). A nudge into a Coach + * that cannot answer is worse than silence. The one reason that does + * not stop the nudge is a missing consent receipt: the deterministic + * template needs no model, and the Coach it invites the user into asks + * for consent itself. * 4. Per-user opt-out: `notificationPrefs.coach.nudgesEnabled` * (default ON; Settings → Notifications). v1.16.5 adds per-group * toggles underneath (medication / vitals / routine) — a disabled @@ -78,7 +83,8 @@ */ import type { PrismaClient } from "@/generated/prisma/client"; import { getAssistantFlags } from "@/lib/feature-flags"; -import { userRowHasProviderCredential } from "@/lib/ai/provider"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; +import { annotate } from "@/lib/logging/context"; import { getEffectiveRange, type ThresholdOverridesJson, @@ -201,6 +207,12 @@ export interface CoachNudgeSummary { persisted: number; skippedOptedOut: number; skippedNoProvider: number; + /** + * The `coach` capability is unavailable for a reason other than a missing + * provider or consent (operator switch, Coach switch, authority, a failed + * check). + */ + skippedUnavailable: number; skippedRecentNudge: number; /** * v1.25.0 — anti-nag: the user talked to the Coach within the last ~24 h, @@ -806,6 +818,7 @@ export async function runCoachNudgeTick( persisted: 0, skippedOptedOut: 0, skippedNoProvider: 0, + skippedUnavailable: 0, skippedRecentNudge: 0, skippedRecentEngagement: 0, skippedNoTrigger: 0, @@ -818,8 +831,7 @@ export async function runCoachNudgeTick( const flags = await getAssistantFlags(); if (!flags.coach) return summary; - // Gate 2 — per-user Coach opt-out, plus the credential-presence - // columns gate 3 needs (evaluated locally, never decrypted). + // Gate 2 — per-user Coach opt-out. const users = await prisma.user.findMany({ where: { disableCoach: false }, select: { @@ -834,28 +846,10 @@ export async function runCoachNudgeTick( gender: true, thresholdsJson: true, timezone: true, - aiProvider: true, - aiProviderChain: true, - aiAnthropicKeyEncrypted: true, - aiLocalKeyEncrypted: true, - aiOpenaiKeyEncrypted: true, - aiBaseUrl: true, - aiModel: true, - aiCompatBaseUrl: true, - aiCompatModel: true, - codexConnectionStatus: true, - codexAccessTokenEncrypted: true, - codexRefreshTokenEncrypted: true, }, }); if (users.length === 0) return summary; - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { adminAiKeyEncrypted: true }, - }); - const adminKeyConfigured = !!settings?.adminAiKeyEncrypted; - for (const user of users) { summary.candidatesScanned += 1; try { @@ -873,9 +867,16 @@ export async function runCoachNudgeTick( continue; } - // Gate 3 — a Coach without a provider cannot answer the nudge. - if (!userRowHasProviderCredential(user, adminKeyConfigured)) { - summary.skippedNoProvider += 1; + // Gate 3 — the `coach` capability. A Coach that cannot answer gets no + // nudge; a missing consent receipt alone still gets the template. + const coach = await aiCapabilityForJob(user.id, "coach"); + if (!coach.available && coach.reason !== "consent_required") { + if (coach.reason === "no_provider") summary.skippedNoProvider += 1; + else summary.skippedUnavailable += 1; + annotate({ + action: { name: "coach.nudge.skipped" }, + meta: { reason: coach.reason }, + }); continue; } diff --git a/src/lib/jobs/data-arrival.ts b/src/lib/jobs/data-arrival.ts index 1fa671331..a99c6da95 100644 --- a/src/lib/jobs/data-arrival.ts +++ b/src/lib/jobs/data-arrival.ts @@ -32,6 +32,7 @@ import type { Job } from "pg-boss"; import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; import { annotate } from "@/lib/logging/context"; +import { aiWorkNotRuledOut } from "@/lib/jobs/ai-job-candidates"; import { withBackgroundEvent } from "@/lib/logging/background"; import { DATA_ARRIVAL_QUEUE } from "@/lib/arrivals/emit-shared"; @@ -212,7 +213,19 @@ export async function runDataArrival( // once-per-workout key, duration floor, hard daily cap, input hash and // token-ledger reservation bound the spend — none of which belong in the // spine, which is why only the generator-free enqueue is imported here. - if (arrival.refId) { + if (!arrival.refId) { + // A workout arrival with no referent cannot address a paragraph. The + // seams always carry one; annotating rather than guessing keeps a + // future seam that forgets it visible. + actions.push("workout_no_ref"); + } else if ( + !(await aiWorkNotRuledOut(arrival.userId, "workoutInsights")) + ) { + // Nothing is enqueued that a switch or a module already rules out. + // The workout itself is data and already stored; only the paragraph + // about it is withheld. The job resolves the full capability itself. + actions.push("workout_insight_unavailable"); + } else { const insight = await enqueueWorkoutInsight({ userId: arrival.userId, workoutId: arrival.refId, @@ -221,11 +234,6 @@ export async function runDataArrival( throw new Error("Workout insight enqueue failed"); } actions.push("workout_insight_enqueued"); - } else { - // A workout arrival with no referent cannot address a paragraph. The - // seams always carry one; annotating rather than guessing keeps a - // future seam that forgets it visible. - actions.push("workout_no_ref"); } break; @@ -237,7 +245,15 @@ export async function runDataArrival( break; } - if (reactionClaim !== "unchanged") { + if ( + reactionClaim !== "unchanged" && + !(await aiWorkNotRuledOut(arrival.userId, "reactionLines")) + ) { + // The marker is the feature and is written above; the line is garnish a + // model writes, and a switch or a module already rules it out. The job + // resolves the full capability itself. + actions.push("line_unavailable"); + } else if (reactionClaim !== "unchanged") { // A fresh marker and a strictly newer replacement both need a line. The // replacement transaction cleared the old generation owner first, and the // enqueue key includes this arrival revision so an older in-flight job diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index 53dec0f23..a7c1368da 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -30,8 +30,10 @@ * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it. */ import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { assertDocumentEgressConsent, + isExternalDocumentEgress, ConsentRequiredError, } from "@/lib/ai/consent-guard"; import { @@ -96,7 +98,8 @@ export interface ContentIndexBackfillSummary { * reservation existed; a later run retries. */ failed: number; - reason: "ok" | "no-provider" | "no-consent" | "budget-reached"; + reason: + "ok" | "no-provider" | "no-consent" | "budget-reached" | "unavailable"; } /** @@ -173,11 +176,34 @@ export async function runContentIndexBackfillForUser( failed: stale.failed, reason: "no-provider", }; + // An external pick is document AI and needs the `documentAi` capability + // (the operator's switches, the vault module, the extraction consent). A + // local pick keeps the document on the machine and indexes as before. + if (isExternalDocumentEgress(pick.providerType)) { + const capability = await aiCapabilityForJob(userId, "documentAi"); + if (!capability.available) { + annotate({ + action: { name: "documents.contentIndex.backfillSkipped" }, + meta: { reason: capability.reason }, + }); + return { + indexed: 0, + retokenised: stale.retokenised, + skipped: 0, + failed: stale.failed, + reason: + capability.reason === "consent_required" + ? "no-consent" + : capability.reason === "no_provider" + ? "no-provider" + : "unavailable", + }; + } + } try { await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); } catch (err) { if (err instanceof ConsentRequiredError) { diff --git a/src/lib/jobs/document-summary-catchup.ts b/src/lib/jobs/document-summary-catchup.ts index 9acb154fe..7a16ffe33 100644 --- a/src/lib/jobs/document-summary-catchup.ts +++ b/src/lib/jobs/document-summary-catchup.ts @@ -33,6 +33,7 @@ * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it. */ import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { enqueueDocumentSummary } from "@/lib/jobs/document-summary"; @@ -92,6 +93,18 @@ export async function runSummaryCatchUpForUser( return { enqueued: 0, capped: false }; } + // The `documentAi` capability, once for the whole pass, before any document + // is listed: a switch off, no provider or no extraction consent means there + // is nothing the per-document jobs could do. + const capability = await aiCapabilityForJob(userId, "documentAi"); + if (!capability.available) { + annotate({ + action: { name: "documents.autoRead.catchUpSkipped" }, + meta: { reason: capability.reason }, + }); + return { enqueued: 0, capped: false }; + } + let enqueued = 0; let capped = false; let cursor: string | null = null; diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index 257586a01..55940bbe3 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -22,6 +22,10 @@ * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it. */ import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { + aiCapabilityForJob, + aiCapabilityForRecord, +} from "@/lib/ai/capabilities/gate"; import { assertDocumentEgressConsent, ConsentRequiredError, @@ -135,6 +139,39 @@ export async function runDocumentSummaryJob( return; } + // The `documentAi` capability before a provider is picked or the document is + // decrypted: the operator's switches (the master and "Reading documents"), + // the vault module, provider presence and the extraction consent. The + // auto-read toggle above is the trigger, not the consent. + const capability = await aiCapabilityForJob(userId, "documentAi"); + if (!capability.available) { + if ( + capability.reason === "no_provider" || + capability.reason === "consent_required" + ) { + // An attempt that could not run, recorded as such. + await markSummaryState(userId, documentId, "UNAVAILABLE"); + } else { + // A switch turned off means this job was never to run at all. Only the + // PENDING this job was meant to resolve goes back to NONE; a recorded + // UNAVAILABLE or WITHHELD is an earlier real attempt and stays. + await prisma.inboundDocument.updateMany({ + where: { + id: documentId, + userId, + deletedAt: null, + summaryState: "PENDING", + }, + data: { summaryState: "NONE" }, + }); + } + annotate({ + action: { name: "documents.summary.autoSkipped" }, + meta: { documentId, reason: capability.reason }, + }); + return; + } + // Resolve the DOCUMENT-order vision provider (local-first, codex last). No // vision-capable provider configured → graceful no-op (unlike the index job, // there is no local text-layer fallback for a descriptive summary). @@ -149,14 +186,13 @@ export async function runDocumentSummaryJob( } // Re-assert egress consent for the picked provider. A local pick is ungated; - // an external pick is authorised by the `documentsAutoAiRead` opt-in checked - // above (the toggle short-circuits the gate). Belt-and-braces: a consent race - // (opt-out flipped mid-flight) resolves to a no-op, never an egress. + // an external pick needs an active extraction receipt (`ai_extraction` or + // `ai_full`); the auto-read toggle mints one but is not one. A consent race + // (a revoke mid-flight) resolves to a no-op, never an egress. try { await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); } catch (err) { await markSummaryState(userId, documentId, "UNAVAILABLE"); @@ -319,6 +355,17 @@ export async function enqueueDocumentSummary( }); return { enqueued: false }; } + // Nothing is queued that the capability already refuses (a switch off, + // no provider, no extraction consent): the document stays NONE and the + // view offers the manual action, exactly as with the opt-in off. + const capability = await aiCapabilityForRecord(userId, "documentAi"); + if (!capability.available) { + annotate({ + action: { name: "documents.summary.autoSkipped" }, + meta: { documentId, reason: capability.reason }, + }); + return { enqueued: false }; + } const boss = getGlobalBoss(); if (!boss) return { enqueued: false }; const payload: DocumentSummaryPayload = { diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 34c95e503..37cac27f8 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -54,6 +54,8 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { checkRateLimit } from "@/lib/rate-limit"; import { getAssistantFlags } from "@/lib/feature-flags"; import { annotate } from "@/lib/logging/context"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; +import { userIdsWithModuleOff } from "@/lib/jobs/ai-job-candidates"; import type { SupportedLocale } from "@/lib/insights/status-shared"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { @@ -504,10 +506,11 @@ interface PregenerateCandidate { } /** - * Discovery query — coach-enabled users with a stale or missing - * comprehensive-insight cache, oldest-cache-first so the staleest users - * are served before the per-run cap bites. Exported so the cron test - * can pin the WHERE shape without a live LLM. + * Discovery query — users with AI analysis on (the `insights` module) and a + * stale or missing comprehensive-insight cache, oldest-cache-first so the + * staleest users are served before the per-run cap bites. Hiding the Coach + * no longer excludes anyone: it hides the Coach and nothing else. Exported + * so the cron test can pin the WHERE shape without a live LLM. */ export async function findPregenerateCandidates( prisma: PrismaClient, @@ -515,9 +518,10 @@ export async function findPregenerateCandidates( cap: number, ): Promise { const staleBefore = new Date(now.getTime() - PREGENERATE_STALE_MS); + const optedOut = await userIdsWithModuleOff(prisma, "insights"); return prisma.user.findMany({ where: { - disableCoach: false, + ...(optedOut.length > 0 ? { id: { notIn: optedOut } } : {}), OR: [ { insightsCachedAt: null }, { insightsCachedAt: { lt: staleBefore } }, @@ -620,6 +624,26 @@ export async function runInsightPregenerate( // sibling consumer in this candidate's warm reuses it. Scoped per user so // no cross-user object can leak. await withFeatureCacheScope(async () => { + // The two capabilities this pass serves, resolved before any snapshot + // or rate-limit write: the briefing for the comprehensive insight, the + // status notes for the per-metric warm. Each half runs only when its + // own capability is available; the chokepoints re-check at the wire. + const [briefing, statusText] = await Promise.all([ + aiCapabilityForJob(candidate.id, "briefing"), + aiCapabilityForJob(candidate.id, "statusText"), + ]); + if (!briefing.available && !statusText.available) { + result.skipped++; + annotate({ + action: { name: "insights.pregenerate.skipped" }, + meta: { + briefing: briefing.reason, + status_text: statusText.reason, + }, + }); + return; + } + // Budget gate — one COMPREHENSIVE pre-generation per user per 20 h. // The route's on-demand path uses a different bucket // (`insights:${userId}`), so this never starves a user's manual @@ -628,18 +652,25 @@ export async function runInsightPregenerate( // the 02:xx status crons already skipped every pregenerate candidate // on the assumption that THIS pass warms their cards — exiting early // here left those cards cold until the first on-visit generation. - const budget = await checkRateLimit( - `insight-pregenerate:${candidate.id}`, - 1, - PREGENERATE_BUDGET_WINDOW_MS, - ); + const budget = briefing.available + ? await checkRateLimit( + `insight-pregenerate:${candidate.id}`, + 1, + PREGENERATE_BUDGET_WINDOW_MS, + ) + : null; // The stored locale, then the operator default: a NULL column used to // resolve to English here, so a German-language instance warmed its // briefings in English every night. const locale = await resolveJobLocale(candidate.locale); - let outcome: GenerateOutcome | null = null; - if (!budget.allowed) { + if (budget === null) { + result.skipped++; + annotate({ + action: { name: "insights.pregenerate.skipped" }, + meta: { briefing: briefing.reason }, + }); + } else if (!budget.allowed) { result.budgetBlocked++; } else { // Force a fresh generation: the discovery window (20 h) is shorter @@ -709,7 +740,7 @@ export async function runInsightPregenerate( // before the morning visit instead of waiting for the next night. await enqueueRetry({ userId: candidate.id, locale }); } else { - outcome = bounded.value; + const outcome = bounded.value; switch (outcome.status) { case "generated": result.generated++; @@ -758,11 +789,12 @@ export async function runInsightPregenerate( // consent-skipped included) because the 02:xx status crons skip every // pregenerate candidate on the assumption that THIS pass covers them. // - // The only outcome with nothing to warm is a missing provider - // (`skipped`/`no-provider`): the per-card generators would no-op to - // their fallbacks anyway, so the pass is skipped to save the - // chain-resolves. - if (outcome?.status === "skipped" && outcome.reason === "no-provider") { + // Nothing to warm when the status notes themselves are unavailable. + if (!statusText.available) { + annotate({ + action: { name: "insights.pregenerate.status_skipped" }, + meta: { reason: statusText.reason }, + }); return; } result.assessmentsWarmed += await warmStatus(candidate.id, [locale]); @@ -881,17 +913,22 @@ export async function forceWarmUser( metricAssessmentsWarmed: 0, }; - // v1.9.0 — gate each section on the flag for the surface it actually - // warms, matching the route. The HTTP route admits this job on the per-user - // `insightStatus` surface, so warming the per-status + generic-metric cards - // must run whenever `insightStatus` is enabled. Only the comprehensive - // briefing belongs to the `briefing` surface — gating the whole warm on - // `briefing` (the previous behaviour) let an operator's global `briefing` - // kill-switch silently suppress the assessment cards the user has enabled. - // When the master assistant switch is off both flags resolve false, so the - // whole thing still no-ops. - const flags = await getAssistantFlags(); - if (!flags.briefing && !flags.insightStatus) return result; + // Each section runs on its own capability, resolved for this user under + // the job's authority: the comprehensive briefing on `briefing`, the + // per-status and generic-metric notes on `statusText`. One unavailable + // half never suppresses the other; both unavailable makes the job a + // no-op before any snapshot is built. + const [briefing, statusText] = await Promise.all([ + aiCapabilityForJob(userId, "briefing"), + aiCapabilityForJob(userId, "statusText"), + ]); + if (!briefing.available && !statusText.available) { + annotate({ + action: { name: "insights.pregenerate.force.skipped" }, + meta: { briefing: briefing.reason, status_text: statusText.reason }, + }); + return result; + } // v1.16.8 — warm only the caller's resolved locale. The old dual-locale // warm compensated for the comprehensive write's cross-locale per-status @@ -926,7 +963,7 @@ export async function forceWarmUser( // 3. daily cap — at most FORCE_WARM_DAILY_LIMIT forced attempts per // rolling 24 h, so even a client bug that defeats 1+2 cannot turn // the forced path into an unmetered generation loop. - if (flags.briefing) { + if (briefing.available) { let freshness: { insightsCachedAt: Date | null; insightsWarmFailedAt: Date | null; @@ -1066,15 +1103,15 @@ export async function forceWarmUser( } } - // Run the per-status + generic-metric warm passes whenever the - // `insightStatus` surface is enabled, regardless of the comprehensive + // Run the per-status + generic-metric warm passes whenever `statusText` + // is available, regardless of the comprehensive // outcome. Each generator independently no-ops to its no-key fallback when // no provider is configured (no LLM call) and serves its fallback without // persisting on a per-card timeout, so running them after a failed // comprehensive is safe and bounded by their own per-card budgets. The only // outcome that has nothing to warm is a missing provider, and the // generators detect that themselves at near-zero cost. - if (flags.insightStatus) { + if (statusText.available) { // Refill-only (v1.16.8): a card already generated today is a cheap // cache read; a cold card runs its generator, whose content-hash gate // skips the LLM when the underlying data did not change. There is no diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts index 9356f8300..80118c473 100644 --- a/src/lib/jobs/insight-status-generate.ts +++ b/src/lib/jobs/insight-status-generate.ts @@ -56,6 +56,7 @@ import { type InsightStatusMetric, type InsightStatusGeneratePayload, } from "@/lib/jobs/insight-status-generate-shared"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { mayDispatchProviderWork, withProviderWorkAuthority, @@ -115,6 +116,19 @@ async function dispatchInsightStatusGenerate( payload: InsightStatusGeneratePayload, generators: Record = GENERATORS, ): Promise { + // The capability, under the job's own authority, before any generator + // builds a snapshot. The route that enqueued this checked it too, but a + // switch, the AI analysis opt-out or a consent can change while the job + // waits in the queue. + const capability = await aiCapabilityForJob(payload.userId, "statusText"); + if (!capability.available) { + annotate({ + action: { name: "insights.status.generate.skipped" }, + meta: { metric: payload.metric, reason: capability.reason }, + }); + return; + } + // v1.8.7.1 — a `metric:` scope routes to the generic // HealthKit-metric generator rather than one of the seven specialised // ones. The generic generator applies its own empty-data guard, so a diff --git a/src/lib/jobs/period-narrative-warm.ts b/src/lib/jobs/period-narrative-warm.ts index 3c5c97600..5272f619e 100644 --- a/src/lib/jobs/period-narrative-warm.ts +++ b/src/lib/jobs/period-narrative-warm.ts @@ -26,6 +26,8 @@ import { mayDispatchProviderWork, withProviderWorkAuthority, } from "@/lib/sharing/provider-work-authority"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; +import { userIdsWithModuleOff } from "@/lib/jobs/ai-job-candidates"; import { annotate } from "@/lib/logging/context"; import { generatePeriodNarrative, @@ -96,7 +98,8 @@ interface NarrativeCandidate { } /** - * Discovery query — coach-enabled users, oldest-narrative-first so the + * Discovery query — users with AI analysis on (the `insights` module), + * oldest-narrative-first so the * staleest users are served before the per-run cap bites. Whether a user has * a provider / enough history is confirmed inside the generator (skipped / * insufficient), so a provider-less or sparse account costs at most one cheap @@ -113,8 +116,9 @@ export async function findNarrativeCandidates( prisma: PrismaClient, cap: number, ): Promise { + const optedOut = await userIdsWithModuleOff(prisma, "insights"); const users: NarrativeCandidate[] = await prisma.user.findMany({ - where: { disableCoach: false }, + where: optedOut.length > 0 ? { id: { notIn: optedOut } } : {}, select: { id: true, locale: true }, }); if (users.length <= cap) return users; @@ -175,6 +179,23 @@ export async function runPeriodNarrativeWarm( result.total = candidates.length; for (const candidate of candidates) { + // The nightly pass exists for the model-written narrative. Unavailable, + // it skips the user before the context build and the budget write; the + // deterministic narrative is written on the next read instead (the + // single-user warm below runs whatever the capability says). + const capability = await aiCapabilityForJob( + candidate.id, + "periodNarrative", + ); + if (!capability.available) { + result.skipped++; + annotate({ + action: { name: "insights.narrative.warm.skipped" }, + meta: { reason: capability.reason }, + }); + continue; + } + const budget = await checkRateLimit( `period-narrative:${candidate.id}`, 1, @@ -254,8 +275,10 @@ export async function warmOneNarrative( if (!payload.userId || !payload.period) return null; const authority = payload.authority; if (!authority || !(await mayDispatchProviderWork(authority))) return null; - const flags = await getAssistantFlags(); - if (!flags.briefing && !flags.insightStatus) return null; + // No switch check here: this warm also writes the deterministic narrative, + // which is data. The model half is refused by the provider chokepoint when + // `periodNarrative` is unavailable, and the generator then writes the + // deterministic text instead. return withProviderWorkAuthority(authority, () => generate(payload.userId!, { period: payload.period!, diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index 35e994009..35a4a6493 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -37,7 +37,7 @@ import { chainRequiresServerManagedConsent, hasActiveConsentForSurface, } from "@/lib/ai/consent-guard"; -import { isModuleEnabled } from "@/lib/modules/gate"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { buildDateKey, reconcileSpend, @@ -467,8 +467,20 @@ export async function runReactionLine( return { status: "skipped", reason: "already_attempted" }; } - if (!(await isModuleEnabled(job.userId, "insights"))) { - return { status: "skipped", reason: "module_disabled" }; + // The `reactionLines` capability before the digest is built or a chain is + // resolved: the operator's switches (the master included, which this job + // never read before), the person's AI analysis switch, provider presence and + // consent in one answer. The marker itself is data and stays. + const capability = await aiCapabilityForJob(job.userId, "reactionLines"); + if (!capability.available) { + annotate({ + action: { name: "arrival.reaction.skipped" }, + meta: { kind: job.kind, reason: capability.reason }, + }); + return { + status: "skipped", + reason: capability.reason ?? "check_failed", + }; } const user = await prisma.user.findUnique({ where: { id: job.userId } }); diff --git a/src/lib/jobs/reminder/insights-handlers.ts b/src/lib/jobs/reminder/insights-handlers.ts index 32a0da584..4fd293222 100644 --- a/src/lib/jobs/reminder/insights-handlers.ts +++ b/src/lib/jobs/reminder/insights-handlers.ts @@ -31,6 +31,7 @@ import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medica import { generateStatusBatchForUser } from "@/lib/insights/status-batch"; import { findStatusCronCandidates } from "@/lib/jobs/status-cron-candidates"; import { annotate } from "@/lib/logging/context"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { getWorkerPrisma } from "./shared"; export interface GeneralStatusPayload { @@ -64,11 +65,12 @@ export interface MedicationComplianceStatusPayload { /** * Shared driver for the nightly 02:xx per-metric status crons. User * discovery is centralised in `findStatusCronCandidates`, which applies - * the operator assistant kill-switch, the per-user `disableCoach` gate, - * and the pregenerate-candidate skip (users with a configured provider - * and a stale comprehensive cache belong to the 04:30 pre-generate pass, - * which re-warms every per-status cache anyway — see - * `status-cron-candidates.ts` for the full division of nightly labour). + * the operator's `insightStatus` switch, the person's AI analysis switch, + * and the pregenerate-candidate skip (users with a stale comprehensive + * cache belong to the 04:30 pre-generate pass, which re-warms every + * per-status note anyway — see `status-cron-candidates.ts` for the full + * division of nightly labour). Each user's `statusText` capability is then + * resolved before a generator runs. * The generators normalise `locale` themselves (de stays de, everything * else gets English prose). * @@ -96,6 +98,16 @@ export async function runStatusCronGenerate( let failed = 0; for (const user of users) { + // The capability before the generator builds a snapshot. The batch + // entry below checks for itself. + const capability = await aiCapabilityForJob(user.id, "statusText"); + if (!capability.available) { + annotate({ + action: { name: "insights.status.cron.skipped" }, + meta: { task: taskName, reason: capability.reason }, + }); + continue; + } try { await generate(user.id, { locale: await resolveJobLocale(user.locale), diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index 8ea3a7a6f..166dc7c14 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -539,6 +539,7 @@ export async function registerStatusQueues( persisted: summary.persisted, skipped_opted_out: summary.skippedOptedOut, skipped_no_provider: summary.skippedNoProvider, + skipped_unavailable: summary.skippedUnavailable, skipped_recent_nudge: summary.skippedRecentNudge, skipped_recent_engagement: summary.skippedRecentEngagement, skipped_no_trigger: summary.skippedNoTrigger, diff --git a/src/lib/jobs/status-cron-candidates.ts b/src/lib/jobs/status-cron-candidates.ts index a32c41843..9b62b23a6 100644 --- a/src/lib/jobs/status-cron-candidates.ts +++ b/src/lib/jobs/status-cron-candidates.ts @@ -12,34 +12,32 @@ * Division of nightly labour (documented here once, referenced by the * handlers): * - * - 02:xx status crons → users WITHOUT a comprehensive-pregenerate - * claim: no configured provider, or a comprehensive cache fresher - * than `PREGENERATE_STALE_MS` so the 04:30 pass will skip them — - * their per-status caches still age out daily and only these crons - * re-fill them. With the one-hour threshold this cohort is, in - * practice, the provider-less accounts. - * - 04:30 insight-pregenerate → the rest: coach-enabled users with a - * configured provider and a comprehensive cache older than - * `PREGENERATE_STALE_MS`. That pass regenerates the comprehensive - * insight AND force-warms every per-status cache, so a 02:xx - * generation for those users would be evicted and redone two hours - * later. + * - 02:xx status crons → users the 04:30 `insight-pregenerate` pass will + * not reach: a comprehensive cache fresher than `PREGENERATE_STALE_MS`, + * so that pass skips them, while their per-status notes still age out + * daily and only these crons re-fill them. + * - 04:30 insight-pregenerate → the rest: users with a comprehensive cache + * older than `PREGENERATE_STALE_MS`. That pass warms every per-status + * note whenever `statusText` is available for the user, whatever became + * of the briefing, so a 02:xx generation for them would be redone two + * hours later. * - * Gates, in order: - * 1. Operator kill-switch via `getAssistantFlags()` — the same source - * `insight-pregenerate` uses. The status cards belong to the - * `insightStatus` surface, so that flag (which the master assistant - * switch also forces off) gates the whole pass. - * 2. Per-user `disableCoach: false` — a user who disabled the AI - * surface gets no nightly generation. - * 3. The pregenerate-candidate skip described above (only when the - * `briefing` surface is enabled — when it is off the 04:30 pass - * no-ops, so the 02:xx crons must keep covering everyone). + * Cheap gates, in SQL and in order (the per-user `statusText` capability in + * each handler, and the chokepoint behind it, are what decide): + * 1. The operator's `insightStatus` switch (master applied). Off, the whole + * pass is empty. + * 2. The person's AI analysis switch (the `insights` module). Someone who + * turned it off gets no nightly generation. Hiding the Coach + * (`disableCoach`) no longer plays a part: it hides the Coach and nothing + * else. + * 3. The pregenerate-candidate skip described above, only while the + * `briefing` switch is on; when it is off the 04:30 pass no-ops, so the + * 02:xx crons keep covering everyone. */ import type { PrismaClient } from "@/generated/prisma/client"; import { getAssistantFlags } from "@/lib/feature-flags"; -import { userRowHasProviderCredential } from "@/lib/ai/provider"; import { PREGENERATE_STALE_MS } from "@/lib/jobs/insight-pregenerate"; +import { userIdsWithModuleOff } from "@/lib/jobs/ai-job-candidates"; export interface StatusCronCandidate { id: string; @@ -53,51 +51,23 @@ export async function findStatusCronCandidates( const flags = await getAssistantFlags(); if (!flags.insightStatus) return []; + const optedOut = await userIdsWithModuleOff(prisma, "insights"); const users = await prisma.user.findMany({ - where: { disableCoach: false }, - select: { - id: true, - locale: true, - insightsCachedAt: true, - // Credential-presence columns for the pregenerate-candidate skip — - // evaluated locally, never decrypted. - aiProvider: true, - aiProviderChain: true, - aiAnthropicKeyEncrypted: true, - aiLocalKeyEncrypted: true, - aiOpenaiKeyEncrypted: true, - aiBaseUrl: true, - aiModel: true, - aiCompatBaseUrl: true, - aiCompatModel: true, - codexConnectionStatus: true, - codexAccessTokenEncrypted: true, - codexRefreshTokenEncrypted: true, - }, + where: optedOut.length > 0 ? { id: { notIn: optedOut } } : {}, + select: { id: true, locale: true, insightsCachedAt: true }, }); if (users.length === 0) return []; - // When the briefing surface is off the 04:30 pregenerate pass no-ops, - // so no user has a comprehensive claim and the 02:xx crons keep all. + // When the briefing switch is off the 04:30 pregenerate pass no-ops, so no + // user has a comprehensive claim and the 02:xx crons keep all. if (!flags.briefing) { return users.map((u) => ({ id: u.id, locale: u.locale })); } - // One presence read for the operator's shared key covers the cohort. - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { adminAiKeyEncrypted: true }, - }); - const adminKeyConfigured = !!settings?.adminAiKeyEncrypted; - const staleBefore = now.getTime() - PREGENERATE_STALE_MS; return users - .filter((u) => { - const cacheStale = - !u.insightsCachedAt || u.insightsCachedAt.getTime() < staleBefore; - const isPregenerateCandidate = - cacheStale && userRowHasProviderCredential(u, adminKeyConfigured); - return !isPregenerateCandidate; - }) + .filter( + (u) => u.insightsCachedAt && u.insightsCachedAt.getTime() >= staleBefore, + ) .map((u) => ({ id: u.id, locale: u.locale })); } diff --git a/tests/integration/ai-optional-jobs.test.ts b/tests/integration/ai-optional-jobs.test.ts new file mode 100644 index 000000000..e0c216f0b --- /dev/null +++ b/tests/integration/ai-optional-jobs.test.ts @@ -0,0 +1,289 @@ +/** + * The AI background jobs, run directly against real Postgres under the states + * that must stop them, with a spy in place of the provider. + * + * For every worker and every refusing state: the spy is never called, the + * worker writes no model text, and a skip annotation is recorded. Under the + * all-on baseline the spy IS called, which is what proves the test can see a + * call at all (a spy that no path reaches would pass every refusing case). + * + * States (design "AI optional", 6.2): + * S0 all on: the person's own key, every switch on. + * S1 the operator's master switch off. + * S3 no provider configured anywhere. + * S4 only the operator's key, and no consent receipt. + * S5 the Coach hidden (`disableCoach`), which stops only the Coach. + * S6 AI analysis switched off (the `insights` module), which stops + * everything but the Coach. + * + * Only the provider chain is replaced; the capability resolution, the + * presence probe, the budget ledger and the writes all run for real. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const providerSpy = vi.fn(async () => ({ + content: JSON.stringify({ summary: "A calm and steady stretch." }), + model: "spy-model", + tokensUsed: 12, +})); + +vi.mock("@/lib/ai/provider", async (importOriginal) => { + const actual = await importOriginal(); + const chain = () => [ + { + providerType: "anthropic", + instance: { type: "anthropic", generateCompletion: providerSpy }, + }, + ]; + return { + ...actual, + resolveProviderChain: vi.fn(async () => chain()), + resolveProvider: vi.fn(async () => ({ type: "none" })), + }; +}); + +const annotations: string[] = []; +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + annotate: vi.fn((event: { action?: { name?: string } }) => { + if (event?.action?.name) annotations.push(event.action.name); + return actual.annotate(event as never); + }), + }; +}); + +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { runCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-refresh-worker"; +import { generateStatusBatchForUser } from "@/lib/insights/status-batch"; +import { runInsightStatusGenerate } from "@/lib/jobs/insight-status-generate"; +import { runReactionLine } from "@/lib/jobs/reaction-line"; +import { runWorkoutInsightGenerate } from "@/lib/jobs/workout-insight-generate"; +import { userDayKey } from "@/lib/tz/resolver"; + +type State = "S0" | "S1" | "S3" | "S4" | "S5" | "S6"; + +let counter = 0; + +/** One account in the given state, with the data every worker below reads. */ +async function seedState(state: State) { + const prisma = getPrismaClient(); + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + create: { + id: "singleton", + assistantEnabled: state !== "S1", + adminAiKeyEncrypted: state === "S4" ? "operator-key-present" : null, + }, + update: { + assistantEnabled: state !== "S1", + adminAiKeyEncrypted: state === "S4" ? "operator-key-present" : null, + }, + }); + const ownKey = state !== "S3" && state !== "S4"; + const suffix = `${state.toLowerCase()}-${counter++}`; + const user = await prisma.user.create({ + data: { + username: `jobs-${suffix}`, + email: `jobs-${suffix}@example.test`, + timezone: "UTC", + locale: "en", + heightCm: 180, + disableCoach: state === "S5", + modulePreferencesJson: state === "S6" ? { insights: false } : undefined, + aiProviderChain: ownKey + ? [{ providerType: "anthropic", enabled: true }] + : undefined, + aiAnthropicKeyEncrypted: ownKey ? "key-present" : null, + }, + }); + + const now = Date.now(); + const DAY = 24 * 60 * 60 * 1000; + await prisma.measurement.createMany({ + data: Array.from({ length: 30 }, (_, i) => ({ + userId: user.id, + type: "WEIGHT" as const, + value: 82 - i * 0.05, + unit: "kg", + source: "MANUAL" as const, + measuredAt: new Date(now - i * DAY), + })), + }); + + const workout = await prisma.workout.create({ + data: { + userId: user.id, + sportType: "cycling", + startedAt: new Date(now - 2 * 60 * 60 * 1000), + endedAt: new Date(now - 60 * 60 * 1000), + durationSec: 3600, + source: "APPLE_HEALTH", + externalId: `workout-${suffix}`, + }, + }); + + const occurredAt = new Date(now - 5 * 60 * 1000); + const localDate = userDayKey(new Date(now), "UTC"); + await prisma.arrivalReaction.create({ + data: { userId: user.id, kind: "weight", localDate, occurredAt }, + }); + + // Long enough that the rolling summary has turns to fold. + const conversation = await prisma.coachConversation.create({ + data: { userId: user.id, title: "long conversation" }, + }); + await prisma.coachMessage.createMany({ + data: Array.from({ length: 40 }, (_, i) => ({ + conversationId: conversation.id, + role: i % 2 === 0 ? "user" : "assistant", + encryptedContent: encryptToBytes(`turn ${i}: about sleep and walks`), + createdAt: new Date(now - (40 - i) * 60_000), + })), + }); + + return { + userId: user.id, + workoutId: workout.id, + reaction: { + userId: user.id, + kind: "weight" as const, + localDate, + revision: occurredAt.toISOString(), + }, + conversationId: conversation.id, + }; +} + +type Seeded = Awaited>; + +interface Worker { + name: string; + /** Which states must stop it. */ + refusedIn: readonly State[]; + run: (s: Seeded) => Promise; + /** Whether the worker wrote model text for this account. */ + wroteText: (s: Seeded) => Promise; + /** The skip annotation it records when refused. */ + skipAnnotation: string; +} + +const ANALYSIS_REFUSED: readonly State[] = ["S1", "S3", "S4", "S6"]; +const COACH_REFUSED: readonly State[] = ["S1", "S3", "S4", "S5"]; + +const WORKERS: Worker[] = [ + { + name: "insight-status-generate (statusText)", + refusedIn: ANALYSIS_REFUSED, + run: (s) => + runInsightStatusGenerate({ + userId: s.userId, + metric: "weight", + locale: "en", + authority: { + origin: "owner", + recordUserId: s.userId, + actorUserId: s.userId, + grantId: null, + }, + }), + wroteText: async (s) => + (await getPrismaClient().insightStatusCache.count({ + where: { userId: s.userId, textEncrypted: { not: null } }, + })) > 0, + skipAnnotation: "insights.status.generate.skipped", + }, + { + name: "nightly status batch (statusText)", + refusedIn: ANALYSIS_REFUSED, + run: (s) => generateStatusBatchForUser(s.userId, { locale: "en" }), + wroteText: async (s) => + (await getPrismaClient().insightStatusCache.count({ + where: { userId: s.userId, textEncrypted: { not: null } }, + })) > 0, + skipAnnotation: "insights.status.batch.skipped", + }, + { + name: "workout-insight-generate (workoutInsights)", + refusedIn: ANALYSIS_REFUSED, + run: (s) => + runWorkoutInsightGenerate({ userId: s.userId, workoutId: s.workoutId }), + wroteText: async (s) => + (await getPrismaClient().workoutInsight.count({ + where: { userId: s.userId }, + })) > 0, + skipAnnotation: "workouts.insight.skipped", + }, + { + name: "reaction-line (reactionLines)", + refusedIn: ANALYSIS_REFUSED, + run: (s) => runReactionLine(s.reaction), + wroteText: async (s) => + (await getPrismaClient().arrivalReaction.count({ + where: { userId: s.userId, lineEncrypted: { not: null } }, + })) > 0, + skipAnnotation: "arrival.reaction.skipped", + }, + { + name: "coach-memory-refresh (coach)", + refusedIn: COACH_REFUSED, + run: (s) => + runCoachMemoryRefresh({ + conversationId: s.conversationId, + userId: s.userId, + locale: "en", + }), + wroteText: async (s) => + (await getPrismaClient().coachConversation.count({ + where: { id: s.conversationId, summaryEncrypted: { not: null } }, + })) > 0, + skipAnnotation: "coach.memory.refresh.skipped", + }, +]; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + providerSpy.mockClear(); + annotations.length = 0; +}); + +describe.each(WORKERS)("$name", (worker) => { + it("calls the provider under S0 (the spy can see a call)", async () => { + const seeded = await seedState("S0"); + await worker.run(seeded); + expect(providerSpy).toHaveBeenCalled(); + expect(annotations).not.toContain(worker.skipAnnotation); + }); + + it.each(worker.refusedIn)( + "never reaches the provider and writes nothing under %s", + async (state) => { + const seeded = await seedState(state); + await worker.run(seeded); + expect(providerSpy).not.toHaveBeenCalled(); + expect(await worker.wroteText(seeded)).toBe(false); + expect(annotations).toContain(worker.skipAnnotation); + }, + ); +}); + +describe("the Coach and the analysis are separate opt-outs", () => { + it("hiding the Coach does not stop the status notes", async () => { + const seeded = await seedState("S5"); + await WORKERS[0].run(seeded); + expect(providerSpy).toHaveBeenCalled(); + }); + + it("switching AI analysis off does not stop the Coach memory", async () => { + const seeded = await seedState("S6"); + await WORKERS[4].run(seeded); + expect(providerSpy).toHaveBeenCalled(); + }); +}); diff --git a/tests/integration/consent-withdrawal-purge.test.ts b/tests/integration/consent-withdrawal-purge.test.ts new file mode 100644 index 000000000..4823be731 --- /dev/null +++ b/tests/integration/consent-withdrawal-purge.test.ts @@ -0,0 +1,327 @@ +/** + * Consent withdrawal deletes the regenerable model-written text, against real + * Postgres, and keeps the person's own records. + * + * What is pinned here and nowhere else: + * + * - the purge reaches every surface the analysis consent covered: status + * notes, the cached briefing, model-written narratives (the deterministic + * one stays), reaction lines (the marker stays), workout paragraphs; + * - Coach conversations and facts, and a document's stored summary, stay; + * - revoke and purge are one transaction: a purge that fails leaves the + * receipt active and every row in place; + * - a withdrawal that leaves the analysis covered, or that never touched + * it, deletes nothing. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { + purgeRegenerableAiText, + withdrawConsent, +} from "@/lib/consent/withdrawal"; + +const BYTES = Buffer.from("ciphertext-stand-in", "utf8"); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +async function seed(username: string, kinds: string[]) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + insightsCachedText: JSON.stringify({ briefing: "model prose" }), + insightsCachedAt: new Date(), + insightsSnapshotHash: "hash", + }, + }); + for (const kind of kinds) { + await prisma.consentReceipt.create({ + data: { userId: user.id, kind, artefact: "{}", signedAt: new Date() }, + }); + } + await prisma.insightStatusCache.createMany({ + data: [ + { + userId: user.id, + metric: "weight", + locale: "en", + dateKey: "2026-09-20", + textEncrypted: BYTES, + }, + { + userId: user.id, + metric: "metric:RESTING_HEART_RATE", + locale: "en", + dateKey: "2026-09-20", + textEncrypted: BYTES, + }, + ], + }); + await prisma.insightNarrative.createMany({ + data: [ + { + userId: user.id, + period: "week", + locale: "en", + dateKey: "2026-09-20", + encryptedContent: BYTES, + providerType: "anthropic", + }, + { + userId: user.id, + period: "month", + locale: "en", + dateKey: "2026-09-20", + encryptedContent: BYTES, + providerType: "deterministic", + }, + ], + }); + await prisma.arrivalReaction.create({ + data: { + userId: user.id, + kind: "weight", + localDate: "2026-09-20", + occurredAt: new Date(), + lineEncrypted: BYTES, + generatedAt: new Date(), + }, + }); + const workout = await prisma.workout.create({ + data: { + userId: user.id, + sportType: "cycling", + startedAt: new Date("2026-09-20T06:00:00Z"), + endedAt: new Date("2026-09-20T06:45:00Z"), + durationSec: 2700, + source: "APPLE_HEALTH", + externalId: `ext-${username}`, + }, + }); + await prisma.workoutInsight.create({ + data: { + userId: user.id, + workoutId: workout.id, + paragraphEncrypted: BYTES, + inputHash: "h", + promptVersion: "1", + providerType: "anthropic", + locale: "en", + generatedAt: new Date(), + }, + }); + const conversation = await prisma.coachConversation.create({ + data: { userId: user.id, title: "t", summaryEncrypted: BYTES }, + }); + await prisma.coachFact.create({ + data: { + userId: user.id, + factEncrypted: BYTES, + category: "goal", + sourceConversationId: conversation.id, + }, + }); + await prisma.inboundDocument.create({ + data: { + userId: user.id, + mimeType: "image/png", + byteSize: BYTES.byteLength, + contentEncrypted: BYTES, + summaryEncrypted: BYTES, + summaryState: "READY", + }, + }); + return user; +} + +async function snapshot(userId: string) { + const prisma = getPrismaClient(); + const [ + user, + notes, + narratives, + reaction, + workouts, + conversations, + facts, + docs, + ] = await Promise.all([ + prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { + insightsCachedText: true, + insightsCachedAt: true, + insightsSnapshotHash: true, + }, + }), + prisma.insightStatusCache.count({ where: { userId } }), + prisma.insightNarrative.findMany({ + where: { userId }, + select: { providerType: true }, + }), + prisma.arrivalReaction.findFirstOrThrow({ + where: { userId }, + select: { lineEncrypted: true, generatedAt: true }, + }), + prisma.workoutInsight.count({ where: { userId } }), + prisma.coachConversation.findMany({ + where: { userId }, + select: { summaryEncrypted: true }, + }), + prisma.coachFact.count({ where: { userId } }), + prisma.inboundDocument.findMany({ + where: { userId }, + select: { summaryEncrypted: true }, + }), + ]); + return { + user, + notes, + narratives, + reaction, + workouts, + conversations, + facts, + docs, + }; +} + +describe("withdrawing the analysis consent", () => { + it("deletes every regenerable text and keeps the person's own records", async () => { + const user = await seed("purge-all", ["ai_full"]); + + const result = await withdrawConsent(user.id, ["ai_full"]); + + expect(result.revoked.map((r) => r.kind)).toEqual(["ai_full"]); + expect(result.purged).toEqual({ + statusNotes: 2, + briefing: 1, + narratives: 1, + reactionLines: 1, + workoutParagraphs: 1, + }); + + const after = await snapshot(user.id); + expect(after.user).toEqual({ + insightsCachedText: null, + insightsCachedAt: null, + insightsSnapshotHash: null, + }); + expect(after.notes).toBe(0); + expect(after.narratives).toEqual([{ providerType: "deterministic" }]); + // The marker stays and stays "generated", so nothing writes it again. + expect(after.reaction.lineEncrypted).toBeNull(); + expect(after.reaction.generatedAt).not.toBeNull(); + expect(after.workouts).toBe(0); + // The person's own records. + expect(after.conversations).toHaveLength(1); + expect(after.conversations[0].summaryEncrypted).not.toBeNull(); + expect(after.facts).toBe(1); + expect(after.docs[0].summaryEncrypted).not.toBeNull(); + + const receipts = await getPrismaClient().consentReceipt.findMany({ + where: { userId: user.id }, + }); + expect(receipts).toHaveLength(1); + expect(receipts[0].revokedAt).not.toBeNull(); + }); + + it("is atomic: a failing purge leaves the receipt active and every row in place", async () => { + const user = await seed("purge-atomic", ["ai_insights_only"]); + const before = await snapshot(user.id); + + await expect( + withdrawConsent( + user.id, + ["ai_insights_only"], + new Date(), + async (tx, id) => { + // Do part of the work, then fail: the part must roll back too. + await purgeRegenerableAiText(tx, id); + throw new Error("purge failed"); + }, + ), + ).rejects.toThrow("purge failed"); + + const receipt = await getPrismaClient().consentReceipt.findFirstOrThrow({ + where: { userId: user.id }, + }); + expect(receipt.revokedAt).toBeNull(); + expect(await snapshot(user.id)).toEqual(before); + }); + + it("deletes nothing while another receipt still covers the analysis", async () => { + const user = await seed("purge-covered", ["ai_full", "ai_insights_only"]); + const before = await snapshot(user.id); + + const result = await withdrawConsent(user.id, ["ai_full"]); + + expect(result.purged).toBeNull(); + expect(await snapshot(user.id)).toEqual(before); + }); + + it("deletes nothing for a withdrawal of the Coach or extraction consent alone", async () => { + const user = await seed("purge-other", ["ai_coach", "ai_extraction"]); + const before = await snapshot(user.id); + + const result = await withdrawConsent(user.id, [ + "ai_coach", + "ai_extraction", + ]); + + expect(result.revoked).toHaveLength(2); + expect(result.purged).toBeNull(); + expect(await snapshot(user.id)).toEqual(before); + }); + + it("touches no other account", async () => { + const user = await seed("purge-me", ["ai_full"]); + const other = await seed("purge-other-account", ["ai_full"]); + const otherBefore = await snapshot(other.id); + + await withdrawConsent(user.id, ["ai_full"]); + + expect(await snapshot(other.id)).toEqual(otherBefore); + }); +}); + +describe("a revoked extraction receipt wins over the auto-read toggle", () => { + it("refuses external document egress once the receipt is revoked, toggle still on", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "k1-toggle", + email: "k1-toggle@example.test", + documentsAutoAiRead: true, + }, + }); + await prisma.consentReceipt.create({ + data: { + userId: user.id, + kind: "ai_extraction", + artefact: "{}", + signedAt: new Date(), + }, + }); + const { assertDocumentEgressConsent, ConsentRequiredError } = + await import("@/lib/ai/consent-guard"); + + await expect( + assertDocumentEgressConsent({ userId: user.id, providerType: "codex" }), + ).resolves.toBeUndefined(); + + await withdrawConsent(user.id, ["ai_extraction"]); + + await expect( + assertDocumentEgressConsent({ userId: user.id, providerType: "codex" }), + ).rejects.toBeInstanceOf(ConsentRequiredError); + // A local pick never leaves the machine and stays admitted. + await expect( + assertDocumentEgressConsent({ userId: user.id, providerType: "local" }), + ).resolves.toBeUndefined(); + }); +});