From a8b3e5d7f4e160608925f64d77db911e5a2e111f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 10:36:27 +0200 Subject: [PATCH] feat(coach): store a turn's result tables encrypted beside the message A new nullable column, coach_messages.results_encrypted, holds the tables of values a turn read as AES-GCM ciphertext; only their metadata rides the plaintext provenance. readMessageResults serves them to the owner, each table whole or withheld with a reason. The provenance restore learns the steps, method, table metadata, chips, clarification and the forced-answer marker, entry by entry against the wire schema. The column is registered for key rotation, carried through both backup formats (ciphertext in a disaster-recovery file, readable JSON in a portable one) and goes with its row on conversation delete and wipe. --- .../0360_coach_message_results/migration.sql | 14 + prisma/schema.prisma | 6 + scripts/rotate-encryption-key.ts | 12 +- .../__tests__/persistence-results.test.ts | 339 ++++++++++++++++++ src/lib/ai/coach/persistence.ts | 185 ++++++++++ src/lib/crypto/encrypted-columns.ts | 2 + src/lib/export/coach-backup.ts | 40 +++ src/lib/validations/backup.ts | 4 + tests/integration/backup-round-trip.test.ts | 113 ++++++ 9 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/0360_coach_message_results/migration.sql create mode 100644 src/lib/ai/coach/__tests__/persistence-results.test.ts diff --git a/prisma/migrations/0360_coach_message_results/migration.sql b/prisma/migrations/0360_coach_message_results/migration.sql new file mode 100644 index 000000000..91969be4f --- /dev/null +++ b/prisma/migrations/0360_coach_message_results/migration.sql @@ -0,0 +1,14 @@ +-- The tables of values a Coach turn read, stored with the assistant message +-- that showed them: a JSON array of result tables, AES-256-GCM encrypted in +-- the same Bytes codec as the message body. The values are health data, so +-- they never sit in the plaintext `metric_source_json`, which carries only the +-- tables' metadata (ref, source, shape, row count). +-- +-- A stored table keeps the values the answer was given with; a later edit or +-- sync does not rewrite what an earlier answer showed. The column goes with +-- its row, so deleting a conversation or wiping the account removes it by +-- the existing cascade. +-- +-- Additive only: a nullable column, no table rewrite, no data change. + +ALTER TABLE "coach_messages" ADD COLUMN "results_encrypted" BYTEA; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e24bf462a..21104452d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6221,6 +6221,12 @@ model CoachMessage { // nullable: user turns, refusals, and pre-feature rows carry NULL. tokensUsed Int? @map("tokens_used") model String? + // v1.39.4 — the tables of values this turn read (JSON `CoachResultTable[]`, + // at most 128 KB), AES-256-GCM encrypted like `encryptedContent`. Health + // values, so they never ride the plaintext `metricSourceJson`, which holds + // only the tables' metadata. Read through `readMessageResults` by the + // owner-narrowed results endpoint; NULL on every turn without a table. + resultsEncrypted Bytes? @map("results_encrypted") createdAt DateTime @default(now()) @map("created_at") conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index da64e9060..dafb6b037 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -418,7 +418,8 @@ async function main() { } // ───── Coach (Bytes columns) ───── - // "encryptedContent" "summaryEncrypted" "titleEncrypted" "factEncrypted" + // "encryptedContent" "resultsEncrypted" "summaryEncrypted" "titleEncrypted" + // "factEncrypted" results.push( await rotateBytesColumn( "CoachMessage", @@ -426,6 +427,15 @@ async function main() { prisma.coachMessage, ), ); + // v1.39.4 — the tables of values a turn read. NULL on every turn without a + // table, which `rotateBytesColumn` skips. + results.push( + await rotateBytesColumn( + "CoachMessage", + "resultsEncrypted", + prisma.coachMessage, + ), + ); results.push( await rotateBytesColumn( "CoachConversation", diff --git a/src/lib/ai/coach/__tests__/persistence-results.test.ts b/src/lib/ai/coach/__tests__/persistence-results.test.ts new file mode 100644 index 000000000..bdbc97589 --- /dev/null +++ b/src/lib/ai/coach/__tests__/persistence-results.test.ts @@ -0,0 +1,339 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * v1.39.4 — the dialog fields of the provenance survive a reload, malformed + * entries are dropped one by one, the tables are sealed into their own + * column, and the owner-narrowed read serves each table whole or names why + * it does not. + */ +const txCreate = { + coachConversation: { update: vi.fn() }, + coachMessage: { create: vi.fn() }, +}; +const findFirst = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + $transaction: vi.fn(async (cb: (tx: typeof txCreate) => Promise) => + cb(txCreate), + ), + coachMessage: { findFirst: (...args: unknown[]) => findFirst(...args) }, + }, +})); +vi.mock("../bytes-codec", () => ({ + encryptToBytes: vi.fn((text: string) => + new TextEncoder().encode(`enc:${text}`), + ), + decryptFromBytes: vi.fn((bytes: Uint8Array) => { + const text = new TextDecoder().decode(bytes); + if (!text.startsWith("enc:")) throw new Error("bad ciphertext"); + return text.slice(4); + }), +})); + +import { + RESULTS_MAX_BYTES, + appendMessage, + readMessageResults, +} from "../persistence"; +import type { + CoachProvenance, + CoachResultMeta, + CoachResultTable, +} from "../types"; + +const META: CoachResultMeta = { + ref: "r1", + source: { + tool: "get_metric_series", + domain: "bp", + window: "last30days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.metricByPeriod", + title: "Blood pressure by day", + rowCount: 2, + chartKind: "line", + displayed: true, +}; + +const TABLE: CoachResultTable = { + ...META, + columns: [ + { + key: "period", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + { + key: "sys", + kind: "number", + labelKey: "coach.result.column.systolic", + label: "Systolic", + unit: "mmHg", + decimals: 0, + }, + ], + rows: [ + ["2026-09-01", 128], + ["2026-09-02", null], + ], + truncated: false, + chart: { kind: "line", x: "period", series: ["sys"] }, +}; + +const SLEEP_META: CoachResultMeta = { + ...META, + ref: "r2", + source: { ...META.source, domain: "sleep", tool: "get_sleep" }, + title: "Sleep by night", +}; + +const FULL: CoachProvenance = { + windows: ["last30days"], + metrics: ["bp"], + steps: [ + { + id: "s1", + tool: "get_metric_series", + labelKey: "coach.step.read", + label: "Blood pressure, last 30 days", + domain: "bp", + window: "last30days", + status: "done", + count: 42, + resultRef: "r1", + }, + ], + method: { + entries: [ + { domain: "bp", window: "last30days", count: 42, aggregation: "mean" }, + ], + text: "Blood pressure, last 30 days: 42 readings", + }, + results: [META], + followUps: [ + { + id: "f1", + kind: "previous_period", + labelKey: "coach.followUp.previousPeriod", + label: "Compare with the period before", + anchor: { ref: "r1", domain: "bp", window: "last30days" }, + reuse: false, + origin: "server", + }, + ], + clarification: { + kind: "window", + choices: [ + { + id: "c1", + labelKey: "coach.clarify.window.last30days", + label: "Last 30 days", + value: { window: "last30days" }, + }, + ], + freeText: true, + }, + forcedFinal: true, +}; + +function stubEchoCreate(overrideJson?: string): void { + txCreate.coachConversation.update.mockResolvedValue({}); + txCreate.coachMessage.create.mockImplementation( + async (arg: { data: { metricSourceJson: string | null } }) => ({ + id: "m1", + role: "assistant", + createdAt: new Date("2026-09-26T00:00:00.000Z"), + metricSourceJson: overrideJson ?? arg.data.metricSourceJson, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + }), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("appendMessage — the dialog fields", () => { + it("restores steps, method, table metadata, chips, clarification and the forced marker", async () => { + stubEchoCreate(); + const out = await appendMessage({ + conversationId: "c1", + role: "assistant", + content: "reply", + metricSource: FULL, + }); + expect(out.metricSource).toEqual(FULL); + }); + + it("drops a malformed entry and keeps its well-formed siblings", async () => { + stubEchoCreate( + JSON.stringify({ + windows: [], + metrics: [], + steps: [FULL.steps![0], { id: "s2", status: "exploded" }], + results: [{ ref: "r9" }, META], + followUps: [{ ...FULL.followUps![0], kind: "free_text" }], + method: { entries: "nope", text: "x" }, + clarification: { kind: "metric", choices: [], freeText: "yes" }, + forcedFinal: "true", + }), + ); + const out = await appendMessage({ + conversationId: "c1", + role: "assistant", + content: "reply", + metricSource: { windows: [], metrics: [] }, + }); + expect(out.metricSource).toEqual({ + windows: [], + metrics: [], + steps: FULL.steps, + results: [META], + }); + }); + + it("strips a key the stored blob carries that the contract does not know", async () => { + stubEchoCreate( + JSON.stringify({ + windows: [], + metrics: [], + results: [{ ...META, rows: [["2026-09-01", 128]] }], + }), + ); + const out = await appendMessage({ + conversationId: "c1", + role: "assistant", + content: "reply", + metricSource: { windows: [], metrics: [] }, + }); + // Values never ride the plaintext provenance, even when a blob has them. + expect(out.metricSource?.results?.[0]).not.toHaveProperty("rows"); + }); +}); + +describe("appendMessage — the tables", () => { + it("seals the tables into their own column and leaves them out of the provenance", async () => { + stubEchoCreate(); + await appendMessage({ + conversationId: "c1", + role: "assistant", + content: "reply", + metricSource: { windows: [], metrics: [], results: [META] }, + results: [TABLE], + }); + const data = txCreate.coachMessage.create.mock.calls[0][0].data; + expect(new TextDecoder().decode(data.resultsEncrypted)).toBe( + `enc:${JSON.stringify([TABLE])}`, + ); + expect(data.metricSourceJson).not.toContain("2026-09-01"); + }); + + it("writes NULL on a turn without tables", async () => { + stubEchoCreate(); + await appendMessage({ + conversationId: "c1", + role: "assistant", + content: "reply", + results: [], + }); + const data = txCreate.coachMessage.create.mock.calls[0][0].data; + expect(data.resultsEncrypted).toBeNull(); + }); + + it("drops whole tables from the end until the message fits the ceiling", async () => { + stubEchoCreate(); + const big: CoachResultTable = { + ...TABLE, + ref: "r2", + rows: Array.from({ length: 400 }, () => ["x".repeat(400), 1]), + }; + await appendMessage({ + conversationId: "c1", + role: "assistant", + content: "reply", + results: [TABLE, big], + }); + const data = txCreate.coachMessage.create.mock.calls[0][0].data; + const json = new TextDecoder().decode(data.resultsEncrypted).slice(4); + expect(Buffer.byteLength(json)).toBeLessThanOrEqual(RESULTS_MAX_BYTES); + expect(JSON.parse(json)).toEqual([TABLE]); + }); +}); + +describe("readMessageResults", () => { + function row(results: CoachResultMeta[], bytes: Uint8Array | null) { + return { + metricSourceJson: JSON.stringify({ windows: [], metrics: [], results }), + resultsEncrypted: bytes, + }; + } + const sealed = (tables: unknown) => + new TextEncoder().encode(`enc:${JSON.stringify(tables)}`); + + it("narrows to the owner through the conversation", async () => { + findFirst.mockResolvedValue(null); + await expect(readMessageResults("u1", "c1", "m1")).resolves.toBeNull(); + expect(findFirst.mock.calls[0][0].where).toEqual({ + id: "m1", + conversationId: "c1", + conversation: { userId: "u1" }, + }); + }); + + it("serves every table the provenance lists, in its order", async () => { + findFirst.mockResolvedValue(row([META], sealed([TABLE]))); + await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([ + TABLE, + ]); + }); + + it("withholds a table whose domain is switched off, without decrypting it", async () => { + findFirst.mockResolvedValue( + row([META, SLEEP_META], sealed([TABLE, { ...TABLE, ...SLEEP_META }])), + ); + const out = await readMessageResults( + "u1", + "c1", + "m1", + (domain) => domain === "sleep", + ); + expect(out).toEqual([TABLE, { ref: "r2", withheld: "module_disabled" }]); + + findFirst.mockResolvedValue(row([SLEEP_META], sealed([TABLE]))); + const { decryptFromBytes } = await import("../bytes-codec"); + vi.mocked(decryptFromBytes).mockClear(); + await readMessageResults("u1", "c1", "m1", () => true); + expect(decryptFromBytes).not.toHaveBeenCalled(); + }); + + it("names a table it cannot read rather than serving nothing", async () => { + findFirst.mockResolvedValue( + row([META], new TextEncoder().encode("garbage")), + ); + await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([ + { ref: "r1", withheld: "unavailable" }, + ]); + + // A ref the ciphertext does not carry (dropped at the size ceiling). + findFirst.mockResolvedValue(row([META, SLEEP_META], sealed([TABLE]))); + await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([ + TABLE, + { ref: "r2", withheld: "unavailable" }, + ]); + }); + + it("answers an empty list for a message without tables", async () => { + findFirst.mockResolvedValue({ + metricSourceJson: null, + resultsEncrypted: null, + }); + await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([]); + }); +}); diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 85f0fc1b1..2f6f764e8 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -10,8 +10,18 @@ * provenance (window names, metric tags, sample counts) and never raw * values, so it can be queried without decryption for analytics. */ +import type { z } from "zod/v4"; + import { prisma } from "@/lib/db"; import { decryptFromBytes, encryptToBytes } from "./bytes-codec"; +import { + coachClarificationSchema, + coachFollowUpSchema, + coachMethodSchema, + coachResultMetaSchema, + coachResultTableSchema, + coachStepSchema, +} from "./stream-events"; import { COACH_CONVERSATION_TITLE_MAX } from "./types"; import { isCheckupIntervalId, @@ -20,12 +30,20 @@ import { } from "./suggest-action"; import type { + CoachClarification, CoachConversationAttachmentDTO, CoachConversationDTO, CoachConversationDetailDTO, + CoachFollowUp, CoachMessageDTO, CoachMessageRole, + CoachMethod, CoachProvenance, + CoachResultEntry, + CoachResultMeta, + CoachResultTable, + CoachStep, + CoachStepDomain, CoachSuggestion, } from "./types"; @@ -56,6 +74,9 @@ export function summariseTitle(input: string): string { return `${cut.trimEnd()}…`; } +/** v1.39.4 — at most this many tables per message (`r1`..`r6`). */ +const MAX_RESULTS_PER_MESSAGE = 6; + function provenanceToJson(provenance: CoachProvenance | null): string | null { if (!provenance) return null; return JSON.stringify(provenance); @@ -162,6 +183,53 @@ function restoreToolCalls(raw: unknown): CoachProvenance["toolCalls"] { return cleaned.length > 0 ? cleaned : undefined; } +/** + * v1.39.4 — restore an array field of the provenance entry by entry against + * the wire schema. A malformed entry is dropped, not the whole field; an + * empty or absent array restores to undefined. The schemas strip keys they do + * not know, so a stored blob can never widen what a reader sees. + */ +function restoreEach( + raw: unknown, + schema: z.ZodType, + cap: number, +): T[] | undefined { + if (!Array.isArray(raw)) return undefined; + const cleaned: T[] = []; + for (const item of raw.slice(0, cap)) { + const parsed = schema.safeParse(item); + if (parsed.success) cleaned.push(parsed.data); + } + return cleaned.length > 0 ? cleaned : undefined; +} + +/** The live steps a turn persisted (at most 12 per turn). */ +function restoreSteps(raw: unknown): CoachStep[] | undefined { + return restoreEach(raw, coachStepSchema, 12); +} + +/** The method line; dropped whole when it does not parse. */ +function restoreMethod(raw: unknown): CoachMethod | undefined { + const parsed = coachMethodSchema.safeParse(raw); + return parsed.success ? parsed.data : undefined; +} + +/** The tables' metadata (at most 6 per message). No values live here. */ +function restoreResultMetas(raw: unknown): CoachResultMeta[] | undefined { + return restoreEach(raw, coachResultMetaSchema, MAX_RESULTS_PER_MESSAGE); +} + +/** The chips offered under the reply (at most 3). */ +function restoreFollowUps(raw: unknown): CoachFollowUp[] | undefined { + return restoreEach(raw, coachFollowUpSchema, 3); +} + +/** The clarification choices; dropped whole when they do not parse. */ +function restoreClarification(raw: unknown): CoachClarification | undefined { + const parsed = coachClarificationSchema.safeParse(raw); + return parsed.success ? parsed.data : undefined; +} + function provenanceFromJson(raw: string | null): CoachProvenance | null { if (!raw) return null; try { @@ -249,6 +317,14 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null { const suggestion = restoreSuggestion(parsed.suggestion); const suggestedAction = restoreSuggestedAction(parsed.suggestedAction); const toolCalls = restoreToolCalls(parsed.toolCalls); + // v1.39.4 — the dialog fields: steps, method, table metadata, chips, the + // clarification choices, and the forced-answer marker. + const steps = restoreSteps(parsed.steps); + const method = restoreMethod(parsed.method); + const results = restoreResultMetas(parsed.results); + const followUps = restoreFollowUps(parsed.followUps); + const clarification = restoreClarification(parsed.clarification); + const forcedFinal = parsed.forcedFinal === true; return { windows, metrics, @@ -259,6 +335,12 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null { ...(suggestedAction ? { suggestedAction } : {}), ...(toolCalls ? { toolCalls } : {}), ...(unverifiedFigures !== undefined ? { unverifiedFigures } : {}), + ...(steps ? { steps } : {}), + ...(method ? { method } : {}), + ...(results ? { results } : {}), + ...(followUps ? { followUps } : {}), + ...(clarification ? { clarification } : {}), + ...(forcedFinal ? { forcedFinal: true as const } : {}), }; } catch { return null; @@ -295,6 +377,39 @@ export interface AppendMessageParams { */ tokensUsed?: number | null; model?: string | null; + /** + * v1.39.4 — the tables of values this turn read. Encrypted into + * `resultsEncrypted`; their metadata rides `metricSource.results`. Omitted + * or empty on every turn without a table. + */ + results?: CoachResultTable[]; +} + +/** + * v1.39.4 — the at-rest ceiling for one message's tables, as JSON before + * encryption. Tables arrive trimmed to 400 rows; a message whose tables still + * exceed this keeps the leading tables that fit, and a reader is told the + * rest are unavailable rather than handed a partial table. + */ +export const RESULTS_MAX_BYTES = 128 * 1024; + +/** + * Serialise a turn's tables for the ciphertext column, or null when there are + * none. Drops whole tables from the end until the JSON fits the ceiling. + */ +function resultsToBytes( + results: CoachResultTable[] | undefined, +): Uint8Array | null { + if (!results || results.length === 0) return null; + const kept = results.slice(0, MAX_RESULTS_PER_MESSAGE); + while (kept.length > 0) { + const json = JSON.stringify(kept); + if (Buffer.byteLength(json, "utf8") <= RESULTS_MAX_BYTES) { + return encryptToBytes(json); + } + kept.pop(); + } + return null; } /** @@ -417,6 +532,7 @@ export async function appendMessage( promptVersion: params.promptVersion ?? null, tokensUsed: params.tokensUsed ?? null, model: params.model ?? null, + resultsEncrypted: resultsToBytes(params.results), }, }); await tx.coachConversation.update({ @@ -547,6 +663,9 @@ export async function fetchConversationWithMessages( // changing the oldest->newest contract the client renders. orderBy: { createdAt: "desc" }, take: CONVERSATION_MESSAGE_DETAIL_CAP, + // v1.39.4 — the tables are read lazily, one message at a time, through + // `readMessageResults`; the detail read never loads their ciphertext. + omit: { resultsEncrypted: true }, }, // v1.29.x (S7) — the LIVE attachment set (join → document label columns // only; the encrypted body is untouched), ordered by attach time. Always @@ -601,6 +720,72 @@ export async function fetchConversationWithMessages( }; } +/** + * v1.39.4 — the stored tables of one assistant message, for its owner. + * + * Narrowed like the conversation read: the message must sit in a + * conversation `userId` owns, and a foreign or missing id is null (the route + * answers 404 for both, so existence never leaks across accounts). + * + * One entry per table the message's provenance lists, in that order. A + * table is served whole or withheld with a reason, never partially: + * - `module_disabled` — `withhold(domain)` said the table's domain is + * switched off for the record now. Such a table is not even decrypted. + * - `unavailable` — the ciphertext did not decrypt, did not parse, or did + * not carry that ref (a table dropped at the size ceiling). Fail closed + * and say so, rather than serve nothing silently. + */ +export async function readMessageResults( + userId: string, + conversationId: string, + messageId: string, + withhold: (domain: CoachStepDomain) => boolean = () => false, +): Promise { + const row = await prisma.coachMessage.findFirst({ + where: { + id: messageId, + conversationId, + conversation: { userId }, + }, + select: { metricSourceJson: true, resultsEncrypted: true }, + }); + if (!row) return null; + + const metas = provenanceFromJson(row.metricSourceJson)?.results ?? []; + if (metas.length === 0) return []; + + const served = metas.filter((meta) => !withhold(meta.source.domain)); + const tables = + served.length > 0 ? decryptResultTables(row.resultsEncrypted) : null; + + return metas.map((meta): CoachResultEntry => { + if (withhold(meta.source.domain)) { + return { ref: meta.ref, withheld: "module_disabled" }; + } + const table = tables?.get(meta.ref); + return table ?? { ref: meta.ref, withheld: "unavailable" }; + }); +} + +/** Decrypt and parse a message's tables by ref; null when unreadable. */ +function decryptResultTables( + bytes: Uint8Array | null, +): Map | null { + if (!bytes || bytes.byteLength === 0) return null; + let parsed: unknown; + try { + parsed = JSON.parse(decryptFromBytes(bytes)); + } catch { + return null; + } + const tables = restoreEach( + parsed, + coachResultTableSchema, + MAX_RESULTS_PER_MESSAGE, + ); + return new Map((tables ?? []).map((table) => [table.ref, table])); +} + export interface ListConversationsParams { userId: string; cursor?: string | null; diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 61c335556..008d19e5f 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -215,6 +215,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // ───── Coach (Bytes columns) ───── { model: "CoachMessage", field: "encryptedContent", kind: "bytes" }, + // v1.39.4 — the tables of values a turn read, JSON under the same codec. + { model: "CoachMessage", field: "resultsEncrypted", kind: "bytes" }, { model: "CoachConversation", field: "summaryEncrypted", kind: "bytes" }, // v1.39.3 — the title: the opening words of the first message, or a rename. { model: "CoachConversation", field: "titleEncrypted", kind: "bytes" }, diff --git a/src/lib/export/coach-backup.ts b/src/lib/export/coach-backup.ts index a373843e6..f5c64ffc2 100644 --- a/src/lib/export/coach-backup.ts +++ b/src/lib/export/coach-backup.ts @@ -79,6 +79,17 @@ export interface CoachMessageBackupEntry { promptVersion: string | null; tokensUsed: number | null; model: string | null; + /** + * v1.39.4 — the tables of values the turn read, as ciphertext in base64. + * Present on a disaster-recovery payload only; null on a turn without one. + */ + resultsEncrypted?: string | null; + /** + * v1.39.4 — the same tables as readable JSON (a `CoachResultTable[]` + * array), on a portable payload only; null on a turn without one. The + * unreadable-row marker stands in for tables that did not decrypt. + */ + resultsJson?: string | null; createdAt: string; } @@ -155,6 +166,7 @@ const COACH_CONVERSATION_BACKUP_SELECT = { promptVersion: true, tokensUsed: true, model: true, + resultsEncrypted: true, createdAt: true, }, }, @@ -248,6 +260,14 @@ export async function buildCoachBackupSection( promptVersion: message.promptVersion, tokensUsed: message.tokensUsed, model: message.model, + // v1.39.4 — the tables follow the same contract as the prose. + ...(disasterRecovery + ? { + resultsEncrypted: message.resultsEncrypted + ? Buffer.from(message.resultsEncrypted).toString("base64") + : null, + } + : { resultsJson: decryptTurnSoft(message.resultsEncrypted) }), createdAt: message.createdAt.toISOString(), })), attachments: row.attachments.map((attachment) => ({ @@ -318,6 +338,8 @@ export type RestoredCoachMessage = Pick< | "promptVersion" | "tokensUsed" | "model" + | "resultsEncrypted" + | "resultsJson" > >; @@ -401,6 +423,7 @@ export async function restoreCoachData( promptVersion: message.promptVersion ?? null, tokensUsed: message.tokensUsed ?? null, model: message.model ?? null, + resultsEncrypted: resolveResultsBytes(message), createdAt: new Date(message.createdAt), })), }, @@ -457,6 +480,23 @@ function resolveTurnBytes( return encryptToBytes(message.content ?? ""); } +/** + * v1.39.4 — a turn's stored tables, from either end of the contract. A + * disaster-recovery file carries the ciphertext; a portable file carries the + * JSON, sealed here under the target instance's key. Neither end writes a + * zero-byte ciphertext for a turn without tables. + */ +function resolveResultsBytes( + message: RestoredCoachMessage, +): Uint8Array | null { + if (message.resultsEncrypted !== undefined) { + return message.resultsEncrypted === null + ? null + : decodeBase64(message.resultsEncrypted); + } + return message.resultsJson ? encryptToBytes(message.resultsJson) : null; +} + function resolveTitleBytes( conversation: RestoredCoachConversation, ): Uint8Array { diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 51c02fb12..e188697fc 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1354,6 +1354,10 @@ const coachMessageBackupSchema = z promptVersion: z.string().nullable().optional(), tokensUsed: z.number().int().nullable().optional(), model: z.string().nullable().optional(), + // v1.39.4 — the turn's tables: ciphertext on a disaster-recovery file, + // readable JSON on a portable one, absent on every file written before. + resultsEncrypted: base64BytesSchema.nullable().optional(), + resultsJson: z.string().nullable().optional(), createdAt: isoDateTime, }) .passthrough(); diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 6f4cc1117..8f50d291b 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -117,6 +117,46 @@ const METRIC_SEALED_NOTE = "right hand, rested"; const COACH_USER_TURN = "my readings look higher this week, is that real?"; const COACH_ASSISTANT_TURN = "the last seven mornings average 4 mmHg above the fortnight before"; +/** The table of values the assistant turn read: ciphertext at rest. */ +const COACH_RESULTS_JSON = JSON.stringify([ + { + ref: "r1", + source: { + tool: "get_metric_series", + domain: "bp", + window: "last7days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.metricByPeriod", + title: "Blood pressure by day", + rowCount: 2, + chartKind: "line", + displayed: true, + columns: [ + { + key: "period", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + { + key: "sys", + kind: "number", + labelKey: "coach.result.column.systolic", + label: "Systolic", + unit: "mmHg", + }, + ], + rows: [ + ["2026-07-19", 131], + ["2026-07-20", null], + ], + truncated: false, + chart: { kind: "line", x: "period", series: ["sys"] }, + }, +]); const DOSE_CHANGE_NOTE = "titration note, encrypted at rest"; const EXTRACTED_FACT_SPAN = "Ferritin 91 ng/mL (30 - 400)"; const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose"; @@ -926,6 +966,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { { role: "assistant", encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN), + resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON), providerType: "anthropic", model: "claude-opus-5", tokensUsed: 812, @@ -1578,6 +1619,9 @@ describe("every model the plan claims two-ended survives a real restore", () => content: decryptFromBytes(message.encryptedContent), model: message.model, tokensUsed: message.tokensUsed, + results: message.resultsEncrypted + ? decryptFromBytes(message.resultsEncrypted) + : null, })), attachments: thread.attachments.map((a) => a.documentId), })), @@ -1595,12 +1639,14 @@ describe("every model the plan claims two-ended survives a real restore", () => content: COACH_USER_TURN, model: null, tokensUsed: null, + results: null, }, { role: "assistant", content: COACH_ASSISTANT_TURN, model: "claude-opus-5", tokensUsed: 812, + results: COACH_RESULTS_JSON, }, ], attachments: [], @@ -1617,6 +1663,7 @@ describe("every model the plan claims two-ended survives a real restore", () => content: "What does the ferritin mean?", model: null, tokensUsed: null, + results: null, }, ], attachments: [vaultDocument.id], @@ -2697,6 +2744,72 @@ describe("every model the plan claims two-ended survives a real restore", () => * side by side: a dropped row and a nulled pointer, each named in the report, * and a restore that still answers 200. */ + /** + * The Coach's stored tables through a portable file. + * + * A disaster-recovery file carries them as ciphertext (the round trip at + * the top of this file). A portable file has to carry them readable, like + * the prose they sit beside, and the restore has to seal them again under + * the target's key. A restore that dropped the readable JSON would bring + * the thread back with every answer's table missing and nothing to say so. + */ + it("carries a turn's tables readable in a portable file and seals them again on restore", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + await prisma.coachConversation.create({ + data: { + userId: OWNER_ID, + titleEncrypted: encryptToBytes("How is my blood pressure trending?"), + documentScoped: false, + messages: { + create: [ + { + role: "assistant", + encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN), + resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON), + createdAt: AT("2026-07-20T09:59:00.000Z"), + }, + ], + }, + }, + }); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "portable-export", + }); + const [turn] = payload.coachConversations[0].messages; + expect(turn).toMatchObject({ resultsJson: COACH_RESULTS_JSON }); + expect(turn).not.toHaveProperty("resultsEncrypted"); + + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "TWO_ENDED_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status, JSON.stringify(await response.json())).toBe(200); + + const restored = await prisma.coachMessage.findFirstOrThrow({ + where: { conversation: { userId: OWNER_ID } }, + }); + expect(restored.resultsEncrypted).not.toBeNull(); + expect(decryptFromBytes(restored.resultsEncrypted!)).toBe( + COACH_RESULTS_JSON, + ); + }); + it("drops and names a filing and a commitment a truncated file cannot resolve", async () => { const prisma = getPrismaClient(); await seedAdminSession(prisma);