diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md
index 4cfe63778..94f0c047d 100644
--- a/docs/ops/deploy.md
+++ b/docs/ops/deploy.md
@@ -78,6 +78,12 @@ roll back:
dev --create-only --name vX_Y_Z_rollback_
`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
+4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account
+ that saved the Coach lookback "Last year" reads its whole Coach
+ preference blob as defaults on the older version, metric exclusions
+ included, until the setting is saved again. Ask affected accounts to
+ pick another lookback before the rollback, or re-save their exclusions
+ after it.
## Catalog templates
diff --git a/src/lib/validations/__tests__/coach-prefs.test.ts b/src/lib/validations/__tests__/coach-prefs.test.ts
index cb3f8c6bc..801a347f4 100644
--- a/src/lib/validations/__tests__/coach-prefs.test.ts
+++ b/src/lib/validations/__tests__/coach-prefs.test.ts
@@ -154,13 +154,30 @@ describe("parseCoachPrefs", () => {
expect(out.dataClusters).toEqual([]);
});
- it("falls back to defaults when dataClusters carries an unknown cluster", () => {
- // Shape drift — an unknown cluster string fails the enum, the whole
- // parse fails, and parseCoachPrefs returns the legacy defaults
- // (dataClusters undefined → legacy cluster expansion).
+ it("keeps the known clusters when dataClusters carries an unknown one", () => {
+ // Shape drift: a cluster a newer version added. Falling back to the
+ // legacy default set would widen what the Coach reads past what the
+ // person chose; dropping only the unknown entry narrows it.
const out = parseCoachPrefs({ dataClusters: ["cardio", "astrology"] });
- expect(out).toEqual(DEFAULT_COACH_PREFS);
- expect(out.dataClusters).toBeUndefined();
+ expect(out.dataClusters).toEqual(["cardio"]);
+ });
+
+ it("lets an invalid field fall back alone and keeps the exclusions", () => {
+ // A window value this version does not know (written by a newer one,
+ // read after a rollback) must not take the metric exclusions with it.
+ const out = parseCoachPrefs({
+ tone: "neutral",
+ defaultWindow: "lastDecade",
+ excludeMetrics: ["mood", "weight"],
+ });
+ expect(out.defaultWindow).toBe("allTime");
+ expect(out.excludeMetrics).toEqual(["mood", "weight"]);
+ expect(out.tone).toBe("neutral");
+ });
+
+ it("keeps the known exclusions when the list carries an unknown metric", () => {
+ const out = parseCoachPrefs({ excludeMetrics: ["mood", "aura"] });
+ expect(out.excludeMetrics).toEqual(["mood"]);
});
it("DEFAULT_COACH_CLUSTERS preserves the legacy five domains' clusters", () => {
diff --git a/src/lib/validations/coach-prefs.ts b/src/lib/validations/coach-prefs.ts
index c74132f41..b1ea83a90 100644
--- a/src/lib/validations/coach-prefs.ts
+++ b/src/lib/validations/coach-prefs.ts
@@ -216,13 +216,44 @@ export const DEFAULT_COACH_PREFS: CoachPrefs = {
/**
* Parse a row's `coachPrefsJson` Json blob into a typed `CoachPrefs`,
- * falling back to defaults when the row is null OR the persisted shape
- * has drifted (a forward-compat field rename, an admin-side hand-edit,
- * etc.). Keeps the call sites at `src/lib/ai/coach/snapshot.ts` and
- * `src/lib/ai/coach/system-prompt.ts` free of the null/parse plumbing.
+ * falling back to defaults when the row is null. Keeps the call sites at
+ * `src/lib/ai/coach/snapshot.ts` and `src/lib/ai/coach/system-prompt.ts`
+ * free of the null/parse plumbing.
+ *
+ * A drifted shape (a value a newer version wrote and this one reads after a
+ * rollback, a hand-edit) is salvaged field by field: an invalid field falls
+ * back to its own default and every valid one stays. Parsing the blob as a
+ * whole used to drop everything on one bad field, metric exclusions
+ * included, which turned an unknown lookback value into a privacy loss. The
+ * two lists keep their known entries, so an unknown metric or cluster
+ * narrows what the Coach reads rather than widening it.
*/
export function parseCoachPrefs(raw: unknown): CoachPrefs {
if (raw == null) return DEFAULT_COACH_PREFS;
- const parsed = coachPrefsSchema.safeParse(raw);
- return parsed.success ? parsed.data : DEFAULT_COACH_PREFS;
+ const whole = coachPrefsSchema.safeParse(raw);
+ if (whole.success) return whole.data;
+ if (typeof raw !== "object" || Array.isArray(raw)) {
+ return DEFAULT_COACH_PREFS;
+ }
+ const source = raw as Record;
+ const salvaged: Record = {};
+ for (const [key, field] of Object.entries(coachPrefsSchema.shape)) {
+ if (!(key in source)) continue;
+ const value = knownItemsOnly(key, source[key]);
+ if (field.safeParse(value).success) salvaged[key] = value;
+ }
+ if (Object.keys(salvaged).length === 0) return DEFAULT_COACH_PREFS;
+ return coachPrefsSchema.parse(salvaged);
+}
+
+/** The entries of a list preference this version knows; other values as is. */
+function knownItemsOnly(key: string, value: unknown): unknown {
+ if (!Array.isArray(value)) return value;
+ const item =
+ key === "excludeMetrics"
+ ? coachExcludeMetricEnum
+ : key === "dataClusters"
+ ? coachDataClusterEnum
+ : null;
+ return item ? value.filter((entry) => item.safeParse(entry).success) : value;
}