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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 10:36:27 +02:00
1 parent cde7af3cf8
commit a8b3e5d7f4
9 files changed
+714 -1

No files matched your search

@@ -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;
+6
View File
@@ -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)
+11 -1
View File
@@ -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",
@@ -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<unknown>) =>
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([]);
});
});
+185
View File
@@ -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<T>(
raw: unknown,
schema: z.ZodType<T>,
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<ArrayBuffer> | 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<CoachResultEntry[] | null> {
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<string, CoachResultTable> | 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;
+2
View File
@@ -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" },
+40
View File
@@ -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<ArrayBuffer> | 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<ArrayBuffer> {
+4
View File
@@ -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();
+113
View File
@@ -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<void> {
{
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);