Squirreleye LLC Squirreleye Service Notes Naked Labs Mirror · Revival Series Index Tools About
26
Section 26 · follows 25

TestFlight, the bundle-id doppelganger, and one curl that fixed a missing year

Written
2026-05-17
Commit
6020d22
§ 26.1

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:

  1. 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.
  2. 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 to com.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.

§ 26.2

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-Key UUID (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-….json with my real calibration factor of 0.999099862 and healthKitEnabled: true, was asked by the new bundle for settings at key D9F8A01A-… and answered (correctly) with 404.
Fig. 26-1
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.

§ 26.3

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:

Fig. 26-2
bash
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/settings

Force-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.

§ 26.4

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:

Fig. 26-3
bash
$ 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.json

Three 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.

§ 26.5

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:

Fig. 26-4
bash
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/settings

Force-quit, reopen, reconcile pulls it down. Done.

For everyone in the future, the actual fix: add a DOB row to ProfileEditView. Fifteen lines.

Fig. 26-5
swift
@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.

§ 26.6

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.

§ 26.7
§ 26.8
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.

1
Rename bundle id for distribution
Change the app's PRODUCT_BUNDLE_IDENTIFIER in project.yml from the throwaway dev value (e.g. com.nakedmirror.app) to the distribution-grade id you intend to ship publicly (e.g. com.squirrelyeye.nakedmirror), regenerate the Xcode project via xcodegen, and commit the change as its own commit before archiving for TestFlight. Also bump the deployment target to the latest major iOS version if a grep of the codebase for @available shows no API usage below it, and register the new bundle id plus any required capabilities (such as HealthKit) in the App ID on the Apple developer portal before the archive step, along with declaring the relevant privacy nutrition-label entries for each HealthKit quantity type the app reads or writes.
2
Audit Keychain access-group assumptions
Audit every place the codebase reads or writes Keychain-backed identity data, including UserKeyStore's X-User-Key UUID and ProfileStore's profile fields (weightCalibrationFactor, healthKitEnabled, name, height, dateOfBirth, etc.), and add a code comment near each one noting that the entry's Keychain access group is <team_id>.<bundle_id>, not the user's Apple ID: iCloud Keychain syncs within that access group only, so a bundle id change (or a different signing team) makes every existing entry invisible to the new build, even on the same device and Apple ID. Treat any future PRODUCT_BUNDLE_IDENTIFIER change as a user-affecting migration event, equivalent to rotating a backend user_id, not a cosmetic rename, and note that in the same comments.
3
Script the user-key migration
Write a migration script (or reusable curl snippet) that migrates a user's mock-cloud settings record from an old X-User-Key to a new one after a bundle-id change: read the stored JSON directly from ~/mock-cloud/logs/user_settings/<old-key>.json to collect all fields, then PUT those same fields to /user/settings under the new key with Content-Type: application/json, a freshly generated updatedAt timestamp in UTC matching the client's stamp format (e.g. date -u +"%Y%m%dT%H%M%S.000000Z"), and the X-User-Key header set to the new key. After the PUT, force-quit and relaunch the app so ProfileStore.load -> reconcileWithCloud sees cloud.updatedAt greater than local.updatedAt, adopts the migrated values, and re-mirrors them into the new build's Keychain access group.
4
Make every onboarding field editable
Audit the first-launch onboarding sheet against the regular profile editor (ProfileEditView) and find any field captured only at onboarding with no later edit path; in this app that was date of birth. Add a DatePicker row bound to @State private var dateOfBirth: Date, constrained to a dobRange computed property spanning from 120 years ago to today (via Calendar.current.date(byAdding: .year, value: -120, to: Date()) through Date()), with displayedComponents: .date. Wire it into the view's existing .onAppear (load from the profile store) and save action (.onDisappear or equivalent) so it flows through the same profileStore.save path that already writes Keychain and fires the mock-cloud PUT, with no separate persistence logic needed.