From e4333f8f71adca218430ecaa1f33caf292d01133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:29:08 +0200 Subject: [PATCH 1/2] docs(api): say which unit the batch route reads a sample in U1. The new `unit` description on the batch entry said `value` is read in the unit HealthLog stores the identifier in. Sixteen mappings disagree: `convertToDbUnit` runs on every entry, and a waist circumference arrives in metres to be stored in centimetres, an oxygen saturation and a body fat arrive as a fraction to be stored as a percent. A client generated against that sentence sends 98 and stores 9800. The description now says what happens: `value` is read in Apple's own HKUnit for the identifier, HealthLog applies its storage scaling afterwards, and `unit` is recorded for audit rather than validated against. Two worked examples, because the percent-shaped ones are the pair that bites. YAML regenerated. --- docs/api/openapi.yaml | 9 ++++++--- src/lib/openapi/routes/measurements.ts | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2eed38ee6..4fa4d8387 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -21350,9 +21350,12 @@ components: type: string minLength: 1 maxLength: 60 - description: "Apple's `HKUnit` string for `value`, captured for audit. The server does NOT convert from it: `value` is - read in the unit HealthLog stores the identifier in (`m` for walking distance, `kg` for body mass, `kcal` - for energy, `degC` for temperature). Send the sample in that unit." + description: "Apple's `HKUnit` string for `value`, captured for audit: the server records it and never validates `value` + against it. `value` is read in Apple's own `HKUnit` for the identifier — `m` for a length, `kg` for body + mass, `kcal` for energy, `degC` for temperature, a `fraction` for the percent-shaped ones (oxygen + saturation, body fat) — and HealthLog applies its storage scaling after that. A waist circumference sent as + `0.86` in `m` is stored as `86` `cm`; an oxygen saturation sent as `0.98` in `fraction` is stored as `98` + `%`. Send the sample in Apple's unit, not in HealthLog's." startDate: type: string format: date-time diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 1f28b049b..f138557cb 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -58,7 +58,7 @@ const batchEntrySchema = z .min(1) .max(60) .describe( - "Apple's `HKUnit` string for `value`, captured for audit. The server does NOT convert from it: `value` is read in the unit HealthLog stores the identifier in (`m` for walking distance, `kg` for body mass, `kcal` for energy, `degC` for temperature). Send the sample in that unit.", + "Apple's `HKUnit` string for `value`, captured for audit: the server records it and never validates `value` against it. `value` is read in Apple's own `HKUnit` for the identifier — `m` for a length, `kg` for body mass, `kcal` for energy, `degC` for temperature, a `fraction` for the percent-shaped ones (oxygen saturation, body fat) — and HealthLog applies its storage scaling after that. A waist circumference sent as `0.86` in `m` is stored as `86` `cm`; an oxygen saturation sent as `0.98` in `fraction` is stored as `98` `%`. Send the sample in Apple's unit, not in HealthLog's.", ), startDate: z.iso.datetime({ offset: true }), endDate: z.iso.datetime({ offset: true }), From c3b5585fd68276503dbb40237109dcee7abd6ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:30:26 +0200 Subject: [PATCH 2/2] fix(ops): stop the distance dry run promising work it will refuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Low, review 2. An account with one out-of-range candidate is refused whole, rows and audit row included, but the dry run counted its rows into "N row(s) would be repaired" — so the number an operator read before --apply was larger than the number --apply could ever produce. The refused accounts are now counted and reported separately, and the per-account line says its rows are not in the total. The refusal itself told the operator to check the Health app "before forcing anything". There is no --force and never was. It now names the only route that exists: re-run with the matching --unit if the archive really was in another one. --- scripts/repair-apple-health-distance.ts | 20 ++++++++++++++----- .../repair-apple-health-distance.ts | 5 +++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/scripts/repair-apple-health-distance.ts b/scripts/repair-apple-health-distance.ts index be77a3b8f..1843f5362 100644 --- a/scripts/repair-apple-health-distance.ts +++ b/scripts/repair-apple-health-distance.ts @@ -84,6 +84,7 @@ async function main(): Promise { let totalUpdated = 0; let totalSkipped = 0; let refused = 0; + let wouldRefuse = 0; let rollupsPending = 0; for (const plan of plans) { @@ -104,7 +105,11 @@ async function main(): Promise { continue; } - totalRepairable += plan.repairable.length; + // An account with one out-of-range candidate is refused whole, so its + // rows are not "would be repaired" — counting them promised work the + // --apply run then declined to do. + if (plan.outOfRange.length > 0) wouldRefuse += 1; + else totalRepairable += plan.repairable.length; const first = plan.repairable[0]; const last = plan.repairable[plan.repairable.length - 1]; console.log( @@ -130,7 +135,8 @@ async function main(): Promise { } if (plan.outOfRange.length > 0) { console.log( - " ! this account will be REFUSED — see the reason under --apply", + " ! this account will be REFUSED whole — its rows are not in " + + "the count below; see the reason under --apply", ); } @@ -166,9 +172,13 @@ async function main(): Promise { apply ? `\nDone. ${totalUpdated} row(s) repaired, ${totalSkipped} skipped, ` + `${refused} account(s) refused.` - : `\nDry run: ${totalRepairable} row(s) would be repaired. ` + - "Read the worked examples above and decide before re-running with " + - "--apply.", + : `\nDry run: ${totalRepairable} row(s) would be repaired` + + (wouldRefuse > 0 + ? `, ${wouldRefuse} account(s) would be refused (their rows are ` + + "not in that number)" + : "") + + ". Read the worked examples above and decide before re-running " + + "with --apply.", ); if (rollupsPending > 0) { diff --git a/src/lib/measurements/repair-apple-health-distance.ts b/src/lib/measurements/repair-apple-health-distance.ts index bd2947586..19cc9364c 100644 --- a/src/lib/measurements/repair-apple-health-distance.ts +++ b/src/lib/measurements/repair-apple-health-distance.ts @@ -259,8 +259,9 @@ function outOfRangeRefusal(plan: AccountRepairPlan, factor: number): string { `leave the plausible range after x${factor}, so these rows are not the ` + "1000x class — the account was most likely re-imported on the fixed " + "build, or its archive was never in the affected unit. Nothing written. " + - "Check the dry run's worked examples against the account's Health app " + - "before forcing anything." + "There is no override: check the dry run's worked examples against the " + + "account's Health app, and if the archive really was in another unit, " + + "re-run with the matching --unit." ); }