fix(coach,rollups): QA reconcile — decrypt isolation + collapse parity

- conversation-summary: fault-isolate the per-message decrypt in the
  fold loop so one undecryptable historic turn (e.g. mid key-rotation)
  no longer permanently stalls a conversation's summary refresh.
- source collapse: unify the no-ladder tiebreak on alphabetically
  smallest source across the JS reader and the all-time rollup SQL, so a
  ranked vital whose day carries only off-ladder sources resolves to the
  same canonical source on the warm and the live-SQL read paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-04 10:38:44 +02:00
1 parent 8e2e697370
commit 9b2b137a6c
5 files changed
+29 -17

No files matched your search

+14 -4
View File
@@ -174,10 +174,20 @@ export async function refreshConversationSummary(
const priorSummary = decryptPriorSummary(conversation.summaryEncrypted);
const foldedTurns: SummaryFoldTurn[] = turns
.slice(0, foldHighWater)
.map((m) => ({
role: m.role === "assistant" ? "assistant" : "user",
content: decryptFromBytes(m.encryptedContent),
}));
.flatMap((m) => {
let content: string;
try {
content = decryptFromBytes(m.encryptedContent);
} catch {
// Skip an undecryptable message (e.g. a row mid key-rotation) instead
// of throwing and permanently stalling this conversation's summary —
// fail-closed like every other decrypt in the Coach stack.
return [];
}
return [
{ role: m.role === "assistant" ? "assistant" : "user", content },
];
});
// 5. Run the user's provider chain. Best-effort: non-ok leaves the old
// summary in place.
+1 -1
View File
@@ -14,7 +14,7 @@
* single-round-trip `GROUP BY type` shape (no six-figure row transfer).
*
* Every spliced literal is a closed-enum value (`MeasurementType` /
* `MeasurementSource`) asserted against `/^[A-Z_]+$/`, the same whitelist
* `MeasurementSource`) asserted against `/^[A-Z0-9_]+$/`, the same whitelist
* convention the rollup writer uses for its date-trunc + type-list splices.
* The user id is always passed as a bound parameter, never spliced.
*/
+1 -1
View File
@@ -415,7 +415,7 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
FROM "measurement_rollups"
WHERE "user_id" = $1
AND "granularity" = 'DAY'
ORDER BY "type", "bucket_start", (${rankUnqualified}), "count" DESC, "source"
ORDER BY "type", "bucket_start", (${rankUnqualified}), "source"
)
SELECT
"type"::text AS type,
@@ -136,19 +136,21 @@ describe("collapseRollupRowsBySource", () => {
expect(out[0].source).toBe("APPLE_HEALTH");
});
it("falls back to max-count when no ladder source is present", () => {
// IMPORT is not on the restingHeartRate ladder; keep one row (most data).
it("falls back to the alphabetically-smallest source when none is on the ladder", () => {
// Neither IMPORT nor MANUAL is on the restingHeartRate ladder; keep one
// row deterministically by source name, matching the live-SQL paths'
// `ORDER BY … source` tiebreak (live/rollup parity).
const out = collapseRollupRowsBySource(
[
row(DAY1, "IMPORT", 60, 2),
row(DAY1, "MANUAL", 58, 5),
row(DAY1, "IMPORT", 60, 2),
],
"RESTING_HEART_RATE",
null,
);
expect(out).toHaveLength(1);
expect(out[0].source).toBe("MANUAL");
expect(out[0].count).toBe(5);
// "IMPORT" < "MANUAL" alphabetically.
expect(out[0].source).toBe("IMPORT");
});
it("returns empty input untouched", () => {
+6 -6
View File
@@ -114,13 +114,13 @@ export function collapseRollupRowsBySource<T extends SourcedBucketRow>(
}
}
if (!picked) {
// No ladder match — keep one row deterministically (most data wins,
// tie-broken by source name) so the bucket neither doubles nor goes dark.
// No ladder match — keep one row deterministically by alphabetically
// smallest source name, so the bucket neither doubles nor goes dark AND
// the pick matches the live-SQL paths' `ORDER BY … source` tiebreak
// (live/rollup parity for a ranked type whose day carries only
// non-ladder sources).
picked = bucketRows.reduce((best, r) =>
r.count > best.count ||
(r.count === best.count && r.source < best.source)
? r
: best,
r.source < best.source ? r : best,
);
}
out.push(picked);