Merge fix/guarded-twins

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 03:23:40 +02:00
commit ee4afc7b9e
33 files changed
+1164 -73

No files matched your search

@@ -0,0 +1,117 @@
/**
* Structural guard on the unguarded Pearson helper.
*
* `pearsonCorrelation` bands a coefficient from |r| alone above a floor of
* five pairs, with no significance test. Its guarded twin
* `significantPearsonCorrelation` sits directly beneath it in the same file
* and exists because of exactly one failure: five days of noise sloping the
* right way reaching a reader labelled "strong". The comment above the twin
* says so in as many words.
*
* The twin was written once and adopted one surface at a time, which is how
* the mood page kept the naive call for four minor lines after the surfaces
* either side of it had moved. Nothing failed when it was skipped, because
* nothing was watching. This is what watches: a new caller of the unguarded
* helper has to be added to the list below, in a diff a reviewer reads, next
* to a written reason.
*
* It is a tripwire, not a proof. It cannot say the remaining entries are
* right — only that the set has not grown without someone editing this file.
* The two entries it carries today are named as open work, not as blessings.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/**
* Files that may still call `pearsonCorrelation` directly, and why.
*
* Neither entry is settled. Both are the same migration the user-facing
* surfaces already made, still owed by two payload builders whose consumers
* pin the n >= 5 shape; moving them changes what those payloads contain and
* belongs in its own change with its own tests, not smuggled into this one.
*/
const ALLOWED: Record<string, string> = {
"app/api/insights/comprehensive/route.ts":
"Five correlations on the /insights envelope (weight x BP, mood x BP, " +
"mood x weight, mood x pulse, BP-medication continuity). Open: they " +
"carry the same small-n exposure the mood page had, and the page's " +
"cards read `n` straight off the envelope.",
"lib/insights/features.ts":
"Eight cross-metric coefficients on the AI feature payload. Open: the " +
"prompt reads them as findings, so the same significance bar applies; " +
"the snapshot shape and its budget tests move with it.",
};
/** Every non-test, non-generated source file under `src/`. */
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
/**
* A call to the unguarded helper. Whitespace-tolerant and anchored on a word
* boundary so `significantPearsonCorrelation(` cannot match: a matcher that
* quietly matches nothing is the failure mode this repository has shipped
* before, so the count is asserted below as well.
*/
const CALL = /(?<![A-Za-z])pearsonCorrelation\s*\(/;
describe("the unguarded Pearson helper has a closed caller set", () => {
it("finds callers at all, so an empty result cannot read as a pass", () => {
const definition = read("lib/analytics/correlations.ts");
expect(definition).toMatch(/export function pearsonCorrelation\s*\(/);
expect(
definition,
"the guarded twin must exist for this guard to mean anything",
).toMatch(/export function significantPearsonCorrelation\s*\(/);
const callers = sourceFiles().filter(
(rel) => rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)),
);
expect(callers.length).toBeGreaterThan(0);
});
it("names every caller outside the allowlist", () => {
const callers = sourceFiles().filter(
(rel) => rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)),
);
expect(
callers.filter((rel) => !(rel in ALLOWED)),
"A new caller of the unguarded `pearsonCorrelation`. A surface a " +
"person reads must use `significantPearsonCorrelation` (n >= 20 " +
"AND p < 0.05). If this one genuinely cannot, add it to ALLOWED " +
"in this file with the reason, so the exception is reviewed rather " +
"than assumed.",
).toEqual([]);
});
it("keeps the allowlist honest by refusing stale entries", () => {
const callers = new Set(
sourceFiles().filter(
(rel) =>
rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)),
),
);
// An entry whose file no longer calls the helper is a licence nobody
// needs any more; leaving it standing is how the next one gets waved in.
expect(
Object.keys(ALLOWED).filter((rel) => !callers.has(rel)),
"this file is on the allowlist but no longer calls the unguarded " +
"helper — drop the entry",
).toEqual([]);
});
});
@@ -776,15 +776,20 @@ describe("GET /api/insights/comprehensive — mood × metric local-day pairing (
scheduleRevisions: [],
pauseEras: [],
};
// Intake taken on the first three days, missed on the last two → a
// non-degenerate continuity series.
const intakeEvents = days.slice(0, 3).map((d) => ({
// Intake taken on the first three days, logged as skipped on the last
// two → a non-degenerate continuity series. The skips used to be absent
// rows: a day with no record read as a measured 0 % continuity, which is
// the fabrication the route no longer makes, and a fixture that relies on
// it would be pinning the defect rather than the timezone question this
// test asks. Recording the two days as skips keeps five paired days AND
// keeps every one of them a thing the person actually logged.
const intakeEvents = days.map((d, i) => ({
medicationId: "med-bp",
userId: "user-comp-1",
deletedAt: null,
scheduledFor: new Date(`${d}T07:00:00Z`),
takenAt: new Date(`${d}T07:05:00Z`),
skipped: false,
takenAt: i < 3 ? new Date(`${d}T07:05:00Z`) : null,
skipped: i >= 3,
}));
function primeMocks() {
@@ -827,3 +832,227 @@ describe("GET /api/insights/comprehensive — mood × metric local-day pairing (
expect(utcBody.data.bpMedicationCorrelation).not.toBeNull();
});
});
// A medication with no schedule expects no dose, so it has no adherence to
// report. `calculateCompliance` answers `rate: 100, totalExpected: 0` for that
// case — arithmetically true and a lie on a surface that reads the number as
// adherence. The doctor report has excluded both the PRN arm and the empty
// schedule since it was written, with the reason beside it; this envelope
// filtered only PRN, so a scheduled medication saved without a schedule (which
// `POST /api/medications` accepts) reported perfect adherence to the reader and
// to the prompt built from the same array.
describe("GET /api/insights/comprehensive — no adherence without an expectation", () => {
const EMPTY_AGGREGATE = {
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: null,
totalMeasurements: 0,
};
type MedBody = {
data: {
medications: Array<{
id: string;
name: string;
compliance7: number;
compliance30: number;
}>;
};
};
function scheduledMed(id: string, name: string, schedules: unknown[]) {
return {
id,
name,
dose: "5mg",
active: true,
asNeeded: false,
createdAt: new Date("2026-04-01T00:00:00Z"),
schedules,
scheduleRevisions: [],
pauseEras: [],
};
}
const SCHEDULE = {
id: "sch-1",
windowStart: "07:00",
windowEnd: "08:00",
timesOfDay: ["07:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: [],
};
it("leaves a scheduled medication with no schedule out of the compliance list", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
EMPTY_AGGREGATE,
);
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
scheduledMed("med-none", "Unscheduled", []),
scheduledMed("med-real", "Amlodipine", [SCHEDULE]),
]);
const body = (await (await callGet(makeReq())).json()) as MedBody;
const names = body.data.medications.map((m) => m.name);
expect(names).not.toContain("Unscheduled");
// The medication that DOES expect a dose still reports, so this is a
// filter and not a blanket suppression.
expect(names).toContain("Amlodipine");
});
it("never reports 100 % for a medication with nothing expected of it", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
EMPTY_AGGREGATE,
);
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
scheduledMed("med-none", "Unscheduled", []),
]);
const body = (await (await callGet(makeReq())).json()) as MedBody;
expect(body.data.medications).toEqual([]);
});
});
// The medication-continuity correlation joined every day that had a systolic
// reading against `takenByDay.get(day) ?? 0`. A day the reader simply never
// logged an intake for therefore entered the scatter and the coefficient as a
// measured 0 % continuity. Absence is not zero anywhere else in this project
// and it is not zero here: a day with a logged skip is a real zero and stays,
// a day with no record at all drops out.
describe("GET /api/insights/comprehensive — an unlogged day is not a zero", () => {
type BpBody = {
data: {
bpMedicationScatterData: Array<{ continuityPct: number; sysBP: number }>;
bpMedicationCorrelation: { n: number } | null;
};
};
const DAYS = [
"2026-05-01",
"2026-05-02",
"2026-05-03",
"2026-05-04",
"2026-05-05",
];
const MEDICATION = {
id: "med-bp",
name: "Amlodipine",
dose: "5mg",
active: true,
asNeeded: false,
createdAt: new Date("2026-04-01T00:00:00Z"),
schedules: [
{
id: "sch-1",
windowStart: "07:00",
windowEnd: "08:00",
timesOfDay: ["07:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: [],
},
],
scheduleRevisions: [],
pauseEras: [],
};
function primeBp(
events: Array<{ day: string; takenAt: Date | null; skipped: boolean }>,
) {
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {
BLOOD_PRESSURE_SYS: DAYS.map((d, i) => ({
day: d,
value: 120 + i * 2,
})),
},
firstMeasurementAt: new Date(),
totalMeasurements: 10,
},
);
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
MEDICATION,
]);
(
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue(
events.map((e) => ({
medicationId: "med-bp",
userId: "user-comp-1",
deletedAt: null,
scheduledFor: new Date(`${e.day}T07:00:00Z`),
takenAt: e.takenAt,
skipped: e.skipped,
})),
);
vi.mocked(getMedicationCategories).mockResolvedValue({
"med-bp": "BLOOD_PRESSURE",
});
}
it("keeps only the days that carry an intake record", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Three days logged; the last two were never touched.
primeBp(
DAYS.slice(0, 3).map((day) => ({
day,
takenAt: new Date(`${day}T07:05:00Z`),
skipped: false,
})),
);
const body = (await (await callGet(makeReq())).json()) as BpBody;
expect(body.data.bpMedicationScatterData).toHaveLength(3);
// The two unlogged days would have entered as 0 % against their own
// systolic reading; both are gone.
expect(body.data.bpMedicationScatterData.map((p) => p.sysBP)).not.toContain(
126,
);
expect(
body.data.bpMedicationScatterData.every((p) => p.continuityPct === 100),
).toBe(true);
});
it("keeps a logged skip, which is a real zero", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeBp([
...DAYS.slice(0, 3).map((day) => ({
day,
takenAt: new Date(`${day}T07:05:00Z`),
skipped: false,
})),
{ day: DAYS[3], takenAt: null, skipped: true },
]);
const body = (await (await callGet(makeReq())).json()) as BpBody;
expect(body.data.bpMedicationScatterData).toHaveLength(4);
expect(body.data.bpMedicationScatterData).toContainEqual({
continuityPct: 0,
sysBP: 126,
});
});
});
+18 -2
View File
@@ -17,6 +17,7 @@ import {
import {
buildComplianceMedicationContext,
calculateCompliance,
expectsDoses,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
@@ -365,7 +366,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// ── Medication compliance ────────────────────────────────
// Bounded reads — left untouched per the directive.
const medications = await prisma.medication.findMany({
const medicationRows = await prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications never surface a compliance
// rate (no expected doses).
where: { userId, active: true, asNeeded: false },
@@ -379,6 +380,12 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
});
// A medication with no schedule expects no dose, and `calculateCompliance`
// answers 100 % for that case. The `asNeeded: false` filter above catches
// only the PRN arm; the empty schedule is reachable on a scheduled
// medication and would otherwise report perfect adherence.
const medications = medicationRows.filter(expectsDoses);
const categoryMap = await getMedicationCategories(
medications.map((m) => m.id),
);
@@ -497,14 +504,23 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
}
const takenByDay = new Map<string, number>();
// Days that carry an intake record of ANY kind. A logged skip is a real
// zero and belongs in the series; a day the person never touched is not a
// zero, it is nothing, and pairing it against that day's systolic reading
// invented a data point they never produced. Absence is not zero, here as
// everywhere else in this project, so the unlogged day drops out of both
// the scatter and the coefficient rather than dragging continuity down.
const loggedDays = new Set<string>();
for (const event of bpMedicationEvents) {
if (event.skipped || !event.takenAt) continue;
const dayKey = event.scheduledFor.toISOString().slice(0, 10);
loggedDays.add(dayKey);
if (event.skipped || !event.takenAt) continue;
takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1);
}
const pairs: Array<{ a: number; b: number; date: Date }> = [];
for (const [dayKey, avgSys] of sysByDay.entries()) {
if (!loggedDays.has(dayKey)) continue;
const taken = takenByDay.get(dayKey) ?? 0;
const continuity = Math.min(1, taken / expectedBpIntakesPerDay);
pairs.push({
@@ -0,0 +1,104 @@
/**
* What the mood correlation card is allowed to claim.
*
* The card used to paint a "Strong" badge from any coefficient it was handed,
* as long as five days had been paired. The coefficient itself now clears the
* significance bar before it leaves the server, so the card's job here is the
* other half: when the bar refuses, say which bar and how far off the reader
* is, rather than falling back to the same "not enough paired data" blank that
* a reader with no data at all sees.
*/
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
MoodCorrelationCards,
type MoodMetricCorrelationData,
} from "../mood-correlation-cards";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const NOTHING: MoodMetricCorrelationData = {
result: null,
points: [],
n: 0,
suppressed: "insufficientPairs",
};
/** The sleep card's own markup — the four siblings carry their own copy. */
function renderSleep(sleep: MoodMetricCorrelationData) {
const html = render(
<MoodCorrelationCards
sleep={sleep}
steps={NOTHING}
pulse={NOTHING}
weight={NOTHING}
bloodPressureSystolic={NOTHING}
/>,
);
const start = html.indexOf('data-kind="sleep"');
const end = html.indexOf('data-kind="steps"');
return html.slice(start, end);
}
describe("<MoodCorrelationCards>", () => {
it("shows no strength badge for a refused correlation", () => {
const html = renderSleep({
result: null,
points: Array.from({ length: 5 }, (_, i) => ({ x: i, y: i })),
n: 5,
suppressed: "insufficientPairs",
});
expect(html).not.toContain("Strong");
expect(html).not.toContain("Moderate");
});
it("names how many paired days are still missing instead of a bare blank", () => {
const html = renderSleep({
result: null,
points: Array.from({ length: 5 }, (_, i) => ({ x: i, y: i })),
n: 5,
suppressed: "insufficientPairs",
});
// The reader is told where they stand, not only that something is absent.
expect(html).toContain("5 of 20");
});
it("keeps the plain empty copy when nothing has been paired at all", () => {
const html = renderSleep(NOTHING);
expect(html).toContain("Not enough paired data yet");
expect(html).not.toContain("0 of 20");
});
it("says a twenty-day pairing showed no reliable link", () => {
const html = renderSleep({
result: null,
points: Array.from({ length: 20 }, (_, i) => ({ x: i, y: i })),
n: 20,
suppressed: "notSignificant",
});
expect(html).toContain("No clear link");
expect(html).not.toContain("Not enough paired data yet");
expect(html).not.toContain("Weak");
});
it("still shows the badge and the n · r caption for a correlation that cleared the bar", () => {
const html = renderSleep({
result: { r: 0.82, strength: "stark", n: 24 },
points: Array.from({ length: 24 }, (_, i) => ({ x: i, y: i })),
n: 24,
});
expect(html).toContain("Strong");
expect(html).toContain("24 paired days");
});
});
@@ -20,7 +20,9 @@ import { MoodExplainerIcon } from "./mood-explainer-icon";
* pulse) pair. The Pearson math comes pre-computed from
* `/api/mood/insights` (single source of truth with the LLM snapshot).
* Below-threshold pairs render an EmptyState rather than a misleading
* near-zero scatter.
* near-zero scatter — and the EmptyState names which threshold, because a
* pairing that is still building and one that found nothing are different
* answers and used to read as the same blank.
*/
const ScatterCorrelationChart = dynamic(
@@ -46,8 +48,17 @@ export interface MoodMetricCorrelationData {
result: CorrelationResult | null;
points: MoodCorrelationPoint[];
n: number;
/** Which bar the pairing fell short of, when there is no coefficient. */
suppressed?: "insufficientPairs" | "notSignificant";
}
/**
* The paired-day floor the server's significance gate enforces. Repeated
* here only to tell the reader how far off they are; the decision itself is
* the server's and arrives as `suppressed`.
*/
const MIN_PAIRED_DAYS = 20;
type MetricKind = "sleep" | "steps" | "pulse" | "weight" | "bloodPressure";
const TITLE_KEY: Record<MetricKind, string> = {
@@ -95,7 +106,33 @@ function MoodCorrelationCard({
className?: string;
}) {
const { t } = useTranslations();
const hasResult = data.result != null && data.n >= 5;
const hasResult = data.result != null;
// Three different silences, and the reader deserves to know which one they
// are looking at: nothing paired yet, some days paired but not enough to
// separate a pattern from chance, or enough days and no pattern in them.
// The last one is a finding, not a gap, and used to render as the same
// "not enough data" blank as the first.
const emptyCopy =
data.suppressed === "notSignificant"
? {
title: t("insights.mood.correlation.noLinkTitle"),
description: t("insights.mood.correlation.noLinkDescription", {
n: data.n,
}),
}
: data.n > 0
? {
title: t("insights.mood.correlation.emptyTitle"),
description: t("insights.mood.correlation.pendingDescription", {
n: data.n,
needed: MIN_PAIRED_DAYS,
}),
}
: {
title: t("insights.mood.correlation.emptyTitle"),
description: t("insights.mood.correlation.emptyDescription"),
};
return (
<Card
@@ -168,8 +205,8 @@ function MoodCorrelationCard({
variant="plain"
size="compact"
icon={<Activity className="size-5" />}
title={t("insights.mood.correlation.emptyTitle")}
description={t("insights.mood.correlation.emptyDescription")}
title={emptyCopy.title}
description={emptyCopy.description}
/>
)}
</CardContent>
+22
View File
@@ -243,6 +243,28 @@ export function buildComplianceDisplay(
* can be driven deterministically from a caller
* that already pinned its own `now`.
*/
/**
* Whether a medication expects any dose at all, and so has an adherence rate
* worth showing.
*
* `calculateCompliance` below answers `rate: 100, totalExpected: 0` for a
* medication with no schedule. That is arithmetically honest — nothing was
* expected, so nothing was missed — and it is a lie on every surface that
* reads the number as adherence. PRN is the obvious arm and every caller
* already filtered it; the empty schedule on a NON-PRN medication is the arm
* that got through, and it is reachable: `POST /api/medications` accepts
* `schedules ?? []` on a scheduled medication. The doctor report has excluded
* both arms since it was written, so the report never prints a fabricated
* 100 %; the same reason applies to every other surface, so the two arms live
* in one predicate rather than being re-derived at each call site.
*/
export function expectsDoses(medication: {
asNeeded: boolean;
schedules: readonly unknown[];
}): boolean {
return !medication.asNeeded && medication.schedules.length > 0;
}
export function calculateCompliance(
events: IntakeEvent[],
schedules: ComplianceSchedule[],
+4 -3
View File
@@ -21,6 +21,7 @@ import { userDayKey } from "@/lib/tz/resolver";
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
import {
buildComplianceMedicationContext,
expectsDoses,
lastNonSkippedTakenAt,
tallyComplianceFromLedger,
} from "@/lib/analytics/compliance";
@@ -363,9 +364,9 @@ export function buildLedgerCompliance(
const compliance: Record<string, DoctorReportCompliance> = {};
for (const med of medications) {
// PRN / as-needed carry no schedule and no expected dose — excluded so
// the report never prints a fabricated 100 %.
if (med.asNeeded) continue;
if (med.schedules.length === 0) continue;
// the report never prints a fabricated 100 %. Both arms now live in the
// shared predicate, which is what the other adherence surfaces read.
if (!expectsDoses(med)) continue;
const events = eventsByMedId.get(med.id) ?? [];
const ctx = buildComplianceMedicationContext(
@@ -19,6 +19,7 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
import { encryptDocumentSummary } from "@/lib/documents/store";
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
import {
buildRecordsBackupSection,
countRecordsBackupSection,
@@ -450,10 +451,14 @@ describe("buildRecordsBackupSection", () => {
});
});
it("fails soft (null, not a throw) on an undecryptable note", async () => {
it("marks an undecryptable note (not a throw, and not a silent null)", async () => {
const prisma = buildMockPrisma();
// Corrupt the ciphertext so decrypt throws internally — the row must
// still come back with `note: null`, never abort the whole backup.
// Corrupt the ciphertext so decrypt throws internally. The row must still
// come back and must never abort the whole backup — that part is
// unchanged. What it must NOT come back as is `null`, which this test used
// to require: in an export file a null note is byte-identical to a note
// that was never written, so the restore writes nothing and the loss is
// invisible. It comes back as a marker instead.
prisma.labResult.findMany.mockResolvedValueOnce([
{
panel: null,
@@ -471,6 +476,6 @@ describe("buildRecordsBackupSection", () => {
]);
const section = await buildRecordsBackupSection(prisma as never, USER_ID);
expect(section.labResults[0].note).toBeNull();
expect(section.labResults[0].note).toBe(UNREADABLE_EXPORT_MARKER);
});
});
+2 -1
View File
@@ -53,6 +53,7 @@
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker";
import {
recordUnknownKeys,
type RestoreSkipLog,
@@ -169,7 +170,7 @@ function decryptTurnSoft(bytes: Uint8Array | null): string | null {
try {
return decryptFromBytes(bytes);
} catch {
return "[unreadable: encrypted with a key this instance no longer holds]";
return UNREADABLE_EXPORT_MARKER;
}
}
+52 -7
View File
@@ -7,14 +7,16 @@
* verbatim. The DR path never decrypts document content or summaries.
*
* Every read is scoped to the owner. Soft-deleted rows are excluded so a
* restore cannot resurrect tombstones. Other encrypted free-text fields keep
* the established fail-soft portable representation and are re-encrypted by
* the restore importer.
* restore cannot resurrect tombstones. Other encrypted free-text fields are
* carried as plaintext and re-encrypted by the restore importer; one this
* instance cannot decrypt is carried as a visible marker rather than as
* nothing (see `readForExport` below).
*/
import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { decryptNoteSoft } from "@/lib/labs/store";
import { decryptContextSoft } from "@/lib/labs/biomarker-store";
import { decryptNoteFromBytes } from "@/lib/labs/store";
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker";
import { decryptDocumentSummary } from "@/lib/documents/store";
import { getEvent } from "@/lib/logging/context";
import {
@@ -44,6 +46,39 @@ export const WORKOUTS_MANIFEST_NOTE =
"steps) are included. GPS routes and per-sample heart-rate/pace time " +
"series are not included in this export.";
/**
* Decrypt an encrypted free-text column for a portable export, or say plainly
* that this one could not be read.
*
* A fail-soft decrypt answers `null` on a wrong or dropped key. In a list view
* that is right — one bad row must not take the page down. In an export it is
* not: `null` is exactly what a column that was never written looks like, so
* the restore importer writes nothing and a note the person did write
* disappears without anyone being told. The Coach transcript and the doctor
* report both refuse that trade, one with a placeholder sentence and one with
* a rendered flag; this follows the same rule.
*
* The warning the soft helpers emitted is kept, so a systemic key gap still
* shows up in the wide event rather than only in the file.
*/
function readForExport(
buf: Uint8Array | null,
label: string,
decode: (bytes: Uint8Array) => string,
): string | null {
if (!buf || buf.byteLength === 0) return null;
try {
return decode(buf);
} catch (err) {
getEvent()?.addWarning(
`${label} decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return UNREADABLE_EXPORT_MARKER;
}
}
export interface LabResultBackupEntry {
/** Present in canonical DR payloads so rows keep a stable identity. */
id?: string;
@@ -339,7 +374,13 @@ export async function buildRecordsBackupSection(
deletedAt: r.deletedAt?.toISOString() ?? null,
note: null,
}
: { note: decryptNoteSoft(r.noteEncrypted) }),
: {
note: readForExport(
r.noteEncrypted,
"lab note",
decryptNoteFromBytes,
),
}),
panel: r.panel,
analyte: r.analyte,
value: r.value,
@@ -369,7 +410,11 @@ export async function buildRecordsBackupSection(
upperBound: b.upperBound,
panel: b.panel,
hidden: b.hidden,
context: decryptContextSoft(b.contextEncrypted),
context: readForExport(
b.contextEncrypted,
"biomarker context",
decryptContextFromBytes,
),
}));
const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery
+19
View File
@@ -0,0 +1,19 @@
/**
* What an export writes where a row could not be decrypted.
*
* A portable export carries plaintext, so an encrypted column that this
* instance can no longer read has to become something. The fail-soft decrypt
* helpers answer `null`, which in an export file is indistinguishable from a
* column that was never written — and a restore then writes nothing, so the
* loss is complete and silent. It is not: the ciphertext was there, and the
* person is entitled to know a key gap ate it rather than to find a note they
* remember writing simply gone.
*
* The marker is a sentence, not an empty string, for the same reason the Coach
* transcript's placeholder is: an empty string reads as "there was nothing
* here", and that is not what happened. It survives a round trip through the
* restore importer like any other text, so the gap stays visible in the
* restored account instead of closing over.
*/
export const UNREADABLE_EXPORT_MARKER =
"[unreadable: encrypted with a key this instance no longer holds]";
+7 -2
View File
@@ -177,8 +177,13 @@ function adherenceObservations(
for (const [name, comp] of Object.entries(data.compliance)) {
if (comp.total <= 0) continue;
// Integer percent — the one canonical rounding the app card + PDF use, so
// a clinician sees the same adherence figure on every surface.
const rate = adherenceRatePercent(comp.taken, comp.total) ?? 0;
// a clinician sees the same adherence figure on every surface. The helper
// returns null exactly when nothing was expected, and a `?? 0` here would
// put a clinical claim of 0 % adherence into an exported bundle in that
// case. The `total <= 0` guard above already means it cannot happen; skip
// rather than coalesce so it stays impossible if that guard ever moves.
const rate = adherenceRatePercent(comp.taken, comp.total);
if (rate === null) continue;
observations.push({
resourceType: "Observation",
id: nextId(),
@@ -260,3 +260,76 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F
expect(parsed.text).not.toContain("metric:");
});
});
// The BP-status gate reads its medications' adherence to decide what to say
// about a reading. `calculateCompliance` answers `rate: 100, totalExpected: 0`
// for a medication with no schedule, so a blood-pressure medication saved
// without one told the model the person was perfectly adherent — the exact
// fabrication the doctor report has excluded since it was written.
describe("generateBloodPressureStatusForUser — no rate without an expectation", () => {
const SCHEDULE = {
id: "sch-1",
windowStart: "07:00",
windowEnd: "08:00",
timesOfDay: ["07:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: [],
};
function bpMed(id: string, name: string, schedules: unknown[]) {
return {
id,
name,
dose: "5mg",
active: true,
asNeeded: false,
createdAt: new Date(Date.now() - 60 * dayMs),
schedules,
scheduleRevisions: [],
pauseEras: [],
};
}
it("omits a blood-pressure medication that expects no dose", async () => {
const now = new Date();
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 },
] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
bpMed("med-empty", "Unscheduled", []),
bpMed("med-real", "Amlodipine", [SCHEDULE]),
] 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);
vi.mocked(getMedicationCategories).mockResolvedValue({
"med-empty": "BLOOD_PRESSURE",
"med-real": "BLOOD_PRESSURE",
});
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK"}', captured);
await generateBloodPressureStatusForUser("user-1", { locale: "en" });
const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
const names = snapshot.bpMedications.map((m: { name: string }) => m.name);
expect(names).not.toContain("Unscheduled");
expect(names).toContain("Amlodipine");
});
});
@@ -360,3 +360,85 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1
expect(parsed.medications[0].text).not.toContain("metric:");
});
});
// A medication with no schedule expects no dose. `calculateCompliance` answers
// `rate: 100, totalExpected: 0` for that case, and this surface averaged that
// 100 into `overall.averageCompliance30` and handed it to the model as the
// person's adherence. The doctor report has excluded the empty schedule
// alongside PRN since it was written — "so the report never prints a
// fabricated 100 %" — and the same reason applies wherever a rate is shown.
describe("generateMedicationComplianceStatusForUser — no rate without an expectation", () => {
function scheduleless(now: Date) {
return {
id: "med-empty",
name: "Unscheduled",
dose: "5mg",
active: true,
asNeeded: false,
createdAt: new Date(now.getTime() - 60 * dayMs),
schedules: [],
scheduleRevisions: [],
pauseEras: [],
};
}
it("keeps a schedule-less medication out of the snapshot and its average", async () => {
const now = new Date();
const scheduled = { ...medFixture(now), asNeeded: false };
const events = Array.from({ length: 30 }, (_, day) => {
const scheduledFor = new Date(now.getTime() - day * dayMs);
// Half the doses taken, so the real medication sits well below 100 %.
return {
medicationId: "med-1",
scheduledFor,
takenAt: day % 2 === 0 ? scheduledFor : null,
skipped: false,
};
});
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
scheduled,
scheduleless(now),
] 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);
await generateMedicationComplianceStatusForUser("user-1", { locale: "en" });
const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
const names = snapshot.medications.map((m: { name: string }) => m.name);
expect(names).not.toContain("Unscheduled");
expect(names).toContain("Ramipril");
expect(snapshot.overall.medicationCount).toBe(1);
// The fabricated 100 is no longer half of the average handed to the model.
expect(snapshot.overall.averageCompliance30).toBeLessThan(100);
});
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);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
const outcome = (await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
})) as { summary: string | null; medications: unknown[] };
expect(outcome.medications).toEqual([]);
expect(outcome.summary).toContain("no active medications");
// Nothing to assess, so no model call and no cache row either.
expect(runStatusCompletion).not.toHaveBeenCalled();
});
});
@@ -292,25 +292,43 @@ describe("computeHeatmapCells", () => {
describe("computeMoodMetricCorrelation", () => {
it("pairs on dayOffset and returns scatter points + a coefficient", () => {
const moodDaily = [
{ dayOffset: 0, value: 5 },
{ dayOffset: 1, value: 4 },
{ dayOffset: 2, value: 3 },
{ dayOffset: 3, value: 2 },
{ dayOffset: 4, value: 1 },
];
// Twenty days, not five. This test used to run on five and assert a
// coefficient came back, which pinned the very thing the significance
// gate exists to refuse: five points that happen to line up are not a
// finding. The pairing and the scatter projection are what it is asking
// about, and they are unchanged — it just has to ask above the bar.
const moodDaily = Array.from({ length: 20 }, (_, i) => ({
dayOffset: i,
value: 5 - (i % 5),
}));
// Perfectly positively correlated metric.
const metricDaily = moodDaily.map((b) => ({
dayOffset: b.dayOffset,
value: b.value * 10,
}));
const corr = computeMoodMetricCorrelation(moodDaily, metricDaily, NOW);
expect(corr.n).toBe(5);
expect(corr.points).toHaveLength(5);
expect(corr.n).toBe(20);
expect(corr.points).toHaveLength(20);
expect(corr.result?.r).toBe(1);
expect(corr.suppressed).toBeUndefined();
expect(corr.points[0]).toEqual({ x: 5, y: 50 });
});
it("refuses the same shape at five days", () => {
const moodDaily = Array.from({ length: 5 }, (_, i) => ({
dayOffset: i,
value: 5 - i,
}));
const metricDaily = moodDaily.map((b) => ({
dayOffset: b.dayOffset,
value: b.value * 10,
}));
const corr = computeMoodMetricCorrelation(moodDaily, metricDaily, NOW);
expect(corr.n).toBe(5);
expect(corr.result).toBeNull();
expect(corr.suppressed).toBe("insufficientPairs");
});
it("returns a null coefficient below the minimum pair count", () => {
const moodDaily = [
{ dayOffset: 0, value: 5 },
@@ -0,0 +1,78 @@
/**
* The mood page's cross-metric correlations clear the same bar as every
* other user-facing correlation surface.
*
* `significantPearsonCorrelation` was written for exactly this failure — its
* own comment names a five-point r ≈ 0.7 fluke rendering as "stark" — and the
* weight, blood-pressure and mood-status surfaces were moved onto it. The
* mood page stayed on the plain `pearsonCorrelation` with its floor of five
* pairs and no significance test, so the same five points that the other
* surfaces refuse still painted a "strong" badge here.
*
* These assertions are about what the reader is told, not about which helper
* is called: below the bar there is no coefficient to show, and the card is
* handed a reason it can name instead of a blank.
*/
import { describe, expect, it } from "vitest";
import {
computeMoodMetricCorrelation,
type DailyPoint,
} from "@/lib/insights/mood-aggregates";
const NOW = new Date("2026-06-01T12:00:00.000Z");
/** One point per day-offset, values taken from the list in order. */
function series(values: number[]): DailyPoint[] {
return values.map((value, i) => ({ dayOffset: i, value }));
}
describe("computeMoodMetricCorrelation — significance gate", () => {
it("refuses a five-day strong-looking slope and says the pairs are too few", () => {
const mood = series([1, 2, 3, 4, 5]);
const metric = series([1, 2, 2, 4, 3]);
const out = computeMoodMetricCorrelation(mood, metric, NOW);
expect(out.n).toBe(5);
// No coefficient reaches the card, so no strength band can be painted.
expect(out.result).toBeNull();
// ... and the card is told WHY, so it can say so rather than showing an
// unexplained blank.
expect(out.suppressed).toBe("insufficientPairs");
});
it("refuses twenty noisy days as not significant rather than as too few", () => {
const ys = [5, 2, 7, 1, 6, 3, 8, 2, 5, 4, 6, 1, 7, 3, 5, 2, 6, 4, 5, 3];
const mood = series(ys.map((_, i) => i));
const metric = series(ys);
const out = computeMoodMetricCorrelation(mood, metric, NOW);
expect(out.n).toBe(20);
expect(out.result).toBeNull();
expect(out.suppressed).toBe("notSignificant");
});
it("surfaces twenty days of a real relationship", () => {
const mood = series(Array.from({ length: 20 }, (_, i) => i));
const metric = series(
Array.from({ length: 20 }, (_, i) => i * 2 + (i % 2 === 0 ? 0.3 : -0.3)),
);
const out = computeMoodMetricCorrelation(mood, metric, NOW);
expect(out.suppressed).toBeUndefined();
expect(out.result).not.toBeNull();
expect(out.result!.n).toBe(20);
expect(out.result!.strength).toBe("stark");
});
it("reports no overlap as too few pairs, not as an insignificant finding", () => {
const out = computeMoodMetricCorrelation(series([3, 4]), [], NOW);
expect(out.n).toBe(0);
expect(out.result).toBeNull();
expect(out.suppressed).toBe("insufficientPairs");
});
});
@@ -50,4 +50,44 @@ describe("prompt reads exclude tombstoned rows", () => {
expect(intakeRead).not.toBeNull();
expect(intakeRead![0]).toContain("deletedAt: null");
});
// A soft delete has to be able to reach the CACHE GATE as well as the
// prompt. `buildStatusInputHash` fingerprints "how much data is there and
// how new is it"; a tombstoned row that still counts leaves the fingerprint
// unchanged, the gate re-dates yesterday's assessment, and text reasoning
// over a deleted reading is served as today's. The measurement arm of that
// same query already filters, which is what makes the two that did not so
// easy to miss.
it("the status input hash filters its mood aggregate", () => {
const src = read("../status-cache.ts");
const moodRead = src.match(
/moodEntry\s*\n?\s*\.aggregate\(\{[\s\S]{0,800}?where:\s*\{[^}]*\}/,
);
expect(moodRead).not.toBeNull();
expect(moodRead![0]).toContain("deletedAt: null");
});
it("the status input hash filters its custom-metric entry reads", () => {
const src = read("../status-cache.ts");
// Both the count and the newest-entry probe: either one left unfiltered
// keeps the fingerprint still after a delete.
expect(src).toMatch(
/_count:\s*\{\s*select:\s*\{\s*entries:\s*\{[\s\S]{0,200}?deletedAt:\s*null/,
);
const newest = src.match(
/entries:\s*\{\s*where:\s*\{[^}]*\}[\s\S]{0,200}?take:\s*1/,
);
expect(newest).not.toBeNull();
expect(newest![0]).toContain("deletedAt: null");
});
it("the custom-metric correlation channel filters its entry read", () => {
// The parent metric's own tombstone is filtered; its entries' were not,
// so a reading the person deleted still shaped the correlation cards.
const src = read("../correlation-channel-series.ts");
const entryRead = src.match(
/entries:\s*\{[\s\S]{0,400}?where:\s*\{[^}]*\}/,
);
expect(entryRead).not.toBeNull();
expect(entryRead![0]).toContain("deletedAt: null");
});
});
+8 -1
View File
@@ -24,6 +24,7 @@ import {
import {
buildComplianceMedicationContext,
calculateCompliance,
expectsDoses,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
@@ -324,7 +325,7 @@ export async function prepareBloodPressureStatusForUser(
weightVsSystolicPairs,
);
const activeMedications = await prisma.medication.findMany({
const activeMedicationRows = await prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications never feed the BP-status
// compliance gate (no expected doses, no rate).
where: { userId, active: true, asNeeded: false },
@@ -339,6 +340,12 @@ export async function prepareBloodPressureStatusForUser(
},
});
// A medication with no schedule expects no dose, and `calculateCompliance`
// answers 100 % for that case. The `asNeeded: false` filter above catches
// only the PRN arm; the empty schedule is reachable on a scheduled
// medication and would otherwise report perfect adherence.
const activeMedications = activeMedicationRows.filter(expectsDoses);
const categoryMap = await getMedicationCategories(
activeMedications.map((medication) => medication.id),
);
@@ -288,7 +288,10 @@ export async function fetchCustomMetricBehaviourSeries(
name: true,
unit: true,
entries: {
where: { measuredAt: { gte: since } },
// The parent metric's tombstone is filtered above; the entries' own
// one has to be too, or a reading the person deleted keeps shaping
// the correlation they are shown.
where: { deletedAt: null, measuredAt: { gte: since } },
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
take: 2000,
select: { value: true, unit: true, measuredAt: true },
@@ -9,6 +9,7 @@ import {
buildComplianceMedicationContext,
buildMedicationComplianceBundle,
dailyComplianceRatesFromLedger,
expectsDoses,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
@@ -208,7 +209,7 @@ export async function prepareMedicationComplianceStatusForUser(
};
}
const medications = await prisma.medication.findMany({
const medicationRows = await prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications never surface a compliance
// rate (no expected doses).
where: { userId, active: true, asNeeded: false },
@@ -224,6 +225,12 @@ export async function prepareMedicationComplianceStatusForUser(
orderBy: { name: "asc" },
});
// A medication with no schedule expects no dose, and `calculateCompliance`
// answers 100 % for that case. The `asNeeded: false` filter above catches
// only the PRN arm; the empty schedule is reachable on a scheduled
// medication and would otherwise report perfect adherence.
const medications = medicationRows.filter(expectsDoses);
if (medications.length === 0) {
return {
phase: "served",
+28 -3
View File
@@ -20,10 +20,11 @@
import { prisma } from "@/lib/db";
import {
pearsonCorrelation,
significantPearsonCorrelation,
type CorrelationResult,
type PairedPoint,
} from "@/lib/analytics/correlations";
import { MIN_PAIRED_N } from "@/lib/insights/correlations";
import {
applyPayloadBudget,
dayOffsetToBerlinDayKey,
@@ -384,11 +385,20 @@ export interface CorrelationScatterPoint {
[key: string]: number;
}
/** Why a pairing produced no coefficient. */
export type MoodCorrelationSuppression = "insufficientPairs" | "notSignificant";
export interface MoodMetricCorrelation {
result: CorrelationResult | null;
/** Mood (x) vs metric (y) scatter points, paired by day. */
points: CorrelationScatterPoint[];
n: number;
/**
* Which bar the pairing fell short of, so the card can name it rather
* than showing a blank that reads the same whether nothing was logged or
* a full year of days simply did not line up. Absent when `result` is set.
*/
suppressed?: MoodCorrelationSuppression;
}
/**
@@ -396,6 +406,14 @@ export interface MoodMetricCorrelation {
* series, paired on `dayOffset`. Returns the scatter points so the
* caller can paint a `<ScatterCorrelationChart>` from the same pairing
* the coefficient was computed over.
*
* This routes through `significantPearsonCorrelation` — n >= 20 AND
* p < 0.05 — for the same reason the weight, blood-pressure and
* mood-status surfaces do: the plain `pearsonCorrelation` bands from |r|
* alone above a floor of five pairs, so five days of noise sloping the
* right way arrive at the reader labelled "strong". A mood page is where
* that costs the most, because the reader is looking for something to
* change about their week.
*/
export function computeMoodMetricCorrelation(
moodDaily: DailyPoint[],
@@ -404,9 +422,16 @@ export function computeMoodMetricCorrelation(
tz: string = DEFAULT_TIMEZONE,
): MoodMetricCorrelation {
const pairs = pairDailyBuckets(moodDaily, metricDaily, now, tz);
const result = pearsonCorrelation(pairs);
const points = pairs.map((p) => ({ x: p.a, y: p.b }));
return { result, points, n: pairs.length };
const result = significantPearsonCorrelation(pairs);
if (result) return { result, points, n: pairs.length };
return {
result: null,
points,
n: pairs.length,
suppressed:
pairs.length < MIN_PAIRED_N ? "insufficientPairs" : "notSignificant",
};
}
// --- Orchestrated aggregate shape ---
+8 -2
View File
@@ -324,7 +324,12 @@ export async function computeStatusInputFingerprint(args: {
}),
args.includeMood
? prisma.moodEntry.aggregate({
where: { userId: args.userId },
// Tombstoned rows are excluded here for the same reason they are in
// the measurement arm above: this aggregate IS the freshness
// fingerprint, so a deletion that leaves the count and the newest
// timestamp untouched cannot invalidate the cached assessment, and
// yesterday's text gets re-dated as today's over data that is gone.
where: { userId: args.userId, deletedAt: null },
_count: { _all: true },
_max: { moodLoggedAt: true },
})
@@ -342,8 +347,9 @@ export async function computeStatusInputFingerprint(args: {
id: true,
unit: true,
updatedAt: true,
_count: { select: { entries: true } },
_count: { select: { entries: { where: { deletedAt: null } } } },
entries: {
where: { deletedAt: null },
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
take: 1,
select: { measuredAt: true, unit: true },
+6 -4
View File
@@ -33,10 +33,12 @@ export function decryptContextFromBytes(buf: Uint8Array): string {
/** Decrypt a stored `Bytes` context note, fail-soft to `null` on any error.
*
* Mirrors `decryptNoteSoft` in the illness DTO layer: a single bad-key /
* malformed row must not 500 the whole catalog list. The single-resource GET
* path uses the throwing `decryptContextFromBytes` so a genuine decrypt
* failure there surfaces instead of silently masking a key-rotation gap. */
* A single bad-key / malformed row must not 500 the whole catalog list, and
* the list surface renders a missing context as nothing either way. The
* single-resource GET path uses the throwing `decryptContextFromBytes` so a
* genuine decrypt failure there surfaces instead of silently masking a
* key-rotation gap, and the backup export reads through its own helper that
* marks the row rather than blanking it. */
export function decryptContextSoft(buf: Uint8Array | null): string | null {
if (!buf) return null;
try {
+5 -24
View File
@@ -5,11 +5,15 @@
* free-text note is the only sensitive column on the model; it shares the
* `encrypt()` string format (`"<keyId>.<base64>"`) every other `*Encrypted`
* column uses, encoded as UTF-8 bytes.
*
* There is one decrypt here and it throws. A fail-soft twin used to sit
* beside it for bulk reads; the list DTO never carries the note (it reports
* `hasNote` only) and the export now writes a visible marker instead of a
* null, so nothing was left that wanted a silent one.
*/
import { Buffer } from "node:buffer";
import { decrypt, encrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
/** Encrypt a UTF-8 note into the `Bytes` payload the schema stores. */
export function encryptNoteToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
@@ -26,26 +30,3 @@ export function encryptNoteToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
export function decryptNoteFromBytes(buf: Uint8Array): string {
return decrypt(Buffer.from(buf).toString("utf8"));
}
/**
* Decrypt a stored `Bytes` note, fail-soft to `null` on any error.
*
* Mirrors `decryptContextSoft` in the biomarker store: a single bad-key /
* malformed row must not fail a bulk read (list view, backup export) that
* spans many rows. The single-resource GET path uses the throwing
* `decryptNoteFromBytes` so a genuine decrypt failure there surfaces instead
* of silently masking a key-rotation gap.
*/
export function decryptNoteSoft(buf: Uint8Array | null): string | null {
if (!buf || buf.byteLength === 0) return null;
try {
return decryptNoteFromBytes(buf);
} catch (err) {
getEvent()?.addWarning(
`lab note decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}