fix(insights): skip tombstoned rows in the correlation channel and the cache gate

A deleted reading is kept with a `deletedAt` stamp, and the schema says in
as many words that normal reads filter it. Three did not.

The custom-metric correlation channel filtered the metric's own tombstone
but not its entries', so a reading the person deleted kept shaping the
correlation they were shown. The status input hash — which decides whether a
cached AI assessment is still current — filtered its measurement arm but not
its mood aggregate and not its custom-metric count or newest-entry probe. A
deletion left that fingerprint unchanged, so the gate re-dated yesterday's
text and served it as today's over data that was gone.

Pinned in the existing soft-delete guard, whose matchers were checked by
removing each filter again.
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 02:08:09 +02:00
1 parent 4d98a273b1
commit 156f7e1523
3 files changed
+52 -3

No files matched your search

@@ -50,4 +50,44 @@ describe("prompt reads exclude tombstoned rows", () => {
expect(intakeRead).not.toBeNull();
expect(intakeRead![0]).toContain("deletedAt: null");
});
// A soft delete has to be able to reach the CACHE GATE as well as the
// prompt. `buildStatusInputHash` fingerprints "how much data is there and
// how new is it"; a tombstoned row that still counts leaves the fingerprint
// unchanged, the gate re-dates yesterday's assessment, and text reasoning
// over a deleted reading is served as today's. The measurement arm of that
// same query already filters, which is what makes the two that did not so
// easy to miss.
it("the status input hash filters its mood aggregate", () => {
const src = read("../status-cache.ts");
const moodRead = src.match(
/moodEntry\s*\n?\s*\.aggregate\(\{[\s\S]{0,800}?where:\s*\{[^}]*\}/,
);
expect(moodRead).not.toBeNull();
expect(moodRead![0]).toContain("deletedAt: null");
});
it("the status input hash filters its custom-metric entry reads", () => {
const src = read("../status-cache.ts");
// Both the count and the newest-entry probe: either one left unfiltered
// keeps the fingerprint still after a delete.
expect(src).toMatch(
/_count:\s*\{\s*select:\s*\{\s*entries:\s*\{[\s\S]{0,200}?deletedAt:\s*null/,
);
const newest = src.match(
/entries:\s*\{\s*where:\s*\{[^}]*\}[\s\S]{0,200}?take:\s*1/,
);
expect(newest).not.toBeNull();
expect(newest![0]).toContain("deletedAt: null");
});
it("the custom-metric correlation channel filters its entry read", () => {
// The parent metric's own tombstone is filtered; its entries' were not,
// so a reading the person deleted still shaped the correlation cards.
const src = read("../correlation-channel-series.ts");
const entryRead = src.match(
/entries:\s*\{[\s\S]{0,400}?where:\s*\{[^}]*\}/,
);
expect(entryRead).not.toBeNull();
expect(entryRead![0]).toContain("deletedAt: null");
});
});
@@ -288,7 +288,10 @@ export async function fetchCustomMetricBehaviourSeries(
name: true,
unit: true,
entries: {
where: { measuredAt: { gte: since } },
// The parent metric's tombstone is filtered above; the entries' own
// one has to be too, or a reading the person deleted keeps shaping
// the correlation they are shown.
where: { deletedAt: null, measuredAt: { gte: since } },
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
take: 2000,
select: { value: true, unit: true, measuredAt: true },
+8 -2
View File
@@ -324,7 +324,12 @@ export async function computeStatusInputFingerprint(args: {
}),
args.includeMood
? prisma.moodEntry.aggregate({
where: { userId: args.userId },
// Tombstoned rows are excluded here for the same reason they are in
// the measurement arm above: this aggregate IS the freshness
// fingerprint, so a deletion that leaves the count and the newest
// timestamp untouched cannot invalidate the cached assessment, and
// yesterday's text gets re-dated as today's over data that is gone.
where: { userId: args.userId, deletedAt: null },
_count: { _all: true },
_max: { moodLoggedAt: true },
})
@@ -342,8 +347,9 @@ export async function computeStatusInputFingerprint(args: {
id: true,
unit: true,
updatedAt: true,
_count: { select: { entries: true } },
_count: { select: { entries: { where: { deletedAt: null } } } },
entries: {
where: { deletedAt: null },
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
take: 1,
select: { measuredAt: true, unit: true },