From d6774b770db316b8dab863565286d59aa8b70aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 19:28:05 +0200 Subject: [PATCH] feat(openapi): publish the Apple Health write-back contract Declare appleHealthMirror on measurement rows and sync upserts, the list filter, the measurementMirror sync domain, the token setting on the list, the mint and the new PATCH, and the unattributed-readings summary. --- docs/api/README.md | 6 +- docs/api/openapi.yaml | 237 +++++++++++++++++++++++-- src/lib/openapi/routes/auth.ts | 86 ++++++++- src/lib/openapi/routes/measurements.ts | 6 + src/lib/openapi/routes/sync.ts | 37 +++- 5 files changed, 354 insertions(+), 18 deletions(-) diff --git a/docs/api/README.md b/docs/api/README.md index 1d4ddd005..507e31faf 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -66,7 +66,11 @@ before treating a warning as a regression. `health:write`), audience-bound to `/mcp` and refused on REST writes; - `POST /api/tokens/measurements` mints `measurements:write`, accepted by `POST /api/measurements` and `POST /api/measurements/batch` only, on the - holder's own record only; + holder's own record only. The readings it writes carry `source: EXTERNAL` + and are linked to the token; whether the HealthLog app writes them into + Apple Health is the token's `mirrorToAppleHealth` setting (off by + default, changed with `PATCH /api/tokens/{id}`), resolved onto every + measurement row as `appleHealthMirror`; - `POST /api/tokens/workouts` mints `workouts:write`, accepted by `POST /api/workouts/batch` only, on the holder's own record only; the workouts it writes carry `source: EXTERNAL`; diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ee9bd53e2..563d48eb1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3372,6 +3372,25 @@ paths: "422": *a4 "429": *a5 /api/tokens/measurements: + get: + tags: + - Auth + summary: Readings no measurement token reaches + description: Counts the caller's live EXTERNAL readings that are linked to no token. Readings are linked to the token + that sent them since v1.46; older ones were linked by a one-time pass only when the account held exactly one + measurement token, so the rest belong to none and no token's Apple Health setting reaches them. Cookie session + only. + responses: + "200": + description: The count. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementTokenSummaryEnvelope" + "401": *a1 + "403": *a9 + "422": *a4 + "429": *a5 post: tags: - Auth @@ -3690,6 +3709,53 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + patch: + tags: + - Auth + summary: Change a measurement token's Apple Health setting + description: "Sets whether the HealthLog app writes the readings this measurement token sent into Apple Health. The + readings keep their `EXTERNAL` source and are not rewritten: every measurement row resolves `appleHealthMirror` + from the token at read time, and a paired client learns of the change through the `measurementMirror` domain of + `GET /api/sync/changes`. Turning it off stops further writes; samples already in Apple Health stay. A revoked + token is accepted, since its readings stay in the record. Cookie session only, the caller's own tokens only + (another account's answers 404). Audited as `tokens.measurements.mirror.update` when the value changes. 30 + changes per account per minute. Not gated on the instance-wide API switch: no credential is created or + extended." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMeasurementTokenRequest" + responses: + "200": + description: The setting as it now stands. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMeasurementTokenEnvelope" + "401": *a1 + "403": *a9 + "404": + description: Token not found or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "Validation failed, or the token is not a measurement token (`meta.errorCode` = + `tokens.mirror.not_measurement_token`): a wildcard, connector or other narrow token has no such setting." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 /api/auth/register: post: security: [] @@ -4224,10 +4290,11 @@ paths: - Sync summary: Multi-domain delta feed description: "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque - keyset cursor spanning five domains — measurements, mood, medication intakes, cycle day logs and cycles. Each - domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts - within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. - `cursorExpired: true` forces a clean re-init and the page is empty in every domain." + keyset cursor spanning six domains — measurements, mood, medication intakes, cycle day logs, cycles, and + `measurementMirror` (the Apple Health write-back of readings whose token setting changed). Each domain carries + `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. + Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` + forces a clean re-init and the page is empty in every domain." parameters: - in: query name: cursor @@ -4554,6 +4621,12 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - in: query + name: appleHealthMirror + schema: + type: string + enum: + - "true" - in: query name: valueMin schema: @@ -28142,12 +28215,15 @@ components: type: integer minimum: 1 maximum: 365 + mirrorToAppleHealth: + type: boolean required: - name description: "Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to - revoke it. `expiresInDays` defaults to 365." + revoke it. `expiresInDays` defaults to 365. `mirrorToAppleHealth` (default false) lets the HealthLog app write + the token's readings into Apple Health; it can be changed later at `PATCH /api/tokens/{id}`." CreateWorkoutTokenRequest: type: object properties: @@ -28178,6 +28254,15 @@ components: - name description: "Mint a document-upload Bearer. No `scope` field: the endpoint mints one shape. `name` is what the token is listed under; name it after the system it is pasted into. `expiresInDays` defaults to 365." + UpdateMeasurementTokenRequest: + type: object + properties: + mirrorToAppleHealth: + type: boolean + required: + - mirrorToAppleHealth + additionalProperties: false + description: "The one setting a measurement token has. Strict: any other key is refused 422." RegisterRequest: type: object properties: @@ -38032,11 +38117,12 @@ components: source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens — - tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; - vaccination — vaccination.booster-invalid, vaccination.booster-no-antigen, - vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, vaccination.custom.invalid, - vaccination.custom.name-taken, vaccination.custom.not-found, vaccination.encounter-not-found, - vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, + tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination — + vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, + vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken, + vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required, + vaccination.invalid, vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, @@ -40800,6 +40886,11 @@ components: type: - string - "null" + ingestTokenId: + anyOf: + - type: string + maxLength: 64 + - type: "null" syncVersion: type: integer minimum: -9007199254740991 @@ -47391,6 +47482,10 @@ components: type: boolean description: Revoked tokens stay in the list rather than disappearing, so a client must filter on this rather than treat presence as validity. + mirrorToAppleHealth: + type: boolean + description: "A measurement token's Apple Health setting: whether the HealthLog app writes the readings this token sent + into Apple Health. Changed with `PATCH /api/tokens/{id}`; false on every other token." required: - id - name @@ -47399,6 +47494,36 @@ components: - expiresAt - createdAt - revoked + - mirrorToAppleHealth + additionalProperties: false + MeasurementTokenSummaryEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementTokenSummaryResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementTokenSummaryResponse: + type: object + properties: + unattributedReadings: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: "Live EXTERNAL readings that belong to no token: sent before readings were linked to their token, by an + account that held more than one. No token's Apple Health setting reaches them." + required: + - unattributedReadings additionalProperties: false CreateMeasurementTokenEnvelope: type: object @@ -47532,6 +47657,34 @@ components: - data - error additionalProperties: false + UpdateMeasurementTokenEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/UpdateMeasurementTokenResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateMeasurementTokenResponse: + type: object + properties: + id: + type: string + mirrorToAppleHealth: + type: boolean + required: + - id + - mirrorToAppleHealth + additionalProperties: false RegisterEnvelope: type: object properties: @@ -48111,12 +48264,37 @@ components: additionalProperties: false description: Menstrual cycles, observed and forward-predicted. Present on every page, empty arrays when the account has the cycle module off. + measurementMirror: + type: object + properties: + upserts: + type: array + items: + $ref: "#/components/schemas/SyncMeasurementMirrorUpsert" + tombstones: + maxItems: 0 + type: array + items: + type: object + properties: + id: + type: string + required: + - id + additionalProperties: false + description: "Always empty: a deleted reading is a measurement tombstone." + required: + - upserts + - tombstones + additionalProperties: false + description: Apple Health write-back changes for readings that are otherwise unchanged. Apply after `measurements`. required: - measurements - mood - intakes - cycleDays - cycles + - measurementMirror additionalProperties: false required: - serverNow @@ -48125,10 +48303,11 @@ components: - cursorExpired - changes additionalProperties: false - description: "Multi-domain delta page: measurements, mood, intakes, cycle day logs and cycles. One opaque multi-domain - keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on - externalId, every other domain on server id. All five keys are present on every response, including the - `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted." + description: "Multi-domain delta page: measurements, mood, intakes, cycle day logs, cycles, and the Apple Health + write-back changes of otherwise unchanged readings. One opaque multi-domain keyset cursor; tombstones apply + before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on + server id. All six keys are present on every response, including the `cursorExpired` short-circuit — a domain + with nothing to report sends empty arrays rather than being omitted." SyncMeasurementUpsert: type: object properties: @@ -48221,6 +48400,10 @@ components: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + appleHealthMirror: + type: boolean + description: Whether the HealthLog app writes this reading into Apple Health. Server-resolved; mirror exactly the rows + that say true. dayKey: type: string sampleCount: @@ -48280,6 +48463,7 @@ components: - externalId - syncVersion - updatedAt + - appleHealthMirror additionalProperties: false MeasurementSource: type: string @@ -48582,6 +48766,21 @@ components: - updatedAt additionalProperties: false description: A soft-deleted menstrual cycle, keyed on server `id`. Apply before upserts within the domain page. + SyncMeasurementMirrorUpsert: + type: object + properties: + id: + type: string + description: The measurement's server id. + appleHealthMirror: + type: boolean + description: The reading's write-back as it now stands. False means stop writing it; samples already in Apple Health stay. + required: + - id + - appleHealthMirror + additionalProperties: false + description: A reading whose `appleHealthMirror` changed because the account switched the Apple Health setting of the + token that sent it. The reading itself did not change and is not re-sent in `measurements`. GetMeasurementsSeriesBatchResponse: type: object properties: @@ -49017,6 +49216,11 @@ components: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + appleHealthMirror: + description: "Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored + row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that + sent it, false otherwise. Absent on the synthesised list rows." + type: boolean dayKey: type: string sampleCount: @@ -49258,6 +49462,11 @@ components: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + appleHealthMirror: + description: "Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored + row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that + sent it, false otherwise. Absent on the synthesised list rows." + type: boolean dayKey: type: string sampleCount: diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 31795eff0..409078787 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -34,6 +34,7 @@ import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native"; import { createDocumentTokenSchema, createMeasurementTokenSchema, + updateMeasurementTokenSchema, createWorkoutTokenSchema, } from "@/lib/validations/tokens"; import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff"; @@ -484,9 +485,36 @@ const apiTokenInfo = z .describe( "Revoked tokens stay in the list rather than disappearing, so a client must filter on this rather than treat presence as validity.", ), + mirrorToAppleHealth: z + .boolean() + .describe( + "A measurement token's Apple Health setting: whether the HealthLog app writes the readings this token sent into Apple Health. Changed with `PATCH /api/tokens/{id}`; false on every other token.", + ), }) .meta({ id: "ApiTokenInfo" }); +const updateMeasurementTokenRequest = updateMeasurementTokenSchema.meta({ + id: "UpdateMeasurementTokenRequest", + description: + "The one setting a measurement token has. Strict: any other key is refused 422.", +}); + +const updateMeasurementTokenResponse = z + .object({ id: z.string(), mirrorToAppleHealth: z.boolean() }) + .meta({ id: "UpdateMeasurementTokenResponse" }); + +const measurementTokenSummaryResponse = z + .object({ + unattributedReadings: z + .number() + .int() + .nonnegative() + .describe( + "Live EXTERNAL readings that belong to no token: sent before readings were linked to their token, by an account that held more than one. No token's Apple Health setting reaches them.", + ), + }) + .meta({ id: "MeasurementTokenSummaryResponse" }); + const apiTokenListResponse = z .array(apiTokenInfo) .meta({ id: "ApiTokenListResponse" }); @@ -494,7 +522,7 @@ const apiTokenListResponse = z const createMeasurementTokenRequest = createMeasurementTokenSchema.meta({ id: "CreateMeasurementTokenRequest", description: - "Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to revoke it. `expiresInDays` defaults to 365.", + "Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to revoke it. `expiresInDays` defaults to 365. `mirrorToAppleHealth` (default false) lets the HealthLog app write the token's readings into Apple Health; it can be changed later at `PATCH /api/tokens/{id}`.", }); const createMeasurementTokenResponse = z @@ -1944,6 +1972,26 @@ export const authPaths: NonNullable = { }, }, "/api/tokens/measurements": { + get: { + tags: ["Auth"], + summary: "Readings no measurement token reaches", + description: + "Counts the caller's live EXTERNAL readings that are linked to no token. Readings are linked to the token that sent them since v1.46; older ones were linked by a one-time pass only when the account held exactly one measurement token, so the rest belong to none and no token's Apple Health setting reaches them. Cookie session only.", + responses: { + "200": { + description: "The count.", + content: { + "application/json": { + schema: dataEnvelope( + measurementTokenSummaryResponse, + "MeasurementTokenSummaryEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, post: { tags: ["Auth"], summary: "Mint a measurement-ingest token", @@ -2170,6 +2218,42 @@ export const authPaths: NonNullable = { ...stdResponses, }, }, + patch: { + tags: ["Auth"], + summary: "Change a measurement token's Apple Health setting", + description: + "Sets whether the HealthLog app writes the readings this measurement token sent into Apple Health. The readings keep their `EXTERNAL` source and are not rewritten: every measurement row resolves `appleHealthMirror` from the token at read time, and a paired client learns of the change through the `measurementMirror` domain of `GET /api/sync/changes`. Turning it off stops further writes; samples already in Apple Health stay. A revoked token is accepted, since its readings stay in the record. Cookie session only, the caller's own tokens only (another account's answers 404). Audited as `tokens.measurements.mirror.update` when the value changes. 30 changes per account per minute. Not gated on the instance-wide API switch: no credential is created or extended.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { schema: updateMeasurementTokenRequest }, + }, + }, + responses: { + "200": { + description: "The setting as it now stands.", + content: { + "application/json": { + schema: dataEnvelope( + updateMeasurementTokenResponse, + "UpdateMeasurementTokenEnvelope", + ), + }, + }, + }, + "404": { + description: "Token not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "Validation failed, or the token is not a measurement token (`meta.errorCode` = `tokens.mirror.not_measurement_token`): a wildcard, connector or other narrow token has no such setting.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, }, // ── Appended: registration, credential rotation, the OIDC entry point and // the Codex device-auth flow. diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 8415c13b8..c8a3dc8af 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -332,6 +332,12 @@ export const measurementResource = z .describe("Soft-delete tombstone; null on a live row."), createdAt: isoDateTime().optional(), updatedAt: isoDateTime().optional(), + appleHealthMirror: z + .boolean() + .optional() + .describe( + "Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that sent it, false otherwise. Absent on the synthesised list rows.", + ), // Synthesised-row extras — present only on the collapsed day-sum mode. dayKey: z.string().optional(), sampleCount: z.number().int().optional(), diff --git a/src/lib/openapi/routes/sync.ts b/src/lib/openapi/routes/sync.ts index 0a690d5f8..5444c6aac 100644 --- a/src/lib/openapi/routes/sync.ts +++ b/src/lib/openapi/routes/sync.ts @@ -75,9 +75,29 @@ const syncMeasurementUpsert = measurementResource "LWW reconciliation counter; echo to keep the mirror monotonic.", ), updatedAt: isoDateTime(), + appleHealthMirror: z + .boolean() + .describe( + "Whether the HealthLog app writes this reading into Apple Health. Server-resolved; mirror exactly the rows that say true.", + ), }) .meta({ id: "SyncMeasurementUpsert" }); +const syncMeasurementMirrorUpsert = z + .object({ + id: z.string().describe("The measurement's server id."), + appleHealthMirror: z + .boolean() + .describe( + "The reading's write-back as it now stands. False means stop writing it; samples already in Apple Health stay.", + ), + }) + .meta({ + id: "SyncMeasurementMirrorUpsert", + description: + "A reading whose `appleHealthMirror` changed because the account switched the Apple Health setting of the token that sent it. The reading itself did not change and is not re-sent in `measurements`.", + }); + const syncMeasurementTombstone = z .object({ id: z.string(), @@ -294,12 +314,25 @@ export const syncChangesResponse = z .describe( "Menstrual cycles, observed and forward-predicted. Present on every page, empty arrays when the account has the cycle module off.", ), + measurementMirror: z + .object({ + upserts: z.array(syncMeasurementMirrorUpsert), + tombstones: z + .array(z.object({ id: z.string() })) + .max(0) + .describe( + "Always empty: a deleted reading is a measurement tombstone.", + ), + }) + .describe( + "Apple Health write-back changes for readings that are otherwise unchanged. Apply after `measurements`.", + ), }), }) .meta({ id: "SyncChangesResponse", description: - "Multi-domain delta page: measurements, mood, intakes, cycle day logs and cycles. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on server id. All five keys are present on every response, including the `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted.", + "Multi-domain delta page: measurements, mood, intakes, cycle day logs, cycles, and the Apple Health write-back changes of otherwise unchanged readings. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on server id. All six keys are present on every response, including the `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted.", }); export const syncPaths: NonNullable = { @@ -327,7 +360,7 @@ export const syncPaths: NonNullable = { tags: ["Sync"], summary: "Multi-domain delta feed", description: - "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque keyset cursor spanning five domains — measurements, mood, medication intakes, cycle day logs and cycles. Each domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` forces a clean re-init and the page is empty in every domain.", + "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque keyset cursor spanning six domains — measurements, mood, medication intakes, cycle day logs, cycles, and `measurementMirror` (the Apple Health write-back of readings whose token setting changed). Each domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` forces a clean re-init and the page is empty in every domain.", requestParams: { query: syncChangesQuery, },