fix(export): carry an undecryptable note as a marker instead of as nothing

A lab note or a biomarker context written under a key this instance no
longer holds came out of a portable export as `null` — byte-identical to a
column that was never written. The restore importer then wrote nothing, so
the text was gone and nothing said it had ever been there. The whole loss
fit inside a fail-soft `catch` written for a list view, where returning null
is the right answer.

Both now export a short marker naming what happened, and the marker survives
a restore. The Coach transcript already did this with a placeholder sentence
and the doctor report with a rendered flag; the marker is now one constant
the transcript shares, and its comment says why an empty string will not do.

The unit test that required `note: null` here pinned the loss. It keeps the
half that matters — one bad row must never abort the export — and asserts
the marker instead. The round trip is proven end to end against a real
restore.
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 02:08:09 +02:00
1 parent c2c018aa08
commit 4d98a273b1
5 files changed
+184 -12

No files matched your search

@@ -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.
*
* `decryptNoteSoft` / `decryptContextSoft` answer `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]";