HealthKit: only six things you can actually write
The first sub-question I had was simple: what does HealthKit even let me write? The app captures weight (from the scale), body-fat % (from the mirror’s CV), and thirteen circumferences (shoulder, chest, waist, stomach, hips, left/right upper-thigh, lower-thigh, calf, upper-arm). Surely most of those have a HKQuantityTypeIdentifier?
They don’t. The web is full of stale guesses, including a confidently-cited .hipCircumference and .chestCircumference that simply do not exist in the iOS SDK. The way to know for sure is to read the header directly:
$ grep -E "HKQuantityTypeIdentifier(Body|Waist|Height|Lean)" \ /Applications/Xcode.app/.../HealthKit.framework/Headers/HKTypeIdentifiers.hSix body-composition types come back. Three the app measures directly:
bodyMass: weight, in kgbodyFatPercentage: 0–1 (so divide my 0–100 stored value by 100 before sending)waistCircumference: in meters
Three are derivable from data the app already has:
bodyMassIndex:weight / (height_m)², stored as acountunit (Apple’s convention)leanBodyMass:weight × (1 − bodyFat/100)height: straight from the user profile
Hip/chest/thigh/calf/arm circumferences are not representable in HealthKit. Those ten stay app-only. Nothing to be done; chasing it further would be pointless.
Writing: HKMetadataKeySyncIdentifier is the part you’d otherwise re-learn the hard way
The naïve write looks like this:
let sample = HKQuantitySample( type: HKQuantityType.quantityType(forIdentifier: .bodyMass)!, quantity: HKQuantity(unit: .gramUnit(with: .kilo), doubleValue: scan.weight), start: scan.createdAt, end: scan.createdAt)try await store.save(sample)This works the first time. Then your scan pipeline has two paths: a “summary-only” insertion the instant scanState == .complete (weight + body-fat only, no zip yet) and an “upgrade” path that re-saves the same Scan a few seconds later with the rings.json mounted (waist, etc.). Both call the HK write hook. Without dedup, HealthKit ends up with two entries per scan: one identical to the first plus one richer one. The chart double-counts.
sequenceDiagram
participant Pipe as Scan pipeline
participant HK as HealthKit
Pipe->>HK: write sample (syncIdentifier = nm-{scanId}-weight), summary only
Note over Pipe: a few seconds later, rings.json mounted
Pipe->>HK: write sample (same syncIdentifier), upgraded with waist etc.
Note over HK: same syncIdentifier replaces in place, one sample survivesHKMetadataKeySyncIdentifier is the fix: tag every write with a stable id derived from the scan id + the metric type, and HealthKit will replace-in-place instead of inserting:
let metadata: [String: Any] = [ HKMetadataKeySyncIdentifier: "nm-\(scan.id)-weight", HKMetadataKeySyncVersion: 1,]Now the summary-path write and the upgrade-path write merge into a single sample, with the latest body of data winning. The version field is required alongside the identifier; I’ll bump it if I ever change what the identifier represents.
Reading: anchored queries, and filtering out your own writes
Importing existing HealthKit history into the app is a two-phase story.
Phase one is the bulk import: one shot, fires when the user enables the toggle. HKAnchoredObjectQuery with anchor: nil, limit: HKObjectQueryNoLimit returns every sample of a given type ever written by any source. For my three readable types (weight, body-fat, waist) that’s potentially years of data from an Apple Watch, a Withings scale, an Aria, a Garmin. The query returns an opaque HKQueryAnchor that I serialize and stash in UserDefaults:
if let data = try? NSKeyedArchiver.archivedData( withRootObject: a, requiringSecureCoding: true) { UserDefaults.standard.set(data, forKey: anchorKey(for: kind))}Phase two is keep-current. Register an HKObserverQuery per type; the callback fires when something elsewhere in iOS adds a new sample. Inside the callback, run a new anchored query starting from the saved anchor, which returns only the delta. Insert the delta into my local HealthSample SwiftData cache, save the new anchor, done.
sequenceDiagram
participant App
participant HK as HealthKit
App->>HK: HKAnchoredObjectQuery(anchor nil) one-time bulk import
HK-->>App: all historical samples + new anchor
App->>App: save anchor to UserDefaults
Note over App,HK: later, something elsewhere writes a new sample
HK-->>App: HKObserverQuery callback fires
App->>HK: HKAnchoredObjectQuery(anchor saved) delta only
HK-->>App: just the new samples + updated anchor
App->>App: insert delta into HealthSample cache, save new anchorThe bit you can miss on a first read: imports include samples you’ve written yourself, and if you don’t filter them out, every scan ends up as both a Scan row (rendered in the chart’s foreground series) and a HealthSample row (rendered in the imported-overlay series). The chart doubles. Filter by the source bundle id:
if qs.sourceRevision.source.bundleIdentifier == Bundle.main.bundleIdentifier { continue}This is also how it should work for the user: the app’s own scans are the foreground series; “imports” should mean other sources of weight/body-fat/waist data.
The chart overlay
The history chart already had a series for the user’s own scans. I added a second translucent dashed series behind it for the imported HealthKit data:
LineMark( x: .value("Date", p.date), y: .value(metric.rawValue, p.value), series: .value("Source", "Health")).interpolationMethod(.monotone).foregroundStyle(AppTheme.secondaryText.opacity(0.5)).lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 2]))The series: parameter is the part you need: without it, SwiftUI Charts treats the points from the two sources as a single series and the line zig-zags between them in date order, producing nonsense. With series: set to distinct strings, the renderer keeps them as separate connected lines.
The overlay only renders for the three metrics that exist in HealthKit (weight, body fat, waist). The hips/chest tabs are scan-only because the data only exists in my SwiftData store; HealthKit has nothing to overlay.
The mock cloud as a third storage tier
Adding the healthKitEnabled flag raised an old question: what survives a TestFlight reinstall, or a “delete app + reinstall,” or a phone migration?
The three storage tiers the app already had:
- Local SwiftData / UserDefaults / Documents/. Instant read at launch. Survives app updates. Wiped on uninstall.
- Local Keychain. Same lifecycle. Survives because iOS keychain entries persist across re-signs as long as the team and bundle id match.
- iCloud Keychain. The same entries marked
kSecAttrSynchronizable=truesync through Apple’s pipe. Survives uninstall and device wipe, syncs to other devices on the same Apple ID. This is howweightCalibrationFactorhas always worked.
iCloud Keychain is good but has two soft spots:
- It’s invisible to me. I can’t tell when it’s synced, what’s in it on any given device, or whether a particular field successfully made it across. The pipe is opaque.
- Sync window is unbounded. I’ve watched it take a couple of minutes on a fresh restore. During those minutes, a freshly-reinstalled app boots with
calibrationFactor = 1.0and shows uncalibrated weight.
A self-hosted backup tier closes both. The mirror box on the LAN is reachable whenever you’re home, and home is the only place scans happen anyway, so the LAN-only constraint isn’t a real constraint. The existing naked-device-hash / X-User-Key header (a UUID stored in iCloud Keychain by UserKeyStore) gives me the user identity already. All that was missing was an endpoint.
Server-side: one route, two methods
The mock cloud already has the JSON-handler pattern from the inbox routes. Adding /user/settings was thirty lines on the Python side:
def _user_settings_get(self) -> "tuple[int, dict]": key = self._user_key_header() if not key: return 400, {"error": "X-User-Key header required"} path = self._user_settings_path(key) if path is None: return 400, {"error": "invalid X-User-Key shape"} if not path.exists(): return 404, {"error": "no settings stored for this user"} return 200, json.loads(path.read_text()) def _user_settings_put(self, body: bytes) -> "tuple[int, dict]": key = self._user_key_header() if not key: return 400, {"error": "X-User-Key header required"} path = self._user_settings_path(key) if path is None: return 400, {"error": "invalid X-User-Key shape"} payload = json.loads(body or b"{}") payload["serverReceivedAt"] = now_iso() path.write_text(json.dumps(payload, indent=2)) return 200, payloadThe “404 if user is new” return is a real return, not an oversight: the client uses it to decide whether to seed the cloud with local data or merge cloud into local. (More on that below.)
Path sanitization is non-negotiable here. The user-key becomes a filename, and even though my key is a UUID, the principle of treating client input as adversarial holds:
@staticmethoddef _safe_user_key(user_key: str) -> "str | None": import re if not re.match(r"^[A-Za-z0-9_-]{1,128}$", user_key): return None return user_keycurl -k -H "X-User-Key: bad/../path" https://<cloud-ip>/user/settings returns 400. Good.
Client-side: last-writer-wins, with a timestamp the client controls
The cleanest reconciliation policy is last-writer-wins, with the client setting updatedAt at edit time (not the server at receive time). The server merely echoes back what it stored, plus a serverReceivedAt for log-correlation purposes.
ProfileStore.load() fires off the reconciliation in a Task after Keychain hydration completes:
case .notFound: // First time this user hits the cloud. Seed it with what I have. await pushToMockCloud(local)case .error: // LAN unreachable; local is still authoritative. returncase .found(let cloud): let cloudDate = MockCloudSettingsSync.parseStamp(cloud.updatedAt) ?? .distantPast if cloudDate > local.updatedAt { applyCloud(cloud, to: local) writeAllKeychain(local) local.updatedAt = cloudDate try? context.save() } else if cloudDate < local.updatedAt { await pushToMockCloud(local) }flowchart TD
A[App launch: load local profile] --> B{GET /user/settings}
B -->|404: cloud has nothing| C[Push local up to cloud]
B -->|network error, LAN unreachable| D[Stay local-only, retry next launch]
B -->|found, cloud.updatedAt greater than local| E[Apply cloud values, re-write Keychain, bump local updatedAt]
B -->|found, cloud.updatedAt less than local| F[Push local up to cloud]Four cases:
- Cloud has nothing for this user. Push local up. This is the fresh-install case: the iOS app starts with whatever survived in Keychain (everything iCloud-synced fields by default; the seven sensitive fields use
kSecAttrSynchronizable=true), and the mock cloud gets seeded as the user’s first launch on the LAN. - Cloud unreachable. Silent. The next save will try the PUT again; the next launch will try the GET. No retry queue; just opportunism.
- Cloud is newer. Apply, re-write into Keychain (so iCloud Keychain carries the merged value to other devices that haven’t reconciled with the mock cloud yet), bump local
updatedAtto match. The “re-write into Keychain” step is the load-bearing part of cross-device sync; without it, two devices would only converge when they both made it home to the LAN. - Local is newer. Push up. Most common case for a power user: edit a setting offline (calibrate while on the road), get home, app launches, push happens before any read could overwrite it.
How it actually behaved on first boot
After deployment, I checked what was on disk:
$ ssh m2 'cat ~/mock-cloud/logs/user_settings/21E70CDA-...JSON'{ "email": "", "unitSystem": "imperial", "version": 1, "updatedAt": "20260518T044304.095000Z", "gender": "MALE", "weightCalibrationFactor": 0.9990998620664758, "name": "<my-name>", "dateOfBirth": "<my-dob>", "heightCm": 175.88, "healthKitEnabled": true, "serverReceivedAt": "20260518T044304.134233Z"}40ms between client updatedAt and server serverReceivedAt: clock skew across the LAN is well under a second, which means the timestamp-based merge will Just Work. The file appearing at all (with no manual PUT on my part, no settings screen tap, no opt-in dialog) means the auto-reconcile path fired on the next normal app launch, found the cloud empty for this user-key, and seeded it. Exactly the behavior you’d want from a backup that you almost never have to think about.
What this is, and what it isn’t
It’s not a multi-device sync product. It’s a local backup of the dozen-or-so fields that would otherwise live in iCloud Keychain alone, written to a server I control, on hardware I own, by way of a UUID that I rotate when I want to and that nobody else has. The motivation isn’t redundancy theater: it’s “I keep wanting to bounce between debug and TestFlight builds without thinking about whether my calibration factor is still 1.0 underneath.”
That’s also why I didn’t reach for CloudKit (the existing dead stub in ProfileStore). CloudKit would require an entitlement, a real DEVELOPMENT_TEAM, and a CKContainer setup process that’s overkill for “save 12 fields somewhere durable.” The mock cloud is right there, has the auth I already use for every other endpoint, and the implementation cost was a Python afternoon.
What I’d watch next
Two things to keep an eye on:
-
Anchored-query observer behavior across iOS lifecycle states. I register the observer in foreground only; the next time someone weighs themselves on an Apple Watch while the app is closed, that sample won’t enter the SwiftData cache until the user reopens the app and the launch-time anchored fetch picks up the delta. Functionally fine, but if I ever want push-driven freshness,
enableBackgroundDelivery(for:frequency:)is the next step and it adds a real entitlement. -
Conflict semantics on two-device-edit. Both devices edit a setting while off-LAN, both come home, both push at nearly the same time: last write wins. Probably fine for me as sole user. If this ever becomes a real product, last-writer-wins is the wrong policy for some fields (calibration factor in particular: you don’t want the older calibration overwriting the newer one if both happened to be edited). Worth a structured merge per field, eventually.
For now the boring useful thing got built: HealthKit gets fed every scan, and the settings live in three places: local memory, iCloud Keychain, and my mirror box.
Follow along
Procedure 25-AThe two changes above generalize past this one project. Here’s the clean sequence if you want to do something similar:
- 1Read the SDK headers for the real HealthKit quantity-type identifiers, don’t trust web guesses.
grep -E "HKQuantityTypeIdentifier..." HealthKit.framework/Headers/HKTypeIdentifiers.htells you exactly what’s writable. - 2Tag every HealthKit write with
HKMetadataKeySyncIdentifierandHKMetadataKeySyncVersionso repeated writes for the same logical event (an initial summary, then a later upgrade with more data) merge instead of duplicate. - 3Use
HKAnchoredObjectQueryfor the one-time bulk import, andHKObserverQueryplus a saved anchor for ongoing delta reads. Persist the anchor (viaNSKeyedArchiver) so a relaunch doesn’t have to re-scan everything. - 4Filter out your own writes by
sourceRevision.source.bundleIdentifierbefore rendering an “imported from HealthKit” overlay, or your own data will double-count against itself. - 5If you want a backup tier independent of Apple’s sync pipeline, add a tiny
GET/PUTsettings endpoint keyed by a stable per-user identifier, with the client (not the server) stampingupdatedAtat edit time. Last-writer-wins on that timestamp is a simple, good-enough reconciliation policy for a single-user tool.
Agent prompts Procedure 25-A as prompts for a coding agent · 5 prompts
Paste into Claude Code or a similar agent, one at a time, from the root of your iOS project. Each prompt maps to the step with the same number above.