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.