feat(coach): add the CoachReminder episodic-memory model

Introduce a third Coach memory type alongside facts (durable traits) and
plans (standing if-then habits): a time-anchored "bring this back later"
reminder with a due moment and a done state. The free-text note is
AES-256-GCM ciphertext via the shared Bytes codec; metric, status, the
trigger fields and the dates stay plain so the sweep and the injection
picker rank and filter without a per-row decrypt.

Migration 0196 is purely additive (status and trigger_kind are app-side
closed enums, not DB enums). Register the new note_encrypted column in the
encrypted-columns registry and the rotation script so a key rotation can
never silently skip it.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:37:50 +02:00
1 parent 7d3c528157
commit 9b61769a32
4 files changed
+105

No files matched your search

@@ -0,0 +1,42 @@
-- v1.22 (B2/B6) — Coach episodic reminder memory.
--
-- A time-anchored "bring this back later" intent the Coach captured from a
-- conversation. The missing memory type between coach_facts (durable traits)
-- and coach_plans (standing if-then habits): an episode with a due moment and
-- a done state. The free-text note is AES-256-GCM ciphertext stored as `bytea`
-- via the shared Bytes codec — the same shape as coach_plans.if_cue_encrypted.
-- metric / status / trigger fields / dates / source stay plain so the daily
-- sweep and the injection picker can rank and filter without a per-row decrypt.
-- status (proposed | active | due | surfaced | done | dismissed) and
-- trigger_kind (date | context) are app-side closed enums, deliberately NOT DB
-- enums so this migration stays purely additive. Soft-delete via deleted_at.
CREATE TABLE "coach_reminders" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"note_encrypted" BYTEA NOT NULL,
"metric" TEXT,
"related_plan_id" TEXT,
"trigger_kind" TEXT NOT NULL DEFAULT 'date',
"due_at" TIMESTAMP(3),
"context_cue" TEXT,
"status" TEXT NOT NULL DEFAULT 'active',
"source" TEXT NOT NULL,
"source_conversation_id" TEXT,
"last_surfaced_at" TIMESTAMP(3),
"surface_count" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "coach_reminders_pkey" PRIMARY KEY ("id")
);
-- The daily sweep scans a user's active reminders whose due_at has passed; the
-- status + deleted_at columns are part of that predicate, so the composite
-- index lets the planner serve the sweep + the injection read index-backed.
CREATE INDEX "coach_reminders_user_id_status_due_at_deleted_at_idx" ON "coach_reminders"("user_id", "status", "due_at", "deleted_at");
-- The context-cue evaluation pass filters by trigger_kind + status per user.
CREATE INDEX "coach_reminders_user_id_trigger_kind_status_idx" ON "coach_reminders"("user_id", "trigger_kind", "status");
ALTER TABLE "coach_reminders" ADD CONSTRAINT "coach_reminders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+51
View File
@@ -571,6 +571,7 @@ model User {
coachConversations CoachConversation[]
coachFacts CoachFact[]
coachPlans CoachPlan[]
coachReminders CoachReminder[]
coachUsage CoachUsage[]
// v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings
// getworkouts / future Strava). Schema lands now so the v1.5 iOS
@@ -3938,6 +3939,56 @@ model CoachPlan {
@@map("coach_plans")
}
// v1.22 (B2/B6) — durable, user-scoped EPISODIC reminder memory: a
// time-anchored "bring this back later" intent the Coach captured from a
// conversation ("remind me about my sleep next week"). This is the middle
// memory type that neither CoachFact (durable traits) nor CoachPlan (standing
// if-then habits) covers — an episode with a due moment and a done state.
//
// The free-text note is the user's own framing, encrypted at rest (AES-256-GCM
// via the shared Bytes codec, exactly like CoachPlan's cue/action). `metric`,
// `status`, the trigger fields and the dates stay plain so the daily sweep and
// the injection picker can rank / filter without paying a per-row decrypt.
//
// `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration
// purely additive): proposed | active | due | surfaced | done | dismissed.
// `triggerKind` is likewise app-side closed: date | context. Soft-delete via
// `deletedAt` so a "forget this" action is reversible / auditable.
model CoachReminder {
id String @id @default(cuid())
userId String @map("user_id")
// What to bring back — the user's own framing, encrypted at rest.
noteEncrypted Bytes @map("note_encrypted")
// Optional link to the metric / plan it concerns; plain for filtering.
metric String?
relatedPlanId String? @map("related_plan_id")
// Trigger model — app-side closed enum (date | context). For `date`, `dueAt`
// carries the fire moment; for `context`, `contextCue` names a closed cue.
triggerKind String @default("date") @map("trigger_kind")
dueAt DateTime? @map("due_at")
contextCue String? @map("context_cue")
// Lifecycle: proposed | active | due | surfaced | done | dismissed.
status String @default("active")
// Provenance — how it was captured (sentinel | extractor | manual | action).
source String
sourceConversationId String? @map("source_conversation_id")
lastSurfacedAt DateTime? @map("last_surfaced_at")
// How many times the sweep has surfaced this without the user acting; capped
// before auto-dismiss so an ignored reminder cannot nag indefinitely.
surfaceCount Int @default(0) @map("surface_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// The daily sweep scans active reminders whose dueAt has passed; the status +
// deleted_at columns are part of the predicate so the read stays index-backed.
@@index([userId, status, dueAt, deletedAt])
@@index([userId, triggerKind, status])
@@map("coach_reminders")
}
// 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
+10
View File
@@ -328,6 +328,16 @@ async function main() {
results.push(await rotateBytesColumn("CoachPlan", field, prisma.coachPlan));
}
// ───── CoachReminder (Bytes column) ─────
// "noteEncrypted"
results.push(
await rotateBytesColumn(
"CoachReminder",
"noteEncrypted",
prisma.coachReminder,
),
);
// ───── UserHealthProfile (Bytes columns) ─────
// "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted"
// "coachFocusEncrypted" "pendingQuestionsEncrypted"
+2
View File
@@ -112,6 +112,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "CoachPlan", field: "ifCueEncrypted", kind: "bytes" },
{ model: "CoachPlan", field: "thenActionEncrypted", kind: "bytes" },
{ model: "CoachPlan", field: "targetEncrypted", kind: "bytes" },
// v1.22 (B2/B6) — Coach episodic reminder free-text note.
{ model: "CoachReminder", field: "noteEncrypted", kind: "bytes" },
// ───── User health profile (Bytes columns) ─────
{ model: "UserHealthProfile", field: "aboutMeEncrypted", kind: "bytes" },