v3.12 — Identity-Anchored Goal Journey
Three optional short-text fields — an identity prompt, a past-attempt note, and a completion reflection — thread the user's own words through the goal lifecycle and back into Coach's personalization pipeline.
Summary
Before this milestone, Coach personalized its messaging from structural signals only: streaks, progress percentages, categories, recent journal entries. It had no way to know why a goal mattered to the user, whether they'd tried and failed before, or what they learned when they finally finished. This milestone bundles three previously-separate proposals — OBJ-1065 (identity prompt at goal creation) and roadmap items #23 (Reflection on Completion) and #27 (Past Goal Attempt Context) — into one three-phase delivery, because all three share the same shape: an optional short-text field, threaded into the same Coach prompt surfaces, under the same fidelity contract.
All three phases shipped: identity_prompt (Phase 113), past_attempt_context (Phase 114), and completion_reflection (Phase 115). Each is a single nullable Goal column, captured through an existing form (GoalForm.vue for the first two, a new GoalCompletionSheet.vue for the third), and folded into Coach's existing prompt templates — no new prompt module was created. Every field is opt-in, free, never required, and never gates a feature; instrumentation records only whether each field is present, never its text.
The milestone also surfaced (and closed) the same code-review finding three times in a row: an updateGoal field that supports explicit-clear needs both a resolver allowlist and an interaction-level guard, or a client can never clear a saved value. See Tech Debt and the Nullable Field: Clear vs. Omit Gotcha.
Goal
Ship three optional Goal fields (
identity_prompt,past_attempt_context,completion_reflection) end-to-end — migration → model → interaction → GraphQL → frontend → Coach prompt threading → tests — reusing the existingAi::CoachServicepersonalization pipeline with no new prompt module, no required-field promotion, and no engagement nudges on empty fields.
(Quoted from the milestone tracking issue description — the original .planning/milestones/v3.12-identity-anchored-goal-journey-ROADMAP.md was never committed to the repo; see Known gaps.)
Scope — What Shipped
identity_prompt(Phase 113, PR #1243) — free-text identity framing at goal creation, max 240 chars.GoalForm.vue: "Who you're becoming" field, always visible.past_attempt_context(Phase 114, PR #1260) — free-text prior-attempt context at goal creation, max 200 chars.GoalForm.vue: "Tried this before?" disclosure, collapsed by default, auto-expands if a saved value exists.completion_reflection(Phase 115, PR #1289) — free-text reflection captured at goal completion, max 200 chars. NewGoalCompletionSheet.vue: "What did you take away?", with Save/Skip actions; always sends the field explicitly (trimmed text ornull), never omits it.- Three additive, reversible migrations, no backfill:
20260703120000_add_identity_prompt_to_goals.rb,20260705000000_add_past_attempt_context_to_goals.rb,20260706000000_add_completion_reflection_to_goals.rb. Pre-v3.12 goals readnilfor all three. - Coach prompt threading — all three fields fold into existing prompt templates (
Ai::Prompts::Coaching,Ai::Prompts::Synopsis,Ai::Prompts::CheckIns,Ai::Prompts::WelcomeBack); each template'sVERSIONbumped. No new prompt module.identity_prompt/past_attempt_context:CoachService#get_advice(),#generate_synopsis(),#generate_check_in_prompt().completion_reflection:CoachService#generate_welcome_back()'s streak-lapse pathway only, sourced from the user's most recently completed goal (not the lapsed goal itself) viaWelcomeBackOfferResolver#last_completed_goal_reflection.
- Prompt-injection fence — the single-turn
get_advice()path andAi::Prompts::Synopsiswrap user-authored text in an explicit<user_input>...</user_input>fence; the multi-turn chat path is intentionally left unfenced per-turn (already role-separated +MULTI_TURN_INJECTION_GUARD). - Presence-only instrumentation — every LLM call records
identity_prompt_present/past_attempt_context_present/completion_reflection_present(booleans) on call metadata; raw text is never logged. CLEARABLE_NULLABLE_FIELDSallowlist — all three fields support explicit-null-clears vs. omit-preserves onupdateGoal, closing the same gap three separate times across the three phases (see Tech Debt).- Full-stack docs — docs/features/goals.md § Coach Personalization, docs/features/coach-surfaces.md (added the previously-undocumented
generate_synopsis()/Ai::Prompts::Synopsissurface), docs/architecture/data-models.md, Nullable Field: Clear vs. Omit Gotcha.
Phases
| Phase | Name | Status | PR | Highlights |
|---|---|---|---|---|
| 113 | Identity Prompt at Goal Creation | Shipped | #1243 | identity_prompt end-to-end; established the <user_input> fence pattern on the single-turn get_advice()/Synopsis path |
| 114 | Past Goal Attempt Context at Goal Creation | Shipped | #1260 | past_attempt_context, reusing the Phase 113 delivery spine |
| 115 | Reflection on Completion | Shipped | #1289 | completion_reflection at goal completion; threaded into generate_welcome_back's streak-lapse pathway; scope collapsed from "completion/archive" to completion-only (no archive state exists on Goal) |
Key Decisions
- Bundle three proposals into one milestone —
identity_prompt,past_attempt_context, andcompletion_reflectionshare the same shape (optional short text, same Coach threading, same fidelity contract), so they shipped as three phases of one milestone rather than three independent efforts. - No new prompt module — all three fields reuse
Ai::Prompts::Coaching/CheckIns/Synopsis/WelcomeBack, bumping eachVERSIONrather than introducing a new template. - Single
completion_reflectioncolumn, not a separate model — consistent withidentity_prompt/past_attempt_context; a dedicatedGoalReflectionmodel was considered and rejected as unnecessary for a single optional field. - Completion scope collapsed to completion-only — the original Phase 115 breakdown asked whether an "archive" touch point should also capture a reflection.
Goalhas no archive state (onlycompleted:boolean+completed_at), so this collapsed to completion-only, resolved against the actual code before Phase 115 was dispatched rather than left as an open question for the planner. - Reflection capture extends
GoalTracking::UpdateGoal; no newCompleteGoalinteraction — completion already flows through the existing interaction plus the model'sgoal_completed?hook. completion_reflectionis explicit-null-on-skip, not omitted —GoalCompletionSheet.vuealways sends thecompletionReflectionkey on completion (trimmed text ornull), so re-completing a goal without new text explicitly clears a stale reflection from a prior completion, rather than silently leaving it. This supersedes the original UI-SPEC wording ("the mutation carries nocompletionReflectionarg on skip").- Prompt-fence scoping: single-turn only — the
<user_input>fence around identity-bearing context applies to the single-turnget_advice()/Synopsispath only. The multi-turn chat path is left unfenced per-turn by design (already role-separated via chat roles plusMULTI_TURN_INJECTION_GUARD); re-fencing every turn would be redundant, not safer. GoalMotivationSnapshotCard.vuegating fix rides along — Phase 113 also fixed a Roy MAJOR finding where the edit field wasn't gated on an activegoalId, causing a false-success save with no goal to attach to. Not scoped to identity/past-attempt/reflection directly, but shipped in the same phase.
Requirements Coverage
No formal REQUIREMENTS.md exists for this milestone (see Known gaps). Informally, 3 / 3 fields shipped end-to-end (migration → model → interaction → GraphQL → frontend → Coach threading → tests), matching the milestone's own acceptance bar.
| Field | Status |
|---|---|
identity_prompt | ✅ Shipped (Phase 113) |
past_attempt_context | ✅ Shipped (Phase 114) |
completion_reflection | ✅ Shipped (Phase 115) |
Outcomes
- Users can optionally tell Coach who they're becoming and what tripped them up last time, right when they create a goal — and Coach's advice, daily synopsis, and check-in prompts can reference it.
- Users can optionally reflect on what they took away from a finished goal — and if they lapse on a different goal later, Coach's welcome-back message can acknowledge that reflection.
- None of the three fields are required, gate any feature, or trigger an engagement nudge when left blank — consistent with the anti-social-app philosophy (opt-in, no dark patterns).
- The
<user_input>fencing pattern established here is the reusable template for any future free-text field threaded into a single-turn Coach prompt.
Tech Debt
- (Phases 113–115) The same "GraphQL nullable field can't distinguish omit from explicit-clear" gap was found and fixed three separate times — once per field — because each phase's RSpec coverage only exercised the "omitted" path, not "explicit clear." Phase 115's fix folded all three fields into one
CLEARABLE_NULLABLE_FIELDSallowlist and onegiven?-guarded loop; see Nullable Field: Clear vs. Omit Gotcha for the pattern to reuse (allowlist + guard + mutation-level spec) on any future clearable field, rather than repeating this a fourth time. - (Phase 113)
create-goal-with-identity.cy.tsCypress spec exists but was never wired into the CIcypress-e2e --specfilter. Carried forward as an open follow-up (Tess). - (Phase 114) A post-ship staging smoke failure was traced to an unrelated broken Playwright regression guard (from OBJ-1140, PR #1264) rather than Phase 114 code; fixed in test-only PR #1266. Surfaced a systemic deploy-concurrency bug in
.github/workflows/staging.yml(two deploys racing on completion order, not just overlap) — filed as OBJ-1144 and fixed separately (see Staging deploy race inCLAUDE.md's gotchas). - (Phase 115) A pre-existing mobile-safari/WebKit-only Playwright smoke failure (unrelated to this milestone, recurring across ≥5 prior master pushes) was observed at merge time; filed as OBJ-1167 (backlog), not a rollback trigger.
Known gaps
- The kickoff
ROADMAP.mdwas lost, then reconstructed at close — the original was authored in an ephemeral workspace and never committed to any branch (flagged from Phase 113 onward), so the phase-completion record that would normally live there was captured across three milestone-update comments on the tracking issue (OBJ-1080) instead — those comments are the primary source for this page's Phases/Key Decisions/Tech Debt sections. Both the reconstructedROADMAP.mdand the formalMILESTONE-AUDIT.mdnow live under.planning/milestones/, having landed with this narrative page in the v3.12 milestone-close PR. NoREQUIREMENTS.mdexists — consistent with other bundled milestones, which don't carry one. - No dedicated
v3.12git tag yet — all three phases are merged tomasterand live on staging; production release is gated to the weekly Wednesday release train and hadn't cut a tag as of this page's last update.
Related Artifacts
- Milestone tracking issue: OBJ-1080 — coordination anchor; primary source for phase completion facts, deviations, and key decisions on this page
- Phase issues: OBJ-1081 (Phase 113) · OBJ-1082 (Phase 114) · OBJ-1083 (Phase 115)
- PRs: #1243 · #1260 · #1289
- Source proposal: OBJ-1065 (Penny-proposed, Josh-greenlit 2026-07-03)
- Feature docs: docs/features/goals.md § Coach Personalization, docs/features/coach-surfaces.md, docs/architecture/data-models.md § Goal, Nullable Field: Clear vs. Omit Gotcha
- Engineering log: CHANGELOG.md § Unreleased, ionic_frontend/CHANGELOG.md § Unreleased
Related Commits
e26a46605— [Codi] feat(goals): add optional identity prompt at goal creation (Phase 113) (#1243)756782d63— [Codi] feat(goals): add optional past-attempt context at goal creation (Phase 114) (#1260)7110d5a6a— [Codi] feat(goals): add optional completion reflection on goal completion (Phase 115) (#1289)993621717— fix(smoke): journey.spec.ts hasError guard fails on every passing run (OBJ-1082/OBJ-1140) (#1266)
Last updated: 2026-07-07