From 1fbd3b628b12de7591ec3122a97378689fc52f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 00:37:38 +0200 Subject: [PATCH] Carry the Coach's memory through backup and restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transcript came back in the previous change; what the Coach keeps BETWEEN threads did not. A restored account had every word of every conversation and a Coach that has to be told its own history again — the register said as much for all three: "a restore makes the account re-tell its history", "keeps the talk and loses the commitment", "a restored account silently stops sending them". Tombstones ride only in a disaster-recovery file. All three soft-delete, and a deleted fact is hidden from injection while kept for audit — a distinction only the owning instance can act on. A portable file carrying them would hand a person rows their own app refuses to show them, and a restore that resurrected one would put a fact back into the Coach's mouth that the person had deliberately taken out of it. Neither reference here is a foreign key. sourceConversationId, on all three, and relatedPlanId on the reminder are bare id columns, so nothing in the database refuses a value pointing at nothing. That makes them more dangerous than a real key rather than less: a dangling reference costs no error, it just quietly stops meaning anything while the row goes on looking complete. The restore resolves both against what it actually wrote, nulls what it cannot resolve, and reports the count. Nulling rather than dropping is the point. A live reminder must not be lost because the plan it mentions was tombstoned out of a portable file. The reminder is still the person's and it still fires; what it loses is a pointer that was already going nowhere. Order follows from that: threads, then plans, then reminders. Running the memory before the transcript would cut every fact loose from the conversation it came out of and still report success. Verified by breaking it: making the conversation lookup always return null fails the round trip on the fact's own source field. --- .../[id]/restore/__tests__/round-trip.test.ts | 3 + .../api/admin/backups/[id]/restore/route.ts | 24 +- .../export/__tests__/per-type-routes.test.ts | 6 + .../export/encrypted/__tests__/route.test.ts | 3 + .../__tests__/full-backup-payload.test.ts | 3 + src/lib/export/backup-plan.ts | 18 +- src/lib/export/coach-backup.ts | 474 ++++++++++++++++++ src/lib/export/full-backup-payload.ts | 17 +- src/lib/export/restore-skips.ts | 12 +- src/lib/validations/backup.ts | 71 +++ tests/integration/backup-round-trip.test.ts | 113 +++++ 11 files changed, 735 insertions(+), 9 deletions(-) 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 41dcda179..640535f51 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 @@ -297,6 +297,9 @@ function sourceClient() { measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, + coachFact: { findMany: vi.fn().mockResolvedValue([]) }, + coachPlan: { findMany: vi.fn().mockResolvedValue([]) }, + coachReminder: { 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 89c64bf45..adee20393 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -50,7 +50,10 @@ 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 { + restoreCoachData, + restoreCoachMemoryData, +} from "@/lib/export/coach-backup"; import { restoreRemindersData } from "@/lib/export/reminders-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; @@ -102,6 +105,9 @@ interface RestoreResponse { coachConversations: number; coachMessages: number; coachConversationDocuments: number; + coachFacts: number; + coachPlans: number; + coachReminders: number; }; } @@ -1433,6 +1439,19 @@ const handler = apiHandler( skips, ); + // What the Coach keeps between threads. AFTER the transcript, + // because all three carry a `sourceConversationId` that is resolved + // against the threads the branch above has just written — resolving + // earlier would cut every fact loose from the conversation it came + // out of and still report success. + const coachMemoryCleared = await restoreCoachMemoryData( + tx, + ownerId, + payload, + new Set(payload.coachConversations.map((c) => c.id)), + skips, + ); + const cleared = { measurements: measurements.count, medications: meds.count, @@ -1470,6 +1489,9 @@ const handler = apiHandler( coachConversations: coachCleared.coachConversations, coachMessages: coachCleared.coachMessages, coachConversationDocuments: coachCleared.coachConversationDocuments, + coachFacts: coachMemoryCleared.coachFacts, + coachPlans: coachMemoryCleared.coachPlans, + coachReminders: coachMemoryCleared.coachReminders, }; 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 ee28c881b..b63623c77 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -43,6 +43,9 @@ vi.mock("@/lib/db", () => ({ measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, + coachFact: { findMany: vi.fn().mockResolvedValue([]) }, + coachPlan: { findMany: vi.fn().mockResolvedValue([]) }, + coachReminder: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -131,6 +134,9 @@ beforeEach(() => { [] as never, ); vi.mocked(prisma.coachConversation.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.coachReminder.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 37ea2a345..101d8bb9f 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -41,6 +41,9 @@ vi.mock("@/lib/db", () => ({ measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) }, coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, + coachFact: { findMany: vi.fn().mockResolvedValue([]) }, + coachPlan: { findMany: vi.fn().mockResolvedValue([]) }, + coachReminder: { 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 349a0fd8d..c1ef893ab 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -309,6 +309,9 @@ function makePrisma() { // future `include` that forgets the relation fails here loudly instead of // exporting an account whose Coach never spoke. coachConversation: { findMany: vi.fn().mockResolvedValue([]) }, + coachFact: { findMany: vi.fn().mockResolvedValue([]) }, + coachPlan: { findMany: vi.fn().mockResolvedValue([]) }, + coachReminder: { 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 8893820c8..3c2369a5c 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -331,6 +331,18 @@ export const TWO_ENDED_MODELS = [ "CoachConversation", "CoachMessage", "CoachConversationDocument", + // And what the Coach keeps between threads. The transcript alone restores an + // account with every word of every conversation and a Coach that has to be + // told its own history again. + // + // Both of the references here — `sourceConversationId` on all three, + // `relatedPlanId` on the reminder — are bare id columns with no foreign key, + // so a value pointing at nothing costs no error and just stops meaning + // anything. The restore resolves them against what it wrote and reports what + // it could not, rather than writing a pointer it knows is dead. + "CoachFact", + "CoachPlan", + "CoachReminder", ] as const; /** One model claimed to travel both ways. */ @@ -399,12 +411,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.", - CoachFact: - "Durable facts the Coach was told to remember. A restore makes the account re-tell its history.", - CoachPlan: - "An agreed plan and its progress. Restoring the conversation without the plan keeps the talk and loses the commitment.", - CoachReminder: - "Reminders the Coach agreed to send. A restored account silently stops sending them.", NotificationPreference: "Per-event channel and quiet-hours choices. A restore reverts to defaults, so an account that had deliberately silenced a category starts being notified again.", ReminderPhaseConfig: diff --git a/src/lib/export/coach-backup.ts b/src/lib/export/coach-backup.ts index c031d8340..c4894fc7f 100644 --- a/src/lib/export/coach-backup.ts +++ b/src/lib/export/coach-backup.ts @@ -438,3 +438,477 @@ function decodeBase64(encoded: string): Uint8Array { bytes.set(decoded); return bytes; } + +/* ──────────────────────────────────────────────────────────────────────────── + * The Coach's MEMORY: the facts, plans and reminders it carries BETWEEN + * threads. + * + * The transcript above is what was said. This is what the Coach was asked to + * keep. Without it a restored account has every word of every conversation and + * a Coach that has to be told its own history again — which the register named + * exactly: "a restore makes the account re-tell its history", "keeps the talk + * and loses the commitment", "a restored account silently stops sending them". + * + * ## Tombstones ride, but only in a disaster-recovery file + * + * All three soft-delete. A deleted fact is hidden from injection and kept for + * audit, which is a distinction only the owning instance can act on, so a + * disaster-recovery payload carries the tombstone and a portable export omits + * it — the same split the Vorsorge reminders use. A portable file that carried + * them would hand a person rows their own app refuses to show them, and a + * restore that resurrected one would put a fact back into the Coach's mouth + * that the person had deliberately taken out of it. + * + * ## Two references, neither of them a foreign key + * + * `sourceConversationId` and `relatedPlanId` are bare id columns — the schema + * declares no relation for either, so nothing in the database refuses a value + * that points at nothing. That makes them more dangerous than a real key, not + * less: a dangling reference costs no error, it just quietly stops meaning + * anything, and the row goes on looking complete. + * + * So the restore resolves both against what it actually wrote and NULLS what it + * cannot resolve, then reports the count. Nulling rather than dropping is the + * point: a live reminder must not be lost because the plan it mentions was + * tombstoned out of a portable file. The reminder is still the person's, and it + * still fires. What it loses is a pointer that was already going nowhere. + * ──────────────────────────────────────────────────────────────────────────── */ + +/** One durable fact the Coach was told to remember. */ +export interface CoachFactBackupEntry { + id: string; + /** Ciphertext as base64. Present on a disaster-recovery payload only. */ + factEncrypted?: string; + /** Plaintext. Present on a portable payload only. */ + fact?: string; + category: string; + confidence: number; + sourceConversationId: string | null; + createdAt: string; + updatedAt: string; + deletedAt?: string | null; +} + +/** One implementation intention: "if , then ". */ +export interface CoachPlanBackupEntry { + id: string; + metric: string; + ifCueEncrypted?: string; + ifCue?: string; + thenActionEncrypted?: string; + thenAction?: string; + targetEncrypted?: string | null; + target?: string | null; + outcomeEncrypted?: string | null; + outcome?: string | null; + status: string; + reviewDate: string | null; + sourceConversationId: string | null; + createdAt: string; + updatedAt: string; + deletedAt?: string | null; +} + +/** One thing to bring back up, in the person's own framing. */ +export interface CoachReminderBackupEntry { + id: string; + noteEncrypted?: string; + note?: string; + metric: string | null; + relatedPlanId: string | null; + triggerKind: string; + dueAt: string | null; + contextCue: string | null; + status: string; + source: string; + sourceConversationId: string | null; + lastSurfacedAt: string | null; + surfaceCount: number; + createdAt: string; + updatedAt: string; + deletedAt?: string | null; +} + +export interface CoachMemoryBackupSection { + coachFacts: CoachFactBackupEntry[]; + coachPlans: CoachPlanBackupEntry[]; + coachReminders: CoachReminderBackupEntry[]; +} + +export interface CoachMemoryBackupCounts { + coachFacts: number; + coachPlans: number; + coachReminders: number; +} + +export async function buildCoachMemoryBackupSection( + prisma: Pick, + userId: string, + options: CoachBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + const scope = disasterRecovery ? { userId } : { userId, deletedAt: null }; + + const [factRows, planRows, reminderRows] = await Promise.all([ + prisma.coachFact.findMany({ + where: scope, + orderBy: { createdAt: "asc" }, + select: COACH_FACT_BACKUP_SELECT, + }), + prisma.coachPlan.findMany({ + where: scope, + orderBy: { createdAt: "asc" }, + select: COACH_PLAN_BACKUP_SELECT, + }), + prisma.coachReminder.findMany({ + where: scope, + orderBy: { createdAt: "asc" }, + select: COACH_REMINDER_BACKUP_SELECT, + }), + ]); + + const asBase64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64"); + const asBase64OrNull = (bytes: Uint8Array | null) => + bytes ? asBase64(bytes) : null; + + return { + coachFacts: factRows.map((row) => ({ + id: row.id, + ...(disasterRecovery + ? { + factEncrypted: asBase64(row.factEncrypted), + deletedAt: row.deletedAt?.toISOString() ?? null, + } + : { fact: decryptTurnSoft(row.factEncrypted) ?? "" }), + category: row.category, + confidence: row.confidence, + sourceConversationId: row.sourceConversationId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })), + coachPlans: planRows.map((row) => ({ + id: row.id, + metric: row.metric, + ...(disasterRecovery + ? { + ifCueEncrypted: asBase64(row.ifCueEncrypted), + thenActionEncrypted: asBase64(row.thenActionEncrypted), + targetEncrypted: asBase64OrNull(row.targetEncrypted), + outcomeEncrypted: asBase64OrNull(row.outcomeEncrypted), + deletedAt: row.deletedAt?.toISOString() ?? null, + } + : { + ifCue: decryptTurnSoft(row.ifCueEncrypted) ?? "", + thenAction: decryptTurnSoft(row.thenActionEncrypted) ?? "", + target: decryptTurnSoft(row.targetEncrypted), + outcome: decryptTurnSoft(row.outcomeEncrypted), + }), + status: row.status, + reviewDate: row.reviewDate?.toISOString() ?? null, + sourceConversationId: row.sourceConversationId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })), + coachReminders: reminderRows.map((row) => ({ + id: row.id, + ...(disasterRecovery + ? { + noteEncrypted: asBase64(row.noteEncrypted), + deletedAt: row.deletedAt?.toISOString() ?? null, + } + : { note: decryptTurnSoft(row.noteEncrypted) ?? "" }), + metric: row.metric, + relatedPlanId: row.relatedPlanId, + triggerKind: row.triggerKind, + dueAt: row.dueAt?.toISOString() ?? null, + contextCue: row.contextCue, + status: row.status, + source: row.source, + sourceConversationId: row.sourceConversationId, + lastSurfacedAt: row.lastSurfacedAt?.toISOString() ?? null, + surfaceCount: row.surfaceCount, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })), + }; +} + +const COACH_FACT_BACKUP_SELECT = { + id: true, + factEncrypted: true, + category: true, + confidence: true, + sourceConversationId: true, + createdAt: true, + updatedAt: true, + deletedAt: true, +} satisfies Prisma.CoachFactSelect; + +const COACH_PLAN_BACKUP_SELECT = { + id: true, + metric: true, + ifCueEncrypted: true, + thenActionEncrypted: true, + targetEncrypted: true, + outcomeEncrypted: true, + status: true, + reviewDate: true, + sourceConversationId: true, + createdAt: true, + updatedAt: true, + deletedAt: true, +} satisfies Prisma.CoachPlanSelect; + +const COACH_REMINDER_BACKUP_SELECT = { + id: true, + noteEncrypted: true, + metric: true, + relatedPlanId: true, + triggerKind: true, + dueAt: true, + contextCue: true, + status: true, + source: true, + sourceConversationId: true, + lastSurfacedAt: true, + surfaceCount: true, + createdAt: true, + updatedAt: true, + deletedAt: true, +} satisfies Prisma.CoachReminderSelect; + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countCoachMemoryBackupSection( + section: CoachMemoryBackupSection, +): CoachMemoryBackupCounts { + return { + coachFacts: section.coachFacts.length, + coachPlans: section.coachPlans.length, + coachReminders: section.coachReminders.length, + }; +} + +/** Counts the Coach-memory restore wiped, for the audit trail. */ +export interface CoachMemoryRestoreCleared { + coachFacts: number; + coachPlans: number; + coachReminders: number; +} + +type OptionalMemory = { [K in keyof T]?: T[K] | undefined }; + +export type RestoredCoachFact = Pick< + CoachFactBackupEntry, + "id" | "category" | "createdAt" | "updatedAt" +> & + OptionalMemory< + Pick< + CoachFactBackupEntry, + | "factEncrypted" + | "fact" + | "confidence" + | "sourceConversationId" + | "deletedAt" + > + >; + +export type RestoredCoachPlan = Pick< + CoachPlanBackupEntry, + "id" | "metric" | "createdAt" | "updatedAt" +> & + OptionalMemory< + Pick< + CoachPlanBackupEntry, + | "ifCueEncrypted" + | "ifCue" + | "thenActionEncrypted" + | "thenAction" + | "targetEncrypted" + | "target" + | "outcomeEncrypted" + | "outcome" + | "status" + | "reviewDate" + | "sourceConversationId" + | "deletedAt" + > + >; + +export type RestoredCoachReminder = Pick< + CoachReminderBackupEntry, + "id" | "source" | "createdAt" | "updatedAt" +> & + OptionalMemory< + Pick< + CoachReminderBackupEntry, + | "noteEncrypted" + | "note" + | "metric" + | "relatedPlanId" + | "triggerKind" + | "dueAt" + | "contextCue" + | "status" + | "sourceConversationId" + | "lastSurfacedAt" + | "surfaceCount" + | "deletedAt" + > + >; + +export interface CoachMemoryRestoreInput { + coachFacts: RestoredCoachFact[]; + coachPlans: RestoredCoachPlan[]; + coachReminders: RestoredCoachReminder[]; +} + +/** + * Re-create the Coach's memory. + * + * MUST be called AFTER `restoreCoachData`: every one of the three carries a + * `sourceConversationId`, and a reference resolved before the threads exist + * would read as unresolvable and be nulled — a restore that reported success + * while quietly cutting every fact loose from the conversation it came out of. + * + * Plans go in before reminders for the same reason one step down: a reminder's + * `relatedPlanId` is resolved against the plans this function has just written. + */ +export async function restoreCoachMemoryData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: CoachMemoryRestoreInput, + conversationIds: ReadonlySet, + skips: RestoreSkipLog, +): Promise { + const [clearedFacts, clearedPlans, clearedReminders] = await Promise.all([ + tx.coachFact.deleteMany({ where: { userId: ownerId } }), + tx.coachPlan.deleteMany({ where: { userId: ownerId } }), + tx.coachReminder.deleteMany({ where: { userId: ownerId } }), + ]); + + // Both references are nulled rather than dropped when they do not resolve, + // and every miss is reported. See the section header: these are bare id + // columns, so a dangling value costs no error and simply stops meaning + // anything. + const danglingRefs: string[] = []; + const resolveConversation = (id: string | null | undefined) => { + if (!id) return null; + if (conversationIds.has(id)) return id; + danglingRefs.push(id); + return null; + }; + + if (payload.coachFacts.length > 0) { + await tx.coachFact.createMany({ + data: payload.coachFacts.map((fact) => ({ + id: fact.id, + userId: ownerId, + factEncrypted: resolveMemoryBytes(fact.factEncrypted, fact.fact), + category: fact.category, + confidence: fact.confidence ?? 50, + sourceConversationId: resolveConversation(fact.sourceConversationId), + createdAt: new Date(fact.createdAt), + updatedAt: new Date(fact.updatedAt), + deletedAt: fact.deletedAt ? new Date(fact.deletedAt) : null, + })), + }); + } + + if (payload.coachPlans.length > 0) { + await tx.coachPlan.createMany({ + data: payload.coachPlans.map((plan) => ({ + id: plan.id, + userId: ownerId, + metric: plan.metric, + ifCueEncrypted: resolveMemoryBytes(plan.ifCueEncrypted, plan.ifCue), + thenActionEncrypted: resolveMemoryBytes( + plan.thenActionEncrypted, + plan.thenAction, + ), + targetEncrypted: resolveOptionalMemoryBytes( + plan.targetEncrypted, + plan.target, + ), + outcomeEncrypted: resolveOptionalMemoryBytes( + plan.outcomeEncrypted, + plan.outcome, + ), + status: plan.status ?? "proposed", + reviewDate: plan.reviewDate ? new Date(plan.reviewDate) : null, + sourceConversationId: resolveConversation(plan.sourceConversationId), + createdAt: new Date(plan.createdAt), + updatedAt: new Date(plan.updatedAt), + deletedAt: plan.deletedAt ? new Date(plan.deletedAt) : null, + })), + }); + } + + const restoredPlanIds = new Set(payload.coachPlans.map((plan) => plan.id)); + if (payload.coachReminders.length > 0) { + await tx.coachReminder.createMany({ + data: payload.coachReminders.map((reminder) => { + let relatedPlanId = reminder.relatedPlanId ?? null; + if (relatedPlanId && !restoredPlanIds.has(relatedPlanId)) { + danglingRefs.push(relatedPlanId); + relatedPlanId = null; + } + return { + id: reminder.id, + userId: ownerId, + noteEncrypted: resolveMemoryBytes( + reminder.noteEncrypted, + reminder.note, + ), + metric: reminder.metric ?? null, + relatedPlanId, + triggerKind: reminder.triggerKind ?? "date", + dueAt: reminder.dueAt ? new Date(reminder.dueAt) : null, + contextCue: reminder.contextCue ?? null, + status: reminder.status ?? "active", + source: reminder.source, + sourceConversationId: resolveConversation( + reminder.sourceConversationId, + ), + lastSurfacedAt: reminder.lastSurfacedAt + ? new Date(reminder.lastSurfacedAt) + : null, + surfaceCount: reminder.surfaceCount ?? 0, + createdAt: new Date(reminder.createdAt), + updatedAt: new Date(reminder.updatedAt), + deletedAt: reminder.deletedAt ? new Date(reminder.deletedAt) : null, + }; + }), + }); + } + + recordUnknownKeys( + skips, + "coachReference", + [...new Set(danglingRefs)], + danglingRefs, + ); + + return { + coachFacts: clearedFacts.count, + coachPlans: clearedPlans.count, + coachReminders: clearedReminders.count, + }; +} + +function resolveMemoryBytes( + ciphertext: string | undefined, + plaintext: string | undefined, +): Uint8Array { + if (ciphertext !== undefined) return decodeBase64(ciphertext); + return encryptToBytes(plaintext ?? ""); +} + +function resolveOptionalMemoryBytes( + ciphertext: string | null | undefined, + plaintext: string | null | undefined, +): Uint8Array | null { + if (ciphertext !== undefined) { + return ciphertext === null ? null : decodeBase64(ciphertext); + } + return plaintext ? encryptToBytes(plaintext) : null; +} diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 6c79f9f92..b57e94c1c 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -57,9 +57,13 @@ import { } from "@/lib/export/reminders-backup"; import { buildCoachBackupSection, + buildCoachMemoryBackupSection, countCoachBackupSection, + countCoachMemoryBackupSection, type CoachBackupCounts, type CoachBackupSection, + type CoachMemoryBackupCounts, + type CoachMemoryBackupSection, } from "@/lib/export/coach-backup"; import { buildIntradayProfileBackupSection, @@ -77,7 +81,8 @@ export interface FullBackupCounts VisitsBackupCounts, VaccinationsBackupCounts, RemindersBackupCounts, - CoachBackupCounts { + CoachBackupCounts, + CoachMemoryBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -295,6 +300,7 @@ export async function buildFullBackupPayload( vaccinations, reminders, coach, + coachMemory, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -449,6 +455,12 @@ export async function buildFullBackupPayload( buildCoachBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // What the Coach keeps BETWEEN threads. Runs beside the transcript rather + // than inside it because the two answer different questions, and because a + // restore has to write them in that order. + buildCoachMemoryBackupSection(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"). @@ -479,6 +491,7 @@ export async function buildFullBackupPayload( const vaccinationsSection: VaccinationsBackupSection = vaccinations; const remindersSection: RemindersBackupSection = reminders; const coachSection: CoachBackupSection = coach; + const coachMemorySection: CoachMemoryBackupSection = coachMemory; const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -758,6 +771,7 @@ export async function buildFullBackupPayload( ...vaccinationsSection, ...remindersSection, ...coachSection, + ...coachMemorySection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -797,6 +811,7 @@ export async function buildFullBackupPayload( ...countVaccinationsBackupSection(vaccinations), ...countRemindersBackupSection(reminders), ...countCoachBackupSection(coach), + ...countCoachMemoryBackupSection(coachMemory), }, }; } diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 8c93f19cb..cf7d7dd03 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -64,7 +64,16 @@ * 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 + * The ninth, `coachReference`, names a conversation or a plan that a Coach + * fact, plan or reminder pointed at and the restore did not put back. It is + * unlike every kind above in one way worth stating: neither `sourceConversationId` + * nor `relatedPlanId` is a foreign key, so a dangling value costs no error and + * simply stops meaning anything. What is dropped here is therefore the POINTER + * and not the row — the fact, the plan and the reminder all restore, and the + * reminder still fires. A portable export omits tombstoned plans, which is the + * ordinary way a live reminder ends up naming one the file does not carry. + * + * The tenth, `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 @@ -81,6 +90,7 @@ export type SkippedCatalogue = | "vaccinationReference" | "reminderReference" | "coachAttachment" + | "coachReference" | "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 b8cc8026d..a0f370395 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1093,6 +1093,67 @@ const coachConversationBackupSchema = z }) .passthrough(); +/** + * The Coach's memory. `deletedAt` is optional rather than nullable-required: + * a portable file omits tombstoned rows entirely and never states the field, + * while a disaster-recovery file always does. + */ +const coachFactBackupSchema = z + .object({ + id: z.string().min(1), + factEncrypted: base64BytesSchema.optional(), + fact: z.string().optional(), + category: z.string().min(1), + confidence: z.number().int().min(0).max(100).optional(), + sourceConversationId: z.string().nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); + +const coachPlanBackupSchema = z + .object({ + id: z.string().min(1), + metric: z.string().min(1), + ifCueEncrypted: base64BytesSchema.optional(), + ifCue: z.string().optional(), + thenActionEncrypted: base64BytesSchema.optional(), + thenAction: z.string().optional(), + targetEncrypted: base64BytesSchema.nullable().optional(), + target: z.string().nullable().optional(), + outcomeEncrypted: base64BytesSchema.nullable().optional(), + outcome: z.string().nullable().optional(), + status: z.string().optional(), + reviewDate: isoDateTime.nullable().optional(), + sourceConversationId: z.string().nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); + +const coachReminderBackupSchema = z + .object({ + id: z.string().min(1), + noteEncrypted: base64BytesSchema.optional(), + note: z.string().optional(), + metric: z.string().nullable().optional(), + relatedPlanId: z.string().nullable().optional(), + triggerKind: z.string().optional(), + dueAt: isoDateTime.nullable().optional(), + contextCue: z.string().nullable().optional(), + status: z.string().optional(), + source: z.string().min(1), + sourceConversationId: z.string().nullable().optional(), + lastSurfacedAt: isoDateTime.nullable().optional(), + surfaceCount: z.number().int().min(0).optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); + const allergyBackupSchema = z .object({ id: z.string().min(1), @@ -1260,6 +1321,9 @@ export const backupPayloadSchema = z // written before the reminders travelled carries no key, and an account // with none writes []. coachConversations: z.array(coachConversationBackupSchema).default([]), + coachFacts: z.array(coachFactBackupSchema).default([]), + coachPlans: z.array(coachPlanBackupSchema).default([]), + coachReminders: z.array(coachReminderBackupSchema).default([]), measurementReminders: z.array(measurementReminderBackupSchema).default([]), measurementReminderEvents: z .array(measurementReminderEventBackupSchema) @@ -1354,6 +1418,10 @@ export interface BackupSummary { coachConversations: number; /** Coach turns across every thread. */ coachMessages: number; + /** Durable facts, agreed plans and things to bring back up. */ + coachFacts: number; + coachPlans: number; + coachReminders: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -1415,6 +1483,9 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { (total, conversation) => total + conversation.messages.length, 0, ), + coachFacts: payload.coachFacts.length, + coachPlans: payload.coachPlans.length, + coachReminders: payload.coachReminders.length, }; } diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 8585e5ff1..340719aa3 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_FACT = "does not tolerate ACE inhibitors"; +const COACH_PLAN_CUE = "if the evening reading is over 140"; +const COACH_PLAN_ACTION = "then walk twenty minutes before dinner tomorrow"; +const COACH_REMINDER_NOTE = "ask how the evening walks are going"; 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 = @@ -175,6 +179,9 @@ const COUNT_BACK: Record< p.coachMessage.count({ where: { conversation: { userId } } }), CoachConversationDocument: (p, userId) => p.coachConversationDocument.count({ where: { conversation: { userId } } }), + CoachFact: (p, userId) => p.coachFact.count({ where: { userId } }), + CoachPlan: (p, userId) => p.coachPlan.count({ where: { userId } }), + CoachReminder: (p, userId) => p.coachReminder.count({ where: { userId } }), }; /** @@ -543,6 +550,48 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { }, }); + // What the Coach keeps between threads, seeded so both references are + // exercised: the fact and the plan point back at the thread they came out + // of, and the reminder points at the plan. + const healthThread = await prisma.coachConversation.findFirstOrThrow({ + where: { userId: OWNER_ID, documentScoped: false }, + }); + await prisma.coachFact.create({ + data: { + userId: OWNER_ID, + factEncrypted: encryptToBytes(COACH_FACT), + category: "constraint", + confidence: 80, + sourceConversationId: healthThread.id, + }, + }); + const plan = await prisma.coachPlan.create({ + data: { + userId: OWNER_ID, + metric: "BLOOD_PRESSURE", + ifCueEncrypted: encryptToBytes(COACH_PLAN_CUE), + thenActionEncrypted: encryptToBytes(COACH_PLAN_ACTION), + targetEncrypted: encryptToBytes("under 130 by October"), + status: "active", + reviewDate: AT("2026-10-01T00:00:00.000Z"), + sourceConversationId: healthThread.id, + }, + }); + await prisma.coachReminder.create({ + data: { + userId: OWNER_ID, + noteEncrypted: encryptToBytes(COACH_REMINDER_NOTE), + metric: "BLOOD_PRESSURE", + relatedPlanId: plan.id, + triggerKind: "date", + dueAt: AT("2026-09-01T07:00:00.000Z"), + status: "active", + source: "extractor", + sourceConversationId: healthThread.id, + surfaceCount: 2, + }, + }); + // 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 @@ -901,6 +950,70 @@ describe("every model the plan claims two-ended survives a real restore", () => }, ]); + // The Coach's memory, and both of its references still pointing at + // something. Neither is a foreign key, so a restore that wrote them back + // as null would break nothing, satisfy every count, and leave the account + // with a Coach that remembers a fact and no longer knows where it came + // from. + const restoredThread = await prisma.coachConversation.findFirstOrThrow({ + where: { userId: OWNER_ID, documentScoped: false }, + }); + const restoredPlan = await prisma.coachPlan.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + const fact = await prisma.coachFact.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect({ + fact: decryptFromBytes(fact.factEncrypted), + category: fact.category, + confidence: fact.confidence, + source: fact.sourceConversationId, + deletedAt: fact.deletedAt, + }).toEqual({ + fact: COACH_FACT, + category: "constraint", + confidence: 80, + source: restoredThread.id, + deletedAt: null, + }); + expect({ + metric: restoredPlan.metric, + ifCue: decryptFromBytes(restoredPlan.ifCueEncrypted), + thenAction: decryptFromBytes(restoredPlan.thenActionEncrypted), + target: restoredPlan.targetEncrypted + ? decryptFromBytes(restoredPlan.targetEncrypted) + : null, + status: restoredPlan.status, + source: restoredPlan.sourceConversationId, + }).toEqual({ + metric: "BLOOD_PRESSURE", + ifCue: COACH_PLAN_CUE, + thenAction: COACH_PLAN_ACTION, + target: "under 130 by October", + status: "active", + source: restoredThread.id, + }); + const reminder = await prisma.coachReminder.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect( + { + note: decryptFromBytes(reminder.noteEncrypted), + relatedPlanId: reminder.relatedPlanId, + source: reminder.sourceConversationId, + surfaceCount: reminder.surfaceCount, + status: reminder.status, + }, + "the reminder still knows which plan it belongs to", + ).toEqual({ + note: COACH_REMINDER_NOTE, + relatedPlanId: restoredPlan.id, + source: restoredThread.id, + surfaceCount: 2, + status: "active", + }); + // 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.