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

@@ -0,0 +1,14 @@
-- The tables of values a Coach turn read, stored with the assistant message
-- that showed them: a JSON array of result tables, AES-256-GCM encrypted in
-- the same Bytes codec as the message body. The values are health data, so
-- they never sit in the plaintext `metric_source_json`, which carries only the
-- tables' metadata (ref, source, shape, row count).
--
-- A stored table keeps the values the answer was given with; a later edit or
-- sync does not rewrite what an earlier answer showed. The column goes with
-- its row, so deleting a conversation or wiping the account removes it by
-- the existing cascade.
--
-- Additive only: a nullable column, no table rewrite, no data change.
ALTER TABLE "coach_messages" ADD COLUMN "results_encrypted" BYTEA;
+6
View File
@@ -6221,6 +6221,12 @@ model CoachMessage {
// nullable: user turns, refusals, and pre-feature rows carry NULL.
tokensUsed Int? @map("tokens_used")
model String?
// v1.39.4 — the tables of values this turn read (JSON `CoachResultTable[]`,
// at most 128 KB), AES-256-GCM encrypted like `encryptedContent`. Health
// values, so they never ride the plaintext `metricSourceJson`, which holds
// only the tables' metadata. Read through `readMessageResults` by the
// owner-narrowed results endpoint; NULL on every turn without a table.
resultsEncrypted Bytes? @map("results_encrypted")
createdAt DateTime @default(now()) @map("created_at")
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)