From 2710dfc04192a92c65540ad2237d9e6634aaf8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:03:58 +0200 Subject: [PATCH 1/5] feat(insights): add an encrypted table for status notes Status notes (the per-metric text a model writes) move out of audit_logs into InsightStatusCache: one row per user, metric and locale, the note encrypted at rest, upserted in place, with the input and snapshot fingerprints and a separate negative-cache window. Migration 0344 creates the table and deletes every audit_logs row whose action matches insights.%-status.%. The notes are a regenerable cache and the old rows are plaintext, so nothing is copied; the first nightly run after the upgrade writes them again. Take a backup before applying. The table is registered for key rotation, excluded from backups as a regenerable cache, and deleted by Delete All Data. Refs #975 --- .../0344_insight_status_cache/migration.sql | 44 ++++++ prisma/schema.prisma | 52 ++++++++ scripts/rotate-encryption-key.ts | 19 +++ src/lib/crypto/encrypted-columns.ts | 6 + src/lib/data-wipe/wipe-plan.ts | 1 + src/lib/export/backup-plan.ts | 2 + .../insight-status-cache-migration.test.ts | 125 ++++++++++++++++++ 7 files changed, 249 insertions(+) create mode 100644 prisma/migrations/0344_insight_status_cache/migration.sql create mode 100644 tests/integration/insight-status-cache-migration.test.ts 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/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/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/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/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()); + }); +}); From 8325f3964debfdbae25c3b279571f8eb93218467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:05:09 +0200 Subject: [PATCH 2/5] feat(consent): add the extraction consent kind and purge on withdrawal A new receipt kind, ai_extraction, covers reading documents, lab report scans and typed medication text with an external model. Switching the document auto-read on now mints ai_extraction instead of ai_full, so it no longer grants the Coach or the analysis as a side effect. Existing ai_full receipts keep covering everything. DELETE /api/consent/ai/latest now runs through withdrawConsent: the revoke and, when it leaves no receipt covering the analysis, the deletion of the text a model wrote under it commit in one transaction. That is the status notes, the cached briefing, model-written period narratives, reaction lines and workout paragraphs. Coach conversations, facts and plans and document summaries stay. The purge is audit-logged as consent.ai.purge with counts only. Refs #975 --- docs/api/openapi.yaml | 53 +++++-- .../__tests__/route.test.ts | 19 +-- .../auth/me/documents-auto-ai-read/route.ts | 11 +- .../consent/ai/latest/__tests__/route.test.ts | 135 +++++++++++++--- src/app/api/consent/ai/latest/route.ts | 81 +++++----- src/lib/consent/__tests__/receipts.test.ts | 40 +---- src/lib/consent/__tests__/web-grant.test.ts | 46 +++++- src/lib/consent/__tests__/withdrawal.test.ts | 119 ++++++++++++++ src/lib/consent/receipts.ts | 60 +++---- src/lib/consent/web-grant.ts | 52 ++++-- src/lib/consent/withdrawal.ts | 149 ++++++++++++++++++ src/lib/openapi/routes/consent.ts | 13 +- src/lib/openapi/routes/profile.ts | 4 +- src/lib/validations/consent.ts | 10 +- 14 files changed, 607 insertions(+), 185 deletions(-) create mode 100644 src/lib/consent/__tests__/withdrawal.test.ts create mode 100644 src/lib/consent/withdrawal.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3930a4101..71627725e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -7948,12 +7948,13 @@ paths: Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user. - Turning it ON does two further things a caller should expect. It is itself the standing consent act, so the - write appends an `ai_full` `ConsentReceipt` — the same receipt POST /api/consent/ai/web mints, and one DELETE - /api/consent/ai/latest revokes without touching this flag. And a genuine OFF→ON flip schedules a bounded - catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would - otherwise only ever apply to future uploads. The catch-up is fire-and-forget and re-runs every consent and - budget gate per document. + Turning it ON does two further things a caller should expect. It is the consent act for reading documents, so + the write appends an `ai_extraction` `ConsentReceipt`: the narrow kind that covers documents, lab report scans + and typed medication text and nothing else (not the Coach, not the analysis). DELETE /api/consent/ai/latest + revokes it without touching this flag, and the revocation wins: document reading stops even while the flag stays + on. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the + summary job is enqueued at upload time and would otherwise only ever apply to future uploads. The catch-up is + fire-and-forget and re-runs every consent and budget gate per document. The body cap here is 1 KB, far tighter than the 64 KB its siblings allow — a payload above it is refused with @@ -12691,6 +12692,7 @@ paths: - ai_full - ai_insights_only - ai_coach + - ai_extraction description: Narrow to one consent kind. Omit for the full keyspace. responses: "200": @@ -12702,8 +12704,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: @@ -12726,9 +12729,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 @@ -12739,6 +12751,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": @@ -23846,8 +23859,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: @@ -26005,6 +26018,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction artefact: type: string minLength: 1 @@ -58019,6 +58033,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction signedAt: type: string revokedAt: @@ -58115,6 +58130,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction receipt: anyOf: - $ref: "#/components/schemas/SerialisedConsentReceipt" @@ -58137,6 +58153,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction signedAt: type: string format: date-time @@ -58193,13 +58210,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: @@ -58248,6 +58270,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction receipt: $ref: "#/components/schemas/SerialisedConsentReceipt" required: diff --git a/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts b/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts index 0fa555dd3..f691111f5 100644 --- a/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts +++ b/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts @@ -22,7 +22,7 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/consent/web-grant", () => ({ - ensureWebAiConsentReceipt: vi.fn().mockResolvedValue(undefined), + ensureExtractionConsentReceipt: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/jobs/document-summary-catchup", () => ({ enqueueSummaryCatchUp: vi.fn().mockResolvedValue({ enqueued: true }), @@ -47,7 +47,7 @@ vi.mock("next/headers", () => ({ import { PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; +import { ensureExtractionConsentReceipt } from "@/lib/consent/web-grant"; import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup"; const SESSION_OK = { @@ -103,23 +103,20 @@ describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () await PATCH(mkPatch(false)); expect(enqueueSummaryCatchUp).not.toHaveBeenCalled(); - expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled(); + expect(ensureExtractionConsentReceipt).not.toHaveBeenCalled(); }); it("still mints the consent receipt on the flip that schedules the pass", async () => { // The catch-up rides the same act of consent, never around it. The mint - // is marked `affirmative` because switching the toggle on IS the consent - // act: unlike the settings-mount heal, it may lift an earlier - // withdrawal, which is the user deciding again rather than the page - // deciding for them. + // is the extraction kind (documents, lab scans, medication text) and + // nothing wider: switching document reading on does not consent to the + // Coach or to the analysis. It is the person's own affirmative act, so + // it may lift an earlier withdrawal of the same kind. withPrevious(false); await PATCH(mkPatch(true)); - expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith( - "user-1", - "affirmative", - ); + expect(ensureExtractionConsentReceipt).toHaveBeenCalledWith("user-1"); }); it("persists the flag field-by-field", async () => { 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 8fef3da24..a4d737abd 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 @@ -30,7 +30,7 @@ import { } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; +import { ensureExtractionConsentReceipt } from "@/lib/consent/web-grant"; import { prisma } from "@/lib/db"; import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -102,11 +102,12 @@ export const PATCH = apiHandler(async (req: Request) => { data: { documentsAutoAiRead: next }, }); - // Turning the toggle ON is the standing consent act — mint an append-only - // `ai_full` receipt (idempotent) so the durable audit trail records it, in - // addition to the runtime short-circuit the document consent gate reads. + // Turning the toggle ON is the consent act for reading documents — mint an + // append-only `ai_extraction` receipt (idempotent). The document consent + // gate reads that receipt, not the toggle, so a later revocation wins over + // a toggle left on. It does not grant the Coach or the analysis. if (next) { - await ensureWebAiConsentReceipt(user.id, "affirmative"); + await ensureExtractionConsentReceipt(user.id); } // A genuine OFF→ON flip schedules a catch-up over the documents already in 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/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__/web-grant.test.ts b/src/lib/consent/__tests__/web-grant.test.ts index 285bc8984..e9650c7fc 100644 --- a/src/lib/consent/__tests__/web-grant.test.ts +++ b/src/lib/consent/__tests__/web-grant.test.ts @@ -21,7 +21,10 @@ vi.mock("@/lib/db", () => { }); import { prisma } from "@/lib/db"; -import { ensureWebAiConsentReceipt } from "../web-grant"; +import { + ensureExtractionConsentReceipt, + ensureWebAiConsentReceipt, +} from "../web-grant"; const $transaction = vi.mocked(prisma.$transaction) as unknown as { mockImplementation: (impl: (fn: TxFn) => unknown) => void; @@ -246,3 +249,44 @@ describe("ensureWebAiConsentReceipt — a revocation is a standing decision", () expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); }); }); + +describe("ensureExtractionConsentReceipt", () => { + it("mints an ai_extraction receipt, never the master grant", async () => { + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + vi.mocked(prisma.consentReceipt.create).mockResolvedValue( + row({ kind: "ai_extraction" }) as never, + ); + + const result = await ensureExtractionConsentReceipt("user-1"); + + expect(result.minted).toBe(true); + const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0]; + expect(createArg.data.kind).toBe("ai_extraction"); + expect(JSON.parse(createArg.data.artefact as string).kind).toBe( + "ai_extraction", + ); + for (const [args] of vi.mocked(prisma.consentReceipt.findFirst).mock + .calls) { + expect((args as { where: { kind: string } }).where.kind).toBe( + "ai_extraction", + ); + } + }); + + it("supersedes an earlier revocation of the same kind (it is the person's own act)", async () => { + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + vi.mocked(prisma.consentReceipt.create).mockResolvedValue( + row({ kind: "ai_extraction" }) as never, + ); + + await ensureExtractionConsentReceipt("user-1"); + + // An affirmative mint never looks for a past revocation. + for (const [args] of vi.mocked(prisma.consentReceipt.findFirst).mock + .calls) { + expect( + (args as { where: { revokedAt: unknown } }).where.revokedAt, + ).toBeNull(); + } + }); +}); 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/web-grant.ts b/src/lib/consent/web-grant.ts index 2a122207d..292230e9b 100644 --- a/src/lib/consent/web-grant.ts +++ b/src/lib/consent/web-grant.ts @@ -35,7 +35,10 @@ import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; import { isP2002 } from "@/lib/prisma-errors"; -import { latestActiveReceipt } from "@/lib/consent/receipts"; +import { + AI_EXTRACTION_CONSENT_KIND, + latestActiveReceipt, +} from "@/lib/consent/receipts"; import type { ConsentReceipt } from "@/lib/consent/receipts"; /** @@ -63,24 +66,55 @@ export type WebConsentGrantResult = */ export type WebConsentGrantIntent = "heal" | "affirmative"; +/** The receipt kinds the web mints: the master grant, or extraction alone. */ +type WebConsentKind = "ai_full" | typeof AI_EXTRACTION_CONSENT_KIND; + /** * Ensure the web user has an active `ai_full` consent receipt, minting one * if absent. Idempotent: returns `{ minted: false }` when an active master * grant already exists, so callers can invoke it freely on mount. * * Mirrors the iOS `ai_full` grant. `ai_full` is the master kind that - * satisfies every surface gate (insights + coach), matching the iOS app's - * single master toggle rather than per-surface web grants. + * satisfies every surface gate (insights + coach + extraction), matching the + * iOS app's single master toggle rather than per-surface web grants. */ export async function ensureWebAiConsentReceipt( userId: string, intent: WebConsentGrantIntent, now: Date = new Date(), +): Promise { + return ensureWebConsentReceipt(userId, "ai_full", intent, now); +} + +/** + * Ensure an active `ai_extraction` receipt: the consent to read documents, + * lab report scans and typed medication text with an external model. Minted + * by switching the document auto-read on, which is the person's own + * affirmative act, so it supersedes an earlier revocation of the same kind. + * It does not satisfy the Coach or the analysis. + */ +export async function ensureExtractionConsentReceipt( + userId: string, + now: Date = new Date(), +): Promise { + return ensureWebConsentReceipt( + userId, + AI_EXTRACTION_CONSENT_KIND, + "affirmative", + now, + ); +} + +async function ensureWebConsentReceipt( + userId: string, + kind: WebConsentKind, + intent: WebConsentGrantIntent, + now: Date, ): Promise { // Fast path: a cheap read outside the transaction short-circuits the // common already-granted case (every mount after the first) without // opening a transaction. - const existing = await latestActiveReceipt(userId, "ai_full"); + const existing = await latestActiveReceipt(userId, kind); if (existing) return { minted: false, reason: "already_active" }; // A revoked receipt anywhere in this account's history means the user made @@ -88,7 +122,7 @@ export async function ensureWebAiConsentReceipt( // affirmative act is the user deciding again, so it may proceed. if (intent === "heal") { const revoked = await prisma.consentReceipt.findFirst({ - where: { userId, kind: "ai_full", revokedAt: { not: null } }, + where: { userId, kind, revokedAt: { not: null } }, select: { id: true }, }); if (revoked) return { minted: false, reason: "previously_revoked" }; @@ -96,7 +130,7 @@ export async function ensureWebAiConsentReceipt( const artefact = JSON.stringify({ source: "web", - kind: "ai_full", + kind, grantedAt: now.toISOString(), note: "In-app affirmative AI consent granted via the web client.", }); @@ -112,7 +146,7 @@ export async function ensureWebAiConsentReceipt( return await prisma.$transaction( async (tx) => { const inTx = await tx.consentReceipt.findFirst({ - where: { userId, kind: "ai_full", revokedAt: null }, + where: { userId, kind, revokedAt: null }, orderBy: { createdAt: "desc" }, }); if (inTx) return { minted: false, reason: "already_active" } as const; @@ -122,7 +156,7 @@ export async function ensureWebAiConsentReceipt( // mint would overwrite a decision made a moment earlier. if (intent === "heal") { const revokedInTx = await tx.consentReceipt.findFirst({ - where: { userId, kind: "ai_full", revokedAt: { not: null } }, + where: { userId, kind, revokedAt: { not: null } }, select: { id: true }, }); if (revokedInTx) @@ -130,7 +164,7 @@ export async function ensureWebAiConsentReceipt( } const receipt = await tx.consentReceipt.create({ - data: { userId, kind: "ai_full", artefact, signedAt: now }, + data: { userId, kind, artefact, signedAt: now }, }); return { minted: true, receipt } as const; }, 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/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 f4a6cae45..f45a6554a 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 @@ -1820,7 +1820,7 @@ export const profilePaths: NonNullable = { summary: "Set the automatic document-reading opt-in", description: "Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" + - "Turning it ON does two further things a caller should expect. It is itself the standing consent act, so the write appends an `ai_full` `ConsentReceipt` — the same receipt POST /api/consent/ai/web mints, and one DELETE /api/consent/ai/latest revokes without touching this flag. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would otherwise only ever apply to future uploads. The catch-up is fire-and-forget and re-runs every consent and budget gate per document.\n\n" + + "Turning it ON does two further things a caller should expect. It is the consent act for reading documents, so the write appends an `ai_extraction` `ConsentReceipt`: the narrow kind that covers documents, lab report scans and typed medication text and nothing else (not the Coach, not the analysis). DELETE /api/consent/ai/latest revokes it without touching this flag, and the revocation wins: document reading stops even while the flag stays on. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would otherwise only ever apply to future uploads. The catch-up is fire-and-forget and re-runs every consent and budget gate per document.\n\n" + "The body cap here is 1 KB, far tighter than the 64 KB its siblings allow — a payload above it is refused with 413 rather than parsed.", requestBody: { required: true, 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({ From b36c7b6697fb2609603ec816efe1f843e82ca031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:06:22 +0200 Subject: [PATCH 3/5] feat(insights): serve status notes from the new table, only while available Every status note reader and writer moves to InsightStatusCache: the generators, the unchanged-data gates, the stale-while-revalidate read, the ingest debounce, the previous-note prompt block and the medication-compliance card with its per-medication lines. A stall now opens a negative-cache window next to the note instead of a stub row that shadowed it. Stored model text is never served while its capability is unavailable, whatever the reason: the status readers and gates check statusText, the period narrative hides a model-written row while periodNarrative is unavailable (the deterministic narrative is data and always shown), and the daily digest drops reaction lines while reactionLines is unavailable. A read-only miss now reports why (`unavailable` with the capability's reason) and keeps hasProvider meaning provider presence, so a withdrawn consent no longer reads as "no provider". runStatusCompletion takes the capability as a required argument and re-checks it before the provider chain is resolved, so a job queued before a switch flipped still stops at the wire. The comprehensive briefing re-checks briefing the same way. hasUsableStatusProvider is the one presence probe. Refs #975 --- scripts/seed-demo.ts | 54 +- src/__tests__/i18n-reverse-coverage.test.ts | 2 +- src/lib/ai/capabilities/gate.ts | 19 + src/lib/ai/coach/conversation-summary.ts | 2 +- src/lib/ai/coach/facts.ts | 2 +- src/lib/ai/coach/plans.ts | 2 +- src/lib/daily/load-digest.ts | 28 +- .../__tests__/biomarker-status.test.ts | 82 +- .../__tests__/blood-pressure-status.test.ts | 95 ++- src/lib/insights/__tests__/bmi-status.test.ts | 121 ++- ...ensive-briefing-grounding-disposal.test.ts | 9 + .../comprehensive-briefing-reroll.test.ts | 9 + .../comprehensive-budget-refusal.test.ts | 9 + .../comprehensive-generate-force.test.ts | 66 ++ .../comprehensive-json-retry.test.ts | 9 + .../comprehensive-privacy-flip.test.ts | 9 + .../comprehensive-timeout-honored.test.ts | 9 + .../comprehensive-warm-timeout.test.ts | 9 + .../insights/__tests__/general-status.test.ts | 95 ++- .../invalidate-status-insights.test.ts | 106 ++- .../__tests__/locale-pipeline.test.ts | 39 +- .../medication-compliance-status.test.ts | 131 ++- src/lib/insights/__tests__/memory.test.ts | 108 ++- .../insights/__tests__/metric-status.test.ts | 59 +- .../insights/__tests__/mood-status.test.ts | 91 +-- .../insights/__tests__/payload-budget.test.ts | 32 +- .../__tests__/pulse-status-timeout.test.ts | 74 +- .../insights/__tests__/pulse-status.test.ts | 92 ++- .../insights/__tests__/status-batch.test.ts | 45 +- .../__tests__/status-cache-delegated.test.ts | 42 +- .../insights/__tests__/status-cache.test.ts | 608 +++++++++----- .../__tests__/status-note-fixtures.ts | 105 +++ .../__tests__/status-provider-budget.test.ts | 49 +- .../insights/__tests__/weight-status.test.ts | 215 +++-- src/lib/insights/biomarker-status.ts | 10 +- src/lib/insights/blood-pressure-status.ts | 12 +- src/lib/insights/bmi-status.ts | 13 +- src/lib/insights/comprehensive-generate.ts | 24 + .../insights/derived/derived-assessment-ai.ts | 6 +- src/lib/insights/general-status.ts | 12 +- .../insights/medication-compliance-status.ts | 176 ++-- src/lib/insights/memory.ts | 62 +- src/lib/insights/metric-status.ts | 14 +- src/lib/insights/mood-status.ts | 12 +- .../period-narrative-generate.test.ts | 146 +++- .../narrative/period-narrative-generate.ts | 47 +- src/lib/insights/pulse-status.ts | 12 +- src/lib/insights/status-batch.ts | 20 +- src/lib/insights/status-cache.ts | 764 ++++++++++-------- src/lib/insights/status-card-generation.ts | 4 +- src/lib/insights/status-invalidation.ts | 70 +- src/lib/insights/status-provider.ts | 98 +-- src/lib/insights/status-shared.ts | 72 +- src/lib/insights/timeout-fallback.ts | 35 +- src/lib/insights/weight-status.ts | 12 +- .../workout-insight-generate.test.ts | 80 +- .../workout-insight-no-backfill-spend.test.ts | 11 + src/lib/jobs/workout-insight-generate.ts | 24 +- src/lib/workouts/insight-gates.ts | 3 +- .../insights-status-real-text.test.ts | 77 +- 60 files changed, 2574 insertions(+), 1669 deletions(-) create mode 100644 src/lib/insights/__tests__/status-note-fixtures.ts 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__/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/lib/ai/capabilities/gate.ts b/src/lib/ai/capabilities/gate.ts index 254a4d794..3c7df11d9 100644 --- a/src/lib/ai/capabilities/gate.ts +++ b/src/lib/ai/capabilities/gate.ts @@ -150,3 +150,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/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/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/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 437c065da..8443734cf 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-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() }, measurementRollup: { findMany: vi.fn() }, medication: { findMany: vi.fn() }, @@ -14,8 +18,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", () => ({ @@ -31,6 +48,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; @@ -54,6 +72,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. @@ -85,16 +108,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); @@ -138,7 +157,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() }, @@ -148,9 +166,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", @@ -164,23 +179,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", @@ -188,16 +200,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 }, @@ -207,9 +218,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."}'); @@ -228,7 +236,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() }, @@ -238,9 +245,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"}', @@ -252,12 +256,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:"); }); }); @@ -301,7 +302,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 }, @@ -314,9 +314,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 d0cc8da06..44a73647a 100644 --- a/src/lib/insights/__tests__/bmi-status.test.ts +++ b/src/lib/insights/__tests__/bmi-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(), + }, // v1.18.11 (P6) — the input gate probes salient inputs via groupBy. measurement: { findMany: vi.fn(), groupBy: vi.fn() }, measurementRollup: { 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 { generateBmiStatusForUser } from "../bmi-status"; +import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -48,6 +66,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); @@ -71,11 +94,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); @@ -119,15 +138,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", @@ -151,13 +168,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", @@ -172,25 +185,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); @@ -203,23 +212,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", @@ -228,22 +234,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."}'); @@ -261,13 +263,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."}', @@ -277,11 +275,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..39a6ffeb1 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,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), diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index 7517e6f66..cf4c94793 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -40,6 +40,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), diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts index b000afac4..1a50d530f 100644 --- a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts @@ -69,6 +69,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), diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 8ab32fb81..180a8a612 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,9 @@ vi.mock("@/lib/db", () => ({ }, }, })); +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 +112,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..a3f42c45a 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -38,6 +38,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), diff --git a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts index 4ee3bc274..4b70cab43 100644 --- a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts +++ b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts @@ -37,6 +37,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), diff --git a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts index 73bc1fc6c..5d9fdbcee 100644 --- a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts +++ b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts @@ -42,6 +42,15 @@ 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. +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..15304c0d1 100644 --- a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts +++ b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts @@ -44,6 +44,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), 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 d930ac00f..668d8b961 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-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(), count: vi.fn() }, measurementRollup: { findMany: 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", () => ({ @@ -52,6 +69,7 @@ import { metricStatusScope, METRIC_STATUS_IDS, } from "../metric-status-registry"; +import { writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -78,6 +96,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 @@ -90,14 +113,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). @@ -161,14 +180,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); @@ -329,7 +344,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({ @@ -358,16 +372,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; @@ -396,12 +406,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 @@ -424,16 +431,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: @@ -464,7 +467,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() }, @@ -473,9 +475,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 11de84479..7e113e547 100644 --- a/src/lib/insights/__tests__/payload-budget.test.ts +++ b/src/lib/insights/__tests__/payload-budget.test.ts @@ -15,7 +15,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(), + }, // v1.18.11 (P6) — the slow-mover input gate probes salient inputs. measurement: { findMany: vi.fn(), groupBy: vi.fn() }, measurementRollup: { findMany: vi.fn() }, @@ -27,8 +31,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", () => ({ @@ -68,11 +85,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 508cff3b2..9528fbd4a 100644 --- a/src/lib/insights/__tests__/pulse-status-timeout.test.ts +++ b/src/lib/insights/__tests__/pulse-status-timeout.test.ts @@ -12,17 +12,21 @@ 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. */ 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 @@ -34,8 +38,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", () => ({ @@ -49,6 +66,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); }); @@ -59,7 +81,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); @@ -86,19 +107,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 1aed06d5c..8dd617ee2 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-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() }, measurementRollup: { findMany: vi.fn() }, // v1.28.25 — the graded-series cold-tier fallback day-buckets dense @@ -15,8 +19,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", () => ({ @@ -27,6 +44,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; @@ -50,6 +68,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. @@ -71,7 +94,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 @@ -82,9 +104,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); @@ -146,16 +165,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); @@ -194,15 +209,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); @@ -234,15 +245,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", @@ -259,8 +268,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", @@ -269,23 +278,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."}'); @@ -303,14 +308,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."}', @@ -320,12 +321,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..05b44961b 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(), @@ -94,7 +105,7 @@ function completionArgs(overrides: Record = {}) { cacheAction: "status:test", systemPrompt: "system", userPrompt: "user", - consentSurface: "insights" as const, + capability: "statusText" as const, ...overrides, }; } @@ -113,6 +124,40 @@ beforeEach(() => { ledgerOperator = 0; resolveProviderChain.mockResolvedValue(OPERATOR_CHAIN); resolveProvider.mockResolvedValue({ type: "none" }); + aiCapabilityForRecord.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); +}); + +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("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 ec4e98a37..d55d0fc8e 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-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(), + }, // 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() }, @@ -18,10 +22,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(""), @@ -30,6 +63,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; @@ -68,6 +103,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", () => { @@ -86,12 +164,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); @@ -127,14 +201,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", @@ -148,44 +218,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."}'); @@ -223,10 +287,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. @@ -235,33 +295,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", @@ -271,15 +322,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(); @@ -292,21 +345,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", { @@ -322,14 +368,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"}', @@ -341,11 +383,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..2d89f3cdb 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -78,6 +78,7 @@ import { BriefingBudgetExceededError, runBriefingCompletion, } from "@/lib/insights/briefing-provider"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; import { chainRequiresServerManagedConsent, hasActiveConsentForSurface, @@ -142,6 +143,8 @@ export type GenerateOutcome = | { status: "skipped"; reason: + /** The `briefing` capability is unavailable for another reason. */ + | "unavailable" | "no-provider" | "no-consent" | "budget" @@ -657,6 +660,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); 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..0d7185b4c 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -1,4 +1,13 @@ -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, @@ -42,7 +51,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 +88,19 @@ 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 the server-managed chain needs. */ - consentSurface: ConsentSurface; + capability: AiCapabilityKey; +} + +/** The consent surface a capability's server-managed egress is gated on. */ +function consentSurfaceFor(capability: AiCapabilityKey): ConsentSurface { + return capability === "coach" ? "coach" : "insights"; } /** @@ -103,42 +121,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,9 +144,22 @@ export async function runStatusCompletion( ): Promise { const { userId, cacheAction, systemPrompt, userPrompt } = args; + // 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 }; + } + const chain = await resolveStatusChain(userId); if (chain === null) { - return { kind: "none" }; + return { kind: "none", reason: "no_provider" }; } // v1.12.1 — consent gate before server-managed external egress. A chain @@ -168,13 +171,16 @@ export async function runStatusCompletion( // never trip this. if ( chainRequiresServerManagedConsent(chain) && - !(await hasActiveConsentForSurface(userId, args.consentSurface)) + !(await hasActiveConsentForSurface( + userId, + consentSurfaceFor(args.capability), + )) ) { annotate({ action: { name: "insights.status.consent_required" }, - meta: { cacheAction, surface: args.consentSurface }, + meta: { cacheAction, capability: args.capability }, }); - return { kind: "none" }; + return { kind: "none", reason: "consent_required" }; } // 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__/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/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/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/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(); }); }); From 3917a2faceff9b7ca34deeb7bb516b8e94610f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:07:16 +0200 Subject: [PATCH 4/5] feat(ai): resolve the AI capability before any background model work Every AI job now asks its capability for the user before it builds a snapshot, and skips with a `.skipped` annotation naming the reason: the nightly pregenerate (briefing and statusText, each half on its own), the status batch and the per-metric crons, the on-demand status worker, the narrative cron, Coach nudges and their AI composer, the Coach memory refresh, reaction lines, workout paragraphs, and the document summary, catch-up and index jobs. Seven of these read no assistant switch at all before, so the master switch now stops them too. The cron candidate queries drop people who switched AI analysis off in SQL, and no longer read `disableCoach` outside the Coach: hiding the Coach hides the Coach. Coach nudges use the one presence probe, so a central Codex account is no longer skipped; a missing consent still gets the deterministic template. The arrival spine asks only the provider-free half (switches and modules) before it enqueues, keeping its module graph free of provider code. Document reading needs an extraction receipt (ai_extraction or ai_full) for any external provider; the auto-read toggle is the trigger, no longer a consent shortcut, so a revoked receipt wins over a toggle left on. Lab report scans get the same rule for a whole chain. Two structural guards: every file that calls a provider is named with the reason its call is admitted, and the chokepoints re-check a capability; every AI job references its capability gate. An integration matrix runs each worker under the refusing states with a provider spy. Refs #975 --- .../ai-egress-capability-guard.test.ts | 195 +++++++++++ .../documents/inbound/[id]/extract/route.ts | 3 - .../api/documents/inbound/[id]/index/route.ts | 1 - .../documents/inbound/[id]/suggest/route.ts | 2 - .../documents/inbound/[id]/summary/route.ts | 2 - .../api/documents/inbound/reindex/route.ts | 1 - src/lib/ai/__tests__/consent-guard.test.ts | 133 ++++--- .../coach-memory-refresh-worker.test.ts | 65 ++++ .../ai/coach/coach-memory-refresh-worker.ts | 14 + src/lib/ai/consent-guard.ts | 77 +++-- .../__tests__/fenced-chat-pipeline.test.ts | 13 +- src/lib/documents/fenced-chat.ts | 1 - .../__tests__/no-dead-assistant-flag.test.ts | 14 +- .../ai-job-capability-inventory.test.ts | 124 +++++++ .../__tests__/async-state-contract.test.ts | 63 ++++ .../__tests__/coach-nudge-ai-consent.test.ts | 34 ++ src/lib/jobs/__tests__/coach-nudge.test.ts | 84 ++++- .../__tests__/data-arrival-worker.test.ts | 45 +++ .../document-content-index-backfill.test.ts | 76 ++++ .../document-summary-catchup.test.ts | 35 ++ .../jobs/__tests__/document-summary.test.ts | 96 +++++ .../__tests__/insight-pregenerate.test.ts | 206 +++++++++-- .../__tests__/insight-status-generate.test.ts | 64 ++++ .../__tests__/period-narrative-warm.test.ts | 70 +++- .../reaction-line-degradation.test.ts | 47 ++- .../__tests__/status-cron-candidates.test.ts | 155 +++++---- src/lib/jobs/ai-job-candidates.ts | 62 ++++ src/lib/jobs/coach-nudge-ai.ts | 13 + src/lib/jobs/coach-nudge.ts | 55 +-- src/lib/jobs/data-arrival.ts | 30 +- .../jobs/document-content-index-backfill.ts | 30 +- src/lib/jobs/document-summary-catchup.ts | 13 + src/lib/jobs/document-summary.ts | 55 ++- src/lib/jobs/insight-pregenerate.ts | 103 ++++-- src/lib/jobs/insight-status-generate.ts | 14 + src/lib/jobs/period-narrative-warm.ts | 31 +- src/lib/jobs/reaction-line.ts | 18 +- src/lib/jobs/reminder/insights-handlers.ts | 22 +- src/lib/jobs/reminder/register-status.ts | 1 + src/lib/jobs/status-cron-candidates.ts | 88 ++--- tests/integration/ai-optional-jobs.test.ts | 289 ++++++++++++++++ .../consent-withdrawal-purge.test.ts | 327 ++++++++++++++++++ 42 files changed, 2389 insertions(+), 382 deletions(-) create mode 100644 src/__tests__/ai-egress-capability-guard.test.ts create mode 100644 src/lib/ai/coach/__tests__/coach-memory-refresh-worker.test.ts create mode 100644 src/lib/jobs/__tests__/ai-job-capability-inventory.test.ts create mode 100644 src/lib/jobs/ai-job-candidates.ts create mode 100644 tests/integration/ai-optional-jobs.test.ts create mode 100644 tests/integration/consent-withdrawal-purge.test.ts diff --git a/src/__tests__/ai-egress-capability-guard.test.ts b/src/__tests__/ai-egress-capability-guard.test.ts new file mode 100644 index 000000000..a8c7acce9 --- /dev/null +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -0,0 +1,195 @@ +/** + * Structural guard on the places a model is actually called. + * + * A capability is only as good as the last check before the wire. A job + * enqueued before an operator flipped a switch, or before the person withdrew + * consent or turned AI analysis off, must still stop there. So every file that + * hands a prompt to a provider (`.generateCompletion(`, the chain runners) is + * named here with the reason its call is admitted, and the ones that ARE a + * chokepoint must re-check a capability in the same file. + * + * Mirrors `ai-consent-enforcement-guard.test.ts` in shape and in its limits: it + * proves a reference exists, not that it sits on every branch or before the + * call. The integration suite with a provider spy + * (`tests/integration/ai-optional-jobs.test.ts`) is what proves behaviour. + * A future egress helper under a new name would slip the matcher; a reviewer + * adding one should add it here. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { runStatusCompletion } from "@/lib/insights/status-provider"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** A provider call: a client's completion (plain or streamed), or a chain runner. */ +const EGRESS_CALL = + /\.generateCompletion(?:Stream)?\s*\(|\brunRawCompletionWithFallback\s*\(|\brunWithFallback\s*\(/; + +/** A capability read in the same file. */ +const CAPABILITY_CHECK = + /\b(?:aiCapabilityForJob|aiCapabilityForRecord|requireAiCapability|getAiCapability)\s*\(/; + +/** Comments name the helpers too; only code counts. */ +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +function code(rel: string): string { + return stripComments(readFileSync(join(SRC, rel), "utf8")); +} + +interface EgressSite { + /** True when this file is a chokepoint and must re-check a capability. */ + recheck: boolean; + reason: string; +} + +/** + * Every file that calls a provider, and why the call is admitted. + */ +const EGRESS_SITES: Record = { + // ── Chokepoints: re-check at the wire ────────────────────────────────── + "lib/insights/status-provider.ts": { + recheck: true, + reason: + "runStatusCompletion: status notes, narratives, workout paragraphs, derived and biomarker assessments, Coach memory. Takes the capability as a required argument and re-checks it before the chain is resolved.", + }, + "lib/jobs/reaction-line.ts": { + recheck: true, + reason: + "Reaction lines call the first chain entry directly; the worker resolves `reactionLines` before the digest or the chain.", + }, + "lib/jobs/coach-nudge-ai.ts": { + recheck: true, + reason: + "AI-composed nudges call the first chain entry directly; the composer re-checks `coach` before the chain.", + }, + + // ── Admitted by the one caller that reaches them ─────────────────────── + "lib/insights/briefing-provider.ts": { + recheck: false, + reason: + "Reached only through generateComprehensiveInsight, which re-checks `briefing` before the chain is resolved.", + }, + "lib/ai/coach/tools/loop.ts": { + recheck: false, + reason: + "The Coach tool loop runs inside the chat stream, which the chat route gates on `coach`.", + }, + "lib/ai/coach/self-context-questions.ts": { + recheck: false, + reason: + "About-me follow-up questions; reached from the about-me route, which gates `aboutMeQuestions`.", + }, + "lib/documents/assist.ts": { + recheck: false, + reason: + "Document suggest/chat helpers; reached from the document routes and jobs, which resolve `documentAi` before a provider is picked.", + }, + "lib/documents/describe.ts": { + recheck: false, + reason: + "Document summary; reached from the summary route and the summary job, which resolve `documentAi` first.", + }, + "lib/documents/extract.ts": { + recheck: false, + reason: + "Document extraction/indexing; reached from the document routes and the index jobs, which resolve `documentAi` first.", + }, + "lib/labs/ocr-extract.ts": { + recheck: false, + reason: + "Lab report scan; reached from the labs OCR route, which gates `labsOcr`.", + }, + "app/api/medications/extract/route.ts": { + recheck: false, + reason: + "Medication text extraction; the route is the chokepoint for `medicationExtract`.", + }, + "app/api/ai/test/route.ts": { + recheck: false, + reason: + "The connection test a person runs on a provider they are configuring; it sends a fixed probe prompt, no health data.", + }, + + // ── The machinery itself ──────────────────────────────────────────────── + "lib/ai/provider-runner.ts": { + recheck: false, + reason: "The chain runners; every caller above is what admits a run.", + }, + "lib/ai/openai-client.ts": { + recheck: false, + reason: "A wire client delegating to its own completion method.", + }, + "lib/ai/local-client.ts": { + recheck: false, + reason: "A wire client delegating to its own completion method.", + }, + "lib/ai/coach/eval/judge.ts": { + recheck: false, + reason: "The offline evaluation harness; never on a request or job path.", + }, +}; + +describe("AI egress call sites", () => { + const found = sourceFiles().filter((rel) => EGRESS_CALL.test(code(rel))); + + it("finds provider calls at all (an empty matcher is a failure, not a pass)", () => { + expect(found.length).toBeGreaterThan(5); + }); + + it("every provider call site is named, with a reason", () => { + const unlisted = found.filter((rel) => !(rel in EGRESS_SITES)); + expect( + unlisted, + "A new file calls a provider. Add it to EGRESS_SITES with the reason its call is admitted, and re-check a capability in it if it is a chokepoint.", + ).toEqual([]); + }); + + it("the list carries no file that no longer calls a provider", () => { + const stale = Object.keys(EGRESS_SITES).filter( + (rel) => !found.includes(rel), + ); + expect(stale).toEqual([]); + }); + + it("every chokepoint re-checks a capability in the same file", () => { + const missing = Object.entries(EGRESS_SITES) + .filter(([, site]) => site.recheck) + .filter(([rel]) => !CAPABILITY_CHECK.test(code(rel))) + .map(([rel]) => rel); + expect(missing).toEqual([]); + }); +}); + +describe("the status chokepoint's capability argument", () => { + it("is required by the type (the @ts-expect-error below fails typecheck if it is not)", () => { + type Args = Parameters[0]; + // @ts-expect-error — `capability` is required: a caller that omits it does not compile. + const withoutCapability: Args = { + userId: "u", + cacheAction: "insights.weight-status.en", + systemPrompt: "s", + userPrompt: "p", + }; + const withCapability: Args = { + ...withoutCapability, + capability: "statusText", + }; + expect(withCapability.capability).toBe("statusText"); + }); +}); diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 118717268..a7655f3fb 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -230,7 +230,6 @@ async function handleTextExtract( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); const rl = await checkDocumentAiRateLimit(userId); @@ -347,7 +346,6 @@ async function handleStoredExtract( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); const rl = await checkDocumentAiRateLimit(userId); @@ -433,7 +431,6 @@ async function handleVisionExtract( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); const rl = await checkDocumentAiRateLimit(userId); diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index 04530af0c..21b77495b 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -192,7 +192,6 @@ async function handleVisionIndex( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); const rl = await checkDocumentAiRateLimit(userId); diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 0fe5d57a7..f7c0dd98b 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -135,7 +135,6 @@ async function handleTextSuggest( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); const rl = await checkDocumentAiRateLimit(userId); @@ -228,7 +227,6 @@ async function handleVisionSuggest( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); const rl = await checkDocumentAiRateLimit(userId); diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index f207faf12..9dc18d6d8 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -332,7 +332,6 @@ async function handleTextSummary( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); } catch (err) { // Refused before any dispatch — the slot goes back with the refusal. @@ -403,7 +402,6 @@ async function handleVisionSummary( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); const rl = await checkDocumentAiRateLimit(userId); diff --git a/src/app/api/documents/inbound/reindex/route.ts b/src/app/api/documents/inbound/reindex/route.ts index de2a13ff8..052efc043 100644 --- a/src/app/api/documents/inbound/reindex/route.ts +++ b/src/app/api/documents/inbound/reindex/route.ts @@ -56,7 +56,6 @@ export const POST = apiHandler(async (request) => { await assertDocumentEgressConsent({ userId: user.id, providerType: pick.providerType, - surface: "insights", }); const { enqueued: jobCreated } = await enqueueContentIndexBackfill(user.id); diff --git a/src/lib/ai/__tests__/consent-guard.test.ts b/src/lib/ai/__tests__/consent-guard.test.ts index a7e5a2e0f..939856a24 100644 --- a/src/lib/ai/__tests__/consent-guard.test.ts +++ b/src/lib/ai/__tests__/consent-guard.test.ts @@ -1,27 +1,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/consent/receipts", () => ({ + AI_EXTRACTION_CONSENT_KIND: "ai_extraction", latestActiveReceipt: vi.fn(), })); -vi.mock("@/lib/documents/document-settings", () => ({ - documentAutoReadEnabled: vi.fn().mockResolvedValue(false), -})); import { ConsentRequiredError, assertConsentForChain, assertDocumentEgressConsent, + assertExtractionConsentForChain, chainRequiresServerManagedConsent, hasActiveConsentForSurface, isExternalDocumentEgress, } from "../consent-guard"; import { latestActiveReceipt } from "@/lib/consent/receipts"; -import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; import type { ProviderChainResolved } from "../provider-runner"; import type { ConsentKind } from "@/lib/validations/consent"; const mockedLatest = vi.mocked(latestActiveReceipt); -const mockedAutoRead = vi.mocked(documentAutoReadEnabled); /** Minimal chain entry — the gate only inspects `providerType`. */ function entry(providerType: string): ProviderChainResolved { @@ -206,34 +203,21 @@ describe("isExternalDocumentEgress", () => { describe("assertDocumentEgressConsent", () => { beforeEach(() => { mockedLatest.mockReset(); - mockedAutoRead.mockReset(); - // Default: the auto-read toggle is OFF (the shipped privacy posture). - mockedAutoRead.mockResolvedValue(false); }); it("never reads consent and never throws for a LOCAL document pick", async () => { grant([]); await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: "local", - surface: "insights", - }), + assertDocumentEgressConsent({ userId: "u1", providerType: "local" }), ).resolves.toBeUndefined(); expect(mockedLatest).not.toHaveBeenCalled(); - // A local pick short-circuits before the toggle is even consulted. - expect(mockedAutoRead).not.toHaveBeenCalled(); }); // The live gap the governance fix closes: codex was ungated for documents. it("REQUIRES a receipt to send a document to codex (the closed gap)", async () => { grant([]); await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: "codex", - surface: "insights", - }), + assertDocumentEgressConsent({ userId: "u1", providerType: "codex" }), ).rejects.toBeInstanceOf(ConsentRequiredError); }); @@ -241,65 +225,100 @@ describe("assertDocumentEgressConsent", () => { grant([]); for (const p of ["openai", "anthropic", "admin-openai"]) { await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: p, - surface: "insights", - }), + assertDocumentEgressConsent({ userId: "u1", providerType: p }), ).rejects.toBeInstanceOf(ConsentRequiredError); } }); - it("proceeds for codex WITH an active document-class receipt", async () => { - grant(["ai_insights_only"]); + it("proceeds for an external pick with an active extraction receipt", async () => { + grant(["ai_extraction"]); await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: "codex", - surface: "insights", - }), + assertDocumentEgressConsent({ userId: "u1", providerType: "codex" }), ).resolves.toBeUndefined(); }); - // Gate A: the documentsAutoAiRead opt-in short-circuits an external pick. - it("proceeds for an external pick when documentsAutoAiRead is ON, without a receipt", async () => { - grant([]); - mockedAutoRead.mockResolvedValue(true); - for (const p of ["codex", "openai", "anthropic", "admin-openai"]) { + it("proceeds for an external pick with the master ai_full receipt", async () => { + grant(["ai_full"]); + await expect( + assertDocumentEgressConsent({ userId: "u1", providerType: "openai" }), + ).resolves.toBeUndefined(); + }); + + // K1: the analysis and the Coach consents no longer cover documents. + it("refuses an external pick when only the analysis or Coach consent is active", async () => { + for (const kinds of [ + ["ai_insights_only"], + ["ai_coach"], + ] as ConsentKind[][]) { + grant(kinds); await expect( - assertDocumentEgressConsent({ - userId: "u1", - providerType: p, - surface: "insights", - }), - ).resolves.toBeUndefined(); + assertDocumentEgressConsent({ userId: "u1", providerType: "codex" }), + ).rejects.toBeInstanceOf(ConsentRequiredError); } - // The toggle short-circuits BEFORE the receipt read. + }); + + it("names the extraction group on the refusal", async () => { + grant([]); + await expect( + assertDocumentEgressConsent({ userId: "u1", providerType: "codex" }), + ).rejects.toMatchObject({ surface: "extraction" }); + }); +}); + +describe("extraction consent does not satisfy the other groups", () => { + it("an ai_extraction receipt alone satisfies neither the Coach nor the analysis", async () => { + grant(["ai_extraction"]); + await expect(hasActiveConsentForSurface("u1", "coach")).resolves.toBe( + false, + ); + await expect(hasActiveConsentForSurface("u1", "insights")).resolves.toBe( + false, + ); + await expect(hasActiveConsentForSurface("u1", "extraction")).resolves.toBe( + true, + ); + }); +}); + +describe("assertExtractionConsentForChain (lab report scans)", () => { + beforeEach(() => mockedLatest.mockReset()); + + it("is a no-op for a chain of local entries only", async () => { + grant([]); + await expect( + assertExtractionConsentForChain({ + userId: "u1", + chain: [entry("local")], + }), + ).resolves.toBeUndefined(); expect(mockedLatest).not.toHaveBeenCalled(); }); - it("STILL requires a receipt for an external pick when documentsAutoAiRead is OFF", async () => { + it("requires an extraction receipt for a BYOK chain, unlike the self-snapshot rule", async () => { grant([]); - mockedAutoRead.mockResolvedValue(false); await expect( - assertDocumentEgressConsent({ + assertExtractionConsentForChain({ userId: "u1", - providerType: "codex", - surface: "insights", + chain: [entry("local"), entry("anthropic")], }), ).rejects.toBeInstanceOf(ConsentRequiredError); - }); - - it("does NOT consult the toggle for a LOCAL pick even when it would be ON", async () => { - grant([]); - mockedAutoRead.mockResolvedValue(true); + // The self-snapshot rule would have admitted the same chain. await expect( - assertDocumentEgressConsent({ + assertConsentForChain({ userId: "u1", - providerType: "local", + chain: [entry("anthropic")], surface: "insights", }), ).resolves.toBeUndefined(); - expect(mockedAutoRead).not.toHaveBeenCalled(); + }); + + it("admits the chain with an extraction receipt", async () => { + grant(["ai_extraction"]); + await expect( + assertExtractionConsentForChain({ + userId: "u1", + chain: [entry("openai")], + }), + ).resolves.toBeUndefined(); }); }); diff --git a/src/lib/ai/coach/__tests__/coach-memory-refresh-worker.test.ts b/src/lib/ai/coach/__tests__/coach-memory-refresh-worker.test.ts new file mode 100644 index 000000000..322afb932 --- /dev/null +++ b/src/lib/ai/coach/__tests__/coach-memory-refresh-worker.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("../conversation-summary", () => ({ + refreshConversationSummary: vi.fn(async () => ({ status: "refreshed" })), +})); +vi.mock("../facts", () => ({ + extractAndStoreFacts: vi.fn(async () => ({ status: "stored", count: 1 })), +})); +vi.mock("../plans", () => ({ + extractAndStorePlanProposals: vi.fn(async () => ({ + status: "stored", + count: 0, + })), +})); + +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; +import { annotate } from "@/lib/logging/context"; +import { runCoachMemoryRefresh } from "../coach-memory-refresh-worker"; +import { refreshConversationSummary } from "../conversation-summary"; +import { extractAndStoreFacts } from "../facts"; +import { extractAndStorePlanProposals } from "../plans"; + +const payload = { conversationId: "c1", userId: "u1", locale: "en" as const }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runCoachMemoryRefresh", () => { + it("runs all three steps when the Coach is available", async () => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + await runCoachMemoryRefresh(payload); + expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "coach"); + expect(refreshConversationSummary).toHaveBeenCalled(); + expect(extractAndStoreFacts).toHaveBeenCalled(); + expect(extractAndStorePlanProposals).toHaveBeenCalled(); + }); + + it.each(["operator_disabled", "user_disabled", "consent_required"] as const)( + "reads no transcript and runs no step when the Coach is unavailable (%s)", + async (reason) => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + await runCoachMemoryRefresh(payload); + expect(refreshConversationSummary).not.toHaveBeenCalled(); + expect(extractAndStoreFacts).not.toHaveBeenCalled(); + expect(extractAndStorePlanProposals).not.toHaveBeenCalled(); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "coach.memory.refresh.skipped" }, + meta: { reason }, + }); + }, + ); +}); diff --git a/src/lib/ai/coach/coach-memory-refresh-worker.ts b/src/lib/ai/coach/coach-memory-refresh-worker.ts index c303e49f0..295a9212d 100644 --- a/src/lib/ai/coach/coach-memory-refresh-worker.ts +++ b/src/lib/ai/coach/coach-memory-refresh-worker.ts @@ -15,6 +15,7 @@ * chokepoint did no budget work at all, so every generator behind it, not just * these two, spent unmetered. */ +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { annotate } from "@/lib/logging/context"; import type { CoachMemoryRefreshPayload } from "./coach-memory-shared"; @@ -31,6 +32,19 @@ export async function runCoachMemoryRefresh( // fallback body for every locale that has no reviewed one. const locale = payload.locale ?? "en"; + // The `coach` capability before any transcript is read. The chat route that + // enqueued this admitted the Coach, but the operator, the person's Coach + // switch or a consent can change while the job waits. Stored memory stays + // as it is; only new model work stops. The chokepoint re-checks per step. + const capability = await aiCapabilityForJob(userId, "coach"); + if (!capability.available) { + annotate({ + action: { name: "coach.memory.refresh.skipped" }, + meta: { reason: capability.reason }, + }); + return; + } + let summaryStatus = "error"; try { const result = await refreshConversationSummary(conversationId, userId, { diff --git a/src/lib/ai/consent-guard.ts b/src/lib/ai/consent-guard.ts index 213c5a37c..bd243b2cc 100644 --- a/src/lib/ai/consent-guard.ts +++ b/src/lib/ai/consent-guard.ts @@ -27,16 +27,26 @@ * failure and we must not race that decision. A receipt of the surface's * mapped kind OR the superset `ai_full` grant satisfies the gate. */ -import { latestActiveReceipt } from "@/lib/consent/receipts"; -import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; +import { + AI_EXTRACTION_CONSENT_KIND, + latestActiveReceipt, +} from "@/lib/consent/receipts"; import type { ConsentKind } from "@/lib/validations/consent"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; /** - * The two AI surfaces that egress PHI. Each maps to the consent kind the iOS - * client collects for it; `ai_full` (the master grant) satisfies either. + * The consent groups that gate PHI egress. Each maps to the consent kind + * collected for it; `ai_full` (the master grant) satisfies every one. + * + * - `coach`: the Coach (`ai_coach`). + * - `insights`: model-written analysis over the person's own readings + * (`ai_insights_only`). + * - `extraction`: reading a document, a lab report scan or typed medication + * text (`ai_extraction`, the narrower grant the document auto-read toggle + * mints). It no longer satisfies the Coach or the analysis, and neither of + * those satisfies it. */ -export type ConsentSurface = "coach" | "insights"; +export type ConsentSurface = "coach" | "insights" | "extraction"; /** * Provider tags that egress via an operator-managed credential the user did not @@ -85,9 +95,14 @@ export function chainRequiresServerManagedConsent( /** The consent kinds that satisfy a given surface (the specific + the master). */ function acceptableKinds(surface: ConsentSurface): ConsentKind[] { - return surface === "coach" - ? ["ai_coach", "ai_full"] - : ["ai_insights_only", "ai_full"]; + switch (surface) { + case "coach": + return ["ai_coach", "ai_full"]; + case "insights": + return ["ai_insights_only", "ai_full"]; + case "extraction": + return [AI_EXTRACTION_CONSENT_KIND, "ai_full"]; + } } /** @@ -157,18 +172,14 @@ export function isExternalDocumentEgress(providerType: string): boolean { /** * Enforce the document-class consent precondition for the provider that will * actually receive the document. No-op for a `local` pick (nothing leaves the - * machine); for any external pick, throw `ConsentRequiredError` unless an active - * receipt of the surface's mapped kind (or `ai_full`) is on file. + * machine); for any external pick, throw `ConsentRequiredError` unless an + * active extraction receipt (`ai_extraction`, or the master `ai_full`) is on + * file. * - * ONE relaxation: the per-user `documentsAutoAiRead` opt-in. Flipping it ON is - * itself the standing consent act (the toggle write also mints an `ai_full` - * receipt for the audit trail), so the gate short-circuits an external pick when - * it is ON — that is what removes the per-document friction ("upload and the AI - * just reads it, no 80 switches"). When it is OFF the branch is inert and the - * gate behaves exactly as shipped: no external egress without an explicit - * receipt. The short-circuit is checked BEFORE the receipt read and ONLY for an - * external pick — a `local` pick is already ungated above, so the toggle can - * never widen egress that was not already external. + * The document auto-read toggle is NOT a shortcut here any more. Switching it + * on mints an `ai_extraction` receipt, and that receipt is what this reads, so + * a receipt the person later revokes wins over a toggle still left on: the + * withdrawal is honoured at the next egress, not at the next toggle flip. * * Call this AFTER the document provider is picked and BEFORE the first * `generateCompletion` on it. @@ -176,10 +187,30 @@ export function isExternalDocumentEgress(providerType: string): boolean { export async function assertDocumentEgressConsent(args: { userId: string; providerType: string; - surface: ConsentSurface; }): Promise { if (!isExternalDocumentEgress(args.providerType)) return; - if (await documentAutoReadEnabled(args.userId)) return; - if (await hasActiveConsentForSurface(args.userId, args.surface)) return; - throw new ConsentRequiredError(args.surface); + if (await hasActiveConsentForSurface(args.userId, "extraction")) return; + throw new ConsentRequiredError("extraction"); +} + +/** + * The document rule for a provider CHAIN (lab report scans, which cascade + * across the chain rather than calling one picked provider). A lab report is a + * document, so any entry that would send it off the machine needs an active + * extraction receipt, whoever holds the credential; a chain of local entries + * only is ungated. Fails closed like `assertConsentForChain`: an external + * entry anywhere in the chain requires the receipt, because the runner may + * cascade to it. + */ +export async function assertExtractionConsentForChain(args: { + userId: string; + chain: ReadonlyArray; +}): Promise { + if ( + !args.chain.some((entry) => isExternalDocumentEgress(entry.providerType)) + ) { + return; + } + if (await hasActiveConsentForSurface(args.userId, "extraction")) return; + throw new ConsentRequiredError("extraction"); } diff --git a/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts b/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts index 95dda88f1..9fe8961c7 100644 --- a/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts +++ b/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts @@ -113,13 +113,12 @@ describe("streamFencedReply — consent fan-out", () => { docs: [doc("a", "x"), doc("b", "y"), doc("c", "z")], }); expect(assertDocumentEgressConsent).toHaveBeenCalledTimes(3); - expect(assertDocumentEgressConsent).toHaveBeenCalledWith( - expect.objectContaining({ - userId: "user-1", - providerType: "anthropic", - surface: "insights", - }), - ); + // The document rule is the extraction receipt for the picked provider; + // no chat surface rides along to widen it. + expect(assertDocumentEgressConsent).toHaveBeenCalledWith({ + userId: "user-1", + providerType: "anthropic", + }); }); it("refuses (throws) with ZERO egress + no user turn persisted when ANY document's consent fails", async () => { diff --git a/src/lib/documents/fenced-chat.ts b/src/lib/documents/fenced-chat.ts index 9b8e63e04..20147139d 100644 --- a/src/lib/documents/fenced-chat.ts +++ b/src/lib/documents/fenced-chat.ts @@ -294,7 +294,6 @@ export async function streamFencedReply( await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); } diff --git a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts index fbb554f3b..3fb7b7ae5 100644 --- a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts +++ b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts @@ -32,7 +32,8 @@ * * Mutation checks: adding `await requireAssistantSurface("coach")` to any * route outside the frozen list turns guard 3 red by file name; removing the - * `documentAi` entry from `AWAITING_READER` turns guard 2 red; putting + * `aiCapabilityForJob(userId, "documentAi")` calls from the document jobs + * turns guard 2 red for `documentAi`; putting * `assistantCorrelationsEnabled` back on the schema turns guard 1 red. */ import { readdirSync, readFileSync, statSync } from "node:fs"; @@ -99,13 +100,10 @@ const PLUMBING = [ * Switches with no reader yet, and why that is correct for now. An entry is * a claim with an expiry: the test below fails as soon as a reader appears. */ -const AWAITING_READER: Record = { - documentAi: - "The capabilities it covers (documentAi, labsOcr, medicationExtract) are " + - "resolved and published, but the document, lab-scan and medication " + - "extraction routes move onto requireAiCapability in the same release; " + - "until then the master switch is what stops them.", -}; +// Empty since the document jobs resolve `documentAi` (the summary job, its +// catch-up pass and the index backfill). A new switch that ships before its +// reader is listed here with the reason. +const AWAITING_READER: Record = {}; /** * Every file that still calls the retired gate. Only ever shrinks. diff --git a/src/lib/jobs/__tests__/ai-job-capability-inventory.test.ts b/src/lib/jobs/__tests__/ai-job-capability-inventory.test.ts new file mode 100644 index 000000000..00f6886bc --- /dev/null +++ b/src/lib/jobs/__tests__/ai-job-capability-inventory.test.ts @@ -0,0 +1,124 @@ +/** + * Every background job that writes model text resolves its AI capability. + * + * The rule for jobs (design: "AI optional"): a worker resolves its capability + * for the user before it builds a snapshot and skips with a `.skipped` + * annotation when the answer is no; the provider chokepoint re-checks at the + * wire. This freezes the first half as a reference: each file below must call + * the named gate with the named capability key. + * + * It proves a reference exists, not that it sits before the snapshot or on + * every branch. The integration suite (`tests/integration/ai-optional-jobs.test.ts`) + * runs each worker under the refusing states with a provider spy and is what + * proves behaviour. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + AI_CAPABILITY_KEYS, + type AiCapabilityKey, +} from "@/lib/ai/capabilities/types"; + +const ROOT = join(process.cwd(), "src"); + +type Gate = + "aiCapabilityForJob" | "aiCapabilityForRecord" | "aiWorkNotRuledOut"; + +interface JobGate { + gate: Gate; + keys: readonly AiCapabilityKey[]; +} + +/** Every AI job (and the enqueue sides that decide whether one is queued). */ +const AI_JOBS: Record = { + "lib/jobs/insight-pregenerate.ts": { + gate: "aiCapabilityForJob", + keys: ["briefing", "statusText"], + }, + "lib/insights/status-batch.ts": { + gate: "aiCapabilityForJob", + keys: ["statusText"], + }, + "lib/jobs/reminder/insights-handlers.ts": { + gate: "aiCapabilityForJob", + keys: ["statusText"], + }, + "lib/jobs/insight-status-generate.ts": { + gate: "aiCapabilityForJob", + keys: ["statusText"], + }, + "lib/jobs/period-narrative-warm.ts": { + gate: "aiCapabilityForJob", + keys: ["periodNarrative"], + }, + "lib/jobs/coach-nudge.ts": { gate: "aiCapabilityForJob", keys: ["coach"] }, + "lib/jobs/coach-nudge-ai.ts": { gate: "aiCapabilityForJob", keys: ["coach"] }, + "lib/ai/coach/coach-memory-refresh-worker.ts": { + gate: "aiCapabilityForJob", + keys: ["coach"], + }, + "lib/jobs/reaction-line.ts": { + gate: "aiCapabilityForJob", + keys: ["reactionLines"], + }, + "lib/jobs/workout-insight-generate.ts": { + gate: "aiCapabilityForJob", + keys: ["workoutInsights"], + }, + "lib/jobs/document-summary.ts": { + gate: "aiCapabilityForJob", + keys: ["documentAi"], + }, + "lib/jobs/document-summary-catchup.ts": { + gate: "aiCapabilityForJob", + keys: ["documentAi"], + }, + "lib/jobs/document-content-index-backfill.ts": { + gate: "aiCapabilityForJob", + keys: ["documentAi"], + }, + // The arrival spine may not import the provider machinery, so it asks the + // provider-free half before it enqueues; the jobs resolve the rest. + "lib/jobs/data-arrival.ts": { + gate: "aiWorkNotRuledOut", + keys: ["workoutInsights", "reactionLines"], + }, + // Enqueue sides reached from requests and jobs alike. + "lib/insights/status-invalidation.ts": { + gate: "aiCapabilityForRecord", + keys: ["statusText"], + }, +}; + +function code(rel: string): string { + return readFileSync(join(ROOT, rel), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +describe("AI job capability inventory", () => { + const entries = Object.entries(AI_JOBS); + + it("is not empty", () => { + expect(entries.length).toBeGreaterThan(10); + }); + + it("names only capabilities that exist", () => { + for (const [, { keys }] of entries) { + for (const key of keys) expect(AI_CAPABILITY_KEYS).toContain(key); + } + }); + + it.each(entries)("%s resolves its capability", (rel, { gate, keys }) => { + const source = code(rel); + for (const key of keys) { + const call = new RegExp(`\\b${gate}\\s*\\([^)]*?["']${key}["']`); + expect( + call.test(source), + `${rel} must call ${gate}(…, "${key}") before it does model work`, + ).toBe(true); + } + }); +}); diff --git a/src/lib/jobs/__tests__/async-state-contract.test.ts b/src/lib/jobs/__tests__/async-state-contract.test.ts index 5f70f8a95..505fe6caf 100644 --- a/src/lib/jobs/__tests__/async-state-contract.test.ts +++ b/src/lib/jobs/__tests__/async-state-contract.test.ts @@ -79,6 +79,10 @@ vi.mock("@/lib/ai/coach/budget", () => ({ resolveDailyCapFor: vi.fn(() => 1000), resolveCostOwner: vi.fn(() => "operator" as const), })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), + aiCapabilityForRecord: vi.fn(), +})); vi.mock("@/lib/ai/ai-budgets", () => ({ AI_BUDGETS: { documentSummary: { temperature: 0.3, maxTokens: 600 }, @@ -109,6 +113,10 @@ import { ConsentRequiredError, } from "@/lib/ai/consent-guard"; import { reserveBudget } from "@/lib/ai/coach/budget"; +import { + aiCapabilityForJob, + aiCapabilityForRecord, +} from "@/lib/ai/capabilities/gate"; const DOC = { id: "doc-1", @@ -163,6 +171,13 @@ beforeEach(() => { locale: "en", } as never); vi.mocked(documentAutoReadEnabled).mockResolvedValue(true); + for (const resolve of [aiCapabilityForJob, aiCapabilityForRecord]) { + vi.mocked(resolve).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + } vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined); vi.mocked(loadOwnedDocument).mockResolvedValue(DOC as never); @@ -204,6 +219,39 @@ describe("contract (a): every runDocumentSummaryJob branch writes a terminal sta }, state: "NONE", }, + { + name: "documentAi unavailable for want of a provider", + setup: () => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "no_provider", + onDeviceAllowed: true, + }); + }, + state: "UNAVAILABLE", + }, + { + name: "documentAi unavailable for want of a consent receipt", + setup: () => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: true, + }); + }, + state: "UNAVAILABLE", + }, + { + name: "documentAi switched off by the operator", + setup: () => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + }); + }, + state: "NONE", + }, { name: "no provider configured", setup: () => { @@ -404,6 +452,21 @@ describe("contract (b): enqueue never claims a pending state its job cannot reso expect(summaryStateWrites()).toEqual([]); }); + it("enqueueDocumentSummary with documentAi unavailable claims nothing and mints no job", async () => { + vi.mocked(aiCapabilityForRecord).mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: true, + }); + const send = vi.fn().mockResolvedValue("job-1"); + vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); + + const result = await enqueueDocumentSummary("user-1", "doc-1"); + expect(result).toEqual({ enqueued: false }); + expect(send).not.toHaveBeenCalled(); + expect(summaryStateWrites()).toEqual([]); + }); + it("enqueueDocumentSummary claims PENDING only once a job id exists", async () => { const send = vi.fn().mockResolvedValue(null); vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); diff --git a/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts b/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts index 8923120a3..430f0faed 100644 --- a/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge-ai-consent.test.ts @@ -42,6 +42,14 @@ vi.mock("@/lib/ai/coach/budget", () => budgetMocks); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +// The composer resolves the `coach` capability before the chain. Available +// by default so the consent logic below is what each case exercises. +const aiCapabilityForJob = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob, + aiCapabilityForRecord: vi.fn(), +})); + import { composeNudgeWithAI, createNudgeAiTickBudget, @@ -78,6 +86,11 @@ beforeEach(() => { vi.clearAllMocks(); provider = makeProvider(); latestActiveReceipt.mockResolvedValue(null); + aiCapabilityForJob.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); budgetMocks.reserveBudget.mockResolvedValue({ allowed: true, reserved: 160, @@ -169,4 +182,25 @@ describe("composeNudgeWithAI — server-managed consent gate", () => { expect(await composeNudgeWithAI(params())).toBeNull(); expect(provider.generateCompletion).not.toHaveBeenCalled(); }); + + it("returns the template before resolving any chain when the coach capability is unavailable", async () => { + aiCapabilityForJob.mockResolvedValue({ + available: false, + reason: "user_disabled", + onDeviceAllowed: false, + }); + resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: provider }, + ]); + const p = params(); + + expect(await composeNudgeWithAI(p)).toBeNull(); + expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "coach"); + expect(resolveProviderChain).not.toHaveBeenCalled(); + expect(budgetMocks.reserveBudget).not.toHaveBeenCalled(); + expect(provider.generateCompletion).not.toHaveBeenCalled(); + expect(p.tickBudget.remainingCount).toBe( + createNudgeAiTickBudget().remainingCount, + ); + }); }); diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts index f34c6e920..bb702bb17 100644 --- a/src/lib/jobs/__tests__/coach-nudge.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge.test.ts @@ -33,7 +33,7 @@ import { runCoachNudgeTick, } from "../coach-nudge"; import { getAssistantFlags } from "@/lib/feature-flags"; -import { userRowHasProviderCredential } from "@/lib/ai/provider"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS, type HealthProfileAiSection, @@ -42,8 +42,10 @@ import { vi.mock("@/lib/feature-flags", () => ({ getAssistantFlags: vi.fn(async () => ({ coach: true })), })); -vi.mock("@/lib/ai/provider", () => ({ - userRowHasProviderCredential: vi.fn(() => true), +// Gate 3 resolves the `coach` capability per user; never the real resolver. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), + aiCapabilityForRecord: vi.fn(), })); vi.mock("@/lib/ai/coach/bytes-codec", () => ({ decryptFromBytes: vi.fn(() => "morning blood pressure"), @@ -602,9 +604,6 @@ describe("runCoachNudgeTick — gates and prefs", () => { user: { findMany: vi.fn(async () => overrides.users ?? []), }, - appSettings: { - findUnique: vi.fn(async () => ({ adminAiKeyEncrypted: "k" })), - }, pushAttempt: { findFirst: vi.fn(async (args?: unknown) => { void args; @@ -661,7 +660,12 @@ describe("runCoachNudgeTick — gates and prefs", () => { vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true, } as Awaited>); - vi.mocked(userRowHasProviderCredential).mockReturnValue(true); + vi.mocked(aiCapabilityForJob).mockReset(); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); restModeMock.resolveRestMode.mockResolvedValue({ active: false, since: null, @@ -713,7 +717,7 @@ describe("runCoachNudgeTick — gates and prefs", () => { { dispatch: vi.fn() }, ); expect(summary.skippedOptedOut).toBe(1); - expect(userRowHasProviderCredential).not.toHaveBeenCalled(); + expect(aiCapabilityForJob).not.toHaveBeenCalled(); expect(prisma.pushAttempt.findFirst).not.toHaveBeenCalled(); }); @@ -741,15 +745,75 @@ describe("runCoachNudgeTick — gates and prefs", () => { }); it("checks the provider gate before the frequency cap", async () => { - vi.mocked(userRowHasProviderCredential).mockReturnValue(false); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "no_provider", + onDeviceAllowed: true, + }); const prisma = prismaMock({ users: [userRow(null)] }); + const dispatch = vi.fn(); const summary = await runCoachNudgeTick( prisma as unknown as PrismaClient, now, - { dispatch: vi.fn() }, + { dispatch }, ); + expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "coach"); expect(summary.skippedNoProvider).toBe(1); + expect(summary.skippedUnavailable).toBe(0); expect(prisma.pushAttempt.findFirst).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it.each([ + "operator_disabled", + "user_disabled", + "not_permitted_for_record", + "check_failed", + ] as const)( + "counts an unavailable coach capability (%s) as skippedUnavailable before the cap", + async (reason) => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + const prisma = prismaMock({ + users: [userRow(null)], + intakeRows: failingIntakes, + }); + const dispatch = vi.fn(); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch }, + ); + expect(summary.skippedUnavailable).toBe(1); + expect(summary.skippedNoProvider).toBe(0); + expect(prisma.pushAttempt.findFirst).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }, + ); + + it("still sends the template nudge when only a consent receipt is missing", async () => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: true, + }); + const prisma = prismaMock({ + users: [userRow(null)], + intakeRows: failingIntakes, + }); + const dispatch = vi.fn(async () => ({ dispatched: true })); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: dispatch as never }, + ); + expect(summary.skippedNoProvider).toBe(0); + expect(summary.skippedUnavailable).toBe(0); + expect(summary.dispatched).toBe(1); }); it("widens the cap cutoff to 14 days for a biweekly user", async () => { diff --git a/src/lib/jobs/__tests__/data-arrival-worker.test.ts b/src/lib/jobs/__tests__/data-arrival-worker.test.ts index 86f9d4ca9..5e5b3656e 100644 --- a/src/lib/jobs/__tests__/data-arrival-worker.test.ts +++ b/src/lib/jobs/__tests__/data-arrival-worker.test.ts @@ -37,6 +37,11 @@ vi.mock("@/lib/jobs/workout-insight-generate-shared", () => ({ vi.mock("@/lib/arrivals/reaction-line-shared", () => ({ enqueueReactionLine: vi.fn(async () => undefined), })); +// The provider-free prefilter the spine asks before enqueueing model work. +// Not ruled out by default; the job itself resolves the full capability. +vi.mock("@/lib/jobs/ai-job-candidates", () => ({ + aiWorkNotRuledOut: vi.fn(async () => true), +})); type ExistingReaction = { id: string; @@ -83,6 +88,7 @@ const { enqueueWorkoutInsight } = await import("@/lib/jobs/workout-insight-generate-shared"); const { enqueueReactionLine } = await import("@/lib/arrivals/reaction-line-shared"); +const { aiWorkNotRuledOut } = await import("@/lib/jobs/ai-job-candidates"); type Runnable = Parameters[1]; @@ -115,6 +121,7 @@ beforeEach(() => { transaction.mockClear(); vi.mocked(enqueueReactionLine).mockClear(); vi.mocked(enqueueWorkoutInsight).mockClear(); + vi.mocked(aiWorkNotRuledOut).mockReset().mockResolvedValue(true); }); describe("data-arrival worker", () => { @@ -286,4 +293,42 @@ describe("data-arrival worker", () => { runDataArrival(fakePrisma as never, arrival()), ).rejects.toThrow("connection terminated"); }); + + it("stores the marker but enqueues no workout paragraph when the work is ruled out", async () => { + vi.mocked(aiWorkNotRuledOut).mockImplementation( + async (_userId, key) => key !== "workoutInsights", + ); + const result = await runDataArrival( + fakePrisma as never, + arrival({ kind: "workout", refId: "w-1" }), + ); + if (result.status !== "processed") throw new Error("unreachable"); + expect(aiWorkNotRuledOut).toHaveBeenCalledWith("user-1", "workoutInsights"); + expect(createMany).toHaveBeenCalledTimes(1); + expect(result.actions).toContain("workout_insight_unavailable"); + expect(result.actions).not.toContain("workout_insight_enqueued"); + expect(enqueueWorkoutInsight).not.toHaveBeenCalled(); + // The reaction line is its own capability and still goes out. + expect(result.actions).toContain("line_pending"); + expect(enqueueReactionLine).toHaveBeenCalledTimes(1); + }); + + it("claims the marker but enqueues no reaction line when the line is ruled out", async () => { + vi.mocked(aiWorkNotRuledOut).mockImplementation( + async (_userId, key) => key !== "reactionLines", + ); + const result = await runDataArrival(fakePrisma as never, arrival()); + if (result.status !== "processed") throw new Error("unreachable"); + expect(aiWorkNotRuledOut).toHaveBeenCalledWith("user-1", "reactionLines"); + expect(createMany).toHaveBeenCalledTimes(1); + expect(result.actions).toContain("line_unavailable"); + expect(result.actions).not.toContain("line_pending"); + expect(enqueueReactionLine).not.toHaveBeenCalled(); + }); + + it("does not ask the prefilter for a line when the claim was a no-op", async () => { + createMany.mockResolvedValue({ count: 0 }); + await runDataArrival(fakePrisma as never, arrival()); + expect(aiWorkNotRuledOut).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/jobs/__tests__/document-content-index-backfill.test.ts b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts index e048cd61f..420b58a3a 100644 --- a/src/lib/jobs/__tests__/document-content-index-backfill.test.ts +++ b/src/lib/jobs/__tests__/document-content-index-backfill.test.ts @@ -42,8 +42,17 @@ vi.mock("@/lib/documents/provider-order", () => ({ })); vi.mock("@/lib/ai/consent-guard", () => ({ assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined), + // Mirrors the real rule: only the self-hosted `local` provider stays on the + // machine. + isExternalDocumentEgress: vi.fn( + (providerType: string) => providerType !== "local", + ), ConsentRequiredError: class ConsentRequiredError extends Error {}, })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), + aiCapabilityForRecord: vi.fn(), +})); vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-07-07"), reserveBudget: vi.fn(), @@ -65,6 +74,7 @@ import { ConsentRequiredError, } from "@/lib/ai/consent-guard"; import { reserveBudget } from "@/lib/ai/coach/budget"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { decryptIndexText, upsertContentIndex, @@ -90,6 +100,11 @@ const doc = (id: string) => ({ beforeEach(() => { vi.clearAllMocks(); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); vi.mocked(reserveBudget).mockResolvedValue({ allowed: true, reserved: 1, @@ -165,6 +180,67 @@ describe("runContentIndexBackfillForUser", () => { expect(upsertContentIndex).not.toHaveBeenCalled(); }); + it.each([ + ["consent_required", "no-consent"], + ["operator_disabled", "unavailable"], + ["module_disabled", "unavailable"], + ["no_provider", "no-provider"], + ] as const)( + "refuses an external pick before any document is read when documentAi is unavailable (%s → %s)", + async (reason, summaryReason) => { + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + { id: "d1" }, + ] as never); + + const result = await runContentIndexBackfillForUser("user-1"); + + expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "documentAi"); + expect(result).toEqual({ + indexed: 0, + retokenised: 0, + skipped: 0, + failed: 0, + reason: summaryReason, + }); + expect(assertDocumentEgressConsent).not.toHaveBeenCalled(); + expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled(); + expect(reserveBudget).not.toHaveBeenCalled(); + expect(transcribeDocument).not.toHaveBeenCalled(); + expect(upsertContentIndex).not.toHaveBeenCalled(); + }, + ); + + it("does not ask the documentAi capability for a local pick", async () => { + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ + chain: [{ providerType: "local", instance: {} }], + pick: { + entry: { providerType: "local", instance: {} }, + providerType: "local", + pdfSupported: false, + }, + } as never); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: true, + }); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + { id: "d1" }, + ] as never); + + const result = await runContentIndexBackfillForUser("user-1"); + + expect(aiCapabilityForJob).not.toHaveBeenCalled(); + expect(result.reason).toBe("ok"); + expect(result.indexed).toBe(1); + }); + it("indexes the not-yet-indexed documents", async () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); // One short page (< PAGE_SIZE) — the walk breaks after it, so a single diff --git a/src/lib/jobs/__tests__/document-summary-catchup.test.ts b/src/lib/jobs/__tests__/document-summary-catchup.test.ts index c546feea9..8c7af8749 100644 --- a/src/lib/jobs/__tests__/document-summary-catchup.test.ts +++ b/src/lib/jobs/__tests__/document-summary-catchup.test.ts @@ -23,6 +23,10 @@ vi.mock("@/lib/documents/document-settings", () => ({ vi.mock("@/lib/jobs/document-summary", () => ({ enqueueDocumentSummary: vi.fn(), })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), + aiCapabilityForRecord: vi.fn(), +})); import { enqueueSummaryCatchUp, @@ -35,10 +39,12 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; import { enqueueDocumentSummary } from "@/lib/jobs/document-summary"; import { annotate } from "@/lib/logging/context"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; const findMany = vi.mocked(prisma.inboundDocument.findMany); const mockEnqueueSummary = vi.mocked(enqueueDocumentSummary); const mockAutoRead = vi.mocked(documentAutoReadEnabled); +const mockCapability = vi.mocked(aiCapabilityForJob); /** Serve `total` document ids across the job's id-cursor paged walk. */ function serveDocuments(total: number) { @@ -59,6 +65,11 @@ function serveDocuments(total: number) { beforeEach(() => { vi.clearAllMocks(); mockAutoRead.mockResolvedValue(true); + mockCapability.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); mockEnqueueSummary.mockResolvedValue({ enqueued: true }); }); @@ -111,6 +122,30 @@ describe("runSummaryCatchUpForUser", () => { expect(findMany).not.toHaveBeenCalled(); }); + it.each(["operator_disabled", "no_provider", "consent_required"] as const)( + "does nothing when the documentAi capability is unavailable (%s)", + async (reason) => { + serveDocuments(5); + mockCapability.mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + + const result = await runSummaryCatchUpForUser("user-1"); + + expect(mockCapability).toHaveBeenCalledWith("user-1", "documentAi"); + expect(result).toEqual({ enqueued: 0, capped: false }); + // Refused before a single document is listed. + expect(findMany).not.toHaveBeenCalled(); + expect(mockEnqueueSummary).not.toHaveBeenCalled(); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "documents.autoRead.catchUpSkipped" }, + meta: { reason }, + }); + }, + ); + it("routes work through the ordinary summary job, granting nothing itself", async () => { // The per-document job is what re-asserts egress consent and reserves the // daily budget. The catch-up must not have its own provider or budget path. diff --git a/src/lib/jobs/__tests__/document-summary.test.ts b/src/lib/jobs/__tests__/document-summary.test.ts index 9a4c84a3f..f1d0837e7 100644 --- a/src/lib/jobs/__tests__/document-summary.test.ts +++ b/src/lib/jobs/__tests__/document-summary.test.ts @@ -53,6 +53,10 @@ vi.mock("@/lib/ai/coach/budget", () => ({ resolveDailyCapFor: vi.fn(() => 1000), resolveCostOwner: vi.fn(() => "operator" as const), })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), + aiCapabilityForRecord: vi.fn(), +})); vi.mock("@/lib/ai/ai-budgets", () => ({ AI_BUDGETS: { documentSummary: { temperature: 0.3, maxTokens: 600 } }, })); @@ -73,6 +77,17 @@ import { runDocumentSummary } from "@/lib/documents/describe"; import { encryptDocumentSummary } from "@/lib/documents/store"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget"; +import { + aiCapabilityForJob, + aiCapabilityForRecord, +} from "@/lib/ai/capabilities/gate"; +import { annotate } from "@/lib/logging/context"; + +const AVAILABLE = { + available: true, + reason: null, + onDeviceAllowed: true, +} as const; const DOC = { id: "doc-1", @@ -115,6 +130,8 @@ beforeEach(() => { locale: "en", } as never); vi.mocked(documentAutoReadEnabled).mockResolvedValue(true); + vi.mocked(aiCapabilityForJob).mockResolvedValue(AVAILABLE); + vi.mocked(aiCapabilityForRecord).mockResolvedValue(AVAILABLE); vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never); vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined); vi.mocked(loadOwnedDocument).mockResolvedValue(DOC as never); @@ -150,6 +167,13 @@ describe("runDocumentSummaryJob — gating", () => { await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); expect(runDocumentSummary).toHaveBeenCalledTimes(1); + expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "documentAi"); + // The egress consent is re-asserted for the picked provider; the auto-read + // toggle is not a consent and no `surface` rides along any more. + expect(assertDocumentEgressConsent).toHaveBeenCalledWith({ + userId: "user-1", + providerType: "anthropic", + }); expect(encryptDocumentSummary).toHaveBeenCalledWith( "A lab report from a clinic listing routine blood values.", ); @@ -203,6 +227,55 @@ describe("runDocumentSummaryJob — gating", () => { expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled(); }); + it.each([ + ["no_provider", "UNAVAILABLE"], + ["consent_required", "UNAVAILABLE"], + ["operator_disabled", "NONE"], + ["module_disabled", "NONE"], + ["check_failed", "NONE"], + ] as const)( + "refuses before any provider is picked when documentAi is unavailable (%s → %s)", + async (reason, state) => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + + await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); + + expect(resolveDocumentVisionProvider).not.toHaveBeenCalled(); + expect(assertDocumentEgressConsent).not.toHaveBeenCalled(); + expect(loadOwnedDocument).not.toHaveBeenCalled(); + expect(reserveBudget).not.toHaveBeenCalled(); + expect(runDocumentSummary).not.toHaveBeenCalled(); + // An attempt that could not run is recorded as UNAVAILABLE; a switch + // turned off only heals the PENDING this job was meant to resolve, so an + // earlier UNAVAILABLE / WITHHELD is never overwritten. + expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({ + where: { + id: "doc-1", + userId: "user-1", + deletedAt: null, + summaryState: state === "NONE" ? "PENDING" : { not: "READY" }, + }, + data: { summaryState: state }, + }); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "documents.summary.autoSkipped" }, + meta: { documentId: "doc-1", reason }, + }); + }, + ); + + it("does not consult the capability when the opt-in is OFF", async () => { + vi.mocked(documentAutoReadEnabled).mockResolvedValue(false); + + await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); + + expect(aiCapabilityForJob).not.toHaveBeenCalled(); + }); + it("is a graceful no-op when NO provider is configured", async () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], @@ -325,6 +398,29 @@ describe("enqueueDocumentSummary — the pending contract", () => { ); }); + it.each(["no_provider", "consent_required", "operator_disabled"] as const)( + "does not enqueue and does not claim PENDING when documentAi is unavailable (%s)", + async (reason) => { + vi.mocked(aiCapabilityForRecord).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + const send = vi.fn().mockResolvedValue("job-1"); + vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); + + const result = await enqueueDocumentSummary("user-1", "doc-1"); + + expect(aiCapabilityForRecord).toHaveBeenCalledWith( + "user-1", + "documentAi", + ); + expect(result).toEqual({ enqueued: false }); + expect(send).not.toHaveBeenCalled(); + expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled(); + }, + ); + it("claims nothing when the boss dropped the send (no job id)", async () => { const send = vi.fn().mockResolvedValue(null); vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 38c92003e..abfb2aac3 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -2,7 +2,11 @@ * v1.7.0 W6 — unit tests for the nightly insight pre-generation cron. * * Covers: - * - the discovery query selects only coach-enabled, stale-cache users; + * - the discovery query selects stale-cache users who kept AI analysis + * (the `insights` module) on; + * - each half of the pass runs on its own capability (`briefing` for the + * comprehensive, `statusText` for the warm), resolved before any budget + * write or generation; * - the per-user budget gate blocks a user already generated today; * - the master assistant kill-switch short-circuits the whole run; * - the generator outcomes tally correctly (generated / cached / @@ -39,6 +43,29 @@ vi.mock("@/lib/logging/context", async () => { vi.mock("@/lib/feature-flags", () => ({ getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a), })); +// Per-user capabilities, keyed by capability; every test starts with both +// halves available and overrides one or both. +type CapabilityState = { + available: boolean; + reason: string | null; + onDeviceAllowed: boolean; +}; +const AVAILABLE: CapabilityState = { + available: true, + reason: null, + onDeviceAllowed: true, +}; +function unavailable(reason: string): CapabilityState { + return { available: false, reason, onDeviceAllowed: false }; +} +let capabilities: Record = {}; +const aiCapabilityForJob = vi.fn( + async (_userId: string, key: string) => capabilities[key] ?? AVAILABLE, +); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: (...a: [string, string]) => aiCapabilityForJob(...a), + aiCapabilityForRecord: vi.fn(), +})); // Never reach the real generator (which would import the provider chain). vi.mock("@/lib/insights/comprehensive-generate", () => ({ generateComprehensiveInsight: vi.fn(), @@ -109,8 +136,10 @@ function makePrisma( locale: string | null; aiResponseTimeoutSeconds?: number | null; }>, + optedOut: string[] = [], ) { const findMany = vi.fn().mockResolvedValue(users); + const queryRaw = vi.fn().mockResolvedValue(optedOut.map((id) => ({ id }))); // forceWarmUser reads `insightsCachedAt` / `insightsWarmFailedAt` at job // start and maintains the failure marker; default: never warmed, never // failed. @@ -120,10 +149,11 @@ function makePrisma( }); const update = vi.fn().mockResolvedValue({}); return { - prisma: { user: { findMany, findUnique, update } }, + prisma: { user: { findMany, findUnique, update }, $queryRaw: queryRaw }, findMany, findUnique, update, + queryRaw, }; } @@ -135,18 +165,26 @@ beforeEach(() => { insightStatus: true, }); checkRateLimit.mockResolvedValue({ allowed: true }); + capabilities = {}; }); describe("findPregenerateCandidates", () => { - it("filters on disableCoach=false + stale-or-null cache, oldest-first, capped", async () => { - const { prisma, findMany } = makePrisma([{ id: "u1", locale: "de" }]); + it("filters on the insights opt-out + stale-or-null cache, oldest-first, capped", async () => { + const { prisma, findMany, queryRaw } = makePrisma( + [{ id: "u1", locale: "de" }], + ["opted-out"], + ); const now = new Date("2026-05-31T04:30:00.000Z"); // eslint-disable-next-line @typescript-eslint/no-explicit-any await findPregenerateCandidates(prisma as any, now, 50); + expect(queryRaw).toHaveBeenCalledTimes(1); + expect(queryRaw.mock.calls[0].slice(1)).toContain("insights"); expect(findMany).toHaveBeenCalledTimes(1); const arg = findMany.mock.calls[0][0]; - expect(arg.where.disableCoach).toBe(false); + // Hiding the Coach is not an AI opt-out any more. + expect(arg.where.disableCoach).toBeUndefined(); + expect(arg.where.id).toEqual({ notIn: ["opted-out"] }); expect(arg.where.OR).toEqual([ { insightsCachedAt: null }, { @@ -158,6 +196,98 @@ describe("findPregenerateCandidates", () => { expect(arg.orderBy).toEqual({ insightsCachedAt: "asc" }); expect(arg.take).toBe(50); }); + + it("adds no id filter when nobody opted out", async () => { + const { prisma, findMany } = makePrisma([{ id: "u1", locale: "de" }]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await findPregenerateCandidates(prisma as any, new Date(), 50); + expect(findMany.mock.calls[0][0].where.id).toBeUndefined(); + }); +}); + +describe("runInsightPregenerate — per-user capabilities", () => { + it("skips a user with neither capability before any budget write or generation", async () => { + capabilities = { + briefing: unavailable("user_disabled"), + statusText: unavailable("user_disabled"), + }; + const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); + const generate = vi.fn(); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "briefing"); + expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "statusText"); + expect(result).toMatchObject({ total: 1, skipped: 1, budgetBlocked: 0 }); + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(generate).not.toHaveBeenCalled(); + for (const g of statusGenerators) expect(g).not.toHaveBeenCalled(); + expect(warmGenericMetrics).not.toHaveBeenCalled(); + expect(annotateSpy).toHaveBeenCalledWith({ + action: { name: "insights.pregenerate.skipped" }, + meta: { briefing: "user_disabled", status_text: "user_disabled" }, + }); + }); + + it("skips the comprehensive without a budget write when only briefing is unavailable, and still warms", async () => { + capabilities = { briefing: unavailable("operator_disabled") }; + const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); + const generate = vi.fn(); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(4); + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(generate).not.toHaveBeenCalled(); + expect(result.skipped).toBe(1); + expect(result.budgetBlocked).toBe(0); + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false }); + } + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]); + expect(result.assessmentsWarmed).toBe(7); + expect(result.metricAssessmentsWarmed).toBe(4); + }); + + it("generates the comprehensive but warms nothing when only statusText is unavailable", async () => { + capabilities = { statusText: unavailable("consent_required") }; + const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(4); + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(generate).toHaveBeenCalledTimes(1); + expect(result.generated).toBe(1); + for (const g of statusGenerators) expect(g).not.toHaveBeenCalled(); + expect(warmGenericMetrics).not.toHaveBeenCalled(); + expect(result.assessmentsWarmed).toBe(0); + expect(result.metricAssessmentsWarmed).toBe(0); + }); }); describe("runInsightPregenerate — kill switch", () => { @@ -591,7 +721,10 @@ describe("runInsightPregenerate — per-metric warm pass", () => { expect(result.assessmentsWarmed).toBe(7); }); - it("does NOT warm when the comprehensive pass skipped (no provider)", async () => { + it("does NOT warm when the statusText capability has no provider", async () => { + // The warm is gated on its own capability now, not on the comprehensive + // outcome: no provider for the status notes → nothing to warm. + capabilities = { statusText: unavailable("no_provider") }; const { prisma } = makePrisma([{ id: "u1", locale: "en" }]); const generate = vi .fn() @@ -714,7 +847,8 @@ describe("runInsightPregenerate — generic metric warm pass (v1.8.7.1)", () => expect(result.metricAssessmentsWarmed).toBe(9); }); - it("does NOT warm the generic metric caches when the comprehensive pass skipped (no provider)", async () => { + it("does NOT warm the generic metric caches when the statusText capability has no provider", async () => { + capabilities = { statusText: unavailable("no_provider") }; const { prisma } = makePrisma([{ id: "u1", locale: "en" }]); const generate = vi .fn() @@ -1047,13 +1181,12 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { } }); - it("short-circuits to a no-op when the whole assistant is disabled globally", async () => { - getAssistantFlags.mockResolvedValue({ - enabled: false, - briefing: false, - insightStatus: false, - }); - const { prisma } = makePrisma([]); + it("short-circuits to a no-op when neither capability is available", async () => { + capabilities = { + briefing: unavailable("operator_disabled"), + statusText: unavailable("operator_disabled"), + }; + const { prisma, findUnique } = makePrisma([]); const generate = vi.fn(); const statusGenerators = Array.from({ length: 7 }, () => vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), @@ -1070,18 +1203,16 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { } expect(result.comprehensive).toBe("skipped"); expect(result.assessmentsWarmed).toBe(0); + // Nothing read, nothing counted against the forced-warm bucket. + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(findUnique).not.toHaveBeenCalled(); }); - it("warms the per-status + generic caches when `briefing` is off but `insightStatus` is on (L1)", async () => { - // v1.9.0 — the route admits the job on the per-user `insightStatus` - // surface. A global `briefing` kill-switch must not suppress the - // assessment cards the user has enabled; only the comprehensive briefing - // belongs to the `briefing` surface. - getAssistantFlags.mockResolvedValue({ - enabled: true, - briefing: false, - insightStatus: true, - }); + it("warms the per-status + generic caches when `briefing` is unavailable but `statusText` is available (L1)", async () => { + // v1.9.0 — the route admits the job on the per-user status surface. An + // unavailable briefing must not suppress the assessment cards; only the + // comprehensive briefing belongs to the `briefing` capability. + capabilities = { briefing: unavailable("operator_disabled") }; const { prisma } = makePrisma([]); const generate = vi.fn(); const statusGenerators = Array.from({ length: 7 }, () => @@ -1109,6 +1240,33 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { }); }); + it("forces the comprehensive but warms no card when `statusText` is unavailable", async () => { + capabilities = { statusText: unavailable("user_disabled") }; + const { prisma } = makePrisma([]); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: false }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(2); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(generate).toHaveBeenCalledTimes(1); + for (const g of statusGenerators) expect(g).not.toHaveBeenCalled(); + expect(warmGenericMetrics).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + comprehensive: "generated", + assessmentsWarmed: 0, + metricAssessmentsWarmed: 0, + }); + }); + it("aborts the comprehensive on timeout so its late resolve cannot write a stale cache row (M-1)", async () => { // v1.9.0 race fix: `withTimeout` cannot cancel the detached generation, // so a comprehensive that resolves AFTER the timeout would still write diff --git a/src/lib/jobs/__tests__/insight-status-generate.test.ts b/src/lib/jobs/__tests__/insight-status-generate.test.ts index 6a494fbf1..b46fb90bd 100644 --- a/src/lib/jobs/__tests__/insight-status-generate.test.ts +++ b/src/lib/jobs/__tests__/insight-status-generate.test.ts @@ -41,6 +41,17 @@ vi.mock("@/lib/insights/mood-status", () => ({ vi.mock("@/lib/insights/medication-compliance-status", () => ({ generateMedicationComplianceStatusForUser: vi.fn(), })); +// The job resolves the `statusText` capability before any generator runs. +const aiCapabilityForJob = vi.fn(); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: (...a: unknown[]) => aiCapabilityForJob(...a), + aiCapabilityForRecord: vi.fn(), +})); +const annotate = vi.fn(); +vi.mock("@/lib/logging/context", async (importOriginal) => ({ + ...(await importOriginal()), + annotate: (...a: unknown[]) => annotate(...a), +})); import { runInsightStatusGenerate, @@ -51,8 +62,32 @@ import { beforeEach(() => { vi.clearAllMocks(); + aiCapabilityForJob.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); }); +const OWNER_AUTHORITY = { + origin: "owner" as const, + recordUserId: "u1", + actorUserId: "u1", + grantId: null, +}; + +function allGenerators() { + return { + general: vi.fn(), + "blood-pressure": vi.fn(), + weight: vi.fn(), + pulse: vi.fn(), + bmi: vi.fn(), + mood: vi.fn(), + "medication-compliance": vi.fn(), + }; +} + describe("runInsightStatusGenerate", () => { it("forces the matching generator with the payload locale", async () => { const weight = vi.fn().mockResolvedValue(undefined); @@ -82,8 +117,37 @@ describe("runInsightStatusGenerate", () => { expect(weight).toHaveBeenCalledWith("u1", { locale: "en", force: true }); // No other generator ran. expect(generators.pulse).not.toHaveBeenCalled(); + expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "statusText"); }); + it.each(["user_disabled", "no_provider", "consent_required"] as const)( + "skips before any generator runs when statusText is unavailable (%s)", + async (reason) => { + aiCapabilityForJob.mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + const generators = allGenerators(); + await runInsightStatusGenerate( + { + userId: "u1", + metric: "weight", + locale: "en", + authority: OWNER_AUTHORITY, + }, + generators, + ); + for (const generator of Object.values(generators)) { + expect(generator).not.toHaveBeenCalled(); + } + expect(annotate).toHaveBeenCalledWith({ + action: { name: "insights.status.generate.skipped" }, + meta: { metric: "weight", reason }, + }); + }, + ); + it("skips (does not throw) an unknown metric", async () => { await expect( runInsightStatusGenerate( diff --git a/src/lib/jobs/__tests__/period-narrative-warm.test.ts b/src/lib/jobs/__tests__/period-narrative-warm.test.ts index 5c9e6ea83..6f11e6f0d 100644 --- a/src/lib/jobs/__tests__/period-narrative-warm.test.ts +++ b/src/lib/jobs/__tests__/period-narrative-warm.test.ts @@ -15,7 +15,15 @@ vi.mock("@/lib/feature-flags", () => ({ vi.mock("@/lib/insights/narrative/period-narrative-generate", () => ({ generatePeriodNarrative: vi.fn(), })); -vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +const annotate = vi.fn(); +vi.mock("@/lib/logging/context", () => ({ + annotate: (...a: unknown[]) => annotate(...a), +})); +const aiCapabilityForJob = vi.fn(); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: (...a: unknown[]) => aiCapabilityForJob(...a), + aiCapabilityForRecord: vi.fn(), +})); import { runPeriodNarrativeWarm, @@ -28,8 +36,10 @@ import { function makePrisma( users: Array<{ id: string; locale: string | null }>, narrativeWrites: Array<{ userId: string; updatedAt: Date }> = [], + optedOut: string[] = [], ) { const findMany = vi.fn().mockResolvedValue(users); + const queryRaw = vi.fn().mockResolvedValue(optedOut.map((id) => ({ id }))); const groupBy = vi.fn().mockResolvedValue( narrativeWrites.map((row) => ({ userId: row.userId, @@ -37,9 +47,14 @@ function makePrisma( })), ); return { - prisma: { user: { findMany }, insightNarrative: { groupBy } }, + prisma: { + user: { findMany }, + insightNarrative: { groupBy }, + $queryRaw: queryRaw, + }, findMany, groupBy, + queryRaw, }; } @@ -58,6 +73,11 @@ beforeEach(() => { insightStatus: true, }); checkRateLimit.mockResolvedValue({ allowed: true }); + aiCapabilityForJob.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); }); describe("periodsForDay — boundary gate", () => { @@ -74,14 +94,18 @@ describe("periodsForDay — boundary gate", () => { }); describe("findNarrativeCandidates", () => { - it("filters coach-enabled users", async () => { - const { prisma, findMany, groupBy } = makePrisma([ - { id: "u1", locale: "de" }, - ]); + it("drops the users who switched AI analysis off, not the Coach-hidden ones", async () => { + const { prisma, findMany, groupBy, queryRaw } = makePrisma( + [{ id: "u1", locale: "de" }], + [], + ["opted-out"], + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await findNarrativeCandidates(prisma as any, 50); + expect(queryRaw.mock.calls[0].slice(1)).toContain("insights"); const arg = findMany.mock.calls[0][0]; - expect(arg.where.disableCoach).toBe(false); + expect(arg.where).toEqual({ id: { notIn: ["opted-out"] } }); + expect(arg.where.disableCoach).toBeUndefined(); expect(result).toEqual([{ id: "u1", locale: "de" }]); // Below the cap the ordering aggregate never runs. expect(groupBy).not.toHaveBeenCalled(); @@ -156,6 +180,38 @@ describe("runPeriodNarrativeWarm", () => { expect(generate).not.toHaveBeenCalled(); }); + it("skips a user whose periodNarrative capability is unavailable before the budget write", async () => { + const { prisma } = makePrisma([ + { id: "off", locale: "de" }, + { id: "on", locale: "de" }, + ]); + aiCapabilityForJob.mockImplementation(async (userId: string) => + userId === "off" + ? { available: false, reason: "no_provider", onDeviceAllowed: true } + : { available: true, reason: null, onDeviceAllowed: true }, + ); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "openai" }); + const result = await runPeriodNarrativeWarm( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma as any, + { now: MON_MID, generate }, + ); + expect(aiCapabilityForJob).toHaveBeenCalledWith("off", "periodNarrative"); + expect(result.skipped).toBe(1); + expect(result.budgetBlocked).toBe(0); + // Only the available user reaches the budget bucket and the generator. + expect(checkRateLimit).toHaveBeenCalledTimes(1); + expect(checkRateLimit.mock.calls[0][0]).toBe("period-narrative:on"); + expect(generate).toHaveBeenCalledTimes(1); + expect(generate.mock.calls[0][0]).toBe("on"); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "insights.narrative.warm.skipped" }, + meta: { reason: "no_provider" }, + }); + }); + it("short-circuits when the briefing surface is disabled", async () => { getAssistantFlags.mockResolvedValueOnce({ enabled: false, diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index ac9403751..8782179eb 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -63,9 +63,10 @@ vi.mock("@/lib/db", () => ({ }, })); -const isModuleEnabled = vi.fn(); -vi.mock("@/lib/modules/gate", () => ({ - isModuleEnabled: (...a: unknown[]) => isModuleEnabled(...a), +const aiCapabilityForJob = vi.fn(); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: (...a: unknown[]) => aiCapabilityForJob(...a), + aiCapabilityForRecord: vi.fn(), })); const resolveProviderChain = vi.fn(); vi.mock("@/lib/ai/provider", () => ({ @@ -196,7 +197,11 @@ beforeEach(async () => { labResultFindMany.mockResolvedValue([]); queryRaw.mockResolvedValue([{ total_tokens: 1_400, operator_tokens: 1_400 }]); executeRaw.mockResolvedValue(1); - isModuleEnabled.mockResolvedValue(true); + aiCapabilityForJob.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); chainRequiresServerManagedConsent.mockReturnValue(false); hasActiveConsentForSurface.mockResolvedValue(true); // v1.38.19 — the claim reservation is no longer a @@ -219,18 +224,32 @@ beforeEach(async () => { }); describe("reaction line — degradation", () => { - it("insights opt-out refuses before provider resolution or spend", async () => { - isModuleEnabled.mockResolvedValue(false); + it.each([ + "user_disabled", + "module_disabled", + "operator_disabled", + "no_provider", + "consent_required", + ] as const)( + "an unavailable reactionLines capability (%s) refuses before the digest, provider resolution or spend", + async (reason) => { + aiCapabilityForJob.mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); - const outcome = await runReactionLine(JOB); + const outcome = await runReactionLine(JOB); - expect(outcome).toEqual({ - status: "skipped", - reason: "module_disabled", - }); - expect(resolveProviderChain).not.toHaveBeenCalled(); - expect(reserveBudget).not.toHaveBeenCalled(); - }); + expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "reactionLines"); + expect(outcome).toEqual({ status: "skipped", reason }); + expect(userFindUnique).not.toHaveBeenCalled(); + expect(loadDailyDigest).not.toHaveBeenCalled(); + expect(resolveProviderChain).not.toHaveBeenCalled(); + expect(reserveBudget).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }, + ); it("grounds the prompt in the exact reading that triggered the arrival", async () => { measurementFindMany.mockResolvedValue([ diff --git a/src/lib/jobs/__tests__/status-cron-candidates.test.ts b/src/lib/jobs/__tests__/status-cron-candidates.test.ts index 4f2690a1f..8ede8ce2d 100644 --- a/src/lib/jobs/__tests__/status-cron-candidates.test.ts +++ b/src/lib/jobs/__tests__/status-cron-candidates.test.ts @@ -3,10 +3,12 @@ * * Pins the three gates the discovery applies: * 1. the operator assistant kill-switch (`insightStatus` flag), - * 2. the per-user `disableCoach: false` filter, - * 3. the pregenerate-candidate skip (configured provider + stale - * comprehensive cache → the 04:30 pass owns the user; the 02:xx - * crons keep fresh-cache and provider-less accounts), + * 2. the per-user AI-analysis opt-out (the `insights` module switched off + * drops the account from the candidate query; `disableCoach` plays no + * part any more), + * 3. the pregenerate-candidate skip (a stale or missing comprehensive + * cache → the 04:30 pass owns the user; the 02:xx crons keep the + * fresh-cache accounts), applied only while `briefing` is on, * plus the mood-status queue registration in the worker source (the * v1.4.37 dead-queue class guard). */ @@ -30,15 +32,6 @@ interface FakeUserRow { id: string; locale: string | null; insightsCachedAt: Date | null; - aiProvider: string | null; - aiProviderChain: unknown; - aiAnthropicKeyEncrypted: string | null; - aiLocalKeyEncrypted: string | null; - aiOpenaiKeyEncrypted: string | null; - aiBaseUrl: string | null; - codexConnectionStatus: string | null; - codexAccessTokenEncrypted: string | null; - codexRefreshTokenEncrypted: string | null; } function userRow(overrides: Partial = {}): FakeUserRow { @@ -46,32 +39,23 @@ function userRow(overrides: Partial = {}): FakeUserRow { id: "u1", locale: "de", insightsCachedAt: null, - aiProvider: null, - aiProviderChain: null, - aiAnthropicKeyEncrypted: null, - aiLocalKeyEncrypted: null, - aiOpenaiKeyEncrypted: null, - aiBaseUrl: null, - codexConnectionStatus: null, - codexAccessTokenEncrypted: null, - codexRefreshTokenEncrypted: null, ...overrides, }; } -function makePrisma( - users: FakeUserRow[], - adminAiKeyEncrypted: string | null = null, -) { +function makePrisma(users: FakeUserRow[], optedOut: string[] = []) { const findMany = vi.fn().mockResolvedValue(users); - const findUnique = vi.fn().mockResolvedValue({ adminAiKeyEncrypted }); + const queryRaw = vi.fn().mockResolvedValue(optedOut.map((id) => ({ id }))); + const findUnique = vi.fn(); return { prisma: { user: { findMany }, appSettings: { findUnique }, + $queryRaw: queryRaw, }, findMany, findUnique, + queryRaw, }; } @@ -93,65 +77,82 @@ describe("findStatusCronCandidates — gates", () => { briefing: false, insightStatus: false, }); - const { prisma, findMany } = makePrisma([userRow()]); + const { prisma, findMany, queryRaw } = makePrisma([userRow()]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await findStatusCronCandidates(prisma as any, NOW); expect(result).toEqual([]); expect(findMany).not.toHaveBeenCalled(); + expect(queryRaw).not.toHaveBeenCalled(); }); - it("filters discovery to disableCoach: false", async () => { + it("does not filter on disableCoach: hiding the Coach is not an AI opt-out", async () => { const { prisma, findMany } = makePrisma([userRow()]); // eslint-disable-next-line @typescript-eslint/no-explicit-any await findStatusCronCandidates(prisma as any, NOW); - expect(findMany.mock.calls[0][0].where).toEqual({ disableCoach: false }); + expect(findMany.mock.calls[0][0].where).toEqual({}); + expect(JSON.stringify(findMany.mock.calls[0][0])).not.toContain( + "disableCoach", + ); }); - it("skips a pregenerate candidate (configured provider + stale cache)", async () => { - const { prisma } = makePrisma([ - userRow({ - id: "stale-with-provider", - insightsCachedAt: STALE_AT, - aiAnthropicKeyEncrypted: "enc", - }), - ]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await findStatusCronCandidates(prisma as any, NOW); - expect(result).toEqual([]); - }); - - it("keeps a provider user whose comprehensive cache is still fresh", async () => { - const { prisma } = makePrisma([ - userRow({ - id: "fresh-with-provider", - insightsCachedAt: FRESH_AT, - aiAnthropicKeyEncrypted: "enc", - }), - ]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await findStatusCronCandidates(prisma as any, NOW); - expect(result).toEqual([{ id: "fresh-with-provider", locale: "de" }]); - }); - - it("keeps a provider-less user even with a stale cache (no pregenerate claim)", async () => { - const { prisma } = makePrisma([ - userRow({ id: "no-provider", insightsCachedAt: STALE_AT }), - ]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await findStatusCronCandidates(prisma as any, NOW); - expect(result).toEqual([{ id: "no-provider", locale: "de" }]); - }); - - it("counts the operator's shared admin key as a configured provider", async () => { - const { prisma } = makePrisma( - [userRow({ id: "admin-covered", insightsCachedAt: STALE_AT })], - "admin-enc", + it("drops the accounts that switched the insights module off", async () => { + const { prisma, findMany, queryRaw } = makePrisma( + [userRow({ id: "kept", insightsCachedAt: FRESH_AT })], + ["opted-out-a", "opted-out-b"], ); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await findStatusCronCandidates(prisma as any, NOW); + expect(queryRaw).toHaveBeenCalledTimes(1); + // The module key rides as a bound parameter, never spliced. + expect(queryRaw.mock.calls[0].slice(1)).toContain("insights"); + expect(findMany.mock.calls[0][0].where).toEqual({ + id: { notIn: ["opted-out-a", "opted-out-b"] }, + }); + expect(result).toEqual([{ id: "kept", locale: "de" }]); + }); + + it("skips a pregenerate candidate (stale cache)", async () => { + const { prisma } = makePrisma([ + userRow({ id: "stale", insightsCachedAt: STALE_AT }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); expect(result).toEqual([]); }); + it("skips a never-warmed user (null cache belongs to pregenerate)", async () => { + const { prisma } = makePrisma([ + userRow({ id: "never", insightsCachedAt: null }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([]); + }); + + it("keeps a user whose comprehensive cache is still fresh", async () => { + const { prisma } = makePrisma([ + userRow({ id: "fresh", insightsCachedAt: FRESH_AT }), + userRow({ id: "stale", insightsCachedAt: STALE_AT }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([{ id: "fresh", locale: "de" }]); + }); + + it("reads no provider credentials and no operator admin key", async () => { + const { prisma, findMany, findUnique } = makePrisma([ + userRow({ id: "fresh", insightsCachedAt: FRESH_AT }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await findStatusCronCandidates(prisma as any, NOW); + expect(findMany.mock.calls[0][0].select).toEqual({ + id: true, + locale: true, + insightsCachedAt: true, + }); + expect(findUnique).not.toHaveBeenCalled(); + }); + it("keeps every user when the briefing surface is off (the 04:30 pass no-ops)", async () => { getAssistantFlags.mockResolvedValue({ enabled: true, @@ -159,18 +160,16 @@ describe("findStatusCronCandidates — gates", () => { briefing: false, insightStatus: true, }); - const { prisma, findUnique } = makePrisma([ - userRow({ - id: "stale-with-provider", - insightsCachedAt: STALE_AT, - aiAnthropicKeyEncrypted: "enc", - }), + const { prisma } = makePrisma([ + userRow({ id: "stale", insightsCachedAt: STALE_AT }), + userRow({ id: "never", insightsCachedAt: null }), ]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await findStatusCronCandidates(prisma as any, NOW); - expect(result).toEqual([{ id: "stale-with-provider", locale: "de" }]); - // No admin-key read needed when the skip never applies. - expect(findUnique).not.toHaveBeenCalled(); + expect(result).toEqual([ + { id: "stale", locale: "de" }, + { id: "never", locale: "de" }, + ]); }); }); diff --git a/src/lib/jobs/ai-job-candidates.ts b/src/lib/jobs/ai-job-candidates.ts new file mode 100644 index 000000000..b11a21756 --- /dev/null +++ b/src/lib/jobs/ai-job-candidates.ts @@ -0,0 +1,62 @@ +/** + * Cheap SQL prefilters for the AI crons. + * + * Every AI job resolves its capability per user (`aiCapabilityForJob`) before + * it builds a snapshot, and the provider chokepoint re-checks it at the wire. + * Those are the answers that count. What this file adds is the cheap part in + * front of them: people who switched a module off are dropped from the + * candidate query itself, so a nightly pass over the instance does not load + * their capability inputs one by one only to be told no. + * + * `modulePreferencesJson` is a DISABLED allowlist: a module is off only when + * its key holds JSON `false`. Selecting the "off" set is NULL-safe by + * construction (an absent key, a NULL column or a non-object value never + * compares equal to `false`), which a negated JSON-path filter would not be. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { + AI_CAPABILITIES, + type AiCapabilityKey, +} from "@/lib/ai/capabilities/types"; +import { loadAssistantSwitches } from "@/lib/feature-flags"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import type { ModuleKey } from "@/lib/modules/registry"; + +/** Ids of the accounts that switched `module` off for themselves. */ +export async function userIdsWithModuleOff( + prisma: Pick, + module: ModuleKey, +): Promise { + const rows = await prisma.$queryRaw>` + SELECT "id" FROM "users" + WHERE "module_preferences_json" -> ${module}::text = 'false'::jsonb`; + return rows.map((row) => row.id); +} + +/** + * The provider-free half of a capability: the operator's switches and the + * owning modules, and nothing that needs the provider machinery. + * + * For the one place that must not import that machinery at all, the + * data-arrival spine, whose cost claim is that its module graph reaches no + * provider (`data-arrival-provider-isolation.test.ts`). It decides whether a + * follow-up job is worth enqueueing; the job itself then resolves the full + * capability (`aiCapabilityForJob`) before it does anything, so this can only + * ever say "no" early, never "yes" for good. + */ +export async function aiWorkNotRuledOut( + userId: string, + key: AiCapabilityKey, +): Promise { + const definition = AI_CAPABILITIES[key]; + const [switches, modules] = await Promise.all([ + loadAssistantSwitches(), + resolveModuleMap(userId), + ]); + if (switches === null || !switches[definition.operatorSwitch]) return false; + const owners = definition.modules.keys; + if (owners.length === 0) return true; + return definition.modules.mode === "all" + ? owners.every((module) => modules[module]) + : owners.some((module) => modules[module]); +} diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index a2916d907..95b786789 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -35,6 +35,7 @@ import type { CoachNudgeTrigger } from "@/lib/jobs/coach-nudge"; import type { Locale } from "@/lib/i18n/config"; import { resolveProviderChain } from "@/lib/ai/provider"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { chainRequiresServerManagedConsent, hasActiveConsentForSurface, @@ -173,6 +174,18 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => { } try { + // The capability at the wire, before the chain is resolved (a Codex + // chain may refresh a token on resolve). The cron checked it for the + // candidate already; a switch can flip within the tick. + const coach = await aiCapabilityForJob(params.userId, "coach"); + if (!coach.available) { + annotate({ + action: { name: "coach.nudge.ai.unavailable" }, + meta: { reason: coach.reason }, + }); + return null; + } + const chain = await resolveProviderChain(params.userId); if (chain.length === 0) return null; diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index d8de29c13..52c8b698e 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -13,9 +13,14 @@ * 1. Operator kill-switch via `getAssistantFlags().coach` — the * master assistant switch forces this off too. * 2. Per-user `disableCoach: false` — no Coach surface, no nudge. - * 3. A working provider (`userRowHasProviderCredential`, including - * the operator's shared key) — a nudge into a Coach that cannot - * answer is worse than silence. + * 3. The `coach` capability for the user (`aiCapabilityForJob`): the + * operator's switches, the person's Coach switch, provider-work + * authority and one provider-presence definition (the operator's + * shared key and the central Codex included). A nudge into a Coach + * that cannot answer is worse than silence. The one reason that does + * not stop the nudge is a missing consent receipt: the deterministic + * template needs no model, and the Coach it invites the user into asks + * for consent itself. * 4. Per-user opt-out: `notificationPrefs.coach.nudgesEnabled` * (default ON; Settings → Notifications). v1.16.5 adds per-group * toggles underneath (medication / vitals / routine) — a disabled @@ -78,7 +83,8 @@ */ import type { PrismaClient } from "@/generated/prisma/client"; import { getAssistantFlags } from "@/lib/feature-flags"; -import { userRowHasProviderCredential } from "@/lib/ai/provider"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; +import { annotate } from "@/lib/logging/context"; import { getEffectiveRange, type ThresholdOverridesJson, @@ -201,6 +207,12 @@ export interface CoachNudgeSummary { persisted: number; skippedOptedOut: number; skippedNoProvider: number; + /** + * The `coach` capability is unavailable for a reason other than a missing + * provider or consent (operator switch, Coach switch, authority, a failed + * check). + */ + skippedUnavailable: number; skippedRecentNudge: number; /** * v1.25.0 — anti-nag: the user talked to the Coach within the last ~24 h, @@ -806,6 +818,7 @@ export async function runCoachNudgeTick( persisted: 0, skippedOptedOut: 0, skippedNoProvider: 0, + skippedUnavailable: 0, skippedRecentNudge: 0, skippedRecentEngagement: 0, skippedNoTrigger: 0, @@ -818,8 +831,7 @@ export async function runCoachNudgeTick( const flags = await getAssistantFlags(); if (!flags.coach) return summary; - // Gate 2 — per-user Coach opt-out, plus the credential-presence - // columns gate 3 needs (evaluated locally, never decrypted). + // Gate 2 — per-user Coach opt-out. const users = await prisma.user.findMany({ where: { disableCoach: false }, select: { @@ -834,28 +846,10 @@ export async function runCoachNudgeTick( gender: true, thresholdsJson: true, timezone: true, - aiProvider: true, - aiProviderChain: true, - aiAnthropicKeyEncrypted: true, - aiLocalKeyEncrypted: true, - aiOpenaiKeyEncrypted: true, - aiBaseUrl: true, - aiModel: true, - aiCompatBaseUrl: true, - aiCompatModel: true, - codexConnectionStatus: true, - codexAccessTokenEncrypted: true, - codexRefreshTokenEncrypted: true, }, }); if (users.length === 0) return summary; - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { adminAiKeyEncrypted: true }, - }); - const adminKeyConfigured = !!settings?.adminAiKeyEncrypted; - for (const user of users) { summary.candidatesScanned += 1; try { @@ -873,9 +867,16 @@ export async function runCoachNudgeTick( continue; } - // Gate 3 — a Coach without a provider cannot answer the nudge. - if (!userRowHasProviderCredential(user, adminKeyConfigured)) { - summary.skippedNoProvider += 1; + // Gate 3 — the `coach` capability. A Coach that cannot answer gets no + // nudge; a missing consent receipt alone still gets the template. + const coach = await aiCapabilityForJob(user.id, "coach"); + if (!coach.available && coach.reason !== "consent_required") { + if (coach.reason === "no_provider") summary.skippedNoProvider += 1; + else summary.skippedUnavailable += 1; + annotate({ + action: { name: "coach.nudge.skipped" }, + meta: { reason: coach.reason }, + }); continue; } diff --git a/src/lib/jobs/data-arrival.ts b/src/lib/jobs/data-arrival.ts index 1fa671331..a99c6da95 100644 --- a/src/lib/jobs/data-arrival.ts +++ b/src/lib/jobs/data-arrival.ts @@ -32,6 +32,7 @@ import type { Job } from "pg-boss"; import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; import { annotate } from "@/lib/logging/context"; +import { aiWorkNotRuledOut } from "@/lib/jobs/ai-job-candidates"; import { withBackgroundEvent } from "@/lib/logging/background"; import { DATA_ARRIVAL_QUEUE } from "@/lib/arrivals/emit-shared"; @@ -212,7 +213,19 @@ export async function runDataArrival( // once-per-workout key, duration floor, hard daily cap, input hash and // token-ledger reservation bound the spend — none of which belong in the // spine, which is why only the generator-free enqueue is imported here. - if (arrival.refId) { + if (!arrival.refId) { + // A workout arrival with no referent cannot address a paragraph. The + // seams always carry one; annotating rather than guessing keeps a + // future seam that forgets it visible. + actions.push("workout_no_ref"); + } else if ( + !(await aiWorkNotRuledOut(arrival.userId, "workoutInsights")) + ) { + // Nothing is enqueued that a switch or a module already rules out. + // The workout itself is data and already stored; only the paragraph + // about it is withheld. The job resolves the full capability itself. + actions.push("workout_insight_unavailable"); + } else { const insight = await enqueueWorkoutInsight({ userId: arrival.userId, workoutId: arrival.refId, @@ -221,11 +234,6 @@ export async function runDataArrival( throw new Error("Workout insight enqueue failed"); } actions.push("workout_insight_enqueued"); - } else { - // A workout arrival with no referent cannot address a paragraph. The - // seams always carry one; annotating rather than guessing keeps a - // future seam that forgets it visible. - actions.push("workout_no_ref"); } break; @@ -237,7 +245,15 @@ export async function runDataArrival( break; } - if (reactionClaim !== "unchanged") { + if ( + reactionClaim !== "unchanged" && + !(await aiWorkNotRuledOut(arrival.userId, "reactionLines")) + ) { + // The marker is the feature and is written above; the line is garnish a + // model writes, and a switch or a module already rules it out. The job + // resolves the full capability itself. + actions.push("line_unavailable"); + } else if (reactionClaim !== "unchanged") { // A fresh marker and a strictly newer replacement both need a line. The // replacement transaction cleared the old generation owner first, and the // enqueue key includes this arrival revision so an older in-flight job diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index 53dec0f23..a7c1368da 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -30,8 +30,10 @@ * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it. */ import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { assertDocumentEgressConsent, + isExternalDocumentEgress, ConsentRequiredError, } from "@/lib/ai/consent-guard"; import { @@ -96,7 +98,8 @@ export interface ContentIndexBackfillSummary { * reservation existed; a later run retries. */ failed: number; - reason: "ok" | "no-provider" | "no-consent" | "budget-reached"; + reason: + "ok" | "no-provider" | "no-consent" | "budget-reached" | "unavailable"; } /** @@ -173,11 +176,34 @@ export async function runContentIndexBackfillForUser( failed: stale.failed, reason: "no-provider", }; + // An external pick is document AI and needs the `documentAi` capability + // (the operator's switches, the vault module, the extraction consent). A + // local pick keeps the document on the machine and indexes as before. + if (isExternalDocumentEgress(pick.providerType)) { + const capability = await aiCapabilityForJob(userId, "documentAi"); + if (!capability.available) { + annotate({ + action: { name: "documents.contentIndex.backfillSkipped" }, + meta: { reason: capability.reason }, + }); + return { + indexed: 0, + retokenised: stale.retokenised, + skipped: 0, + failed: stale.failed, + reason: + capability.reason === "consent_required" + ? "no-consent" + : capability.reason === "no_provider" + ? "no-provider" + : "unavailable", + }; + } + } try { await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); } catch (err) { if (err instanceof ConsentRequiredError) { diff --git a/src/lib/jobs/document-summary-catchup.ts b/src/lib/jobs/document-summary-catchup.ts index 9acb154fe..7a16ffe33 100644 --- a/src/lib/jobs/document-summary-catchup.ts +++ b/src/lib/jobs/document-summary-catchup.ts @@ -33,6 +33,7 @@ * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it. */ import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { enqueueDocumentSummary } from "@/lib/jobs/document-summary"; @@ -92,6 +93,18 @@ export async function runSummaryCatchUpForUser( return { enqueued: 0, capped: false }; } + // The `documentAi` capability, once for the whole pass, before any document + // is listed: a switch off, no provider or no extraction consent means there + // is nothing the per-document jobs could do. + const capability = await aiCapabilityForJob(userId, "documentAi"); + if (!capability.available) { + annotate({ + action: { name: "documents.autoRead.catchUpSkipped" }, + meta: { reason: capability.reason }, + }); + return { enqueued: 0, capped: false }; + } + let enqueued = 0; let capped = false; let cursor: string | null = null; diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index 257586a01..55940bbe3 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -22,6 +22,10 @@ * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it. */ import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { + aiCapabilityForJob, + aiCapabilityForRecord, +} from "@/lib/ai/capabilities/gate"; import { assertDocumentEgressConsent, ConsentRequiredError, @@ -135,6 +139,39 @@ export async function runDocumentSummaryJob( return; } + // The `documentAi` capability before a provider is picked or the document is + // decrypted: the operator's switches (the master and "Reading documents"), + // the vault module, provider presence and the extraction consent. The + // auto-read toggle above is the trigger, not the consent. + const capability = await aiCapabilityForJob(userId, "documentAi"); + if (!capability.available) { + if ( + capability.reason === "no_provider" || + capability.reason === "consent_required" + ) { + // An attempt that could not run, recorded as such. + await markSummaryState(userId, documentId, "UNAVAILABLE"); + } else { + // A switch turned off means this job was never to run at all. Only the + // PENDING this job was meant to resolve goes back to NONE; a recorded + // UNAVAILABLE or WITHHELD is an earlier real attempt and stays. + await prisma.inboundDocument.updateMany({ + where: { + id: documentId, + userId, + deletedAt: null, + summaryState: "PENDING", + }, + data: { summaryState: "NONE" }, + }); + } + annotate({ + action: { name: "documents.summary.autoSkipped" }, + meta: { documentId, reason: capability.reason }, + }); + return; + } + // Resolve the DOCUMENT-order vision provider (local-first, codex last). No // vision-capable provider configured → graceful no-op (unlike the index job, // there is no local text-layer fallback for a descriptive summary). @@ -149,14 +186,13 @@ export async function runDocumentSummaryJob( } // Re-assert egress consent for the picked provider. A local pick is ungated; - // an external pick is authorised by the `documentsAutoAiRead` opt-in checked - // above (the toggle short-circuits the gate). Belt-and-braces: a consent race - // (opt-out flipped mid-flight) resolves to a no-op, never an egress. + // an external pick needs an active extraction receipt (`ai_extraction` or + // `ai_full`); the auto-read toggle mints one but is not one. A consent race + // (a revoke mid-flight) resolves to a no-op, never an egress. try { await assertDocumentEgressConsent({ userId, providerType: pick.providerType, - surface: "insights", }); } catch (err) { await markSummaryState(userId, documentId, "UNAVAILABLE"); @@ -319,6 +355,17 @@ export async function enqueueDocumentSummary( }); return { enqueued: false }; } + // Nothing is queued that the capability already refuses (a switch off, + // no provider, no extraction consent): the document stays NONE and the + // view offers the manual action, exactly as with the opt-in off. + const capability = await aiCapabilityForRecord(userId, "documentAi"); + if (!capability.available) { + annotate({ + action: { name: "documents.summary.autoSkipped" }, + meta: { documentId, reason: capability.reason }, + }); + return { enqueued: false }; + } const boss = getGlobalBoss(); if (!boss) return { enqueued: false }; const payload: DocumentSummaryPayload = { diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 34c95e503..37cac27f8 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -54,6 +54,8 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { checkRateLimit } from "@/lib/rate-limit"; import { getAssistantFlags } from "@/lib/feature-flags"; import { annotate } from "@/lib/logging/context"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; +import { userIdsWithModuleOff } from "@/lib/jobs/ai-job-candidates"; import type { SupportedLocale } from "@/lib/insights/status-shared"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { @@ -504,10 +506,11 @@ interface PregenerateCandidate { } /** - * Discovery query — coach-enabled users with a stale or missing - * comprehensive-insight cache, oldest-cache-first so the staleest users - * are served before the per-run cap bites. Exported so the cron test - * can pin the WHERE shape without a live LLM. + * Discovery query — users with AI analysis on (the `insights` module) and a + * stale or missing comprehensive-insight cache, oldest-cache-first so the + * staleest users are served before the per-run cap bites. Hiding the Coach + * no longer excludes anyone: it hides the Coach and nothing else. Exported + * so the cron test can pin the WHERE shape without a live LLM. */ export async function findPregenerateCandidates( prisma: PrismaClient, @@ -515,9 +518,10 @@ export async function findPregenerateCandidates( cap: number, ): Promise { const staleBefore = new Date(now.getTime() - PREGENERATE_STALE_MS); + const optedOut = await userIdsWithModuleOff(prisma, "insights"); return prisma.user.findMany({ where: { - disableCoach: false, + ...(optedOut.length > 0 ? { id: { notIn: optedOut } } : {}), OR: [ { insightsCachedAt: null }, { insightsCachedAt: { lt: staleBefore } }, @@ -620,6 +624,26 @@ export async function runInsightPregenerate( // sibling consumer in this candidate's warm reuses it. Scoped per user so // no cross-user object can leak. await withFeatureCacheScope(async () => { + // The two capabilities this pass serves, resolved before any snapshot + // or rate-limit write: the briefing for the comprehensive insight, the + // status notes for the per-metric warm. Each half runs only when its + // own capability is available; the chokepoints re-check at the wire. + const [briefing, statusText] = await Promise.all([ + aiCapabilityForJob(candidate.id, "briefing"), + aiCapabilityForJob(candidate.id, "statusText"), + ]); + if (!briefing.available && !statusText.available) { + result.skipped++; + annotate({ + action: { name: "insights.pregenerate.skipped" }, + meta: { + briefing: briefing.reason, + status_text: statusText.reason, + }, + }); + return; + } + // Budget gate — one COMPREHENSIVE pre-generation per user per 20 h. // The route's on-demand path uses a different bucket // (`insights:${userId}`), so this never starves a user's manual @@ -628,18 +652,25 @@ export async function runInsightPregenerate( // the 02:xx status crons already skipped every pregenerate candidate // on the assumption that THIS pass warms their cards — exiting early // here left those cards cold until the first on-visit generation. - const budget = await checkRateLimit( - `insight-pregenerate:${candidate.id}`, - 1, - PREGENERATE_BUDGET_WINDOW_MS, - ); + const budget = briefing.available + ? await checkRateLimit( + `insight-pregenerate:${candidate.id}`, + 1, + PREGENERATE_BUDGET_WINDOW_MS, + ) + : null; // The stored locale, then the operator default: a NULL column used to // resolve to English here, so a German-language instance warmed its // briefings in English every night. const locale = await resolveJobLocale(candidate.locale); - let outcome: GenerateOutcome | null = null; - if (!budget.allowed) { + if (budget === null) { + result.skipped++; + annotate({ + action: { name: "insights.pregenerate.skipped" }, + meta: { briefing: briefing.reason }, + }); + } else if (!budget.allowed) { result.budgetBlocked++; } else { // Force a fresh generation: the discovery window (20 h) is shorter @@ -709,7 +740,7 @@ export async function runInsightPregenerate( // before the morning visit instead of waiting for the next night. await enqueueRetry({ userId: candidate.id, locale }); } else { - outcome = bounded.value; + const outcome = bounded.value; switch (outcome.status) { case "generated": result.generated++; @@ -758,11 +789,12 @@ export async function runInsightPregenerate( // consent-skipped included) because the 02:xx status crons skip every // pregenerate candidate on the assumption that THIS pass covers them. // - // The only outcome with nothing to warm is a missing provider - // (`skipped`/`no-provider`): the per-card generators would no-op to - // their fallbacks anyway, so the pass is skipped to save the - // chain-resolves. - if (outcome?.status === "skipped" && outcome.reason === "no-provider") { + // Nothing to warm when the status notes themselves are unavailable. + if (!statusText.available) { + annotate({ + action: { name: "insights.pregenerate.status_skipped" }, + meta: { reason: statusText.reason }, + }); return; } result.assessmentsWarmed += await warmStatus(candidate.id, [locale]); @@ -881,17 +913,22 @@ export async function forceWarmUser( metricAssessmentsWarmed: 0, }; - // v1.9.0 — gate each section on the flag for the surface it actually - // warms, matching the route. The HTTP route admits this job on the per-user - // `insightStatus` surface, so warming the per-status + generic-metric cards - // must run whenever `insightStatus` is enabled. Only the comprehensive - // briefing belongs to the `briefing` surface — gating the whole warm on - // `briefing` (the previous behaviour) let an operator's global `briefing` - // kill-switch silently suppress the assessment cards the user has enabled. - // When the master assistant switch is off both flags resolve false, so the - // whole thing still no-ops. - const flags = await getAssistantFlags(); - if (!flags.briefing && !flags.insightStatus) return result; + // Each section runs on its own capability, resolved for this user under + // the job's authority: the comprehensive briefing on `briefing`, the + // per-status and generic-metric notes on `statusText`. One unavailable + // half never suppresses the other; both unavailable makes the job a + // no-op before any snapshot is built. + const [briefing, statusText] = await Promise.all([ + aiCapabilityForJob(userId, "briefing"), + aiCapabilityForJob(userId, "statusText"), + ]); + if (!briefing.available && !statusText.available) { + annotate({ + action: { name: "insights.pregenerate.force.skipped" }, + meta: { briefing: briefing.reason, status_text: statusText.reason }, + }); + return result; + } // v1.16.8 — warm only the caller's resolved locale. The old dual-locale // warm compensated for the comprehensive write's cross-locale per-status @@ -926,7 +963,7 @@ export async function forceWarmUser( // 3. daily cap — at most FORCE_WARM_DAILY_LIMIT forced attempts per // rolling 24 h, so even a client bug that defeats 1+2 cannot turn // the forced path into an unmetered generation loop. - if (flags.briefing) { + if (briefing.available) { let freshness: { insightsCachedAt: Date | null; insightsWarmFailedAt: Date | null; @@ -1066,15 +1103,15 @@ export async function forceWarmUser( } } - // Run the per-status + generic-metric warm passes whenever the - // `insightStatus` surface is enabled, regardless of the comprehensive + // Run the per-status + generic-metric warm passes whenever `statusText` + // is available, regardless of the comprehensive // outcome. Each generator independently no-ops to its no-key fallback when // no provider is configured (no LLM call) and serves its fallback without // persisting on a per-card timeout, so running them after a failed // comprehensive is safe and bounded by their own per-card budgets. The only // outcome that has nothing to warm is a missing provider, and the // generators detect that themselves at near-zero cost. - if (flags.insightStatus) { + if (statusText.available) { // Refill-only (v1.16.8): a card already generated today is a cheap // cache read; a cold card runs its generator, whose content-hash gate // skips the LLM when the underlying data did not change. There is no diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts index 9356f8300..80118c473 100644 --- a/src/lib/jobs/insight-status-generate.ts +++ b/src/lib/jobs/insight-status-generate.ts @@ -56,6 +56,7 @@ import { type InsightStatusMetric, type InsightStatusGeneratePayload, } from "@/lib/jobs/insight-status-generate-shared"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { mayDispatchProviderWork, withProviderWorkAuthority, @@ -115,6 +116,19 @@ async function dispatchInsightStatusGenerate( payload: InsightStatusGeneratePayload, generators: Record = GENERATORS, ): Promise { + // The capability, under the job's own authority, before any generator + // builds a snapshot. The route that enqueued this checked it too, but a + // switch, the AI analysis opt-out or a consent can change while the job + // waits in the queue. + const capability = await aiCapabilityForJob(payload.userId, "statusText"); + if (!capability.available) { + annotate({ + action: { name: "insights.status.generate.skipped" }, + meta: { metric: payload.metric, reason: capability.reason }, + }); + return; + } + // v1.8.7.1 — a `metric:` scope routes to the generic // HealthKit-metric generator rather than one of the seven specialised // ones. The generic generator applies its own empty-data guard, so a diff --git a/src/lib/jobs/period-narrative-warm.ts b/src/lib/jobs/period-narrative-warm.ts index 3c5c97600..5272f619e 100644 --- a/src/lib/jobs/period-narrative-warm.ts +++ b/src/lib/jobs/period-narrative-warm.ts @@ -26,6 +26,8 @@ import { mayDispatchProviderWork, withProviderWorkAuthority, } from "@/lib/sharing/provider-work-authority"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; +import { userIdsWithModuleOff } from "@/lib/jobs/ai-job-candidates"; import { annotate } from "@/lib/logging/context"; import { generatePeriodNarrative, @@ -96,7 +98,8 @@ interface NarrativeCandidate { } /** - * Discovery query — coach-enabled users, oldest-narrative-first so the + * Discovery query — users with AI analysis on (the `insights` module), + * oldest-narrative-first so the * staleest users are served before the per-run cap bites. Whether a user has * a provider / enough history is confirmed inside the generator (skipped / * insufficient), so a provider-less or sparse account costs at most one cheap @@ -113,8 +116,9 @@ export async function findNarrativeCandidates( prisma: PrismaClient, cap: number, ): Promise { + const optedOut = await userIdsWithModuleOff(prisma, "insights"); const users: NarrativeCandidate[] = await prisma.user.findMany({ - where: { disableCoach: false }, + where: optedOut.length > 0 ? { id: { notIn: optedOut } } : {}, select: { id: true, locale: true }, }); if (users.length <= cap) return users; @@ -175,6 +179,23 @@ export async function runPeriodNarrativeWarm( result.total = candidates.length; for (const candidate of candidates) { + // The nightly pass exists for the model-written narrative. Unavailable, + // it skips the user before the context build and the budget write; the + // deterministic narrative is written on the next read instead (the + // single-user warm below runs whatever the capability says). + const capability = await aiCapabilityForJob( + candidate.id, + "periodNarrative", + ); + if (!capability.available) { + result.skipped++; + annotate({ + action: { name: "insights.narrative.warm.skipped" }, + meta: { reason: capability.reason }, + }); + continue; + } + const budget = await checkRateLimit( `period-narrative:${candidate.id}`, 1, @@ -254,8 +275,10 @@ export async function warmOneNarrative( if (!payload.userId || !payload.period) return null; const authority = payload.authority; if (!authority || !(await mayDispatchProviderWork(authority))) return null; - const flags = await getAssistantFlags(); - if (!flags.briefing && !flags.insightStatus) return null; + // No switch check here: this warm also writes the deterministic narrative, + // which is data. The model half is refused by the provider chokepoint when + // `periodNarrative` is unavailable, and the generator then writes the + // deterministic text instead. return withProviderWorkAuthority(authority, () => generate(payload.userId!, { period: payload.period!, diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index 35e994009..35a4a6493 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -37,7 +37,7 @@ import { chainRequiresServerManagedConsent, hasActiveConsentForSurface, } from "@/lib/ai/consent-guard"; -import { isModuleEnabled } from "@/lib/modules/gate"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { buildDateKey, reconcileSpend, @@ -467,8 +467,20 @@ export async function runReactionLine( return { status: "skipped", reason: "already_attempted" }; } - if (!(await isModuleEnabled(job.userId, "insights"))) { - return { status: "skipped", reason: "module_disabled" }; + // The `reactionLines` capability before the digest is built or a chain is + // resolved: the operator's switches (the master included, which this job + // never read before), the person's AI analysis switch, provider presence and + // consent in one answer. The marker itself is data and stays. + const capability = await aiCapabilityForJob(job.userId, "reactionLines"); + if (!capability.available) { + annotate({ + action: { name: "arrival.reaction.skipped" }, + meta: { kind: job.kind, reason: capability.reason }, + }); + return { + status: "skipped", + reason: capability.reason ?? "check_failed", + }; } const user = await prisma.user.findUnique({ where: { id: job.userId } }); diff --git a/src/lib/jobs/reminder/insights-handlers.ts b/src/lib/jobs/reminder/insights-handlers.ts index 32a0da584..4fd293222 100644 --- a/src/lib/jobs/reminder/insights-handlers.ts +++ b/src/lib/jobs/reminder/insights-handlers.ts @@ -31,6 +31,7 @@ import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medica import { generateStatusBatchForUser } from "@/lib/insights/status-batch"; import { findStatusCronCandidates } from "@/lib/jobs/status-cron-candidates"; import { annotate } from "@/lib/logging/context"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { getWorkerPrisma } from "./shared"; export interface GeneralStatusPayload { @@ -64,11 +65,12 @@ export interface MedicationComplianceStatusPayload { /** * Shared driver for the nightly 02:xx per-metric status crons. User * discovery is centralised in `findStatusCronCandidates`, which applies - * the operator assistant kill-switch, the per-user `disableCoach` gate, - * and the pregenerate-candidate skip (users with a configured provider - * and a stale comprehensive cache belong to the 04:30 pre-generate pass, - * which re-warms every per-status cache anyway — see - * `status-cron-candidates.ts` for the full division of nightly labour). + * the operator's `insightStatus` switch, the person's AI analysis switch, + * and the pregenerate-candidate skip (users with a stale comprehensive + * cache belong to the 04:30 pre-generate pass, which re-warms every + * per-status note anyway — see `status-cron-candidates.ts` for the full + * division of nightly labour). Each user's `statusText` capability is then + * resolved before a generator runs. * The generators normalise `locale` themselves (de stays de, everything * else gets English prose). * @@ -96,6 +98,16 @@ export async function runStatusCronGenerate( let failed = 0; for (const user of users) { + // The capability before the generator builds a snapshot. The batch + // entry below checks for itself. + const capability = await aiCapabilityForJob(user.id, "statusText"); + if (!capability.available) { + annotate({ + action: { name: "insights.status.cron.skipped" }, + meta: { task: taskName, reason: capability.reason }, + }); + continue; + } try { await generate(user.id, { locale: await resolveJobLocale(user.locale), diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index 8ea3a7a6f..166dc7c14 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -539,6 +539,7 @@ export async function registerStatusQueues( persisted: summary.persisted, skipped_opted_out: summary.skippedOptedOut, skipped_no_provider: summary.skippedNoProvider, + skipped_unavailable: summary.skippedUnavailable, skipped_recent_nudge: summary.skippedRecentNudge, skipped_recent_engagement: summary.skippedRecentEngagement, skipped_no_trigger: summary.skippedNoTrigger, diff --git a/src/lib/jobs/status-cron-candidates.ts b/src/lib/jobs/status-cron-candidates.ts index a32c41843..9b62b23a6 100644 --- a/src/lib/jobs/status-cron-candidates.ts +++ b/src/lib/jobs/status-cron-candidates.ts @@ -12,34 +12,32 @@ * Division of nightly labour (documented here once, referenced by the * handlers): * - * - 02:xx status crons → users WITHOUT a comprehensive-pregenerate - * claim: no configured provider, or a comprehensive cache fresher - * than `PREGENERATE_STALE_MS` so the 04:30 pass will skip them — - * their per-status caches still age out daily and only these crons - * re-fill them. With the one-hour threshold this cohort is, in - * practice, the provider-less accounts. - * - 04:30 insight-pregenerate → the rest: coach-enabled users with a - * configured provider and a comprehensive cache older than - * `PREGENERATE_STALE_MS`. That pass regenerates the comprehensive - * insight AND force-warms every per-status cache, so a 02:xx - * generation for those users would be evicted and redone two hours - * later. + * - 02:xx status crons → users the 04:30 `insight-pregenerate` pass will + * not reach: a comprehensive cache fresher than `PREGENERATE_STALE_MS`, + * so that pass skips them, while their per-status notes still age out + * daily and only these crons re-fill them. + * - 04:30 insight-pregenerate → the rest: users with a comprehensive cache + * older than `PREGENERATE_STALE_MS`. That pass warms every per-status + * note whenever `statusText` is available for the user, whatever became + * of the briefing, so a 02:xx generation for them would be redone two + * hours later. * - * Gates, in order: - * 1. Operator kill-switch via `getAssistantFlags()` — the same source - * `insight-pregenerate` uses. The status cards belong to the - * `insightStatus` surface, so that flag (which the master assistant - * switch also forces off) gates the whole pass. - * 2. Per-user `disableCoach: false` — a user who disabled the AI - * surface gets no nightly generation. - * 3. The pregenerate-candidate skip described above (only when the - * `briefing` surface is enabled — when it is off the 04:30 pass - * no-ops, so the 02:xx crons must keep covering everyone). + * Cheap gates, in SQL and in order (the per-user `statusText` capability in + * each handler, and the chokepoint behind it, are what decide): + * 1. The operator's `insightStatus` switch (master applied). Off, the whole + * pass is empty. + * 2. The person's AI analysis switch (the `insights` module). Someone who + * turned it off gets no nightly generation. Hiding the Coach + * (`disableCoach`) no longer plays a part: it hides the Coach and nothing + * else. + * 3. The pregenerate-candidate skip described above, only while the + * `briefing` switch is on; when it is off the 04:30 pass no-ops, so the + * 02:xx crons keep covering everyone. */ import type { PrismaClient } from "@/generated/prisma/client"; import { getAssistantFlags } from "@/lib/feature-flags"; -import { userRowHasProviderCredential } from "@/lib/ai/provider"; import { PREGENERATE_STALE_MS } from "@/lib/jobs/insight-pregenerate"; +import { userIdsWithModuleOff } from "@/lib/jobs/ai-job-candidates"; export interface StatusCronCandidate { id: string; @@ -53,51 +51,23 @@ export async function findStatusCronCandidates( const flags = await getAssistantFlags(); if (!flags.insightStatus) return []; + const optedOut = await userIdsWithModuleOff(prisma, "insights"); const users = await prisma.user.findMany({ - where: { disableCoach: false }, - select: { - id: true, - locale: true, - insightsCachedAt: true, - // Credential-presence columns for the pregenerate-candidate skip — - // evaluated locally, never decrypted. - aiProvider: true, - aiProviderChain: true, - aiAnthropicKeyEncrypted: true, - aiLocalKeyEncrypted: true, - aiOpenaiKeyEncrypted: true, - aiBaseUrl: true, - aiModel: true, - aiCompatBaseUrl: true, - aiCompatModel: true, - codexConnectionStatus: true, - codexAccessTokenEncrypted: true, - codexRefreshTokenEncrypted: true, - }, + where: optedOut.length > 0 ? { id: { notIn: optedOut } } : {}, + select: { id: true, locale: true, insightsCachedAt: true }, }); if (users.length === 0) return []; - // When the briefing surface is off the 04:30 pregenerate pass no-ops, - // so no user has a comprehensive claim and the 02:xx crons keep all. + // When the briefing switch is off the 04:30 pregenerate pass no-ops, so no + // user has a comprehensive claim and the 02:xx crons keep all. if (!flags.briefing) { return users.map((u) => ({ id: u.id, locale: u.locale })); } - // One presence read for the operator's shared key covers the cohort. - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { adminAiKeyEncrypted: true }, - }); - const adminKeyConfigured = !!settings?.adminAiKeyEncrypted; - const staleBefore = now.getTime() - PREGENERATE_STALE_MS; return users - .filter((u) => { - const cacheStale = - !u.insightsCachedAt || u.insightsCachedAt.getTime() < staleBefore; - const isPregenerateCandidate = - cacheStale && userRowHasProviderCredential(u, adminKeyConfigured); - return !isPregenerateCandidate; - }) + .filter( + (u) => u.insightsCachedAt && u.insightsCachedAt.getTime() >= staleBefore, + ) .map((u) => ({ id: u.id, locale: u.locale })); } diff --git a/tests/integration/ai-optional-jobs.test.ts b/tests/integration/ai-optional-jobs.test.ts new file mode 100644 index 000000000..e0c216f0b --- /dev/null +++ b/tests/integration/ai-optional-jobs.test.ts @@ -0,0 +1,289 @@ +/** + * The AI background jobs, run directly against real Postgres under the states + * that must stop them, with a spy in place of the provider. + * + * For every worker and every refusing state: the spy is never called, the + * worker writes no model text, and a skip annotation is recorded. Under the + * all-on baseline the spy IS called, which is what proves the test can see a + * call at all (a spy that no path reaches would pass every refusing case). + * + * States (design "AI optional", 6.2): + * S0 all on: the person's own key, every switch on. + * S1 the operator's master switch off. + * S3 no provider configured anywhere. + * S4 only the operator's key, and no consent receipt. + * S5 the Coach hidden (`disableCoach`), which stops only the Coach. + * S6 AI analysis switched off (the `insights` module), which stops + * everything but the Coach. + * + * Only the provider chain is replaced; the capability resolution, the + * presence probe, the budget ledger and the writes all run for real. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const providerSpy = vi.fn(async () => ({ + content: JSON.stringify({ summary: "A calm and steady stretch." }), + model: "spy-model", + tokensUsed: 12, +})); + +vi.mock("@/lib/ai/provider", async (importOriginal) => { + const actual = await importOriginal(); + const chain = () => [ + { + providerType: "anthropic", + instance: { type: "anthropic", generateCompletion: providerSpy }, + }, + ]; + return { + ...actual, + resolveProviderChain: vi.fn(async () => chain()), + resolveProvider: vi.fn(async () => ({ type: "none" })), + }; +}); + +const annotations: string[] = []; +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + annotate: vi.fn((event: { action?: { name?: string } }) => { + if (event?.action?.name) annotations.push(event.action.name); + return actual.annotate(event as never); + }), + }; +}); + +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { runCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-refresh-worker"; +import { generateStatusBatchForUser } from "@/lib/insights/status-batch"; +import { runInsightStatusGenerate } from "@/lib/jobs/insight-status-generate"; +import { runReactionLine } from "@/lib/jobs/reaction-line"; +import { runWorkoutInsightGenerate } from "@/lib/jobs/workout-insight-generate"; +import { userDayKey } from "@/lib/tz/resolver"; + +type State = "S0" | "S1" | "S3" | "S4" | "S5" | "S6"; + +let counter = 0; + +/** One account in the given state, with the data every worker below reads. */ +async function seedState(state: State) { + const prisma = getPrismaClient(); + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + create: { + id: "singleton", + assistantEnabled: state !== "S1", + adminAiKeyEncrypted: state === "S4" ? "operator-key-present" : null, + }, + update: { + assistantEnabled: state !== "S1", + adminAiKeyEncrypted: state === "S4" ? "operator-key-present" : null, + }, + }); + const ownKey = state !== "S3" && state !== "S4"; + const suffix = `${state.toLowerCase()}-${counter++}`; + const user = await prisma.user.create({ + data: { + username: `jobs-${suffix}`, + email: `jobs-${suffix}@example.test`, + timezone: "UTC", + locale: "en", + heightCm: 180, + disableCoach: state === "S5", + modulePreferencesJson: state === "S6" ? { insights: false } : undefined, + aiProviderChain: ownKey + ? [{ providerType: "anthropic", enabled: true }] + : undefined, + aiAnthropicKeyEncrypted: ownKey ? "key-present" : null, + }, + }); + + const now = Date.now(); + const DAY = 24 * 60 * 60 * 1000; + await prisma.measurement.createMany({ + data: Array.from({ length: 30 }, (_, i) => ({ + userId: user.id, + type: "WEIGHT" as const, + value: 82 - i * 0.05, + unit: "kg", + source: "MANUAL" as const, + measuredAt: new Date(now - i * DAY), + })), + }); + + const workout = await prisma.workout.create({ + data: { + userId: user.id, + sportType: "cycling", + startedAt: new Date(now - 2 * 60 * 60 * 1000), + endedAt: new Date(now - 60 * 60 * 1000), + durationSec: 3600, + source: "APPLE_HEALTH", + externalId: `workout-${suffix}`, + }, + }); + + const occurredAt = new Date(now - 5 * 60 * 1000); + const localDate = userDayKey(new Date(now), "UTC"); + await prisma.arrivalReaction.create({ + data: { userId: user.id, kind: "weight", localDate, occurredAt }, + }); + + // Long enough that the rolling summary has turns to fold. + const conversation = await prisma.coachConversation.create({ + data: { userId: user.id, title: "long conversation" }, + }); + await prisma.coachMessage.createMany({ + data: Array.from({ length: 40 }, (_, i) => ({ + conversationId: conversation.id, + role: i % 2 === 0 ? "user" : "assistant", + encryptedContent: encryptToBytes(`turn ${i}: about sleep and walks`), + createdAt: new Date(now - (40 - i) * 60_000), + })), + }); + + return { + userId: user.id, + workoutId: workout.id, + reaction: { + userId: user.id, + kind: "weight" as const, + localDate, + revision: occurredAt.toISOString(), + }, + conversationId: conversation.id, + }; +} + +type Seeded = Awaited>; + +interface Worker { + name: string; + /** Which states must stop it. */ + refusedIn: readonly State[]; + run: (s: Seeded) => Promise; + /** Whether the worker wrote model text for this account. */ + wroteText: (s: Seeded) => Promise; + /** The skip annotation it records when refused. */ + skipAnnotation: string; +} + +const ANALYSIS_REFUSED: readonly State[] = ["S1", "S3", "S4", "S6"]; +const COACH_REFUSED: readonly State[] = ["S1", "S3", "S4", "S5"]; + +const WORKERS: Worker[] = [ + { + name: "insight-status-generate (statusText)", + refusedIn: ANALYSIS_REFUSED, + run: (s) => + runInsightStatusGenerate({ + userId: s.userId, + metric: "weight", + locale: "en", + authority: { + origin: "owner", + recordUserId: s.userId, + actorUserId: s.userId, + grantId: null, + }, + }), + wroteText: async (s) => + (await getPrismaClient().insightStatusCache.count({ + where: { userId: s.userId, textEncrypted: { not: null } }, + })) > 0, + skipAnnotation: "insights.status.generate.skipped", + }, + { + name: "nightly status batch (statusText)", + refusedIn: ANALYSIS_REFUSED, + run: (s) => generateStatusBatchForUser(s.userId, { locale: "en" }), + wroteText: async (s) => + (await getPrismaClient().insightStatusCache.count({ + where: { userId: s.userId, textEncrypted: { not: null } }, + })) > 0, + skipAnnotation: "insights.status.batch.skipped", + }, + { + name: "workout-insight-generate (workoutInsights)", + refusedIn: ANALYSIS_REFUSED, + run: (s) => + runWorkoutInsightGenerate({ userId: s.userId, workoutId: s.workoutId }), + wroteText: async (s) => + (await getPrismaClient().workoutInsight.count({ + where: { userId: s.userId }, + })) > 0, + skipAnnotation: "workouts.insight.skipped", + }, + { + name: "reaction-line (reactionLines)", + refusedIn: ANALYSIS_REFUSED, + run: (s) => runReactionLine(s.reaction), + wroteText: async (s) => + (await getPrismaClient().arrivalReaction.count({ + where: { userId: s.userId, lineEncrypted: { not: null } }, + })) > 0, + skipAnnotation: "arrival.reaction.skipped", + }, + { + name: "coach-memory-refresh (coach)", + refusedIn: COACH_REFUSED, + run: (s) => + runCoachMemoryRefresh({ + conversationId: s.conversationId, + userId: s.userId, + locale: "en", + }), + wroteText: async (s) => + (await getPrismaClient().coachConversation.count({ + where: { id: s.conversationId, summaryEncrypted: { not: null } }, + })) > 0, + skipAnnotation: "coach.memory.refresh.skipped", + }, +]; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + providerSpy.mockClear(); + annotations.length = 0; +}); + +describe.each(WORKERS)("$name", (worker) => { + it("calls the provider under S0 (the spy can see a call)", async () => { + const seeded = await seedState("S0"); + await worker.run(seeded); + expect(providerSpy).toHaveBeenCalled(); + expect(annotations).not.toContain(worker.skipAnnotation); + }); + + it.each(worker.refusedIn)( + "never reaches the provider and writes nothing under %s", + async (state) => { + const seeded = await seedState(state); + await worker.run(seeded); + expect(providerSpy).not.toHaveBeenCalled(); + expect(await worker.wroteText(seeded)).toBe(false); + expect(annotations).toContain(worker.skipAnnotation); + }, + ); +}); + +describe("the Coach and the analysis are separate opt-outs", () => { + it("hiding the Coach does not stop the status notes", async () => { + const seeded = await seedState("S5"); + await WORKERS[0].run(seeded); + expect(providerSpy).toHaveBeenCalled(); + }); + + it("switching AI analysis off does not stop the Coach memory", async () => { + const seeded = await seedState("S6"); + await WORKERS[4].run(seeded); + expect(providerSpy).toHaveBeenCalled(); + }); +}); diff --git a/tests/integration/consent-withdrawal-purge.test.ts b/tests/integration/consent-withdrawal-purge.test.ts new file mode 100644 index 000000000..4823be731 --- /dev/null +++ b/tests/integration/consent-withdrawal-purge.test.ts @@ -0,0 +1,327 @@ +/** + * Consent withdrawal deletes the regenerable model-written text, against real + * Postgres, and keeps the person's own records. + * + * What is pinned here and nowhere else: + * + * - the purge reaches every surface the analysis consent covered: status + * notes, the cached briefing, model-written narratives (the deterministic + * one stays), reaction lines (the marker stays), workout paragraphs; + * - Coach conversations and facts, and a document's stored summary, stay; + * - revoke and purge are one transaction: a purge that fails leaves the + * receipt active and every row in place; + * - a withdrawal that leaves the analysis covered, or that never touched + * it, deletes nothing. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { + purgeRegenerableAiText, + withdrawConsent, +} from "@/lib/consent/withdrawal"; + +const BYTES = Buffer.from("ciphertext-stand-in", "utf8"); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +async function seed(username: string, kinds: string[]) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + insightsCachedText: JSON.stringify({ briefing: "model prose" }), + insightsCachedAt: new Date(), + insightsSnapshotHash: "hash", + }, + }); + for (const kind of kinds) { + await prisma.consentReceipt.create({ + data: { userId: user.id, kind, artefact: "{}", signedAt: new Date() }, + }); + } + await prisma.insightStatusCache.createMany({ + data: [ + { + userId: user.id, + metric: "weight", + locale: "en", + dateKey: "2026-09-20", + textEncrypted: BYTES, + }, + { + userId: user.id, + metric: "metric:RESTING_HEART_RATE", + locale: "en", + dateKey: "2026-09-20", + textEncrypted: BYTES, + }, + ], + }); + await prisma.insightNarrative.createMany({ + data: [ + { + userId: user.id, + period: "week", + locale: "en", + dateKey: "2026-09-20", + encryptedContent: BYTES, + providerType: "anthropic", + }, + { + userId: user.id, + period: "month", + locale: "en", + dateKey: "2026-09-20", + encryptedContent: BYTES, + providerType: "deterministic", + }, + ], + }); + await prisma.arrivalReaction.create({ + data: { + userId: user.id, + kind: "weight", + localDate: "2026-09-20", + occurredAt: new Date(), + lineEncrypted: BYTES, + generatedAt: new Date(), + }, + }); + const workout = await prisma.workout.create({ + data: { + userId: user.id, + sportType: "cycling", + startedAt: new Date("2026-09-20T06:00:00Z"), + endedAt: new Date("2026-09-20T06:45:00Z"), + durationSec: 2700, + source: "APPLE_HEALTH", + externalId: `ext-${username}`, + }, + }); + await prisma.workoutInsight.create({ + data: { + userId: user.id, + workoutId: workout.id, + paragraphEncrypted: BYTES, + inputHash: "h", + promptVersion: "1", + providerType: "anthropic", + locale: "en", + generatedAt: new Date(), + }, + }); + const conversation = await prisma.coachConversation.create({ + data: { userId: user.id, title: "t", summaryEncrypted: BYTES }, + }); + await prisma.coachFact.create({ + data: { + userId: user.id, + factEncrypted: BYTES, + category: "goal", + sourceConversationId: conversation.id, + }, + }); + await prisma.inboundDocument.create({ + data: { + userId: user.id, + mimeType: "image/png", + byteSize: BYTES.byteLength, + contentEncrypted: BYTES, + summaryEncrypted: BYTES, + summaryState: "READY", + }, + }); + return user; +} + +async function snapshot(userId: string) { + const prisma = getPrismaClient(); + const [ + user, + notes, + narratives, + reaction, + workouts, + conversations, + facts, + docs, + ] = await Promise.all([ + prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { + insightsCachedText: true, + insightsCachedAt: true, + insightsSnapshotHash: true, + }, + }), + prisma.insightStatusCache.count({ where: { userId } }), + prisma.insightNarrative.findMany({ + where: { userId }, + select: { providerType: true }, + }), + prisma.arrivalReaction.findFirstOrThrow({ + where: { userId }, + select: { lineEncrypted: true, generatedAt: true }, + }), + prisma.workoutInsight.count({ where: { userId } }), + prisma.coachConversation.findMany({ + where: { userId }, + select: { summaryEncrypted: true }, + }), + prisma.coachFact.count({ where: { userId } }), + prisma.inboundDocument.findMany({ + where: { userId }, + select: { summaryEncrypted: true }, + }), + ]); + return { + user, + notes, + narratives, + reaction, + workouts, + conversations, + facts, + docs, + }; +} + +describe("withdrawing the analysis consent", () => { + it("deletes every regenerable text and keeps the person's own records", async () => { + const user = await seed("purge-all", ["ai_full"]); + + const result = await withdrawConsent(user.id, ["ai_full"]); + + expect(result.revoked.map((r) => r.kind)).toEqual(["ai_full"]); + expect(result.purged).toEqual({ + statusNotes: 2, + briefing: 1, + narratives: 1, + reactionLines: 1, + workoutParagraphs: 1, + }); + + const after = await snapshot(user.id); + expect(after.user).toEqual({ + insightsCachedText: null, + insightsCachedAt: null, + insightsSnapshotHash: null, + }); + expect(after.notes).toBe(0); + expect(after.narratives).toEqual([{ providerType: "deterministic" }]); + // The marker stays and stays "generated", so nothing writes it again. + expect(after.reaction.lineEncrypted).toBeNull(); + expect(after.reaction.generatedAt).not.toBeNull(); + expect(after.workouts).toBe(0); + // The person's own records. + expect(after.conversations).toHaveLength(1); + expect(after.conversations[0].summaryEncrypted).not.toBeNull(); + expect(after.facts).toBe(1); + expect(after.docs[0].summaryEncrypted).not.toBeNull(); + + const receipts = await getPrismaClient().consentReceipt.findMany({ + where: { userId: user.id }, + }); + expect(receipts).toHaveLength(1); + expect(receipts[0].revokedAt).not.toBeNull(); + }); + + it("is atomic: a failing purge leaves the receipt active and every row in place", async () => { + const user = await seed("purge-atomic", ["ai_insights_only"]); + const before = await snapshot(user.id); + + await expect( + withdrawConsent( + user.id, + ["ai_insights_only"], + new Date(), + async (tx, id) => { + // Do part of the work, then fail: the part must roll back too. + await purgeRegenerableAiText(tx, id); + throw new Error("purge failed"); + }, + ), + ).rejects.toThrow("purge failed"); + + const receipt = await getPrismaClient().consentReceipt.findFirstOrThrow({ + where: { userId: user.id }, + }); + expect(receipt.revokedAt).toBeNull(); + expect(await snapshot(user.id)).toEqual(before); + }); + + it("deletes nothing while another receipt still covers the analysis", async () => { + const user = await seed("purge-covered", ["ai_full", "ai_insights_only"]); + const before = await snapshot(user.id); + + const result = await withdrawConsent(user.id, ["ai_full"]); + + expect(result.purged).toBeNull(); + expect(await snapshot(user.id)).toEqual(before); + }); + + it("deletes nothing for a withdrawal of the Coach or extraction consent alone", async () => { + const user = await seed("purge-other", ["ai_coach", "ai_extraction"]); + const before = await snapshot(user.id); + + const result = await withdrawConsent(user.id, [ + "ai_coach", + "ai_extraction", + ]); + + expect(result.revoked).toHaveLength(2); + expect(result.purged).toBeNull(); + expect(await snapshot(user.id)).toEqual(before); + }); + + it("touches no other account", async () => { + const user = await seed("purge-me", ["ai_full"]); + const other = await seed("purge-other-account", ["ai_full"]); + const otherBefore = await snapshot(other.id); + + await withdrawConsent(user.id, ["ai_full"]); + + expect(await snapshot(other.id)).toEqual(otherBefore); + }); +}); + +describe("a revoked extraction receipt wins over the auto-read toggle", () => { + it("refuses external document egress once the receipt is revoked, toggle still on", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "k1-toggle", + email: "k1-toggle@example.test", + documentsAutoAiRead: true, + }, + }); + await prisma.consentReceipt.create({ + data: { + userId: user.id, + kind: "ai_extraction", + artefact: "{}", + signedAt: new Date(), + }, + }); + const { assertDocumentEgressConsent, ConsentRequiredError } = + await import("@/lib/ai/consent-guard"); + + await expect( + assertDocumentEgressConsent({ userId: user.id, providerType: "codex" }), + ).resolves.toBeUndefined(); + + await withdrawConsent(user.id, ["ai_extraction"]); + + await expect( + assertDocumentEgressConsent({ userId: user.id, providerType: "codex" }), + ).rejects.toBeInstanceOf(ConsentRequiredError); + // A local pick never leaves the machine and stays admitted. + await expect( + assertDocumentEgressConsent({ userId: user.id, providerType: "local" }), + ).resolves.toBeUndefined(); + }); +}); From ce72fe226edc3c21de4e9c7ecd2980b73de9947f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:29:00 +0200 Subject: [PATCH 5/5] refactor(ai): re-check every chokepoint through the one egress helper The status chokepoint, the comprehensive briefing, reaction lines and AI-composed nudges now ask `aiEgressRefusal` about exactly the chain they are about to send to, instead of each pairing the operator-key check with a surface receipt lookup of its own. The consent kinds come from the capability table, so the wire and the published capability answer from the same rule. The document summary, its catch-up pass and the index backfill stop on a closed switch or module before anything is picked, and leave "no provider" and "consent needed" to the document pick, which re-checks the wire for the provider it chose. A closed switch still only heals PENDING to NONE; a withheld pick records UNAVAILABLE. The per-pick document consent helper is removed, since nothing calls it any more. The auto-read toggle mints the shared `AI_EXTRACTION_CONSENT_KIND`, and the web grant helper goes back to minting the master grant only. Refs #975 --- docs/api/openapi.yaml | 40 ++++-- .../ai-consent-enforcement-guard.test.ts | 4 +- .../ai-egress-capability-guard.test.ts | 10 +- .../auth/me/documents-auto-ai-read/route.ts | 3 +- src/lib/ai/__tests__/consent-guard.test.ts | 124 ++--------------- src/lib/ai/consent-guard.ts | 84 ++--------- src/lib/consent/__tests__/web-grant.test.ts | 46 +------ src/lib/consent/web-grant.ts | 52 ++----- src/lib/documents/document-settings.ts | 7 +- ...ensive-briefing-grounding-disposal.test.ts | 5 + .../comprehensive-briefing-reroll.test.ts | 5 + .../comprehensive-budget-refusal.test.ts | 44 ++++-- .../comprehensive-generate-force.test.ts | 5 + .../comprehensive-json-retry.test.ts | 5 + .../comprehensive-privacy-flip.test.ts | 5 + .../comprehensive-timeout-honored.test.ts | 5 + .../comprehensive-warm-timeout.test.ts | 5 + .../__tests__/status-provider-budget.test.ts | 45 +++++- src/lib/insights/comprehensive-generate.ts | 30 ++-- src/lib/insights/status-provider.ts | 50 +++---- .../__tests__/async-state-contract.test.ts | 73 ++++++---- .../__tests__/coach-nudge-ai-consent.test.ts | 56 ++++++-- .../document-content-index-backfill.test.ts | 130 ++++++++++-------- .../document-summary-catchup.test.ts | 27 +++- .../jobs/__tests__/document-summary.test.ts | 108 ++++++++++----- .../reaction-line-degradation.test.ts | 27 ++-- src/lib/jobs/coach-nudge-ai.ts | 32 +++-- .../jobs/document-content-index-backfill.ts | 87 +++++------- src/lib/jobs/document-summary-catchup.ts | 11 +- src/lib/jobs/document-summary.ts | 103 +++++++------- src/lib/jobs/reaction-line.ts | 19 ++- src/lib/openapi/routes/profile.ts | 2 +- .../consent-withdrawal-purge.test.ts | 16 +-- 33 files changed, 628 insertions(+), 637 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ccd1197d7..c21ebb4e7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12772,6 +12772,7 @@ paths: - ai_full - ai_insights_only - ai_coach + - ai_extraction description: Narrow to one consent kind. Omit for the full keyspace. responses: "200": @@ -12783,8 +12784,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: @@ -12807,9 +12809,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 @@ -12820,6 +12831,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": @@ -23990,8 +24002,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: @@ -26149,6 +26161,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction artefact: type: string minLength: 1 @@ -58163,6 +58176,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction signedAt: type: string revokedAt: @@ -58259,6 +58273,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction receipt: anyOf: - $ref: "#/components/schemas/SerialisedConsentReceipt" @@ -58281,6 +58296,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction signedAt: type: string format: date-time @@ -58337,13 +58353,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: @@ -58392,6 +58413,7 @@ components: - ai_full - ai_insights_only - ai_coach + - ai_extraction receipt: $ref: "#/components/schemas/SerialisedConsentReceipt" required: 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 index a8c7acce9..123c56808 100644 --- a/src/__tests__/ai-egress-capability-guard.test.ts +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -29,9 +29,13 @@ const SRC = join(process.cwd(), "src"); const EGRESS_CALL = /\.generateCompletion(?:Stream)?\s*\(|\brunRawCompletionWithFallback\s*\(|\brunWithFallback\s*\(/; -/** A capability read in the same file. */ +/** + * 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)\s*\(/; + /\b(?:aiCapabilityForJob|aiCapabilityForRecord|requireAiCapability|getAiCapability|aiEgressRefusal|assertAiEgress)\s*\(/; /** Comments name the helpers too; only code counts. */ function stripComments(source: string): string { @@ -71,7 +75,7 @@ const EGRESS_SITES: Record = { "lib/jobs/reaction-line.ts": { recheck: true, reason: - "Reaction lines call the first chain entry directly; the worker resolves `reactionLines` before the digest or the chain.", + "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, 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/lib/ai/__tests__/consent-guard.test.ts b/src/lib/ai/__tests__/consent-guard.test.ts index 939856a24..57fcf0495 100644 --- a/src/lib/ai/__tests__/consent-guard.test.ts +++ b/src/lib/ai/__tests__/consent-guard.test.ts @@ -1,15 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/consent/receipts", () => ({ - AI_EXTRACTION_CONSENT_KIND: "ai_extraction", latestActiveReceipt: vi.fn(), })); import { ConsentRequiredError, assertConsentForChain, - assertDocumentEgressConsent, - assertExtractionConsentForChain, chainRequiresServerManagedConsent, hasActiveConsentForSurface, isExternalDocumentEgress, @@ -200,73 +197,14 @@ describe("isExternalDocumentEgress", () => { }); }); -describe("assertDocumentEgressConsent", () => { - beforeEach(() => { - mockedLatest.mockReset(); - }); - - it("never reads consent and never throws for a LOCAL document pick", async () => { - grant([]); - await expect( - assertDocumentEgressConsent({ userId: "u1", providerType: "local" }), - ).resolves.toBeUndefined(); - expect(mockedLatest).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" }), - ).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 }), - ).rejects.toBeInstanceOf(ConsentRequiredError); - } - }); - - it("proceeds for an external pick with an active extraction receipt", async () => { - grant(["ai_extraction"]); - await expect( - assertDocumentEgressConsent({ userId: "u1", providerType: "codex" }), - ).resolves.toBeUndefined(); - }); - - it("proceeds for an external pick with the master ai_full receipt", async () => { - grant(["ai_full"]); - await expect( - assertDocumentEgressConsent({ userId: "u1", providerType: "openai" }), - ).resolves.toBeUndefined(); - }); - - // K1: the analysis and the Coach consents no longer cover documents. - it("refuses an external pick when only the analysis or Coach consent is active", async () => { - for (const kinds of [ - ["ai_insights_only"], - ["ai_coach"], - ] as ConsentKind[][]) { - grant(kinds); - await expect( - assertDocumentEgressConsent({ userId: "u1", providerType: "codex" }), - ).rejects.toBeInstanceOf(ConsentRequiredError); - } - }); - - it("names the extraction group on the refusal", async () => { - grant([]); - await expect( - assertDocumentEgressConsent({ userId: "u1", providerType: "codex" }), - ).rejects.toMatchObject({ surface: "extraction" }); - }); -}); - -describe("extraction consent does not satisfy the other groups", () => { - it("an ai_extraction receipt alone satisfies neither the Coach nor the analysis", async () => { +// 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, @@ -274,51 +212,5 @@ describe("extraction consent does not satisfy the other groups", () => { await expect(hasActiveConsentForSurface("u1", "insights")).resolves.toBe( false, ); - await expect(hasActiveConsentForSurface("u1", "extraction")).resolves.toBe( - true, - ); - }); -}); - -describe("assertExtractionConsentForChain (lab report scans)", () => { - beforeEach(() => mockedLatest.mockReset()); - - it("is a no-op for a chain of local entries only", async () => { - grant([]); - await expect( - assertExtractionConsentForChain({ - userId: "u1", - chain: [entry("local")], - }), - ).resolves.toBeUndefined(); - expect(mockedLatest).not.toHaveBeenCalled(); - }); - - it("requires an extraction receipt for a BYOK chain, unlike the self-snapshot rule", async () => { - grant([]); - await expect( - assertExtractionConsentForChain({ - userId: "u1", - chain: [entry("local"), entry("anthropic")], - }), - ).rejects.toBeInstanceOf(ConsentRequiredError); - // The self-snapshot rule would have admitted the same chain. - await expect( - assertConsentForChain({ - userId: "u1", - chain: [entry("anthropic")], - surface: "insights", - }), - ).resolves.toBeUndefined(); - }); - - it("admits the chain with an extraction receipt", async () => { - grant(["ai_extraction"]); - await expect( - assertExtractionConsentForChain({ - userId: "u1", - chain: [entry("openai")], - }), - ).resolves.toBeUndefined(); }); }); diff --git a/src/lib/ai/consent-guard.ts b/src/lib/ai/consent-guard.ts index bd243b2cc..7774e836d 100644 --- a/src/lib/ai/consent-guard.ts +++ b/src/lib/ai/consent-guard.ts @@ -27,26 +27,17 @@ * failure and we must not race that decision. A receipt of the surface's * mapped kind OR the superset `ai_full` grant satisfies the gate. */ -import { - AI_EXTRACTION_CONSENT_KIND, - latestActiveReceipt, -} from "@/lib/consent/receipts"; +import { latestActiveReceipt } from "@/lib/consent/receipts"; import type { ConsentKind } from "@/lib/validations/consent"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; /** - * The consent groups that gate PHI egress. Each maps to the consent kind - * collected for it; `ai_full` (the master grant) satisfies every one. - * - * - `coach`: the Coach (`ai_coach`). - * - `insights`: model-written analysis over the person's own readings - * (`ai_insights_only`). - * - `extraction`: reading a document, a lab report scan or typed medication - * text (`ai_extraction`, the narrower grant the document auto-read toggle - * mints). It no longer satisfies the Coach or the analysis, and neither of - * those satisfies it. + * 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" | "extraction"; +export type ConsentSurface = "coach" | "insights"; /** * Provider tags that egress via an operator-managed credential the user did not @@ -95,14 +86,9 @@ export function chainRequiresServerManagedConsent( /** The consent kinds that satisfy a given surface (the specific + the master). */ function acceptableKinds(surface: ConsentSurface): ConsentKind[] { - switch (surface) { - case "coach": - return ["ai_coach", "ai_full"]; - case "insights": - return ["ai_insights_only", "ai_full"]; - case "extraction": - return [AI_EXTRACTION_CONSENT_KIND, "ai_full"]; - } + return surface === "coach" + ? ["ai_coach", "ai_full"] + : ["ai_insights_only", "ai_full"]; } /** @@ -156,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"]); @@ -168,49 +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 extraction receipt (`ai_extraction`, or the master `ai_full`) is on - * file. - * - * The document auto-read toggle is NOT a shortcut here any more. Switching it - * on mints an `ai_extraction` receipt, and that receipt is what this reads, so - * a receipt the person later revokes wins over a toggle still left on: the - * withdrawal is honoured at the next egress, not at the next toggle flip. - * - * Call this AFTER the document provider is picked and BEFORE the first - * `generateCompletion` on it. - */ -export async function assertDocumentEgressConsent(args: { - userId: string; - providerType: string; -}): Promise { - if (!isExternalDocumentEgress(args.providerType)) return; - if (await hasActiveConsentForSurface(args.userId, "extraction")) return; - throw new ConsentRequiredError("extraction"); -} - -/** - * The document rule for a provider CHAIN (lab report scans, which cascade - * across the chain rather than calling one picked provider). A lab report is a - * document, so any entry that would send it off the machine needs an active - * extraction receipt, whoever holds the credential; a chain of local entries - * only is ungated. Fails closed like `assertConsentForChain`: an external - * entry anywhere in the chain requires the receipt, because the runner may - * cascade to it. - */ -export async function assertExtractionConsentForChain(args: { - userId: string; - chain: ReadonlyArray; -}): Promise { - if ( - !args.chain.some((entry) => isExternalDocumentEgress(entry.providerType)) - ) { - return; - } - if (await hasActiveConsentForSurface(args.userId, "extraction")) return; - throw new ConsentRequiredError("extraction"); -} diff --git a/src/lib/consent/__tests__/web-grant.test.ts b/src/lib/consent/__tests__/web-grant.test.ts index e9650c7fc..285bc8984 100644 --- a/src/lib/consent/__tests__/web-grant.test.ts +++ b/src/lib/consent/__tests__/web-grant.test.ts @@ -21,10 +21,7 @@ vi.mock("@/lib/db", () => { }); import { prisma } from "@/lib/db"; -import { - ensureExtractionConsentReceipt, - ensureWebAiConsentReceipt, -} from "../web-grant"; +import { ensureWebAiConsentReceipt } from "../web-grant"; const $transaction = vi.mocked(prisma.$transaction) as unknown as { mockImplementation: (impl: (fn: TxFn) => unknown) => void; @@ -249,44 +246,3 @@ describe("ensureWebAiConsentReceipt — a revocation is a standing decision", () expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); }); }); - -describe("ensureExtractionConsentReceipt", () => { - it("mints an ai_extraction receipt, never the master grant", async () => { - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); - vi.mocked(prisma.consentReceipt.create).mockResolvedValue( - row({ kind: "ai_extraction" }) as never, - ); - - const result = await ensureExtractionConsentReceipt("user-1"); - - expect(result.minted).toBe(true); - const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0]; - expect(createArg.data.kind).toBe("ai_extraction"); - expect(JSON.parse(createArg.data.artefact as string).kind).toBe( - "ai_extraction", - ); - for (const [args] of vi.mocked(prisma.consentReceipt.findFirst).mock - .calls) { - expect((args as { where: { kind: string } }).where.kind).toBe( - "ai_extraction", - ); - } - }); - - it("supersedes an earlier revocation of the same kind (it is the person's own act)", async () => { - vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); - vi.mocked(prisma.consentReceipt.create).mockResolvedValue( - row({ kind: "ai_extraction" }) as never, - ); - - await ensureExtractionConsentReceipt("user-1"); - - // An affirmative mint never looks for a past revocation. - for (const [args] of vi.mocked(prisma.consentReceipt.findFirst).mock - .calls) { - expect( - (args as { where: { revokedAt: unknown } }).where.revokedAt, - ).toBeNull(); - } - }); -}); diff --git a/src/lib/consent/web-grant.ts b/src/lib/consent/web-grant.ts index 292230e9b..2a122207d 100644 --- a/src/lib/consent/web-grant.ts +++ b/src/lib/consent/web-grant.ts @@ -35,10 +35,7 @@ import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; import { isP2002 } from "@/lib/prisma-errors"; -import { - AI_EXTRACTION_CONSENT_KIND, - latestActiveReceipt, -} from "@/lib/consent/receipts"; +import { latestActiveReceipt } from "@/lib/consent/receipts"; import type { ConsentReceipt } from "@/lib/consent/receipts"; /** @@ -66,55 +63,24 @@ export type WebConsentGrantResult = */ export type WebConsentGrantIntent = "heal" | "affirmative"; -/** The receipt kinds the web mints: the master grant, or extraction alone. */ -type WebConsentKind = "ai_full" | typeof AI_EXTRACTION_CONSENT_KIND; - /** * Ensure the web user has an active `ai_full` consent receipt, minting one * if absent. Idempotent: returns `{ minted: false }` when an active master * grant already exists, so callers can invoke it freely on mount. * * Mirrors the iOS `ai_full` grant. `ai_full` is the master kind that - * satisfies every surface gate (insights + coach + extraction), matching the - * iOS app's single master toggle rather than per-surface web grants. + * satisfies every surface gate (insights + coach), matching the iOS app's + * single master toggle rather than per-surface web grants. */ export async function ensureWebAiConsentReceipt( userId: string, intent: WebConsentGrantIntent, now: Date = new Date(), -): Promise { - return ensureWebConsentReceipt(userId, "ai_full", intent, now); -} - -/** - * Ensure an active `ai_extraction` receipt: the consent to read documents, - * lab report scans and typed medication text with an external model. Minted - * by switching the document auto-read on, which is the person's own - * affirmative act, so it supersedes an earlier revocation of the same kind. - * It does not satisfy the Coach or the analysis. - */ -export async function ensureExtractionConsentReceipt( - userId: string, - now: Date = new Date(), -): Promise { - return ensureWebConsentReceipt( - userId, - AI_EXTRACTION_CONSENT_KIND, - "affirmative", - now, - ); -} - -async function ensureWebConsentReceipt( - userId: string, - kind: WebConsentKind, - intent: WebConsentGrantIntent, - now: Date, ): Promise { // Fast path: a cheap read outside the transaction short-circuits the // common already-granted case (every mount after the first) without // opening a transaction. - const existing = await latestActiveReceipt(userId, kind); + const existing = await latestActiveReceipt(userId, "ai_full"); if (existing) return { minted: false, reason: "already_active" }; // A revoked receipt anywhere in this account's history means the user made @@ -122,7 +88,7 @@ async function ensureWebConsentReceipt( // affirmative act is the user deciding again, so it may proceed. if (intent === "heal") { const revoked = await prisma.consentReceipt.findFirst({ - where: { userId, kind, revokedAt: { not: null } }, + where: { userId, kind: "ai_full", revokedAt: { not: null } }, select: { id: true }, }); if (revoked) return { minted: false, reason: "previously_revoked" }; @@ -130,7 +96,7 @@ async function ensureWebConsentReceipt( const artefact = JSON.stringify({ source: "web", - kind, + kind: "ai_full", grantedAt: now.toISOString(), note: "In-app affirmative AI consent granted via the web client.", }); @@ -146,7 +112,7 @@ async function ensureWebConsentReceipt( return await prisma.$transaction( async (tx) => { const inTx = await tx.consentReceipt.findFirst({ - where: { userId, kind, revokedAt: null }, + where: { userId, kind: "ai_full", revokedAt: null }, orderBy: { createdAt: "desc" }, }); if (inTx) return { minted: false, reason: "already_active" } as const; @@ -156,7 +122,7 @@ async function ensureWebConsentReceipt( // mint would overwrite a decision made a moment earlier. if (intent === "heal") { const revokedInTx = await tx.consentReceipt.findFirst({ - where: { userId, kind, revokedAt: { not: null } }, + where: { userId, kind: "ai_full", revokedAt: { not: null } }, select: { id: true }, }); if (revokedInTx) @@ -164,7 +130,7 @@ async function ensureWebConsentReceipt( } const receipt = await tx.consentReceipt.create({ - data: { userId, kind, artefact, signedAt: now }, + data: { userId, kind: "ai_full", artefact, signedAt: now }, }); return { minted: true, receipt } as const; }, 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/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts index 39a6ffeb1..ba835d180 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts @@ -42,6 +42,11 @@ vi.mock("@/lib/db", () => ({ })); // 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, diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index cf4c94793..4bcad810e 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -42,6 +42,11 @@ vi.mock("@/lib/db", () => ({ })); // 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, diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts index 1a50d530f..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: { @@ -113,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" } }); @@ -148,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" }, @@ -224,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 180a8a612..eb101bd99 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -50,6 +50,11 @@ 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), })); diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index a3f42c45a..791557886 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -40,6 +40,11 @@ vi.mock("@/lib/db", () => ({ })); // 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, diff --git a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts index 4b70cab43..92607581e 100644 --- a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts +++ b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts @@ -39,6 +39,11 @@ vi.mock("@/lib/db", () => ({ })); // 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, diff --git a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts index 5d9fdbcee..7cc7486a0 100644 --- a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts +++ b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts @@ -44,6 +44,11 @@ vi.mock("@/lib/db", () => ({ })); // 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, diff --git a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts index 15304c0d1..e6f58c204 100644 --- a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts +++ b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts @@ -46,6 +46,11 @@ vi.mock("@/lib/db", () => ({ })); // 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, diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index 05b44961b..b1bbad4f5 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -85,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: {} }]; @@ -129,6 +130,7 @@ beforeEach(() => { reason: null, onDeviceAllowed: true, }); + aiEgressRefusal.mockResolvedValue(null); }); describe("runStatusCompletion — capability at the wire", () => { @@ -152,6 +154,37 @@ describe("runStatusCompletion — capability at the wire", () => { 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); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 2d89f3cdb..cc29fcfa8 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -79,10 +79,7 @@ import { runBriefingCompletion, } from "@/lib/insights/briefing-provider"; import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; -import { - chainRequiresServerManagedConsent, - hasActiveConsentForSurface, -} from "@/lib/ai/consent-guard"; +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { stripJsonFences, @@ -690,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/status-provider.ts b/src/lib/insights/status-provider.ts index 0d7185b4c..bb0342bca 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -12,11 +12,7 @@ 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, @@ -93,16 +89,11 @@ interface RunStatusCompletionArgs { * 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 the server-managed chain needs. + * consent kinds an operator-held chain entry needs. */ capability: AiCapabilityKey; } -/** The consent surface a capability's server-managed egress is gated on. */ -function consentSurfaceFor(capability: AiCapabilityKey): ConsentSurface { - return capability === "coach" ? "coach" : "insights"; -} - /** * Resolve the provider chain for `userId`, falling back to the legacy * single provider. Returns `null` when the user has no usable provider @@ -162,25 +153,28 @@ export async function runStatusCompletion( return { kind: "none", reason: "no_provider" }; } - // 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, - consentSurfaceFor(args.capability), - )) - ) { + // 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, capability: args.capability }, + action: { name: "insights.status.capability_unavailable" }, + meta: { + cacheAction, + capability: args.capability, + reason: refusal.reason, + at: "wire", + }, }); - return { kind: "none", reason: "consent_required" }; + return { kind: "none", reason: refusal.reason }; } // Honour the per-user response-timeout setting the operator dials in for a diff --git a/src/lib/jobs/__tests__/async-state-contract.test.ts b/src/lib/jobs/__tests__/async-state-contract.test.ts index 505fe6caf..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(), @@ -108,10 +102,7 @@ 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, @@ -129,6 +120,7 @@ const DOC = { const PICK = { chain: [{ providerType: "anthropic", instance: {} }], + withheld: null, pick: { entry: { providerType: "anthropic", instance: {} }, providerType: "anthropic", @@ -179,7 +171,6 @@ beforeEach(() => { }); } 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, @@ -220,18 +211,27 @@ describe("contract (a): every runDocumentSummaryJob branch writes a terminal sta state: "NONE", }, { - name: "documentAi unavailable for want of a provider", + // 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: "documentAi unavailable for want of a consent receipt", + // 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(aiCapabilityForJob).mockResolvedValue({ available: false, @@ -239,7 +239,7 @@ describe("contract (a): every runDocumentSummaryJob branch writes a terminal sta onDeviceAllowed: true, }); }, - state: "UNAVAILABLE", + state: "READY", }, { name: "documentAi switched off by the operator", @@ -258,16 +258,19 @@ describe("contract (a): every runDocumentSummaryJob branch writes a terminal sta vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], pick: null, + withheld: null, } as never); }, state: "UNAVAILABLE", }, { - name: "consent refused", + name: "consent refused at the pick's wire re-check", setup: () => { - vi.mocked(assertDocumentEgressConsent).mockRejectedValue( - new ConsentRequiredError("insights"), - ); + vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ + chain: PICK.chain, + pick: null, + withheld: new AiUnavailableError("documentAi", "consent_required"), + } as never); }, state: "UNAVAILABLE", }, @@ -357,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, @@ -367,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, @@ -452,7 +457,25 @@ describe("contract (b): enqueue never claims a pending state its job cannot reso expect(summaryStateWrites()).toEqual([]); }); - it("enqueueDocumentSummary with documentAi unavailable claims nothing and mints no job", async () => { + 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", @@ -462,9 +485,9 @@ describe("contract (b): enqueue never claims a pending state its job cannot reso 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([]); + expect(result).toEqual({ enqueued: true }); + expect(send).toHaveBeenCalledTimes(1); + expect(summaryStateWrites()).toContain("PENDING"); }); it("enqueueDocumentSummary claims PENDING only once a job id exists", async () => { 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 430f0faed..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,7 +50,12 @@ 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. @@ -55,6 +70,7 @@ import { createNudgeAiTickBudget, type ComposeNudgeParams, } from "../coach-nudge-ai"; +import { annotate } from "@/lib/logging/context"; function makeProvider() { return { @@ -85,7 +101,7 @@ function params(): ComposeNudgeParams { beforeEach(() => { vi.clearAllMocks(); provider = makeProvider(); - latestActiveReceipt.mockResolvedValue(null); + activeKinds.current = []; aiCapabilityForJob.mockResolvedValue({ available: true, reason: null, @@ -138,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()); @@ -155,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 }, @@ -170,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 () => { 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 420b58a3a..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,15 +41,6 @@ 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), - // Mirrors the real rule: only the self-hosted `local` provider stays on the - // machine. - isExternalDocumentEgress: vi.fn( - (providerType: string) => providerType !== "local", - ), - ConsentRequiredError: class ConsentRequiredError extends Error {}, -})); vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForJob: vi.fn(), aiCapabilityForRecord: vi.fn(), @@ -69,10 +61,7 @@ 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 { @@ -82,6 +71,7 @@ import { const PICK = { chain: [{ providerType: "anthropic", instance: {} }], + withheld: null, pick: { entry: { providerType: "anthropic", instance: {} }, providerType: "anthropic", @@ -151,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"); @@ -164,30 +155,43 @@ 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"], - ["no_provider", "no-provider"], ] as const)( - "refuses an external pick before any document is read when documentAi is unavailable (%s → %s)", + "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, @@ -206,9 +210,9 @@ describe("runContentIndexBackfillForUser", () => { retokenised: 0, skipped: 0, failed: 0, - reason: summaryReason, + reason: "unavailable", }); - expect(assertDocumentEgressConsent).not.toHaveBeenCalled(); + expect(resolveDocumentVisionProvider).not.toHaveBeenCalled(); expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled(); expect(reserveBudget).not.toHaveBeenCalled(); expect(transcribeDocument).not.toHaveBeenCalled(); @@ -216,30 +220,37 @@ describe("runContentIndexBackfillForUser", () => { }, ); - it("does not ask the documentAi capability for a local pick", async () => { - vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ - chain: [{ providerType: "local", instance: {} }], - pick: { - entry: { providerType: "local", instance: {} }, - providerType: "local", - pdfSupported: false, - }, - } as never); - vi.mocked(aiCapabilityForJob).mockResolvedValue({ - available: false, - reason: "consent_required", - onDeviceAllowed: true, - }); - vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ - { id: "d1" }, - ] as never); + 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"); + const result = await runContentIndexBackfillForUser("user-1"); - expect(aiCapabilityForJob).not.toHaveBeenCalled(); - expect(result.reason).toBe("ok"); - expect(result.indexed).toBe(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); @@ -268,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", @@ -310,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", @@ -365,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([ @@ -409,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([ @@ -434,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 8c7af8749..9a803b316 100644 --- a/src/lib/jobs/__tests__/document-summary-catchup.test.ts +++ b/src/lib/jobs/__tests__/document-summary-catchup.test.ts @@ -122,7 +122,7 @@ describe("runSummaryCatchUpForUser", () => { expect(findMany).not.toHaveBeenCalled(); }); - it.each(["operator_disabled", "no_provider", "consent_required"] as const)( + it.each(["operator_disabled", "module_disabled", "user_disabled"] as const)( "does nothing when the documentAi capability is unavailable (%s)", async (reason) => { serveDocuments(5); @@ -146,6 +146,31 @@ describe("runSummaryCatchUpForUser", () => { }, ); + 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 f1d0837e7..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(), @@ -75,7 +68,6 @@ 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, @@ -106,6 +98,7 @@ const PICK = { providerType: "anthropic", pdfSupported: true, }, + withheld: null, }; function visionOk() { @@ -133,7 +126,6 @@ beforeEach(() => { 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({ @@ -168,12 +160,8 @@ describe("runDocumentSummaryJob — gating", () => { expect(runDocumentSummary).toHaveBeenCalledTimes(1); expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "documentAi"); - // The egress consent is re-asserted for the picked provider; the auto-read - // toggle is not a consent and no `surface` rides along any more. - expect(assertDocumentEgressConsent).toHaveBeenCalledWith({ - userId: "user-1", - providerType: "anthropic", - }); + // 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.", ); @@ -227,15 +215,9 @@ describe("runDocumentSummaryJob — gating", () => { expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled(); }); - it.each([ - ["no_provider", "UNAVAILABLE"], - ["consent_required", "UNAVAILABLE"], - ["operator_disabled", "NONE"], - ["module_disabled", "NONE"], - ["check_failed", "NONE"], - ] as const)( - "refuses before any provider is picked when documentAi is unavailable (%s → %s)", - async (reason, state) => { + 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, @@ -245,29 +227,71 @@ describe("runDocumentSummaryJob — gating", () => { await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); expect(resolveDocumentVisionProvider).not.toHaveBeenCalled(); - expect(assertDocumentEgressConsent).not.toHaveBeenCalled(); expect(loadOwnedDocument).not.toHaveBeenCalled(); expect(reserveBudget).not.toHaveBeenCalled(); expect(runDocumentSummary).not.toHaveBeenCalled(); - // An attempt that could not run is recorded as UNAVAILABLE; a switch - // turned off only heals the PENDING this job was meant to resolve, so an - // earlier UNAVAILABLE / WITHHELD is never overwritten. + // A switch turned off only heals the PENDING this job was meant to + // resolve, so an earlier UNAVAILABLE / WITHHELD is never overwritten. expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({ where: { id: "doc-1", userId: "user-1", deletedAt: null, - summaryState: state === "NONE" ? "PENDING" : { not: "READY" }, + summaryState: "PENDING", }, - data: { summaryState: state }, + data: { summaryState: "NONE" }, }); expect(annotate).toHaveBeenCalledWith({ action: { name: "documents.summary.autoSkipped" }, - meta: { documentId: "doc-1", reason }, + 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); @@ -280,6 +304,7 @@ describe("runDocumentSummaryJob — gating", () => { vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({ chain: [], pick: null, + withheld: null, } as never); await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" }); @@ -398,8 +423,8 @@ describe("enqueueDocumentSummary — the pending contract", () => { ); }); - it.each(["no_provider", "consent_required", "operator_disabled"] as const)( - "does not enqueue and does not claim PENDING when documentAi is unavailable (%s)", + 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, @@ -421,6 +446,23 @@ describe("enqueueDocumentSummary — the pending contract", () => { }, ); + 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__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index 8782179eb..051f2b0a2 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -73,13 +73,11 @@ 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(); @@ -106,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", @@ -202,8 +201,7 @@ beforeEach(async () => { reason: null, onDeviceAllowed: true, }); - chainRequiresServerManagedConsent.mockReturnValue(false); - hasActiveConsentForSurface.mockResolvedValue(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 @@ -512,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/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index 95b786789..ed3c3a374 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -36,10 +36,7 @@ import type { CoachNudgeTrigger } from "@/lib/jobs/coach-nudge"; import type { Locale } from "@/lib/i18n/config"; import { resolveProviderChain } from "@/lib/ai/provider"; import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; -import { - chainRequiresServerManagedConsent, - hasActiveConsentForSurface, -} from "@/lib/ai/consent-guard"; +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; import { buildDateKey, reconcileSpend, @@ -189,16 +186,23 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => { const chain = await resolveProviderChain(params.userId); if (chain.length === 0) return null; - // Consent gate — before the budget reservation, so a user without a - // receipt never spends a slot or a token. Skip-shaped like every other - // guard here: no receipt → return null and the caller ships the - // deterministic template, so the nudge itself is never lost. BYOK / local - // / ChatGPT-OAuth chains are the user's own egress and stay ungated. - if ( - chainRequiresServerManagedConsent(chain) && - !(await hasActiveConsentForSurface(params.userId, "coach")) - ) { - annotate({ action: { name: "coach.nudge.ai.consent_required" } }); + // 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/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index a7c1368da..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, @@ -31,11 +32,7 @@ */ import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; -import { - assertDocumentEgressConsent, - isExternalDocumentEgress, - ConsentRequiredError, -} from "@/lib/ai/consent-guard"; +import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types"; import { buildDateKey, reconcileSpend, @@ -167,55 +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", }; - // An external pick is document AI and needs the `documentAi` capability - // (the operator's switches, the vault module, the extraction consent). A - // local pick keeps the document on the machine and indexes as before. - if (isExternalDocumentEgress(pick.providerType)) { - const capability = await aiCapabilityForJob(userId, "documentAi"); - if (!capability.available) { - annotate({ - action: { name: "documents.contentIndex.backfillSkipped" }, - meta: { reason: capability.reason }, - }); - return { - indexed: 0, - retokenised: stale.retokenised, - skipped: 0, - failed: stale.failed, - reason: - capability.reason === "consent_required" - ? "no-consent" - : capability.reason === "no_provider" - ? "no-provider" - : "unavailable", - }; - } } - try { - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - }); - } catch (err) { - if (err instanceof ConsentRequiredError) { - return { - indexed: 0, - retokenised: stale.retokenised, - skipped: 0, - failed: stale.failed, - reason: "no-consent", - }; - } - throw err; + + // 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", + }; } // 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 7a16ffe33..65ca18df6 100644 --- a/src/lib/jobs/document-summary-catchup.ts +++ b/src/lib/jobs/document-summary-catchup.ts @@ -34,6 +34,7 @@ */ 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"; @@ -94,10 +95,14 @@ export async function runSummaryCatchUpForUser( } // The `documentAi` capability, once for the whole pass, before any document - // is listed: a switch off, no provider or no extraction consent means there - // is nothing the per-document jobs could do. + // 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.available) { + if ( + capability.reason !== null && + !PICK_DECIDED_REASONS.has(capability.reason) + ) { annotate({ action: { name: "documents.autoRead.catchUpSkipped" }, meta: { reason: capability.reason }, diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index 55940bbe3..feec890ba 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -26,10 +26,7 @@ import { aiCapabilityForJob, aiCapabilityForRecord, } from "@/lib/ai/capabilities/gate"; -import { - assertDocumentEgressConsent, - ConsentRequiredError, -} from "@/lib/ai/consent-guard"; +import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types"; import { buildDateKey, reconcileSpend, @@ -140,68 +137,58 @@ export async function runDocumentSummaryJob( } // The `documentAi` capability before a provider is picked or the document is - // decrypted: the operator's switches (the master and "Reading documents"), - // the vault module, provider presence and the extraction consent. The - // auto-read toggle above is the trigger, not the consent. + // 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.available) { - if ( - capability.reason === "no_provider" || - capability.reason === "consent_required" - ) { - // An attempt that could not run, recorded as such. - await markSummaryState(userId, documentId, "UNAVAILABLE"); - } else { - // A switch turned off means this job was never to run at all. Only the - // PENDING this job was meant to resolve goes back to NONE; a recorded - // UNAVAILABLE or WITHHELD is an earlier real attempt and stays. - await prisma.inboundDocument.updateMany({ - where: { - id: documentId, - userId, - deletedAt: null, - summaryState: "PENDING", - }, - data: { summaryState: "NONE" }, - }); - } + 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: capability.reason }, + meta: { + documentId, + reason: "unavailable", + capability_reason: capability.reason, + }, }); return; } - // Resolve the DOCUMENT-order vision provider (local-first, codex last). No - // vision-capable provider configured → graceful no-op (unlike the index job, - // there is no local text-layer fallback for a descriptive summary). - const { pick } = await resolveDocumentVisionProvider(userId); + // 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: "no-provider" }, - }); - return; - } - - // Re-assert egress consent for the picked provider. A local pick is ungated; - // an external pick needs an active extraction receipt (`ai_extraction` or - // `ai_full`); the auto-read toggle mints one but is not one. A consent race - // (a revoke mid-flight) resolves to a no-op, never an egress. - try { - await assertDocumentEgressConsent({ - userId, - providerType: pick.providerType, - }); - } catch (err) { 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; @@ -355,11 +342,15 @@ export async function enqueueDocumentSummary( }); return { enqueued: false }; } - // Nothing is queued that the capability already refuses (a switch off, - // no provider, no extraction consent): the document stays NONE and the - // view offers the manual action, exactly as with the opt-in off. + // 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.available) { + if ( + capability.reason !== null && + !PICK_DECIDED_REASONS.has(capability.reason) + ) { annotate({ action: { name: "documents.summary.autoSkipped" }, meta: { documentId, reason: capability.reason }, diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index 35a4a6493..a036f0025 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -33,10 +33,7 @@ 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 { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { buildDateKey, @@ -490,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/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/tests/integration/consent-withdrawal-purge.test.ts b/tests/integration/consent-withdrawal-purge.test.ts index 4823be731..e80e75790 100644 --- a/tests/integration/consent-withdrawal-purge.test.ts +++ b/tests/integration/consent-withdrawal-purge.test.ts @@ -307,21 +307,15 @@ describe("a revoked extraction receipt wins over the auto-read toggle", () => { signedAt: new Date(), }, }); - const { assertDocumentEgressConsent, ConsentRequiredError } = - await import("@/lib/ai/consent-guard"); + const { aiEgressRefusal } = await import("@/lib/ai/capabilities/egress"); - await expect( - assertDocumentEgressConsent({ userId: user.id, providerType: "codex" }), - ).resolves.toBeUndefined(); + expect(await aiEgressRefusal("documentAi", user.id, ["codex"])).toBeNull(); await withdrawConsent(user.id, ["ai_extraction"]); - await expect( - assertDocumentEgressConsent({ userId: user.id, providerType: "codex" }), - ).rejects.toBeInstanceOf(ConsentRequiredError); + const refusal = await aiEgressRefusal("documentAi", user.id, ["codex"]); + expect(refusal?.reason).toBe("consent_required"); // A local pick never leaves the machine and stays admitted. - await expect( - assertDocumentEgressConsent({ userId: user.id, providerType: "local" }), - ).resolves.toBeUndefined(); + expect(await aiEgressRefusal("documentAi", user.id, ["local"])).toBeNull(); }); });