feat(coach): store a turn's result tables encrypted beside the message

A new nullable column, coach_messages.results_encrypted, holds the tables
of values a turn read as AES-GCM ciphertext; only their metadata rides the
plaintext provenance. readMessageResults serves them to the owner, each
table whole or withheld with a reason. The provenance restore learns the
steps, method, table metadata, chips, clarification and the forced-answer
marker, entry by entry against the wire schema.

The column is registered for key rotation, carried through both backup
formats (ciphertext in a disaster-recovery file, readable JSON in a
portable one) and goes with its row on conversation delete and wipe.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 10:36:27 +02:00
1 parent cde7af3cf8
commit a8b3e5d7f4
9 files changed
+714 -1

No files matched your search

+40
View File
@@ -79,6 +79,17 @@ export interface CoachMessageBackupEntry {
promptVersion: string | null;
tokensUsed: number | null;
model: string | null;
/**
* v1.39.4 — the tables of values the turn read, as ciphertext in base64.
* Present on a disaster-recovery payload only; null on a turn without one.
*/
resultsEncrypted?: string | null;
/**
* v1.39.4 — the same tables as readable JSON (a `CoachResultTable[]`
* array), on a portable payload only; null on a turn without one. The
* unreadable-row marker stands in for tables that did not decrypt.
*/
resultsJson?: string | null;
createdAt: string;
}
@@ -155,6 +166,7 @@ const COACH_CONVERSATION_BACKUP_SELECT = {
promptVersion: true,
tokensUsed: true,
model: true,
resultsEncrypted: true,
createdAt: true,
},
},
@@ -248,6 +260,14 @@ export async function buildCoachBackupSection(
promptVersion: message.promptVersion,
tokensUsed: message.tokensUsed,
model: message.model,
// v1.39.4 — the tables follow the same contract as the prose.
...(disasterRecovery
? {
resultsEncrypted: message.resultsEncrypted
? Buffer.from(message.resultsEncrypted).toString("base64")
: null,
}
: { resultsJson: decryptTurnSoft(message.resultsEncrypted) }),
createdAt: message.createdAt.toISOString(),
})),
attachments: row.attachments.map((attachment) => ({
@@ -318,6 +338,8 @@ export type RestoredCoachMessage = Pick<
| "promptVersion"
| "tokensUsed"
| "model"
| "resultsEncrypted"
| "resultsJson"
>
>;
@@ -401,6 +423,7 @@ export async function restoreCoachData(
promptVersion: message.promptVersion ?? null,
tokensUsed: message.tokensUsed ?? null,
model: message.model ?? null,
resultsEncrypted: resolveResultsBytes(message),
createdAt: new Date(message.createdAt),
})),
},
@@ -457,6 +480,23 @@ function resolveTurnBytes(
return encryptToBytes(message.content ?? "");
}
/**
* v1.39.4 — a turn's stored tables, from either end of the contract. A
* disaster-recovery file carries the ciphertext; a portable file carries the
* JSON, sealed here under the target instance's key. Neither end writes a
* zero-byte ciphertext for a turn without tables.
*/
function resolveResultsBytes(
message: RestoredCoachMessage,
): Uint8Array<ArrayBuffer> | null {
if (message.resultsEncrypted !== undefined) {
return message.resultsEncrypted === null
? null
: decodeBase64(message.resultsEncrypted);
}
return message.resultsJson ? encryptToBytes(message.resultsJson) : null;
}
function resolveTitleBytes(
conversation: RestoredCoachConversation,
): Uint8Array<ArrayBuffer> {