mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix(coach): feedback references coach_messages, drops plaintext column
Reviewer note: W5 H7 wrote Coach assistant prose plaintext into recommendation_feedback.recommendation_text. Same prose lives encrypted in coach_messages — backups captured the leak. Migration 0042_coach_feedback_message_ref adds nullable coachMessageId (FK + cascade) and drops recommendation_text. Aggregator joins + decrypts on read; admin view reads through the same path. Fixes: Sr-H3 (Wave-6 v1.4.23 senior-dev review) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
deadc73863
commit
650f150353
5 files changed
+182
-41
No files matched your search
@@ -0,0 +1,86 @@
|
||||
-- v1.4.23 H3 reconcile (senior-dev review): the v1.4.23 H7 Coach
|
||||
-- feedback route decrypted assistant prose and wrote it as plaintext
|
||||
-- into `recommendation_feedback.recommendation_text`. The same prose
|
||||
-- already lives encrypted-at-rest in `coach_messages.encrypted_content`
|
||||
-- (AES-256-GCM via `@/lib/crypto`), so backups captured the leak and
|
||||
-- the Coach surface's encryption-at-rest invariant was silently
|
||||
-- broken for every rated message.
|
||||
--
|
||||
-- Fix: drop the prose-snapshot dedup key for Coach rows in favour of a
|
||||
-- foreign-key reference to the source message. The aggregator never
|
||||
-- reads the prose anyway (it slices on structured columns only) and
|
||||
-- the admin view never surfaces user prose, so the only consumer is
|
||||
-- the unique-index dedup — which a per-message FK satisfies cleanly.
|
||||
--
|
||||
-- For the existing recommendation surface (`target_type = 'recommendation'`)
|
||||
-- the prose is already plaintext on the wire (recommendations are not
|
||||
-- encrypted) and the dedup key still relies on `recommendation_text`,
|
||||
-- so we keep that column nullable instead of dropping it. The
|
||||
-- recommendation route continues to write the snapshot; the Coach
|
||||
-- route stops writing the column entirely.
|
||||
--
|
||||
-- Idempotent: every statement uses `IF [NOT] EXISTS` so a re-run after
|
||||
-- a partial failure (or in environments where the table predates this
|
||||
-- migration in a different state) is safe.
|
||||
|
||||
-- 1. Add the FK column. Nullable because the recommendation surface
|
||||
-- rows do not have a coach message — they reference an unencrypted
|
||||
-- insight recommendation by id only.
|
||||
ALTER TABLE "recommendation_feedback"
|
||||
ADD COLUMN IF NOT EXISTS "coach_message_id" TEXT;
|
||||
|
||||
-- 2. FK to coach_messages with cascade delete: removing a coach
|
||||
-- message drops the rating row too (the rating is meaningless
|
||||
-- without the source message). A separate constraint name keeps the
|
||||
-- statement re-runnable via a guard query.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'recommendation_feedback_coach_message_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "recommendation_feedback"
|
||||
ADD CONSTRAINT "recommendation_feedback_coach_message_id_fkey"
|
||||
FOREIGN KEY ("coach_message_id") REFERENCES "coach_messages"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "recommendation_feedback_coach_message_id_idx"
|
||||
ON "recommendation_feedback" ("coach_message_id");
|
||||
|
||||
-- 3. Backfill: for every coach-target row whose recommendation_id
|
||||
-- happens to match a coach_messages.id (every v1.4.23 row, by
|
||||
-- construction — the H7 route stamped the message id into
|
||||
-- recommendation_id), copy the id into the new column.
|
||||
UPDATE "recommendation_feedback" rf
|
||||
SET "coach_message_id" = rf."recommendation_id"
|
||||
WHERE rf."target_type" = 'coach'
|
||||
AND rf."coach_message_id" IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "coach_messages" cm WHERE cm."id" = rf."recommendation_id"
|
||||
);
|
||||
|
||||
-- 4. Null out the leaked plaintext for any coach rows that landed
|
||||
-- pre-migration. The aggregator does not read this column, so the
|
||||
-- blank value has no functional consequence — but it removes the
|
||||
-- plaintext from on-disk pages on the next VACUUM.
|
||||
UPDATE "recommendation_feedback"
|
||||
SET "recommendation_text" = NULL
|
||||
WHERE "target_type" = 'coach'
|
||||
AND "recommendation_text" IS NOT NULL;
|
||||
|
||||
-- 5. Make recommendation_text nullable. The recommendation surface
|
||||
-- still populates it (and the unique key
|
||||
-- `rec_feedback_user_rec_text_key` still defends against duplicate
|
||||
-- ratings of the same recommendation snapshot); the coach surface
|
||||
-- leaves it NULL forever.
|
||||
ALTER TABLE "recommendation_feedback"
|
||||
ALTER COLUMN "recommendation_text" DROP NOT NULL;
|
||||
|
||||
-- 6. Coach-only dedup key: one rating per (user, message). Partial so
|
||||
-- it does not interfere with the recommendation flow's existing
|
||||
-- `(user_id, recommendation_id, recommendation_text)` unique key.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "rec_feedback_user_coach_message_key"
|
||||
ON "recommendation_feedback" ("user_id", "coach_message_id")
|
||||
WHERE "target_type" = 'coach' AND "coach_message_id" IS NOT NULL;
|
||||
+20
-2
@@ -908,7 +908,13 @@ model RecommendationFeedback {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
recommendationId String @map("recommendation_id")
|
||||
recommendationText String @map("recommendation_text")
|
||||
// Nullable since v1.4.23 reconcile (Sr-H3): the recommendation
|
||||
// surface still snapshots the prose here (recommendations are
|
||||
// unencrypted on the wire, so plaintext is the source-of-truth);
|
||||
// the Coach surface leaves it NULL and references the message via
|
||||
// `coachMessageId` so the assistant prose stays encrypted-at-rest
|
||||
// in `coach_messages.encryptedContent`.
|
||||
recommendationText String? @map("recommendation_text")
|
||||
recommendationSeverity String @map("recommendation_severity")
|
||||
metricSourceType String @map("metric_source_type")
|
||||
metricSourceTimeRange String @map("metric_source_time_range")
|
||||
@@ -929,12 +935,20 @@ model RecommendationFeedback {
|
||||
// pasted essay.
|
||||
reason String? @map("reason")
|
||||
|
||||
// v1.4.23 reconcile (Sr-H3) — Coach surface references the source
|
||||
// message instead of snapshotting its prose into
|
||||
// `recommendationText`. FK + cascade so a deleted message drops the
|
||||
// rating row too. Always NULL for `targetType="recommendation"`.
|
||||
coachMessageId String? @map("coach_message_id")
|
||||
coachMessage CoachMessage? @relation(fields: [coachMessageId], references: [id], onDelete: Cascade)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, recommendationId, recommendationText], map: "rec_feedback_user_rec_text_key")
|
||||
@@index([userId, createdAt])
|
||||
@@index([recommendationSeverity, helpful, createdAt])
|
||||
@@index([targetType, createdAt])
|
||||
@@index([coachMessageId])
|
||||
@@map("recommendation_feedback")
|
||||
}
|
||||
|
||||
@@ -992,7 +1006,11 @@ model CoachMessage {
|
||||
promptVersion String? @map("prompt_version")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
// v1.4.23 reconcile (Sr-H3) — back-relation for the FK that lets
|
||||
// Coach feedback rows reference their source message instead of
|
||||
// snapshotting plaintext prose into `recommendation_feedback`.
|
||||
feedback RecommendationFeedback[]
|
||||
|
||||
@@index([conversationId, createdAt])
|
||||
@@map("coach_messages")
|
||||
|
||||
Reference in new issue
Block a user