Skip to content

iOS team change: exact specs for the prep work

Decisions made after this spec (2026-09-25). Where they differ, they override the text below. - Item B (Nutri-E device identity) is dropped. The operator is Nutri-E's only paying subscriber: the production SUBSCRIPTIONS store holds exactly 1 record. After the first Dashecorp update he taps Restore Purchases once. Free users get a fresh free allowance, and their logged data stays on the phone. No App Group, no migration release. - Item D (Reward-E) shrinks to a one-off script. Only the operator's family pays. The script keeps Apple's transfer_sub path, so Firebase UIDs and the family data are preserved, but it drops the GitHub Actions tool, the _siwaMigration state machine and the daily re-runs. The operator runs it twice, with the Sign in with Apple keys. - No Count-E pilot first. All four apps transfer together, once the Dashecorp membership is active. - Item A drops the team-suffixed ASC secrets (A0a). Secret names stay the same, and the values are swapped at cutover.

This spec covers the code and config work for the App Transfer of Nutri-E, Fast-E, Count-E and Reward-E from Invotek AS (Team ID V9GL74NMBR) to Dashecorp AS (Team ID unknown, written NEW_TEAM). It builds on ios-app-transfer.md (the program reference) and ios-transfer-factbase.json, and does not repeat their eligibility and cleanup rules.

  • Revision 2. Eight verifiers tried to refute revision 1 against the code and the primary sources. The results are in ~/jobs/ios-prep-spec-v2/verify.json, and each of the 48 flagged items is handled here. changes.md gives one line per item. Design bugs are fixed in the design, the pseudocode, the tests and the issue drafts:
  • the Matchfile, the Swift regex and the guard (A);
  • the B2 retry and latch, the migrated-ID case, and the refund path (B);
  • the APNs PATCH rule (C);
  • the SIWA window and the revoked-user gate, and the App Check window (D).
  • Code read: read-only clones in ~/repos/ios-move/: nutri-e 3c82647, reward-e f01b46c, count-e 5325986, fast-e 80bc166, drink-e 671dc66, heart-e 0cfb397, ios-sdk 5d55132, dashe-cutie 20bfc6a.
  • References: repo/path:line at those commits. The dashe- prefix is dropped where it is unambiguous.
  • Evidence tags: VERIFIED means an Apple, Firebase, RevenueCat, GitHub, XcodeGen or fastlane page or source states it (URL given), or the code shows it (file:line). UNVERIFIED means no primary source states it, or it is our inference. "(review)" marks a source the verifiers fetched that this revision did not fetch again.
  • Quotes: the Apple quotes were taken on 2026-09-25 through Apple's DocC JSON endpoints and the forums. Check the punctuation before pasting any of them into user-facing text.
  • Secrets: none are reproduced here. Where a file holds one, only the file:line is given.

0. Read this first

0.1 Terms

Term Meaning
NEW_TEAM Dashecorp's 10-character Apple Team ID, not yet known
T0 Invotek's Account Holder initiates the transfer of one app
TA Dashecorp's Account Holder accepts the transfer (recorded as transfer_accepted_at)
T1 The transfer is complete: "Processing App Transfer" (up to 2 business days after TA) is done
U1 The first NEW_TEAM-signed update of that app is released on the App Store
NB1 The first NEW_TEAM-signed build of that app runs anywhere: TestFlight, App Review, then U1. NB1 comes before U1
60-day window TA to TA+60 days, the stricter of Apple's two anchors (D.2.4). Sign in with Apple transfer identifiers can be generated from before T0 until TA+60, and exchanged from T1 until TA+60

0.2 What breaks, and when

The distinction that matters is between T1 (server-side credentials stop working) and U1 (on-device state tied to the App ID prefix is lost). Nothing on the device changes at T1. A device that runs a NEW_TEAM-signed TestFlight build (NB1) hits the U1 effects on that device earlier.

Dependency Apps Breaks at Evidence
CI upload with the Invotek ASC key and Invotek profiles all 4 T1 The app leaves the sender's account (Overview of app transfer, VERIFIED). New profiles are needed because "the transferred App ID will now have your Team ID as the App ID prefix" (TN2311, VERIFIED). How the upload fails is UNVERIFIED
Cuti-E reply pushes signed with Invotek APNs key 635XA3L42Q Nutri-E, Fast-E, Reward-E (per the fact base; the key-to-app mapping is D1 data, UNVERIFIED, see C.1) T1, after a short propagation delay VERIFIED, Apple DTS: "After a short (but non-zero) propagation delay following the completion of the transfer, the credentials of the old team (either certificates or provider tokens) will stop working", and "once the transfer is complete, the old team can no longer send pushes to the app regardless of version, update, or install state" (forum 744468). DTS says the same in 760355 and 795723 (review). Caveat from 795723 (review): open connections from the old team "might still be able to send notifications for a while", so a test push that succeeds at T1 proves nothing until connections are recycled (C.3)
RevenueCat transaction validation with the Invotek In-App Purchase key Nutri-E, Reward-E around T1 RevenueCat staff: "As soon as the Apple Developer Account change is complete, update the in-app purchase key in RevenueCat" (community, 2026-02-20, VERIFIED staff answer). The quote says when to update the key, not when validation breaks. The break time is inference (UNVERIFIED), and it may start while the transfer is processing. Both apps use StoreKit 2 (purchases-ios 5.x default), so the shared-secret fallback probably does not help and purchases likely fail until the Dashecorp key is in (E)
Sign in with Apple sub and relay email Reward-E T1 "After the app transfer is completed, the ID token will contain the user ID scoped to the recipient team" (Apple DTS, forum 765778, VERIFIED)
Keychain items in the default access group Nutri-E device ID; Reward-E Firebase Auth session; Cuti-E device token (all apps) U1 (per device: first run of a NEW_TEAM-signed build) "The keychain access will only be lost after an update to the app is released on the App Store" (TN2311, VERIFIED). Firebase Auth 10.29.0, which Reward-E pins, keeps its session in a keychain item with no access group: VERIFIED from the SDK source (FIRAuthKeychainServices.m@10.29.0, genericPasswordQueryWithKey: sets only class, account and service; see D.1.4)
App Attest RP ID (Firebase App Check) Reward-E NB1 for App Check validation; U1 for the user base The RP ID is "A hash of your app's App ID, which is the concatenation of your app's App ID prefix, a period, and your app's CFBundleIdentifier value" (Validating apps, VERIFIED). Firebase holds a single Team ID per iOS app (IosApp.teamId, review), so one of the two build populations fails validation at any moment (D.4)
App Groups (widgets; Nutri-E keychain group) Count-E, Fast-E, Reward-E; Nutri-E after B U1. The group can only move after T1 "Once an app has completed transfer, the app group can be deleted from the transferor's account and registered to the recipient's account" (Overview of app transfer, VERIFIED). The delete-then-register gap is a steal window (B.4, A.6)
StoreKit config team and the Reward-E test that asserts it Nutri-E, Reward-E the cutover PR (CI goes red) reward-e/ios/RewardETests/SubscriptionConfigValidationTests.swift:150-156
App Store Server Notifications URL Nutri-E, Reward-E UNVERIFIED Apple does not say whether the URL survives a transfer. Re-check it at T1

0.3 Corrections to ios-app-transfer.md found in this pass

Fold these into the reference doc.

  1. Nutri-E sets the team in 8 places, not 1. DEVELOPMENT_TEAM = V9GL74NMBR appears at nutri-e/ios/Nutri-E/Nutri-E.xcodeproj/project.pbxproj:315,379,411,453,491,513,534,554: project, app, unit-test and UI-test targets, Debug and Release each.
  2. Nutri-E's "device secret" is not a secret. getDeviceSecret() computes SHA-256 of the device ID plus a salt that is compiled into the app (nutri-e/ios/Nutri-E/Nutri-E/Services/DeviceAuthService.swift:14-16,50-65,79-87). The worker checks the same hash (nutri-e/cloudflare-worker-openai/worker.js:378-384,594-605). Only the device ID needs to survive. Knowing a device ID is enough to act as that device. This is a pre-existing weakness, filed separately (see Open questions). The review found the salt in plaintext in 4 more repo files, which strengthens "not a secret".
  3. The App Group keychain method is now Apple DTS guidance, not an untested idea. Quinn "The Eskimo!" rewrote the "Transfer Your App to Another Team" section of thread 706128 on 2026-03-31. VERIFIED (Apple employee).
  4. Its step 7 is the claim that matters: after the transferee's update, the app "will have access to your app group, and hence your keychain items".
  5. The survival depends on the app group itself being transferred (step 5).
  6. The sentence "an app group is tied to your team, not your App ID prefix" is from the same thread's other section, "Migrate From a Unique App ID Prefix to Your Team ID". That section covers a prefix change inside one team, so it is not the evidence for a transfer.
  7. Quinn also calls the method "bleeding edge" (thread 821062).
  8. The caveats are listed in B.4.
  9. The Cuti-E SDK needs no change for the transfer:
  10. No dashe app turns on App Attest. All call configure(appId:), and the default is useAppAttest: false (ios-sdk/Sources/CutiE/CutiE.swift:57 on main 2.4.1; :56 in 2.3.1, the version the apps ship). Call sites: e.g. nutri-e/.../Services/CutiEService.swift:37, reward-e/ios/RewardE/Services/CutiEService.swift:26.
  11. The public SDK has no DeviceCheck code at all. CutiEDeviceCheck.swift exists only in the stale private copy dashe-cutie/ios/Sources/. Caveat: dashe-cutie/ios/release-public.sh:34-35 replaces the public SDK's Sources/ with that stale copy, so running it would bring CutiEDeviceCheck.swift back. Do not run it during the migration.
  12. A lost device token is re-issued unchanged, because re-registration returns the existing token (dashe-cutie/cloudflare-worker/src/handlers/device-tokens.js:75-99).
  13. So the "per-team DeviceCheck credentials" code change in the reference doc is not needed.
  14. Do not change the Cuti-E global APNS_* env at a dashe cutover. It serves only the Cuti-E Admin fallback and the Feedback App (src/utils/notifications/apns.js:83-92, queue.js:650-658,719-726). Both stay with Invotek for now. When the reference doc's later step recreates Cuti-E Admin and the Feedback App under Dashecorp, that step changes the env.
  15. APNs breaks at T1, not at U1, and this is now VERIFIED by Apple DTS (0.2). Apple also says: "For transferred apps, close and recreate any existing connections from your push provider server to APNs. If you don't, APNs can't start accepting push requests from the new team", and "You can't map a connection to APNs to multiple teams. If you manage APNs for multiple developer accounts, open separate connection pools for each of them" (APNs token auth, VERIFIED). After Fast-E's T1 the Cuti-E worker serves both teams, so per-team connection separation is a requirement (C.2).
  16. Reward-E's DeviceCheck fallback is dead code. The deployment target is iOS 16.0 (reward-e/ios/project.yml:5) and the fallback runs only below iOS 14 (AppCheckService.swift:64-68). No Firebase DeviceCheck key is needed.
  17. Reward-E signs out every signed-in iOS user once, per device, when the device first runs a NEW_TEAM-signed build.
  18. That starts at U1, and earlier for TestFlight testers.
  19. Firebase Auth keeps its session in the default keychain group. This is VERIFIED from the SDK source (D.1.4).
  20. Free-tier users with local data only are not affected: they need no Firebase session (reward-e/ios/RewardE/Models/AppState.swift:185-187). Android users are not affected either.
  21. So the Sign in with Apple re-link must finish before U1, not just within 60 days.
  22. Two more docs name the team: fast-e/CLAUDE.md:212,260 and drink-e/CLAUDE.md:130. The reference doc's "Docs naming the team" list misses both.
  23. Nutri-E's StoreKit config has a wrong app ID. _applicationInternalID is 6738736024, but the ASC app is 6753125391 (nutri-e/ios/Nutri-E/Nutri-E/Configuration.storekit:10). This is pre-existing drift; fix it in A6.
  24. Reward-E's dev Firebase project is reward-e-dev, not reward-e-app-dev. Sources: reward-e/.firebaserc, ios/RewardE/GoogleService-Info-Dev.plist:14, README.md:61 and ios/project.yml:146. Only the stale reward-e/CLAUDE.md:297 says reward-e-app-dev.
  25. Nutri-E's live RevenueCat project is proj2c32896f. proj61d576be is archived: "Archived project proj61d576be do not use" (nutri-e/docs/subscriptions.md:22-31). The fact base's "no In-App Purchase key" observation came from the archived project and says nothing about the live one (E).

A. Team ID as one setting per app repo (all 6 app repos)

A.1 Inventory: every place the team is hard-coded

In the Change column, del means delete the line, read means it already uses $TEAM_ID and now gets it from the xcconfig, and mirror means the value must equal the xcconfig value (the guard enforces this). The review confirmed every line number below except the corrections now folded in (heart-e secrets, drink-e generate-match-profiles.yml).

Repo File:line What Change
count-e ios/project.yml:31 DEVELOPMENT_TEAM: V9GL74NMBR del, and add configFiles
count-e .github/workflows/build-and-upload.yml:14 env TEAM_ID: V9GL74NMBR del, and add a read step
count-e same file :63, :117, :128 match team_id, xcodebuild DEVELOPMENT_TEAM, ExportOptions teamID read
count-e same file :57-67 match git_url with no git_branch (the default master is Invotek's store) add git_branch:"$TEAM_ID"
count-e same file :77-79,146-147 secrets.ASC_KEY_ID/ASC_ISSUER_ID/ASC_KEY_P8_BASE64 (Invotek ASC key) select by team
count-e CLAUDE.md:147 doc point to the xcconfig
fast-e ios/project.yml:63 DEVELOPMENT_TEAM del
fast-e .github/workflows/build-and-upload.yml:14 / :63,:117,:128 / :77-79,:146-147 env / uses / ASC secrets del / read / select by team
fast-e CLAUDE.md:212,260 doc point to the xcconfig
heart-e ios/project.yml:31 DEVELOPMENT_TEAM del
heart-e .github/workflows/build-and-upload.yml:14 / :63,:121,:133 / :77-79,:151-152 env / uses / ASC secrets. The upload-step secrets are at :151-152, not :146-147: heart-e's archive step has four extra MARKETING_VERSION lines del / read / select by team
heart-e CLAUDE.md:118 doc point to the xcconfig
drink-e ios/project.yml:30 DEVELOPMENT_TEAM del
drink-e .github/workflows/build-and-upload.yml:14 / :63,:117,:128 / :77-79,:146-147 env / uses / ASC secrets del / read / select by team
drink-e .github/workflows/generate-match-profiles.yml:4,6,16,121,147-148 comments and a step name that name the team, cert C8788TMK34 and branch master as the store rewrite: the store is the branch named after the team
drink-e same file :30-33, :34-37 team_id input default V9GL74NMBR; expected_cert_id default C8788TMK34 required inputs, no defaults
drink-e same file :157-159 team_id:"$TEAM_ID", git_branch:"master" git_branch:"$TEAM_ID"
drink-e same file :68-69,:80-81,:125-126,:167-168 secrets.ASC_* in four steps select by team_id
drink-e CLAUDE.md:130 doc point to the xcconfig
reward-e ios/project.yml:82 DEVELOPMENT_TEAM del
reward-e .github/workflows/build-and-upload.yml:14 / :63,:117,:128 / :77-79,:146-147 env / uses / ASC secrets del / read / select by team
reward-e ios/fastlane/Matchfile:5 team_id("V9GL74NMBR") del (CI passes team_id and git_branch explicitly; see A.3)
reward-e ios/RewardE/Configuration.storekit:11 "_developerTeamID" : "V9GL74NMBR" mirror
reward-e ios/RewardETests/SubscriptionConfigValidationTests.swift:150-156 test asserts the literal compare to the xcconfig
reward-e scripts/validate-subscription-config.sh:113-118 greps the literal compare to the xcconfig
reward-e .github/workflows/subscription-validation.yml:5-13 path filter lacks ios/Team.xcconfig, which the validator will read add the path (push and pull_request)
reward-e CLAUDE.md:365, AppStore/SUBMISSION_CHECKLIST.md:183 doc point to the xcconfig
nutri-e ios/Nutri-E/Nutri-E.xcodeproj/project.pbxproj:315,379,411,453,491,513,534,554 8 × DEVELOPMENT_TEAM = V9GL74NMBR; del, and add baseConfigurationReference
nutri-e .github/workflows/build-and-upload.yml:13 / :57,:168,:179 / :59 / :67-69,:196-197 env / uses / git_branch:"master" / ASC secrets del / read / "$TEAM_ID" / select by team
nutri-e ios/Nutri-E/fastlane/Appfile:5,8, Fastfile:41,96 legacy fastlane lanes; CI does not use them delete the files
nutri-e ios/ExportOptions.plist:8 (and a legacy profile at :25-26), ios/Nutri-E/ExportOptions.plist:8 legacy; CI generates its own ExportOptions (build-and-upload.yml:174-188) delete
nutri-e ios/Nutri-E/deploy.sh:15, deploy-api.sh:15, test-build.sh:15 read the legacy ../ExportOptions.plist delete
nutri-e scripts/setup-testflight-secrets.sh:28, ios/Nutri-E/setup.sh:41 (comment) legacy delete, or drop the line
nutri-e ios/Nutri-E/Nutri-E/Configuration.storekit:11 _developerTeamID mirror; also fix :10 to 6753125391
nutri-e ios/Nutri-E/DEPLOYMENT.md:67, docs/testflight.md:181 doc point to the xcconfig
count-e, drink-e, heart-e, nutri-e .github/workflows/monitor-xcode-cloud.yml dead (references secrets that are not set, per the fact base) delete
  • Found nowhere in the six app repos: AppIdentifierPrefix, TeamIdentifierPrefix, keychain-access-groups, iCloud containers. Checked by grep; see also the fact base.
  • Found only in dashe-cutie (Cuti-E apps, recreated later, outside this item):
  • signing: ios-admin-app/.../project.pbxproj:369,390,533,565, and super-admin-app/ios/project.yml:15 and project.pbxproj:138,271,339;
  • docs, examples and placeholders that sign nothing: GOOGLE_AUTH.md:17, CLAUDE.md:630 (APNS_TEAM_ID example), admin-dashboard/mock-apns-settings.html:233 (input default) and android-admin-app/.../ui/screens/AppDetailScreen.kt:497 (placeholder text).
  • ios-sdk has no hits.

A.2 Design: one file per repo

One file holds the team: ios/Team.xcconfig (Nutri-E: ios/Nutri-E/Team.xcconfig, next to the .xcodeproj).

// Apple Developer team that signs this app. Moving the app to another team is a one-line change here.
// Xcode, CI (TEAM_ID), fastlane match (team and git branch), ExportOptions, the ASC secret selection and
// scripts/check-team-id.sh all read this file. Never write the Team ID anywhere else.
DEVELOPMENT_TEAM = V9GL74NMBR
  1. Xcode. Each target inherits DEVELOPMENT_TEAM from the project-level xcconfig. The per-target CODE_SIGN_STYLE and PROVISIONING_PROFILE_SPECIFIER stay as they are: "match AppStore <bundle>" names the bundle, not the team.
  2. XcodeGen apps use a root configFiles key. VERIFIED: it is documented in XcodeGen's ProjectSpec.md, and a local XcodeGen and xcodebuild run resolved the xcconfig team on all 4 Count-E targets. Flipping only the xcconfig changed all four (review).
  3. Nutri-E sets baseConfigurationReference on its two project-level configurations.
  4. CI. A step right after checkout reads the xcconfig, checks that it has exactly one DEVELOPMENT_TEAM line, and exports TEAM_ID to GITHUB_ENV. All existing $TEAM_ID uses stay unchanged.
  5. fastlane match: one git branch per team in dashecorp/ios-certs, named after the Team ID (git_branch:"$TEAM_ID").
  6. VERIFIED: fastlane documents git_branch for multiple teams, the default branch is master, and match creates a new orphan branch on its first write run (fastlane docs and source, review).
  7. Prep: create branch V9GL74NMBR from master (same content, so nothing needs decrypting). Without it, the first readonly run would meet an empty orphan branch. The first Dashecorp match write run creates branch NEW_TEAM.
  8. MATCH_PASSWORD is a repo passphrase, not an Apple credential, so both branches can use the same one.
  9. Explicit parameters win over Matchfile values. VERIFIED: fastlane's ConfigurationFile#method_missing begins with return if self.options.key?(method_sym) (configuration_file.rb).
  10. ASC API key: selected by team. The secrets get team-suffixed names, provisioned by OpenTofu: ASC_KEY_ID_<TEAM>, ASC_ISSUER_ID_<TEAM>, ASC_KEY_P8_BASE64_<TEAM>. Workflows read ${{ secrets[format('ASC_KEY_ID_{0}', env.TEAM_ID)] }}.
  11. This lets the Dashecorp key be provisioned before T1, so the cutover PR stays one line.
  12. VERIFIED: the GitHub docs' contexts table allows both secrets and env in step env, and the actions/runner source shows that GITHUB_ENV values reach the env context of later steps (review). The docs never say the second part outright, so the Count-E PR's CI run is still the proof.
  13. Fallback, if it does not work: keep today's names and swap the values in dashe-secrets.tf at each cutover. That costs one OpenTofu apply per app, synchronised with the PR.
  14. StoreKit config files (Nutri-E, Reward-E) are JSON written by Xcode's App Store Connect sync and cannot include an xcconfig. They keep a mirror of the value, and the guard fails CI if the mirror differs. The cutover in these two repos is therefore two lines in one PR.
  15. Guard. scripts/check-team-id.sh runs in its own workflow, .github/workflows/team-id-guard.yml, which triggers on every pull request and every push to main with no path filter. Make it a required check.
  16. Why not ci.yml: in all five XcodeGen repos ci.yml runs only for ios/** and .github/workflows/ci.yml (count-e/.github/workflows/ci.yml:3-8, the same in fast-e, heart-e, drink-e and reward-e). A PR that touches only a workflow or a script would skip the guard, and those are exactly the files where the team was hard-coded.
  17. Nutri-E has no ci.yml; its PR workflows are test.yml and lint.yml.
  18. What it checks:
    • Team.xcconfig has exactly one valid DEVELOPMENT_TEAM line;
    • no Team ID sits next to a team key outside Team.xcconfig;
    • the current and every past team value, read from the xcconfig's git history, appears nowhere outside the allowed files. This catches a leftover old Team ID after a cutover even when no team key is on the same line;
    • the StoreKit mirrors match;
    • a git grep error fails the job and is never read as "no match".

Cutover per app (at T1, after the Dashecorp cert, profiles, secrets and App Groups exist):

-DEVELOPMENT_TEAM = V9GL74NMBR
+DEVELOPMENT_TEAM = NEW_TEAM

Plus _developerTeamID in the .storekit mirror for Nutri-E and Reward-E.

A.3 Exact changes (Count-E shown in full; the other repos differ only in the lines listed in A.1)

ios/project.yml (XcodeGen apps):

 settings:
   base:
     MARKETING_VERSION: "1.2.0"
     CURRENT_PROJECT_VERSION: "1"
-    DEVELOPMENT_TEAM: V9GL74NMBR
     CODE_SIGN_STYLE: Automatic
@@
+configFiles:
+  Debug: Team.xcconfig
+  Release: Team.xcconfig

.github/workflows/build-and-upload.yml:

 env:
   APP_IDENTIFIER: no.invotek.CountE
   SCHEME: CountE
-  TEAM_ID: V9GL74NMBR
@@
       - uses: actions/checkout@v4
+
+      - name: Read signing team (ios/Team.xcconfig is the only source)
+        run: |
+          CFG=ios/Team.xcconfig
+          [ "$(grep -cE '^[[:space:]]*DEVELOPMENT_TEAM[[:space:]]*=' "$CFG")" = 1 ] \
+            || { echo "::error::$CFG needs exactly one DEVELOPMENT_TEAM line"; exit 1; }
+          TEAM_ID=$(sed -n 's/^DEVELOPMENT_TEAM[[:space:]]*=[[:space:]]*\([A-Z0-9]\{10\}\)[[:space:]]*$/\1/p' "$CFG")
+          [ -n "$TEAM_ID" ] || { echo "::error::$CFG needs 'DEVELOPMENT_TEAM = <10-char Team ID>'"; exit 1; }
+          echo "TEAM_ID=$TEAM_ID" >> "$GITHUB_ENV"
@@ (match step)
                 team_id:"$TEAM_ID" \
+                git_branch:"$TEAM_ID" \
                 git_url:"https://github.com/dashecorp/ios-certs" \
@@ (build-number step :77-79 and upload step :146-147; heart-e :151-152)
-          ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
-          ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
-          ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
+          ASC_KEY_ID: ${{ secrets[format('ASC_KEY_ID_{0}', env.TEAM_ID)] }}
+          ASC_ISSUER_ID: ${{ secrets[format('ASC_ISSUER_ID_{0}', env.TEAM_ID)] }}
+          ASC_KEY_P8_BASE64: ${{ secrets[format('ASC_KEY_P8_BASE64_{0}', env.TEAM_ID)] }}
         run: |
+          [ -n "$ASC_KEY_ID" ] || { echo "::error::No ASC key provisioned for team $TEAM_ID (secret ASC_KEY_ID_$TEAM_ID)"; exit 1; }

.github/workflows/team-id-guard.yml (new, every repo):

name: Team ID guard
on:
  pull_request:
  push:
    branches: [main]
jobs:
  team-id-guard:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # the guard reads past Team IDs from the history of Team.xcconfig
      - run: scripts/check-team-id.sh   # Nutri-E: scripts/check-team-id.sh ios/Nutri-E/Team.xcconfig

Nutri-E project.pbxproj (no XcodeGen):

  1. Add a PBXFileReference for Team.xcconfig, with lastKnownFileType = text.xcconfig, to the main group.
  2. Add baseConfigurationReference = <ref> /* Team.xcconfig */; to the project-level Debug and Release XCBuildConfiguration blocks. These are the ones starting at :281 and :345, referenced by the Build configuration list for PBXProject "Nutri-E" at :572.
  3. Delete all 8 DEVELOPMENT_TEAM = V9GL74NMBR; lines.
  4. In build-and-upload.yml:
  5. delete :13;
  6. add the read step with CFG=ios/Nutri-E/Team.xcconfig;
  7. change :59 from git_branch:"master" to git_branch:"$TEAM_ID";
  8. switch :67-69 and :196-197 to the team-suffixed secrets.

Reward-E extras:

  1. ios/fastlane/Matchfile: delete :5 team_id("V9GL74NMBR") and do not read the xcconfig from Ruby.
  2. Revision 1 used File.expand_path("../Team.xcconfig", __dir__). That breaks every Reward-E TestFlight build.
  3. fastlane evaluates the Matchfile with a bare eval(content) (VERIFIED, configuration_file.rb), so __dir__ is nil. The path then resolves against the cwd, which is ios/ (build-and-upload.yml:50), and becomes <repo>/Team.xcconfig. File.read raises Errno::ENOENT, and fastlane re-raises it. The review reproduced this on Ruby 2.6.10 and 3.4.6.
  4. CI already passes team_id:"$TEAM_ID" (:63) and, after A5, git_branch:"$TEAM_ID". Explicit parameters win (A.2.3). Add a comment in the Matchfile instead:
    # team_id and git_branch come from ios/Team.xcconfig via CI (.github/workflows/build-and-upload.yml).
    # Do not set them here: this file is eval'd without a path, so it cannot locate Team.xcconfig itself.
    
  5. ios/RewardETests/SubscriptionConfigValidationTests.swift:150-156 replaces testStoreKitConfigTeamId.
  6. The file already finds sources through #filePath (:65-69).
  7. Reward-E builds in Swift 5 mode (ios/project.yml:84 SWIFT_VERSION: "5.9") without SWIFT_ENABLE_BARE_SLASH_REGEX, so a bare /…/ regex literal does not parse. Use the extended delimiter #/…/#. The review compiled both forms with swiftc -swift-version 5: the bare form fails with "expected expression", and #/…/# compiles.
  8. A missing file fails the test instead of skipping it, so a wrong path cannot pass silently.
    func testStoreKitConfigTeamIdMatchesTeamXcconfig() throws {
        let config = try loadStoreKitConfig()
        let xcconfig = URL(fileURLWithPath: #filePath)
            .deletingLastPathComponent()   // RewardETests/
            .deletingLastPathComponent()   // ios/
            .appendingPathComponent("Team.xcconfig")
        let text = try String(contentsOf: xcconfig, encoding: .utf8)
        // Extended delimiters: this target builds in Swift 5 mode without bare-slash regex literals.
        let team = try XCTUnwrap(
            text.firstMatch(of: #/(?m)^DEVELOPMENT_TEAM\s*=\s*([A-Z0-9]{10})\s*$/#)?.1,
            "ios/Team.xcconfig needs one 'DEVELOPMENT_TEAM = <Team ID>' line"
        )
        XCTAssertEqual(config.settings._developerTeamID, String(team),
                       "Configuration.storekit _developerTeamID must mirror ios/Team.xcconfig")
    }
    
  9. scripts/validate-subscription-config.sh:113-118: read TEAM=$(sed -n ...) from ios/Team.xcconfig, with the same pattern as the CI read step, then grep for "_developerTeamID" : "$TEAM".
  10. subscription-validation.yml:5-13: add ios/Team.xcconfig to both path lists.

generate-match-profiles.yml (the operator tool that lives in dashe-drink-e):

  1. Remove the defaults V9GL74NMBR (:33) and C8788TMK34 (:37). team_id and expected_cert_id become required inputs. When allow_new_cert=true, expected_cert_id may be empty.
  2. Change git_branch:"master" (:159) to git_branch:"$TEAM_ID".
  3. Select the ASC secrets by team_id in all four steps (:68-69, :80-81, :125-126, :167-168), the same way as in A.2.
  4. Rewrite the comments and the step name that call master "the store" (:4, :6, :16, :121, :147-148). The store is the branch named after the team, and the header comment names no team or cert.
  5. Add a boolean input allow_new_cert (default false). When it is true, PRE-FLIGHT skips the "expected cert present" check, and POST-FLIGHT requires exactly one new distribution certificate.
  6. This is how the first Dashecorp run creates Dashecorp's single shared distribution cert.
  7. The safety model at :10-21 stays as it is for every other run.

A.4 Tests to write first (TDD)

  1. scripts/check-team-id.sh (new, each repo). Write it and add only Team.xcconfig, then run it before any other change.
  2. It must fail. It lists only the lines that contain the literal team value outside *.md, docs/** and the allowed files. It does not list doc sites, the $TEAM_ID read sites, the missing git_branch, the ASC secret sites, monitor-xcode-cloud.yml or the cert default; A.1 remains the checklist for those.
  3. Expected output, from the review's run of the revision 1 logic, which lists the same sites. A line appears once per check that hits it:
    • count-e: build-and-upload.yml:14, ios/project.yml:31;
    • drink-e: those plus generate-match-profiles.yml:6,33;
    • reward-e: those plus Matchfile:5, SubscriptionConfigValidationTests.swift:153 and validate-subscription-config.sh:114,115,117;
    • nutri-e (with the argument ios/Nutri-E/Team.xcconfig): the 8 pbxproj lines, Appfile:5,8, Fastfile:41,96, setup.sh:41, setup-testflight-secrets.sh:28 and both ExportOptions.plist:8.
  4. With no xcconfig at all, it fails even earlier, on the missing file. After the change it must pass.
#!/usr/bin/env bash
# The Apple Team ID lives only in Team.xcconfig. Fails with the list of violations.
# No `set -e`: git grep's exit status is inspected explicitly (0 = hits, 1 = none, other = error).
set -uo pipefail
CFG="${1:-ios/Team.xcconfig}"   # Nutri-E passes ios/Nutri-E/Team.xcconfig
[ -f "$CFG" ] || { echo "::error::$CFG is missing"; exit 1; }
n=$(grep -cE '^[[:space:]]*DEVELOPMENT_TEAM[[:space:]]*=' "$CFG")
TEAM=$(sed -n 's/^DEVELOPMENT_TEAM[[:space:]]*=[[:space:]]*\([A-Z0-9]\{10\}\)[[:space:]]*$/\1/p' "$CFG")
if [ "$n" != 1 ] || [ -z "$TEAM" ]; then
  echo "::error::$CFG needs exactly one 'DEVELOPMENT_TEAM = <10-char Team ID>' line (found $n)"; exit 1
fi
fail=0
check() {   # $1 = message; the rest are git grep arguments
  local msg=$1 out rc; shift
  out=$(git grep -nE "$@"); rc=$?
  case $rc in
    0) echo "::error::$msg"; echo "$out"; fail=1 ;;
    1) ;;
    *) echo "::error::git grep failed (exit $rc); refusing to pass"; exit 2 ;;
  esac
}
EXCL=(":(exclude)$CFG" ':(exclude)*.md' ':(exclude)docs/**' ':(exclude)scripts/check-team-id.sh')
KEYS='(DEVELOPMENT_TEAM|DevelopmentTeam|TEAM_ID|team_id|teamID|itc_team_id)'
check "Team ID hard-coded next to a team key outside $CFG:" \
  "${KEYS}[^A-Za-z0-9]{1,6}[A-Z0-9]{10}([^A-Za-z0-9]|\$)" -- . "${EXCL[@]}"
# The current team plus every team this repo has used (history of $CFG; needs fetch-depth: 0).
TEAMS=$( { echo "$TEAM"; git log -p --format= -- "$CFG" \
  | sed -n 's/^[-+]DEVELOPMENT_TEAM[[:space:]]*=[[:space:]]*\([A-Z0-9]\{10\}\).*/\1/p'; } | sort -u | paste -sd'|' -)
check "A current or past Team ID ($TEAMS) appears outside $CFG:" "($TEAMS)" -- . "${EXCL[@]}" ':(exclude)*.storekit'
while IFS= read -r f; do
  v=$(sed -n 's/.*"_developerTeamID"[[:space:]]*:[[:space:]]*"\([A-Z0-9]*\)".*/\1/p' "$f")
  [ "$v" = "$TEAM" ] || { echo "::error::$f _developerTeamID='$v' must mirror $CFG ($TEAM)"; fail=1; }
done < <(git ls-files '*.storekit')
exit $fail

Guard self-tests (a scripts/test-check-team-id.sh run by the same workflow, on a temporary copy of the repo): 1. Two DEVELOPMENT_TEAM lines → exit 1. Revision 1 passed this with exit 0 (review). 2. GIT_DIR=/nonexistent scripts/check-team-id.sh → exit 2. That is a git grep error, and it must not pass. 3. After a committed flip to ABCDE12345, leave V9GL74NMBR in a workflow comment → exit 1 (the past-team check). 4. The mirror differs → exit 1. 2. Resolved-settings check in the existing macOS CI job, after xcodegen generate. For every target, e.g. Count-E's CountE, CountEWidget, CountETests and CountEUITests:

xcodebuild -project ios/<Proj>.xcodeproj -target "$T" -showBuildSettings | awk '$1=="DEVELOPMENT_TEAM"{print $3}'
The output must equal the xcconfig value. Before the change this passes by accident, so it is a regression test and carries no TDD signal. The flip test below gives the signal. 3. Flip test (in the PR, local or throwaway commit): set Team.xcconfig and the .storekit mirror to ABCDE12345. - The guard must pass. - Every target must resolve ABCDE12345. - The workflow's read step, run with bash -x locally, must export TEAM_ID=ABCDE12345. - Then revert. This proves that one file is enough. 4. Reward-E: change SubscriptionConfigValidationTests first to compare against the xcconfig. It passes today because both say V9GL74NMBR. It must fail when only one of the two is flipped. It must compile in CI, which is what catches the regex-literal mode.

A.5 Before, at and after the transfer

When What
Before (now) A0 (infra secrets and the ios-certs branch), then A1-A6 merged with Team.xcconfig = V9GL74NMBR. The guard is a required check. Signing does not change and CI stays green. Nothing depends on the Dashecorp account.
When NEW_TEAM exists Operator: Dashecorp ASC API key, then ASC_*_<NEW_TEAM> secrets via OpenTofu, provisioned early
At T1 (per app) Operator: register the App Group(s) (A.6); run generate-match-profiles with team_id=NEW_TEAM (allow_new_cert=true on the first app only) for the app and widget bundle IDs. Agent: the cutover PR (one line, or two with the StoreKit mirror). CI builds NB1, which becomes U1
After When no repo uses them any more, remove the _V9GL74NMBR secrets from that repo

A.6 App Groups and widget App IDs: what moves, what the recipient re-creates

  1. App Groups do not move with the app. They are re-registered.
  2. VERIFIED: "Once an app has completed transfer, the app group can be deleted from the transferor's account and registered to the recipient's account. This won't impact the app's availability on the App Store." (Overview of app transfer)
  3. Names are unique across teams. VERIFIED: Xcode "guards against the reuse of app group names across teams" (Sharing keychain items). So Invotek deletes first, then Dashecorp registers the same identifier.
  4. Steal window. VERIFIED, Quinn in 706128: "The current transfer process of app groups exposes a small window where some other team can 'steal' your app group ID. We have a bug on file to improve that process (r. 171616887)."
    • Do the move in one operator session, with both accounts signed in: Invotek deletes, and Dashecorp registers immediately.
    • Then confirm the group in Dashecorp's Identifiers list before NB1 is built.
    • If the identifier is taken, stop and contact Apple Developer Support. Do not ship a build with a new group ID without deciding on it first (B.9).
  5. iOS app group IDs carry no team prefix. VERIFIED: "Xcode doesn't prepend the app group with the team identifier".
  6. Keychain items in an app-group access group survive the move, as long as the app group itself is transferred (Apple DTS step 7, VERIFIED, see B.4).
  7. Shared-container data survives: UNVERIFIED. This is widget UserDefaults suites and containerURL files. At worst the widget shows empty or stale data until the app writes again.
  8. Widget extension App IDs (no.invotek.CountE.Widget, .FastE.Widget, .RewardE.Widget): UNVERIFIED.
  9. Apple speaks only of "its associated App ID" (singular).
  10. Forum threads 82600 and 820446, with no Apple staff answer, report extension IDs left behind.
  11. The Count-E pilot decides this. Right after its T1, check Certificates, Identifiers & Profiles in the Dashecorp account.
  12. If the widget ID is missing, contact Apple Developer Support.
  13. Last resort: a new widget bundle ID, e.g. no.invotek.CountE.WidgetV2. Users then re-add the widget. Whether NEW_TEAM may register a new no.invotek.* ID is UNVERIFIED.
  14. The recipient must re-create, per app:
  15. Register the App Group with the identical ID, in the same session in which Invotek deletes it (item 1).
  16. Enable App Groups on the app App ID and the widget App ID, and assign the group.
  17. Check that Push, HealthKit, Sign in with Apple and App Attest are still enabled on the transferred App IDs (UNVERIFIED whether capabilities carry over).
  18. Create a new distribution certificate and App Store profiles for the app and the widget. VERIFIED: TN2311 and Accept an app transfer.
  19. Keep every identifier string unchanged. The groups in use:
Group App Where it appears
group.no.invotek.CountE Count-E CountE.entitlements:9, CountEWidget.entitlements:7, WidgetDataReader.swift:7, TallyService.swift:219
group.no.invotek.FastE Fast-E FastE.entitlements:11, FastEWidget.entitlements:7, FastE/Services/WidgetDataProvider.swift:7, FastEWidget/WidgetDataProvider.swift:9
group.no.invotek.RewardE Reward-E RewardE.entitlements:15, RewardEWidget.entitlements:7, WidgetSync/WidgetDataProvider.swift:34, RewardEWidget/WidgetDataProvider.swift:42
group.no.invotek.Nutri-E Nutri-E (new, see B) —

Drink-E and Heart-E are not transferred. They get new com.dashecorp.* groups when they are recreated. 7. At the NB1 submission, App Store Connect warns about a possible loss of keychain access. VERIFIED, Quinn: "That warning is talking about keychain items in normal keychain access groups. Items in an AGI keychain access group will still be accessible as long as you transfer the app group." Expect it for every app. It is not a reason to abort.

A.7 Risks

  • configFiles or secrets[format()] does not behave as expected in this org. Both are now VERIFIED from docs and source (A.2), and the Count-E PR's CI still proves them. Fallbacks are given in A.2.
  • A match write run with the wrong ASC key or branch creates a stray certificate. Keep the PRE- and POST-FLIGHT checks. allow_new_cert is used exactly once.
  • A staggered migration puts both teams in one org. Per-branch match stores and per-team secrets are what make that safe. dashe-cutie, rig-cockpit-ios and dashecorp/.github ios-testflight.yml keep the unsuffixed Invotek secrets until they are migrated.
  • The widget App ID stays behind (A.6.4). The pilot catches it.
  • Another team takes an App Group ID in the delete-then-register gap (A.6.1). Mitigated by one-session moves.

A.8 Issue drafts

A0a. Repo dashecorp/infra. Title: "Team-scoped ASC API key secrets for the iOS apps (prep for the team change)"

The six dashe-* app workflows will select their App Store Connect API key by Apple Team ID (secrets[format('ASC_KEY_ID_{0}', env.TEAM_ID)]), so moving an app to Dashecorp's team is a one-line repo change.

  • Provision ASC_KEY_ID_V9GL74NMBR, ASC_ISSUER_ID_V9GL74NMBR and ASC_KEY_P8_BASE64_V9GL74NMBR in github/dashecorp/dashe-secrets.tf for dashe-count-e, dashe-fast-e, dashe-heart-e, dashe-drink-e, dashe-reward-e and dashe-nutri-e. They use the same backing values as today's ASC_*.
  • Keep the unsuffixed ASC_* secrets: dashe-cutie, rig-cockpit-ios and dashecorp/.github still use them.
  • Phase 2, when the Dashecorp key exists: add ASC_*_<NEW_TEAM> to the same repos.

Acceptance: - [ ] tofu plan shows only the 18 added secrets and no changes to existing ones. - [ ] gh secret list on each repo shows the three suffixed names. - [ ] secrets-registry.md is updated.

Est.: ~40 LOC HCL.

A0b. Repo dashecorp/ios-certs (operator or iBuild-E with write access). Title: "Add match branch V9GL74NMBR (copy of master)"

Workflows move to git_branch: <Team ID>. Create branch V9GL74NMBR at the current master head. This is a git-only operation and nothing is decrypted. Without it, match would create an empty orphan branch and the readonly runs would fail.

After this, profile renewals for the six dashe apps are written to V9GL74NMBR, while dashe-cutie, rig-cockpit-ios and dashecorp/.github keep reading master. Both branches hold the same shared distribution certificate, so the divergence is harmless. Write it down in the ios-certs README.

Acceptance: - [ ] git ls-remote shows refs/heads/V9GL74NMBR equal to master. - [ ] A readonly match appstore run with git_branch:V9GL74NMBR succeeds (the first A1 CI run shows it).

Est.: 0 LOC.

A1. Repo dashecorp/dashe-count-e. Title: "Read the Apple Team ID from ios/Team.xcconfig only (team-change prep)"

Why: the app moves from team V9GL74NMBR to Dashecorp by App Transfer. The team is hard-coded at ios/project.yml:31 and .github/workflows/build-and-upload.yml:14 (used at :63, :117, :128). After this issue, the cutover is one line.

Do: 1. First, write scripts/check-team-id.sh and its self-tests (spec A.4), .github/workflows/team-id-guard.yml (every PR and push to main, no path filter, fetch-depth: 0), and ios/Team.xcconfig containing DEVELOPMENT_TEAM = V9GL74NMBR. The guard fails, listing ios/project.yml:31 and build-and-upload.yml:14. 2. Add configFiles (Debug and Release pointing to Team.xcconfig) to ios/project.yml. Delete :31. 3. In the workflow: - delete env TEAM_ID; - add the "Read signing team" step after checkout, with the exactly-one check; - add git_branch:"$TEAM_ID" to the match loop (:57-67); - select ASC_KEY_ID_/ASC_ISSUER_ID_/ASC_KEY_P8_BASE64_<TEAM_ID> at :77-79 and :146-147, with an empty-check. 4. Add the showBuildSettings check for CountE, CountEWidget, CountETests and CountEUITests to the macOS CI job. 5. CLAUDE.md:147: "Team ID: see ios/Team.xcconfig". Delete .github/workflows/monitor-xcode-cloud.yml (dead). 6. After merge: make Team ID guard a required status check on main.

Acceptance: - [ ] The guard and its self-tests pass. A PR that touches only a workflow file runs the guard. - [ ] Every target resolves DEVELOPMENT_TEAM = V9GL74NMBR. - [ ] The flip test (spec A.4.3) is done and its result is written in the PR. - [ ] build-and-upload.yml (workflow_dispatch) uploads a TestFlight build, proving the branch and secret selection. - [ ] git grep V9GL74NMBR -- ':!*.md' ':!docs/**' hits only ios/Team.xcconfig.

Depends on: A0a, A0b. Est.: ~110 LOC added, ~60 removed.

A2. dashecorp/dashe-fast-e: same as A1. Sites: ios/project.yml:63, workflow :14 (uses :63,:117,:128; secrets :77-79,:146-147), CLAUDE.md:212,260. Targets for the showBuildSettings check are the app, widget and test targets in ios/project.yml. Est.: ~110 LOC.

A3. dashecorp/dashe-heart-e: same as A1. Sites: ios/project.yml:31, workflow :14 (uses :63,:121,:133; secrets :77-79 and :151-152), CLAUDE.md:118, monitor-xcode-cloud.yml. Heart-E is not transferred. It is recreated as com.dashecorp.*, and Team.xcconfig makes that switch one line too. Est.: ~110 LOC.

A4. dashecorp/dashe-drink-e: same as A1. Sites: ios/project.yml:30, workflow :14 (uses :63,:117,:128; secrets :77-79,:146-147), CLAUDE.md:130, monitor-xcode-cloud.yml. Also the generate-match-profiles.yml changes in spec A.3: - no team or cert defaults (:33, :37); required inputs; - git_branch = team (:159); - ASC secrets selected by team in all four steps (:68-69, :80-81, :125-126, :167-168); - comments and the step name at :4, :6, :16, :121 and :147-148 no longer name master, the team or the cert; - allow_new_cert with an exactly-one-new-cert post-check.

Acceptance adds: - [ ] A dispatch with team_id=V9GL74NMBR, expected_cert_id=C8788TMK34, app_identifiers=no.invotek.DrinkE, allow_new_cert=false passes PRE- and POST-FLIGHT with no new certificate. - [ ] git grep C8788TMK34 -- ':!*.md' ':!docs/**' has no hits.

Est.: ~160 LOC.

A5. dashecorp/dashe-reward-e: same as A1. Sites: ios/project.yml:82, workflow :14 (uses :63,:117,:128; secrets :77-79,:146-147), ios/fastlane/Matchfile:5, CLAUDE.md:365, AppStore/SUBMISSION_CHECKLIST.md:183. In addition: - Matchfile:5 is deleted, with a comment explaining why. The Matchfile does not read the xcconfig (spec A.3). - SubscriptionConfigValidationTests.swift:150-156 compares against Team.xcconfig, using a #/…/# regex literal and XCTUnwrap (spec A.3). - scripts/validate-subscription-config.sh:113-118 reads the xcconfig; subscription-validation.yml paths gain ios/Team.xcconfig. - The guard checks ios/RewardE/Configuration.storekit:11 as a mirror. - CLAUDE.md:297: the dev Firebase project is reward-e-dev.

Acceptance adds: - [ ] ci.yml (RewardETests compiles and passes) and subscription-validation.yml are green. - [ ] A workflow_dispatch TestFlight build succeeds with the edited Matchfile, proving the match step still loads it. - [ ] The flip test changes Team.xcconfig and .storekit:11 together, and flipping only one fails both the guard and the XCTest.

Est.: ~150 LOC.

A6. dashecorp/dashe-nutri-e. Title: "Read the Apple Team ID from ios/Nutri-E/Team.xcconfig; delete the legacy fastlane/ExportOptions signing"

  • Sites: project.pbxproj:315,379,411,453,491,513,534,554; workflow :13 (uses :57,:168,:179; git_branch:"master" at :59; secrets :67-69,:196-197).
  • Guard: team-id-guard.yml calls scripts/check-team-id.sh ios/Nutri-E/Team.xcconfig. Nutri-E has no ci.yml, so the showBuildSettings check goes into test.yml's iOS job.
  • Delete as legacy, since CI does not use them: ios/Nutri-E/fastlane/{Appfile,Fastfile,README.md,report.xml}, ios/ExportOptions.plist, ios/Nutri-E/ExportOptions.plist, ios/Nutri-E/{deploy.sh,deploy-api.sh,test-build.sh}, scripts/setup-testflight-secrets.sh; drop ios/Nutri-E/setup.sh:40-41.
  • Fix Configuration.storekit:10 from 6738736024 to 6753125391. :11 is a mirror.
  • Docs: ios/Nutri-E/DEPLOYMENT.md:67, docs/testflight.md:181, and the troubleshooting.md:110 ExportOptions hint.
  • Delete monitor-xcode-cloud.yml.

pbxproj: add a file reference for Team.xcconfig plus baseConfigurationReference on the project-level Debug and Release configurations (:281, :345), and delete the 8 lines. showBuildSettings targets: Nutri-E, Nutri-ETests, Nutri-EUITests.

Acceptance: - [ ] Same as A1. - [ ] xcodebuild -project ios/Nutri-E/Nutri-E.xcodeproj -list still opens. - [ ] A workflow_dispatch TestFlight upload succeeds.

Est.: ~130 LOC added, ~650 removed (deletions only).


B. Nutri-E device identity across the team change (highest user impact)

B.1 What is stored

All references in this section are under nutri-e/ios/Nutri-E/Nutri-E/.

  1. Device ID. Keychain item kSecClassGenericPassword, account no.invotek.nutrie.deviceId (Services/DeviceAuthService.swift:11).
  2. Value: a UUID, taken from identifierForVendor at first launch, or a random UUID if IDFV is nil (:23-45).
  3. It is lowercased on every read (:28), not on storage. Commit bf59b1f (2025-10-09) added the read-side lowercasing but never rewrote stored items. So a device from an earlier build can still hold an uppercase item.
  4. Accessibility: kSecAttrAccessibleAfterFirstUnlock, not ThisDeviceOnly (:111). So it survives reinstall and restores to a new device from an encrypted backup.
  5. No kSecAttrAccessGroup (:100-112). It is written to the default group, which is the app ID V9GL74NMBR.no.invotek.Nutri-E. VERIFIED: "If you don't explicitly set a group, keychain services defaults to the app's first access group, which is either the first keychain access group, or the app ID when the app has no keychain groups" (kSecAttrAccessGroup). Nutri-E has no keychain groups (Nutri-E.entitlements:1-12).
  6. Device "secret". Keychain item no.invotek.nutrie.deviceSecret (:12).
  7. It is SHA-256(deviceId + salt), recomputed on every call (:50-65). The keychain copy is only a cache.
  8. The salt is a constant compiled into the app (:14-16; value not reproduced). The worker checks the same hash against DEVICE_SALT (cloudflare-worker-openai/worker.js:378-384,594-605).
  9. Classification.
  10. The device ID is an identifier that also works as a weak bearer credential. Anyone who knows it can compute the "secret".
  11. The app treats it as non-secret: it shows it and copies it to the clipboard (Views/MoreView.swift:267,275,331), and sends it to Crashlytics as the user ID (AppDelegate.swift:21-25).
  12. The secret needs no migration. It is derived.
  13. The review could not break "only the device ID needs to survive". The Cuti-E ID is in UserDefaults, Core Data is not CloudKit-backed, and the app uses the keychain for nothing else.

B.2 Who keys on it

Consumer Key Where
RevenueCat (live project proj2c32896f, docs/subscriptions.md:22-31) appUserID = device ID, set at configure Services/SubscriptionManager.swift:50-60
Apple (StoreKit 2) appAccountToken = device ID. RevenueCat puts it into transactions when the ID parses as a UUID, and it is frozen into the original transaction SubscriptionManager.swift:50-55 (comment); VERIFIED: "The App Store returns the same UUID in appAccountToken in the transaction information and subscription renewal information" (appAccountToken)
Nutri-E Apple webhook worker SUBSCRIPTIONS[appAccountToken] = plan and expiry. No bundle-ID or team check cloudflare-worker-apple/worker.js:336-337,79-101
Nutri-E Apple webhook worker (RevenueCat path) transferSubscription(transferred_from, transferred_to) moves the record and sets a forwarding pointer. Forwarding covers writes only: SUBSCRIBED, DID_RENEW, OFFER_REDEEMED and REFUND_REVERSED on the old token land on the new ID (updateSubscription, :79-101). Apple EXPIRED, REFUND and REVOKE on the old token call removeSubscription(oldId) (:107-130, :437-454, :480-482), which does not follow forwarding. The RevenueCat CANCELLATION branch only logs (:771-773). Fixed by B3 worker.js:49-61,605-645,714-721; tested in test-revenuecat-transfer.mjs: the move, archive and no-op cases at :95-190, and forwarding at :277-299,301-324
OpenAI worker (quota authority) SUBSCRIPTIONS[deviceId] gives the paid tier. Without a record the device gets the free wallet wallet:v1:<id> (10 credits, +3 a week). An expired record that is still present gives FREE_DAILY_POLICY (:536-547). Also daily:<id>:<day> and DEVICE_ACTIVITY device:<id> worker.js:529-569,415,489-509,619,681-692
DSLD worker (supplement lookups) SUBSCRIPTIONS[deviceId] gives the daily limit: 100 for free and basic, 500 pro, 2000 premium, unlimited ultimate (cloudflare-worker-dsld/worker.js:40-70). Usage key daily:dsld:<id>:<day> (:111). The app sends the same auth headers (Services/DSLDSearchService.swift:179). It binds the same production SUBSCRIPTIONS namespace as the Apple worker (cloudflare-worker-dsld/wrangler.toml:13-14, cloudflare-worker-apple/wrangler.toml:12-13), so a TRANSFER fixes it too as listed
Crashlytics user ID and device_id key (Release builds) AppDelegate.swift:21-25

Nutri-E stores no user content on the server. Supplements, doses and food logs are in Core Data in the app container, which is not team-bound and survives U1.

B.3 What a user loses at U1 if nothing is done

The keychain item is unreadable from U1 (TN2311, B.4). getDeviceId() then mints a new ID from IDFV, or from a random UUID. Whether IDFV itself changes at a transfer is UNVERIFIED: Apple says only "Normally, the vendor is determined by data provided by the App Store" (identifierForVendor).

User Effect
Free user New ID, so a fresh free wallet of 10 credits: no loss, in fact a small gain. The support correlation (Cuti-E and Crashlytics device ID) breaks
Paying subscriber Effect: RevenueCat sees a new appUserID with no entitlement, so the app shows the free tier and the paywall (SubscriptionManager.swift:187-226). The OpenAI worker serves the free wallet and the DSLD worker 100 lookups a day, because SUBSCRIPTIONS[newId] is missing. They keep being charged, and Apple renewals keep writing SUBSCRIPTIONS[oldId].
Recovery routes without B2:
(1) Tapping Restore Purchases (:163-174). With the "Transfer to new App User ID" behaviour, RevenueCat sends TRANSFER and the worker moves the record.
(2) The legacy syncPurchases (:83-105) runs again under the new ID on devices where it never latched. Its guard is "done, or 3 completed empty attempts" (:85-86), "done" is set only when it recovered an entitlement (:93-99), and a thrown sync counts as neither (:102-104). This covers subscribers who bought during their first two launches of 1.0.5 and have not launched since, those whose syncs all threw, and those who never ran 1.0.5. For most subscribers "done" is already set, so it does not run.
(3) Possibly, a StoreKit 2 renewal that the SDK receives after U1 is posted under the new ID and triggers the same transfer. The review found that SDK 5.59.0 posts Transaction.updates with isRestore=false (PurchasesOrchestrator.swift:1424-1460). RevenueCat's backend behaviour for that post is not documented (UNVERIFIED).
So B2 does not create the only automatic route. It makes recovery deterministic at the first launch
Everyone Nothing on the device is lost apart from the ID

B.4 Apple sources on keychain behaviour after a transfer

  • TN2311, VERIFIED:
  • "If the previous version of your app was writing data to the keychain, submitting an app update with the new App ID will result in a loss of access to the previous keychain data."
  • "The keychain access will only be lost after an update to the app is released on the App Store."
  • TN2311 offers no mitigation beyond accepting one re-entry of credentials.
  • App Store Connect, VERIFIED: "Keychain sharing continues to work only until the app is updated. … After the update, users must re-login once as the app cannot retrieve the authentication token from the keychain." (Overview of app transfer)
  • App-group keychain groups have no team prefix. VERIFIED:
  • "You can use app group names as keychain access group names, without adding them to the Keychain access groups entitlement." (Sharing access to keychain items, under "Use app groups to expand sharing of keychain and non-keychain data")
  • "App groups that you register in your Apple Developer profile also act as keychain access groups." (App Groups entitlement)
  • What a query on a group the app is not entitled to returns. VERIFIED on the same Sharing page:
  • "If you specify a group to which your app doesn't belong, no items match and the query returns the errSecItemNotFound status."
  • For an add: "the operation fails and returns the errSecMissingEntitlement status."
  • So a build that lacks the entitlement sees notFound on read, not an error (B.6).
  • Apple DTS method for exactly this case (Quinn, thread 706128, "Transfer Your App to Another Team", rewritten 2026-03-31, VERIFIED Apple employee):
  • "Identify an app group ID to transfer. … in many cases you'll want to register a new app group ID solely for this purpose."
  • "Use the old team (the transferor) to release a version of your app that moves keychain items from other keychain access groups to the AGI keychain access group for this app group ID."
  • "Give your users time to update to this new version, run it, and so move their keychain items."
  • "When you're confident that the bulk of your users have done this, initiate the app transfer."
  • "Once that's complete, transfer the app group ID you selected in step 1."
  • "Publish an update to your app from the new team (the transferee)."
  • "When a user installs this version, it will have access to your app group, and hence your keychain items."

Caveats, all VERIFIED in the same section: - "Once you transfer the app group, the old team won't be able to publish a new version of any app that uses this app group". Nutri-E is the only user of group.no.invotek.Nutri-E. - Steal window: "The current transfer process of app groups exposes a small window where some other team can 'steal' your app group ID. We have a bug on file to improve that process (r. 171616887)." (B.8, B.9) - "The process works best when transferring between two teams that are both under the control of the same entity." That holds here: one operator controls both teams. - "When you submit the app from the new team (step 6), App Store Connect will warn you about a potential loss of keychain access. That warning is talking about keychain items in normal keychain access groups. Items in an AGI keychain access group will still be accessible as long as you transfer the app group." (B.8 U1 row) - A "Test Before You Ship" section was added 2026-04-07: test in Xcode and with a TestFlight internal build. The post-transfer half cannot be tested before T1, so the Count-E canary (B0) plays that role. - It is "bleeding edge" (thread 821062). - What happens to users who never ran the step-2 build is not addressed (UNVERIFIED). For them the item stays in the old group and is lost; B2 covers them.

B.5 Options

# Option Identity kept? Covers Security Verdict
1 Do nothing; rely on manual Restore and the partial automatic routes in B.3 No Subscribers who notice and tap Restore, plus the partial routes unchanged Reject: most paying users are silently downgraded
2 App-group keychain group (Apple DTS method): move the device ID into group.no.invotek.Nutri-E in an Invotek-signed build Yes, the same ID, so RevenueCat, the workers, Crashlytics and quota are all intact Everyone who ran the migration build before U1 Unchanged: still keychain, same accessibility Recommended (primary)
3 Server-side re-binding on identity loss: the app detects the loss, calls syncPurchases() until an entitlement is found (capped), RevenueCat sends TRANSFER old→new, and the worker moves SUBSCRIPTIONS and forwards renewals (B3 makes refunds and expiries follow too) New ID; the subscription is re-bound on the server Every subscriber whose App Store account holds the purchase, including those who skipped the migration build No new storage; the proof of purchase is Apple's signed transaction through RevenueCat Recommended (safety net)
4 New worker endpoint that verifies a StoreKit 2 transaction JWS and re-binds appAccountToken (old ID) to the new ID Old ID recoverable on the purchasing device Subscribers whose purchase carries appAccountToken Apple-signed proof; new attack surface Only if option 3 is unavailable (the live restore behaviour is not "Transfer")
5 Copy the device ID to UserDefaults or a file before the transfer Yes Everyone who ran the build Weaker: outside the keychain, plaintext in unencrypted local backups Reject. The ID works as a bearer credential, so keep it in the keychain and re-bind on the server. The marginal risk is small only because the app already shows the ID in the UI
6 Re-derive the ID from identifierForVendor after U1 Maybe UNVERIFIED whether IDFV survives a transfer. Changing the seller name "will reset the identifierForVendor (IDFV) for existing users on their next app update" (Updating your account information), which suggests it does not. Many IDs were never IDFV anyway — Reject

Recommendation: ship options 2 and 3 together in one Invotek-signed Nutri-E release, as early as possible, plus the B3 worker fix. - Option 2 keeps the identity for active users with no user action. - Option 3 limits the loss for everyone else to "new free wallet, subscription re-bound automatically". - Neither weakens storage. - The Count-E pilot proves option 2 on a real transfer before Nutri-E moves (issue B0).

B.6 Design

Prerequisite (operator, Invotek portal, before the code ships): 1. Register App Group group.no.invotek.Nutri-E. 2. Enable App Groups on App ID no.invotek.Nutri-E and assign the group. 3. Regenerate the match AppStore no.invotek.Nutri-E profile with generate-match-profiles, app_identifiers=no.invotek.Nutri-E. - A capability change invalidates the old profile. Whether force:false then regenerates it is UNVERIFIED. - The CI archive fails if the profile lacks the group, which is the check.

Code changes (B1):

  1. Nutri-E.entitlements: add com.apple.security.application-groups = [group.no.invotek.Nutri-E].
  2. New Services/KeychainStore.swift:
  3. protocol KeychainStoring { read(account:accessGroup:) -> KeychainRead; write(account:value:accessGroup:) -> OSStatus } with enum KeychainRead { case found(String), notFound, failed(OSStatus) }. read maps errSecItemNotFound to notFound;
  4. SecKeychainStore implements it with SecItemCopyMatching and SecItemAdd/SecItemUpdate. The access group is passed only when non-nil. Accessibility is kSecAttrAccessibleAfterFirstUnlock, the same as today.
  5. DeviceAuthService:
  6. Inject a KeychainStoring and UserDefaults (defaults: the real ones).
  7. static let identityAccessGroup = "group.no.invotek.Nutri-E".
  8. getDeviceId():

1. r = read(deviceIdKey, group: identityAccessGroup)
   found(v)       -> markIssued(); record "appGroup"; return v.lowercased()
   failed(status) -> // e.g. errSecInteractionNotAllowed before first unlock (prewarm, background launch).
                     // NOT an entitlement miss: a query on a group the app does not belong to returns
                     // errSecItemNotFound, i.e. notFound (Apple, "Sharing access to keychain items").
                     if case .found(v) = read(deviceIdKey, group: nil) { return v.lowercased() }
                     record "read_error:<status>"; return a transient id (not persisted, no markers set)
   notFound       -> go to 2
2. legacy = read(deviceIdKey, group: nil)    // no group = search all of the app's groups (the legacy default group)
   found(v)       -> id = v.lowercased()     // builds before bf59b1f may have stored it uppercase
                     s = write(deviceIdKey, id, group: identityAccessGroup)
                     if s == errSecSuccess && read(deviceIdKey, group: identityAccessGroup) == .found(id)
                         record "migrated"
                     else
                         record "group_write_error:<s>"   // e.g. errSecMissingEntitlement: profile lacks the group
                     keep the legacy item unchanged (rollback safety; it becomes unreadable at U1 anyway)
                     markIssued(); return id
   failed(status) -> record "read_error:<status>"; return a transient id (not persisted, no markers set)
   notFound       -> go to 3
3. none found:
   priorRun = defaults.bool("no.invotek.nutrie.deviceIdIssued")      // written by this build and later
           || defaults.bool("onboardingCompleted")                   // written by every shipped build (Models/UserProfile.swift:109,168)
           || defaults.object("no.invotek.nutrie.revenueCatLegacySyncDone") != nil
           || defaults.object("no.invotek.nutrie.revenueCatLegacySyncAttempts") != nil
   new = (IDFV ?? UUID).lowercased()
   s = write(deviceIdKey, new, group: identityAccessGroup)
   if s != errSecSuccess {                   // keep the ID stable on this build rather than minting one per launch
       s2 = write(deviceIdKey, new, group: nil); record "group_write_error:<s>"
       if s2 != errSecSuccess { record "write_error:<s2>"; return new }   // transient: no markers set
   }
   markIssued()
   if priorRun {
       defaults.set(Date(), "no.invotek.nutrie.identityRecoveryPendingSince")   // persisted: B2 reads this
       record "lost"
   } else { record "new" }
   return new
- markIssued() sets UserDefaults no.invotek.nutrie.deviceIdIssued = true. It is a non-secret marker; the ID is never written to UserDefaults. - The pending marker identityRecoveryPendingSince is what B2 acts on. It is persisted because the next launch finds the newly written ID in step 1 and would otherwise never know that identity was lost. Revision 1's in-memory identityLost flag made the promised retry impossible. - priorRun also uses markers that older builds already wrote, because the UserDefaults in the app container survive U1. So B2 also fires for users who skip the migration build and go straight from an old version to U1. That is what makes option 3's coverage claim hold. - getDeviceSecret() returns hashDeviceId(getDeviceId()) and stops writing the secret item. It is derived (B.1), so there is nothing to migrate. - resetDeviceCredentials() (DEBUG) deletes the item in both groups and the markers. - Crashlytics custom key device_id_store gets appGroup|migrated|group_write_error:<n>|new|lost|read_error:<n>|write_error:<n>. It is for support, and to measure how many devices migrated.

Code changes (B2, identity-loss recovery):

  1. A PurchasesSyncing seam over Purchases.shared.syncPurchases(), so tests can fake it.
  2. SubscriptionManager.refreshCustomer() (:66-76) calls syncIdentityRecoveryIfNeeded() right after syncLegacyPurchasesIfNeeded(). It mirrors the legacy sync's rules (:83-105), including its documented trap (:89-92, docs/subscriptions.md:164-169):

syncIdentityRecoveryIfNeeded():
  guard defaults.object("no.invotek.nutrie.identityRecoveryPendingSince") != nil else return
  guard defaults.integer("no.invotek.nutrie.identityRecoveryAttempts") < 3 else return
  do {
    info = try await purchases.syncPurchases()
    if info.entitlements.active.isEmpty {        // success with nothing found: do NOT latch
      attempts += 1; record Crashlytics "identity_recovery=empty:<attempts>"; return
    }
    defaults.removeObject("…identityRecoveryPendingSince"); defaults.set(true, "…identityRecoverySyncDone")
    updateFromCustomerInfo(info); record "identity_recovery=recovered"
  } catch {
    record "identity_recovery=error"             // retry on the next launch; does not count as an attempt
  }
- A free user who never paid makes 3 empty attempts and stops. - syncPurchases() "reports success even when it found no transaction to post" (SubscriptionManager.swift:89-92). The review found that in SDK 5.59.0 an empty StoreKit 2 sync returns the current CustomerInfo as success (PurchasesOrchestrator.swift:1728-1770). So latching on any success would strand the subscriber whose App Store account is signed out at that launch. 3. After a recovered entitlement, updateFromCustomerInfo runs refreshServerQuotaUntilTierApplies (:223-245). That poll is bounded: 5 fetches at 0, 2, 5, 10 and 20 s (about 37 s). A TRANSFER webhook that lands later is picked up by the next normal quota fetch. 4. Why syncPurchases and not restorePurchases: it shows no App Store sign-in prompt. 5. RevenueCat behaviour, now VERIFIED for Nutri-E's custom UUID appUserID: - purchases-ios 5.59.0 sends syncPurchases() with isRestore: allowSharingAppStoreAccount. That property's getter is self._allowSharingAppStoreAccount.value ?? self.currentUserProvider.currentUserIsAnonymous, which is false for Nutri-E (PurchasesOrchestrator.swift@5.59.0). So on the wire it is a purchase post. - RevenueCat: "The configured behavior will also apply if an identified App User ID makes a new purchase and the device receipt is already associated with a different identified App User ID in RevenueCat" (Restore behavior, VERIFIED). So the transfer setting governs the call. - The only exception is the legacy "Share between App User IDs" mode, which "will only be available for legacy projects with this behavior already enabled, not new projects" (same page). proj2c32896f was created in the March 2026 split (docs/subscriptions.md:29-31). - "The default behavior is to transfer purchases between identified App User IDs if needed" (same page, VERIFIED). - "You can optionally set a different restore behavior for sandbox purchases" (same page, VERIFIED). So the sandbox test in B.7 proves only the sandbox setting unless the operator confirms both are equal. - The TRANSFER webhook "is sent only for the destination user" and carries transferred_from and transferred_to (event types, VERIFIED). The worker already handles exactly that shape (worker.js:714-721). 6. B2 re-binds a subscriber if four conditions hold (review): - (a) the live project's production restore behaviour is "Transfer to new App User ID"; - (b) Dashecorp's In-App Purchase key is in RevenueCat before U1. The sync is a StoreKit 2 JWS post, and RevenueCat cannot record it without the key. Phase 4's order (T1, then E, then U1) gives this; - (c) the production webhook integration whintgr812efdd741 (docs/subscriptions.md:136-142) delivers TRANSFER. Its event filter is UNVERIFIED; - (d) the device's App Store account holds the purchase. (a) and (c) are operator checks in E.

Code changes (B3, worker: removals follow a transfer). This is a pre-existing gap, but B2 makes TRANSFER the normal path for every non-migrated subscriber, so it ships before Nutri-E's T0: 1. Every write stores originalTransactionId in the record: - the Apple path from the decoded transaction; - the RevenueCat path from original_transaction_id ("Always" present on subscription lifecycle events, VERIFIED on the event-types page); - transferSubscription (:605-645) copies it. 2. removeSubscription(deviceId, reason, env, { originalTransactionId }): if SUBSCRIPTIONS[deviceId] is absent and getTransferForwarding(deviceId) (:49-61) returns targets, remove each target whose record has the same originalTransactionId. - A target record without the field (written before B3) is removed too, and the worker logs forwarded_removal_unkeyed. - A target with a different originalTransactionId is a separate, newer purchase. It is never removed. 3. RevenueCat CANCELLATION with cancel_reason == "CUSTOMER_SUPPORT" removes the record. - RevenueCat's definition: "Customer received a refund from Apple support" (VERIFIED, event-types page). - Other cancel reasons keep today's log-only behaviour (:771-773), since the subscription stays active until expiry. 4. It deploys to the v3 workers only. Apple's notifications reach it only after the App Store Server Notifications URL is repointed from v2 to v3 (Order of work, Phase 0 step 6); v2 is frozen.

Count-E canary (B0). It proves the app-group keychain method on the real pilot transfer, and it is the post-transfer half of Quinn's "Test Before You Ship", which cannot run before T1. - Count-E already has group.no.invotek.CountE (CountE.entitlements:9), so it needs no new capability. - On launch, write a random UUID once. Keychain account no.invotek.CountE.keychainCanary, access group group.no.invotek.CountE, AfterFirstUnlock. Also set UserDefaults keychainCanaryWritten=true. - Settings > About shows "Keychain continuity: present / missing / not written" (accessibility id settings-keychain-canary). - After Count-E's U1, a device that ran the canary build before T1 must show present.

B.7 Tests to write first

Unit tests in Nutri-ETests use a fake KeychainStoring that models access groups: - items are keyed by (group, account), and read(group: nil) searches all visible groups; - a group the app is not entitled to reads as notFound, and writes to it return errSecMissingEntitlement, as Apple documents.

Simulator unit tests run without the entitlement, so real app-group writes fail there.

  1. Legacy item only → getDeviceId() returns it, the group now holds the same value, the legacy item is still there, and deviceIdIssued is set.
  2. Legacy item stored uppercase → the returned ID and the group copy are lowercase.
  3. Group item present → returned lowercase, no writes.
  4. Simulated U1: hide the legacy group, as the new team would. The group item is still visible → same ID. This is the case the migration protects.
  5. Simulated U1 without migration:
  6. Hide the legacy group, with no group item and deviceIdIssued set → a new ID, and identityRecoveryPendingSince is persisted.
  7. Repeat with only onboardingCompleted=true (a user who never ran the migration build): the same result.
  8. Relaunch (a new service instance): the new ID is returned, and the pending marker is still set.
  9. Fresh install, no markers → a new ID and no pending marker.
  10. read returns failed(errSecInteractionNotAllowed) → the app does not write a new ID and returns a transient one. Today's code would mint and overwrite.
  11. Entitlement missing (the group reads notFound, and group writes return errSecMissingEntitlement):
  12. with a legacy item: the legacy ID is returned and device_id_store=group_write_error:-34018 is recorded;
  13. with no item: the ID is written to the default group, the next launch returns the same ID, and no pending marker is set on a fresh install.
  14. getDeviceSecret() equals the hash of getDeviceId() and performs no keychain write.
  15. B2, with a fake PurchasesSyncing:
    • never called without the pending marker;
    • called once per launch while pending;
    • an empty success increments attempts and keeps the marker, and stops after 3;
    • a throw keeps the marker and does not count;
    • a success with an active entitlement clears the marker and sets identityRecoverySyncDone, and no later launch calls it.
  16. B3 worker tests, in cloudflare-worker-apple/test-revenuecat-transfer.mjs style:
    • TRANSFER A→B, then an Apple REFUND on token A with the same originalTransactionId → SUBSCRIPTIONS[B] is removed;
    • TRANSFER A→B, then a RevenueCat INITIAL_PURCHASE on B with another originalTransactionId, then an Apple EXPIRED on A → B is kept;
    • RevenueCat CANCELLATION with cancel_reason=CUSTOMER_SUPPORT → removed; with UNSUBSCRIBE → kept;
    • the existing forwarding tests (:277-324) stay green.
  17. Device test on hardware (manual checklist in the PR):
    1. Install the current App Store build.
    2. Install the TestFlight migration build and launch it: the Crashlytics key device_id_store=migrated, and the ID in More > Device ID is unchanged.
    3. Relaunch: appGroup.
  18. Sandbox end-to-end test of B2 (TestFlight, sandbox Apple ID):
    1. Operator first confirms that the sandbox restore-behaviour override is off, or set to the same value as production, and that the sandbox webhook integration whintgr46e9d8efa9 delivers TRANSFER.
    2. Buy no.invotek.nutrie.basic_monthly.
    3. In a DEBUG build, delete only the app-group and legacy items but keep deviceIdIssued.
    4. Relaunch: a new ID, syncPurchases, then a TRANSFER event in the RevenueCat dashboard, SUBSCRIPTIONS[newId] in the sandbox KV, and the tier restored in the app with no user action.
    5. This confirms the chain end to end. The SDK and doc evidence (B.6 B2.5) already settles that syncPurchases follows the setting. It does not prove the production setting, which is an operator check (E).

B.8 Before, at and after

When Step
Now Operator registers the group (B.6 prerequisite). B1, B2 and B3 merged; the ASSN URL repointed to v3; Nutri-E released (Invotek-signed). Count-E canary (B0) released
Before T0 (Nutri-E) Gate: at least 90% of 30-day active devices on the migration build or later (ASC App Analytics, operator), and Count-E canary = present after the pilot's U1. Proposed gate values; see Open questions
T1 One operator session: Invotek deletes group.no.invotek.Nutri-E, Dashecorp registers it immediately, enables it on no.invotek.Nutri-E and adds it to the Dashecorp App Store profile. Then confirm it in Dashecorp's Identifiers list before NB1 is built (steal window, B.4)
U1 The first Dashecorp build keeps the same entitlement. At submission, App Store Connect warns about a possible keychain loss. That is expected for AGI items (B.4) and is not a reason to abort. Migrated users read their ID. Others hit B2
After Watch the Crashlytics device_id_store and identity_recovery distributions and the TRANSFER events. Support path: "Restore Purchases" in More

B.9 Risks

  • The DTS method is "bleeding edge". The Count-E canary and the optional two-team dev spike (below) reduce this. B2 is the fallback.
  • Another team registers group.no.invotek.Nutri-E in the gap between delete and register (r. 171616887). Then U1 cannot carry the group, and every user falls to B2. Mitigated by the one-session move (B.8 T1). If it happens, do not release U1 with a different group until the operator decides: every migrated item would be lost.
  • The App Group cannot be deleted at Invotek, or not registered at Dashecorp. Then U1 cannot carry the entitlement. Order: delete first, then register. Test it on Count-E.
  • The live project's production restore behaviour is not "Transfer to new App User ID", or TRANSFER is filtered out of whintgr812efdd741. Then B2 recovers nothing and option 4 is needed. Operator checks (E).
  • The Dashecorp In-App Purchase key is not in RevenueCat before U1. Then B2's post cannot be recorded. Phase 4's order prevents it.
  • syncPurchases can move a subscription away from another device that shares the same Apple ID and a different Nutri-E ID. That is how "Transfer" works, and the legacy sync and Restore already have the same effect. It is limited to capped attempts after an identity loss.
  • Adding App Groups to Nutri-E needs a profile regeneration. A wrong match run could create a certificate; A4's safety model covers it.
  • Pre-existing: the salt is in the binary (B.1). Do not fix it in the same release. Changing the auth scheme during the migration would confound both.

B.10 Issue drafts

B0. Repo dashecorp/dashe-count-e. Title: "Keychain continuity canary in the App Group (proves the transfer method before Nutri-E)"

Count-E is the transfer pilot. Before Nutri-E relies on Apple DTS's app-group keychain method (thread 706128), prove it on Count-E's real transfer. This is also the post-transfer half of Quinn's "Test Before You Ship".

  • Add KeychainContinuityCanary:
  • account no.invotek.CountE.keychainCanary, access group group.no.invotek.CountE, kSecAttrAccessibleAfterFirstUnlock;
  • writes a random UUID once and sets UserDefaults keychainCanaryWritten;
  • exposes status: notWritten | present | missing | error(OSStatus).
  • Show it in Settings > About with accessibility id settings-keychain-canary.

Tests first: unit tests with a fake keychain for each status transition (a group the app is not entitled to reads as notFound), plus a UI test that the row exists.

Acceptance: - [ ] Released on the App Store under Invotek before Count-E's T0. - [ ] Runbook note: after Count-E's U1, 2 devices that ran this build show present. Record the result in claude-3#954.

Est.: ~120 LOC including tests.

B1. Repo dashecorp/dashe-nutri-e. Title: "Move the device ID into the App Group keychain group before the App Transfer"

Why: DeviceAuthService keeps the device ID in the default keychain group (DeviceAuthService.swift:96-138), which is prefixed with Team ID V9GL74NMBR. It becomes unreadable at the first Dashecorp-signed update (TN2311). The ID is the RevenueCat appUserID, the StoreKit appAccountToken and the key for SUBSCRIPTIONS and quota in the OpenAI and DSLD workers. This implements Apple DTS's app-group method (forums thread 706128, steps 1-3).

Prerequisite (operator): the group.no.invotek.Nutri-E App Group is registered in team V9GL74NMBR, enabled on App ID no.invotek.Nutri-E, and the App Store profile is regenerated.

Do: spec B.6 (B1): - entitlement; - KeychainStoring plus SecKeychainStore (errSecItemNotFound maps to notFound); - the new getDeviceId() algorithm, which lowercases the migrated legacy ID and falls back to the default group when the group write fails; - getDeviceSecret() becomes derive-only; - the deviceIdIssued marker, the priorRun detection (which also reads onboardingCompleted and the RevenueCat legacy-sync keys that older builds wrote), and the persisted identityRecoveryPendingSince marker; - the Crashlytics device_id_store key.

Keep the account name, the lowercase normalisation and AfterFirstUnlock.

Tests first: spec B.7 items 1-9, plus the device checklist in item 12.

Acceptance: - [ ] All B.7 unit tests pass in CI. - [ ] The device checklist is done on 1 device, and its result is written in the PR. - [ ] The archive in build-and-upload.yml succeeds with the new entitlement. - [ ] No device ID is ever written to UserDefaults (grep test). - [ ] docs/ gets a dated design note; CLAUDE.md "Device Authentication" is updated.

Est.: ~340 LOC including tests.

B2. Repo dashecorp/dashe-nutri-e. Title: "Re-bind the subscription automatically when the device identity is lost"

Why: users who do not run the B1 build before the first Dashecorp-signed update get a new device ID. Their subscription must follow without a manual Restore. syncPurchases, then RevenueCat TRANSFER, then the worker's transferSubscription (cloudflare-worker-apple/worker.js:605-645, tested) re-binds it on the server.

Do: spec B.6 (B2): - the PurchasesSyncing seam; - syncIdentityRecoveryIfNeeded(), gated on the persisted identityRecoveryPendingSince marker from B1; - it latches (clears the marker and sets no.invotek.nutrie.identityRecoverySyncDone) only when entitlements.active is non-empty; - an empty success counts toward identityRecoveryAttempts (cap 3), and a throw retries next launch without counting, the same rule as the legacy sync; - Crashlytics identity_recovery.

Tests first: spec B.7 item 10, then the sandbox end-to-end test in item 13, with its result in the PR.

Acceptance: - [ ] Unit tests pass. - [ ] The sandbox end-to-end run shows a TRANSFER event, SUBSCRIPTIONS[newId] in the sandbox KV, and the paid tier restored with no taps. The operator confirmed the sandbox override beforehand. - [ ] Operator has confirmed that the production restore behaviour of proj2c32896f is "Transfer to new App User ID", and that whintgr812efdd741 delivers TRANSFER.

Ships in the same release as B1. Depends on: B1. Est.: ~170 LOC including tests.

B3. Repo dashecorp/dashe-nutri-e. Title: "Apple webhook worker: refunds and expiries follow a RevenueCat transfer"

Why: transferSubscription sets a forwarding pointer that only writes follow (updateSubscription, cloudflare-worker-apple/worker.js:79-101). - Apple EXPIRED, REFUND and REVOKE on a transferred-out appAccountToken call removeSubscription(oldId) (:107-130,437-454,480-482). The record is already gone there, so the new ID keeps paid access until expiresAt. After that the OpenAI worker gives FREE_DAILY_POLICY instead of the wallet (cloudflare-worker-openai/worker.js:536-547). - The RevenueCat CANCELLATION branch only logs (:771-773), so a RevenueCat-reported refund is ignored too. - Identity-loss recovery (B2) makes TRANSFER the normal path for every non-migrated subscriber at the team change.

Do: spec B.6 (B3): - store originalTransactionId on every write; - removeSubscription follows forwarding for targets with the same originalTransactionId, never for a different one, and logs unkeyed legacy targets; - RevenueCat CANCELLATION with cancel_reason=CUSTOMER_SUPPORT removes the record.

Tests first: spec B.7 item 11.

Acceptance: - [ ] test-revenuecat-transfer.mjs and the new tests pass in CI. - [ ] Deployed to sandbox-v3 and v3 by deploy-workers.yml. - [ ] v2 is untouched.

Depends on: the ASSN URL repoint to v3 (Order of work, Phase 0). Est.: ~120 LOC including tests.

B-spike (optional; needs NEW_TEAM). Repo: a scratch repo dashecorp/keychain-team-spike, not a product repo. Title: "Two-team App Group keychain spike"

Gives an earlier signal than the Count-E pilot. 1. Invotek registers group.no.invotek.kcspike. 2. A dev-signed Invotek app writes a keychain item into that group. 3. In one session, Invotek deletes the group and Dashecorp registers it. 4. A dev-signed Dashecorp app (another bundle ID) with the group reads the item on the same device.

Pass means the item is read.

Est.: ~100 LOC, throwaway.


C. Cuti-E SDK and backend (ios-sdk, dashe-cutie)

C.1 Findings

Paths in this table are under ios-sdk/Sources/CutiE/ for the SDK and dashe-cutie/cloudflare-worker/src/ for the backend.

Item Where Team-bound? At T1 / U1 Change
SDK device ID com.cutie.deviceID UserDefaults, CutiE.swift:30-39 No Survives both none
SDK device token com.cutie.deviceToken Keychain, default group, ThisDeviceOnly, CutiEAPIClient.swift:37-79 Yes (keychain) Lost at U1. ensureDeviceToken() re-registers (:697-717). The server returns the same token for (customer, device ID) (handlers/device-tokens.js:75-99), so conversations, which are keyed by device ID, stay intact none
SDK App Attest key ID com.cutie.appAttest.keyId Keychain, CutiEAppAttest.swift:70-79,345-390 Yes Unused: no dashe app sets useAppAttest: true, and the default is false (CutiE.swift:57 on main, :56 in the shipped 2.3.1; :84-90). Call sites: Nutri-E CutiEService.swift:37, Reward-E :26, Count-E :32, Fast-E :32, Drink-E :24, Heart-E :24 none
Backend App Attest verification handlers/app-attest.js:339-372,383-418 Would be (RP ID) verifyAttestation rejects blobs under 100 bytes and otherwise accepts (:345-366). verifyAssertion requires only 50 bytes (:390), then increments the sign count and accepts (:405-411). Neither checks the RP ID or the signature none now. Constraint: any future real verification must accept both V9GL74NMBR.<bundle> and NEW_TEAM.<bundle> during the migration
Backend DeviceCheck handlers/device-check.js:16-18,56,343-348 (APPLE_TEAM_ID, APPLE_DEVICECHECK_KEY_ID, APPLE_DEVICECHECK_PRIVATE_KEY) Yes The public SDK has no DeviceCheck client code in any version. It exists only in the stale dashe-cutie/ios/Sources/CutiE/CutiEDeviceCheck.swift, and nothing calls /v1/device/check (review). The dashe apps resolve SDK 2.3.x (Package.resolved in fast-e, drink-e, heart-e, reward-e; nutri-e requires ≥ 2.3.0 at project.pbxproj:613-616). Do not run ios/release-public.sh:34-35, which would publish the stale copy none. Operator confirms zero traffic on /v1/device/check/*
APNs per app Cloudflare D1 table apps: apns_key_id, apns_team_id, apns_auth_key, bundle_id, apns_sandbox; utils/notifications/apns.js:20-41 Yes Fails from T1 (VERIFIED, DTS, 0.2) for every transferred app with an APNs row. Which apps have rows, and that they share Invotek key 635XA3L42Q, comes from the fact base and ios-app-transfer.md:415-416, not from code: UNVERIFIED config PATCH at T1. The operator confirms the rows first (C.3.0)
APNs customer-level fallback customers table, apns.js:50-80; written by handlers/customers.js:152-160 Yes Used if an app row is incomplete. It would silently sign with a possibly Invotek key validation (C1), for both handlers
APNs env fallback apns.js:83-92; Feedback App queue.js:650-658,719-726 Yes Serves Cuti-E Admin and the Feedback App (no.invotek.Cuti-E-Feedback), which stay with Invotek for now do not change at dashe cutovers
Invalid-token handling apns.js:228-243 deactivates only on 410, BadDeviceToken and Unregistered — A wrong-team 403 does not wipe tokens, so the T1 gap does not lose push registrations pin with a test (C1)
Test push POST /v1/admin/notifications/test (index.js:1152, handlers/notifications.js:365-509) picks the most recent active token of any SDK user for the app (:401-407). Its sender builds the endpoint URL itself (:555-565) — A verification push at T1 would go to a real user add a device_id target and the port (C1)
Connection per team apns.js:210-219 (fetch per push to api.push.apple.com / api.sandbox.push.apple.com); wrangler.toml:3-4 (compatibility_date = "2024-01-01", flags nodejs_compat only) Yes, after Fast-E's T1 Apple: "You can't map a connection to APNs to multiple teams … open separate connection pools for each of them" (VERIFIED). From Fast-E's T1 the worker signs for two teams through one origin. Whether Cloudflare's fetch pooling mixes them is UNVERIFIED per-team port, built in C1 (C.2)
Cert pinning CutiECertificatePinning.swift:49-53: api.cuti-e.com, cutie-worker-sandbox.invotekas.workers.dev, invotekas.workers.dev No (Cloudflare) — phase 2, out of scope

Apps and push (from the fact base; the operator confirms in C.3.0): - Transferred, with APNs rows: Nutri-E, Fast-E, Reward-E. - Count-E: no APNs config. - Drink-E: uses 635XA3L42Q, but stays with Invotek until it is recreated as com.dashecorp.* with a new Cuti-E app row. - Heart-E: not registered in Cuti-E.

C.2 What must change

  1. SDK (shipped before): nothing is required.
  2. Optional hardening, not needed for the transfer: on DCError.invalidKey from generateAssertion or attestKey, call reset() and re-attest once. Today isAttested is assumed whenever a key ID exists (CutiEAppAttest.swift:121-127).
  3. It matters only if an app ever enables App Attest. VERIFIED: attested keys "don't survive app reinstallation, device migration, or restoration of a device from a backup" (Establishing your app's integrity).
  4. Backend code (C1, before Fast-E's T0, the first transferred app with APNs):
  5. PATCH /v1/apps/:appId (handlers/apps.js:251-325): no half-changed credentials, and routine saves still work.
    • Revision 1's "all three or none" rule would break both admin clients.
      • The web dashboard (admin-dashboard/js/settings.js:612-620) sends the prefilled apns_key_id and apns_team_id on every save of an iOS app, and apns_auth_key only "if new key provided".
      • The Android admin app does the same (AppDetailScreen.kt:550-552, AppDetailViewModel.kt:133-135).
      • A rename, an icon change or a brand-colour change would then return 400.
      • The iOS admin app sends only changed fields (ios-admin-app/.../Views/SettingsView.swift:2273-2275).
    • The rule, evaluated against the stored row:
      • A field changes when the body carries a non-empty apns_key_id or apns_team_id that differs from the stored value. If either changes, the same request must carry a non-empty apns_auth_key. The resulting key ID and team ID must both be non-empty. Otherwise return 400 apns_credentials_incomplete, and the row is unchanged.
      • Unchanged apns_key_id and apns_team_id sent without an auth key → accepted. This is the routine save.
      • apns_auth_key alone (a re-upload for the same key ID) → accepted.
      • Clearing: all three null or empty in one request clears them. Clearing only some of them → 400.
      • The format check ^[A-Z0-9]{10}$ applies to changed key ID and team ID values only. Unchanged legacy values stay editable.
      • The auth key must be a PKCS#8 PEM that crypto.subtle.importKey('pkcs8', …) accepts, as apns.js:125-141 does.
    • The existing single UPDATE stays atomic (apps.js:404-407).
    • Apply the same rule to PATCH /v1/customer (handlers/customers.js:152-160), which feeds the fallback.
  6. POST /v1/admin/notifications/test: accept an optional device_id (the SDK X-Device-ID, device_<UUID>) and use only that device's active token for app_id. Without it, keep today's behaviour but return a warning field.
  7. Per-team APNs connections. Add an optional per-app apns_port column (443 or 2197, default 443). sendAPNs (apns.js:210-219) and sendAPNsTest (notifications.js:555-565) build https://<host>:<port>/3/device/<token>.
    • VERIFIED: "You can also use port 2197 (instead of port 443) on either server when communicating with APNs" (Sending notification requests).
    • Add allow_custom_ports to compatibility_flags in wrangler.toml:4, for every environment. Do not bump the whole compatibility date. Without the flag, Workers ignore a custom port and connect to 443, so the column would silently do nothing. The flag became the default only from compatibility date 2024-09-02 (Cloudflare compatibility-flags doc, review; developers.cloudflare.com was outside this revision's fetch allow-list).
    • Use it from Fast-E's T1: NEW_TEAM rows get apns_port=2197, and Invotek rows (Cuti-E Admin, Drink-E) stay on 443. That gives each team its own origin. Whether Cloudflare pools by origin including the port is UNVERIFIED; the Fast-E cutover checks delivery for both teams (C.3).
    • Revision 1 planned this only as a contingency. Apple requires separate pools, so it is planned from the start.
  8. Tests: a 403 InvalidProviderToken response from APNs does not deactivate the token (C.4).
  9. Document the global APNS_* env (C.1) in code comments.
  10. Backend config (at T1 per app, operator, secret-bearing):
  11. PATCH the app row with Dashecorp's APNs key ID, apns_team_id=NEW_TEAM, the .p8 contents and apns_port=2197, in one request.
  12. Then force fresh APNs connections. Apple: "For transferred apps, close and recreate any existing connections" (VERIFIED). The worker uses fetch per push and cannot close pooled connections itself.
    • A redeploy of cutie-worker recycles isolates: re-run the deploy workflow, or wrangler deploy --env production.
    • Whether that also drops Cloudflare's upstream pooled HTTP/2 connections is UNVERIFIED. The port change gives the new team a new origin, which does not depend on it.
  13. Keep the global APNS_* env and the Invotek key for Cuti-E Admin, the Feedback App and Drink-E.
  14. The Dashecorp APNs key is a team-scoped Production key.
  15. Apple's current APNs page: team-scoped keys "restrict usage to either Sandbox or Production", and existing dual-environment keys "will continue to be supported" (VERIFIED, APNs token auth page).
  16. The Account help page still says a key "works for both the development and production environments" (review). The two pages disagree, so do not rely on a dual key.
  17. Each Cuti-E app row holds one key plus the apns_sandbox flag, and App Store and TestFlight builds use production. Add a Sandbox key only if dev-build testing needs it.
  18. dashe-cutie/CLAUDE.md still tells the reader to pick "Sandbox & Production". Update it in C1.
  19. Existing device tokens keep working.
  20. A token belongs to the app on the device, and Apple tells the recipient to "update your APNs servers with the chosen key" (VERIFIED).
  21. DTS says device tokens do not change because of a transfer (review). Apple's token docs list only restore, a new device and an OS reinstall.
  22. The SDK re-registers the token whenever the app passes it on (CutiEPushNotifications.swift:128-151).

C.3 Order

  1. Confirm the rows (operator, before Fast-E's T0). List the apps rows' non-secret columns only: app_id, bundle_id, apns_key_id, apns_team_id, apns_sandbox. Never select apns_auth_key. This settles which apps need a PATCH.
  2. Merge and deploy C1, including allow_custom_ports, before Fast-E's T0.
  3. When NEW_TEAM exists: operator creates one Dashecorp team-scoped Production APNs key and stores it in Bitwarden.
  4. Per app at T1:
  5. PATCH the row (with apns_port=2197).
  6. Redeploy the worker.
  7. Operator: on a test device with notifications allowed, open the app (still the Invotek build) and send feedback. Read the conversation's full device_id from GET /v1/conversations/:id, which returns c.* (handlers/conversations.js:536), or from the Cuti-E MCP get_conversation. The admin dashboard shows only the last 6 characters (admin-dashboard/js/conversation.js:200).
  8. POST /v1/admin/notifications/test {app_id, device_id}. It must be delivered. The worker log must show the new key ID and port 2197. A delivery over an old pooled connection proves nothing (0.2), which is why this runs after the redeploy and checks the port.
  9. An admin reply to a real conversation must be delivered.
  10. Fast-E only (the first mixed-team day): also send a test push to Cuti-E Admin or Drink-E (Invotek, port 443). Both teams must deliver.
  11. At U1: nothing on the Cuti-E side. The SDK token re-registers silently.

C.4 Tests first (dashe-cutie, Vitest, test/handlers/apps.test.js and notifications.test.js)

  1. PATCH that changes only apns_team_id, without an auth key → 400, and the row is unchanged.
  2. PATCH that changes the key ID and team ID with an auth key → 200, and all three are stored.
  3. PATCH with the stored key ID and team ID and no auth key (the dashboard's routine save) → 200, and nothing is cleared.
  4. PATCH with all three null → cleared. PATCH that clears only one → 400.
  5. Changed team ID with an invalid format → 400.
  6. Rewrite the existing apps.test.js:532-552 ("should update APNs settings"). It sends key ID ABC123 and team ID TEAM123 without an auth key to an app with no stored values, so it fails both new rules. Use valid 10-character values and include an auth key generated in the test.
  7. The same rules for PATCH /v1/customer.
  8. A test push with device_id sends to that device's token only. A mocked fetch checks the path /3/device/<token>.
  9. apns_port=2197 → the mocked fetch URL is https://api.push.apple.com:2197/3/device/<token>, for both sendAPNs and sendAPNsTest. The unit test proves the URL only; the Fast-E check in C.3 proves that the runtime honours it.
  10. sendAPNs with a mocked 403 {"reason":"InvalidProviderToken"} → shouldDeactivate is false. Optionally also pin 400 TopicDisallowed and DeviceTokenNotForTopic as not deactivating.

C.5 Risks

  • Push gap between T1 and the PATCH. Keep it to minutes: the key exists in advance, and the operator does the PATCH right after the transfer completes.
  • Mixed-team connection reuse inside Cloudflare. Apple requires separate pools (VERIFIED). Whether the per-team port achieves that on Cloudflare is UNVERIFIED. It is detected at the Fast-E cutover (C.3.3.6).
  • The flag is missing in one environment and the port is silently ignored. C1 sets it at the top level and in every [env.*] block, and the C.3 port check in the log catches a miss.
  • A future "real" App Attest verification that pins one App ID would break old or new builds. Note it in app-attest.js.
  • The later Dashecorp recreation of Cuti-E Admin and the Feedback App needs the env APNS_* changed then (0.3.5).

C.6 Issue drafts

C1. Repo dashecorp/dashe-cutie. Title: "APNs credentials: safe per-app update, per-team port and targeted test push (iOS team-change prep)"

Why: Nutri-E, Fast-E and Reward-E move from Apple team V9GL74NMBR to Dashecorp. At each app's transfer completion, its apps row needs a new APNs key ID, team ID and .p8 in one step, verified without pushing to a random user. From Fast-E's T1 the worker signs for two teams, and Apple requires separate connection pools per team.

Do: - handlers/apps.js:273-325 and handlers/customers.js:152-160: when the key ID or team ID changes, the auth key is required in the same request; unchanged values without an auth key are accepted (the dashboard and Android save contract); clearing is all-or-nothing; format checks on changed values (spec C.2.2.1). - Migration: apps.apns_port INTEGER DEFAULT 443; sendAPNs and sendAPNsTest use it; wrangler.toml gains allow_custom_ports in compatibility_flags for every environment. - handlers/notifications.js:365-509: optional device_id target; add a warning when it is missing. - A test pinning that 403 InvalidProviderToken never deactivates tokens (utils/notifications/apns.js:228-243). - A code comment in utils/notifications/apns.js:83 and queue.js:650: the env APNS_* serves Invotek's Cuti-E Admin and Feedback App; do not repoint it at a dashe cutover. - Update CLAUDE.md "Push Notifications": team-scoped keys are Sandbox-only or Production-only. Update the admin API doc.

Tests first: spec C.4 1-10.

Acceptance: - [ ] Vitest is green, with the new tests failing before the change. - [ ] A rename of a configured iOS app in the web dashboard still saves (manual check on sandbox). - [ ] Deployed to sandbox. - [ ] A dry run: PATCH a sandbox app with all three fields and apns_port=2197, then a targeted test push to a sandbox device is delivered.

Est.: ~220 LOC including tests.

No ios-sdk issue is needed (C.2.1).


D. Reward-E: Sign in with Apple via Firebase, App Check, APNs, StoreKit

D.1 Findings

  1. Sign in with Apple is native.
  2. ASAuthorizationAppleIDProvider requests .fullName, .email with a nonce (reward-e/ios/RewardE/Services/AuthService.swift:93-110).
  3. The token is passed to Firebase through OAuthProvider.appleCredential(withIDToken:rawNonce:fullName:) and Auth.auth().signIn(with:) (:191-228).
  4. Android uses Google sign-in (android/.../OnboardingScreen.kt:21,61), and web/ has no Firebase Auth. So nothing here uses Apple's web or code flow (VERIFIED by grep). The app never calls getCredentialState (review).
  5. Server-side data is keyed by the Firebase UID:
  6. Firestore members {uid} and userId (firestore.rules:14-19,138-142);
  7. subscription.ownerId (:219-220);
  8. the RevenueCat appUserID, through logIn(uid) (Services/SubscriptionService.swift:320-335, Models/AppState.swift:228,369).
  9. Local Core Data is not keyed by the UID. CDFamily.ownerId (Services/Storage/LocalStorageService.swift:27,46) holds localUserId, a local_<UUID> kept in UserDefaults (Services/Storage/StorageManager.swift:24-31). Core Data backs only the free tier (:47-62, :73-85). Paid-tier data is in Firestore.

So if the UID is preserved, nothing in Firestore or RevenueCat needs rewriting, and Core Data never depends on the UID. The only thing to change is the apple.com provider link on each Firebase Auth user: old sub to new sub, plus the relay email. 3. Without migration: - From T1, Apple returns a new sub (VERIFIED, 0.2). - Firebase finds no user for that sub, so it creates a new UID (or raises an account-exists error when the email matches). Which of the two happens is UNVERIFIED. - The user sees an empty app, and the family data is orphaned. docs/troubleshooting.md:467 describes the same orphaning after an account deletion. 4. Forced re-sign-in at U1 (VERIFIED from the SDK source): - Firebase Auth 10.29.0 (the version Package.resolved pins) saves the signed-in user through FIRAuthKeychainServices. Its query sets only kSecClass, kSecAttrAccount and kSecAttrService, with no kSecAttrAccessGroup (FIRAuthKeychainServices.m@10.29.0; the review also cites FIRAuth.m@10.29.0:2358-2386). No access group is used unless useUserAccessGroup is set, and Reward-E sets none (grep). - RewardE.entitlements has no keychain-access-groups, only an app group. So the default group is the App ID (kSecAttrAccessGroup, B.1), which carries the old prefix. - No Firebase doc page states the default; the SDK source is the primary evidence. - Effect: on a device's first run of a NEW_TEAM-signed build, authService.currentUser is nil. AppState.swift:179-190 then takes the no-session path, and a paid user sees "Sign in to continue" (OnboardingView.swift:230-245, review). - Free-tier users need no Firebase session ("Free tier: no auth needed", AppState.swift:185-187), and Android users are unaffected. - By then their sub is the Dashecorp one. So the re-link must be complete before U1 ships, and before any external TestFlight tester runs NB1. - A possible mitigation, UNVERIFIED and not recommended (D.7): Firebase documents moving the current user into a shared group with useUserAccessGroup followed by updateCurrentUser. Calling useUserAccessGroup alone signs the user out (FIRAuth.m@10.29.0:2567-2590, review). 5. App Check: - Release builds use AppAttestProvider (Services/AppCheckService.swift:40-51,63-69, Release at :47-49). The DeviceCheck fallback runs only below iOS 14; the target is 16.0 (ios/project.yml:5), so it is dead code. - Firebase: "enter the Team ID of your Apple Developer Account" for App Attest (App Attest codelab, VERIFIED). - The setting is the Firebase iOS app's single teamId (IosApp.teamId in the Firebase Management API, review). No setting accepts both teams at once. - Whether App Check enforcement is on is UNVERIFIED (operator). It can only matter for Firestore. Reward-E's functions are all onDocumentCreated triggers (functions/src/index.ts:9,39,103,137,174), with no callable or HTTP function. Cloud Functions enforcement is set in code (onCall({enforceAppCheck: true})), not in the console. 6. FCM is not used. The only Firebase products are Auth, Firestore, Crashlytics and AppCheck (ios/project.yml:125-132). Pushes go through Cuti-E (item C). No Firebase APNs key is on any path. 7. Build and backend: - Firebase projects: reward-e-app (prod) and reward-e-dev (dev). Sources: .firebaserc, ios/RewardE/GoogleService-Info-Dev.plist:14, android/app/src/debug/google-services.json:4, README.md:61, ios/project.yml:146. CLAUDE.md:297 is stale. - Cloud Functions deploy through Workload Identity Federation (.github/workflows/deploy-functions.yml:23-27,51). - GoogleService-Info.plist is keyed by bundle ID, so it is unchanged.

D.2 Apple's procedure (VERIFIED)

Sources: - [T] Transferring your apps and users to another team - [B] Bringing new apps and users into your team - [TN] TN3159 - [CS] Creating a client secret is the JWT format reference. Its kid is described as the key "generated for the Account and Organizational Data Sharing private key" (VERIFIED). For Sign in with Apple the kid is the Sign in with Apple key ID. That comes from Apple's Sign in with Apple token-generation page (review; this revision could not resolve that page's DocC URL).

  1. Sender token [T]:
    POST https://appleid.apple.com/auth/token
    Content-Type: application/x-www-form-urlencoded
    grant_type=client_credentials&scope=user.migration&client_id=no.invotek.RewardE&client_secret=<JWT>
    
  2. client_id is "The identifier (App ID or Services ID) for the transferring app. The identifier must not include your Team ID".
  3. Client secret JWT:
    • header alg: ES256, kid: <Sign in with Apple key ID>;
    • claims iss: V9GL74NMBR, iat, exp (at most 15777000 s ahead), aud: https://appleid.apple.com, sub: no.invotek.RewardE ("case-sensitive").
  4. The example response has expires_in: 3600. "There is also no rate limiting for this endpoint" [TN].
  5. Generate a transfer ID per user (sender) [T]:
    POST https://appleid.apple.com/auth/usermigrationinfo
    Authorization: Bearer <access_token>
    sub=<old team-scoped sub>&target=<NEW_TEAM>&client_id=no.invotek.RewardE&client_secret=<sender JWT>
    → {"transfer_sub": "..."}
    
    Apple: "you must exclude the team-scoped identifier and private email address, and include only the transfer identifier" when exporting to the recipient. See D.3.3 for how this design deviates.
  6. Exchange (recipient) [B]:
    POST https://appleid.apple.com/auth/usermigrationinfo
    Authorization: Bearer <recipient access_token>
    transfer_sub=<transfer_sub>&client_id=no.invotek.RewardE&client_secret=<JWT signed by NEW_TEAM's key, iss=NEW_TEAM>
    → {"sub": "<new sub>", "email": "<new relay address, only if private>", "is_private_email": true}
    
    Apple: "This value is the same as the sub in the ID token issued during user sign-in."
  7. Windows. Apple anchors the 60 days in two places. This spec uses the earlier one, acceptance (TA).
  8. [T], VERIFIED: "Your team can generate transfer identifiers before the recipient team accepts the app transfer, or up to 60 days after the recipient team accepts the app transfer."
  9. [T], VERIFIED: "The recipient team needs to exchange the transfer identifiers for new team-scoped identifiers within 60 days of accepting the app transfer."
  10. [TN] counts from completion (T1): "you have a sixty (60) days transfer period from the date you complete the app transfer". DTS forum 764888 does too (review). Processing takes up to 2 business days, so TA can be up to 4 calendar days before T1.
  11. ID tokens carry transfer_sub "for up to 60 days from the transfer date"; after that "the server endpoint … becomes inactive" [B].
  12. Missed window: "transfer the app back to the initial team and back" [TN].
  13. Order [TN]: ungroup the apps and decide on the Services ID, then:
  14. "Team A obtains access token(s)"
  15. "generates transfer identifiers"
  16. "initiates app transfer"
  17. "Team B obtains access token(s)"
  18. "exchanges transfer identifiers"
  19. "confirms successful user migration"
  20. Other rules:
  21. Grouped apps must be ungrouped ([Overview of app transfer]; [T]: "The App Store Connect app won't let you transfer an app if it's part of an app group").
  22. The Services ID associated with the primary App ID transfers "If you don't want the Service ID to be transferred … remove its association before initiating the transfer" [TN].
  23. Relay addresses are team-scoped, and Invotek loses the channel to users "If a user doesn't use any other applications from your team" [T].
  24. "There is no test, debug or sandbox environment for Sign in with Apple, or app transfers" (DTS, forum 764888). The same thread suggests rehearsing with a test app transferred and back (review). That is a real option, not an Apple limit; see D.7.
  25. Errors for usermigrationinfo (TN3107, "User migration info response errors", VERIFIED):
    • The section lists only invalid_request and invalid_client. invalid_scope belongs to the authorize endpoint, not here.
    • The section's introduction names a user who has revoked access to the client as a cause of failure. The review reads it as a cause of invalid_request.
    • Such a user fails permanently, so D.3 gives them a terminal status.
    • A rate limit for usermigrationinfo is UNVERIFIED.
  26. Firebase: nothing in its docs covers transfer_sub (UNVERIFIED; see firebase-ios-sdk#7190). We need our own mapping.

D.3 Migration design

  1. Where it runs: a TypeScript tool in reward-e/tools/siwa-migration/ (Node 20, firebase-admin, jose), started by .github/workflows/siwa-migration.yml.
  2. Trigger: workflow_dispatch with these inputs:
    • step = generate|exchange|relink|verify|all;
    • dry_run (default true);
    • project = reward-e-dev|reward-e-app;
    • only_uid (optional, for single-user rehearsal);
    • transfer_accepted_at (ISO date, the TA). It is required for exchange and later steps, and for the post-TA catch-up generate.
  3. GCP auth: the existing WIF secrets GCP_WORKLOAD_IDENTITY_PROVIDER and GCP_SERVICE_ACCOUNT. The SA needs Firebase Authentication Admin and Cloud Datastore User on reward-e-app (operator grants), and the same on reward-e-dev for the rehearsal.
  4. Why GitHub Actions: it is auditable, keeps keys off laptops, and reuses the repo's WIF. Rejected: a Cloud Run job (new infrastructure) and a laptop script (keys on disk).
  5. Deadline guard: every Apple call refuses to run after transfer_accepted_at + 59 days, and the summary prints the days left.
  6. Apple keys: repo secrets, provisioned by OpenTofu; values never shown.
  7. Sender (Invotek): SIWA_SENDER_KEY_ID, SIWA_SENDER_KEY_P8.
  8. Recipient (Dashecorp): SIWA_RECIPIENT_KEY_ID, SIWA_RECIPIENT_KEY_P8.
  9. Non-secret workflow env: SIWA_SENDER_TEAM_ID=V9GL74NMBR, SIWA_RECIPIENT_TEAM_ID=NEW_TEAM, SIWA_CLIENT_ID=no.invotek.RewardE.
  10. State: Firestore _siwaMigration/{firebaseUid} holding { transferSub, newSub, isPrivateEmail, status, error?, attempts, updatedAt }.
  11. status is one of generated, exchanged, relinked, conflict, skipped_post_transfer, revoked_or_unmigratable (terminal) and error (retryable).
  12. Clients are denied by default. firestore.rules has no match for it; add an explicit deny and a rules test.
  13. The old sub is not stored.
  14. Export rule: a conscious deviation.
    • Apple says the export to the recipient must leave out the team-scoped identifier and the private email (D.2.2).
    • In this design one Firebase project serves both sides. That project still holds Invotek's sub (providerData.uid) and the old relay email until the relink, and the recipient-side job runs with access to it.
    • Nothing is exported. But the recipient-side tooling can reach exactly the data Apple says not to hand over, so the design does not meet the letter of the rule. The justification is that one operator controls both teams and the Firebase project.
    • To keep the deviation small, only generate (sender credentials) reads providerData.uid. exchange, relink and verify read only _siwaMigration rows, and getUsers by UID for the email-domain check. They never read the old sub. A test pins this (D.6.3).
  15. Delete the collection after TA+60 days.
  16. generate (sender credentials; before T0, again on the day of T0, and a catch-up after T1, all before TA+60):
  17. Page through listUsers(1000).
  18. For each user with a providerData entry providerId=='apple.com', take oldSub = providerData.uid. That this is the Apple sub is UNVERIFIED: confirm the format on the operator's own user in the dry run.
  19. Skip users already generated or later, and terminal rows.
  20. Get a sender token (cached, re-requested at 55 minutes), then call usermigrationinfo(sub, target=NEW_TEAM) and upsert transferSub.
  21. Users created after transfer_accepted_at already have a Dashecorp sub. Mark them skipped_post_transfer.
  22. Errors:
    • invalid_client → error, and the run stops: the credentials are wrong for everyone.
    • invalid_request → retry once with a fresh token and client secret. If it persists, the row becomes revoked_or_unmigratable, with the Apple error code. Typically the user revoked Sign in with Apple for the app.
    • 5xx → retry with backoff, then error.
  23. exchange (recipient credentials; only after T1, before TA+60): for generated rows, call usermigrationinfo(transfer_sub) and store newSub, email and isPrivateEmail. Errors are handled as in 4.6.
  24. relink (Firebase Admin), per exchanged row:
  25. getUsers([{providerId:'apple.com', providerUid:newSub}]) (VERIFIED API, at most 100 per call).
  26. If that returns a different UID, a duplicate was created by a sign-in after T1:
    • if the duplicate has no members document (collectionGroup('members').where('userId','==',dup) is empty) and resolve_empty_duplicates=true, delete the duplicate;
    • otherwise mark conflict for manual handling.
  27. updateUser(uid, { providersToUnlink: ['apple.com'] }), then updateUser(uid, { providerToLink: { providerId: 'apple.com', uid: newSub, email } }).
    • providerToLink is PARTIAL: a snippet reads "Links this user to the specified provider" (UpdateRequest).
    • providersToUnlink exists in firebase-admin-node (auth-config.ts:187-190, review), so it is VERIFIED as API. Its exact semantics are proved by the dev rehearsal.
  28. If isPrivateEmail and the user's email is at privaterelay.appleid.com, updateUser(uid, {email: newEmail}).
  29. Mark relinked. The step is idempotent.
  30. verify: print counts:
  31. Firebase users with apple.com;
  32. rows by status, with revoked_or_unmigratable reported separately;
  33. relinked users whose getUsers(apple.com/newSub) returns the same UID;
  34. users with apple.com but no row.

The job summary gets counts only, with no identifiers. 8. Gates: - U1 (and any external TestFlight distribution of NB1) is not submitted until verify shows 0 error and every conflict is resolved. - revoked_or_unmigratable does not block the gate. Those users cannot be migrated by Apple's API. They get a new UID at their next Sign in with Apple, if they ever sign in again. The operator records the count. - After U1, re-run exchange + relink + verify daily until TA+55 days, for users created between the last generate and TA. 9. Optional fallback D3 (only if the T1→relink gap cannot be kept to hours): - A callable Cloud Function claimAppleTransfer({appleIdToken}). It verifies the Apple ID token (JWKS, aud=no.invotek.RewardE, iss=https://appleid.apple.com), reads its transfer_sub, finds the row, relinks the original UID and deletes the empty duplicate. - The app calls it when a sign-in lands on a new UID that has no family. - ~250 LOC. Not in the recommended scope.

D.4 Firebase and store config

Setting Today Change When Who
Firebase Auth → Apple provider: Services ID UNVERIFIED (check the console) None if the Services ID is associated with Reward-E's App ID: it moves to NEW_TEAM with the app [TN] — operator checks
Firebase Auth → Apple provider: OAuth code flow (Team ID, Key ID, private key) UNVERIFIED; probably empty, because only the native idToken flow is used (D.1.1) If filled: Team ID → NEW_TEAM, Key ID and key → the Dashecorp Sign in with Apple key T1 operator
Apple private email relay: email sources UNVERIFIED Only needed if Reward-E or Firebase sends email to relay addresses ("If you use any of Firebase Authentication's features that send emails to users", Firebase Apple, VERIFIED). Reward-E uses none — operator confirms none
App Check enforcement (Firestore only) UNVERIFIED If enforced: switch to monitor-only before the first NB1 build reaches TestFlight or App Review, and keep it off until at least 98% of verified requests come from NEW_TEAM builds. NB1 builds are Release builds with AppAttestProvider (AppCheckService.swift:47-49), so with enforcement on, a TestFlight tester or the App Review reviewer would hit Firestore permission errors. The 15-minute quote ("it can take up to 15 minutes after you enable enforcement for it to take effect", enable enforcement, VERIFIED) is about enabling. How long disabling takes is undocumented, so switch it off at least a day ahead. The 98% threshold is this spec's own number; Firebase's monitor metrics page says only "almost all". Functions are not affected (D.1.5) before NB1 → U1+n weeks operator
App Check → Reward-E (iOS) → App Attest → Team ID V9GL74NMBR (assumed) → NEW_TEAM when the first NB1 build is uploaded to TestFlight, with enforcement already off. The single teamId means one build population always fails validation, so no moment is lockout-free with enforcement on. Switching at NB1 shows before release whether new-team attestation verifies, at the cost of the Invotek-signed production traffic showing as unverified until users update. Switching at the U1 release is the alternative; it keeps the metrics clean longer but gives no signal until release. Whether Firebase's assertion exchange re-checks the RP ID of an artifact from an earlier attestation is undocumented (UNVERIFIED) NB1 upload operator
App Check DeviceCheck key n/a none (dead path, D.1.5) — —
FCM APNs key n/a none (FCM not used) — —
Configuration.storekit:11, test, validator V9GL74NMBR the A5 mirror, flipped in the cutover PR U1 PR agent
App Group group.no.invotek.RewardE Invotek Invotek deletes it and Dashecorp registers it in one session, then enables it on the app and widget App IDs (A.6.1, steal window) T1 operator

D.5 Runbook

Tags: [OP] operator only, [AG] agent PR or workflow dispatch, [CI] workflow run.

Prep (weeks before T0) 1. [AG] Merge A5, C1, D1a and D1b, with dry_run defaulting to true. 2. [AG] Rehearse relink on reward-e-dev with synthetic users (D.6 test 5). 3. [OP] In the Invotek portal: - check whether no.invotek.RewardE is grouped for Sign in with Apple (e.g. with Cuti-E Admin); ungroup if so; - list the Services IDs associated with it and decide whether to keep them (they transfer) or remove the association; - note which Firebase Apple-provider fields are filled. 4. [OP] Create or identify an Invotek Sign in with Apple key enabled for primary App ID no.invotek.RewardE, store it in Bitwarden, and apply SIWA_SENDER_* through OpenTofu. 5. [OP] Record NEW_TEAM (needed as target). Grant the WIF SA the roles in D.3. 6. [OP] Send any user communication to relay addresses now. Invotek loses that channel after T1. 7. [CI] generate with dry_run=true on reward-e-app (counts only). Then [OP] approves, and [CI] runs generate with dry_run=false. 8. [OP] Record the App Check enforcement state. Freeze Reward-E releases. Remove the draft review submission and do the TestFlight and Xcode Cloud cleanup (reference doc). 9. [CI] Catch-up generate on the day of T0. [OP] Invotek initiates the transfer (T0).

Transfer day (TA, then T1)

  1. [OP] Dashecorp accepts (TA; record the timestamp as transfer_accepted_at, since the 60 days start here), then waits for "Processing App Transfer" to finish (T1).
  2. [OP] Create the Dashecorp Sign in with Apple key with primary App ID no.invotek.RewardE, now in the Dashecorp account.
    • Whether a key created earlier for a different primary App ID could sign for no.invotek.RewardE is UNVERIFIED, so create it after T1.
    • Store it in Bitwarden and apply SIWA_RECIPIENT_* through OpenTofu.
  3. [CI] Catch-up generate, with transfer_accepted_at set.
  4. [CI] exchange then relink with only_uid=<operator's own Firebase UID>.
    • [OP] On a device still running the Invotek-signed build: sign out, then Sign in with Apple. After T1 this returns the recipient-team sub (DTS 765778), so it tests the relink.
    • The same family must be visible, which means the same UID.
    • If not, stop and debug. The 60-day window allows it.
  5. [CI] exchange, relink and verify for everyone. Resolve conflicts. Record the revoked_or_unmigratable count.
  6. [OP] Firebase Apple provider fields per D.4 (only if they are filled).
  7. [OP] RevenueCat keys (E), the Cuti-E APNs row (C.3), and a re-check of the App Store Server Notifications URL (https://api.revenuecat.com/v1/subscribers/apple, production and sandbox).
  8. [OP] App Group move in one session (D.4). Run generate-match-profiles with team_id=NEW_TEAM and app_identifiers=no.invotek.RewardE,no.invotek.RewardE.Widget.

First Dashecorp build (NB1) and update (U1)

  1. [OP] If App Check is enforced: set it to monitor-only now, at least a day before step 20.
  2. [AG] Cutover PR: ios/Team.xcconfig and Configuration.storekit:11 → NEW_TEAM. The guard and XCTest are green.
  3. [CI] Build and upload NB1 to TestFlight (internal testers only). [OP] Set the App Check App Attest Team ID to NEW_TEAM. Check that App Check metrics show verified requests from the NB1 build.
  4. [OP] Gate (D.3.8), then submit for review. App Store Connect will warn about a possible keychain loss; that is expected (A.6.7). Release (U1).
  5. [OP] Monitor for 72 h:
    • Crashlytics "Apple Sign-In: Firebase auth failed" (AuthService.swift:221-222);
    • new rows in conflict;
    • App Check metrics;
    • Firestore permission errors.

Tail (to TA+60 days)

  1. [CI] Daily exchange, relink and verify until TA+55 days.
  2. [OP] Generate a new Dashecorp app-specific shared secret (E). Re-enforce App Check at the D.4 threshold.
    • Regrouping (UNVERIFIED): [B] says to coordinate with the sending team and regroup after the transfer completes. But if Reward-E's group partner stays at Invotek (e.g. Cuti-E Admin, whose Sign in with Apple audience is com.cutie.admin: dashe-cutie/cloudflare-worker/src/handlers/apple-auth.js:397), there is no Dashecorp app to regroup with. Reward-E then stays its own primary App ID, and any regrouping is an Invotek-side action for its remaining apps.
    • No primary source says whether cross-team grouping is possible.
  3. [AG] After TA+60 days: delete _siwaMigration, remove the SIWA_SENDER_* secrets, and delete the workflow or disable it (keep the tool for Cuti-E Admin, which uses Sign in with Apple too: dashe-cutie/cloudflare-worker/src/handlers/apple-auth.js).

D.6 Tests to write first

  1. JWT builder: header alg=ES256 and kid; claims iss, sub=client_id, aud=https://appleid.apple.com, and exp at most 15777000 s after iat. Signed with a throwaway P-256 key generated inside the test (no real key).
  2. HTTP layer with a mocked fetch:
  3. form-urlencoded bodies exactly as in D.2;
  4. Authorization: Bearer;
  5. the token cache is refreshed at 55 minutes;
  6. invalid_client → row error, and the run stops;
  7. invalid_request → one retry with a fresh token, then revoked_or_unmigratable with the Apple error code;
  8. 5xx retries with backoff;
  9. no call after transfer_accepted_at + 59 days.
  10. Idempotency and isolation:
  11. generate, exchange and relink are idempotent: a second run writes nothing.
  12. dry_run performs zero Firestore or Auth writes (spy on the admin mocks).
  13. exchange, relink and verify never read providerData (spy).
  14. Conflict handling and gates:
  15. a duplicate with no membership is deleted only when resolve_empty_duplicates is set;
  16. a duplicate with a membership becomes conflict;
  17. verify's gate passes with revoked_or_unmigratable rows present, and fails with one error row.
  18. Relink integration against the Firebase Auth emulator, or reward-e-dev if the emulator lacks providerToLink:
  19. Import a user U with providerData apple.com uid=OLD.
  20. Relink to NEW.
  21. getUsers(apple.com/NEW) returns U; getUsers(apple.com/OLD) returns nothing; the UID is unchanged; the members document still resolves.
  22. Firestore rules test (the repo has firestore-rules-tests.yml): an authenticated client cannot read or write _siwaMigration/*.
  23. iOS: A5's SubscriptionConfigValidationTests change.

D.7 Risks

  • The 60-day window is missed. The recovery is "transfer the app back and forth" (VERIFIED), which costs weeks. The deadline guard keyed to TA, the daily catch-up and the calendar gate prevent it.
  • Duplicates from sign-ins between T1 and the relink. Keep the gap to hours; conflict resolution handles the rest.
  • The providerToLink / providersToUnlink semantics. Both exist as API; the dev rehearsal and the single-user rehearsal at T1 settle their behaviour.
  • The Apple half is first exercised at T1. Apple has no sandbox for transfers (VERIFIED). DTS 764888 suggests a test transfer of a throwaway app and back (review). This spec does not do that: it costs a released app and weeks, and the single-user rehearsal at step 13 is the control. Revisit if NEW_TEAM exists months before Reward-E's turn.
  • Users who revoked Sign in with Apple cannot be migrated. They are counted, and they do not block U1.
  • Every signed-in Reward-E iOS user signs in again on their first NEW_TEAM build. Expect a spike in Crashlytics sign-in logs. The alternative, sharing Firebase Auth through the app-group keychain (useUserAccessGroup("group.no.invotek.RewardE") plus updateCurrentUser, and B's DTS method), adds risk and is not recommended.
  • App Check lockout if enforcement is on when NB1 runs (D.4).
  • The export-rule deviation (D.3.3). It is accepted because one operator controls both teams.
  • Users who never open the app again: the batch relink covers them. Only users with no transferSub at TA+60 days are lost (users created after the last generate but before TA). The day-of-T0 and T1 catch-ups close that gap.

D.8 Issue drafts

D1a. Repo dashecorp/dashe-reward-e. Title: "Sign in with Apple team transfer, part 1: transfer_sub generation tool (sender side)"

Why: Reward-E moves from team V9GL74NMBR to Dashecorp. Apple's sub is team-scoped. Every Firebase user's Apple identity must be mapped, or returning users land on a new, empty Firebase account. Apple procedure: TN3159 (spec D.2).

Do: - tools/siwa-migration/: - a client-secret JWT (ES256, kid = the Sign in with Apple key ID, iss=V9GL74NMBR, sub=no.invotek.RewardE, aud=https://appleid.apple.com); - POST https://appleid.apple.com/auth/token with grant_type=client_credentials&scope=user.migration; - POST https://appleid.apple.com/auth/usermigrationinfo with sub, target=<NEW_TEAM>, client_id and client_secret. - The generate step over listUsers, the _siwaMigration/{uid} schema with the terminal revoked_or_unmigratable status, and the TA+59 deadline guard (spec D.3). - .github/workflows/siwa-migration.yml: workflow_dispatch, WIF auth, project = reward-e-dev|reward-e-app, dry_run defaults to true, and a job summary with counts only. - firestore.rules: explicit deny for _siwaMigration. - A dated design note in docs/, which records the export-rule deviation (spec D.3.3). - CLAUDE.md:297: the dev project is reward-e-dev.

Secrets (OpenTofu, operator): SIWA_SENDER_KEY_ID, SIWA_SENDER_KEY_P8.

Tests first: spec D.6 1-3 (the generate path) and 6.

Acceptance: - [ ] Tests green. - [ ] generate dry run on reward-e-app reports the number of apple.com users, and confirms that the operator's own providerData.uid has Apple sub format. - [ ] A real generate stores a transferSub for every apple.com user, or marks them revoked_or_unmigratable, with 0 error rows.

Est.: ~330 LOC including tests.

D1b. Repo dashecorp/dashe-reward-e. Title: "Sign in with Apple team transfer, part 2: exchange + Firebase re-link + verify (recipient side)"

Do: - exchange: usermigrationinfo with transfer_sub, signed with NEW_TEAM's key and iss=NEW_TEAM. - relink: getUsers conflict check, then providersToUnlink ['apple.com'], then providerToLink {providerId:'apple.com', uid:newSub}, and a relay-email update. None of these steps reads the old sub from providerData. - verify (terminal rows reported separately and excluded from the gate), the only_uid input, and resolve_empty_duplicates. - The runbook, spec D.5, as docs/siwa-team-transfer-runbook.md.

Secrets: SIWA_RECIPIENT_KEY_ID, SIWA_RECIPIENT_KEY_P8, created after T1.

Tests first: spec D.6 2-5.

Acceptance: - [ ] Emulator or reward-e-dev rehearsal: 20 synthetic users relinked, UIDs unchanged, lookup by the new sub works. - [ ] The dry run on prod shows the planned operations and performs no writes.

Est.: ~370 LOC including tests.

D-infra. Repo dashecorp/infra. Title: "Reward-E Sign in with Apple migration secrets": SIWA_SENDER_* now and SIWA_RECIPIENT_* at T1, for dashe-reward-e only, plus the secrets-registry.md entry. Est.: ~20 LOC.


E. RevenueCat (Nutri-E, Reward-E): config only, operator does it

Both apps run purchases-ios 5.x with the default configuration, which uses StoreKit 2: Nutri-E 5.59.0 (its Package.resolved), Reward-E 5.52.1 (ios/Package.resolved:122-127), set up at SubscriptionManager.swift:56-58 and SubscriptionService.swift:101 (review). This decides which key matters.

RevenueCat setting (per iOS app) Team-bound? Nutri-E Reward-E When
In-App Purchase key (Key ID, Issuer ID, .p8) Yes: "You can use the same in-app purchase key for all App Store apps belonging to the same App Store Connect account" (IAP key, VERIFIED). "transactions will fail to be recorded without this key being set" Replace with Dashecorp's. The live project proj2c32896f almost certainly has one today: it recorded a StoreKit 2 sync in production on 2026-09-10 (docs/subscriptions.md:212-230), which RevenueCat cannot record without the key (inference; one dashboard look confirms) Replace Minutes after T1. This is the gate for purchases: with StoreKit 2, purchases are likely to fail between the Invotek key failing and the Dashecorp key being in (0.2). Also before Nutri-E's U1, for B2 (B.6)
App Store Connect API key (Key ID, Issuer ID, .p8, Vendor number) Yes; used "to import products and prices" (ASC API key, VERIFIED). The Vendor number is Dashecorp's (Payments and Financial Reports) Replace Replace Right after the IAP key
App-specific shared secret No: it belongs to the app and travels with it. Apple has the recipient obtain it from the initiator, and generate a new one after the transfer "so that users outside of your organization no longer have access to it" (Overview of app transfer; Apple "Generate a shared secret", which also warns of "a transition period of up to 24 hours", review). RevenueCat: "mandatory for receipt validation when using StoreKit 1"; StoreKit 2 uses the IAP key (shared secret, VERIFIED) Make sure one is configured before T0; RevenueCat staff call it "a fallback … while the transfer is taking place". For these StoreKit 2 apps that fallback probably does nothing (UNVERIFIED), so it is hygiene, not the safety net. After T1, Dashecorp regenerates it and enters it same before T0, then after T1
App Store Server Notifications URL (set in ASC, per app) Survival after a transfer is UNVERIFIED Points at its own worker (nutrie-apple-webhook-worker-v2…/webhook), not RevenueCat. Repoint it to v3 before T0 (reference doc). B3 depends on this. Re-check it at T1 https://api.revenuecat.com/v1/subscribers/apple (production and sandbox, V2). Re-check at T1. RevenueCat's button "will automatically apply the URL in App Store Connect to both the Production and Sandbox environments" (RevenueCat ASSN, VERIFIED, review). That the button needs the Dashecorp ASC key is UNVERIFIED. Fallback: enter the URL by hand in the Dashecorp ASC account (App Information → App Store Server Notifications) T1
Restore behaviour (project setting) No, but B2 depends on it Must be "Transfer to new App User ID" (the default, VERIFIED). It governs syncPurchases for Nutri-E's UUID appUserID (VERIFIED, B.6 B2.5). Check the production value, and that the sandbox override is off or the same n/a (identified by Firebase UID) Before Nutri-E's B2 release
Webhook event filter No TRANSFER must be delivered by whintgr812efdd741 (production) and whintgr46e9d8efa9 (sandbox) (docs/subscriptions.md:136-142). The filter is UNVERIFIED n/a Before Nutri-E's B2 release
SDK public API keys, bundle IDs, product IDs, entitlements, offerings No unchanged unchanged —
Project ownership (RevenueCat account) No (not Apple) Answered: the live project is proj2c32896f; proj61d576be is archived ("do not use", docs/subscriptions.md:22-31) Open: the embedded key maps to projdd4fe8ba (fact base); proj61d576be also holds a Reward-E iOS app record separate

RevenueCat staff procedure (community, 2026-02-20, VERIFIED staff answer): 1. Before: "Ensure that your app has a app-specific shared secret and App Store Connect API key correctly configured". 2. After: "As soon as the Apple Developer Account change is complete, update the in-app purchase key", "Then update the app-specific shared secret and App Store Connect API key". 3. The reason for the shared secret: "Configuring the app-specific shared secret prior to the transfer ensures a fallback for RevenueCat validating/processing transactions while the transfer is taking place." As noted above, that fallback is a StoreKit 1 path.

Dashecorp can create its keys ahead of T1. Whether RevenueCat accepts and validates them before the app is in the Dashecorp account is UNVERIFIED, so have them ready in Bitwarden and upload them at T1.


Order of work (what ships first)

Phase 0: now. Nothing here needs the Dashecorp account.

  1. A0a, A0b, then A1-A6 (team as one setting). The guard is its own unfiltered workflow and a required check. Count-E goes first, since its PR proves the secrets[format()] and configFiles mechanics in this org; then the other five. A5 includes a TestFlight dispatch that proves the edited Matchfile.
  2. B1 + B2 (Nutri-E), after [OP] registers group.no.invotek.Nutri-E and the profile is regenerated. Release them on the App Store under Invotek as early as possible. Adoption time is the long pole.
  3. [OP] Repoint Nutri-E's App Store Server Notifications URL from v2 to v3 (reference doc; pre-existing drift). Then B3 (worker: refunds and expiries follow a transfer), deployed to v3.
  4. [OP] E checks for Nutri-E: the production restore behaviour, the sandbox override, and TRANSFER in both webhook integrations. B2's sandbox end-to-end test runs after them.
  5. B0 Count-E canary, released under Invotek before Count-E's T0. It can go in the same release as A1's first build.
  6. [OP] C.3.0: confirm the Cuti-E apps rows (non-secret columns). Then C1 (Cuti-E backend, including allow_custom_ports and apns_port), deployed before Fast-E's T0.
  7. D1a + D1b (Reward-E tool), merged with dry_run=true, rehearsed on reward-e-dev.

Phase 1: when NEW_TEAM exists.

  1. [OP] Create the Dashecorp ASC API key, In-App Purchase key and team-scoped Production APNs key. Apply ASC_*_<NEW_TEAM> through OpenTofu. The distribution cert comes on the first generate-match-profiles run with allow_new_cert=true. The Sign in with Apple key waits until Reward-E's T1 (D.5.11).
  2. Optional B-spike (two-team keychain).
  3. [CI] Reward-E generate (needs target=NEW_TEAM).

Phase 2: pilot, Count-E.

  1. T0 → TA → T1. Then [OP]:
    • check whether the widget App ID arrived (A.6.4);
    • move the App Group in one session and confirm it in Dashecorp's Identifiers list (A.6.1);
    • create the cert and profiles on branch NEW_TEAM.
  2. [AG] Cutover PR, then NB1 and U1. At submission, expect the ASC keychain warning (A.6.7). [OP] Checks the canary is present on 2 devices.
  3. Go/no-go for Nutri-E's reliance on B1. B2 and B3 ship regardless.

Phase 3: Fast-E (after its subscription fix; reference doc).

  1. T1 → Cuti-E APNs PATCH with apns_port=2197 → redeploy → targeted test push for Fast-E and for an Invotek app (C.3) → App Group (one session) → cutover → U1. This is the first real test of the APNs switch and of mixed-team pushes.

Phase 4: Nutri-E.

  1. Gate: at least 90% of active devices on the B1/B2 build, and the canary passed.
  2. T1 → RevenueCat In-App Purchase key within minutes, then the ASC key (E) → APNs PATCH → App Group group.no.invotek.Nutri-E moved in one session → cutover → U1.
  3. Watch the device_id_store and identity_recovery values and the TRANSFER events.

Phase 5: Reward-E, last.

  1. Runbook D.5. App Check enforcement is off before NB1 reaches TestFlight.

Phase 6: tail.

  1. Remove the _V9GL74NMBR secrets once no repo uses them.
  2. Keep Invotek renewed until the last app and its credentials have moved (reference doc).

Open questions for the operator

  1. When will NEW_TEAM exist? It blocks A's cutovers, the Reward-E generate, the B-spike and every key creation.
  2. Nutri-E (RevenueCat, proj2c32896f):
  3. How many active paying subscribers are there?
  4. Confirm in the dashboard that the production restore behaviour is "Transfer to new App User ID", that the sandbox override is off (or the same), and that whintgr812efdd741 and whintgr46e9d8efa9 deliver TRANSFER.
  5. Confirm with one look that an In-App Purchase key is configured (expected, E).
  6. Nutri-E: may we register App Group group.no.invotek.Nutri-E in the Invotek account and regenerate the Nutri-E App Store profile with a match write run?
  7. Nutri-E transfer gate: at least 90% of 30-day active devices on the migration build (ASC App Analytics), or a different threshold or date?
  8. Reward-E:
  9. How many Firebase users have an apple.com provider?
  10. Is no.invotek.RewardE grouped for Sign in with Apple, and with which apps?
  11. Which Services ID and Sign in with Apple key exist at Invotek?
  12. Are the Firebase Apple-provider code-flow fields filled?
  13. Which RevenueCat account owns projdd4fe8ba?
  14. Reward-E:
  15. Is Firebase App Check enforced on Firestore? (Functions are not affected, D.1.5.)
  16. Switch the App Attest Team ID at the first NB1 TestFlight upload (recommended, D.4), or at the U1 release?
  17. Do you accept one forced Sign in with Apple for every signed-in iOS user at U1 (recommended), rather than the riskier shared-keychain alternative?
  18. Do you accept that users who revoked Sign in with Apple are not migrated (revoked_or_unmigratable) and get a new account at their next sign-in?
  19. Reward-E: approve running the migration tool from GitHub Actions with the WIF service account, plus the roles Firebase Authentication Admin and Cloud Datastore User on reward-e-app and reward-e-dev. Approve the export-rule deviation in D.3.3 (one Firebase project, one operator for both teams).
  20. Secrets: approve team-suffixed ASC secret names (ASC_*_<TEAM>) in dashe-secrets.tf, and reuse of the same MATCH_PASSWORD for the Dashecorp match branch.
  21. Cuti-E:
  22. Confirm from Cloudflare analytics that /v1/device/check/* gets no traffic. If so, the DeviceCheck config stays untouched.
  23. List the apps rows' non-secret APNs columns (C.3.0).
  24. Nutri-E: may we delete the legacy fastlane lanes, ExportOptions plists and deploy scripts (A6)?
  25. generate-match-profiles.yml lives in dashe-drink-e, which is not transferred. Keep it there or move it to dashecorp/ios-certs?
  26. Pre-existing security issue. Nutri-E's X-Device-Secret salt ships in the app binary (DeviceAuthService.swift:14-16), so any known device ID can use that device's quota and tier. File it as a separate issue, to be fixed after the transfer, not with B1.

Answered by the review (removed from the list): - Which RevenueCat project is live for Nutri-E, and does the archived record's missing IAP key matter? The live project is proj2c32896f, and proj61d576be is archived (nutri-e/docs/subscriptions.md:22-31). The missing key was on the archived project. The live project's StoreKit 2 production sync on 2026-09-10 (:212-230) implies the key exists. - Does the restore behaviour govern syncPurchases? Yes, for Nutri-E's identified UUID user (purchases-ios 5.59.0 isRestore logic plus RevenueCat's Restore Behavior page, B.6 B2.5). - Does Firebase Auth lose its session at U1? Yes, for signed-in iOS users (FIRAuthKeychainServices.m@10.29.0, D.1.4). - Does the Invotek APNs key stop at T1? Yes, after a short propagation delay (DTS 744468, 0.2). - Do configFiles, secrets[format()] and per-team match branches work? Yes, per XcodeGen, GitHub and fastlane docs and source (A.2). The Count-E PR still proves them in this org. - Does App Check enforcement cover Functions? No: Reward-E has only Firestore triggers (D.1.5).

Sources

Apple, all fetched 2026-09-25: - Overview of app transfer - Accept an app transfer - TN2311 - Sharing access to keychain items - kSecAttrAccessGroup - App Groups entitlement - Forum threads 706128 and 821062 (Quinn, DTS) - Validating apps that connect to your server - Establishing your app's integrity - APNs token-based connection - Sending notification requests - APNs and app transfer, DTS: forums 744468, 760355 and 795723 (the latter two: review) - identifierForVendor - Updating your account information - appAccountToken - Set App Account Token - Sign in with Apple: transferring, bringing in - TN3159 - TN3107 - Creating a client secret - DTS forum threads 764888, 765778 and 770264

Firebase: - Authenticate using Apple - Manage users - Import users - App Attest provider - App Attest codelab - DeviceCheck provider - Enable enforcement - Monitor metrics - SDK source: FIRAuthKeychainServices.m@10.29.0; FIRAuth.m@10.29.0 and firebase-admin-node auth-config.ts (review); the Management API IosApp.teamId (review)

RevenueCat: - In-App Purchase key - App Store Connect API key - Shared secret - Apple server notifications - Restore behavior - Restoring purchases - Identifying customers - Webhook event types - Community staff answer, 2026-02-20 - SDK source: PurchasesOrchestrator.swift@5.59.0

Build tooling: - fastlane: configuration_file.rb (bare eval, config values never override passed options); match git_branch docs (review) - XcodeGen ProjectSpec.md (configFiles), the GitHub Actions contexts docs and the actions/runner source (review) - Cloudflare Workers compatibility flags (allow_custom_ports, default from 2024-09-02) (review; outside this revision's fetch allow-list)