diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bce702fa5..224d4d910 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12774,6 +12774,7 @@ paths: - ai_full - ai_insights_only - ai_coach + - ai_extraction description: Narrow to one consent kind. Omit for the full keyspace. responses: "200": @@ -12785,8 +12786,9 @@ paths: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" "400": &a22 - description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope; - nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." + description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`. + Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the + API answers 422." content: application/json: schema: @@ -12809,9 +12811,18 @@ paths: each revocation is audit-logged. - Revoking `ai_full` does NOT switch off `documentsAutoAiRead`; that flag is written through PATCH - /api/auth/me/documents-auto-ai-read and turning it on mints a fresh receipt. A client offering both controls - should show them as the two separate things they are. + Revoking `ai_full` or `ai_extraction` does NOT switch off `documentsAutoAiRead`, but it does stop document + reading: the document gate reads the receipt, not the switch, so a revocation wins over a switch left on. + Turning the switch on again mints a fresh `ai_extraction` receipt through PATCH + /api/auth/me/documents-auto-ai-read. A client offering both controls should show them as the two separate things + they are. + + + When a revocation leaves no receipt covering the analysis (`ai_insights_only` or `ai_full`), the same + transaction deletes the text a model wrote under it that can be written again: per-metric status notes, the + cached briefing, model-written period narratives, reaction lines and workout paragraphs. Coach conversations, + facts and plans and document summaries are the person's records and stay. A deletion is audit-logged with counts + only (`consent.ai.purge`). parameters: - in: query name: kind @@ -12822,6 +12833,7 @@ paths: - ai_full - ai_insights_only - ai_coach + - ai_extraction description: Revoke one kind. Omit to revoke the latest active receipt across every kind. responses: "200": @@ -23992,8 +24004,8 @@ components: type: boolean required: - documentsAutoAiRead - description: Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_full` - consent receipt and schedules a catch-up over the existing vault. + description: Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an + `ai_extraction` consent receipt and schedules a catch-up over the existing vault. InjectionSitePrefsPatchRequest: type: object properties: @@ -26151,6 +26163,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction artefact: type: string minLength: 1 @@ -58183,6 +58196,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction signedAt: type: string revokedAt: @@ -58279,6 +58293,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction receipt: anyOf: - $ref: "#/components/schemas/SerialisedConsentReceipt" @@ -58301,6 +58316,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction signedAt: type: string format: date-time @@ -58357,13 +58373,18 @@ components: anyOf: - $ref: "#/components/schemas/SerialisedConsentReceipt" - type: "null" + ai_extraction: + anyOf: + - $ref: "#/components/schemas/SerialisedConsentReceipt" + - type: "null" required: - ai_full - ai_insights_only - ai_coach + - ai_extraction additionalProperties: false - description: "The latest active receipt for every kind. All three keys are always present: a null value means the kind - is not granted, so a client never has to read absence as ambiguity." + description: "The latest active receipt for every kind. Every key is always present: a null value means the kind is not + granted, so a client never has to read absence as ambiguity." ConsentRevokeForKindEnvelope: type: object properties: @@ -58412,6 +58433,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction receipt: $ref: "#/components/schemas/SerialisedConsentReceipt" required: diff --git a/prisma/migrations/0344_insight_status_cache/migration.sql b/prisma/migrations/0344_insight_status_cache/migration.sql new file mode 100644 index 000000000..9571572c1 --- /dev/null +++ b/prisma/migrations/0344_insight_status_cache/migration.sql @@ -0,0 +1,44 @@ +-- Status notes move out of the audit log. +-- +-- The per-metric status notes used to be appended to `audit_logs` as plaintext +-- JSON, one row per metric per day, next to the security trail. They now live +-- in their own table, encrypted at rest, one row per (user, metric, locale), +-- upserted in place. +-- +-- 1. The new table. +-- CreateTable +CREATE TABLE "insight_status_caches" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "metric" TEXT NOT NULL, + "locale" TEXT NOT NULL, + "text_encrypted" BYTEA, + "items_encrypted" BYTEA, + "input_hash" TEXT, + "snapshot_hash" TEXT, + "date_key" TEXT NOT NULL, + "generated_at" TIMESTAMP(3), + "retry_at" TIMESTAMP(3), + "negative_reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "insight_status_caches_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "insight_status_caches_user_id_metric_locale_key" ON "insight_status_caches"("user_id", "metric", "locale"); + +-- AddForeignKey +ALTER TABLE "insight_status_caches" ADD CONSTRAINT "insight_status_caches_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- 2. The old rows go. DESTRUCTIVE by design: the notes are a regenerable +-- cache, nothing reads them from `audit_logs` any more, and leaving them +-- would keep plaintext health text in a table documented as holding none. +-- Nothing is copied across, because the old rows are plaintext and a +-- migration cannot encrypt; the first nightly run after the upgrade writes +-- every note again. Take a database backup before applying, as for 0339. +-- Matches exactly the cache keys `insights.-status.` (the +-- seven specialised scopes and the `metric:` / `derived-score:` / +-- `biomarker:` generic ones); no security-trail action has this shape. +DELETE FROM "audit_logs" WHERE "action" LIKE 'insights.%-status.%'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1745fce4b..66075b211 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -860,6 +860,9 @@ model User { // v1.11.0 W3 — one durable period-narrative row per (period, locale). // Delete/regenerate-clean; the generated prose is AES-256-GCM at rest. insightNarratives InsightNarrative[] + // v1.39.0 — the per-metric status notes, one encrypted row per + // (metric, locale). A regenerable cache; it used to live in `audit_logs`. + insightStatusCaches InsightStatusCache[] // v1.31.0 — the data-arrival spine's durable per-day reaction marker. One // row per (kind, local day); the unique index IS the once-per-kind-per-day // claim every reaction surface reads. Retained 14 days. @@ -6722,6 +6725,55 @@ model InsightNarrative { @@map("insight_narratives") } +// v1.39.0 — the per-metric status notes ("Status notes"): what a model wrote +// about one metric for one person in one language, and the fingerprints that +// let the next run skip the model when nothing changed. +// +// One row per (user, metric, locale), upserted in place. Until v1.39.0 these +// notes were appended to `audit_logs` as plaintext JSON, one row per metric per +// day, mixed with the security trail; migration 0344 dropped those rows, so the +// first night after the upgrade regenerates every note once. +// +// A regenerable cache, not a record: excluded from backups, deleted by "Delete +// All Data", purged when the person withdraws AI consent, and never served +// while the `statusText` capability is unavailable. +model InsightStatusCache { + id String @id @default(cuid()) + userId String @map("user_id") + /// The status scope: `weight`, `blood-pressure`, `general`, …, or a prefixed + /// generic scope (`metric:`, `derived-score:`, `biomarker:`). + metric String + /// The UI locale the note was written in. + locale String + /// AES-256-GCM ciphertext of the note. Null while the row only carries a + /// negative-cache window (a generation that stalled before any note existed). + textEncrypted Bytes? @map("text_encrypted") + /// AES-256-GCM ciphertext of per-item notes as JSON, for a card that carries + /// them (medication compliance: one line per medication). Null otherwise. + itemsEncrypted Bytes? @map("items_encrypted") + /// Cheap fingerprint of the salient inputs (count + newest reading). + inputHash String? @map("input_hash") + /// Fingerprint of the full data snapshot the note was written from. + snapshotHash String? @map("snapshot_hash") + /// The person's local YYYY-MM-DD the note counts as current for. + dateKey String @map("date_key") + /// When the note was written, or last re-dated by an unchanged-data run. + generatedAt DateTime? @map("generated_at") + /// Negative cache: while in the future, a read does not enqueue another + /// generation. Cleared when a note is written. + retryAt DateTime? @map("retry_at") + /// Why the last generation served the deterministic line instead of a note: + /// `timeout` | `error` | `screened`. Set together with `retryAt`. + negativeReason String? @map("negative_reason") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, metric, locale]) + @@map("insight_status_caches") +} + // ─── Data-arrival spine (v1.31.0) ───────────────────────────── // // One row per (user, arrival kind, local day). The row is written by the diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index ff2367ff7..1546d0e90 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -454,6 +454,25 @@ async function main() { ), ); + // ───── v1.39.0 InsightStatusCache."textEncrypted" / "itemsEncrypted" ───── + // The per-metric status notes. Both nullable (a row may carry only a + // negative-cache window; only one card stores per-item lines), and + // `rotateBytesColumn` skips a NULL payload. + results.push( + await rotateBytesColumn( + "InsightStatusCache", + "textEncrypted", + prisma.insightStatusCache, + ), + ); + results.push( + await rotateBytesColumn( + "InsightStatusCache", + "itemsEncrypted", + prisma.insightStatusCache, + ), + ); + // ───── v1.31.0 ArrivalReaction."lineEncrypted" (Bytes, nullable) ───── // The data-arrival spine's generated reaction line. Nullable by design — a // provider-less install writes markers with no line at all — and diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index ed24d0515..33167d40a 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -211,6 +211,7 @@ async function seed() { await client.query("DELETE FROM coach_conversations"); await client.query("DELETE FROM consent_receipts"); await client.query("DELETE FROM insight_narratives"); + await client.query("DELETE FROM insight_status_caches"); await client.query("DELETE FROM illness_symptom_links"); await client.query("DELETE FROM illness_day_logs"); await client.query("DELETE FROM illness_episodes"); @@ -1826,11 +1827,10 @@ async function seed() { console.log("Creating baked AI insight texts..."); const todayKey = berlinDayKey(); - // A real-looking provider/model pair so the read path treats the row as a - // genuine assessment (NOT a `model: "timeout-stub"`, which the cache-read - // rejects). The text is what renders; provider/model are provenance only. + // A real-looking provider tag for the rows that record one (the cached + // briefing, the narratives). The text is what renders; the tag is + // provenance only. const bakedProvider = "anthropic"; - const bakedModel = "claude-3-5-sonnet"; // ── Comprehensive insight + daily briefing ── // Stored on users.insights_cached_text (JSON). The GET read parses it and @@ -2037,13 +2037,12 @@ async function seed() { ); // ── Per-metric status cards ── - // Each card is an audit_logs row keyed `insights.-status.` - // whose details JSON is { dateKey, locale, text, providerType, model, - // tokensUsed, snapshotHash }. The read serves a row only when its dateKey - // equals today's Berlin key and the model is not the timeout-stub - // sentinel. We stamp today's key + a real model name so every card reads - // as a current assessment. `text` is the rendered field. 2-4 sentences - // each, grounded in the seeded data, in the base-system advisor voice. + // Each card is an insight_status_caches row keyed (user, metric, locale), + // the note encrypted with the app's codec. The read serves a note only + // when its date_key equals today's key, so every card is stamped with + // today's key and reads as a current assessment. The medication card also + // carries its (empty) per-medication list. 2-4 sentences each, grounded in + // the seeded data, in the base-system advisor voice. const statusCards: Array<{ scope: string; text: string }> = [ { scope: "blood-pressure", @@ -2108,23 +2107,30 @@ async function seed() { ]; for (const card of statusCards) { - const action = `insights.${card.scope}-status.en`; await client.query( - `INSERT INTO audit_logs (id, user_id, action, details, created_at) - VALUES ($1, $2, $3, $4, NOW())`, + `INSERT INTO insight_status_caches + (id, user_id, metric, locale, text_encrypted, items_encrypted, + snapshot_hash, date_key, generated_at, created_at, updated_at) + VALUES ($1, $2, $3, 'en', $4, $5, $6, $7, NOW(), NOW(), NOW()) + ON CONFLICT (user_id, metric, locale) DO UPDATE SET + text_encrypted = EXCLUDED.text_encrypted, + items_encrypted = EXCLUDED.items_encrypted, + snapshot_hash = EXCLUDED.snapshot_hash, + date_key = EXCLUDED.date_key, + generated_at = EXCLUDED.generated_at, + retry_at = NULL, + negative_reason = NULL, + updated_at = NOW()`, [ cuid(), userId, - action, - JSON.stringify({ - dateKey: todayKey, - locale: "en", - text: card.text, - providerType: bakedProvider, - model: bakedModel, - tokensUsed: null, - snapshotHash: `demo-baked-${card.scope}`, - }), + card.scope, + Buffer.from(encryptToBytes(card.text)), + card.scope === "medication-compliance" + ? Buffer.from(encryptToBytes("[]")) + : null, + `demo-baked-${card.scope}`, + todayKey, ], ); } diff --git a/src/__tests__/ai-consent-enforcement-guard.test.ts b/src/__tests__/ai-consent-enforcement-guard.test.ts index 874012310..a18d03239 100644 --- a/src/__tests__/ai-consent-enforcement-guard.test.ts +++ b/src/__tests__/ai-consent-enforcement-guard.test.ts @@ -70,8 +70,8 @@ const CONSENT_HELPERS = [ "assertConsentForChain", "chainRequiresServerManagedConsent", "hasActiveConsentForSurface", - // Document-class, pick-shaped. - "assertDocumentEgressConsent", + // Document-class, pick-shaped (the receipt itself is read by the capability + // egress re-check below). "isExternalDocumentEgress", ] as const; 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..123c56808 --- /dev/null +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -0,0 +1,199 @@ +/** + * 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: one of the gates, or the wire re-check + * (`aiEgressRefusal` / `assertAiEgress`), which also answers the consent a + * picked provider needs. + */ +const CAPABILITY_CHECK = + /\b(?:aiCapabilityForJob|aiCapabilityForRecord|requireAiCapability|getAiCapability|aiEgressRefusal|assertAiEgress)\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 and re-checks the chain at the wire (`aiEgressRefusal`).", + }, + "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/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index cb5a0d316..759d4b43c 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -74,7 +74,7 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [ */ const EXEMPT_TEMPLATES: Record = { "insights.${scope}-status.${locale}": - "auditLog action name for the per-metric status cache (statusCacheAction), not an i18n key", + "the per-metric status note name (statusCacheAction), not an i18n key", "insights.${scope}.${locale}": "auditLog action name for the previous-insight memory read (getPreviousInsightContext), not an i18n key", "apiErrors.${errorCode}": diff --git a/src/app/api/auth/me/documents-auto-ai-read/route.ts b/src/app/api/auth/me/documents-auto-ai-read/route.ts index c5e175fea..ee1161184 100644 --- a/src/app/api/auth/me/documents-auto-ai-read/route.ts +++ b/src/app/api/auth/me/documents-auto-ai-read/route.ts @@ -37,6 +37,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { AI_CAPABILITIES } from "@/lib/ai/capabilities/types"; +import { AI_EXTRACTION_CONSENT_KIND } from "@/lib/consent/receipts"; import { prisma } from "@/lib/db"; import { isP2002 } from "@/lib/prisma-errors"; import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup"; @@ -57,7 +58,7 @@ type DocumentsAutoAiReadResponse = { * master `ai_full`. The capability table lists it (with `ai_full`) as what * satisfies the document consent rule. */ -const AUTO_READ_CONSENT_KIND = "ai_extraction"; +const AUTO_READ_CONSENT_KIND = AI_EXTRACTION_CONSENT_KIND; /** * Mint the extraction receipt unless one that satisfies document reads is diff --git a/src/app/api/consent/ai/latest/__tests__/route.test.ts b/src/app/api/consent/ai/latest/__tests__/route.test.ts index b486eb6b7..356be50e0 100644 --- a/src/app/api/consent/ai/latest/__tests__/route.test.ts +++ b/src/app/api/consent/ai/latest/__tests__/route.test.ts @@ -1,24 +1,30 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -// v1.16.16 — `createReceipt` + `revokeLatest` run inside a transaction; the -// mock runs the callback against the same proxy. +// v1.16.16 — `createReceipt` and the withdrawal run inside a transaction; +// the mock runs the callback against the same proxy. The withdrawal also +// purges the regenerable AI text, so the proxy carries those delegates too. type TxFn = (tx: unknown) => unknown; vi.mock("@/lib/db", () => { - const consentReceipt = { - create: vi.fn(), - findFirst: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), - updateMany: vi.fn(), - }; - return { - prisma: { - consentReceipt, - $transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })), + const prisma = { + consentReceipt: { + create: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + count: vi.fn(), }, + insightStatusCache: { deleteMany: vi.fn() }, + user: { updateMany: vi.fn() }, + insightNarrative: { deleteMany: vi.fn() }, + arrivalReaction: { updateMany: vi.fn() }, + workoutInsight: { deleteMany: vi.fn() }, + $transaction: vi.fn(), }; + prisma.$transaction.mockImplementation((fn: TxFn) => fn(prisma)); + return { prisma }; }); vi.mock("@/lib/rate-limit", () => ({ @@ -78,9 +84,21 @@ beforeEach(() => { vi.mocked(auditLog).mockResolvedValue(undefined); // Re-arm rate-limit + transaction pass-throughs cleared by reset. vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK); - $transaction.mockImplementation((fn: TxFn) => - fn({ consentReceipt: prisma.consentReceipt }), - ); + $transaction.mockImplementation((fn: TxFn) => fn(prisma)); + // Default: another receipt still covers the analysis, so no purge runs. + // The purge cases below set this to 0. + vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never); + for (const deleteMany of [ + prisma.insightStatusCache.deleteMany, + prisma.insightNarrative.deleteMany, + prisma.workoutInsight.deleteMany, + ]) { + vi.mocked(deleteMany).mockResolvedValue({ count: 0 } as never); + } + vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never); + vi.mocked(prisma.arrivalReaction.updateMany).mockResolvedValue({ + count: 0, + } as never); }); describe("GET /api/consent/ai/latest", () => { @@ -278,14 +296,15 @@ describe("DELETE /api/consent/ai/latest", () => { const createdAt = new Date("2026-05-18T10:00:01.000Z"); const revokedAt = new Date("2026-05-18T11:00:00.000Z"); - // v1.16.16 — `revokeLatest` runs per kind in `consentKindEnum.options` - // order: ai_full → ai_insights_only → ai_coach. The `updateMany` count - // gates the follow-up re-read. ai_full + ai_coach revoke a row; - // ai_insights_only has none (count 0, no re-read). + // The withdrawal runs per kind in `consentKindEnum.options` order: + // ai_full → ai_insights_only → ai_coach → ai_extraction. The + // `updateMany` count gates the follow-up re-read. ai_full + ai_coach + // revoke a row; the others have none (count 0, no re-read). vi.mocked(prisma.consentReceipt.updateMany) .mockResolvedValueOnce({ count: 1 } as never) // ai_full .mockResolvedValueOnce({ count: 0 } as never) // ai_insights_only - .mockResolvedValueOnce({ count: 1 } as never); // ai_coach + .mockResolvedValueOnce({ count: 1 } as never) // ai_coach + .mockResolvedValueOnce({ count: 0 } as never); // ai_extraction vi.mocked(prisma.consentReceipt.findFirst) .mockResolvedValueOnce({ id: "rcpt-full", @@ -323,6 +342,80 @@ describe("DELETE /api/consent/ai/latest", () => { }); }); +describe("DELETE /api/consent/ai/latest — purge of regenerable AI text", () => { + const revokedRow = { + id: "rcpt-1", + userId: "user-1", + kind: "ai_insights_only", + artefact: "PDF", + signedAt: new Date("2026-05-18T10:00:00.000Z"), + revokedAt: new Date("2026-05-18T11:00:00.000Z"), + createdAt: new Date("2026-05-18T10:00:01.000Z"), + }; + + it("deletes the regenerable text and writes a counts-only purge audit row when nothing covers the analysis any more", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue( + revokedRow as never, + ); + vi.mocked(prisma.consentReceipt.count).mockResolvedValue(0 as never); + vi.mocked(prisma.insightStatusCache.deleteMany).mockResolvedValue({ + count: 4, + } as never); + + const res = await DELETE( + new NextRequest( + "http://localhost/api/consent/ai/latest?kind=ai_insights_only", + { method: "DELETE" }, + ), + ); + expect(res.status).toBe(200); + expect(prisma.insightStatusCache.deleteMany).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + }); + expect(prisma.workoutInsight.deleteMany).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + }); + expect(auditLog).toHaveBeenCalledWith("consent.ai.purge", { + userId: "user-1", + details: { + statusNotes: 4, + briefing: 0, + narratives: 0, + reactionLines: 0, + workoutParagraphs: 0, + }, + }); + }); + + it("keeps the text while another receipt still covers the analysis", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue( + revokedRow as never, + ); + vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never); + + const res = await DELETE( + new NextRequest( + "http://localhost/api/consent/ai/latest?kind=ai_insights_only", + { method: "DELETE" }, + ), + ); + expect(res.status).toBe(200); + expect(prisma.insightStatusCache.deleteMany).not.toHaveBeenCalled(); + expect(auditLog).not.toHaveBeenCalledWith( + "consent.ai.purge", + expect.anything(), + ); + }); +}); + describe("append-only invariant", () => { /** * v1.4.40 SB-10 — the receipts table is the legal audit trail. diff --git a/src/app/api/consent/ai/latest/route.ts b/src/app/api/consent/ai/latest/route.ts index a316f5adb..babdba8ac 100644 --- a/src/app/api/consent/ai/latest/route.ts +++ b/src/app/api/consent/ai/latest/route.ts @@ -13,6 +13,11 @@ * DELETE /api/consent/ai/latest * Master toggle — revokes the latest active receipt across every * consent kind. Returns the list of revoked rows. + * + * Either DELETE also deletes the regenerable model-written text (status + * notes, the cached briefing, model-written narratives, reaction lines, + * workout paragraphs) when it leaves no receipt covering the analysis, in the + * same transaction as the revoke. Coach memory and document summaries stay. */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; @@ -28,8 +33,8 @@ import { import { latestActiveReceipt, latestActiveReceiptsByKind, - revokeLatest, } from "@/lib/consent/receipts"; +import { withdrawConsent } from "@/lib/consent/withdrawal"; import { serialiseReceipt } from "../route"; export const GET = apiHandler(async (request: NextRequest) => { @@ -81,6 +86,9 @@ export const GET = apiHandler(async (request: NextRequest) => { ? serialiseReceipt(byKind.ai_insights_only) : null, ai_coach: byKind.ai_coach ? serialiseReceipt(byKind.ai_coach) : null, + ai_extraction: byKind.ai_extraction + ? serialiseReceipt(byKind.ai_extraction) + : null, }; annotate({ @@ -119,53 +127,50 @@ export const DELETE = apiHandler(async (request: NextRequest) => { const { kind } = parsed.data; - if (kind) { - const revoked = await revokeLatest(user.id, kind); - if (!revoked) { - // Idempotent: "no active receipt to revoke" is success-shaped. - // The iOS toggle hammers this on every flip; returning 404 - // would surface as a spurious error in the client. - annotate({ - action: { name: "consent.ai.revoke" }, - meta: { kind, revoked: false }, - }); - return apiSuccess({ kind, receipt: null }); - } + // One kind, or every kind for the master "turn AI off" toggle. The revoke + // and the purge of the regenerable text the consent covered commit + // together (`withdrawConsent`). + const kinds = kind ? [kind] : consentKindEnum.options; + const { revoked, purged } = await withdrawConsent(user.id, kinds); + + for (const { kind: k, receipt } of revoked) { auditLog("consent.ai.revoke", { userId: user.id, - details: { kind, receiptId: revoked.id }, + details: { kind: k, receiptId: receipt.id }, }).catch(() => {}); - annotate({ - action: { name: "consent.ai.revoke" }, - meta: { kind, receiptId: revoked.id }, - }); - return apiSuccess({ kind, receipt: serialiseReceipt(revoked) }); } - - // No kind specified — master "AI deaktivieren" toggle. Revoke the - // latest active row for every kind that currently has one. - const revokedRows: Array<{ - kind: ConsentKind; - receipt: ReturnType; - }> = []; - for (const k of consentKindEnum.options) { - const r = await revokeLatest(user.id, k); - if (r) { - revokedRows.push({ kind: k, receipt: serialiseReceipt(r) }); - auditLog("consent.ai.revoke", { - userId: user.id, - details: { kind: k, receiptId: r.id }, - }).catch(() => {}); - } + if (purged) { + // Counts only, never content. + auditLog("consent.ai.purge", { + userId: user.id, + details: { ...purged }, + }).catch(() => {}); } annotate({ action: { name: "consent.ai.revoke" }, meta: { - kinds_revoked: revokedRows.map((r) => r.kind), - count: revokedRows.length, + kinds_revoked: revoked.map((r) => r.kind), + count: revoked.length, + purged: purged !== null, }, }); - return apiSuccess({ revoked: revokedRows }); + if (kind) { + // Idempotent: "no active receipt to revoke" is success-shaped. The iOS + // toggle hammers this on every flip; returning 404 would surface as a + // spurious error in the client. + const receipt = revoked[0]?.receipt ?? null; + return apiSuccess({ + kind, + receipt: receipt ? serialiseReceipt(receipt) : null, + }); + } + + return apiSuccess({ + revoked: revoked.map((r) => ({ + kind: r.kind, + receipt: serialiseReceipt(r.receipt), + })), + }); }); diff --git a/src/lib/ai/__tests__/consent-guard.test.ts b/src/lib/ai/__tests__/consent-guard.test.ts index a7e5a2e0f..57fcf0495 100644 --- a/src/lib/ai/__tests__/consent-guard.test.ts +++ b/src/lib/ai/__tests__/consent-guard.test.ts @@ -3,25 +3,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/consent/receipts", () => ({ latestActiveReceipt: vi.fn(), })); -vi.mock("@/lib/documents/document-settings", () => ({ - documentAutoReadEnabled: vi.fn().mockResolvedValue(false), -})); import { ConsentRequiredError, assertConsentForChain, - assertDocumentEgressConsent, 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 { @@ -203,103 +197,20 @@ 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", - }), - ).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", - }), - ).rejects.toBeInstanceOf(ConsentRequiredError); - }); - - it("requires a receipt for BYOK document egress too (openai / anthropic)", async () => { - grant([]); - for (const p of ["openai", "anthropic", "admin-openai"]) { - await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: p, - surface: "insights", - }), - ).rejects.toBeInstanceOf(ConsentRequiredError); - } - }); - - it("proceeds for codex WITH an active document-class receipt", async () => { - grant(["ai_insights_only"]); - await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: "codex", - surface: "insights", - }), - ).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"]) { - await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: p, - surface: "insights", - }), - ).resolves.toBeUndefined(); - } - // The toggle short-circuits BEFORE the receipt read. - expect(mockedLatest).not.toHaveBeenCalled(); - }); - - it("STILL requires a receipt for an external pick when documentsAutoAiRead is OFF", async () => { - grant([]); - mockedAutoRead.mockResolvedValue(false); - await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: "codex", - surface: "insights", - }), - ).rejects.toBeInstanceOf(ConsentRequiredError); - }); - - it("does NOT consult the toggle for a LOCAL pick even when it would be ON", async () => { - grant([]); - mockedAutoRead.mockResolvedValue(true); - await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: "local", - surface: "insights", - }), - ).resolves.toBeUndefined(); - expect(mockedAutoRead).not.toHaveBeenCalled(); +// The document-class receipt (`ai_extraction` or `ai_full`) is read by the +// capability egress re-check, not here: see +// `src/lib/ai/capabilities/__tests__/egress.test.ts` and +// `tests/integration/consent-withdrawal-purge.test.ts`. What this module still +// owes the document surfaces is the local/external split above, and that the +// extraction kind satisfies neither chain surface. +describe("an extraction receipt does not satisfy the chain surfaces", () => { + it("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, + ); }); }); diff --git a/src/lib/ai/capabilities/gate.ts b/src/lib/ai/capabilities/gate.ts index 76fc70463..6a70b43f4 100644 --- a/src/lib/ai/capabilities/gate.ts +++ b/src/lib/ai/capabilities/gate.ts @@ -166,3 +166,22 @@ export async function aiCapabilityForJob( return resolveAiCapability(key, null); } } + +/** + * One capability for one record, from wherever the caller runs. Inside an + * authenticated request it is the request's view (masked to the active grant, + * memoised with every other capability read of the request); outside one it + * is the worker's view under the worker's own authority. + * + * For code that serves stored model text on both paths: a status note read on + * a page visit and the same read inside the nightly batch must answer the same + * question without the caller knowing which path it is on. + */ +export async function aiCapabilityForRecord( + recordId: string, + key: AiCapabilityKey, +): Promise { + return getEvent()?.getAuth()?.user_id + ? getAiCapability(key, { recordId }) + : aiCapabilityForJob(recordId, key); +} 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/coach/conversation-summary.ts b/src/lib/ai/coach/conversation-summary.ts index 41d7306b5..9bdc03b4a 100644 --- a/src/lib/ai/coach/conversation-summary.ts +++ b/src/lib/ai/coach/conversation-summary.ts @@ -193,7 +193,7 @@ export async function refreshConversationSummary( const completion: StatusProviderResult = await runCompletion({ userId, cacheAction: "coach.summary", - consentSurface: "coach", + capability: "coach", systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN, userPrompt: buildSummaryUserPrompt(priorSummary, foldedTurns, locale), temperature: 0.3, diff --git a/src/lib/ai/coach/facts.ts b/src/lib/ai/coach/facts.ts index 8d3921c19..c3fb74daa 100644 --- a/src/lib/ai/coach/facts.ts +++ b/src/lib/ai/coach/facts.ts @@ -310,7 +310,7 @@ export async function extractAndStoreFacts( const result = await runCompletion({ userId, cacheAction: "coach.facts", - consentSurface: "coach", + capability: "coach", systemPrompt: extractionSystemPrompt(opts?.locale), userPrompt: buildUserPrompt(turns, activeTexts), temperature: 0.2, diff --git a/src/lib/ai/coach/plans.ts b/src/lib/ai/coach/plans.ts index cade32d18..d418a587f 100644 --- a/src/lib/ai/coach/plans.ts +++ b/src/lib/ai/coach/plans.ts @@ -335,7 +335,7 @@ export async function extractAndStorePlanProposals( const result = await runCompletion({ userId, cacheAction: "coach.plans", - consentSurface: "coach", + capability: "coach", systemPrompt: extractionSystemPrompt(opts?.locale), userPrompt: buildUserPrompt(turns, existingSignatures), temperature: 0.2, diff --git a/src/lib/ai/consent-guard.ts b/src/lib/ai/consent-guard.ts index 213c5a37c..7774e836d 100644 --- a/src/lib/ai/consent-guard.ts +++ b/src/lib/ai/consent-guard.ts @@ -28,13 +28,14 @@ * 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 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 two AI surfaces that egress PHI through a chain. Each maps to the consent + * kind the iOS client collects for it; `ai_full` (the master grant) satisfies + * either. Reading documents has its own kind (`ai_extraction`), checked by the + * capability egress re-check rather than here. */ export type ConsentSurface = "coach" | "insights"; @@ -141,7 +142,11 @@ export async function assertConsentForChain(args: { * AI routes call the single resolved provider directly (no runner cascade), so * the exact egress is the picked provider. A `local` pick never leaves the * machine and stays ungated; EVERY external pick (codex, BYOK openai/anthropic, - * the operator's admin key) requires an active document-class consent receipt. + * the operator's admin key) requires an active extraction receipt + * (`ai_extraction` or `ai_full`). The check itself runs through the capability + * egress re-check (`aiEgressRefusal` in `@/lib/ai/capabilities/egress`), which + * reads the receipt kinds from the capability table; the auto-read toggle is a + * trigger, never the consent, so a revoked receipt wins over a toggle left on. */ const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet = new Set(["local"]); @@ -153,33 +158,3 @@ const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet = new Set(["local"]); export function isExternalDocumentEgress(providerType: string): boolean { return !LOCAL_ONLY_PROVIDER_TYPES.has(providerType); } - -/** - * 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. - * - * 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. - * - * Call this AFTER the document provider is picked and BEFORE the first - * `generateCompletion` on it. - */ -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); -} diff --git a/src/lib/consent/__tests__/receipts.test.ts b/src/lib/consent/__tests__/receipts.test.ts index b48c0e239..873b551ee 100644 --- a/src/lib/consent/__tests__/receipts.test.ts +++ b/src/lib/consent/__tests__/receipts.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -// `createReceipt` + `revokeLatest` run their supersede/revoke logic inside -// a transaction. Run the callback against the same mock proxy. The Prisma +// `createReceipt` runs its supersede logic inside a transaction. Run the callback against the same mock proxy. The Prisma // `$transaction` signature is generic; the mock takes a loose callback. type TxFn = (tx: unknown) => unknown; @@ -26,7 +25,6 @@ import { createReceipt, latestActiveReceipt, latestActiveReceiptsByKind, - revokeLatest, } from "../receipts"; const $transaction = vi.mocked(prisma.$transaction) as unknown as { @@ -196,39 +194,3 @@ describe("latestActiveReceiptsByKind", () => { expect(result.ai_insights_only).toBeUndefined(); }); }); - -describe("revokeLatest", () => { - it("atomically revokes the active row via updateMany and returns it", async () => { - const now = new Date("2026-05-18T11:00:00.000Z"); - vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ - count: 1, - } as never); - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue( - row({ revokedAt: now }) as never, - ); - - const result = await revokeLatest("user-1", "ai_full", now); - - expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({ - where: { userId: "user-1", kind: "ai_full", revokedAt: null }, - data: { revokedAt: now }, - }); - // Re-reads the just-revoked row so the audit log keeps the receipt id. - expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({ - where: { userId: "user-1", kind: "ai_full", revokedAt: now }, - orderBy: { createdAt: "desc" }, - }); - expect(result?.revokedAt).toEqual(now); - }); - - it("returns null without re-reading when no active receipt exists", async () => { - vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({ - count: 0, - } as never); - - const result = await revokeLatest("user-1", "ai_full"); - - expect(result).toBeNull(); - expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/consent/__tests__/withdrawal.test.ts b/src/lib/consent/__tests__/withdrawal.test.ts new file mode 100644 index 000000000..5d19ba9dc --- /dev/null +++ b/src/lib/consent/__tests__/withdrawal.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// The revoke and the purge run inside one transaction; the mock runs the +// callback against the same proxy. Atomicity against a real database is +// pinned by `tests/integration/consent-withdrawal-purge.test.ts`. +type TxFn = (tx: unknown) => unknown; + +vi.mock("@/lib/db", () => { + const consentReceipt = { + updateMany: vi.fn(), + findFirst: vi.fn(), + count: vi.fn(), + }; + return { + prisma: { + consentReceipt, + $transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })), + }, + }; +}); + +import { prisma } from "@/lib/db"; +import { withdrawConsent, type AiTextPurgeCounts } from "../withdrawal"; + +const COUNTS: AiTextPurgeCounts = { + statusNotes: 3, + briefing: 1, + narratives: 2, + reactionLines: 1, + workoutParagraphs: 1, +}; + +const now = new Date("2026-09-20T08:00:00.000Z"); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.$transaction).mockImplementation(((fn: TxFn) => + fn({ consentReceipt: prisma.consentReceipt })) as never); +}); + +/** Receipts active before the call, by kind. */ +function active(kinds: string[]) { + const live = new Set(kinds); + vi.mocked(prisma.consentReceipt.updateMany).mockImplementation((async (args: { + where: { kind: string }; + }) => { + const had = live.delete(args.where.kind); + return { count: had ? 1 : 0 }; + }) as never); + vi.mocked(prisma.consentReceipt.findFirst).mockImplementation((async (args: { + where: { kind: string }; + }) => ({ id: `r-${args.where.kind}`, kind: args.where.kind })) as never); + vi.mocked(prisma.consentReceipt.count).mockImplementation( + (async (args: { where: { kind: { in: string[] } } }) => + args.where.kind.in.filter((k) => live.has(k)).length) as never, + ); +} + +describe("withdrawConsent", () => { + it("revokes each active kind conditionally and re-reads it for the audit trail", async () => { + active(["ai_coach"]); + const purge = vi.fn(); + const result = await withdrawConsent("u1", ["ai_coach"], now, purge); + expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({ + where: { userId: "u1", kind: "ai_coach", revokedAt: null }, + data: { revokedAt: now }, + }); + expect(result.revoked.map((r) => r.kind)).toEqual(["ai_coach"]); + }); + + it("purges when the revoke leaves no receipt covering the analysis", async () => { + active(["ai_full"]); + const purge = vi.fn(async () => COUNTS); + const result = await withdrawConsent("u1", ["ai_full"], now, purge); + expect(purge).toHaveBeenCalledTimes(1); + expect(result.purged).toEqual(COUNTS); + }); + + it("keeps the text while another receipt still covers the analysis", async () => { + active(["ai_full", "ai_insights_only"]); + const purge = vi.fn(async () => COUNTS); + const result = await withdrawConsent("u1", ["ai_full"], now, purge); + expect(purge).not.toHaveBeenCalled(); + expect(result.purged).toBeNull(); + }); + + it("never purges for a revoke that did not touch the analysis (Coach or extraction only)", async () => { + active(["ai_coach", "ai_extraction"]); + const purge = vi.fn(async () => COUNTS); + const result = await withdrawConsent( + "u1", + ["ai_coach", "ai_extraction"], + now, + purge, + ); + expect(purge).not.toHaveBeenCalled(); + expect(result.purged).toBeNull(); + expect(result.revoked).toHaveLength(2); + }); + + it("does nothing, and purges nothing, when no receipt was active", async () => { + active([]); + const purge = vi.fn(async () => COUNTS); + const result = await withdrawConsent("u1", ["ai_full"], now, purge); + expect(result).toEqual({ revoked: [], purged: null }); + expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled(); + expect(purge).not.toHaveBeenCalled(); + }); + + it("propagates a purge failure out of the transaction (the revoke rolls back with it)", async () => { + active(["ai_insights_only"]); + const purge = vi.fn(async () => { + throw new Error("purge failed"); + }); + await expect( + withdrawConsent("u1", ["ai_insights_only"], now, purge), + ).rejects.toThrow("purge failed"); + }); +}); diff --git a/src/lib/consent/receipts.ts b/src/lib/consent/receipts.ts index 95c93abde..e9c6827a6 100644 --- a/src/lib/consent/receipts.ts +++ b/src/lib/consent/receipts.ts @@ -2,10 +2,10 @@ * v1.4.40 SB-10 — AI consent receipts CRUD helper. * * Append-only audit trail. Every grant + revoke mints a fresh row; - * no updates, no deletes outside of cascade-on-user-delete. The - * `revokeLatest` helper writes the current row's `revokedAt` and - * returns it — that single field flip is the only mutation the - * helper performs. A subsequent re-grant inserts a new row, so the + * no updates, no deletes outside of cascade-on-user-delete. A + * revocation (`withdrawConsent` in `./withdrawal`) writes the current + * row's `revokedAt` — that single field flip is the only mutation a + * receipt ever sees. A subsequent re-grant inserts a new row, so the * historical chain stays intact for legal review. * * Source of truth for "is AI active right now?" is @@ -19,6 +19,25 @@ import { isP2002 } from "@/lib/prisma-errors"; export type ConsentReceipt = ConsentReceiptModel; +/** + * The consent kind for reading documents, lab report scans and typed + * medication text with an external model. Minted by switching the document + * auto-read on; required (or the master `ai_full`) by every extraction path. + * It does not satisfy the Coach or the analysis. + */ +export const AI_EXTRACTION_CONSENT_KIND = "ai_extraction" satisfies ConsentKind; + +/** + * The kinds whose active receipt authorises model-written analysis over the + * person's own readings (briefing, status notes, narratives, workout + * paragraphs, reaction lines). When none of these is left active, the + * regenerable text those surfaces stored is purged. + */ +export const INSIGHTS_CONSENT_KINDS: readonly ConsentKind[] = [ + "ai_insights_only", + "ai_full", +]; + /** * Insert a fresh consent receipt for (userId, kind). The caller has * already Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601). @@ -119,36 +138,3 @@ export async function latestActiveReceiptsByKind( } return out; } - -/** - * Mark the active receipt for (userId, kind) as revoked. Returns the - * updated row, or `null` if no active receipt exists. - * - * v1.16.16 — the partial unique index guarantees at most one active row - * per (user, kind), so a single atomic `updateMany` over the active - * predicate revokes "the" active receipt without a separate read. Two - * concurrent revokes converge: the first flips `revoked_at`, the second's - * predicate no longer matches and writes nothing. The follow-up read - * returns the row the caller (audit log) needs; it is `null` only when no - * active receipt existed at all. - */ -export async function revokeLatest( - userId: string, - kind: ConsentKind, - now: Date = new Date(), -): Promise { - return prisma.$transaction(async (tx) => { - const updated = await tx.consentReceipt.updateMany({ - where: { userId, kind, revokedAt: null }, - data: { revokedAt: now }, - }); - if (updated.count === 0) return null; - // Re-read the just-revoked row (revoked at exactly `now`) so the audit - // trail keeps the receipt id. The active predicate guaranteed a single - // row, so this resolves it unambiguously. - return tx.consentReceipt.findFirst({ - where: { userId, kind, revokedAt: now }, - orderBy: { createdAt: "desc" }, - }); - }); -} diff --git a/src/lib/consent/withdrawal.ts b/src/lib/consent/withdrawal.ts new file mode 100644 index 000000000..eaf220da3 --- /dev/null +++ b/src/lib/consent/withdrawal.ts @@ -0,0 +1,149 @@ +/** + * Consent withdrawal: revoking receipts, and deleting the model-written text + * that the withdrawn consent covered. + * + * A revocation stops new AI work at once (every capability reads the active + * receipts). What it also owes the person is the text already written under + * that consent. Two kinds of stored text are treated differently: + * + * - Regenerable caches are DELETED, in the same transaction as the revoke, + * when the revoke leaves no active receipt covering the analysis + * (`ai_insights_only` or `ai_full`): the per-metric status notes, the + * cached briefing, model-written period narratives (the deterministic ones + * are data and stay), arrival reaction lines (the markers stay), and the + * workout paragraphs. Nothing of value is lost; granting consent again + * regenerates them. + * - The person's own records stay: Coach conversations, facts and plans, + * document summaries and extracted text. They are hidden from AI surfaces + * while a capability is unavailable, but they remain the person's to read + * and delete. + * + * Revoke and purge commit together or not at all: a purge that fails leaves + * the receipt active, so the person sees the withdrawal fail and can retry, + * rather than a revoked consent with its text still stored. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { DETERMINISTIC_PROVIDER_TYPE } from "@/lib/insights/narrative/period-narrative-deterministic"; +import type { ConsentKind } from "@/lib/validations/consent"; + +import { INSIGHTS_CONSENT_KINDS, type ConsentReceipt } from "./receipts"; + +/** How many stored items one withdrawal deleted, per surface. */ +export interface AiTextPurgeCounts { + statusNotes: number; + /** 1 when a cached briefing was cleared, else 0. */ + briefing: number; + narratives: number; + reactionLines: number; + workoutParagraphs: number; +} + +export interface ConsentWithdrawal { + /** The receipts this call revoked, in the order of `kinds`. */ + revoked: Array<{ kind: ConsentKind; receipt: ConsentReceipt }>; + /** What was deleted, or `null` when the withdrawal left the analysis covered. */ + purged: AiTextPurgeCounts | null; +} + +/** + * Delete the regenerable model-written text for one account, inside `tx`. + * Exported for the transaction below and its tests; call it through + * `withdrawConsent`, never on its own. + */ +export async function purgeRegenerableAiText( + tx: Prisma.TransactionClient, + userId: string, +): Promise { + const [statusNotes, briefing, narratives, reactionLines, workoutParagraphs] = + await Promise.all([ + tx.insightStatusCache.deleteMany({ where: { userId } }), + tx.user.updateMany({ + where: { + id: userId, + OR: [ + { insightsCachedText: { not: null } }, + { insightsCachedAt: { not: null } }, + { insightsSnapshotHash: { not: null } }, + ], + }, + data: { + insightsCachedText: null, + insightsCachedAt: null, + insightsSnapshotHash: null, + }, + }), + tx.insightNarrative.deleteMany({ + where: { + userId, + OR: [ + { providerType: null }, + { providerType: { not: DETERMINISTIC_PROVIDER_TYPE } }, + ], + }, + }), + // The marker is data (it drives the "just in" chip); only the sentence + // goes. `generatedAt` stays, so the worker does not write it again. + tx.arrivalReaction.updateMany({ + where: { userId, lineEncrypted: { not: null } }, + data: { lineEncrypted: null }, + }), + tx.workoutInsight.deleteMany({ where: { userId } }), + ]); + return { + statusNotes: statusNotes.count, + briefing: briefing.count, + narratives: narratives.count, + reactionLines: reactionLines.count, + workoutParagraphs: workoutParagraphs.count, + }; +} + +/** + * Revoke the active receipt of each of `kinds`, and purge the regenerable + * text when that leaves the analysis without a covering receipt. One + * transaction. The purge runs only when this call revoked a receipt that + * covered the analysis; revoking the Coach consent alone never deletes a + * status note. + */ +export async function withdrawConsent( + userId: string, + kinds: readonly ConsentKind[], + now: Date = new Date(), + purge: typeof purgeRegenerableAiText = purgeRegenerableAiText, +): Promise { + return prisma.$transaction(async (tx) => { + const revoked: ConsentWithdrawal["revoked"] = []; + for (const kind of kinds) { + // The partial unique index keeps at most one active row per (user, + // kind), so one conditional update revokes "the" active receipt; a + // concurrent revoke finds nothing left to match and writes nothing. + const updated = await tx.consentReceipt.updateMany({ + where: { userId, kind, revokedAt: null }, + data: { revokedAt: now }, + }); + if (updated.count === 0) continue; + const receipt = await tx.consentReceipt.findFirst({ + where: { userId, kind, revokedAt: now }, + orderBy: { createdAt: "desc" }, + }); + if (receipt) revoked.push({ kind, receipt }); + } + + const touchedAnalysis = revoked.some(({ kind }) => + INSIGHTS_CONSENT_KINDS.includes(kind), + ); + if (!touchedAnalysis) return { revoked, purged: null }; + + const stillCovered = await tx.consentReceipt.count({ + where: { + userId, + revokedAt: null, + kind: { in: [...INSIGHTS_CONSENT_KINDS] }, + }, + }); + if (stillCovered > 0) return { revoked, purged: null }; + + return { revoked, purged: await purge(tx, userId) }; + }); +} diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 1133fcf8b..c9ce41df9 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -232,6 +232,12 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // ───── Insight narratives (Bytes column) ───── { model: "InsightNarrative", field: "encryptedContent", kind: "bytes" }, + // ───── v1.39.0 status notes (Bytes columns) ───── + // Both nullable: a row can carry only a negative-cache window, and only the + // medication-compliance card stores per-item lines. + { model: "InsightStatusCache", field: "textEncrypted", kind: "bytes" }, + { model: "InsightStatusCache", field: "itemsEncrypted", kind: "bytes" }, + // ───── v1.31.0 data-arrival spine (Bytes column) ───── // Nullable: the reaction marker is written on every salient arrival, but the // generated line only exists where a provider was reachable and in budget. diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index a7e14b030..79d68db23 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -21,6 +21,7 @@ import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; import { userDayKey } from "@/lib/tz/format"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; @@ -305,13 +306,16 @@ function toCoachPlanCandidate(row: CoachPlanRow): DailyDigestCoachPlan { * marker itself — which is what actually drives the "just in" chip — survives * a lost sentence intact. Same discipline as `toCoachPlanCandidate` above. */ -function toDigestArrival(row: { - kind: string; - occurredAt: Date; - arrivedAt: Date; - lineEncrypted: Uint8Array | null; - generatedAt: Date | null; -}): DailyDigestArrival | null { +function toDigestArrival( + row: { + kind: string; + occurredAt: Date; + arrivedAt: Date; + lineEncrypted: Uint8Array | null; + generatedAt: Date | null; + }, + linesServable: boolean, +): DailyDigestArrival | null { // A kind this build does not know about (a row written by a newer version) // is dropped rather than widened — the DTO's kind union is closed. if (!isArrivalKind(row.kind)) return null; @@ -319,7 +323,9 @@ function toDigestArrival(row: { let line: string | null = null; // The ciphertext rides only once the generation actually COMMITTED. A row // mid-generation carries no `generatedAt`, and its line must not surface. - if (row.generatedAt !== null && row.lineEncrypted !== null) { + // A model-written line is never served while `reactionLines` is + // unavailable for the record, whatever the reason; the marker still is. + if (linesServable && row.generatedAt !== null && row.lineEncrypted !== null) { try { line = decryptFromBytes(row.lineEncrypted); } catch { @@ -366,6 +372,7 @@ export async function loadDailyDigest( ); const [ + linesServable, { body: snapshot, locale }, modules, syncRows, @@ -375,6 +382,9 @@ export async function loadDailyDigest( arrivalRows, nextVisitRow, ] = await Promise.all([ + aiCapabilityForRecord(user.id, "reactionLines").then( + (capability) => capability.available, + ), readDashboardSnapshotCached(user, undefined, { locale: options.locale }), resolveModuleMap(user.id), prisma.integrationStatus.findMany({ @@ -637,7 +647,7 @@ export async function loadDailyDigest( todayLocalDate, dismissedItemKeys, arrivals: arrivalRows - .map(toDigestArrival) + .map((row) => toDigestArrival(row, linesServable)) .filter((a): a is DailyDigestArrival => a !== null), }, t, diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 015bdb295..27209034d 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -157,6 +157,7 @@ export const WIPE_MODELS = [ "CoachReminder", "CoachUsage", "InsightNarrative", + "InsightStatusCache", "ProviderHealth", // ── Sharing ───────────────────────────────────────────────────────────── diff --git a/src/lib/documents/document-settings.ts b/src/lib/documents/document-settings.ts index 348bd504f..e07aeafd5 100644 --- a/src/lib/documents/document-settings.ts +++ b/src/lib/documents/document-settings.ts @@ -6,9 +6,10 @@ * uploaded documents. OFF by default: the vault stays local-first and every * external egress needs an explicit per-document action + consent receipt. * - * Two gates read this flag: - * - the document consent gate (`assertDocumentEgressConsent`) short-circuits an - * external pick when it is ON (the toggle IS the standing consent); + * It is a trigger, not a consent. Switching it on mints an `ai_extraction` + * receipt, and that receipt is what the document egress re-check reads, so a + * revoked receipt stops external reading even with the flag still on. Readers: + * - the summary job and its catch-up pass run only while it is ON; * - the auto-index-on-upload job stays strictly local when it is OFF and uses * the document-order external pick when it is ON. */ diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index e650fb705..a9c0c5daa 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -582,6 +582,8 @@ export const DERIVED_MODELS: Readonly> = { "A concurrency claim guarding the generator above. Meaningless outside the run that took it.", InsightNarrative: "Generated prose over measurements. Same reasoning as WorkoutInsight — the record restores, the essay about it does not.", + InsightStatusCache: + "Regenerable cache: the per-metric status notes a model wrote over measurements, rewritten by the nightly run. Same reasoning as InsightNarrative.", DocumentContentIndex: "A search index over `InboundDocument` text; rebuilt by the indexer.", DocumentThumbnail: diff --git a/src/lib/insights/__tests__/biomarker-status.test.ts b/src/lib/insights/__tests__/biomarker-status.test.ts index da9f3c7f7..70c8acdef 100644 --- a/src/lib/insights/__tests__/biomarker-status.test.ts +++ b/src/lib/insights/__tests__/biomarker-status.test.ts @@ -4,7 +4,11 @@ vi.mock("@/lib/db", () => ({ prisma: { biomarker: { findFirst: vi.fn() }, labResult: { findMany: vi.fn(), count: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, // v1.30.3 (QA F5) — `resolveUserTimezone` reads this directly. user: { findUnique: vi.fn() }, }, @@ -12,8 +16,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); import { prisma } from "@/lib/db"; @@ -26,6 +43,7 @@ import { biomarkerStatusScope, } from "../biomarker-status"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; +import { noteRow, writtenNotes } from "./status-note-fixtures"; const MARKER = { id: "bm-1", @@ -74,10 +92,12 @@ function inputHashFor(reading: { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(MARKER as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date("2026-06-20T09:00:00.000Z"), - } as never); // Default: no stored timezone → resolveUserTimezone falls to the // Europe/Berlin server default, matching every other fixture's assumption. vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); @@ -85,7 +105,6 @@ beforeEach(() => { describe("generateBiomarkerStatus — empty-data guard", () => { it("returns insufficient WITHOUT calling the provider for a marker with no numeric readings", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); const result = await generateBiomarkerStatus({ @@ -119,15 +138,13 @@ describe("generateBiomarkerStatus — empty-data guard", () => { describe("generateBiomarkerStatus — cache read", () => { it("serves today's cached text without calling the provider", async () => { const todayKey = toBerlinDayKey(new Date()); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: new Date(), - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", text: "Your LDL is steady.", - model: "x", - }), - } as never); + generatedAt: new Date(), + }) as never, + ); const result = await generateBiomarkerStatus({ biomarkerId: MARKER.id, @@ -146,7 +163,6 @@ describe("generateBiomarkerStatus — cache read", () => { describe("generateBiomarkerStatus — generation path", () => { it("builds a snapshot, runs the completion, and persists with an inputHash", async () => { const reading = { id: "r1", value: 95, takenAt: TAKEN_AT }; - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never); vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); @@ -175,13 +191,9 @@ describe("generateBiomarkerStatus — generation path", () => { expect(snapshot.latest.value).toBe(95); // Persisted under the biomarker scope cache action, carrying the inputHash. - const createCall = vi - .mocked(prisma.auditLog.create) - .mock.calls.at(-1)![0] as { - data: { action: string; details: string }; - }; - expect(createCall.data.action).toBe("insights.biomarker:bm-1-status.en"); - const persisted = JSON.parse(createCall.data.details); + const persisted = writtenNotes(prisma.insightStatusCache.upsert).at(-1)!; + expect(persisted.metric).toBe("biomarker:bm-1"); + expect(persisted.locale).toBe("en"); expect(persisted.inputHash).toBe(inputHashFor(reading)); }); }); @@ -200,7 +212,6 @@ describe("generateBiomarkerStatus — prompt-injection fence", () => { unit: hostileUnit, } as never); const reading = { id: "r1", value: 95, takenAt: TAKEN_AT }; - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never); vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); @@ -253,7 +264,6 @@ describe("generateBiomarkerStatus — per-user tz (QA F5)", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ timezone: "America/New_York", } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never); vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); @@ -279,16 +289,14 @@ describe("generateBiomarkerStatus — input gate (regenerate only on a new readi // A prior assessment from an earlier day whose inputHash matches the // current latest reading — readFreshStatusText misses on the day key, but // the input gate matches the fingerprint. - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: new Date(), - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2000-01-01", - locale: "en", text: "Stable LDL from a previous reading.", - model: "x", + generatedAt: new Date(), inputHash: inputHashFor(reading), - }), - } as never); + }) as never, + ); vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never); vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); @@ -312,16 +320,14 @@ describe("generateBiomarkerStatus — input gate (regenerate only on a new readi }; // The cached row's inputHash is for the OLD reading; the live latest is the // new reading, so the fingerprint differs and the gate misses. - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: new Date(), - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2000-01-01", - locale: "en", text: "Stale assessment.", - model: "x", + generatedAt: new Date(), inputHash: inputHashFor(oldReading), - }), - } as never); + }) as never, + ); vi.mocked(prisma.labResult.findMany).mockResolvedValue([ newReading, ] as never); diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index ce3d9f7a0..7e9ef8e87 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -11,7 +11,11 @@ vi.mock("@/lib/measurements/day-aggregates", async () => ({ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, measurement: { findMany: vi.fn() }, measurementRollup: { findMany: vi.fn() }, medication: { findMany: vi.fn() }, @@ -22,8 +26,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -39,6 +56,7 @@ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { getMedicationCategories } from "@/lib/medication-category"; import { generateBloodPressureStatusForUser } from "../blood-pressure-status"; +import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -62,6 +80,11 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); vi.mocked(getMedicationCategories).mockResolvedValue({}); // Cold rollup tier: the BP channels fold monthly/yearly from the // full-history `measurement.findMany` fallback on a tier miss. @@ -93,16 +116,12 @@ describe("generateBloodPressureStatusForUser — graded payload", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -146,7 +165,6 @@ describe("generateBloodPressureStatusForUser — timeout/error never persists", vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: new Date() }, { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: new Date() }, @@ -156,9 +174,6 @@ describe("generateBloodPressureStatusForUser — timeout/error never persists", [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "timeout", @@ -172,23 +187,20 @@ describe("generateBloodPressureStatusForUser — timeout/error never persists", expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); // v1.8.3 — no real assessment persisted (updatedAt stays null above), - // but a short-TTL negative stub IS written so the read-only route does + // but a short-TTL negative window IS opened so the read-only route does // not re-enqueue on every navigation while the provider is degraded. - // The stub is a timeout marker that `readFreshStatusText` rejects. + // The window carries no note, so it can never be served as one. await Promise.resolve(); - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ); - expect(details.timeout === true || details.model === "timeout-stub").toBe( - true, - ); - } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); + const windows = upsertedNotes(prisma.insightStatusCache.upsert); + expect(windows).toHaveLength(1); + expect(windows[0].retryAt).toBeInstanceOf(Date); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); -describe("generateBloodPressureStatusForUser — cache-read skips a stub", () => { - it("regenerates when the only cached row is a timeout stub", async () => { +describe("generateBloodPressureStatusForUser — a negative window is not a note", () => { + it("regenerates when today's row carries only a negative-cache window", async () => { const now = new Date(); const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", @@ -196,16 +208,15 @@ describe("generateBloodPressureStatusForUser — cache-read skips a stub", () => vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", - text: "Blood pressure assessment fallback…", - model: "timeout-stub", - timeout: true, - }), - } as never); + text: null, + generatedAt: null, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", + }) as never, + ); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now }, { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now }, @@ -215,9 +226,6 @@ describe("generateBloodPressureStatusForUser — cache-read skips a stub", () => [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion('{"summary":"Fresh BP assessment."}'); @@ -236,7 +244,6 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: new Date() }, { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: new Date() }, @@ -246,9 +253,6 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); stubCompletion( '{"summary":"Systolic averaged 132. metric:BLOOD_PRESSURE_SYS Diastolic stable. metric:BLOOD_PRESSURE_DIA"}', @@ -260,12 +264,9 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F expect(result.text).toBeTruthy(); expect(result.text).not.toContain("metric:"); - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBeGreaterThan(0); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { text: string }; - expect(parsed.text).not.toContain("metric:"); + const notes = writtenNotes(prisma.insightStatusCache.upsert); + expect(notes.length).toBeGreaterThan(0); + expect(notes[0].text).not.toContain("metric:"); }); }); @@ -309,7 +310,6 @@ describe("generateBloodPressureStatusForUser — no rate without an expectation" vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now }, { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now }, @@ -322,9 +322,6 @@ describe("generateBloodPressureStatusForUser — no rate without an expectation" [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(getMedicationCategories).mockResolvedValue({ "med-empty": "BLOOD_PRESSURE", "med-real": "BLOOD_PRESSURE", diff --git a/src/lib/insights/__tests__/bmi-status.test.ts b/src/lib/insights/__tests__/bmi-status.test.ts index 9db3e07a5..b133c8df9 100644 --- a/src/lib/insights/__tests__/bmi-status.test.ts +++ b/src/lib/insights/__tests__/bmi-status.test.ts @@ -11,7 +11,11 @@ vi.mock("@/lib/measurements/day-aggregates", async () => ({ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, // v1.18.11 (P6) — the input gate probes salient inputs via groupBy. measurement: { findMany: vi.fn(), groupBy: vi.fn() }, measurementRollup: { findMany: vi.fn() }, @@ -20,8 +24,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -32,6 +49,7 @@ vi.mock("@/lib/insights/memory", () => ({ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generateBmiStatusForUser } from "../bmi-status"; +import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -56,6 +74,11 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); // Cold rollup tier: the BMI graded series scales the WEIGHT tier, which // folds from the full-history `measurement.findMany` fallback on a miss. vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); @@ -79,11 +102,7 @@ describe("generateBmiStatusForUser — graded payload", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: 175, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -127,15 +146,13 @@ describe("generateBmiStatusForUser — per-user tz (QA F5)", () => { } as never); // A cached row keyed to the LA calendar day should be served as a // fresh hit even though it is already "tomorrow" in Berlin. - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: new Date(), - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: laToday, - locale: "en", text: "LA-anchored cached BMI text.", - model: "x", - }), - } as never); + generatedAt: new Date(), + }) as never, + ); const result = await generateBmiStatusForUser("user-la", { locale: "en", @@ -159,13 +176,9 @@ describe("generateBmiStatusForUser — timeout/error never persists", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: 175, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 80, measuredAt: new Date() }, ] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "timeout", @@ -180,25 +193,21 @@ describe("generateBmiStatusForUser — timeout/error never persists", () => { expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); // v1.8.3 — no real assessment persisted (updatedAt stays null above), - // but a short-TTL negative stub IS written so the read-only route does + // but a short-TTL negative window IS opened so the read-only route does // not re-enqueue on every navigation while the provider is degraded. - // The stub is a timeout marker that `readFreshStatusText` rejects. + // The window carries no note, so it can never be served as one. await Promise.resolve(); - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ); - expect(details.timeout === true || details.model === "timeout-stub").toBe( - true, - ); - } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); + const windows = upsertedNotes(prisma.insightStatusCache.upsert); + expect(windows).toHaveLength(1); + expect(windows[0].retryAt).toBeInstanceOf(Date); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); it("serves the fallback without writing a cache row on provider error", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: 175, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 80, measuredAt: new Date() }, ] as never); @@ -211,23 +220,20 @@ describe("generateBmiStatusForUser — timeout/error never persists", () => { expect(result.text).toBeTruthy(); expect(result.updatedAt).toBeNull(); // v1.8.3 — no real assessment persisted (updatedAt stays null above), - // but a short-TTL negative stub IS written so the read-only route does + // but a short-TTL negative window IS opened so the read-only route does // not re-enqueue on every navigation while the provider is degraded. - // The stub is a timeout marker that `readFreshStatusText` rejects. + // The window carries no note, so it can never be served as one. await Promise.resolve(); - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ); - expect(details.timeout === true || details.model === "timeout-stub").toBe( - true, - ); - } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); + const windows = upsertedNotes(prisma.insightStatusCache.upsert); + expect(windows).toHaveLength(1); + expect(windows[0].retryAt).toBeInstanceOf(Date); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); -describe("generateBmiStatusForUser — cache-read skips a stub", () => { - it("regenerates when the only cached row is a timeout stub", async () => { +describe("generateBmiStatusForUser — a negative window is not a note", () => { + it("regenerates when today's row carries only a negative-cache window", async () => { const now = new Date(); const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", @@ -236,22 +242,18 @@ describe("generateBmiStatusForUser — cache-read skips a stub", () => { heightCm: 175, } as never); // The most recent cached row is a stub keyed to today. - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", - text: "Generic stub fallback.", - model: "timeout-stub", - timeout: true, - }), - } as never); + text: null, + generatedAt: null, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", + }) as never, + ); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 80, measuredAt: now }, ] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion('{"summary":"Fresh real assessment."}'); @@ -269,13 +271,9 @@ describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () = vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: 175, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 80, measuredAt: new Date() }, ] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); stubCompletion( '{"summary":"BMI sits in the green band. metric:WEIGHT and steady."}', @@ -285,11 +283,8 @@ describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () = expect(result.text).toBeTruthy(); expect(result.text).not.toContain("metric:"); - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBeGreaterThan(0); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { text: string }; - expect(parsed.text).not.toContain("metric:"); + const notes = writtenNotes(prisma.insightStatusCache.upsert); + expect(notes.length).toBeGreaterThan(0); + expect(notes[0].text).not.toContain("metric:"); }); }); diff --git a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts index 53a38a769..ba835d180 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts @@ -40,6 +40,20 @@ vi.mock("@/lib/db", () => ({ auditLog: { deleteMany: vi.fn() }, }, })); +// The `briefing` capability is available in these fixtures; the refusal +// path has its own test in comprehensive-generate-force.test.ts. +// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal +// arm has its own test in comprehensive-budget-refusal.test.ts. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index 7517e6f66..4bcad810e 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -40,6 +40,20 @@ vi.mock("@/lib/db", () => ({ auditLog: { deleteMany: vi.fn() }, }, })); +// The `briefing` capability is available in these fixtures; the refusal +// path has its own test in comprehensive-generate-force.test.ts. +// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal +// arm has its own test in comprehensive-budget-refusal.test.ts. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts index b000afac4..9ca1ab0b9 100644 --- a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts @@ -25,7 +25,8 @@ const resolveProvider = vi.fn(); const runRawCompletionWithFallback = vi.fn(); const extractFeatures = vi.fn(); const recordBriefingFailure = vi.fn(); -const hasActiveConsentForSurface = vi.fn(); +const hasReceipt = vi.fn(); +const aiEgressRefusal = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { @@ -69,6 +70,15 @@ vi.mock("@/lib/db", () => ({ auditLog: { deleteMany: vi.fn() }, }, })); +// The `briefing` capability is available in these fixtures; the refusal +// path has its own test in comprehensive-generate-force.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), @@ -104,18 +114,16 @@ vi.mock("@/lib/ai/coach/about-me", () => ({ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserInsights: vi.fn(), })); -// Modelled truthfully rather than stubbed open: only an operator-credential -// chain needs a receipt, which is what lets the consent-ordering test below -// assert the real gate rather than a bypass. -vi.mock("@/lib/ai/consent-guard", () => ({ - chainRequiresServerManagedConsent: (chain: { providerType: string }[] = []) => - chain.some((c) => c.providerType.startsWith("admin-")), - hasActiveConsentForSurface: (...a: unknown[]) => - hasActiveConsentForSurface(...a), +// The wire re-check, modelled truthfully rather than stubbed open: only an +// operator-credential entry needs a receipt, which is what lets the +// consent-ordering test below assert the real gate rather than a bypass. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: (...a: unknown[]) => aiEgressRefusal(...a), })); import { generateComprehensiveInsight } from "../comprehensive-generate"; import { OPERATOR_COST_CAP, USER_PLAN_CAP } from "@/lib/ai/coach/budget"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; const VALID = JSON.stringify({ dailyBriefing: { paragraph: "ok" } }); @@ -139,7 +147,13 @@ beforeEach(() => { insightsSnapshotHash: null, insightsBriefingRerollDate: null, }); - hasActiveConsentForSurface.mockResolvedValue(true); + hasReceipt.mockResolvedValue(true); + aiEgressRefusal.mockImplementation( + async (key: never, _userId: string, providerTypes: string[]) => + providerTypes.some((t) => t.startsWith("admin-")) && !(await hasReceipt()) + ? new AiUnavailableError(key, "consent_required") + : null, + ); runRawCompletionWithFallback.mockResolvedValue({ result: { content: VALID, tokensUsed: 900, model: "m" }, workingProvider: { providerType: "admin-openai" }, @@ -215,11 +229,28 @@ describe("generateComprehensiveInsight — daily token ceiling", () => { // Reserving first would bill an operator-key user for a snapshot that is // never sent anywhere, and could lock them out of a tier they are not // even permitted to use. - hasActiveConsentForSurface.mockResolvedValue(false); + hasReceipt.mockResolvedValue(false); const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); expect(outcome).toEqual({ status: "skipped", reason: "no-consent" }); + expect(aiEgressRefusal).toHaveBeenCalledWith("briefing", "u1", [ + "admin-openai", + ]); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + expect(ledgerTotal).toBe(0); + }); + + it("reports any other wire refusal as `skipped: unavailable`, before any spend", async () => { + // A switch the operator turned off after the run was scheduled: not a + // consent question, so not reported as one. + aiEgressRefusal.mockResolvedValue( + new AiUnavailableError("briefing", "operator_disabled"), + ); + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome).toEqual({ status: "skipped", reason: "unavailable" }); expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); expect(ledgerTotal).toBe(0); }); diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 8ab32fb81..eb101bd99 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -24,6 +24,7 @@ const resolveProviderChain = vi.fn(); const resolveProvider = vi.fn(); const runRawCompletionWithFallback = vi.fn(); const extractFeatures = vi.fn(); +const aiCapabilityForRecord = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { @@ -49,6 +50,14 @@ vi.mock("@/lib/db", () => ({ }, }, })); +// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal +// arm has its own test in comprehensive-budget-refusal.test.ts. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), @@ -108,6 +117,68 @@ beforeEach(() => { resolveProvider.mockResolvedValue({ type: "none" }); extractFeatures.mockResolvedValue(FEATURES); userUpdate.mockResolvedValue({}); + aiCapabilityForRecord.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); +}); + +describe("generateComprehensiveInsight — the briefing capability at the wire", () => { + const STALE_USER = { + insightsPrivacyMode: "aggregated", + insightsCachedAt: null, + insightsCachedText: null, + insightsExcludeMetrics: [], + insightsSnapshotHash: null, + }; + + it.each([ + ["no_provider", "no-provider"], + ["consent_required", "no-consent"], + ["user_disabled", "unavailable"], + ["operator_disabled", "unavailable"], + ] as const)( + "skips with %s → %s and never resolves the chain", + async (reason, skipped) => { + findUnique.mockResolvedValue(STALE_USER); + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + + const outcome = await generateComprehensiveInsight("u1", { + locale: "de", + force: true, + }); + + expect(outcome).toEqual({ status: "skipped", reason: skipped }); + expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "briefing"); + expect(resolveProviderChain).not.toHaveBeenCalled(); + expect(resolveProvider).not.toHaveBeenCalled(); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + expect(extractFeatures).not.toHaveBeenCalled(); + }, + ); + + it("still serves a fresh cache without asking the capability (cache short-circuit comes first)", async () => { + findUnique.mockResolvedValue({ + ...STALE_USER, + insightsCachedAt: new Date(Date.now() - 60 * 60 * 1000), + insightsCachedText: "{}", + }); + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "user_disabled", + onDeviceAllowed: false, + }); + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome).toEqual({ status: "cached" }); + expect(aiCapabilityForRecord).not.toHaveBeenCalled(); + }); }); describe("generateComprehensiveInsight — cache short-circuit", () => { diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index f64cfdc86..791557886 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -38,6 +38,20 @@ vi.mock("@/lib/db", () => ({ auditLog: { deleteMany: vi.fn() }, }, })); +// The `briefing` capability is available in these fixtures; the refusal +// path has its own test in comprehensive-generate-force.test.ts. +// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal +// arm has its own test in comprehensive-budget-refusal.test.ts. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), diff --git a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts index 4ee3bc274..92607581e 100644 --- a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts +++ b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts @@ -37,6 +37,20 @@ vi.mock("@/lib/db", () => ({ auditLog: { deleteMany: vi.fn() }, }, })); +// The `briefing` capability is available in these fixtures; the refusal +// path has its own test in comprehensive-generate-force.test.ts. +// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal +// arm has its own test in comprehensive-budget-refusal.test.ts. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), diff --git a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts index 73bc1fc6c..7cc7486a0 100644 --- a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts +++ b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts @@ -42,6 +42,20 @@ vi.mock("@/lib/db", () => ({ auditLog: { deleteMany: vi.fn(), create: vi.fn() }, }, })); +// The `briefing` capability is available in these fixtures; the refusal +// path has its own test in comprehensive-generate-force.test.ts. +// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal +// arm has its own test in comprehensive-budget-refusal.test.ts. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), diff --git a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts index 24ee58f6d..e6f58c204 100644 --- a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts +++ b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts @@ -44,6 +44,20 @@ vi.mock("@/lib/db", () => ({ auditLog: { deleteMany: vi.fn() }, }, })); +// The `briefing` capability is available in these fixtures; the refusal +// path has its own test in comprehensive-generate-force.test.ts. +// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal +// arm has its own test in comprehensive-budget-refusal.test.ts. +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: vi.fn(async () => null), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts index 6d004dfbe..e5fd0f0c8 100644 --- a/src/lib/insights/__tests__/general-status.test.ts +++ b/src/lib/insights/__tests__/general-status.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, measurement: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, @@ -12,8 +16,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -24,6 +41,7 @@ vi.mock("@/lib/insights/memory", () => ({ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generateGeneralStatusForUser } from "../general-status"; +import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -47,6 +65,11 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); }); describe("generateGeneralStatusForUser — graded payload", () => { @@ -69,7 +92,6 @@ describe("generateGeneralStatusForUser — graded payload", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue( weightRecords as never, ); @@ -77,9 +99,6 @@ describe("generateGeneralStatusForUser — graded payload", () => { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -113,7 +132,6 @@ describe("generateGeneralStatusForUser — graded payload", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 80, measuredAt: now }, ] as never); @@ -121,9 +139,6 @@ describe("generateGeneralStatusForUser — graded payload", () => { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -141,7 +156,6 @@ describe("generateGeneralStatusForUser — timeout/error never persists", () => vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 82, measuredAt: new Date() }, ] as never); @@ -149,9 +163,6 @@ describe("generateGeneralStatusForUser — timeout/error never persists", () => [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "timeout", @@ -165,23 +176,20 @@ describe("generateGeneralStatusForUser — timeout/error never persists", () => expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); // v1.8.3 — no real assessment persisted (updatedAt stays null above), - // but a short-TTL negative stub IS written so the read-only route does + // but a short-TTL negative window IS opened so the read-only route does // not re-enqueue on every navigation while the provider is degraded. - // The stub is a timeout marker that `readFreshStatusText` rejects. + // The window carries no note, so it can never be served as one. await Promise.resolve(); - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ); - expect(details.timeout === true || details.model === "timeout-stub").toBe( - true, - ); - } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); + const windows = upsertedNotes(prisma.insightStatusCache.upsert); + expect(windows).toHaveLength(1); + expect(windows[0].retryAt).toBeInstanceOf(Date); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); -describe("generateGeneralStatusForUser — cache-read skips a stub", () => { - it("regenerates when the only cached row is a timeout stub", async () => { +describe("generateGeneralStatusForUser — a negative window is not a note", () => { + it("regenerates when today's row carries only a negative-cache window", async () => { const now = new Date(); const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", @@ -189,16 +197,15 @@ describe("generateGeneralStatusForUser — cache-read skips a stub", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", - text: "General status fallback…", - model: "timeout-stub", - timeout: true, - }), - } as never); + text: null, + generatedAt: null, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", + }) as never, + ); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 82, measuredAt: now }, ] as never); @@ -206,9 +213,6 @@ describe("generateGeneralStatusForUser — cache-read skips a stub", () => { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion('{"summary":"Fresh general assessment."}'); @@ -227,7 +231,6 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 82, measuredAt: new Date() }, ] as never); @@ -235,9 +238,6 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); stubCompletion( '{"summary":"Weight trended down. metric:WEIGHT BP stable. metric:BLOOD_PRESSURE_SYS"}', @@ -249,11 +249,8 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", expect(result.text).toBeTruthy(); expect(result.text).not.toContain("metric:"); - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBeGreaterThan(0); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { text: string }; - expect(parsed.text).not.toContain("metric:"); + const notes = writtenNotes(prisma.insightStatusCache.upsert); + expect(notes.length).toBeGreaterThan(0); + expect(notes[0].text).not.toContain("metric:"); }); }); diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index fed621785..8ed1626a3 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -15,7 +15,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const deleteMany = vi.fn(); -const auditFindMany = vi.fn(); +const noteFindMany = vi.fn(); +const aiCapabilityForRecord = vi.fn(); const enqueueStatusGeneration = vi.fn(); const userFindUnique = vi.fn(); const measurementFindMany = vi.fn(); @@ -23,9 +24,9 @@ const measurementGroupBy = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { + insightStatusCache: { deleteMany: (...a: unknown[]) => deleteMany(...a), - findMany: (...a: unknown[]) => auditFindMany(...a), + findMany: (...a: unknown[]) => noteFindMany(...a), }, user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, measurement: { @@ -41,6 +42,21 @@ vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ enqueueStatusGeneration: (...a: unknown[]) => enqueueStatusGeneration(...a), })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a), + aiCapabilityForJob: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); + +const UNAVAILABLE = { + available: false, + reason: "user_disabled", + onDeviceAllowed: false, +}; + import { enqueueStatusRefillForUser, invalidateStatusInsightsForTypes, @@ -51,7 +67,12 @@ beforeEach(() => { deleteMany.mockResolvedValue({ count: 0 }); // Default: no recently-warmed cache rows, so every dirtied scope is stale // and refreshes — the pre-debounce contract the bulk of these tests pin. - auditFindMany.mockResolvedValue([]); + noteFindMany.mockResolvedValue([]); + aiCapabilityForRecord.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); enqueueStatusGeneration.mockResolvedValue(undefined); userFindUnique.mockResolvedValue({ locale: "de" }); measurementFindMany.mockResolvedValue([]); @@ -138,7 +159,15 @@ describe("invalidateStatusInsightsForTypes", () => { it("is a no-op for an empty type set (no DB call)", async () => { await invalidateStatusInsightsForTypes("u1", []); - expect(auditFindMany).not.toHaveBeenCalled(); + expect(noteFindMany).not.toHaveBeenCalled(); + expect(enqueueStatusGeneration).not.toHaveBeenCalled(); + }); + + it("enqueues nothing while statusText is unavailable for the record", async () => { + aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT", "PULSE"]); + expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "statusText"); + expect(noteFindMany).not.toHaveBeenCalled(); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); @@ -201,18 +230,15 @@ describe("invalidateStatusInsightsForTypes", () => { }); describe("ingest-invalidation debounce (v1.9.0; a 1 h minimum gap since v1.16.8)", () => { - /** Build a recent (within-window) real assessment cache row. */ + /** A recent (within-window) stored note, as the probe selects it. */ function freshRow(scope: string, locale = "de") { - return { - action: `insights.${scope}-status.${locale}`, - details: JSON.stringify({ text: "fresh assessment", model: "gpt" }), - }; + return { metric: scope, locale }; } it("skips a scope whose assessment was warmed within the gap", async () => { // `general` was regenerated within the gap; a fresh WEIGHT sample // must not re-enqueue it. weight + bmi are still stale, so they refresh. - auditFindMany.mockResolvedValue([freshRow("general")]); + noteFindMany.mockResolvedValue([freshRow("general")]); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); expect(enqueuedScopes()).toEqual(["bmi", "weight"]); }); @@ -224,13 +250,13 @@ describe("invalidateStatusInsightsForTypes", () => { // hash gate, which makes a no-change run a free timestamp refresh — // this is what lets same-day data be narrated same-day without // reopening the per-batch regeneration storm. - auditFindMany.mockResolvedValue([]); + noteFindMany.mockResolvedValue([]); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); }); it("is a complete no-op (no enqueue) when every scope is fresh", async () => { - auditFindMany.mockResolvedValue([ + noteFindMany.mockResolvedValue([ freshRow("weight"), freshRow("bmi"), freshRow("general"), @@ -239,16 +265,14 @@ describe("invalidateStatusInsightsForTypes", () => { expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); - it("treats a recent timeout stub as not-fresh and still refreshes the scope", async () => { - // A stub carries no real assessment, so a scope that recently stalled - // must retry rather than be debounced into staying cold. - auditFindMany.mockResolvedValue([ - { - action: "insights.general-status.de", - details: JSON.stringify({ model: "timeout-stub", text: "stub" }), - }, - ]); + it("counts only a stored note as fresh, so a recently stalled scope still refreshes", async () => { + // A row that carries only a negative-cache window has no note, so a + // scope that recently stalled must retry rather than be debounced into + // staying cold. The probe asks the database for noted rows only; with + // none returned, every dirtied scope refreshes. await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + const where = noteFindMany.mock.calls[0][0].where; + expect(where.textEncrypted).toEqual({ not: null }); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); }); @@ -256,23 +280,23 @@ describe("invalidateStatusInsightsForTypes", () => { userFindUnique.mockResolvedValue({ locale: "en" }); const before = Date.now(); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); - const where = auditFindMany.mock.calls[0][0].where; + const where = noteFindMany.mock.calls[0][0].where; expect(where.userId).toBe("u1"); - // Only the en cache actions for the dirtied scopes are probed. - expect(new Set(where.action.in)).toEqual( - new Set([ - "insights.weight-status.en", - "insights.bmi-status.en", - "insights.general-status.en", - ]), - ); + // Only the en notes for the dirtied scopes are probed. + expect( + new Set( + (where.OR as { metric: string; locale: string }[]).map( + (k) => `${k.metric}.${k.locale}`, + ), + ), + ).toEqual(new Set(["weight.en", "bmi.en", "general.en"])); // The recency floor is ONE hour in the past. The 6 h wall this // started as kept same-day data from being narrated same-day (the // nightly warm restarted the window, so a morning reading stayed // un-narrated until tomorrow); the 1 h gap only bounds worker-run // frequency, while the worker's content-hash gate keeps an // unchanged re-run at zero LLM calls. - const cutoff = (where.createdAt.gte as Date).getTime(); + const cutoff = (where.generatedAt.gte as Date).getTime(); const oneHour = 60 * 60 * 1000; expect(before - cutoff).toBeGreaterThanOrEqual(oneHour - 5_000); expect(before - cutoff).toBeLessThanOrEqual(oneHour + 60_000); @@ -323,17 +347,21 @@ describe("enqueueStatusRefillForUser", () => { it("bypasses the ingest debounce — an explicit regenerate refills even freshly-warmed scopes", async () => { // Every scope reads as freshly warmed; the refill must NOT consult the // freshness probe at all (the user is explicitly asking). - auditFindMany.mockResolvedValue([ - { - action: "insights.general-status.en", - details: JSON.stringify({ text: "fresh assessment", model: "gpt" }), - }, - ]); + noteFindMany.mockResolvedValue([{ metric: "general", locale: "en" }]); await enqueueStatusRefillForUser("u1", "en"); - expect(auditFindMany).not.toHaveBeenCalled(); + expect(noteFindMany).not.toHaveBeenCalled(); expect(enqueuedScopes()).toContain("general"); }); + it("refills nothing and returns 0 while statusText is unavailable", async () => { + aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); + measurementGroupBy.mockResolvedValue([{ type: "BLOOD_GLUCOSE" }]); + const count = await enqueueStatusRefillForUser("u1", "de"); + expect(count).toBe(0); + expect(enqueueStatusGeneration).not.toHaveBeenCalled(); + expect(measurementGroupBy).not.toHaveBeenCalled(); + }); + it("still refills the specialised scopes when the generic-scope discovery read fails", async () => { measurementGroupBy.mockRejectedValue(new Error("pool exhausted")); const count = await enqueueStatusRefillForUser("u1", "de"); diff --git a/src/lib/insights/__tests__/locale-pipeline.test.ts b/src/lib/insights/__tests__/locale-pipeline.test.ts index 356539dc0..cb8452d11 100644 --- a/src/lib/insights/__tests__/locale-pipeline.test.ts +++ b/src/lib/insights/__tests__/locale-pipeline.test.ts @@ -70,7 +70,11 @@ describe("status cache keys", () => { vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, measurement: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, @@ -79,7 +83,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -104,6 +122,7 @@ vi.mock("@/lib/ai/prompts/general-status", () => ({ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generateGeneralStatusForUser } from "@/lib/insights/general-status"; +import { writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -117,10 +136,6 @@ beforeEach(() => { heightCm: null, insightsExcludeMetrics: [], } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); @@ -166,12 +181,10 @@ describe("a fr locale survives from the generator entry point to the prompt", () it("writes the fr assessment to the fr cache row, leaving the en row untouched", async () => { await generateGeneralStatusForUser("u-fr", { locale: "fr", force: true }); - const actions = vi - .mocked(prisma.auditLog.create) - .mock.calls.map( - (call) => (call[0] as { data: { action: string } }).data.action, - ); - expect(actions).toContain("insights.general-status.fr"); - expect(actions).not.toContain("insights.general-status.en"); + const keys = writtenNotes(prisma.insightStatusCache.upsert).map( + (note) => `${note.metric}.${note.locale}`, + ); + expect(keys).toContain("general.fr"); + expect(keys).not.toContain("general.en"); }); }); diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index e480a3f65..7252318f9 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, }, @@ -10,8 +14,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -28,6 +45,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider"; import { getMedicationCategories } from "@/lib/medication-category"; import { getNoKeyMedicationComplianceStatusText } from "@/lib/insights/no-key-fallbacks"; import { generateMedicationComplianceStatusForUser } from "../medication-compliance-status"; +import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -64,6 +82,11 @@ function medFixture(now: Date) { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); vi.mocked(getMedicationCategories).mockResolvedValue({}); }); @@ -102,16 +125,12 @@ describe("generateMedicationComplianceStatusForUser — graded payload", () => { }); } - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.medication.findMany).mockResolvedValue([ medication, ] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( events as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK","medications":[]}', captured); @@ -140,16 +159,12 @@ describe("generateMedicationComplianceStatusForUser — graded payload", () => { describe("generateMedicationComplianceStatusForUser — timeout never persists", () => { it("serves the fallback summary without writing a cache row on timeout", async () => { const now = new Date(); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.medication.findMany).mockResolvedValue([ medFixture(now), ] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "timeout", @@ -164,46 +179,39 @@ describe("generateMedicationComplianceStatusForUser — timeout never persists", expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); // v1.8.3 — no real assessment persisted (updatedAt stays null above), - // but a short-TTL negative stub IS written so the read-only route does + // but a short-TTL negative window IS opened so the read-only route does // not re-enqueue on every navigation while the provider is degraded. - // The stub is a timeout marker that `readFreshStatusText` rejects. + // The window carries no note, so it can never be served as one. await Promise.resolve(); - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ); - expect(details.timeout === true || details.model === "timeout-stub").toBe( - true, - ); - } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); + const windows = upsertedNotes(prisma.insightStatusCache.upsert); + expect(windows).toHaveLength(1); + expect(windows[0].retryAt).toBeInstanceOf(Date); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); -describe("generateMedicationComplianceStatusForUser — cache-read skips a stub", () => { - it("regenerates when the only cached row is a timeout stub", async () => { +describe("generateMedicationComplianceStatusForUser — a negative window is not a note", () => { + it("regenerates when today's row carries only a negative-cache window", async () => { const now = new Date(); const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", }).format(now); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", - text: "Medication compliance fallback…", - model: "timeout-stub", - timeout: true, - }), - } as never); + text: null, + generatedAt: null, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", + }) as never, + ); vi.mocked(prisma.medication.findMany).mockResolvedValue([ medFixture(now), ] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion( '{"summary":"Fresh compliance assessment.","medications":[]}', @@ -230,16 +238,12 @@ describe("generateMedicationComplianceStatusForUser — outbound screen (v1.32.2 * stays null; the per-medication placeholder rows still render. */ function setupCard(now: Date) { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.medication.findMany).mockResolvedValue([ medFixture(now), ] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); } it("withholds a dose-change imperative instead of rendering the raw prose", async () => { @@ -257,13 +261,11 @@ describe("generateMedicationComplianceStatusForUser — outbound screen (v1.32.2 expect(result.updatedAt).toBeNull(); // The per-medication placeholder rows still surface for the active med. expect(result.medications).toHaveLength(1); - // No cache row ever persisted the screened model text. - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ) as { summary?: string }; - expect(details.summary ?? "").not.toContain("10 mg"); + // No note ever stored the screened model text. + for (const note of upsertedNotes(prisma.insightStatusCache.upsert)) { + expect(note.text ?? "").not.toContain("10 mg"); } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); }); it("withholds a fabricated risk figure instead of rendering the raw prose", async () => { @@ -299,17 +301,12 @@ describe("generateMedicationComplianceStatusForUser — outbound screen (v1.32.2 ); expect(result.updatedAt).not.toBeNull(); expect(result.cached).toBe(false); - // The clean assessment was persisted (not the negative stub). - const persisted = vi - .mocked(prisma.auditLog.create) - .mock.calls.map( - (call) => - JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ) as { summary?: string }, - ) - .find((details) => (details.summary ?? "").includes("96%")); + // The clean assessment was stored as a note (not a negative window). + const persisted = writtenNotes(prisma.insightStatusCache.upsert).find( + (note) => (note.text ?? "").includes("96%"), + ); expect(persisted).toBeTruthy(); + expect(persisted!.negativeReason).toBeNull(); }); }); @@ -317,16 +314,12 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1 it("strips metric: tokens out of the cached summary", async () => { const now = new Date(); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.medication.findMany).mockResolvedValue([ medFixture(now), ] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); // The compliance prompt returns only a `{ summary }` envelope; the // per-medication cards are placeholder text built server-side, never @@ -348,16 +341,11 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1 expect(result.medications).toHaveLength(1); expect(result.medications[0].medicationId).toBe("med-1"); expect(result.medications[0].text).not.toContain("metric:"); - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBeGreaterThan(0); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { - summary: string; - medications: Array<{ text: string }>; - }; - expect(parsed.summary).not.toContain("metric:"); - expect(parsed.medications[0].text).not.toContain("metric:"); + const notes = writtenNotes(prisma.insightStatusCache.upsert); + expect(notes.length).toBeGreaterThan(0); + expect(notes[0].text).not.toContain("metric:"); + const items = notes[0].items as Array<{ text: string }>; + expect(items[0].text).not.toContain("metric:"); }); }); @@ -396,7 +384,6 @@ describe("generateMedicationComplianceStatusForUser — no rate without an expec }; }); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.medication.findMany).mockResolvedValue([ scheduled, scheduleless(now), @@ -404,9 +391,6 @@ describe("generateMedicationComplianceStatusForUser — no rate without an expec vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( events as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK","medications":[]}', captured); @@ -424,7 +408,6 @@ describe("generateMedicationComplianceStatusForUser — no rate without an expec it("reports no active medications when the only one has no schedule", async () => { const now = new Date(); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.medication.findMany).mockResolvedValue([ scheduleless(now), ] as never); diff --git a/src/lib/insights/__tests__/memory.test.ts b/src/lib/insights/__tests__/memory.test.ts index 1b1317078..bca6fcea9 100644 --- a/src/lib/insights/__tests__/memory.test.ts +++ b/src/lib/insights/__tests__/memory.test.ts @@ -2,7 +2,18 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { findFirst: vi.fn() }, + insightStatusCache: { findUnique: vi.fn() }, + }, +})); + +// A reversible stand-in for the at-rest codec; what this file pins is which +// stored note becomes prompt context, not AES-GCM. +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (plain: string) => new TextEncoder().encode(`enc:${plain}`), + decryptFromBytes: (buf: Uint8Array) => { + const text = new TextDecoder().decode(buf); + if (!text.startsWith("enc:")) throw new Error("unknown key id"); + return text.slice(4); }, })); @@ -12,75 +23,90 @@ import { getPreviousInsightContext, } from "../memory"; +function noteRow(text: string | null, generatedAt: Date | null) { + return { + textEncrypted: + text === null ? null : new TextEncoder().encode(`enc:${text}`), + itemsEncrypted: null, + inputHash: null, + snapshotHash: null, + dateKey: "2026-05-01", + generatedAt, + retryAt: null, + negativeReason: null, + }; +} + beforeEach(() => { - vi.mocked(prisma.auditLog.findFirst).mockReset(); + vi.mocked(prisma.insightStatusCache.findUnique).mockReset(); }); describe("getPreviousInsightContext", () => { - it("returns null when no audit row exists", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce(null); + it("returns null when no stored note exists", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce(null); const ctx = await getPreviousInsightContext("u-1", "general-status", "en"); expect(ctx).toBeNull(); }); - it("returns null when the audit row's details cannot be parsed", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({ - createdAt: new Date(), - details: "", - } as never); + it("returns null when the row carries no note (only a negative window)", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow(null, null) as never, + ); const ctx = await getPreviousInsightContext("u-1", "general-status", "en"); expect(ctx).toBeNull(); }); - it("extracts text from the JSON-shaped cache payload", async () => { + it("returns the stored note with its age", async () => { const sevenDaysAgo = new Date(Date.now() - 7 * 86_400_000); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({ - createdAt: sevenDaysAgo, - details: JSON.stringify({ - dateKey: "2026-05-01", - text: "Your BP averaged 130/80 — high-normal.", - }), - } as never); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow("Your BP averaged 130/80 — high-normal.", sevenDaysAgo) as never, + ); const ctx = await getPreviousInsightContext("u-1", "general-status", "en"); expect(ctx).not.toBeNull(); expect(ctx!.ageDays).toBe(7); expect(ctx!.text).toContain("130/80"); - }); - - it("falls back to raw audit details when not JSON", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({ - createdAt: new Date(Date.now() - 86_400_000), - details: "Plain text from a legacy cache row.", - } as never); - const ctx = await getPreviousInsightContext("u-1", "general-status", "en"); - expect(ctx?.text).toBe("Plain text from a legacy cache row."); + expect(ctx!.generatedAt).toBe(sevenDaysAgo.toISOString()); }); it("caps the text length so a verbose snapshot cannot bloat the prompt", async () => { const big = "x".repeat(5000); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({ - createdAt: new Date(Date.now() - 86_400_000), - details: JSON.stringify({ text: big }), - } as never); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow(big, new Date(Date.now() - 86_400_000)) as never, + ); const ctx = await getPreviousInsightContext("u-1", "general-status", "en"); expect(ctx!.text.length).toBeLessThanOrEqual(1502); // 1500 + "…" expect(ctx!.text.endsWith("…")).toBe(true); }); it("filters by minAgeHours so today's earlier run isn't treated as 'previous'", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce(null); - await getPreviousInsightContext("u-1", "general-status", "en", 24); - const call = vi.mocked(prisma.auditLog.findFirst).mock.calls[0]?.[0]; - expect(call?.where?.action).toBe("insights.general-status.en"); - // The lt filter exists with a date in the past. It can't be more - // than 24h ago because we just constructed it; allow a 30s slack. - const lt = (call?.where?.createdAt as { lt: Date }).lt; - expect(Date.now() - lt.getTime()).toBeGreaterThan( - 24 * 60 * 60 * 1000 - 30_000, + // Written 23 h ago: inside a 24 h floor, so not "previous". + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow("Earlier today.", new Date(Date.now() - 23 * 3_600_000)) as never, ); - expect(Date.now() - lt.getTime()).toBeLessThan( - 24 * 60 * 60 * 1000 + 30_000, + expect( + await getPreviousInsightContext("u-1", "general-status", "en", 24), + ).toBeNull(); + const call = vi.mocked(prisma.insightStatusCache.findUnique).mock + .calls[0]?.[0] as { + where: { userId_metric_locale: Record }; + }; + expect(call.where.userId_metric_locale).toEqual({ + userId: "u-1", + metric: "general", + locale: "en", + }); + + // Written 25 h ago: past the floor, so it is the previous analysis. + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow("Yesterday.", new Date(Date.now() - 25 * 3_600_000)) as never, ); + const ctx = await getPreviousInsightContext( + "u-1", + "general-status", + "en", + 24, + ); + expect(ctx?.text).toBe("Yesterday."); }); }); diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 89f68e479..acec22802 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -11,7 +11,11 @@ vi.mock("@/lib/measurements/day-aggregates", async () => ({ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, measurement: { findMany: vi.fn(), count: vi.fn() }, measurementRollup: { findMany: vi.fn() }, }, @@ -19,8 +23,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -60,6 +77,7 @@ import { metricStatusScope, METRIC_STATUS_IDS, } from "../metric-status-registry"; +import { writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -86,6 +104,11 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); // resetAllMocks clears the module-mock default impls too — restore the // benign defaults so the relations fetch and the previous-context format @@ -98,14 +121,10 @@ beforeEach(() => { describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)", () => { it("feeds the snapshot the per-night time-asleep total, not a single stage", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date("2026-06-04T07:00:00.000Z"), - } as never); // One overnight session of granular stages, each row ONE stage (minutes). // Total time asleep = CORE + DEEP + REM = 300 + 90 + 75 = 465 min (7.75 h). @@ -169,14 +188,10 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)" // granular stage + IN_BED + AWAKE into one day bucket (~1490 min ≈ 24.8 h). // Post-fix the graded series is built from the deduped per-night points, so // every recent bucket mean is the night total (480 min = 8 h). - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); // A recent night (last 24 h) so it lands in `graded.recent`. const wake = new Date(Date.now() - 6 * 60 * 60 * 1000); @@ -337,7 +352,6 @@ describe("archetype prompt templates", () => { describe("generateMetricStatus — empty-data guard", () => { it("returns insufficient WITHOUT calling the provider when the metric has no data", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never); const result = await generateMetricStatus({ @@ -366,16 +380,12 @@ describe("generateMetricStatus — generation path", () => { }); } - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.count).mockResolvedValue(400 as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); const captured = { systemPrompt: null, userPrompt: null } as { systemPrompt: string | null; @@ -404,12 +414,9 @@ describe("generateMetricStatus — generation path", () => { expect(snapshot.metric.unit).toBe("bpm"); // Persisted under the generic scope cache action. - const createCall = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { - data: { action: string; details: string }; - }; - expect(createCall.data.action).toBe( - "insights.metric:RESTING_HEART_RATE-status.en", - ); + const [note] = writtenNotes(prisma.insightStatusCache.upsert); + expect(note.metric).toBe("metric:RESTING_HEART_RATE"); + expect(note.locale).toBe("en"); // v1.12.1 — the diversity context (variety lead + explicit data strength) // reaches the user prompt, and the relations fetch is keyed by this @@ -432,16 +439,12 @@ describe("generateMetricStatus — generation path", () => { measuredAt: new Date(now.getTime() - day * dayMs), }); } - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.count).mockResolvedValue(60 as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); vi.mocked(getRelevantCorrelationsForMetric).mockResolvedValueOnce([ { interpretation: @@ -472,7 +475,6 @@ describe("generateMetricStatus — generation path", () => { }); it("strips chart tokens from the persisted text", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.count).mockResolvedValue(3 as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 58, measuredAt: new Date() }, @@ -481,9 +483,6 @@ describe("generateMetricStatus — generation path", () => { dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); stubCompletion( '{"summary":"Steady. metric:RESTING_HEART_RATE Good baseline."}', diff --git a/src/lib/insights/__tests__/mood-status.test.ts b/src/lib/insights/__tests__/mood-status.test.ts index 91dc4e8b0..f0dce0b46 100644 --- a/src/lib/insights/__tests__/mood-status.test.ts +++ b/src/lib/insights/__tests__/mood-status.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, moodEntry: { findMany: vi.fn() }, measurement: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, @@ -11,8 +15,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -23,6 +40,7 @@ vi.mock("@/lib/insights/memory", () => ({ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generateMoodStatusForUser } from "../mood-status"; +import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -46,6 +64,11 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); // The snapshot threads the user's source priority into the factor crosstab; // default to "no custom priority" so every test resolves it without stubbing. vi.mocked(prisma.user.findUnique).mockResolvedValue({ @@ -72,12 +95,8 @@ describe("generateMoodStatusForUser — graded payload", () => { }); } - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue(entries as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -108,7 +127,6 @@ describe("generateMoodStatusForUser — graded payload", () => { describe("generateMoodStatusForUser — timeout/error never persists", () => { it("serves the fallback without writing a cache row on timeout", async () => { const t = new Date(); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ { date: t.toISOString().slice(0, 10), @@ -118,9 +136,6 @@ describe("generateMoodStatusForUser — timeout/error never persists", () => { }, ] as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "timeout", @@ -132,37 +147,33 @@ describe("generateMoodStatusForUser — timeout/error never persists", () => { expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); // v1.8.3 — no real assessment persisted (updatedAt stays null above), - // but a short-TTL negative stub IS written so the read-only route does + // but a short-TTL negative window IS opened so the read-only route does // not re-enqueue on every navigation while the provider is degraded. - // The stub is a timeout marker that `readFreshStatusText` rejects. + // The window carries no note, so it can never be served as one. await Promise.resolve(); - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ); - expect(details.timeout === true || details.model === "timeout-stub").toBe( - true, - ); - } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); + const windows = upsertedNotes(prisma.insightStatusCache.upsert); + expect(windows).toHaveLength(1); + expect(windows[0].retryAt).toBeInstanceOf(Date); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); -describe("generateMoodStatusForUser — cache-read skips a stub", () => { - it("regenerates when the only cached row is a timeout stub", async () => { +describe("generateMoodStatusForUser — a negative window is not a note", () => { + it("regenerates when today's row carries only a negative-cache window", async () => { const now = new Date(); const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", }).format(now); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", - text: "Mood fallback text…", - model: "timeout-stub", - timeout: true, - }), - } as never); + text: null, + generatedAt: null, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", + }) as never, + ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ { date: now.toISOString().slice(0, 10), @@ -172,9 +183,6 @@ describe("generateMoodStatusForUser — cache-read skips a stub", () => { }, ] as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion('{"summary":"Fresh mood assessment."}'); @@ -189,7 +197,6 @@ describe("generateMoodStatusForUser — cache-read skips a stub", () => { describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { const t = new Date(); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ { date: t.toISOString().slice(0, 10), @@ -199,9 +206,6 @@ describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () }, ] as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); stubCompletion('{"summary":"Mood stayed positive. metric:MOOD"}'); @@ -209,11 +213,8 @@ describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () expect(result.text).toBeTruthy(); expect(result.text).not.toContain("metric:"); - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBeGreaterThan(0); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { text: string }; - expect(parsed.text).not.toContain("metric:"); + const notes = writtenNotes(prisma.insightStatusCache.upsert); + expect(notes.length).toBeGreaterThan(0); + expect(notes[0].text).not.toContain("metric:"); }); }); diff --git a/src/lib/insights/__tests__/payload-budget.test.ts b/src/lib/insights/__tests__/payload-budget.test.ts index 36c84eb29..a20504dbd 100644 --- a/src/lib/insights/__tests__/payload-budget.test.ts +++ b/src/lib/insights/__tests__/payload-budget.test.ts @@ -23,7 +23,11 @@ vi.mock("@/lib/measurements/day-aggregates", async () => ({ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, // v1.18.11 (P6) — the slow-mover input gate probes salient inputs. measurement: { findMany: vi.fn(), groupBy: vi.fn() }, measurementRollup: { findMany: vi.fn() }, @@ -35,8 +39,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -76,11 +93,12 @@ function snapshotChars(): number { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); capturedPrompt = null; - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, diff --git a/src/lib/insights/__tests__/pulse-status-timeout.test.ts b/src/lib/insights/__tests__/pulse-status-timeout.test.ts index 7ecb3c025..874b78cec 100644 --- a/src/lib/insights/__tests__/pulse-status-timeout.test.ts +++ b/src/lib/insights/__tests__/pulse-status-timeout.test.ts @@ -12,11 +12,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; * (it is a deterministic, signal-grounded line, not a fresh AI assessment), * and the served text names the user's own value rather than a generic tip. * - * v1.8.3 — the timeout path now writes a *short-TTL negative stub* - * (`{ timeout:true, model:"timeout-stub", retryAt }`). It is explicitly - * rejected by `readFreshStatusText`, so it can never hide the real - * assessment; its sole purpose is to stop the read-only route re-enqueuing - * generation on every navigation while a provider is degraded. + * v1.8.3 — the timeout path opens a *short-TTL negative window* on the note + * row (`retryAt`, `negativeReason`). Writing it never touches the stored + * note, so it can never hide the real assessment; its sole purpose is to + * stop the read-only route re-enqueuing generation on every navigation while + * a provider is degraded. */ // The graded series folds per-day aggregates in SQL; the fake folds the @@ -30,7 +30,11 @@ vi.mock("@/lib/measurements/day-aggregates", async () => ({ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, measurement: { findMany: vi.fn() }, measurementRollup: { findMany: vi.fn() }, // v1.28.25 — the graded-series cold-tier fallback day-buckets dense @@ -42,8 +46,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -57,6 +74,11 @@ import { generatePulseStatusForUser } from "../pulse-status"; beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); }); @@ -67,7 +89,6 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => { dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 72, measuredAt: new Date() }, ] as never); @@ -94,19 +115,34 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => { // never mislabels the fallback as a fresh assessment. expect(result.updatedAt).toBeNull(); - // v1.8.3 — a short-TTL negative stub IS persisted (fire-and-forget) so + // v1.8.3 — a short-TTL negative window IS opened (fire-and-forget) so // the read-only route doesn't re-enqueue on every navigation while the - // provider is degraded. It is marked as a timeout stub, which - // `readFreshStatusText` rejects, so it never hides the real assessment. + // provider is degraded. It carries no note text, so it never hides or + // replaces the real assessment. // The write is fire-and-forget (`void`), so flush the microtask queue. await Promise.resolve(); - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); - const persisted = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { - data: { details: string }; + expect(prisma.insightStatusCache.upsert).toHaveBeenCalledTimes(1); + const persisted = vi.mocked(prisma.insightStatusCache.upsert).mock + .calls[0][0] as { + where: { userId_metric_locale: Record }; + create: Record; + update: Record; }; - const stub = JSON.parse(persisted.data.details); - expect(stub.timeout).toBe(true); - expect(stub.model).toBe("timeout-stub"); - expect(typeof stub.retryAt).toBe("string"); + expect(persisted.where.userId_metric_locale).toEqual({ + userId: "user-1", + metric: "pulse", + locale: "en", + }); + expect(persisted.create.textEncrypted).toBeUndefined(); + expect(persisted.create.negativeReason).toBe("timeout"); + // The update arm moves only the window, never an existing note. + expect(Object.keys(persisted.update).sort()).toEqual([ + "negativeReason", + "retryAt", + ]); + expect(persisted.update.retryAt).toBeInstanceOf(Date); + expect((persisted.update.retryAt as Date).getTime()).toBeGreaterThan( + Date.now(), + ); }); }); diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index ddf2632b2..54825a867 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -11,7 +11,11 @@ vi.mock("@/lib/measurements/day-aggregates", async () => ({ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, measurement: { findMany: vi.fn() }, measurementRollup: { findMany: vi.fn() }, // v1.28.25 — the graded-series cold-tier fallback day-buckets dense @@ -23,8 +27,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -35,6 +52,7 @@ vi.mock("@/lib/insights/memory", () => ({ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generatePulseStatusForUser } from "../pulse-status"; +import { noteRow, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -58,6 +76,11 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); // Cold rollup tier: the pulse graded series folds monthly/yearly from // the full-history `measurement.findMany` fallback on a tier miss. @@ -79,7 +102,6 @@ describe("generatePulseStatusForUser — graded payload", () => { dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); // v1.28.25 — PULSE is a dense type, so the cold-tier fallback reads // a SQL day-bucket aggregate instead of the raw findMany walk. Feed @@ -90,9 +112,6 @@ describe("generatePulseStatusForUser — graded payload", () => { .reverse() as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -154,16 +173,12 @@ describe("generatePulseStatusForUser — A2 resting-target in-target %", () => { dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); // pulse-status reads PULSE first, then RESTING_HEART_RATE. vi.mocked(prisma.measurement.findMany) .mockResolvedValueOnce(pulseRecords as never) .mockResolvedValueOnce(restingRecords as never) .mockResolvedValue([] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -202,15 +217,11 @@ describe("generatePulseStatusForUser — A2 resting-target in-target %", () => { dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany) .mockResolvedValueOnce(pulseRecords as never) .mockResolvedValueOnce([] as never); // no RESTING_HEART_RATE vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -242,15 +253,13 @@ describe("generatePulseStatusForUser — per-user tz (QA F5)", () => { gender: null, timezone: "America/New_York", } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: new Date(), - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: nyToday, - locale: "en", text: "NY-anchored cached pulse text.", - model: "x", - }), - } as never); + generatedAt: new Date(), + }) as never, + ); const result = await generatePulseStatusForUser("user-ny", { locale: "en", @@ -267,8 +276,8 @@ describe("generatePulseStatusForUser — per-user tz (QA F5)", () => { }); }); -describe("generatePulseStatusForUser — cache-read skips a stub", () => { - it("regenerates when the only cached row is a timeout stub", async () => { +describe("generatePulseStatusForUser — a negative window is not a note", () => { + it("regenerates when today's row carries only a negative-cache window", async () => { const now = new Date(); const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", @@ -277,23 +286,19 @@ describe("generatePulseStatusForUser — cache-read skips a stub", () => { dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", - text: "Pulse fallback text…", - model: "timeout-stub", - timeout: true, - }), - } as never); + text: null, + generatedAt: null, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", + }) as never, + ); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 72, measuredAt: now }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion('{"summary":"Fresh pulse assessment."}'); @@ -311,14 +316,10 @@ describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", () dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 72, measuredAt: new Date() }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); stubCompletion( '{"summary":"Your pulse is stable. metric:PULSE The 7-day average sits inside the band."}', @@ -328,12 +329,9 @@ describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", () expect(result.text).toBeTruthy(); expect(result.text).not.toContain("metric:"); - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBeGreaterThan(0); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { text: string }; - expect(parsed.text).not.toContain("metric:"); - expect(parsed.text).toContain("Your pulse is stable."); + const notes = writtenNotes(prisma.insightStatusCache.upsert); + expect(notes.length).toBeGreaterThan(0); + expect(notes[0].text).not.toContain("metric:"); + expect(notes[0].text).toContain("Your pulse is stable."); }); }); diff --git a/src/lib/insights/__tests__/status-batch.test.ts b/src/lib/insights/__tests__/status-batch.test.ts index dfcc82827..6aaf2dff5 100644 --- a/src/lib/insights/__tests__/status-batch.test.ts +++ b/src/lib/insights/__tests__/status-batch.test.ts @@ -29,6 +29,10 @@ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), +})); + // The single-card fallback path — assert it is invoked for omitted / failed // metrics, but don't re-drive a real completion. vi.mock("@/lib/insights/status-card-generation", async (importOriginal) => { @@ -48,6 +52,7 @@ import { prepareMedicationComplianceStatusForUser } from "@/lib/insights/medicat import { prepareGeneralStatusForUser } from "@/lib/insights/general-status"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { runPreparedStatusCard } from "@/lib/insights/status-card-generation"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { generateStatusBatchForUser } from "../status-batch"; function servedCard() { @@ -101,6 +106,37 @@ const ALL_PREPARES = [ beforeEach(() => { vi.resetAllMocks(); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + } as never); +}); + +describe("generateStatusBatchForUser — the statusText capability comes first", () => { + it("builds no snapshot and makes no call while statusText is unavailable", async () => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: false, + } as never); + ALL_PREPARES.forEach((prepare, i) => { + vi.mocked(prepare).mockResolvedValue(pendingCard(`m${i}`) as never); + }); + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "statusText"); + for (const prepare of ALL_PREPARES) expect(prepare).not.toHaveBeenCalled(); + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(runPreparedStatusCard).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + served: 0, + batched: 0, + fellBack: 0, + batchCallMade: false, + }); + }); }); describe("generateStatusBatchForUser — one call covers all present metrics", () => { @@ -166,6 +202,10 @@ describe("generateStatusBatchForUser — one call covers all present metrics", ( // ONE provider round-trip for all seven metrics. expect(runStatusCompletion).toHaveBeenCalledTimes(1); + // The chokepoint re-checks the same capability at the wire. + expect(runStatusCompletion).toHaveBeenCalledWith( + expect.objectContaining({ capability: "statusText" }), + ); expect(result.batched).toBe(7); expect(result.fellBack).toBe(0); // Each metric's finalize got its own summary, wrapped in the { summary } @@ -360,7 +400,10 @@ describe("generateStatusBatchForUser — graceful partial degradation", () => { pendingCard(`m${i}`, finalizers[i]) as never, ); }); - vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" } as never); + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "none", + reason: "no_provider", + } as never); const result = await generateStatusBatchForUser("u1", { locale: "en" }); diff --git a/src/lib/insights/__tests__/status-cache-delegated.test.ts b/src/lib/insights/__tests__/status-cache-delegated.test.ts index 45afb40fe..049fca9b4 100644 --- a/src/lib/insights/__tests__/status-cache-delegated.test.ts +++ b/src/lib/insights/__tests__/status-cache-delegated.test.ts @@ -16,8 +16,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const enqueueStatusGeneration = vi.fn(async () => {}); -const hasUsableStatusProvider = vi.fn(async () => true); -const statusConsentBlocksGeneration = vi.fn(async () => false); +const probeProviderPresence = vi.fn(async () => true); +const aiCapabilityForRecord = vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, +})); const suppressed = vi.fn(() => false); vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ @@ -26,11 +30,14 @@ vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ STATUS_GENERATE_QUEUE: "insight-status-generate", })); -vi.mock("@/lib/insights/status-provider", () => ({ - hasUsableStatusProvider: (...args: unknown[]) => - hasUsableStatusProvider(...(args as [])), - statusConsentBlocksGeneration: (...args: unknown[]) => - statusConsentBlocksGeneration(...(args as [])), +vi.mock("@/lib/ai/provider", () => ({ + probeProviderPresence: (...args: unknown[]) => + probeProviderPresence(...(args as [])), +})); + +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: (...args: unknown[]) => + aiCapabilityForRecord(...(args as [])), })); vi.mock("@/lib/sharing/delegated-generation", () => ({ @@ -41,11 +48,10 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/db", () => ({ prisma: { - // The last-good text and the negative-cache probe both read the audit - // ledger; an empty ledger is the cold-cache case this function is for. - auditLog: { - findMany: vi.fn(async () => []), - findFirst: vi.fn(async () => null), + // The last-good text and the negative-cache probe both read the one + // status-note row; no row is the cold-cache case this function is for. + insightStatusCache: { + findUnique: vi.fn(async () => null), }, }, })); @@ -57,11 +63,9 @@ beforeEach(() => { // the provider probe's call count, and a counter carried over from the // previous test would make it fail for the wrong reason (or pass for one). enqueueStatusGeneration.mockClear(); - hasUsableStatusProvider.mockClear(); - statusConsentBlocksGeneration.mockClear(); + probeProviderPresence.mockClear(); + aiCapabilityForRecord.mockClear(); suppressed.mockReturnValue(false); - hasUsableStatusProvider.mockResolvedValue(true); - statusConsentBlocksGeneration.mockResolvedValue(false); }); const ARGS = { @@ -103,7 +107,9 @@ describe("resolveReadOnlyStatusMiss — the delegated path", () => { await resolveReadOnlyStatusMiss(ARGS); - expect(hasUsableStatusProvider).not.toHaveBeenCalled(); - expect(statusConsentBlocksGeneration).not.toHaveBeenCalled(); + // The capability is the one read allowed ahead of the decision; the + // provider-presence probe belongs to the unavailable branch only. + expect(aiCapabilityForRecord).toHaveBeenCalledWith("owner-1", "statusText"); + expect(probeProviderPresence).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index 0a4334b86..210abb34e 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + findMany: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, // v1.18.11 (P6) — the input gate / fingerprint probe salient inputs. measurement: { groupBy: vi.fn() }, moodEntry: { aggregate: vi.fn() }, @@ -10,12 +15,26 @@ vi.mock("@/lib/db", () => ({ }, })); -const hasUsableStatusProvider = vi.fn(); -const statusConsentBlocksGeneration = vi.fn(); -vi.mock("@/lib/insights/status-provider", () => ({ - hasUsableStatusProvider: (...a: unknown[]) => hasUsableStatusProvider(...a), - statusConsentBlocksGeneration: (...a: unknown[]) => - statusConsentBlocksGeneration(...a), +// A reversible stand-in for the at-rest codec: the store's own logic (what it +// serves, what it re-dates, what it leaves alone) is what these tests pin, not +// AES-GCM. +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (plain: string) => new TextEncoder().encode(`enc:${plain}`), + decryptFromBytes: (buf: Uint8Array) => { + const text = new TextDecoder().decode(buf); + if (!text.startsWith("enc:")) throw new Error("unknown key id"); + return text.slice(4); + }, +})); + +const aiCapabilityForRecord = vi.fn(); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a), +})); + +const probeProviderPresence = vi.fn(); +vi.mock("@/lib/ai/provider", () => ({ + probeProviderPresence: (...a: unknown[]) => probeProviderPresence(...a), })); const enqueueStatusGeneration = vi.fn(); @@ -27,46 +46,108 @@ import { prisma } from "@/lib/db"; import { computeStatusInputFingerprint, gateUnchangedStatusInput, - isTimeoutStub, readFreshStatusText, + readLastGoodStatusText, + readStatusNegativeCache, refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, + writeStatusNegativeWindow, + writeStatusNote, } from "../status-cache"; const TODAY = "2026-05-31"; +const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true }; -function cacheRow(details: Record, createdAt = new Date()) { - return { createdAt, details: JSON.stringify(details) }; +function enc(text: string): Uint8Array { + return new TextEncoder().encode(`enc:${text}`); +} + +/** One `InsightStatusCache` row as `findUnique` would return it. */ +function noteRow(fields: { + text?: string | null; + dateKey?: string; + generatedAt?: Date | null; + snapshotHash?: string | null; + inputHash?: string | null; + retryAt?: Date | null; + negativeReason?: string | null; + textEncrypted?: Uint8Array | null; +}) { + return { + textEncrypted: + fields.textEncrypted !== undefined + ? fields.textEncrypted + : fields.text == null + ? null + : enc(fields.text), + itemsEncrypted: null, + inputHash: fields.inputHash ?? null, + snapshotHash: fields.snapshotHash ?? null, + dateKey: fields.dateKey ?? TODAY, + generatedAt: + fields.generatedAt !== undefined ? fields.generatedAt : new Date(), + retryAt: fields.retryAt ?? null, + negativeReason: fields.negativeReason ?? null, + }; } beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never); + aiCapabilityForRecord.mockResolvedValue(AVAILABLE); + probeProviderPresence.mockResolvedValue(true); }); -describe("isTimeoutStub", () => { - it("flags the timeout-stub model marker", () => { - expect(isTimeoutStub({ model: "timeout-stub" })).toBe(true); +describe("writeStatusNote / writeStatusNegativeWindow", () => { + it("upserts one encrypted row per (user, metric, locale) and clears the negative window", async () => { + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + await writeStatusNote({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + text: "Stable.", + snapshotHash: "s".repeat(64), + }); + const arg = vi.mocked(prisma.insightStatusCache.upsert).mock + .calls[0][0] as { + where: { userId_metric_locale: Record }; + create: Record; + update: Record; + }; + expect(arg.where.userId_metric_locale).toEqual({ + userId: "u1", + metric: "weight", + locale: "en", + }); + expect( + new TextDecoder().decode(arg.update.textEncrypted as Uint8Array), + ).toBe("enc:Stable."); + expect(arg.update.dateKey).toBe(TODAY); + expect(arg.update.snapshotHash).toBe("s".repeat(64)); + expect(arg.update.retryAt).toBeNull(); + expect(arg.update.negativeReason).toBeNull(); }); - it("flags the timeout:true marker", () => { - expect(isTimeoutStub({ timeout: true })).toBe(true); - }); - - it("does not flag a real assessment", () => { - expect(isTimeoutStub({ model: "gpt-4o-mini", timeout: false })).toBe(false); - expect(isTimeoutStub({})).toBe(false); + it("opens a negative window without touching the stored note", async () => { + const retryAt = new Date(Date.now() + 60_000); + await writeStatusNegativeWindow({ + userId: "u1", + cacheAction: "insights.pulse-status.de", + todayKey: TODAY, + reason: "timeout", + retryAt, + }); + const arg = vi.mocked(prisma.insightStatusCache.upsert).mock + .calls[0][0] as { update: Record }; + // Only the window moves: a stall must not hide yesterday's text. + expect(arg.update).toEqual({ retryAt, negativeReason: "timeout" }); }); }); describe("readFreshStatusText", () => { it("returns today's real assessment text", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ - dateKey: TODAY, - text: "Your weight trend is stable.", - model: "gpt-4o-mini", - }) as never, + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ text: "Your weight trend is stable." }) as never, ); const hit = await readFreshStatusText({ userId: "u1", @@ -75,15 +156,44 @@ describe("readFreshStatusText", () => { force: false, }); expect(hit?.text).toBe("Your weight trend is stable."); + expect(prisma.insightStatusCache.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId_metric_locale: { + userId: "u1", + metric: "weight", + locale: "en", + }, + }, + }), + ); }); - it("skips a timeout-stub row keyed to today", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ - dateKey: TODAY, - text: "Generic fallback advice.", - model: "timeout-stub", - timeout: true, + it("serves today's note even while a negative window is open on it", async () => { + // A stall writes the window next to the note; it never shadows the text. + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + text: "Real assessment.", + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", + }) as never, + ); + const hit = await readFreshStatusText({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + force: false, + }); + expect(hit?.text).toBe("Real assessment."); + }); + + it("misses on a row that carries only a negative window", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + text: null, + generatedAt: null, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", }) as never, ); const hit = await readFreshStatusText({ @@ -96,8 +206,8 @@ describe("readFreshStatusText", () => { }); it("skips a stale-day row", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ dateKey: "2026-05-30", text: "Yesterday." }) as never, + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2026-05-30", text: "Yesterday." }) as never, ); const hit = await readFreshStatusText({ userId: "u1", @@ -109,8 +219,8 @@ describe("readFreshStatusText", () => { }); it("skips an empty-text row", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ dateKey: TODAY, text: " " }) as never, + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ text: " " }) as never, ); const hit = await readFreshStatusText({ userId: "u1", @@ -129,14 +239,15 @@ describe("readFreshStatusText", () => { force: true, }); expect(hit).toBeNull(); - expect(prisma.auditLog.findFirst).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled(); }); - it("treats a malformed payload as a miss", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: new Date(), - details: "{not json", - } as never); + it("treats a note it cannot decrypt as a miss", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + textEncrypted: new TextEncoder().encode("{not ciphertext"), + }) as never, + ); const hit = await readFreshStatusText({ userId: "u1", cacheAction: "insights.weight-status.en", @@ -145,35 +256,153 @@ describe("readFreshStatusText", () => { }); expect(hit).toBeNull(); }); + + it("returns null when statusText is unavailable, without reading the row", async () => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "user_disabled", + onDeviceAllowed: false, + }); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ text: "Would have been served." }) as never, + ); + const hit = await readFreshStatusText({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + force: false, + }); + expect(hit).toBeNull(); + expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "statusText"); + expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled(); + }); +}); + +describe("readLastGoodStatusText", () => { + it("serves a note from an earlier day", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + dateKey: "2026-05-29", + text: "Two days old.", + generatedAt: new Date("2026-05-29T04:30:00.000Z"), + }) as never, + ); + expect( + await readLastGoodStatusText({ + userId: "u1", + cacheAction: "insights.weight-status.en", + }), + ).toEqual({ + text: "Two days old.", + updatedAt: "2026-05-29T04:30:00.000Z", + }); + }); + + it("serves nothing when statusText is unavailable", async () => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: false, + }); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ text: "Old text." }) as never, + ); + expect( + await readLastGoodStatusText({ + userId: "u1", + cacheAction: "insights.weight-status.en", + }), + ).toBeNull(); + }); +}); + +describe("readStatusNegativeCache", () => { + it("reports an open window and flips retryable once retryAt passes", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow({ + text: "Kept.", + retryAt: new Date(Date.now() + 60_000), + negativeReason: "screened", + }) as never, + ); + const open = await readStatusNegativeCache({ + userId: "u1", + cacheAction: "insights.weight-status.en", + }); + expect(open).toMatchObject({ reason: "screened", retryable: false }); + + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow({ + text: "Kept.", + retryAt: new Date(Date.now() - 60_000), + negativeReason: "timeout", + }) as never, + ); + const closed = await readStatusNegativeCache({ + userId: "u1", + cacheAction: "insights.weight-status.en", + }); + expect(closed).toMatchObject({ reason: "timeout", retryable: true }); + }); }); describe("resolveReadOnlyStatusMiss", () => { beforeEach(() => { - // Default: no prior assessment to serve stale (readLastGoodStatusText). - vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never); + // Default: no row at all — no last-good text, no negative window. + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + null as never, + ); }); - it("returns no-provider without enqueuing when the user has no provider", async () => { - hasUsableStatusProvider.mockResolvedValue(false); + it("returns unavailable with hasProvider from the probe and enqueues nothing", async () => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: false, + }); + probeProviderPresence.mockResolvedValue(true); const outcome = await resolveReadOnlyStatusMiss({ userId: "u1", metric: "weight", locale: "en", }); - expect(outcome.kind).toBe("no-provider"); + // A withdrawn consent is not "no provider": the probe says one exists. + expect(outcome).toEqual({ + kind: "unavailable", + reason: "consent_required", + hasProvider: true, + }); + expect(probeProviderPresence).toHaveBeenCalledWith("u1", "text"); + expect(enqueueStatusGeneration).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled(); + }); + + it("returns unavailable/no_provider without enqueuing when the user has no provider", async () => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "no_provider", + onDeviceAllowed: false, + }); + probeProviderPresence.mockResolvedValue(false); + const outcome = await resolveReadOnlyStatusMiss({ + userId: "u1", + metric: "weight", + locale: "en", + }); + expect(outcome).toEqual({ + kind: "unavailable", + reason: "no_provider", + hasProvider: false, + }); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); it("enqueues generation and returns preparing on a clean miss", async () => { - hasUsableStatusProvider.mockResolvedValue(true); - // No negative stub present. - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); const outcome = await resolveReadOnlyStatusMiss({ userId: "u1", metric: "pulse", locale: "de", }); - expect(outcome.kind).toBe("preparing"); // No last-good text to show, so nothing to revalidate against — the card // polls on `preparing` alone. expect(outcome).toEqual({ @@ -189,19 +418,14 @@ describe("resolveReadOnlyStatusMiss", () => { }); it("serves the last good assessment stale-while-revalidate on a clean miss", async () => { - hasUsableStatusProvider.mockResolvedValue(true); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); // A prior (e.g. yesterday's) real assessment is on record. - vi.mocked(prisma.auditLog.findMany).mockResolvedValue([ - cacheRow( - { - dateKey: "2026-05-30", - text: "Steady upward trend.", - model: "gpt-4o-mini", - }, - new Date("2026-05-30T04:30:00.000Z"), - ), - ] as never); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + dateKey: "2026-05-30", + text: "Steady upward trend.", + generatedAt: new Date("2026-05-30T04:30:00.000Z"), + }) as never, + ); const outcome = await resolveReadOnlyStatusMiss({ userId: "u1", metric: "weight", @@ -217,14 +441,13 @@ describe("resolveReadOnlyStatusMiss", () => { expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1); }); - it("suppresses re-enqueue while a fresh timeout stub exists", async () => { - hasUsableStatusProvider.mockResolvedValue(true); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ - dateKey: TODAY, - timeout: true, - model: "timeout-stub", - retryAt: new Date(Date.now() + 60_000).toISOString(), + it("suppresses re-enqueue while a negative window is open, still serving the last note", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + dateKey: "2026-05-30", + text: "Yesterday's note.", + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", }) as never, ); const outcome = await resolveReadOnlyStatusMiss({ @@ -234,19 +457,20 @@ describe("resolveReadOnlyStatusMiss", () => { }); expect(outcome.kind).toBe("preparing"); if (outcome.kind !== "preparing") throw new Error("expected preparing"); + // The window does not hide the stored note. + expect(outcome.lastGood?.text).toBe("Yesterday's note."); // No enqueue on the suppressed branch → nothing in flight to revalidate. expect(outcome.revalidating).toBe(false); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); - it("re-enqueues once the negative stub's retryAt has passed", async () => { - hasUsableStatusProvider.mockResolvedValue(true); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ - dateKey: TODAY, - timeout: true, - model: "timeout-stub", - retryAt: new Date(Date.now() - 60_000).toISOString(), + it("re-enqueues once the negative window's retryAt has passed", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + text: null, + generatedAt: null, + retryAt: new Date(Date.now() - 60_000), + negativeReason: "timeout", }) as never, ); const outcome = await resolveReadOnlyStatusMiss({ @@ -262,13 +486,12 @@ describe("resolveReadOnlyStatusMiss", () => { describe("refreshUnchangedStatusInsight (v1.16.8)", () => { const HASH = "a".repeat(64); - beforeEach(() => { - // Default: consent does not block (BYOK / consented chains). - statusConsentBlocksGeneration.mockResolvedValue(false); - }); - - it("misses (and writes nothing) when the server-managed consent is revoked, even on a hash match", async () => { - statusConsentBlocksGeneration.mockResolvedValue(true); + it("misses (and writes nothing) when statusText is unavailable, even on a hash match", async () => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: false, + }); const hit = await refreshUnchangedStatusInsight({ userId: "u1", cacheAction: "insights.weight-status.en", @@ -276,31 +499,24 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => { snapshotHash: HASH, }); expect(hit).toBeNull(); - // The gate must not even read the cache row — a revoked consent can - // never re-stamp old AI text as today's assessment. - expect(prisma.auditLog.findFirst).not.toHaveBeenCalled(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); - expect(statusConsentBlocksGeneration).toHaveBeenCalledWith( - "u1", - "insights", - ); + // The gate must not even read the row — an unavailable capability can + // never re-date old AI text as today's assessment. + expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); + expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "statusText"); }); - it("re-persists the row under today's dateKey and returns the text on a hash match", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ + it("re-dates the row under today's dateKey and returns the text on a hash match", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2026-05-30", - locale: "en", text: "Stable weight, no concerns.", - providerType: "openai", - model: "gpt-4o-mini", - tokensUsed: 321, snapshotHash: HASH, }) as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date("2026-05-31T04:30:00.000Z"), - } as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); const hit = await refreshUnchangedStatusInsight({ userId: "u1", @@ -310,27 +526,33 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => { }); expect(hit?.text).toBe("Stable weight, no concerns."); - expect(hit?.updatedAt).toBe("2026-05-31T04:30:00.000Z"); - // The refresh row carries the same payload re-keyed to today, so the - // read path and the ingest debounce both see a current assessment. - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); - const arg = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { - data: { userId: string; action: string; details: string }; + expect(hit?.expiresAfterDateKey).toBe(TODAY); + // The re-date moves only the day key and timestamp: the note and its + // fingerprints stay, so the read path and the ingest debounce both see a + // current assessment and tomorrow's gate still matches. + expect(prisma.insightStatusCache.updateMany).toHaveBeenCalledTimes(1); + const arg = vi.mocked(prisma.insightStatusCache.updateMany).mock + .calls[0][0] as { + where: Record; + data: Record; }; - expect(arg.data.userId).toBe("u1"); - expect(arg.data.action).toBe("insights.weight-status.en"); - const details = JSON.parse(arg.data.details) as Record; - expect(details.dateKey).toBe(TODAY); - expect(details.text).toBe("Stable weight, no concerns."); - expect(details.snapshotHash).toBe(HASH); + expect(arg.where).toEqual({ + userId: "u1", + metric: "weight", + locale: "en", + }); + expect(arg.data.dateKey).toBe(TODAY); + expect(arg.data).not.toHaveProperty("textEncrypted"); + expect(arg.data).not.toHaveProperty("snapshotHash"); + expect(hit?.updatedAt).toBe((arg.data.generatedAt as Date).toISOString()); + expect(prisma.insightStatusCache.upsert).not.toHaveBeenCalled(); }); it("misses (and writes nothing) when the stored hash differs", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2026-05-30", text: "Older text.", - model: "gpt-4o-mini", snapshotHash: "b".repeat(64), }) as never, ); @@ -341,15 +563,31 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => { snapshotHash: HASH, }); expect(hit).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); - it("misses when the latest row carries no hash (pre-gate rows)", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ - dateKey: "2026-05-30", - text: "Older text.", - model: "gpt-4o-mini", + it("misses when the row carries no hash", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2026-05-30", text: "Older text." }) as never, + ); + const hit = await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }); + expect(hit).toBeNull(); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); + }); + + it("never refreshes off a row that carries only a negative window", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + text: null, + generatedAt: null, + snapshotHash: HASH, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", }) as never, ); const hit = await refreshUnchangedStatusInsight({ @@ -359,31 +597,28 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => { snapshotHash: HASH, }); expect(hit).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); - it("never refreshes off a timeout stub, even with a matching hash", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ - dateKey: "2026-05-30", - text: "Generic fallback advice.", - model: "timeout-stub", - timeout: true, + it("misses on no prior row and on an undecryptable note", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + null as never, + ); + expect( + await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }), + ).toBeNull(); + + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow({ + textEncrypted: new TextEncoder().encode("{not ciphertext"), snapshotHash: HASH, }) as never, ); - const hit = await refreshUnchangedStatusInsight({ - userId: "u1", - cacheAction: "insights.weight-status.en", - todayKey: TODAY, - snapshotHash: HASH, - }); - expect(hit).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); - }); - - it("misses on no prior row and on malformed payloads", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce(null as never); expect( await refreshUnchangedStatusInsight({ userId: "u1", @@ -392,27 +627,11 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => { snapshotHash: HASH, }), ).toBeNull(); - - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({ - details: "{not json", - } as never); - expect( - await refreshUnchangedStatusInsight({ - userId: "u1", - cacheAction: "insights.weight-status.en", - todayKey: TODAY, - snapshotHash: HASH, - }), - ).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); describe("computeStatusInputFingerprint (v1.18.11 P6)", () => { - beforeEach(() => { - statusConsentBlocksGeneration.mockResolvedValue(false); - }); - it("is stable across group order and flips when a count or newest moves", async () => { const t0 = new Date("2026-05-30T08:00:00.000Z"); vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ @@ -598,24 +817,18 @@ describe("computeStatusInputFingerprint (v1.18.11 P6)", () => { describe("gateUnchangedStatusInput (v1.18.11 P6)", () => { const INPUT = "b".repeat(64); - beforeEach(() => { - statusConsentBlocksGeneration.mockResolvedValue(false); - }); - - it("re-stamps the cached text and skips the build on a matching input hash", async () => { - const created = new Date("2026-05-31T02:00:00.000Z"); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ + it("re-dates the cached text and skips the build on a matching input hash", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2026-05-30", text: "Stable weight.", - model: "gpt-4o-mini", inputHash: INPUT, snapshotHash: "c".repeat(64), }) as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: created, - } as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); const hit = await gateUnchangedStatusInput({ userId: "u1", @@ -625,25 +838,23 @@ describe("gateUnchangedStatusInput (v1.18.11 P6)", () => { force: false, }); expect(hit?.text).toBe("Stable weight."); - const persisted = JSON.parse( - ( - vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { - data: { details: string }; - } - ).data.details, - ) as { dateKey: string; inputHash: string; snapshotHash: string }; - expect(persisted.dateKey).toBe(TODAY); - // The prior fingerprints are preserved so the next day's gates match. - expect(persisted.inputHash).toBe(INPUT); - expect(persisted.snapshotHash).toBe("c".repeat(64)); + const data = ( + vi.mocked(prisma.insightStatusCache.updateMany).mock.calls[0][0] as { + data: Record; + } + ).data; + expect(data.dateKey).toBe(TODAY); + // The prior fingerprints are preserved (not rewritten) so the next day's + // gates match. + expect(data).not.toHaveProperty("inputHash"); + expect(data).not.toHaveProperty("snapshotHash"); }); it("misses on a differing or missing input hash (caller builds)", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2026-05-30", text: "Stable weight.", - model: "gpt-4o-mini", // no inputHash on the row }) as never, ); @@ -656,7 +867,7 @@ describe("gateUnchangedStatusInput (v1.18.11 P6)", () => { force: false, }), ).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); it("misses on a forced run without touching the cache", async () => { @@ -669,11 +880,18 @@ describe("gateUnchangedStatusInput (v1.18.11 P6)", () => { force: true, }), ).toBeNull(); - expect(prisma.auditLog.findFirst).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled(); }); - it("misses when the server-managed consent gate would block (no stale re-date)", async () => { - statusConsentBlocksGeneration.mockResolvedValue(true); + it("misses when statusText is unavailable (no stale re-date)", async () => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + }); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ text: "Stable weight.", inputHash: INPUT }) as never, + ); expect( await gateUnchangedStatusInput({ userId: "u1", @@ -683,6 +901,6 @@ describe("gateUnchangedStatusInput (v1.18.11 P6)", () => { force: false, }), ).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/insights/__tests__/status-note-fixtures.ts b/src/lib/insights/__tests__/status-note-fixtures.ts new file mode 100644 index 000000000..c8d93f504 --- /dev/null +++ b/src/lib/insights/__tests__/status-note-fixtures.ts @@ -0,0 +1,105 @@ +/** + * Shared fixtures for the status-generator tests that run against the real + * status-note store (`status-cache.ts`) over a mocked `prisma`. + * + * `fakeBytesCodec` stands in for the at-rest codec so a fixture can build a + * stored note from plain text and read back what a generator wrote. Wire it + * with + * + * vi.mock("@/lib/ai/coach/bytes-codec", async () => + * (await import("./status-note-fixtures")).fakeBytesCodec, + * ); + * + * It is not AES-GCM and does not try to be: these tests pin what the store + * serves, re-dates and writes, not the cipher. + */ +import type { Mock } from "vitest"; + +const PREFIX = "enc:"; + +export const fakeBytesCodec = { + encryptToBytes: (plain: string): Uint8Array => + new TextEncoder().encode(`${PREFIX}${plain}`), + decryptFromBytes: (buf: Uint8Array): string => { + const text = new TextDecoder().decode(buf); + if (!text.startsWith(PREFIX)) throw new Error("unknown key id"); + return text.slice(PREFIX.length); + }, +}; + +/** The capability state every generator fixture runs under by default. */ +export const CAPABILITY_AVAILABLE = { + available: true, + reason: null, + onDeviceAllowed: true, +} as const; + +/** One `InsightStatusCache` row as `findUnique` returns it. */ +export function noteRow(fields: { + text?: string | null; + items?: unknown; + dateKey: string; + generatedAt?: Date | null; + snapshotHash?: string | null; + inputHash?: string | null; + retryAt?: Date | null; + negativeReason?: string | null; +}) { + return { + textEncrypted: + fields.text == null ? null : fakeBytesCodec.encryptToBytes(fields.text), + itemsEncrypted: + fields.items === undefined + ? null + : fakeBytesCodec.encryptToBytes(JSON.stringify(fields.items)), + inputHash: fields.inputHash ?? null, + snapshotHash: fields.snapshotHash ?? null, + dateKey: fields.dateKey, + generatedAt: + fields.generatedAt !== undefined ? fields.generatedAt : new Date(), + retryAt: fields.retryAt ?? null, + negativeReason: fields.negativeReason ?? null, + }; +} + +/** A note a generator wrote through `insightStatusCache.upsert`, decoded. */ +export interface WrittenNote { + metric: string; + locale: string; + text: string | null; + items: unknown; + dateKey: string | undefined; + snapshotHash: string | null | undefined; + inputHash: string | null | undefined; + retryAt: Date | null | undefined; + negativeReason: string | null | undefined; +} + +/** + * Every `upsert` call on the mocked `insightStatusCache`, decoded from its + * `create` arm (which carries the full row either way). + */ +export function upsertedNotes(upsert: Mock | unknown): WrittenNote[] { + return (upsert as Mock).mock.calls.map((call) => { + const create = (call[0] as { create: Record }).create; + const decode = (v: unknown) => + v instanceof Uint8Array ? fakeBytesCodec.decryptFromBytes(v) : null; + const itemsJson = decode(create.itemsEncrypted); + return { + metric: create.metric as string, + locale: create.locale as string, + text: decode(create.textEncrypted), + items: itemsJson === null ? null : JSON.parse(itemsJson), + dateKey: create.dateKey as string | undefined, + snapshotHash: create.snapshotHash as string | null | undefined, + inputHash: create.inputHash as string | null | undefined, + retryAt: create.retryAt as Date | null | undefined, + negativeReason: create.negativeReason as string | null | undefined, + }; + }); +} + +/** Only the upserts that wrote a note (not a bare negative-cache window). */ +export function writtenNotes(upsert: Mock | unknown): WrittenNote[] { + return upsertedNotes(upsert).filter((n) => n.text !== null); +} diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index 1ef502f0e..b1bbad4f5 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -64,7 +64,18 @@ const { resolveProviderChain, resolveProvider } = vi.hoisted(() => ({ resolveProviderChain: vi.fn(), resolveProvider: vi.fn(), })); -vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain, resolveProvider })); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain, + resolveProvider, + probeProviderPresence: vi.fn(async () => true), +})); + +// The capability re-check at the wire. Available by default so the budget is +// what these tests isolate; one test below flips it. +const { aiCapabilityForRecord } = vi.hoisted(() => ({ + aiCapabilityForRecord: vi.fn(), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord })); const { runRawCompletionWithFallback } = vi.hoisted(() => ({ runRawCompletionWithFallback: vi.fn(), @@ -74,15 +85,16 @@ vi.mock("@/lib/ai/provider-runner", () => ({ runRawCompletionWithFallback, })); -// Consent gate is exercised by its own suite; keep it open here so the budget -// behaviour is what these tests isolate. -vi.mock("@/lib/ai/consent-guard", () => ({ - chainRequiresServerManagedConsent: vi.fn(() => false), - hasActiveConsentForSurface: vi.fn(async () => true), -})); +// The wire re-check for the resolved chain (`aiEgressRefusal`). Its own rule +// (capability + receipt for an operator-held entry) is pinned in egress.test.ts; +// here it passes by default so the budget is what these tests isolate, and +// the refusal test below flips it. +const { aiEgressRefusal } = vi.hoisted(() => ({ aiEgressRefusal: vi.fn() })); +vi.mock("@/lib/ai/capabilities/egress", () => ({ aiEgressRefusal })); import { runStatusCompletion } from "../status-provider"; import { annotate } from "@/lib/logging/context"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { OPERATOR_COST_CAP, USER_PLAN_CAP } from "@/lib/ai/coach/budget"; const OPERATOR_CHAIN = [{ providerType: "admin-openai", instance: {} }]; @@ -94,7 +106,7 @@ function completionArgs(overrides: Record = {}) { cacheAction: "status:test", systemPrompt: "system", userPrompt: "user", - consentSurface: "insights" as const, + capability: "statusText" as const, ...overrides, }; } @@ -113,6 +125,72 @@ beforeEach(() => { ledgerOperator = 0; resolveProviderChain.mockResolvedValue(OPERATOR_CHAIN); resolveProvider.mockResolvedValue({ type: "none" }); + aiCapabilityForRecord.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + aiEgressRefusal.mockResolvedValue(null); +}); + +describe("runStatusCompletion — capability at the wire", () => { + it("returns none with the capability's reason and never resolves the chain when the capability is unavailable", async () => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "user_disabled", + onDeviceAllowed: false, + }); + + const outcome = await runStatusCompletion( + completionArgs({ capability: "periodNarrative" }) as never, + ); + + expect(outcome).toEqual({ kind: "none", reason: "user_disabled" }); + expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "periodNarrative"); + expect(resolveProviderChain).not.toHaveBeenCalled(); + expect(resolveProvider).not.toHaveBeenCalled(); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + // Nothing reserved against the day's ledger either. + expect(ledgerTotal).toBe(0); + }); + + it("re-checks exactly the resolved chain and refuses before any budget or provider work", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + { providerType: "admin-openai", instance: {} }, + ]); + aiEgressRefusal.mockResolvedValue( + new AiUnavailableError("coach", "consent_required"), + ); + + const outcome = await runStatusCompletion( + completionArgs({ capability: "coach" }) as never, + ); + + expect(outcome).toEqual({ kind: "none", reason: "consent_required" }); + expect(aiEgressRefusal).toHaveBeenCalledWith("coach", "u1", [ + "anthropic", + "admin-openai", + ]); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + expect(ledgerTotal).toBe(0); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "insights.status.capability_unavailable" }, + meta: expect.objectContaining({ + reason: "consent_required", + at: "wire", + }), + }), + ); + }); + + it("returns none with reason no_provider when the chain is empty", async () => { + resolveProviderChain.mockResolvedValue([]); + const outcome = await runStatusCompletion(completionArgs() as never); + expect(outcome).toEqual({ kind: "none", reason: "no_provider" }); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + }); }); describe("runStatusCompletion — ledger accounting", () => { diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index 5f715f219..805944536 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -10,7 +10,11 @@ vi.mock("@/lib/measurements/day-aggregates", async () => ({ vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, // v1.18.11 (P6) — the input gate probes salient inputs via groupBy + // moodEntry.aggregate before the heavy findMany build. measurement: { findMany: vi.fn(), groupBy: vi.fn() }, @@ -26,10 +30,39 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), })); +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available unless a test says otherwise — the capability +// read itself has its own tests in status-cache.test.ts. +const { + aiCapabilityForRecord, + probeProviderPresence, + enqueueStatusGeneration, +} = vi.hoisted(() => ({ + aiCapabilityForRecord: vi.fn(), + probeProviderPresence: vi.fn(), + enqueueStatusGeneration: vi.fn(), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord })); +vi.mock("@/lib/ai/provider", async (importOriginal) => ({ + ...(await importOriginal()), + probeProviderPresence, +})); +vi.mock( + "@/lib/jobs/insight-status-generate-shared", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/jobs/insight-status-generate-shared") + >()), + enqueueStatusGeneration, + }), +); + vi.mock("@/lib/insights/memory", () => ({ getPreviousInsightContext: vi.fn().mockResolvedValue(null), formatPreviousContextForPrompt: vi.fn().mockReturnValue(""), @@ -38,6 +71,8 @@ vi.mock("@/lib/insights/memory", () => ({ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generateWeightStatusForUser } from "../weight-status"; +import { getNoKeyWeightStatusText } from "@/lib/insights/no-key-fallbacks"; +import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -76,6 +111,49 @@ beforeEach(() => { _max: { moodLoggedAt: null }, } as never); vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); + aiCapabilityForRecord.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + probeProviderPresence.mockResolvedValue(true); + enqueueStatusGeneration.mockResolvedValue(undefined); +}); + +describe("generateWeightStatusForUser — read-only miss while statusText is unavailable", () => { + it.each([ + ["consent_required", true], + ["no_provider", false], + ] as const)( + "serves the deterministic line with hasProvider from the probe (%s) and enqueues nothing", + async (reason, present) => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + probeProviderPresence.mockResolvedValue(present); + + const result = await generateWeightStatusForUser("user-1", { + locale: "en", + readOnly: true, + }); + + // `hasProvider` is provider presence only: a withdrawn consent is not + // "no provider configured". + expect(result.hasProvider).toBe(present); + expect(result.text).toBe(getNoKeyWeightStatusText("en")); + expect(result.updatedAt).toBeNull(); + expect(enqueueStatusGeneration).not.toHaveBeenCalled(); + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled(); + }, + ); }); describe("generateWeightStatusForUser — graded payload", () => { @@ -94,12 +172,8 @@ describe("generateWeightStatusForUser — graded payload", () => { }); } - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -135,14 +209,10 @@ describe("generateWeightStatusForUser — graded payload", () => { describe("generateWeightStatusForUser — timeout/error never persists", () => { it("serves the fallback without writing a cache row on timeout", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 82, measuredAt: new Date() }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "timeout", @@ -156,44 +226,38 @@ describe("generateWeightStatusForUser — timeout/error never persists", () => { expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); // v1.8.3 — no real assessment persisted (updatedAt stays null above), - // but a short-TTL negative stub IS written so the read-only route does + // but a short-TTL negative window IS opened so the read-only route does // not re-enqueue on every navigation while the provider is degraded. - // The stub is a timeout marker that `readFreshStatusText` rejects. + // The window carries no note, so it can never be served as one. await Promise.resolve(); - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ); - expect(details.timeout === true || details.model === "timeout-stub").toBe( - true, - ); - } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); + const windows = upsertedNotes(prisma.insightStatusCache.upsert); + expect(windows).toHaveLength(1); + expect(windows[0].negativeReason).toBe("timeout"); + expect(windows[0].retryAt).toBeInstanceOf(Date); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); -describe("generateWeightStatusForUser — cache-read skips a stub", () => { - it("regenerates when the only cached row is a timeout stub", async () => { +describe("generateWeightStatusForUser — a negative window is not a note", () => { + it("regenerates when today's row carries only a negative-cache window", async () => { const now = new Date(); const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", }).format(now); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", - text: "Generic stub fallback.", - model: "timeout-stub", - timeout: true, - }), - } as never); + text: null, + generatedAt: null, + retryAt: new Date(now.getTime() + 60_000), + negativeReason: "timeout", + }) as never, + ); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 82, measuredAt: now }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion('{"summary":"Fresh real assessment."}'); @@ -231,10 +295,6 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => { vi.mocked(prisma.measurement.findMany).mockImplementation((async () => records.map((r) => ({ ...r }))) as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion('{"summary":"First real assessment."}'); // First run: a real generation persists the snapshot fingerprint. @@ -243,33 +303,24 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => { force: true, }); expect(runStatusCompletion).toHaveBeenCalledTimes(1); - const persisted = JSON.parse( - ( - vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { - data: { details: string }; - } - ).data.details, - ) as { text: string; snapshotHash: string }; + const [persisted] = writtenNotes(prisma.insightStatusCache.upsert); + expect(persisted.text).toBe("First real assessment."); expect(persisted.snapshotHash).toMatch(/^[0-9a-f]{64}$/); // Second run, same data: the gate finds the matching fingerprint, // re-persists the same text under today's dateKey, and never calls // the provider — even though the run is forced. vi.mocked(runStatusCompletion).mockClear(); - vi.mocked(prisma.auditLog.create).mockClear(); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ - // Yesterday's row — outside the same-day cache read. + vi.mocked(prisma.insightStatusCache.upsert).mockClear(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + // Yesterday's note — outside the same-day cache read. dateKey: "2026-06-09", - locale: "en", text: persisted.text, - providerType: "anthropic", - model: "x", - tokensUsed: 1, + generatedAt: now, snapshotHash: persisted.snapshotHash, - }), - } as never); + }) as never, + ); const result = await generateWeightStatusForUser("user-1", { locale: "en", @@ -279,15 +330,17 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => { expect(runStatusCompletion).not.toHaveBeenCalled(); expect(result.cached).toBe(true); expect(result.text).toBe(persisted.text); - // The refresh row re-keys the payload to today. - const refreshed = JSON.parse( - ( - vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { - data: { details: string }; - } - ).data.details, - ) as { dateKey: string; snapshotHash: string }; - expect(refreshed.snapshotHash).toBe(persisted.snapshotHash); + // The refresh re-dates the stored note to today and leaves the note + // and its fingerprint alone. + expect(prisma.insightStatusCache.upsert).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.updateMany).toHaveBeenCalledTimes(1); + const refreshed = ( + vi.mocked(prisma.insightStatusCache.updateMany).mock.calls[0][0] as { + data: Record; + } + ).data; + expect(refreshed).not.toHaveProperty("snapshotHash"); + expect(refreshed).not.toHaveProperty("textEncrypted"); expect(refreshed.dateKey).not.toBe("2026-06-09"); } finally { vi.useRealTimers(); @@ -300,21 +353,14 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => { { type: "WEIGHT", value: 82, measuredAt: now }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2026-06-09", - locale: "en", text: "Older assessment.", - providerType: "anthropic", - model: "x", - tokensUsed: 1, + generatedAt: now, snapshotHash: "f".repeat(64), - }), - } as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); + }) as never, + ); stubCompletion('{"summary":"Fresh assessment for changed data."}'); const result = await generateWeightStatusForUser("user-1", { @@ -330,14 +376,10 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => { describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 82, measuredAt: new Date() }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); stubCompletion( '{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}', @@ -349,11 +391,8 @@ describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", ( expect(result.text).toBeTruthy(); expect(result.text).not.toContain("metric:"); - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBeGreaterThan(0); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { text: string }; - expect(parsed.text).not.toContain("metric:"); + const notes = writtenNotes(prisma.insightStatusCache.upsert); + expect(notes.length).toBeGreaterThan(0); + expect(notes[0].text).not.toContain("metric:"); }); }); diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index 9238b0920..58ba09cf4 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -211,9 +211,9 @@ export async function generateBiomarkerStatus(args: { metric: scope, locale, }); - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + if (outcome.kind === "unavailable") { return { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyBiomarkerStatusText(locale, marker.name), cached: true, updatedAt: null, @@ -324,7 +324,7 @@ export async function generateBiomarkerStatus(args: { const outcome = await runStatusCompletion({ userId: args.userId, cacheAction, - consentSurface: "insights", + capability: "statusText", systemPrompt: getBiomarkerSystemPrompt(safeMarkerName, locale as Locale), userPrompt: getBiomarkerUserPrompt( snapshotJson, @@ -383,11 +383,7 @@ export async function generateBiomarkerStatus(args: { userId: args.userId, cacheAction, todayKey, - locale, text, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, inputHash, }); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 378988eea..c1ad7db91 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -191,13 +191,13 @@ export async function prepareBloodPressureStatusForUser( metric: "blood-pressure", locale, }); - // v1.16.13 — `consent-missing` serves the same no-key fallback (see - // bmi-status); no enqueue happens for it. - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason serves the deterministic line and enqueues + // nothing. `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { phase: "served", result: { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyBloodPressureStatusText(locale), cached: true, updatedAt: null, @@ -769,11 +769,7 @@ export async function prepareBloodPressureStatusForUser( userId, cacheAction, todayKey, - locale, text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, snapshotHash, }); return { diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 1f88f8b96..0ad6d4a93 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -132,14 +132,13 @@ export async function prepareBmiStatusForUser( metric: "bmi", locale, }); - // v1.16.13 — `consent-missing` (provider configured but the - // server-managed consent gate blocks egress) serves the same no-key - // fallback; no enqueue happens for it (the resolver short-circuits). - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason serves the deterministic line and enqueues + // nothing. `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { phase: "served", result: { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyBmiStatusText(locale), cached: true, updatedAt: null, @@ -460,11 +459,7 @@ export async function prepareBmiStatusForUser( userId, cacheAction, todayKey, - locale, text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, snapshotHash, // v1.18.11 (P6) — persist the input fingerprint for tomorrow's gate. inputHash, diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 2891697ab..cc29fcfa8 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -78,10 +78,8 @@ import { BriefingBudgetExceededError, runBriefingCompletion, } from "@/lib/insights/briefing-provider"; -import { - chainRequiresServerManagedConsent, - hasActiveConsentForSurface, -} from "@/lib/ai/consent-guard"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { stripJsonFences, @@ -142,6 +140,8 @@ export type GenerateOutcome = | { status: "skipped"; reason: + /** The `briefing` capability is unavailable for another reason. */ + | "unavailable" | "no-provider" | "no-consent" | "budget" @@ -657,6 +657,27 @@ export async function generateComprehensiveInsight( return { status: "cached" }; } + // The `briefing` capability at the wire, before the chain is resolved (a + // Codex chain may refresh a token on resolve). Callers resolve it too (the + // route before it enqueues, the nightly pass before this call); switches, + // the AI analysis opt-out and consent can change in between. + const capability = await aiCapabilityForRecord(userId, "briefing"); + if (!capability.available) { + annotate({ + action: { name: "insights.comprehensive.unavailable" }, + meta: { reason: capability.reason }, + }); + return { + status: "skipped", + reason: + capability.reason === "no_provider" + ? "no-provider" + : capability.reason === "consent_required" + ? "no-consent" + : "unavailable", + }; + } + const chain = await resolveProviderChain(userId); if (chain.length === 0) { const legacy = await resolveProvider(userId); @@ -666,16 +687,23 @@ export async function generateComprehensiveInsight( chain.push({ providerType: "admin-openai", instance: legacy }); } - // v1.12.1 — consent gate before any server-managed external egress. This + // The wire re-check for exactly this chain (`aiEgressRefusal`): the + // capability again, and the consent an operator-held entry needs. This // pipeline runs off-request (nightly cron + on-demand force-warm), so a - // missing receipt is a typed `skipped` outcome rather than a throw — the - // cron batch continues to the next user. BYOK / local / ChatGPT-OAuth - // chains never trip the check. - if ( - chainRequiresServerManagedConsent(chain) && - !(await hasActiveConsentForSurface(userId, "insights")) - ) { - return { status: "skipped", reason: "no-consent" }; + // refusal is a typed `skipped` outcome rather than a throw — the cron batch + // continues to the next user. A person's own key, their ChatGPT account or + // a local model need no receipt. + const refusal = await aiEgressRefusal( + "briefing", + userId, + chain.map((entry) => entry.providerType), + ); + if (refusal) { + return { + status: "skipped", + reason: + refusal.reason === "consent_required" ? "no-consent" : "unavailable", + }; } const includeRaw = dbUser?.insightsPrivacyMode === "raw"; diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts index 82bb9c24b..89c895df1 100644 --- a/src/lib/insights/derived/derived-assessment-ai.ts +++ b/src/lib/insights/derived/derived-assessment-ai.ts @@ -300,7 +300,7 @@ export async function generateDerivedScoreAssessment(args: { const outcome = await runStatusCompletion({ userId: args.userId, cacheAction, - consentSurface: "insights", + capability: "statusText", systemPrompt: scoreSystemPrompt(locale), userPrompt: scoreUserPrompt( signal, @@ -399,11 +399,7 @@ export async function generateDerivedScoreAssessment(args: { userId: args.userId, cacheAction, todayKey, - locale, text, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, // v1.22 (W6) — store the input fingerprint so the next day's gate can skip // the warm when no contributor source changed. inputHash, diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index e294ed998..ed398a136 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -130,13 +130,13 @@ export async function prepareGeneralStatusForUser( metric: "general", locale, }); - // v1.16.13 — `consent-missing` serves the same no-key fallback (see - // bmi-status); no enqueue happens for it. - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason serves the deterministic line and enqueues + // nothing. `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { phase: "served", result: { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyGeneralStatusText(locale), cached: true, updatedAt: null, @@ -538,11 +538,7 @@ export async function prepareGeneralStatusForUser( userId, cacheAction, todayKey, - locale, text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, snapshotHash, }); return { diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 17236dd2d..a93a3c2af 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -37,9 +37,11 @@ import { round, } from "@/lib/insights/status-shared"; import { - isTimeoutStub, + readFreshStatusItems, + refreshUnchangedStatusItems, resolveReadOnlyStatusMiss, statusCacheAction, + writeStatusNote, } from "@/lib/insights/status-cache"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; @@ -58,6 +60,17 @@ interface MedicationSummaryItem { text: string; } +/** The stored per-medication lines, keeping only well-formed entries. */ +function parseMedicationItems(items: unknown[]): MedicationSummaryItem[] { + return items.filter( + (entry): entry is MedicationSummaryItem => + typeof (entry as MedicationSummaryItem | null)?.medicationId === + "string" && + typeof (entry as MedicationSummaryItem | null)?.text === "string" && + (entry as MedicationSummaryItem).text.trim().length > 0, + ); +} + /** * The richer compliance result envelope (`summary` + per-medication * `medications`) the cards read — distinct from the standard `text`-only @@ -120,53 +133,25 @@ export async function prepareMedicationComplianceStatusForUser( const userTz = await resolveUserTimezone(userId); const todayKey = userDayKey(new Date(), userTz); - // This route carries a richer cached envelope (`summary` + - // `medications`) than the standard `text`-only generators, so it - // keeps its own cache-read — but it shares the stub-rejection - // predicate so a timeout stub never sticks for the day. + // This card stores per-medication lines next to its summary, so it reads + // its note through the items variant of the shared reader. if (!force) { - const latestCache = await prisma.auditLog.findFirst({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true, details: true }, + const cached = await readFreshStatusItems({ + userId, + cacheAction, + todayKey, }); - if (latestCache?.details) { - try { - const parsed = JSON.parse(latestCache.details) as { - dateKey?: string; - summary?: string; - text?: string; - model?: string; - timeout?: boolean; - medications?: MedicationSummaryItem[]; - }; - - if ( - parsed.dateKey === todayKey && - !isTimeoutStub(parsed) && - typeof parsed.summary === "string" && - parsed.summary.trim().length > 0 && - Array.isArray(parsed.medications) - ) { - return { - phase: "served", - result: { - hasProvider: true, - summary: parsed.summary, - medications: parsed.medications.filter( - (entry): entry is MedicationSummaryItem => - typeof entry?.medicationId === "string" && - typeof entry?.text === "string" && - entry.text.trim().length > 0, - ), - cached: true, - updatedAt: latestCache.createdAt.toISOString(), - }, - }; - } - } catch { - // ignore invalid cache payload - } + if (cached && Array.isArray(cached.items)) { + return { + phase: "served", + result: { + hasProvider: true, + summary: cached.text, + medications: parseMedicationItems(cached.items), + cached: true, + updatedAt: cached.updatedAt, + }, + }; } } @@ -178,13 +163,13 @@ export async function prepareMedicationComplianceStatusForUser( metric: "medication-compliance", locale, }); - // v1.16.13 — `consent-missing` serves the same no-key fallback (see - // bmi-status); no enqueue happens for it. - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason: the deterministic line, no enqueue. + // `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { phase: "served", result: { - hasProvider: false, + hasProvider: outcome.hasProvider, summary: getNoKeyMedicationComplianceStatusText(locale), medications: [], cached: true, @@ -429,56 +414,26 @@ export async function prepareMedicationComplianceStatusForUser( // Content-hash gate (v1.16.8): when the snapshot is unchanged since the // last real assessment, refresh the cache timestamp and skip the LLM. - // This generator caches the richer `{ summary, medications }` envelope, - // so it carries its own gate rather than the shared text-only one. + // This card stores per-medication lines next to the summary, so it uses + // the items variant of the shared gate. const snapshotHash = hashInsightSnapshot(snapshot); - const latestRow = await prisma.auditLog.findFirst({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - select: { details: true }, + const unchanged = await refreshUnchangedStatusItems({ + userId, + cacheAction, + todayKey, + snapshotHash, }); - if (latestRow?.details) { - try { - const parsed = JSON.parse(latestRow.details) as { - summary?: string; - medications?: MedicationSummaryItem[]; - model?: string; - timeout?: boolean; - snapshotHash?: string; - }; - if ( - !isTimeoutStub(parsed) && - typeof parsed.summary === "string" && - parsed.summary.trim().length > 0 && - Array.isArray(parsed.medications) && - parsed.snapshotHash === snapshotHash - ) { - const refreshed = await prisma.auditLog.create({ - data: { - userId, - action: cacheAction, - details: JSON.stringify({ ...parsed, dateKey: todayKey }), - }, - select: { createdAt: true }, - }); - annotate({ - action: { name: "insights.status.skipped_unchanged" }, - meta: { cache_action: cacheAction }, - }); - return { - phase: "served", - result: { - hasProvider: true, - summary: parsed.summary, - medications: parsed.medications, - cached: true, - updatedAt: refreshed.createdAt.toISOString(), - }, - }; - } - } catch { - // Malformed cache payload — fall through to a real generation. - } + if (unchanged && Array.isArray(unchanged.items)) { + return { + phase: "served", + result: { + hasProvider: true, + summary: unchanged.text, + medications: parseMedicationItems(unchanged.items), + cached: true, + updatedAt: unchanged.updatedAt, + }, + }; } const previousContext = await getPreviousInsightContext( @@ -622,29 +577,20 @@ export async function prepareMedicationComplianceStatusForUser( "Medication-compliance-status summary was empty after normalization", ); } - const created = await prisma.auditLog.create({ - data: { - userId, - action: cacheAction, - details: JSON.stringify({ - dateKey: todayKey, - locale, - summary, - medications: medicationSummaries, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, - snapshotHash, - }), - }, - select: { createdAt: true }, + const generatedAt = await writeStatusNote({ + userId, + cacheAction, + todayKey, + text: summary, + items: medicationSummaries, + snapshotHash, }); return { hasProvider: true, summary, medications: medicationSummaries, cached: false, - updatedAt: created.createdAt.toISOString(), + updatedAt: generatedAt.toISOString(), }; }, }; diff --git a/src/lib/insights/memory.ts b/src/lib/insights/memory.ts index f58e1e15c..645018a33 100644 --- a/src/lib/insights/memory.ts +++ b/src/lib/insights/memory.ts @@ -2,9 +2,9 @@ * Insight memory — what the AI told this user last time. * * Every status generator (general / blood-pressure / weight / pulse / - * bmi / medication-compliance / mood) caches its rendered text in an - * `audit_logs` row keyed by `action = "insights.{scope}.{locale}"`. - * v1.4 turns that history into prompt context: the next generation + * bmi / medication-compliance / mood, and the generic metric cards) stores + * its note in `InsightStatusCache`, named `insights.{scope}.{locale}`. + * v1.4 turns that note into prompt context: the next generation * sees the previous classification + a short summary, so the model * can call out improvements and regressions instead of restating * status from scratch every day. @@ -17,14 +17,13 @@ * `compareSnapshots()` and renders ↑/↓/→ glyphs next to findings. */ -import { prisma } from "@/lib/db"; +import { readPreviousStatusNote } from "@/lib/insights/status-cache"; import { instructionLocale } from "@/lib/ai/prompts/output-language"; import type { SupportedLocale } from "@/lib/insights/status-shared"; /** * Stable identifiers for the seven insight scopes. The string MUST - * match the suffix in the existing audit-log `action` field so we - * read the same rows the cache already writes. + * match the note name the generators store under (`statusCacheAction`). */ export type InsightScope = | "general-status" @@ -59,10 +58,9 @@ export interface PreviousInsightContext { } /** - * Read the most recent cached insight for `userId` + `scope` + `locale` - * that is at least `minAgeHours` old. Returns null when no eligible - * snapshot exists (first-run users) or when the cache row's payload - * is malformed. + * Read the stored note for `userId` + `scope` + `locale` when it is at + * least `minAgeHours` old. Returns null when there is none (first-run + * users) or it was written more recently than that. * * The default 12-hour floor keeps the model from comparing today's * 09:00 reading to today's 06:00 reading and treating sub-day noise as @@ -78,47 +76,19 @@ export async function getPreviousInsightContext( const action = `insights.${scope}.${locale}`; const olderThan = new Date(Date.now() - minAgeHours * 60 * 60 * 1000); - const row = await prisma.auditLog.findFirst({ - where: { - userId, - action, - createdAt: { lt: olderThan }, - }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true, details: true }, + const row = await readPreviousStatusNote({ + userId, + cacheAction: action, + olderThan, }); + if (!row) return null; + const text = row.text; - if (!row?.details) return null; - - let text: string | null = null; - try { - const parsed = JSON.parse(row.details) as { - text?: unknown; - summary?: unknown; - }; - if (typeof parsed.text === "string" && parsed.text.trim().length > 0) { - text = parsed.text; - } else if ( - typeof parsed.summary === "string" && - parsed.summary.trim().length > 0 - ) { - text = parsed.summary; - } - } catch { - // Older cache rows stored raw text instead of JSON. Surface them - // verbatim so the prompt still benefits from the comparison. - if (row.details.trim().length > 0) { - text = row.details; - } - } - - if (!text) return null; - - const ageMs = Date.now() - row.createdAt.getTime(); + const ageMs = Date.now() - row.generatedAt.getTime(); const ageDays = Math.max(0, Math.floor(ageMs / (24 * 60 * 60 * 1000))); return { - generatedAt: row.createdAt.toISOString(), + generatedAt: row.generatedAt.toISOString(), ageDays, // Cap text so a verbose previous analysis cannot bloat the prompt. text: text.length > 1500 ? text.slice(0, 1500) + "…" : text, diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index d9defd0d2..c165d9828 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -197,11 +197,11 @@ export async function generateMetricStatus(args: { metric: scope, locale, }); - // v1.16.13 — `consent-missing` serves the same no-key fallback (see - // bmi-status); no enqueue happens for it. - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason serves the deterministic line and enqueues + // nothing. `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyGeneralStatusText(locale), cached: true, updatedAt: null, @@ -500,7 +500,7 @@ export async function generateMetricStatus(args: { const outcome = await runStatusCompletion({ userId: args.userId, cacheAction, - consentSurface: "insights", + capability: "statusText", systemPrompt: getMetricArchetypeSystemPrompt(meta, locale), userPrompt: getMetricArchetypeUserPrompt( meta, @@ -564,11 +564,7 @@ export async function generateMetricStatus(args: { userId: args.userId, cacheAction, todayKey, - locale, text, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, snapshotHash, }); diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index ec2dd6889..13d11a647 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -179,13 +179,13 @@ export async function prepareMoodStatusForUser( metric: "mood", locale, }); - // v1.16.13 — `consent-missing` serves the same no-key fallback (see - // bmi-status); no enqueue happens for it. - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason serves the deterministic line and enqueues + // nothing. `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { phase: "served", result: { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyMoodStatusText(locale), cached: true, updatedAt: null, @@ -768,11 +768,7 @@ export async function prepareMoodStatusForUser( userId, cacheAction, todayKey, - locale, text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, snapshotHash, }); return { diff --git a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts index 12fec86c8..21286fb9b 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts @@ -15,6 +15,19 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); // prisma double, so this is only a guard against accidental real-DB use. vi.mock("@/lib/db", () => ({ prisma: {} })); +// The `periodNarrative` capability decides whether model-written prose may be +// served. Available unless a test says otherwise. +const { aiCapabilityForRecord } = vi.hoisted(() => ({ + aiCapabilityForRecord: vi.fn(), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord })); + +const UNAVAILABLE = { + available: false, + reason: "user_disabled", + onDeviceAllowed: false, +} as const; + import { generatePeriodNarrative, readPeriodNarrative, @@ -107,7 +120,29 @@ function makePrisma(seedRow?: FakeRow) { }; } -beforeEach(() => vi.clearAllMocks()); +beforeEach(() => { + vi.clearAllMocks(); + aiCapabilityForRecord.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); +}); + +function seededRow(over: Partial = {}): FakeRow { + return { + userId: "u1", + period: "week", + locale: "en", + dateKey: "2026-05-15", + encryptedContent: new Uint8Array(Buffer.from("enc:old", "utf8")), + provenanceJson: null, + providerType: "openai", + promptVersion: NARRATIVE_PROMPT_VERSION, + updatedAt: new Date(), + ...over, + }; +} describe("buildNarrativeUserPrompt", () => { it("renders metrics, drivers, and the FDR footer with the prompt version", () => { @@ -150,16 +185,20 @@ describe("generatePeriodNarrative — descriptive generation", () => { it("passes a descriptive-never-causal system prompt to the provider", async () => { const prisma = makePrisma(); let systemPrompt = ""; - const runCompletion = vi.fn(async (args: { systemPrompt: string }) => { - systemPrompt = args.systemPrompt; - return { - kind: "ok" as const, - content: "ok", - providerType: "openai", - model: "m", - tokensUsed: 1, - }; - }); + let capability = ""; + const runCompletion = vi.fn( + async (args: { systemPrompt: string; capability: string }) => { + systemPrompt = args.systemPrompt; + capability = args.capability; + return { + kind: "ok" as const, + content: "ok", + providerType: "openai", + model: "m", + tokensUsed: 1, + }; + }, + ); await generatePeriodNarrative("u1", { period: "week", locale: "en", @@ -174,6 +213,8 @@ describe("generatePeriodNarrative — descriptive generation", () => { // v1.21.0 (coach C1 MEDIUM-1) — the retrospective now carries the shared // warm tone contract verbatim, matching the briefing's house voice. expect(systemPrompt).toContain(toneContract.en); + // The chokepoint re-checks this narrative's own capability at the wire. + expect(capability).toBe("periodNarrative"); }); }); @@ -208,7 +249,10 @@ describe("generatePeriodNarrative — honesty floor", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any prisma: prisma as any, buildContext: async () => readyContext() as PeriodNarrativeResult, - runCompletion: async () => ({ kind: "none" as const }), + runCompletion: async () => ({ + kind: "none" as const, + reason: "no_provider" as const, + }), }); expect(outcome).toEqual({ status: "generated", @@ -292,3 +336,81 @@ describe("generatePeriodNarrative — cache + regenerate", () => { expect(read?.providerType).toBe("anthropic"); }); }); + +describe("period narrative — the periodNarrative capability", () => { + it("readPeriodNarrative hides a model-written row while the capability is unavailable", async () => { + const prisma = makePrisma(seededRow({ providerType: "openai" })); + aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); + const read = await readPeriodNarrative( + "u1", + "week", + "en", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma as any, + ); + expect(read).toBeNull(); + expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "periodNarrative"); + }); + + it("readPeriodNarrative still serves the deterministic narrative, which is data", async () => { + const prisma = makePrisma( + seededRow({ + providerType: "deterministic", + encryptedContent: new Uint8Array( + Buffer.from("enc:Your weight eased down.", "utf8"), + ), + }), + ); + aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); + const read = await readPeriodNarrative( + "u1", + "week", + "en", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma as any, + ); + expect(read?.text).toBe("Your weight eased down."); + expect(aiCapabilityForRecord).not.toHaveBeenCalled(); + }); + + it("does not short-circuit on a fresh model-written row it may not serve", async () => { + const prisma = makePrisma(seededRow({ providerType: "openai" })); + aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); + // The chokepoint refuses on the same capability, so the deterministic + // narrative replaces the hidden prose in place. + const runCompletion = vi.fn(async () => ({ + kind: "none" as const, + reason: "user_disabled" as const, + })); + const outcome = await generatePeriodNarrative("u1", { + period: "week", + locale: "en", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: prisma as any, + buildContext: async () => readyContext() as PeriodNarrativeResult, + runCompletion, + }); + expect(outcome).toEqual({ + status: "generated", + providerType: "deterministic", + }); + expect(runCompletion).toHaveBeenCalledTimes(1); + expect(prisma._get()?.providerType).toBe("deterministic"); + }); + + it("still short-circuits on a fresh deterministic row whatever the capability says", async () => { + const prisma = makePrisma(seededRow({ providerType: "deterministic" })); + aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); + const runCompletion = vi.fn(); + const outcome = await generatePeriodNarrative("u1", { + period: "week", + locale: "en", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: prisma as any, + buildContext: async () => readyContext() as PeriodNarrativeResult, + runCompletion, + }); + expect(outcome).toEqual({ status: "cached" }); + expect(runCompletion).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index adacbf36f..89d4209df 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -32,6 +32,7 @@ import { annotate } from "@/lib/logging/context"; import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets"; import { dayRotatedSeed } from "@/lib/ai/prompts/opener-archetype"; import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; import { buildPeriodNarrativeContext, type NarrativePeriod, @@ -315,15 +316,19 @@ export async function generatePeriodNarrative( const runCompletion = options.runCompletion ?? runStatusCompletion; const period = options.period; - // Freshness short-circuit — a recently-generated row is served as-is. + // Freshness short-circuit — a recently-generated row is served as-is, as + // long as a reader may serve it. A model-written row whose capability has + // since become unavailable is hidden on read, so it counts as missing here + // and the deterministic narrative below takes its place. if (!force) { const existing = await prisma.insightNarrative.findUnique({ where: { userId_period_locale: { userId, period, locale } }, - select: { updatedAt: true }, + select: { updatedAt: true, providerType: true }, }); if ( existing && - now.getTime() - existing.updatedAt.getTime() < NARRATIVE_FRESH_MS + now.getTime() - existing.updatedAt.getTime() < NARRATIVE_FRESH_MS && + (await narrativeServable(userId, existing.providerType)) ) { return { status: "cached" }; } @@ -347,7 +352,7 @@ export async function generatePeriodNarrative( const completion = await runCompletion({ userId, cacheAction: `insights.narrative.${period}.${locale}`, - consentSurface: "insights", + capability: "periodNarrative", systemPrompt: buildNarrativeSystemPrompt(locale), userPrompt: buildNarrativeUserPrompt(context, bodyLocale), temperature: AI_BUDGETS.narrative.temperature, @@ -418,9 +423,10 @@ export async function generatePeriodNarrative( }); }; - // No usable provider → compose the deterministic, non-causal fallback from - // the same structured context, so the retrospective card is never empty for - // an active account (iOS H2). A later AI warm overwrites this row in place. + // No usable provider, or the `periodNarrative` capability is unavailable for + // any other reason → compose the deterministic, non-causal fallback from the + // same structured context, so the retrospective card is never empty for an + // active account (iOS H2). A later AI warm overwrites this row in place. if (completion.kind === "none") { const text = buildDeterministicNarrative(context, bodyLocale); await persist(text, DETERMINISTIC_PROVIDER_TYPE); @@ -453,7 +459,7 @@ export async function generatePeriodNarrative( const retry = await runCompletion({ userId, cacheAction: `insights.narrative.${period}.${locale}`, - consentSurface: "insights", + capability: "periodNarrative", systemPrompt: buildNarrativeSystemPrompt(locale), userPrompt: buildNarrativeUserPrompt(context, bodyLocale) + @@ -500,7 +506,8 @@ export interface NarrativeRead { /** * Read the latest narrative for `(userId, period, locale)`, decrypting the - * prose. Null when none was ever generated. This is the stale-while-revalidate + * prose. Null when none was ever generated, or when it is model-written and + * `periodNarrative` is unavailable for the record. This is the stale-while-revalidate * source: it returns whatever was last produced, regardless of age, so the * surface renders prior prose immediately while a refresh warms out of band. */ @@ -514,6 +521,15 @@ export async function readPeriodNarrative( where: { userId_period_locale: { userId, period, locale } }, }); if (!row) return null; + // Model-written prose is never served while the capability is unavailable, + // whatever the reason. The deterministic narrative is data and always is. + if (!(await narrativeServable(userId, row.providerType))) { + annotate({ + action: { name: "insights.narrative.hidden" }, + meta: { period, locale }, + }); + return null; + } let text: string; try { @@ -546,6 +562,19 @@ export async function readPeriodNarrative( // ── helpers ─────────────────────────────────────────────────────────────── +/** + * Whether a stored narrative may be served: always for the deterministic + * narrative, and for model-written prose only while `periodNarrative` is + * available for the record. + */ +async function narrativeServable( + userId: string, + providerType: string | null, +): Promise { + if (providerType === DETERMINISTIC_PROVIDER_TYPE) return true; + return (await aiCapabilityForRecord(userId, "periodNarrative")).available; +} + /** Prisma `Bytes` ↔ ciphertext, mirroring the CoachMessage helper. */ function encryptToBytes(plaintext: string): Uint8Array { const ciphertext = encrypt(plaintext); diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 8fe30a89c..a2efb912f 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -139,13 +139,13 @@ export async function preparePulseStatusForUser( metric: "pulse", locale, }); - // v1.16.13 — `consent-missing` serves the same no-key fallback (see - // bmi-status); no enqueue happens for it. - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason serves the deterministic line and enqueues + // nothing. `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { phase: "served", result: { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyPulseStatusText(locale), cached: true, updatedAt: null, @@ -509,11 +509,7 @@ export async function preparePulseStatusForUser( userId, cacheAction, todayKey, - locale, text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, snapshotHash, }); return { diff --git a/src/lib/insights/status-batch.ts b/src/lib/insights/status-batch.ts index 2afd0c5bf..a494ac3e7 100644 --- a/src/lib/insights/status-batch.ts +++ b/src/lib/insights/status-batch.ts @@ -18,7 +18,7 @@ * are omitted, never fabricated), and run ONE completion with one * JSON-correction retry. * 3. Fan each returned summary back into that metric's `finalize`, which - * persists the SAME per-metric `auditLog` cache row the standalone + * stores the SAME per-metric status note the standalone * generator wrote — so the card read path is unchanged. * * Graceful degradation (audit requirement): any metric the batch omits, or @@ -51,6 +51,7 @@ import { } from "@/lib/ai/prompts/status-batch"; import type { Locale } from "@/lib/i18n/config"; import { annotate } from "@/lib/logging/context"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; /** The seven specialised prepares, in the warm order the cron used. */ const PREPARES: ReadonlyArray< @@ -121,6 +122,19 @@ export async function generateStatusBatchForUser( batchCallMade: false, }; + // The capability before any snapshot is built: someone whose status notes + // are unavailable (switch, AI analysis off, no provider, consent withdrawn) + // costs one capability read, not seven snapshot builds. The chokepoint + // re-checks it at the wire. + const capability = await aiCapabilityForJob(userId, "statusText"); + if (!capability.available) { + annotate({ + action: { name: "insights.status.batch.skipped" }, + meta: { reason: capability.reason }, + }); + return result; + } + // Build every card's snapshot once. A prepare that throws must not abort // the whole batch — that one metric is simply skipped this cycle. const prepared = await Promise.all( @@ -183,7 +197,7 @@ export async function generateStatusBatchForUser( let outcome = await runStatusCompletion({ userId, cacheAction: "insights.status-batch", - consentSurface: "insights", + capability: "statusText", systemPrompt, userPrompt, temperature: AI_BUDGETS.statusBatch.temperature, @@ -220,7 +234,7 @@ export async function generateStatusBatchForUser( const retry = await runStatusCompletion({ userId, cacheAction: "insights.status-batch", - consentSurface: "insights", + capability: "statusText", systemPrompt, userPrompt: `${userPrompt}\n\nYour previous response was not valid JSON matching { "perMetric": { ... } }. Reply with that JSON object ONLY — no prose, no markdown fences.`, temperature: AI_BUDGETS.statusBatch.temperature, diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 645b46724..126460113 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -1,9 +1,9 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; -import { - hasUsableStatusProvider, - statusConsentBlocksGeneration, -} from "@/lib/insights/status-provider"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; +import type { AiUnavailableReason } from "@/lib/ai/capabilities/types"; +import { probeProviderPresence } from "@/lib/ai/provider"; import { enqueueStatusGeneration, type InsightStatusScope, @@ -44,75 +44,220 @@ const CORRELATION_CHANNEL_TYPES: readonly MeasurementType[] = ]) as MeasurementType[]; /** - * Shared cache-read for the seven `*-status.ts` insight generators. + * The status-note store: what a model wrote about one metric, for one person, + * in one language. * - * Every generator persists its assessment as an `auditLog` row keyed - * `insights.-status.` whose `details` JSON carries - * `{ dateKey, locale, text, providerType, model, tokensUsed }`. On the - * next mount the generator reads the most recent such row and, if it is - * still for today, serves it without re-hitting the provider. + * Every per-metric generator (the seven specialised cards, the generic + * `metric:` cards, the derived-score and biomarker assessments) keeps its + * note in `InsightStatusCache`, one row per `(user, metric, locale)`, the note + * itself encrypted at rest. Until v1.39.0 the notes were appended to + * `audit_logs` as plaintext JSON; the callers still name a note by the old + * cache-action string (`insights.-status.`, built only by + * `statusCacheAction`), and this module maps it onto the row. * - * The timeout fallback used to poison this read. When a provider call - * exceeded the status budget the route persisted a `model:"timeout-stub"` - * / `timeout:true` row carrying the generic no-key text under the SAME - * `text` field a real assessment uses. The cache-read only checked - * `dateKey === today && text` — so the stub looked like a valid - * assessment and stuck until midnight, hiding the real data-driven text - * for the rest of the day. + * A row carries two things that are updated independently: * - * `readFreshStatusText` is the one cache-read every standard generator - * shares. It rejects stubs explicitly so a single stall no longer pins - * the fallback for the day, and a fresh generation is attempted instead. + * - the note (`textEncrypted`, its `dateKey`, `generatedAt` and the two + * fingerprints), written when a generation succeeds or an unchanged-data + * run re-dates it; + * - a short negative-cache window (`retryAt`, `negativeReason`), written + * when a generation stalls, errs or is screened, so a stalled provider + * does not turn every page visit into another enqueue. Writing it never + * touches the note, so one stall cannot hide yesterday's text, and writing + * a note clears it. + * + * Nothing here serves a note while the `statusText` capability is unavailable + * for the record, whatever the reason: an operator switch, the person's AI + * opt-out, a missing provider, a withdrawn consent. The note stays stored (a + * switch flipped back costs nothing) unless consent withdrawal purges it. */ /** - * The single source of truth for the per-status cache-action shape. - * - * Every per-metric assessment is persisted as an `auditLog` row whose - * `action` is `insights.-status.`. The shape IS the cache - * key, so building it by hand in multiple places risks the same silent - * drift the queryKey factory guards against on the client. `statusCacheAction` - * is the one builder. + * The single builder of a status note's name. The shape is part of the + * contract between the generators, the queue and this store, so it is built + * in one place only. */ export function statusCacheAction(scope: string, locale: string): string { return `insights.${scope}-status.${locale}`; } -interface ParsedStatusCache { - dateKey?: string; - locale?: string; - text?: string; - summary?: string; - providerType?: string; - model?: string; - tokensUsed?: number | null; - timeout?: boolean; - /** v1.8.3 — ISO timestamp before which a timeout stub suppresses re-enqueue. */ - retryAt?: string; - /** Explicit generated/negative cache classification for new rows. */ - statusKind?: "generated" | "negative"; - /** Whether the producer may be retried at the time this row is read. */ - retryable?: boolean; - /** Generated rows expire once the caller's current day differs. */ - expiresAfterDateKey?: string; - /** Stable non-sensitive negative-cache reason code. */ - reason?: "timeout" | "error" | "screened"; - /** v1.16.8 — fingerprint of the data snapshot the text was generated from. */ - snapshotHash?: string; - /** v1.18.11 (P6) — cheap fingerprint of the salient inputs (count + newest). */ - inputHash?: string; +const CACHE_ACTION_SHAPE = /^insights\.(.+)-status\.([^.]+)$/; + +/** The row key behind a note's name, or `null` for a name of another shape. */ +function statusCacheKey( + cacheAction: string, +): { metric: string; locale: string } | null { + const match = CACHE_ACTION_SHAPE.exec(cacheAction); + if (!match) return null; + return { metric: match[1], locale: match[2] }; +} + +/** One stored row, decrypted. */ +interface StoredStatusNote { + /** The note, `null` when the row carries only a negative-cache window. */ + text: string | null; + /** Per-item notes (JSON), for a card that carries them. */ + items: unknown; + inputHash: string | null; + snapshotHash: string | null; + dateKey: string; + generatedAt: Date | null; + retryAt: Date | null; + negativeReason: string | null; +} + +function decryptOrNull(payload: Uint8Array | null): string | null { + if (!payload || payload.byteLength === 0) return null; + try { + return decryptFromBytes(payload); + } catch { + // A note encrypted under a key the host no longer holds is a cache miss; + // the next run writes it again. + return null; + } +} + +async function readStatusNote( + userId: string, + cacheAction: string, +): Promise { + const key = statusCacheKey(cacheAction); + if (!key) return null; + const row = await prisma.insightStatusCache.findUnique({ + where: { userId_metric_locale: { userId, ...key } }, + select: { + textEncrypted: true, + itemsEncrypted: true, + inputHash: true, + snapshotHash: true, + dateKey: true, + generatedAt: true, + retryAt: true, + negativeReason: true, + }, + }); + if (!row) return null; + const text = decryptOrNull(row.textEncrypted); + const itemsJson = decryptOrNull(row.itemsEncrypted); + let items: unknown = null; + if (itemsJson !== null) { + try { + items = JSON.parse(itemsJson); + } catch { + items = null; + } + } + return { + text: text !== null && text.trim().length > 0 ? text : null, + items, + inputHash: row.inputHash, + snapshotHash: row.snapshotHash, + dateKey: row.dateKey, + generatedAt: row.generatedAt, + retryAt: row.retryAt, + negativeReason: row.negativeReason, + }; } /** - * A cached row is a timeout stub when it carries the sentinel marker the - * timeout path writes. Either flag is sufficient — older stub rows may - * predate one of the two markers, so both are honoured. + * Whether stored status notes may be served, or re-dated, for this record. + * Every reader and both unchanged-data gates ask this first. */ -export function isTimeoutStub(parsed: { - model?: string; - timeout?: boolean; -}): boolean { - return parsed.model === "timeout-stub" || parsed.timeout === true; +async function statusTextServable(userId: string): Promise { + return (await aiCapabilityForRecord(userId, "statusText")).available; +} + +/** + * Write a note: a successful generation. Upserts the one row, clears any + * negative-cache window, and returns when the note was written. + */ +export async function writeStatusNote(args: { + userId: string; + cacheAction: string; + todayKey: string; + text: string; + /** Per-item notes for a card that carries them; serialised as JSON. */ + items?: unknown; + snapshotHash?: string; + inputHash?: string; +}): Promise { + const key = statusCacheKey(args.cacheAction); + if (!key) { + throw new Error(`Not a status note name: ${args.cacheAction}`); + } + const generatedAt = new Date(); + const note = { + textEncrypted: encryptToBytes(args.text), + itemsEncrypted: + args.items === undefined + ? null + : encryptToBytes(JSON.stringify(args.items)), + inputHash: args.inputHash ?? null, + snapshotHash: args.snapshotHash ?? null, + dateKey: args.todayKey, + generatedAt, + retryAt: null, + negativeReason: null, + }; + await prisma.insightStatusCache.upsert({ + where: { userId_metric_locale: { userId: args.userId, ...key } }, + create: { userId: args.userId, ...key, ...note }, + update: note, + }); + return generatedAt; +} + +/** + * Re-date the stored note under today's day key without touching the note or + * its fingerprints: an unchanged-data run. Returns the new timestamp. + */ +async function redateStatusNote( + userId: string, + cacheAction: string, + todayKey: string, +): Promise { + const key = statusCacheKey(cacheAction); + const generatedAt = new Date(); + if (key) { + // `updateMany`, not `update`: a note purged between the read and here (a + // consent withdrawal) is a no-op, not a thrown "record not found". + await prisma.insightStatusCache.updateMany({ + where: { userId, ...key }, + data: { + dateKey: todayKey, + generatedAt, + retryAt: null, + negativeReason: null, + }, + }); + } + return generatedAt; +} + +/** + * Open a negative-cache window for a note: the generation stalled, erred, or + * was screened. Leaves any stored note exactly as it was. + */ +export async function writeStatusNegativeWindow(args: { + userId: string; + cacheAction: string; + todayKey: string; + reason: "timeout" | "error" | "screened"; + retryAt: Date; +}): Promise { + const key = statusCacheKey(args.cacheAction); + if (!key) return; + await prisma.insightStatusCache.upsert({ + where: { userId_metric_locale: { userId: args.userId, ...key } }, + create: { + userId: args.userId, + ...key, + dateKey: args.todayKey, + retryAt: args.retryAt, + negativeReason: args.reason, + }, + update: { retryAt: args.retryAt, negativeReason: args.reason }, + }); } export interface FreshStatusCacheHit { @@ -124,14 +269,12 @@ export interface FreshStatusCacheHit { } /** - * Read the latest cached assessment for `(userId, cacheAction)` and - * return its text only when it is (a) for today and (b) NOT a timeout - * stub. Returns `null` on a miss, a stale day, a stub, or a malformed - * payload — every one of those means the caller should regenerate. + * Today's note for `(userId, cacheAction)`, or `null` on a miss, a note from + * an earlier day, no note at all, or a capability that is unavailable. Every + * one of those means the caller should not serve stored text as current. * - * `force` short-circuits to `null` so a forced regeneration never reads - * the cache. The DB read is still skipped entirely under `force` to - * keep the forced path cheap. + * `force` short-circuits to `null` without a read, so a forced regeneration + * never reads the cache. */ export async function readFreshStatusText(args: { userId: string; @@ -141,61 +284,57 @@ export async function readFreshStatusText(args: { }): Promise { const { userId, cacheAction, todayKey, force } = args; if (force) return null; + if (!(await statusTextServable(userId))) return null; - const latestCache = await prisma.auditLog.findFirst({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true, details: true }, - }); - if (!latestCache?.details) return null; - - try { - const parsed = JSON.parse(latestCache.details) as ParsedStatusCache; - if (parsed.dateKey !== todayKey) return null; - if (isTimeoutStub(parsed)) return null; - if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { - return null; - } - return { - kind: "generated", - text: parsed.text, - updatedAt: latestCache.createdAt.toISOString(), - retryable: false, - expiresAfterDateKey: parsed.expiresAfterDateKey ?? parsed.dateKey, - }; - } catch { - // Malformed cache payload — treat as a miss and regenerate. + const note = await readStatusNote(userId, cacheAction); + if (!note?.text || note.dateKey !== todayKey || !note.generatedAt) { return null; } + return { + kind: "generated", + text: note.text, + updatedAt: note.generatedAt.toISOString(), + retryable: false, + expiresAfterDateKey: note.dateKey, + }; } /** - * v1.16.8 — the content-hash regeneration gate for the per-status and - * generic metric cards. + * Today's per-item note for a card that carries one (medication compliance): + * the summary plus the parsed items, under the same rules as + * `readFreshStatusText`. + */ +export async function readFreshStatusItems(args: { + userId: string; + cacheAction: string; + todayKey: string; +}): Promise<{ text: string; items: unknown; updatedAt: string } | null> { + if (!(await statusTextServable(args.userId))) return null; + const note = await readStatusNote(args.userId, args.cacheAction); + if (!note?.text || note.dateKey !== args.todayKey || !note.generatedAt) { + return null; + } + return { + text: note.text, + items: note.items, + updatedAt: note.generatedAt.toISOString(), + }; +} + +/** + * The content-hash regeneration gate for the per-status and generic metric + * cards. * - * A generator that has already gathered its data snapshot calls this - * BEFORE the provider round-trip. When the latest cached assessment for - * `(userId, cacheAction)` is a real (non-stub) text whose stored - * `snapshotHash` equals the fresh snapshot's hash, nothing the prompt - * sees has changed — so the gate re-persists the same text under - * today's `dateKey` (a pure timestamp refresh that keeps the read path - * and the ingest debounce satisfied) and returns it, and the caller - * skips the LLM call entirely. Returns `null` on any miss (no prior - * row, stub, empty text, missing or differing hash) — every one of - * those means the caller should generate for real. + * A generator that has already gathered its data snapshot calls this BEFORE + * the provider round-trip. When the stored note's `snapshotHash` equals the + * fresh snapshot's, nothing the prompt sees has changed: the note is re-dated + * under today's key and returned, and the caller skips the model entirely. + * `null` on any miss (no note, a missing or differing hash). * - * This single gate is what turns the nightly warm, the forced warm, and - * the ingest-driven regeneration into no-ops on unchanged data: each of - * those paths forces past the same-day cache read, gathers the - * snapshot, and lands here. - * - * Consent comes BEFORE the hash compare (mirroring the comprehensive - * pipeline's consent-before-gate order): re-stamping a cached text - * under today's `dateKey` presents it as a current AI assessment, so a - * user who revoked the server-managed AI consent must not have old - * text re-dated by an unchanged-data refresh. On a blocked consent the - * gate misses, the generator proceeds to `runStatusCompletion`, and - * that gate returns the no-key fallback without persisting anything. + * The capability comes first. Re-dating a note presents it as current, so a + * record whose `statusText` capability is unavailable (consent withdrawn, + * AI analysis switched off, …) never has old text re-dated; the gate misses, + * and the generator's own chokepoint then refuses the generation. */ export async function refreshUnchangedStatusInsight(args: { userId: string; @@ -203,57 +342,64 @@ export async function refreshUnchangedStatusInsight(args: { todayKey: string; snapshotHash: string; }): Promise { - if (await statusConsentBlocksGeneration(args.userId, "insights")) { + if (!(await statusTextServable(args.userId))) { annotate({ - action: { name: "insights.status.consent_required" }, + action: { name: "insights.status.unavailable" }, meta: { cache_action: args.cacheAction, gate: "unchanged-refresh" }, }); return null; } - const latest = await prisma.auditLog.findFirst({ - where: { userId: args.userId, action: args.cacheAction }, - orderBy: { createdAt: "desc" }, - select: { details: true }, - }); - if (!latest?.details) return null; + const note = await readStatusNote(args.userId, args.cacheAction); + if (!note?.text || note.snapshotHash !== args.snapshotHash) return null; - let parsed: ParsedStatusCache; - try { - parsed = JSON.parse(latest.details) as ParsedStatusCache; - } catch { - return null; - } - if (isTimeoutStub(parsed)) return null; - if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { - return null; - } - if (parsed.snapshotHash !== args.snapshotHash) return null; - - // Same data, valid text — refresh the cache row's day key so the - // read path serves it as today's assessment and the ingest debounce - // window restarts, without touching the provider. - const created = await prisma.auditLog.create({ - data: { - userId: args.userId, - action: args.cacheAction, - details: JSON.stringify({ ...parsed, dateKey: args.todayKey }), - }, - select: { createdAt: true }, - }); + const generatedAt = await redateStatusNote( + args.userId, + args.cacheAction, + args.todayKey, + ); annotate({ action: { name: "insights.status.skipped_unchanged" }, meta: { cache_action: args.cacheAction }, }); return { kind: "generated", - text: parsed.text, - updatedAt: created.createdAt.toISOString(), + text: note.text, + updatedAt: generatedAt.toISOString(), retryable: false, expiresAfterDateKey: args.todayKey, }; } +/** + * The same gate for a card that carries per-item notes: re-dates the stored + * note when its `snapshotHash` matches and hands back the items too. + */ +export async function refreshUnchangedStatusItems(args: { + userId: string; + cacheAction: string; + todayKey: string; + snapshotHash: string; +}): Promise<{ text: string; items: unknown; updatedAt: string } | null> { + if (!(await statusTextServable(args.userId))) return null; + const note = await readStatusNote(args.userId, args.cacheAction); + if (!note?.text || note.snapshotHash !== args.snapshotHash) return null; + const generatedAt = await redateStatusNote( + args.userId, + args.cacheAction, + args.todayKey, + ); + annotate({ + action: { name: "insights.status.skipped_unchanged" }, + meta: { cache_action: args.cacheAction }, + }); + return { + text: note.text, + items: note.items, + updatedAt: generatedAt.toISOString(), + }; +} + /** * v1.18.11 (P6) — cheap input fingerprint for the slow-moving status * metrics (weight / BMI). @@ -393,21 +539,17 @@ export async function computeStatusInputFingerprint(args: { } /** - * v1.18.11 (P6) — the INPUT gate for slow-moving status metrics. + * The INPUT gate for slow-moving status metrics. * - * Runs BEFORE the snapshot build. When the latest cached assessment is a - * real (non-stub) text whose stored `inputHash` equals the freshly probed - * one, nothing the prompt could see has changed, so the gate re-stamps that - * text under today's `dateKey` and returns it — the caller then skips the - * whole gather AND the provider call. Returns `null` on any miss (no prior - * row, stub, empty text, missing or differing `inputHash`, or a forced - * regeneration), in which case the caller proceeds to the normal build + - * the finer post-build content-hash gate. + * Runs BEFORE the snapshot build. When the stored note's `inputHash` equals + * the freshly probed one, nothing the prompt could see has changed, so the + * note is re-dated under today's key and returned; the caller then skips the + * whole gather AND the provider call. `null` on any miss (no note, a missing + * or differing `inputHash`, a forced regeneration), in which case the caller + * proceeds to the normal build and the finer post-build content-hash gate. * - * Consent is checked first, mirroring `refreshUnchangedStatusInsight`: - * re-dating a cached text presents it as current, so a user who revoked the - * server-managed AI consent must never have stale text re-stamped by an - * unchanged-input refresh. + * The capability is checked first, for the reason `refreshUnchangedStatusInsight` + * gives. */ export async function gateUnchangedStatusInput(args: { userId: string; @@ -418,52 +560,34 @@ export async function gateUnchangedStatusInput(args: { }): Promise { if (args.force) return null; - if (await statusConsentBlocksGeneration(args.userId, "insights")) { + if (!(await statusTextServable(args.userId))) { annotate({ - action: { name: "insights.status.consent_required" }, + action: { name: "insights.status.unavailable" }, meta: { cache_action: args.cacheAction, gate: "unchanged-input" }, }); return null; } - const latest = await prisma.auditLog.findFirst({ - where: { userId: args.userId, action: args.cacheAction }, - orderBy: { createdAt: "desc" }, - select: { details: true }, - }); - if (!latest?.details) return null; - - let parsed: ParsedStatusCache; - try { - parsed = JSON.parse(latest.details) as ParsedStatusCache; - } catch { + const note = await readStatusNote(args.userId, args.cacheAction); + if (!note?.text || !note.inputHash || note.inputHash !== args.inputHash) { return null; } - if (isTimeoutStub(parsed)) return null; - if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { - return null; - } - if (!parsed.inputHash || parsed.inputHash !== args.inputHash) return null; - // Same inputs, valid text — re-stamp the day key WITHOUT rebuilding the - // snapshot or calling the provider. Preserve every field (incl. the prior - // `snapshotHash` + `inputHash`) so the next day's gates still match. - const created = await prisma.auditLog.create({ - data: { - userId: args.userId, - action: args.cacheAction, - details: JSON.stringify({ ...parsed, dateKey: args.todayKey }), - }, - select: { createdAt: true }, - }); + // Same inputs, a stored note: re-date it WITHOUT rebuilding the snapshot or + // calling the provider. Both fingerprints stay, so tomorrow's gates match. + const generatedAt = await redateStatusNote( + args.userId, + args.cacheAction, + args.todayKey, + ); annotate({ action: { name: "insights.status.skipped_unchanged" }, meta: { cache_action: args.cacheAction, gate: "input" }, }); return { kind: "generated", - text: parsed.text, - updatedAt: created.createdAt.toISOString(), + text: note.text, + updatedAt: generatedAt.toISOString(), retryable: false, expiresAfterDateKey: args.todayKey, }; @@ -475,70 +599,94 @@ export interface LastGoodStatusHit { } /** - * v1.8.7 — read the most recent NON-stub assessment for `(userId, - * cacheAction)` regardless of which day it was generated. This is the - * stale-while-revalidate source: when today's cache is a miss the - * read-only path can still serve yesterday's (or older) good text - * instantly while a fresh generation is warmed out of band, so opening a - * category never drops to the "preparing" skeleton if an assessment was - * ever produced. Returns `null` only when there is genuinely no prior - * assessment (or every prior row is a timeout stub / malformed). + * The stored note for `(userId, cacheAction)` whatever day it was written: + * the stale-while-revalidate source. When today's note is a miss the read path + * can still show the last one instantly while a fresh generation is warmed, + * so opening a category never drops to the "preparing" skeleton if a note + * was ever written. `null` when there is none, or when the capability is + * unavailable. */ export async function readLastGoodStatusText(args: { userId: string; cacheAction: string; }): Promise { - const { userId, cacheAction } = args; - const rows = await prisma.auditLog.findMany({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - take: 5, - select: { createdAt: true, details: true }, - }); - for (const row of rows) { - if (!row.details) continue; - try { - const parsed = JSON.parse(row.details) as ParsedStatusCache; - if (isTimeoutStub(parsed)) continue; - if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { - continue; - } - return { text: parsed.text, updatedAt: row.createdAt.toISOString() }; - } catch { - // Malformed payload — skip and look further back. - continue; - } + if (!(await statusTextServable(args.userId))) return null; + const note = await readStatusNote(args.userId, args.cacheAction); + if (!note?.text || !note.generatedAt) return null; + return { text: note.text, updatedAt: note.generatedAt.toISOString() }; +} + +/** + * The stored note written at least `minAgeHours` ago, for the next + * generation's "what did I say last time" prompt block. Not a serving read: + * it feeds a generation that the chokepoint has already admitted. + */ +export async function readPreviousStatusNote(args: { + userId: string; + cacheAction: string; + olderThan: Date; +}): Promise<{ text: string; generatedAt: Date } | null> { + const note = await readStatusNote(args.userId, args.cacheAction); + if (!note?.text || !note.generatedAt) return null; + if (note.generatedAt.getTime() >= args.olderThan.getTime()) return null; + return { text: note.text, generatedAt: note.generatedAt }; +} + +/** + * Which of `cacheActions` carry a note written at or after `since`. The + * ingest invalidator skips those, so a fresh note survives a sync drip. + */ +export async function statusNotesWrittenSince(args: { + userId: string; + cacheActions: readonly string[]; + since: Date; +}): Promise> { + const byKey = new Map(); + for (const action of args.cacheActions) { + const key = statusCacheKey(action); + if (key) byKey.set(`${key.metric}\u0000${key.locale}`, action); } - return null; + if (byKey.size === 0) return new Set(); + const keys = [...byKey.keys()].map((k) => { + const [metric, locale] = k.split("\u0000"); + return { metric, locale }; + }); + const rows = await prisma.insightStatusCache.findMany({ + where: { + userId: args.userId, + OR: keys, + generatedAt: { gte: args.since }, + textEncrypted: { not: null }, + }, + select: { metric: true, locale: true }, + }); + const fresh = new Set(); + for (const row of rows) { + const action = byKey.get(`${row.metric}\u0000${row.locale}`); + if (action) fresh.add(action); + } + return fresh; } /** * Outcome of the read-only cache-miss resolution. The generators map this * onto their public return shape: - * - `no-provider` → `{ hasProvider: false, text: }` - * - `preparing` → `{ hasProvider: true, text: , preparing: true }` * - * v1.8.7 — `preparing` now carries the last good assessment (if any) so - * the card renders the previous text immediately (stale-while-revalidate) - * instead of a skeleton while the worker re-warms the cache. - * - * v1.9.0 — `revalidating` is true only when last-good text is served AND a - * fresh generation was actually enqueued (the open-card refresh case). When - * the served text is terminal the card otherwise stops polling and never - * sees the freshly-warmed assessment until a remount; the flag keeps the - * bounded poll alive until the new row lands. It is false on the - * suppressed-stub branch (no enqueue, so nothing is in flight) and whenever - * there is no last-good text (that path already polls via `preparing`). + * - `unavailable` → the deterministic line, no enqueue. `hasProvider` is + * provider presence and nothing else, so a withdrawn consent or a switch + * turned off no longer reads as "no provider configured"; `reason` says + * which layer said no. + * - `preparing` → a generation is (or recently was) in flight; the last + * note, if any, is served meanwhile. `revalidating` is true only when a + * last note is served AND a fresh generation was actually enqueued, so + * an open card keeps polling until the new note lands. */ export type ReadOnlyMissOutcome = - | { kind: "no-provider" } - // v1.16.13 — a provider IS configured, but it resolves via the operator's - // server-managed key and the user has no active consent receipt, so the - // generation gate (`assertConsentForChain` / `runStatusCompletion`) would - // serve the no-key fallback. Distinct from `no-provider` so the status DTO - // can render an honest "consent required" signal instead of conflating it - // with "no AI configured at all". - | { kind: "consent-missing" } + | { + kind: "unavailable"; + reason: AiUnavailableReason; + hasProvider: boolean; + } | { kind: "preparing"; lastGood: LastGoodStatusHit | null; @@ -546,34 +694,39 @@ export type ReadOnlyMissOutcome = }; /** - * v1.8.3 — resolve what a read-only status generation should return on a - * cache miss WITHOUT running the heavy SQL gather or the blocking LLM - * round-trip. This is the core of the holistic freeze fix: a navigation - * request must never await an uncapped provider call. + * Resolve what a read-only status read returns on a cache miss WITHOUT the + * heavy SQL gather or a blocking model round-trip: a navigation request never + * awaits a provider. * - * On a miss the route either: - * - finds no usable provider → returns `no-provider` (the card shows the - * no-key fallback; nothing to generate), or - * - finds a provider → fire-and-forget enqueues a generation job and - * returns `preparing` (the card shows a preparing state and the client - * polls until the worker warms the cache). - * - * The provider probe is a cheap chain-resolve (no completion), so the GET - * stays sub-second even on a cold cache. + * The `statusText` capability decides first. Unavailable, for any reason, + * means no enqueue and no stored text. Available means: serve the last note + * (if any) and enqueue a generation out of band, unless a recent stall opened + * a negative-cache window. */ export async function resolveReadOnlyStatusMiss(args: { userId: string; metric: InsightStatusScope; locale: SupportedLocale; }): Promise { - // v1.37.0 — the one gate on the delegated path, and it sits here rather - // than in the ten generators that reach this function precisely because it - // is one gate: a generator added next year inherits it without knowing it - // exists. A manager holding a MANAGE grant may READ the record's generated - // assessments; the miss behind that read must not ship the owner's data to - // the owner's provider on the owner's budget. Served the same way the - // negative-cache branch below serves it: last-good text if there is any, - // `preparing` if there is not, and nothing enqueued either way. + const capability = await aiCapabilityForRecord(args.userId, "statusText"); + if (!capability.available) { + const reason = capability.reason ?? "check_failed"; + annotate({ + action: { name: "insights.status.unavailable" }, + meta: { metric: args.metric, reason, read_only_miss: true }, + }); + return { + kind: "unavailable", + reason, + hasProvider: await probeProviderPresence(args.userId, "text"), + }; + } + + // v1.37.0 — a manager holding a MANAGE grant may READ the record's notes; + // the miss behind that read must not ship the owner's data to the owner's + // provider on the owner's budget. The last note if there is one, + // `preparing` if not, and nothing enqueued either way. One gate here rather + // than in every generator, so a generator added later inherits it. if (delegatedGenerationSuppressed()) { const lastGood = await readLastGoodStatusText({ userId: args.userId, @@ -590,39 +743,14 @@ export async function resolveReadOnlyStatusMiss(args: { return { kind: "preparing", lastGood, revalidating: false }; } - const hasProvider = await hasUsableStatusProvider(args.userId); - if (!hasProvider) return { kind: "no-provider" }; - - // v1.16.13 — a provider is configured but the server-managed consent gate - // would block egress (no active receipt for the surface). Surface this as - // a distinct outcome so the card renders an honest "consent required" - // signal rather than the generic no-key fallback. Enqueuing here would be - // wasted work — the generator's own gate would short-circuit to `none`. - if (await statusConsentBlocksGeneration(args.userId, "insights")) { - annotate({ - action: { name: "insights.status.consent_required" }, - meta: { metric: args.metric, read_only_miss: true }, - }); - return { kind: "consent-missing" }; - } - const cacheAction = statusCacheAction(args.metric, args.locale); - - // v1.8.7 — stale-while-revalidate. Surface the last good (non-stub) - // assessment for this scope so the card renders the previous text - // immediately instead of a skeleton while a refresh is warmed. Null when - // no assessment was ever produced — only then does the card show - // "preparing"/"no analysis yet". const lastGood = await readLastGoodStatusText({ userId: args.userId, cacheAction, }); - // v1.8.3 — honour the short-TTL negative cache. If the worker recently - // hit a provider stall it wrote a `retryAt` stub; re-enqueuing on every - // navigation while the provider is still degraded would be a storm. While - // the stub is fresh, stay in `preparing` without enqueuing; once it goes - // stale (or never existed) enqueue a fresh generation. + // Honour the short negative-cache window: re-enqueuing on every navigation + // while the provider is still degraded would be a storm. const negativeCache = await readStatusNegativeCache({ userId: args.userId, cacheAction, @@ -632,14 +760,11 @@ export async function resolveReadOnlyStatusMiss(args: { action: { name: "insights.status.preparing" }, meta: { metric: args.metric, suppressed_enqueue: true }, }); - // No enqueue on this branch — nothing is in flight, so the open card has - // nothing to revalidate against. It still polls via `preparing` when - // there is no last-good text to show. return { kind: "preparing", lastGood, revalidating: false }; } - // Enqueue out of band — do NOT await an LLM here. The enqueue itself is - // best-effort and de-duped per (user, metric, locale). + // Enqueue out of band, never await a model here. Best-effort and de-duped + // per (user, metric). await enqueueStatusGeneration({ userId: args.userId, metric: args.metric, @@ -649,20 +774,10 @@ export async function resolveReadOnlyStatusMiss(args: { action: { name: "insights.status.preparing" }, meta: { metric: args.metric, stale_served: lastGood !== null }, }); - // A fresh generation is now in flight. When last-good text is served the - // payload is otherwise terminal (`preparing` is false), so the open card - // would stop polling and never pick up the warmed assessment. Signal - // `revalidating` so the bounded poll stays alive until the new row lands. return { kind: "preparing", lastGood, revalidating: lastGood !== null }; } -/** - * True when the most recent cache row for `(userId, cacheAction)` is a - * timeout stub whose `retryAt` is still in the future. Used by the - * read-only resolver to suppress a re-enqueue storm while a provider is - * degraded. A stub without `retryAt` (legacy) is treated as stale so the - * resolver retries, matching the pre-v1.8.3 "transient miss" behaviour. - */ +/** An open negative-cache window on a note. */ export interface StatusNegativeCache { kind: "negative"; reason: "timeout" | "error" | "screened"; @@ -671,38 +786,23 @@ export interface StatusNegativeCache { } /** - * Read the latest negative-cache policy without exposing provider errors or - * assessment content. `retryable` flips only when the explicit `retryAt` - * boundary has passed; legacy/malformed stubs are treated as absent so the - * next request may recover. + * The note's negative-cache window, without exposing provider errors or note + * content. `retryable` flips once `retryAt` has passed. */ export async function readStatusNegativeCache(args: { userId: string; cacheAction: string; }): Promise { - const latest = await prisma.auditLog.findFirst({ - where: { userId: args.userId, action: args.cacheAction }, - orderBy: { createdAt: "desc" }, - select: { details: true }, - }); - if (!latest?.details) return null; - try { - const parsed = JSON.parse(latest.details) as ParsedStatusCache; - if (!isTimeoutStub(parsed)) return null; - if (typeof parsed.retryAt !== "string") return null; - const retryAtMs = new Date(parsed.retryAt).getTime(); - if (!Number.isFinite(retryAtMs)) return null; - const reason = - parsed.reason === "error" || parsed.reason === "screened" - ? parsed.reason - : "timeout"; - return { - kind: "negative", - reason, - retryAt: parsed.retryAt, - retryable: retryAtMs <= Date.now(), - }; - } catch { - return null; - } + const note = await readStatusNote(args.userId, args.cacheAction); + if (!note?.retryAt) return null; + const reason = + note.negativeReason === "error" || note.negativeReason === "screened" + ? note.negativeReason + : "timeout"; + return { + kind: "negative", + reason, + retryAt: note.retryAt.toISOString(), + retryable: note.retryAt.getTime() <= Date.now(), + }; } diff --git a/src/lib/insights/status-card-generation.ts b/src/lib/insights/status-card-generation.ts index e2e6e3d93..4439c7552 100644 --- a/src/lib/insights/status-card-generation.ts +++ b/src/lib/insights/status-card-generation.ts @@ -32,7 +32,7 @@ * for falls back to its own single-card path, never crashing the cycle. * * The cache + read contract is untouched: `finalize` persists the SAME - * `auditLog` row (`persistStatusInsight`, or the richer medication-compliance + * status note (`persistStatusInsight`, or the per-medication items * shape via a custom closure) the standalone generator wrote, so every card * keeps reading per-metric results with the same keys. */ @@ -125,7 +125,7 @@ export async function runPreparedStatusCard( const outcome: StatusProviderResult = await runStatusCompletion({ userId: prepared.userId, cacheAction: prepared.cacheAction, - consentSurface: "insights", + capability: "statusText", systemPrompt: prepared.systemPrompt, userPrompt: prepared.userPrompt, temperature: prepared.temperature, diff --git a/src/lib/insights/status-invalidation.ts b/src/lib/insights/status-invalidation.ts index 977dea890..373e50df7 100644 --- a/src/lib/insights/status-invalidation.ts +++ b/src/lib/insights/status-invalidation.ts @@ -20,12 +20,16 @@ import { normalizeLocale, type SupportedLocale, } from "@/lib/insights/status-shared"; -import { isTimeoutStub, statusCacheAction } from "@/lib/insights/status-cache"; +import { + statusCacheAction, + statusNotesWrittenSince, +} from "@/lib/insights/status-cache"; import { metricIdForMeasurementType, metricStatusScope, } from "@/lib/insights/metric-status-registry"; import { annotate } from "@/lib/logging/context"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; import type { MeasurementType } from "@/generated/prisma/client"; /** @@ -146,6 +150,17 @@ export async function invalidateStatusInsightsForTypes( } if (scopes.size === 0) return; + // No enqueue while status notes are unavailable for this record: the worker + // would refuse anyway, and the queue should not carry work nobody may do. + const capability = await aiCapabilityForRecord(userId, "statusText"); + if (!capability.available) { + annotate({ + action: { name: "insights.status.invalidate.skipped" }, + meta: { reason: capability.reason }, + }); + return; + } + // v1.8.7 — regenerate only the user's resolved locale, matching the // read-path (every `*-status` GET serves `normalizeLocale(user.locale)`). // Warming both locales doubled provider spend on every sync, half of it @@ -223,6 +238,14 @@ export async function enqueueStatusRefillForUser( userId: string, locale: SupportedLocale, ): Promise { + const capability = await aiCapabilityForRecord(userId, "statusText"); + if (!capability.available) { + annotate({ + action: { name: "insights.status.refill.skipped" }, + meta: { reason: capability.reason }, + }); + return 0; + } const scopes = new Set(PER_STATUS_SCOPES); try { // `groupBy` compiles to a server-side `GROUP BY` — Prisma's @@ -252,11 +275,10 @@ export async function enqueueStatusRefillForUser( * real (non-stub) assessment. Those scopes are skipped by the ingest * invalidator so a fresh assessment survives the sync drip. * - * One indexed read per user (a single `findMany` over this user's recent - * status-cache rows, newest-first) answers it for every candidate scope at - * once — cheaper than a per-scope probe and bounded by `take`. A timeout - * stub never counts as fresh (it carries no real assessment), so a scope - * that recently stalled still gets a retry enqueued. + * One indexed read per user answers it for every candidate scope at once. + * Only a stored note counts: a row that carries nothing but a negative-cache + * window is not fresh, so a scope that recently stalled still gets a retry + * enqueued. */ async function findRecentlyWarmedScopes( userId: string, @@ -273,37 +295,15 @@ async function findRecentlyWarmedScopes( actionToScope.set(statusCacheAction(scope, locale), scope); } - const rows = await prisma.auditLog.findMany({ - where: { - userId, - action: { in: candidateActions }, - createdAt: { gte: cutoff }, - }, - orderBy: { createdAt: "desc" }, - select: { action: true, details: true }, + const written = await statusNotesWrittenSince({ + userId, + cacheActions: candidateActions, + since: cutoff, }); - const fresh = new Set(); - for (const row of rows) { - const scope = actionToScope.get(row.action); - if (!scope || fresh.has(scope)) continue; - if (!row.details) continue; - try { - const parsed = JSON.parse(row.details) as { - model?: string; - timeout?: boolean; - text?: string; - }; - // A stub is not a real assessment — let a stalled scope retry. - if (isTimeoutStub(parsed)) continue; - if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { - continue; - } - fresh.add(scope); - } catch { - // Malformed payload — treat as not-fresh so the scope refreshes. - continue; - } + for (const action of written) { + const scope = actionToScope.get(action); + if (scope) fresh.add(scope); } return fresh; } diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index bb187ab5d..bb0342bca 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -1,13 +1,18 @@ -import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; +import { + probeProviderPresence, + resolveProvider, + resolveProviderChain, +} from "@/lib/ai/provider"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; +import type { + AiCapabilityKey, + AiUnavailableReason, +} from "@/lib/ai/capabilities/types"; import { runRawCompletionWithFallback, type ProviderChainResolved, } from "@/lib/ai/provider-runner"; -import { - chainRequiresServerManagedConsent, - hasActiveConsentForSurface, - type ConsentSurface, -} from "@/lib/ai/consent-guard"; +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; import { annotate } from "@/lib/logging/context"; import { buildDateKey, @@ -42,7 +47,12 @@ import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout"; */ export type StatusProviderResult = - | { kind: "none" } + /** + * Nothing was sent: no provider, or the capability is unavailable for this + * record (operator switch, opt-out, module, consent, …). Callers serve the + * deterministic line and persist nothing; `reason` says which layer said no. + */ + | { kind: "none"; reason: AiUnavailableReason } | { kind: "timeout" } | { kind: "error" } | { @@ -74,15 +84,14 @@ interface RunStatusCompletionArgs { */ responseFormat?: "json" | "text"; /** - * v1.12.1 — which AI surface this generation serves, for the consent gate. - * `insights` for the per-metric status cards + period narrative; `coach` - * for the off-budget Coach memory workers (rolling summary + fact - * extraction). When the resolved chain would egress via the operator's - * server-managed key and no active receipt of the matching kind exists, - * the run short-circuits to `{ kind: "none" }` (the no-key fallback) so no - * PHI leaves the server. BYOK / local / ChatGPT-OAuth chains are ungated. + * The AI capability this generation serves (`statusText`, `periodNarrative`, + * `workoutInsights`, `coach`, …). Required: the chokepoint re-checks it + * immediately before anything leaves the machine, so a job enqueued before + * an operator flipped a switch, or before the person withdrew consent or + * turned AI analysis off, still stops here. The capability also names the + * consent kinds an operator-held chain entry needs. */ - consentSurface: ConsentSurface; + capability: AiCapabilityKey; } /** @@ -103,42 +112,14 @@ async function resolveStatusChain( } /** - * Cheap provider-availability probe for the read-only status path. - * - * v1.8.3 — when a status route runs in read-only mode (serve cache, never - * block on the LLM) it still has to tell the difference between "no - * provider — show the no-key fallback" and "provider configured but the - * assessment isn't warm yet — show preparing + enqueue a generation". This - * resolves the same chain `runStatusCompletion` would, but does NOT run a - * completion, so the navigation request never awaits an LLM round-trip. + * Whether any configured provider could serve text for `userId`: the one + * presence probe (`probeProviderPresence`). No client construction, no token + * refresh, no network. */ export async function hasUsableStatusProvider( userId: string, ): Promise { - return (await resolveStatusChain(userId)) !== null; -} - -/** - * v1.16.8 — true when a status generation for `userId` would be blocked - * by the server-managed consent gate (the same check `runStatusCompletion` - * applies before egress). The content-hash gate - * (`refreshUnchangedStatusInsight`) consults this BEFORE re-stamping a - * cached assessment as current: after a consent revocation the unchanged- - * data refresh must fall through to the generator, whose own gate then - * serves the no-key fallback instead of presenting old AI text as fresh. - * `false` when no provider is configured at all — that path already - * resolves to the fallback via `{ kind: "none" }`. - */ -export async function statusConsentBlocksGeneration( - userId: string, - surface: ConsentSurface, -): Promise { - const chain = await resolveStatusChain(userId); - if (chain === null) return false; - return ( - chainRequiresServerManagedConsent(chain) && - !(await hasActiveConsentForSurface(userId, surface)) - ); + return probeProviderPresence(userId, "text"); } /** @@ -154,27 +135,46 @@ export async function runStatusCompletion( ): Promise { const { userId, cacheAction, systemPrompt, userPrompt } = args; - const chain = await resolveStatusChain(userId); - if (chain === null) { - return { kind: "none" }; + // The capability, re-checked at the wire. Every caller resolved it earlier + // (a worker before building its snapshot, a route before enqueueing), but + // switches, opt-outs and consent can change between the enqueue and here. + const capability = await aiCapabilityForRecord(userId, args.capability); + if (!capability.available) { + const reason = capability.reason ?? "check_failed"; + annotate({ + action: { name: "insights.status.capability_unavailable" }, + meta: { cacheAction, capability: args.capability, reason }, + }); + return { kind: "none", reason }; } - // v1.12.1 — consent gate before server-managed external egress. A chain - // that could egress via the operator's global key requires an active - // receipt of the surface's mapped kind (or master `ai_full`). Without one, - // surface the no-key fallback (`none`) rather than egress the snapshot — - // identical to a missing provider from the caller's perspective, so no - // generator branch needs to change. BYOK / local / ChatGPT-OAuth chains - // never trip this. - if ( - chainRequiresServerManagedConsent(chain) && - !(await hasActiveConsentForSurface(userId, args.consentSurface)) - ) { + const chain = await resolveStatusChain(userId); + if (chain === null) { + return { kind: "none", reason: "no_provider" }; + } + + // The wire re-check for exactly this chain (`aiEgressRefusal`, the one + // helper every chokepoint uses): the capability again, and whether sending + // to these providers needs a consent receipt under the capability's rule + // (the operator's key or the shared central Codex does; a person's own key + // or a local model does not). Fails closed like the rest: a chain that + // COULD cascade to an operator-held entry needs the receipt. + const refusal = await aiEgressRefusal( + args.capability, + userId, + chain.map((entry) => entry.providerType), + ); + if (refusal) { annotate({ - action: { name: "insights.status.consent_required" }, - meta: { cacheAction, surface: args.consentSurface }, + action: { name: "insights.status.capability_unavailable" }, + meta: { + cacheAction, + capability: args.capability, + reason: refusal.reason, + at: "wire", + }, }); - return { kind: "none" }; + return { kind: "none", reason: refusal.reason }; } // Honour the per-user response-timeout setting the operator dials in for a diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts index 0dc0316ff..8e8b8fc90 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -4,8 +4,7 @@ * Every `src/lib/insights/*-status.ts` generator carried byte-identical * copies of `round`, `normalizeSummaryText`, `normalizeLocale`, and (in * six of seven) `summarizeSeries`, plus an identical "parse the model's - * `{summary}` envelope" block and an identical `prisma.auditLog.create` - * persist block. This module is the single source of truth so a change + * `{summary}` envelope" block and an identical persist block. This module is the single source of truth so a change * to the rounding precision, the chart-token scrub, or the cache-row * shape lands once rather than seven times. * @@ -14,12 +13,12 @@ * reader's own language in an explicit output directive, so the reader's * locale must survive the whole pipeline rather than being collapsed to a * binary on the way in. The - * medication-compliance generator writes a richer cache `details` shape - * (it carries a per-medication array), so it shares `round`, - * `normalizeSummaryText`, `normalizeLocale`, and `parseSummaryFromContent` - * but keeps its own `auditLog.create`. + * medication-compliance generator stores per-medication lines next to its + * summary, so it shares `round`, `normalizeSummaryText`, `normalizeLocale`, + * and `parseSummaryFromContent` but writes its note through `writeStatusNote` + * directly. */ -import { prisma } from "@/lib/db"; +import { writeStatusNote } from "@/lib/insights/status-cache"; import { locales, type Locale } from "@/lib/i18n/config"; import { extractJsonObject } from "@/lib/ai/json-extract"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; @@ -306,62 +305,37 @@ export function parseSummaryFromContent(content: string): string { } /** - * Persist one status assessment cache row in the standard text-only - * shape (`{ dateKey, locale, text, providerType, model, tokensUsed }`). - * Returns the row's `createdAt` ISO string. The medication-compliance - * generator writes a richer `details` shape (per-medication array) and - * keeps its own `auditLog.create` call. + * Store one status note (`InsightStatusCache`, encrypted) for today and return + * when it was written. The medication-compliance generator also stores its + * per-medication lines and goes through `writeStatusNote` directly. */ export async function persistStatusInsight(args: { userId: string; cacheAction: string; todayKey: string; - locale: SupportedLocale; text: string; - providerType: string; - model: string; - tokensUsed: number | null; /** - * v1.16.8 — fingerprint of the data snapshot this assessment was - * generated from (see `snapshot-hash.ts`). The regeneration gate - * compares the fresh snapshot's hash against this and skips the - * provider call when nothing changed. + * Fingerprint of the data snapshot this note was written from (see + * `snapshot-hash.ts`). The regeneration gate compares the next snapshot's + * hash against it and skips the provider call when nothing changed. */ snapshotHash?: string; /** - * v1.18.11 (P6) — cheap fingerprint of the SALIENT INPUTS (per-type - * count + newest measuredAt) for slow-moving metrics. The input gate - * compares a freshly probed fingerprint against this BEFORE the heavy - * snapshot build, so a no-change day for weight/BMI skips the whole - * gather (not just the provider call). Absent on metrics that don't - * opt into the input gate. + * Cheap fingerprint of the SALIENT INPUTS (per-type count + newest + * measuredAt) for slow-moving metrics. The input gate compares a freshly + * probed fingerprint against it BEFORE the heavy snapshot build. */ inputHash?: string; }): Promise { - const created = await prisma.auditLog.create({ - data: { - userId: args.userId, - action: args.cacheAction, - details: JSON.stringify({ - dateKey: args.todayKey, - locale: args.locale, - text: args.text, - providerType: args.providerType, - model: args.model, - tokensUsed: args.tokensUsed, - statusKind: "generated", - retryable: false, - // Freshness is defined by the user's calendar day. Keeping the - // boundary in the payload makes expiry explicit without inventing a - // UTC timestamp for a day key that belongs to another timezone. - expiresAfterDateKey: args.todayKey, - ...(args.snapshotHash ? { snapshotHash: args.snapshotHash } : {}), - ...(args.inputHash ? { inputHash: args.inputHash } : {}), - }), - }, - select: { createdAt: true }, + const generatedAt = await writeStatusNote({ + userId: args.userId, + cacheAction: args.cacheAction, + todayKey: args.todayKey, + text: args.text, + snapshotHash: args.snapshotHash, + inputHash: args.inputHash, }); - return created.createdAt.toISOString(); + return generatedAt.toISOString(); } /** diff --git a/src/lib/insights/timeout-fallback.ts b/src/lib/insights/timeout-fallback.ts index 72c3956e4..7b7010e01 100644 --- a/src/lib/insights/timeout-fallback.ts +++ b/src/lib/insights/timeout-fallback.ts @@ -1,4 +1,4 @@ -import { prisma } from "@/lib/db"; +import { writeStatusNegativeWindow } from "@/lib/insights/status-cache"; import { annotate } from "@/lib/logging/context"; /** @@ -16,13 +16,12 @@ import { annotate } from "@/lib/logging/context"; * status route is now read-only: a cache miss enqueues an out-of-band * generation. If the provider stalls inside the worker, nothing stops the * next navigation from enqueuing again, and again — a stalled provider - * turns into a re-enqueue storm. So the timeout path writes a short-TTL - * negative stub (`{ timeout:true, model:"timeout-stub", retryAt }`). The - * read-only resolver honours it: while the stub is fresh it returns - * `preparing` WITHOUT re-enqueuing; once `retryAt` passes the stub is stale - * and the next visit re-attempts. The stub is NEVER served as assessment - * text — `readFreshStatusText` still rejects every timeout stub by marker, - * so a transient stall can't hide the real assessment for the day. + * turns into a re-enqueue storm. So the timeout path opens a short + * negative-cache window on the note (`retryAt`, `negativeReason`). The + * read-only resolver honours it: while the window is open it returns + * `preparing` WITHOUT re-enqueuing; once `retryAt` passes the next visit + * re-attempts. The window never replaces or hides a stored note, so a + * transient stall can't hide the real assessment for the day. */ /** @@ -53,20 +52,12 @@ export async function persistTimeoutNegativeStub(args: { reason: StatusFallbackReason; }): Promise { try { - await prisma.auditLog.create({ - data: { - userId: args.userId, - action: args.cacheAction, - details: JSON.stringify({ - dateKey: args.todayKey, - timeout: true, - model: "timeout-stub", - reason: args.reason, - retryAt: new Date( - Date.now() + TIMEOUT_NEGATIVE_CACHE_MS, - ).toISOString(), - }), - }, + await writeStatusNegativeWindow({ + userId: args.userId, + cacheAction: args.cacheAction, + todayKey: args.todayKey, + reason: args.reason, + retryAt: new Date(Date.now() + TIMEOUT_NEGATIVE_CACHE_MS), }); } catch { // Negative cache is best-effort — never throw out of the timeout path. diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 607e334bb..9b818a3f9 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -193,13 +193,13 @@ export async function prepareWeightStatusForUser( metric: "weight", locale, }); - // v1.16.13 — `consent-missing` serves the same no-key fallback (see - // bmi-status); no enqueue happens for it. - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason serves the deterministic line and enqueues + // nothing. `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { phase: "served", result: { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyWeightStatusText(locale), cached: true, updatedAt: null, @@ -668,11 +668,7 @@ export async function prepareWeightStatusForUser( userId, cacheAction, todayKey, - locale, text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, snapshotHash, // v1.18.11 (P6) — persist the input fingerprint so tomorrow's input // gate can skip the rebuild when nothing salient changed. 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..76f7a55f0 100644 --- a/src/lib/jobs/__tests__/async-state-contract.test.ts +++ b/src/lib/jobs/__tests__/async-state-contract.test.ts @@ -62,15 +62,9 @@ vi.mock("@/lib/documents/auto-stage-labs", () => ({ vi.mock("@/lib/labs/ocr-upload", () => ({ detectOcrMimeType: vi.fn(() => "application/pdf"), })); -vi.mock("@/lib/ai/consent-guard", () => { - class ConsentRequiredError extends Error {} - return { - ConsentRequiredError, - assertDocumentEgressConsent: vi.fn(), - isExternalDocumentEgress: (providerType: string) => - providerType !== "local", - }; -}); +vi.mock("@/lib/ai/consent-guard", () => ({ + isExternalDocumentEgress: (providerType: string) => providerType !== "local", +})); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-08-13"), reserveBudget: vi.fn(), @@ -79,6 +73,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 }, @@ -104,11 +102,12 @@ import { transcribeDocument, } from "@/lib/documents/describe"; import { localExtractText } from "@/lib/documents/local-extract"; -import { - assertDocumentEgressConsent, - ConsentRequiredError, -} from "@/lib/ai/consent-guard"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { reserveBudget } from "@/lib/ai/coach/budget"; +import { + aiCapabilityForJob, + aiCapabilityForRecord, +} from "@/lib/ai/capabilities/gate"; const DOC = { id: "doc-1", @@ -121,6 +120,7 @@ const DOC = { const PICK = { chain: [{ providerType: "anthropic", instance: {} }], + withheld: null, pick: { entry: { providerType: "anthropic", instance: {} }, providerType: "anthropic", @@ -163,8 +163,14 @@ 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); vi.mocked(prepareVisionInput).mockResolvedValue({ ok: true, @@ -205,21 +211,66 @@ describe("contract (a): every runDocumentSummaryJob branch writes a terminal sta state: "NONE", }, { - name: "no provider configured", + // Presence says no provider, but that is the pick's to decide; with no + // pick either, the attempt is recorded as one that could not run. + name: "documentAi unavailable for want of a provider, no pick", setup: () => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "no_provider", + onDeviceAllowed: true, + }); vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], pick: null, + withheld: null, } as never); }, state: "UNAVAILABLE", }, { - name: "consent refused", + // Presence says a receipt is missing, but the document order lands on a + // pick that needs none: the pick decides, and the summary is written. + name: "documentAi presence wants a receipt the pick does not need", setup: () => { - vi.mocked(assertDocumentEgressConsent).mockRejectedValue( - new ConsentRequiredError("insights"), - ); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: true, + }); + }, + state: "READY", + }, + { + 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: () => { + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ + chain: [], + pick: null, + withheld: null, + } as never); + }, + state: "UNAVAILABLE", + }, + { + name: "consent refused at the pick's wire re-check", + setup: () => { + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ + chain: PICK.chain, + pick: null, + withheld: new AiUnavailableError("documentAi", "consent_required"), + } as never); }, state: "UNAVAILABLE", }, @@ -309,6 +360,7 @@ describe("contract (a): every runDocumentIndex branch records the attempt", () = vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], pick: null, + withheld: null, } as never); }, outcome: null, @@ -319,6 +371,7 @@ describe("contract (a): every runDocumentIndex branch records the attempt", () = vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], pick: null, + withheld: null, } as never); vi.mocked(localExtractText).mockResolvedValue({ ok: false, @@ -404,6 +457,39 @@ describe("contract (b): enqueue never claims a pending state its job cannot reso expect(summaryStateWrites()).toEqual([]); }); + it("enqueueDocumentSummary with documentAi switched off claims nothing and mints no job", async () => { + vi.mocked(aiCapabilityForRecord).mockResolvedValue({ + available: false, + reason: "operator_disabled", + 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(result).toEqual({ enqueued: false }); + expect(send).not.toHaveBeenCalled(); + expect(summaryStateWrites()).toEqual([]); + }); + + it("enqueueDocumentSummary leaves a missing receipt to the job's pick, whose every branch is terminal", async () => { + // Presence cannot say whether the document-order pick needs a receipt, so + // the enqueue does not refuse on it; contract (a) pins that the job then + // writes a terminal state for both a withheld pick and a served one. + 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: true }); + expect(send).toHaveBeenCalledTimes(1); + expect(summaryStateWrites()).toContain("PENDING"); + }); + 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..ab941a0cc 100644 --- a/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts @@ -10,15 +10,25 @@ * The gate is skip-shaped: no receipt → `null` → the caller ships the * deterministic template, so the nudge itself is never lost. * - * `@/lib/consent/receipts` is mocked rather than the consent guard, so the real - * `chainRequiresServerManagedConsent` + `hasActiveConsentForSurface` logic runs. + * Only the receipt table (`prisma.consentReceipt`) is mocked, not the wire + * re-check, so the real `aiEgressRefusal` rule runs: the capability again (a + * job, so `aiCapabilityForJob`), then whether exactly this chain needs a + * receipt under the `coach` capability's rule, then whether one is active. */ import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("@/lib/db", () => ({ prisma: {} })); - -const latestActiveReceipt = vi.hoisted(() => vi.fn()); -vi.mock("@/lib/consent/receipts", () => ({ latestActiveReceipt })); +/** The active receipt kinds the record holds; `findFirst` answers from them. */ +const activeKinds = vi.hoisted(() => ({ current: [] as string[] })); +const consentReceiptFindFirst = vi.hoisted(() => + vi.fn(async (args: { where: { kind: { in: string[] } } }) => + args.where.kind.in.some((kind) => activeKinds.current.includes(kind)) + ? { id: "receipt-1" } + : null, + ), +); +vi.mock("@/lib/db", () => ({ + prisma: { consentReceipt: { findFirst: consentReceiptFindFirst } }, +})); vi.mock("@/lib/documents/document-settings", () => ({ documentAutoReadEnabled: vi.fn(async () => false), })); @@ -40,13 +50,27 @@ const budgetMocks = vi.hoisted(() => ({ })); vi.mock("@/lib/ai/coach/budget", () => budgetMocks); -vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +// No request event: the nudge runs off-request, which is the arm of +// `aiEgressRefusal` that resolves the capability for the job's record. +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => undefined), +})); + +// 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, type ComposeNudgeParams, } from "../coach-nudge-ai"; +import { annotate } from "@/lib/logging/context"; function makeProvider() { return { @@ -77,7 +101,12 @@ function params(): ComposeNudgeParams { beforeEach(() => { vi.clearAllMocks(); provider = makeProvider(); - latestActiveReceipt.mockResolvedValue(null); + activeKinds.current = []; + aiCapabilityForJob.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); budgetMocks.reserveBudget.mockResolvedValue({ allowed: true, reserved: 160, @@ -125,9 +154,7 @@ describe("composeNudgeWithAI — server-managed consent gate", () => { resolveProviderChain.mockResolvedValue([ { providerType: "admin-openai", instance: provider }, ]); - latestActiveReceipt.mockImplementation(async (_userId, kind) => - kind === "ai_coach" ? { id: "receipt-1" } : null, - ); + activeKinds.current = ["ai_coach"]; const out = await composeNudgeWithAI(params()); @@ -142,14 +169,26 @@ describe("composeNudgeWithAI — server-managed consent gate", () => { resolveProviderChain.mockResolvedValue([ { providerType: "admin-openai", instance: provider }, ]); - latestActiveReceipt.mockImplementation(async (_userId, kind) => - kind === "ai_full" ? { id: "receipt-2" } : null, - ); + activeKinds.current = ["ai_full"]; expect(await composeNudgeWithAI(params())).not.toBeNull(); expect(provider.generateCompletion).toHaveBeenCalledTimes(1); }); + it("does not count a receipt of an unrelated kind", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "admin-openai", instance: provider }, + ]); + activeKinds.current = ["ai_extraction"]; + + expect(await composeNudgeWithAI(params())).toBeNull(); + expect(provider.generateCompletion).not.toHaveBeenCalled(); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "coach.nudge.ai.refused" }, + meta: { reason: "consent_required" }, + }); + }); + it("leaves a BYOK chain ungated — the user's own egress needs no receipt", async () => { resolveProviderChain.mockResolvedValue([ { providerType: "openai", instance: provider }, @@ -157,7 +196,7 @@ describe("composeNudgeWithAI — server-managed consent gate", () => { expect(await composeNudgeWithAI(params())).not.toBeNull(); expect(provider.generateCompletion).toHaveBeenCalledTimes(1); - expect(latestActiveReceipt).not.toHaveBeenCalled(); + expect(consentReceiptFindFirst).not.toHaveBeenCalled(); }); it("fails closed when a server-managed entry sits BEHIND a BYOK primary", async () => { @@ -169,4 +208,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..ca33cee73 100644 --- a/src/lib/jobs/__tests__/document-content-index-backfill.test.ts +++ b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; /** - * Content-index backfill (Document vault P2). Pins: no provider / no consent → - * clean no-op; indexes not-yet-indexed docs; stops when the budget is reached + * Content-index backfill (Document vault P2). Pins: an operator / module / + * personal switch off → refused before the pick; no provider or a pick the + * wire re-check withholds (no consent, a switch) → clean no-op; indexes not-yet-indexed docs; stops when the budget is reached * (resumable); skips are bounded by the MIME-filtered candidate set; rows on a * stale `tokenizerVersion` are re-tokenised locally (the column's consumer) * while current-version rows are left alone. @@ -40,9 +41,9 @@ vi.mock("@/lib/documents/content-index", () => ({ vi.mock("@/lib/documents/provider-order", () => ({ resolveDocumentVisionProvider: vi.fn(), })); -vi.mock("@/lib/ai/consent-guard", () => ({ - assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), - 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"), @@ -60,11 +61,9 @@ import { detectOcrMimeType } from "@/lib/labs/ocr-upload"; import { rasterizePdf } from "@/lib/documents/rasterize-pdf"; import { transcribeDocument } from "@/lib/documents/describe"; import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order"; -import { - assertDocumentEgressConsent, - ConsentRequiredError, -} from "@/lib/ai/consent-guard"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { reserveBudget } from "@/lib/ai/coach/budget"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { decryptIndexText, upsertContentIndex, @@ -72,6 +71,7 @@ import { const PICK = { chain: [{ providerType: "anthropic", instance: {} }], + withheld: null, pick: { entry: { providerType: "anthropic", instance: {} }, providerType: "anthropic", @@ -90,6 +90,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, @@ -136,6 +141,7 @@ describe("runContentIndexBackfillForUser", () => { it("no-ops with no provider", async () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], + withheld: null, pick: null, } as never); const result = await runContentIndexBackfillForUser("user-1"); @@ -149,21 +155,102 @@ describe("runContentIndexBackfillForUser", () => { expect(upsertContentIndex).not.toHaveBeenCalled(); }); - it("no-ops when consent is missing", async () => { - vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); - vi.mocked(assertDocumentEgressConsent).mockRejectedValueOnce( - new ConsentRequiredError("insights" as never), - ); - const result = await runContentIndexBackfillForUser("user-1"); - expect(result).toEqual({ - indexed: 0, - retokenised: 0, - skipped: 0, - failed: 0, - reason: "no-consent", - }); - expect(upsertContentIndex).not.toHaveBeenCalled(); - }); + it.each([ + ["consent_required", "no-consent"], + ["operator_disabled", "unavailable"], + ["module_disabled", "unavailable"], + ] as const)( + "no-ops when the wire re-check withholds the pick (%s → %s)", + async (reason, summaryReason) => { + // The pick's own re-check answers for exactly the provider picked: an + // external pick without an extraction receipt, or a switch turned off + // between the pass-level check and the pick. + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ + chain: PICK.chain, + pick: null, + withheld: new AiUnavailableError("documentAi", reason), + } as never); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + { id: "d1" }, + ] as never); + + const result = await runContentIndexBackfillForUser("user-1"); + + expect(result).toEqual({ + indexed: 0, + retokenised: 0, + skipped: 0, + failed: 0, + reason: summaryReason, + }); + expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled(); + expect(reserveBudget).not.toHaveBeenCalled(); + expect(upsertContentIndex).not.toHaveBeenCalled(); + }, + ); + + it.each(["operator_disabled", "module_disabled", "user_disabled"] as const)( + "refuses before the pick and before any document is read when documentAi is switched off (%s)", + async (reason) => { + 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: "unavailable", + }); + expect(resolveDocumentVisionProvider).not.toHaveBeenCalled(); + expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled(); + expect(reserveBudget).not.toHaveBeenCalled(); + expect(transcribeDocument).not.toHaveBeenCalled(); + expect(upsertContentIndex).not.toHaveBeenCalled(); + }, + ); + + it.each(["no_provider", "consent_required"] as const)( + "leaves %s to the pick: a local pick the presence answer would refuse still indexes", + async (reason) => { + // Presence may see an external entry without a receipt, or no server + // provider at all; the document order can still land on the local + // model, which needs neither. The pick decides, not the presence answer. + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ + chain: [{ providerType: "local", instance: {} }], + withheld: null, + pick: { + entry: { providerType: "local", instance: {} }, + providerType: "local", + pdfSupported: false, + }, + } as never); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: true, + }); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + { id: "d1" }, + ] as never); + + const result = await runContentIndexBackfillForUser("user-1"); + + expect(resolveDocumentVisionProvider).toHaveBeenCalledWith("user-1"); + 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); @@ -192,6 +279,7 @@ describe("runContentIndexBackfillForUser", () => { // silently leaves every PDF out for exactly those providers. vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [{ providerType: "codex", instance: {} }], + withheld: null, pick: { entry: { providerType: "codex", instance: {} }, providerType: "codex", @@ -234,6 +322,7 @@ describe("runContentIndexBackfillForUser", () => { it("counts a PDF the rasteriser cannot render as skipped, not failed", async () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [{ providerType: "codex", instance: {} }], + withheld: null, pick: { entry: { providerType: "codex", instance: {} }, providerType: "codex", @@ -289,6 +378,7 @@ describe("runContentIndexBackfillForUser", () => { // No provider at all: the sweep is local work and must run anyway. vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], + withheld: null, pick: null, } as never); seedIndexRows([ @@ -333,6 +423,7 @@ describe("runContentIndexBackfillForUser", () => { it("touches nothing when every row already carries the current tokenizerVersion", async () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], + withheld: null, pick: null, } as never); seedIndexRows([ @@ -358,6 +449,7 @@ describe("runContentIndexBackfillForUser", () => { it("counts an undecryptable stale row as failed and keeps walking", async () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], + withheld: null, pick: null, } as never); seedIndexRows([ diff --git a/src/lib/jobs/__tests__/document-summary-catchup.test.ts b/src/lib/jobs/__tests__/document-summary-catchup.test.ts index c546feea9..9a803b316 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,55 @@ describe("runSummaryCatchUpForUser", () => { expect(findMany).not.toHaveBeenCalled(); }); + it.each(["operator_disabled", "module_disabled", "user_disabled"] 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.each(["no_provider", "consent_required"] as const)( + "leaves %s to each job's pick: still walks and enqueues", + async (reason) => { + // Whether there is a provider and whether sending to it needs a receipt + // depends on the document-order pick, which the per-document job + // re-checks at the wire. The pass-level presence answer does not decide. + serveDocuments(2); + mockCapability.mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: true, + }); + + const result = await runSummaryCatchUpForUser("user-1"); + + expect(result).toEqual({ enqueued: 2, capped: false }); + expect(mockEnqueueSummary).toHaveBeenCalledTimes(2); + expect(annotate).not.toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "documents.autoRead.catchUpSkipped" }, + }), + ); + }, + ); + 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..61f27dcd2 100644 --- a/src/lib/jobs/__tests__/document-summary.test.ts +++ b/src/lib/jobs/__tests__/document-summary.test.ts @@ -38,13 +38,6 @@ vi.mock("@/lib/documents/describe", () => ({ vi.mock("@/lib/documents/store", () => ({ encryptDocumentSummary: vi.fn(() => new Uint8Array([9, 9, 9])), })); -vi.mock("@/lib/ai/consent-guard", () => { - class ConsentRequiredError extends Error {} - return { - ConsentRequiredError, - assertDocumentEgressConsent: vi.fn(), - }; -}); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-07-17"), reserveBudget: vi.fn(), @@ -53,6 +46,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 } }, })); @@ -71,8 +68,18 @@ import { } from "@/lib/documents/ai-route-support"; 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", @@ -91,6 +98,7 @@ const PICK = { providerType: "anthropic", pdfSupported: true, }, + withheld: null, }; function visionOk() { @@ -115,8 +123,9 @@ 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); visionOk(); vi.mocked(runDocumentSummary).mockResolvedValue({ @@ -150,6 +159,9 @@ describe("runDocumentSummaryJob — gating", () => { await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); expect(runDocumentSummary).toHaveBeenCalledTimes(1); + expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "documentAi"); + // The pick is re-checked at the wire inside the provider order. + expect(resolveDocumentVisionProvider).toHaveBeenCalledWith("user-1"); expect(encryptDocumentSummary).toHaveBeenCalledWith( "A lab report from a clinic listing routine blood values.", ); @@ -203,10 +215,96 @@ describe("runDocumentSummaryJob — gating", () => { expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled(); }); + it.each(["operator_disabled", "module_disabled", "check_failed"] as const)( + "refuses before any provider is picked when documentAi is closed (%s → only PENDING heals to NONE)", + async (reason) => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + + await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); + + expect(resolveDocumentVisionProvider).not.toHaveBeenCalled(); + expect(loadOwnedDocument).not.toHaveBeenCalled(); + expect(reserveBudget).not.toHaveBeenCalled(); + expect(runDocumentSummary).not.toHaveBeenCalled(); + // 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: "PENDING", + }, + data: { summaryState: "NONE" }, + }); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "documents.summary.autoSkipped" }, + meta: { + documentId: "doc-1", + reason: "unavailable", + capability_reason: reason, + }, + }); + }, + ); + + it.each(["no_provider", "consent_required"] as const)( + "leaves %s to the pick: the provider order decides", + async (reason) => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: true, + }); + + await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); + + expect(resolveDocumentVisionProvider).toHaveBeenCalledWith("user-1"); + expect(runDocumentSummary).toHaveBeenCalledTimes(1); + }, + ); + + it("records UNAVAILABLE with the reason when the pick is withheld at the wire", async () => { + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ + chain: PICK.chain, + pick: null, + withheld: { reason: "consent_required" }, + } as never); + + await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); + + expect(loadOwnedDocument).not.toHaveBeenCalled(); + expect(runDocumentSummary).not.toHaveBeenCalled(); + expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ data: { summaryState: "UNAVAILABLE" } }), + ); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "documents.summary.autoSkipped" }, + meta: { + documentId: "doc-1", + reason: "consent", + capability_reason: "consent_required", + }, + }); + }); + + 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: [], pick: null, + withheld: null, } as never); await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); @@ -325,6 +423,46 @@ describe("enqueueDocumentSummary — the pending contract", () => { ); }); + it.each(["operator_disabled", "module_disabled", "check_failed"] as const)( + "does not enqueue and does not claim PENDING when documentAi is closed (%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.each(["no_provider", "consent_required"] as const)( + "still enqueues on %s: the job's own pick decides", + async (reason) => { + vi.mocked(aiCapabilityForRecord).mockResolvedValue({ + available: false, + reason, + 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: true }); + }, + ); + 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..051f2b0a2 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -63,22 +63,21 @@ 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", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), })); -const chainRequiresServerManagedConsent = vi.fn(); -const hasActiveConsentForSurface = vi.fn(); -vi.mock("@/lib/ai/consent-guard", () => ({ - chainRequiresServerManagedConsent: (...a: unknown[]) => - chainRequiresServerManagedConsent(...a), - hasActiveConsentForSurface: (...a: unknown[]) => - hasActiveConsentForSurface(...a), +// The wire re-check for the resolved chain. Its rule is pinned in +// egress.test.ts; here it passes unless a case refuses. +const aiEgressRefusal = vi.fn(); +vi.mock("@/lib/ai/capabilities/egress", () => ({ + aiEgressRefusal: (...a: unknown[]) => aiEgressRefusal(...a), })); const reserveBudget = vi.fn(); @@ -105,6 +104,7 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ })); import { runReactionLine } from "@/lib/jobs/reaction-line"; +import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; const JOB = { userId: "u1", @@ -196,9 +196,12 @@ beforeEach(async () => { labResultFindMany.mockResolvedValue([]); queryRaw.mockResolvedValue([{ total_tokens: 1_400, operator_tokens: 1_400 }]); executeRaw.mockResolvedValue(1); - isModuleEnabled.mockResolvedValue(true); - chainRequiresServerManagedConsent.mockReturnValue(false); - hasActiveConsentForSurface.mockResolvedValue(true); + aiCapabilityForJob.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + aiEgressRefusal.mockResolvedValue(null); // v1.38.19 — the claim reservation is no longer a // second copy of the upsert; it calls the shared `reserveBudget` with the // transaction client. The spy therefore runs the REAL gate against the @@ -219,18 +222,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([ @@ -493,14 +510,21 @@ describe("reaction line — degradation", () => { it("no consent on a server-managed chain: refuses before reserving", async () => { resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: {} }, { providerType: "admin-openai", instance: {} }, ]); - chainRequiresServerManagedConsent.mockReturnValue(true); - hasActiveConsentForSurface.mockResolvedValue(false); + aiEgressRefusal.mockResolvedValue( + new AiUnavailableError("reactionLines", "consent_required"), + ); const outcome = await runReactionLine(JOB); expect(outcome).toEqual({ status: "skipped", reason: "consent_required" }); + // Asked about exactly the chain it would cascade through. + expect(aiEgressRefusal).toHaveBeenCalledWith("reactionLines", "u1", [ + "openai", + "admin-openai", + ]); // A user without a receipt must not spend a token OR a ledger slot. expect(reserveBudget).not.toHaveBeenCalled(); expect(update).not.toHaveBeenCalled(); 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/__tests__/workout-insight-generate.test.ts b/src/lib/jobs/__tests__/workout-insight-generate.test.ts index fd7c721ee..bd3798933 100644 --- a/src/lib/jobs/__tests__/workout-insight-generate.test.ts +++ b/src/lib/jobs/__tests__/workout-insight-generate.test.ts @@ -36,7 +36,10 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn() }, }, })); -vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap: vi.fn() })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), + aiCapabilityForRecord: vi.fn(), +})); vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn() })); vi.mock("@/lib/workouts/hr-series", () => ({ buildWorkoutHrSeries: vi.fn(), @@ -53,7 +56,7 @@ vi.mock("@/lib/measurements/pick-canonical-workout-rows", () => ({ import { runWorkoutInsightGenerate } from "../workout-insight-generate"; import { prisma } from "@/lib/db"; -import { resolveModuleMap } from "@/lib/modules/gate"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series"; import { runStatusCompletion } from "@/lib/insights/status-provider"; @@ -85,10 +88,11 @@ function workoutRow(over: Record = {}) { } function arrangeHappyPath() { - vi.mocked(resolveModuleMap).mockResolvedValue({ - workouts: true, - insights: true, - } as never); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); vi.mocked(resolveUserTimezone).mockResolvedValue("Europe/Berlin"); vi.mocked(prisma.workout.findFirst).mockResolvedValue(workoutRow() as never); vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never); @@ -155,35 +159,42 @@ describe("Activity Insight — the gate stack", () => { expect(write.create.paragraphEncrypted).toBeInstanceOf(Uint8Array); }); - it("gate 1 — refuses when the workouts module is off", async () => { - vi.mocked(resolveModuleMap).mockResolvedValue({ - workouts: false, - insights: true, - } as never); + it.each([ + "module_disabled", + "user_disabled", + "operator_disabled", + "no_provider", + "consent_required", + ] as const)( + "gate 1 — refuses when the workoutInsights capability is unavailable (%s), before any evidence is built", + async (reason) => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); - const outcome = await runWorkoutInsightGenerate( - { userId: USER, workoutId: WORKOUT }, - NOW, - ); + const outcome = await runWorkoutInsightGenerate( + { userId: USER, workoutId: WORKOUT }, + NOW, + ); - expect(outcome).toEqual({ status: "skipped", reason: "module_off" }); - expect(runStatusCompletion).not.toHaveBeenCalled(); - expect(prisma.workoutInsight.upsert).not.toHaveBeenCalled(); - }); + expect(aiCapabilityForJob).toHaveBeenCalledWith(USER, "workoutInsights"); + expect(outcome).toEqual({ status: "skipped", reason }); + expect(prisma.workout.findFirst).not.toHaveBeenCalled(); + expect(buildWorkoutHrSeries).not.toHaveBeenCalled(); + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(prisma.workoutInsight.upsert).not.toHaveBeenCalled(); + }, + ); - it("gate 1 — refuses when the insights module is off", async () => { - vi.mocked(resolveModuleMap).mockResolvedValue({ - workouts: true, - insights: false, - } as never); + it("routes the provider call through the workoutInsights capability", async () => { + await runWorkoutInsightGenerate({ userId: USER, workoutId: WORKOUT }, NOW); - const outcome = await runWorkoutInsightGenerate( - { userId: USER, workoutId: WORKOUT }, - NOW, - ); - - expect(outcome).toEqual({ status: "skipped", reason: "module_off" }); - expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(vi.mocked(runStatusCompletion).mock.calls[0][0]).toMatchObject({ + userId: USER, + capability: "workoutInsights", + }); }); it("gate 2 — refuses a session under the ten-minute floor", async () => { @@ -335,7 +346,10 @@ describe("Activity Insight — the gate stack", () => { }); it("gate 5 — a provider-less install writes no row", async () => { - vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" }); + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "none", + reason: "no_provider", + }); const outcome = await runWorkoutInsightGenerate( { userId: USER, workoutId: WORKOUT }, @@ -499,7 +513,7 @@ describe("Activity Insight — durable generation ownership", () => { }); it.each([ - { kind: "none" as const }, + { kind: "none" as const, reason: "no_provider" as const }, { kind: "timeout" as const }, { kind: "error" as const }, ])( diff --git a/src/lib/jobs/__tests__/workout-insight-no-backfill-spend.test.ts b/src/lib/jobs/__tests__/workout-insight-no-backfill-spend.test.ts index 3d29e50d3..cf48bc32d 100644 --- a/src/lib/jobs/__tests__/workout-insight-no-backfill-spend.test.ts +++ b/src/lib/jobs/__tests__/workout-insight-no-backfill-spend.test.ts @@ -23,6 +23,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap: vi.fn() })); +// The spine's provider-free prefilter (`aiWorkNotRuledOut`) runs for real; only +// its operator-switch read is an edge. +vi.mock("@/lib/feature-flags", () => ({ loadAssistantSwitches: vi.fn() })); vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn() })); vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserDashboardSnapshot: vi.fn(), @@ -47,6 +50,7 @@ import { runDataArrival } from "../data-arrival"; import { enqueueWorkoutInsight } from "@/lib/jobs/workout-insight-generate-shared"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { resolveModuleMap } from "@/lib/modules/gate"; +import { loadAssistantSwitches } from "@/lib/feature-flags"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import type { DataArrival } from "@/lib/arrivals/types"; @@ -80,6 +84,13 @@ beforeEach(() => { workouts: true, insights: true, } as never); + vi.mocked(loadAssistantSwitches).mockResolvedValue({ + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + documentAi: true, + }); vi.mocked(resolveUserTimezone).mockResolvedValue(TZ); }); 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..ed3c3a374 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -35,10 +35,8 @@ import type { CoachNudgeTrigger } from "@/lib/jobs/coach-nudge"; import type { Locale } from "@/lib/i18n/config"; import { resolveProviderChain } from "@/lib/ai/provider"; -import { - chainRequiresServerManagedConsent, - hasActiveConsentForSurface, -} from "@/lib/ai/consent-guard"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; import { buildDateKey, reconcileSpend, @@ -173,19 +171,38 @@ 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; - // Consent gate — before the budget reservation, so a user without a - // receipt never spends a slot or a token. Skip-shaped like every other - // guard here: no receipt → return null and the caller ships the - // deterministic template, so the nudge itself is never lost. BYOK / local - // / ChatGPT-OAuth chains are the user's own egress and stay ungated. - if ( - chainRequiresServerManagedConsent(chain) && - !(await hasActiveConsentForSurface(params.userId, "coach")) - ) { - annotate({ action: { name: "coach.nudge.ai.consent_required" } }); + // The wire re-check for exactly this chain, before the budget + // reservation so a user without a receipt never spends a slot or a token: + // the capability again, and the consent an operator-held entry needs. + // Skip-shaped like every other guard here: a refusal returns null and the + // caller ships the deterministic template, so the nudge itself is never + // lost. A person's own key, their ChatGPT account or a local model need no + // receipt. + const refusal = await aiEgressRefusal( + "coach", + params.userId, + chain.map((entry) => entry.providerType), + ); + if (refusal) { + annotate({ + action: { name: "coach.nudge.ai.refused" }, + meta: { reason: refusal.reason }, + }); 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..17b616348 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -12,9 +12,10 @@ * indexing egresses to a provider under the user's key + budget, so it must be * a deliberate, consented act, exactly like the per-document index route. * - * Consent: gated on the document-egress consent (`assertDocumentEgressConsent`), - * the same gate the extract / index routes use — any external provider needs an - * active receipt; a local pick stays ungated. There is no separate per-user + * Consent: gated on the `documentAi` capability and the wire re-check of the + * document pick (`resolveDocumentVisionProvider` → `aiEgressRefusal`), the same + * gate the extract / index routes use — any external provider needs an active + * extraction receipt; a local pick needs none. There is no separate per-user * toggle (maintainer decision, 2026-07-07). * * Bounded + resumable: an id-cursor forward walk over the not-yet-indexed set, @@ -30,10 +31,8 @@ * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it. */ import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { - assertDocumentEgressConsent, - ConsentRequiredError, -} from "@/lib/ai/consent-guard"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; +import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types"; import { buildDateKey, reconcileSpend, @@ -96,7 +95,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"; } /** @@ -164,32 +164,45 @@ export async function runContentIndexBackfillForUser( // provider still gets their existing index moved to the current rules. const stale = await retokeniseStaleRows(userId); - const { pick } = await resolveDocumentVisionProvider(userId); - if (!pick) + // The `documentAi` capability before any document is listed: the + // operator's switches (the master and "Reading documents") and the vault + // module. Whether there is a provider and whether sending to it needs a + // receipt is the pick's to answer, just below. + const capability = await aiCapabilityForJob(userId, "documentAi"); + if ( + capability.reason !== null && + !PICK_DECIDED_REASONS.has(capability.reason) + ) { + annotate({ + action: { name: "documents.contentIndex.backfillSkipped" }, + meta: { reason: capability.reason }, + }); return { indexed: 0, retokenised: stale.retokenised, skipped: 0, failed: stale.failed, - reason: "no-provider", + reason: "unavailable", + }; + } + + // The document-order pick, re-checked at the wire for exactly that + // provider: the capability again, and the extraction receipt an external + // pick needs (`ai_extraction` or `ai_full`). A local pick needs none. + const { pick, withheld } = await resolveDocumentVisionProvider(userId); + if (!pick) { + return { + indexed: 0, + retokenised: stale.retokenised, + skipped: 0, + failed: stale.failed, + reason: + withheld === null + ? "no-provider" + : withheld.reason === "consent_required" + ? "no-consent" + : "unavailable", }; - try { - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - surface: "insights", - }); - } catch (err) { - if (err instanceof ConsentRequiredError) { - return { - indexed: 0, - retokenised: stale.retokenised, - skipped: 0, - failed: stale.failed, - reason: "no-consent", - }; - } - throw err; } // PDFs are always candidates: `prepareVisionInput` passes them natively diff --git a/src/lib/jobs/document-summary-catchup.ts b/src/lib/jobs/document-summary-catchup.ts index 9acb154fe..65ca18df6 100644 --- a/src/lib/jobs/document-summary-catchup.ts +++ b/src/lib/jobs/document-summary-catchup.ts @@ -33,6 +33,8 @@ * (`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 { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types"; import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { enqueueDocumentSummary } from "@/lib/jobs/document-summary"; @@ -92,6 +94,22 @@ export async function runSummaryCatchUpForUser( return { enqueued: 0, capped: false }; } + // The `documentAi` capability, once for the whole pass, before any document + // is listed: a switch or the vault module off means there is nothing the + // per-document jobs could do. A missing provider or receipt is each job's + // pick to decide. + const capability = await aiCapabilityForJob(userId, "documentAi"); + if ( + capability.reason !== null && + !PICK_DECIDED_REASONS.has(capability.reason) + ) { + 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..feec890ba 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -23,9 +23,10 @@ */ import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { - assertDocumentEgressConsent, - ConsentRequiredError, -} from "@/lib/ai/consent-guard"; + aiCapabilityForJob, + aiCapabilityForRecord, +} from "@/lib/ai/capabilities/gate"; +import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types"; import { buildDateKey, reconcileSpend, @@ -135,37 +136,59 @@ export async function runDocumentSummaryJob( 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). - const { pick } = await resolveDocumentVisionProvider(userId); - if (!pick) { - await markSummaryState(userId, documentId, "UNAVAILABLE"); + // The `documentAi` capability before a provider is picked or the document is + // decrypted: the operator's switches (the master and "Reading documents") + // and the vault module. A switch turned off means this job was never to run + // at all, so only the PENDING it was meant to resolve goes back to NONE; a + // recorded UNAVAILABLE or WITHHELD is an earlier real attempt and stays. + // Whether there is a provider and whether sending to it needs a receipt is + // the pick's to answer (the document order differs from the chain order the + // presence probe reads), so those two reasons wait for the pick below. + const capability = await aiCapabilityForJob(userId, "documentAi"); + if ( + capability.reason !== null && + !PICK_DECIDED_REASONS.has(capability.reason) + ) { + await prisma.inboundDocument.updateMany({ + where: { + id: documentId, + userId, + deletedAt: null, + summaryState: "PENDING", + }, + data: { summaryState: "NONE" }, + }); annotate({ action: { name: "documents.summary.autoSkipped" }, - meta: { documentId, reason: "no-provider" }, + meta: { + documentId, + reason: "unavailable", + capability_reason: capability.reason, + }, }); return; } - // 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. - try { - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - surface: "insights", - }); - } catch (err) { + // Resolve the DOCUMENT-order vision provider (local-first, codex last) and + // re-check the wire for exactly that pick: the capability again and the + // extraction receipt an external pick needs (`ai_extraction` or `ai_full`; + // the auto-read toggle mints one but is not one, so a revoke mid-flight wins). + // No pick → an attempt that could not run, recorded as UNAVAILABLE (unlike + // the index job, there is no local text-layer fallback for a summary). + const { pick, withheld } = await resolveDocumentVisionProvider(userId); + if (!pick) { await markSummaryState(userId, documentId, "UNAVAILABLE"); annotate({ action: { name: "documents.summary.autoSkipped" }, meta: { documentId, reason: - err instanceof ConsentRequiredError ? "consent" : "consent-error", + withheld === null + ? "no-provider" + : withheld.reason === "consent_required" + ? "consent" + : "unavailable", + capability_reason: withheld?.reason ?? null, }, }); return; @@ -319,6 +342,21 @@ export async function enqueueDocumentSummary( }); return { enqueued: false }; } + // Nothing is queued that the capability already refuses (a switch or the + // vault module off): the document stays NONE and the view offers the + // manual action, exactly as with the opt-in off. A missing provider or + // receipt is the job's pick to decide. + const capability = await aiCapabilityForRecord(userId, "documentAi"); + if ( + capability.reason !== null && + !PICK_DECIDED_REASONS.has(capability.reason) + ) { + 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..a036f0025 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -33,11 +33,8 @@ import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; import { annotate } from "@/lib/logging/context"; import { withBackgroundEvent } from "@/lib/logging/background"; import { resolveProviderChain } from "@/lib/ai/provider"; -import { - chainRequiresServerManagedConsent, - hasActiveConsentForSurface, -} from "@/lib/ai/consent-guard"; -import { isModuleEnabled } from "@/lib/modules/gate"; +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { buildDateKey, reconcileSpend, @@ -467,8 +464,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 } }); @@ -478,12 +487,14 @@ export async function runReactionLine( const chain = await resolveProviderChain(job.userId); if (chain.length === 0) return { status: "skipped", reason: "no_provider" }; - if ( - chainRequiresServerManagedConsent(chain) && - !(await hasActiveConsentForSurface(job.userId, "insights")) - ) { - return { status: "skipped", reason: "consent_required" }; - } + // The wire re-check for exactly this chain: the capability again, and the + // consent an operator-held entry needs (`aiEgressRefusal`). + const refusal = await aiEgressRefusal( + "reactionLines", + job.userId, + chain.map((entry) => entry.providerType), + ); + if (refusal) return { status: "skipped", reason: refusal.reason }; const generationClaimId = randomUUID(); const claimedAt = new Date(); 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/src/lib/jobs/workout-insight-generate.ts b/src/lib/jobs/workout-insight-generate.ts index 67966b14b..cc1b29168 100644 --- a/src/lib/jobs/workout-insight-generate.ts +++ b/src/lib/jobs/workout-insight-generate.ts @@ -52,7 +52,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; import { annotate } from "@/lib/logging/context"; import { withBackgroundEvent } from "@/lib/logging/background"; -import { resolveModuleMap } from "@/lib/modules/gate"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { userDayKey } from "@/lib/tz/format"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { startOfLocalDayInTz } from "@/lib/tz/local-day"; @@ -215,13 +215,19 @@ export async function runWorkoutInsightGenerate( if (!userId || !workoutId) return { status: "skipped", reason: "bad_payload" }; - // ── Gate 1: modules ────────────────────────────────────────────────────── - // Both, not either. `workouts` is the domain the surface lives in and - // `insights` is the AI surface family it belongs to; a user who turned either - // off has said no to this card. - const modules = await resolveModuleMap(userId); - if (modules.workouts === false || modules.insights === false) { - return { status: "skipped", reason: "module_off" }; + // ── Gate 1: the `workoutInsights` capability ───────────────────────────── + // The operator's switches (the master included, which this job never read + // before), both owning modules (`workouts` is the domain, `insights` the + // person's AI analysis switch), provider presence and consent, in one + // answer, before any evidence is built. The chokepoint re-checks at the wire. + const capability = await aiCapabilityForJob(userId, "workoutInsights"); + if (!capability.available) { + const reason = capability.reason ?? "check_failed"; + annotate({ + action: { name: "workouts.insight.skipped" }, + meta: { workoutId, reason }, + }); + return { status: "skipped", reason }; } const row = await prisma.workout.findFirst({ @@ -429,7 +435,7 @@ export async function runWorkoutInsightGenerate( outcome = await runStatusCompletion({ userId, cacheAction: "insights.workout-insight", - consentSurface: "insights", + capability: "workoutInsights", systemPrompt: getWorkoutInsightSystemPrompt(prepared.locale), userPrompt: getWorkoutInsightUserPrompt( JSON.stringify(prepared.evidence), diff --git a/src/lib/openapi/routes/consent.ts b/src/lib/openapi/routes/consent.ts index e98167411..e042f14ce 100644 --- a/src/lib/openapi/routes/consent.ts +++ b/src/lib/openapi/routes/consent.ts @@ -9,7 +9,9 @@ * clients have a stable reference. * * Receipt kinds: `ai_full` (master — satisfies every surface), - * `ai_insights_only` (Insights only), `ai_coach` (Coach only). + * `ai_insights_only` (Insights only), `ai_coach` (Coach only), + * `ai_extraction` (reading documents, lab report scans and typed medication + * text; minted by the document auto-read switch). * * - POST /api/consent/ai — explicit grant with a signed artefact (iOS). * - POST /api/consent/ai/web — idempotent `ai_full` grant for the web @@ -110,11 +112,12 @@ const consentLatestAllKinds = z ai_full: serialisedReceipt.nullable(), ai_insights_only: serialisedReceipt.nullable(), ai_coach: serialisedReceipt.nullable(), + ai_extraction: serialisedReceipt.nullable(), }) .meta({ id: "ConsentLatestByKind", description: - "The latest active receipt for every kind. All three keys are always present: a null value means the kind is not granted, so a client never has to read absence as ambiguity.", + "The latest active receipt for every kind. Every key is always present: a null value means the kind is not granted, so a client never has to read absence as ambiguity.", }); const consentRevokeAllKinds = z @@ -137,7 +140,7 @@ const consentRevokeAllKinds = z const consentQueryValidation400 = { "400": { description: - "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422.", + "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422.", content: { "application/json": { schema: errorEnvelope } }, }, }; @@ -257,7 +260,9 @@ export const consentPaths: NonNullable = { "\n" + "Idempotent on purpose, and it is worth knowing why: revoking a kind that has nothing active answers 200 with `receipt: null` rather than 404, because the client toggle fires this on every flip and a 404 would render as a failure the user cannot act on. Revocation is append-only in effect — the row is stamped, never deleted — and each revocation is audit-logged.\n" + "\n" + - "Revoking `ai_full` does NOT switch off `documentsAutoAiRead`; that flag is written through PATCH /api/auth/me/documents-auto-ai-read and turning it on mints a fresh receipt. A client offering both controls should show them as the two separate things they are.", + "Revoking `ai_full` or `ai_extraction` does NOT switch off `documentsAutoAiRead`, but it does stop document reading: the document gate reads the receipt, not the switch, so a revocation wins over a switch left on. Turning the switch on again mints a fresh `ai_extraction` receipt through PATCH /api/auth/me/documents-auto-ai-read. A client offering both controls should show them as the two separate things they are.\n" + + "\n" + + "When a revocation leaves no receipt covering the analysis (`ai_insights_only` or `ai_full`), the same transaction deletes the text a model wrote under it that can be written again: per-metric status notes, the cached briefing, model-written period narratives, reaction lines and workout paragraphs. Coach conversations, facts and plans and document summaries are the person's records and stay. A deletion is audit-logged with counts only (`consent.ai.purge`).", requestParams: { query: z.object({ kind: consentKindEnum diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index ccf50488f..f34275a95 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -709,7 +709,7 @@ const glucoseUnitResponse = z const documentsAutoAiReadPatchRequest = documentsAutoAiReadPatchSchema.meta({ id: "DocumentsAutoAiReadPatchRequest", description: - "Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_full` consent receipt and schedules a catch-up over the existing vault.", + "Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_extraction` consent receipt and schedules a catch-up over the existing vault.", }); const documentsAutoAiReadResponse = z diff --git a/src/lib/validations/consent.ts b/src/lib/validations/consent.ts index d0d4f1fb6..ffd154cb4 100644 --- a/src/lib/validations/consent.ts +++ b/src/lib/validations/consent.ts @@ -4,11 +4,14 @@ import { z } from "zod/v4"; * v1.4.40 SB-10 — AI consent receipts (App-Store Guideline 5.1.2(i) + * GDPR Art. 7 audit trail). * - * The discriminator covers the three consent surfaces the iOS client - * collects independently: + * The discriminator covers the consent surfaces collected independently: * - `ai_full` full assistant + on-device personalisation * - `ai_insights_only` read-only Insights generation (no Coach) * - `ai_coach` chat-style Coach access + * - `ai_extraction` reading documents, lab report scans and typed + * medication text with an external model (minted by + * the document auto-read toggle; it satisfies neither + * the Coach nor the analysis) * * Keeping each surface as its own row means the legal team can prove * which exact scope the user agreed to without parsing the signed @@ -20,6 +23,7 @@ export const consentKindEnum = z.enum([ "ai_full", "ai_insights_only", "ai_coach", + "ai_extraction", ]); export type ConsentKind = z.infer; @@ -73,7 +77,7 @@ export type WebConsentGrantBodyInput = z.input; * * Omitting `kind` on GET returns the latest active receipt per kind * (a `Record` shape). Omitting it on DELETE revokes - * the latest receipt across all three kinds — handy for the iOS + * the latest receipt across every kind — handy for the iOS * "deaktivieren alle KI-Funktionen" master toggle. */ export const consentLatestQuery = z.object({ diff --git a/src/lib/workouts/insight-gates.ts b/src/lib/workouts/insight-gates.ts index f02beee75..a0246f3c1 100644 --- a/src/lib/workouts/insight-gates.ts +++ b/src/lib/workouts/insight-gates.ts @@ -5,7 +5,8 @@ * in this order for a reason — each one is cheaper than the one after it, so * the common refusals cost nothing: * - * 1. modules — `workouts` AND `insights` both on (one cached map lookup) + * 1. capability — `workoutInsights` (both owning modules, the operator + * switches, provider presence and consent in one answer) * 2. duration — at least `MIN_DURATION_SEC` (a field already in hand) * 3. daily cap — at most `MAX_INSIGHTS_PER_DAY` generated today (one COUNT) * 4. input hash — the evidence is unchanged since the last paragraph (one 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..e80e75790 --- /dev/null +++ b/tests/integration/consent-withdrawal-purge.test.ts @@ -0,0 +1,321 @@ +/** + * 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 { aiEgressRefusal } = await import("@/lib/ai/capabilities/egress"); + + expect(await aiEgressRefusal("documentAi", user.id, ["codex"])).toBeNull(); + + await withdrawConsent(user.id, ["ai_extraction"]); + + const refusal = await aiEgressRefusal("documentAi", user.id, ["codex"]); + expect(refusal?.reason).toBe("consent_required"); + // A local pick never leaves the machine and stays admitted. + expect(await aiEgressRefusal("documentAi", user.id, ["local"])).toBeNull(); + }); +}); diff --git a/tests/integration/insight-status-cache-migration.test.ts b/tests/integration/insight-status-cache-migration.test.ts new file mode 100644 index 000000000..9bb2e46fd --- /dev/null +++ b/tests/integration/insight-status-cache-migration.test.ts @@ -0,0 +1,125 @@ +/** + * Migration 0344: status notes move out of the audit log. + * + * The container this suite runs on has every migration applied already, so + * the new table is asserted as it landed (columns, the one-row-per-note + * unique key, the cascade from the user), and the destructive data step is + * pulled out of the `.sql` file and run again against rows seeded the way an + * upgrading instance holds them: the old status rows go, and every other + * audit row stays, including the ones whose action merely starts with + * `insights.`. + * + * The step runs twice; the second run must change nothing. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const MIGRATION = readFileSync( + path.join( + process.cwd(), + "prisma", + "migrations", + "0344_insight_status_cache", + "migration.sql", + ), + "utf8", +); + +/** Every `DELETE … ;` statement in the migration, comments stripped. */ +function dataSteps(): string[] { + const sql = MIGRATION.split("\n") + .filter((line) => !line.trimStart().startsWith("--")) + .join("\n"); + const steps = [...sql.matchAll(/DELETE\s+FROM\s+"[a-z_]+"[\s\S]*?;/gi)].map( + (m) => m[0], + ); + // The data step is the point; an empty match is a failure, not a pass. + expect(steps.length).toBe(1); + return steps; +} + +async function runDataSteps() { + const prisma = getPrismaClient(); + for (const step of dataSteps()) await prisma.$executeRawUnsafe(step); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("the new table", () => { + it("keys one note per (user, metric, locale) and cascades from the user", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { username: "mig-0344", email: "mig-0344@example.test" }, + }); + await prisma.insightStatusCache.create({ + data: { + userId: user.id, + metric: "weight", + locale: "en", + dateKey: "2026-09-20", + textEncrypted: Buffer.from("ciphertext-stand-in", "utf8"), + }, + }); + await expect( + prisma.insightStatusCache.create({ + data: { + userId: user.id, + metric: "weight", + locale: "en", + dateKey: "2026-09-21", + }, + }), + ).rejects.toThrow(); + + await prisma.user.delete({ where: { id: user.id } }); + expect(await prisma.insightStatusCache.count()).toBe(0); + }); +}); + +describe("the audit-log purge", () => { + it("deletes every status note row and nothing else", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { username: "mig-0344-audit", email: "mig-0344-a@example.test" }, + }); + const statusActions = [ + "insights.weight-status.en", + "insights.blood-pressure-status.de", + "insights.metric:RESTING_HEART_RATE-status.fr", + "insights.derived-score:READINESS-status.en", + "insights.biomarker:ldl-status.it", + "insights.medication-compliance-status.pl", + ]; + const kept = [ + "insights.generate", + "insights.coach.fence_drift", + "insights.recommendation.feedback", + "auth.login", + "consent.ai.revoke", + ]; + for (const action of [...statusActions, ...kept]) { + await prisma.auditLog.create({ + data: { + userId: user.id, + action, + details: JSON.stringify({ text: "plaintext" }), + }, + }); + } + + await runDataSteps(); + await runDataSteps(); + + const remaining = await prisma.auditLog.findMany({ + select: { action: true }, + orderBy: { action: "asc" }, + }); + expect(remaining.map((r) => r.action)).toEqual([...kept].sort()); + }); +}); diff --git a/tests/integration/insights-status-real-text.test.ts b/tests/integration/insights-status-real-text.test.ts index 3c21ab49e..248965294 100644 --- a/tests/integration/insights-status-real-text.test.ts +++ b/tests/integration/insights-status-real-text.test.ts @@ -8,9 +8,9 @@ * the generic no-key fallback). This is the regression the * sticky-stub bug used to break. * - * 2. A timeout-stub cache row keyed to today is NOT served — the - * generator skips it and re-generates, proving the stub no longer - * pins the fallback for the day. + * 2. A negative-cache window opened by an earlier stall never stands in + * for a note: with no note stored for today, the generator regenerates + * and stores the real text, and writing it closes the window. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -24,7 +24,10 @@ vi.mock("@/lib/db-compat", () => ({ // provider to walk. `runRawCompletionWithFallback` runs for real on top // of this — exercising the same plumbing the live status path uses. const KNOWN_TEXT = "Your weight has trended down steadily over the past month."; -vi.mock("@/lib/ai/provider", () => ({ +vi.mock("@/lib/ai/provider", async (importOriginal) => ({ + // The presence probe the capability reads stays real; only the chain the + // generation walks is replaced. + ...(await importOriginal()), resolveProviderChain: vi.fn().mockResolvedValue([ { providerType: "anthropic", @@ -41,7 +44,7 @@ vi.mock("@/lib/ai/provider", () => ({ resolveProvider: vi.fn().mockResolvedValue({ type: "none" }), })); -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; beforeEach(async () => { await truncateAllTables(getPrismaClient()); @@ -60,6 +63,9 @@ async function seedRichWeightUser(username: string) { role: "USER", heightCm: 180, dateOfBirth: new Date("1985-01-01"), + // Presence of a provider for the capability; never decrypted here. + aiProviderChain: [{ providerType: "anthropic", enabled: true }], + aiAnthropicKeyEncrypted: "key-present", }, }); const now = Date.now(); @@ -90,39 +96,40 @@ describe("status path delivers real provider text", () => { expect(result.cached).toBe(false); expect(result.text).toBe(KNOWN_TEXT); - // The real assessment was persisted (not a stub). + // The real assessment was stored, encrypted, as the weight note. const prisma = getPrismaClient(); - const cached = await prisma.auditLog.findFirst({ - where: { userId: user.id, action: "insights.weight-status.en" }, - orderBy: { createdAt: "desc" }, - select: { details: true }, + const stored = await prisma.insightStatusCache.findUniqueOrThrow({ + where: { + userId_metric_locale: { + userId: user.id, + metric: "weight", + locale: "en", + }, + }, }); - const parsed = JSON.parse(cached?.details ?? "{}") as { - text: string; - model: string; - timeout?: boolean; - }; - expect(parsed.text).toBe(KNOWN_TEXT); - expect(parsed.model).toBe("test-model"); - expect(parsed.timeout).toBeUndefined(); + expect(decryptFromBytes(stored.textEncrypted!)).toBe(KNOWN_TEXT); + expect(stored.retryAt).toBeNull(); + // Nothing about the note lands in the audit log any more. + expect( + await prisma.auditLog.count({ + where: { userId: user.id, action: { contains: "-status." } }, + }), + ).toBe(0); }); - it("skips a timeout-stub cache row and regenerates the real text", async () => { + it("regenerates past a negative-cache window and closes it", async () => { const user = await seedRichWeightUser("status-stub-skip-user"); const prisma = getPrismaClient(); - // Pre-seed a stub row keyed to today — the bug used to serve this. - await prisma.auditLog.create({ + // An earlier stall opened a window, with no note stored. + await prisma.insightStatusCache.create({ data: { userId: user.id, - action: "insights.weight-status.en", - details: JSON.stringify({ - dateKey: toBerlinDayKey(new Date()), - locale: "en", - text: "Generic fallback advice that must not be served.", - model: "timeout-stub", - timeout: true, - }), + metric: "weight", + locale: "en", + dateKey: "2026-01-01", + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", }, }); @@ -133,8 +140,18 @@ describe("status path delivers real provider text", () => { locale: "en", }); - // The stub is skipped; the real provider text is returned + cached. expect(result.text).toBe(KNOWN_TEXT); expect(result.cached).toBe(false); + const stored = await prisma.insightStatusCache.findUniqueOrThrow({ + where: { + userId_metric_locale: { + userId: user.id, + metric: "weight", + locale: "en", + }, + }, + }); + expect(stored.retryAt).toBeNull(); + expect(stored.negativeReason).toBeNull(); }); });