Carry the Coach transcript through backup and restore

A restored account got a Coach that had never spoken to it. The
conversations, every turn in them, and the record of which vault
documents a thread was grounded in were all classified as backed up and
travelled with nothing. The register's own entry for the messages called
them the largest volume of free text an account owns after its
documents.

The three travel together because two of them mean nothing alone: a turn
without its thread has nowhere to hang, and an attachment names a
pairing rather than a thing.

documentScoped is the field that makes this more than lost history. It
is a permanent fence — set when a conversation is created through the
fenced endpoint or gets its first document, and lowered by no code path
anywhere, not by detaching every document and not by deleting one. A
restore that let it default to false would hand the tool loop back to
precisely the conversations the fence exists to keep away from it, and
nothing in the interface would look wrong afterwards. So it is carried
verbatim, restored verbatim, and asserted per thread rather than in
aggregate: a restore that wrote false everywhere would still return both
threads and every turn and satisfy every count.

Prose follows the contract the rest of the payload already uses. A
disaster-recovery file carries the stored ciphertext; a portable file
carries the text, because a portable export exists to be readable by the
person who owns it, exactly as it already decrypts medication notes,
mood notes and document summaries. A single row under a key the instance
no longer holds is named rather than thrown, so one unreadable turn
cannot cost someone the rest of the transcript.

Ids ride in both modes, as the reminders do, so an attachment resolves
and the facts, plans and reminders still owed have something real to
point at. The restore runs after the documents for the same reason, and
an attachment naming a document the file does not carry is dropped and
reported instead of failing the whole file over one link.

Verified by breaking it: forcing documentScoped to false on the way back
fails the round trip on that field by name.
This commit is contained in:
Marc-André Bombeck committed 2026-08-22 00:25:07 +02:00
1 parent 3f570b6f53
commit bcb7716ffa
11 files changed
+730 -8

No files matched your search

@@ -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([
{
@@ -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) };
},
@@ -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(() => {
@@ -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([]) },
@@ -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.
+25 -6
View File
@@ -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<Record<string, string>> = {
"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:
+440
View File
@@ -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<PrismaClient, "coachConversation">,
userId: string,
options: CoachBackupOptions = {},
): Promise<CoachBackupSection> {
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<T> = { [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<string>,
skips: RestoreSkipLog,
): Promise<CoachRestoreCleared> {
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<ArrayBuffer> {
if (message.contentEncrypted !== undefined) {
return decodeBase64(message.contentEncrypted);
}
return encryptToBytes(message.content ?? "");
}
function resolveSummaryBytes(
conversation: RestoredCoachConversation,
): Uint8Array<ArrayBuffer> | 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<ArrayBuffer> {
const decoded = Buffer.from(encoded, "base64");
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
bytes.set(decoded);
return bytes;
}
+18 -1
View File
@@ -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),
},
};
}
+11 -1
View File
@@ -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. */
+61
View File
@@ -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,
),
};
}
+144
View File
@@ -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<void> {
},
});
// 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.