Archiving for distribution
The cycle started with “I should put this on TestFlight.” Up to now I’d been running the app via Xcode’s debug build on my own device, which carries a few real downsides: most acutely the seven-day signing rotation that forces a manual rebuild every week. TestFlight signs for 90 days and survives reboots. Long past time.
Two prep steps before archiving made sense:
- Bump the deployment target to iOS 18. No source changes: the code had zero
@available(iOS X+)markers and nothing in the codebase reached for an iOS 17-only API. Raising the floor to 18 unlocks the newer SwiftUI Charts (chartXVisibleDomain) and SwiftData schema-migration improvements for later, costs nothing now. - Pick a distribution-grade bundle id. The dev build had been signed as
com.nakedmirror.app, which is fine for personal install but reads as squatting on the dead vendor’s name when seen by anyone else. Renamed tocom.squirrelyeye.nakedmirror, under the LLC I’d already set up for the publication target (notes.squirrelyeye.com). One config-file change, regenerate via xcodegen, commit.
Archive went smoothly. App Store Connect needed the HealthKit capability ticked in the App ID, the new bundle id registered, and the privacy nutrition labels for the six HealthKit quantity types declared. About 10 minutes of clicking through the developer portal. TF build uploaded, processing completed, install link sent to my phone, install ran.
Then I opened the app and saw a first-launch sheet, the calibration factor at 1.0, and no scan history.
The bundle-id doppelganger
This was actually the predicted behavior, but I’d half-expected iCloud Keychain to save me anyway. It didn’t, and the reason is one of those Apple-platform details that gets handwaved away in casual descriptions.
The iOS Keychain has a thing called the access group. Every entry is tagged with the access group of the app that wrote it, and entries are visible only to apps whose access group matches. The access group is not a property of your Apple ID: it’s a property of <team_id>.<bundle_id>. iCloud Keychain dutifully syncs an entry to all your devices, but on each device the entry remains scoped to that one combination. An app signed under a different bundle id can’t see it, even on the same device, under the same Apple ID, owned by the same person.
So at the moment I changed com.nakedmirror.app → com.squirrelyeye.nakedmirror:
- The old
X-User-KeyUUID (21E70CDA-…), written by the dev build, was synced via iCloud Keychain, but only to apps under the old access group. - The new TestFlight build called
UserKeyStore.shared, found nothing in its Keychain, generated a fresh UUID (D9F8A01A-…), and stashed it under the new access group. - The mock cloud, holding
21E70CDA-….jsonwith my real calibration factor of0.999099862andhealthKitEnabled: true, was asked by the new bundle for settings at keyD9F8A01A-…and answered (correctly) with 404.
flowchart LR
subgraph Old["Dev build: com.nakedmirror.app"]
OK[Keychain access group A] --> OldKey[X-User-Key 21E70CDA]
end
subgraph New["TF build: com.squirrelyeye.nakedmirror"]
NK[Keychain access group B] --> NewKey[X-User-Key D9F8A01A]
end
OldKey -. iCloud Keychain syncs only within group A .-> OK
NewKey --> Cloud[(Mock cloud /user/settings)]
Cloud -->|GET D9F8A01A| NotFound[404: not found]
OldKey -.->|real data still sits here, under 21E70CDA| CloudOld[(same mock cloud, different key)]So the cloud-settings backup tier did exactly what it was supposed to. The reconciliation hit the “cloud has nothing for this user, seed it” branch and pushed up an empty profile derived from whatever I typed in the first-launch sheet (which was nearly nothing; I cancelled out of it after the second field).
The instructive part: this is the failure mode the backup tier was specifically designed to handle, except the backup itself was scoped to a different key. The cloud was working perfectly; the index into it had changed.
The migration: one curl
Once I had both keys in hand, the migration was trivial. The mock-cloud daemon at /user/settings accepts PUT under any key. The old data was sitting at ~/mock-cloud/logs/user_settings/21E70CDA-….json; copy the relevant fields into a new PUT under D9F8A01A-… with an updatedAt set to right now:
NEW_KEY="D9F8A01A-DE62-4616-A1FD-2F3C24A89F81"STAMP=$(date -u +"%Y%m%dT%H%M%S.000000Z")curl -sk -X PUT \ -H "X-User-Key: $NEW_KEY" \ -H "Content-Type: application/json" \ -d "{ \"version\":1, \"updatedAt\":\"$STAMP\", \"name\":\"<my-name>\", \"heightCm\":175.88, \"unitSystem\":\"imperial\", \"dateOfBirth\":\"<my-dob>\", \"weightCalibrationFactor\":0.9990998620664758, \"healthKitEnabled\":true }" \ https://<cloud-ip>/user/settingsForce-quit the TF app, reopen, ProfileStore.load → reconcileWithCloud sees cloud.updatedAt > local.updatedAt, adopts the cloud values, re-mirrors them into the new Keychain access group so they’ll sync forward. Calibration factor back to its real value, height back to 175.88 from the slider-rounded 175, HK toggle re-enabled. The migration was a 90-second operation that could have been a re-onboarding flow lasting several minutes, and lossy on the calibration factor I couldn’t have remembered exactly.
If I ever ship this widely, that curl needs to become a UI: “I had a previous install: restore my settings” with a paste-your-old-key text field. For one user it’s faster as a curl. For ten users it’s a settings screen.
The second user
The TF build went out to one other person, call them BJL, the second-ever real user of the recovered app. They installed, opened, walked through the first-launch sheet, and immediately their record appeared on the mock cloud:
$ ssh m2 'ls -lt ~/mock-cloud/logs/user_settings/'total 32-rw-r--r-- 1 root staff 310 May 17 23:05 B868D085-10EB-463D-BDB0-6F9A5A764F15.json-rw-r--r-- 1 root staff 332 May 17 23:03 D9F8A01A-DE62-4616-A1FD-2F3C24A89F81.json-rw-r--r-- 1 root staff 332 May 17 22:43 21E70CDA-2BA4-4A9A-BD61-8C9E85024077.jsonThree keys, three UUIDs, none overlapping. The user-key scoping works exactly as the design said it would: different Apple IDs → different iCloud Keychain views → UserKeyStore calls UUID() for a fresh value on each fresh install. The 21E70CDA-… is my own orphan from the dev-build days; D9F8A01A-… is me on TF; B868D085-… is BJL.
There’s nothing they could have done to accidentally share data with me. The bundle is the same, the mock cloud is the same, the LAN host is the same, but the key is the user-identity boundary and it’s drawn at the Apple-ID level, automatically, by the OS, before my app gets to do anything.
The DOB typo, and the gap it exposed
BJL filled in their first-launch sheet correctly except for one field: they typed the wrong birth year, off by about a decade. They tried to fix it from the More → Account info screen and discovered they couldn’t. The profile editor had Name, Gender, Height, Units, and nothing for DOB. DOB was captured exactly once, at the first-launch sheet, with no edit path afterward.
This was a real gap I hadn’t noticed because my own DOB had been correct from the start, and the gap was hidden by the simple fact that nobody else had typed in their profile yet. First real user exposes the first real-user bug. Classic.
Two-step fix:
For BJL right now, same shape as my calibration migration: a curl PUT to the cloud with the corrected DOB and a fresh updatedAt:
curl -sk -X PUT \ -H "X-User-Key: B868D085-10EB-463D-BDB0-6F9A5A764F15" \ -d "{...all their existing fields..., \"dateOfBirth\":\"<bjl-dob>\"}" \ https://<cloud-ip>/user/settingsForce-quit, reopen, reconcile pulls it down. Done.
For everyone in the future, the actual fix: add a DOB row to ProfileEditView. Fifteen lines.
@State private var dateOfBirth: Date = .now private var dobRange: ClosedRange<Date> { let cal = Calendar.current let upper = Date() let lower = cal.date(byAdding: .year, value: -120, to: upper) ?? upper return lower...upper} // in Form body:DatePicker("Date of birth", selection: $dateOfBirth, in: dobRange, displayedComponents: .date)Plus a matching line in .onAppear and .onDisappear to load/save. The 120-year past range covers any plausible user without allowing future DOBs. The existing profileStore.save path picks it up automatically: Keychain write, fire-and-forget mock-cloud PUT, all three storage tiers updated.
What the cycle teaches
A few small things worth writing down.
Bundle id is a soft user-identity boundary. Not the way I’d have framed it before this cycle. I’d have called it “the app’s name in reverse-DNS form.” But everything Keychain-backed gets re-scoped when you change it, and for a personal-recovery app that’s stashing a lot in Keychain (X-User-Key, profile fields, soon-to-be-more), bundle id changes are user-affecting events and need explicit migration logic, the same way a backend would handle a user_id migration.
Backup tiers must be independent of identity boundaries to actually help. The cloud-settings backup tier still saved my calibration factor across the bundle-id change, but only because the data was still sitting on the server under the old key, waiting to be re-keyed. If the backup had been keyed to the bundle, the data would have been just as inaccessible as the Keychain entries. Identity-scoped backups need their own key-rotation story.
First real user, first real-user bug. I’ve worked enough with onboarding flows to know this is universal, and it still surprised me how immediately it surfaced. There’s a generation of bugs that only exist when somebody who isn’t you fills in a form. Build for those bugs by making every persistable field editable, not just the ones you happen to test.
One curl is fine for one user. Several curls would be a signal that the in-app path needs to grow. The DOB-editor fix landed in the same session as the DOB-typo bug; that’s the right ratio. Don’t pre-build the recovery UI for problems you haven’t seen yet.
Follow along
Procedure 26-AThe bundle-id move and the recovery steps above generalize to any iOS app that stores identity-scoped data in Keychain:
- 1Before archiving for TestFlight, pick the bundle id you’re willing to live with publicly, and make the switch once, deliberately, rather than letting the dev build’s throwaway id slide into production.
- 2Know that Keychain access groups are scoped to
<team_id>.<bundle_id>, not your Apple ID. iCloud Keychain syncs an access group’s entries across your devices, but a different bundle id is a different access group even on the same device, same Apple ID. - 3If you have a server-side backup keyed by a client-generated identifier (not the bundle id), you can migrate by hand: read the old record by its old key,
PUTthe same fields under the new key with a fresh client-side timestamp, and let your existing last-writer-wins reconciliation pull it down on next launch. - 4Expose every field you let a user set during onboarding in the regular settings/profile editor too. If a field is only ever writable once, at first launch, the first person who fat-fingers it has no way to fix it short of you hand-editing their record.
Agent prompts Procedure 26-A as prompts for a coding agent · 4 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.