mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user