@mmerterden/multi-agent-pipeline 20.7.0 → 20.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +0 -10
  3. package/docs/facts.json +1 -1
  4. package/manifest.json +266 -267
  5. package/package.json +2 -2
  6. package/pipeline/scripts/_notices.mjs +1 -1
  7. package/pipeline/skills/.skill-manifest.json +68 -68
  8. package/pipeline/skills/shared/README.md +70 -70
  9. package/pipeline/skills/shared/external/alarmkit/SKILL.md +373 -381
  10. package/pipeline/skills/shared/external/alarmkit/evals/evals.json +23 -18
  11. package/pipeline/skills/shared/external/alarmkit/references/alarmkit-patterns.md +328 -378
  12. package/pipeline/skills/shared/external/app-clips/SKILL.md +260 -160
  13. package/pipeline/skills/shared/external/app-clips/evals/evals.json +27 -27
  14. package/pipeline/skills/shared/external/app-clips/references/data-handoff-notifications-location.md +150 -83
  15. package/pipeline/skills/shared/external/app-clips/references/routing-and-experiences.md +135 -83
  16. package/pipeline/skills/shared/external/app-clips/references/size-capabilities-and-promotion.md +143 -85
  17. package/pipeline/skills/shared/external/app-intents/SKILL.md +302 -304
  18. package/pipeline/skills/shared/external/app-intents/evals/evals.json +21 -21
  19. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +594 -894
  20. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +339 -277
  21. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +27 -23
  22. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +105 -122
  23. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +143 -166
  24. package/pipeline/skills/shared/external/app-store-review/SKILL.md +307 -326
  25. package/pipeline/skills/shared/external/app-store-review/evals/evals.json +21 -21
  26. package/pipeline/skills/shared/external/app-store-review/references/privacy-manifest.md +105 -67
  27. package/pipeline/skills/shared/external/app-store-review/references/review-checklists.md +114 -101
  28. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +333 -360
  29. package/pipeline/skills/shared/external/apple-on-device-ai/evals/evals.json +24 -27
  30. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-conversion.md +215 -322
  31. package/pipeline/skills/shared/external/apple-on-device-ai/references/coreml-optimization.md +161 -256
  32. package/pipeline/skills/shared/external/apple-on-device-ai/references/foundation-models.md +277 -387
  33. package/pipeline/skills/shared/external/apple-on-device-ai/references/mlx-swift.md +196 -210
  34. package/pipeline/skills/shared/external/authentication/SKILL.md +265 -381
  35. package/pipeline/skills/shared/external/authentication/evals/evals.json +25 -25
  36. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +133 -178
  37. package/pipeline/skills/shared/external/authentication/references/passkeys.md +111 -147
  38. package/pipeline/skills/shared/external/avkit/SKILL.md +267 -364
  39. package/pipeline/skills/shared/external/avkit/evals/evals.json +26 -26
  40. package/pipeline/skills/shared/external/avkit/references/avkit-patterns.md +375 -493
  41. package/pipeline/skills/shared/external/background-processing/SKILL.md +270 -382
  42. package/pipeline/skills/shared/external/background-processing/evals/evals.json +22 -22
  43. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +169 -317
  44. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +290 -371
  45. package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +24 -24
  46. package/pipeline/skills/shared/external/callkit-voip/references/callkit-patterns.md +175 -343
  47. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +292 -381
  48. package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +33 -30
  49. package/pipeline/skills/shared/external/cloudkit-sync/references/cloudkit-patterns.md +227 -355
  50. package/pipeline/skills/shared/external/contacts-framework/SKILL.md +197 -346
  51. package/pipeline/skills/shared/external/contacts-framework/evals/evals.json +19 -21
  52. package/pipeline/skills/shared/external/contacts-framework/references/contacts-patterns.md +169 -308
  53. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +226 -376
  54. package/pipeline/skills/shared/external/core-bluetooth/evals/evals.json +25 -22
  55. package/pipeline/skills/shared/external/core-bluetooth/references/ble-patterns.md +257 -337
  56. package/pipeline/skills/shared/external/core-data/SKILL.md +292 -368
  57. package/pipeline/skills/shared/external/core-data/evals/evals.json +30 -27
  58. package/pipeline/skills/shared/external/core-motion/SKILL.md +235 -324
  59. package/pipeline/skills/shared/external/core-motion/evals/evals.json +31 -27
  60. package/pipeline/skills/shared/external/core-motion/references/motion-patterns.md +210 -310
  61. package/pipeline/skills/shared/external/core-nfc/SKILL.md +292 -366
  62. package/pipeline/skills/shared/external/core-nfc/evals/evals.json +22 -24
  63. package/pipeline/skills/shared/external/core-nfc/references/nfc-patterns.md +233 -329
  64. package/pipeline/skills/shared/external/coreml/SKILL.md +323 -367
  65. package/pipeline/skills/shared/external/coreml/evals/evals.json +24 -21
  66. package/pipeline/skills/shared/external/coreml/references/coreml-swift-integration.md +562 -565
  67. package/pipeline/skills/shared/external/cryptokit/SKILL.md +253 -394
  68. package/pipeline/skills/shared/external/cryptokit/evals/evals.json +20 -18
  69. package/pipeline/skills/shared/external/cryptokit/references/cryptokit-patterns.md +299 -488
  70. package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +270 -323
  71. package/pipeline/skills/shared/external/debugging-instruments/evals/evals.json +27 -30
  72. package/pipeline/skills/shared/external/debugging-instruments/references/instruments-guide.md +167 -315
  73. package/pipeline/skills/shared/external/debugging-instruments/references/lldb-patterns.md +140 -193
  74. package/pipeline/skills/shared/external/device-integrity/SKILL.md +230 -353
  75. package/pipeline/skills/shared/external/device-integrity/evals/evals.json +25 -21
  76. package/pipeline/skills/shared/external/device-integrity/references/device-integrity-patterns.md +159 -197
  77. package/pipeline/skills/shared/external/energykit/SKILL.md +225 -392
  78. package/pipeline/skills/shared/external/energykit/evals/evals.json +29 -28
  79. package/pipeline/skills/shared/external/energykit/references/energykit-patterns.md +174 -470
  80. package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +261 -383
  81. package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +25 -22
  82. package/pipeline/skills/shared/external/eventkit-calendar/references/eventkit-patterns.md +165 -268
  83. package/pipeline/skills/shared/external/healthkit/SKILL.md +252 -303
  84. package/pipeline/skills/shared/external/healthkit/evals/evals.json +24 -23
  85. package/pipeline/skills/shared/external/healthkit/references/healthkit-patterns.md +369 -523
  86. package/pipeline/skills/shared/external/homekit-matter/SKILL.md +233 -348
  87. package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +27 -22
  88. package/pipeline/skills/shared/external/homekit-matter/references/matter-commissioning.md +199 -305
  89. package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +368 -340
  90. package/pipeline/skills/shared/external/ios-accessibility/evals/evals.json +28 -27
  91. package/pipeline/skills/shared/external/ios-accessibility/references/a11y-patterns.md +314 -260
  92. package/pipeline/skills/shared/external/ios-accessibility/references/media-accessibility.md +97 -67
  93. package/pipeline/skills/shared/external/ios-accessibility/references/nutrition-labels.md +165 -101
  94. package/pipeline/skills/shared/external/ios-localization/SKILL.md +258 -371
  95. package/pipeline/skills/shared/external/ios-localization/evals/evals.json +23 -23
  96. package/pipeline/skills/shared/external/ios-localization/references/formatstyle-locale.md +283 -491
  97. package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +313 -440
  98. package/pipeline/skills/shared/external/ios-networking/SKILL.md +265 -341
  99. package/pipeline/skills/shared/external/ios-networking/evals/evals.json +24 -24
  100. package/pipeline/skills/shared/external/ios-networking/references/background-websocket.md +425 -652
  101. package/pipeline/skills/shared/external/ios-networking/references/file-storage-patterns.md +143 -285
  102. package/pipeline/skills/shared/external/ios-networking/references/lightweight-clients.md +93 -53
  103. package/pipeline/skills/shared/external/ios-networking/references/network-framework.md +231 -456
  104. package/pipeline/skills/shared/external/ios-networking/references/urlsession-patterns.md +517 -784
  105. package/pipeline/skills/shared/external/ios-simulator/SKILL.md +265 -393
  106. package/pipeline/skills/shared/external/ios-simulator/evals/evals.json +21 -21
  107. package/pipeline/skills/shared/external/ios-simulator/references/simctl-commands.md +177 -270
  108. package/pipeline/skills/shared/external/live-activities/SKILL.md +318 -360
  109. package/pipeline/skills/shared/external/live-activities/evals/evals.json +21 -21
  110. package/pipeline/skills/shared/external/live-activities/references/activitykit-patterns.md +478 -710
  111. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +295 -267
  112. package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +28 -24
  113. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-corelocation-patterns.md +378 -532
  114. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +397 -499
  115. package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +165 -348
  116. package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +26 -23
  117. package/pipeline/skills/shared/external/metrickit-diagnostics/references/metrickit-patterns.md +123 -130
  118. package/pipeline/skills/shared/external/musickit-audio/SKILL.md +189 -315
  119. package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +22 -21
  120. package/pipeline/skills/shared/external/musickit-audio/references/musickit-patterns.md +181 -270
  121. package/pipeline/skills/shared/external/natural-language/SKILL.md +188 -340
  122. package/pipeline/skills/shared/external/natural-language/evals/evals.json +21 -21
  123. package/pipeline/skills/shared/external/natural-language/references/translation-patterns.md +171 -225
  124. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +258 -392
  125. package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +30 -29
  126. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +164 -231
  127. package/pipeline/skills/shared/external/pdfkit/SKILL.md +312 -344
  128. package/pipeline/skills/shared/external/pdfkit/evals/evals.json +19 -19
  129. package/pipeline/skills/shared/external/pdfkit/references/pdfkit-patterns.md +413 -624
  130. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +242 -358
  131. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +25 -21
  132. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +161 -226
  133. package/pipeline/skills/shared/external/permissionkit/SKILL.md +282 -400
  134. package/pipeline/skills/shared/external/permissionkit/evals/evals.json +27 -30
  135. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +237 -350
  136. package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +276 -325
  137. package/pipeline/skills/shared/external/photos-camera-media/references/av-playback.md +299 -545
  138. package/pipeline/skills/shared/external/photos-camera-media/references/camera-capture.md +344 -588
  139. package/pipeline/skills/shared/external/photos-camera-media/references/image-loading-caching.md +316 -660
  140. package/pipeline/skills/shared/external/photos-camera-media/references/photokit-patterns.md +270 -416
  141. package/pipeline/skills/shared/external/push-notifications/SKILL.md +312 -340
  142. package/pipeline/skills/shared/external/push-notifications/evals/evals.json +27 -26
  143. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +328 -485
  144. package/pipeline/skills/shared/external/push-notifications/references/rich-notifications.md +327 -560
  145. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +218 -410
  146. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +24 -27
  147. package/pipeline/skills/shared/external/realitykit-ar/references/realitykit-patterns.md +221 -348
  148. package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +222 -393
  149. package/pipeline/skills/shared/external/shareplay-activities/evals/evals.json +23 -24
  150. package/pipeline/skills/shared/external/shareplay-activities/references/shareplay-patterns.md +280 -420
  151. package/pipeline/skills/shared/external/speech-recognition/SKILL.md +217 -421
  152. package/pipeline/skills/shared/external/speech-recognition/evals/evals.json +23 -26
  153. package/pipeline/skills/shared/external/speech-recognition/references/speechanalyzer-patterns.md +133 -125
  154. package/pipeline/skills/shared/external/storekit/SKILL.md +228 -204
  155. package/pipeline/skills/shared/external/storekit/evals/evals.json +27 -24
  156. package/pipeline/skills/shared/external/storekit/references/app-review-guidelines.md +98 -109
  157. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +298 -242
  158. package/pipeline/skills/shared/external/storekit/references/storekit-advanced.md +356 -649
  159. package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +274 -399
  160. package/pipeline/skills/shared/external/swift-api-design-guidelines/evals/evals.json +22 -24
  161. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/argument-labels-and-parameters.md +107 -108
  162. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/conventions-and-special-rules.md +93 -165
  163. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/naming-and-clarity.md +99 -137
  164. package/pipeline/skills/shared/external/swift-api-design-guidelines/references/side-effects-and-mutating-pairs.md +77 -120
  165. package/pipeline/skills/shared/external/swift-architecture/SKILL.md +334 -350
  166. package/pipeline/skills/shared/external/swift-architecture/evals/evals.json +22 -22
  167. package/pipeline/skills/shared/external/swift-charts/SKILL.md +208 -394
  168. package/pipeline/skills/shared/external/swift-charts/evals/evals.json +27 -30
  169. package/pipeline/skills/shared/external/swift-charts/references/charts-patterns.md +351 -762
  170. package/pipeline/skills/shared/external/swift-codable/SKILL.md +339 -343
  171. package/pipeline/skills/shared/external/swift-codable/evals/evals.json +20 -20
  172. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +303 -351
  173. package/pipeline/skills/shared/external/swift-concurrency/evals/evals.json +27 -24
  174. package/pipeline/skills/shared/external/swift-concurrency/references/approachable-concurrency.md +65 -80
  175. package/pipeline/skills/shared/external/swift-concurrency/references/async-algorithms.md +48 -84
  176. package/pipeline/skills/shared/external/swift-concurrency/references/bridging-interop.md +134 -79
  177. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +145 -167
  178. package/pipeline/skills/shared/external/swift-concurrency/references/diagnostics.md +62 -50
  179. package/pipeline/skills/shared/external/swift-concurrency/references/swiftui-concurrency.md +92 -121
  180. package/pipeline/skills/shared/external/swift-concurrency/references/synchronization-primitives.md +177 -241
  181. package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +258 -234
  182. package/pipeline/skills/shared/external/swift-language/SKILL.md +342 -382
  183. package/pipeline/skills/shared/external/swift-language/evals/evals.json +24 -27
  184. package/pipeline/skills/shared/external/swift-language/references/swift-attributes-interop.md +79 -56
  185. package/pipeline/skills/shared/external/swift-language/references/swift-patterns-extended.md +297 -340
  186. package/pipeline/skills/shared/external/swift-security/SKILL.md +180 -161
  187. package/pipeline/skills/shared/external/swift-security/evals/evals.json +25 -25
  188. package/pipeline/skills/shared/external/swift-security/references/biometric-authentication.md +314 -469
  189. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +408 -476
  190. package/pipeline/skills/shared/external/swift-security/references/common-anti-patterns.md +260 -530
  191. package/pipeline/skills/shared/external/swift-security/references/compliance-owasp-mapping.md +270 -477
  192. package/pipeline/skills/shared/external/swift-security/references/credential-storage-patterns.md +573 -571
  193. package/pipeline/skills/shared/external/swift-security/references/cryptokit-public-key.md +370 -441
  194. package/pipeline/skills/shared/external/swift-security/references/cryptokit-symmetric.md +332 -433
  195. package/pipeline/skills/shared/external/swift-security/references/keychain-access-control.md +346 -468
  196. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +352 -472
  197. package/pipeline/skills/shared/external/swift-security/references/keychain-item-classes.md +431 -432
  198. package/pipeline/skills/shared/external/swift-security/references/keychain-sharing.md +328 -425
  199. package/pipeline/skills/shared/external/swift-security/references/migration-legacy-stores.md +341 -579
  200. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +396 -457
  201. package/pipeline/skills/shared/external/swift-security/references/testing-security-code.md +354 -614
  202. package/pipeline/skills/shared/external/swift-testing/SKILL.md +188 -175
  203. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +26 -24
  204. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +80 -84
  205. package/pipeline/skills/shared/external/swift-testing/references/testing-patterns.md +317 -433
  206. package/pipeline/skills/shared/external/swiftdata/SKILL.md +392 -256
  207. package/pipeline/skills/shared/external/swiftdata/evals/evals.json +24 -24
  208. package/pipeline/skills/shared/external/swiftdata/references/core-data-coexistence.md +206 -402
  209. package/pipeline/skills/shared/external/swiftdata/references/indexing.md +59 -52
  210. package/pipeline/skills/shared/external/swiftdata/references/predicate-pitfalls.md +57 -33
  211. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-advanced.md +354 -747
  212. package/pipeline/skills/shared/external/swiftdata/references/swiftdata-queries.md +300 -508
  213. package/pipeline/skills/shared/external/swiftlint/SKILL.md +175 -226
  214. package/pipeline/skills/shared/external/swiftlint/references/adoption-and-configuration.md +141 -208
  215. package/pipeline/skills/shared/external/swiftlint/references/custom-rules-and-analyze.md +100 -109
  216. package/pipeline/skills/shared/external/swiftlint/references/plugins-run-scripts-and-integrations.md +159 -179
  217. package/pipeline/skills/shared/external/swiftlint/references/rule-reference.md +383 -18
  218. package/pipeline/skills/shared/external/swiftlint/references/rules-suppressions-and-baselines.md +143 -229
  219. package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +283 -366
  220. package/pipeline/skills/shared/external/swiftui-animation/references/animation-advanced.md +396 -608
  221. package/pipeline/skills/shared/external/swiftui-animation/references/core-animation-bridge.md +336 -385
  222. package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +239 -349
  223. package/pipeline/skills/shared/external/swiftui-gestures/references/gesture-patterns.md +228 -310
  224. package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +260 -249
  225. package/pipeline/skills/shared/external/swiftui-layout-components/references/form.md +92 -74
  226. package/pipeline/skills/shared/external/swiftui-layout-components/references/grids.md +112 -177
  227. package/pipeline/skills/shared/external/swiftui-layout-components/references/list.md +61 -64
  228. package/pipeline/skills/shared/external/swiftui-layout-components/references/scrollview.md +94 -134
  229. package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +193 -225
  230. package/pipeline/skills/shared/external/swiftui-liquid-glass/references/liquid-glass.md +173 -327
  231. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +193 -168
  232. package/pipeline/skills/shared/external/swiftui-navigation/references/deeplinks.md +127 -150
  233. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +132 -133
  234. package/pipeline/skills/shared/external/swiftui-navigation/references/sheets.md +152 -117
  235. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +106 -140
  236. package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +316 -252
  237. package/pipeline/skills/shared/external/swiftui-patterns/references/architecture-patterns.md +341 -332
  238. package/pipeline/skills/shared/external/swiftui-patterns/references/deprecated-migration.md +547 -854
  239. package/pipeline/skills/shared/external/swiftui-patterns/references/design-polish.md +485 -537
  240. package/pipeline/skills/shared/external/swiftui-patterns/references/platform-and-sharing.md +417 -499
  241. package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +213 -376
  242. package/pipeline/skills/shared/external/swiftui-performance/references/demystify-swiftui-performance-wwdc23.md +86 -175
  243. package/pipeline/skills/shared/external/swiftui-performance/references/optimizing-swiftui-performance-instruments.md +89 -195
  244. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-hangs-in-your-app.md +95 -182
  245. package/pipeline/skills/shared/external/swiftui-performance/references/understanding-improving-swiftui-performance.md +71 -149
  246. package/pipeline/skills/shared/external/swiftui-performance/references/wwdc-session-sources.md +21 -27
  247. package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +303 -295
  248. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/hosting-migration.md +204 -387
  249. package/pipeline/skills/shared/external/swiftui-uikit-interop/references/representable-recipes.md +469 -683
  250. package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +140 -186
  251. package/pipeline/skills/shared/external/swiftui-webkit/references/loading-and-observation.md +75 -86
  252. package/pipeline/skills/shared/external/swiftui-webkit/references/local-content-and-custom-schemes.md +63 -60
  253. package/pipeline/skills/shared/external/swiftui-webkit/references/migration-and-fallbacks.md +69 -137
  254. package/pipeline/skills/shared/external/swiftui-webkit/references/navigation-and-javascript.md +95 -67
  255. package/pipeline/skills/shared/external/tipkit/SKILL.md +220 -335
  256. package/pipeline/skills/shared/external/tipkit/references/tipkit-patterns.md +356 -494
  257. package/pipeline/skills/shared/external/vision-framework/SKILL.md +260 -375
  258. package/pipeline/skills/shared/external/vision-framework/references/vision-requests.md +393 -515
  259. package/pipeline/skills/shared/external/vision-framework/references/visionkit-scanner.md +363 -539
  260. package/pipeline/skills/shared/external/weatherkit/SKILL.md +152 -310
  261. package/pipeline/skills/shared/external/weatherkit/references/weatherkit-patterns.md +288 -407
  262. package/pipeline/skills/shared/external/widgetkit/SKILL.md +216 -288
  263. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +414 -719
  264. package/pipeline/skills/shared/external/NOTICE-swift-ios-skills.md +0 -39
@@ -1,747 +1,509 @@
1
- # Migration & Legacy Stores
1
+ # Migrating Secrets Out of Legacy Stores
2
2
 
3
- > **Scope:** Migrating sensitive data from UserDefaults, plists, NSCoding archives, and other insecure storage to Apple Keychain Services. Covers secure deletion of legacy data, first-launch keychain cleanup, versioned migration patterns, and the Team ID transfer edge case.
4
- >
5
- > **Applies to:** iOS 15+ (actor support, pre-warming), iOS 17+ (recommended deployment target)
6
- >
7
- > **Cross-references:** `keychain-fundamentals.md` (SecItem CRUD), `keychain-access-control.md` (accessibility classes), `common-anti-patterns.md` (UserDefaults secrets anti-pattern), `credential-storage-patterns.md` (token lifecycle post-migration), `testing-security-code.md` (protocol-based mocking)
3
+ Scope: moving credentials that an older release kept in `UserDefaults`, property lists or `NSCoding` archives into the keychain, deleting the old copies safely, wiping keychain leftovers after a reinstall, running migrations by schema version, and surviving a Team ID change after an app transfer.
8
4
 
9
- ---
5
+ Availability: the patterns rely on actors and on iOS 15 pre-warming behaviour, so they apply from iOS 15. Write new code against iOS 17 or later where you can.
10
6
 
11
- ## Contents
12
-
13
- - [Why Migrate - The Risk of Legacy Storage](#why-migrate-the-risk-of-legacy-storage)
14
- - [The Five Correctness Traps](#the-five-correctness-traps)
15
- - [First-Launch Keychain Cleanup](#first-launch-keychain-cleanup)
16
- - [Atomic Migration: Read → Write → Verify → Delete](#atomic-migration-read-write-verify-delete)
17
- - [Versioned Migration with Schema Tracking](#versioned-migration-with-schema-tracking)
18
- - [Orphaned Items: Why You Must Never Rename kSecAttrService](#orphaned-items-why-you-must-never-rename-ksecattrservice)
19
- - [Background Launch and the Locked-Device Trap](#background-launch-and-the-locked-device-trap)
20
- - [The Phantom Mismatch Bug](#the-phantom-mismatch-bug)
21
- - [Team ID Change: The App Transfer Edge Case](#team-id-change-the-app-transfer-edge-case)
22
- - [Deferred Legacy Cleanup with Rollback Window](#deferred-legacy-cleanup-with-rollback-window)
23
- - [Complete App Launch Sequence](#complete-app-launch-sequence)
24
- - [Thread Safety Note](#thread-safety-note)
25
- - [Testing Migration Paths](#testing-migration-paths)
26
- - [Handling Very Old Versions and Collapse Strategy](#handling-very-old-versions-and-collapse-strategy)
27
- - [Secure Deletion: Trust Cryptographic Erasure](#secure-deletion-trust-cryptographic-erasure)
28
- - [Conclusion](#conclusion)
29
- - [Summary Checklist](#summary-checklist)
30
-
31
- ## Why Migrate - The Risk of Legacy Storage
32
-
33
- UserDefaults, `.plist` files, and NSCoding archives store data as unencrypted plaintext within the app sandbox. This data is readable on jailbroken devices and included in unencrypted iTunes/Finder backups - anyone with backup access can extract tokens, passwords, and PII. OWASP ranks insecure data storage as a top-10 mobile risk (M9).
7
+ Related references: [keychain-fundamentals.md](keychain-fundamentals.md) (add-or-update, OSStatus), [keychain-access-control.md](keychain-access-control.md) (accessibility classes), [common-anti-patterns.md](common-anti-patterns.md) (patterns 1 and 9), [credential-storage-patterns.md](credential-storage-patterns.md) (token lifecycles), [testing-security-code.md](testing-security-code.md) (mocks and device tests).
34
8
 
35
- | Store | Encrypted at rest | In backups | Survives app uninstall | Suitable for secrets |
36
- | ----------------- | ----------------- | ---------------------------------- | ---------------------- | -------------------- |
37
- | UserDefaults | No | Yes | No | **No** |
38
- | .plist files | No (default) | Yes | No | **No** |
39
- | NSCoding archives | No (default) | Yes | No | **No** |
40
- | Keychain | Yes (AES-256-GCM) | `ThisDeviceOnly` variants excluded | **Yes** | **Yes** |
41
-
42
- Keychain items are managed by the `securityd` daemon, encrypted with per-row keys protected by the Secure Enclave, and isolated from the app sandbox. This is the only appropriate location for tokens, passwords, API keys, and PII on Apple platforms.
9
+ ## Contents
43
10
 
44
- ---
11
+ - [Why the old stores are not acceptable](#why-the-old-stores-are-not-acceptable)
12
+ - [Five traps to avoid](#five-traps-to-avoid)
13
+ - [Cleaning up after a reinstall](#cleaning-up-after-a-reinstall)
14
+ - [Atomic migration: read, write, verify, delete](#atomic-migration-read-write-verify-delete)
15
+ - [Versioned migration with a schema number](#versioned-migration-with-a-schema-number)
16
+ - [Orphaned items after a rename](#orphaned-items-after-a-rename)
17
+ - [Locked devices and background launches](#locked-devices-and-background-launches)
18
+ - [The phantom mismatch](#the-phantom-mismatch)
19
+ - [Team ID change after an app transfer](#team-id-change-after-an-app-transfer)
20
+ - [Cleaning up legacy artefacts](#cleaning-up-legacy-artefacts)
21
+ - [Launch order](#launch-order)
22
+ - [Concurrency](#concurrency)
23
+ - [Testing migrations](#testing-migrations)
24
+ - [Very old versions](#very-old-versions)
25
+ - [Deleting files](#deleting-files)
26
+ - [Key decisions](#key-decisions)
27
+ - [Checklist](#checklist)
45
28
 
46
- ## The Five Correctness Traps
29
+ ## Why the old stores are not acceptable
47
30
 
48
- Most AI-generated migration code contains at least one of these errors. Each passes testing but fails catastrophically in production.
31
+ `UserDefaults`, a hand-written `.plist` and a keyed archive are all plain files inside the app container. Nothing encrypts their contents beyond the file's data protection class, so anyone with a jailbroken device or an unencrypted Finder or iTunes backup can open them. OWASP files this under M9, Insecure Data Storage.
49
32
 
50
- **Trap 1 - Legacy data survives after migration.** Calling `UserDefaults.standard.removeObject(forKey:)` removes the key-value pair from the in-memory cache and plist file, but does not securely overwrite NAND flash. However, iOS achieves secure deletion through _cryptographic erasure_: every file has a per-file AES-256 key, and standard deletion APIs destroy that key via Effaceable Storage, rendering physical bits permanently inaccessible. The real risk vector is **unencrypted backups** created before migration completes - the plist stays on disk until the filesystem reclaims space. **Always delete all legacy keys explicitly after verified keychain writes.**
33
+ | Property | UserDefaults / plist / NSCoding archive | Keychain |
34
+ | --- | --- | --- |
35
+ | Encryption of the value | None beyond file protection | AES-256-GCM per item |
36
+ | Included in backups | Yes | Yes, except `...ThisDeviceOnly` classes |
37
+ | Removed when the app is deleted | Yes | No, items outlive the app |
38
+ | Fit for secrets | No | Yes |
51
39
 
52
- **Trap 2 - Keychain items survive app deletion.** When a user uninstalls your app, UserDefaults and sandbox files are wiped, but keychain items persist indefinitely. Apple attempted to change this in iOS 10.3 betas but reverted due to compatibility issues. On reinstall, stale keychain items (old tokens, expired credentials, outdated schemas) cause silent authentication failures or - worse - restore a _previous user's_ session.
40
+ Keychain rows live in a database owned by the `securityd` daemon, outside the app sandbox. Each row is encrypted with its own key, and those keys are wrapped by class keys that the Secure Enclave protects.
53
41
 
54
- **Trap 3 - Migration runs on every launch.** Checking UserDefaults for legacy data on every launch wastes cycles and risks data loss during iOS 15+ app pre-warming. When the system pre-warms your process before the device is unlocked, `UserDefaults` may return empty values (the encrypted plist is inaccessible). A migration that interprets empty results as "nothing to migrate" will skip real data or overwrite valid keychain entries with nil.
42
+ ## Five traps to avoid
55
43
 
56
- **Trap 4 - Non-atomic migration leaves data in limbo.** Writing to keychain then deleting from UserDefaults as two independent operations creates a failure window. If the app is killed between write and delete - or the keychain write silently fails - users lose their data entirely.
44
+ 1. **Believing deletion is the risk.** `removeObject(forKey:)` does not scrub flash storage, but it does not need to: iOS protects each file with its own AES-256 key and destroys that key through Effaceable Storage when the file goes away, which is cryptographic erasure. The exposure that remains is backups taken, unencrypted, before the migration ran. Delete each legacy key explicitly once its keychain copy is verified.
45
+ 2. **Forgetting that keychain items outlive the app.** An iOS 10.3 beta briefly removed items on uninstall; the change was reverted and items still persist. After a reinstall, stale entries cause confusing authentication failures or silently resume the previous owner's session.
46
+ 3. **Migrating on every launch.** Besides wasted work, a pre-warmed launch on iOS 15 and later can start before first unlock, when the encrypted `UserDefaults` file cannot be read and returns nothing. Code that reads "empty" as "nothing to migrate" skips data or writes nil over good keychain values.
47
+ 4. **Treating write and delete as unrelated steps.** If the process dies between them, or the write failed silently, the secret is gone.
48
+ 5. **Renaming the item's identity.** Changing `kSecAttrService` or `kSecAttrAccount` produces a new item and strands the old one as a duplicate. `SecItemUpdate` cannot change primary-key attributes, so a rename is always read old, write new, verify, delete old.
57
49
 
58
- **Trap 5 - Changing `kSecAttrService` or `kSecAttrAccount` orphans existing items.** These attributes form the primary key for `kSecClassGenericPassword`. Changing either in a new version doesn't update existing items - it creates new ones. The old items become invisible orphans that waste keychain space and cause `errSecDuplicateItem` in unexpected contexts. Critically, `SecItemUpdate` **cannot change primary key attributes** - the call will error. You must perform a full rekey migration: read old → write new → verify → delete old.
50
+ ## Cleaning up after a reinstall
59
51
 
60
- ---
52
+ The two stores behave differently on uninstall: `UserDefaults` is removed, the keychain is not. A missing marker in `UserDefaults` together with keychain content therefore means "fresh install over old data".
61
53
 
62
- ## First-Launch Keychain Cleanup
54
+ Run this before anything else touches the keychain. Analytics, crash reporting, backend-as-a-service and authentication SDKs often read the keychain while they initialise, and would pick up the old session.
63
55
 
64
- The persistence asymmetry (UserDefaults deleted on uninstall, keychain not) enables a reliable reinstall detector. This pattern **must run before any other keychain or SDK initialization** - Firebase, analytics, and auth libraries all read keychain items during setup.
56
+ Wait for protected data before reading the marker. During pre-warm both `UserDefaults` and `WhenUnlocked` keychain items are unreadable, so the marker looks unset and the guard would wipe a signed-in user. Widely used apps mass-logged-out their users on iOS 15 by treating that empty read as "no credentials".
65
57
 
66
58
  ```swift
67
- // ✅ CORRECT: First-launch cleanup with protected data guard
68
- // iOS 15+ required for isProtectedDataAvailable / pre-warming behavior
69
-
70
- actor FirstLaunchGuard {
71
- static let shared = FirstLaunchGuard()
72
- private let hasRunKey = "com.myapp.hasCompletedFirstLaunch"
73
-
74
- /// Call at the very start of app lifecycle, before SDK initialization.
75
- func performCleanupIfNeeded() async {
76
- let isSubsequentRun = UserDefaults.standard.bool(forKey: hasRunKey)
77
- guard !isSubsequentRun else { return }
78
-
79
- // iOS 15+ pre-warming guard: device may still be locked
80
- guard await isProtectedDataAvailable() else {
81
- await waitForProtectedData()
82
- return
83
- }
84
-
85
- // Wipe stale keychain items from a previous installation
86
- deleteAllKeychainItems()
87
-
88
- // Set flag so this only runs once per install
89
- UserDefaults.standard.set(true, forKey: hasRunKey)
90
- }
91
-
92
- private func deleteAllKeychainItems() {
93
- let classes: [CFString] = [
94
- kSecClassGenericPassword, kSecClassInternetPassword,
95
- kSecClassCertificate, kSecClassKey, kSecClassIdentity
96
- ]
97
- for itemClass in classes {
98
- let query: NSDictionary = [
59
+ import Security
60
+ import UIKit
61
+
62
+ actor ReinstallSweeper {
63
+ private let markerKey = "vault.installMarker"
64
+ private let itemClasses: [CFString] = [
65
+ kSecClassGenericPassword, kSecClassInternetPassword,
66
+ kSecClassCertificate, kSecClassKey, kSecClassIdentity
67
+ ]
68
+
69
+ func sweepIfFreshInstall() async {
70
+ await Self.waitUntilProtectedDataIsReadable()
71
+ guard !UserDefaults.standard.bool(forKey: markerKey) else { return }
72
+ for itemClass in itemClasses {
73
+ let scope: [CFString: Any] = [
99
74
  kSecClass: itemClass,
100
75
  kSecAttrSynchronizable: kSecAttrSynchronizableAny
101
76
  ]
102
- SecItemDelete(query)
77
+ let status = SecItemDelete(scope as CFDictionary)
78
+ guard status == errSecSuccess || status == errSecItemNotFound else { return }
103
79
  }
80
+ UserDefaults.standard.set(true, forKey: markerKey)
104
81
  }
105
82
 
106
- private func isProtectedDataAvailable() async -> Bool {
107
- await MainActor.run {
108
- UIApplication.shared.isProtectedDataAvailable
109
- }
110
- }
111
-
112
- private func waitForProtectedData() async {
113
- await withCheckedContinuation { continuation in
114
- NotificationCenter.default.addObserver(
115
- forName: UIApplication.protectedDataDidBecomeAvailableNotification,
116
- object: nil, queue: .main
117
- ) { _ in
118
- Task {
119
- self.deleteAllKeychainItems()
120
- UserDefaults.standard.set(true, forKey: self.hasRunKey)
121
- continuation.resume()
122
- }
123
- }
124
- }
83
+ @MainActor
84
+ static func waitUntilProtectedDataIsReadable() async {
85
+ guard !UIApplication.shared.isProtectedDataAvailable else { return }
86
+ let signals = NotificationCenter.default.notifications(
87
+ named: UIApplication.protectedDataDidBecomeAvailableNotification
88
+ )
89
+ for await _ in signals { return }
125
90
  }
126
91
  }
127
92
  ```
128
93
 
94
+ `kSecAttrSynchronizable: kSecAttrSynchronizableAny` is required in every cleanup query. Without it `SecItemDelete` only matches non-synchronized items and leaves iCloud Keychain entries behind. If any delete fails, the marker stays unset and the sweep runs again next launch.
95
+
96
+ What goes wrong without it:
97
+
129
98
  ```swift
130
- // ❌ INCORRECT: No first-launch cleanup - stale keychain from previous install
131
99
  @main
132
- struct BrokenApp: App {
100
+ struct LedgerApp: App {
133
101
  init() {
134
- // Reads keychain without checking for stale data
135
- if let token = try? keychainRead(service: "com.myapp", account: "authToken") {
136
- // This token might be from a PREVIOUS user who deleted the app.
137
- // The new user inherits someone else's session.
138
- AuthManager.shared.restoreSession(token: token)
139
- }
102
+ SessionStore.shared.restoreTokenFromKeychain()
140
103
  }
141
- var body: some Scene { WindowGroup { ContentView() } }
104
+ var body: some Scene { WindowGroup { RootView() } }
142
105
  }
143
106
  ```
144
107
 
145
- The `isProtectedDataAvailable` check is critical. iOS 15 introduced app pre-warming - the system can launch your process before the user unlocks the device. During pre-warming, both UserDefaults and keychain items with `kSecAttrAccessibleWhenUnlocked` are unavailable. Multiple high-profile apps (including Twitter) suffered mass user logouts on iOS 15 because their startup code interpreted empty data during pre-warm as "no credentials" and wiped sessions.
108
+ Here the token is restored with no reinstall check, so a new owner of a resold device, or a different person on a reinstalled app, inherits the previous session.
146
109
 
147
- > **Include `kSecAttrSynchronizableAny`** in cleanup queries. Without it, `SecItemDelete` skips iCloud-synced items, leaving them as invisible ghosts.
110
+ ## Atomic migration: read, write, verify, delete
148
111
 
149
- ---
112
+ The order never changes. Removing the plaintext before the keychain write is confirmed is the single most dangerous mistake in this area.
150
113
 
151
- ## Atomic Migration: Read → Write → Verify → Delete
114
+ ```swift
115
+ import Foundation
116
+ import Security
152
117
 
153
- The most dangerous pattern is deleting legacy data before confirming the keychain write succeeded. The correct sequence is always: **read → write → verify → delete**.
118
+ protocol MigrationKeychainProtocol: Actor {
119
+ func save(_ data: Data, account: String, accessibility: String) throws
120
+ func read(account: String) throws -> Data?
121
+ func delete(account: String) throws
122
+ func deleteAll() throws
123
+ }
154
124
 
155
- ```swift
156
- // ✅ CORRECT: Atomic per-key migration with verification and rollback
157
- actor AtomicMigrator {
158
- struct MigrationResult {
159
- let key: String
160
- let succeeded: Bool
161
- let error: Error?
162
- }
125
+ struct KeychainFailure: Error {
126
+ let status: OSStatus
127
+ init(_ status: OSStatus) { self.status = status }
128
+ }
163
129
 
164
- private let keychain: any MigrationKeychainProtocol
130
+ enum KeyMigrationResult: Sendable, Equatable {
131
+ case moved, nothingToMove, failed(reason: String)
132
+ }
165
133
 
166
- init(keychain: any MigrationKeychainProtocol) {
167
- self.keychain = keychain
168
- }
134
+ actor DefaultsToKeychainMover {
135
+ private let vault: any MigrationKeychainProtocol
136
+ private let defaults: UserDefaults
169
137
 
170
- /// Failed keys remain in UserDefaults for retry on next launch.
171
- func migrateUserDefaultsKeys(
172
- _ keys: [String],
173
- service: String,
174
- accessible: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
175
- ) async -> [MigrationResult] {
176
- var results: [MigrationResult] = []
138
+ init(vault: any MigrationKeychainProtocol, defaults: UserDefaults = .standard) {
139
+ self.vault = vault
140
+ self.defaults = defaults
141
+ }
177
142
 
143
+ func move(keys: [String]) async -> [String: KeyMigrationResult] {
144
+ var report: [String: KeyMigrationResult] = [:]
178
145
  for key in keys {
146
+ guard let plaintext = defaults.string(forKey: key) else {
147
+ report[key] = .nothingToMove
148
+ continue
149
+ }
150
+ let payload = Data(plaintext.utf8)
179
151
  do {
180
- // STEP 1: Read from legacy storage
181
- guard let legacyValue = UserDefaults.standard.string(forKey: key),
182
- let data = legacyValue.data(using: .utf8) else {
183
- results.append(.init(key: key, succeeded: true, error: nil))
152
+ try await vault.save(payload, account: key,
153
+ accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
154
+ guard try await vault.read(account: key) == payload else {
155
+ report[key] = .failed(reason: "read-back mismatch")
184
156
  continue
185
157
  }
186
-
187
- // STEP 2: Write to keychain (add-or-update handles duplicates)
188
- try await keychain.save(data, service: service,
189
- account: key, accessible: accessible)
190
-
191
- // STEP 3: Verify by reading back
192
- let readBack = try await keychain.read(service: service, account: key)
193
- guard readBack == data else {
194
- throw MigrationError.verificationFailed(key: key)
195
- }
196
-
197
- // STEP 4: Delete from UserDefaults ONLY after verified write
198
- UserDefaults.standard.removeObject(forKey: key)
199
- results.append(.init(key: key, succeeded: true, error: nil))
200
-
158
+ defaults.removeObject(forKey: key)
159
+ report[key] = .moved
201
160
  } catch {
202
- // ROLLBACK: Leave UserDefaults intact for this key
203
- results.append(.init(key: key, succeeded: false, error: error))
161
+ report[key] = .failed(reason: String(describing: error))
204
162
  }
205
163
  }
206
- return results
207
- }
208
-
209
- enum MigrationError: Error {
210
- case verificationFailed(key: String)
211
- case corruptArchive(path: String)
164
+ return report
212
165
  }
213
166
  }
214
167
  ```
215
168
 
169
+ `save` is the add-or-update helper from [keychain-fundamentals.md](keychain-fundamentals.md): `SecItemAdd` first, and on `errSecDuplicateItem` a `SecItemUpdate` with the new data. A failed key keeps its `UserDefaults` value, so the result is idempotent: moved keys read as absent next time and are skipped, failed keys are retried after a crash, a force quit or a memory kill.
170
+
171
+ The protocol takes the accessibility class as a `String`. The `kSecAttrAccessible*` constants are `CFString` globals, which are not `Sendable`, and Swift 6 rejects handing one to another actor; `as String` makes a copy that is. The conforming type turns it back with `as CFString` when it builds the query.
172
+
173
+ The version to avoid:
174
+
216
175
  ```swift
217
- // ❌ INCORRECT: Deletes legacy data BEFORE verifying keychain write
218
- func dangerousMigration() {
219
- let keys = ["authToken", "refreshToken"]
220
- for key in keys {
221
- guard let value = UserDefaults.standard.string(forKey: key) else { continue }
222
-
223
- // Deletes FIRST - if keychain write fails, data is gone forever
224
- UserDefaults.standard.removeObject(forKey: key) // ← CATASTROPHIC
225
-
226
- let query: [String: Any] = [
227
- kSecClass as String: kSecClassGenericPassword,
228
- kSecAttrService as String: "com.myapp",
229
- kSecAttrAccount as String: key,
230
- kSecValueData as String: value.data(using: .utf8)!
231
- ]
232
- let status = SecItemAdd(query as CFDictionary, nil)
233
- // If status != errSecSuccess, the token is permanently lost.
234
- }
235
- }
176
+ let token = UserDefaults.standard.string(forKey: "authToken") ?? ""
177
+ UserDefaults.standard.removeObject(forKey: "authToken")
178
+ let item: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
179
+ kSecAttrAccount: "authToken",
180
+ kSecValueData: Data(token.utf8)]
181
+ SecItemAdd(item as CFDictionary, nil)
236
182
  ```
237
183
 
238
- The migration is **idempotent by design**: already-migrated keys return `nil` from UserDefaults in Step 1 and are skipped. Failed keys retain their original values, ready for retry. This makes it safe to re-run after crash, app kill, or OOM termination.
239
-
240
- ---
184
+ The plaintext is gone before the add, and the add's status is ignored, so any failure loses the token.
241
185
 
242
- ## Versioned Migration with Schema Tracking
186
+ ## Versioned migration with a schema number
243
187
 
244
- A production system needs version tracking to avoid re-running completed migrations and to handle users who skip versions. The schema version belongs in the **keychain** (survives reinstalls), not UserDefaults.
188
+ Store the schema version in the keychain, not in `UserDefaults`, so it survives a reinstall together with the items it describes.
245
189
 
246
190
  ```swift
247
- // ✅ CORRECT: Versioned chain migration with schema version in keychain
248
- actor MigrationCoordinator {
249
- static let shared = MigrationCoordinator()
250
-
251
- private let serviceName = "com.myapp.credentials"
252
- private let schemaVersionAccount = "com.myapp.schema.version"
253
- private static let currentSchemaVersion: Int = 3
254
-
255
- enum MigrationState {
256
- case upToDate
257
- case migrated(from: Int, to: Int)
258
- case deferred(reason: String)
259
- case failed(Error)
260
- }
191
+ import OSLog
192
+ import UIKit
193
+
194
+ enum MigrationState: Sendable, Equatable {
195
+ case upToDate
196
+ case migrated(from: Int, to: Int)
197
+ case deferred(reason: String)
198
+ case failed(reason: String)
199
+ }
261
200
 
262
- func migrateIfNeeded() async -> MigrationState {
263
- // Guard: protected data must be available (pre-warming defense)
264
- let dataAvailable = await MainActor.run {
265
- UIApplication.shared.isProtectedDataAvailable
266
- }
267
- guard dataAvailable else {
268
- return .deferred(reason: "Device locked - protected data unavailable")
269
- }
201
+ enum MigrationError: Error { case keysLeftBehind(Int), corruptArchive, readBackMismatch }
270
202
 
271
- let storedVersion = readSchemaVersion()
272
- guard storedVersion < Self.currentSchemaVersion else { return .upToDate }
203
+ actor SchemaMigrator {
204
+ static let targetVersion = 3
205
+ private let vault: any MigrationKeychainProtocol
206
+ private let versionAccount = "vault.schemaVersion"
207
+ private let log = OSLog(subsystem: "com.example.ledger", category: "KeychainMigration")
273
208
 
209
+ init(vault: any MigrationKeychainProtocol) { self.vault = vault }
210
+
211
+ func migrateIfNeeded() async -> MigrationState {
212
+ let readable = await MainActor.run { UIApplication.shared.isProtectedDataAvailable }
213
+ guard readable else { return .deferred(reason: "device locked") }
274
214
  do {
275
- // Chain migration: each step runs sequentially
276
- if storedVersion < 1 {
277
- try await migrateV0toV1_UserDefaultsToKeychain()
278
- }
279
- if storedVersion < 2 {
280
- try await migrateV1toV2_NSCodingArchivesToKeychain()
281
- }
282
- if storedVersion < 3 {
283
- try await migrateV2toV3_UpgradeAccessibilityClass()
215
+ let current = try await storedVersion()
216
+ guard current < Self.targetVersion else { return .upToDate }
217
+ for step in current..<Self.targetVersion {
218
+ try await run(stepFrom: step)
284
219
  }
285
-
286
- // Update version ONLY after all steps succeed
287
- try saveSchemaVersion(Self.currentSchemaVersion)
288
- return .migrated(from: storedVersion, to: Self.currentSchemaVersion)
220
+ try await vault.save(Data(String(Self.targetVersion).utf8), account: versionAccount,
221
+ accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
222
+ return .migrated(from: current, to: Self.targetVersion)
289
223
  } catch {
290
- // Do NOT update schema version - retry on next launch
291
- os_log(.error, log: .migration,
292
- "Migration failed: %{public}@", error.localizedDescription)
293
- return .failed(error)
224
+ os_log("migration stopped: %{public}@", log: log, type: .error,
225
+ String(describing: type(of: error)))
226
+ return .failed(reason: String(describing: error))
294
227
  }
295
228
  }
296
229
 
297
- // MARK: - Schema Version (stored in keychain, survives reinstall)
298
-
299
- private func readSchemaVersion() -> Int {
300
- guard let data = try? keychainRead(
301
- service: serviceName, account: schemaVersionAccount),
302
- let str = String(data: data, encoding: .utf8),
303
- let version = Int(str) else { return 0 }
304
- return version
305
- }
306
-
307
- private func saveSchemaVersion(_ version: Int) throws {
308
- let data = "\(version)".data(using: .utf8)!
309
- try keychainSave(data, service: serviceName,
310
- account: schemaVersionAccount)
230
+ private func storedVersion() async throws -> Int {
231
+ guard let raw = try await vault.read(account: versionAccount) else { return 0 }
232
+ return Int(String(decoding: raw, as: UTF8.self)) ?? 0
311
233
  }
312
234
 
313
- // MARK: - V1: UserDefaults → Keychain
314
-
315
- private func migrateV0toV1_UserDefaultsToKeychain() async throws {
316
- let migrator = AtomicMigrator(keychain: KeychainManager.shared)
317
- let results = await migrator.migrateUserDefaultsKeys(
318
- ["authToken", "refreshToken", "apiSecret"],
319
- service: serviceName
320
- )
321
- // Check for critical failures (non-nil keys that didn't migrate)
322
- let failures = results.filter { !$0.succeeded }
323
- if !failures.isEmpty {
324
- os_log(.error, log: .migration,
325
- "V1 migration: %d keys failed", failures.count)
235
+ private func run(stepFrom version: Int) async throws {
236
+ switch version {
237
+ case 0: try await moveDefaults()
238
+ case 1: try await moveArchive()
239
+ case 2: try await tightenAccessibility()
240
+ default: break
326
241
  }
327
- // Force-sync UserDefaults deletions to disk
328
- UserDefaults.standard.synchronize()
329
242
  }
243
+ }
244
+ ```
330
245
 
331
- // MARK: - V2: NSCoding Archives → Keychain
332
-
333
- private func migrateV1toV2_NSCodingArchivesToKeychain() async throws {
334
- let documentsURL = FileManager.default.urls(
335
- for: .documentDirectory, in: .userDomainMask).first!
336
- let archiveURL = documentsURL.appendingPathComponent("UserSession.archive")
337
-
338
- guard FileManager.default.fileExists(atPath: archiveURL.path) else { return }
339
-
340
- let archiveData = try Data(contentsOf: archiveURL)
341
- guard let session = try NSKeyedUnarchiver.unarchivedObject(
342
- ofClass: LegacySession.self, from: archiveData) else {
343
- throw AtomicMigrator.MigrationError.corruptArchive(path: archiveURL.path)
344
- }
345
-
346
- let sessionData = try JSONEncoder().encode(session.toModernSession())
347
- try keychainSave(sessionData, service: serviceName, account: "userSession")
246
+ The steps, one per version:
348
247
 
349
- // Verify before deleting archive file
350
- let verified = try keychainRead(service: serviceName, account: "userSession")
351
- guard verified == sessionData else {
352
- throw AtomicMigrator.MigrationError.verificationFailed(key: "userSession")
353
- }
354
- try FileManager.default.removeItem(at: archiveURL)
355
- }
248
+ - **v0 to v1, UserDefaults.** Run `DefaultsToKeychainMover` over the named keys, log how many failed, and throw if any did so the version does not advance. Older samples call `UserDefaults.standard.synchronize()` afterwards; it is harmless and no longer needed.
249
+ - **v1 to v2, NSCoding archive.** If the archive file exists, decode it with `NSKeyedUnarchiver.unarchivedObject(ofClass:from:)` (a corrupt file throws), encode the modern session value as JSON, save it to the keychain, read it back, and only then call `FileManager.default.removeItem(at:)`.
250
+ - **v2 to v3, stricter accessibility.** Re-save every existing item with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` through add-or-update; the duplicate branch runs `SecItemUpdate` with the new `kSecAttrAccessible` in the attributes dictionary. Items that carry a `SecAccessControl` are the exception: changing their access control means delete and re-add.
356
251
 
357
- // MARK: - V3: Upgrade accessibility class on existing items
358
-
359
- private func migrateV2toV3_UpgradeAccessibilityClass() async throws {
360
- let accounts = ["authToken", "refreshToken", "apiSecret", "userSession"]
361
- for account in accounts {
362
- guard let data = try? keychainRead(
363
- service: serviceName, account: account) else { continue }
364
- // Re-save with updated accessibility - add-or-update pattern
365
- // updates the accessibility class via SecItemUpdate
366
- try keychainSave(data, service: serviceName, account: account,
367
- accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
252
+ ```swift
253
+ extension SchemaMigrator {
254
+ private func moveArchive() async throws {
255
+ let file = URL.applicationSupportDirectory.appending(path: "Session.archive")
256
+ guard FileManager.default.fileExists(atPath: file.path()) else { return }
257
+ let raw = try Data(contentsOf: file)
258
+ guard let old = try NSKeyedUnarchiver.unarchivedObject(ofClass: LegacySession.self, from: raw) else {
259
+ throw MigrationError.corruptArchive
368
260
  }
261
+ let modern = try JSONEncoder().encode(SessionRecord(userID: old.userID, accessToken: old.token))
262
+ try await vault.save(modern, account: "session",
263
+ accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
264
+ guard try await vault.read(account: "session") == modern else { throw MigrationError.readBackMismatch }
265
+ try FileManager.default.removeItem(at: file)
369
266
  }
370
267
  }
371
-
372
- private extension OSLog {
373
- static let migration = OSLog(
374
- subsystem: Bundle.main.bundleIdentifier ?? "com.myapp",
375
- category: "KeychainMigration"
376
- )
377
- }
378
268
  ```
379
269
 
380
- ```swift
381
- // ❌ INCORRECT: Runs every launch, no version check, no verification, no legacy delete
382
- func brokenMigration() {
383
- // No version check - runs every single launch
384
- // No isProtectedDataAvailable check - fails during pre-warm
385
- if let token = UserDefaults.standard.string(forKey: "authToken") {
386
- let query: [String: Any] = [
387
- kSecClass as String: kSecClassGenericPassword,
388
- kSecAttrService as String: "com.myapp",
389
- kSecAttrAccount as String: "authToken",
390
- kSecValueData as String: token.data(using: .utf8)!
391
- ]
392
- // No errSecDuplicateItem handling - crashes on second launch
393
- SecItemAdd(query as CFDictionary, nil)
394
- // Never deletes from UserDefaults - plaintext secret persists
395
- // No verification that write succeeded
396
- }
397
- }
398
- ```
270
+ `LegacySession` stands for the app's existing `NSSecureCoding` class and `SessionRecord` for a new `Codable` struct.
399
271
 
400
- The chain migration approach (v1 → v2 → v3 sequentially) is deliberately chosen over direct migration because it reuses tested migration logic from each version. For users upgrading from v1.0 directly to v3.0, all three steps run. The schema version only advances after all steps succeed - a crash mid-migration leaves the version at the old number for clean retry.
272
+ A migration that runs every launch, skips the protected-data check, ignores `errSecDuplicateItem`, never reads back and never removes the `UserDefaults` copy shows every defect this file warns about.
401
273
 
402
- ---
274
+ Why a chain of single steps instead of direct jumps: each step is tested once and reused, a user skipping several releases simply runs every step in order, and a crash in the middle leaves the stored version at its old value so the next launch retries cleanly. Log through a dedicated `OSLog` subsystem with a `KeychainMigration` category so the steps can be filtered in Console.
403
275
 
404
- ## Orphaned Items: Why You Must Never Rename kSecAttrService
276
+ ## Orphaned items after a rename
405
277
 
406
- ```swift
407
- // ❌ INCORRECT: SecItemUpdate CANNOT change primary key attributes
408
- let query: [String: Any] = [
409
- kSecClass as String: kSecClassGenericPassword,
410
- kSecAttrService as String: "OldServiceName",
411
- kSecAttrAccount as String: "authToken"
412
- ]
413
- let update: [String: Any] = [
414
- kSecAttrService as String: "com.mycompany.myapp" // ERROR: primary key
415
- ]
416
- // SecItemUpdate returns an error - primary keys are immutable via Update
417
- SecItemUpdate(query as CFDictionary, update as CFDictionary)
418
- ```
278
+ `SecItemUpdate` with a new `kSecAttrService` in the attributes dictionary fails, because the service is part of the item's primary key. Rekey instead:
419
279
 
420
280
  ```swift
421
- // ✅ CORRECT: Full rekey migration when service name must change
422
- func migrateServiceName() async throws {
423
- let oldService = "OldServiceName"
424
- let newService = "com.mycompany.myapp"
425
- let accounts = ["authToken", "refreshToken"]
426
-
281
+ func rekey(accounts: [String], from oldService: String, to newService: String) throws {
427
282
  for account in accounts {
428
- let oldData: Data
429
- do {
430
- oldData = try keychainRead(service: oldService, account: account)
431
- } catch { continue } // Already migrated or never existed
432
-
433
- try keychainSave(oldData, service: newService, account: account)
434
-
435
- // Verify new location before deleting old
436
- let verified = try keychainRead(service: newService, account: account)
437
- guard verified == oldData else {
438
- throw AtomicMigrator.MigrationError.verificationFailed(key: account)
283
+ let lookup: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
284
+ kSecAttrService: oldService,
285
+ kSecAttrAccount: account,
286
+ kSecReturnData: true]
287
+ var found: CFTypeRef?
288
+ let status = SecItemCopyMatching(lookup as CFDictionary, &found)
289
+ if status == errSecItemNotFound { continue }
290
+ guard status == errSecSuccess, let secret = found as? Data else { throw KeychainFailure(status) }
291
+ try KeychainVault.upsert(secret, service: newService, account: account)
292
+ guard try KeychainVault.read(service: newService, account: account) == secret else {
293
+ throw KeychainFailure(errSecDecode)
439
294
  }
440
- try keychainDelete(service: oldService, account: account)
295
+ let oldItem: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
296
+ kSecAttrService: oldService,
297
+ kSecAttrAccount: account]
298
+ SecItemDelete(oldItem as CFDictionary)
441
299
  }
442
300
  }
443
301
  ```
444
302
 
445
- **Lock down your `kSecAttrService` value early and never change it.** Use your bundle identifier (e.g., `com.mycompany.myapp`) - it's unique, stable, and conventional.
303
+ `KeychainVault` stands for the app's add-or-update helper described in [keychain-fundamentals.md](keychain-fundamentals.md). The better fix is to never need this: choose `kSecAttrService` once, typically the bundle identifier, and keep it forever.
446
304
 
447
- ---
305
+ ## Locked devices and background launches
448
306
 
449
- ## Background Launch and the Locked-Device Trap
307
+ Pre-warming and background work (remote notifications, background fetch, Live Activity updates) can run while the device is locked. The item's accessibility class decides whether reads succeed.
450
308
 
451
- iOS 15+ pre-warming and background execution (push notifications, background fetch, Live Activities) can launch your app while the device is locked. The `kSecAttrAccessible` value you choose determines whether keychain operations succeed in these contexts.
309
+ | Class | Readable in the background | Notes |
310
+ | --- | --- | --- |
311
+ | `WhenUnlocked` (default) | No | Foreground only, backed up and eligible for sync |
312
+ | `AfterFirstUnlockThisDeviceOnly` | Yes, after first unlock | Recommended; stays on this device |
313
+ | `AfterFirstUnlock` | Yes, after first unlock | Also backed up and eligible for sync; use only when needed |
314
+ | `WhenPasscodeSetThisDeviceOnly` | No | For biometric-gated items; removed if the passcode is removed |
315
+ | `Always` | Yes | Deprecated in iOS 12; do not use |
452
316
 
453
- > For the complete accessibility constant selection matrix with data protection tiers and security trade-offs, see `keychain-access-control.md` section The "When" Layer: Seven Accessibility Constants. The table below summarizes the four constants most relevant to background migration scenarios.
317
+ Migrated credentials default to `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`: usable from background code, never backed up, never synced.
454
318
 
455
- | Accessibility constant | Available when locked | Background safe | Notes |
456
- | -------------------------------------------------- | --------------------- | --------------- | ---------------------------------------------------- |
457
- | `kSecAttrAccessibleWhenUnlocked` (default) | No | No | Foreground only |
458
- | `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | After first unlock | Yes | **Recommended** - background + device-bound |
459
- | `kSecAttrAccessibleAfterFirstUnlock` | After first unlock | Yes | Background + backup migration (use only when needed) |
460
- | `kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly` | No | No | Biometric-gated items |
461
- | `kSecAttrAccessibleAlways` | Yes | Yes | **Deprecated iOS 12** - do not use |
319
+ On sync: every `...ThisDeviceOnly` class keeps the item on this device. Such an item is never restored to another device and never syncs through iCloud Keychain, and asking for both (`kSecAttrSynchronizable: true` with a `ThisDeviceOnly` class) is rejected by `SecItemAdd` with `errSecParam`. Pick a non-ThisDeviceOnly class only when the item is meant to sync.
462
320
 
463
- **Recommended default for migrated credentials:** `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` - background-safe, not synced to iCloud, not included in backups. Apple uses `AfterFirstUnlock` for Wi-Fi passwords and mail account credentials.
464
-
465
- A critical trap: **`SecItemDelete` does NOT require the item's protection-class key material** - it succeeds even when the item's data is unreadable due to lock state. This enables a devastating anti-pattern:
321
+ `SecItemDelete` does not need the class key, so it succeeds even while the data is unreadable. That makes this pattern destructive:
466
322
 
467
323
  ```swift
468
- // ❌ DANGEROUS: Delete-on-read-failure destroys data during background launch
469
- func dangerousTokenRefresh() {
470
- var result: AnyObject?
471
- let status = SecItemCopyMatching(query as CFDictionary, &result)
472
-
473
- if status != errSecSuccess {
474
- // "Can't read? Must be corrupted. Delete and start fresh."
475
- SecItemDelete(query as CFDictionary) // ← DESTROYS VALID TOKEN
476
- // During background launch with WhenUnlocked, the read fails
477
- // with -25308 (interaction not allowed), but delete succeeds.
478
- }
479
- }
480
-
481
- // ✅ CORRECT: Distinguish "not found" from "device locked"
482
- func safeTokenRead() throws -> Data? {
483
- var result: AnyObject?
484
- let status = SecItemCopyMatching(query as CFDictionary, &result)
485
-
486
- switch status {
487
- case errSecSuccess:
488
- return result as? Data
489
- case errSecItemNotFound:
490
- return nil // Genuinely absent
491
- case errSecInteractionNotAllowed:
492
- // Device locked - item exists but unreadable right now.
493
- // Do NOT delete. Do NOT treat as missing. Retry later.
494
- throw KeychainError.interactionNotAllowed
495
- default:
496
- throw KeychainError.unexpectedStatus(status)
497
- }
324
+ if SecItemCopyMatching(query as CFDictionary, &out) != errSecSuccess {
325
+ SecItemDelete(query as CFDictionary)
498
326
  }
499
327
  ```
500
328
 
501
- **Migration rule:** Always guard migration behind `UIApplication.shared.isProtectedDataAvailable`. If the device is locked, defer using `protectedDataDidBecomeAvailableNotification`. Never interpret an empty read during a locked state as "nothing to migrate."
502
-
503
- ---
504
-
505
- ## The Phantom Mismatch Bug
506
-
507
- Including `kSecAttrAccessible` in a search query causes a "not-found then duplicate" paradox. The search filters by accessibility class, but the item was stored with a different class - so `SecItemCopyMatching` returns `errSecItemNotFound` while `SecItemAdd` sees the item via primary key and returns `errSecDuplicateItem`.
329
+ During a locked background launch the read returns `errSecInteractionNotAllowed` (-25308), the delete succeeds, and a valid token is gone. Map the statuses instead:
508
330
 
509
331
  ```swift
510
- // ❌ INCORRECT: kSecAttrAccessible in search query causes phantom mismatches
511
- let query: [String: Any] = [
512
- kSecClass as String: kSecClassGenericPassword,
513
- kSecAttrService as String: service,
514
- kSecAttrAccount as String: account,
515
- kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, // ← BUG
516
- kSecReturnData as String: kCFBooleanTrue as Any
517
- ]
518
- // If stored with AfterFirstUnlock, query returns errSecItemNotFound.
519
- // But SecItemAdd sees the item via primary key → errSecDuplicateItem. Deadlock.
520
- ```
332
+ enum VaultError: Error { case lockedTryLater, unexpected(OSStatus) }
521
333
 
522
- **Rule:** Use **only primary key attributes** (`kSecClass`, `kSecAttrService`, `kSecAttrAccount`) in search queries. Set `kSecAttrAccessible` only during `SecItemAdd` or in the update dictionary of `SecItemUpdate`.
523
-
524
- ```swift
525
- // ✅ CORRECT: search by primary key only
526
- let query: [String: Any] = [
527
- kSecClass as String: kSecClassGenericPassword,
528
- kSecAttrService as String: service,
529
- kSecAttrAccount as String: account,
530
- kSecReturnData as String: kCFBooleanTrue as Any
531
- ]
334
+ func readToken(_ query: [CFString: Any]) throws -> Data? {
335
+ var out: CFTypeRef?
336
+ let status = SecItemCopyMatching(query as CFDictionary, &out)
337
+ switch status {
338
+ case errSecSuccess: return out as? Data
339
+ case errSecItemNotFound: return nil
340
+ case errSecInteractionNotAllowed: throw VaultError.lockedTryLater
341
+ default: throw VaultError.unexpected(status)
342
+ }
343
+ }
532
344
  ```
533
345
 
534
- ---
346
+ `errSecInteractionNotAllowed` is neither "missing" nor a reason to delete; retry once protected data is available. Gate migration on `isProtectedDataAvailable`, defer it through `protectedDataDidBecomeAvailableNotification`, and never interpret an empty read on a locked device as "nothing to migrate".
535
347
 
536
- ## Team ID Change: The App Transfer Edge Case
348
+ ## The phantom mismatch
537
349
 
538
- When an app is transferred to a different Apple Developer account, the Team ID changes. Keychain access is permanently tied to the original Team ID - all existing keychain items become inaccessible under the new signing identity. Users are effectively logged out and lose all locally stored secrets on the first launch after updating.
350
+ Putting `kSecAttrAccessible` into a search query turns it into a filter. If the stored item has a different class, `SecItemCopyMatching` reports `errSecItemNotFound` while `SecItemAdd` reports `errSecDuplicateItem` for the same item, and the code loops between the two. Search with primary-key attributes only (`kSecClass`, `kSecAttrService`, `kSecAttrAccount`). Accessibility belongs in the `SecItemAdd` dictionary or in the attributes-to-update dictionary of `SecItemUpdate`.
539
351
 
540
- **If a Team ID change is unavoidable**, you must release a "bridge" update under the **old** Team ID before the transfer:
352
+ ## Team ID change after an app transfer
541
353
 
542
- 1. Bridge update reads all keychain items and exports them to a temporary, app-group-shared container (or encrypted file in the app sandbox)
543
- 2. Transfer the app to the new developer account
544
- 3. First release under the new Team ID reads from the temporary store, writes to the new keychain, verifies, and deletes the temporary data
354
+ Moving the app to another developer account changes the Team ID, which is part of every keychain access group. All existing items become unreadable and every user is signed out. There is no recovery afterwards, so plan a bridge well before the transfer:
545
355
 
546
- This is a one-way operation and must be planned well in advance. There is no way to recover keychain items after a Team ID change without the bridge update.
356
+ 1. Ship an update under the old Team ID that exports the needed values into a temporary app-group container or an encrypted file in the sandbox.
357
+ 2. Transfer the app.
358
+ 3. In the first release under the new team, import the values, write them to the keychain, verify them, then delete the temporary copy.
547
359
 
548
- ---
360
+ ## Cleaning up legacy artefacts
549
361
 
550
- ## Deferred Legacy Cleanup with Rollback Window
362
+ Secret values are deleted as soon as their keychain copy is verified, in the same step that moved them. Keeping plaintext credentials around "just in case" preserves exactly the exposure the migration exists to remove, and Apple's guidance is that `UserDefaults` and plain files are not for sensitive data.
551
363
 
552
- The safest approach keeps legacy data as backup for one release cycle after migration. Track a migration timestamp in keychain:
364
+ A rollback window is still useful for what is left: non-secret legacy files and preference domains that an older build or a support engineer might want. Record the migration date in the keychain and clear those artefacts after roughly one release cycle, such as 30 days.
553
365
 
554
366
  ```swift
555
- // ✅ CORRECT: Deferred cleanup with 30-day rollback window
556
- actor DeferredCleanup {
557
- private let cleanupDelayDays = 30
558
- private let timestampAccount = "com.myapp.migration.timestamp"
559
- private let serviceName = "com.myapp.credentials"
560
-
561
- func cleanupIfExpired() async {
562
- guard let data = try? keychainRead(
563
- service: serviceName, account: timestampAccount),
564
- let str = String(data: data, encoding: .utf8),
565
- let migrationDate = ISO8601DateFormatter().date(from: str) else { return }
566
-
567
- let days = Calendar.current.dateComponents(
568
- [.day], from: migrationDate, to: Date()).day ?? 0
569
- guard days >= cleanupDelayDays else { return }
570
-
571
- // Past rollback window - safe to permanently delete legacy files
572
- let documentsURL = FileManager.default.urls(
573
- for: .documentDirectory, in: .userDomainMask).first!
574
- for file in ["UserSession.archive", "Credentials.plist", "TokenCache.dat"] {
575
- try? FileManager.default.removeItem(
576
- at: documentsURL.appendingPathComponent(file))
367
+ actor LegacyArtefactJanitor {
368
+ private let vault: any MigrationKeychainProtocol
369
+ private let window: TimeInterval = 30 * 24 * 60 * 60
370
+ private let leftovers = ["SessionCache.archive", "Preferences.plist", "FeedCache.dat"]
371
+
372
+ init(vault: any MigrationKeychainProtocol) { self.vault = vault }
373
+
374
+ func sweepIfWindowElapsed(now: Date = .now) async throws {
375
+ guard let stamp = try await vault.read(account: "vault.migratedAt"),
376
+ let migratedAt = ISO8601DateFormatter().date(from: String(decoding: stamp, as: UTF8.self)),
377
+ now.timeIntervalSince(migratedAt) > window else { return }
378
+ let base = URL.documentsDirectory
379
+ for name in leftovers {
380
+ try? FileManager.default.removeItem(at: base.appending(path: name))
577
381
  }
578
- if let bundleID = Bundle.main.bundleIdentifier {
579
- UserDefaults.standard.removePersistentDomain(forName: bundleID)
382
+ if let domain = Bundle.main.bundleIdentifier {
383
+ UserDefaults.standard.removePersistentDomain(forName: domain)
580
384
  }
581
385
  }
582
386
  }
583
387
  ```
584
388
 
585
- ---
586
-
587
- ## Complete App Launch Sequence
588
-
589
- The correct ordering at app startup is critical. Keychain cleanup must happen before SDK initialization, migration must wait for protected data, and schema version gates all logic.
389
+ ## Launch order
590
390
 
591
391
  ```swift
592
- // ✅ CORRECT: Complete launch sequence with migration
593
- @main
594
- struct MyApp: App {
595
- @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
596
- var body: some Scene { WindowGroup { ContentView() } }
597
- }
392
+ import SwiftUI
393
+ import OSLog
598
394
 
599
- class AppDelegate: NSObject, UIApplicationDelegate {
600
- func application(
601
- _ application: UIApplication,
602
- didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
603
- ) -> Bool {
604
- Task {
605
- // 1. First-launch cleanup (stale keychain from previous install)
606
- await FirstLaunchGuard.shared.performCleanupIfNeeded()
607
-
608
- // 2. Versioned migration
609
- let state = await MigrationCoordinator.shared.migrateIfNeeded()
610
- switch state {
611
- case .upToDate: break
612
- case .migrated(let from, let to):
613
- os_log(.info, "Migrated schema v%d → v%d", from, to)
614
- case .deferred(let reason):
615
- os_log(.info, "Migration deferred: %{public}@", reason)
616
- case .failed(let error):
617
- os_log(.error, "Migration failed: %{public}@",
618
- error.localizedDescription)
619
- }
620
-
621
- // 3. Deferred cleanup of legacy files past rollback window
622
- await DeferredCleanup().cleanupIfExpired()
395
+ final class LaunchDelegate: NSObject, UIApplicationDelegate {
396
+ private let vault = LiveMigrationKeychain()
397
+ private let log = Logger(subsystem: "com.example.ledger", category: "KeychainMigration")
623
398
 
624
- // 4. NOW initialize Firebase, analytics, auth SDKs
625
- // Stale data cleared, migration complete or safely deferred
399
+ func application(_ application: UIApplication,
400
+ didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
401
+ Task {
402
+ await ReinstallSweeper().sweepIfFreshInstall()
403
+ let state = await SchemaMigrator(vault: vault).migrateIfNeeded()
404
+ log.info("migration state: \(String(describing: state), privacy: .public)")
405
+ try? await LegacyArtefactJanitor(vault: vault).sweepIfWindowElapsed()
406
+ ThirdPartyServices.start()
626
407
  }
627
408
  return true
628
409
  }
629
410
  }
630
- ```
631
-
632
- ---
633
411
 
634
- ## Thread Safety Note
412
+ @main
413
+ struct LedgerApp: App {
414
+ @UIApplicationDelegateAdaptor(LaunchDelegate.self) private var delegate
415
+ var body: some Scene { WindowGroup { RootView() } }
416
+ }
417
+ ```
635
418
 
636
- `SecItem*` functions are safe to call from concurrent code, but your wrapper's mutable state (caches, migration flags, version tracking) still needs synchronization. An `actor` provides this naturally in modern Swift concurrency; prefer actors over serial queues for new code when the deployment target allows it.
419
+ The order is fixed: reinstall sweep, schema migration, artefact cleanup, and only then the analytics, crash reporting and authentication SDKs.
637
420
 
638
- ---
421
+ ## Concurrency
639
422
 
640
- ## Testing Migration Paths
423
+ The `SecItem*` functions are safe to call from any thread. The wrapper's own state (caches, flags, the stored version) is not, so protect it; an actor is the simplest choice when the deployment target allows it, otherwise a serial queue.
641
424
 
642
- Keychain behavior differs between Simulator and real devices:
425
+ ## Testing migrations
643
426
 
644
- | Aspect | Simulator | Real device |
645
- | ----------------------------- | ------------------------ | -------------------------------------- |
646
- | Data Protection enforcement | Not enforced | Fully enforced (hardware) |
647
- | Keychain entitlements | Loosely enforced | Strictly enforced |
648
- | `errSecInteractionNotAllowed` | Rarely triggered | Triggered when locked |
649
- | Lock state testing | Cannot meaningfully test | Essential for accessibility validation |
427
+ | Aspect | Simulator | Device |
428
+ | --- | --- | --- |
429
+ | Data protection | Not enforced | Enforced |
430
+ | Entitlement checks | Lenient | Strict |
431
+ | `errSecInteractionNotAllowed` | Rare | Returned while locked |
432
+ | Lock-state testing | Not possible | Required |
650
433
 
651
- Use **protocol-based abstraction** for unit tests (runs in CI on simulators) and real-device integration tests for accessibility-class validation:
434
+ An in-memory mock that conforms to the same protocol lets unit tests inject failures:
652
435
 
653
436
  ```swift
654
- // ✅ Protocol-based keychain abstraction for testable migrations
655
- protocol MigrationKeychainProtocol: Actor {
656
- func save(_ data: Data, service: String, account: String,
657
- accessible: CFString) throws
658
- func read(service: String, account: String) throws -> Data
659
- func delete(service: String, account: String) throws
660
- func deleteAll()
661
- }
437
+ actor InMemoryMigrationKeychain: MigrationKeychainProtocol {
438
+ private var rows: [String: [String: Data]] = [:]
439
+ var injectedStatus: OSStatus?
662
440
 
663
- // In-memory mock for unit tests
664
- actor MockMigrationKeychain: MigrationKeychainProtocol {
665
- var store: [String: [String: Data]] = [:]
666
- var simulatedError: KeychainError?
441
+ func inject(_ status: OSStatus?) { injectedStatus = status }
667
442
 
668
- func save(_ data: Data, service: String, account: String,
669
- accessible: CFString) throws {
670
- if let error = simulatedError { throw error }
671
- store[service, default: [:]][account] = data
443
+ func save(_ data: Data, account: String, accessibility: String) throws {
444
+ if let status = injectedStatus { throw KeychainFailure(status) }
445
+ rows[accessibility, default: [:]][account] = data
672
446
  }
673
-
674
- func read(service: String, account: String) throws -> Data {
675
- if let error = simulatedError { throw error }
676
- guard let data = store[service]?[account] else {
677
- throw KeychainError.itemNotFound
678
- }
679
- return data
447
+ func read(account: String) throws -> Data? {
448
+ rows.values.lazy.compactMap { $0[account] }.first
680
449
  }
681
-
682
- func delete(service: String, account: String) throws {
683
- store[service]?[account] = nil
450
+ func delete(account: String) throws {
451
+ for key in rows.keys { rows[key]?[account] = nil }
684
452
  }
685
-
686
- func deleteAll() { store.removeAll() }
453
+ func deleteAll() throws { rows.removeAll() }
687
454
  }
688
455
  ```
689
456
 
690
457
  ```swift
691
- // ✅ Example: verify atomic behavior - legacy data preserved on failure
692
- @Test func migrationPreservesLegacyDataOnKeychainFailure() async {
693
- let mock = MockMigrationKeychain()
694
- mock.simulatedError = .unexpectedStatus(-25308) // Simulate locked device
458
+ import Testing
695
459
 
696
- let defaults = UserDefaults(suiteName: "test")!
697
- defaults.set("secret-token", forKey: "authToken")
460
+ @Test func lockedWriteLeavesPlaintextForRetry() async throws {
461
+ let suite = try #require(UserDefaults(suiteName: "migration-test"))
462
+ suite.removePersistentDomain(forName: "migration-test")
463
+ suite.set("refresh-abc", forKey: "refreshToken")
464
+ let vault = InMemoryMigrationKeychain()
465
+ await vault.inject(errSecInteractionNotAllowed)
698
466
 
699
- let migrator = AtomicMigrator(keychain: mock)
700
- let results = await migrator.migrateUserDefaultsKeys(
701
- ["authToken"], service: "com.myapp"
702
- )
467
+ let mover = DefaultsToKeychainMover(vault: vault, defaults: try #require(UserDefaults(suiteName: "migration-test")))
468
+ let report = await mover.move(keys: ["refreshToken"])
703
469
 
704
- #expect(results.contains(where: { !$0.succeeded }))
705
- #expect(defaults.string(forKey: "authToken") == "secret-token") // Still intact
470
+ #expect(suite.string(forKey: "refreshToken") == "refresh-abc")
471
+ #expect(report["refreshToken"] != .moved)
706
472
  }
707
473
  ```
708
474
 
709
- Always clean up keychain items in `setUp()`/`tearDown()` - items persist between test runs on the same simulator. For integration tests hitting real keychain, create a Test Host app target with the Keychain capability enabled.
710
-
711
- ---
712
-
713
- ## Handling Very Old Versions and Collapse Strategy
714
-
715
- The App Store always delivers the latest binary - a user jumping from v1.0 to v3.0 never installs v2.0. Your v3.0 binary must contain migration logic for every historical schema version.
716
-
717
- Pragmatically, after sufficient time (when analytics show <1% of users on legacy versions), **collapse old migrations into a single mega-migration** from v0 to current, reducing code maintenance. For users on versions so old that the legacy format is unknown or corrupted, the migration should **fail gracefully** and prompt a fresh login rather than crashing.
475
+ The mover gets its own `UserDefaults` instance for the same suite. `UserDefaults` is not `Sendable`, so once `suite` has been handed to the actor the test could not read it afterwards.
718
476
 
719
- ---
477
+ Clear the keychain in `setUp` and `tearDown` for any test that touches the real one, because Simulator keeps items between runs. Integration tests against the real keychain need a host app target with the Keychain Sharing capability. More in [testing-security-code.md](testing-security-code.md).
720
478
 
721
- ## Secure Deletion: Trust Cryptographic Erasure
479
+ ## Very old versions
722
480
 
723
- Do **not** attempt to manually overwrite files with zeros or random bytes before deletion - NAND flash wear-leveling makes this ineffective and wastes write cycles. iOS handles secure deletion through cryptographic erasure: every file has a per-file AES-256 key, and when the file is deleted via standard APIs (`FileManager.removeItem`, `UserDefaults.removeObject`), iOS destroys the per-file key through Effaceable Storage, rendering the physical bits permanently unrecoverable.
481
+ The App Store only serves the newest binary, so it must still carry every migration from every schema a user could be on. Once analytics show fewer than about 1% of users on the oldest versions, collapse the early steps into a single v0-to-current migration. An unknown or corrupt legacy format fails gracefully and sends the user to a fresh sign-in; it never crashes.
724
482
 
725
- Standard deletion APIs are sufficient. The residual risk is unencrypted backups created _before_ migration - encourage users to use encrypted backups, and delete legacy data promptly after verified migration.
483
+ ## Deleting files
726
484
 
727
- ---
485
+ Do not overwrite a file with zeros or random bytes before deleting it. Wear levelling on NAND means the overwrite lands elsewhere, and it only burns write cycles. `FileManager.removeItem(at:)` and `UserDefaults.removeObject(forKey:)` are enough, because the per-file key is destroyed through Effaceable Storage. The residual risk is an unencrypted backup made before migration; encourage encrypted backups and remove the plaintext promptly once the migration is verified.
728
486
 
729
- ## Conclusion
487
+ ## Key decisions
730
488
 
731
- The core insight of safe keychain migration: **deletion is the irreversible step, not the write**. Every pattern in this file follows from that principle - verify before deleting, defer when uncertain, and treat keychain persistence across reinstalls as a feature to plan for rather than a bug to fight. The five most impactful decisions are: using `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` for background-safe encrypted storage, implementing first-launch cleanup before SDK initialization, storing schema versions in keychain rather than UserDefaults, gating all migration behind `isProtectedDataAvailable`, and never changing `kSecAttrService` after shipping.
489
+ The irreversible step is the delete, not the write: verify first, defer when in doubt, and assume keychain items outlive the app. The five decisions that matter most:
732
490
 
733
- ---
491
+ 1. `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` as the default class for migrated credentials.
492
+ 2. Reinstall cleanup before any SDK initialises.
493
+ 3. Schema version stored in the keychain.
494
+ 4. Migration gated on `isProtectedDataAvailable`.
495
+ 5. `kSecAttrService` never changes after the first release.
734
496
 
735
- ## Summary Checklist
497
+ ## Checklist
736
498
 
737
- 1. **First-launch cleanup runs before any SDK initialization** - uses UserDefaults flag to detect reinstall, wipes stale keychain items, includes `kSecAttrSynchronizableAny` to catch iCloud-synced items
738
- 2. **Migration is atomic: read → write → verify → delete** - legacy data is never deleted until keychain write is confirmed by read-back; failed keys remain intact for retry
739
- 3. **Schema version stored in keychain, not UserDefaults** - survives app reinstall; version only advances after all migration steps succeed
740
- 4. **Protected data availability checked before any migration** - guards against iOS 15+ pre-warming and locked-device scenarios; defers via `protectedDataDidBecomeAvailableNotification`
741
- 5. **`errSecInteractionNotAllowed` (-25308) is never treated as "item missing"** - distinguishes locked-device failures from genuine absence; never deletes on read failure without checking status code
742
- 6. **`kSecAttrService` and `kSecAttrAccount` are immutable after shipping** - changing either orphans existing items; `SecItemUpdate` cannot modify primary keys; use full rekey migration if change is unavoidable
743
- 7. **`kSecAttrAccessible` is never included in search queries** - causes phantom "not-found then duplicate" mismatches; set only during add or in update dictionary
744
- 8. **Default accessibility is `AfterFirstUnlockThisDeviceOnly`** - background-safe, not synced, not backed up; matches Apple's own credential storage patterns
745
- 9. **Deferred legacy cleanup with rollback window** - keep legacy data for 30 days post-migration as safety net; timestamp stored in keychain
746
- 10. **Team ID changes sever all keychain access** - must release bridge update under old Team ID before app transfer; no recovery possible after transfer without bridge
747
- 11. **Migration tested via protocol-based abstraction** - mock keychain in unit tests; real-device integration tests for accessibility class validation; clean up items in setUp/tearDown
499
+ - [ ] Reinstall cleanup runs before SDK setup, after protected data is readable, and uses `kSecAttrSynchronizableAny`.
500
+ - [ ] Each key moves read, write, verify, delete; failed keys keep their source value.
501
+ - [ ] Schema version lives in the keychain and advances only after every step succeeds.
502
+ - [ ] Migration checks protected-data availability and defers through the notification.
503
+ - [ ] `errSecInteractionNotAllowed` (-25308) is never read as "missing" and never triggers a delete.
504
+ - [ ] Service and account never change after shipping; an unavoidable rename uses a rekey migration.
505
+ - [ ] Search queries never include `kSecAttrAccessible`.
506
+ - [ ] Migrated credentials use `AfterFirstUnlockThisDeviceOnly` unless they are meant to sync.
507
+ - [ ] Plaintext secrets are deleted right after verification; only non-secret artefacts wait for a 30-day window whose start date lives in the keychain.
508
+ - [ ] A Team ID change is preceded by a bridge release.
509
+ - [ ] Unit tests use a protocol mock; accessibility is tested on a device; real-keychain tests clean up in `setUp` and `tearDown`.