From bcb7716ffa0838ee17da7e1fcde7d2e371f617b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 00:25:07 +0200 Subject: [PATCH] Carry the Coach transcript through backup and restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restored account got a Coach that had never spoken to it. The conversations, every turn in them, and the record of which vault documents a thread was grounded in were all classified as backed up and travelled with nothing. The register's own entry for the messages called them the largest volume of free text an account owns after its documents. The three travel together because two of them mean nothing alone: a turn without its thread has nowhere to hang, and an attachment names a pairing rather than a thing. documentScoped is the field that makes this more than lost history. It is a permanent fence — set when a conversation is created through the fenced endpoint or gets its first document, and lowered by no code path anywhere, not by detaching every document and not by deleting one. A restore that let it default to false would hand the tool loop back to precisely the conversations the fence exists to keep away from it, and nothing in the interface would look wrong afterwards. So it is carried verbatim, restored verbatim, and asserted per thread rather than in aggregate: a restore that wrote false everywhere would still return both threads and every turn and satisfy every count. Prose follows the contract the rest of the payload already uses. A disaster-recovery file carries the stored ciphertext; a portable file carries the text, because a portable export exists to be readable by the person who owns it, exactly as it already decrypts medication notes, mood notes and document summaries. A single row under a key the instance no longer holds is named rather than thrown, so one unreadable turn cannot cost someone the rest of the transcript. Ids ride in both modes, as the reminders do, so an attachment resolves and the facts, plans and reminders still owed have something real to point at. The restore runs after the documents for the same reason, and an attachment naming a document the file does not carry is dropped and reported instead of failing the whole file over one link. Verified by breaking it: forcing documentScoped to false on the way back fails the round trip on that field by name. --- .../[id]/restore/__tests__/round-trip.test.ts | 1 + .../api/admin/backups/[id]/restore/route.ts | 23 + .../export/__tests__/per-type-routes.test.ts | 2 + .../export/encrypted/__tests__/route.test.ts | 1 + .../__tests__/full-backup-payload.test.ts | 4 + src/lib/export/backup-plan.ts | 31 +- src/lib/export/coach-backup.ts | 440 ++++++++++++++++++ src/lib/export/full-backup-payload.ts | 19 +- src/lib/export/restore-skips.ts | 12 +- src/lib/validations/backup.ts | 61 +++ tests/integration/backup-round-trip.test.ts | 144 ++++++ 11 files changed, 730 insertions(+), 8 deletions(-) create mode 100644 src/lib/export/coach-backup.ts diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 9d6921831..41dcda179 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -296,6 +296,7 @@ function sourceClient() { // same reason as the visit tables above. measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, + coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 579e25967..89c64bf45 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -50,6 +50,7 @@ import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; import { restoreVisitsData } from "@/lib/export/visits-backup"; import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup"; +import { restoreCoachData } from "@/lib/export/coach-backup"; import { restoreRemindersData } from "@/lib/export/reminders-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; @@ -98,6 +99,9 @@ interface RestoreResponse { vaccinationLinks: number; measurementReminders: number; measurementReminderEvents: number; + coachConversations: number; + coachMessages: number; + coachConversationDocuments: number; }; } @@ -1413,6 +1417,22 @@ const handler = apiHandler( skips, ); + // The Coach's threads, turns and document links. AFTER the + // documents on purpose: an attachment carries a `documentId` that + // is a foreign key against them, so running earlier would make + // every attachment unresolvable and drop the provenance that makes + // an old answer checkable. The id set is threaded in rather than + // re-queried so the function stays a pure reader of this + // transaction. Both ends of this section live in + // `src/lib/export/coach-backup.ts`. + const coachCleared = await restoreCoachData( + tx, + ownerId, + payload, + new Set(payload.documents.map((document) => document.id)), + skips, + ); + const cleared = { measurements: measurements.count, medications: meds.count, @@ -1447,6 +1467,9 @@ const handler = apiHandler( measurementReminders: remindersCleared.measurementReminders, measurementReminderEvents: remindersCleared.measurementReminderEvents, + coachConversations: coachCleared.coachConversations, + coachMessages: coachCleared.coachMessages, + coachConversationDocuments: coachCleared.coachConversationDocuments, }; return { cleared, skipped: summarizeRestoreSkips(skips) }; }, diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index dbd70b192..ee28c881b 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -42,6 +42,7 @@ vi.mock("@/lib/db", () => ({ // same reason as the visit tables above. measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, + coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -129,6 +130,7 @@ beforeEach(() => { vi.mocked(prisma.measurementReminderEvent.findMany).mockResolvedValue( [] as never, ); + vi.mocked(prisma.coachConversation.findMany).mockResolvedValue([] as never); }); afterEach(() => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index e124d475f..37ea2a345 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -40,6 +40,7 @@ vi.mock("@/lib/db", () => ({ // same reason as the visit tables above. measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, + coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 4a776c329..349a0fd8d 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -305,6 +305,10 @@ function makePrisma() { // same reason as the visit tables above. measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, + // The Coach transcript. Deliberately NOT `?? []` at the call site, so a + // future `include` that forgets the relation fails here loudly instead of + // exporting an account whose Coach never spoke. + coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, // Left unmocked on purpose: `buildProfileBackupSection` runs for real // against these, so the assertions below exercise the builder rather than // a stand-in that would agree with whatever the payload happened to do. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 16367a481..8893820c8 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -157,6 +157,11 @@ export const BACKED_UP_MODELS = [ "VaccinationDocumentLink", // ── Coach ───────────────────────────────────────────────────────────────── + // The transcript — conversation, message, attachment — travels; see + // `src/lib/export/coach-backup.ts`. What is still owed is the Coach's + // MEMORY: the facts, plans and reminders it carries between threads. Each of + // those addresses a conversation by `sourceConversationId`, so they follow + // the transcript rather than lead it. "CoachConversation", "CoachMessage", "CoachConversationDocument", @@ -211,6 +216,10 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ // The Vorsorge reminders and their completion ledger, both ends beside each // other like the visits and vaccinations above. "src/lib/export/reminders-backup.ts", + // The Coach transcript, same arrangement. `attachments` is a relation name + // this file shares with nothing else, but each model still reads through its + // own delegate here for the reason the visits comment gives. + "src/lib/export/coach-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -222,6 +231,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/visits-backup.ts", "src/lib/export/vaccinations-backup.ts", "src/lib/export/reminders-backup.ts", + "src/lib/export/coach-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -306,6 +316,21 @@ export const TWO_ENDED_MODELS = [ // dropping to NULL in the same release — the rows they name come back now. "MeasurementReminder", "MeasurementReminderEvent", + // The Coach's transcript. The register entry for the messages called them + // "the largest volume of free text an account owns after its documents", + // and until now a restored account got a Coach that had never spoken to it. + // The three travel together because two of them are meaningless alone: a + // turn without its thread has nowhere to hang, and an attachment names a + // pairing rather than a thing. + // + // `documentScoped` is the field that makes this more than history. It is a + // permanent fence — no code path lowers it — so a restore that let it + // default to false would hand the tool loop back to precisely the + // conversations the fence exists to keep away from it, invisibly. It is + // carried and asserted, not defaulted. + "CoachConversation", + "CoachMessage", + "CoachConversationDocument", ] as const; /** One model claimed to travel both ways. */ @@ -374,12 +399,6 @@ export const COVERAGE_PENDING: Readonly> = { "Which documents were filed against which condition. Documents and conditions both restore; the filing between them does not, so a restored vault is unsorted.", ExtractedFact: "Facts read out of a document by the AI pass, with their provenance back to the page. Re-derivable only by re-running the extraction against a provider, at the operator's cost.", - CoachConversation: - "The thread structure of past coaching conversations — which messages belong together and what each exchange was about. Without it the messages below, if they were ever carried, would restore as one undifferentiated pile.", - CoachMessage: - "The messages themselves, encrypted at rest. The largest volume of free text an account owns after its documents.", - CoachConversationDocument: - "Which documents a conversation was grounded in — the provenance that makes an old answer checkable.", CoachFact: "Durable facts the Coach was told to remember. A restore makes the account re-tell its history.", CoachPlan: diff --git a/src/lib/export/coach-backup.ts b/src/lib/export/coach-backup.ts new file mode 100644 index 000000000..c031d8340 --- /dev/null +++ b/src/lib/export/coach-backup.ts @@ -0,0 +1,440 @@ +/** + * The Coach's conversations, with both backup ends in one file. + * + * Same arrangement as `reminders-backup.ts`, `visits-backup.ts` and + * `vaccinations-backup.ts`, for the same reason: a reader asking "is this + * carried at both ends?" answers it here, and a reader who greps only the + * restore ROUTE gets a false negative because the route delegates. + * + * Three models ride: the conversation, its messages, and the join naming which + * vault documents a conversation was grounded in. All three sat on the + * coverage-pending register, where the entry for the messages read "the largest + * volume of free text an account owns after its documents". A restore without + * this section hands back an account whose Coach has never spoken to it. + * + * ## Three things this file is careful about + * + * **The fence flag is the important field.** `documentScoped` is a PERMANENT + * marker: it goes true when a conversation is created through the fenced + * endpoint or gets its first document, and no code path anywhere sets it back + * to false — not detaching every document, not deleting the document. A + * conversation whose history may contain document-derived text must never + * regain the tool loop. So it is carried verbatim and restored verbatim. A + * restore that let it default to false would hand the tool loop back to exactly + * the conversations the fence exists to keep away from it, and nothing in the + * interface would look wrong afterwards. It is the one field here where losing + * the value is a security regression rather than lost history. + * + * **Ids are carried in both modes**, as the reminders are, and for the same + * kind of reason: a message addresses its conversation, an attachment addresses + * both a conversation and a document, and the Coach's facts, plans and + * reminders carry a `sourceConversationId` that has to land somewhere real. + * + * **The prose follows the contract every other encrypted column here uses.** A + * disaster-recovery payload carries the stored ciphertext verbatim as base64, + * because the same instance's key reads it back unchanged. A portable export + * decrypts it, because a portable export exists to be readable by the person + * who owns it, exactly as it already decrypts medication notes, mood notes and + * document summaries. There is no third mode where a person's own words are + * withheld from their own export. + * + * ## The one reference that needs care + * + * `CoachConversationDocument.documentId` addresses a vault document, which is + * restored by the route BEFORE this section runs. A DR payload carries every + * document, so the reference always resolves. A portable payload never reaches + * a restore at all while the account has documents — the route refuses it + * ahead of the wipe, because document ciphertext is not in the file — so an + * attachment cannot survive into a restore whose document is missing. That + * leaves the hand-edited or truncated file, and there the row is dropped and + * REPORTED rather than invented, the same answer the ledger gives for a + * reminder it cannot find. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + recordUnknownKeys, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; + +export interface CoachBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** + * One turn. `role` is the app-side pair "user" | "assistant", stored as a + * plain string rather than an enum, so it travels as written. + */ +export interface CoachMessageBackupEntry { + id: string; + role: string; + /** Ciphertext as base64. Present on a disaster-recovery payload only. */ + contentEncrypted?: string; + /** Plaintext. Present on a portable payload only. */ + content?: string; + metricSourceJson: string | null; + providerType: string | null; + promptVersion: string | null; + tokensUsed: number | null; + model: string | null; + createdAt: string; +} + +/** Which vault document a conversation was grounded in. */ +export interface CoachConversationDocumentBackupEntry { + documentId: string; + addedAt: string; +} + +export interface CoachConversationBackupEntry { + /** Always carried: messages, attachments and the Coach's own facts, plans + * and reminders all address a conversation by it. */ + id: string; + title: string; + /** + * The permanent fence marker. Carried and restored verbatim; see the file + * header for why letting this default is a security regression and not a + * cosmetic one. + */ + documentScoped: boolean; + /** Rolling summary of elided older turns. Same codec as a message. */ + summaryEncrypted?: string | null; + summary?: string | null; + summaryUpdatedAt: string | null; + summaryTurnCount: number; + createdAt: string; + updatedAt: string; + messages: CoachMessageBackupEntry[]; + attachments: CoachConversationDocumentBackupEntry[]; +} + +export interface CoachBackupSection { + coachConversations: CoachConversationBackupEntry[]; +} + +export interface CoachBackupCounts { + coachConversations: number; + coachMessages: number; + coachConversationDocuments: number; +} + +/** + * Every restorable `CoachConversation` scalar plus its two child relations. + * + * Named rather than inlined so a structural test can read the field list, and + * so a column added to the model shows up as a diff here rather than as a + * silent omission from the file. + */ +const COACH_CONVERSATION_BACKUP_SELECT = { + id: true, + title: true, + documentScoped: true, + summaryEncrypted: true, + summaryUpdatedAt: true, + summaryTurnCount: true, + createdAt: true, + updatedAt: true, + messages: { + orderBy: { createdAt: "asc" }, + select: { + id: true, + role: true, + encryptedContent: true, + metricSourceJson: true, + providerType: true, + promptVersion: true, + tokensUsed: true, + model: true, + createdAt: true, + }, + }, + attachments: { + orderBy: { addedAt: "asc" }, + select: { documentId: true, addedAt: true }, + }, +} satisfies Prisma.CoachConversationSelect; + +/** + * Decrypt for a reader, or say plainly that this one turn could not be read. + * + * A single row encrypted under a key the instance has since dropped must not + * take the export down with it: the rest of the transcript is still the + * person's, and a thrown error would cost them all of it. The placeholder is + * deliberately a sentence rather than an empty string, because an empty string + * reads as "they said nothing here" and that is not what happened. + */ +function decryptTurnSoft(bytes: Uint8Array | null): string | null { + if (!bytes) return null; + try { + return decryptFromBytes(bytes); + } catch { + return "[unreadable: encrypted with a key this instance no longer holds]"; + } +} + +export async function buildCoachBackupSection( + prisma: Pick, + userId: string, + options: CoachBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + + const rows = await prisma.coachConversation.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: COACH_CONVERSATION_BACKUP_SELECT, + }); + + return { + coachConversations: rows.map((row) => ({ + id: row.id, + title: row.title, + documentScoped: row.documentScoped, + ...(disasterRecovery + ? { + summaryEncrypted: row.summaryEncrypted + ? Buffer.from(row.summaryEncrypted).toString("base64") + : null, + } + : { summary: decryptTurnSoft(row.summaryEncrypted) }), + summaryUpdatedAt: row.summaryUpdatedAt?.toISOString() ?? null, + summaryTurnCount: row.summaryTurnCount, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + messages: row.messages.map((message) => ({ + id: message.id, + role: message.role, + ...(disasterRecovery + ? { + contentEncrypted: Buffer.from(message.encryptedContent).toString( + "base64", + ), + } + : { content: decryptTurnSoft(message.encryptedContent) ?? "" }), + metricSourceJson: message.metricSourceJson, + providerType: message.providerType, + promptVersion: message.promptVersion, + tokensUsed: message.tokensUsed, + model: message.model, + createdAt: message.createdAt.toISOString(), + })), + attachments: row.attachments.map((attachment) => ({ + documentId: attachment.documentId, + addedAt: attachment.addedAt.toISOString(), + })), + })), + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countCoachBackupSection( + section: CoachBackupSection, +): CoachBackupCounts { + return { + coachConversations: section.coachConversations.length, + coachMessages: section.coachConversations.reduce( + (total, conversation) => total + conversation.messages.length, + 0, + ), + coachConversationDocuments: section.coachConversations.reduce( + (total, conversation) => total + conversation.attachments.length, + 0, + ), + }; +} + +/** Counts the Coach restore wiped, for the audit trail. */ +export interface CoachRestoreCleared { + coachConversations: number; + coachMessages: number; + coachConversationDocuments: number; +} + +/** + * The slice of a parsed backup this restore consumes. + * + * Required rather than optional: the payload schema defaults the key, so every + * caller already satisfies this, and one that stops satisfying it fails to + * compile instead of quietly restoring nothing. + */ +export interface CoachRestoreInput { + coachConversations: RestoredCoachConversation[]; +} + +/** + * What the parser hands over, which is looser than what the builder writes. + * + * The wire schema defaults or leaves optional every field an older file might + * not carry, so the restore reads that shape rather than the builder's. The + * one field kept strictly required is `documentScoped`: a file that does not + * state the fence must not be silently re-fenced to the permissive value, and + * making it optional here would let exactly that compile. + */ +type OptionalNullable = { [K in keyof T]?: T[K] | undefined }; + +export type RestoredCoachMessage = Pick< + CoachMessageBackupEntry, + "id" | "role" | "createdAt" +> & + OptionalNullable< + Pick< + CoachMessageBackupEntry, + | "contentEncrypted" + | "content" + | "metricSourceJson" + | "providerType" + | "promptVersion" + | "tokensUsed" + | "model" + > + >; + +export type RestoredCoachConversation = Pick< + CoachConversationBackupEntry, + "id" | "title" | "documentScoped" | "createdAt" | "updatedAt" +> & + OptionalNullable< + Pick< + CoachConversationBackupEntry, + "summaryEncrypted" | "summary" | "summaryUpdatedAt" | "summaryTurnCount" + > + > & { + messages: RestoredCoachMessage[]; + attachments: CoachConversationDocumentBackupEntry[]; + }; + +/** + * Re-create the account's Coach conversations, turns and document links. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. Messages and attachments cascade from the conversation, so deleting + * the conversations is enough to empty them — they are counted first anyway, so + * the cleared numbers are truthful rather than "whatever the cascade took". + * + * MUST be called AFTER the vault documents are restored: an attachment carries + * a `documentId` that is a foreign key against them, and running earlier would + * make every attachment unresolvable. + * + * `documentIds` is the set of documents the restore actually put back, passed + * in rather than re-queried so this stays a pure function of the transaction it + * was handed. An attachment naming anything outside it is dropped and reported. + */ +export async function restoreCoachData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: CoachRestoreInput, + documentIds: ReadonlySet, + skips: RestoreSkipLog, +): Promise { + const [clearedMessages, clearedAttachments] = await Promise.all([ + tx.coachMessage.count({ where: { conversation: { userId: ownerId } } }), + tx.coachConversationDocument.count({ + where: { conversation: { userId: ownerId } }, + }), + ]); + const clearedConversations = await tx.coachConversation.deleteMany({ + where: { userId: ownerId }, + }); + + for (const conversation of payload.coachConversations) { + await tx.coachConversation.create({ + data: { + id: conversation.id, + userId: ownerId, + title: conversation.title, + // Verbatim. See the file header: this is the one field whose loss is a + // security regression rather than a gap in the history. + documentScoped: conversation.documentScoped, + summaryEncrypted: resolveSummaryBytes(conversation), + summaryUpdatedAt: conversation.summaryUpdatedAt + ? new Date(conversation.summaryUpdatedAt) + : null, + summaryTurnCount: conversation.summaryTurnCount ?? 0, + createdAt: new Date(conversation.createdAt), + updatedAt: new Date(conversation.updatedAt), + messages: { + create: conversation.messages.map((message) => ({ + id: message.id, + role: message.role, + encryptedContent: resolveTurnBytes(message), + metricSourceJson: message.metricSourceJson ?? null, + providerType: message.providerType ?? null, + promptVersion: message.promptVersion ?? null, + tokensUsed: message.tokensUsed ?? null, + model: message.model ?? null, + createdAt: new Date(message.createdAt), + })), + }, + }, + }); + } + + // The attachments go in a second pass so a dropped document reference costs + // one link rather than the conversation that held it. + const droppedDocumentRefs: string[] = []; + for (const conversation of payload.coachConversations) { + const writable = conversation.attachments.filter((attachment) => { + if (documentIds.has(attachment.documentId)) return true; + droppedDocumentRefs.push(attachment.documentId); + return false; + }); + if (writable.length === 0) continue; + await tx.coachConversationDocument.createMany({ + data: writable.map((attachment) => ({ + conversationId: conversation.id, + documentId: attachment.documentId, + addedAt: new Date(attachment.addedAt), + })), + }); + } + recordUnknownKeys( + skips, + "coachAttachment", + [...new Set(droppedDocumentRefs)], + droppedDocumentRefs, + ); + + return { + coachConversations: clearedConversations.count, + coachMessages: clearedMessages, + coachConversationDocuments: clearedAttachments, + }; +} + +/** + * A turn's stored bytes, whichever end of the contract the file came from. + * + * A disaster-recovery file carries ciphertext that decodes straight back into + * the column. A portable file carries plaintext, which has to be encrypted on + * the way in — the target instance's key, not the source's, which is the whole + * point of a portable file being portable. + */ +function resolveTurnBytes( + message: RestoredCoachMessage, +): Uint8Array { + if (message.contentEncrypted !== undefined) { + return decodeBase64(message.contentEncrypted); + } + return encryptToBytes(message.content ?? ""); +} + +function resolveSummaryBytes( + conversation: RestoredCoachConversation, +): Uint8Array | null { + if (conversation.summaryEncrypted !== undefined) { + return conversation.summaryEncrypted === null + ? null + : decodeBase64(conversation.summaryEncrypted); + } + // A portable file with no summary and one with an empty summary are the same + // thing to a reader, and neither should write a zero-byte ciphertext. + return conversation.summary ? encryptToBytes(conversation.summary) : null; +} + +function decodeBase64(encoded: string): Uint8Array { + const decoded = Buffer.from(encoded, "base64"); + const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + bytes.set(decoded); + return bytes; +} diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index c34e73696..6c79f9f92 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -55,6 +55,12 @@ import { type RemindersBackupCounts, type RemindersBackupSection, } from "@/lib/export/reminders-backup"; +import { + buildCoachBackupSection, + countCoachBackupSection, + type CoachBackupCounts, + type CoachBackupSection, +} from "@/lib/export/coach-backup"; import { buildIntradayProfileBackupSection, countIntradayProfileBackupSection, @@ -70,7 +76,8 @@ export interface FullBackupCounts HealthScoreBackupCounts, VisitsBackupCounts, VaccinationsBackupCounts, - RemindersBackupCounts { + RemindersBackupCounts, + CoachBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -287,6 +294,7 @@ export async function buildFullBackupPayload( visits, vaccinations, reminders, + coach, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -435,6 +443,12 @@ export async function buildFullBackupPayload( buildRemindersBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // The Coach's conversations, turns and document links. Both ends live in + // `src/lib/export/coach-backup.ts` beside each other, the same arrangement + // as the three sections above and for the same reason. + buildCoachBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -464,6 +478,7 @@ export async function buildFullBackupPayload( const visitsSection: VisitsBackupSection = visits; const vaccinationsSection: VaccinationsBackupSection = vaccinations; const remindersSection: RemindersBackupSection = reminders; + const coachSection: CoachBackupSection = coach; const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -742,6 +757,7 @@ export async function buildFullBackupPayload( ...visitsSection, ...vaccinationsSection, ...remindersSection, + ...coachSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -780,6 +796,7 @@ export async function buildFullBackupPayload( ...countVisitsBackupSection(visits), ...countVaccinationsBackupSection(vaccinations), ...countRemindersBackupSection(reminders), + ...countCoachBackupSection(coach), }, }; } diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 4336bf999..8c93f19cb 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -55,7 +55,16 @@ * it exists for the hand-edited or truncated file, where inventing the * reminder and silently dropping the row are equally wrong. * - * The eighth, `checkupClosure`, is not about a restore at all, and it borrows + * The eighth, `coachAttachment`, is the same kind again, for the join naming + * which vault documents a Coach conversation was grounded in. The document is + * restored before the conversations are, so a file this release writes always + * resolves: a disaster-recovery payload carries every document, and a portable + * payload never reaches a restore while the account has any, because the route + * refuses it ahead of the wipe. It exists for the hand-edited or truncated + * file, where inventing the document and silently dropping the provenance are + * equally wrong. + * + * The ninth, `checkupClosure`, is not about a restore at all, and it borrows * this shape deliberately rather than growing a second reporting mechanism * beside it. The situation is the same one: something a write was asked to do * could not be done, the record itself survives, and the person is told which @@ -71,6 +80,7 @@ export type SkippedCatalogue = | "visitReference" | "vaccinationReference" | "reminderReference" + | "coachAttachment" | "checkupClosure"; /** One key this instance does not know, and the links it cost. */ diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 092741828..b8cc8026d 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1042,6 +1042,57 @@ const measurementReminderEventBackupSchema = z }) .passthrough(); +/** + * One Coach turn. + * + * `contentEncrypted` and `content` are the two ends of the same contract, so + * both are optional here and exactly one arrives: a disaster-recovery file + * carries the ciphertext, a portable file carries the prose. Requiring either + * would refuse half the valid files; requiring both would refuse all of them. + * The restore picks whichever it was handed. + */ +const coachMessageBackupSchema = z + .object({ + id: z.string().min(1), + role: z.string().min(1), + contentEncrypted: base64BytesSchema.optional(), + content: z.string().optional(), + metricSourceJson: z.string().nullable().optional(), + providerType: z.string().nullable().optional(), + promptVersion: z.string().nullable().optional(), + tokensUsed: z.number().int().nullable().optional(), + model: z.string().nullable().optional(), + createdAt: isoDateTime, + }) + .passthrough(); + +const coachConversationDocumentBackupSchema = z + .object({ + documentId: z.string().min(1), + addedAt: isoDateTime, + }) + .passthrough(); + +const coachConversationBackupSchema = z + .object({ + id: z.string().min(1), + title: z.string(), + // NOT optional with a default. The fence is permanent and a file that does + // not state it is a file that cannot be trusted to re-fence the + // conversation, so the absence has to be visible rather than defaulted to + // the permissive value. + documentScoped: z.boolean(), + summaryEncrypted: base64BytesSchema.nullable().optional(), + summary: z.string().nullable().optional(), + summaryUpdatedAt: isoDateTime.nullable().optional(), + summaryTurnCount: z.number().int().min(0).optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + messages: z.array(coachMessageBackupSchema).default([]), + attachments: z.array(coachConversationDocumentBackupSchema).default([]), + }) + .passthrough(); + const allergyBackupSchema = z .object({ id: z.string().min(1), @@ -1208,6 +1259,7 @@ export const backupPayloadSchema = z // iOS #68). Defaulted for the same reason as the sections above: a file // written before the reminders travelled carries no key, and an account // with none writes []. + coachConversations: z.array(coachConversationBackupSchema).default([]), measurementReminders: z.array(measurementReminderBackupSchema).default([]), measurementReminderEvents: z .array(measurementReminderEventBackupSchema) @@ -1298,6 +1350,10 @@ export interface BackupSummary { measurementReminders: number; /** v1.37.20 (#223 / iOS #68) — completion-ledger rows across every reminder. */ measurementReminderEvents: number; + /** Coach conversation threads. */ + coachConversations: number; + /** Coach turns across every thread. */ + coachMessages: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -1354,6 +1410,11 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { // file with reminders the way it once did for visits. measurementReminders: payload.measurementReminders.length, measurementReminderEvents: payload.measurementReminderEvents.length, + coachConversations: payload.coachConversations.length, + coachMessages: payload.coachConversations.reduce( + (total, conversation) => total + conversation.messages.length, + 0, + ), }; } diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index f1670215e..8585e5ff1 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -82,6 +82,10 @@ vi.mock("@/lib/cache/invalidate", () => ({ const OWNER_ID = "round-trip-owner"; const AT = (iso: string) => new Date(iso); +const COACH_SUMMARY = "earlier turns: weight trend and evening walks"; +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"; const DOSE_CHANGE_NOTE = "titration note, encrypted at rest"; const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose"; @@ -163,6 +167,14 @@ const COUNT_BACK: Record< p.measurementReminder.count({ where: { userId } }), MeasurementReminderEvent: (p, userId) => p.measurementReminderEvent.count({ where: { userId } }), + CoachConversation: (p, userId) => + p.coachConversation.count({ where: { userId } }), + // Neither the turn nor the attachment carries its own `userId` — both are + // reached through the thread that holds them. + CoachMessage: (p, userId) => + p.coachMessage.count({ where: { conversation: { userId } } }), + CoachConversationDocument: (p, userId) => + p.coachConversationDocument.count({ where: { conversation: { userId } } }), }; /** @@ -475,6 +487,62 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { }, }); + // Two Coach threads, and the pair is the point. One is an ordinary health + // conversation; the other is FENCED — `documentScoped` true, grounded in the + // document seeded above. A restore that let the flag default to false would + // return both threads, satisfy every count, and quietly hand the tool loop + // back to the one conversation whose history may contain document-derived + // text. That is why the assertion below reads the flag per thread rather + // than counting rows. + await prisma.coachConversation.create({ + data: { + userId: OWNER_ID, + title: "How is my blood pressure trending?", + documentScoped: false, + summaryEncrypted: encryptToBytes(COACH_SUMMARY), + summaryUpdatedAt: AT("2026-07-20T10:00:00.000Z"), + summaryTurnCount: 4, + messages: { + create: [ + { + role: "user", + encryptedContent: encryptToBytes(COACH_USER_TURN), + createdAt: AT("2026-07-20T09:58:00.000Z"), + }, + { + role: "assistant", + encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN), + providerType: "anthropic", + model: "claude-opus-5", + tokensUsed: 812, + createdAt: AT("2026-07-20T09:59:00.000Z"), + }, + ], + }, + }, + }); + await prisma.coachConversation.create({ + data: { + userId: OWNER_ID, + title: "About my June labs", + documentScoped: true, + messages: { + create: [ + { + role: "user", + encryptedContent: encryptToBytes("What does the ferritin mean?"), + createdAt: AT("2026-07-21T08:00:00.000Z"), + }, + ], + }, + attachments: { + create: [ + { documentId: document.id, addedAt: AT("2026-07-21T07:59:00.000Z") }, + ], + }, + }, + }); + // One visit, the practice it was at, and one link of each of the three // kinds. The links reach the document, the lab result and the condition // episode seeded above rather than fresh rows, because a link to something @@ -757,6 +825,82 @@ describe("every model the plan claims two-ended survives a real restore", () => { pausedAt: "2026-07-15T08:00:00.000Z", resumedAt: null }, ]); + // The Coach came back able to speak, and the fence held. + // + // `documentScoped` is asserted per thread rather than in aggregate: a + // restore that wrote `false` everywhere would still return two threads, + // two counts and every turn, and the only visible difference would be a + // fenced conversation quietly holding a tool loop again. + // Resolved from the database rather than remembered from the fixture: the + // attachment has to point at the document the RESTORE put back, not at an + // id that happens to match one the seeding used. + const vaultDocument = await prisma.inboundDocument.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + const threads = await prisma.coachConversation.findMany({ + where: { userId: OWNER_ID }, + orderBy: { createdAt: "asc" }, + include: { + messages: { orderBy: { createdAt: "asc" } }, + attachments: true, + }, + }); + expect( + threads.map((thread) => ({ + title: thread.title, + documentScoped: thread.documentScoped, + summary: thread.summaryEncrypted + ? decryptFromBytes(thread.summaryEncrypted) + : null, + summaryTurnCount: thread.summaryTurnCount, + turns: thread.messages.map((message) => ({ + role: message.role, + content: decryptFromBytes(message.encryptedContent), + model: message.model, + tokensUsed: message.tokensUsed, + })), + attachments: thread.attachments.map((a) => a.documentId), + })), + "the transcript, the fence and the provenance all come back", + ).toEqual([ + { + title: "How is my blood pressure trending?", + documentScoped: false, + summary: COACH_SUMMARY, + summaryTurnCount: 4, + turns: [ + { + role: "user", + content: COACH_USER_TURN, + model: null, + tokensUsed: null, + }, + { + role: "assistant", + content: COACH_ASSISTANT_TURN, + model: "claude-opus-5", + tokensUsed: 812, + }, + ], + attachments: [], + }, + { + title: "About my June labs", + documentScoped: true, + summary: null, + summaryTurnCount: 0, + turns: [ + { + role: "user", + content: "What does the ferritin mean?", + model: null, + tokensUsed: null, + }, + ], + attachments: [vaultDocument.id], + }, + ]); + // The ramp, in order, with the note back in its column. Counting says two // rows returned; only this says they came back as the same ramp, and that // the encrypted note did not land in the plaintext column on the way.