fix(backup): carry the aggregation provenance through backup and restore

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 15:20:47 +02:00
1 parent f8f460bb66
commit 2a396dd23e
6 files changed
+242 -3

No files matched your search

@@ -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 ??
@@ -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<Record<string, unknown>>;
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<Record<string, unknown>>;
// 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");
});
});
+2
View File
@@ -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,
+5 -3
View File
@@ -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
+7
View File
@@ -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(),