From 3917a2faceff9b7ca34deeb7bb516b8e94610f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:07:16 +0200 Subject: [PATCH] feat(ai): resolve the AI capability before any background model work Every AI job now asks its capability for the user before it builds a snapshot, and skips with a `.skipped` annotation naming the reason: the nightly pregenerate (briefing and statusText, each half on its own), the status batch and the per-metric crons, the on-demand status worker, the narrative cron, Coach nudges and their AI composer, the Coach memory refresh, reaction lines, workout paragraphs, and the document summary, catch-up and index jobs. Seven of these read no assistant switch at all before, so the master switch now stops them too. The cron candidate queries drop people who switched AI analysis off in SQL, and no longer read `disableCoach` outside the Coach: hiding the Coach hides the Coach. Coach nudges use the one presence probe, so a central Codex account is no longer skipped; a missing consent still gets the deterministic template. The arrival spine asks only the provider-free half (switches and modules) before it enqueues, keeping its module graph free of provider code. Document reading needs an extraction receipt (ai_extraction or ai_full) for any external provider; the auto-read toggle is the trigger, no longer a consent shortcut, so a revoked receipt wins over a toggle left on. Lab report scans get the same rule for a whole chain. Two structural guards: every file that calls a provider is named with the reason its call is admitted, and the chokepoints re-check a capability; every AI job references its capability gate. An integration matrix runs each worker under the refusing states with a provider spy. Refs #975 --- .../ai-egress-capability-guard.test.ts | 195 +++++++++++ .../documents/inbound/[id]/extract/route.ts | 3 - .../api/documents/inbound/[id]/index/route.ts | 1 - .../documents/inbound/[id]/suggest/route.ts | 2 - .../documents/inbound/[id]/summary/route.ts | 2 - .../api/documents/inbound/reindex/route.ts | 1 - src/lib/ai/__tests__/consent-guard.test.ts | 133 ++++--- .../coach-memory-refresh-worker.test.ts | 65 ++++ .../ai/coach/coach-memory-refresh-worker.ts | 14 + src/lib/ai/consent-guard.ts | 77 +++-- .../__tests__/fenced-chat-pipeline.test.ts | 13 +- src/lib/documents/fenced-chat.ts | 1 - .../__tests__/no-dead-assistant-flag.test.ts | 14 +- .../ai-job-capability-inventory.test.ts | 124 +++++++ .../__tests__/async-state-contract.test.ts | 63 ++++ .../__tests__/coach-nudge-ai-consent.test.ts | 34 ++ src/lib/jobs/__tests__/coach-nudge.test.ts | 84 ++++- .../__tests__/data-arrival-worker.test.ts | 45 +++ .../document-content-index-backfill.test.ts | 76 ++++ .../document-summary-catchup.test.ts | 35 ++ .../jobs/__tests__/document-summary.test.ts | 96 +++++ .../__tests__/insight-pregenerate.test.ts | 206 +++++++++-- .../__tests__/insight-status-generate.test.ts | 64 ++++ .../__tests__/period-narrative-warm.test.ts | 70 +++- .../reaction-line-degradation.test.ts | 47 ++- .../__tests__/status-cron-candidates.test.ts | 155 +++++---- src/lib/jobs/ai-job-candidates.ts | 62 ++++ src/lib/jobs/coach-nudge-ai.ts | 13 + src/lib/jobs/coach-nudge.ts | 55 +-- src/lib/jobs/data-arrival.ts | 30 +- .../jobs/document-content-index-backfill.ts | 30 +- src/lib/jobs/document-summary-catchup.ts | 13 + src/lib/jobs/document-summary.ts | 55 ++- src/lib/jobs/insight-pregenerate.ts | 103 ++++-- src/lib/jobs/insight-status-generate.ts | 14 + src/lib/jobs/period-narrative-warm.ts | 31 +- src/lib/jobs/reaction-line.ts | 18 +- src/lib/jobs/reminder/insights-handlers.ts | 22 +- src/lib/jobs/reminder/register-status.ts | 1 + src/lib/jobs/status-cron-candidates.ts | 88 ++--- tests/integration/ai-optional-jobs.test.ts | 289 ++++++++++++++++ .../consent-withdrawal-purge.test.ts | 327 ++++++++++++++++++ 42 files changed, 2389 insertions(+), 382 deletions(-) create mode 100644 src/__tests__/ai-egress-capability-guard.test.ts create mode 100644 src/lib/ai/coach/__tests__/coach-memory-refresh-worker.test.ts create mode 100644 src/lib/jobs/__tests__/ai-job-capability-inventory.test.ts create mode 100644 src/lib/jobs/ai-job-candidates.ts create mode 100644 tests/integration/ai-optional-jobs.test.ts create mode 100644 tests/integration/consent-withdrawal-purge.test.ts 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(); + }); +});