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] 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({