scramble branch worktree-t-1670-trip-reinstall-loss commits 3 files 5 touched lines +443 / -11 tests +8 unit

Pre-push review: T-1670 — trips lost on reinstall

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.

At a glance

  • Root cause: a newly-created unshared trip's CloudKit custom zone was never created, so its records failed to upload with .zoneNotFound and never reached iCloud — nothing to restore on reinstall. Master lists survived because they ride SwiftData's native CloudKit mirror.
  • Upload fix: recover from .zoneNotFound in the sent-changes callback — create the zone and re-queue the records (Apple's documented pattern).
  • Download fix: replace a no-op add(pendingDatabaseChanges: []) with an explicit cold-launch fetchChanges() so a reinstalled device deterministically pulls its zones back.
  • Hardening (from review): recovery is now gated to the private DB and bounded per zone (max 3, reset on success) so it can't loop forever or misfire on shared zones.
  • Testability: the decision logic is pure (classifyFailedSaves, planZoneRecovery) and covered by 8 unit tests.

Verdict

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.

Review findings

7 raised · 5 fixed · 2 skipped

Jump to findings →

Commits

Three-level explanation

What changed

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.

Why it matters

Every delete-and-reinstall wiped all your unshared trips while the lists survived. That is permanent loss of the app's most important object.

The fix

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.

Architecture

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}).

Root cause

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.

Patterns

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.

Trade-offs

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.

Deep dive

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.

Interaction with the migration journal

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.

Edge cases

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.

Important changes — detailed

TripSyncEngine: zone-not-found recovery is the only path that creates a new trip's zone

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

Takeaway. CKSyncEngine does not auto-create zones; the canonical pattern is to catch .zoneNotFound in the sent-changes callback, enqueue .saveZone, and re-queue the records — not to pre-create zones speculatively.
Rationale. Reactive recovery keeps the change contained (no engine wiring threaded into the trip-create UI path); the cost is one failed round-trip per new trip.

planZoneRecovery: scope gate + per-zone attempt bound

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:)

Takeaway. Push safety gates (scope, retry bounds) into a pure function that returns a plan; keep the impure enqueue/emit in the caller. The gate is then unit-testable, which is exactly where the two MAJOR bugs lived.
Rationale. Chosen over reading event.failedZoneSaves directly: bounding attempts stops the loop regardless of the zone-save error cause, and resetting on savedZones makes the counter track consecutive (not lifetime) failures.

Deterministic cold-launch fetch replaces a no-op

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()

Takeaway. An empty pendingDatabaseChanges add is a silent no-op — if you want a fetch on fresh state, call fetchChanges() explicitly. Gate it on the private scope having no persisted state to avoid fetching on every launch.
Rationale. Keyed on the private scope because both state files are wiped together on reinstall and keying on shared risks perpetual re-fetch if CKSyncEngine never persists empty shared-DB state.

prepareLaunch kicks the launch fetch off the critical path

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

Takeaway. Capture the fresh-state decision before start() (which reads/clears the same state), then fire the network fetch fire-and-forget so restored rows land reactively via the delegate's context.save().
Rationale. syncEngine is a stable let and start() runs synchronously first, so the fire-and-forget Task is safe; errors are logged inside fetchChangesOnLaunch, not thrown.

Key decisions

Reactive .zoneNotFound recovery over proactive .saveZone at trip creation.

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.

Bound recovery by attempt count (3), reset on zone save.

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.

needsInitialFetch keyed on the private scope only.

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.

Did not de-duplicate the recovery enqueue against ZoneMigrationCoordinator's driver.

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.

Review findings

SeverityAreaFindingResolution
majorTripSyncEngine — self-heal loophandleSentDatabaseChanges 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.
majorTripSyncEngine — recovery scopeRecovery 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.
majorZoneMigrationCoordinator — journal signalSuppressing .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).
majorTest coverageThe 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.
minorTripSyncEngine — needsInitialFetch scopeneedsInitialFetch 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.
minorReuse — driver duplicationThe 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.
minorScrambleApp — comment accuracyThe 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)'.

Per-file diffs

Click to expand.

Scramble/Scramble/Sharing/TripSyncEngine.swift Modified +121 / -8
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))     }   }
Scramble/Scramble/Sharing/TripSyncEngine+ZoneRecovery.swift Added +91 / -0
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] = []+  }+}
Scramble/Scramble/ScrambleApp.swift Modified +10 / -1
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
Scramble/ScrambleTests/Sharing/TripSyncEngineTests.swift Modified +115 / -0
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 {
specs/bugfixes/trips-lost-on-reinstall/report.md Added +118 / -0
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.

Things to double-check

On-device CloudKit end-to-end (the one thing tests can't cover).

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.

markSelfOriginated on re-queued records.

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.

Efficiency: needsInitialFetch double-decodes state.

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.