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

+15 -104
View File
@@ -3,25 +3,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/consent/receipts", () => ({
latestActiveReceipt: vi.fn(),
}));
vi.mock("@/lib/documents/document-settings", () => ({
documentAutoReadEnabled: vi.fn().mockResolvedValue(false),
}));
import {
ConsentRequiredError,
assertConsentForChain,
assertDocumentEgressConsent,
chainRequiresServerManagedConsent,
hasActiveConsentForSurface,
isExternalDocumentEgress,
} from "../consent-guard";
import { latestActiveReceipt } from "@/lib/consent/receipts";
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
import type { ProviderChainResolved } from "../provider-runner";
import type { ConsentKind } from "@/lib/validations/consent";
const mockedLatest = vi.mocked(latestActiveReceipt);
const mockedAutoRead = vi.mocked(documentAutoReadEnabled);
/** Minimal chain entry — the gate only inspects `providerType`. */
function entry(providerType: string): ProviderChainResolved {
@@ -203,103 +197,20 @@ describe("isExternalDocumentEgress", () => {
});
});
describe("assertDocumentEgressConsent", () => {
beforeEach(() => {
mockedLatest.mockReset();
mockedAutoRead.mockReset();
// Default: the auto-read toggle is OFF (the shipped privacy posture).
mockedAutoRead.mockResolvedValue(false);
});
it("never reads consent and never throws for a LOCAL document pick", async () => {
grant([]);
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: "local",
surface: "insights",
}),
).resolves.toBeUndefined();
expect(mockedLatest).not.toHaveBeenCalled();
// A local pick short-circuits before the toggle is even consulted.
expect(mockedAutoRead).not.toHaveBeenCalled();
});
// The live gap the governance fix closes: codex was ungated for documents.
it("REQUIRES a receipt to send a document to codex (the closed gap)", async () => {
grant([]);
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: "codex",
surface: "insights",
}),
).rejects.toBeInstanceOf(ConsentRequiredError);
});
it("requires a receipt for BYOK document egress too (openai / anthropic)", async () => {
grant([]);
for (const p of ["openai", "anthropic", "admin-openai"]) {
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: p,
surface: "insights",
}),
).rejects.toBeInstanceOf(ConsentRequiredError);
}
});
it("proceeds for codex WITH an active document-class receipt", async () => {
grant(["ai_insights_only"]);
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: "codex",
surface: "insights",
}),
).resolves.toBeUndefined();
});
// Gate A: the documentsAutoAiRead opt-in short-circuits an external pick.
it("proceeds for an external pick when documentsAutoAiRead is ON, without a receipt", async () => {
grant([]);
mockedAutoRead.mockResolvedValue(true);
for (const p of ["codex", "openai", "anthropic", "admin-openai"]) {
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: p,
surface: "insights",
}),
).resolves.toBeUndefined();
}
// The toggle short-circuits BEFORE the receipt read.
expect(mockedLatest).not.toHaveBeenCalled();
});
it("STILL requires a receipt for an external pick when documentsAutoAiRead is OFF", async () => {
grant([]);
mockedAutoRead.mockResolvedValue(false);
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: "codex",
surface: "insights",
}),
).rejects.toBeInstanceOf(ConsentRequiredError);
});
it("does NOT consult the toggle for a LOCAL pick even when it would be ON", async () => {
grant([]);
mockedAutoRead.mockResolvedValue(true);
await expect(
assertDocumentEgressConsent({
userId: "u1",
providerType: "local",
surface: "insights",
}),
).resolves.toBeUndefined();
expect(mockedAutoRead).not.toHaveBeenCalled();
// The document-class receipt (`ai_extraction` or `ai_full`) is read by the
// capability egress re-check, not here: see
// `src/lib/ai/capabilities/__tests__/egress.test.ts` and
// `tests/integration/consent-withdrawal-purge.test.ts`. What this module still
// owes the document surfaces is the local/external split above, and that the
// extraction kind satisfies neither chain surface.
describe("an extraction receipt does not satisfy the chain surfaces", () => {
it("satisfies neither the Coach nor the analysis", async () => {
grant(["ai_extraction"]);
await expect(hasActiveConsentForSurface("u1", "coach")).resolves.toBe(
false,
);
await expect(hasActiveConsentForSurface("u1", "insights")).resolves.toBe(
false,
);
});
});
@@ -0,0 +1,254 @@
/**
* The capability re-check at the wire, and the gate option that leaves the
* provider questions to the route's own pick.
*
* The loader is replaced so each case hands the gate exactly the inputs it
* needs; the receipt read is replaced so the exact-pick consent answer can be
* steered independently of the presence-based one.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
let auth: Record<string, unknown> | undefined;
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ getAuth: () => auth, addWarning: vi.fn() }),
}));
vi.mock("@/lib/sharing/grants", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/sharing/grants")>()),
findActiveGrant: vi.fn(),
}));
vi.mock("../load", () => ({ loadAiCapabilityInputs: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: { consentReceipt: { findFirst: vi.fn() } },
}));
import { prisma } from "@/lib/db";
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
import { aiEgressRefusal, assertAiEgress } from "../egress";
import { requireAiCapability } from "../gate";
import { loadAiCapabilityInputs } from "../load";
import { AiUnavailableError } from "../refusal";
import type { AiCapabilityInputs } from "../resolve";
function inputs(
overrides: Partial<AiCapabilityInputs> = {},
): AiCapabilityInputs {
const moduleAccess = {} as Record<ModuleKey, ModuleAccessState>;
for (const key of MODULE_KEYS) moduleAccess[key] = "enabled";
return {
switches: {
enabled: true,
coach: true,
briefing: true,
insightStatus: true,
documentAi: true,
},
moduleAccess,
providerWorkAdmitted: true,
provider: {
entries: [{ providerType: "local", vision: true }],
localOcrEnabled: false,
managedBy: "local",
},
activeConsentKinds: new Set(),
recordKind: "self",
...overrides,
};
}
const LOAD = vi.mocked(loadAiCapabilityInputs);
const RECEIPT = vi.mocked(prisma.consentReceipt.findFirst);
beforeEach(() => {
LOAD.mockReset();
RECEIPT.mockReset();
RECEIPT.mockResolvedValue(null);
auth = { user_id: "user-1" };
});
describe("requireAiCapability with pickDecides", () => {
it("leaves no_provider to the route's pick", async () => {
LOAD.mockResolvedValue(
inputs({
provider: { entries: [], localOcrEnabled: false, managedBy: null },
}),
);
await expect(
requireAiCapability("documentAi", { pickDecides: true }),
).resolves.toMatchObject({ available: true });
});
it("leaves consent_required to the route's pick", async () => {
LOAD.mockResolvedValue(
inputs({
provider: {
entries: [{ providerType: "openai", vision: true }],
localOcrEnabled: false,
managedBy: "user",
},
}),
);
await expect(
requireAiCapability("documentAi", { pickDecides: true }),
).resolves.toMatchObject({ available: true });
});
it("still refuses a layer the pick cannot answer", async () => {
LOAD.mockResolvedValue(
inputs({ switches: { ...inputs().switches, documentAi: false } }),
);
const error = await requireAiCapability("documentAi", {
pickDecides: true,
}).catch((e) => e);
expect(error).toBeInstanceOf(AiUnavailableError);
expect(error.meta.errorCode).toBe("assistant.disabled.documentAi");
});
});
describe("aiEgressRefusal inside a request", () => {
it("refuses when the operator switch is off, whatever the pick", async () => {
LOAD.mockResolvedValue(
inputs({ switches: { ...inputs().switches, documentAi: false } }),
);
const refusal = await aiEgressRefusal("documentAi", "user-1", ["local"]);
expect(refusal).toBeInstanceOf(AiUnavailableError);
expect(refusal?.meta).toEqual({
errorCode: "assistant.disabled.documentAi",
capability: "documentAi",
reason: "operator_disabled",
});
});
it("refuses the master switch through every extraction capability", async () => {
LOAD.mockResolvedValue(
inputs({ switches: { ...inputs().switches, enabled: false } }),
);
for (const key of ["documentAi", "labsOcr", "medicationExtract"] as const) {
const refusal = await aiEgressRefusal(key, "user-1", ["local"]);
expect(refusal?.reason, key).toBe("operator_disabled");
}
});
it("asks for a receipt when the actual pick leaves the machine", async () => {
// Presence says a local model would read it; the route picked an external
// provider anyway (text mode reads the chain head). The pick decides.
LOAD.mockResolvedValue(inputs());
const refusal = await aiEgressRefusal("documentAi", "user-1", ["openai"]);
expect(refusal?.meta).toEqual({
errorCode: "consent.ai.required",
capability: "documentAi",
reason: "consent_required",
});
expect(RECEIPT).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
revokedAt: null,
kind: { in: ["ai_extraction", "ai_full"] },
}),
}),
);
});
it("admits an external pick with an extraction receipt", async () => {
LOAD.mockResolvedValue(inputs());
RECEIPT.mockResolvedValue({ id: "r1" } as never);
await expect(
aiEgressRefusal("documentAi", "user-1", ["openai"]),
).resolves.toBeNull();
});
it("needs no receipt for a local pick, even when presence says one is needed", async () => {
LOAD.mockResolvedValue(
inputs({
provider: {
entries: [{ providerType: "openai", vision: true }],
localOcrEnabled: false,
managedBy: "user",
},
}),
);
await expect(
aiEgressRefusal("documentAi", "user-1", ["local"]),
).resolves.toBeNull();
expect(RECEIPT).not.toHaveBeenCalled();
});
it("checks every entry of a cascading chain under the document rule", async () => {
LOAD.mockResolvedValue(inputs());
const refusal = await aiEgressRefusal("medicationExtract", "user-1", [
"local",
"admin-openai",
]);
expect(refusal?.reason).toBe("consent_required");
await expect(
aiEgressRefusal("medicationExtract", "user-1", ["local"]),
).resolves.toBeNull();
});
it("uses the self-snapshot rule for the Coach: a person's own key needs nothing", async () => {
LOAD.mockResolvedValue(inputs());
await expect(
aiEgressRefusal("coach", "user-1", ["openai"]),
).resolves.toBeNull();
const refusal = await aiEgressRefusal("coach", "user-1", ["admin-openai"]);
expect(refusal?.reason).toBe("consent_required");
expect(RECEIPT).toHaveBeenLastCalledWith(
expect.objectContaining({
where: expect.objectContaining({
kind: { in: ["ai_coach", "ai_full"] },
}),
}),
);
});
it("fails closed when the inputs cannot be loaded", async () => {
LOAD.mockResolvedValue(null);
const refusal = await aiEgressRefusal("labsOcr", "user-1", ["local"]);
expect(refusal?.reason).toBe("check_failed");
expect(refusal?.status).toBe(503);
});
});
describe("aiEgressRefusal outside a request (a job)", () => {
beforeEach(() => {
auth = undefined;
});
it("resolves for the record the job names", async () => {
LOAD.mockResolvedValue(
inputs({ switches: { ...inputs().switches, documentAi: false } }),
);
const refusal = await aiEgressRefusal("documentAi", "user-9", ["local"]);
expect(refusal?.reason).toBe("operator_disabled");
expect(LOAD).toHaveBeenCalledWith(
expect.objectContaining({ recordId: "user-9" }),
);
});
it("admits the wire when every layer is open", async () => {
LOAD.mockResolvedValue(inputs());
await expect(
aiEgressRefusal("documentAi", "user-9", ["local"]),
).resolves.toBeNull();
});
});
describe("assertAiEgress", () => {
it("throws the refusal", async () => {
LOAD.mockResolvedValue(
inputs({ switches: { ...inputs().switches, documentAi: false } }),
);
await expect(
assertAiEgress("medicationExtract", "user-1", ["local"]),
).rejects.toBeInstanceOf(AiUnavailableError);
});
it("returns quietly when the wire is open", async () => {
LOAD.mockResolvedValue(inputs());
await expect(
assertAiEgress("labsOcr", "user-1", ["local"]),
).resolves.toBeUndefined();
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* The capability re-check at the wire.
*
* `requireAiCapability` answers at the top of a route, before anything is
* picked. That is the refusal a client sees, but it is not the last word: a
* route may pick its provider in its own order, and a job enqueued before the
* operator turned a switch off runs long after any route answered. So every
* place that hands input to a model (the document provider pick, the labs
* scan, the medication extractor, the fenced document chat) asks again here,
* with the provider it is about to send to, immediately before it sends.
*
* Two questions, both about the actual wire:
*
* 1. Is the capability still open for this record? Inside a request this is
* the request's own resolution (memoised per request, so the answer
* matches the one the route gave); outside a request it is the job's
* resolution for the record it names.
* 2. Does sending to exactly these providers need a consent receipt, and is
* one active? Presence cannot answer this for a route that reads the
* chain in its own order, so the pick answers it here, from the same
* rule and the same receipt kinds the capability table declares.
*
* `no_provider` is never answered here: a caller that has a pick has a
* provider, and one without a pick refuses with its own typed code.
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { aiCapabilityForJob, requireAiCapability } from "./gate";
import { AiUnavailableError } from "./refusal";
import { consentNeededForProviders } from "./resolve";
import {
AI_CAPABILITIES,
PICK_DECIDED_REASONS,
type AiCapabilityKey,
} from "./types";
/** Whether the record holds an active receipt of one of these kinds. */
async function hasActiveReceipt(
recordId: string,
kinds: readonly string[],
): Promise<boolean> {
const row = await prisma.consentReceipt.findFirst({
where: { userId: recordId, revokedAt: null, kind: { in: [...kinds] } },
select: { id: true },
});
return row !== null;
}
/**
* Why input may not leave for these providers right now, or `null` when it
* may. Never throws for a refusal; a database error propagates, which is the
* closed direction for a caller about to send.
*
* @param recordId The record the input belongs to. Inside a request the
* request's own record is used, which is the record the route acts on.
* @param providerTypes The providers the input is about to reach: the one
* picked provider for a single-pick read, the whole chain for a cascade.
*/
export async function aiEgressRefusal(
key: AiCapabilityKey,
recordId: string,
providerTypes: readonly string[],
): Promise<AiUnavailableError | null> {
if (getEvent()?.getAuth()?.user_id) {
try {
await requireAiCapability(key, { pickDecides: true });
} catch (error) {
if (error instanceof AiUnavailableError) return error;
throw error;
}
} else {
const state = await aiCapabilityForJob(recordId, key);
if (state.reason !== null && !PICK_DECIDED_REASONS.has(state.reason)) {
return new AiUnavailableError(key, state.reason);
}
}
if (
consentNeededForProviders(key, providerTypes) &&
!(await hasActiveReceipt(recordId, AI_CAPABILITIES[key].consent.kinds))
) {
return new AiUnavailableError(key, "consent_required");
}
return null;
}
/** {@link aiEgressRefusal}, thrown. For routes, where `apiHandler` renders it. */
export async function assertAiEgress(
key: AiCapabilityKey,
recordId: string,
providerTypes: readonly string[],
): Promise<void> {
const refusal = await aiEgressRefusal(key, recordId, providerTypes);
if (refusal) throw refusal;
}
+76 -2
View File
@@ -40,7 +40,11 @@ import { providerWorkAuthorityForRecord } from "@/lib/sharing/provider-work-auth
import { loadAiCapabilityInputs, type AiCapabilityScope } from "./load";
import { explainAiCapability, resolveAiCapability } from "./resolve";
import { AiUnavailableError, type NoProviderRefusal } from "./refusal";
import type { AiCapabilityKey, AiCapabilityState } from "./types";
import {
PICK_DECIDED_REASONS,
type AiCapabilityKey,
type AiCapabilityState,
} from "./types";
export interface AiGateOptions {
/**
@@ -50,6 +54,15 @@ export interface AiGateOptions {
recordId?: string;
/** The route's own `no_provider` refusal, where a client already reads it. */
noProvider?: NoProviderRefusal;
/**
* Leave `no_provider` and `consent_required` to the provider the route
* actually picks. The resolver answers both from presence, which is right
* for the published payload but can differ from a route that reads the
* chain in its own order (a text-mode document read takes the chain head,
* not the first vision entry). A route that sets this must run the pick
* through `assertAiEgress` / `aiEgressRefusal`, which answers both exactly.
*/
pickDecides?: boolean;
}
/**
@@ -118,7 +131,10 @@ export async function requireAiCapability(
} catch {
finding = { reason: "check_failed", module: null };
}
if (finding.reason !== null) {
if (
finding.reason !== null &&
!(options.pickDecides && PICK_DECIDED_REASONS.has(finding.reason))
) {
throw new AiUnavailableError(
key,
finding.reason,
@@ -150,3 +166,61 @@ export async function aiCapabilityForJob(
return resolveAiCapability(key, null);
}
}
/**
* One capability for one record, from wherever the caller runs. Inside an
* authenticated request it is the request's view (masked to the active grant,
* memoised with every other capability read of the request); outside one it
* is the worker's view under the worker's own authority.
*
* For code that serves stored model text on both paths: a status note read on
* a page visit and the same read inside the nightly batch must answer the same
* question without the caller knowing which path it is on.
*/
export async function aiCapabilityForRecord(
recordId: string,
key: AiCapabilityKey,
): Promise<AiCapabilityState> {
return getEvent()?.getAuth()?.user_id
? getAiCapability(key, { recordId })
: aiCapabilityForJob(recordId, key);
}
/**
* Whether text a model already wrote for a record may be SHOWN, answered from
* the record's own state, whoever is reading.
*
* Serving stored text and starting model work are different questions. A
* delegate inside somebody else's record may not start model work there
* (`not_permitted_for_record`: no generation, no warm, no chat on the owner's
* key), but reads the owner's stored briefing, narrative, status notes and
* workout notes exactly when the owner would. So this resolves the record's
* operator switches, its modules and AI opt-outs, its provider and its
* consent, under the record's own authority (`system`, as a job would, which
* also answers a managed profile correctly), unmasked by the viewer's grant.
*
* Use it only to decide whether stored model text is served. Anything that
* generates, warms or enqueues asks `getAiCapability` / `requireAiCapability`
* (the viewer's view) instead. Never throws.
*/
export async function aiCapabilityToServe(
recordId: string,
key: AiCapabilityKey,
): Promise<AiCapabilityState> {
try {
const inputs = await loadAiCapabilityInputs({
recordId,
authority: {
origin: "system",
recordUserId: recordId,
actorUserId: null,
grantId: null,
},
sections: null,
recordKind: "self",
});
return resolveAiCapability(key, inputs);
} catch {
return resolveAiCapability(key, null);
}
}
-26
View File
@@ -1,26 +0,0 @@
/**
* One capability for one record, from whichever context is asking.
*
* Some readers run both inside an API request and outside one: the dashboard
* snapshot and the daily digest are read by their routes, by the server
* components that prefetch them, and by the morning push. Inside a request
* the route gate answers, and it honours the request's grant (a delegate
* inside somebody else's record gets that record's masked answer). Outside a
* request there is no caller to mask for, so the worker path answers with
* the record's own authority, exactly as a job would.
*
* Never throws; an input that cannot be loaded reads `check_failed`.
*/
import { getEvent } from "@/lib/logging/context";
import { aiCapabilityForJob, getAiCapability } from "./gate";
import type { AiCapabilityKey, AiCapabilityState } from "./types";
export function aiCapabilityForRecord(
recordId: string,
key: AiCapabilityKey,
): Promise<AiCapabilityState> {
return getEvent()?.getAuth()?.user_id
? getAiCapability(key, { recordId })
: aiCapabilityForJob(recordId, key);
}
+23
View File
@@ -223,6 +223,29 @@ function consentNeeded(
);
}
/**
* Whether sending to exactly these providers needs a receipt under the
* capability's consent rule. The egress re-check asks it about the providers a
* route actually picked, so the answer at the wire comes from the same rule the
* published capability does. Pass the one picked provider for a single-pick
* read, or the whole chain for a cascade.
*/
export function consentNeededForProviders(
key: AiCapabilityKey,
providerTypes: readonly string[],
): boolean {
return consentNeeded(AI_CAPABILITIES[key], {
// Every entry named here is one the route will send to, so each counts as
// able to read the input.
entries: providerTypes.map((providerType) => ({
providerType,
vision: true,
})),
localOcrEnabled: false,
managedBy: null,
});
}
function consentReason(
def: AiCapabilityDefinition,
provider: ProviderPresence,
+9
View File
@@ -84,6 +84,15 @@ export interface AiCapabilityState {
export const ON_DEVICE_ALLOWED_REASONS: ReadonlySet<AiUnavailableReason> =
new Set<AiUnavailableReason>(["no_provider", "consent_required"]);
/**
* The reasons a route's own provider pick answers exactly: whether there is a
* provider, and whether sending to it needs a receipt. The resolver answers
* both from presence, which is right for the published payload; a route that
* reads the chain in its own order asks again about the provider it picked.
*/
export const PICK_DECIDED_REASONS: ReadonlySet<AiUnavailableReason> =
new Set<AiUnavailableReason>(["no_provider", "consent_required"]);
/**
* The operator's assistant switches below the master. Each maps to a cost or
* egress profile an operator can reason about: the Coach, the daily briefing
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForJob: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("../conversation-summary", () => ({
refreshConversationSummary: vi.fn(async () => ({ status: "refreshed" })),
}));
vi.mock("../facts", () => ({
extractAndStoreFacts: vi.fn(async () => ({ status: "stored", count: 1 })),
}));
vi.mock("../plans", () => ({
extractAndStorePlanProposals: vi.fn(async () => ({
status: "stored",
count: 0,
})),
}));
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
import { annotate } from "@/lib/logging/context";
import { runCoachMemoryRefresh } from "../coach-memory-refresh-worker";
import { refreshConversationSummary } from "../conversation-summary";
import { extractAndStoreFacts } from "../facts";
import { extractAndStorePlanProposals } from "../plans";
const payload = { conversationId: "c1", userId: "u1", locale: "en" as const };
beforeEach(() => {
vi.clearAllMocks();
});
describe("runCoachMemoryRefresh", () => {
it("runs all three steps when the Coach is available", async () => {
vi.mocked(aiCapabilityForJob).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
await runCoachMemoryRefresh(payload);
expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "coach");
expect(refreshConversationSummary).toHaveBeenCalled();
expect(extractAndStoreFacts).toHaveBeenCalled();
expect(extractAndStorePlanProposals).toHaveBeenCalled();
});
it.each(["operator_disabled", "user_disabled", "consent_required"] as const)(
"reads no transcript and runs no step when the Coach is unavailable (%s)",
async (reason) => {
vi.mocked(aiCapabilityForJob).mockResolvedValue({
available: false,
reason,
onDeviceAllowed: false,
});
await runCoachMemoryRefresh(payload);
expect(refreshConversationSummary).not.toHaveBeenCalled();
expect(extractAndStoreFacts).not.toHaveBeenCalled();
expect(extractAndStorePlanProposals).not.toHaveBeenCalled();
expect(annotate).toHaveBeenCalledWith({
action: { name: "coach.memory.refresh.skipped" },
meta: { reason },
});
},
);
});
@@ -10,6 +10,14 @@ vi.mock("@/lib/db", () => ({
},
}));
// The crosstab reads per-source day aggregates; the fake groups the mocked
// `measurement.findMany` rows with the same rules.
vi.mock("@/lib/measurements/day-aggregates", async () => ({
readSourceDayAggregates: (
await import("@/lib/measurements/__tests__/fake-day-aggregates")
).fakeReadSourceDayAggregates,
}));
import { buildCycleSnapshotBlock } from "../cycle-snapshot";
import { prisma } from "@/lib/db";
@@ -15,6 +15,7 @@
* chokepoint did no budget work at all, so every generator behind it, not just
* these two, spent unmetered.
*/
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
import { annotate } from "@/lib/logging/context";
import type { CoachMemoryRefreshPayload } from "./coach-memory-shared";
@@ -31,6 +32,19 @@ export async function runCoachMemoryRefresh(
// fallback body for every locale that has no reviewed one.
const locale = payload.locale ?? "en";
// The `coach` capability before any transcript is read. The chat route that
// enqueued this admitted the Coach, but the operator, the person's Coach
// switch or a consent can change while the job waits. Stored memory stays
// as it is; only new model work stops. The chokepoint re-checks per step.
const capability = await aiCapabilityForJob(userId, "coach");
if (!capability.available) {
annotate({
action: { name: "coach.memory.refresh.skipped" },
meta: { reason: capability.reason },
});
return;
}
let summaryStatus = "error";
try {
const result = await refreshConversationSummary(conversationId, userId, {
+1 -1
View File
@@ -193,7 +193,7 @@ export async function refreshConversationSummary(
const completion: StatusProviderResult = await runCompletion({
userId,
cacheAction: "coach.summary",
consentSurface: "coach",
capability: "coach",
systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN,
userPrompt: buildSummaryUserPrompt(priorSummary, foldedTurns, locale),
temperature: 0.3,
+14 -17
View File
@@ -40,6 +40,7 @@ import {
} from "@/lib/cycle/phase-crosstab";
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
import { addDays } from "@/lib/cycle/day-math";
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
/** Trailing window the phase contrast walks (days). Mirrors the read route. */
@@ -195,21 +196,16 @@ export async function buildCycleSnapshotBlock(
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
}),
prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
measuredAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
},
orderBy: { measuredAt: "asc" },
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
},
// A year of steps, heart-rate variability and CGM glucose is far too
// many readings to hold as objects (#1023). The crosstab only needs
// each day's sum and count per source and device, which is what the
// per-source picker decides on, so the fold runs in SQL. Days are the
// Berlin days `metricDayMap` keys on.
readSourceDayAggregates({
userId,
types: PHASE_CROSSTAB_METRIC_TYPES,
since: new Date(Date.parse(`${from}T00:00:00Z`)),
timeZone: "Europe/Berlin",
}),
prisma.user.findUnique({
where: { id: userId },
@@ -285,8 +281,9 @@ export async function buildCycleSnapshotBlock(
);
const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({
type: m.type,
value: m.value,
measuredAt: m.measuredAt,
value: m.sum,
count: m.n,
measuredAt: m.firstAt,
source: m.source,
deviceType: m.deviceType,
}));
+1 -1
View File
@@ -310,7 +310,7 @@ export async function extractAndStoreFacts(
const result = await runCompletion({
userId,
cacheAction: "coach.facts",
consentSurface: "coach",
capability: "coach",
systemPrompt: extractionSystemPrompt(opts?.locale),
userPrompt: buildUserPrompt(turns, activeTexts),
temperature: 0.2,
+1 -1
View File
@@ -335,7 +335,7 @@ export async function extractAndStorePlanProposals(
const result = await runCompletion({
userId,
cacheAction: "coach.plans",
consentSurface: "coach",
capability: "coach",
systemPrompt: extractionSystemPrompt(opts?.locale),
userPrompt: buildUserPrompt(turns, existingSignatures),
temperature: 0.2,
+10 -35
View File
@@ -28,13 +28,14 @@
* mapped kind OR the superset `ai_full` grant satisfies the gate.
*/
import { latestActiveReceipt } from "@/lib/consent/receipts";
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
import type { ConsentKind } from "@/lib/validations/consent";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
/**
* The two AI surfaces that egress PHI. Each maps to the consent kind the iOS
* client collects for it; `ai_full` (the master grant) satisfies either.
* The two AI surfaces that egress PHI through a chain. Each maps to the consent
* kind the iOS client collects for it; `ai_full` (the master grant) satisfies
* either. Reading documents has its own kind (`ai_extraction`), checked by the
* capability egress re-check rather than here.
*/
export type ConsentSurface = "coach" | "insights";
@@ -51,7 +52,7 @@ const SERVER_MANAGED_PROVIDER_TYPES: ReadonlySet<string> = new Set([
/**
* Error thrown when an external-LLM egress on a server-managed key is
* attempted without an active consent receipt. Mirrors `AssistantDisabledError`
* attempted without an active consent receipt. Mirrors the AI capability refusal
* so the api-handler renders the same 403 envelope shape the iOS client
* already branches on:
*
@@ -141,7 +142,11 @@ export async function assertConsentForChain(args: {
* AI routes call the single resolved provider directly (no runner cascade), so
* the exact egress is the picked provider. A `local` pick never leaves the
* machine and stays ungated; EVERY external pick (codex, BYOK openai/anthropic,
* the operator's admin key) requires an active document-class consent receipt.
* the operator's admin key) requires an active extraction receipt
* (`ai_extraction` or `ai_full`). The check itself runs through the capability
* egress re-check (`aiEgressRefusal` in `@/lib/ai/capabilities/egress`), which
* reads the receipt kinds from the capability table; the auto-read toggle is a
* trigger, never the consent, so a revoked receipt wins over a toggle left on.
*/
const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet<string> = new Set(["local"]);
@@ -153,33 +158,3 @@ const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet<string> = new Set(["local"]);
export function isExternalDocumentEgress(providerType: string): boolean {
return !LOCAL_ONLY_PROVIDER_TYPES.has(providerType);
}
/**
* Enforce the document-class consent precondition for the provider that will
* actually receive the document. No-op for a `local` pick (nothing leaves the
* machine); for any external pick, throw `ConsentRequiredError` unless an active
* receipt of the surface's mapped kind (or `ai_full`) is on file.
*
* ONE relaxation: the per-user `documentsAutoAiRead` opt-in. Flipping it ON is
* itself the standing consent act (the toggle write also mints an `ai_full`
* receipt for the audit trail), so the gate short-circuits an external pick when
* it is ON — that is what removes the per-document friction ("upload and the AI
* just reads it, no 80 switches"). When it is OFF the branch is inert and the
* gate behaves exactly as shipped: no external egress without an explicit
* receipt. The short-circuit is checked BEFORE the receipt read and ONLY for an
* external pick — a `local` pick is already ungated above, so the toggle can
* never widen egress that was not already external.
*
* Call this AFTER the document provider is picked and BEFORE the first
* `generateCompletion` on it.
*/
export async function assertDocumentEgressConsent(args: {
userId: string;
providerType: string;
surface: ConsentSurface;
}): Promise<void> {
if (!isExternalDocumentEgress(args.providerType)) return;
if (await documentAutoReadEnabled(args.userId)) return;
if (await hasActiveConsentForSurface(args.userId, args.surface)) return;
throw new ConsentRequiredError(args.surface);
}
+2 -20
View File
@@ -26,7 +26,6 @@ import {
selectorNamesAnAccount,
type ActingCarrier,
} from "./auth/acting-carrier";
import { AssistantDisabledError } from "./feature-flags";
import { AiUnavailableError } from "./ai/capabilities/refusal";
import { ConsentRequiredError } from "./ai/consent-guard";
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config";
@@ -246,27 +245,10 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
{ data: null, error: error.message, meta: error.meta },
{ status: error.status },
);
} else if (error instanceof AssistantDisabledError) {
// v1.4.31 — operator has disabled the assistant surface.
// The 403 + `errorCode: "assistant.disabled.<surface>"`
// envelope is locked per
// `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
// Older iOS clients that don't know the errorCode surface
// this as a generic 403; v1.4.31+ clients can branch on the
// errorCode to render an inline operator-disabled notice.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: 403 },
);
} else if (error instanceof ConsentRequiredError) {
// v1.12.1 — server-side consent gate before external-LLM PHI
// egress on the operator's server-managed key. Mirrors the
// AssistantDisabledError envelope (403 + meta.errorCode) so the
// egress on the operator's server-managed key. 403 +
// meta.errorCode, like the capability refusal above, so the
// iOS client renders an inline "grant consent" notice instead of
// a generic failure.
evt.setError(error);
+1 -39
View File
@@ -1,7 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// `createReceipt` + `revokeLatest` run their supersede/revoke logic inside
// a transaction. Run the callback against the same mock proxy. The Prisma
// `createReceipt` runs its supersede logic inside a transaction. Run the callback against the same mock proxy. The Prisma
// `$transaction` signature is generic; the mock takes a loose callback.
type TxFn = (tx: unknown) => unknown;
@@ -26,7 +25,6 @@ import {
createReceipt,
latestActiveReceipt,
latestActiveReceiptsByKind,
revokeLatest,
} from "../receipts";
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
@@ -196,39 +194,3 @@ describe("latestActiveReceiptsByKind", () => {
expect(result.ai_insights_only).toBeUndefined();
});
});
describe("revokeLatest", () => {
it("atomically revokes the active row via updateMany and returns it", async () => {
const now = new Date("2026-05-18T11:00:00.000Z");
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
row({ revokedAt: now }) as never,
);
const result = await revokeLatest("user-1", "ai_full", now);
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
data: { revokedAt: now },
});
// Re-reads the just-revoked row so the audit log keeps the receipt id.
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
where: { userId: "user-1", kind: "ai_full", revokedAt: now },
orderBy: { createdAt: "desc" },
});
expect(result?.revokedAt).toEqual(now);
});
it("returns null without re-reading when no active receipt exists", async () => {
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
count: 0,
} as never);
const result = await revokeLatest("user-1", "ai_full");
expect(result).toBeNull();
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// The revoke and the purge run inside one transaction; the mock runs the
// callback against the same proxy. Atomicity against a real database is
// pinned by `tests/integration/consent-withdrawal-purge.test.ts`.
type TxFn = (tx: unknown) => unknown;
vi.mock("@/lib/db", () => {
const consentReceipt = {
updateMany: vi.fn(),
findFirst: vi.fn(),
count: vi.fn(),
};
return {
prisma: {
consentReceipt,
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
},
};
});
import { prisma } from "@/lib/db";
import { withdrawConsent, type AiTextPurgeCounts } from "../withdrawal";
const COUNTS: AiTextPurgeCounts = {
statusNotes: 3,
briefing: 1,
narratives: 2,
reactionLines: 1,
workoutParagraphs: 1,
};
const now = new Date("2026-09-20T08:00:00.000Z");
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.$transaction).mockImplementation(((fn: TxFn) =>
fn({ consentReceipt: prisma.consentReceipt })) as never);
});
/** Receipts active before the call, by kind. */
function active(kinds: string[]) {
const live = new Set(kinds);
vi.mocked(prisma.consentReceipt.updateMany).mockImplementation((async (args: {
where: { kind: string };
}) => {
const had = live.delete(args.where.kind);
return { count: had ? 1 : 0 };
}) as never);
vi.mocked(prisma.consentReceipt.findFirst).mockImplementation((async (args: {
where: { kind: string };
}) => ({ id: `r-${args.where.kind}`, kind: args.where.kind })) as never);
vi.mocked(prisma.consentReceipt.count).mockImplementation(
(async (args: { where: { kind: { in: string[] } } }) =>
args.where.kind.in.filter((k) => live.has(k)).length) as never,
);
}
describe("withdrawConsent", () => {
it("revokes each active kind conditionally and re-reads it for the audit trail", async () => {
active(["ai_coach"]);
const purge = vi.fn();
const result = await withdrawConsent("u1", ["ai_coach"], now, purge);
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
where: { userId: "u1", kind: "ai_coach", revokedAt: null },
data: { revokedAt: now },
});
expect(result.revoked.map((r) => r.kind)).toEqual(["ai_coach"]);
});
it("purges when the revoke leaves no receipt covering the analysis", async () => {
active(["ai_full"]);
const purge = vi.fn(async () => COUNTS);
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
expect(purge).toHaveBeenCalledTimes(1);
expect(result.purged).toEqual(COUNTS);
});
it("keeps the text while another receipt still covers the analysis", async () => {
active(["ai_full", "ai_insights_only"]);
const purge = vi.fn(async () => COUNTS);
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
expect(purge).not.toHaveBeenCalled();
expect(result.purged).toBeNull();
});
it("never purges for a revoke that did not touch the analysis (Coach or extraction only)", async () => {
active(["ai_coach", "ai_extraction"]);
const purge = vi.fn(async () => COUNTS);
const result = await withdrawConsent(
"u1",
["ai_coach", "ai_extraction"],
now,
purge,
);
expect(purge).not.toHaveBeenCalled();
expect(result.purged).toBeNull();
expect(result.revoked).toHaveLength(2);
});
it("does nothing, and purges nothing, when no receipt was active", async () => {
active([]);
const purge = vi.fn(async () => COUNTS);
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
expect(result).toEqual({ revoked: [], purged: null });
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
expect(purge).not.toHaveBeenCalled();
});
it("propagates a purge failure out of the transaction (the revoke rolls back with it)", async () => {
active(["ai_insights_only"]);
const purge = vi.fn(async () => {
throw new Error("purge failed");
});
await expect(
withdrawConsent("u1", ["ai_insights_only"], now, purge),
).rejects.toThrow("purge failed");
});
});
+23 -37
View File
@@ -2,10 +2,10 @@
* v1.4.40 SB-10 — AI consent receipts CRUD helper.
*
* Append-only audit trail. Every grant + revoke mints a fresh row;
* no updates, no deletes outside of cascade-on-user-delete. The
* `revokeLatest` helper writes the current row's `revokedAt` and
* returns it — that single field flip is the only mutation the
* helper performs. A subsequent re-grant inserts a new row, so the
* no updates, no deletes outside of cascade-on-user-delete. A
* revocation (`withdrawConsent` in `./withdrawal`) writes the current
* row's `revokedAt` — that single field flip is the only mutation a
* receipt ever sees. A subsequent re-grant inserts a new row, so the
* historical chain stays intact for legal review.
*
* Source of truth for "is AI active right now?" is
@@ -19,6 +19,25 @@ import { isP2002 } from "@/lib/prisma-errors";
export type ConsentReceipt = ConsentReceiptModel;
/**
* The consent kind for reading documents, lab report scans and typed
* medication text with an external model. Minted by switching the document
* auto-read on; required (or the master `ai_full`) by every extraction path.
* It does not satisfy the Coach or the analysis.
*/
export const AI_EXTRACTION_CONSENT_KIND = "ai_extraction" satisfies ConsentKind;
/**
* The kinds whose active receipt authorises model-written analysis over the
* person's own readings (briefing, status notes, narratives, workout
* paragraphs, reaction lines). When none of these is left active, the
* regenerable text those surfaces stored is purged.
*/
export const INSIGHTS_CONSENT_KINDS: readonly ConsentKind[] = [
"ai_insights_only",
"ai_full",
];
/**
* Insert a fresh consent receipt for (userId, kind). The caller has
* already Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601).
@@ -119,36 +138,3 @@ export async function latestActiveReceiptsByKind(
}
return out;
}
/**
* Mark the active receipt for (userId, kind) as revoked. Returns the
* updated row, or `null` if no active receipt exists.
*
* v1.16.16 — the partial unique index guarantees at most one active row
* per (user, kind), so a single atomic `updateMany` over the active
* predicate revokes "the" active receipt without a separate read. Two
* concurrent revokes converge: the first flips `revoked_at`, the second's
* predicate no longer matches and writes nothing. The follow-up read
* returns the row the caller (audit log) needs; it is `null` only when no
* active receipt existed at all.
*/
export async function revokeLatest(
userId: string,
kind: ConsentKind,
now: Date = new Date(),
): Promise<ConsentReceipt | null> {
return prisma.$transaction(async (tx) => {
const updated = await tx.consentReceipt.updateMany({
where: { userId, kind, revokedAt: null },
data: { revokedAt: now },
});
if (updated.count === 0) return null;
// Re-read the just-revoked row (revoked at exactly `now`) so the audit
// trail keeps the receipt id. The active predicate guaranteed a single
// row, so this resolves it unambiguously.
return tx.consentReceipt.findFirst({
where: { userId, kind, revokedAt: now },
orderBy: { createdAt: "desc" },
});
});
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Consent withdrawal: revoking receipts, and deleting the model-written text
* that the withdrawn consent covered.
*
* A revocation stops new AI work at once (every capability reads the active
* receipts). What it also owes the person is the text already written under
* that consent. Two kinds of stored text are treated differently:
*
* - Regenerable caches are DELETED, in the same transaction as the revoke,
* when the revoke leaves no active receipt covering the analysis
* (`ai_insights_only` or `ai_full`): the per-metric status notes, the
* cached briefing, model-written period narratives (the deterministic ones
* are data and stay), arrival reaction lines (the markers stay), and the
* workout paragraphs. Nothing of value is lost; granting consent again
* regenerates them.
* - The person's own records stay: Coach conversations, facts and plans,
* document summaries and extracted text. They are hidden from AI surfaces
* while a capability is unavailable, but they remain the person's to read
* and delete.
*
* Revoke and purge commit together or not at all: a purge that fails leaves
* the receipt active, so the person sees the withdrawal fail and can retry,
* rather than a revoked consent with its text still stored.
*/
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { DETERMINISTIC_PROVIDER_TYPE } from "@/lib/insights/narrative/period-narrative-deterministic";
import type { ConsentKind } from "@/lib/validations/consent";
import { INSIGHTS_CONSENT_KINDS, type ConsentReceipt } from "./receipts";
/** How many stored items one withdrawal deleted, per surface. */
export interface AiTextPurgeCounts {
statusNotes: number;
/** 1 when a cached briefing was cleared, else 0. */
briefing: number;
narratives: number;
reactionLines: number;
workoutParagraphs: number;
}
export interface ConsentWithdrawal {
/** The receipts this call revoked, in the order of `kinds`. */
revoked: Array<{ kind: ConsentKind; receipt: ConsentReceipt }>;
/** What was deleted, or `null` when the withdrawal left the analysis covered. */
purged: AiTextPurgeCounts | null;
}
/**
* Delete the regenerable model-written text for one account, inside `tx`.
* Exported for the transaction below and its tests; call it through
* `withdrawConsent`, never on its own.
*/
export async function purgeRegenerableAiText(
tx: Prisma.TransactionClient,
userId: string,
): Promise<AiTextPurgeCounts> {
const [statusNotes, briefing, narratives, reactionLines, workoutParagraphs] =
await Promise.all([
tx.insightStatusCache.deleteMany({ where: { userId } }),
tx.user.updateMany({
where: {
id: userId,
OR: [
{ insightsCachedText: { not: null } },
{ insightsCachedAt: { not: null } },
{ insightsSnapshotHash: { not: null } },
],
},
data: {
insightsCachedText: null,
insightsCachedAt: null,
insightsSnapshotHash: null,
},
}),
tx.insightNarrative.deleteMany({
where: {
userId,
OR: [
{ providerType: null },
{ providerType: { not: DETERMINISTIC_PROVIDER_TYPE } },
],
},
}),
// The marker is data (it drives the "just in" chip); only the sentence
// goes. `generatedAt` stays, so the worker does not write it again.
tx.arrivalReaction.updateMany({
where: { userId, lineEncrypted: { not: null } },
data: { lineEncrypted: null },
}),
tx.workoutInsight.deleteMany({ where: { userId } }),
]);
return {
statusNotes: statusNotes.count,
briefing: briefing.count,
narratives: narratives.count,
reactionLines: reactionLines.count,
workoutParagraphs: workoutParagraphs.count,
};
}
/**
* Revoke the active receipt of each of `kinds`, and purge the regenerable
* text when that leaves the analysis without a covering receipt. One
* transaction. The purge runs only when this call revoked a receipt that
* covered the analysis; revoking the Coach consent alone never deletes a
* status note.
*/
export async function withdrawConsent(
userId: string,
kinds: readonly ConsentKind[],
now: Date = new Date(),
purge: typeof purgeRegenerableAiText = purgeRegenerableAiText,
): Promise<ConsentWithdrawal> {
return prisma.$transaction(async (tx) => {
const revoked: ConsentWithdrawal["revoked"] = [];
for (const kind of kinds) {
// The partial unique index keeps at most one active row per (user,
// kind), so one conditional update revokes "the" active receipt; a
// concurrent revoke finds nothing left to match and writes nothing.
const updated = await tx.consentReceipt.updateMany({
where: { userId, kind, revokedAt: null },
data: { revokedAt: now },
});
if (updated.count === 0) continue;
const receipt = await tx.consentReceipt.findFirst({
where: { userId, kind, revokedAt: now },
orderBy: { createdAt: "desc" },
});
if (receipt) revoked.push({ kind, receipt });
}
const touchedAnalysis = revoked.some(({ kind }) =>
INSIGHTS_CONSENT_KINDS.includes(kind),
);
if (!touchedAnalysis) return { revoked, purged: null };
const stillCovered = await tx.consentReceipt.count({
where: {
userId,
revokedAt: null,
kind: { in: [...INSIGHTS_CONSENT_KINDS] },
},
});
if (stillCovered > 0) return { revoked, purged: null };
return { revoked, purged: await purge(tx, userId) };
});
}
+6
View File
@@ -232,6 +232,12 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// ───── Insight narratives (Bytes column) ─────
{ model: "InsightNarrative", field: "encryptedContent", kind: "bytes" },
// ───── v1.39.0 status notes (Bytes columns) ─────
// Both nullable: a row can carry only a negative-cache window, and only the
// medication-compliance card stores per-item lines.
{ model: "InsightStatusCache", field: "textEncrypted", kind: "bytes" },
{ model: "InsightStatusCache", field: "itemsEncrypted", kind: "bytes" },
// ───── v1.31.0 data-arrival spine (Bytes column) ─────
// Nullable: the reaction marker is written on every salient arrival, but the
// generated line only exists where a provider was reachable and in budget.
@@ -9,8 +9,11 @@
*/
import { describe, it, expect } from "vitest";
import { bleedOpensCycle } from "../cycle-boundaries";
import { HARD_CYCLE_MIN } from "../types";
import {
bleedOpensCycle,
isSamePeriodStartedEarlier,
} from "../cycle-boundaries";
import { HARD_CYCLE_MIN, PERIOD_MAX } from "../types";
import { addDays } from "../day-math";
const DAY = "2026-06-01";
@@ -65,3 +68,23 @@ describe("bleedOpensCycle", () => {
expect(bleedOpensCycle("HEAVY", false, addDays(DAY, 10), DAY)).toBe(false);
});
});
describe("isSamePeriodStartedEarlier", () => {
it("reads a start a few days later as the same period entered out of order", () => {
expect(isSamePeriodStartedEarlier(DAY, addDays(DAY, 1))).toBe(true);
expect(isSamePeriodStartedEarlier(DAY, addDays(DAY, PERIOD_MAX - 1))).toBe(
true,
);
});
it("does not reach past the longest period the engine accepts", () => {
expect(isSamePeriodStartedEarlier(DAY, addDays(DAY, PERIOD_MAX))).toBe(
false,
);
});
it("never folds the same day or an earlier start", () => {
expect(isSamePeriodStartedEarlier(DAY, DAY)).toBe(false);
expect(isSamePeriodStartedEarlier(DAY, addDays(DAY, -3))).toBe(false);
});
});
@@ -17,6 +17,8 @@ const db = vi.hoisted(() => {
findUnique: vi.fn(),
upsert: vi.fn(),
update: vi.fn(),
// An opened cycle re-attributes the days it now owns.
updateMany: vi.fn(async () => ({ count: 0 })),
},
// The write path asks whether the logged day opens a cycle. These cases
// are about the encryption columns, so the fake answers "no cycles on
+111 -1
View File
@@ -3,7 +3,11 @@
*/
import { describe, it, expect } from "vitest";
import { buildCalendar, type CalendarDayLogRow } from "../engine-adapter";
import {
buildCalendar,
type CalendarDayDTO,
type CalendarDayLogRow,
} from "../engine-adapter";
import type { CycleProfile, MenstrualCycle } from "@/generated/prisma/client";
function profile(overrides: Partial<CycleProfile> = {}): CycleProfile {
@@ -226,4 +230,110 @@ describe("buildCalendar", () => {
expect(days.some((d) => d.phase !== null)).toBe(true);
});
});
describe("the position of each date in its own cycle (#1004)", () => {
// The log sheet used to label every date with TODAY's cycle day, so a
// January date opened in September read "Day 2", and it offered the
// one-tap period end only when today was a bleeding day. Both answers now
// come per date, from the cycle that date actually belongs to.
const history = [
cycle("2026-01-01", { endDate: "2026-01-28", lengthDays: 28 }),
cycle("2026-01-29", { endDate: "2026-02-25", lengthDays: 28 }),
cycle("2026-02-26"),
];
function dayOf(days: CalendarDayDTO[], date: string): CalendarDayDTO {
const d = days.find((x) => x.date === date);
if (!d) throw new Error(`no grid day for ${date}`);
return d;
}
it("counts a historical date from the start of the cycle it sits in", () => {
const { days } = buildCalendar(
profile(),
history,
[],
[],
"2025-12-20",
"2026-03-20",
"2026-03-10",
false,
);
expect(dayOf(days, "2026-01-26").cycleDay).toBe(26);
expect(dayOf(days, "2026-01-29").cycleDay).toBe(1);
expect(dayOf(days, "2026-02-27").cycleDay).toBe(2);
// Today, in the open cycle: the same count the verdict reports.
expect(dayOf(days, "2026-03-10").cycleDay).toBe(13);
});
it("claims no cycle day before the first logged start or after today", () => {
const { days } = buildCalendar(
profile(),
history,
[],
[],
"2025-12-20",
"2026-03-20",
"2026-03-10",
false,
);
expect(dayOf(days, "2025-12-31").cycleDay).toBeNull();
expect(dayOf(days, "2026-03-11").cycleDay).toBeNull();
});
it("offers a period end only inside the first days of a logged cycle", () => {
const { days } = buildCalendar(
profile(),
history,
[],
[],
"2025-12-20",
"2026-03-20",
"2026-03-10",
false,
);
expect(dayOf(days, "2026-01-01").periodEndable).toBe(true);
expect(dayOf(days, "2026-01-05").periodEndable).toBe(true);
expect(dayOf(days, "2026-02-01").periodEndable).toBe(true);
expect(dayOf(days, "2026-01-26").periodEndable).toBe(false);
expect(dayOf(days, "2025-12-31").periodEndable).toBe(false);
expect(dayOf(days, "2026-03-11").periodEndable).toBe(false);
});
it("answers the same whichever order the starts were entered in", () => {
// A September start entered first, then January: January's own days
// count from January, never from September or from today.
const { days } = buildCalendar(
profile(),
[cycle("2026-09-17"), cycle("2026-01-26")],
[],
[],
"2026-01-01",
"2026-02-28",
"2026-09-24",
false,
);
expect(dayOf(days, "2026-01-26").cycleDay).toBe(1);
expect(dayOf(days, "2026-01-28").cycleDay).toBe(3);
expect(dayOf(days, "2026-01-28").periodEndable).toBe(true);
expect(dayOf(days, "2026-01-25").cycleDay).toBeNull();
});
it("stops counting an open cycle where the verdict stops", () => {
// One start in January and nothing since: by August the count is no
// longer an observed cycle day, the same ceiling the ring applies.
const { days } = buildCalendar(
profile(),
[cycle("2026-01-01")],
[],
[],
"2026-01-01",
"2026-09-24",
"2026-09-24",
false,
);
expect(dayOf(days, "2026-01-05").cycleDay).toBe(5);
expect(dayOf(days, "2026-08-01").cycleDay).toBeNull();
});
});
});
+140 -6
View File
@@ -21,7 +21,7 @@
*/
import { prisma } from "@/lib/db";
import { addDays, dayDiff } from "./day-math";
import { HARD_CYCLE_MIN } from "./types";
import { HARD_CYCLE_MIN, PERIOD_MAX } from "./types";
import type {
FlowLevel,
Prisma,
@@ -46,6 +46,27 @@ export interface MovedAnchors {
priorLengthAfter?: number | null;
openedEndDateBefore?: string | null;
openedEndDateAfter?: string | null;
/**
* A cycle that started a few days AFTER the opened date and was folded into
* it: the period began earlier than first recorded, not twice.
*/
absorbedCycleId?: string;
absorbedStartDate?: string;
}
/**
* Whether a cycle starting at `laterStart` is the same period as one starting
* at `date`, just recorded late: it begins within a maximum-length period
* after `date`. Two starts that close together are one period entered out of
* order, never two cycles, and keeping both leaves a one- or two-day cycle
* that corrupts every length after it.
*/
export function isSamePeriodStartedEarlier(
date: string,
laterStart: string,
): boolean {
const gap = dayDiff(laterStart, date);
return gap > 0 && gap < PERIOD_MAX;
}
/**
@@ -71,6 +92,34 @@ export async function openCycleAt(
): Promise<{ cycleId: string; moved: MovedAnchors }> {
const moved: MovedAnchors = {};
// A start recorded a few days after this date is the same period, entered
// in the other order (day two first, day one remembered later). Fold it
// into the start being opened instead of leaving a cycle a day or two long
// in front of it. What that row knew beyond its start carries over below.
const following = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: { gt: date } },
orderBy: { startDate: "asc" },
select: {
id: true,
startDate: true,
periodEndDate: true,
ovulationDate: true,
ovulationConfirmed: true,
},
});
const absorbed =
following && isSamePeriodStartedEarlier(date, following.startDate)
? following
: null;
if (absorbed) {
moved.absorbedCycleId = absorbed.id;
moved.absorbedStartDate = absorbed.startDate;
await db.menstrualCycle.update({
where: { id: absorbed.id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
}
// Close the prior open cycle: its end is the day before this start.
const prior = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: { lt: date } },
@@ -93,12 +142,26 @@ export async function openCycleAt(
});
}
const carried = absorbed
? {
...(absorbed.periodEndDate
? { periodEndDate: absorbed.periodEndDate }
: {}),
...(absorbed.ovulationDate
? {
ovulationDate: absorbed.ovulationDate,
ovulationConfirmed: absorbed.ovulationConfirmed,
}
: {}),
}
: {};
const cycle = await db.menstrualCycle.upsert({
where: { userId_startDate: { userId, startDate: date } },
create: { userId, startDate: date, tz, isPredicted: false },
create: { userId, startDate: date, tz, isPredicted: false, ...carried },
update: {
deletedAt: null,
isPredicted: false,
...carried,
syncVersion: { increment: 1 },
},
});
@@ -120,11 +183,58 @@ export async function openCycleAt(
syncVersion: { increment: 1 },
},
});
} else if (cycle.endDate != null || cycle.lengthDays != null) {
// With nothing after it the cycle is open. A re-tapped start whose row
// still carries an end from a successor that no longer exists (the
// one-day cycles an earlier rule left in front of a real start, once
// that start is folded in) would otherwise stay closed on a stale date.
moved.openedEndDateBefore = cycle.endDate;
moved.openedEndDateAfter = null;
await db.menstrualCycle.update({
where: { id: cycle.id },
data: { endDate: null, lengthDays: null, syncVersion: { increment: 1 } },
});
}
// The days between this start and the next one belong to this cycle now.
// A start back-filled into the middle of an existing cycle used to leave
// them attributed to the cycle before it.
await attributeDaysToCycle(
db,
userId,
cycle.id,
date,
next?.startDate ?? null,
);
return { cycleId: cycle.id, moved };
}
/**
* Point every live day-log in `[from, until)` at `cycleId` (open-ended when
* `until` is null), bumping `syncVersion` only on rows whose attribution
* actually changes so the sync feed does not re-pull untouched days.
*/
async function attributeDaysToCycle(
db: CycleDb,
userId: string,
cycleId: string | null,
from: string,
until: string | null,
): Promise<void> {
await db.cycleDayLog.updateMany({
where: {
userId,
deletedAt: null,
date: { gte: from, ...(until ? { lt: until } : {}) },
...(cycleId === null
? { cycleId: { not: null } }
: { OR: [{ cycleId: null }, { cycleId: { not: cycleId } }] }),
},
data: { cycleId, syncVersion: { increment: 1 } },
});
}
/**
* Whether a logged day's bleeding is the first day of a period, and therefore
* opens a cycle.
@@ -205,6 +315,22 @@ export async function ensureCycleForBleedingDay(
) {
return null;
}
// The same judgement looking forward. A logged start a few days later is
// this period entered out of order, and `openCycleAt` folds it in. One
// further out but still inside the hard minimum is neither the same
// period nor a cycle of its own, so nothing is inferred from the flow.
const following = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: { gt: date } },
orderBy: { startDate: "asc" },
select: { startDate: true },
});
if (
following &&
dayDiff(following.startDate, date) < HARD_CYCLE_MIN &&
!isSamePeriodStartedEarlier(date, following.startDate)
) {
return null;
}
const { cycleId } = await openCycleAt(db, userId, date, tz);
return cycleId;
});
@@ -233,10 +359,18 @@ export async function removeCycleStartedOn(
where: { id: opened.id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
return {
cycleId: opened.id,
reanchored: await reanchorAfterRemovedStart(db, userId, date),
};
const reanchored = await reanchorAfterRemovedStart(db, userId, date);
// The removed cycle's days go back to the cycle before it (or to none).
const prior = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: { lt: date } },
orderBy: { startDate: "desc" },
select: { id: true },
});
await db.cycleDayLog.updateMany({
where: { userId, cycleId: opened.id },
data: { cycleId: prior?.id ?? null, syncVersion: { increment: 1 } },
});
return { cycleId: opened.id, reanchored };
}
/** The boundary columns the re-derivation moved, for the audit row. */
+27
View File
@@ -20,7 +20,9 @@ import {
resolveLuteal,
clampLuteal,
POPULATION_DEFAULT_CYCLE,
PERIOD_MAX,
} from "@/lib/cycle";
import { resolveCycleDay } from "./verdict";
import type {
CycleProfile,
CycleDayLog,
@@ -41,6 +43,21 @@ export interface CalendarDayDTO {
* wrong thing.
*/
isCycleStart: boolean;
/**
* The 1-based day of the logged cycle this date belongs to, or null before
* the first logged start, after today, or past the point where an open
* cycle's count stops being an observed fact (the verdict's ceiling). For
* today it equals `verdict.dayOfCycle`. The log sheet labels a date with
* this, never with today's count.
*/
cycleDay: number | null;
/**
* Whether the one-tap period end can land on this date: it sits inside the
* first `PERIOD_MAX` days of a logged cycle, which is where the end
* boundary stamps `periodEndDate`. Resolved per date so a back-dated period
* can be closed whatever phase today is in.
*/
periodEndable: boolean;
flow: string | null;
hasSymptoms: boolean;
confidence: number;
@@ -376,6 +393,12 @@ export function buildCalendar(
for (const l of dayLogs) logByDate.set(l.date, l);
const cycleStartDates = new Set(cycles.map((c) => c.startDate));
const sortedStarts = [...cycleStartDates].sort((a, b) => dayDiff(a, b));
const profileLengths = {
typicalCycleLength: profile.typicalCycleLength,
typicalPeriodLength: profile.typicalPeriodLength,
lutealPhaseLength: profile.lutealPhaseLength,
};
const days: CalendarDayDTO[] = [];
const span = dayDiff(to, from);
@@ -405,6 +428,8 @@ export function buildCalendar(
prediction?.predictedOvulation != null &&
prediction.predictedOvulation === date;
const cycleDay = resolveCycleDay(date, sortedStarts, today, profileLengths);
days.push({
date,
// No asserted phase band while learning — a population-28 frame off a
@@ -415,6 +440,8 @@ export function buildCalendar(
isPredictedOvulation,
isPeriodLogged: log?.flow != null && log.flow !== "NONE",
isCycleStart: cycleStartDates.has(date),
cycleDay,
periodEndable: cycleDay !== null && cycleDay <= PERIOD_MAX,
flow: log?.flow ?? null,
hasSymptoms: log?.hasSymptoms ?? false,
confidence: prediction?.confidence ?? 0,
+35
View File
@@ -284,6 +284,41 @@ function verdictFromLoggedStart(
};
}
/**
* The 1-based day of the logged cycle `date` falls in, or null when the record
* cannot say.
*
* This is the per-date sibling of the verdict's `dayOfCycle`, and it answers
* for the date asked about rather than for today. A date belongs to the latest
* logged start at or before it. A cycle that has a successor is closed, so any
* day inside it is a recorded fact. The open cycle runs through today and is
* held to the same ceiling as the verdict: past the typical length plus the
* grace window the count is the engine's open-ended window, not an observed
* day. No count before the first logged start, and none after today.
*
* `starts` must be sorted ascending.
*/
export function resolveCycleDay(
date: string,
starts: readonly string[],
today: string,
profile?: CycleProfileLengths,
): number | null {
if (dayDiff(date, today) > 0) return null;
let idx = -1;
for (let i = 0; i < starts.length; i++) {
if (dayDiff(date, starts[i]) >= 0) idx = i;
else break;
}
if (idx < 0) return null;
const day = dayDiff(date, starts[idx]) + 1;
const isOpenCycle = idx === starts.length - 1;
if (isOpenCycle && day > typicalCycleLength(profile) + OVERDUE_GRACE_DAYS) {
return null;
}
return day;
}
/**
* Resolve everything a client needs to render the cycle ring and its caption.
* The only place in the product that decides any of it.
+12 -10
View File
@@ -44,8 +44,9 @@ vi.mock("@/lib/analytics/intraday-pulse-io", () => ({
loadIntradayPulse: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/record", () => ({
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: vi.fn(),
aiCapabilityToServe: vi.fn(),
}));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
@@ -66,7 +67,10 @@ import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
import { prisma } from "@/lib/db";
import { aiCapabilityForRecord } from "@/lib/ai/capabilities/record";
import {
aiCapabilityForRecord,
aiCapabilityToServe,
} from "@/lib/ai/capabilities/gate";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import {
AI_AVAILABLE,
@@ -111,6 +115,7 @@ beforeEach(() => {
vi.mocked(probeRollupCoverage).mockResolvedValue(new Map());
vi.mocked(loadIntradayPulse).mockResolvedValue({ tension: null } as never);
vi.mocked(aiCapabilityForRecord).mockResolvedValue(AI_AVAILABLE);
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
});
describe("loadDailyDigest — S11/S12 extras cache", () => {
@@ -222,10 +227,7 @@ describe("loadDailyDigest — AI parts", () => {
const digest = await loadDailyDigest(USER, NOW);
expect(aiCapabilityForRecord).toHaveBeenCalledWith("user-1", "coach");
expect(aiCapabilityForRecord).toHaveBeenCalledWith(
"user-1",
"reactionLines",
);
expect(aiCapabilityToServe).toHaveBeenCalledWith("user-1", "reactionLines");
expect(digest.ai).toEqual({
briefing: AI_AVAILABLE,
coach: AI_AVAILABLE,
@@ -244,8 +246,8 @@ describe("loadDailyDigest — AI parts", () => {
});
it("neither decrypts nor serves a stored reaction line while the capability is unavailable", async () => {
vi.mocked(aiCapabilityForRecord).mockImplementation(async (_id, key) =>
key === "reactionLines" ? aiUnavailable("user_disabled") : AI_AVAILABLE,
vi.mocked(aiCapabilityToServe).mockResolvedValue(
aiUnavailable("user_disabled"),
);
vi.mocked(prisma.arrivalReaction.findMany).mockResolvedValueOnce([
ARRIVAL,
@@ -260,8 +262,8 @@ describe("loadDailyDigest — AI parts", () => {
});
it("does not decrypt plan prose while the coach capability is unavailable", async () => {
vi.mocked(aiCapabilityForRecord).mockImplementation(async (_id, key) =>
key === "coach" ? aiUnavailable("operator_disabled") : AI_AVAILABLE,
vi.mocked(aiCapabilityForRecord).mockResolvedValue(
aiUnavailable("operator_disabled"),
);
vi.mocked(prisma.coachPlan.findMany).mockResolvedValueOnce([
{
+13 -10
View File
@@ -17,11 +17,14 @@ import type { User } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { resolveModuleMap } from "@/lib/modules/gate";
import { aiCapabilityForRecord } from "@/lib/ai/capabilities/record";
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import {
aiCapabilityForRecord,
aiCapabilityToServe,
} from "@/lib/ai/capabilities/gate";
import { userDayKey } from "@/lib/tz/format";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
@@ -315,8 +318,8 @@ function toDigestArrival(
lineEncrypted: Uint8Array | null;
generatedAt: Date | null;
},
/** The `reactionLines` capability: the line is model text. */
linesAvailable: boolean,
/** Whether the record's `reactionLines` text may be shown. */
linesServable: boolean,
): DailyDigestArrival | null {
// A kind this build does not know about (a row written by a newer version)
// is dropped rather than widened — the DTO's kind union is closed.
@@ -325,12 +328,9 @@ function toDigestArrival(
let line: string | null = null;
// The ciphertext rides only once the generation actually COMMITTED. A row
// mid-generation carries no `generatedAt`, and its line must not surface.
// Nor while the capability is unavailable: it is not even decrypted.
if (
linesAvailable &&
row.generatedAt !== null &&
row.lineEncrypted !== null
) {
// A model-written line is never served (nor decrypted) while
// `reactionLines` is unavailable for the record; the marker still is.
if (linesServable && row.generatedAt !== null && row.lineEncrypted !== null) {
try {
line = decryptFromBytes(row.lineEncrypted);
} catch {
@@ -481,8 +481,11 @@ export async function loadDailyDigest(
practitioner: { select: { name: true } },
},
}),
// The check-in opens the Coach, so it asks as the person in front of
// the screen; the reaction line is stored text, shown whenever the
// record's own state allows it, whoever is reading.
aiCapabilityForRecord(user.id, "coach"),
aiCapabilityForRecord(user.id, "reactionLines"),
aiCapabilityToServe(user.id, "reactionLines"),
]);
// The snapshot read has already applied the `briefing` capability and
@@ -27,7 +27,7 @@ const briefingBody = {
briefingAi: null,
};
const buildDashboardSnapshot = vi.fn(async () => briefingBody);
const aiCapabilityForRecord = vi.fn();
const aiCapabilityToServe = vi.fn();
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({
@@ -37,8 +37,8 @@ vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: async () => "en",
}));
vi.mock("@/lib/ai/capabilities/record", () => ({
aiCapabilityForRecord: (...args: unknown[]) => aiCapabilityForRecord(...args),
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityToServe: (...args: unknown[]) => aiCapabilityToServe(...args),
}));
const { readDashboardSnapshotCached } = await import("../snapshot-read");
@@ -49,26 +49,26 @@ const USER = { id: "user-briefing-gate", locale: "en" } as unknown as User;
beforeEach(() => {
__resetAllCachesForTests();
buildDashboardSnapshot.mockClear();
aiCapabilityForRecord.mockReset();
aiCapabilityToServe.mockReset();
});
describe("readDashboardSnapshotCached — briefing capability per read", () => {
it("resolves the briefing capability for the record being read", async () => {
aiCapabilityForRecord.mockResolvedValue(AI_AVAILABLE);
aiCapabilityToServe.mockResolvedValue(AI_AVAILABLE);
await readDashboardSnapshotCached(USER);
expect(aiCapabilityForRecord).toHaveBeenCalledWith(
expect(aiCapabilityToServe).toHaveBeenCalledWith(
"user-briefing-gate",
"briefing",
);
});
it("hides the cached briefing on the next read once the capability goes away", async () => {
aiCapabilityForRecord.mockResolvedValue(AI_AVAILABLE);
aiCapabilityToServe.mockResolvedValue(AI_AVAILABLE);
const first = await readDashboardSnapshotCached(USER);
expect(first.body.briefing).not.toBeNull();
expect(first.body.briefingAi).toEqual(AI_AVAILABLE);
aiCapabilityForRecord.mockResolvedValue(aiUnavailable("consent_required"));
aiCapabilityToServe.mockResolvedValue(aiUnavailable("consent_required"));
const second = await readDashboardSnapshotCached(USER);
// Served from the same cell: the builder ran once.
@@ -79,11 +79,11 @@ describe("readDashboardSnapshotCached — briefing capability per read", () => {
});
it("shows it again when the capability comes back, without a rebuild", async () => {
aiCapabilityForRecord.mockResolvedValue(aiUnavailable("operator_disabled"));
aiCapabilityToServe.mockResolvedValue(aiUnavailable("operator_disabled"));
const hidden = await readDashboardSnapshotCached(USER);
expect(hidden.body.briefing).toBeNull();
aiCapabilityForRecord.mockResolvedValue(AI_AVAILABLE);
aiCapabilityToServe.mockResolvedValue(AI_AVAILABLE);
const shown = await readDashboardSnapshotCached(USER);
expect(buildDashboardSnapshot).toHaveBeenCalledTimes(1);
expect(shown.body.briefing).not.toBeNull();
@@ -25,8 +25,8 @@ vi.mock("@/lib/dashboard/snapshot", () => ({
buildDashboardSnapshot(...(args as [])),
applyBriefingCapability: (body: unknown) => body,
}));
vi.mock("@/lib/ai/capabilities/record", () => ({
aiCapabilityForRecord: async () => ({
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
+6 -5
View File
@@ -34,7 +34,7 @@ import {
type DashboardSnapshot,
type SnapshotUserInput,
} from "@/lib/dashboard/snapshot";
import { aiCapabilityForRecord } from "@/lib/ai/capabilities/record";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import type { Locale } from "@/lib/i18n/config";
@@ -123,12 +123,13 @@ export async function readDashboardSnapshotCached(
annotate,
SNAPSHOT_CACHE_TTL_MS,
),
aiCapabilityForRecord(user.id, "briefing"),
aiCapabilityToServe(user.id, "briefing"),
]);
// The briefing is model text: shown only while the `briefing` capability is
// available for this record, decided on every read rather than baked into
// the cached body.
// The briefing is model text: shown only while the record's `briefing`
// capability is available, decided on every read rather than baked into
// the cached body. The record's own state decides, whoever is reading, so a
// delegate sees the owner's briefing exactly when the owner would.
const body = applyBriefingCapability(cached, briefingAi);
return { body, locale };
+1
View File
@@ -157,6 +157,7 @@ export const WIPE_MODELS = [
"CoachReminder",
"CoachUsage",
"InsightNarrative",
"InsightStatusCache",
"ProviderHealth",
// ── Sharing ─────────────────────────────────────────────────────────────
@@ -2,9 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* v1.29.x (S7) — the shared fenced turn pipeline (design §3, adversarial tests
* 12, 14, 16, 17, 18, 19). Pins: per-document egress consent fan-out (ALL clear
* or refuse, zero egress + no user turn persisted on partial consent), the SINGLE
* consent-checked provider (no cascade), numeric grounding over the LIVE
* 12, 14, 16, 17, 18, 19). Pins: the capability re-check at the wire (the
* document pick answers `documentAi`, each extra capability the entry point
* names is asked for the same provider; any refusal means zero egress and no
* user turn persisted), the SINGLE checked provider (no cascade), numeric grounding over the LIVE
* attachment UNION, and the owner-scoped context loader (a corrupted/foreign join
* row yields null → the turn refuses, never foreign text).
*/
@@ -21,8 +22,8 @@ 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),
vi.mock("@/lib/ai/capabilities/egress", () => ({
assertAiEgress: vi.fn(),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-16"),
@@ -49,7 +50,8 @@ import {
} from "../fenced-chat";
import { loadDocumentChatText } from "@/lib/documents/content-index";
import { resolveDocumentTextProvider } from "@/lib/documents/provider-order";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import { runStreamingRawCompletionWithFallback } from "@/lib/ai/provider-runner";
import { appendMessage } from "@/lib/ai/coach/persistence";
@@ -90,12 +92,13 @@ const baseArgs = {
contractLocale: "en" as const,
locale: "en" as const,
signal: new AbortController().signal,
alsoRequires: [],
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(resolveDocumentTextProvider).mockResolvedValue(PICK as never);
vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined);
vi.mocked(assertAiEgress).mockResolvedValue(undefined);
vi.mocked(appendMessage).mockImplementation(
async (p) => ({ id: p.role === "assistant" ? "a" : "u", ...p }) as never,
);
@@ -106,36 +109,67 @@ beforeEach(() => {
} as never);
});
describe("streamFencedReply — consent fan-out", () => {
it("checks egress consent ONCE PER attached document", async () => {
await streamFencedReply({
...baseArgs,
docs: [doc("a", "x"), doc("b", "y"), doc("c", "z")],
});
expect(assertDocumentEgressConsent).toHaveBeenCalledTimes(3);
expect(assertDocumentEgressConsent).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
providerType: "anthropic",
surface: "insights",
}),
);
});
it("refuses (throws) with ZERO egress + no user turn persisted when ANY document's consent fails", async () => {
vi.mocked(assertDocumentEgressConsent)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("ConsentRequiredError"));
describe("streamFencedReply — the capability at the wire", () => {
it("throws the document pick's refusal with ZERO egress and no user turn persisted", async () => {
const refusal = new AiUnavailableError("documentAi", "consent_required");
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
chain: [],
pick: null,
withheld: refusal,
} as never);
await expect(
streamFencedReply({ ...baseArgs, docs: [doc("a", "x"), doc("b", "y")] }),
).rejects.toThrow();
).rejects.toBe(refusal);
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
expect(appendMessage).not.toHaveBeenCalled();
});
it("asks every extra capability about the picked provider", async () => {
await drain(
await streamFencedReply({
...baseArgs,
alsoRequires: ["coach"],
docs: [doc("a", "x"), doc("b", "y")],
}),
);
expect(assertAiEgress).toHaveBeenCalledTimes(1);
expect(assertAiEgress).toHaveBeenCalledWith("coach", "user-1", [
"anthropic",
]);
});
it("refuses with ZERO egress when an extra capability closed", async () => {
vi.mocked(assertAiEgress).mockRejectedValueOnce(
new AiUnavailableError("coach", "operator_disabled"),
);
await expect(
streamFencedReply({
...baseArgs,
alsoRequires: ["coach"],
docs: [doc("a", "x")],
}),
).rejects.toBeInstanceOf(AiUnavailableError);
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
expect(appendMessage).not.toHaveBeenCalled();
});
it("keeps a missing provider an SSE frame, not a refusal", async () => {
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
chain: [],
pick: null,
withheld: null,
} as never);
const events = await drain(
await streamFencedReply({ ...baseArgs, docs: [doc("a", "x")] }),
);
expect(events).toContainEqual(
expect.objectContaining({ code: "documents.chat.provider.none" }),
);
});
});
describe("streamFencedReply — single provider, no cascade", () => {
it("passes exactly the ONE consent-checked provider entry to the runner", async () => {
it("passes exactly the ONE checked provider entry to the runner", async () => {
await drain(
await streamFencedReply({ ...baseArgs, docs: [doc("a", "x")] }),
);
@@ -53,6 +53,7 @@ vi.mock("@/lib/ai/ai-budgets", () => ({
}));
import { indexDocumentContent } from "../index-document";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import {
loadOwnedDocument,
prepareVisionInput,
@@ -259,6 +260,26 @@ describe("indexDocumentContent — local fallback path", () => {
});
});
describe("indexDocumentContent — the documentAi capability", () => {
it("indexes through the provider-free text layer when the pick was withheld", async () => {
// The operator turned document reading off after the upload enqueued this
// job: the pick comes back empty with the refusal beside it, no model is
// called, and the text layer still makes the document searchable.
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
chain: [],
pick: null,
withheld: new AiUnavailableError("documentAi", "operator_disabled"),
} as never);
const outcome = await indexDocumentContent("user-1", "doc-1");
expect(transcribeDocument).not.toHaveBeenCalled();
expect(reserveBudget).not.toHaveBeenCalled();
expect(localExtractText).toHaveBeenCalledTimes(1);
expect(outcome).toMatchObject({ indexed: true });
});
});
describe("indexDocumentContent — fail-closed", () => {
it("returns not-found for an unknown / non-owned document", async () => {
vi.mocked(loadOwnedDocument).mockResolvedValue(null as never);
@@ -12,13 +12,27 @@ vi.mock("@/lib/labs/ocr-capability", () => ({
resolveVisionProvider: vi.fn(),
resolveTextProvider: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/egress", () => ({
aiEgressRefusal: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
getAiCapability: vi.fn(),
}));
import {
documentEgressClass,
reorderChainForDocumentClass,
requireDocumentTextProvider,
requireDocumentVisionProvider,
resolveDocumentAiCapability,
resolveDocumentTextProvider,
resolveDocumentVisionProvider,
} from "../provider-order";
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import {
resolveTextProvider,
resolveVisionProvider,
type VisionProviderPick,
} from "@/lib/labs/ocr-capability";
@@ -103,7 +117,11 @@ describe("documentEgressClass", () => {
});
describe("resolveDocumentVisionProvider", () => {
beforeEach(() => vi.mocked(resolveVisionProvider).mockReset());
beforeEach(() => {
vi.mocked(resolveVisionProvider).mockReset();
vi.mocked(aiEgressRefusal).mockReset();
vi.mocked(aiEgressRefusal).mockResolvedValue(null);
});
it("invokes the shared resolver with the document reorder that demotes codex", async () => {
vi.mocked(resolveVisionProvider).mockResolvedValue({
@@ -124,3 +142,152 @@ describe("resolveDocumentVisionProvider", () => {
).toEqual(["local", "codex"]);
});
});
function visionPick(providerType: string): VisionProviderPick {
return {
chain: [entry(providerType)],
localOcrEnabled: false,
pick: {
entry: entry(providerType),
providerType: providerType as never,
pdfSupported: false,
},
};
}
describe("the document pick re-checks documentAi at the wire", () => {
beforeEach(() => {
vi.mocked(resolveVisionProvider).mockReset();
vi.mocked(resolveTextProvider).mockReset();
vi.mocked(aiEgressRefusal).mockReset();
vi.mocked(aiEgressRefusal).mockResolvedValue(null);
});
it("asks about the picked provider, under documentAi", async () => {
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("openai"));
const result = await resolveDocumentVisionProvider("u1");
expect(aiEgressRefusal).toHaveBeenCalledWith("documentAi", "u1", [
"openai",
]);
expect(result.pick?.providerType).toBe("openai");
expect(result.withheld).toBeNull();
});
it("withholds the pick when the capability closed after the route answered", async () => {
// A job enqueued before the operator turned the switch off: the pick is
// resolved, and the wire says no. The caller sees no provider to call.
const refusal = new AiUnavailableError("documentAi", "operator_disabled");
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local"));
vi.mocked(aiEgressRefusal).mockResolvedValue(refusal);
const result = await resolveDocumentVisionProvider("u1");
expect(result.pick).toBeNull();
expect(result.withheld).toBe(refusal);
});
it("withholds a text-mode pick the same way", async () => {
const refusal = new AiUnavailableError("documentAi", "consent_required");
vi.mocked(resolveTextProvider).mockResolvedValue({
chain: [entry("codex")],
pick: { entry: entry("codex"), providerType: "codex" },
});
vi.mocked(aiEgressRefusal).mockResolvedValue(refusal);
const result = await resolveDocumentTextProvider("u1");
expect(aiEgressRefusal).toHaveBeenCalledWith("documentAi", "u1", ["codex"]);
expect(result.pick).toBeNull();
expect(result.withheld).toBe(refusal);
});
it("does not ask when nothing was picked", async () => {
vi.mocked(resolveVisionProvider).mockResolvedValue({
chain: [],
localOcrEnabled: false,
pick: null,
});
const result = await resolveDocumentVisionProvider("u1");
expect(aiEgressRefusal).not.toHaveBeenCalled();
expect(result.withheld).toBeNull();
});
it("the route form throws the refusal", async () => {
const refusal = new AiUnavailableError("documentAi", "operator_disabled");
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local"));
vi.mocked(aiEgressRefusal).mockResolvedValue(refusal);
await expect(requireDocumentVisionProvider("u1")).rejects.toBe(refusal);
});
it("the route form refuses a missing provider with the vault's own code", async () => {
vi.mocked(resolveTextProvider).mockResolvedValue({ chain: [], pick: null });
const error = await requireDocumentTextProvider("u1").catch((e) => e);
expect(error).toBeInstanceOf(AiUnavailableError);
expect(error.status).toBe(422);
expect(error.meta).toEqual({
errorCode: "documents.inbound.providerUnsupported",
capability: "documentAi",
reason: "no_provider",
});
});
});
describe("resolveDocumentAiCapability", () => {
beforeEach(() => {
vi.mocked(resolveVisionProvider).mockReset();
vi.mocked(aiEgressRefusal).mockReset();
vi.mocked(getAiCapability).mockReset();
});
it("reports the capability beside the provider answer", async () => {
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local"));
vi.mocked(getAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
const dto = await resolveDocumentAiCapability("u1");
expect(dto).toMatchObject({
available: true,
mode: "vision",
egress: "local",
ai: { available: true, reason: null },
});
// A probe never runs the wire re-check: it sends nothing.
expect(aiEgressRefusal).not.toHaveBeenCalled();
});
it("is unavailable when the operator turned document reading off", async () => {
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("local"));
vi.mocked(getAiCapability).mockResolvedValue({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
});
const dto = await resolveDocumentAiCapability("u1");
expect(dto).toEqual({
available: false,
mode: null,
reason: null,
pdfSupported: false,
egress: null,
ai: {
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
},
});
});
it("stays available when only a consent receipt is missing, so the read can ask for it", async () => {
vi.mocked(resolveVisionProvider).mockResolvedValue(visionPick("openai"));
vi.mocked(getAiCapability).mockResolvedValue({
available: false,
reason: "consent_required",
onDeviceAllowed: true,
});
const dto = await resolveDocumentAiCapability("u1");
expect(dto).toMatchObject({
available: true,
mode: "vision",
egress: "external",
ai: { reason: "consent_required" },
});
});
});
+4 -3
View File
@@ -6,9 +6,10 @@
* uploaded documents. OFF by default: the vault stays local-first and every
* external egress needs an explicit per-document action + consent receipt.
*
* Two gates read this flag:
* - the document consent gate (`assertDocumentEgressConsent`) short-circuits an
* external pick when it is ON (the toggle IS the standing consent);
* It is a trigger, not a consent. Switching it on mints an `ai_extraction`
* receipt, and that receipt is what the document egress re-check reads, so a
* revoked receipt stops external reading even with the flag still on. Readers:
* - the summary job and its catch-up pass run only while it is ON;
* - the auto-index-on-upload job stays strictly local when it is OFF and uses
* the document-order external pick when it is ON.
*/
+33 -23
View File
@@ -19,9 +19,12 @@
* - NO TOOLS, NO SNAPSHOT — one completion per turn.
* - PROMPT-INJECTION FENCING — every document fenced as DATA (`fenceDocument`),
* per-doc header fields marker-scrubbed (`buildFencedChatSystemPrompt`).
* - PER-DOCUMENT EGRESS CONSENT — the single picked provider (no cascade) is
* consent-checked once per attached document; ALL must clear or the turn
* refuses (403) BEFORE any egress and BEFORE the user turn is persisted.
* - CAPABILITY AT THE WIRE — the single picked provider (no cascade) is
* re-checked against `documentAi` (the operator's switch, the module, the
* sharing grant, and a document consent receipt for a pick that leaves the
* machine) and against every other capability the entry point names (the
* Coach, for a turn inside a Coach conversation). Any refusal is a 403
* BEFORE any egress and BEFORE the user turn is persisted.
* - NUMERIC GROUNDING over the LIVE attachment union only — recomputed per turn
* from the join rows, never history / snapshot.
* - OWNER-SCOPED TEXT LOADER as a second ownership layer — a corrupted/foreign
@@ -33,7 +36,8 @@ import { prisma } from "@/lib/db";
import { createSseStream } from "@/lib/sse/create-stream";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
import {
AllProvidersFailedError,
runStreamingRawCompletionWithFallback,
@@ -260,12 +264,19 @@ export interface StreamFencedReplyArgs {
/** i18n locale for outbound-guard fallbacks. */
locale: Locale;
signal: AbortSignal;
/**
* The capabilities this turn is answered under, beside `documentAi`, which
* every fenced turn needs because it reads documents. The Coach's fenced
* chat adds `coach`; the vault's single-document chat adds nothing.
*/
alsoRequires: readonly AiCapabilityKey[];
}
/**
* Run ONE fenced turn against a resolved conversation and its live documents, and
* return the SSE response. May throw `ConsentRequiredError` (403, rendered as
* JSON by the api-handler) BEFORE any egress — the consent fan-out runs first.
* return the SSE response. May throw `AiUnavailableError` (403, rendered as
* JSON by the api-handler) BEFORE any egress — the capability re-check runs
* first.
*/
export async function streamFencedReply(
args: StreamFencedReplyArgs,
@@ -274,29 +285,28 @@ export async function streamFencedReply(
args;
// ── Provider resolution: document order (local-first, codex last), SINGLE
// pick, NO cascade — so the exact egress equals the consent-checked target. ──
const { pick } = await resolveDocumentTextProvider(userId);
// pick, NO cascade — so the exact egress equals the checked target. The pick
// re-checks `documentAi` for that provider, consent receipt included; a
// refusal is thrown (403) before any egress. ──
const { pick, withheld } = await resolveDocumentTextProvider(userId);
if (withheld) throw withheld;
if (!pick) {
annotate({ action: { name: "documents.chat.noProvider" } });
return streamFencedError("documents.chat.provider.none");
}
// ── Per-document egress consent fan-out. Structured as a loop over the live
// attachments (annotated per doc id) so a future per-document consent
// dimension slots into this seam. ALL must clear or the first throws
// ConsentRequiredError (403) — zero egress on partial consent, BEFORE budget
// and BEFORE the user turn is persisted. ──
for (const doc of docs) {
annotate({
action: { name: "documents.chat.consent_check" },
meta: { documentId: doc.documentId, provider: pick.providerType },
});
await assertDocumentEgressConsent({
userId,
providerType: pick.providerType,
surface: "insights",
});
// ── Every other capability the entry point answers under, for the same
// provider, BEFORE budget and BEFORE the user turn is persisted. ──
for (const key of args.alsoRequires) {
await assertAiEgress(key, userId, [pick.providerType]);
}
annotate({
action: { name: "documents.chat.consent_check" },
meta: {
documentIds: docs.map((doc) => doc.documentId),
provider: pick.providerType,
},
});
// Persist the user's turn first so it's on disk regardless of the outcome.
await appendMessage({ conversationId, role: "user", content: message });
+16 -3
View File
@@ -3,8 +3,13 @@
* both the per-document auto-index-on-upload job and the corpus backfill.
*
* Provider resolution follows the DOCUMENT order (local-first, codex last —
* `resolveDocumentVisionProvider`), NOT the cost-first app-wide chain, and the
* external egress is governed by the per-user `documentsAutoAiRead` opt-in:
* `resolveDocumentVisionProvider`), NOT the cost-first app-wide chain. That
* pick re-checks the `documentAi` capability for the provider it picked: with
* the operator's switch off, the module off, or (for a pick that leaves the
* machine) no extraction consent receipt, there is no pick and the tree goes
* straight to the provider-free text-layer path below, so search keeps working
* with AI off. The external egress is further governed by the per-user
* `documentsAutoAiRead` opt-in:
*
* 1. LOCAL PROVIDER (a self-hosted vision model) — never egresses, so it runs
* whenever it is the document-order pick, toggle-independent.
@@ -149,7 +154,15 @@ export interface ResolvedIndexProvider {
export async function resolveIndexProvider(
userId: string,
): Promise<ResolvedIndexProvider> {
const { chain, pick } = await resolveDocumentVisionProvider(userId);
// A pick the `documentAi` capability refused comes back null, which lands on
// the local text-layer path exactly like having no provider at all.
const { chain, pick, withheld } = await resolveDocumentVisionProvider(userId);
if (withheld) {
annotate({
action: { name: "documents.contentIndex.providerWithheld" },
meta: { reason: withheld.reason },
});
}
let consentOk = false;
if (pick) {
if (!isExternalDocumentEgress(pick.providerType)) {
+134 -21
View File
@@ -19,12 +19,28 @@
* consented opt-in.
*
* The reorder only decides WHICH configured provider is preferred; it never
* invents a provider. Egress consent (`assertDocumentEgressConsent`) and the
* per-egress vendor-blind UI notice sit on top of this order — the reorder
* keeps codex from being the silent default, the consent gate keeps any
* external egress from happening without an active receipt.
* invents a provider. The per-egress vendor-blind UI notice sits on top of
* this order — the reorder keeps codex from being the silent default.
*
* The pick is also the document class's wire: every read of a stored document
* resolves its provider here, the vault routes and the background jobs alike,
* so this is where the `documentAi` capability is asked again, about the
* provider actually picked, immediately before anything is sent. A switch the
* operator turned off after a job was enqueued stops the job here, and an
* external pick without an extraction consent receipt never reaches its
* provider. The document class is the `documentAi` capability; there is no
* other key a caller could pass, so the check is built in rather than taken
* as an argument.
*/
import { isExternalDocumentEgress } from "@/lib/ai/consent-guard";
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types";
import {
AiUnavailableError,
type NoProviderRefusal,
} from "@/lib/ai/capabilities/refusal";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import { documentProviderRank } from "@/lib/documents/provider-rank";
import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf";
import {
@@ -38,6 +54,15 @@ import type {
DocumentEgressClass,
} from "@/lib/validations/inbound-documents";
/**
* How the vault refuses a read with no provider that can serve it. The code
* predates the capability envelope and a client already branches on it.
*/
export const DOCUMENT_NO_PROVIDER: NoProviderRefusal = {
errorCode: "documents.inbound.providerUnsupported",
status: 422,
};
/**
* Reorder a resolved chain for the document class: stable sort by
* `documentProviderRank`, so within a rank tier the user's own chain order is
@@ -53,27 +78,92 @@ export const reorderChainForDocumentClass: ChainReorder = (chain) => {
};
/**
* Resolve the vision provider for a DOCUMENT read — local-first, codex last.
* Same shape as `resolveVisionProvider`; the returned `chain` is already in
* document order and the `pick` is the first vision-capable entry in it.
* A document pick after the wire re-check. `pick` is null when nothing can
* serve the read OR when the capability refused it; `withheld` says which.
* A job reads `pick` and skips; a route throws `withheld` so the person sees
* the real reason.
*/
export function resolveDocumentVisionProvider(
export type DocumentProviderPick<T> = T & {
withheld: AiUnavailableError | null;
};
async function atTheWire<T extends { pick: { providerType: string } | null }>(
userId: string,
): Promise<VisionProviderPick> {
return resolveVisionProvider(userId, {
reorder: reorderChainForDocumentClass,
});
resolved: T,
): Promise<DocumentProviderPick<T>> {
if (!resolved.pick) return { ...resolved, withheld: null };
const withheld = await aiEgressRefusal("documentAi", userId, [
resolved.pick.providerType,
]);
return withheld
? { ...resolved, pick: null, withheld }
: { ...resolved, withheld: null };
}
/**
* Resolve the text-mode (browser-OCR) structuring provider for a DOCUMENT —
* local-first, codex last. The `pick` is the first entry of the reordered
* chain.
* Resolve the vision provider for a DOCUMENT read — local-first, codex last.
* Same shape as `resolveVisionProvider`; the returned `chain` is already in
* document order and the `pick` is the first vision-capable entry in it, or
* null when `documentAi` refuses the wire.
*/
export function resolveDocumentTextProvider(userId: string) {
return resolveTextProvider(userId, {
reorder: reorderChainForDocumentClass,
});
export async function resolveDocumentVisionProvider(
userId: string,
): Promise<DocumentProviderPick<VisionProviderPick>> {
return atTheWire(
userId,
await resolveVisionProvider(userId, {
reorder: reorderChainForDocumentClass,
}),
);
}
/**
* Resolve the text-mode structuring provider for a DOCUMENT — local-first,
* codex last. The `pick` is the first entry of the reordered chain, or null
* when `documentAi` refuses the wire.
*/
export async function resolveDocumentTextProvider(userId: string): Promise<
DocumentProviderPick<{
chain: ProviderChainResolved[];
pick: { entry: ProviderChainResolved; providerType: string } | null;
}>
> {
return atTheWire(
userId,
await resolveTextProvider(userId, {
reorder: reorderChainForDocumentClass,
}),
);
}
function required<T>(result: {
pick: T | null;
withheld: AiUnavailableError | null;
}): T {
if (result.withheld) throw result.withheld;
if (!result.pick) {
throw new AiUnavailableError(
"documentAi",
"no_provider",
null,
DOCUMENT_NO_PROVIDER,
);
}
return result.pick;
}
/**
* The route form of {@link resolveDocumentVisionProvider}: the pick, or the
* refusal thrown for `apiHandler` to render (`documents.inbound.providerUnsupported`
* when nothing can read the document, the capability envelope otherwise).
*/
export async function requireDocumentVisionProvider(userId: string) {
return required(await resolveDocumentVisionProvider(userId));
}
/** The route form of {@link resolveDocumentTextProvider}. */
export async function requireDocumentTextProvider(userId: string) {
return required(await resolveDocumentTextProvider(userId));
}
/**
@@ -89,12 +179,31 @@ export function documentEgressClass(providerType: string): DocumentEgressClass {
* Resolve the document AI capability for the vault UI. The `mode` /
* `pdfSupported` / `egress` reflect the DOCUMENT provider order (local-first),
* so the affordance the UI offers matches exactly what the document routes do.
*
* `ai` is the `documentAi` capability for the request's record. A read the
* operator, the record's modules or the sharing grant close is not offered
* (`available: false`, no mode). A missing consent receipt keeps the read
* offered: the read itself is where the person is asked for the receipt, and
* hiding the button would leave them no way to give it.
*/
export async function resolveDocumentAiCapability(
userId: string,
): Promise<DocumentAiCapabilityDto> {
const { chain, pick, localOcrEnabled } =
await resolveDocumentVisionProvider(userId);
const [{ chain, pick, localOcrEnabled }, ai] = await Promise.all([
resolveVisionProvider(userId, { reorder: reorderChainForDocumentClass }),
getAiCapability("documentAi"),
]);
if (ai.reason !== null && !PICK_DECIDED_REASONS.has(ai.reason)) {
return {
available: false,
mode: null,
reason: null,
pdfSupported: false,
egress: null,
ai,
};
}
// A vision-capable provider is available — the read runs directly over the
// stored original. Egress follows the picked provider. PDFs are readable
@@ -108,6 +217,7 @@ export async function resolveDocumentAiCapability(
reason: null,
pdfSupported: pick.pdfSupported || RASTERIZATION_AVAILABLE,
egress: documentEgressClass(pick.providerType),
ai,
};
}
@@ -119,6 +229,7 @@ export async function resolveDocumentAiCapability(
reason: "no-provider",
pdfSupported: false,
egress: null,
ai,
};
}
@@ -131,6 +242,7 @@ export async function resolveDocumentAiCapability(
reason: null,
pdfSupported: false,
egress: documentEgressClass(chain[0]!.providerType),
ai,
};
}
@@ -141,5 +253,6 @@ export async function resolveDocumentAiCapability(
reason: "enable-local-ocr",
pdfSupported: false,
egress: null,
ai,
};
}
+2
View File
@@ -582,6 +582,8 @@ export const DERIVED_MODELS: Readonly<Record<string, string>> = {
"A concurrency claim guarding the generator above. Meaningless outside the run that took it.",
InsightNarrative:
"Generated prose over measurements. Same reasoning as WorkoutInsight — the record restores, the essay about it does not.",
InsightStatusCache:
"Regenerable cache: the per-metric status notes a model wrote over measurements, rewritten by the nightly run. Same reasoning as InsightNarrative.",
DocumentContentIndex:
"A search index over `InboundDocument` text; rebuilt by the indexer.",
DocumentThumbnail:
@@ -22,10 +22,8 @@ vi.mock("@/lib/logging/context", () => ({
import { prisma } from "@/lib/db";
import {
ASSISTANT_FLAGS_DEFAULT,
AssistantDisabledError,
getAssistantFlags,
loadAssistantSwitches,
requireAssistantSurface,
resolveAssistantFlags,
} from "../index";
@@ -139,52 +137,6 @@ describe("loadAssistantSwitches", () => {
});
});
describe("requireAssistantSurface", () => {
it("returns the resolved flag set when the surface is enabled", async () => {
FIND.mockResolvedValue({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
});
const flags = await requireAssistantSurface("coach");
expect(flags.coach).toBe(true);
});
it("throws AssistantDisabledError when the sub-flag is off", async () => {
FIND.mockResolvedValue({
assistantEnabled: true,
assistantCoachEnabled: false,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
});
await expect(requireAssistantSurface("coach")).rejects.toThrow(
AssistantDisabledError,
);
});
it("throws AssistantDisabledError when the master kills the surface", async () => {
FIND.mockResolvedValue({
assistantEnabled: false,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
});
await expect(requireAssistantSurface("briefing")).rejects.toThrow(
AssistantDisabledError,
);
});
it("AssistantDisabledError carries the surface-tagged errorCode", () => {
const err = new AssistantDisabledError("insightStatus");
expect(err.surface).toBe("insightStatus");
expect(err.errorCode).toBe("assistant.disabled.insightStatus");
});
});
describe("per-request memoisation", () => {
// v1.4.33 — When the same request opens the Coach drawer, five gated
// routes (`/api/insights/chat`, the rail list, each `<metric>-status`
@@ -1,6 +1,6 @@
/**
* Every assistant switch an operator can flip has to move something, a
* switch that was retired stays retired, and the retired gate only shrinks.
* switch that was retired stays retired, and the retired gate stays gone.
*
* `healthScoreExplainer` did not move anything. It gated a caption beside the
* Health-Score delta; the caption's component went when the health score was
@@ -21,19 +21,18 @@
* it. A switch whose reader lands with the routes it covers is named in
* `AWAITING_READER` with the reason, and the entry fails the moment a
* reader appears, so it cannot outlive its reason.
* 3. `requireAssistantSurface` is the retired gate. It answers only the
* operator layer; `requireAiCapability` answers every layer. The routes
* that still call it are frozen below, in both directions: a new caller
* fails, and a caller that moved to the capability gate fails until its
* entry is removed. When the list is empty the function and
* `AssistantDisabledError` are deleted and this guard asserts zero.
* 3. `requireAssistantSurface` and `AssistantDisabledError` are gone. They
* answered only the operator layer; `requireAiCapability` answers every
* layer with the same `assistant.disabled.<switch>` code for the operator
* case. Nothing may define or call them again.
* 4. The matchers find what they are meant to judge (non-zero counts), so a
* matcher that stopped matching cannot agree with an empty world.
*
* Mutation checks: adding `await requireAssistantSurface("coach")` to any
* route outside the frozen list turns guard 3 red by file name; removing the
* `documentAi` entry from `AWAITING_READER` turns guard 2 red; putting
* `assistantCorrelationsEnabled` back on the schema turns guard 1 red.
* route turns guard 3 red by file name; deleting the
* `requireAiCapability("documentAi"` calls from the document routes (the only
* reader of that switch's capabilities in the route tree) turns guard 2 red;
* putting `assistantCorrelationsEnabled` back on the schema turns guard 1 red.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
@@ -99,18 +98,7 @@ const PLUMBING = [
* Switches with no reader yet, and why that is correct for now. An entry is
* a claim with an expiry: the test below fails as soon as a reader appears.
*/
const AWAITING_READER: Record<string, string> = {
documentAi:
"The capabilities it covers (documentAi, labsOcr, medicationExtract) are " +
"resolved and published, but the document, lab-scan and medication " +
"extraction routes move onto requireAiCapability in the same release; " +
"until then the master switch is what stops them.",
};
/**
* Every file that still calls the retired gate. Only ever shrinks.
*/
const RETIRED_GATE_CALLERS = ["src/app/api/medications/extract/route.ts"];
const AWAITING_READER: Record<string, string> = {};
function sourceFiles(dir: string, acc: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
@@ -223,7 +211,6 @@ describe("no assistant sub-switch is a dead switch", () => {
.join("\n");
function hasReader(toggle: string): boolean {
if (corpus.includes(`requireAssistantSurface("${toggle}")`)) return true;
if (new RegExp(`flags\\.${toggle}\\b`).test(corpus)) return true;
const capabilities = AI_CAPABILITY_KEYS.filter(
(key) => AI_CAPABILITIES[key].operatorSwitch === toggle,
@@ -257,37 +244,28 @@ describe("no assistant sub-switch is a dead switch", () => {
});
});
describe("the retired gate only shrinks", () => {
const CALL = /(?:await|return|=)\s*requireAssistantSurface\s*\(/;
const callers = SOURCES.filter(
(file) =>
rel(file) !== "src/lib/feature-flags/index.ts" && CALL.test(code(file)),
)
.map(rel)
.sort();
describe("the retired gate stays gone", () => {
const NAMES = /\b(?:requireAssistantSurface|AssistantDisabledError)\b/;
it("finds the callers it is meant to judge", () => {
// A matcher that stopped matching would agree with an empty list.
expect(callers.length).toBe(RETIRED_GATE_CALLERS.length);
});
it("gains no new caller", () => {
const added = callers.filter(
(file) => !RETIRED_GATE_CALLERS.includes(file),
);
it("is neither defined nor called anywhere in the source tree", () => {
// The matcher is proven able to match before its empty answer counts.
expect(NAMES.test('await requireAssistantSurface("coach")')).toBe(true);
expect(NAMES.test("new AssistantDisabledError(s)")).toBe(true);
expect(SOURCES.length).toBeGreaterThan(100);
const offenders = SOURCES.filter((file) => NAMES.test(code(file))).map(rel);
expect(
added,
"a new route calls requireAssistantSurface; use requireAiCapability",
offenders,
"the retired operator-only gate is back; use requireAiCapability",
).toEqual([]);
});
it("forgets a caller once it has moved to the capability gate", () => {
const moved = RETIRED_GATE_CALLERS.filter(
(file) => !callers.includes(file),
it("keeps the operator code family on the capability refusal", async () => {
const { aiRefusal } = await import("@/lib/ai/capabilities/refusal");
expect(aiRefusal("coach", "operator_disabled").meta.errorCode).toBe(
"assistant.disabled.coach",
);
expect(aiRefusal("statusText", "operator_disabled").meta.errorCode).toBe(
"assistant.disabled.insightStatus",
);
expect(
moved,
"these no longer call requireAssistantSurface; remove them from the list",
).toEqual([]);
});
});
-41
View File
@@ -21,9 +21,6 @@ import type {
* a route that serves or calls a model asks the resolver, not this file.
*/
/** A sub-switch name. Also the last segment of `assistant.disabled.<switch>`. */
export type AssistantSurface = AiOperatorSwitch;
/** The resolved switch set, master applied. */
export type AssistantFlagSet = AiOperatorSwitchSet;
@@ -104,41 +101,3 @@ export function resolveAssistantFlags(raw: AssistantFlagSet): AssistantFlagSet {
if (!raw.enabled) return { ...ASSISTANT_FLAGS_OFF };
return { ...raw };
}
/**
* The operator-switch-only refusal, `403 { meta: { errorCode:
* "assistant.disabled.<switch>" } }`.
*
* Being retired. It answers only the operator layer, so a route gated on it
* refuses when a switch is off and says nothing about modules, consent or the
* provider; `requireAiCapability` (`src/lib/ai/capabilities/gate.ts`) answers
* all of them with the same code for the operator case. The routes that still
* call `requireAssistantSurface` are frozen by
* `src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts`: the list
* may only shrink, and this class goes with its last caller.
*/
export class AssistantDisabledError extends Error {
readonly surface: AssistantSurface;
readonly errorCode: string;
constructor(surface: AssistantSurface) {
super(`Assistant surface "${surface}" is disabled on this server`);
this.name = "AssistantDisabledError";
this.surface = surface;
this.errorCode = `assistant.disabled.${surface}`;
}
}
/**
* Throws `AssistantDisabledError` when the switch (or the master) is off.
* Retired in favour of `requireAiCapability`; see the class above.
*/
export async function requireAssistantSurface(
surface: AssistantSurface,
): Promise<AssistantFlagSet> {
const flags = await getAssistantFlags();
if (!flags[surface]) {
throw new AssistantDisabledError(surface);
}
return flags;
}
@@ -4,7 +4,11 @@ vi.mock("@/lib/db", () => ({
prisma: {
biomarker: { findFirst: vi.fn() },
labResult: { findMany: vi.fn(), count: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
insightStatusCache: {
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
// v1.30.3 (QA F5) — `resolveUserTimezone` reads this directly.
user: { findUnique: vi.fn() },
},
@@ -12,8 +16,21 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
// Consent never blocks in these fixtures — the gate has its own tests.
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
vi.mock(
"@/lib/ai/coach/bytes-codec",
async () => (await import("./status-note-fixtures")).fakeBytesCodec,
);
// statusText is available in these fixtures — the capability read has its
// own tests in status-cache.test.ts.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
import { prisma } from "@/lib/db";
@@ -26,6 +43,7 @@ import {
biomarkerStatusScope,
} from "../biomarker-status";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
import { noteRow, writtenNotes } from "./status-note-fixtures";
const MARKER = {
id: "bm-1",
@@ -74,10 +92,12 @@ function inputHashFor(reading: {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null);
vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue(
{} as never,
);
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(MARKER as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date("2026-06-20T09:00:00.000Z"),
} as never);
// Default: no stored timezone → resolveUserTimezone falls to the
// Europe/Berlin server default, matching every other fixture's assumption.
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
@@ -85,7 +105,6 @@ beforeEach(() => {
describe("generateBiomarkerStatus — empty-data guard", () => {
it("returns insufficient WITHOUT calling the provider for a marker with no numeric readings", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
const result = await generateBiomarkerStatus({
@@ -119,15 +138,13 @@ describe("generateBiomarkerStatus — empty-data guard", () => {
describe("generateBiomarkerStatus — cache read", () => {
it("serves today's cached text without calling the provider", async () => {
const todayKey = toBerlinDayKey(new Date());
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: todayKey,
locale: "en",
text: "Your LDL is steady.",
model: "x",
}),
} as never);
generatedAt: new Date(),
}) as never,
);
const result = await generateBiomarkerStatus({
biomarkerId: MARKER.id,
@@ -146,7 +163,6 @@ describe("generateBiomarkerStatus — cache read", () => {
describe("generateBiomarkerStatus — generation path", () => {
it("builds a snapshot, runs the completion, and persists with an inputHash", async () => {
const reading = { id: "r1", value: 95, takenAt: TAKEN_AT };
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never);
vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never);
@@ -175,13 +191,9 @@ describe("generateBiomarkerStatus — generation path", () => {
expect(snapshot.latest.value).toBe(95);
// Persisted under the biomarker scope cache action, carrying the inputHash.
const createCall = vi
.mocked(prisma.auditLog.create)
.mock.calls.at(-1)![0] as {
data: { action: string; details: string };
};
expect(createCall.data.action).toBe("insights.biomarker:bm-1-status.en");
const persisted = JSON.parse(createCall.data.details);
const persisted = writtenNotes(prisma.insightStatusCache.upsert).at(-1)!;
expect(persisted.metric).toBe("biomarker:bm-1");
expect(persisted.locale).toBe("en");
expect(persisted.inputHash).toBe(inputHashFor(reading));
});
});
@@ -200,7 +212,6 @@ describe("generateBiomarkerStatus — prompt-injection fence", () => {
unit: hostileUnit,
} as never);
const reading = { id: "r1", value: 95, takenAt: TAKEN_AT };
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never);
vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never);
@@ -253,7 +264,6 @@ describe("generateBiomarkerStatus — per-user tz (QA F5)", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "America/New_York",
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never);
vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never);
@@ -279,16 +289,14 @@ describe("generateBiomarkerStatus — input gate (regenerate only on a new readi
// A prior assessment from an earlier day whose inputHash matches the
// current latest reading — readFreshStatusText misses on the day key, but
// the input gate matches the fingerprint.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: "2000-01-01",
locale: "en",
text: "Stable LDL from a previous reading.",
model: "x",
generatedAt: new Date(),
inputHash: inputHashFor(reading),
}),
} as never);
}) as never,
);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never);
vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never);
@@ -312,16 +320,14 @@ describe("generateBiomarkerStatus — input gate (regenerate only on a new readi
};
// The cached row's inputHash is for the OLD reading; the live latest is the
// new reading, so the fingerprint differs and the gate misses.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: "2000-01-01",
locale: "en",
text: "Stale assessment.",
model: "x",
generatedAt: new Date(),
inputHash: inputHashFor(oldReading),
}),
} as never);
}) as never,
);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([
newReading,
] as never);
@@ -1,9 +1,21 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// The graded series folds 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() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
insightStatusCache: {
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
@@ -14,8 +26,21 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
// Consent never blocks in these fixtures — the gate has its own tests.
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
vi.mock(
"@/lib/ai/coach/bytes-codec",
async () => (await import("./status-note-fixtures")).fakeBytesCodec,
);
// statusText is available in these fixtures — the capability read has its
// own tests in status-cache.test.ts.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -31,6 +56,7 @@ import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { getMedicationCategories } from "@/lib/medication-category";
import { generateBloodPressureStatusForUser } from "../blood-pressure-status";
import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures";
const dayMs = 24 * 60 * 60 * 1000;
@@ -54,6 +80,11 @@ function stubCompletion(
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null);
vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue(
{} as never,
);
vi.mocked(getMedicationCategories).mockResolvedValue({});
// Cold rollup tier: the BP channels fold monthly/yearly from the
// full-history `measurement.findMany` fallback on a tier miss.
@@ -85,16 +116,12 @@ describe("generateBloodPressureStatusForUser — graded payload", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK"}', captured);
@@ -138,7 +165,6 @@ describe("generateBloodPressureStatusForUser — timeout/error never persists",
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: new Date() },
{ type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: new Date() },
@@ -148,9 +174,6 @@ describe("generateBloodPressureStatusForUser — timeout/error never persists",
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
@@ -164,23 +187,20 @@ describe("generateBloodPressureStatusForUser — timeout/error never persists",
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeNull();
// v1.8.3 — no real assessment persisted (updatedAt stays null above),
// but a short-TTL negative stub IS written so the read-only route does
// but a short-TTL negative window IS opened so the read-only route does
// not re-enqueue on every navigation while the provider is degraded.
// The stub is a timeout marker that `readFreshStatusText` rejects.
// The window carries no note, so it can never be served as one.
await Promise.resolve();
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
const details = JSON.parse(
(call[0] as { data: { details: string } }).data.details,
);
expect(details.timeout === true || details.model === "timeout-stub").toBe(
true,
);
}
expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]);
const windows = upsertedNotes(prisma.insightStatusCache.upsert);
expect(windows).toHaveLength(1);
expect(windows[0].retryAt).toBeInstanceOf(Date);
expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled();
});
});
describe("generateBloodPressureStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
describe("generateBloodPressureStatusForUser — a negative window is not a note", () => {
it("regenerates when today's row carries only a negative-cache window", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
@@ -188,16 +208,15 @@ describe("generateBloodPressureStatusForUser — cache-read skips a stub", () =>
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: todayKey,
locale: "en",
text: "Blood pressure assessment fallback…",
model: "timeout-stub",
timeout: true,
}),
} as never);
text: null,
generatedAt: null,
retryAt: new Date(Date.now() + 60_000),
negativeReason: "timeout",
}) as never,
);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now },
{ type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now },
@@ -207,9 +226,6 @@ describe("generateBloodPressureStatusForUser — cache-read skips a stub", () =>
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh BP assessment."}');
@@ -228,7 +244,6 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: new Date() },
{ type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: new Date() },
@@ -238,9 +253,6 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
stubCompletion(
'{"summary":"Systolic averaged 132. metric:BLOOD_PRESSURE_SYS Diastolic stable. metric:BLOOD_PRESSURE_DIA"}',
@@ -252,12 +264,9 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
const notes = writtenNotes(prisma.insightStatusCache.upsert);
expect(notes.length).toBeGreaterThan(0);
expect(notes[0].text).not.toContain("metric:");
});
});
@@ -301,7 +310,6 @@ describe("generateBloodPressureStatusForUser — no rate without an expectation"
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now },
{ type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now },
@@ -314,9 +322,6 @@ describe("generateBloodPressureStatusForUser — no rate without an expectation"
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(getMedicationCategories).mockResolvedValue({
"med-empty": "BLOOD_PRESSURE",
"med-real": "BLOOD_PRESSURE",
+66 -63
View File
@@ -1,9 +1,21 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// The graded series folds 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() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
insightStatusCache: {
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
// v1.18.11 (P6) — the input gate probes salient inputs via groupBy.
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
measurementRollup: { findMany: vi.fn() },
@@ -12,8 +24,21 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
// Consent never blocks in these fixtures — the gate has its own tests.
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
vi.mock(
"@/lib/ai/coach/bytes-codec",
async () => (await import("./status-note-fixtures")).fakeBytesCodec,
);
// statusText is available in these fixtures — the capability read has its
// own tests in status-cache.test.ts.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -24,6 +49,7 @@ vi.mock("@/lib/insights/memory", () => ({
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateBmiStatusForUser } from "../bmi-status";
import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures";
const dayMs = 24 * 60 * 60 * 1000;
@@ -48,6 +74,11 @@ function stubCompletion(
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null);
vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue(
{} as never,
);
// Cold rollup tier: the BMI graded series scales the WEIGHT tier, which
// folds from the full-history `measurement.findMany` fallback on a miss.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
@@ -71,11 +102,7 @@ describe("generateBmiStatusForUser — graded payload", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK"}', captured);
@@ -119,15 +146,13 @@ describe("generateBmiStatusForUser — per-user tz (QA F5)", () => {
} as never);
// A cached row keyed to the LA calendar day should be served as a
// fresh hit even though it is already "tomorrow" in Berlin.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: laToday,
locale: "en",
text: "LA-anchored cached BMI text.",
model: "x",
}),
} as never);
generatedAt: new Date(),
}) as never,
);
const result = await generateBmiStatusForUser("user-la", {
locale: "en",
@@ -151,13 +176,9 @@ describe("generateBmiStatusForUser — timeout/error never persists", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 80, measuredAt: new Date() },
] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
@@ -172,25 +193,21 @@ describe("generateBmiStatusForUser — timeout/error never persists", () => {
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeNull();
// v1.8.3 — no real assessment persisted (updatedAt stays null above),
// but a short-TTL negative stub IS written so the read-only route does
// but a short-TTL negative window IS opened so the read-only route does
// not re-enqueue on every navigation while the provider is degraded.
// The stub is a timeout marker that `readFreshStatusText` rejects.
// The window carries no note, so it can never be served as one.
await Promise.resolve();
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
const details = JSON.parse(
(call[0] as { data: { details: string } }).data.details,
);
expect(details.timeout === true || details.model === "timeout-stub").toBe(
true,
);
}
expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]);
const windows = upsertedNotes(prisma.insightStatusCache.upsert);
expect(windows).toHaveLength(1);
expect(windows[0].retryAt).toBeInstanceOf(Date);
expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled();
});
it("serves the fallback without writing a cache row on provider error", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 80, measuredAt: new Date() },
] as never);
@@ -203,23 +220,20 @@ describe("generateBmiStatusForUser — timeout/error never persists", () => {
expect(result.text).toBeTruthy();
expect(result.updatedAt).toBeNull();
// v1.8.3 — no real assessment persisted (updatedAt stays null above),
// but a short-TTL negative stub IS written so the read-only route does
// but a short-TTL negative window IS opened so the read-only route does
// not re-enqueue on every navigation while the provider is degraded.
// The stub is a timeout marker that `readFreshStatusText` rejects.
// The window carries no note, so it can never be served as one.
await Promise.resolve();
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
const details = JSON.parse(
(call[0] as { data: { details: string } }).data.details,
);
expect(details.timeout === true || details.model === "timeout-stub").toBe(
true,
);
}
expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]);
const windows = upsertedNotes(prisma.insightStatusCache.upsert);
expect(windows).toHaveLength(1);
expect(windows[0].retryAt).toBeInstanceOf(Date);
expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled();
});
});
describe("generateBmiStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
describe("generateBmiStatusForUser — a negative window is not a note", () => {
it("regenerates when today's row carries only a negative-cache window", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
@@ -228,22 +242,18 @@ describe("generateBmiStatusForUser — cache-read skips a stub", () => {
heightCm: 175,
} as never);
// The most recent cached row is a stub keyed to today.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: todayKey,
locale: "en",
text: "Generic stub fallback.",
model: "timeout-stub",
timeout: true,
}),
} as never);
text: null,
generatedAt: null,
retryAt: new Date(Date.now() + 60_000),
negativeReason: "timeout",
}) as never,
);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 80, measuredAt: now },
] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh real assessment."}');
@@ -261,13 +271,9 @@ describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () =
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 80, measuredAt: new Date() },
] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
stubCompletion(
'{"summary":"BMI sits in the green band. metric:WEIGHT and steady."}',
@@ -277,11 +283,8 @@ describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () =
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
const notes = writtenNotes(prisma.insightStatusCache.upsert);
expect(notes.length).toBeGreaterThan(0);
expect(notes[0].text).not.toContain("metric:");
});
});
@@ -40,6 +40,20 @@ vi.mock("@/lib/db", () => ({
auditLog: { deleteMany: vi.fn() },
},
}));
// The `briefing` capability is available in these fixtures; the refusal
// path has its own test in comprehensive-generate-force.test.ts.
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
// arm has its own test in comprehensive-budget-refusal.test.ts.
vi.mock("@/lib/ai/capabilities/egress", () => ({
aiEgressRefusal: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
resolveProvider: (...a: unknown[]) => resolveProvider(...a),
@@ -40,6 +40,20 @@ vi.mock("@/lib/db", () => ({
auditLog: { deleteMany: vi.fn() },
},
}));
// The `briefing` capability is available in these fixtures; the refusal
// path has its own test in comprehensive-generate-force.test.ts.
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
// arm has its own test in comprehensive-budget-refusal.test.ts.
vi.mock("@/lib/ai/capabilities/egress", () => ({
aiEgressRefusal: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
resolveProvider: (...a: unknown[]) => resolveProvider(...a),
@@ -25,7 +25,8 @@ const resolveProvider = vi.fn();
const runRawCompletionWithFallback = vi.fn();
const extractFeatures = vi.fn();
const recordBriefingFailure = vi.fn();
const hasActiveConsentForSurface = vi.fn();
const hasReceipt = vi.fn();
const aiEgressRefusal = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
@@ -69,6 +70,15 @@ vi.mock("@/lib/db", () => ({
auditLog: { deleteMany: vi.fn() },
},
}));
// The `briefing` capability is available in these fixtures; the refusal
// path has its own test in comprehensive-generate-force.test.ts.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
resolveProvider: (...a: unknown[]) => resolveProvider(...a),
@@ -104,18 +114,16 @@ vi.mock("@/lib/ai/coach/about-me", () => ({
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserInsights: vi.fn(),
}));
// Modelled truthfully rather than stubbed open: only an operator-credential
// chain needs a receipt, which is what lets the consent-ordering test below
// assert the real gate rather than a bypass.
vi.mock("@/lib/ai/consent-guard", () => ({
chainRequiresServerManagedConsent: (chain: { providerType: string }[] = []) =>
chain.some((c) => c.providerType.startsWith("admin-")),
hasActiveConsentForSurface: (...a: unknown[]) =>
hasActiveConsentForSurface(...a),
// The wire re-check, modelled truthfully rather than stubbed open: only an
// operator-credential entry needs a receipt, which is what lets the
// consent-ordering test below assert the real gate rather than a bypass.
vi.mock("@/lib/ai/capabilities/egress", () => ({
aiEgressRefusal: (...a: unknown[]) => aiEgressRefusal(...a),
}));
import { generateComprehensiveInsight } from "../comprehensive-generate";
import { OPERATOR_COST_CAP, USER_PLAN_CAP } from "@/lib/ai/coach/budget";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
const VALID = JSON.stringify({ dailyBriefing: { paragraph: "ok" } });
@@ -139,7 +147,13 @@ beforeEach(() => {
insightsSnapshotHash: null,
insightsBriefingRerollDate: null,
});
hasActiveConsentForSurface.mockResolvedValue(true);
hasReceipt.mockResolvedValue(true);
aiEgressRefusal.mockImplementation(
async (key: never, _userId: string, providerTypes: string[]) =>
providerTypes.some((t) => t.startsWith("admin-")) && !(await hasReceipt())
? new AiUnavailableError(key, "consent_required")
: null,
);
runRawCompletionWithFallback.mockResolvedValue({
result: { content: VALID, tokensUsed: 900, model: "m" },
workingProvider: { providerType: "admin-openai" },
@@ -215,11 +229,28 @@ describe("generateComprehensiveInsight — daily token ceiling", () => {
// Reserving first would bill an operator-key user for a snapshot that is
// never sent anywhere, and could lock them out of a tier they are not
// even permitted to use.
hasActiveConsentForSurface.mockResolvedValue(false);
hasReceipt.mockResolvedValue(false);
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome).toEqual({ status: "skipped", reason: "no-consent" });
expect(aiEgressRefusal).toHaveBeenCalledWith("briefing", "u1", [
"admin-openai",
]);
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
expect(ledgerTotal).toBe(0);
});
it("reports any other wire refusal as `skipped: unavailable`, before any spend", async () => {
// A switch the operator turned off after the run was scheduled: not a
// consent question, so not reported as one.
aiEgressRefusal.mockResolvedValue(
new AiUnavailableError("briefing", "operator_disabled"),
);
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome).toEqual({ status: "skipped", reason: "unavailable" });
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
expect(ledgerTotal).toBe(0);
});
@@ -24,6 +24,7 @@ const resolveProviderChain = vi.fn();
const resolveProvider = vi.fn();
const runRawCompletionWithFallback = vi.fn();
const extractFeatures = vi.fn();
const aiCapabilityForRecord = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
@@ -49,6 +50,14 @@ vi.mock("@/lib/db", () => ({
},
},
}));
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
// arm has its own test in comprehensive-budget-refusal.test.ts.
vi.mock("@/lib/ai/capabilities/egress", () => ({
aiEgressRefusal: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
resolveProvider: (...a: unknown[]) => resolveProvider(...a),
@@ -108,6 +117,68 @@ beforeEach(() => {
resolveProvider.mockResolvedValue({ type: "none" });
extractFeatures.mockResolvedValue(FEATURES);
userUpdate.mockResolvedValue({});
aiCapabilityForRecord.mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
});
describe("generateComprehensiveInsight — the briefing capability at the wire", () => {
const STALE_USER = {
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
insightsExcludeMetrics: [],
insightsSnapshotHash: null,
};
it.each([
["no_provider", "no-provider"],
["consent_required", "no-consent"],
["user_disabled", "unavailable"],
["operator_disabled", "unavailable"],
] as const)(
"skips with %s → %s and never resolves the chain",
async (reason, skipped) => {
findUnique.mockResolvedValue(STALE_USER);
aiCapabilityForRecord.mockResolvedValue({
available: false,
reason,
onDeviceAllowed: false,
});
const outcome = await generateComprehensiveInsight("u1", {
locale: "de",
force: true,
});
expect(outcome).toEqual({ status: "skipped", reason: skipped });
expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "briefing");
expect(resolveProviderChain).not.toHaveBeenCalled();
expect(resolveProvider).not.toHaveBeenCalled();
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
expect(extractFeatures).not.toHaveBeenCalled();
},
);
it("still serves a fresh cache without asking the capability (cache short-circuit comes first)", async () => {
findUnique.mockResolvedValue({
...STALE_USER,
insightsCachedAt: new Date(Date.now() - 60 * 60 * 1000),
insightsCachedText: "{}",
});
aiCapabilityForRecord.mockResolvedValue({
available: false,
reason: "user_disabled",
onDeviceAllowed: false,
});
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome).toEqual({ status: "cached" });
expect(aiCapabilityForRecord).not.toHaveBeenCalled();
});
});
describe("generateComprehensiveInsight — cache short-circuit", () => {
@@ -38,6 +38,20 @@ vi.mock("@/lib/db", () => ({
auditLog: { deleteMany: vi.fn() },
},
}));
// The `briefing` capability is available in these fixtures; the refusal
// path has its own test in comprehensive-generate-force.test.ts.
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
// arm has its own test in comprehensive-budget-refusal.test.ts.
vi.mock("@/lib/ai/capabilities/egress", () => ({
aiEgressRefusal: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
resolveProvider: (...a: unknown[]) => resolveProvider(...a),
@@ -37,6 +37,20 @@ vi.mock("@/lib/db", () => ({
auditLog: { deleteMany: vi.fn() },
},
}));
// The `briefing` capability is available in these fixtures; the refusal
// path has its own test in comprehensive-generate-force.test.ts.
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
// arm has its own test in comprehensive-budget-refusal.test.ts.
vi.mock("@/lib/ai/capabilities/egress", () => ({
aiEgressRefusal: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
resolveProvider: (...a: unknown[]) => resolveProvider(...a),
@@ -42,6 +42,20 @@ vi.mock("@/lib/db", () => ({
auditLog: { deleteMany: vi.fn(), create: vi.fn() },
},
}));
// The `briefing` capability is available in these fixtures; the refusal
// path has its own test in comprehensive-generate-force.test.ts.
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
// arm has its own test in comprehensive-budget-refusal.test.ts.
vi.mock("@/lib/ai/capabilities/egress", () => ({
aiEgressRefusal: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
resolveProvider: (...a: unknown[]) => resolveProvider(...a),
@@ -44,6 +44,20 @@ vi.mock("@/lib/db", () => ({
auditLog: { deleteMany: vi.fn() },
},
}));
// The `briefing` capability is available in these fixtures; the refusal
// path has its own test in comprehensive-generate-force.test.ts.
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
// arm has its own test in comprehensive-budget-refusal.test.ts.
vi.mock("@/lib/ai/capabilities/egress", () => ({
aiEgressRefusal: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
resolveProvider: (...a: unknown[]) => resolveProvider(...a),
@@ -3,7 +3,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
insightStatusCache: {
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
measurement: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
@@ -12,8 +16,21 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
// Consent never blocks in these fixtures — the gate has its own tests.
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
vi.mock(
"@/lib/ai/coach/bytes-codec",
async () => (await import("./status-note-fixtures")).fakeBytesCodec,
);
// statusText is available in these fixtures — the capability read has its
// own tests in status-cache.test.ts.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -24,6 +41,7 @@ vi.mock("@/lib/insights/memory", () => ({
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateGeneralStatusForUser } from "../general-status";
import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures";
const dayMs = 24 * 60 * 60 * 1000;
@@ -47,6 +65,11 @@ function stubCompletion(
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null);
vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue(
{} as never,
);
});
describe("generateGeneralStatusForUser — graded payload", () => {
@@ -69,7 +92,6 @@ describe("generateGeneralStatusForUser — graded payload", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
weightRecords as never,
);
@@ -77,9 +99,6 @@ describe("generateGeneralStatusForUser — graded payload", () => {
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK"}', captured);
@@ -113,7 +132,6 @@ describe("generateGeneralStatusForUser — graded payload", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 80, measuredAt: now },
] as never);
@@ -121,9 +139,6 @@ describe("generateGeneralStatusForUser — graded payload", () => {
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK"}', captured);
@@ -141,7 +156,6 @@ describe("generateGeneralStatusForUser — timeout/error never persists", () =>
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
] as never);
@@ -149,9 +163,6 @@ describe("generateGeneralStatusForUser — timeout/error never persists", () =>
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
@@ -165,23 +176,20 @@ describe("generateGeneralStatusForUser — timeout/error never persists", () =>
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeNull();
// v1.8.3 — no real assessment persisted (updatedAt stays null above),
// but a short-TTL negative stub IS written so the read-only route does
// but a short-TTL negative window IS opened so the read-only route does
// not re-enqueue on every navigation while the provider is degraded.
// The stub is a timeout marker that `readFreshStatusText` rejects.
// The window carries no note, so it can never be served as one.
await Promise.resolve();
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
const details = JSON.parse(
(call[0] as { data: { details: string } }).data.details,
);
expect(details.timeout === true || details.model === "timeout-stub").toBe(
true,
);
}
expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]);
const windows = upsertedNotes(prisma.insightStatusCache.upsert);
expect(windows).toHaveLength(1);
expect(windows[0].retryAt).toBeInstanceOf(Date);
expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled();
});
});
describe("generateGeneralStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
describe("generateGeneralStatusForUser — a negative window is not a note", () => {
it("regenerates when today's row carries only a negative-cache window", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
@@ -189,16 +197,15 @@ describe("generateGeneralStatusForUser — cache-read skips a stub", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: todayKey,
locale: "en",
text: "General status fallback…",
model: "timeout-stub",
timeout: true,
}),
} as never);
text: null,
generatedAt: null,
retryAt: new Date(Date.now() + 60_000),
negativeReason: "timeout",
}) as never,
);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: now },
] as never);
@@ -206,9 +213,6 @@ describe("generateGeneralStatusForUser — cache-read skips a stub", () => {
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh general assessment."}');
@@ -227,7 +231,6 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)",
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
] as never);
@@ -235,9 +238,6 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)",
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
stubCompletion(
'{"summary":"Weight trended down. metric:WEIGHT BP stable. metric:BLOOD_PRESSURE_SYS"}',
@@ -249,11 +249,8 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)",
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
const notes = writtenNotes(prisma.insightStatusCache.upsert);
expect(notes.length).toBeGreaterThan(0);
expect(notes[0].text).not.toContain("metric:");
});
});
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const findMany = vi.fn();
const queryRaw = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
@@ -10,12 +9,14 @@ vi.mock("@/lib/db", () => ({
// loadUserSourcePriority lazy-loads the source-priority blob; null
// (default findUnique) falls back to the default ladders.
user: { findUnique: vi.fn() },
// v1.28.25 — dense types (BLOOD_GLUCOSE / PULSE) day-bucket the
// cold-tier fallback in SQL.
$queryRaw: (...args: unknown[]) => queryRaw(...args),
},
}));
const readDayAggregates = vi.fn();
vi.mock("@/lib/measurements/day-aggregates", () => ({
readDayAggregates: (...args: unknown[]) => readDayAggregates(...args),
}));
const readBestGranularityRollups = vi.fn();
const rollupRow = (bucketStart: Date, mean: number) => ({
bucketStart,
@@ -31,147 +32,160 @@ const rollupRow = (bucketStart: Date, mean: number) => ({
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
readBestGranularityRollups: (...args: unknown[]) =>
readBestGranularityRollups(...args),
// aggregateWmyBuckets is no longer imported by graded-series, but keep the
// module shape intact for any transitive consumer.
aggregateWmyBuckets: vi.fn(),
}));
import { buildGradedSeriesWithRollups } from "../graded-series";
import {
buildGradedSeriesFromDayAggregates,
buildGradedSeriesFromPoints,
buildGradedSeriesWithRollups,
} from "../graded-series";
import { foldDayAggregates } from "@/lib/measurements/__tests__/fake-day-aggregates";
import type { ReadDayAggregatesOptions } from "@/lib/measurements/day-aggregates";
const dayMs = 24 * 60 * 60 * 1000;
/** Daily readings spanning `days` back from `now`. */
function dailyRows(days: number, now: Date) {
/** Readings spanning `days` back from `now`, `perDay` per day. */
function points(days: number, now: Date, perDay = 1) {
const rows: Array<{ measuredAt: Date; value: number }> = [];
for (let i = 0; i < days; i++) {
rows.push({ measuredAt: new Date(now.getTime() - i * dayMs), value: 80 });
for (let k = 0; k < perDay; k++) {
rows.push({
measuredAt: new Date(now.getTime() - i * dayMs - k * 3_600_000),
value: 60 + ((i * 7 + k * 3) % 41),
});
}
}
return rows;
return rows.reverse();
}
/** Serve `readDayAggregates` from an in-memory row set. */
function serveFrom(rows: Array<{ measuredAt: Date; value: number }>) {
readDayAggregates.mockImplementation(async (opts: ReadDayAggregatesOptions) =>
foldDayAggregates(rows, opts),
);
}
afterEach(() => {
vi.clearAllMocks();
});
describe("buildGradedSeriesWithRollups — rollup-miss fallback", () => {
describe("buildGradedSeriesWithRollups — bounded reads (#1023)", () => {
const now = new Date("2026-05-31T12:00:00Z");
it("never materialises raw rows: the recent read is a per-day aggregate", async () => {
serveFrom(points(80, now, 24));
readBestGranularityRollups.mockResolvedValue({
granularity: "MONTH",
rows: [rollupRow(new Date("2025-09-01T00:00:00Z"), 79)],
});
await buildGradedSeriesWithRollups("u1", "PULSE", now);
expect(findMany).not.toHaveBeenCalled();
const opts = readDayAggregates.mock.calls[0][0] as ReadDayAggregatesOptions;
expect(opts).toMatchObject({ userId: "u1", type: "PULSE" });
expect(opts.until).toEqual(now);
expect(opts.timeZone).toBe("Europe/Berlin");
expect(now.getTime() - opts.since.getTime()).toBe(91 * dayMs);
// The same age partition the parity test below proves exact: a reading
// exactly 21 / 91 / 451 days old belongs to the older slice.
expect(opts.segmentStarts).toEqual(
[21, 91, 451].map((d) => new Date(now.getTime() - d * dayMs + 1)),
);
});
it("folds monthly/yearly from the bounded fallback when the tier has no coverage", async () => {
// The bounded recent read (~90 d) returns only recent points; the
// fallback read returns 2+ years so the fold has monthly/yearly.
findMany
// 1st call — bounded recent read (gte: since)
.mockResolvedValueOnce(dailyRows(80, now))
// 2nd call — bounded fallback read (gte: 1095-day horizon)
.mockResolvedValueOnce(dailyRows(800, now));
// Tier miss: no MONTH / YEAR coverage at all.
serveFrom(points(800, now));
readBestGranularityRollups.mockResolvedValue(null);
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
// The bug: before the fix monthly/yearly were derived from the
// bounded ~90-day read and so were always empty even when years of
// raw history existed. After the fix they fold from the fallback.
expect(series.monthly.length).toBeGreaterThan(0);
// 800 days of history puts at least one year into the yearly tail.
expect(series.yearly.length).toBeGreaterThan(0);
// The fallback read must have been issued...
expect(findMany).toHaveBeenCalledTimes(2);
// ...and bounded (v1.28.25): it must carry a `measuredAt.gte` floor
// ~1095 days back, never an unwindowed full-history walk.
const fallbackArgs = findMany.mock.calls[1][0] as {
where: { measuredAt?: { gte?: Date } };
};
const gte = fallbackArgs.where.measuredAt?.gte;
expect(gte).toBeInstanceOf(Date);
expect(gte!.getTime()).toBe(now.getTime() - 1095 * dayMs);
});
it("day-buckets the dense-type fallback in SQL instead of a raw walk (v1.28.25)", async () => {
// BLOOD_GLUCOSE on a cold tier (heavy import before the rollup
// backfill warms) used to findMany the entire CGM history. The
// fallback now runs a single day-bucket aggregate; only the bounded
// recent read touches findMany.
findMany.mockResolvedValueOnce(dailyRows(80, now)); // bounded recent
const bucketRows: Array<{ bucket_start: Date; mean: number }> = [];
for (let i = 0; i < 800; i++) {
bucketRows.push({
bucket_start: new Date(now.getTime() - i * dayMs),
mean: 100,
});
}
queryRaw.mockResolvedValueOnce(bucketRows.reverse());
readBestGranularityRollups.mockResolvedValue(null);
const series = await buildGradedSeriesWithRollups(
"u1",
"BLOOD_GLUCOSE",
now,
);
expect(series.monthly.length).toBeGreaterThan(0);
expect(series.yearly.length).toBeGreaterThan(0);
// Raw findMany ran once (the bounded recent read) — the fallback
// itself went through the SQL aggregate.
expect(findMany).toHaveBeenCalledTimes(1);
expect(queryRaw).toHaveBeenCalledTimes(1);
expect(readDayAggregates).toHaveBeenCalledTimes(2);
const fallback = readDayAggregates.mock
.calls[1][0] as ReadDayAggregatesOptions;
// Bounded to the yearly router's horizon, never an unwindowed walk.
expect(fallback.since.getTime()).toBe(now.getTime() - 1095 * dayMs);
expect(findMany).not.toHaveBeenCalled();
});
it("reads monthly/yearly from the tier when it has coverage and skips the full read", async () => {
findMany.mockResolvedValueOnce(dailyRows(80, now));
// MONTH coverage for the 1-year window, YEAR coverage for the 3-year.
it("reads monthly/yearly from the tier when it has coverage and skips the fallback", async () => {
serveFrom(points(80, now));
readBestGranularityRollups.mockImplementation(
async (_u: string, _t: string, windowDays: number) => {
if (windowDays === 365) {
return {
granularity: "MONTH",
rows: [
rollupRow(new Date("2025-09-01T00:00:00Z"), 79),
rollupRow(new Date("2025-10-01T00:00:00Z"), 80),
],
};
}
return {
granularity: "YEAR",
rows: [rollupRow(new Date("2023-01-01T00:00:00Z"), 82)],
};
},
async (_u: string, _t: string, windowDays: number) =>
windowDays === 365
? {
granularity: "MONTH",
rows: [
rollupRow(new Date("2025-09-01T00:00:00Z"), 79),
rollupRow(new Date("2025-10-01T00:00:00Z"), 80),
],
}
: {
granularity: "YEAR",
rows: [rollupRow(new Date("2023-01-01T00:00:00Z"), 82)],
},
);
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
expect(series.monthly.length).toBeGreaterThan(0);
expect(series.yearly.length).toBeGreaterThan(0);
// Only the bounded recent read — no full-history fallback when the
// tier covers both coarse slices.
expect(findMany).toHaveBeenCalledTimes(1);
expect(readDayAggregates).toHaveBeenCalledTimes(1);
});
it("falls back to a full read for only the slice the tier misses", async () => {
findMany
.mockResolvedValueOnce(dailyRows(80, now)) // bounded recent
.mockResolvedValueOnce(dailyRows(800, now)); // full-history fallback
// MONTH covered, YEAR missing.
it("falls back only for the slice the tier misses", async () => {
serveFrom(points(800, now));
readBestGranularityRollups.mockImplementation(
async (_u: string, _t: string, windowDays: number) => {
if (windowDays === 365) {
return {
granularity: "MONTH",
rows: [rollupRow(new Date("2025-10-01T00:00:00Z"), 80)],
};
}
return null;
},
async (_u: string, _t: string, windowDays: number) =>
windowDays === 365
? {
granularity: "MONTH",
rows: [rollupRow(new Date("2025-10-01T00:00:00Z"), 80)],
}
: null,
);
const series = await buildGradedSeriesWithRollups("u1", "WEIGHT", now);
expect(series.monthly.length).toBeGreaterThan(0);
expect(series.monthly).toHaveLength(1);
expect(series.yearly.length).toBeGreaterThan(0);
expect(findMany).toHaveBeenCalledTimes(2);
expect(readDayAggregates).toHaveBeenCalledTimes(2);
});
});
describe("buildGradedSeriesFromDayAggregates — parity with the raw fold", () => {
const now = new Date("2026-05-31T12:00:00Z");
const segmentStarts = [21, 91, 451].map(
(d) => new Date(now.getTime() - d * dayMs + 1),
);
it("matches the raw-row fold for recent / weekly / monthly and yearly stats", () => {
// Several readings per day, including readings exactly on the segment
// edges, so the partition itself is under test.
const rows = [
...points(900, now, 5),
{ measuredAt: new Date(now.getTime() - 21 * dayMs), value: 99 },
{ measuredAt: new Date(now.getTime() - 91 * dayMs), value: 41 },
];
const raw = buildGradedSeriesFromPoints(rows, now);
const agg = buildGradedSeriesFromDayAggregates(
foldDayAggregates(rows, {
since: new Date(0),
until: now,
timeZone: "Europe/Berlin",
segmentStarts,
}),
);
expect(agg.recent).toEqual(raw.recent);
expect(agg.weekly).toEqual(raw.weekly);
expect(agg.monthly).toEqual(raw.monthly);
// The yearly slope runs over day means rather than single readings;
// every other yearly figure is exact.
expect(agg.yearly.map(({ slope: _s, ...b }) => b)).toEqual(
raw.yearly.map(({ slope: _s, ...b }) => b),
);
});
});
@@ -15,7 +15,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const deleteMany = vi.fn();
const auditFindMany = vi.fn();
const noteFindMany = vi.fn();
const aiCapabilityForRecord = vi.fn();
const enqueueStatusGeneration = vi.fn();
const userFindUnique = vi.fn();
const measurementFindMany = vi.fn();
@@ -23,9 +24,9 @@ const measurementGroupBy = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: {
insightStatusCache: {
deleteMany: (...a: unknown[]) => deleteMany(...a),
findMany: (...a: unknown[]) => auditFindMany(...a),
findMany: (...a: unknown[]) => noteFindMany(...a),
},
user: { findUnique: (...a: unknown[]) => userFindUnique(...a) },
measurement: {
@@ -41,6 +42,21 @@ vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({
enqueueStatusGeneration: (...a: unknown[]) => enqueueStatusGeneration(...a),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a),
aiCapabilityForJob: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
const UNAVAILABLE = {
available: false,
reason: "user_disabled",
onDeviceAllowed: false,
};
import {
enqueueStatusRefillForUser,
invalidateStatusInsightsForTypes,
@@ -51,7 +67,12 @@ beforeEach(() => {
deleteMany.mockResolvedValue({ count: 0 });
// Default: no recently-warmed cache rows, so every dirtied scope is stale
// and refreshes — the pre-debounce contract the bulk of these tests pin.
auditFindMany.mockResolvedValue([]);
noteFindMany.mockResolvedValue([]);
aiCapabilityForRecord.mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
enqueueStatusGeneration.mockResolvedValue(undefined);
userFindUnique.mockResolvedValue({ locale: "de" });
measurementFindMany.mockResolvedValue([]);
@@ -138,7 +159,15 @@ describe("invalidateStatusInsightsForTypes", () => {
it("is a no-op for an empty type set (no DB call)", async () => {
await invalidateStatusInsightsForTypes("u1", []);
expect(auditFindMany).not.toHaveBeenCalled();
expect(noteFindMany).not.toHaveBeenCalled();
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
});
it("enqueues nothing while statusText is unavailable for the record", async () => {
aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE);
await invalidateStatusInsightsForTypes("u1", ["WEIGHT", "PULSE"]);
expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "statusText");
expect(noteFindMany).not.toHaveBeenCalled();
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
});
@@ -201,18 +230,15 @@ describe("invalidateStatusInsightsForTypes", () => {
});
describe("ingest-invalidation debounce (v1.9.0; a 1 h minimum gap since v1.16.8)", () => {
/** Build a recent (within-window) real assessment cache row. */
/** A recent (within-window) stored note, as the probe selects it. */
function freshRow(scope: string, locale = "de") {
return {
action: `insights.${scope}-status.${locale}`,
details: JSON.stringify({ text: "fresh assessment", model: "gpt" }),
};
return { metric: scope, locale };
}
it("skips a scope whose assessment was warmed within the gap", async () => {
// `general` was regenerated within the gap; a fresh WEIGHT sample
// must not re-enqueue it. weight + bmi are still stale, so they refresh.
auditFindMany.mockResolvedValue([freshRow("general")]);
noteFindMany.mockResolvedValue([freshRow("general")]);
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
expect(enqueuedScopes()).toEqual(["bmi", "weight"]);
});
@@ -224,13 +250,13 @@ describe("invalidateStatusInsightsForTypes", () => {
// hash gate, which makes a no-change run a free timestamp refresh —
// this is what lets same-day data be narrated same-day without
// reopening the per-batch regeneration storm.
auditFindMany.mockResolvedValue([]);
noteFindMany.mockResolvedValue([]);
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
});
it("is a complete no-op (no enqueue) when every scope is fresh", async () => {
auditFindMany.mockResolvedValue([
noteFindMany.mockResolvedValue([
freshRow("weight"),
freshRow("bmi"),
freshRow("general"),
@@ -239,16 +265,14 @@ describe("invalidateStatusInsightsForTypes", () => {
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
});
it("treats a recent timeout stub as not-fresh and still refreshes the scope", async () => {
// A stub carries no real assessment, so a scope that recently stalled
// must retry rather than be debounced into staying cold.
auditFindMany.mockResolvedValue([
{
action: "insights.general-status.de",
details: JSON.stringify({ model: "timeout-stub", text: "stub" }),
},
]);
it("counts only a stored note as fresh, so a recently stalled scope still refreshes", async () => {
// A row that carries only a negative-cache window has no note, so a
// scope that recently stalled must retry rather than be debounced into
// staying cold. The probe asks the database for noted rows only; with
// none returned, every dirtied scope refreshes.
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
const where = noteFindMany.mock.calls[0][0].where;
expect(where.textEncrypted).toEqual({ not: null });
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
});
@@ -256,23 +280,23 @@ describe("invalidateStatusInsightsForTypes", () => {
userFindUnique.mockResolvedValue({ locale: "en" });
const before = Date.now();
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
const where = auditFindMany.mock.calls[0][0].where;
const where = noteFindMany.mock.calls[0][0].where;
expect(where.userId).toBe("u1");
// Only the en cache actions for the dirtied scopes are probed.
expect(new Set(where.action.in)).toEqual(
new Set([
"insights.weight-status.en",
"insights.bmi-status.en",
"insights.general-status.en",
]),
);
// Only the en notes for the dirtied scopes are probed.
expect(
new Set(
(where.OR as { metric: string; locale: string }[]).map(
(k) => `${k.metric}.${k.locale}`,
),
),
).toEqual(new Set(["weight.en", "bmi.en", "general.en"]));
// The recency floor is ONE hour in the past. The 6 h wall this
// started as kept same-day data from being narrated same-day (the
// nightly warm restarted the window, so a morning reading stayed
// un-narrated until tomorrow); the 1 h gap only bounds worker-run
// frequency, while the worker's content-hash gate keeps an
// unchanged re-run at zero LLM calls.
const cutoff = (where.createdAt.gte as Date).getTime();
const cutoff = (where.generatedAt.gte as Date).getTime();
const oneHour = 60 * 60 * 1000;
expect(before - cutoff).toBeGreaterThanOrEqual(oneHour - 5_000);
expect(before - cutoff).toBeLessThanOrEqual(oneHour + 60_000);
@@ -323,17 +347,21 @@ describe("enqueueStatusRefillForUser", () => {
it("bypasses the ingest debounce — an explicit regenerate refills even freshly-warmed scopes", async () => {
// Every scope reads as freshly warmed; the refill must NOT consult the
// freshness probe at all (the user is explicitly asking).
auditFindMany.mockResolvedValue([
{
action: "insights.general-status.en",
details: JSON.stringify({ text: "fresh assessment", model: "gpt" }),
},
]);
noteFindMany.mockResolvedValue([{ metric: "general", locale: "en" }]);
await enqueueStatusRefillForUser("u1", "en");
expect(auditFindMany).not.toHaveBeenCalled();
expect(noteFindMany).not.toHaveBeenCalled();
expect(enqueuedScopes()).toContain("general");
});
it("refills nothing and returns 0 while statusText is unavailable", async () => {
aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE);
measurementGroupBy.mockResolvedValue([{ type: "BLOOD_GLUCOSE" }]);
const count = await enqueueStatusRefillForUser("u1", "de");
expect(count).toBe(0);
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
expect(measurementGroupBy).not.toHaveBeenCalled();
});
it("still refills the specialised scopes when the generic-scope discovery read fails", async () => {
measurementGroupBy.mockRejectedValue(new Error("pool exhausted"));
const count = await enqueueStatusRefillForUser("u1", "de");
@@ -70,7 +70,11 @@ describe("status cache keys", () => {
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
insightStatusCache: {
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
measurement: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
@@ -79,7 +83,21 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
vi.mock(
"@/lib/ai/coach/bytes-codec",
async () => (await import("./status-note-fixtures")).fakeBytesCodec,
);
// statusText is available in these fixtures — the capability read has its
// own tests in status-cache.test.ts.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -104,6 +122,7 @@ vi.mock("@/lib/ai/prompts/general-status", () => ({
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateGeneralStatusForUser } from "@/lib/insights/general-status";
import { writtenNotes } from "./status-note-fixtures";
const dayMs = 24 * 60 * 60 * 1000;
@@ -117,10 +136,6 @@ beforeEach(() => {
heightCm: null,
insightsExcludeMetrics: [],
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
@@ -166,12 +181,10 @@ describe("a fr locale survives from the generator entry point to the prompt", ()
it("writes the fr assessment to the fr cache row, leaving the en row untouched", async () => {
await generateGeneralStatusForUser("u-fr", { locale: "fr", force: true });
const actions = vi
.mocked(prisma.auditLog.create)
.mock.calls.map(
(call) => (call[0] as { data: { action: string } }).data.action,
);
expect(actions).toContain("insights.general-status.fr");
expect(actions).not.toContain("insights.general-status.en");
const keys = writtenNotes(prisma.insightStatusCache.upsert).map(
(note) => `${note.metric}.${note.locale}`,
);
expect(keys).toContain("general.fr");
expect(keys).not.toContain("general.en");
});
});
@@ -2,7 +2,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findFirst: vi.fn(), create: vi.fn() },
insightStatusCache: {
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
},
@@ -10,8 +14,21 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
// Consent never blocks in these fixtures — the gate has its own tests.
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
vi.mock(
"@/lib/ai/coach/bytes-codec",
async () => (await import("./status-note-fixtures")).fakeBytesCodec,
);
// statusText is available in these fixtures — the capability read has its
// own tests in status-cache.test.ts.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -28,6 +45,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider";
import { getMedicationCategories } from "@/lib/medication-category";
import { getNoKeyMedicationComplianceStatusText } from "@/lib/insights/no-key-fallbacks";
import { generateMedicationComplianceStatusForUser } from "../medication-compliance-status";
import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures";
const dayMs = 24 * 60 * 60 * 1000;
@@ -64,6 +82,11 @@ function medFixture(now: Date) {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null);
vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue(
{} as never,
);
vi.mocked(getMedicationCategories).mockResolvedValue({});
});
@@ -102,16 +125,12 @@ describe("generateMedicationComplianceStatusForUser — graded payload", () => {
});
}
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication,
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
events as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK","medications":[]}', captured);
@@ -140,16 +159,12 @@ describe("generateMedicationComplianceStatusForUser — graded payload", () => {
describe("generateMedicationComplianceStatusForUser — timeout never persists", () => {
it("serves the fallback summary without writing a cache row on timeout", async () => {
const now = new Date();
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medFixture(now),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
@@ -164,46 +179,39 @@ describe("generateMedicationComplianceStatusForUser — timeout never persists",
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeNull();
// v1.8.3 — no real assessment persisted (updatedAt stays null above),
// but a short-TTL negative stub IS written so the read-only route does
// but a short-TTL negative window IS opened so the read-only route does
// not re-enqueue on every navigation while the provider is degraded.
// The stub is a timeout marker that `readFreshStatusText` rejects.
// The window carries no note, so it can never be served as one.
await Promise.resolve();
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
const details = JSON.parse(
(call[0] as { data: { details: string } }).data.details,
);
expect(details.timeout === true || details.model === "timeout-stub").toBe(
true,
);
}
expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]);
const windows = upsertedNotes(prisma.insightStatusCache.upsert);
expect(windows).toHaveLength(1);
expect(windows[0].retryAt).toBeInstanceOf(Date);
expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled();
});
});
describe("generateMedicationComplianceStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
describe("generateMedicationComplianceStatusForUser — a negative window is not a note", () => {
it("regenerates when today's row carries only a negative-cache window", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: todayKey,
locale: "en",
text: "Medication compliance fallback…",
model: "timeout-stub",
timeout: true,
}),
} as never);
text: null,
generatedAt: null,
retryAt: new Date(Date.now() + 60_000),
negativeReason: "timeout",
}) as never,
);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medFixture(now),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion(
'{"summary":"Fresh compliance assessment.","medications":[]}',
@@ -230,16 +238,12 @@ describe("generateMedicationComplianceStatusForUser — outbound screen (v1.32.2
* stays null; the per-medication placeholder rows still render.
*/
function setupCard(now: Date) {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medFixture(now),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
}
it("withholds a dose-change imperative instead of rendering the raw prose", async () => {
@@ -257,13 +261,11 @@ describe("generateMedicationComplianceStatusForUser — outbound screen (v1.32.2
expect(result.updatedAt).toBeNull();
// The per-medication placeholder rows still surface for the active med.
expect(result.medications).toHaveLength(1);
// No cache row ever persisted the screened model text.
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
const details = JSON.parse(
(call[0] as { data: { details: string } }).data.details,
) as { summary?: string };
expect(details.summary ?? "").not.toContain("10 mg");
// No note ever stored the screened model text.
for (const note of upsertedNotes(prisma.insightStatusCache.upsert)) {
expect(note.text ?? "").not.toContain("10 mg");
}
expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]);
});
it("withholds a fabricated risk figure instead of rendering the raw prose", async () => {
@@ -299,17 +301,12 @@ describe("generateMedicationComplianceStatusForUser — outbound screen (v1.32.2
);
expect(result.updatedAt).not.toBeNull();
expect(result.cached).toBe(false);
// The clean assessment was persisted (not the negative stub).
const persisted = vi
.mocked(prisma.auditLog.create)
.mock.calls.map(
(call) =>
JSON.parse(
(call[0] as { data: { details: string } }).data.details,
) as { summary?: string },
)
.find((details) => (details.summary ?? "").includes("96%"));
// The clean assessment was stored as a note (not a negative window).
const persisted = writtenNotes(prisma.insightStatusCache.upsert).find(
(note) => (note.text ?? "").includes("96%"),
);
expect(persisted).toBeTruthy();
expect(persisted!.negativeReason).toBeNull();
});
});
@@ -317,16 +314,12 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1
it("strips metric: tokens out of the cached summary", async () => {
const now = new Date();
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medFixture(now),
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
// The compliance prompt returns only a `{ summary }` envelope; the
// per-medication cards are placeholder text built server-side, never
@@ -348,16 +341,11 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1
expect(result.medications).toHaveLength(1);
expect(result.medications[0].medicationId).toBe("med-1");
expect(result.medications[0].text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
summary: string;
medications: Array<{ text: string }>;
};
expect(parsed.summary).not.toContain("metric:");
expect(parsed.medications[0].text).not.toContain("metric:");
const notes = writtenNotes(prisma.insightStatusCache.upsert);
expect(notes.length).toBeGreaterThan(0);
expect(notes[0].text).not.toContain("metric:");
const items = notes[0].items as Array<{ text: string }>;
expect(items[0].text).not.toContain("metric:");
});
});
@@ -396,7 +384,6 @@ describe("generateMedicationComplianceStatusForUser — no rate without an expec
};
});
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
scheduled,
scheduleless(now),
@@ -404,9 +391,6 @@ describe("generateMedicationComplianceStatusForUser — no rate without an expec
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
events as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK","medications":[]}', captured);
@@ -424,7 +408,6 @@ describe("generateMedicationComplianceStatusForUser — no rate without an expec
it("reports no active medications when the only one has no schedule", async () => {
const now = new Date();
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
scheduleless(now),
] as never);
+67 -41
View File
@@ -2,7 +2,18 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findFirst: vi.fn() },
insightStatusCache: { findUnique: vi.fn() },
},
}));
// A reversible stand-in for the at-rest codec; what this file pins is which
// stored note becomes prompt context, not AES-GCM.
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: (plain: string) => new TextEncoder().encode(`enc:${plain}`),
decryptFromBytes: (buf: Uint8Array) => {
const text = new TextDecoder().decode(buf);
if (!text.startsWith("enc:")) throw new Error("unknown key id");
return text.slice(4);
},
}));
@@ -12,75 +23,90 @@ import {
getPreviousInsightContext,
} from "../memory";
function noteRow(text: string | null, generatedAt: Date | null) {
return {
textEncrypted:
text === null ? null : new TextEncoder().encode(`enc:${text}`),
itemsEncrypted: null,
inputHash: null,
snapshotHash: null,
dateKey: "2026-05-01",
generatedAt,
retryAt: null,
negativeReason: null,
};
}
beforeEach(() => {
vi.mocked(prisma.auditLog.findFirst).mockReset();
vi.mocked(prisma.insightStatusCache.findUnique).mockReset();
});
describe("getPreviousInsightContext", () => {
it("returns null when no audit row exists", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce(null);
it("returns null when no stored note exists", async () => {
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce(null);
const ctx = await getPreviousInsightContext("u-1", "general-status", "en");
expect(ctx).toBeNull();
});
it("returns null when the audit row's details cannot be parsed", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
createdAt: new Date(),
details: "",
} as never);
it("returns null when the row carries no note (only a negative window)", async () => {
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce(
noteRow(null, null) as never,
);
const ctx = await getPreviousInsightContext("u-1", "general-status", "en");
expect(ctx).toBeNull();
});
it("extracts text from the JSON-shaped cache payload", async () => {
it("returns the stored note with its age", async () => {
const sevenDaysAgo = new Date(Date.now() - 7 * 86_400_000);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
createdAt: sevenDaysAgo,
details: JSON.stringify({
dateKey: "2026-05-01",
text: "Your BP averaged 130/80 — high-normal.",
}),
} as never);
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce(
noteRow("Your BP averaged 130/80 — high-normal.", sevenDaysAgo) as never,
);
const ctx = await getPreviousInsightContext("u-1", "general-status", "en");
expect(ctx).not.toBeNull();
expect(ctx!.ageDays).toBe(7);
expect(ctx!.text).toContain("130/80");
});
it("falls back to raw audit details when not JSON", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
createdAt: new Date(Date.now() - 86_400_000),
details: "Plain text from a legacy cache row.",
} as never);
const ctx = await getPreviousInsightContext("u-1", "general-status", "en");
expect(ctx?.text).toBe("Plain text from a legacy cache row.");
expect(ctx!.generatedAt).toBe(sevenDaysAgo.toISOString());
});
it("caps the text length so a verbose snapshot cannot bloat the prompt", async () => {
const big = "x".repeat(5000);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
createdAt: new Date(Date.now() - 86_400_000),
details: JSON.stringify({ text: big }),
} as never);
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce(
noteRow(big, new Date(Date.now() - 86_400_000)) as never,
);
const ctx = await getPreviousInsightContext("u-1", "general-status", "en");
expect(ctx!.text.length).toBeLessThanOrEqual(1502); // 1500 + "…"
expect(ctx!.text.endsWith("…")).toBe(true);
});
it("filters by minAgeHours so today's earlier run isn't treated as 'previous'", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce(null);
await getPreviousInsightContext("u-1", "general-status", "en", 24);
const call = vi.mocked(prisma.auditLog.findFirst).mock.calls[0]?.[0];
expect(call?.where?.action).toBe("insights.general-status.en");
// The lt filter exists with a date in the past. It can't be more
// than 24h ago because we just constructed it; allow a 30s slack.
const lt = (call?.where?.createdAt as { lt: Date }).lt;
expect(Date.now() - lt.getTime()).toBeGreaterThan(
24 * 60 * 60 * 1000 - 30_000,
// Written 23 h ago: inside a 24 h floor, so not "previous".
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce(
noteRow("Earlier today.", new Date(Date.now() - 23 * 3_600_000)) as never,
);
expect(Date.now() - lt.getTime()).toBeLessThan(
24 * 60 * 60 * 1000 + 30_000,
expect(
await getPreviousInsightContext("u-1", "general-status", "en", 24),
).toBeNull();
const call = vi.mocked(prisma.insightStatusCache.findUnique).mock
.calls[0]?.[0] as {
where: { userId_metric_locale: Record<string, string> };
};
expect(call.where.userId_metric_locale).toEqual({
userId: "u-1",
metric: "general",
locale: "en",
});
// Written 25 h ago: past the floor, so it is the previous analysis.
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce(
noteRow("Yesterday.", new Date(Date.now() - 25 * 3_600_000)) as never,
);
const ctx = await getPreviousInsightContext(
"u-1",
"general-status",
"en",
24,
);
expect(ctx?.text).toBe("Yesterday.");
});
});
@@ -1,9 +1,21 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// The graded series folds 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() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
insightStatusCache: {
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
measurement: { findMany: vi.fn(), count: vi.fn() },
measurementRollup: { findMany: vi.fn() },
},
@@ -11,8 +23,21 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
// Consent never blocks in these fixtures — the gate has its own tests.
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
vi.mock(
"@/lib/ai/coach/bytes-codec",
async () => (await import("./status-note-fixtures")).fakeBytesCodec,
);
// statusText is available in these fixtures — the capability read has its
// own tests in status-cache.test.ts.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -52,6 +77,7 @@ import {
metricStatusScope,
METRIC_STATUS_IDS,
} from "../metric-status-registry";
import { writtenNotes } from "./status-note-fixtures";
const dayMs = 24 * 60 * 60 * 1000;
@@ -78,6 +104,11 @@ function stubCompletion(
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null);
vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue(
{} as never,
);
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
// resetAllMocks clears the module-mock default impls too — restore the
// benign defaults so the relations fetch and the previous-context format
@@ -90,14 +121,10 @@ beforeEach(() => {
describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)", () => {
it("feeds the snapshot the per-night time-asleep total, not a single stage", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date("2026-06-04T07:00:00.000Z"),
} as never);
// One overnight session of granular stages, each row ONE stage (minutes).
// Total time asleep = CORE + DEEP + REM = 300 + 90 + 75 = 465 min (7.75 h).
@@ -161,14 +188,10 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)"
// granular stage + IN_BED + AWAKE into one day bucket (~1490 min ≈ 24.8 h).
// Post-fix the graded series is built from the deduped per-night points, so
// every recent bucket mean is the night total (480 min = 8 h).
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
// A recent night (last 24 h) so it lands in `graded.recent`.
const wake = new Date(Date.now() - 6 * 60 * 60 * 1000);
@@ -329,7 +352,6 @@ describe("archetype prompt templates", () => {
describe("generateMetricStatus — empty-data guard", () => {
it("returns insufficient WITHOUT calling the provider when the metric has no data", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never);
const result = await generateMetricStatus({
@@ -358,16 +380,12 @@ describe("generateMetricStatus — generation path", () => {
});
}
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.count).mockResolvedValue(400 as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
const captured = { systemPrompt: null, userPrompt: null } as {
systemPrompt: string | null;
@@ -396,12 +414,9 @@ describe("generateMetricStatus — generation path", () => {
expect(snapshot.metric.unit).toBe("bpm");
// Persisted under the generic scope cache action.
const createCall = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as {
data: { action: string; details: string };
};
expect(createCall.data.action).toBe(
"insights.metric:RESTING_HEART_RATE-status.en",
);
const [note] = writtenNotes(prisma.insightStatusCache.upsert);
expect(note.metric).toBe("metric:RESTING_HEART_RATE");
expect(note.locale).toBe("en");
// v1.12.1 — the diversity context (variety lead + explicit data strength)
// reaches the user prompt, and the relations fetch is keyed by this
@@ -424,16 +439,12 @@ describe("generateMetricStatus — generation path", () => {
measuredAt: new Date(now.getTime() - day * dayMs),
});
}
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.count).mockResolvedValue(60 as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
vi.mocked(getRelevantCorrelationsForMetric).mockResolvedValueOnce([
{
interpretation:
@@ -464,7 +475,6 @@ describe("generateMetricStatus — generation path", () => {
});
it("strips chart tokens from the persisted text", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.count).mockResolvedValue(3 as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 58, measuredAt: new Date() },
@@ -473,9 +483,6 @@ describe("generateMetricStatus — generation path", () => {
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
stubCompletion(
'{"summary":"Steady. metric:RESTING_HEART_RATE Good baseline."}',
+46 -45
View File
@@ -2,7 +2,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findFirst: vi.fn(), create: vi.fn() },
insightStatusCache: {
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
moodEntry: { findMany: vi.fn() },
measurement: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
@@ -11,8 +15,21 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
// Consent never blocks in these fixtures — the gate has its own tests.
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
vi.mock(
"@/lib/ai/coach/bytes-codec",
async () => (await import("./status-note-fixtures")).fakeBytesCodec,
);
// statusText is available in these fixtures — the capability read has its
// own tests in status-cache.test.ts.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -23,6 +40,7 @@ vi.mock("@/lib/insights/memory", () => ({
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateMoodStatusForUser } from "../mood-status";
import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures";
const dayMs = 24 * 60 * 60 * 1000;
@@ -46,6 +64,11 @@ function stubCompletion(
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null);
vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue(
{} as never,
);
// The snapshot threads the user's source priority into the factor crosstab;
// default to "no custom priority" so every test resolves it without stubbing.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
@@ -72,12 +95,8 @@ describe("generateMoodStatusForUser — graded payload", () => {
});
}
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue(entries as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK"}', captured);
@@ -108,7 +127,6 @@ describe("generateMoodStatusForUser — graded payload", () => {
describe("generateMoodStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
const t = new Date();
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{
date: t.toISOString().slice(0, 10),
@@ -118,9 +136,6 @@ describe("generateMoodStatusForUser — timeout/error never persists", () => {
},
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
@@ -132,37 +147,33 @@ describe("generateMoodStatusForUser — timeout/error never persists", () => {
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeNull();
// v1.8.3 — no real assessment persisted (updatedAt stays null above),
// but a short-TTL negative stub IS written so the read-only route does
// but a short-TTL negative window IS opened so the read-only route does
// not re-enqueue on every navigation while the provider is degraded.
// The stub is a timeout marker that `readFreshStatusText` rejects.
// The window carries no note, so it can never be served as one.
await Promise.resolve();
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
const details = JSON.parse(
(call[0] as { data: { details: string } }).data.details,
);
expect(details.timeout === true || details.model === "timeout-stub").toBe(
true,
);
}
expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]);
const windows = upsertedNotes(prisma.insightStatusCache.upsert);
expect(windows).toHaveLength(1);
expect(windows[0].retryAt).toBeInstanceOf(Date);
expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled();
});
});
describe("generateMoodStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
describe("generateMoodStatusForUser — a negative window is not a note", () => {
it("regenerates when today's row carries only a negative-cache window", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: todayKey,
locale: "en",
text: "Mood fallback text…",
model: "timeout-stub",
timeout: true,
}),
} as never);
text: null,
generatedAt: null,
retryAt: new Date(Date.now() + 60_000),
negativeReason: "timeout",
}) as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{
date: now.toISOString().slice(0, 10),
@@ -172,9 +183,6 @@ describe("generateMoodStatusForUser — cache-read skips a stub", () => {
},
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh mood assessment."}');
@@ -189,7 +197,6 @@ describe("generateMoodStatusForUser — cache-read skips a stub", () => {
describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
const t = new Date();
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{
date: t.toISOString().slice(0, 10),
@@ -199,9 +206,6 @@ describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", ()
},
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
stubCompletion('{"summary":"Mood stayed positive. metric:MOOD"}');
@@ -209,11 +213,8 @@ describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", ()
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
const notes = writtenNotes(prisma.insightStatusCache.upsert);
expect(notes.length).toBeGreaterThan(0);
expect(notes[0].text).not.toContain("metric:");
});
});
@@ -12,10 +12,22 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
* `runStatusCompletion` is stubbed to capture it.
*/
// The graded series folds 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() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
insightStatusCache: {
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
// v1.18.11 (P6) — the slow-mover input gate probes salient inputs.
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
measurementRollup: { findMany: vi.fn() },
@@ -27,8 +39,21 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
// Consent never blocks in these fixtures — the gate has its own tests.
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
vi.mock(
"@/lib/ai/coach/bytes-codec",
async () => (await import("./status-note-fixtures")).fakeBytesCodec,
);
// statusText is available in these fixtures — the capability read has its
// own tests in status-cache.test.ts.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -68,11 +93,12 @@ function snapshotChars(): number {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null);
vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue(
{} as never,
);
capturedPrompt = null;
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
@@ -12,17 +12,29 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
* (it is a deterministic, signal-grounded line, not a fresh AI assessment),
* and the served text names the user's own value rather than a generic tip.
*
* v1.8.3 — the timeout path now writes a *short-TTL negative stub*
* (`{ timeout:true, model:"timeout-stub", retryAt }`). It is explicitly
* rejected by `readFreshStatusText`, so it can never hide the real
* assessment; its sole purpose is to stop the read-only route re-enqueuing
* generation on every navigation while a provider is degraded.
* v1.8.3 — the timeout path opens a *short-TTL negative window* on the note
* row (`retryAt`, `negativeReason`). Writing it never touches the stored
* note, so it can never hide the real assessment; its sole purpose is to
* stop the read-only route re-enqueuing generation on every navigation while
* a provider is degraded.
*/
// The graded series folds per-day aggregates in SQL; the fake folds the
// 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() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
insightStatusCache: {
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
// v1.28.25 — the graded-series cold-tier fallback day-buckets dense
@@ -34,8 +46,21 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
// Consent never blocks in these fixtures — the gate has its own tests.
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
vi.mock(
"@/lib/ai/coach/bytes-codec",
async () => (await import("./status-note-fixtures")).fakeBytesCodec,
);
// statusText is available in these fixtures — the capability read has its
// own tests in status-cache.test.ts.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -49,6 +74,11 @@ import { generatePulseStatusForUser } from "../pulse-status";
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null);
vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue(
{} as never,
);
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
});
@@ -59,7 +89,6 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => {
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 72, measuredAt: new Date() },
] as never);
@@ -86,19 +115,34 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => {
// never mislabels the fallback as a fresh assessment.
expect(result.updatedAt).toBeNull();
// v1.8.3 — a short-TTL negative stub IS persisted (fire-and-forget) so
// v1.8.3 — a short-TTL negative window IS opened (fire-and-forget) so
// the read-only route doesn't re-enqueue on every navigation while the
// provider is degraded. It is marked as a timeout stub, which
// `readFreshStatusText` rejects, so it never hides the real assessment.
// provider is degraded. It carries no note text, so it never hides or
// replaces the real assessment.
// The write is fire-and-forget (`void`), so flush the microtask queue.
await Promise.resolve();
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const persisted = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as {
data: { details: string };
expect(prisma.insightStatusCache.upsert).toHaveBeenCalledTimes(1);
const persisted = vi.mocked(prisma.insightStatusCache.upsert).mock
.calls[0][0] as {
where: { userId_metric_locale: Record<string, string> };
create: Record<string, unknown>;
update: Record<string, unknown>;
};
const stub = JSON.parse(persisted.data.details);
expect(stub.timeout).toBe(true);
expect(stub.model).toBe("timeout-stub");
expect(typeof stub.retryAt).toBe("string");
expect(persisted.where.userId_metric_locale).toEqual({
userId: "user-1",
metric: "pulse",
locale: "en",
});
expect(persisted.create.textEncrypted).toBeUndefined();
expect(persisted.create.negativeReason).toBe("timeout");
// The update arm moves only the window, never an existing note.
expect(Object.keys(persisted.update).sort()).toEqual([
"negativeReason",
"retryAt",
]);
expect(persisted.update.retryAt).toBeInstanceOf(Date);
expect((persisted.update.retryAt as Date).getTime()).toBeGreaterThan(
Date.now(),
);
});
});
+53 -47
View File
@@ -1,9 +1,21 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// The graded series folds 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() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
insightStatusCache: {
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
// v1.28.25 — the graded-series cold-tier fallback day-buckets dense
@@ -15,8 +27,21 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
// Consent never blocks in these fixtures — the gate has its own tests.
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
vi.mock(
"@/lib/ai/coach/bytes-codec",
async () => (await import("./status-note-fixtures")).fakeBytesCodec,
);
// statusText is available in these fixtures — the capability read has its
// own tests in status-cache.test.ts.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -27,6 +52,7 @@ vi.mock("@/lib/insights/memory", () => ({
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generatePulseStatusForUser } from "../pulse-status";
import { noteRow, writtenNotes } from "./status-note-fixtures";
const dayMs = 24 * 60 * 60 * 1000;
@@ -50,6 +76,11 @@ function stubCompletion(
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null);
vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue(
{} as never,
);
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
// Cold rollup tier: the pulse graded series folds monthly/yearly from
// the full-history `measurement.findMany` fallback on a tier miss.
@@ -71,7 +102,6 @@ describe("generatePulseStatusForUser — graded payload", () => {
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
// v1.28.25 — PULSE is a dense type, so the cold-tier fallback reads
// a SQL day-bucket aggregate instead of the raw findMany walk. Feed
@@ -82,9 +112,6 @@ describe("generatePulseStatusForUser — graded payload", () => {
.reverse() as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK"}', captured);
@@ -146,16 +173,12 @@ describe("generatePulseStatusForUser — A2 resting-target in-target %", () => {
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
// pulse-status reads PULSE first, then RESTING_HEART_RATE.
vi.mocked(prisma.measurement.findMany)
.mockResolvedValueOnce(pulseRecords as never)
.mockResolvedValueOnce(restingRecords as never)
.mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK"}', captured);
@@ -194,15 +217,11 @@ describe("generatePulseStatusForUser — A2 resting-target in-target %", () => {
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany)
.mockResolvedValueOnce(pulseRecords as never)
.mockResolvedValueOnce([] as never); // no RESTING_HEART_RATE
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK"}', captured);
@@ -234,15 +253,13 @@ describe("generatePulseStatusForUser — per-user tz (QA F5)", () => {
gender: null,
timezone: "America/New_York",
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: JSON.stringify({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: nyToday,
locale: "en",
text: "NY-anchored cached pulse text.",
model: "x",
}),
} as never);
generatedAt: new Date(),
}) as never,
);
const result = await generatePulseStatusForUser("user-ny", {
locale: "en",
@@ -259,8 +276,8 @@ describe("generatePulseStatusForUser — per-user tz (QA F5)", () => {
});
});
describe("generatePulseStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
describe("generatePulseStatusForUser — a negative window is not a note", () => {
it("regenerates when today's row carries only a negative-cache window", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
@@ -269,23 +286,19 @@ describe("generatePulseStatusForUser — cache-read skips a stub", () => {
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: todayKey,
locale: "en",
text: "Pulse fallback text…",
model: "timeout-stub",
timeout: true,
}),
} as never);
text: null,
generatedAt: null,
retryAt: new Date(Date.now() + 60_000),
negativeReason: "timeout",
}) as never,
);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 72, measuredAt: now },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh pulse assessment."}');
@@ -303,14 +316,10 @@ describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", ()
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 72, measuredAt: new Date() },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
stubCompletion(
'{"summary":"Your pulse is stable. metric:PULSE The 7-day average sits inside the band."}',
@@ -320,12 +329,9 @@ describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", ()
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
expect(parsed.text).toContain("Your pulse is stable.");
const notes = writtenNotes(prisma.insightStatusCache.upsert);
expect(notes.length).toBeGreaterThan(0);
expect(notes[0].text).not.toContain("metric:");
expect(notes[0].text).toContain("Your pulse is stable.");
});
});
@@ -29,6 +29,10 @@ vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForJob: vi.fn(),
}));
// The single-card fallback path — assert it is invoked for omitted / failed
// metrics, but don't re-drive a real completion.
vi.mock("@/lib/insights/status-card-generation", async (importOriginal) => {
@@ -48,6 +52,7 @@ import { prepareMedicationComplianceStatusForUser } from "@/lib/insights/medicat
import { prepareGeneralStatusForUser } from "@/lib/insights/general-status";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { runPreparedStatusCard } from "@/lib/insights/status-card-generation";
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
import { generateStatusBatchForUser } from "../status-batch";
function servedCard() {
@@ -101,6 +106,37 @@ const ALL_PREPARES = [
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(aiCapabilityForJob).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
} as never);
});
describe("generateStatusBatchForUser — the statusText capability comes first", () => {
it("builds no snapshot and makes no call while statusText is unavailable", async () => {
vi.mocked(aiCapabilityForJob).mockResolvedValue({
available: false,
reason: "consent_required",
onDeviceAllowed: false,
} as never);
ALL_PREPARES.forEach((prepare, i) => {
vi.mocked(prepare).mockResolvedValue(pendingCard(`m${i}`) as never);
});
const result = await generateStatusBatchForUser("u1", { locale: "en" });
expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "statusText");
for (const prepare of ALL_PREPARES) expect(prepare).not.toHaveBeenCalled();
expect(runStatusCompletion).not.toHaveBeenCalled();
expect(runPreparedStatusCard).not.toHaveBeenCalled();
expect(result).toMatchObject({
served: 0,
batched: 0,
fellBack: 0,
batchCallMade: false,
});
});
});
describe("generateStatusBatchForUser — one call covers all present metrics", () => {
@@ -166,6 +202,10 @@ describe("generateStatusBatchForUser — one call covers all present metrics", (
// ONE provider round-trip for all seven metrics.
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
// The chokepoint re-checks the same capability at the wire.
expect(runStatusCompletion).toHaveBeenCalledWith(
expect.objectContaining({ capability: "statusText" }),
);
expect(result.batched).toBe(7);
expect(result.fellBack).toBe(0);
// Each metric's finalize got its own summary, wrapped in the { summary }
@@ -360,7 +400,10 @@ describe("generateStatusBatchForUser — graceful partial degradation", () => {
pendingCard(`m${i}`, finalizers[i]) as never,
);
});
vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" } as never);
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "none",
reason: "no_provider",
} as never);
const result = await generateStatusBatchForUser("u1", { locale: "en" });
@@ -16,8 +16,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const enqueueStatusGeneration = vi.fn(async () => {});
const hasUsableStatusProvider = vi.fn(async () => true);
const statusConsentBlocksGeneration = vi.fn(async () => false);
const probeProviderPresence = vi.fn(async () => true);
const aiCapabilityForRecord = vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}));
const suppressed = vi.fn(() => false);
vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({
@@ -26,11 +30,14 @@ vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({
STATUS_GENERATE_QUEUE: "insight-status-generate",
}));
vi.mock("@/lib/insights/status-provider", () => ({
hasUsableStatusProvider: (...args: unknown[]) =>
hasUsableStatusProvider(...(args as [])),
statusConsentBlocksGeneration: (...args: unknown[]) =>
statusConsentBlocksGeneration(...(args as [])),
vi.mock("@/lib/ai/provider", () => ({
probeProviderPresence: (...args: unknown[]) =>
probeProviderPresence(...(args as [])),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: (...args: unknown[]) =>
aiCapabilityForRecord(...(args as [])),
}));
vi.mock("@/lib/sharing/delegated-generation", () => ({
@@ -41,11 +48,10 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
// The last-good text and the negative-cache probe both read the audit
// ledger; an empty ledger is the cold-cache case this function is for.
auditLog: {
findMany: vi.fn(async () => []),
findFirst: vi.fn(async () => null),
// The last-good text and the negative-cache probe both read the one
// status-note row; no row is the cold-cache case this function is for.
insightStatusCache: {
findUnique: vi.fn(async () => null),
},
},
}));
@@ -57,11 +63,9 @@ beforeEach(() => {
// the provider probe's call count, and a counter carried over from the
// previous test would make it fail for the wrong reason (or pass for one).
enqueueStatusGeneration.mockClear();
hasUsableStatusProvider.mockClear();
statusConsentBlocksGeneration.mockClear();
probeProviderPresence.mockClear();
aiCapabilityForRecord.mockClear();
suppressed.mockReturnValue(false);
hasUsableStatusProvider.mockResolvedValue(true);
statusConsentBlocksGeneration.mockResolvedValue(false);
});
const ARGS = {
@@ -103,7 +107,9 @@ describe("resolveReadOnlyStatusMiss — the delegated path", () => {
await resolveReadOnlyStatusMiss(ARGS);
expect(hasUsableStatusProvider).not.toHaveBeenCalled();
expect(statusConsentBlocksGeneration).not.toHaveBeenCalled();
// The capability is the one read allowed ahead of the decision; the
// provider-presence probe belongs to the unavailable branch only.
expect(aiCapabilityForRecord).toHaveBeenCalledWith("owner-1", "statusText");
expect(probeProviderPresence).not.toHaveBeenCalled();
});
});
+413 -195
View File
@@ -2,7 +2,12 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() },
insightStatusCache: {
findUnique: vi.fn(),
findMany: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
// v1.18.11 (P6) — the input gate / fingerprint probe salient inputs.
measurement: { groupBy: vi.fn() },
moodEntry: { aggregate: vi.fn() },
@@ -10,12 +15,26 @@ vi.mock("@/lib/db", () => ({
},
}));
const hasUsableStatusProvider = vi.fn();
const statusConsentBlocksGeneration = vi.fn();
vi.mock("@/lib/insights/status-provider", () => ({
hasUsableStatusProvider: (...a: unknown[]) => hasUsableStatusProvider(...a),
statusConsentBlocksGeneration: (...a: unknown[]) =>
statusConsentBlocksGeneration(...a),
// A reversible stand-in for the at-rest codec: the store's own logic (what it
// serves, what it re-dates, what it leaves alone) is what these tests pin, not
// AES-GCM.
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: (plain: string) => new TextEncoder().encode(`enc:${plain}`),
decryptFromBytes: (buf: Uint8Array) => {
const text = new TextDecoder().decode(buf);
if (!text.startsWith("enc:")) throw new Error("unknown key id");
return text.slice(4);
},
}));
const aiCapabilityForRecord = vi.fn();
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a),
}));
const probeProviderPresence = vi.fn();
vi.mock("@/lib/ai/provider", () => ({
probeProviderPresence: (...a: unknown[]) => probeProviderPresence(...a),
}));
const enqueueStatusGeneration = vi.fn();
@@ -27,46 +46,108 @@ import { prisma } from "@/lib/db";
import {
computeStatusInputFingerprint,
gateUnchangedStatusInput,
isTimeoutStub,
readFreshStatusText,
readLastGoodStatusText,
readStatusNegativeCache,
refreshUnchangedStatusInsight,
resolveReadOnlyStatusMiss,
writeStatusNegativeWindow,
writeStatusNote,
} from "../status-cache";
const TODAY = "2026-05-31";
const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true };
function cacheRow(details: Record<string, unknown>, createdAt = new Date()) {
return { createdAt, details: JSON.stringify(details) };
function enc(text: string): Uint8Array {
return new TextEncoder().encode(`enc:${text}`);
}
/** One `InsightStatusCache` row as `findUnique` would return it. */
function noteRow(fields: {
text?: string | null;
dateKey?: string;
generatedAt?: Date | null;
snapshotHash?: string | null;
inputHash?: string | null;
retryAt?: Date | null;
negativeReason?: string | null;
textEncrypted?: Uint8Array | null;
}) {
return {
textEncrypted:
fields.textEncrypted !== undefined
? fields.textEncrypted
: fields.text == null
? null
: enc(fields.text),
itemsEncrypted: null,
inputHash: fields.inputHash ?? null,
snapshotHash: fields.snapshotHash ?? null,
dateKey: fields.dateKey ?? TODAY,
generatedAt:
fields.generatedAt !== undefined ? fields.generatedAt : new Date(),
retryAt: fields.retryAt ?? null,
negativeReason: fields.negativeReason ?? null,
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
aiCapabilityForRecord.mockResolvedValue(AVAILABLE);
probeProviderPresence.mockResolvedValue(true);
});
describe("isTimeoutStub", () => {
it("flags the timeout-stub model marker", () => {
expect(isTimeoutStub({ model: "timeout-stub" })).toBe(true);
describe("writeStatusNote / writeStatusNegativeWindow", () => {
it("upserts one encrypted row per (user, metric, locale) and clears the negative window", async () => {
vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never);
await writeStatusNote({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
text: "Stable.",
snapshotHash: "s".repeat(64),
});
const arg = vi.mocked(prisma.insightStatusCache.upsert).mock
.calls[0][0] as {
where: { userId_metric_locale: Record<string, string> };
create: Record<string, unknown>;
update: Record<string, unknown>;
};
expect(arg.where.userId_metric_locale).toEqual({
userId: "u1",
metric: "weight",
locale: "en",
});
expect(
new TextDecoder().decode(arg.update.textEncrypted as Uint8Array),
).toBe("enc:Stable.");
expect(arg.update.dateKey).toBe(TODAY);
expect(arg.update.snapshotHash).toBe("s".repeat(64));
expect(arg.update.retryAt).toBeNull();
expect(arg.update.negativeReason).toBeNull();
});
it("flags the timeout:true marker", () => {
expect(isTimeoutStub({ timeout: true })).toBe(true);
});
it("does not flag a real assessment", () => {
expect(isTimeoutStub({ model: "gpt-4o-mini", timeout: false })).toBe(false);
expect(isTimeoutStub({})).toBe(false);
it("opens a negative window without touching the stored note", async () => {
const retryAt = new Date(Date.now() + 60_000);
await writeStatusNegativeWindow({
userId: "u1",
cacheAction: "insights.pulse-status.de",
todayKey: TODAY,
reason: "timeout",
retryAt,
});
const arg = vi.mocked(prisma.insightStatusCache.upsert).mock
.calls[0][0] as { update: Record<string, unknown> };
// Only the window moves: a stall must not hide yesterday's text.
expect(arg.update).toEqual({ retryAt, negativeReason: "timeout" });
});
});
describe("readFreshStatusText", () => {
it("returns today's real assessment text", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: TODAY,
text: "Your weight trend is stable.",
model: "gpt-4o-mini",
}) as never,
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({ text: "Your weight trend is stable." }) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
@@ -75,15 +156,44 @@ describe("readFreshStatusText", () => {
force: false,
});
expect(hit?.text).toBe("Your weight trend is stable.");
expect(prisma.insightStatusCache.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId_metric_locale: {
userId: "u1",
metric: "weight",
locale: "en",
},
},
}),
);
});
it("skips a timeout-stub row keyed to today", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: TODAY,
text: "Generic fallback advice.",
model: "timeout-stub",
timeout: true,
it("serves today's note even while a negative window is open on it", async () => {
// A stall writes the window next to the note; it never shadows the text.
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
text: "Real assessment.",
retryAt: new Date(Date.now() + 60_000),
negativeReason: "timeout",
}) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit?.text).toBe("Real assessment.");
});
it("misses on a row that carries only a negative window", async () => {
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
text: null,
generatedAt: null,
retryAt: new Date(Date.now() + 60_000),
negativeReason: "timeout",
}) as never,
);
const hit = await readFreshStatusText({
@@ -96,8 +206,8 @@ describe("readFreshStatusText", () => {
});
it("skips a stale-day row", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({ dateKey: "2026-05-30", text: "Yesterday." }) as never,
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({ dateKey: "2026-05-30", text: "Yesterday." }) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
@@ -109,8 +219,8 @@ describe("readFreshStatusText", () => {
});
it("skips an empty-text row", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({ dateKey: TODAY, text: " " }) as never,
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({ text: " " }) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
@@ -129,14 +239,15 @@ describe("readFreshStatusText", () => {
force: true,
});
expect(hit).toBeNull();
expect(prisma.auditLog.findFirst).not.toHaveBeenCalled();
expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled();
});
it("treats a malformed payload as a miss", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: "{not json",
} as never);
it("treats a note it cannot decrypt as a miss", async () => {
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
textEncrypted: new TextEncoder().encode("{not ciphertext"),
}) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
@@ -145,35 +256,153 @@ describe("readFreshStatusText", () => {
});
expect(hit).toBeNull();
});
it("returns null when statusText is unavailable, without reading the row", async () => {
aiCapabilityForRecord.mockResolvedValue({
available: false,
reason: "user_disabled",
onDeviceAllowed: false,
});
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({ text: "Would have been served." }) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "statusText");
expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled();
});
});
describe("readLastGoodStatusText", () => {
it("serves a note from an earlier day", async () => {
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: "2026-05-29",
text: "Two days old.",
generatedAt: new Date("2026-05-29T04:30:00.000Z"),
}) as never,
);
expect(
await readLastGoodStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
}),
).toEqual({
text: "Two days old.",
updatedAt: "2026-05-29T04:30:00.000Z",
});
});
it("serves nothing when statusText is unavailable", async () => {
aiCapabilityForRecord.mockResolvedValue({
available: false,
reason: "consent_required",
onDeviceAllowed: false,
});
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({ text: "Old text." }) as never,
);
expect(
await readLastGoodStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
}),
).toBeNull();
});
});
describe("readStatusNegativeCache", () => {
it("reports an open window and flips retryable once retryAt passes", async () => {
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce(
noteRow({
text: "Kept.",
retryAt: new Date(Date.now() + 60_000),
negativeReason: "screened",
}) as never,
);
const open = await readStatusNegativeCache({
userId: "u1",
cacheAction: "insights.weight-status.en",
});
expect(open).toMatchObject({ reason: "screened", retryable: false });
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce(
noteRow({
text: "Kept.",
retryAt: new Date(Date.now() - 60_000),
negativeReason: "timeout",
}) as never,
);
const closed = await readStatusNegativeCache({
userId: "u1",
cacheAction: "insights.weight-status.en",
});
expect(closed).toMatchObject({ reason: "timeout", retryable: true });
});
});
describe("resolveReadOnlyStatusMiss", () => {
beforeEach(() => {
// Default: no prior assessment to serve stale (readLastGoodStatusText).
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
// Default: no row at all — no last-good text, no negative window.
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
null as never,
);
});
it("returns no-provider without enqueuing when the user has no provider", async () => {
hasUsableStatusProvider.mockResolvedValue(false);
it("returns unavailable with hasProvider from the probe and enqueues nothing", async () => {
aiCapabilityForRecord.mockResolvedValue({
available: false,
reason: "consent_required",
onDeviceAllowed: false,
});
probeProviderPresence.mockResolvedValue(true);
const outcome = await resolveReadOnlyStatusMiss({
userId: "u1",
metric: "weight",
locale: "en",
});
expect(outcome.kind).toBe("no-provider");
// A withdrawn consent is not "no provider": the probe says one exists.
expect(outcome).toEqual({
kind: "unavailable",
reason: "consent_required",
hasProvider: true,
});
expect(probeProviderPresence).toHaveBeenCalledWith("u1", "text");
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled();
});
it("returns unavailable/no_provider without enqueuing when the user has no provider", async () => {
aiCapabilityForRecord.mockResolvedValue({
available: false,
reason: "no_provider",
onDeviceAllowed: false,
});
probeProviderPresence.mockResolvedValue(false);
const outcome = await resolveReadOnlyStatusMiss({
userId: "u1",
metric: "weight",
locale: "en",
});
expect(outcome).toEqual({
kind: "unavailable",
reason: "no_provider",
hasProvider: false,
});
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
});
it("enqueues generation and returns preparing on a clean miss", async () => {
hasUsableStatusProvider.mockResolvedValue(true);
// No negative stub present.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
const outcome = await resolveReadOnlyStatusMiss({
userId: "u1",
metric: "pulse",
locale: "de",
});
expect(outcome.kind).toBe("preparing");
// No last-good text to show, so nothing to revalidate against — the card
// polls on `preparing` alone.
expect(outcome).toEqual({
@@ -189,19 +418,14 @@ describe("resolveReadOnlyStatusMiss", () => {
});
it("serves the last good assessment stale-while-revalidate on a clean miss", async () => {
hasUsableStatusProvider.mockResolvedValue(true);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
// A prior (e.g. yesterday's) real assessment is on record.
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([
cacheRow(
{
dateKey: "2026-05-30",
text: "Steady upward trend.",
model: "gpt-4o-mini",
},
new Date("2026-05-30T04:30:00.000Z"),
),
] as never);
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: "2026-05-30",
text: "Steady upward trend.",
generatedAt: new Date("2026-05-30T04:30:00.000Z"),
}) as never,
);
const outcome = await resolveReadOnlyStatusMiss({
userId: "u1",
metric: "weight",
@@ -217,14 +441,13 @@ describe("resolveReadOnlyStatusMiss", () => {
expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1);
});
it("suppresses re-enqueue while a fresh timeout stub exists", async () => {
hasUsableStatusProvider.mockResolvedValue(true);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: TODAY,
timeout: true,
model: "timeout-stub",
retryAt: new Date(Date.now() + 60_000).toISOString(),
it("suppresses re-enqueue while a negative window is open, still serving the last note", async () => {
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: "2026-05-30",
text: "Yesterday's note.",
retryAt: new Date(Date.now() + 60_000),
negativeReason: "timeout",
}) as never,
);
const outcome = await resolveReadOnlyStatusMiss({
@@ -234,19 +457,20 @@ describe("resolveReadOnlyStatusMiss", () => {
});
expect(outcome.kind).toBe("preparing");
if (outcome.kind !== "preparing") throw new Error("expected preparing");
// The window does not hide the stored note.
expect(outcome.lastGood?.text).toBe("Yesterday's note.");
// No enqueue on the suppressed branch → nothing in flight to revalidate.
expect(outcome.revalidating).toBe(false);
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
});
it("re-enqueues once the negative stub's retryAt has passed", async () => {
hasUsableStatusProvider.mockResolvedValue(true);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: TODAY,
timeout: true,
model: "timeout-stub",
retryAt: new Date(Date.now() - 60_000).toISOString(),
it("re-enqueues once the negative window's retryAt has passed", async () => {
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
text: null,
generatedAt: null,
retryAt: new Date(Date.now() - 60_000),
negativeReason: "timeout",
}) as never,
);
const outcome = await resolveReadOnlyStatusMiss({
@@ -262,13 +486,12 @@ describe("resolveReadOnlyStatusMiss", () => {
describe("refreshUnchangedStatusInsight (v1.16.8)", () => {
const HASH = "a".repeat(64);
beforeEach(() => {
// Default: consent does not block (BYOK / consented chains).
statusConsentBlocksGeneration.mockResolvedValue(false);
});
it("misses (and writes nothing) when the server-managed consent is revoked, even on a hash match", async () => {
statusConsentBlocksGeneration.mockResolvedValue(true);
it("misses (and writes nothing) when statusText is unavailable, even on a hash match", async () => {
aiCapabilityForRecord.mockResolvedValue({
available: false,
reason: "consent_required",
onDeviceAllowed: false,
});
const hit = await refreshUnchangedStatusInsight({
userId: "u1",
cacheAction: "insights.weight-status.en",
@@ -276,31 +499,24 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => {
snapshotHash: HASH,
});
expect(hit).toBeNull();
// The gate must not even read the cache row — a revoked consent can
// never re-stamp old AI text as today's assessment.
expect(prisma.auditLog.findFirst).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(statusConsentBlocksGeneration).toHaveBeenCalledWith(
"u1",
"insights",
);
// The gate must not even read the row — an unavailable capability can
// never re-date old AI text as today's assessment.
expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled();
expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled();
expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "statusText");
});
it("re-persists the row under today's dateKey and returns the text on a hash match", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
it("re-dates the row under today's dateKey and returns the text on a hash match", async () => {
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: "2026-05-30",
locale: "en",
text: "Stable weight, no concerns.",
providerType: "openai",
model: "gpt-4o-mini",
tokensUsed: 321,
snapshotHash: HASH,
}) as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date("2026-05-31T04:30:00.000Z"),
} as never);
vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue(
{} as never,
);
const hit = await refreshUnchangedStatusInsight({
userId: "u1",
@@ -310,27 +526,33 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => {
});
expect(hit?.text).toBe("Stable weight, no concerns.");
expect(hit?.updatedAt).toBe("2026-05-31T04:30:00.000Z");
// The refresh row carries the same payload re-keyed to today, so the
// read path and the ingest debounce both see a current assessment.
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const arg = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as {
data: { userId: string; action: string; details: string };
expect(hit?.expiresAfterDateKey).toBe(TODAY);
// The re-date moves only the day key and timestamp: the note and its
// fingerprints stay, so the read path and the ingest debounce both see a
// current assessment and tomorrow's gate still matches.
expect(prisma.insightStatusCache.updateMany).toHaveBeenCalledTimes(1);
const arg = vi.mocked(prisma.insightStatusCache.updateMany).mock
.calls[0][0] as {
where: Record<string, string>;
data: Record<string, unknown>;
};
expect(arg.data.userId).toBe("u1");
expect(arg.data.action).toBe("insights.weight-status.en");
const details = JSON.parse(arg.data.details) as Record<string, unknown>;
expect(details.dateKey).toBe(TODAY);
expect(details.text).toBe("Stable weight, no concerns.");
expect(details.snapshotHash).toBe(HASH);
expect(arg.where).toEqual({
userId: "u1",
metric: "weight",
locale: "en",
});
expect(arg.data.dateKey).toBe(TODAY);
expect(arg.data).not.toHaveProperty("textEncrypted");
expect(arg.data).not.toHaveProperty("snapshotHash");
expect(hit?.updatedAt).toBe((arg.data.generatedAt as Date).toISOString());
expect(prisma.insightStatusCache.upsert).not.toHaveBeenCalled();
});
it("misses (and writes nothing) when the stored hash differs", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: "2026-05-30",
text: "Older text.",
model: "gpt-4o-mini",
snapshotHash: "b".repeat(64),
}) as never,
);
@@ -341,15 +563,31 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => {
snapshotHash: HASH,
});
expect(hit).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled();
});
it("misses when the latest row carries no hash (pre-gate rows)", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: "2026-05-30",
text: "Older text.",
model: "gpt-4o-mini",
it("misses when the row carries no hash", async () => {
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({ dateKey: "2026-05-30", text: "Older text." }) as never,
);
const hit = await refreshUnchangedStatusInsight({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
snapshotHash: HASH,
});
expect(hit).toBeNull();
expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled();
});
it("never refreshes off a row that carries only a negative window", async () => {
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
text: null,
generatedAt: null,
snapshotHash: HASH,
retryAt: new Date(Date.now() + 60_000),
negativeReason: "timeout",
}) as never,
);
const hit = await refreshUnchangedStatusInsight({
@@ -359,31 +597,28 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => {
snapshotHash: HASH,
});
expect(hit).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled();
});
it("never refreshes off a timeout stub, even with a matching hash", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: "2026-05-30",
text: "Generic fallback advice.",
model: "timeout-stub",
timeout: true,
it("misses on no prior row and on an undecryptable note", async () => {
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce(
null as never,
);
expect(
await refreshUnchangedStatusInsight({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
snapshotHash: HASH,
}),
).toBeNull();
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce(
noteRow({
textEncrypted: new TextEncoder().encode("{not ciphertext"),
snapshotHash: HASH,
}) as never,
);
const hit = await refreshUnchangedStatusInsight({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
snapshotHash: HASH,
});
expect(hit).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it("misses on no prior row and on malformed payloads", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce(null as never);
expect(
await refreshUnchangedStatusInsight({
userId: "u1",
@@ -392,27 +627,11 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => {
snapshotHash: HASH,
}),
).toBeNull();
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
details: "{not json",
} as never);
expect(
await refreshUnchangedStatusInsight({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
snapshotHash: HASH,
}),
).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled();
});
});
describe("computeStatusInputFingerprint (v1.18.11 P6)", () => {
beforeEach(() => {
statusConsentBlocksGeneration.mockResolvedValue(false);
});
it("is stable across group order and flips when a count or newest moves", async () => {
const t0 = new Date("2026-05-30T08:00:00.000Z");
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
@@ -598,24 +817,18 @@ describe("computeStatusInputFingerprint (v1.18.11 P6)", () => {
describe("gateUnchangedStatusInput (v1.18.11 P6)", () => {
const INPUT = "b".repeat(64);
beforeEach(() => {
statusConsentBlocksGeneration.mockResolvedValue(false);
});
it("re-stamps the cached text and skips the build on a matching input hash", async () => {
const created = new Date("2026-05-31T02:00:00.000Z");
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
it("re-dates the cached text and skips the build on a matching input hash", async () => {
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: "2026-05-30",
text: "Stable weight.",
model: "gpt-4o-mini",
inputHash: INPUT,
snapshotHash: "c".repeat(64),
}) as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: created,
} as never);
vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue(
{} as never,
);
const hit = await gateUnchangedStatusInput({
userId: "u1",
@@ -625,25 +838,23 @@ describe("gateUnchangedStatusInput (v1.18.11 P6)", () => {
force: false,
});
expect(hit?.text).toBe("Stable weight.");
const persisted = JSON.parse(
(
vi.mocked(prisma.auditLog.create).mock.calls[0][0] as {
data: { details: string };
}
).data.details,
) as { dateKey: string; inputHash: string; snapshotHash: string };
expect(persisted.dateKey).toBe(TODAY);
// The prior fingerprints are preserved so the next day's gates match.
expect(persisted.inputHash).toBe(INPUT);
expect(persisted.snapshotHash).toBe("c".repeat(64));
const data = (
vi.mocked(prisma.insightStatusCache.updateMany).mock.calls[0][0] as {
data: Record<string, unknown>;
}
).data;
expect(data.dateKey).toBe(TODAY);
// The prior fingerprints are preserved (not rewritten) so the next day's
// gates match.
expect(data).not.toHaveProperty("inputHash");
expect(data).not.toHaveProperty("snapshotHash");
});
it("misses on a differing or missing input hash (caller builds)", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: "2026-05-30",
text: "Stable weight.",
model: "gpt-4o-mini",
// no inputHash on the row
}) as never,
);
@@ -656,7 +867,7 @@ describe("gateUnchangedStatusInput (v1.18.11 P6)", () => {
force: false,
}),
).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled();
});
it("misses on a forced run without touching the cache", async () => {
@@ -669,11 +880,18 @@ describe("gateUnchangedStatusInput (v1.18.11 P6)", () => {
force: true,
}),
).toBeNull();
expect(prisma.auditLog.findFirst).not.toHaveBeenCalled();
expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled();
});
it("misses when the server-managed consent gate would block (no stale re-date)", async () => {
statusConsentBlocksGeneration.mockResolvedValue(true);
it("misses when statusText is unavailable (no stale re-date)", async () => {
aiCapabilityForRecord.mockResolvedValue({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
});
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({ text: "Stable weight.", inputHash: INPUT }) as never,
);
expect(
await gateUnchangedStatusInput({
userId: "u1",
@@ -683,6 +901,6 @@ describe("gateUnchangedStatusInput (v1.18.11 P6)", () => {
force: false,
}),
).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,105 @@
/**
* Shared fixtures for the status-generator tests that run against the real
* status-note store (`status-cache.ts`) over a mocked `prisma`.
*
* `fakeBytesCodec` stands in for the at-rest codec so a fixture can build a
* stored note from plain text and read back what a generator wrote. Wire it
* with
*
* vi.mock("@/lib/ai/coach/bytes-codec", async () =>
* (await import("./status-note-fixtures")).fakeBytesCodec,
* );
*
* It is not AES-GCM and does not try to be: these tests pin what the store
* serves, re-dates and writes, not the cipher.
*/
import type { Mock } from "vitest";
const PREFIX = "enc:";
export const fakeBytesCodec = {
encryptToBytes: (plain: string): Uint8Array =>
new TextEncoder().encode(`${PREFIX}${plain}`),
decryptFromBytes: (buf: Uint8Array): string => {
const text = new TextDecoder().decode(buf);
if (!text.startsWith(PREFIX)) throw new Error("unknown key id");
return text.slice(PREFIX.length);
},
};
/** The capability state every generator fixture runs under by default. */
export const CAPABILITY_AVAILABLE = {
available: true,
reason: null,
onDeviceAllowed: true,
} as const;
/** One `InsightStatusCache` row as `findUnique` returns it. */
export function noteRow(fields: {
text?: string | null;
items?: unknown;
dateKey: string;
generatedAt?: Date | null;
snapshotHash?: string | null;
inputHash?: string | null;
retryAt?: Date | null;
negativeReason?: string | null;
}) {
return {
textEncrypted:
fields.text == null ? null : fakeBytesCodec.encryptToBytes(fields.text),
itemsEncrypted:
fields.items === undefined
? null
: fakeBytesCodec.encryptToBytes(JSON.stringify(fields.items)),
inputHash: fields.inputHash ?? null,
snapshotHash: fields.snapshotHash ?? null,
dateKey: fields.dateKey,
generatedAt:
fields.generatedAt !== undefined ? fields.generatedAt : new Date(),
retryAt: fields.retryAt ?? null,
negativeReason: fields.negativeReason ?? null,
};
}
/** A note a generator wrote through `insightStatusCache.upsert`, decoded. */
export interface WrittenNote {
metric: string;
locale: string;
text: string | null;
items: unknown;
dateKey: string | undefined;
snapshotHash: string | null | undefined;
inputHash: string | null | undefined;
retryAt: Date | null | undefined;
negativeReason: string | null | undefined;
}
/**
* Every `upsert` call on the mocked `insightStatusCache`, decoded from its
* `create` arm (which carries the full row either way).
*/
export function upsertedNotes(upsert: Mock | unknown): WrittenNote[] {
return (upsert as Mock).mock.calls.map((call) => {
const create = (call[0] as { create: Record<string, unknown> }).create;
const decode = (v: unknown) =>
v instanceof Uint8Array ? fakeBytesCodec.decryptFromBytes(v) : null;
const itemsJson = decode(create.itemsEncrypted);
return {
metric: create.metric as string,
locale: create.locale as string,
text: decode(create.textEncrypted),
items: itemsJson === null ? null : JSON.parse(itemsJson),
dateKey: create.dateKey as string | undefined,
snapshotHash: create.snapshotHash as string | null | undefined,
inputHash: create.inputHash as string | null | undefined,
retryAt: create.retryAt as Date | null | undefined,
negativeReason: create.negativeReason as string | null | undefined,
};
});
}
/** Only the upserts that wrote a note (not a bare negative-cache window). */
export function writtenNotes(upsert: Mock | unknown): WrittenNote[] {
return upsertedNotes(upsert).filter((n) => n.text !== null);
}
@@ -64,7 +64,18 @@ const { resolveProviderChain, resolveProvider } = vi.hoisted(() => ({
resolveProviderChain: vi.fn(),
resolveProvider: vi.fn(),
}));
vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain, resolveProvider }));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain,
resolveProvider,
probeProviderPresence: vi.fn(async () => true),
}));
// The capability re-check at the wire. Available by default so the budget is
// what these tests isolate; one test below flips it.
const { aiCapabilityForRecord } = vi.hoisted(() => ({
aiCapabilityForRecord: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord }));
const { runRawCompletionWithFallback } = vi.hoisted(() => ({
runRawCompletionWithFallback: vi.fn(),
@@ -74,15 +85,16 @@ vi.mock("@/lib/ai/provider-runner", () => ({
runRawCompletionWithFallback,
}));
// Consent gate is exercised by its own suite; keep it open here so the budget
// behaviour is what these tests isolate.
vi.mock("@/lib/ai/consent-guard", () => ({
chainRequiresServerManagedConsent: vi.fn(() => false),
hasActiveConsentForSurface: vi.fn(async () => true),
}));
// The wire re-check for the resolved chain (`aiEgressRefusal`). Its own rule
// (capability + receipt for an operator-held entry) is pinned in egress.test.ts;
// here it passes by default so the budget is what these tests isolate, and
// the refusal test below flips it.
const { aiEgressRefusal } = vi.hoisted(() => ({ aiEgressRefusal: vi.fn() }));
vi.mock("@/lib/ai/capabilities/egress", () => ({ aiEgressRefusal }));
import { runStatusCompletion } from "../status-provider";
import { annotate } from "@/lib/logging/context";
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
import { OPERATOR_COST_CAP, USER_PLAN_CAP } from "@/lib/ai/coach/budget";
const OPERATOR_CHAIN = [{ providerType: "admin-openai", instance: {} }];
@@ -94,7 +106,7 @@ function completionArgs(overrides: Record<string, unknown> = {}) {
cacheAction: "status:test",
systemPrompt: "system",
userPrompt: "user",
consentSurface: "insights" as const,
capability: "statusText" as const,
...overrides,
};
}
@@ -113,6 +125,72 @@ beforeEach(() => {
ledgerOperator = 0;
resolveProviderChain.mockResolvedValue(OPERATOR_CHAIN);
resolveProvider.mockResolvedValue({ type: "none" });
aiCapabilityForRecord.mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
aiEgressRefusal.mockResolvedValue(null);
});
describe("runStatusCompletion — capability at the wire", () => {
it("returns none with the capability's reason and never resolves the chain when the capability is unavailable", async () => {
aiCapabilityForRecord.mockResolvedValue({
available: false,
reason: "user_disabled",
onDeviceAllowed: false,
});
const outcome = await runStatusCompletion(
completionArgs({ capability: "periodNarrative" }) as never,
);
expect(outcome).toEqual({ kind: "none", reason: "user_disabled" });
expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "periodNarrative");
expect(resolveProviderChain).not.toHaveBeenCalled();
expect(resolveProvider).not.toHaveBeenCalled();
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
// Nothing reserved against the day's ledger either.
expect(ledgerTotal).toBe(0);
});
it("re-checks exactly the resolved chain and refuses before any budget or provider work", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
{ providerType: "admin-openai", instance: {} },
]);
aiEgressRefusal.mockResolvedValue(
new AiUnavailableError("coach", "consent_required"),
);
const outcome = await runStatusCompletion(
completionArgs({ capability: "coach" }) as never,
);
expect(outcome).toEqual({ kind: "none", reason: "consent_required" });
expect(aiEgressRefusal).toHaveBeenCalledWith("coach", "u1", [
"anthropic",
"admin-openai",
]);
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
expect(ledgerTotal).toBe(0);
expect(annotate).toHaveBeenCalledWith(
expect.objectContaining({
action: { name: "insights.status.capability_unavailable" },
meta: expect.objectContaining({
reason: "consent_required",
at: "wire",
}),
}),
);
});
it("returns none with reason no_provider when the chain is empty", async () => {
resolveProviderChain.mockResolvedValue([]);
const outcome = await runStatusCompletion(completionArgs() as never);
expect(outcome).toEqual({ kind: "none", reason: "no_provider" });
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
});
describe("runStatusCompletion — ledger accounting", () => {
+135 -88
View File
@@ -1,8 +1,20 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// The graded series folds 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: {
auditLog: { findFirst: vi.fn(), create: vi.fn() },
insightStatusCache: {
findUnique: vi.fn(),
upsert: vi.fn(),
updateMany: vi.fn(),
},
// v1.18.11 (P6) — the input gate probes salient inputs via groupBy +
// moodEntry.aggregate before the heavy findMany build.
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
@@ -18,10 +30,39 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
// Consent never blocks in these fixtures — the gate has its own tests.
statusConsentBlocksGeneration: vi.fn(async () => false),
}));
vi.mock(
"@/lib/ai/coach/bytes-codec",
async () => (await import("./status-note-fixtures")).fakeBytesCodec,
);
// statusText is available unless a test says otherwise — the capability
// read itself has its own tests in status-cache.test.ts.
const {
aiCapabilityForRecord,
probeProviderPresence,
enqueueStatusGeneration,
} = vi.hoisted(() => ({
aiCapabilityForRecord: vi.fn(),
probeProviderPresence: vi.fn(),
enqueueStatusGeneration: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord }));
vi.mock("@/lib/ai/provider", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ai/provider")>()),
probeProviderPresence,
}));
vi.mock(
"@/lib/jobs/insight-status-generate-shared",
async (importOriginal) => ({
...(await importOriginal<
typeof import("@/lib/jobs/insight-status-generate-shared")
>()),
enqueueStatusGeneration,
}),
);
vi.mock("@/lib/insights/memory", () => ({
getPreviousInsightContext: vi.fn().mockResolvedValue(null),
formatPreviousContextForPrompt: vi.fn().mockReturnValue(""),
@@ -30,6 +71,8 @@ vi.mock("@/lib/insights/memory", () => ({
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateWeightStatusForUser } from "../weight-status";
import { getNoKeyWeightStatusText } from "@/lib/insights/no-key-fallbacks";
import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures";
const dayMs = 24 * 60 * 60 * 1000;
@@ -68,6 +111,49 @@ beforeEach(() => {
_max: { moodLoggedAt: null },
} as never);
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null);
vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue(
{} as never,
);
aiCapabilityForRecord.mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
probeProviderPresence.mockResolvedValue(true);
enqueueStatusGeneration.mockResolvedValue(undefined);
});
describe("generateWeightStatusForUser — read-only miss while statusText is unavailable", () => {
it.each([
["consent_required", true],
["no_provider", false],
] as const)(
"serves the deterministic line with hasProvider from the probe (%s) and enqueues nothing",
async (reason, present) => {
aiCapabilityForRecord.mockResolvedValue({
available: false,
reason,
onDeviceAllowed: false,
});
probeProviderPresence.mockResolvedValue(present);
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
readOnly: true,
});
// `hasProvider` is provider presence only: a withdrawn consent is not
// "no provider configured".
expect(result.hasProvider).toBe(present);
expect(result.text).toBe(getNoKeyWeightStatusText("en"));
expect(result.updatedAt).toBeNull();
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
expect(runStatusCompletion).not.toHaveBeenCalled();
expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled();
},
);
});
describe("generateWeightStatusForUser — graded payload", () => {
@@ -86,12 +172,8 @@ describe("generateWeightStatusForUser — graded payload", () => {
});
}
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK"}', captured);
@@ -127,14 +209,10 @@ describe("generateWeightStatusForUser — graded payload", () => {
describe("generateWeightStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
@@ -148,44 +226,38 @@ describe("generateWeightStatusForUser — timeout/error never persists", () => {
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeNull();
// v1.8.3 — no real assessment persisted (updatedAt stays null above),
// but a short-TTL negative stub IS written so the read-only route does
// but a short-TTL negative window IS opened so the read-only route does
// not re-enqueue on every navigation while the provider is degraded.
// The stub is a timeout marker that `readFreshStatusText` rejects.
// The window carries no note, so it can never be served as one.
await Promise.resolve();
for (const call of vi.mocked(prisma.auditLog.create).mock.calls) {
const details = JSON.parse(
(call[0] as { data: { details: string } }).data.details,
);
expect(details.timeout === true || details.model === "timeout-stub").toBe(
true,
);
}
expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]);
const windows = upsertedNotes(prisma.insightStatusCache.upsert);
expect(windows).toHaveLength(1);
expect(windows[0].negativeReason).toBe("timeout");
expect(windows[0].retryAt).toBeInstanceOf(Date);
expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled();
});
});
describe("generateWeightStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
describe("generateWeightStatusForUser — a negative window is not a note", () => {
it("regenerates when today's row carries only a negative-cache window", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: todayKey,
locale: "en",
text: "Generic stub fallback.",
model: "timeout-stub",
timeout: true,
}),
} as never);
text: null,
generatedAt: null,
retryAt: new Date(now.getTime() + 60_000),
negativeReason: "timeout",
}) as never,
);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: now },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh real assessment."}');
@@ -223,10 +295,6 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => {
vi.mocked(prisma.measurement.findMany).mockImplementation((async () =>
records.map((r) => ({ ...r }))) as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"First real assessment."}');
// First run: a real generation persists the snapshot fingerprint.
@@ -235,33 +303,24 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => {
force: true,
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
const persisted = JSON.parse(
(
vi.mocked(prisma.auditLog.create).mock.calls[0][0] as {
data: { details: string };
}
).data.details,
) as { text: string; snapshotHash: string };
const [persisted] = writtenNotes(prisma.insightStatusCache.upsert);
expect(persisted.text).toBe("First real assessment.");
expect(persisted.snapshotHash).toMatch(/^[0-9a-f]{64}$/);
// Second run, same data: the gate finds the matching fingerprint,
// re-persists the same text under today's dateKey, and never calls
// the provider — even though the run is forced.
vi.mocked(runStatusCompletion).mockClear();
vi.mocked(prisma.auditLog.create).mockClear();
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
// Yesterday's row — outside the same-day cache read.
vi.mocked(prisma.insightStatusCache.upsert).mockClear();
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
// Yesterday's note — outside the same-day cache read.
dateKey: "2026-06-09",
locale: "en",
text: persisted.text,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
generatedAt: now,
snapshotHash: persisted.snapshotHash,
}),
} as never);
}) as never,
);
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
@@ -271,15 +330,17 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => {
expect(runStatusCompletion).not.toHaveBeenCalled();
expect(result.cached).toBe(true);
expect(result.text).toBe(persisted.text);
// The refresh row re-keys the payload to today.
const refreshed = JSON.parse(
(
vi.mocked(prisma.auditLog.create).mock.calls[0][0] as {
data: { details: string };
}
).data.details,
) as { dateKey: string; snapshotHash: string };
expect(refreshed.snapshotHash).toBe(persisted.snapshotHash);
// The refresh re-dates the stored note to today and leaves the note
// and its fingerprint alone.
expect(prisma.insightStatusCache.upsert).not.toHaveBeenCalled();
expect(prisma.insightStatusCache.updateMany).toHaveBeenCalledTimes(1);
const refreshed = (
vi.mocked(prisma.insightStatusCache.updateMany).mock.calls[0][0] as {
data: Record<string, unknown>;
}
).data;
expect(refreshed).not.toHaveProperty("snapshotHash");
expect(refreshed).not.toHaveProperty("textEncrypted");
expect(refreshed.dateKey).not.toBe("2026-06-09");
} finally {
vi.useRealTimers();
@@ -292,21 +353,14 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => {
{ type: "WEIGHT", value: 82, measuredAt: now },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(
noteRow({
dateKey: "2026-06-09",
locale: "en",
text: "Older assessment.",
providerType: "anthropic",
model: "x",
tokensUsed: 1,
generatedAt: now,
snapshotHash: "f".repeat(64),
}),
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
}) as never,
);
stubCompletion('{"summary":"Fresh assessment for changed data."}');
const result = await generateWeightStatusForUser("user-1", {
@@ -322,14 +376,10 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => {
describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
stubCompletion(
'{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}',
@@ -341,11 +391,8 @@ describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", (
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
const notes = writtenNotes(prisma.insightStatusCache.upsert);
expect(notes.length).toBeGreaterThan(0);
expect(notes[0].text).not.toContain("metric:");
});
});
+3 -7
View File
@@ -211,9 +211,9 @@ export async function generateBiomarkerStatus(args: {
metric: scope,
locale,
});
if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") {
if (outcome.kind === "unavailable") {
return {
hasProvider: false,
hasProvider: outcome.hasProvider,
text: getNoKeyBiomarkerStatusText(locale, marker.name),
cached: true,
updatedAt: null,
@@ -324,7 +324,7 @@ export async function generateBiomarkerStatus(args: {
const outcome = await runStatusCompletion({
userId: args.userId,
cacheAction,
consentSurface: "insights",
capability: "statusText",
systemPrompt: getBiomarkerSystemPrompt(safeMarkerName, locale as Locale),
userPrompt: getBiomarkerUserPrompt(
snapshotJson,
@@ -383,11 +383,7 @@ export async function generateBiomarkerStatus(args: {
userId: args.userId,
cacheAction,
todayKey,
locale,
text,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
inputHash,
});
+4 -8
View File
@@ -191,13 +191,13 @@ export async function prepareBloodPressureStatusForUser(
metric: "blood-pressure",
locale,
});
// v1.16.13 — `consent-missing` serves the same no-key fallback (see
// bmi-status); no enqueue happens for it.
if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") {
// Unavailable for any reason serves the deterministic line and enqueues
// nothing. `hasProvider` is provider presence only.
if (outcome.kind === "unavailable") {
return {
phase: "served",
result: {
hasProvider: false,
hasProvider: outcome.hasProvider,
text: getNoKeyBloodPressureStatusText(locale),
cached: true,
updatedAt: null,
@@ -769,11 +769,7 @@ export async function prepareBloodPressureStatusForUser(
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
snapshotHash,
});
return {
+4 -9
View File
@@ -132,14 +132,13 @@ export async function prepareBmiStatusForUser(
metric: "bmi",
locale,
});
// v1.16.13 — `consent-missing` (provider configured but the
// server-managed consent gate blocks egress) serves the same no-key
// fallback; no enqueue happens for it (the resolver short-circuits).
if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") {
// Unavailable for any reason serves the deterministic line and enqueues
// nothing. `hasProvider` is provider presence only.
if (outcome.kind === "unavailable") {
return {
phase: "served",
result: {
hasProvider: false,
hasProvider: outcome.hasProvider,
text: getNoKeyBmiStatusText(locale),
cached: true,
updatedAt: null,
@@ -460,11 +459,7 @@ export async function prepareBmiStatusForUser(
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
snapshotHash,
// v1.18.11 (P6) — persist the input fingerprint for tomorrow's gate.
inputHash,
+41 -13
View File
@@ -78,10 +78,8 @@ import {
BriefingBudgetExceededError,
runBriefingCompletion,
} from "@/lib/insights/briefing-provider";
import {
chainRequiresServerManagedConsent,
hasActiveConsentForSurface,
} from "@/lib/ai/consent-guard";
import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate";
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import {
stripJsonFences,
@@ -142,6 +140,8 @@ export type GenerateOutcome =
| {
status: "skipped";
reason:
/** The `briefing` capability is unavailable for another reason. */
| "unavailable"
| "no-provider"
| "no-consent"
| "budget"
@@ -657,6 +657,27 @@ export async function generateComprehensiveInsight(
return { status: "cached" };
}
// The `briefing` capability at the wire, before the chain is resolved (a
// Codex chain may refresh a token on resolve). Callers resolve it too (the
// route before it enqueues, the nightly pass before this call); switches,
// the AI analysis opt-out and consent can change in between.
const capability = await aiCapabilityForRecord(userId, "briefing");
if (!capability.available) {
annotate({
action: { name: "insights.comprehensive.unavailable" },
meta: { reason: capability.reason },
});
return {
status: "skipped",
reason:
capability.reason === "no_provider"
? "no-provider"
: capability.reason === "consent_required"
? "no-consent"
: "unavailable",
};
}
const chain = await resolveProviderChain(userId);
if (chain.length === 0) {
const legacy = await resolveProvider(userId);
@@ -666,16 +687,23 @@ export async function generateComprehensiveInsight(
chain.push({ providerType: "admin-openai", instance: legacy });
}
// v1.12.1 — consent gate before any server-managed external egress. This
// The wire re-check for exactly this chain (`aiEgressRefusal`): the
// capability again, and the consent an operator-held entry needs. This
// pipeline runs off-request (nightly cron + on-demand force-warm), so a
// missing receipt is a typed `skipped` outcome rather than a throw — the
// cron batch continues to the next user. BYOK / local / ChatGPT-OAuth
// chains never trip the check.
if (
chainRequiresServerManagedConsent(chain) &&
!(await hasActiveConsentForSurface(userId, "insights"))
) {
return { status: "skipped", reason: "no-consent" };
// refusal is a typed `skipped` outcome rather than a throw — the cron batch
// continues to the next user. A person's own key, their ChatGPT account or
// a local model need no receipt.
const refusal = await aiEgressRefusal(
"briefing",
userId,
chain.map((entry) => entry.providerType),
);
if (refusal) {
return {
status: "skipped",
reason:
refusal.reason === "consent_required" ? "no-consent" : "unavailable",
};
}
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
@@ -12,9 +12,27 @@
* The numbers below are shaped like the real thing: a resting-pulse series in
* the 60s and 70s, one impossible sample dropped into it.
*/
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { buildBaselineBand, dayMeansFromRows } from "../baseline";
import type { ReadDayAggregatesOptions } from "@/lib/measurements/day-aggregates";
// The live day-mean read folds in SQL; the fake folds `rowsForRead` with the
// same day / range rules, so these tests see the filter the read asks for.
let rowsForRead: { value: number; measuredAt: Date }[] = [];
const seenOptions: ReadDayAggregatesOptions[] = [];
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/measurements/day-aggregates", async () => {
const { foldDayAggregates } =
await import("@/lib/measurements/__tests__/fake-day-aggregates");
return {
readDayAggregates: async (opts: ReadDayAggregatesOptions) => {
seenOptions.push(opts);
return foldDayAggregates(rowsForRead, opts);
},
};
});
import { buildBaselineBand, readDayMeanSeries } from "../baseline";
import { latestDayMeanFromRows } from "../coincident-deviation";
import { isPlausibleMetricValue } from "@/lib/measurements/value-domain";
@@ -47,28 +65,49 @@ describe("metric plausibility domain", () => {
});
});
/** The live (rollup-miss) day-mean read over `rows`. */
async function dayMeansFromRows(
rows: { value: number; measuredAt: Date }[],
type: "PULSE",
) {
rowsForRead = rows;
const { points } = await readDayMeanSeries(
"u1",
type,
3650,
new Date("2026-09-01T00:00:00Z"),
new Map(),
);
return points;
}
describe("per-day means behind the personal band", () => {
it("keeps the day mean on the real readings when one sample is impossible", () => {
it("asks the read for the metric's own plausibility domain", async () => {
await dayMeansFromRows(pulseDay("2026-08-01", [66]), "PULSE");
expect(seenOptions.at(-1)?.valueRange).toEqual({ min: 20, max: 300 });
});
it("keeps the day mean on the real readings when one sample is impossible", async () => {
const rows = pulseDay("2026-08-01", [66, 70, 111287531.01, 68]);
const [point] = dayMeansFromRows(rows, "PULSE");
const [point] = await dayMeansFromRows(rows, "PULSE");
expect(point.day).toBe("2026-08-01");
expect(point.mean).toBeCloseTo(68, 6);
});
it("drops a day whose every reading is impossible rather than inventing one", () => {
it("drops a day whose every reading is impossible rather than inventing one", async () => {
const rows = [
...pulseDay("2026-08-01", [66, 70, 68]),
...pulseDay("2026-08-02", [36016.75, 111287531.01]),
];
const points = dayMeansFromRows(rows, "PULSE");
const points = await dayMeansFromRows(rows, "PULSE");
expect(points.map((p) => p.day)).toEqual(["2026-08-01"]);
});
it("holds the band inside the metric's own domain across a poisoned month", () => {
it("holds the band inside the metric's own domain across a poisoned month", async () => {
const rows: { value: number; measuredAt: Date }[] = [];
for (let d = 1; d <= 28; d += 1) {
const day = `2026-08-${String(d).padStart(2, "0")}`;
@@ -81,7 +120,7 @@ describe("per-day means behind the personal band", () => {
rows.push(...pulseDay("2026-08-26", [98211.5]));
const band = buildBaselineBand(
dayMeansFromRows(rows, "PULSE").map((p) => p.mean),
(await dayMeansFromRows(rows, "PULSE")).map((p) => p.mean),
"PULSE",
);
@@ -1,5 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// 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: { measurement: { findMany: vi.fn() } },
}));
@@ -1,5 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// 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: { measurement: { findMany: vi.fn() } },
}));
@@ -17,6 +17,14 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// 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: {
measurement: {
@@ -1,5 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// 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: {
measurement: { findMany: vi.fn().mockResolvedValue([]) },
@@ -1,5 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// 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: {
measurement: { findMany: vi.fn() },
@@ -1,5 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// 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: { measurement: { findMany: vi.fn() } },
}));
+16 -49
View File
@@ -29,12 +29,13 @@
* composed-bucket baseline matches the raw-DAY baseline within tolerance.
*/
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { toProfileSex } from "@/lib/profile/sex";
import {
clampDerivedLowerBound,
isPlausibleMetricValue,
plausibleMetricRange,
} from "@/lib/measurements/value-domain";
import type { ProfileSex } from "@/lib/profile/sex";
import {
@@ -212,39 +213,6 @@ export function buildBaselineBand(
};
}
/**
* Group raw readings into per-day means, keeping only the values that sit
* inside the metric's own plausibility domain.
*
* Pure, and exported because this is the arithmetic the band rests on: a
* single stored value the application itself declares impossible drags the
* day's mean with it, the median then carries it into the band, and the
* personal "usual range" reads in the tens of thousands. Filtering here rather
* than at the edge of the card keeps the number wrong in exactly one place —
* nowhere — instead of right on one surface and wrong on the next.
*
* A day whose every reading is implausible produces no point at all. That is
* the honest outcome: the day contributed no measurement, and an absent day is
* something the coverage floor already knows how to report.
*/
export function dayMeansFromRows(
rows: readonly { value: number; measuredAt: Date }[],
type: MeasurementType,
): DayMeanPoint[] {
const byDay = new Map<string, { sum: number; count: number }>();
for (const row of rows) {
if (!isPlausibleMetricValue(type, row.value)) continue;
const day = row.measuredAt.toISOString().slice(0, 10);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += row.value;
acc.count += 1;
byDay.set(day, acc);
}
return [...byDay.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([day, acc]) => ({ day, mean: acc.sum / acc.count }));
}
// ── reads ────────────────────────────────────────────────────────────
/**
@@ -292,23 +260,22 @@ export async function readDayMeanSeries(
// the spread is still DAY-native.
}
// Per-type live fallback — bounded raw read, grouped into per-day
// means. Honest provenance: source = "live".
// Per-type live fallback — per-day means folded in SQL, so the read is
// one row per day however densely the metric is sampled (#1023). Only
// values inside the metric's own plausibility domain count: a single
// stored value the application declares impossible would drag the day's
// mean with it and the median would carry it into the band. A day whose
// every reading is implausible produces no point at all. Days are UTC
// days, matching the DAY rollup path above.
const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
const rows = await prisma.measurement.findMany({
where: {
userId,
type,
deletedAt: null,
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true },
const days = await readDayAggregates({
userId,
type,
since,
timeZone: "UTC",
valueRange: plausibleMetricRange(type),
});
if (rows.length === 0) {
return { points: [], source: "none" };
}
const points = dayMeansFromRows(rows, type);
const points = days.map((d) => ({ day: d.day, mean: d.sum / d.n }));
if (points.length === 0) {
return { points: [], source: "none" };
}
@@ -309,7 +309,7 @@ export async function generateDerivedScoreAssessment(args: {
const outcome = await runStatusCompletion({
userId: args.userId,
cacheAction,
consentSurface: "insights",
capability: "statusText",
systemPrompt: scoreSystemPrompt(locale),
userPrompt: scoreUserPrompt(
signal,
@@ -408,11 +408,7 @@ export async function generateDerivedScoreAssessment(args: {
userId: args.userId,
cacheAction,
todayKey,
locale,
text,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
// v1.22 (W6) — store the input fingerprint so the next day's gate can skip
// the warm when no contributor source changed.
inputHash,
+4 -8
View File
@@ -130,13 +130,13 @@ export async function prepareGeneralStatusForUser(
metric: "general",
locale,
});
// v1.16.13 — `consent-missing` serves the same no-key fallback (see
// bmi-status); no enqueue happens for it.
if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") {
// Unavailable for any reason serves the deterministic line and enqueues
// nothing. `hasProvider` is provider presence only.
if (outcome.kind === "unavailable") {
return {
phase: "served",
result: {
hasProvider: false,
hasProvider: outcome.hasProvider,
text: getNoKeyGeneralStatusText(locale),
cached: true,
updatedAt: null,
@@ -538,11 +538,7 @@ export async function prepareGeneralStatusForUser(
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
snapshotHash,
});
return {
+158 -79
View File
@@ -48,12 +48,15 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
readBestGranularityRollups,
type RollupBucketRow,
} from "@/lib/rollups/measurement-read-wmy";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
readDayAggregates,
type DayAggregateRow,
} from "@/lib/measurements/day-aggregates";
import { toBerlinYmd } from "@/lib/tz/resolver";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -83,18 +86,6 @@ const MONTHLY_WINDOW_MS = WEEKLY_WINDOW_MS + MONTHLY_MONTHS * 30 * MS_PER_DAY;
*/
const COLD_FALLBACK_WINDOW_DAYS = 1095;
/**
* Sample-dense types whose cold-tier fallback is day-bucketed in SQL
* (one AVG row per day) instead of read raw — same dense set the series
* route caps (CGM glucose ~288 rows/day, PULSE per-sample / hourly).
* The fold's output shape is unchanged; on the cold path only, a dense
* type's monthly/yearly min/max/n derive from day means rather than
* individual samples — the warm rollup tier restores sample-exact
* figures as soon as the backfill lands.
*/
const DENSE_FALLBACK_TYPES: ReadonlySet<MeasurementType> =
new Set<MeasurementType>(["BLOOD_GLUCOSE", "PULSE"]);
export interface RecentDayBucket {
/** Berlin YYYY-MM-DD. */
date: string;
@@ -171,6 +162,11 @@ function berlinYearKey(date: Date): string {
*/
function berlinIsoWeekKey(date: Date): string {
const { year, month, day } = toBerlinYmd(date);
return isoWeekKeyOfYmd(year, month, day);
}
/** ISO week key of a calendar date given as `YYYY`, `MM`, `DD`. */
function isoWeekKeyOfYmd(year: string, month: string, day: string): string {
const localMidnight = new Date(
Date.UTC(Number(year), Number(month) - 1, Number(day)),
);
@@ -309,6 +305,133 @@ export function buildGradedSeriesFromPoints(
return { recent, weekly, monthly, yearly };
}
function mergeInto(
map: Map<string, Agg>,
key: string,
row: DayAggregateRow,
): void {
const existing = map.get(key);
if (existing) {
existing.sum += row.sum;
existing.n += row.n;
if (row.min < existing.min) existing.min = row.min;
if (row.max > existing.max) existing.max = row.max;
} else {
map.set(key, { sum: row.sum, min: row.min, max: row.max, n: row.n });
}
}
/** Segment boundaries matching `buildGradedSeriesFromPoints`' age split. */
function gradedSegmentStarts(now: Date): Date[] {
const nowMs = now.getTime();
return [
new Date(nowMs - RECENT_WINDOW_MS),
new Date(nowMs - WEEKLY_WINDOW_MS),
new Date(nowMs - MONTHLY_WINDOW_MS),
];
}
/**
* Read the graded series' per-day aggregates for one type, folded in SQL.
*
* Replaces the raw `findMany` over the window (#1023): a watch that streams
* heart rate every few seconds put a million rows into that read, which is
* what exhausted the worker's heap. The rows here are one per Berlin day per
* age segment, and their sum / count / min / max are exact, so the recent,
* weekly and monthly slices come out identical to folding the raw rows.
*
* The segment boundaries use the same edges as `buildGradedSeriesFromPoints`:
* a row exactly `RECENT_WINDOW_MS` old is weekly there, so it is segment 1
* here too.
*/
async function readGradedDayAggregates(
userId: string,
type: MeasurementType,
since: Date,
now: Date,
): Promise<DayAggregateRow[]> {
return readDayAggregates({
userId,
type,
since,
// Future-dated readings were skipped by the in-memory fold.
until: now,
timeZone: "Europe/Berlin",
// `readDayAggregates` puts a row at segment k when it is older than k
// starts (`measured_at < start`). The in-memory fold treats an age of
// exactly the window as the older bucket, so shift each start by 1 ms.
segmentStarts: gradedSegmentStarts(now).map(
(d) => new Date(d.getTime() + 1),
),
});
}
/**
* The graded series folded from per-day aggregates (segment = age class).
*
* recent / weekly / monthly and every bucket's min / max / mean / n match
* the raw-row fold exactly. The yearly slope is taken over the day means in
* date order rather than over individual readings: a direction signal either
* way, and the only form that stays bounded on a dense stream.
*/
export function buildGradedSeriesFromDayAggregates(
rows: readonly DayAggregateRow[],
): GradedSeries {
const recentAgg = new Map<string, Agg>();
const weeklyAgg = new Map<string, Agg>();
const monthlyAgg = new Map<string, Agg>();
const yearlyAgg = new Map<string, Agg>();
const yearlyValues = new Map<string, number[]>();
const ordered = [...rows].sort((a, b) =>
a.day < b.day ? -1 : a.day > b.day ? 1 : b.segment - a.segment,
);
for (const row of ordered) {
const [year, month, day] = row.day.split("-");
if (row.segment === 0) {
mergeInto(recentAgg, row.day, row);
} else if (row.segment === 1) {
mergeInto(weeklyAgg, isoWeekKeyOfYmd(year, month, day), row);
} else if (row.segment === 2) {
mergeInto(monthlyAgg, `${year}-${month}`, row);
} else {
mergeInto(yearlyAgg, year, row);
const list = yearlyValues.get(year);
const dayMean = row.sum / row.n;
if (list) list.push(dayMean);
else yearlyValues.set(year, [dayMean]);
}
}
const toBuckets = (map: Map<string, Agg>) =>
Array.from(map.entries())
.map(([key, a]) => ({
key,
min: round(a.min),
max: round(a.max),
mean: round(a.sum / a.n),
n: a.n,
}))
.sort((x, y) => x.key.localeCompare(y.key));
return {
recent: toBuckets(recentAgg).map(({ key, ...b }) => ({ date: key, ...b })),
weekly: toBuckets(weeklyAgg).map(({ key, ...b }) => ({
weekISO: key,
...b,
})),
monthly: toBuckets(monthlyAgg).map(({ key, ...b }) => ({
month: key,
...b,
})),
yearly: toBuckets(yearlyAgg).map(({ key, ...b }) => ({
year: key,
...b,
slope: leastSquaresSlope(yearlyValues.get(key) ?? []),
})),
};
}
/**
* Project a rollup bucket row to a monthly/yearly graded bucket. The
* tier already carries min/max/mean/slope per bucket — no JS folding.
@@ -335,24 +458,25 @@ function rollupYearly(rows: RollupBucketRow[]): YearlyBucket[] {
}
/**
* Build the graded series with the recent / weekly slices folded from a
* bounded raw read and the monthly / yearly slices read from the
* Build the graded series with the recent / weekly slices folded from
* per-day aggregates and the monthly / yearly slices read from the
* pre-aggregated rollup tier.
*
* The recent / weekly read is capped to the recent + weekly horizon
* (last ~90 days), so the common warm-tier path never pulls the full
* multi-year history into memory. The rollup router picks MONTH for the
* ~1-year window and YEAR for the multi-year tail.
* The recent / weekly read covers the recent + weekly horizon (last ~90
* days) and is folded in SQL to one row per day, so its size depends on
* the number of days, never on how densely a device samples (#1023). The
* rollup router picks MONTH for the ~1-year window and YEAR for the
* multi-year tail.
*
* Coverage-miss fallback: when the tier has no MONTH (resp. YEAR)
* coverage for a metric — a fresh account the boot-backfill has not yet
* caught up on — the bounded ~90-day read can NOT supply the monthly /
* yearly slices (every row in it folds into recent / weekly), so naively
* reusing it would ship empty monthly / yearly arrays even when years of
* raw history exist. On that miss this falls back to an in-memory fold
* over a bounded window (1095 days — the yearly router's own horizon;
* sample-dense types are additionally day-bucketed in SQL) so the coarse
* slices are populated from the raw rows rather than left falsely empty.
* raw history exist. On that miss this falls back to the same per-day
* aggregate over a bounded window (1095 days — the yearly router's own
* horizon) so the coarse slices are populated from the stored rows rather
* than left falsely empty.
* The fallback read only happens when the tier is cold, which is exactly
* the case the write-amplified tier was meant to avoid — so the hot path
* stays bounded and the cold path stays correct.
@@ -363,15 +487,8 @@ export async function buildGradedSeriesWithRollups(
now: Date,
): Promise<GradedSeries> {
const since = new Date(now.getTime() - WEEKLY_WINDOW_MS);
const rawRecent = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
const recentGraded = buildGradedSeriesFromPoints(
rawRecent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
now,
const recentGraded = buildGradedSeriesFromDayAggregates(
await readGradedDayAggregates(userId, type, since, now),
);
// v1.11.2 — load the source-priority blob once and thread it into both
@@ -420,57 +537,19 @@ export async function buildGradedSeriesWithRollups(
}
// Tier coverage miss for one or both coarse slices: the bounded
// ~90-day read can't supply them, so fold a BOUNDED history window in
// memory and take whichever slices the tier left empty. The fallback
// read is the exception (cold-tier accounts), never the warm-tier
// norm. v1.28.25 — the read is bounded to the same 1095-day horizon
// the yearly rollup router serves (it was previously unwindowed), and
// the sample-dense types are day-bucketed in SQL so a heavy import
// ahead of the rollup backfill can't drag six-figure row sets into JS.
// ~90-day read can't supply them, so fold a BOUNDED history window and
// take whichever slices the tier left empty. The fallback read is the
// exception (cold-tier accounts), never the warm-tier norm. It is
// bounded to the same 1095-day horizon the yearly rollup router serves,
// and folded per day in SQL for every type, so a heavy import ahead of
// the rollup backfill can't drag six-figure row sets into JS.
if (!monthlyCovered || !yearlyCovered) {
const fallbackSince = new Date(
now.getTime() - COLD_FALLBACK_WINDOW_DAYS * MS_PER_DAY,
);
let fallbackPoints: Point[];
if (DENSE_FALLBACK_TYPES.has(type)) {
// Parameter-bound day-bucket aggregate, mirroring the rollup tier's
// `date_trunc(...) GROUP BY` (measurement-rollups.ts) — at most
// ~1095 rows regardless of sample density.
const bucketRows = await prisma.$queryRaw<
Array<{ bucket_start: Date; mean: number }>
>`
SELECT
date_trunc('day', m."measured_at") AS bucket_start,
AVG(m."value")::double precision AS mean
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."type" = ${type}::"measurement_type"
AND m."measured_at" >= ${fallbackSince}
AND m."deleted_at" IS NULL
GROUP BY date_trunc('day', m."measured_at")
ORDER BY bucket_start ASC
`;
fallbackPoints = bucketRows.map((r) => ({
measuredAt: r.bucket_start,
value: r.mean,
}));
} else {
const rows = await prisma.measurement.findMany({
where: {
userId,
type,
deletedAt: null,
measuredAt: { gte: fallbackSince },
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
fallbackPoints = rows.map((r) => ({
measuredAt: r.measuredAt,
value: r.value,
}));
}
const fullGraded = buildGradedSeriesFromPoints(fallbackPoints, now);
const fullGraded = buildGradedSeriesFromDayAggregates(
await readGradedDayAggregates(userId, type, fallbackSince, now),
);
if (!monthlyCovered) monthly = fullGraded.monthly;
if (!yearlyCovered) yearly = fullGraded.yearly;
}
+61 -115
View File
@@ -37,9 +37,11 @@ import {
round,
} from "@/lib/insights/status-shared";
import {
isTimeoutStub,
readFreshStatusItems,
refreshUnchangedStatusItems,
resolveReadOnlyStatusMiss,
statusCacheAction,
writeStatusNote,
} from "@/lib/insights/status-cache";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
@@ -58,6 +60,17 @@ interface MedicationSummaryItem {
text: string;
}
/** The stored per-medication lines, keeping only well-formed entries. */
function parseMedicationItems(items: unknown[]): MedicationSummaryItem[] {
return items.filter(
(entry): entry is MedicationSummaryItem =>
typeof (entry as MedicationSummaryItem | null)?.medicationId ===
"string" &&
typeof (entry as MedicationSummaryItem | null)?.text === "string" &&
(entry as MedicationSummaryItem).text.trim().length > 0,
);
}
/**
* The richer compliance result envelope (`summary` + per-medication
* `medications`) the cards read — distinct from the standard `text`-only
@@ -120,53 +133,25 @@ export async function prepareMedicationComplianceStatusForUser(
const userTz = await resolveUserTimezone(userId);
const todayKey = userDayKey(new Date(), userTz);
// This route carries a richer cached envelope (`summary` +
// `medications`) than the standard `text`-only generators, so it
// keeps its own cache-read — but it shares the stub-rejection
// predicate so a timeout stub never sticks for the day.
// This card stores per-medication lines next to its summary, so it reads
// its note through the items variant of the shared reader.
if (!force) {
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
const cached = await readFreshStatusItems({
userId,
cacheAction,
todayKey,
});
if (latestCache?.details) {
try {
const parsed = JSON.parse(latestCache.details) as {
dateKey?: string;
summary?: string;
text?: string;
model?: string;
timeout?: boolean;
medications?: MedicationSummaryItem[];
};
if (
parsed.dateKey === todayKey &&
!isTimeoutStub(parsed) &&
typeof parsed.summary === "string" &&
parsed.summary.trim().length > 0 &&
Array.isArray(parsed.medications)
) {
return {
phase: "served",
result: {
hasProvider: true,
summary: parsed.summary,
medications: parsed.medications.filter(
(entry): entry is MedicationSummaryItem =>
typeof entry?.medicationId === "string" &&
typeof entry?.text === "string" &&
entry.text.trim().length > 0,
),
cached: true,
updatedAt: latestCache.createdAt.toISOString(),
},
};
}
} catch {
// ignore invalid cache payload
}
if (cached && Array.isArray(cached.items)) {
return {
phase: "served",
result: {
hasProvider: true,
summary: cached.text,
medications: parseMedicationItems(cached.items),
cached: true,
updatedAt: cached.updatedAt,
},
};
}
}
@@ -178,13 +163,13 @@ export async function prepareMedicationComplianceStatusForUser(
metric: "medication-compliance",
locale,
});
// v1.16.13 — `consent-missing` serves the same no-key fallback (see
// bmi-status); no enqueue happens for it.
if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") {
// Unavailable for any reason: the deterministic line, no enqueue.
// `hasProvider` is provider presence only.
if (outcome.kind === "unavailable") {
return {
phase: "served",
result: {
hasProvider: false,
hasProvider: outcome.hasProvider,
summary: getNoKeyMedicationComplianceStatusText(locale),
medications: [],
cached: true,
@@ -429,56 +414,26 @@ export async function prepareMedicationComplianceStatusForUser(
// Content-hash gate (v1.16.8): when the snapshot is unchanged since the
// last real assessment, refresh the cache timestamp and skip the LLM.
// This generator caches the richer `{ summary, medications }` envelope,
// so it carries its own gate rather than the shared text-only one.
// This card stores per-medication lines next to the summary, so it uses
// the items variant of the shared gate.
const snapshotHash = hashInsightSnapshot(snapshot);
const latestRow = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { details: true },
const unchanged = await refreshUnchangedStatusItems({
userId,
cacheAction,
todayKey,
snapshotHash,
});
if (latestRow?.details) {
try {
const parsed = JSON.parse(latestRow.details) as {
summary?: string;
medications?: MedicationSummaryItem[];
model?: string;
timeout?: boolean;
snapshotHash?: string;
};
if (
!isTimeoutStub(parsed) &&
typeof parsed.summary === "string" &&
parsed.summary.trim().length > 0 &&
Array.isArray(parsed.medications) &&
parsed.snapshotHash === snapshotHash
) {
const refreshed = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({ ...parsed, dateKey: todayKey }),
},
select: { createdAt: true },
});
annotate({
action: { name: "insights.status.skipped_unchanged" },
meta: { cache_action: cacheAction },
});
return {
phase: "served",
result: {
hasProvider: true,
summary: parsed.summary,
medications: parsed.medications,
cached: true,
updatedAt: refreshed.createdAt.toISOString(),
},
};
}
} catch {
// Malformed cache payload — fall through to a real generation.
}
if (unchanged && Array.isArray(unchanged.items)) {
return {
phase: "served",
result: {
hasProvider: true,
summary: unchanged.text,
medications: parseMedicationItems(unchanged.items),
cached: true,
updatedAt: unchanged.updatedAt,
},
};
}
const previousContext = await getPreviousInsightContext(
@@ -622,29 +577,20 @@ export async function prepareMedicationComplianceStatusForUser(
"Medication-compliance-status summary was empty after normalization",
);
}
const created = await prisma.auditLog.create({
data: {
userId,
action: cacheAction,
details: JSON.stringify({
dateKey: todayKey,
locale,
summary,
medications: medicationSummaries,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
snapshotHash,
}),
},
select: { createdAt: true },
const generatedAt = await writeStatusNote({
userId,
cacheAction,
todayKey,
text: summary,
items: medicationSummaries,
snapshotHash,
});
return {
hasProvider: true,
summary,
medications: medicationSummaries,
cached: false,
updatedAt: created.createdAt.toISOString(),
updatedAt: generatedAt.toISOString(),
};
},
};
+16 -46
View File
@@ -2,9 +2,9 @@
* Insight memory — what the AI told this user last time.
*
* Every status generator (general / blood-pressure / weight / pulse /
* bmi / medication-compliance / mood) caches its rendered text in an
* `audit_logs` row keyed by `action = "insights.{scope}.{locale}"`.
* v1.4 turns that history into prompt context: the next generation
* bmi / medication-compliance / mood, and the generic metric cards) stores
* its note in `InsightStatusCache`, named `insights.{scope}.{locale}`.
* v1.4 turns that note into prompt context: the next generation
* sees the previous classification + a short summary, so the model
* can call out improvements and regressions instead of restating
* status from scratch every day.
@@ -17,14 +17,13 @@
* `compareSnapshots()` and renders ↑/↓/→ glyphs next to findings.
*/
import { prisma } from "@/lib/db";
import { readPreviousStatusNote } from "@/lib/insights/status-cache";
import { instructionLocale } from "@/lib/ai/prompts/output-language";
import type { SupportedLocale } from "@/lib/insights/status-shared";
/**
* Stable identifiers for the seven insight scopes. The string MUST
* match the suffix in the existing audit-log `action` field so we
* read the same rows the cache already writes.
* match the note name the generators store under (`statusCacheAction`).
*/
export type InsightScope =
| "general-status"
@@ -59,10 +58,9 @@ export interface PreviousInsightContext {
}
/**
* Read the most recent cached insight for `userId` + `scope` + `locale`
* that is at least `minAgeHours` old. Returns null when no eligible
* snapshot exists (first-run users) or when the cache row's payload
* is malformed.
* Read the stored note for `userId` + `scope` + `locale` when it is at
* least `minAgeHours` old. Returns null when there is none (first-run
* users) or it was written more recently than that.
*
* The default 12-hour floor keeps the model from comparing today's
* 09:00 reading to today's 06:00 reading and treating sub-day noise as
@@ -78,47 +76,19 @@ export async function getPreviousInsightContext(
const action = `insights.${scope}.${locale}`;
const olderThan = new Date(Date.now() - minAgeHours * 60 * 60 * 1000);
const row = await prisma.auditLog.findFirst({
where: {
userId,
action,
createdAt: { lt: olderThan },
},
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
const row = await readPreviousStatusNote({
userId,
cacheAction: action,
olderThan,
});
if (!row) return null;
const text = row.text;
if (!row?.details) return null;
let text: string | null = null;
try {
const parsed = JSON.parse(row.details) as {
text?: unknown;
summary?: unknown;
};
if (typeof parsed.text === "string" && parsed.text.trim().length > 0) {
text = parsed.text;
} else if (
typeof parsed.summary === "string" &&
parsed.summary.trim().length > 0
) {
text = parsed.summary;
}
} catch {
// Older cache rows stored raw text instead of JSON. Surface them
// verbatim so the prompt still benefits from the comparison.
if (row.details.trim().length > 0) {
text = row.details;
}
}
if (!text) return null;
const ageMs = Date.now() - row.createdAt.getTime();
const ageMs = Date.now() - row.generatedAt.getTime();
const ageDays = Math.max(0, Math.floor(ageMs / (24 * 60 * 60 * 1000)));
return {
generatedAt: row.createdAt.toISOString(),
generatedAt: row.generatedAt.toISOString(),
ageDays,
// Cap text so a verbose previous analysis cannot bloat the prompt.
text: text.length > 1500 ? text.slice(0, 1500) + "…" : text,
+5 -9
View File
@@ -197,11 +197,11 @@ export async function generateMetricStatus(args: {
metric: scope,
locale,
});
// v1.16.13 — `consent-missing` serves the same no-key fallback (see
// bmi-status); no enqueue happens for it.
if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") {
// Unavailable for any reason serves the deterministic line and enqueues
// nothing. `hasProvider` is provider presence only.
if (outcome.kind === "unavailable") {
return {
hasProvider: false,
hasProvider: outcome.hasProvider,
text: getNoKeyGeneralStatusText(locale),
cached: true,
updatedAt: null,
@@ -500,7 +500,7 @@ export async function generateMetricStatus(args: {
const outcome = await runStatusCompletion({
userId: args.userId,
cacheAction,
consentSurface: "insights",
capability: "statusText",
systemPrompt: getMetricArchetypeSystemPrompt(meta, locale),
userPrompt: getMetricArchetypeUserPrompt(
meta,
@@ -564,11 +564,7 @@ export async function generateMetricStatus(args: {
userId: args.userId,
cacheAction,
todayKey,
locale,
text,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
snapshotHash,
});
+6
View File
@@ -254,6 +254,12 @@ export interface CrossMetricMeasurement {
measuredAt: Date;
source?: MeasurementSource | null;
deviceType?: string | null;
/**
* Readings this row stands for. Absent on a raw reading (one). A row
* pre-aggregated per day, source and device carries its readings' sum in
* `value` and their number here, so the day's mean stays exact.
*/
count?: number;
}
/**
+2 -1
View File
@@ -231,6 +231,7 @@ export function metricDayMap(
deviceType: m.deviceType ?? null,
type: type as MeasurementType,
value: m.value,
count: m.count,
})),
metricKey,
userPriorityJson,
@@ -243,7 +244,7 @@ export function metricDayMap(
const key = berlinDayKey(m.measuredAt);
const cur = byDay.get(key) ?? { sum: 0, count: 0 };
cur.sum += m.value;
cur.count += 1;
cur.count += m.count ?? 1;
byDay.set(key, cur);
}
const out = new Map<string, number>();
+4 -8
View File
@@ -179,13 +179,13 @@ export async function prepareMoodStatusForUser(
metric: "mood",
locale,
});
// v1.16.13 — `consent-missing` serves the same no-key fallback (see
// bmi-status); no enqueue happens for it.
if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") {
// Unavailable for any reason serves the deterministic line and enqueues
// nothing. `hasProvider` is provider presence only.
if (outcome.kind === "unavailable") {
return {
phase: "served",
result: {
hasProvider: false,
hasProvider: outcome.hasProvider,
text: getNoKeyMoodStatusText(locale),
cached: true,
updatedAt: null,
@@ -768,11 +768,7 @@ export async function prepareMoodStatusForUser(
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
snapshotHash,
});
return {
@@ -15,6 +15,19 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
// prisma double, so this is only a guard against accidental real-DB use.
vi.mock("@/lib/db", () => ({ prisma: {} }));
// The `periodNarrative` capability decides whether model-written prose may be
// served. Available unless a test says otherwise.
const { aiCapabilityForRecord } = vi.hoisted(() => ({
aiCapabilityForRecord: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord }));
const UNAVAILABLE = {
available: false,
reason: "user_disabled",
onDeviceAllowed: false,
} as const;
import {
generatePeriodNarrative,
readPeriodNarrative,
@@ -107,7 +120,29 @@ function makePrisma(seedRow?: FakeRow) {
};
}
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
aiCapabilityForRecord.mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
});
function seededRow(over: Partial<FakeRow> = {}): FakeRow {
return {
userId: "u1",
period: "week",
locale: "en",
dateKey: "2026-05-15",
encryptedContent: new Uint8Array(Buffer.from("enc:old", "utf8")),
provenanceJson: null,
providerType: "openai",
promptVersion: NARRATIVE_PROMPT_VERSION,
updatedAt: new Date(),
...over,
};
}
describe("buildNarrativeUserPrompt", () => {
it("renders metrics, drivers, and the FDR footer with the prompt version", () => {
@@ -150,16 +185,20 @@ describe("generatePeriodNarrative — descriptive generation", () => {
it("passes a descriptive-never-causal system prompt to the provider", async () => {
const prisma = makePrisma();
let systemPrompt = "";
const runCompletion = vi.fn(async (args: { systemPrompt: string }) => {
systemPrompt = args.systemPrompt;
return {
kind: "ok" as const,
content: "ok",
providerType: "openai",
model: "m",
tokensUsed: 1,
};
});
let capability = "";
const runCompletion = vi.fn(
async (args: { systemPrompt: string; capability: string }) => {
systemPrompt = args.systemPrompt;
capability = args.capability;
return {
kind: "ok" as const,
content: "ok",
providerType: "openai",
model: "m",
tokensUsed: 1,
};
},
);
await generatePeriodNarrative("u1", {
period: "week",
locale: "en",
@@ -174,6 +213,8 @@ describe("generatePeriodNarrative — descriptive generation", () => {
// v1.21.0 (coach C1 MEDIUM-1) — the retrospective now carries the shared
// warm tone contract verbatim, matching the briefing's house voice.
expect(systemPrompt).toContain(toneContract.en);
// The chokepoint re-checks this narrative's own capability at the wire.
expect(capability).toBe("periodNarrative");
});
});
@@ -208,7 +249,10 @@ describe("generatePeriodNarrative — honesty floor", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
buildContext: async () => readyContext() as PeriodNarrativeResult,
runCompletion: async () => ({ kind: "none" as const }),
runCompletion: async () => ({
kind: "none" as const,
reason: "no_provider" as const,
}),
});
expect(outcome).toEqual({
status: "generated",
@@ -292,3 +336,81 @@ describe("generatePeriodNarrative — cache + regenerate", () => {
expect(read?.providerType).toBe("anthropic");
});
});
describe("period narrative — the periodNarrative capability", () => {
it("readPeriodNarrative hides a model-written row while the capability is unavailable", async () => {
const prisma = makePrisma(seededRow({ providerType: "openai" }));
aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE);
const read = await readPeriodNarrative(
"u1",
"week",
"en",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma as any,
);
expect(read).toBeNull();
expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "periodNarrative");
});
it("readPeriodNarrative still serves the deterministic narrative, which is data", async () => {
const prisma = makePrisma(
seededRow({
providerType: "deterministic",
encryptedContent: new Uint8Array(
Buffer.from("enc:Your weight eased down.", "utf8"),
),
}),
);
aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE);
const read = await readPeriodNarrative(
"u1",
"week",
"en",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma as any,
);
expect(read?.text).toBe("Your weight eased down.");
expect(aiCapabilityForRecord).not.toHaveBeenCalled();
});
it("does not short-circuit on a fresh model-written row it may not serve", async () => {
const prisma = makePrisma(seededRow({ providerType: "openai" }));
aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE);
// The chokepoint refuses on the same capability, so the deterministic
// narrative replaces the hidden prose in place.
const runCompletion = vi.fn(async () => ({
kind: "none" as const,
reason: "user_disabled" as const,
}));
const outcome = await generatePeriodNarrative("u1", {
period: "week",
locale: "en",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
buildContext: async () => readyContext() as PeriodNarrativeResult,
runCompletion,
});
expect(outcome).toEqual({
status: "generated",
providerType: "deterministic",
});
expect(runCompletion).toHaveBeenCalledTimes(1);
expect(prisma._get()?.providerType).toBe("deterministic");
});
it("still short-circuits on a fresh deterministic row whatever the capability says", async () => {
const prisma = makePrisma(seededRow({ providerType: "deterministic" }));
aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE);
const runCompletion = vi.fn();
const outcome = await generatePeriodNarrative("u1", {
period: "week",
locale: "en",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
buildContext: async () => readyContext() as PeriodNarrativeResult,
runCompletion,
});
expect(outcome).toEqual({ status: "cached" });
expect(runCompletion).not.toHaveBeenCalled();
});
});
@@ -32,6 +32,7 @@ import { annotate } from "@/lib/logging/context";
import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets";
import { dayRotatedSeed } from "@/lib/ai/prompts/opener-archetype";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate";
import {
buildPeriodNarrativeContext,
type NarrativePeriod,
@@ -315,15 +316,19 @@ export async function generatePeriodNarrative(
const runCompletion = options.runCompletion ?? runStatusCompletion;
const period = options.period;
// Freshness short-circuit — a recently-generated row is served as-is.
// Freshness short-circuit — a recently-generated row is served as-is, as
// long as a reader may serve it. A model-written row whose capability has
// since become unavailable is hidden on read, so it counts as missing here
// and the deterministic narrative below takes its place.
if (!force) {
const existing = await prisma.insightNarrative.findUnique({
where: { userId_period_locale: { userId, period, locale } },
select: { updatedAt: true },
select: { updatedAt: true, providerType: true },
});
if (
existing &&
now.getTime() - existing.updatedAt.getTime() < NARRATIVE_FRESH_MS
now.getTime() - existing.updatedAt.getTime() < NARRATIVE_FRESH_MS &&
(await narrativeServable(userId, existing.providerType))
) {
return { status: "cached" };
}
@@ -347,7 +352,7 @@ export async function generatePeriodNarrative(
const completion = await runCompletion({
userId,
cacheAction: `insights.narrative.${period}.${locale}`,
consentSurface: "insights",
capability: "periodNarrative",
systemPrompt: buildNarrativeSystemPrompt(locale),
userPrompt: buildNarrativeUserPrompt(context, bodyLocale),
temperature: AI_BUDGETS.narrative.temperature,
@@ -418,9 +423,10 @@ export async function generatePeriodNarrative(
});
};
// No usable provider → compose the deterministic, non-causal fallback from
// the same structured context, so the retrospective card is never empty for
// an active account (iOS H2). A later AI warm overwrites this row in place.
// No usable provider, or the `periodNarrative` capability is unavailable for
// any other reason → compose the deterministic, non-causal fallback from the
// same structured context, so the retrospective card is never empty for an
// active account (iOS H2). A later AI warm overwrites this row in place.
if (completion.kind === "none") {
const text = buildDeterministicNarrative(context, bodyLocale);
await persist(text, DETERMINISTIC_PROVIDER_TYPE);
@@ -453,7 +459,7 @@ export async function generatePeriodNarrative(
const retry = await runCompletion({
userId,
cacheAction: `insights.narrative.${period}.${locale}`,
consentSurface: "insights",
capability: "periodNarrative",
systemPrompt: buildNarrativeSystemPrompt(locale),
userPrompt:
buildNarrativeUserPrompt(context, bodyLocale) +
@@ -500,7 +506,8 @@ export interface NarrativeRead {
/**
* Read the latest narrative for `(userId, period, locale)`, decrypting the
* prose. Null when none was ever generated. This is the stale-while-revalidate
* prose. Null when none was ever generated, or when it is model-written and
* `periodNarrative` is unavailable for the record. This is the stale-while-revalidate
* source: it returns whatever was last produced, regardless of age, so the
* surface renders prior prose immediately while a refresh warms out of band.
*/
@@ -514,6 +521,15 @@ export async function readPeriodNarrative(
where: { userId_period_locale: { userId, period, locale } },
});
if (!row) return null;
// Model-written prose is never served while the capability is unavailable,
// whatever the reason. The deterministic narrative is data and always is.
if (!(await narrativeServable(userId, row.providerType))) {
annotate({
action: { name: "insights.narrative.hidden" },
meta: { period, locale },
});
return null;
}
let text: string;
try {
@@ -546,6 +562,19 @@ export async function readPeriodNarrative(
// ── helpers ───────────────────────────────────────────────────────────────
/**
* Whether a stored narrative may be served: always for the deterministic
* narrative, and for model-written prose only while `periodNarrative` is
* available for the record.
*/
async function narrativeServable(
userId: string,
providerType: string | null,
): Promise<boolean> {
if (providerType === DETERMINISTIC_PROVIDER_TYPE) return true;
return (await aiCapabilityForRecord(userId, "periodNarrative")).available;
}
/** Prisma `Bytes` ↔ ciphertext, mirroring the CoachMessage helper. */
function encryptToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
const ciphertext = encrypt(plaintext);
+4 -8
View File
@@ -139,13 +139,13 @@ export async function preparePulseStatusForUser(
metric: "pulse",
locale,
});
// v1.16.13 — `consent-missing` serves the same no-key fallback (see
// bmi-status); no enqueue happens for it.
if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") {
// Unavailable for any reason serves the deterministic line and enqueues
// nothing. `hasProvider` is provider presence only.
if (outcome.kind === "unavailable") {
return {
phase: "served",
result: {
hasProvider: false,
hasProvider: outcome.hasProvider,
text: getNoKeyPulseStatusText(locale),
cached: true,
updatedAt: null,
@@ -509,11 +509,7 @@ export async function preparePulseStatusForUser(
userId,
cacheAction,
todayKey,
locale,
text: summary,
providerType: outcome.providerType,
model: outcome.model,
tokensUsed: outcome.tokensUsed,
snapshotHash,
});
return {
+17 -3
View File
@@ -18,7 +18,7 @@
* are omitted, never fabricated), and run ONE completion with one
* JSON-correction retry.
* 3. Fan each returned summary back into that metric's `finalize`, which
* persists the SAME per-metric `auditLog` cache row the standalone
* stores the SAME per-metric status note the standalone
* generator wrote — so the card read path is unchanged.
*
* Graceful degradation (audit requirement): any metric the batch omits, or
@@ -51,6 +51,7 @@ import {
} from "@/lib/ai/prompts/status-batch";
import type { Locale } from "@/lib/i18n/config";
import { annotate } from "@/lib/logging/context";
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
/** The seven specialised prepares, in the warm order the cron used. */
const PREPARES: ReadonlyArray<
@@ -121,6 +122,19 @@ export async function generateStatusBatchForUser(
batchCallMade: false,
};
// The capability before any snapshot is built: someone whose status notes
// are unavailable (switch, AI analysis off, no provider, consent withdrawn)
// costs one capability read, not seven snapshot builds. The chokepoint
// re-checks it at the wire.
const capability = await aiCapabilityForJob(userId, "statusText");
if (!capability.available) {
annotate({
action: { name: "insights.status.batch.skipped" },
meta: { reason: capability.reason },
});
return result;
}
// Build every card's snapshot once. A prepare that throws must not abort
// the whole batch — that one metric is simply skipped this cycle.
const prepared = await Promise.all(
@@ -183,7 +197,7 @@ export async function generateStatusBatchForUser(
let outcome = await runStatusCompletion({
userId,
cacheAction: "insights.status-batch",
consentSurface: "insights",
capability: "statusText",
systemPrompt,
userPrompt,
temperature: AI_BUDGETS.statusBatch.temperature,
@@ -220,7 +234,7 @@ export async function generateStatusBatchForUser(
const retry = await runStatusCompletion({
userId,
cacheAction: "insights.status-batch",
consentSurface: "insights",
capability: "statusText",
systemPrompt,
userPrompt: `${userPrompt}\n\nYour previous response was not valid JSON matching { "perMetric": { ... } }. Reply with that JSON object ONLY — no prose, no markdown fences.`,
temperature: AI_BUDGETS.statusBatch.temperature,
+432 -332
View File
@@ -1,9 +1,9 @@
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
hasUsableStatusProvider,
statusConsentBlocksGeneration,
} from "@/lib/insights/status-provider";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate";
import type { AiUnavailableReason } from "@/lib/ai/capabilities/types";
import { probeProviderPresence } from "@/lib/ai/provider";
import {
enqueueStatusGeneration,
type InsightStatusScope,
@@ -44,75 +44,220 @@ const CORRELATION_CHANNEL_TYPES: readonly MeasurementType[] =
]) as MeasurementType[];
/**
* Shared cache-read for the seven `*-status.ts` insight generators.
* The status-note store: what a model wrote about one metric, for one person,
* in one language.
*
* Every generator persists its assessment as an `auditLog` row keyed
* `insights.<metric>-status.<locale>` whose `details` JSON carries
* `{ dateKey, locale, text, providerType, model, tokensUsed }`. On the
* next mount the generator reads the most recent such row and, if it is
* still for today, serves it without re-hitting the provider.
* Every per-metric generator (the seven specialised cards, the generic
* `metric:<ID>` cards, the derived-score and biomarker assessments) keeps its
* note in `InsightStatusCache`, one row per `(user, metric, locale)`, the note
* itself encrypted at rest. Until v1.39.0 the notes were appended to
* `audit_logs` as plaintext JSON; the callers still name a note by the old
* cache-action string (`insights.<scope>-status.<locale>`, built only by
* `statusCacheAction`), and this module maps it onto the row.
*
* The timeout fallback used to poison this read. When a provider call
* exceeded the status budget the route persisted a `model:"timeout-stub"`
* / `timeout:true` row carrying the generic no-key text under the SAME
* `text` field a real assessment uses. The cache-read only checked
* `dateKey === today && text` — so the stub looked like a valid
* assessment and stuck until midnight, hiding the real data-driven text
* for the rest of the day.
* A row carries two things that are updated independently:
*
* `readFreshStatusText` is the one cache-read every standard generator
* shares. It rejects stubs explicitly so a single stall no longer pins
* the fallback for the day, and a fresh generation is attempted instead.
* - the note (`textEncrypted`, its `dateKey`, `generatedAt` and the two
* fingerprints), written when a generation succeeds or an unchanged-data
* run re-dates it;
* - a short negative-cache window (`retryAt`, `negativeReason`), written
* when a generation stalls, errs or is screened, so a stalled provider
* does not turn every page visit into another enqueue. Writing it never
* touches the note, so one stall cannot hide yesterday's text, and writing
* a note clears it.
*
* Nothing here serves a note while the `statusText` capability is unavailable
* for the record, whatever the reason: an operator switch, the person's AI
* opt-out, a missing provider, a withdrawn consent. The note stays stored (a
* switch flipped back costs nothing) unless consent withdrawal purges it.
*/
/**
* The single source of truth for the per-status cache-action shape.
*
* Every per-metric assessment is persisted as an `auditLog` row whose
* `action` is `insights.<scope>-status.<locale>`. The shape IS the cache
* key, so building it by hand in multiple places risks the same silent
* drift the queryKey factory guards against on the client. `statusCacheAction`
* is the one builder.
* The single builder of a status note's name. The shape is part of the
* contract between the generators, the queue and this store, so it is built
* in one place only.
*/
export function statusCacheAction(scope: string, locale: string): string {
return `insights.${scope}-status.${locale}`;
}
interface ParsedStatusCache {
dateKey?: string;
locale?: string;
text?: string;
summary?: string;
providerType?: string;
model?: string;
tokensUsed?: number | null;
timeout?: boolean;
/** v1.8.3 — ISO timestamp before which a timeout stub suppresses re-enqueue. */
retryAt?: string;
/** Explicit generated/negative cache classification for new rows. */
statusKind?: "generated" | "negative";
/** Whether the producer may be retried at the time this row is read. */
retryable?: boolean;
/** Generated rows expire once the caller's current day differs. */
expiresAfterDateKey?: string;
/** Stable non-sensitive negative-cache reason code. */
reason?: "timeout" | "error" | "screened";
/** v1.16.8 — fingerprint of the data snapshot the text was generated from. */
snapshotHash?: string;
/** v1.18.11 (P6) — cheap fingerprint of the salient inputs (count + newest). */
inputHash?: string;
const CACHE_ACTION_SHAPE = /^insights\.(.+)-status\.([^.]+)$/;
/** The row key behind a note's name, or `null` for a name of another shape. */
function statusCacheKey(
cacheAction: string,
): { metric: string; locale: string } | null {
const match = CACHE_ACTION_SHAPE.exec(cacheAction);
if (!match) return null;
return { metric: match[1], locale: match[2] };
}
/** One stored row, decrypted. */
interface StoredStatusNote {
/** The note, `null` when the row carries only a negative-cache window. */
text: string | null;
/** Per-item notes (JSON), for a card that carries them. */
items: unknown;
inputHash: string | null;
snapshotHash: string | null;
dateKey: string;
generatedAt: Date | null;
retryAt: Date | null;
negativeReason: string | null;
}
function decryptOrNull(payload: Uint8Array | null): string | null {
if (!payload || payload.byteLength === 0) return null;
try {
return decryptFromBytes(payload);
} catch {
// A note encrypted under a key the host no longer holds is a cache miss;
// the next run writes it again.
return null;
}
}
async function readStatusNote(
userId: string,
cacheAction: string,
): Promise<StoredStatusNote | null> {
const key = statusCacheKey(cacheAction);
if (!key) return null;
const row = await prisma.insightStatusCache.findUnique({
where: { userId_metric_locale: { userId, ...key } },
select: {
textEncrypted: true,
itemsEncrypted: true,
inputHash: true,
snapshotHash: true,
dateKey: true,
generatedAt: true,
retryAt: true,
negativeReason: true,
},
});
if (!row) return null;
const text = decryptOrNull(row.textEncrypted);
const itemsJson = decryptOrNull(row.itemsEncrypted);
let items: unknown = null;
if (itemsJson !== null) {
try {
items = JSON.parse(itemsJson);
} catch {
items = null;
}
}
return {
text: text !== null && text.trim().length > 0 ? text : null,
items,
inputHash: row.inputHash,
snapshotHash: row.snapshotHash,
dateKey: row.dateKey,
generatedAt: row.generatedAt,
retryAt: row.retryAt,
negativeReason: row.negativeReason,
};
}
/**
* A cached row is a timeout stub when it carries the sentinel marker the
* timeout path writes. Either flag is sufficient — older stub rows may
* predate one of the two markers, so both are honoured.
* Whether stored status notes may be served, or re-dated, for this record.
* Every reader and both unchanged-data gates ask this first.
*/
export function isTimeoutStub(parsed: {
model?: string;
timeout?: boolean;
}): boolean {
return parsed.model === "timeout-stub" || parsed.timeout === true;
async function statusTextServable(userId: string): Promise<boolean> {
return (await aiCapabilityForRecord(userId, "statusText")).available;
}
/**
* Write a note: a successful generation. Upserts the one row, clears any
* negative-cache window, and returns when the note was written.
*/
export async function writeStatusNote(args: {
userId: string;
cacheAction: string;
todayKey: string;
text: string;
/** Per-item notes for a card that carries them; serialised as JSON. */
items?: unknown;
snapshotHash?: string;
inputHash?: string;
}): Promise<Date> {
const key = statusCacheKey(args.cacheAction);
if (!key) {
throw new Error(`Not a status note name: ${args.cacheAction}`);
}
const generatedAt = new Date();
const note = {
textEncrypted: encryptToBytes(args.text),
itemsEncrypted:
args.items === undefined
? null
: encryptToBytes(JSON.stringify(args.items)),
inputHash: args.inputHash ?? null,
snapshotHash: args.snapshotHash ?? null,
dateKey: args.todayKey,
generatedAt,
retryAt: null,
negativeReason: null,
};
await prisma.insightStatusCache.upsert({
where: { userId_metric_locale: { userId: args.userId, ...key } },
create: { userId: args.userId, ...key, ...note },
update: note,
});
return generatedAt;
}
/**
* Re-date the stored note under today's day key without touching the note or
* its fingerprints: an unchanged-data run. Returns the new timestamp.
*/
async function redateStatusNote(
userId: string,
cacheAction: string,
todayKey: string,
): Promise<Date> {
const key = statusCacheKey(cacheAction);
const generatedAt = new Date();
if (key) {
// `updateMany`, not `update`: a note purged between the read and here (a
// consent withdrawal) is a no-op, not a thrown "record not found".
await prisma.insightStatusCache.updateMany({
where: { userId, ...key },
data: {
dateKey: todayKey,
generatedAt,
retryAt: null,
negativeReason: null,
},
});
}
return generatedAt;
}
/**
* Open a negative-cache window for a note: the generation stalled, erred, or
* was screened. Leaves any stored note exactly as it was.
*/
export async function writeStatusNegativeWindow(args: {
userId: string;
cacheAction: string;
todayKey: string;
reason: "timeout" | "error" | "screened";
retryAt: Date;
}): Promise<void> {
const key = statusCacheKey(args.cacheAction);
if (!key) return;
await prisma.insightStatusCache.upsert({
where: { userId_metric_locale: { userId: args.userId, ...key } },
create: {
userId: args.userId,
...key,
dateKey: args.todayKey,
retryAt: args.retryAt,
negativeReason: args.reason,
},
update: { retryAt: args.retryAt, negativeReason: args.reason },
});
}
export interface FreshStatusCacheHit {
@@ -124,14 +269,12 @@ export interface FreshStatusCacheHit {
}
/**
* Read the latest cached assessment for `(userId, cacheAction)` and
* return its text only when it is (a) for today and (b) NOT a timeout
* stub. Returns `null` on a miss, a stale day, a stub, or a malformed
* payload — every one of those means the caller should regenerate.
* Today's note for `(userId, cacheAction)`, or `null` on a miss, a note from
* an earlier day, no note at all, or a capability that is unavailable. Every
* one of those means the caller should not serve stored text as current.
*
* `force` short-circuits to `null` so a forced regeneration never reads
* the cache. The DB read is still skipped entirely under `force` to
* keep the forced path cheap.
* `force` short-circuits to `null` without a read, so a forced regeneration
* never reads the cache.
*/
export async function readFreshStatusText(args: {
userId: string;
@@ -141,61 +284,57 @@ export async function readFreshStatusText(args: {
}): Promise<FreshStatusCacheHit | null> {
const { userId, cacheAction, todayKey, force } = args;
if (force) return null;
if (!(await statusTextServable(userId))) return null;
const latestCache = await prisma.auditLog.findFirst({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
select: { createdAt: true, details: true },
});
if (!latestCache?.details) return null;
try {
const parsed = JSON.parse(latestCache.details) as ParsedStatusCache;
if (parsed.dateKey !== todayKey) return null;
if (isTimeoutStub(parsed)) return null;
if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) {
return null;
}
return {
kind: "generated",
text: parsed.text,
updatedAt: latestCache.createdAt.toISOString(),
retryable: false,
expiresAfterDateKey: parsed.expiresAfterDateKey ?? parsed.dateKey,
};
} catch {
// Malformed cache payload — treat as a miss and regenerate.
const note = await readStatusNote(userId, cacheAction);
if (!note?.text || note.dateKey !== todayKey || !note.generatedAt) {
return null;
}
return {
kind: "generated",
text: note.text,
updatedAt: note.generatedAt.toISOString(),
retryable: false,
expiresAfterDateKey: note.dateKey,
};
}
/**
* v1.16.8 — the content-hash regeneration gate for the per-status and
* generic metric cards.
* Today's per-item note for a card that carries one (medication compliance):
* the summary plus the parsed items, under the same rules as
* `readFreshStatusText`.
*/
export async function readFreshStatusItems(args: {
userId: string;
cacheAction: string;
todayKey: string;
}): Promise<{ text: string; items: unknown; updatedAt: string } | null> {
if (!(await statusTextServable(args.userId))) return null;
const note = await readStatusNote(args.userId, args.cacheAction);
if (!note?.text || note.dateKey !== args.todayKey || !note.generatedAt) {
return null;
}
return {
text: note.text,
items: note.items,
updatedAt: note.generatedAt.toISOString(),
};
}
/**
* The content-hash regeneration gate for the per-status and generic metric
* cards.
*
* A generator that has already gathered its data snapshot calls this
* BEFORE the provider round-trip. When the latest cached assessment for
* `(userId, cacheAction)` is a real (non-stub) text whose stored
* `snapshotHash` equals the fresh snapshot's hash, nothing the prompt
* sees has changed — so the gate re-persists the same text under
* today's `dateKey` (a pure timestamp refresh that keeps the read path
* and the ingest debounce satisfied) and returns it, and the caller
* skips the LLM call entirely. Returns `null` on any miss (no prior
* row, stub, empty text, missing or differing hash) — every one of
* those means the caller should generate for real.
* A generator that has already gathered its data snapshot calls this BEFORE
* the provider round-trip. When the stored note's `snapshotHash` equals the
* fresh snapshot's, nothing the prompt sees has changed: the note is re-dated
* under today's key and returned, and the caller skips the model entirely.
* `null` on any miss (no note, a missing or differing hash).
*
* This single gate is what turns the nightly warm, the forced warm, and
* the ingest-driven regeneration into no-ops on unchanged data: each of
* those paths forces past the same-day cache read, gathers the
* snapshot, and lands here.
*
* Consent comes BEFORE the hash compare (mirroring the comprehensive
* pipeline's consent-before-gate order): re-stamping a cached text
* under today's `dateKey` presents it as a current AI assessment, so a
* user who revoked the server-managed AI consent must not have old
* text re-dated by an unchanged-data refresh. On a blocked consent the
* gate misses, the generator proceeds to `runStatusCompletion`, and
* that gate returns the no-key fallback without persisting anything.
* The capability comes first. Re-dating a note presents it as current, so a
* record whose `statusText` capability is unavailable (consent withdrawn,
* AI analysis switched off, …) never has old text re-dated; the gate misses,
* and the generator's own chokepoint then refuses the generation.
*/
export async function refreshUnchangedStatusInsight(args: {
userId: string;
@@ -203,57 +342,64 @@ export async function refreshUnchangedStatusInsight(args: {
todayKey: string;
snapshotHash: string;
}): Promise<FreshStatusCacheHit | null> {
if (await statusConsentBlocksGeneration(args.userId, "insights")) {
if (!(await statusTextServable(args.userId))) {
annotate({
action: { name: "insights.status.consent_required" },
action: { name: "insights.status.unavailable" },
meta: { cache_action: args.cacheAction, gate: "unchanged-refresh" },
});
return null;
}
const latest = await prisma.auditLog.findFirst({
where: { userId: args.userId, action: args.cacheAction },
orderBy: { createdAt: "desc" },
select: { details: true },
});
if (!latest?.details) return null;
const note = await readStatusNote(args.userId, args.cacheAction);
if (!note?.text || note.snapshotHash !== args.snapshotHash) return null;
let parsed: ParsedStatusCache;
try {
parsed = JSON.parse(latest.details) as ParsedStatusCache;
} catch {
return null;
}
if (isTimeoutStub(parsed)) return null;
if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) {
return null;
}
if (parsed.snapshotHash !== args.snapshotHash) return null;
// Same data, valid text — refresh the cache row's day key so the
// read path serves it as today's assessment and the ingest debounce
// window restarts, without touching the provider.
const created = await prisma.auditLog.create({
data: {
userId: args.userId,
action: args.cacheAction,
details: JSON.stringify({ ...parsed, dateKey: args.todayKey }),
},
select: { createdAt: true },
});
const generatedAt = await redateStatusNote(
args.userId,
args.cacheAction,
args.todayKey,
);
annotate({
action: { name: "insights.status.skipped_unchanged" },
meta: { cache_action: args.cacheAction },
});
return {
kind: "generated",
text: parsed.text,
updatedAt: created.createdAt.toISOString(),
text: note.text,
updatedAt: generatedAt.toISOString(),
retryable: false,
expiresAfterDateKey: args.todayKey,
};
}
/**
* The same gate for a card that carries per-item notes: re-dates the stored
* note when its `snapshotHash` matches and hands back the items too.
*/
export async function refreshUnchangedStatusItems(args: {
userId: string;
cacheAction: string;
todayKey: string;
snapshotHash: string;
}): Promise<{ text: string; items: unknown; updatedAt: string } | null> {
if (!(await statusTextServable(args.userId))) return null;
const note = await readStatusNote(args.userId, args.cacheAction);
if (!note?.text || note.snapshotHash !== args.snapshotHash) return null;
const generatedAt = await redateStatusNote(
args.userId,
args.cacheAction,
args.todayKey,
);
annotate({
action: { name: "insights.status.skipped_unchanged" },
meta: { cache_action: args.cacheAction },
});
return {
text: note.text,
items: note.items,
updatedAt: generatedAt.toISOString(),
};
}
/**
* v1.18.11 (P6) — cheap input fingerprint for the slow-moving status
* metrics (weight / BMI).
@@ -393,21 +539,17 @@ export async function computeStatusInputFingerprint(args: {
}
/**
* v1.18.11 (P6) — the INPUT gate for slow-moving status metrics.
* The INPUT gate for slow-moving status metrics.
*
* Runs BEFORE the snapshot build. When the latest cached assessment is a
* real (non-stub) text whose stored `inputHash` equals the freshly probed
* one, nothing the prompt could see has changed, so the gate re-stamps that
* text under today's `dateKey` and returns it — the caller then skips the
* whole gather AND the provider call. Returns `null` on any miss (no prior
* row, stub, empty text, missing or differing `inputHash`, or a forced
* regeneration), in which case the caller proceeds to the normal build +
* the finer post-build content-hash gate.
* Runs BEFORE the snapshot build. When the stored note's `inputHash` equals
* the freshly probed one, nothing the prompt could see has changed, so the
* note is re-dated under today's key and returned; the caller then skips the
* whole gather AND the provider call. `null` on any miss (no note, a missing
* or differing `inputHash`, a forced regeneration), in which case the caller
* proceeds to the normal build and the finer post-build content-hash gate.
*
* Consent is checked first, mirroring `refreshUnchangedStatusInsight`:
* re-dating a cached text presents it as current, so a user who revoked the
* server-managed AI consent must never have stale text re-stamped by an
* unchanged-input refresh.
* The capability is checked first, for the reason `refreshUnchangedStatusInsight`
* gives.
*/
export async function gateUnchangedStatusInput(args: {
userId: string;
@@ -418,52 +560,34 @@ export async function gateUnchangedStatusInput(args: {
}): Promise<FreshStatusCacheHit | null> {
if (args.force) return null;
if (await statusConsentBlocksGeneration(args.userId, "insights")) {
if (!(await statusTextServable(args.userId))) {
annotate({
action: { name: "insights.status.consent_required" },
action: { name: "insights.status.unavailable" },
meta: { cache_action: args.cacheAction, gate: "unchanged-input" },
});
return null;
}
const latest = await prisma.auditLog.findFirst({
where: { userId: args.userId, action: args.cacheAction },
orderBy: { createdAt: "desc" },
select: { details: true },
});
if (!latest?.details) return null;
let parsed: ParsedStatusCache;
try {
parsed = JSON.parse(latest.details) as ParsedStatusCache;
} catch {
const note = await readStatusNote(args.userId, args.cacheAction);
if (!note?.text || !note.inputHash || note.inputHash !== args.inputHash) {
return null;
}
if (isTimeoutStub(parsed)) return null;
if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) {
return null;
}
if (!parsed.inputHash || parsed.inputHash !== args.inputHash) return null;
// Same inputs, valid text — re-stamp the day key WITHOUT rebuilding the
// snapshot or calling the provider. Preserve every field (incl. the prior
// `snapshotHash` + `inputHash`) so the next day's gates still match.
const created = await prisma.auditLog.create({
data: {
userId: args.userId,
action: args.cacheAction,
details: JSON.stringify({ ...parsed, dateKey: args.todayKey }),
},
select: { createdAt: true },
});
// Same inputs, a stored note: re-date it WITHOUT rebuilding the snapshot or
// calling the provider. Both fingerprints stay, so tomorrow's gates match.
const generatedAt = await redateStatusNote(
args.userId,
args.cacheAction,
args.todayKey,
);
annotate({
action: { name: "insights.status.skipped_unchanged" },
meta: { cache_action: args.cacheAction, gate: "input" },
});
return {
kind: "generated",
text: parsed.text,
updatedAt: created.createdAt.toISOString(),
text: note.text,
updatedAt: generatedAt.toISOString(),
retryable: false,
expiresAfterDateKey: args.todayKey,
};
@@ -475,70 +599,94 @@ export interface LastGoodStatusHit {
}
/**
* v1.8.7 — read the most recent NON-stub assessment for `(userId,
* cacheAction)` regardless of which day it was generated. This is the
* stale-while-revalidate source: when today's cache is a miss the
* read-only path can still serve yesterday's (or older) good text
* instantly while a fresh generation is warmed out of band, so opening a
* category never drops to the "preparing" skeleton if an assessment was
* ever produced. Returns `null` only when there is genuinely no prior
* assessment (or every prior row is a timeout stub / malformed).
* The stored note for `(userId, cacheAction)` whatever day it was written:
* the stale-while-revalidate source. When today's note is a miss the read path
* can still show the last one instantly while a fresh generation is warmed,
* so opening a category never drops to the "preparing" skeleton if a note
* was ever written. `null` when there is none, or when the capability is
* unavailable.
*/
export async function readLastGoodStatusText(args: {
userId: string;
cacheAction: string;
}): Promise<LastGoodStatusHit | null> {
const { userId, cacheAction } = args;
const rows = await prisma.auditLog.findMany({
where: { userId, action: cacheAction },
orderBy: { createdAt: "desc" },
take: 5,
select: { createdAt: true, details: true },
});
for (const row of rows) {
if (!row.details) continue;
try {
const parsed = JSON.parse(row.details) as ParsedStatusCache;
if (isTimeoutStub(parsed)) continue;
if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) {
continue;
}
return { text: parsed.text, updatedAt: row.createdAt.toISOString() };
} catch {
// Malformed payload — skip and look further back.
continue;
}
if (!(await statusTextServable(args.userId))) return null;
const note = await readStatusNote(args.userId, args.cacheAction);
if (!note?.text || !note.generatedAt) return null;
return { text: note.text, updatedAt: note.generatedAt.toISOString() };
}
/**
* The stored note written at least `minAgeHours` ago, for the next
* generation's "what did I say last time" prompt block. Not a serving read:
* it feeds a generation that the chokepoint has already admitted.
*/
export async function readPreviousStatusNote(args: {
userId: string;
cacheAction: string;
olderThan: Date;
}): Promise<{ text: string; generatedAt: Date } | null> {
const note = await readStatusNote(args.userId, args.cacheAction);
if (!note?.text || !note.generatedAt) return null;
if (note.generatedAt.getTime() >= args.olderThan.getTime()) return null;
return { text: note.text, generatedAt: note.generatedAt };
}
/**
* Which of `cacheActions` carry a note written at or after `since`. The
* ingest invalidator skips those, so a fresh note survives a sync drip.
*/
export async function statusNotesWrittenSince(args: {
userId: string;
cacheActions: readonly string[];
since: Date;
}): Promise<Set<string>> {
const byKey = new Map<string, string>();
for (const action of args.cacheActions) {
const key = statusCacheKey(action);
if (key) byKey.set(`${key.metric}\u0000${key.locale}`, action);
}
return null;
if (byKey.size === 0) return new Set();
const keys = [...byKey.keys()].map((k) => {
const [metric, locale] = k.split("\u0000");
return { metric, locale };
});
const rows = await prisma.insightStatusCache.findMany({
where: {
userId: args.userId,
OR: keys,
generatedAt: { gte: args.since },
textEncrypted: { not: null },
},
select: { metric: true, locale: true },
});
const fresh = new Set<string>();
for (const row of rows) {
const action = byKey.get(`${row.metric}\u0000${row.locale}`);
if (action) fresh.add(action);
}
return fresh;
}
/**
* Outcome of the read-only cache-miss resolution. The generators map this
* onto their public return shape:
* - `no-provider` → `{ hasProvider: false, text: <no-key fallback> }`
* - `preparing` → `{ hasProvider: true, text: <last-good|null>, preparing: true }`
*
* v1.8.7 — `preparing` now carries the last good assessment (if any) so
* the card renders the previous text immediately (stale-while-revalidate)
* instead of a skeleton while the worker re-warms the cache.
*
* v1.9.0 — `revalidating` is true only when last-good text is served AND a
* fresh generation was actually enqueued (the open-card refresh case). When
* the served text is terminal the card otherwise stops polling and never
* sees the freshly-warmed assessment until a remount; the flag keeps the
* bounded poll alive until the new row lands. It is false on the
* suppressed-stub branch (no enqueue, so nothing is in flight) and whenever
* there is no last-good text (that path already polls via `preparing`).
* - `unavailable` → the deterministic line, no enqueue. `hasProvider` is
* provider presence and nothing else, so a withdrawn consent or a switch
* turned off no longer reads as "no provider configured"; `reason` says
* which layer said no.
* - `preparing` → a generation is (or recently was) in flight; the last
* note, if any, is served meanwhile. `revalidating` is true only when a
* last note is served AND a fresh generation was actually enqueued, so
* an open card keeps polling until the new note lands.
*/
export type ReadOnlyMissOutcome =
| { kind: "no-provider" }
// v1.16.13 — a provider IS configured, but it resolves via the operator's
// server-managed key and the user has no active consent receipt, so the
// generation gate (`assertConsentForChain` / `runStatusCompletion`) would
// serve the no-key fallback. Distinct from `no-provider` so the status DTO
// can render an honest "consent required" signal instead of conflating it
// with "no AI configured at all".
| { kind: "consent-missing" }
| {
kind: "unavailable";
reason: AiUnavailableReason;
hasProvider: boolean;
}
| {
kind: "preparing";
lastGood: LastGoodStatusHit | null;
@@ -546,34 +694,39 @@ export type ReadOnlyMissOutcome =
};
/**
* v1.8.3 — resolve what a read-only status generation should return on a
* cache miss WITHOUT running the heavy SQL gather or the blocking LLM
* round-trip. This is the core of the holistic freeze fix: a navigation
* request must never await an uncapped provider call.
* Resolve what a read-only status read returns on a cache miss WITHOUT the
* heavy SQL gather or a blocking model round-trip: a navigation request never
* awaits a provider.
*
* On a miss the route either:
* - finds no usable provider → returns `no-provider` (the card shows the
* no-key fallback; nothing to generate), or
* - finds a provider → fire-and-forget enqueues a generation job and
* returns `preparing` (the card shows a preparing state and the client
* polls until the worker warms the cache).
*
* The provider probe is a cheap chain-resolve (no completion), so the GET
* stays sub-second even on a cold cache.
* The `statusText` capability decides first. Unavailable, for any reason,
* means no enqueue and no stored text. Available means: serve the last note
* (if any) and enqueue a generation out of band, unless a recent stall opened
* a negative-cache window.
*/
export async function resolveReadOnlyStatusMiss(args: {
userId: string;
metric: InsightStatusScope;
locale: SupportedLocale;
}): Promise<ReadOnlyMissOutcome> {
// v1.37.0 — the one gate on the delegated path, and it sits here rather
// than in the ten generators that reach this function precisely because it
// is one gate: a generator added next year inherits it without knowing it
// exists. A manager holding a MANAGE grant may READ the record's generated
// assessments; the miss behind that read must not ship the owner's data to
// the owner's provider on the owner's budget. Served the same way the
// negative-cache branch below serves it: last-good text if there is any,
// `preparing` if there is not, and nothing enqueued either way.
const capability = await aiCapabilityForRecord(args.userId, "statusText");
if (!capability.available) {
const reason = capability.reason ?? "check_failed";
annotate({
action: { name: "insights.status.unavailable" },
meta: { metric: args.metric, reason, read_only_miss: true },
});
return {
kind: "unavailable",
reason,
hasProvider: await probeProviderPresence(args.userId, "text"),
};
}
// v1.37.0 — a manager holding a MANAGE grant may READ the record's notes;
// the miss behind that read must not ship the owner's data to the owner's
// provider on the owner's budget. The last note if there is one,
// `preparing` if not, and nothing enqueued either way. One gate here rather
// than in every generator, so a generator added later inherits it.
if (delegatedGenerationSuppressed()) {
const lastGood = await readLastGoodStatusText({
userId: args.userId,
@@ -590,39 +743,14 @@ export async function resolveReadOnlyStatusMiss(args: {
return { kind: "preparing", lastGood, revalidating: false };
}
const hasProvider = await hasUsableStatusProvider(args.userId);
if (!hasProvider) return { kind: "no-provider" };
// v1.16.13 — a provider is configured but the server-managed consent gate
// would block egress (no active receipt for the surface). Surface this as
// a distinct outcome so the card renders an honest "consent required"
// signal rather than the generic no-key fallback. Enqueuing here would be
// wasted work — the generator's own gate would short-circuit to `none`.
if (await statusConsentBlocksGeneration(args.userId, "insights")) {
annotate({
action: { name: "insights.status.consent_required" },
meta: { metric: args.metric, read_only_miss: true },
});
return { kind: "consent-missing" };
}
const cacheAction = statusCacheAction(args.metric, args.locale);
// v1.8.7 — stale-while-revalidate. Surface the last good (non-stub)
// assessment for this scope so the card renders the previous text
// immediately instead of a skeleton while a refresh is warmed. Null when
// no assessment was ever produced — only then does the card show
// "preparing"/"no analysis yet".
const lastGood = await readLastGoodStatusText({
userId: args.userId,
cacheAction,
});
// v1.8.3 — honour the short-TTL negative cache. If the worker recently
// hit a provider stall it wrote a `retryAt` stub; re-enqueuing on every
// navigation while the provider is still degraded would be a storm. While
// the stub is fresh, stay in `preparing` without enqueuing; once it goes
// stale (or never existed) enqueue a fresh generation.
// Honour the short negative-cache window: re-enqueuing on every navigation
// while the provider is still degraded would be a storm.
const negativeCache = await readStatusNegativeCache({
userId: args.userId,
cacheAction,
@@ -632,14 +760,11 @@ export async function resolveReadOnlyStatusMiss(args: {
action: { name: "insights.status.preparing" },
meta: { metric: args.metric, suppressed_enqueue: true },
});
// No enqueue on this branch — nothing is in flight, so the open card has
// nothing to revalidate against. It still polls via `preparing` when
// there is no last-good text to show.
return { kind: "preparing", lastGood, revalidating: false };
}
// Enqueue out of band — do NOT await an LLM here. The enqueue itself is
// best-effort and de-duped per (user, metric, locale).
// Enqueue out of band, never await a model here. Best-effort and de-duped
// per (user, metric).
await enqueueStatusGeneration({
userId: args.userId,
metric: args.metric,
@@ -649,20 +774,10 @@ export async function resolveReadOnlyStatusMiss(args: {
action: { name: "insights.status.preparing" },
meta: { metric: args.metric, stale_served: lastGood !== null },
});
// A fresh generation is now in flight. When last-good text is served the
// payload is otherwise terminal (`preparing` is false), so the open card
// would stop polling and never pick up the warmed assessment. Signal
// `revalidating` so the bounded poll stays alive until the new row lands.
return { kind: "preparing", lastGood, revalidating: lastGood !== null };
}
/**
* True when the most recent cache row for `(userId, cacheAction)` is a
* timeout stub whose `retryAt` is still in the future. Used by the
* read-only resolver to suppress a re-enqueue storm while a provider is
* degraded. A stub without `retryAt` (legacy) is treated as stale so the
* resolver retries, matching the pre-v1.8.3 "transient miss" behaviour.
*/
/** An open negative-cache window on a note. */
export interface StatusNegativeCache {
kind: "negative";
reason: "timeout" | "error" | "screened";
@@ -671,38 +786,23 @@ export interface StatusNegativeCache {
}
/**
* Read the latest negative-cache policy without exposing provider errors or
* assessment content. `retryable` flips only when the explicit `retryAt`
* boundary has passed; legacy/malformed stubs are treated as absent so the
* next request may recover.
* The note's negative-cache window, without exposing provider errors or note
* content. `retryable` flips once `retryAt` has passed.
*/
export async function readStatusNegativeCache(args: {
userId: string;
cacheAction: string;
}): Promise<StatusNegativeCache | null> {
const latest = await prisma.auditLog.findFirst({
where: { userId: args.userId, action: args.cacheAction },
orderBy: { createdAt: "desc" },
select: { details: true },
});
if (!latest?.details) return null;
try {
const parsed = JSON.parse(latest.details) as ParsedStatusCache;
if (!isTimeoutStub(parsed)) return null;
if (typeof parsed.retryAt !== "string") return null;
const retryAtMs = new Date(parsed.retryAt).getTime();
if (!Number.isFinite(retryAtMs)) return null;
const reason =
parsed.reason === "error" || parsed.reason === "screened"
? parsed.reason
: "timeout";
return {
kind: "negative",
reason,
retryAt: parsed.retryAt,
retryable: retryAtMs <= Date.now(),
};
} catch {
return null;
}
const note = await readStatusNote(args.userId, args.cacheAction);
if (!note?.retryAt) return null;
const reason =
note.negativeReason === "error" || note.negativeReason === "screened"
? note.negativeReason
: "timeout";
return {
kind: "negative",
reason,
retryAt: note.retryAt.toISOString(),
retryable: note.retryAt.getTime() <= Date.now(),
};
}
+2 -2
View File
@@ -32,7 +32,7 @@
* for falls back to its own single-card path, never crashing the cycle.
*
* The cache + read contract is untouched: `finalize` persists the SAME
* `auditLog` row (`persistStatusInsight`, or the richer medication-compliance
* status note (`persistStatusInsight`, or the per-medication items
* shape via a custom closure) the standalone generator wrote, so every card
* keeps reading per-metric results with the same keys.
*/
@@ -125,7 +125,7 @@ export async function runPreparedStatusCard(
const outcome: StatusProviderResult = await runStatusCompletion({
userId: prepared.userId,
cacheAction: prepared.cacheAction,
consentSurface: "insights",
capability: "statusText",
systemPrompt: prepared.systemPrompt,
userPrompt: prepared.userPrompt,
temperature: prepared.temperature,
+35 -35
View File
@@ -20,12 +20,16 @@ import {
normalizeLocale,
type SupportedLocale,
} from "@/lib/insights/status-shared";
import { isTimeoutStub, statusCacheAction } from "@/lib/insights/status-cache";
import {
statusCacheAction,
statusNotesWrittenSince,
} from "@/lib/insights/status-cache";
import {
metricIdForMeasurementType,
metricStatusScope,
} from "@/lib/insights/metric-status-registry";
import { annotate } from "@/lib/logging/context";
import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate";
import type { MeasurementType } from "@/generated/prisma/client";
/**
@@ -146,6 +150,17 @@ export async function invalidateStatusInsightsForTypes(
}
if (scopes.size === 0) return;
// No enqueue while status notes are unavailable for this record: the worker
// would refuse anyway, and the queue should not carry work nobody may do.
const capability = await aiCapabilityForRecord(userId, "statusText");
if (!capability.available) {
annotate({
action: { name: "insights.status.invalidate.skipped" },
meta: { reason: capability.reason },
});
return;
}
// v1.8.7 — regenerate only the user's resolved locale, matching the
// read-path (every `*-status` GET serves `normalizeLocale(user.locale)`).
// Warming both locales doubled provider spend on every sync, half of it
@@ -223,6 +238,14 @@ export async function enqueueStatusRefillForUser(
userId: string,
locale: SupportedLocale,
): Promise<number> {
const capability = await aiCapabilityForRecord(userId, "statusText");
if (!capability.available) {
annotate({
action: { name: "insights.status.refill.skipped" },
meta: { reason: capability.reason },
});
return 0;
}
const scopes = new Set<InsightStatusScope>(PER_STATUS_SCOPES);
try {
// `groupBy` compiles to a server-side `GROUP BY` — Prisma's
@@ -252,11 +275,10 @@ export async function enqueueStatusRefillForUser(
* real (non-stub) assessment. Those scopes are skipped by the ingest
* invalidator so a fresh assessment survives the sync drip.
*
* One indexed read per user (a single `findMany` over this user's recent
* status-cache rows, newest-first) answers it for every candidate scope at
* once — cheaper than a per-scope probe and bounded by `take`. A timeout
* stub never counts as fresh (it carries no real assessment), so a scope
* that recently stalled still gets a retry enqueued.
* One indexed read per user answers it for every candidate scope at once.
* Only a stored note counts: a row that carries nothing but a negative-cache
* window is not fresh, so a scope that recently stalled still gets a retry
* enqueued.
*/
async function findRecentlyWarmedScopes(
userId: string,
@@ -273,37 +295,15 @@ async function findRecentlyWarmedScopes(
actionToScope.set(statusCacheAction(scope, locale), scope);
}
const rows = await prisma.auditLog.findMany({
where: {
userId,
action: { in: candidateActions },
createdAt: { gte: cutoff },
},
orderBy: { createdAt: "desc" },
select: { action: true, details: true },
const written = await statusNotesWrittenSince({
userId,
cacheActions: candidateActions,
since: cutoff,
});
const fresh = new Set<InsightStatusScope>();
for (const row of rows) {
const scope = actionToScope.get(row.action);
if (!scope || fresh.has(scope)) continue;
if (!row.details) continue;
try {
const parsed = JSON.parse(row.details) as {
model?: string;
timeout?: boolean;
text?: string;
};
// A stub is not a real assessment — let a stalled scope retry.
if (isTimeoutStub(parsed)) continue;
if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) {
continue;
}
fresh.add(scope);
} catch {
// Malformed payload — treat as not-fresh so the scope refreshes.
continue;
}
for (const action of written) {
const scope = actionToScope.get(action);
if (scope) fresh.add(scope);
}
return fresh;
}
Loaded 100 of 256 files, more files were not shown because too many files have changed in this diff. Show more