Merge pull request #813 from MBombeck/feat/backup-coach-transcript

Carry the Coach transcript through backup and restore
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-22 00:41:21 +02:00
commit ad8325f18d
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.