feat(coach): goal / if-then plan memory + locale-keyed dashboard snapshot

Add a CoachPlan model: durable, user-scoped if-then implementation plans
the Coach proposes and the user confirms. The background memory-refresh
pass extracts plans the user agreed to and writes them as "proposed"; only
the user-facing PATCH on /api/coach/plans/[id] activates one. The free-text
cue / action / target are encrypted at rest with the shared Bytes codec;
metric, status and dates stay plain so the injection picker ranks without a
per-row decrypt. Active plans fold into the Coach snapshot's memory block
and survive the prompt-budget drop alongside facts. List, confirm and
soft-delete routes are Coach-gated and narrow the owner from the session.

Key the dashboard-snapshot query cell by locale so a locale switch reads
freshly localised tile copy on its own cell instead of serving the prior
locale's prose until the slot goes stale. The zero-arg key stays
byte-identical, so every prefix-based invalidation keeps reaching the
locale-keyed cells; the offline-banner and preloader match on the prefix.

Migration 0194 (additive CREATE TABLE). Encryption-rotation registry and
script cover the three new Bytes columns.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 01:25:35 +02:00
1 parent ee704cacab
commit dcbac63fdf
20 files changed
+2162 -14

No files matched your search

@@ -0,0 +1,34 @@
-- v1.21.3 (B1) — Coach goal / if-then implementation-plan memory.
--
-- Durable, user-scoped plans the Coach PROPOSES and the user CONFIRMS. The
-- extractor writes a row as status 'proposed'; only the user-facing PATCH
-- flips it to 'active'. Free-text fields (the if-cue, then-action, optional
-- target) are AES-256-GCM ciphertext stored as `bytea` via the shared Bytes
-- codec — the same shape as coach_facts.fact_encrypted. metric / status /
-- dates / source_conversation_id stay plain so the injection picker can rank
-- and filter without a per-row decrypt. status is an app-side closed enum
-- (proposed | active | met | abandoned), deliberately NOT a DB enum so this
-- migration stays purely additive. Soft-delete via deleted_at.
CREATE TABLE "coach_plans" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"metric" TEXT NOT NULL,
"if_cue_encrypted" BYTEA NOT NULL,
"then_action_encrypted" BYTEA NOT NULL,
"target_encrypted" BYTEA,
"status" TEXT NOT NULL DEFAULT 'proposed',
"review_date" TIMESTAMP(3),
"source_conversation_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "coach_plans_pkey" PRIMARY KEY ("id")
);
-- Injection picker scans a user's active plans newest-first; the status +
-- deleted_at columns are part of the predicate, so the composite index lets
-- the planner serve the read index-backed.
CREATE INDEX "coach_plans_user_id_status_deleted_at_idx" ON "coach_plans"("user_id", "status", "deleted_at");
ALTER TABLE "coach_plans" ADD CONSTRAINT "coach_plans_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+43
View File
@@ -570,6 +570,7 @@ model User {
integrationStatuses IntegrationStatus[]
coachConversations CoachConversation[]
coachFacts CoachFact[]
coachPlans CoachPlan[]
coachUsage CoachUsage[]
// v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings
// getworkouts / future Strava). Schema lands now so the v1.5 iOS
@@ -3891,6 +3892,48 @@ model CoachFact {
@@map("coach_facts")
}
// v1.21.3 (B1) — durable, user-scoped goal / if-then implementation plans the
// Coach proposes and the user CONFIRMS. The Coach extractor writes a plan as
// status "proposed"; only the user-facing PATCH activates it (status "active")
// — there is no silent self-edit of the user's plan. Free-text fields (the
// if-cue, the then-action, and the optional target) are encrypted at rest
// (AES-256-GCM, the same Bytes codec as CoachFact). `metric`, `status`, the
// dates and the source-conversation id stay plain so the injection picker can
// rank / filter without paying a per-row decrypt. Soft-delete via `deletedAt`
// so an abandoned plan is hidden from injection but kept for audit.
//
// `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration
// purely additive): proposed | active | met | abandoned.
model CoachPlan {
id String @id @default(cuid())
userId String @map("user_id")
// The metric this plan moves (e.g. WEIGHT, SLEEP, BLOOD_PRESSURE). Plain so
// the picker can filter / group without a decrypt.
metric String
// Free-text "if <cue>" and "then <action>" — implementation-intention prose,
// encrypted at rest. Never a number / measurement (those live in the data).
ifCueEncrypted Bytes @map("if_cue_encrypted")
thenActionEncrypted Bytes @map("then_action_encrypted")
// Optional free-text target ("70 kg by August", "lights out by 23:00"). Free
// prose like the cue/action, so encrypted at rest as well.
targetEncrypted Bytes? @map("target_encrypted")
// App-side closed enum: proposed | active | met | abandoned.
status String @default("proposed")
// Optional review checkpoint the Coach can recall ("how did this go by …").
reviewDate DateTime? @map("review_date")
// Provenance — nullable so a future manual-entry surface can leave it null.
sourceConversationId String? @map("source_conversation_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Soft-delete: hides the plan from injection but keeps the row for audit.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status, deletedAt])
@@map("coach_plans")
}
// v1.15.20 — user-authored "about me" self-description the AI surfaces
// (Coach system prompt + daily-briefing user prompt) inject as a clearly
// delimited, user-provided context block. 1:1 with User and lazily