From 2a396dd23e307d678d6ae68dc03cd868bd2b38cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 15:20:47 +0200 Subject: [PATCH] fix(backup): carry the aggregation provenance through backup and restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The disaster-recovery backup selected every Measurement scalar except the aggregation fields, so a restore brought every `stats:` aggregate back with no provenance and the Apple Health authority ladder came back flat: an export.xml source-day estimate could then overwrite a native HealthKit statistic. `aggregationProvenance` now rides the select, the disaster-recovery serialization, the payload schema and the restore mapping, following `externalSourceVersion` exactly — including staying out of the reduced portable shape, which carries the human-readable subset only. The schema field is optional, so every backup written before this parses unchanged. Restoring one of those older backups no longer recreates the ambiguity migration 0263 repaired: an Apple Health row under a `stats:` identity with no provenance comes back as LEGACY_UNKNOWN, the sentinel the cumulative drain already writes in that situation, so a native sync or archive reprocess can repair it. The gate is scoped as 0263 scoped its backfill — other providers mint `stats:` identities for their cumulative dailies and have never carried provenance, and ordinary point rows keep NULL. --- .../api/admin/backups/[id]/restore/route.ts | 19 +++ .../__tests__/full-backup-payload.test.ts | 71 +++++++++ src/lib/export/full-backup-payload.ts | 2 + src/lib/onboarding/goals.ts | 8 +- src/lib/validations/backup.ts | 7 + .../admin-backups-canonical-roundtrip.test.ts | 138 ++++++++++++++++++ 6 files changed, 242 insertions(+), 3 deletions(-) diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index d417d0568..879a0a42e 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -407,6 +407,25 @@ const handler = apiHandler( : decodeEncryptedBytes(measurement.notesEncrypted), externalId: measurement.externalId ?? null, externalSourceVersion: measurement.externalSourceVersion ?? null, + // Authority of an Apple Health `stats:` aggregate. A backup + // written before this field rode the payload carries nothing, and + // restoring NULL would flatten the authority ladder: an export.xml + // source-day estimate could then overwrite a native HealthKit + // statistic, which is the ambiguity migration 0263 already + // repaired once. Such a row is exactly the case LEGACY_UNKNOWN + // exists for, so it comes back repairable instead of ambiguous. + // + // The gate is scoped exactly as 0263 scoped its backfill and as + // the cumulative drain scopes its own LEGACY_UNKNOWN writes: + // Apple Health rows only. Other providers mint `stats:` ids for + // their cumulative dailies too (Fitbit, Google Health) and have + // never carried provenance — marking those would invent a state + // they were never in. Ordinary point rows keep NULL honestly. + aggregationProvenance: (measurement.aggregationProvenance ?? + (measurement.source === "APPLE_HEALTH" && + measurement.externalId?.startsWith("stats:") + ? "LEGACY_UNKNOWN" + : null)) as never, glucoseContext: (measurement.glucoseContext ?? null) as never, sleepStage: (measurement.sleepStage ?? null) as never, rhythmClassification: (measurement.rhythmClassification ?? diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 82170b68e..aac92b01f 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -204,6 +204,7 @@ function installSectionMocks() { notesEncrypted: measurementNote, externalId: "oura-stage-deep", externalSourceVersion: "oura-v3", + aggregationProvenance: "HEALTHKIT_STATISTICS", glucoseContext: null, sleepStage: "DEEP", rhythmClassification: null, @@ -297,6 +298,7 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { notesEncrypted: Buffer.from(measurementNote).toString("base64"), externalId: "oura-stage-deep", externalSourceVersion: "oura-v3", + aggregationProvenance: "HEALTHKIT_STATISTICS", glucoseContext: null, sleepStage: "DEEP", rhythmClassification: null, @@ -371,3 +373,72 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { ).rejects.toBe(failure); }); }); + +/** + * `aggregationProvenance` is what stops an export.xml source-day estimate from + * overwriting a native HealthKit statistic. A disaster-recovery backup that + * drops it restores a flattened authority ladder, so it rides the select and + * the disaster-recovery serialization the same way `externalSourceVersion` + * does — and stays out of the reduced portable shape, also like + * `externalSourceVersion`. + * + * Mutation check: drop `aggregationProvenance: true` from the select, or drop + * the field from the disaster-recovery arm, and the first two cases go red. + */ +describe("buildFullBackupPayload — aggregation provenance", () => { + it("selects the provenance column", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + await buildFullBackupPayload(prisma as never, "user-1", { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }); + + expect(mocks.iterateMeasurementPages).toHaveBeenCalledWith( + prisma, + expect.anything(), + expect.objectContaining({ aggregationProvenance: true }), + ); + }); + + it("carries the provenance into the disaster-recovery payload", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + const rows = payload.measurements as Array>; + expect(rows[0]).toHaveProperty( + "aggregationProvenance", + "HEALTHKIT_STATISTICS", + ); + }); + + it("leaves the provenance out of the reduced portable shape", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "portable-export", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + const rows = payload.measurements as Array>; + // The portable arm carries the human-readable subset only — no + // externalSourceVersion, no glucoseContext, and no provenance either. + expect(rows[0]).not.toHaveProperty("aggregationProvenance"); + expect(rows[0]).not.toHaveProperty("externalSourceVersion"); + }); +}); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 5e870a023..e0c813eb5 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -83,6 +83,7 @@ export async function buildFullBackupPayload( notesEncrypted: true, externalId: true, externalSourceVersion: true, + aggregationProvenance: true, glucoseContext: true, sleepStage: true, rhythmClassification: true, @@ -112,6 +113,7 @@ export async function buildFullBackupPayload( : null, externalId: measurement.externalId, externalSourceVersion: measurement.externalSourceVersion, + aggregationProvenance: measurement.aggregationProvenance, glucoseContext: measurement.glucoseContext, sleepStage: measurement.sleepStage, rhythmClassification: measurement.rhythmClassification, diff --git a/src/lib/onboarding/goals.ts b/src/lib/onboarding/goals.ts index 3a9655502..bf5385d91 100644 --- a/src/lib/onboarding/goals.ts +++ b/src/lib/onboarding/goals.ts @@ -6,9 +6,11 @@ * personalization signal: which dashboard tiles get promoted to the * top and made visible. Goals are NOT a clinical target store — the * targets surface (`/api/insights/targets`) is fully derived from - * height/age/gender + clinical references. Their only leverage is - * (a) dashboard tile pinning (here) and (b) reminder suggestions - * (read by the reminders panel from `User.onboardingGoals`). + * height/age/gender + clinical references. Their only leverage is the + * one-time dashboard tile pinning below, applied by + * `/api/onboarding/step` while the layout is still unset. Nothing else + * reads `User.onboardingGoals` — the column is collected, seeded from + * once, and then inert. * * One home for the slug enum so the API validation * (`/api/onboarding/step`), the client picker, and the seeding all diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 326bec119..854b1a7be 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -43,6 +43,7 @@ import { InjectionSite, IntakeAttributionSource, IntakeSource, + MeasurementAggregationProvenance, MeasurementSource, MeasurementType, MedicationCategory, @@ -86,6 +87,12 @@ const measurementSchema = z notesEncrypted: base64BytesSchema.nullable().optional(), externalId: z.string().nullable().optional(), externalSourceVersion: z.string().nullable().optional(), + // Authority of a `stats:` aggregate. `.optional()` keeps every backup + // written before this field rode the payload parseable. + aggregationProvenance: z + .enum(MeasurementAggregationProvenance) + .nullable() + .optional(), glucoseContext: z.enum(GlucoseContext).nullable().optional(), sleepStage: z.enum(SleepStage).nullable().optional(), rhythmClassification: z.enum(RhythmClassification).nullable().optional(), diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index 506fad988..cfd86f30e 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -1130,4 +1130,142 @@ describe("canonical disaster-recovery backup round-trip", () => { ); expect(invalidateUserData).not.toHaveBeenCalled(); }); + + /** + * `aggregationProvenance` is what keeps an export.xml source-day estimate + * from overwriting a native HealthKit statistic. A backup that drops it + * restores a flattened authority ladder, so it has to survive the round + * trip — and a backup written before it rode the payload has to land in the + * documented repairable state rather than reproduce the ambiguity migration + * 0263 already repaired once. + * + * Mutation check: drop `aggregationProvenance` from the backup select or + * from the restore mapping and the first case goes red; drop the + * Apple-Health `stats:` fallback and the second goes red. + */ + it("round-trips the aggregation provenance of a stats aggregate", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const payload = backupPayloadSchema.parse({ + schemaVersion: "2", + exportedAt: "2026-07-25T00:00:00.000Z", + userId: admin.id, + measurements: [ + { + id: "provenance-native", + type: "ACTIVITY_STEPS", + value: 8_600, + unit: "steps", + measuredAt: "2026-07-20T10:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20", + aggregationProvenance: "HEALTHKIT_STATISTICS", + }, + { + id: "provenance-xml", + type: "ACTIVITY_STEPS", + value: 8_526, + unit: "steps", + measuredAt: "2026-07-21T10:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-21", + aggregationProvenance: "EXPORT_XML_SOURCE_MAX", + }, + ], + }); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "PROVENANCE_ROUNDTRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + expect( + await prisma.measurement.findMany({ + where: { userId: admin.id }, + orderBy: { measuredAt: "asc" }, + select: { id: true, aggregationProvenance: true }, + }), + ).toEqual([ + { + id: "provenance-native", + aggregationProvenance: "HEALTHKIT_STATISTICS", + }, + { id: "provenance-xml", aggregationProvenance: "EXPORT_XML_SOURCE_MAX" }, + ]); + }); + + it("marks an unlabelled Apple Health stats aggregate as legacy-unknown on restore", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + // A backup taken before the provenance rode the payload: no field at all. + const payload = backupPayloadSchema.parse({ + schemaVersion: "2", + exportedAt: "2026-07-25T00:00:00.000Z", + userId: admin.id, + measurements: [ + { + id: "legacy-apple-stats", + type: "ACTIVITY_STEPS", + value: 8_526, + unit: "steps", + measuredAt: "2026-07-20T10:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20", + }, + { + // Another provider's cumulative daily. It mints a `stats:` id too but + // has never carried provenance, so inventing one would be a lie. + id: "legacy-fitbit-stats", + type: "ACTIVITY_STEPS", + value: 7_100, + unit: "steps", + measuredAt: "2026-07-21T10:00:00.000Z", + source: "FITBIT", + externalId: "stats:steps", + }, + { + // An ordinary point reading never had provenance either. + id: "legacy-point-row", + type: "PULSE", + value: 61, + unit: "bpm", + measuredAt: "2026-07-22T10:00:00.000Z", + source: "MANUAL", + }, + ], + }); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "PROVENANCE_LEGACY", + data: encrypt(JSON.stringify(payload)), + }, + }); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + expect( + await prisma.measurement.findMany({ + where: { userId: admin.id }, + orderBy: { measuredAt: "asc" }, + select: { id: true, aggregationProvenance: true }, + }), + ).toEqual([ + { id: "legacy-apple-stats", aggregationProvenance: "LEGACY_UNKNOWN" }, + { id: "legacy-fitbit-stats", aggregationProvenance: null }, + { id: "legacy-point-row", aggregationProvenance: null }, + ]); + }); });