Merge release/v1.39.0 into feat/ai-routes-insights-coach

Brings in the jobs, stored-text and consent package and the extraction
routes. Conflicts resolved:

- The digest keeps its capability-driven reaction line and check-in,
  now through the shared `aiCapabilityForRecord`; the local copy of it
  is removed.
- The route inventories classify the extraction routes (documents,
  labs OCR, medication text, fenced chat and attachments) as actions;
  a capability call counts as the gate for the module inventory.
- With every caller moved, `requireAssistantSurface` and
  `AssistantDisabledError` are deleted. The `assistant.disabled.*`
  codes stay on the capability refusal, and the guard now asserts the
  retired gate is gone.
- `aiCapabilityToServe` answers whether stored model text may be shown
  from the record's own state, apart from who is reading.

Refs #975
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 10:16:53 +02:00
commit c509afeb2e
256 files changed
+13048 -4642

No files matched your search

@@ -45,13 +45,18 @@ function code(path: string): string {
}
/** Whitespace-tolerant: a call split across lines still matches. */
const GATE_CALL = /\b(requireAiCapability|getAiCapability)\s*\(\s*"(\w+)"/g;
const ANY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/;
const GATE_CALL =
/\b(?:(requireAiCapability|getAiCapability)\s*\(\s*"(\w+)"|(aiCapabilityToServe)\s*\([^,()]*,\s*"(\w+)")/g;
const ANY_GATE =
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
const REQUIRE_GATE = /\brequireAiCapability\s*\(/;
const RETIRED_GATE = /\brequireAssistantSurface\s*\(/;
function gateCalls(text: string): Array<{ fn: string; key: string }> {
return [...text.matchAll(GATE_CALL)].map((m) => ({ fn: m[1], key: m[2] }));
return [...text.matchAll(GATE_CALL)].map((m) => ({
fn: m[1] ?? m[3],
key: m[2] ?? m[4],
}));
}
function routeFiles(): string[] {
@@ -81,6 +86,9 @@ describe("AI capability route inventory", () => {
{ fn: "requireAiCapability", key: "coach" },
]);
expect(ANY_GATE.test("getAiCapability (\n")).toBe(true);
expect(
gateCalls('aiCapabilityToServe(\n user.id,\n "statusText",\n )'),
).toEqual([{ fn: "aiCapabilityToServe", key: "statusText" }]);
});
it("finds the routes it is meant to judge", () => {
+71 -12
View File
@@ -11,9 +11,11 @@
*
* - `action` routes call `requireAiCapability(key)` and refuse with the
* capability envelope;
* - `mixed` reads call `getAiCapability(key)`, answer 200, null the model
* text and say why in `ai`. They never refuse for an AI reason, so they
* never call `requireAiCapability`.
* - `mixed` reads call `getAiCapability(key)` (the viewer's view, for work
* they would start) or `aiCapabilityToServe(recordId, key)` (the record's
* own view, for stored model text they would show), answer 200, null the
* model text and say why in `ai`. They never refuse for an AI reason, so
* they never call `requireAiCapability`.
*
* Read by two guards: `ai-capability-route-inventory.test.ts` (the whole API
* tree) and `insights/__tests__/coach-route-gate-inventory.test.ts` (every
@@ -29,6 +31,67 @@ export interface AiRouteEntry {
}
export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
// Reading documents, lab scans and medication text.
"src/app/api/documents/inbound/[id]/chat/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "POST asks a model about one document; the history GET asks nothing.",
},
"src/app/api/documents/inbound/[id]/extract/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Reads a document's text with a model.",
},
"src/app/api/documents/inbound/[id]/index/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Indexes a document's content through a provider.",
},
"src/app/api/documents/inbound/[id]/suggest/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Suggests a title and category from the document with a model.",
},
"src/app/api/documents/inbound/[id]/summary/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Summarises a document with a model.",
},
"src/app/api/documents/inbound/reindex/route.ts": {
kind: "action",
capabilities: ["documentAi"],
why: "Re-indexes the vault through a provider.",
},
"src/app/api/documents/inbound/route.ts": {
kind: "mixed",
capabilities: ["documentAi"],
why: "The upload is data and always accepted; only the AI work queued after it follows the capability.",
},
"src/app/api/auth/me/documents-auto-ai-read/route.ts": {
kind: "mixed",
capabilities: ["documentAi"],
why: "The preference is stored whatever AI says; the catch-up it queues follows the capability.",
},
"src/app/api/insights/chat/fenced/route.ts": {
kind: "action",
capabilities: ["coach", "documentAi"],
why: "Document chat inside a Coach conversation.",
},
"src/app/api/insights/chat/[id]/attachments/route.ts": {
kind: "action",
capabilities: ["coach", "documentAi"],
why: "Attaching a document to a Coach conversation for the model to read.",
},
"src/app/api/labs/ocr/extract/route.ts": {
kind: "action",
capabilities: ["labsOcr"],
why: "Reads a lab report image with a model.",
},
"src/app/api/medications/extract/route.ts": {
kind: "action",
capabilities: ["medicationExtract"],
why: "Turns typed medication text into a schedule with a model.",
},
"src/app/api/insights/chat/route.ts": {
kind: "action",
capabilities: ["coach"],
@@ -189,23 +252,19 @@ export const DATA_ROUTES: Readonly<Record<string, string>> = {
"src/app/api/coach/reminders/[id]/route.ts": "One stored Coach reminder.",
"src/app/api/coach/reminder-suggestions/route.ts":
"Deterministic reminder suggestions.",
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts":
"Detaching a document: removal of one's own data.",
"src/app/api/coach/suggested-actions/route.ts":
"Confirms a proposed action from a closed allowlist; no model call.",
};
/**
* Routes in the AI trees whose AI gating another change owns. Each still has
* Routes in the AI trees whose AI gating another change still owns. Each has
* to be classified; the entry names who moves it. An entry that starts
* calling a capability gate fails the guard until it moves to `AI_ROUTES`.
* Empty: every route in these trees is classified.
*/
export const PENDING_ROUTES: Readonly<Record<string, string>> = {
"src/app/api/insights/chat/fenced/route.ts":
"Document chat inside a conversation: moves to `coach` and `documentAi` with the document-extraction routes.",
"src/app/api/insights/chat/[id]/attachments/route.ts":
"Attaching a document to a conversation: moves to `coach` and `documentAi` with the document-extraction routes.",
"src/app/api/insights/chat/[id]/attachments/[documentId]/route.ts":
"Detaching a document: removal of one's own data, stays ungated once the document routes move.",
};
export const PENDING_ROUTES: Readonly<Record<string, string>> = {};
/** The trees in which every route has to be classified above. */
export const AI_ROUTE_TREES = [
@@ -42,9 +42,6 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
* module is the AI analysis opt-out and gates only AI parts;
* the data routes that happen to live under
* `/api/insights` are EXEMPT below.
* - coach → `requireAssistantSurface("coach")`, the retired gate,
* still recognised for the one route outside these trees
* that has not moved to its capability yet.
*
* 3. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a
* toggleable domain but are deliberately NOT gated, each with the
@@ -253,10 +250,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// affordance), not the row store. The extract route is read-only vision
// assistance, the commit route writes the user's own confirmed lab rows,
// and the capability probe is an infra availability check carrying no
// module data. All three are owner-scoped and AI-gated (consent / budget /
// rate); the module toggle hiding the surface does not need to wedge them.
// module data. The extract route is not listed: it names the `labsOcr` AI
// capability, which carries the labs module, so it counts as gated.
"src/app/api/labs/ocr/capability/route.ts",
"src/app/api/labs/ocr/extract/route.ts",
"src/app/api/labs/ocr/commit/route.ts",
// ── DATA LAYER (medications) ──────────────────────────────────────
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module,
@@ -277,9 +273,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// per-medication importer below: it writes intake rows and reads its own job.
"src/app/api/medications/intake/dose-history-import/route.ts",
"src/app/api/medications/intake/dose-history-import/[jobId]/status/route.ts",
// NB: `medications/extract` is NOT exempt — it gates on
// `requireAssistantSurface("coach")` (the NL-extraction is an assistant
// surface), so the inventory already counts it as a delegated gate.
// `medications/extract` is not listed: it is model work under the
// `medicationExtract` AI capability (no owning module; medications is a
// core domain for extraction), and a capability call counts as the gate.
"src/app/api/medications/[id]/route.ts",
"src/app/api/medications/[id]/api-endpoint/route.ts",
"src/app/api/medications/[id]/cadence/route.ts",
@@ -421,10 +417,10 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
const CYCLE_GATE_NEEDLE = "requireCycleEnabled(";
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
// An AI capability resolves its owning modules and the AI opt-out, so a route
// that names one is gated on them.
const AI_CAPABILITY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/;
const AI_CAPABILITY_GATE =
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
// v1.18.1 — the illness journal's thin gate wrapper. `requireIllnessEnabled`
// delegates to `requireModuleEnabled(userId, "illness")` and re-stamps the
// illness-specific errorCode, exactly mirroring how `cycle` delegates to
@@ -679,7 +675,6 @@ describe("module API route gate inventory", () => {
if (fileHasCall(text, MODULE_GATE_NEEDLE)) continue;
if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue;
if (fileHasCall(text, COACH_GATE_NEEDLE)) continue;
if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue;
if (fileHasCapabilityGate(text)) continue;
@@ -801,7 +796,6 @@ describe("module API route gate inventory", () => {
if (
fileHasCall(text, MODULE_GATE_NEEDLE) ||
fileHasCall(text, CYCLE_GATE_NEEDLE) ||
fileHasCall(text, COACH_GATE_NEEDLE) ||
fileHasCall(text, ILLNESS_GATE_NEEDLE) ||
fileHasCapabilityGate(text)
) {
@@ -43,8 +43,19 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/feature-flags", () => ({
loadAssistantSwitches: vi.fn(async () => ({
enabled: true,
coach: true,
briefing: true,
insightStatus: true,
documentAi: true,
})),
}));
import { POST } from "../route";
import { resolveProviderForTest } from "@/lib/ai/provider";
import { loadAssistantSwitches } from "@/lib/feature-flags";
beforeEach(() => {
vi.resetAllMocks();
@@ -367,6 +378,8 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => {
expect(response.status).toBe(429);
expect(generateCompletion).not.toHaveBeenCalled();
const body = (await response.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("ai.budget.exceeded");
});
it("records a successful probe on the ledger", async () => {
@@ -385,3 +398,48 @@ describe("POST /api/ai/test — daily ceiling + ledger", () => {
);
});
});
describe("POST /api/ai/test — the operator's master switch and typed refusals", () => {
it("refuses with the master's code when AI is off on the server, before any provider resolves", async () => {
vi.mocked(loadAssistantSwitches).mockResolvedValueOnce({
enabled: false,
coach: false,
briefing: false,
insightStatus: false,
documentAi: false,
});
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(403);
const body = (await response.json()) as { meta: Record<string, unknown> };
expect(body.meta).toEqual({
errorCode: "assistant.disabled.enabled",
reason: "operator_disabled",
});
expect(resolveProviderForTest).not.toHaveBeenCalled();
});
it("fails closed, without a 5xx, when the switches cannot be read", async () => {
vi.mocked(loadAssistantSwitches).mockResolvedValueOnce(null);
const response = await POST(emptyRequest() as never);
expect(response.status).toBeLessThan(500);
const body = (await response.json()) as { meta: Record<string, unknown> };
expect(body.meta).toEqual({
errorCode: "ai.unavailable",
reason: "check_failed",
});
expect(resolveProviderForTest).not.toHaveBeenCalled();
});
it("names a missing provider", async () => {
vi.mocked(resolveProviderForTest).mockResolvedValue({
type: "none",
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(422);
const body = (await response.json()) as { meta: Record<string, unknown> };
expect(body.meta).toEqual({
errorCode: "ai.provider.none",
reason: "no_provider",
});
});
});
+45 -2
View File
@@ -23,6 +23,23 @@ import {
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { aiTestOverrideSchema } from "@/lib/validations/ai-provider";
import {
AI_PROVIDER_NONE_ERROR_CODE,
AI_UNAVAILABLE_ERROR_CODE,
} from "@/lib/ai/capabilities/types";
import { loadAssistantSwitches } from "@/lib/feature-flags";
/**
* The master switch's refusal code. The connection test sends a fixed prompt
* and no health data, so it belongs to no capability; it answers to the
* operator's master switch alone, under the `assistant.disabled.<switch>`
* template with the master's own name (`enabled`) as the last segment.
*/
const MASTER_SWITCH = "enabled";
const AI_TEST_MASTER_DISABLED_CODE = `assistant.disabled.${MASTER_SWITCH}`;
/** The daily AI budget refusal's code. */
const AI_TEST_BUDGET_CODE = "ai.budget.exceeded";
/**
* Output ceiling of the probe below. Mirrors the `maxTokens` on the completion
@@ -50,6 +67,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "ai.test" } });
// With the operator's master switch off no AI call leaves this server, and
// the connection test is an AI call. A switch set that could not be read
// fails closed, like every capability does.
// (403, not the usual 503 for `ai.unavailable`: this route never answers
// 5xx, because a proxy rewrites a 5xx into an HTML page the settings form
// cannot parse. See the catch below.)
const switches = await loadAssistantSwitches();
if (switches === null) {
return apiError("AI is unavailable right now", 403, {
errorCode: AI_UNAVAILABLE_ERROR_CODE,
reason: "check_failed",
});
}
if (!switches.enabled) {
annotate({ action: { name: "ai.test.operator_disabled" } });
return apiError("AI is turned off on this server", 403, {
errorCode: AI_TEST_MASTER_DISABLED_CODE,
reason: "operator_disabled",
});
}
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
if (!rl.allowed) return apiError("Too many test requests", 429);
@@ -96,7 +134,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
if (provider.type === "none") {
return apiError("No AI provider configured", 422);
return apiError("No AI provider configured", 422, {
errorCode: AI_PROVIDER_NONE_ERROR_CODE,
reason: "no_provider",
});
}
// Meter the probe on the same daily ledger every other AI surface writes to,
@@ -123,7 +164,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
if (!reservation.allowed) {
annotate({ action: { name: "ai.test.budget_exceeded" } });
return apiError("Daily AI budget exhausted", 429);
return apiError("Daily AI budget exhausted", 429, {
errorCode: AI_TEST_BUDGET_CODE,
});
}
try {
@@ -11,7 +11,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
*/
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
consentReceipt: { findFirst: vi.fn(), create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -21,8 +24,8 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/consent/web-grant", () => ({
ensureWebAiConsentReceipt: vi.fn().mockResolvedValue(undefined),
vi.mock("@/lib/ai/capabilities/gate", () => ({
getAiCapability: vi.fn(),
}));
vi.mock("@/lib/jobs/document-summary-catchup", () => ({
enqueueSummaryCatchUp: vi.fn().mockResolvedValue({ enqueued: true }),
@@ -47,7 +50,7 @@ vi.mock("next/headers", () => ({
import { PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
const SESSION_OK = {
@@ -74,6 +77,13 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
vi.mocked(prisma.consentReceipt.create).mockResolvedValue({} as never);
vi.mocked(getAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
});
describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () => {
@@ -103,23 +113,75 @@ describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", ()
await PATCH(mkPatch(false));
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
});
it("still mints the consent receipt on the flip that schedules the pass", async () => {
// The catch-up rides the same act of consent, never around it. The mint
// is marked `affirmative` because switching the toggle on IS the consent
// act: unlike the settings-mount heal, it may lift an earlier
// withdrawal, which is the user deciding again rather than the page
// deciding for them.
it("mints the extraction receipt on the flip that schedules the pass", async () => {
// The catch-up rides the same act of consent, never around it. The toggle
// grants document reading only: `ai_extraction`, not the master `ai_full`
// that would also open the Coach and the AI analysis.
withPrevious(false);
await PATCH(mkPatch(true));
expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith(
"user-1",
"affirmative",
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1);
const data = vi.mocked(prisma.consentReceipt.create).mock.calls[0]![0]
.data as { userId: string; kind: string };
expect(data.userId).toBe("user-1");
expect(data.kind).toBe("ai_extraction");
// The capability is read after the mint, so the fresh receipt counts.
const mintOrder = vi.mocked(prisma.consentReceipt.create).mock
.invocationCallOrder[0]!;
const readOrder = vi.mocked(getAiCapability).mock.invocationCallOrder[0]!;
expect(mintOrder).toBeLessThan(readOrder);
});
it("does not mint a second receipt when one that covers document reads is active", async () => {
withPrevious(false);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
id: "r1",
} as never);
await PATCH(mkPatch(true));
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
revokedAt: null,
kind: { in: ["ai_extraction", "ai_full"] },
}),
}),
);
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
});
it("treats a concurrent mint as success", async () => {
withPrevious(false);
vi.mocked(prisma.consentReceipt.create).mockRejectedValue({
code: "P2002",
});
const res = await PATCH(mkPatch(true));
expect(res.status).toBe(200);
});
it("saves the preference but schedules no catch-up while document reading is closed", async () => {
// The operator turned document reading off: every summary the pass would
// enqueue could only be refused, so it is not enqueued at all.
withPrevious(false);
vi.mocked(getAiCapability).mockResolvedValue({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
});
const res = await PATCH(mkPatch(true));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalled();
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
});
it("persists the flag field-by-field", async () => {
@@ -10,12 +10,17 @@
* through the user's configured external provider with no per-document tap.
*
* Flipping it ON is itself the standing consent act, so the write also mints an
* append-only `ai_full` consent receipt (`ensureWebAiConsentReceipt`) — the
* durable audit record that sits alongside the runtime short-circuit the
* document consent gate reads. An OFF→ON flip additionally schedules a bounded
* catch-up over the documents already stored (`enqueueSummaryCatchUp`), because
* the summary job is enqueued at upload time and would otherwise never revisit
* a vault filled before the opt-in. Mirrors `auth/me/labs-local-ocr`: 60/min rate
* append-only `ai_extraction` consent receipt — the durable record the document
* capability reads. It covers reading documents, lab reports and medication
* text and nothing else: the Coach and the AI analysis keep asking for their
* own consent. (Before this release the toggle minted `ai_full`, which also
* opened those; receipts minted that way stay valid, because nothing tells
* them apart from a deliberate master grant.) An OFF→ON flip additionally
* schedules a bounded catch-up over the documents already stored
* (`enqueueSummaryCatchUp`), because the summary job is enqueued at upload time
* and would otherwise never revisit a vault filled before the opt-in; it is
* skipped when the `documentAi` capability is closed for the record, since
* every summary it would enqueue could only be refused. Mirrors `auth/me/labs-local-ocr`: 60/min rate
* limit, Zod `safeParse` → 422 via `returnAllZodIssues`, audit-log row,
* field-by-field write (no mass assignment). Idempotent — always returns the
* resolved next state so the client can hard-set the optimistic update.
@@ -30,8 +35,11 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
import { 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";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { documentsAutoAiReadPatchSchema } from "@/lib/validations/user-prefs";
@@ -45,6 +53,55 @@ type DocumentsAutoAiReadResponse = {
documentsAutoAiRead: boolean;
};
/**
* The receipt kind the toggle mints: the extraction grant, narrower than the
* 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_CONSENT_KIND;
/**
* Mint the extraction receipt unless one that satisfies document reads is
* already active. The toggle is an affirmative act, so an earlier revocation
* does not block it, exactly as a first grant would not be blocked. The
* partial unique index on active receipts is the backstop for two concurrent
* flips; losing that race means the other flip already minted, which is the
* outcome wanted. Returns whether a receipt was written.
*/
async function ensureExtractionReceipt(
userId: string,
now: Date = new Date(),
): Promise<boolean> {
const active = await prisma.consentReceipt.findFirst({
where: {
userId,
revokedAt: null,
kind: { in: [...AI_CAPABILITIES.documentAi.consent.kinds] },
},
select: { id: true },
});
if (active) return false;
try {
await prisma.consentReceipt.create({
data: {
userId,
kind: AUTO_READ_CONSENT_KIND,
artefact: JSON.stringify({
source: "web",
kind: AUTO_READ_CONSENT_KIND,
grantedAt: now.toISOString(),
note: "Documents read automatically with AI, turned on in the app.",
}),
signedAt: now,
},
});
return true;
} catch (err) {
if (isP2002(err)) return false;
throw err;
}
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.documentsAutoAiRead.get" } });
@@ -103,11 +160,9 @@ export const PATCH = apiHandler(async (req: Request) => {
});
// Turning the toggle ON is the standing consent act — mint an append-only
// `ai_full` receipt (idempotent) so the durable audit trail records it, in
// addition to the runtime short-circuit the document consent gate reads.
if (next) {
await ensureWebAiConsentReceipt(user.id, "affirmative");
}
// extraction receipt (idempotent) so the durable audit trail records it and
// the document capability can read it.
const consentMinted = next ? await ensureExtractionReceipt(user.id) : false;
// A genuine OFF→ON flip schedules a catch-up over the documents already in
// the vault. Without it the switch only ever applied to FUTURE uploads: the
@@ -117,7 +172,16 @@ export const PATCH = apiHandler(async (req: Request) => {
// budget gate still runs per document inside the summary job itself.
const wasEnabled = previous?.documentsAutoAiRead ?? false;
if (next && !wasEnabled) {
void enqueueSummaryCatchUp(user.id);
// Read after the mint, so a receipt this request just wrote counts.
const documentAi = await getAiCapability("documentAi");
if (documentAi.available) {
void enqueueSummaryCatchUp(user.id);
} else {
annotate({
action: { name: "auth.me.documentsAutoAiRead.catchUpSkipped" },
meta: { reason: documentAi.reason },
});
}
}
await auditLog("user.documentsAutoAiRead.update", {
@@ -126,6 +190,7 @@ export const PATCH = apiHandler(async (req: Request) => {
details: {
previous: previous?.documentsAutoAiRead ?? false,
next,
consentMinted,
},
});
@@ -43,7 +43,7 @@ vi.mock("@/lib/rate-limit", () => ({
}));
// Operator master flag = on by default; coach delegation keys off it.
// Partial mock — api-handler imports `AssistantDisabledError` from here.
// Partial mock: the rest of the module stays real.
vi.mock("@/lib/feature-flags", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/feature-flags")>();
return {
+10 -1
View File
@@ -49,6 +49,10 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { buildAvatarUrl } from "@/lib/avatar";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import {
isValidTimezone,
resolveServerDefaultTimezone,
} from "@/lib/tz/resolver";
import { isCycleEnabled } from "@/lib/cycle/gate";
import {
resolveModuleMap,
@@ -272,7 +276,12 @@ export const GET = apiHandler(async () => {
heightCm: user.heightCm,
dateOfBirth: user.dateOfBirth,
gender: user.gender,
timezone: user.timezone,
// The zone the server cuts this account's days in: the stored zone, or
// the instance default when the stored one is unusable. Clients bucket
// by this value, so it must be the resolved one, not the raw column.
timezone: isValidTimezone(user.timezone)
? user.timezone
: await resolveServerDefaultTimezone(),
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
// v1.39 (C1) — the needs-based setup flow, published additively:
@@ -1,24 +1,30 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// v1.16.16 — `createReceipt` + `revokeLatest` run inside a transaction; the
// mock runs the callback against the same proxy.
// v1.16.16 — `createReceipt` and the withdrawal run inside a transaction;
// the mock runs the callback against the same proxy. The withdrawal also
// purges the regenerable AI text, so the proxy carries those delegates too.
type TxFn = (tx: unknown) => unknown;
vi.mock("@/lib/db", () => {
const consentReceipt = {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
};
return {
prisma: {
consentReceipt,
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
const prisma = {
consentReceipt: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
count: vi.fn(),
},
insightStatusCache: { deleteMany: vi.fn() },
user: { updateMany: vi.fn() },
insightNarrative: { deleteMany: vi.fn() },
arrivalReaction: { updateMany: vi.fn() },
workoutInsight: { deleteMany: vi.fn() },
$transaction: vi.fn(),
};
prisma.$transaction.mockImplementation((fn: TxFn) => fn(prisma));
return { prisma };
});
vi.mock("@/lib/rate-limit", () => ({
@@ -78,9 +84,21 @@ beforeEach(() => {
vi.mocked(auditLog).mockResolvedValue(undefined);
// Re-arm rate-limit + transaction pass-throughs cleared by reset.
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
$transaction.mockImplementation((fn: TxFn) =>
fn({ consentReceipt: prisma.consentReceipt }),
);
$transaction.mockImplementation((fn: TxFn) => fn(prisma));
// Default: another receipt still covers the analysis, so no purge runs.
// The purge cases below set this to 0.
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never);
for (const deleteMany of [
prisma.insightStatusCache.deleteMany,
prisma.insightNarrative.deleteMany,
prisma.workoutInsight.deleteMany,
]) {
vi.mocked(deleteMany).mockResolvedValue({ count: 0 } as never);
}
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
vi.mocked(prisma.arrivalReaction.updateMany).mockResolvedValue({
count: 0,
} as never);
});
describe("GET /api/consent/ai/latest", () => {
@@ -278,14 +296,15 @@ describe("DELETE /api/consent/ai/latest", () => {
const createdAt = new Date("2026-05-18T10:00:01.000Z");
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
// v1.16.16 — `revokeLatest` runs per kind in `consentKindEnum.options`
// order: ai_full → ai_insights_only → ai_coach. The `updateMany` count
// gates the follow-up re-read. ai_full + ai_coach revoke a row;
// ai_insights_only has none (count 0, no re-read).
// The withdrawal runs per kind in `consentKindEnum.options` order:
// ai_full → ai_insights_only → ai_coach → ai_extraction. The
// `updateMany` count gates the follow-up re-read. ai_full + ai_coach
// revoke a row; the others have none (count 0, no re-read).
vi.mocked(prisma.consentReceipt.updateMany)
.mockResolvedValueOnce({ count: 1 } as never) // ai_full
.mockResolvedValueOnce({ count: 0 } as never) // ai_insights_only
.mockResolvedValueOnce({ count: 1 } as never); // ai_coach
.mockResolvedValueOnce({ count: 1 } as never) // ai_coach
.mockResolvedValueOnce({ count: 0 } as never); // ai_extraction
vi.mocked(prisma.consentReceipt.findFirst)
.mockResolvedValueOnce({
id: "rcpt-full",
@@ -323,6 +342,80 @@ describe("DELETE /api/consent/ai/latest", () => {
});
});
describe("DELETE /api/consent/ai/latest — purge of regenerable AI text", () => {
const revokedRow = {
id: "rcpt-1",
userId: "user-1",
kind: "ai_insights_only",
artefact: "PDF",
signedAt: new Date("2026-05-18T10:00:00.000Z"),
revokedAt: new Date("2026-05-18T11:00:00.000Z"),
createdAt: new Date("2026-05-18T10:00:01.000Z"),
};
it("deletes the regenerable text and writes a counts-only purge audit row when nothing covers the analysis any more", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
revokedRow as never,
);
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(0 as never);
vi.mocked(prisma.insightStatusCache.deleteMany).mockResolvedValue({
count: 4,
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/consent/ai/latest?kind=ai_insights_only",
{ method: "DELETE" },
),
);
expect(res.status).toBe(200);
expect(prisma.insightStatusCache.deleteMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
});
expect(prisma.workoutInsight.deleteMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
});
expect(auditLog).toHaveBeenCalledWith("consent.ai.purge", {
userId: "user-1",
details: {
statusNotes: 4,
briefing: 0,
narratives: 0,
reactionLines: 0,
workoutParagraphs: 0,
},
});
});
it("keeps the text while another receipt still covers the analysis", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
revokedRow as never,
);
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/consent/ai/latest?kind=ai_insights_only",
{ method: "DELETE" },
),
);
expect(res.status).toBe(200);
expect(prisma.insightStatusCache.deleteMany).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalledWith(
"consent.ai.purge",
expect.anything(),
);
});
});
describe("append-only invariant", () => {
/**
* v1.4.40 SB-10 — the receipts table is the legal audit trail.
+43 -38
View File
@@ -13,6 +13,11 @@
* DELETE /api/consent/ai/latest
* Master toggle — revokes the latest active receipt across every
* consent kind. Returns the list of revoked rows.
*
* Either DELETE also deletes the regenerable model-written text (status
* notes, the cached briefing, model-written narratives, reaction lines,
* workout paragraphs) when it leaves no receipt covering the analysis, in the
* same transaction as the revoke. Coach memory and document summaries stay.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -28,8 +33,8 @@ import {
import {
latestActiveReceipt,
latestActiveReceiptsByKind,
revokeLatest,
} from "@/lib/consent/receipts";
import { withdrawConsent } from "@/lib/consent/withdrawal";
import { serialiseReceipt } from "../route";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -81,6 +86,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
? serialiseReceipt(byKind.ai_insights_only)
: null,
ai_coach: byKind.ai_coach ? serialiseReceipt(byKind.ai_coach) : null,
ai_extraction: byKind.ai_extraction
? serialiseReceipt(byKind.ai_extraction)
: null,
};
annotate({
@@ -119,53 +127,50 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const { kind } = parsed.data;
if (kind) {
const revoked = await revokeLatest(user.id, kind);
if (!revoked) {
// Idempotent: "no active receipt to revoke" is success-shaped.
// The iOS toggle hammers this on every flip; returning 404
// would surface as a spurious error in the client.
annotate({
action: { name: "consent.ai.revoke" },
meta: { kind, revoked: false },
});
return apiSuccess({ kind, receipt: null });
}
// One kind, or every kind for the master "turn AI off" toggle. The revoke
// and the purge of the regenerable text the consent covered commit
// together (`withdrawConsent`).
const kinds = kind ? [kind] : consentKindEnum.options;
const { revoked, purged } = await withdrawConsent(user.id, kinds);
for (const { kind: k, receipt } of revoked) {
auditLog("consent.ai.revoke", {
userId: user.id,
details: { kind, receiptId: revoked.id },
details: { kind: k, receiptId: receipt.id },
}).catch(() => {});
annotate({
action: { name: "consent.ai.revoke" },
meta: { kind, receiptId: revoked.id },
});
return apiSuccess({ kind, receipt: serialiseReceipt(revoked) });
}
// No kind specified — master "AI deaktivieren" toggle. Revoke the
// latest active row for every kind that currently has one.
const revokedRows: Array<{
kind: ConsentKind;
receipt: ReturnType<typeof serialiseReceipt>;
}> = [];
for (const k of consentKindEnum.options) {
const r = await revokeLatest(user.id, k);
if (r) {
revokedRows.push({ kind: k, receipt: serialiseReceipt(r) });
auditLog("consent.ai.revoke", {
userId: user.id,
details: { kind: k, receiptId: r.id },
}).catch(() => {});
}
if (purged) {
// Counts only, never content.
auditLog("consent.ai.purge", {
userId: user.id,
details: { ...purged },
}).catch(() => {});
}
annotate({
action: { name: "consent.ai.revoke" },
meta: {
kinds_revoked: revokedRows.map((r) => r.kind),
count: revokedRows.length,
kinds_revoked: revoked.map((r) => r.kind),
count: revoked.length,
purged: purged !== null,
},
});
return apiSuccess({ revoked: revokedRows });
if (kind) {
// Idempotent: "no active receipt to revoke" is success-shaped. The iOS
// toggle hammers this on every flip; returning 404 would surface as a
// spurious error in the client.
const receipt = revoked[0]?.receipt ?? null;
return apiSuccess({
kind,
receipt: receipt ? serialiseReceipt(receipt) : null,
});
}
return apiSuccess({
revoked: revoked.map((r) => ({
kind: r.kind,
receipt: serialiseReceipt(r.receipt),
})),
});
});
+8
View File
@@ -180,6 +180,14 @@ async function postPeriod(request: NextRequest): Promise<Response> {
...(txResult.moved.priorCycleId
? { priorCycleId: txResult.moved.priorCycleId }
: {}),
// A later start folded into this one (the same period, recorded in
// the other order): name the row that stopped existing and its date.
...(txResult.moved.absorbedCycleId
? {
absorbedCycleId: txResult.moved.absorbedCycleId,
absorbedStartDate: txResult.moved.absorbedStartDate,
}
: {}),
...overwriteDetails({
before: {
priorEndDate: txResult.moved.priorEndDateBefore,
@@ -44,8 +44,8 @@ vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({
}));
// The per-read briefing capability; its own behaviour is pinned in
// `snapshot-read-briefing-capability.test.ts`.
vi.mock("@/lib/ai/capabilities/record", () => ({
aiCapabilityForRecord: async () => ({
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
@@ -34,17 +34,13 @@ vi.mock("@/lib/documents/content-index", () => ({
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentTextProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {
errorCode = "consent.ai.required" as const;
surface: string;
constructor(surface: string) {
super("consent required");
this.surface = surface;
this.name = "ConsentRequiredError";
}
},
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/ai/capabilities/egress", () => ({
assertAiEgress: vi.fn(),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-07"),
@@ -94,10 +90,9 @@ import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { loadDocumentChatText } from "@/lib/documents/content-index";
import { resolveDocumentTextProvider } from "@/lib/documents/provider-order";
import {
assertDocumentEgressConsent,
ConsentRequiredError,
} from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import { reserveBudget } from "@/lib/ai/coach/budget";
import { runStreamingRawCompletionWithFallback } from "@/lib/ai/provider-runner";
import {
@@ -162,7 +157,7 @@ beforeEach(() => {
allowed: true,
reserved: 600,
} as never);
vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined);
vi.mocked(assertAiEgress).mockResolvedValue(undefined);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-1",
@@ -413,9 +408,44 @@ describe("POST — numeric grounding", () => {
});
describe("POST — consent / budget gating", () => {
it("403s when document egress consent is missing", async () => {
vi.mocked(assertDocumentEgressConsent).mockRejectedValue(
new ConsentRequiredError("insights"),
it("403s when the pick needs a consent receipt the record does not hold", async () => {
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
chain: [],
pick: null,
withheld: new AiUnavailableError("documentAi", "consent_required"),
} as never);
const res = await POST(
req("doc-1", { message: "hi" }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta).toEqual({
errorCode: "consent.ai.required",
capability: "documentAi",
reason: "consent_required",
});
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
expect(appendMessage).not.toHaveBeenCalled();
});
it("answers under documentAi alone: the vault's chat asks nothing of the Coach", async () => {
await frames(
await POST(
req("doc-1", { message: "hi" }) as never,
ctx("doc-1") as never,
),
);
expect(requireAiCapability).toHaveBeenCalledTimes(1);
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
pickDecides: true,
});
expect(assertAiEgress).not.toHaveBeenCalled();
});
it("refuses before loading anything when document reading is off", async () => {
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("documentAi", "operator_disabled"),
);
const res = await POST(
req("doc-1", { message: "hi" }) as never,
@@ -423,7 +453,8 @@ describe("POST — consent / budget gating", () => {
);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta?.errorCode ?? body.errorCode).toBe("consent.ai.required");
expect(body.meta.errorCode).toBe("assistant.disabled.documentAi");
expect(loadDocumentChatText).not.toHaveBeenCalled();
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
});
@@ -23,6 +23,7 @@
*/
import { type NextRequest } from "next/server";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
@@ -76,6 +77,11 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(userId, "inboundDocuments");
if (!gate.enabled) return gate.response;
// A turn sends the document's text to a model. The provider and the
// consent receipt are answered by the pick, for the provider actually used.
// Reading the history (GET) is the person's own data and stays open.
await requireAiCapability("documentAi", { pickDecides: true });
const { id } = await params;
const document = await loadOwnedDocument(userId, id);
if (!document) {
@@ -213,6 +219,7 @@ export const POST = apiHandler(
contractLocale,
locale,
signal: request.signal,
alsoRequires: [],
});
},
);
@@ -56,14 +56,14 @@ vi.mock("@/lib/labs/ocr-upload", () => ({
detectOcrMimeType: vi.fn(() => "image/png"),
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
resolveDocumentTextProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", async () =>
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
);
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-06-27"),
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
@@ -117,6 +117,8 @@ import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
const tx = (
prisma as unknown as {
@@ -323,3 +325,39 @@ describe("POST /api/documents/inbound/[id]/extract — stored mode", () => {
expect(runInboundExtraction).not.toHaveBeenCalled();
});
});
describe("POST /api/documents/inbound/[id]/extract — the documentAi capability", () => {
it("refuses with the operator's code before the document is loaded", async () => {
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("documentAi", "operator_disabled"),
);
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta).toEqual({
errorCode: "assistant.disabled.documentAi",
capability: "documentAi",
reason: "operator_disabled",
});
expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled();
expect(resolveDocumentVisionProvider).not.toHaveBeenCalled();
expect(runInboundExtraction).not.toHaveBeenCalled();
});
it("stops at the wire when the picked provider needs a receipt, before any slot is charged", async () => {
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
chain: [],
pick: null,
withheld: new AiUnavailableError("documentAi", "consent_required"),
} as never);
vi.mocked(loadDocumentChatText).mockResolvedValue({
text: "Glucose 95 mg/dL",
} as never);
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta.errorCode).toBe("consent.ai.required");
expect(checkRateLimit).not.toHaveBeenCalled();
expect(runInboundExtraction).not.toHaveBeenCalled();
});
});
@@ -4,8 +4,9 @@
* Extraction is no longer part of upload: a document is stored first
* (provider-free), and THIS route is the explicit, user-triggered enhancement.
* It carries the entire ingest gauntlet that upload used to run inline —
* resolve document provider (local-first, codex last) → assertDocumentEgressConsent
* → rate-limit → reserveBudget → runInboundExtraction → reconcileSpend → stage
* requireAiCapability("documentAi") → resolve document provider (local-first,
* codex last) and re-check the capability for that pick, consent receipt
* included → rate-limit → reserveBudget → runInboundExtraction → reconcileSpend → stage
* facts — but now against a row that already exists. Absent a provider this 422s
* the ENHANCEMENT only; the stored document is untouched and remains filed.
*
@@ -36,7 +37,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -58,8 +59,8 @@ import {
serialiseDocumentDetail,
} from "@/lib/documents/store";
import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
requireDocumentTextProvider,
requireDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import {
checkDocumentAiRateLimit,
@@ -139,6 +140,10 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
// Reading a document is model work. The provider and the consent receipt
// are answered by the pick below, for the provider actually used.
await requireAiCapability("documentAi", { pickDecides: true });
const { id } = await params;
const document = await prisma.inboundDocument.findFirst({
where: { id, userId: user.id, deletedAt: null },
@@ -220,18 +225,7 @@ async function handleTextExtract(
});
}
const { pick } = await resolveDocumentTextProvider(userId);
if (!pick) {
return apiError("No AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentTextProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -337,18 +331,7 @@ async function handleStoredExtract(
});
}
const { pick } = await resolveDocumentTextProvider(userId);
if (!pick) {
return apiError("No AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentTextProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -423,18 +406,7 @@ async function handleVisionExtract(
userId: string,
document: LoadedDocument,
): Promise<Response> {
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentVisionProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -35,18 +35,19 @@ vi.mock("@/lib/documents/describe", () => ({
vi.mock("@/lib/documents/content-index", () => ({
upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 7 }),
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
}));
vi.mock("@/lib/documents/provider-order", async () =>
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
);
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/documents/auto-stage-labs", () => ({
maybeAutoStageLabFacts: vi
.fn()
.mockResolvedValue({ staged: false, reason: "not-lab" }),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-07"),
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
@@ -32,7 +32,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -57,7 +57,7 @@ import {
DocumentDescribeError,
transcribeDocument,
} from "@/lib/documents/describe";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
@@ -183,17 +183,12 @@ async function handleVisionIndex(
userId: string,
document: LoadedDocument,
): Promise<Response> {
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
// Transcribing the stored original is model work; indexing text the browser
// already read (the text mode above) is not, and stays open with AI off so
// search keeps working. The provider and the consent receipt are answered
// by the pick, for the provider actually used.
await requireAiCapability("documentAi", { pickDecides: true });
const pick = await requireDocumentVisionProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -27,14 +27,14 @@ vi.mock("@/lib/documents/assist", () => ({
runDocumentAssist: vi.fn(),
DocumentAssistError: class DocumentAssistError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
resolveDocumentTextProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", async () =>
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
);
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-07"),
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
@@ -24,7 +24,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -48,8 +48,8 @@ import {
type LoadedDocument,
} from "@/lib/documents/ai-route-support";
import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
requireDocumentTextProvider,
requireDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -67,6 +67,10 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
// Reading a document is model work. The provider and the consent receipt
// are answered by the pick below, for the provider actually used.
await requireAiCapability("documentAi", { pickDecides: true });
const { id } = await params;
const document = await loadOwnedDocument(user.id, id);
if (!document) {
@@ -125,18 +129,7 @@ async function handleTextSuggest(
});
}
const { pick } = await resolveDocumentTextProvider(userId);
if (!pick) {
return apiError("No AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentTextProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -218,18 +211,7 @@ async function handleVisionSuggest(
userId: string,
document: LoadedDocument,
): Promise<Response> {
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentVisionProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -40,14 +40,14 @@ vi.mock("@/lib/documents/describe", () => ({
documentSummaryBlockedCopy: () => "The summary was withheld.",
DocumentDescribeError: class DocumentDescribeError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
resolveDocumentTextProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
}));
vi.mock("@/lib/documents/provider-order", async () =>
(await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
);
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-07"),
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
@@ -31,7 +31,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -59,8 +59,8 @@ import {
import { encryptDocumentSummary } from "@/lib/documents/store";
import type { OutboundReason } from "@/lib/ai/safety/outbound-screen";
import {
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
requireDocumentTextProvider,
requireDocumentVisionProvider,
} from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -203,6 +203,10 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
// Reading a document is model work. The provider and the consent receipt
// are answered by the pick below, for the provider actually used.
await requireAiCapability("documentAi", { pickDecides: true });
const { id } = await params;
const document = await loadOwnedDocument(user.id, id);
if (!document) {
@@ -321,19 +325,9 @@ async function handleTextSummary(
return finishSummary(request, userId, document.id, "text", mode, result);
}
const { pick } = await resolveDocumentTextProvider(userId);
if (!pick) {
await refundDocumentAiSlot(userId);
return apiError("No AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
let pick: Awaited<ReturnType<typeof requireDocumentTextProvider>>;
try {
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
pick = await requireDocumentTextProvider(userId);
} catch (err) {
// Refused before any dispatch — the slot goes back with the refusal.
await refundDocumentAiSlot(userId);
@@ -394,17 +388,7 @@ async function handleVisionSummary(
locale: Locale,
persist: SummaryPersistenceTarget | null,
): Promise<Response> {
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
const pick = await requireDocumentVisionProvider(userId);
const rl = await checkDocumentAiRateLimit(userId);
if (!rl.allowed) return documentAiRateLimited(rl);
@@ -92,6 +92,14 @@ vi.mock("@/lib/jobs/document-index", () => ({
enqueueDocumentIndex: vi.fn().mockResolvedValue({ enqueued: true }),
}));
vi.mock("@/lib/jobs/document-summary", () => ({
enqueueDocumentSummary: vi.fn().mockResolvedValue({ enqueued: true }),
}));
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/jobs/document-thumbnail", () => ({
enqueueDocumentThumbnail: vi.fn().mockResolvedValue({ enqueued: true }),
}));
@@ -129,6 +137,8 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import {
acquireDocumentUploadSlot,
DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY,
@@ -547,3 +557,27 @@ describe("GET /api/documents/inbound (list)", () => {
expect(res.status).toBe(422);
});
});
describe("POST /api/documents/inbound — AI work on upload follows documentAi", () => {
it("enqueues the background summary when document reading is open", async () => {
const res = await post(mkUpload());
expect(res.status).toBe(201);
expect(getAiCapability).toHaveBeenCalledWith("documentAi");
expect(enqueueDocumentSummary).toHaveBeenCalledWith("user-1", "doc-1");
});
it("stores the document and indexes it, but enqueues no summary, with document reading closed", async () => {
vi.mocked(getAiCapability).mockResolvedValueOnce({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
});
const res = await post(mkUpload());
// The upload is data: accepted whatever AI says.
expect(res.status).toBe(201);
expect(txCreate).toHaveBeenCalled();
// The index job still runs its provider-free text-layer path.
expect(enqueueDocumentIndex).toHaveBeenCalledWith("user-1", "doc-1");
expect(enqueueDocumentSummary).not.toHaveBeenCalled();
});
});
@@ -35,6 +35,7 @@ export const GET = apiHandler(async () => {
reason: capability.reason,
pdfSupported: capability.pdfSupported,
egress: capability.egress,
aiReason: capability.ai.reason,
},
});
@@ -20,11 +20,10 @@ vi.mock("@/lib/jobs/document-content-index-backfill", () => ({
enqueueContentIndexBackfill: vi.fn(),
}));
vi.mock("@/lib/documents/provider-order", () => ({
resolveDocumentVisionProvider: vi.fn(),
requireDocumentVisionProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
ConsentRequiredError: class ConsentRequiredError extends Error {},
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
@@ -54,7 +53,9 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
const SESSION_OK = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -78,7 +79,14 @@ const req = () =>
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
vi.mocked(requireAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
vi.mocked(requireDocumentVisionProvider).mockResolvedValue(
PICK.pick as never,
);
vi.mocked(enqueueContentIndexBackfill).mockResolvedValue({ enqueued: true });
// 7 live documents, 4 already indexed → 3 remain.
vi.mocked(prisma.inboundDocument.count).mockResolvedValue(7 as never);
@@ -114,3 +122,36 @@ describe("POST /api/documents/inbound/reindex", () => {
});
});
});
describe("POST /api/documents/inbound/reindex — the documentAi capability", () => {
it("answers the capability envelope and enqueues nothing when document reading is off", async () => {
vi.mocked(requireAiCapability).mockRejectedValue(
new AiUnavailableError("documentAi", "operator_disabled"),
);
const res = await POST(req() as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta).toEqual({
errorCode: "assistant.disabled.documentAi",
capability: "documentAi",
reason: "operator_disabled",
});
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
pickDecides: true,
});
expect(enqueueContentIndexBackfill).not.toHaveBeenCalled();
});
it("keeps the vault's own code when no provider can read documents", async () => {
vi.mocked(requireDocumentVisionProvider).mockRejectedValue(
new AiUnavailableError("documentAi", "no_provider", null, {
errorCode: "documents.inbound.providerUnsupported",
}),
);
const res = await POST(req() as never);
expect(res.status).toBe(422);
const body = await res.json();
expect(body.meta.errorCode).toBe("documents.inbound.providerUnsupported");
expect(enqueueContentIndexBackfill).not.toHaveBeenCalled();
});
});
+10 -15
View File
@@ -3,17 +3,17 @@
*
* Enqueues a per-user job that indexes the caller's not-yet-indexed documents
* (one provider transcription each, bounded + resumable). Gated on the module,
* a configured vision provider, and the EXISTING AI consent (the worker
* re-checks all three). The work runs off-request on pg-boss; this route only
* the `documentAi` capability, a configured vision provider, and a document
* consent receipt for that provider (the worker re-checks all of it). The work runs off-request on pg-boss; this route only
* enqueues and returns immediately so the vault stays responsive.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill";
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
import { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -30,6 +30,10 @@ export const POST = apiHandler(async (request) => {
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
// The backfill transcribes each document through a model. The provider and
// the consent receipt are answered by the pick below.
await requireAiCapability("documentAi", { pickDecides: true });
const rl = await checkRateLimit(
`documents-reindex:${user.id}`,
REINDEX_LIMIT_PER_HOUR,
@@ -47,17 +51,8 @@ export const POST = apiHandler(async (request) => {
// Fail fast when the precondition is not met so the UI gets immediate
// feedback rather than a silently no-op'd job.
const { pick } = await resolveDocumentVisionProvider(user.id);
if (!pick) {
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
}
await assertDocumentEgressConsent({
userId: user.id,
providerType: pick.providerType,
surface: "insights",
});
// The worker re-checks all of it per run.
await requireDocumentVisionProvider(user.id);
const { enqueued: jobCreated } = await enqueueContentIndexBackfill(user.id);
+14 -2
View File
@@ -40,6 +40,7 @@ import {
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { hashQueryTokens } from "@/lib/documents/content-index";
@@ -446,10 +447,21 @@ async function processUpload(
// Summarise the freshly stored document in the background — but ONLY when the
// `documentsAutoAiRead` opt-in is ON (the job re-checks it and the egress
// consent, and no-ops otherwise). The persisted summary shows on the detail
// consent, and no-ops otherwise) and the `documentAi` capability is open for
// this record. The upload itself never depends on AI: the document is stored
// above whatever the capability says, and the index job still runs its
// provider-free text-layer path. The persisted summary shows on the detail
// view. Same fire-and-forget contract: the upload never blocks on or fails
// because of it. Only fresh inserts reach here (a duplicate returns early).
void enqueueDocumentSummary(user.id, document.id);
const documentAi = await getAiCapability("documentAi");
if (documentAi.available) {
void enqueueDocumentSummary(user.id, document.id);
} else {
annotate({
action: { name: "documents.summary.enqueueSkipped" },
meta: { documentId: document.id, reason: documentAi.reason },
});
}
const [links, visitLinks] = await Promise.all([
loadConditionLinks(user.id, [document.id]),
+5 -3
View File
@@ -14,7 +14,7 @@ import {
DOCUMENT_ACCEPTED_EXTENSIONS,
resolveDocumentLimits,
} from "@/lib/documents/upload-policy";
import { resolveOcrCapability } from "@/lib/labs/ocr-capability";
import { resolveDocumentAiCapability } from "@/lib/documents/provider-order";
import { listDistinctTargets } from "@/lib/links";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -58,8 +58,10 @@ export const GET = apiHandler(async () => {
}),
// Whether an AI action can run for this caller (assist + indexing share
// the same provider precondition). Honest availability so the UI never
// offers what the endpoint would 422.
resolveOcrCapability(user.id),
// offers what the endpoint would refuse: the document provider order and
// the `documentAi` capability for this record, the same answer the
// capability probe gives.
resolveDocumentAiCapability(user.id),
// Content-index coverage: how many live documents are indexed …
// Scope the count to LIVE documents: a soft-deleted document keeps its
// index row (cascade fires on hard purge only), so an unscoped count
@@ -59,7 +59,8 @@ function routeFiles(tree: string): string[] {
const ROUTES = AI_ROUTE_TREES.flatMap(routeFiles).sort();
const ANY_GATE = /\b(?:requireAiCapability|getAiCapability)\s*\(/;
const ANY_GATE =
/\b(?:requireAiCapability|getAiCapability|aiCapabilityToServe)\s*\(/;
function code(path: string): string {
return readFileSync(resolve(repoRoot, path), "utf8")
@@ -1,6 +1,14 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// Day-mean reads fold per-day aggregates in SQL; the fake folds the mocked
// `measurement.findMany` rows with the same rules.
vi.mock("@/lib/measurements/day-aggregates", async () => ({
readDayAggregates: (
await import("@/lib/measurements/__tests__/fake-day-aggregates")
).fakeReadDayAggregates,
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
@@ -2,14 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
const { requireAuth, requireAssistantSurface, annotate, auditLog } = vi.hoisted(
() => ({
requireAuth: vi.fn(),
requireAssistantSurface: vi.fn(),
annotate: vi.fn(),
auditLog: vi.fn(),
}),
);
const { requireAuth, annotate, auditLog } = vi.hoisted(() => ({
requireAuth: vi.fn(),
annotate: vi.fn(),
auditLog: vi.fn(),
}));
vi.mock("@/lib/api-handler", async (importOriginal) => {
const actual = await importOriginal<typeof ApiHandlerModule>();
@@ -20,7 +17,6 @@ vi.mock("@/lib/api-handler", async (importOriginal) => {
requireAuth,
};
});
vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface }));
vi.mock("@/lib/logging/context", () => ({ annotate }));
vi.mock("@/lib/auth/audit", () => ({ auditLog }));
vi.mock("@/lib/ai/coach/persistence", () => ({
@@ -65,7 +61,6 @@ async function body(response: Response) {
beforeEach(() => {
vi.clearAllMocks();
requireAuth.mockResolvedValue({ user: { id: USER_ID, locale: "en" } });
requireAssistantSurface.mockResolvedValue(undefined);
auditLog.mockResolvedValue(undefined);
});
@@ -77,7 +72,6 @@ describe("PATCH /api/insights/chat/[id]", () => {
await expect(PATCH(request({ title: "Renamed" }), context())).rejects.toBe(
unauthenticated,
);
expect(requireAssistantSurface).not.toHaveBeenCalled();
expect(renameConversation).not.toHaveBeenCalled();
});
@@ -8,6 +8,7 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
MODULE_DISABLED_ERROR_CODE: "module.disabled",
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
@@ -28,6 +29,11 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -43,6 +49,8 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { DELETE } from "../[documentId]/route";
import { getSession } from "@/lib/auth/session";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import {
attachDocument,
detachDocument,
@@ -211,3 +219,50 @@ describe("DELETE detach", () => {
expect(body.data.fenced).toBe(true);
});
});
describe("attachments — the coach and documentAi capabilities", () => {
it("refuses an attach when the Coach is off for the record", async () => {
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("coach", "user_disabled", "coach"),
);
const res = await POST(
postReq({ documentId: "doc-a" }) as never,
postCtx() as never,
);
expect(res.status).toBe(403);
expect(await code(res)).toBe("module.disabled");
expect(attachDocument).not.toHaveBeenCalled();
});
it("checks both capabilities on attach", async () => {
vi.mocked(fetchConversationAttachmentState).mockResolvedValue(null);
await POST(postReq({ documentId: "doc-a" }) as never, postCtx() as never);
expect(requireAiCapability).toHaveBeenCalledWith("coach", {
pickDecides: true,
});
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
pickDecides: true,
});
});
it("lets a detach through with every capability closed: it removes the person's own data", async () => {
vi.mocked(requireAiCapability).mockRejectedValue(
new AiUnavailableError("coach", "operator_disabled"),
);
vi.mocked(fetchConversationAttachmentState).mockResolvedValue({
id: "conv-1",
documentScoped: true,
messageCount: 0,
attachmentIds: ["doc-a"],
});
const res = await DELETE(
new NextRequest(
new URL("http://localhost/api/insights/chat/conv-1/attachments/doc-a"),
{ method: "DELETE" },
) as never,
delCtx() as never,
);
expect(res.status).not.toBe(403);
expect(requireAiCapability).not.toHaveBeenCalled();
});
});
@@ -9,6 +9,7 @@
*/
import { type NextRequest } from "next/server";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
@@ -48,6 +49,12 @@ export const POST = apiHandler(
const gate = await requireModuleEnabled(userId, "inboundDocuments");
if (!gate.enabled) return gate.response;
// Attaching prepares a fenced turn, which is Coach work over documents.
// Nothing is sent here, so the provider and the consent receipt wait for
// the turn. Detaching (DELETE) removes the person's own data and stays open.
await requireAiCapability("coach", { pickDecides: true });
await requireAiCapability("documentAi", { pickDecides: true });
const { id: conversationId } = await params;
const rl = await checkRateLimit(
@@ -10,6 +10,11 @@ import { NextRequest } from "next/server";
*/
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/ai/capabilities/gate", async () =>
(
await import("@/__tests__/helpers/provider-order-mock")
).openCapabilityGateMock(),
);
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
}));
@@ -54,6 +59,8 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import {
createConversation,
fetchConversationWithMessages,
@@ -260,3 +267,50 @@ describe("fenced route — refusal", () => {
expect(streamFencedReply).not.toHaveBeenCalled();
});
});
describe("fenced route — the coach and documentAi capabilities", () => {
it("answers under both, leaving provider and consent to the pick", async () => {
await POST(req({ message: "hi", attachmentIds: ["doc-a"] }) as never);
expect(requireAiCapability).toHaveBeenCalledWith("coach", {
pickDecides: true,
});
expect(requireAiCapability).toHaveBeenCalledWith("documentAi", {
pickDecides: true,
});
// The turn re-checks the Coach for the picked provider too.
expect(streamFencedReply).toHaveBeenCalledWith(
expect.objectContaining({ alsoRequires: ["coach"] }),
);
});
it("refuses with the Coach's code before any conversation is created", async () => {
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("coach", "operator_disabled"),
);
const res = await POST(
req({ message: "hi", attachmentIds: ["doc-a"] }) as never,
);
expect(res.status).toBe(403);
expect(await errorCode(res)).toBe("assistant.disabled.coach");
expect(createConversation).not.toHaveBeenCalled();
expect(streamFencedReply).not.toHaveBeenCalled();
});
it("refuses when document reading is off even with the Coach on", async () => {
vi.mocked(requireAiCapability)
.mockResolvedValueOnce({
available: true,
reason: null,
onDeviceAllowed: true,
})
.mockRejectedValueOnce(
new AiUnavailableError("documentAi", "operator_disabled"),
);
const res = await POST(
req({ message: "hi", attachmentIds: ["doc-a"] }) as never,
);
expect(res.status).toBe(403);
expect(await errorCode(res)).toBe("assistant.disabled.documentAi");
expect(streamFencedReply).not.toHaveBeenCalled();
});
});
+10 -1
View File
@@ -7,13 +7,15 @@
* route via `@/lib/documents/fenced-chat` so the two cannot drift.
*
* THE FENCE (see `fenced-chat.ts` for the full posture): NO tools, NO health
* snapshot; every document fenced as untrusted DATA; per-document egress consent;
* snapshot; every document fenced as untrusted DATA; the `coach` and
* `documentAi` capabilities, re-checked for the picked provider;
* numeric grounding over the LIVE attachment union only. A plain tool
* conversation 404s here (`documentScoped: true` in the fetch), so a fenced turn
* can never be appended into a tool thread's history.
*/
import { type NextRequest } from "next/server";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
@@ -56,6 +58,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
const gate = await requireModuleEnabled(userId, "inboundDocuments");
if (!gate.enabled) return gate.response;
// A fenced turn is a Coach turn that reads documents: both capabilities must
// be open. The provider and the consent receipt are answered by the pick,
// for the provider actually used (a missing provider stays an SSE frame).
await requireAiCapability("coach", { pickDecides: true });
await requireAiCapability("documentAi", { pickDecides: true });
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
@@ -224,5 +232,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
contractLocale,
locale,
signal: request.signal,
alsoRequires: ["coach"],
});
});
@@ -33,9 +33,6 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAnalyticsReadRateLimit: vi.fn(),
}));
+4 -1
View File
@@ -4,7 +4,9 @@
* Cheap probe (no provider call) the Labs UI uses to decide whether to show
* the "Scan a report" affordance. Returns `{ available, reason, pdfSupported }`
* for the calling user's configured AI provider. The surface stays dark for
* users with no provider configured and no local-OCR opt-in.
* users with no provider configured and no local-OCR opt-in, and whenever the
* `labsOcr` capability is closed (operator switch, labs module, sharing grant);
* `ai` says which.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -25,6 +27,7 @@ export const GET = apiHandler(async () => {
mode: capability.mode,
reason: capability.reason,
pdfSupported: capability.pdfSupported,
aiReason: capability.ai.reason,
},
});
@@ -27,15 +27,14 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/labs/ocr-capability", () => ({
resolveTextProvider: vi.fn(),
resolveVisionProvider: vi.fn(),
requireLabsOcrProvider: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(),
}));
vi.mock("@/lib/documents/rasterize-pdf", () => ({
rasterizePdf: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
refundRateLimit: vi.fn().mockResolvedValue(undefined),
@@ -60,10 +59,9 @@ import { POST } from "../route";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import {
resolveTextProvider,
resolveVisionProvider,
} from "@/lib/labs/ocr-capability";
import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
@@ -94,12 +92,14 @@ beforeEach(() => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
labsLocalOcrEnabled: true,
} as never);
vi.mocked(resolveTextProvider).mockResolvedValue({
chain: [{ providerType: "openai", instance: {} as never }],
pick: {
entry: { providerType: "openai", instance: {} as never },
providerType: "openai",
},
vi.mocked(requireAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
vi.mocked(requireLabsOcrProvider).mockResolvedValue({
entry: { providerType: "openai", instance: {} as never },
providerType: "openai",
} as never);
vi.mocked(reserveBudget).mockResolvedValue({
allowed: true,
@@ -187,14 +187,10 @@ describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
beforeEach(() => {
// A non-Anthropic vision provider (codex): no native PDF block, so the
// route must rasterize.
vi.mocked(resolveVisionProvider).mockResolvedValue({
chain: [{ providerType: "codex", instance: {} as never }],
localOcrEnabled: false,
pick: {
entry: { providerType: "codex", instance: {} as never },
providerType: "codex",
pdfSupported: false,
},
vi.mocked(requireLabsOcrProvider).mockResolvedValue({
entry: { providerType: "codex", instance: {} as never },
providerType: "codex",
pdfSupported: false,
} as never);
vi.mocked(reserveBudget).mockResolvedValue({
allowed: true,
@@ -334,3 +330,55 @@ describe("POST /api/labs/ocr/extract — the hourly scan bucket", () => {
expect(refundRateLimit).not.toHaveBeenCalled();
});
});
describe("POST /api/labs/ocr/extract — the labsOcr capability", () => {
it("refuses before anything is read when the operator turned document reading off", async () => {
vi.mocked(requireAiCapability).mockRejectedValue(
new AiUnavailableError("labsOcr", "operator_disabled"),
);
const error = await POST(textReq()).catch((e) => e);
expect(error).toBeInstanceOf(AiUnavailableError);
expect(error.meta.errorCode).toBe("assistant.disabled.documentAi");
expect(requireAiCapability).toHaveBeenCalledWith("labsOcr", {
pickDecides: true,
});
expect(requireLabsOcrProvider).not.toHaveBeenCalled();
expect(checkRateLimit).not.toHaveBeenCalled();
expect(runOcrExtraction).not.toHaveBeenCalled();
});
it("stops at the wire when the pick is refused, before any slot or budget is spent", async () => {
vi.mocked(requireLabsOcrProvider).mockRejectedValue(
new AiUnavailableError("labsOcr", "consent_required"),
);
const error = await POST(textReq()).catch((e) => e);
expect(error.meta).toEqual({
errorCode: "consent.ai.required",
capability: "labsOcr",
reason: "consent_required",
});
expect(requireLabsOcrProvider).toHaveBeenCalledWith("user-1", "text");
expect(checkRateLimit).not.toHaveBeenCalled();
expect(reserveBudget).not.toHaveBeenCalled();
expect(runOcrExtraction).not.toHaveBeenCalled();
});
it("asks for the vision pick on a multipart upload", async () => {
vi.mocked(requireLabsOcrProvider).mockRejectedValue(
new AiUnavailableError("labsOcr", "no_provider", null, {
errorCode: "labs.ocr.providerUnsupported",
}),
);
const form = new FormData();
form.append("file", new File([new Uint8Array([1])], "a.png"));
const error = await POST(
new Request("http://localhost/api/labs/ocr/extract", {
method: "POST",
body: form,
}),
).catch((e) => e);
expect(requireLabsOcrProvider).toHaveBeenCalledWith("user-1", "vision");
expect(error.status).toBe(422);
expect(error.meta.errorCode).toBe("labs.ocr.providerUnsupported");
});
});
+18 -35
View File
@@ -18,7 +18,10 @@
* `labsLocalOcrEnabled` preference.
*
* Guards mirror the Coach's discipline in both modes:
* requireAuth → resolve provider → assertConsentForChain → rate-limit
* requireAuth → requireAiCapability("labsOcr") → resolve provider and
* re-check the capability for that pick (`requireLabsOcrProvider`: the
* operator's switch, the labs module, and a document consent receipt for a
* pick that leaves the machine) → rate-limit
* (`LABS_OCR_LIMIT_PER_HOUR`, default 6/h) → reserveBudget → run extraction
* → reconcile budget. A slot is charged early so a 429 stays cheap, and it is
* handed back when the scan fails before the provider is called.
@@ -38,7 +41,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
buildDateKey,
reconcileSpend,
@@ -48,10 +51,7 @@ import {
} from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import {
resolveTextProvider,
resolveVisionProvider,
} from "@/lib/labs/ocr-capability";
import { requireLabsOcrProvider } from "@/lib/labs/ocr-capability";
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
import {
BodyTooLargeError,
@@ -94,6 +94,11 @@ function providerFailureMeta(error: unknown, mode?: "text") {
export const POST = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
// Reading a lab report is model work in both modes: vision sends the image,
// text mode sends what the browser read off it. The provider and the consent
// receipt are answered by the pick below, for the provider actually used.
await requireAiCapability("labsOcr", { pickDecides: true });
// Dispatch on the body shape: a JSON body is the local-OCR text mode; a
// multipart body is the native-vision image/PDF mode.
const contentType = request.headers.get("content-type") ?? "";
@@ -124,21 +129,10 @@ async function handleTextExtract(
});
}
// Any configured provider can structure the text.
const { chain, pick } = await resolveTextProvider(userId);
if (!pick) {
annotate({
action: { name: "labs.ocr.providerUnsupported" },
meta: { chainLength: chain.length, mode: "text" },
});
return apiError("No AI provider is configured", 422, {
errorCode: "labs.ocr.providerUnsupported",
});
}
// Consent gate — only the server-managed key trips this; BYOK / local / codex
// egress is the user's own act (the toggle is the consent for local OCR).
await assertConsentForChain({ userId, chain, surface: "insights" });
// Any configured provider can structure the text. The pick is re-checked
// against `labsOcr` for exactly that provider; one that leaves the machine
// needs a document consent receipt.
const pick = await requireLabsOcrProvider(userId, "text");
const rl = await checkLabsOcrRateLimit(userId);
if (!rl.allowed) {
@@ -244,20 +238,9 @@ async function handleVisionExtract(
request: Request,
userId: string,
): Promise<Response> {
// 1. Resolve a vision-capable provider. 422 when none is configured.
const { chain, pick } = await resolveVisionProvider(userId);
if (!pick) {
annotate({
action: { name: "labs.ocr.providerUnsupported" },
meta: { chainLength: chain.length },
});
return apiError("No vision-capable AI provider is configured", 422, {
errorCode: "labs.ocr.providerUnsupported",
});
}
// 2. Consent gate — server-managed egress of health data requires a receipt.
await assertConsentForChain({ userId, chain, surface: "insights" });
// 1-2. Resolve a vision-capable provider (422 when none is configured) and
// re-check `labsOcr` for it, consent receipt included.
const pick = await requireLabsOcrProvider(userId, "vision");
// 3. Rate-limit (vision calls are costly).
const rl = await checkLabsOcrRateLimit(userId);
@@ -32,9 +32,6 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.4.39.2 — the rollup branch's coverage-mismatch fallback may call
// `recomputeUserRollups` inline. Mock it so tests can assert whether
// the inline fold fired without exercising the real populator.
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/measurement-rollups")
@@ -66,7 +63,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -221,318 +218,50 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
expect(months.size).toBe(24);
});
it("source=rollup + aggregate=daily reads measurement_rollups without firing the heavy date_trunc query", async () => {
// v1.4.36 W1 — Insights trends row routes the three daily chart
// fetches through the persistent DAY buckets via `source=rollup`.
// The heavy `date_trunc` $queryRaw must NOT fire on the happy
// path where the rollup has rows for the requested window.
const buckets = Array.from({ length: 30 }, (_, i) => ({
type: "WEIGHT",
bucketStart: new Date(Date.UTC(2026, 3, 16 + i, 0, 0, 0)),
mean: 81 + i * 0.05,
count: 5,
// v1.11.1 — single source per day → collapse is identity.
source: "APPLE_HEALTH",
}));
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
buckets as never,
);
it("source=rollup + aggregate=daily folds live in the user's zone, never the UTC-day rollup (#1026)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Asia/Kolkata",
sourcePriorityJson: null,
} as never);
// The resolver caches per user; drop what earlier cases left behind.
invalidateUserTimezone("user-1");
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{
type: "ACTIVITY_STEPS",
bucket_start: new Date("2026-05-09T18:30:00.000Z"),
avg: 8123,
cnt: 96,
min_value: 0,
max_value: 400,
},
] as never);
const res = await GET(
getRequest(
"type=WEIGHT&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
"type=ACTIVITY_STEPS&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
measurements: Array<{ type: string; value: number; count: number }>;
meta: { aggregate?: string };
measurements: Array<{
measuredAt: string;
value: number;
minValue?: number;
}>;
};
};
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(json.data.measurements.length).toBe(30);
// First bucket reflects the rollup mean — round-trip-stable.
expect(json.data.measurements[0].type).toBe("WEIGHT");
expect(json.data.measurements[0].value).toBeCloseTo(81, 5);
expect(json.data.measurements[0].count).toBe(5);
expect(json.data.meta.aggregate).toBe("daily");
});
it("source=rollup cumulative path reads sum_value directly (v1.4.39 W-SUM)", async () => {
// ACTIVITY_STEPS rollup row with sum_value populated. The route
// must consume sumValue directly — NOT reconstruct from mean *
// count. Use a sum that is NOT mean × count so the test
// distinguishes the two paths.
const buckets = Array.from({ length: 3 }, (_, i) => ({
type: "ACTIVITY_STEPS",
bucketStart: new Date(Date.UTC(2026, 4, 1 + i, 0, 0, 0)),
mean: 2000, // would yield 10000 if multiplied by count=5
count: 5,
sumValue: 11000 + i * 250, // distinct from mean × count
source: "APPLE_HEALTH",
}));
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
buckets as never,
);
const res = await GET(
getRequest(
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { measurements: Array<{ value: number }> };
};
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(json.data.measurements.map((m) => m.value)).toEqual([
11000, 11250, 11500,
]);
});
it("source=rollup cumulative path falls back to mean*count when sum_value is NULL (v1.4.39 W-SUM)", async () => {
// Pre-v1.4.39 row — boot-backfill hasn't converged yet. The
// route falls back to mean × count so the chart never paints
// a hole during the convergence window.
const buckets = [
{
type: "ACTIVITY_STEPS",
bucketStart: new Date(Date.UTC(2026, 4, 1)),
mean: 2000,
count: 5,
sumValue: null,
source: "APPLE_HEALTH",
},
];
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
buckets as never,
);
const res = await GET(
getRequest(
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
const json = (await res.json()) as {
data: { measurements: Array<{ value: number }> };
};
expect(json.data.measurements[0].value).toBe(10000);
});
it("falls back to live date_trunc when source=rollup returns zero buckets", async () => {
// Empty rollup ⇒ heavy aggregate runs so brand-new accounts still
// see a correct chart on their first render.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
const liveBuckets = Array.from({ length: 7 }, (_, i) => ({
type: "WEIGHT",
bucket_start: new Date(Date.UTC(2026, 4, 8 + i, 0, 0, 0)),
avg: 81 + i * 0.05,
cnt: 2,
}));
vi.mocked(prisma.$queryRaw).mockResolvedValue(liveBuckets as never);
const res = await GET(
getRequest(
"type=WEIGHT&from=2026-05-08T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
const json = (await res.json()) as {
data: { measurements: Array<unknown> };
};
expect(json.data.measurements.length).toBe(7);
});
it("source=rollup folds the partition inline and re-reads when the rollup is under-converged (v1.4.39.2)", async () => {
// The maintainer's pre-fix production trace: 30-day BP_SYS window returned
// `total: 2` from the rollup branch even though `measurements`
// carried ~30 distinct days. The chart tripped its `< 3 daily
// points` empty-state because the route short-circuited on
// `rollupRows.length > 0` and never reconciled with the live
// table. The coverage-mismatch fallback runs an inline
// `recomputeUserRollups` for the (user, type, DAY, window)
// partition and re-reads so the request returns the converged
// rows.
const sparseRollup = [
{
type: "BLOOD_PRESSURE_SYS",
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
mean: 128,
count: 1,
sumValue: null,
source: "APPLE_HEALTH",
},
{
type: "BLOOD_PRESSURE_SYS",
bucketStart: new Date(Date.UTC(2026, 4, 10, 0, 0, 0)),
mean: 132,
count: 1,
sumValue: null,
source: "APPLE_HEALTH",
},
];
const convergedRollup = Array.from({ length: 28 }, (_, i) => ({
type: "BLOOD_PRESSURE_SYS",
bucketStart: new Date(Date.UTC(2026, 4, 1 + i, 0, 0, 0)),
mean: 128 + (i % 5),
count: 2,
sumValue: null,
source: "APPLE_HEALTH",
}));
// First read returns the sparse rows; second (post-fold) read
// returns the converged rows.
vi.mocked(prisma.measurementRollup.findMany)
.mockResolvedValueOnce(sparseRollup as never)
.mockResolvedValueOnce(convergedRollup as never);
// Coverage probe — live `measurements` has 28 distinct days.
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{ days: BigInt(28) },
] as never);
const res = await GET(
getRequest(
"type=BLOOD_PRESSURE_SYS&from=2026-04-30T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { measurements: Array<unknown> };
};
// The inline fold fired exactly once with the (type, DAY, window)
// bound so the partition write stays small.
expect(recomputeUserRollups).toHaveBeenCalledTimes(1);
expect(recomputeUserRollups).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({
types: ["BLOOD_PRESSURE_SYS"],
granularities: ["DAY"],
}),
);
// The route re-read the rollup after the fold so the response
// carries the converged 28 buckets — well above the chart's
// `< 3 daily points` empty-state threshold.
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(2);
expect(json.data.measurements.length).toBe(28);
});
it("source=rollup skips the coverage probe on a covered window (v1.4.39.2 hot-path)", async () => {
// Happy case: 30-day window, 28 rollup rows present. The route
// must short-circuit on the first read and skip the probe so the
// covered-tenant hot path stays a single indexed read.
const buckets = Array.from({ length: 28 }, (_, i) => ({
type: "WEIGHT",
bucketStart: new Date(Date.UTC(2026, 3, 16 + i, 0, 0, 0)),
mean: 81 + i * 0.05,
count: 2,
sumValue: null,
source: "APPLE_HEALTH",
}));
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
buckets as never,
);
const res = await GET(
getRequest(
"type=WEIGHT&from=2026-04-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
expect(recomputeUserRollups).not.toHaveBeenCalled();
expect(prisma.$queryRaw).not.toHaveBeenCalled();
});
it("source=rollup skips the coverage fallback on short windows (v1.4.39.2)", async () => {
// A 5-day window with 2 rollup rows is plausibly genuine sparsity
// for a user who measures BP every other day. The coverage
// fallback gates on `windowDays >= 7` so the probe + inline fold
// never fire on these short windows.
const sparseRollup = [
{
type: "BLOOD_PRESSURE_SYS",
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
mean: 128,
count: 1,
sumValue: null,
source: "APPLE_HEALTH",
},
{
type: "BLOOD_PRESSURE_SYS",
bucketStart: new Date(Date.UTC(2026, 4, 3, 0, 0, 0)),
mean: 132,
count: 1,
sumValue: null,
source: "APPLE_HEALTH",
},
];
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
sparseRollup as never,
);
const res = await GET(
getRequest(
"type=BLOOD_PRESSURE_SYS&from=2026-05-01T00:00:00Z&to=2026-05-06T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
expect(recomputeUserRollups).not.toHaveBeenCalled();
expect(prisma.$queryRaw).not.toHaveBeenCalled();
const json = (await res.json()) as {
data: { measurements: Array<unknown> };
};
expect(json.data.measurements.length).toBe(2);
});
it("source=rollup keeps the sparse rollup when live `measurements` agrees (v1.4.39.2)", async () => {
// Genuine sparsity: the user measured BP twice in 30 days. The
// probe finds the same 2 distinct days in `measurements` so the
// route returns the 2 rollup rows without firing the inline
// fold. The chart's `< 3 daily points` empty-state is the right
// surface for this case — the data really is sparse.
const sparseRollup = [
{
type: "BLOOD_PRESSURE_SYS",
bucketStart: new Date(Date.UTC(2026, 4, 1, 0, 0, 0)),
mean: 128,
count: 1,
sumValue: null,
source: "APPLE_HEALTH",
},
{
type: "BLOOD_PRESSURE_SYS",
bucketStart: new Date(Date.UTC(2026, 4, 10, 0, 0, 0)),
mean: 132,
count: 1,
sumValue: null,
source: "APPLE_HEALTH",
},
];
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
sparseRollup as never,
);
// Probe agrees — 2 distinct days in `measurements` too.
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{ days: BigInt(2) },
] as never);
const res = await GET(
getRequest(
"type=BLOOD_PRESSURE_SYS&from=2026-04-30T00:00:00Z&to=2026-05-30T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
// Probe fired (suspicious-low rollup) but the inline fold did
// not (live and rollup agree).
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
expect(recomputeUserRollups).not.toHaveBeenCalled();
const json = (await res.json()) as {
data: { measurements: Array<unknown> };
};
expect(json.data.measurements.length).toBe(2);
expect(prisma.measurementRollup.findMany).not.toHaveBeenCalled();
// The zone travels as a bound parameter of the fold.
const call = vi.mocked(prisma.$queryRaw).mock.calls[0] as unknown[];
expect(call.slice(1)).toContain("Asia/Kolkata");
// Local midnight of 10 May in Kolkata; a SUM carries no spread band.
expect(json.data.measurements[0]).toMatchObject({
measuredAt: "2026-05-09T18:30:00.000Z",
value: 8123,
});
expect(json.data.measurements[0].minValue).toBeUndefined();
});
it("returns a weekly series when the all-time window is under two years", async () => {
+27 -115
View File
@@ -45,20 +45,21 @@ import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { runSafetyFloorCheck } from "@/lib/illness/safety-floor-check";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { readDailySeries } from "@/lib/measurements/daily-series-read";
import {
readDailySeries,
readLiveBuckets,
} from "@/lib/measurements/daily-series-read";
import {
reconstructSleepSessions,
pickMainNightAndNaps,
} from "@/lib/analytics/sleep-night";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
import { NextRequest } from "next/server";
import type {
MeasurementType,
MeasurementSource,
GlucoseContext,
} from "@/generated/prisma/client";
import { Prisma } from "@/generated/prisma/client";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
@@ -393,36 +394,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
// The route's response is bounded by the `BUCKET_CAP` ceiling per
// grain (monthly: 24, weekly: 105, daily: 365) so a multi-decade
// account never paints an unbounded series.
// v1.4.36 W1 — rollup-table read for daily aggregates.
//
// The Insights trends row fires three parallel
// `GET /api/measurements?type=…&aggregate=daily&limit=5000` requests
// (BP_SYS / BP_DIA / WEIGHT). The legacy `date_trunc('day', …)`
// path scanned the measurements table each time — a full year window
// on the maintainer's account hit 3 × ~3 s on the v1.4.35 HAR. Reading from
// the persistent `measurement_rollups` DAY buckets drops each call
// to a small indexed read against the ~5 k-row rollup table.
//
// Gating:
// - `source=rollup` is an explicit client opt-in (`@/lib/validations/measurement`
// constrains the enum to `["rollup"]`).
// - We require `aggregate=daily` because the rollup populator only
// keeps DAY buckets on the synchronous write hook; the WEEK /
// MONTH paths still depend on the async pg-boss recomputes which
// may lag behind a recent write.
// - `type` is mandatory so the response shape mirrors the per-type
// ask the chart code makes.
// - When the rollup window returns zero rows we fall through to
// the live `date_trunc` path so a brand-new account whose
// populator hasn't caught up still sees a correct chart on its
// first render.
// `source=rollup` + `aggregate=daily` — the chart's series read. Windows up
// to the DAY cap fold live into the user's local days (#1026); wider
// windows step up to the WEEK / MONTH / YEAR rollup tier. `type` is
// mandatory so the response mirrors the per-type ask the chart makes.
if (source === "rollup" && aggregate === "daily" && type && from && to) {
// v1.18.6 — the rollup-daily read (per-source collapse + coverage
// probe + inline fold + live-SQL fallback) moved to the shared
// `readDailySeries` reader so the batched series endpoint
// (`/api/measurements/series-batch`) reads through the SAME path
// instead of duplicating this SQL. Behaviour is unchanged: the rows
// returned here are byte-identical with the pre-extraction branch.
// Shared with the batched series endpoint, so both read the same rows.
const cap = Math.min(limit, BUCKET_CAP.daily);
const priorityJson = await loadUserSourcePriority(user.id);
const measurements = await readDailySeries({
@@ -444,94 +421,29 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
}
// `readDailySeries` already falls back to the live `date_trunc`
// aggregate on a rollup-coverage miss, so an empty result here means
// the window genuinely holds no rows. Fall through to the shared
// live aggregate branch below to keep the response shape identical
// for the truly-empty case.
// An empty result means the window genuinely holds no rows. Fall
// through to the shared live aggregate branch below to keep the
// response shape identical for the truly-empty case.
}
if (aggregate && aggregate !== "raw" && from && to) {
const grain: AggregateGrain = aggregate;
const grain: Exclude<AggregateGrain, "raw"> = aggregate;
const cap = Math.min(limit, BUCKET_CAP[grain]);
// Postgres `date_trunc` requires the unit argument to be a SQL
// literal — it cannot be supplied as a prepared-statement parameter.
// The previous `${truncUnit}` (bound) interpolation 500'd in
// production for every grain; the grain string is restricted to
// the `AggregateGrain` enum upstream via the Zod schema, so
// injecting the mapped unit via `Prisma.raw` is safe. The mapping
// also fixes the "weekly"/"monthly" passthrough — Postgres expects
// the singular forms `week`/`month`.
const TRUNC_UNIT: Record<Exclude<AggregateGrain, "raw">, string> = {
daily: "day",
weekly: "week",
monthly: "month",
};
const truncUnit = TRUNC_UNIT[grain];
const truncUnitLiteral = Prisma.raw(`'${truncUnit}'`);
// Cumulative HK types (steps, active energy, flights, distance,
// daylight) are partial-day increments — averaging across the day
// understates the daily total by the per-bucket sample count.
// SUM for those; AVG for spot metrics (BP, weight, pulse, BG,
// body fat, mood, sleep). See R-A finding 2.
const useSum =
type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType);
const aggregator = useSum
? Prisma.raw(`SUM(m."value")::double precision`)
: Prisma.raw(`AVG(m."value")::double precision`);
// v1.11.1 — collapse overlapping sources to the ladder-canonical reading
// per (type, bucket) before aggregating, so this cold/uncovered fallback
// agrees with the warm rollup path. `canon` picks the winning source for
// each (type, bucket) via the per-user rank; the join keeps only its rows.
// Buckets are cut at the user's own calendar boundaries (#1026), the
// same day the raw list and the chart's short range use. Cumulative
// types SUM per bucket, every other type averages; overlapping sources
// collapse to the ladder-canonical one first.
const priorityJson = await loadUserSourcePriority(user.id);
const rankRaw = Prisma.raw(
buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'),
);
const typeFilter = type
? Prisma.sql`AND m."type" = ${type}::measurement_type`
: Prisma.empty;
const buckets = await prisma.$queryRaw<
Array<{ type: string; bucket_start: Date; avg: number; cnt: number }>
>`
WITH canon AS (
SELECT DISTINCT ON (m."type", date_trunc(${truncUnitLiteral}, m."measured_at"))
m."type" AS t,
date_trunc(${truncUnitLiteral}, m."measured_at") AS d,
m."source" AS canon
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."measured_at" >= ${from}
AND m."measured_at" <= ${to}
AND m."deleted_at" IS NULL
${typeFilter}
ORDER BY m."type", date_trunc(${truncUnitLiteral}, m."measured_at"), (${rankRaw}), m."source"
)
SELECT
m."type"::text AS type,
date_trunc(${truncUnitLiteral}, m."measured_at") AS bucket_start,
${aggregator} AS avg,
COUNT(*)::int AS cnt
FROM measurements m
JOIN canon c
ON c.t = m."type"
AND c.d = date_trunc(${truncUnitLiteral}, m."measured_at")
AND c.canon = m."source"
WHERE m."user_id" = ${user.id}
AND m."measured_at" >= ${from}
AND m."measured_at" <= ${to}
AND m."deleted_at" IS NULL
${typeFilter}
GROUP BY m."type", bucket_start
ORDER BY bucket_start ASC
LIMIT ${cap}
`;
const measurements = buckets.map((b) => ({
type: b.type,
value: Number(b.avg),
measuredAt: b.bucket_start.toISOString(),
count: Number(b.cnt),
}));
const measurements = await readLiveBuckets({
userId: user.id,
type: (type as MeasurementType | undefined) ?? null,
from,
to,
cap,
priorityJson,
grain,
timeZone: await resolveUserTimezone(user.id),
});
annotate({
action: { name: "measurement.list" },
meta: { total: measurements.length, type, aggregate: grain },
@@ -264,3 +264,165 @@ describe("GET /api/medications/[id]/dose-history", () => {
expect(res.status).toBe(422);
});
});
/**
* Issue #1028 — a dose logged for earlier today on a medication created later
* the same day vanished from the trailing-window ledger while the full
* history (which has no window) showed it. The window floor was clamped to
* the medication's creation instant and the same clamped floor filtered the
* intakes, so any dose recorded with a time before the moment the medication
* was added fell out of "Last 90 days". The creation floor exists to keep
* the ledger from minting phantom slots before the medication existed; it
* says nothing about which recorded doses belong in the window.
*
* Pinned in a positive and a negative UTC offset so a zone-dependent floor
* cannot hide behind one of them.
*/
describe("GET /api/medications/[id]/dose-history — intakes recorded before creation (#1028)", () => {
for (const tz of ["Asia/Kolkata", "America/Los_Angeles"]) {
it(`keeps a same-day dose logged before the medication was added (${tz})`, async () => {
const today = new Date("2026-09-22T12:00:00Z");
const now = localHmAsUtc(today, tz, 16, 30);
const createdAt = localHmAsUtc(today, tz, 16, 20);
const takenAt = localHmAsUtc(today, tz, 14, 0);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(now);
try {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, timezone: tz },
} as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt,
scheduleRevisions: [],
schedules: [
{
id: "sched-1",
windowStart: "08:00",
windowEnd: "08:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
id: "evt-earlier-today",
scheduledFor: takenAt,
takenAt,
skipped: false,
autoMissed: false,
attributionSource: "AUTO",
doseTaken: null,
source: "WEB",
},
] as never);
// Exactly what the Verlauf tab sends: the trailing 90 days to now.
const from = new Date(now.getTime() - 90 * 86_400_000).toISOString();
const to = now.toISOString();
const res = await GET(
getReq(
`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
),
ROUTE_PARAMS,
);
expect(res.status).toBe(200);
const json = await res.json();
const ids = (
json.data.rows as Array<{ intake: { id: string | null } | null }>
).map((r) => r.intake?.id);
expect(ids).toContain("evt-earlier-today");
// The response reports the window the recorded doses were read
// over: the requested start, not the medication's creation.
expect(json.data.from).toBe(from);
} finally {
vi.useRealTimers();
}
});
}
});
describe("GET /api/medications/[id]/dose-history — early take (#1028 class)", () => {
it("keeps a dose taken inside its window before the slot's own time", async () => {
const tz = "Asia/Kolkata";
const today = new Date("2026-09-22T12:00:00Z");
const now = localHmAsUtc(today, tz, 19, 30);
const slot = localHmAsUtc(today, tz, 20, 0);
const takenAt = localHmAsUtc(today, tz, 19, 15);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(now);
try {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, timezone: tz },
} as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
scheduleRevisions: [],
schedules: [
{
id: "sched-1",
windowStart: "20:00",
windowEnd: "20:00",
daysOfWeek: null,
timesOfDay: ["20:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
id: "evt-early",
scheduledFor: slot,
takenAt,
skipped: false,
autoMissed: false,
attributionSource: "AUTO",
doseTaken: null,
source: "WEB",
},
] as never);
const from = new Date(now.getTime() - 90 * 86_400_000).toISOString();
const res = await GET(
getReq(`?from=${encodeURIComponent(from)}`),
ROUTE_PARAMS,
);
const json = await res.json();
const rows = json.data.rows as Array<{
status: string;
intake: { id: string | null } | null;
}>;
// `to` reports how far recorded doses were read: up to the early
// dose's slot, past the request's own now.
expect(json.data.to).toBe(slot.toISOString());
const early = rows.find((r) => r.intake?.id === "evt-early");
expect(early?.status).toBe("taken_on_time");
// The read horizon stretches only to the recorded anchor: no later
// slot is minted as upcoming.
expect(rows.filter((r) => r.status === "upcoming")).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
});
@@ -142,12 +142,18 @@ export const GET = apiHandler(
const requestedFrom =
parsed.data.from ?? new Date(to.getTime() - 90 * DAY_MS);
const spanFloor = new Date(to.getTime() - MAX_WINDOW_DAYS * DAY_MS);
// The window the caller asked for, capped only by the span limit. This is
// the floor for recorded doses: a dose logged with a time before the
// medication was added (created at 16:20, "taken at 14:00") is a real
// record and belongs in the window, exactly as the unwindowed full
// history shows it (#1028).
const intakeFrom = new Date(
Math.max(requestedFrom.getTime(), spanFloor.getTime()),
);
// The floor for minted slots additionally stops at the medication's
// creation, so pre-existence days never mint phantom missed slots.
const from = new Date(
Math.max(
requestedFrom.getTime(),
medication.createdAt.getTime(),
spanFloor.getTime(),
),
Math.max(intakeFrom.getTime(), medication.createdAt.getTime()),
);
const events = await prisma.medicationIntakeEvent.findMany({
@@ -182,6 +188,23 @@ export const GET = apiHandler(
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
// A dose may be recorded before its slot's own time: the dose window opens
// up to two hours early, and a skip can be logged ahead. Such a row is
// stored on the slot's anchor, which still lies after `to`, so a window
// that ends at `to` dropped a dose the user had just recorded until the
// slot's clock time passed (#1028). Extend the read horizon to the latest
// anchor of a recorded row (taken or skipped) within a day of `to`, and no
// further: pending reminder rows record nothing and never stretch it.
const horizon = new Date(
mapped.reduce((latest, e) => {
const t = e.scheduledFor.getTime();
const recorded = e.skipped || (e.takenAt !== null && e.takenAt <= to);
return recorded && t > latest && t <= to.getTime() + DAY_MS
? t
: latest;
}, to.getTime()),
);
const bandMedication: BandMinterMedication = {
id: medication.id,
startsOn: medication.startsOn,
@@ -223,7 +246,7 @@ export const GET = apiHandler(
revisions: medication.scheduleRevisions ?? [],
ctx,
userTz,
range: { from, to },
range: { from, to: horizon },
now,
intakeInstants,
});
@@ -241,7 +264,7 @@ export const GET = apiHandler(
// partition, so filter on the stored anchor (a take snapped to an
// out-of-window slot is excluded, matching the compliance read).
const historyIntakes: HistoryIntake[] = mapped
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= to)
.filter((e) => e.scheduledFor >= intakeFrom && e.scheduledFor <= horizon)
.map((e) => ({
id: e.id,
scheduledFor: e.scheduledFor,
@@ -290,9 +313,13 @@ export const GET = apiHandler(
meta: { row_count: serialized.length, family },
});
// The window the recorded doses were read over (#1028): from the
// requested start (slots additionally start at the medication's
// creation) to the read horizon, which reaches past `to` only for a dose
// recorded ahead of its slot.
return apiSuccess({
from: from.toISOString(),
to: to.toISOString(),
from: intakeFrom.toISOString(),
to: horizon.toISOString(),
family,
hasExpectedSlots: bands.length > 0,
rows: serialized,
@@ -22,9 +22,11 @@ vi.mock("@/lib/api-handler", async () => {
};
});
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
AssistantDisabledError: class extends Error {},
vi.mock("@/lib/ai/capabilities/gate", () => ({
requireAiCapability: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/egress", () => ({
assertAiEgress: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -53,14 +55,6 @@ vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: vi.fn(async () => []),
}));
// v1.12.1 — free-text medication extraction egresses PHI to the operator's
// server-managed key, so it now passes through the consent gate. Mock it to a
// no-op here; the gate's own fail-closed behaviour is covered by
// `consent-guard.test.ts`.
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/provider-runner", async () => {
const actual = await vi.importActual<
typeof import("@/lib/ai/provider-runner")
@@ -93,7 +87,9 @@ import {
runRawCompletionWithFallback,
AllProvidersFailedError,
} from "@/lib/ai/provider-runner";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/extract", {
@@ -110,6 +106,12 @@ const AUTH_OK = {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
vi.mocked(assertAiEgress).mockResolvedValue(undefined);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
@@ -208,10 +210,15 @@ describe("POST /api/medications/extract — happy path", () => {
doseUnit: "mg",
cadenceKind: "everyNWeeks",
});
// The PHI free-text egress is gated on an active consent receipt for the
// coach surface before the provider chain runs.
expect(assertConsentForChain).toHaveBeenCalledWith(
expect.objectContaining({ surface: "coach" }),
// The PHI free-text egress is re-checked against the capability for the
// whole chain the runner may cascade through, before it runs.
expect(assertAiEgress).toHaveBeenCalledWith(
"medicationExtract",
"user-1",
expect.any(Array),
);
expect(vi.mocked(assertAiEgress).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(runRawCompletionWithFallback).mock.invocationCallOrder[0]!,
);
});
@@ -300,6 +307,8 @@ describe("POST /api/medications/extract — daily budget", () => {
expect(res.status).toBe(429);
const body = await res.json();
expect(body.error).toMatch(/budget/i);
// The code a client branches on, the same string the body always carried.
expect(body.meta).toEqual({ errorCode: "coach.budget.exceeded" });
// The refusal happens BEFORE any upstream spend.
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
@@ -374,8 +383,19 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
type: "none",
} as never);
const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never);
expect(res.status).toBe(503);
// The status stays 503 (a shipped client may branch on it) and gains the
// code that says what it means.
const error = await POST(
postReq({ text: "Mounjaro 5mg weekly" }) as never,
).catch((e) => e);
expect(error).toBeInstanceOf(AiUnavailableError);
expect(error.status).toBe(503);
expect(error.meta).toEqual({
errorCode: "ai.provider.none",
capability: "medicationExtract",
reason: "no_provider",
});
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
it("returns 502 when the provider returns unparseable JSON", async () => {
@@ -395,3 +415,39 @@ describe("POST /api/medications/extract — validation + provider errors", () =>
expect(res.status).toBe(502);
});
});
describe("POST /api/medications/extract — the medicationExtract capability", () => {
it("answers under medicationExtract, keeping 503 for no provider", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
await POST(postReq({ text: "5mg weekly" }) as never).catch(() => null);
expect(requireAiCapability).toHaveBeenCalledWith("medicationExtract", {
noProvider: { errorCode: "ai.provider.none", status: 503 },
});
});
it("refuses before the body is read when the operator turned it off", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(requireAiCapability).mockRejectedValueOnce(
new AiUnavailableError("medicationExtract", "operator_disabled"),
);
const error = await POST(postReq({ text: "5mg weekly" }) as never).catch(
(e) => e,
);
expect(error.meta.errorCode).toBe("assistant.disabled.documentAi");
expect(checkRateLimit).not.toHaveBeenCalled();
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
it("stops at the wire without reserving budget or calling a provider", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(assertAiEgress).mockRejectedValueOnce(
new AiUnavailableError("medicationExtract", "consent_required"),
);
const error = await POST(postReq({ text: "5mg weekly" }) as never).catch(
(e) => e,
);
expect(error.meta.errorCode).toBe("consent.ai.required");
expect(reserveBudget).not.toHaveBeenCalled();
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
});
+53 -14
View File
@@ -24,9 +24,16 @@
* Behaviour:
*
* 1. requireAuth() — cookie OR Bearer.
* 2. requireAssistantSurface("coach") — operator can disable.
* 2. requireAiCapability("medicationExtract") — the operator's "Reading
* documents" switch (and the master), provider-work authority for the
* record, a configured provider (503 `ai.provider.none` otherwise; the
* status predates the envelope and stays until no shipped client reads
* it), and a document-class consent receipt: free-text medication input
* is health data, and any provider that leaves the machine needs one.
* 3. checkRateLimit(...) — 10 requests / 5 min / user.
* 4. resolveProviderChain() — fall back through providers.
* 4. resolveProviderChain() — fall back through providers,
* then `assertAiEgress` re-checks the capability for exactly that chain
* immediately before anything is sent.
* 5. reserveBudget(resolveDailyCap(chain)) — atomic daily token ceiling,
* capped against the COST OWNER: a self-hoster on their own key or a
* local model is measured against the user-plan ceiling, not the
@@ -56,7 +63,13 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
import {
AiUnavailableError,
type NoProviderRefusal,
} from "@/lib/ai/capabilities/refusal";
import { AI_PROVIDER_NONE_ERROR_CODE } from "@/lib/ai/capabilities/types";
import {
AllProvidersFailedError,
@@ -64,7 +77,6 @@ import {
} from "@/lib/ai/provider-runner";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import {
buildDateKey,
@@ -80,6 +92,19 @@ import {
type MedicationExtractionResult,
} from "@/lib/ai/coach/medication-extract-prompt";
/**
* How the extractor refuses with no provider. 503 predates the capability
* envelope; it stays until no shipped client is known to branch on it, and the
* code says what it means either way.
*/
const MEDICATION_EXTRACT_NO_PROVIDER: NoProviderRefusal = {
errorCode: AI_PROVIDER_NONE_ERROR_CODE,
status: 503,
};
/** The budget refusal's code; the same string the body always carried. */
const MEDICATION_EXTRACT_BUDGET_CODE = "coach.budget.exceeded";
/** Hard cap on the free-text payload; mirrors `coachChatRequestSchema.message`. */
const MAX_TEXT_LENGTH = 2000;
@@ -128,10 +153,12 @@ function parseModelJson(content: string): unknown | null {
async function handleExtract(request: NextRequest): Promise<Response> {
const auth = await requireAuth();
// Same operator-disable gate the Coach SSE route honours — when
// "coach" is off, the overlay also disappears so the wizard simply
// falls back to the manual path.
await requireAssistantSurface("coach");
// Medication text extraction is its own capability under the operator's
// "Reading documents" switch. When it is closed the wizard keeps its
// manual path; nothing here is data the wizard needs.
await requireAiCapability("medicationExtract", {
noProvider: MEDICATION_EXTRACT_NO_PROVIDER,
});
const userId = auth.user.id;
@@ -178,15 +205,25 @@ async function handleExtract(request: NextRequest): Promise<Response> {
const legacy = await resolveProvider(userId);
if (legacy.type === "none") {
annotate({ action: { name: "medications.extract.no-provider" } });
return apiError("No AI provider configured", 503);
throw new AiUnavailableError(
"medicationExtract",
"no_provider",
null,
MEDICATION_EXTRACT_NO_PROVIDER,
);
}
chain.push({ providerType: "admin-openai", instance: legacy });
}
// Free-text medication input is PHI. If the resolved chain could egress
// via the operator's server-managed key, an active consent receipt is
// required first — same gate as the Coach (which shares this chain).
await assertConsentForChain({ userId, chain, surface: "coach" });
// The wire. The runner may cascade through every entry, so each one is
// named: any entry that leaves the machine needs a document-class consent
// receipt, and a switch turned off since the gate above answered stops the
// call here.
await assertAiEgress(
"medicationExtract",
userId,
chain.map((entry) => entry.providerType),
);
const today = todayOverride ?? buildDateKey();
const { systemPrompt, userPrompt } = buildMedicationExtractionPrompt({
@@ -219,7 +256,9 @@ async function handleExtract(request: NextRequest): Promise<Response> {
action: { name: "medications.extract.budget-exceeded" },
meta: { totalAfter: reservation.totalAfter },
});
return apiError("coach.budget.exceeded", 429);
return apiError(MEDICATION_EXTRACT_BUDGET_CODE, 429, {
errorCode: MEDICATION_EXTRACT_BUDGET_CODE,
});
}
let completion;