From 83b1400e5bbedd16c1197b5faaaf5bafdccd66d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:29:37 +0200 Subject: [PATCH] refactor(labs): drop the fail-soft lab-note decrypt, which has no caller left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export was its only user, and the export now reads through a helper that marks an undecryptable note instead of blanking it. Nothing else wanted the silent null: the list DTO never carries the note at all, only `hasNote`, and the single-resource GET has always used the throwing decrypt so a key gap surfaces there. Deleted rather than kept behind a dead-code exemption. A fail-soft twin sitting unused next to a strict one is the exact shape this branch has been clearing out — the next person writing an export reaches for whatever is nearest, and the whole point was that the nearest thing should not silently swallow a row. It had no test of its own, so none was removed; the three comments that pointed at it are rewritten to say what is true now. --- src/lib/export/records-backup.ts | 14 +++++----- src/lib/labs/biomarker-store.ts | 10 ++++--- src/lib/labs/store.ts | 29 ++++----------------- tests/integration/backup-round-trip.test.ts | 2 +- 4 files changed, 19 insertions(+), 36 deletions(-) diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index 383212b0c..2948689e8 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -50,13 +50,13 @@ export const WORKOUTS_MANIFEST_NOTE = * 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. + * 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. diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index b6cefe5e1..f1e11411e 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -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 { diff --git a/src/lib/labs/store.ts b/src/lib/labs/store.ts index 11a18e45e..2a215fa16 100644 --- a/src/lib/labs/store.ts +++ b/src/lib/labs/store.ts @@ -5,11 +5,15 @@ * free-text note is the only sensitive column on the model; it shares the * `encrypt()` string format (`"."`) 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 { @@ -26,26 +30,3 @@ export function encryptNoteToBytes(plaintext: string): Uint8Array { 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; - } -} diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index aba427cd9..2d22ba3bf 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -2627,7 +2627,7 @@ describe("every model the plan claims two-ended survives a real restore", () => * A note this instance can no longer decrypt leaves the export as a marker, * not as nothing. * - * `decryptNoteSoft` answers `null` on a wrong or dropped key, and in a + * A fail-soft decrypt answers `null` on a wrong or dropped key, and in a * portable file `null` is byte-for-byte what a note that was never written * looks like. The restore importer then writes no note, so a note the person * did write is gone and no one is told — the whole loss fits inside a