mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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
This commit is contained in:
1 parent
f158f01f18
commit
ce72fe226e
33 files changed
+628
-637
No files matched your search
+31
-9
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, EgressSite> = {
|
||||
"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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+14
-70
@@ -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<string> = new Set(["local"]);
|
||||
|
||||
@@ -168,49 +158,3 @@ const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet<string> = 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<void> {
|
||||
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<ProviderChainResolved>;
|
||||
}): Promise<void> {
|
||||
if (
|
||||
!args.chain.some((entry) => isExternalDocumentEgress(entry.providerType))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (await hasActiveConsentForSurface(args.userId, "extraction")) return;
|
||||
throw new ConsentRequiredError("extraction");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<WebConsentGrantResult> {
|
||||
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<WebConsentGrantResult> {
|
||||
return ensureWebConsentReceipt(
|
||||
userId,
|
||||
AI_EXTRACTION_CONSENT_KIND,
|
||||
"affirmative",
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureWebConsentReceipt(
|
||||
userId: string,
|
||||
kind: WebConsentKind,
|
||||
intent: WebConsentGrantIntent,
|
||||
now: Date,
|
||||
): Promise<WebConsentGrantResult> {
|
||||
// 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;
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user