Bugfix T-1670 — unshared trips vanished on every reinstall. Adds CloudKit zone-not-found recovery and a deterministic cold-launch fetch, hardened by three parallel review agents (reuse / quality / efficiency). All raised findings fixed; suite green, lint clean.
.zoneNotFound and never reached iCloud — nothing to restore on reinstall. Master lists survived because they ride SwiftData's native CloudKit mirror..zoneNotFound in the sent-changes callback — create the zone and re-queue the records (Apple's documented pattern).add(pendingDatabaseChanges: []) with an explicit cold-launch fetchChanges() so a reinstalled device deterministically pulls its zones back.classifyFailedSaves, planZoneRecovery) and covered by 8 unit tests.Ready to merge — pending one on-device check
The review found two MAJOR correctness gaps in the first-pass recovery (an unbounded self-heal loop and scope-blind zone creation on the shared DB) plus a test-coverage gap — all fixed and re-verified. Code is complete, make test-quick is green and make lint is clean. The one thing unit tests cannot exercise is the end-to-end CloudKit path (they run against cloudKitDatabase: .none); confirm the new-trip → zone-created → uploaded → reinstall → restored loop once on a real device before relying on it.
b6a31fe [bug] T-1670: clarify launch-fetch comment (unstructured Task, not detached) d46d678 [bug] T-1670: harden zone-not-found recovery (pre-push review) c467d1c [bug] T-1670: Restore trips on reinstall The app keeps your trips and your master lists (tasks & packing items) in two separate stores that sync to iCloud in two different ways. Master lists use Apple's built-in sync, which automatically restores them after a reinstall. Trips use a custom sync that puts each trip in its own iCloud ‘zone’. The bug: a new trip's zone was never actually created in iCloud, so the trip's data was never uploaded — and on reinstall there was nothing to bring back.
Every delete-and-reinstall wiped all your unshared trips while the lists survived. That is permanent loss of the app's most important object.
When iCloud rejects a trip's records because the zone doesn't exist, the app now creates the zone and retries, so trips actually get saved. On a fresh install it explicitly asks iCloud for your trips instead of hoping a background sync notices. A safety limit stops it retrying forever if something is genuinely wrong.
Two SwiftData containers: globals (cloudKitDatabase: .private — the native NSPersistentCloudKitContainer mirror, auto-restores) and tripsLocal (.none, local-only, synced by a hand-rolled CKSyncEngine into per-trip custom zones trip-{uuid}).
TripPersistence.create inserts a local TripZoneState but never enqueues a .saveZone, and LocalWriteHook only enqueues .saveRecord. CKSyncEngine does not auto-create zones, so uploads fail with .zoneNotFound. Worse, because the TripZoneState now exists, ZoneMigrationCoordinator.enqueueAll skips the trip forever, so Stage B never creates the zone either.
The fix follows Apple's sample: recover from .zoneNotFound in the sent-changes callback. The decision (what to recover) is factored into pure functions (classifyFailedSaves, planZoneRecovery) separate from the side-effecting recoverFailedSaves, so the logic is unit-tested without a live engine.
Reactive recovery (one failed round-trip per new trip) was chosen over a proactive zone-save at creation to keep the change contained. The download path adds an explicit cold-launch fetch rather than trusting automaticallySync.
planZoneRecovery applies two gates the raw classification can't: scope (only .private creates a zone — a participant cannot create a shared-DB zone it doesn't own, so shared .zoneNotFound is terminal) and bounded attempts (a per-zone counter caps optimistic re-queues at 3, reset on the observed savedZones confirmation in handleSentDatabaseChanges). Because the primary new-trip case has no MigrationJournalEntry, this recovery is the only path that ever creates its zone — so the gating correctness is load-bearing.
Previously every failure emitted .recordsFailed, which ZoneMigrationCoordinator.handleRecordsFailed uses to mark a Stage-B entry .failed (enabling the banner + retry(tripID:)). Now transient .zoneNotFound self-heals silently; only after exhausting the bound does it emit .recordsFailed, preserving the failure signal for genuinely-stuck migrations.
needsInitialFetch() keys on the private scope only — both state files are wiped together on reinstall, and keying on the shared scope risks perpetual re-fetch if CKSyncEngine never persists empty shared-DB state. The end-to-end path is unverifiable in unit tests (.none database); it needs an on-device run.
TripSyncEngine.swift
Why it matters. This is the fix's load-bearing change. Unshared new trips never get a zone from Stage B or sharing, so uploading them depends entirely on recovering the .zoneNotFound send failure here. If this is wrong, trips silently never reach iCloud.
What to look at. recoverFailedSaves(_:scope:) + handleSentDatabaseChanges reset
TripSyncEngine+ZoneRecovery.swift
Why it matters. Without these gates the first-pass recovery had two MAJOR defects: it would try to create zones on the shared DB (which a participant can't) and would re-queue records forever if a zone-save persistently failed — both silent loops with no signal.
What to look at. planZoneRecovery(failures:scope:attempts:maxAttempts:)
TripSyncEngine.swift
Why it matters. Even for trips that are in iCloud, restore relied solely on automaticallySync. makeEngine's add(pendingDatabaseChanges: []) was an empty-array no-op despite a comment claiming it forced reconciliation.
What to look at. needsInitialFetch() + fetchChangesOnLaunch()
ScrambleApp.swift
Why it matters. The restore fetch must not block the migration gate from releasing the UI.
What to look at. prepareLaunch(): needsInitialFetch captured before start(), fetch fired in an unstructured Task
A proactive zone-save when a trip is created would avoid the one failed upload round-trip, but requires threading the sync engine/driver into the trip-create UI path. Reactive recovery is self-contained in the engine's delegate callback and is Apple's documented pattern. The extra round-trip per new trip is negligible. Noted as a possible future optimization in the bugfix report.
Rather than parsing event.failedZoneSaves, the recovery bounds retries with a per-zone counter that resets on the observed savedZones confirmation. This stops the self-heal loop regardless of why a zone-save fails, and makes the counter track consecutive failures so a healthy zone (1 attempt then success) never trips the limit.
Both scopes' state files are wiped together on reinstall, so the private scope is a reliable reinstall signal. Keying on the shared scope risks fetching on every launch if CKSyncEngine does not persist state for an empty shared database. The launch fetch itself still fetches both databases.
The reuse review flagged that recovery hand-rolls the same saveZone + saveRecords + markSelfOriginated triple as TripSyncEngineZoneMigrationDriver. Left as-is: the driver is coordinator-owned and hardcoded to privateEngine, while recovery is scope-parametric; sharing it would invert ownership for a ~4-line saving.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | TripSyncEngine — self-heal loop | handleSentDatabaseChanges observed only savedZones, never failed zone-saves. A persistent .saveZone failure would leave records re-failing .zoneNotFound → recovery re-queues zone+records → loops forever, emitting no .recordsFailed and no journal signal. | Added a per-zone attempt bound (maxZoneRecoveryAttempts = 3) in planZoneRecovery, reset when the zone actually saves; exhausted zones' records surface via .recordsFailed. |
| major | TripSyncEngine — recovery scope | Recovery used engine(for: scope) with the failure's own scope, so a .zoneNotFound on the shared DB (e.g. the owner removed the zone) would enqueue a .saveZone into a database a participant cannot write — semantically wrong and another infinite loop. | planZoneRecovery gates zone creation to the .private scope; shared-DB .zoneNotFound is terminal and emits .recordsFailed. |
| major | ZoneMigrationCoordinator — journal signal | Suppressing .recordsFailed for all .zoneNotFound could strand a Stage-B migrated trip's journal entry in .stageBInProgress indefinitely (no .failed, so no banner and retry(tripID:) can't recover it). | Resolved by the attempt bound: exhausted recovery emits .recordsFailed, re-arming the journal's failed/retry path. Transient races still self-heal (the desired improvement). |
| major | Test coverage | The original 4 tests covered only the pure zoneNotFound partition and needsInitialFetch's true cases; the scope gate, the attempt bound, and the enqueue/emit filtering — where the two MAJOR bugs lived — were untested. | Extracted planZoneRecovery as a pure helper and added 4 tests: private-fresh, exhausted, shared-terminal, and mixed-errors. |
| minor | TripSyncEngine — needsInitialFetch scope | needsInitialFetch keys on .private only while fetchChangesOnLaunch fetches both DBs; a shared-only missing/corrupt state wouldn't trigger a deterministic launch fetch (shared relies on automaticallySync). | Intentional and documented: both state files are wiped together on reinstall, and keying on shared risks perpetual re-fetch. Left as-is. |
| minor | Reuse — driver duplication | The recovery enqueue duplicates TripSyncEngineZoneMigrationDriver's saveZone + saveRecords + markSelfOriginated triple. | Left as-is: the driver is coordinator-owned and hardcoded to privateEngine while recovery is scope-parametric; extraction would invert ownership for little gain. |
| minor | ScrambleApp — comment accuracy | The launch comment said 'Detached' but the code uses an unstructured, context-inheriting Task, not Task.detached. | Reworded the comment to 'Fire-and-forget (unstructured Task, not awaited)'. |
Click to expand.
diff --git a/Scramble/Scramble/Sharing/TripSyncEngine.swift b/Scramble/Scramble/Sharing/TripSyncEngine.swiftindex 96dc074..4912d5b 100644--- a/Scramble/Scramble/Sharing/TripSyncEngine.swift+++ b/Scramble/Scramble/Sharing/TripSyncEngine.swift@@ -43,6 +43,15 @@ final class TripSyncEngine: NSObject, PendingChangeNotifier { /// `enqueueShareSave(_:)` until `handleSentChanges` confirms upload. private var pendingShares: [CKRecord.ID: CKShare] = [:] + /// Consecutive zone-not-found recovery attempts per zone, to bound the+ /// create-zone-and-retry self-heal loop. A healthy new trip needs exactly+ /// one attempt; the count resets when the zone actually saves+ /// (`handleSentDatabaseChanges`). A zone whose save keeps failing stops+ /// after `maxZoneRecoveryAttempts` and surfaces `.recordsFailed` rather+ /// than retrying forever with no signal (T-1670).+ private var zoneRecoveryAttempts: [CKRecordZone.ID: Int] = [:]+ private static let maxZoneRecoveryAttempts = 3+ let events: AsyncStream<TripSyncEvent> private let eventContinuation: AsyncStream<TripSyncEvent>.Continuation @@ -85,15 +94,50 @@ final class TripSyncEngine: NSObject, PendingChangeNotifier { ) configuration.automaticallySync = true let engine = CKSyncEngine(configuration)- if stateBlob == nil {- // Either there was no prior state, or it was corrupt and discarded.- // Either way, queue a full reconciliation so we don't miss changes- // the engine would otherwise have learned about via stored tokens.- engine.state.add(pendingDatabaseChanges: [])- }+ // No prior (or corrupt-and-discarded) state means fresh install /+ // reinstall. `MigrationGate`'s launch path calls+ // `fetchChangesOnLaunch()` when `needsInitialFetch()` is true so the+ // trip zones are pulled back down deterministically. The previous+ // `add(pendingDatabaseChanges: [])` here was an empty array — a no-op+ // that triggered no reconciliation (T-1670). return engine } + /// True when the private database has no usable persisted CKSyncEngine+ /// state — the fresh-install / reinstall case. A fresh engine otherwise+ /// relies solely on `automaticallySync` to discover the existing trip+ /// zones; an explicit launch fetch makes restore deterministic. Keyed on+ /// the private scope because owned trips live there and both scopes'+ /// state files are wiped together on reinstall. Reads through+ /// `loadStateBlob` so a corrupt (then cleared) blob also counts as fresh.+ func needsInitialFetch() -> Bool {+ loadStateBlob(for: .private) == nil+ }++ /// Force an initial fetch on cold launch so a reinstalled device (empty+ /// local store, no persisted engine state) pulls existing trip zones back+ /// down from CloudKit rather than waiting on an unprompted automatic+ /// sync. Fetches both databases so accepted shares restore alongside+ /// owned trips. Errors are logged, not thrown — a failed launch fetch+ /// must not block app startup; `automaticallySync` remains as a backstop.+ func fetchChangesOnLaunch() async {+ await fetchChanges(privateEngine, scope: .private)+ await fetchChanges(sharedEngine, scope: .shared)+ }++ private func fetchChanges(_ engine: CKSyncEngine?, scope: CKDatabase.Scope) async {+ guard let engine else { return }+ do {+ try await engine.fetchChanges()+ } catch {+ let scopeLabel = String(describing: scope)+ let message = error.localizedDescription+ modelLogger.error(+ "[TripSyncEngine] launch fetch failed for \(scopeLabel, privacy: .public): \(message, privacy: .public)"+ )+ }+ }+ /// Best-effort load of the persisted state blob, with corruption /// recovery: a non-decodable blob is logged, cleared from the store, /// and `nil` is returned so the engine starts fresh.@@ -535,10 +579,62 @@ extension TripSyncEngine: CKSyncEngineDelegate { if !savedIDs.isEmpty { emit(.recordsSaved(savedIDs)) }- let failedIDs = event.failedRecordSaves.map { $0.record.recordID }- if !failedIDs.isEmpty {- let message = event.failedRecordSaves.first?.error.localizedDescription ?? "unknown error"- emit(.recordsFailed(failedIDs, error: message))++ // Zone-not-found recovery + genuine-failure reporting (T-1670).+ recoverFailedSaves(event, scope: scope)+ }++ /// Zone-not-found recovery for a sent-changes batch: create the missing+ /// private-DB zone(s), re-queue their records, and surface everything+ /// else as `.recordsFailed`. A freshly-created trip's records are queued+ /// for upload before its custom zone exists server-side (CKSyncEngine+ /// does not auto-create zones), so the first send fails with+ /// `.zoneNotFound`; recovering here is the only path that ever creates+ /// that zone. `planZoneRecovery` owns the scope gate and the per-zone+ /// attempt bound; this method applies the resulting plan.+ private func recoverFailedSaves(+ _ event: CKSyncEngine.Event.SentRecordZoneChanges,+ scope: CKDatabase.Scope+ ) {+ guard !event.failedRecordSaves.isEmpty else { return }+ let failures: [(recordID: CKRecord.ID, code: CKError.Code)] =+ event.failedRecordSaves.map { failure in+ let code = (failure.error as? CKError)?.code ?? .internalError+ return (recordID: failure.record.recordID, code: code)+ }+ let plan = Self.planZoneRecovery(+ failures: failures,+ scope: scope,+ attempts: zoneRecoveryAttempts,+ maxAttempts: Self.maxZoneRecoveryAttempts+ )+ for zone in plan.attemptedZones {+ zoneRecoveryAttempts[zone, default: 0] += 1+ }+ if !plan.zonesToCreate.isEmpty {+ let recoveryEngine = engine(for: scope)+ recoveryEngine?.state.add(+ pendingDatabaseChanges: plan.zonesToCreate.map {+ .saveZone(CKRecordZone(zoneID: $0))+ }+ )+ recoveryEngine?.state.add(+ pendingRecordZoneChanges: plan.recordsToRetry.map { .saveRecord($0) }+ )+ markSelfOriginated(plan.recordsToRetry)+ let zoneCount = plan.zonesToCreate.count+ let recordCount = plan.recordsToRetry.count+ modelLogger.info(+ "[TripSyncEngine] zoneNotFound recovery: \(zoneCount, privacy: .public) zone(s), \(recordCount, privacy: .public) record(s)"+ )+ }+ if !plan.failedRecordIDs.isEmpty {+ let failedIDs = Set(plan.failedRecordIDs)+ let message =+ event.failedRecordSaves+ .first { failedIDs.contains($0.record.recordID) }?+ .error.localizedDescription ?? "unknown error"+ emit(.recordsFailed(plan.failedRecordIDs, error: message)) } } @@ -548,8 +644,11 @@ extension TripSyncEngine: CKSyncEngineDelegate { // Phase 5.1 — emit one `.zoneSaved` per successfully saved zone so // the migration coordinator can advance the journal entry's // `zoneSaved` flag. `savedZones` is the CKSyncEngine confirmation- // that the zone now exists server-side.+ // that the zone now exists server-side — which also clears any+ // zone-not-found recovery counter so the bound tracks *consecutive*+ // failures, not lifetime ones (T-1670). for zone in event.savedZones {+ zoneRecoveryAttempts[zone.zoneID] = nil emit(.zoneSaved(zone.zoneID)) } }
diff --git a/Scramble/Scramble/Sharing/TripSyncEngine+ZoneRecovery.swift b/Scramble/Scramble/Sharing/TripSyncEngine+ZoneRecovery.swiftnew file mode 100644index 0000000..b85679a--- /dev/null+++ b/Scramble/Scramble/Sharing/TripSyncEngine+ZoneRecovery.swift@@ -0,0 +1,91 @@+import CloudKit+import Foundation++/// Pure zone-not-found recovery decisions for `TripSyncEngine`. Kept in a+/// separate file, free of engine/instance state, so the logic that decides+/// *what* to recover is unit-tested without a live `CKSyncEngine` (T-1670).+/// The side-effecting application (enqueue + emit) lives on `TripSyncEngine`+/// itself (`recoverFailedSaves`).+extension TripSyncEngine {+ /// Partition failed record saves. `.zoneNotFound` failures are+ /// recoverable — CKSyncEngine does **not** auto-create record zones, so+ /// a freshly-created trip's records are rejected until the zone exists;+ /// the fix is to create the zone and re-queue the records (Apple's+ /// sample handles this in the sent-changes callback). Every other error+ /// is a genuine failure that should surface via `.recordsFailed`.+ static func classifyFailedSaves(+ _ failures: [(recordID: CKRecord.ID, code: CKError.Code)]+ ) -> FailedSaveClassification {+ var result = FailedSaveClassification()+ for failure in failures {+ if failure.code == .zoneNotFound {+ result.zonesToCreate.insert(failure.recordID.zoneID)+ result.recordsToRetry.append(failure.recordID)+ } else {+ result.unrecoverable.append(failure.recordID)+ }+ }+ return result+ }++ /// Result of `classifyFailedSaves`: the zones to (re)create, the records+ /// to re-queue once they exist, and the genuinely-failed records.+ struct FailedSaveClassification: Equatable {+ var zonesToCreate: Set<CKRecordZone.ID> = []+ var recordsToRetry: [CKRecord.ID] = []+ var unrecoverable: [CKRecord.ID] = []+ }++ /// Decide zone-not-found recovery for a batch of failed record saves,+ /// applying the two safety gates the raw classification can't express+ /// (both live here, not in `handleSentChanges`, so they're unit-tested):+ ///+ /// - **Scope.** Only the private database recovers by creating a zone —+ /// a participant cannot create a shared-DB zone it doesn't own, so a+ /// `.zoneNotFound` there (e.g. the owner removed the zone) is terminal.+ /// - **Bounded attempts.** Recovery is optimistic: it re-queues records+ /// without observing whether the `.saveZone` it depends on succeeded.+ /// A zone that has already failed `maxAttempts` times stops retrying+ /// and its records surface as failures, so a persistent zone-save+ /// error can't loop forever (T-1670 Findings 1–3).+ ///+ /// Pure: `attemptedZones` are the zones whose per-zone counter the caller+ /// should increment. `zonesToCreate` are sorted for deterministic output.+ static func planZoneRecovery(+ failures: [(recordID: CKRecord.ID, code: CKError.Code)],+ scope: CKDatabase.Scope,+ attempts: [CKRecordZone.ID: Int],+ maxAttempts: Int+ ) -> ZoneRecoveryPlan {+ let classified = classifyFailedSaves(failures)+ var plan = ZoneRecoveryPlan()+ // Non-zoneNotFound errors are always genuine failures.+ plan.failedRecordIDs = classified.unrecoverable++ guard scope == .private else {+ plan.failedRecordIDs.append(contentsOf: classified.recordsToRetry)+ return plan+ }++ for zone in classified.zonesToCreate.sorted(by: { $0.zoneName < $1.zoneName }) {+ let zoneRecords = classified.recordsToRetry.filter { $0.zoneID == zone }+ if attempts[zone, default: 0] < maxAttempts {+ plan.attemptedZones.append(zone)+ plan.zonesToCreate.append(zone)+ plan.recordsToRetry.append(contentsOf: zoneRecords)+ } else {+ plan.failedRecordIDs.append(contentsOf: zoneRecords)+ }+ }+ return plan+ }++ /// Result of `planZoneRecovery`. `attemptedZones` ⊆ `zonesToCreate` and+ /// names the zones whose recovery-attempt counter should be bumped.+ struct ZoneRecoveryPlan: Equatable {+ var zonesToCreate: [CKRecordZone.ID] = []+ var recordsToRetry: [CKRecord.ID] = []+ var failedRecordIDs: [CKRecord.ID] = []+ var attemptedZones: [CKRecordZone.ID] = []+ }+}
diff --git a/Scramble/Scramble/ScrambleApp.swift b/Scramble/Scramble/ScrambleApp.swiftindex 874b94e..4a9b0fa 100644--- a/Scramble/Scramble/ScrambleApp.swift+++ b/Scramble/Scramble/ScrambleApp.swift@@ -247,7 +247,17 @@ struct ScrambleApp: App { "[MigrationGate] journal back-stop count failed: \(error.localizedDescription, privacy: .public)" ) }+ // Capture fresh-state before `start()` so a reinstalled device (empty+ // local trips store, no persisted engine state) explicitly pulls its+ // trip zones back down rather than waiting on an unprompted automatic+ // sync (T-1670). Fire-and-forget (unstructured `Task`, not awaited) so+ // the migration gate releases promptly; restored rows land reactively+ // as the fetch applies them.+ let needsInitialFetch = syncEngine.needsInitialFetch() syncEngine.start()+ if needsInitialFetch {+ Task { @MainActor in await syncEngine.fetchChangesOnLaunch() }+ } // Phase 6 — seed `authStatus` so the "Open Settings" affordance can // render `.denied` on cold launch (before any scene-phase transition // fires). The `UNUserNotificationCenter.delegate` is installed at
diff --git a/Scramble/ScrambleTests/Sharing/TripSyncEngineTests.swift b/Scramble/ScrambleTests/Sharing/TripSyncEngineTests.swiftindex 4114286..2a959f8 100644--- a/Scramble/ScrambleTests/Sharing/TripSyncEngineTests.swift+++ b/Scramble/ScrambleTests/Sharing/TripSyncEngineTests.swift@@ -150,6 +150,121 @@ struct TripSyncEngineTests { #expect(engine.wasSelfOriginated(recordID)) } + // MARK: - Zone-not-found recovery (T-1670)++ @Test("classifyFailedSaves routes zoneNotFound to zone-create + retry, other errors to unrecoverable")+ func classifyFailedSavesPartitions() {+ let zoneA = CKRecordZone.ID(zoneName: "trip-a", ownerName: CKCurrentUserDefaultName)+ let zoneB = CKRecordZone.ID(zoneName: "trip-b", ownerName: CKCurrentUserDefaultName)+ let missing1 = CKRecord.ID(recordName: "r1", zoneID: zoneA)+ let missing2 = CKRecord.ID(recordName: "r2", zoneID: zoneA)+ let missing3 = CKRecord.ID(recordName: "r3", zoneID: zoneB)+ let conflict = CKRecord.ID(recordName: "r4", zoneID: zoneB)++ let result = TripSyncEngine.classifyFailedSaves([+ (missing1, .zoneNotFound),+ (missing2, .zoneNotFound),+ (missing3, .zoneNotFound),+ (conflict, .serverRecordChanged),+ ])++ #expect(result.zonesToCreate == [zoneA, zoneB])+ #expect(Set(result.recordsToRetry) == [missing1, missing2, missing3])+ #expect(result.unrecoverable == [conflict])+ }++ @Test("classifyFailedSaves with no zoneNotFound leaves everything unrecoverable")+ func classifyFailedSavesNoRecovery() {+ let zone = CKRecordZone.ID(zoneName: "trip-a", ownerName: CKCurrentUserDefaultName)+ let record = CKRecord.ID(recordName: "r1", zoneID: zone)+ let result = TripSyncEngine.classifyFailedSaves([(record, .networkUnavailable)])+ #expect(result.zonesToCreate.isEmpty)+ #expect(result.recordsToRetry.isEmpty)+ #expect(result.unrecoverable == [record])+ }++ @Test("planZoneRecovery on the private DB creates the zone and re-queues its records")+ func planZoneRecoveryPrivateFresh() {+ let zone = CKRecordZone.ID(zoneName: "trip-a", ownerName: CKCurrentUserDefaultName)+ let r1 = CKRecord.ID(recordName: "r1", zoneID: zone)+ let r2 = CKRecord.ID(recordName: "r2", zoneID: zone)+ let plan = TripSyncEngine.planZoneRecovery(+ failures: [(r1, .zoneNotFound), (r2, .zoneNotFound)],+ scope: .private, attempts: [:], maxAttempts: 3+ )+ #expect(plan.zonesToCreate == [zone])+ #expect(Set(plan.recordsToRetry) == [r1, r2])+ #expect(plan.attemptedZones == [zone])+ #expect(plan.failedRecordIDs.isEmpty)+ }++ @Test("planZoneRecovery stops recovering a zone that has hit the attempt limit")+ func planZoneRecoveryExhausted() {+ let zone = CKRecordZone.ID(zoneName: "trip-a", ownerName: CKCurrentUserDefaultName)+ let r1 = CKRecord.ID(recordName: "r1", zoneID: zone)+ let plan = TripSyncEngine.planZoneRecovery(+ failures: [(r1, .zoneNotFound)],+ scope: .private, attempts: [zone: 3], maxAttempts: 3+ )+ #expect(plan.zonesToCreate.isEmpty)+ #expect(plan.recordsToRetry.isEmpty)+ #expect(plan.attemptedZones.isEmpty)+ #expect(plan.failedRecordIDs == [r1])+ }++ @Test("planZoneRecovery never creates a zone on the shared DB — zoneNotFound is terminal there")+ func planZoneRecoverySharedTerminal() {+ let zone = CKRecordZone.ID(zoneName: "trip-a", ownerName: "remoteOwner")+ let r1 = CKRecord.ID(recordName: "r1", zoneID: zone)+ let plan = TripSyncEngine.planZoneRecovery(+ failures: [(r1, .zoneNotFound)],+ scope: .shared, attempts: [:], maxAttempts: 3+ )+ #expect(plan.zonesToCreate.isEmpty)+ #expect(plan.attemptedZones.isEmpty)+ #expect(plan.failedRecordIDs == [r1])+ }++ @Test("planZoneRecovery recovers zoneNotFound and fails other errors in the same batch")+ func planZoneRecoveryMixed() {+ let zone = CKRecordZone.ID(zoneName: "trip-a", ownerName: CKCurrentUserDefaultName)+ let missing = CKRecord.ID(recordName: "r1", zoneID: zone)+ let conflict = CKRecord.ID(recordName: "r2", zoneID: zone)+ let plan = TripSyncEngine.planZoneRecovery(+ failures: [(missing, .zoneNotFound), (conflict, .serverRecordChanged)],+ scope: .private, attempts: [:], maxAttempts: 3+ )+ #expect(plan.zonesToCreate == [zone])+ #expect(plan.recordsToRetry == [missing])+ #expect(plan.failedRecordIDs == [conflict])+ }++ // MARK: - Initial fetch decision (T-1670)++ @Test("needsInitialFetch is true when the private scope has no persisted state")+ func needsInitialFetchFreshStore() throws {+ let container = try Self.makeContainer()+ let engine = TripSyncEngine(+ context: container.mainContext,+ container: CKContainer(identifier: "iCloud.test"),+ stateStore: InMemoryTripSyncStateStore()+ )+ #expect(engine.needsInitialFetch())+ }++ @Test("needsInitialFetch is true when the private scope's state is corrupt")+ func needsInitialFetchCorruptStore() throws {+ let container = try Self.makeContainer()+ let store = InMemoryTripSyncStateStore()+ store.returnCorruptDataForScopes = [.private]+ let engine = TripSyncEngine(+ context: container.mainContext,+ container: CKContainer(identifier: "iCloud.test"),+ stateStore: store+ )+ #expect(engine.needsInitialFetch())+ }+ // MARK: - Helpers private static func makeContainer() throws -> ModelContainer {
diff --git a/specs/bugfixes/trips-lost-on-reinstall/report.md b/specs/bugfixes/trips-lost-on-reinstall/report.mdnew file mode 100644index 0000000..434c465--- /dev/null+++ b/specs/bugfixes/trips-lost-on-reinstall/report.md@@ -0,0 +1,118 @@+# Bugfix Report: Trips lost on reinstall (T-1670)++**Date:** 2026-07-05+**Status:** Fixed (pending on-device CloudKit verification)++## Description of the Issue++Installing a new build over an existing install (or deleting + reinstalling) leaves+the trip list empty. The master lists (tasks + per-person packing items) survive,+but every trip is gone. Reproducible on every debug reinstall; release builds are+expected to behave the same.++**Reproduction steps:**+1. Create one or more trips (do not share them).+2. Delete the app (or install a fresh build that resets the app container).+3. Launch. Observe: master lists are restored from iCloud, but no trips appear.++**Impact:** High. Trips are the app's primary object. Any user who reinstalls, or+moves to a new device, silently loses all unshared trips — the data was never in+iCloud to begin with, so it is unrecoverable.++## Investigation Summary++- **Two persistence paths.** Master lists live in the `globals` SwiftData container+ (`cloudKitDatabase: .private`) — SwiftData's native `NSPersistentCloudKitContainer`+ mirror, which auto-restores on reinstall. Trips live in the `tripsLocal` container+ (`cloudKitDatabase: .none`, local-only); their CloudKit sync is hand-rolled via+ `TripSyncEngine` / `CKSyncEngine` into per-trip custom zones (`trip-{uuid}`). A+ reinstall wipes the local trips store, so trips only return if the bespoke pipeline+ both uploaded them and re-fetches them on first launch.+- **Zone-creation sites.** The only `.saveZone` call sites are `ZoneMigrationCoordinator`+ (Stage B migration) and `CloudKitSharingService.createShare` (sharing). There is no+ zone creation for an ordinary, unshared, newly-created trip.+- **CKSyncEngine does not auto-create zones** (confirmed against Apple's docs and+ `apple/sample-cloudkit-sync-engine`): saving a record into a missing zone fails with+ `.zoneNotFound`, which must be handled by creating the zone and retrying.++## Discovered Root Cause++**Primary (upload):** a newly-created unshared trip's CloudKit zone is never created,+so its records never reach iCloud.++- `TripPersistence.create` inserts a local `TripZoneState` but queues no `.saveZone`.+- `LocalWriteHook` only queues `.saveRecord` changes — never a zone save.+- The engine sends the records into a zone that does not exist → CloudKit returns+ `.zoneNotFound` per record. `TripSyncEngine.handleSentChanges` had no recovery; it+ just emitted `.recordsFailed`.+- Because `create` already inserted a `TripZoneState`, `ZoneMigrationCoordinator.enqueueAll`+ permanently skips the trip (`!migratedTripIDs.contains(tripID)` is false), so Stage B+ never creates the zone either.+- Net: the trip's records never upload. On reinstall there is nothing to restore.+ Only shared trips (createShare does `saveZone`) or pre-Phase-5.1 migrated trips+ survive.++**Secondary (download):** even for trips that *are* in iCloud, restore was not+deterministic. `TripSyncEngine.makeEngine` called `engine.state.add(pendingDatabaseChanges: [])`+on fresh/corrupt state — an empty array, i.e. a no-op — despite the comment claiming it+forced a reconciliation. A reinstalled device relied solely on `automaticallySync` to+discover its zones, with no explicit launch fetch.++**Defect type:** Missing zone-creation step + a no-op that never delivered its intended+reconciliation.++## Fix++`Scramble/Sharing/TripSyncEngine.swift`+- **Zone-not-found recovery** in `handleSentChanges`: `.zoneNotFound` record failures now+ create the missing zone (`.saveZone`) and re-queue the records (`.saveRecord`), marking+ them self-originated. This is Apple's documented pattern. Two safety gates (added during+ pre-push review) keep it correct:+ - **Scope:** recovery only creates a zone on the **private** DB. A `.zoneNotFound` on the+ shared DB (a participant cannot create a zone it doesn't own — e.g. the owner removed+ it) is terminal and surfaces via `.recordsFailed`.+ - **Bounded:** a per-zone attempt counter (`maxZoneRecoveryAttempts = 3`) stops the+ optimistic re-queue after repeated failures so a persistent zone-save error can't+ self-heal forever; the counter resets when the zone actually saves+ (`handleSentDatabaseChanges`). Exhausted zones' records emit `.recordsFailed`, which+ re-arms the Stage-B migration journal's `.failed` / `retry(tripID:)` path.+- Extracted two pure helpers so the recovery decision (including the scope gate and the+ bound — the actual bug surface) is unit-tested without a live engine:+ `classifyFailedSaves(_:)` → `FailedSaveClassification` (zoneNotFound partition) and+ `planZoneRecovery(failures:scope:attempts:maxAttempts:)` → `ZoneRecoveryPlan`.+- `needsInitialFetch()` + `fetchChangesOnLaunch()`: replaced the no-op `add(pendingDatabaseChanges: [])`+ with an explicit cold-launch fetch of both databases, keyed on the private scope having+ no persisted state (fresh install / reinstall / corrupt-and-cleared blob).++`Scramble/ScrambleApp.swift`+- `prepareLaunch` captures `needsInitialFetch()` before `syncEngine.start()` and, when+ true, kicks `fetchChangesOnLaunch()` in a detached task so the migration gate still+ releases promptly and restored rows land reactively.++## Tests++`ScrambleTests/Sharing/TripSyncEngineTests.swift` (Swift Testing):+- `classifyFailedSavesPartitions` / `classifyFailedSavesNoRecovery` — zoneNotFound partition.+- `planZoneRecoveryPrivateFresh` — private DB creates the zone + re-queues its records.+- `planZoneRecoveryExhausted` — a zone at the attempt limit stops recovering; records fail.+- `planZoneRecoverySharedTerminal` — shared-DB zoneNotFound never creates a zone.+- `planZoneRecoveryMixed` — recovers zoneNotFound and fails other errors in one batch.+- `needsInitialFetchFreshStore` / `needsInitialFetchCorruptStore` — fresh and corrupt+ private-scope state both request the launch fetch.++`make test-quick` passes; `make lint` clean.++## Verification still needed (cannot run against real CloudKit here)++The unit tests cover the pure decision logic. The end-to-end path — new trip → first send+`zoneNotFound` → zone created → records uploaded → reinstall → `fetchChangesOnLaunch`+restores the trip — must be confirmed on a real device with an iCloud account (the+simulator and tests use `cloudKitDatabase: .none`). Suggested manual check: create a trip,+confirm the `trip-{uuid}` zone and records appear in the CloudKit dashboard (private DB),+delete + reinstall, confirm the trip returns.++## Follow-up worth considering (not in this fix)++The reactive recovery costs one failed send round-trip per new trip. A proactive+`.saveZone` at trip creation would avoid it, but requires threading the engine into the+create path; deferred to keep this fix contained and low-risk.
Unit tests run against cloudKitDatabase: .none. On a real device with an iCloud account: create a trip, confirm the trip-{uuid} zone and its records appear in the private CloudKit DB (CloudKit dashboard), then delete + reinstall and confirm the trip returns.
Re-marking the retried record IDs is idempotent — a failed send never consumes the echo entry (only fetched echoes call wasSelfOriginated), and re-inserting into a Set is a no-op. Verified harmless.
Reviewed and kept: the extra loadStateBlob read is a sub-millisecond file read + JSON decode of a few tokens, dwarfed by container init on the same launch path, and its corrupt-state clear is self-idempotent.