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.