scramble branch fix-cloudkit-inverse-and-checksum commits 1 files 21 touched lines +225 / -222

Pre-push review: on-device launch fix (CloudKit inverse + schema checksum)

One commit fixing two device-only launch failures that the simulator/test suite cannot reproduce: a SwiftData migration-plan checksum collision and a CloudKit relationship-inverse rejection.

At a glance

  • Crash fix: removed SchemaV4 (byte-identical to SchemaV3Trip.countryCode sits on a shared top-level class, so checksums collided and the V3→V4 stage aborted with Duplicate version checksums across stages detected). countryCode now rides on V3 via lightweight inference.
  • CloudKit fix: TripPackingItem.personSnapshot changed from an inverse-less @Relationship to a stored personSnapshotID: UUID? value reference plus a computed bridge. The globals CloudKit mirror was rejecting the inverse-less relationship (error 134060) and silently falling back to local-only, killing iCloud sync for Person + master lists.
  • Both failures only manifest on a real device with iCloud; the test suite uses cloudKitDatabase: .none and fresh in-memory stores, so neither was caught pre-merge.
  • Wire format (personSnapshotID: String) and all read sites are unchanged; the computed bridge keeps callers source-compatible.
  • Decision logs (Phase 5 #14, Phase 6 #18) and persistence.md capture why a property-only change to a shared top-level class can't be a distinct schema version.

Verdict

Ready to push

Four parallel review agents (reuse, quality, efficiency, spec/docs) found no blockers and no majors. The change is structurally sound: the duplicate-checksum crash is impossible once SchemaV3 is the sole current version, and the inverse-less relationship is gone. Efficiency is neutral-to-positive (the bridge's fetch fallback provably never fires on render paths). Three minor documentation refinements surfaced by review were applied. The only residual risk — actual on-device launch — is structurally untestable in the suite and is documented, not coverable.

Review findings

6 raised · 3 fixed · 3 skipped

Jump to findings →

Commits

Three-level explanation

What changed

The app was crashing on a real iPhone before its window even appeared. Two unrelated problems were stacked on top of each other.

Problem 1 — a duplicate fingerprint. The app stores data with Apple's SwiftData framework, which versions its data layout as SchemaV1, V2, V3, V4. To upgrade an old database it walks the versions in order. SwiftData identifies each version by a fingerprint computed from its shape. SchemaV4 only added one field (a trip's destination country) — but that field lives on a class (Trip) that every version shares, so V3 and V4 ended up with the same fingerprint. When the phone's existing database tried to upgrade through that step, SwiftData saw two identical fingerprints and threw up its hands: "Duplicate version checksums." The fix deletes SchemaV4 and keeps the country field on SchemaV3 — the database just adds the new column automatically.

Problem 2 — a one-way link iCloud won't accept. A packing item pointed at a "person snapshot" through a database relationship. iCloud syncing requires every relationship to point both ways. This one only pointed one way (making it two-way re-triggers a separate known crash). So on a real device, iCloud refused to load that part of the database and quietly turned off cloud sync for people and the master lists. The fix stops using a relationship and instead stores the snapshot's ID number — a plain value that iCloud is happy with — while a small helper still lets the rest of the code read the snapshot the same way as before.

Why it matters

Without this, the app can't launch on a device and family iCloud sharing is broken. Neither bug showed up in testing because tests run on the simulator with cloud sync switched off.

Architecture

Two SwiftData/CloudKit constraints, both invisible to the test harness:

1. Migration-plan checksum collision

AppMigrationPlan declared [V1, V2, V3, V4]. The trip-owned entities (Trip, TripTask, TripPackingItem) are single top-level @Model classes shared across every version (a pattern adopted to dodge an iOS 26.4 cascade-traversal panic that fires when two same-named @Models coexist). Because SwiftData derives a version's checksum from structure only — not from versionIdentifier — adding Trip.countryCode changed V3's and V4's view of Trip identically, making them byte-identical. A fresh store short-circuits stage traversal (so in-memory tests passed), but an existing on-disk store forces the V3→V4 walk and trips the duplicate-checksum assertion. Fix: drop SchemaV4; countryCode is part of V3 and added via automatic lightweight inference (Optional/nil), exactly like ckRecordSystemFields before it.

2. CloudKit inverse requirement

The globals container mirrors its full model list to CloudKit via cloudKitDatabase: .private, and SwiftData's CloudKit mirror rejects any relationship lacking an inverse at store load (NSCocoaErrorDomain 134060). TripPackingItem.personSnapshot had no inverse (adding the inverse collection re-creates the cascade panic). On-device this dropped globals to the local-only fallback, disabling iCloud sync for Person/master lists. Fix: store personSnapshotID: UUID? (a value reference, matching TripTask.assigneePersonID) and expose a computed personSnapshot bridge.

Patterns & trade-offs

  • The bridge lives in extension TripPackingItem so the @Model macro never materialises it as a relationship (only stored properties in the primary declaration are persisted).
  • Getter resolves in-memory via trip.participantSnapshots first, falling back to a modelContext fetch only when the trip-side array isn't wired (CKRecord decode path). Writers set the scalar; predicates use the scalar (computed properties can't appear in #Predicate).
  • Decode now stores the bare ID (dangling tolerated) instead of fetch-and-link — strictly less work and resilient to out-of-order sync.

Deep dive

Checksum collision is intrinsic to the shared-top-level-class pattern

SwiftData's SchemaMigrationPlan validation computes a structural checksum per VersionedSchema and rejects a plan whose stages bridge two identical checksums (NSInvalidArgumentException: Duplicate version checksums across stages detected). The versionIdentifier tuple is not part of that hash. Because Trip/TripTask/TripPackingItem are one live class each (forking them re-introduces the iOS 26.4 same-simple-name cascade panic), any property-only delta is applied to every version's schema simultaneously. V2→V3 survived because V3 adds entity types (TripZoneState, MigrationJournalEntry) to its models array — a genuine structural delta. V3→V4's only delta was Trip.countryCode on the shared class — no structural delta, hence collision. This generalises: only models-array changes yield a distinct checksum. The deferred removal of the dead V2 relationships (Trip.participants, TripPackingItem.person), previously slated for "a future SchemaV4", is invalidated for the same reason and the columns stay as inert storage.

Why value reference beats restoring the inverse

CloudKit's all-relationships-need-an-inverse rule is non-negotiable for a mirrored schema. Option A (add TripPersonSnapshot.tripPackingItems inverse) re-creates the snapshot↔item nullify chain that panics cascade traversal on iOS 26.4 — the same class of bug already worked around for participantSnapshots (which SwiftData auto-pairs by type, so it escapes the 134060 check). Option B (value reference) removes the relationship from the validated graph entirely and aligns with the existing assigneePersonID precedent (Decision 9). A configuration split (CloudKit + local stores within globals) was rejected because the deprecated Trip.participants → Person / Person.tripPackingItems relationships cross the two model subsets and SwiftData relationships can't span configurations.

Edge cases & isolation

  • The bridge's idempotence semantics improved: the migration-stage check moved from personSnapshot != nil (evaluated the getter, could fetch per item inside a nonisolated didMigrate) to the stored personSnapshotID != nil — zero fetches, and a previously-set dangling ID is now correctly not overwritten on re-run.
  • Isolation verified: every nonisolated caller (SchemaV3MigrationStage, the #Predicate sites) touches only the stored scalar; the MainActor-isolated computed bridge is only reached from MainActor contexts (SnapshotMaintenance @MainActor enum, PackingItemRow View static func).
  • Render-path safety: on rendered rows item.trip is always wired and personSnapshotID is always present in trip.participantSnapshots (both creation paths select the snapshot from that array), so the getter resolves in-memory and the fetch fallback never fires per-render.

Residual risk

The relationship→value migration drops the old relationship column without copying its data into personSnapshotID. Acceptable because no production store predates the fix (the app never launched on-device). A clean reinstall is the safe path; a data-preserving custom stage would be required only if an existing store with real owner links must survive.

Important changes — detailed

Remove SchemaV4 — fix duplicate-checksum launch crash

Scramble__Scramble__Models__Schema.swift

Why it matters. This is the fatal crash. SchemaV4 was byte-identical to SchemaV3 (Trip.countryCode lives on the shared top-level Trip class), so the V3->V4 stage aborted container construction on any existing on-device store.

What to look at. Schema.swift: SchemaV4 enum removed; AppMigrationPlan.schemas now [V1, V2, V3]; V3->V4 stage removed. ModelStore.swift: 4x SchemaV4 -> SchemaV3.

Takeaway. Under the shared-top-level-class pattern, a property-only addition can never be a distinct VersionedSchema — SwiftData checksums structure, not the version number. Only models-array changes (new/removed entity types) produce a distinct checksum.
Rationale. Collapsing V4 into V3 is the only correct expression: V3 already contains countryCode structurally, and lightweight inference adds the Optional column to existing stores (Decision 18).

personSnapshot: @Relationship -> personSnapshotID value reference + computed bridge

Scramble__Scramble__Models__TripPackingItem.swift

Why it matters. The inverse-less relationship caused CloudKit error 134060 on-device, dropping globals to a local-only fallback that silently disabled iCloud sync for Person and master lists.

What to look at. TripPackingItem.swift: stored `personSnapshotID: UUID?` replaces `@Relationship var personSnapshot`; computed `personSnapshot` get/set bridge added in the extension (resolves via trip.participantSnapshots, falls back to a modelContext fetch).

Takeaway. A computed property in an extension is guaranteed excluded from the @Model schema, so it can mimic a relationship accessor without being a CloudKit-validated relationship. Mirrors the assigneePersonID value-reference precedent.
Rationale. A value reference removes the relationship from CloudKit's validated graph entirely and avoids the iOS 26.4 cascade panic that re-adding the inverse would trigger (Decision 14).

Propagate the value-reference across translator / migration / maintenance

Scramble__Scramble__Sharing__Translators__TripPackingItemRecordTranslator.swift

Why it matters. All read/write sites must use the stored scalar (predicates especially — computed properties can't appear in #Predicate).

What to look at. Translator from(): stores bare personSnapshotID (drops fetchSnapshot); SnapshotMaintenance referrerCount predicate keys on personSnapshotID; SchemaV3MigrationStage idempotence check + assignment use the scalar; ZoneMigrationCoordinator maps by ID.

Takeaway. Decode-time dangling tolerance (store the ID, resolve on read) is more robust than fetch-and-link against out-of-order CloudKit sync.
Rationale. Consistent value-reference handling; the migration idempotence check is also safer reading the scalar than the getter (no per-item fetch inside nonisolated didMigrate).

Test realignment + decision logs

Scramble__ScrambleTests__Persistence__SchemaV3MigrationTests.swift

Why it matters. The deleted SchemaV4MigrationTests only validated plan wiring (its own comment admitted it never seeded a true legacy store). Coverage of the value-reference path lives in existing translator/migration/maintenance/accessibility suites.

What to look at. SchemaV4MigrationTests.swift deleted; SchemaV3MigrationTests gains countryCode-on-V3 coverage + corrected plan-shape (2 stages); 3 test setups swap SchemaV4 -> SchemaV3. Decision logs + persistence.md document the change.

Takeaway. The on-device-only failure class (CloudKit inverse rejection, fresh-store checksum short-circuit) is structurally untestable in a .none / in-memory suite — mitigation is documentation, not coverage.
Rationale. Decision 14 and Decision 18 record both the change and the shared-class limitation so a future reader doesn't re-attempt a property-only SchemaV4.

Key decisions

Value reference over restoring the relationship inverse

CloudKit requires an inverse on every mirrored relationship. Re-adding TripPersonSnapshot.tripPackingItems would satisfy that but re-creates the iOS 26.4 snapshot↔item cascade-traversal panic. A personSnapshotID: UUID? value reference removes the relationship from the validated graph entirely and matches the existing TripTask.assigneePersonID precedent.

Collapse SchemaV4 into SchemaV3 rather than make V4 structurally distinct

Making V4 distinct would require a dummy entity (dishonest schema noise) or forking Trip (re-triggers the same-simple-name cascade panic). Since V3 already contains countryCode structurally, collapsing is the only honest expression; lightweight inference handles the on-disk column add.

Computed bridge in an extension, not the primary declaration

The @Model macro only persists stored properties in the primary class body. Placing the personSnapshot get/set in extension TripPackingItem guarantees it is never materialised as a CloudKit-validated relationship — the same mechanism as the existing state/source bridges.

Store bare ID on decode (dangling tolerated)

The translator's from() now stores personSnapshotID directly instead of fetching and linking. The referenced snapshot may not have synced into the store yet; the bridge resolves it on read once it arrives, and an unresolvable ID simply reads as nil.

Review findings

SeverityAreaFindingResolution
minorspecs/phase-5-cloudkit-sharing/design.md (frozen)Design doc still depicts personSnapshot as a @Relationship with a never-shipped TripPersonSnapshot.tripPackingItems inverse — the same inverse Decision 14 explicitly rejects, creating an apparent contradiction for a cross-referencing reader.Added a Negative-consequence pointer in Decision 14 naming design.md's stale depiction and marking the decision log + persistence.md as authoritative. Frozen historical spec left unedited per repo convention.
minordocs/agent-notes/sync-infrastructure.md:64Wording 'Relationships encoded as UUID-valued record fields (e.g. personSnapshotID: String)' is now imprecise — personSnapshot is no longer a relationship (wire format unchanged, so not strictly wrong).Reworded to 'Relationships and value references ... (personSnapshotID is now a stored value reference, not a @Relationship)'.
nitdocs/agent-notes/persistence.mdFrozen Phase-5/5.1 specs defer removing the dead V2 relationships 'to a future SchemaV4', which Decision 18 establishes will never exist.Added a forward-pointer in the 'Shared top-level classes...' section: removing those columns is the same structural-on-a-shared-class change and can't be a distinct version; they stay as inert storage.
minorTripPackingItem.swift:90-94 (code reuse)The bridge getter's snapshot-by-id fetch duplicates the inline fetch in TripPersonSnapshotRecordTranslator.existingSnapshot and the fetchSnapshot this diff deleted; no shared 'fetch model by id' helper exists.Skipped — pre-existing house style (the repo already carries 4 near-identical private fetchTrip(id:in:) copies). Extracting a generic helper is separate pre-existing-debt cleanup touching unrelated call sites, out of scope for this bugfix.
nitTripPackingItemRecordTranslator.swift:54-61 (code quality)Absent-field decode preserves the local value (only-set-when-present), differing from TripTask's assigneePersonID translator which clears unconditionally.Skipped — the conditional is pre-existing (only the body changed) and the kept comment already documents the 'no change on absent' semantics, citing the tripID handling above. Behaviour is the defensible forward-compatible choice.
nitTripPackingItem.swift bridge getter (efficiency)Suggested a comment noting the getter assumes the in-memory path on render and the fetch fallback is for the decode path.Skipped — the getter's existing doc comment already states it resolves in-memory and falls back only when the trip-side array isn't wired (e.g. a freshly decoded CloudKit record).

Per-file diffs

Click to expand.

Scramble/Scramble/Models/Schema.swift Modified +~30 / -~30
diff --git a/Scramble/Scramble/Models/Schema.swift b/Scramble/Scramble/Models/Schema.swiftindex d9efcb5..b603ca8 100644--- a/Scramble/Scramble/Models/Schema.swift+++ b/Scramble/Scramble/Models/Schema.swift@@ -299,32 +299,22 @@ extension MigrationJournalEntry {   } } -// MARK: - SchemaV4 (Phase 6 — adds Trip.countryCode)--/// Phase 6 schema. Additive only: introduces `Trip.countryCode: String?`-/// (Decision 5). All other entities are unchanged from V3, so the V3 → V4-/// stage is a lightweight migration — SwiftData adds the new column on-/// first open via Core Data's automatic inference because the property-/// is `Optional` with a `nil` default.-nonisolated enum SchemaV4: VersionedSchema {-  nonisolated static var versionIdentifier: Schema.Version {-    Schema.Version(4, 0, 0)-  }--  nonisolated static var models: [any PersistentModel.Type] {-    [-      Trip.self,-      Person.self,-      MasterTaskItem.self,-      MasterPackingItem.self,-      TripTask.self,-      TripPackingItem.self,-      SchemaV3.TripPersonSnapshot.self,-      SchemaV3.TripZoneState.self,-      SchemaV3.MigrationJournalEntry.self,-    ]-  }-}+// MARK: - Phase 6 note — why there is no SchemaV4++// Phase 6 added `Trip.countryCode: String?` (Decision 5). That cannot be+// modelled as a distinct `SchemaV4`: `Trip` is a single top-level `@Model`+// shared by every schema version, so adding a property to it changes the+// structure of *every* version's view of `Trip` simultaneously. A V4 whose+// only difference from V3 is `Trip.countryCode` is byte-identical to V3 at+// the metadata level — SwiftData computes the version checksum purely from+// structure (the `versionIdentifier` is not part of it), so a V3 → V4 stage+// fails plan validation with `Duplicate version checksums across stages+// detected` the moment an older store forces the plan to traverse it.+// `countryCode` therefore lives on the current `Trip` and is part of+// `SchemaV3`; SwiftData adds the column to existing stores via automatic+// lightweight inference (Optional with a `nil` default), the same mechanism+// already used for `ckRecordSystemFields`. See the persistence note+// "Shared top-level classes can't express property-only schema bumps".  // MARK: - Top-level current types @@ -422,7 +412,7 @@ extension TripTask {  nonisolated enum AppMigrationPlan: SchemaMigrationPlan {   nonisolated static var schemas: [any VersionedSchema.Type] {-    [SchemaV1.self, SchemaV2.self, SchemaV3.self, SchemaV4.self]+    [SchemaV1.self, SchemaV2.self, SchemaV3.self]   }    nonisolated static var stages: [MigrationStage] {@@ -436,10 +426,17 @@ nonisolated enum AppMigrationPlan: SchemaMigrationPlan {       // inference (every new V3 column is `Optional` with `nil`       // default). The custom `didMigrate` step then backfills       // `TripPersonSnapshot` rows from existing `Person` references and-      // wires `TripPackingItem.personSnapshot`. Custom is required+      // wires `TripPackingItem.personSnapshotID`. Custom is required       // because the backfill reads V2 trip-roster relationships and       // writes V3 snapshot rows in a single transaction. Offline-safe —       // no CloudKit calls; runs unconditionally even when signed out.+      //+      // V3 is the current schema. Additive property changes to the shared+      // top-level classes (e.g. Phase 6's `Trip.countryCode`, and the+      // `TripPackingItem.personSnapshot` → `personSnapshotID` value-+      // reference change) ride in on V3's automatic lightweight inference+      // rather than a new stage — a property-only bump can't be a distinct+      // version under the shared-class pattern (see the SchemaV4 note).       .custom(         fromVersion: SchemaV2.self,         toVersion: SchemaV3.self,@@ -448,13 +445,6 @@ nonisolated enum AppMigrationPlan: SchemaMigrationPlan {           try SchemaV3MigrationStage.backfillSnapshots(in: context)         }       ),-      // V3 → V4: lightweight; adds `Trip.countryCode: String?` with-      // `nil` default. SwiftData adds the column on first open via-      // automatic inference. Runs on both `globals` and `tripsLocal`-      // containers; `globals` has no `Trip` rows in production-      // (Phase 5.1 moved them to `tripsLocal`) so the data effect on-      // `globals` is zero.-      .lightweight(fromVersion: SchemaV3.self, toVersion: SchemaV4.self),     ]   } }
Scramble/Scramble/Models/TripPackingItem.swift Modified +~35 / -~9
diff --git a/Scramble/Scramble/Models/TripPackingItem.swift b/Scramble/Scramble/Models/TripPackingItem.swiftindex 94fd93a..ccd1fa7 100644--- a/Scramble/Scramble/Models/TripPackingItem.swift+++ b/Scramble/Scramble/Models/TripPackingItem.swift@@ -6,9 +6,11 @@ final class TripPackingItem {   var id: UUID = UUID()   @Relationship var trip: Trip? -  /// Deprecated in V3, removed in V4. New code reads `personSnapshot`-  /// (Decision 7) so a shared trip renders without crossing into the-  /// owner's globals zone.+  /// Deprecated in V3. New code reads `personSnapshot` (Decision 7) so a+  /// shared trip renders without crossing into the owner's globals zone.+  /// The planned V4 removal was folded into V3 — the shared top-level+  /// class pattern makes a property-only V4 indistinguishable from V3 —+  /// so this field is still present but unused on read paths.   @Relationship var person: Person?    var masterItemID: UUID?@@ -21,7 +23,16 @@ final class TripPackingItem {   /// V3 — replaces `person` for read paths; resolved through the trip   /// zone so participants render without crossing into the owner's   /// globals zone (Req 2.5).-  @Relationship var personSnapshot: TripPersonSnapshot?+  ///+  /// Stored as a `UUID?` value reference rather than a `@Relationship`.+  /// The globals container's SwiftData CloudKit mirror rejects any+  /// relationship without an inverse, and the snapshot↔item inverse is+  /// exactly the pair that panics SwiftData's cascade traversal on iOS+  /// 26.4 (see `Trip.participantSnapshots`). A value reference satisfies+  /// CloudKit, sidesteps that cascade, and matches+  /// `TripTask.assigneePersonID`'s dangling-reference tolerance. Read it+  /// through the `personSnapshot` computed bridge below.+  var personSnapshotID: UUID?    /// V3 — see `Trip.ckRecordSystemFields`.   var ckRecordSystemFields: Data?@@ -47,7 +58,7 @@ final class TripPackingItem {     self.sourceRaw = source.rawValue     self.currentlyMatchesRules = currentlyMatchesRules     self.pinnedByUser = pinnedByUser-    self.personSnapshot = personSnapshot+    self.personSnapshotID = personSnapshot?.id   } } @@ -61,4 +72,27 @@ extension TripPackingItem {     get { ItemSource(rawValue: sourceRaw) ?? .manual }     set { sourceRaw = newValue.rawValue }   }++  /// Bridge over the `personSnapshotID` value reference. Lives in an+  /// extension so the `@Model` macro never treats it as a stored+  /// relationship (it has no inverse to CloudKit — that is the whole+  /// point of the value reference). Resolves the snapshot in-memory via+  /// the owning trip's `participantSnapshots`, falling back to a fetch+  /// from the item's `modelContext` when the trip-side array isn't wired+  /// (e.g. a freshly decoded CloudKit record, or a snapshot attached via+  /// the unpaired `TripPersonSnapshot.trip` back-link only). Returns+  /// `nil` for a dangling ID — references are tolerated, not enforced.+  var personSnapshot: TripPersonSnapshot? {+    get {+      guard let snapshotID = personSnapshotID else { return nil }+      let onTrip = (trip?.participantSnapshots ?? []).first { $0.id == snapshotID }+      if let onTrip { return onTrip }+      guard let modelContext else { return nil }+      let descriptor = FetchDescriptor<TripPersonSnapshot>(+        predicate: #Predicate { $0.id == snapshotID }+      )+      return try? modelContext.fetch(descriptor).first+    }+    set { personSnapshotID = newValue?.id }+  } }
Scramble/Scramble/Models/Trip.swift Modified +5 / -2
diff --git a/Scramble/Scramble/Models/Trip.swift b/Scramble/Scramble/Models/Trip.swiftindex a0eec3a..12d5986 100644--- a/Scramble/Scramble/Models/Trip.swift+++ b/Scramble/Scramble/Models/Trip.swift@@ -43,9 +43,10 @@ final class Trip {   /// pre-Phase-5 records and freshly created trips have no cache yet.   var ckRecordSystemFields: Data? -  /// V4 — ISO 3166-1 alpha-2 destination country, uppercase or `nil`.-  /// Used by `CountryFlag.emoji(for:)` to render a flag glyph on the-  /// Trip Detail header (Phase 6 Decision 5).+  /// Phase 6 (Decision 5) — ISO 3166-1 alpha-2 destination country,+  /// uppercase or `nil`. Used by `CountryFlag.emoji(for:)` to render a+  /// flag glyph on the Trip Detail header. Part of `SchemaV3` (added via+  /// lightweight inference, not a distinct V4 — see the Schema.swift note).   var countryCode: String?    init(
Scramble/Scramble/Persistence/ModelStore.swift Modified +~9 / -~7
diff --git a/Scramble/Scramble/Persistence/ModelStore.swift b/Scramble/Scramble/Persistence/ModelStore.swiftindex afd950b..2838420 100644--- a/Scramble/Scramble/Persistence/ModelStore.swift+++ b/Scramble/Scramble/Persistence/ModelStore.swift@@ -12,11 +12,13 @@ enum ModelStore {   /// is a local-only store whose CloudKit sync is driven by   /// `TripSyncEngine`, not by SwiftData.   ///-  /// Both schemas remain `SchemaV4`-shaped (full model list) because the+  /// Both schemas remain `SchemaV3`-shaped (full model list) because the   /// deprecated V2 cross-references — `Trip.participants → Person`,   /// `TripPackingItem.person → Person` — still resolve via the shared-  /// top-level entity classes. Phase 6 promoted the schema from V3 to V4-  /// purely to add `Trip.countryCode`. The logical ownership table from+  /// top-level entity classes. `Trip.countryCode` (Phase 6) is part of+  /// `SchemaV3`; there is no `SchemaV4` (see the Schema.swift note on why+  /// a property-only bump can't be a distinct version). The ownership+  /// table from   /// the design document is enforced by convention: `globals.mainContext`   /// holds globals records, `tripsLocal.mainContext` holds trip-zone   /// records. Stage B performed the actual data move during Phase 5.@@ -84,7 +86,7 @@ enum ModelStore {   /// the full V3 model list so the deprecated V2 cross-references   /// (`Trip.participants → Person`) keep resolving until V4 cleanup.   nonisolated static func globalsConfiguration(probe: EnvironmentProbe) -> ModelConfiguration {-    let schema = Schema(versionedSchema: SchemaV4.self)+    let schema = Schema(versionedSchema: SchemaV3.self)     switch strategy(probe: probe) {     case .inMemory:       return ModelConfiguration(@@ -101,7 +103,7 @@ enum ModelStore {   }    static func makeGlobalsContainer(probe: EnvironmentProbe) -> ModelContainer {-    let schema = Schema(versionedSchema: SchemaV4.self)+    let schema = Schema(versionedSchema: SchemaV3.self)     let primary = globalsConfiguration(probe: probe)     do {       return try ModelContainer(@@ -135,7 +137,7 @@ enum ModelStore {   /// 13). The store URL is distinct from the globals store so the two do   /// not collide.   nonisolated static func tripsLocalConfiguration(probe: EnvironmentProbe) -> ModelConfiguration {-    let schema = Schema(versionedSchema: SchemaV4.self)+    let schema = Schema(versionedSchema: SchemaV3.self)     switch strategy(probe: probe) {     case .inMemory:       return ModelConfiguration(@@ -154,7 +156,7 @@ enum ModelStore {   }    static func makeTripsLocalContainer(probe: EnvironmentProbe) -> ModelContainer {-    let schema = Schema(versionedSchema: SchemaV4.self)+    let schema = Schema(versionedSchema: SchemaV3.self)     let primary = tripsLocalConfiguration(probe: probe)     do {       return try ModelContainer(
Scramble/Scramble/Persistence/Migrations/SchemaV3MigrationStage.swift Modified +2 / -2
diff --git a/Scramble/Scramble/Persistence/Migrations/SchemaV3MigrationStage.swift b/Scramble/Scramble/Persistence/Migrations/SchemaV3MigrationStage.swiftindex 3f1bf0f..efca47f 100644--- a/Scramble/Scramble/Persistence/Migrations/SchemaV3MigrationStage.swift+++ b/Scramble/Scramble/Persistence/Migrations/SchemaV3MigrationStage.swift@@ -72,13 +72,13 @@ nonisolated enum SchemaV3MigrationStage {      for item in trip.packingItems ?? [] {       // Already linked — nothing to do (idempotence on a re-run).-      if item.personSnapshot != nil { continue }+      if item.personSnapshotID != nil { continue }       guard let person = item.person else { continue }       // The item's person may have been removed from the roster after the       // packing item was created (Req 2.4 dangling-snapshot rule); only       // wire up a snapshot when there is one for this person on this trip.       if let snapshot = snapshotByPersonID[person.id] {-        item.personSnapshot = snapshot+        item.personSnapshotID = snapshot.id       }     }   }
Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift Modified +1 / -1
diff --git a/Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift b/Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swiftindex 5a17bd5..f44f5dc 100644--- a/Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift+++ b/Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift@@ -356,7 +356,7 @@ final class ZoneMigrationCoordinator {     }      for item in trip.packingItems ?? [] {-      let mappedSnapshot = item.personSnapshot.flatMap { snapshotsByID[$0.id] }+      let mappedSnapshot = item.personSnapshotID.flatMap { snapshotsByID[$0] }       let copiedItem = TripPackingItem(         id: item.id,         trip: copiedTrip,
Scramble/Scramble/Sharing/SnapshotMaintenance.swift Modified +2 / -2
diff --git a/Scramble/Scramble/Sharing/SnapshotMaintenance.swift b/Scramble/Scramble/Sharing/SnapshotMaintenance.swiftindex 3f116a9..2515cc5 100644--- a/Scramble/Scramble/Sharing/SnapshotMaintenance.swift+++ b/Scramble/Scramble/Sharing/SnapshotMaintenance.swift@@ -90,7 +90,7 @@ enum SnapshotMaintenance {   ///   /// **Ordering contract — call BEFORE `context.delete(item)`.** This   /// routine counts referrers via a SwiftData fetch (predicate over-  /// `personSnapshot?.id`) which sees the in-context state, including+  /// `personSnapshotID`) which sees the in-context state, including   /// any deletions staged in this transaction. If `item` is already   /// deleted at the moment of this call, the referrer count drops to   /// zero prematurely and a snapshot that other packing items still@@ -149,7 +149,7 @@ enum SnapshotMaintenance {     let snapshotID = snapshot.id     let items = try context.fetch(       FetchDescriptor<TripPackingItem>(-        predicate: #Predicate { $0.personSnapshot?.id == snapshotID }+        predicate: #Predicate { $0.personSnapshotID == snapshotID }       )     )     if let excludedItemID {
Scramble/Scramble/Sharing/Translators/TripPackingItemRecordTranslator.swift Modified +~12 / -~8
diff --git a/Scramble/Scramble/Sharing/Translators/TripPackingItemRecordTranslator.swift b/Scramble/Scramble/Sharing/Translators/TripPackingItemRecordTranslator.swiftindex cb1b144..b72a432 100644--- a/Scramble/Scramble/Sharing/Translators/TripPackingItemRecordTranslator.swift+++ b/Scramble/Scramble/Sharing/Translators/TripPackingItemRecordTranslator.swift@@ -2,9 +2,9 @@ import CloudKit import Foundation import SwiftData -/// `TripPackingItem` ↔ `CKRecord` translator. The `personSnapshot`-/// relationship is encoded as `personSnapshotID: String` per the design's-/// "relationships are UUID record fields" rule (Decision 13).+/// `TripPackingItem` ↔ `CKRecord` translator. The `personSnapshotID`+/// value reference is encoded as `personSnapshotID: String` per the+/// design's "relationships are UUID record fields" rule (Decision 13). @MainActor enum TripPackingItemRecordTranslator {   static let recordType: String = "TripPackingItem"@@ -25,7 +25,7 @@ enum TripPackingItemRecordTranslator {     } else {       record["tripID"] = nil     }-    record["personSnapshotID"] = item.personSnapshot?.id.uuidString as CKRecordValue?+    record["personSnapshotID"] = item.personSnapshotID?.uuidString as CKRecordValue?     record["masterItemID"] = item.masterItemID?.uuidString as CKRecordValue?     record["name"] = item.name as CKRecordValue     record["stateRaw"] = item.stateRaw as CKRecordValue@@ -54,7 +54,10 @@ enum TripPackingItemRecordTranslator {     let snapshotID = (record["personSnapshotID"] as? String)       .flatMap(UUID.init(uuidString:))     if let snapshotID {-      item.personSnapshot = try fetchSnapshot(id: snapshotID, in: context)+      // Store the bare ID — dangling references are tolerated. The+      // snapshot may not have synced into this store yet; the+      // `personSnapshot` bridge resolves it on read once it arrives.+      item.personSnapshotID = snapshotID     }     item.masterItemID =       (record["masterItemID"] as? String).flatMap(UUID.init(uuidString:))@@ -83,11 +86,4 @@ enum TripPackingItemRecordTranslator {     let descriptor = FetchDescriptor<Trip>(predicate: #Predicate { $0.id == id })     return try context.fetch(descriptor).first   }--  private static func fetchSnapshot(-    id: UUID, in context: ModelContext-  ) throws -> TripPersonSnapshot? {-    let descriptor = FetchDescriptor<TripPersonSnapshot>(predicate: #Predicate { $0.id == id })-    return try context.fetch(descriptor).first-  } }
Scramble/Scramble/Sharing/Translators/RecordRepresentable.swift Modified +6 / -5
diff --git a/Scramble/Scramble/Sharing/Translators/RecordRepresentable.swift b/Scramble/Scramble/Sharing/Translators/RecordRepresentable.swiftindex 238f9be..f844825 100644--- a/Scramble/Scramble/Sharing/Translators/RecordRepresentable.swift+++ b/Scramble/Scramble/Sharing/Translators/RecordRepresentable.swift@@ -9,11 +9,12 @@ import SwiftData /// /// Translator rules (uniform across every entity): ///-/// - Relationships are stored as UUID-valued record fields, **not**-///   `CKRecord.Reference`. A `TripPackingItem`'s `personSnapshot` is encoded-///   as `personSnapshotID: String` on the `CKRecord`; lookup at decode time-///   happens against `tripsLocal`. This avoids cross-record dependency-///   ordering inside `CKSyncEngine` batches.+/// - Relationships and snapshot references are stored as UUID-valued+///   record fields, **not** `CKRecord.Reference`. A `TripPackingItem`'s+///   `personSnapshotID` is encoded as `personSnapshotID: String` on the+///   `CKRecord`; the bare ID is stored back on decode (dangling references+///   tolerated). This avoids cross-record dependency ordering inside+///   `CKSyncEngine` batches. /// - System fields are preserved on every write. `existing: CKRecord?` is ///   constructed by decoding the entity's `ckRecordSystemFields` blob via ///   `CKRecord(coder:)`. After every send/fetch, the translator re-encodes
Scramble/Scramble/Features/Trips/PackingItemForm.swift Modified +3 / -3
diff --git a/Scramble/Scramble/Features/Trips/PackingItemForm.swift b/Scramble/Scramble/Features/Trips/PackingItemForm.swiftindex 9d1ae78..8436fda 100644--- a/Scramble/Scramble/Features/Trips/PackingItemForm.swift+++ b/Scramble/Scramble/Features/Trips/PackingItemForm.swift@@ -142,9 +142,9 @@ extension PackingItemForm {   }    /// Inserts a manual `TripPackingItem` with the documented field values-  /// (Req 5.3). Phase 5.1: writes the V3 `personSnapshot` relationship-  /// (looked up against `trip.participantSnapshots` by `person.id`)-  /// instead of the V2 `person → Person` relationship that would span+  /// (Req 5.3). Phase 5.1: writes the V3 `personSnapshotID` value+  /// reference (looked up against `trip.participantSnapshots` by+  /// `person.id`) instead of the V2 `person → Person` relationship that would span   /// containers under the dual-container split. Throws on   /// `modelContext.save()` failure; on throw the inserted instance is   /// removed from the context so the caller's retry does not
Scramble/ScrambleTests/Persistence/SchemaV3MigrationTests.swift Modified +27 / -3
diff --git a/Scramble/ScrambleTests/Persistence/SchemaV3MigrationTests.swift b/Scramble/ScrambleTests/Persistence/SchemaV3MigrationTests.swiftindex 353b288..607cd55 100644--- a/Scramble/ScrambleTests/Persistence/SchemaV3MigrationTests.swift+++ b/Scramble/ScrambleTests/Persistence/SchemaV3MigrationTests.swift@@ -20,19 +20,41 @@ struct SchemaV3MigrationTests {    // MARK: - Migration plan shape -  @Test("AppMigrationPlan declares V1, V2, V3, and V4")+  @Test("AppMigrationPlan declares V1, V2, and V3 (V3 is the current schema)")   func planLinksAllVersions() {     let schemaIDs = AppMigrationPlan.schemas.map(ObjectIdentifier.init)     #expect(schemaIDs.contains(ObjectIdentifier(SchemaV1.self)))     #expect(schemaIDs.contains(ObjectIdentifier(SchemaV2.self)))     #expect(schemaIDs.contains(ObjectIdentifier(SchemaV3.self)))-    #expect(schemaIDs.contains(ObjectIdentifier(SchemaV4.self)))   } -  @Test("AppMigrationPlan exposes V1→V2 lightweight, V2→V3 custom, V3→V4 lightweight")+  @Test("AppMigrationPlan exposes V1→V2 lightweight and V2→V3 custom")   func planExposesAllStages() {     let stages = AppMigrationPlan.stages-    #expect(stages.count == 3, "Expected one stage per consecutive version pair")+    #expect(stages.count == 2, "Expected one stage per consecutive version pair")+  }++  // MARK: - Phase 6 countryCode rides on V3 (no SchemaV4)++  @Test("SchemaV3 Trip entity includes Phase 6 countryCode")+  func schemaV3IncludesCountryCode() {+    let schema = Schema(versionedSchema: SchemaV3.self)+    let trip = schema.entities.first(where: { $0.name == "Trip" })+    let propertyNames = Set(trip?.properties.map(\.name) ?? [])+    #expect(propertyNames.contains("countryCode"))+  }++  @Test("Fresh V3 Trip defaults countryCode to nil and round-trips through the plan")+  func freshV3TripCountryCodeNil() throws {+    let container = try Self.makeV3Container()+    let context = container.mainContext++    let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)+    context.insert(trip)+    try context.save()++    let stored = try #require(try context.fetch(FetchDescriptor<Trip>()).first)+    #expect(stored.countryCode == nil)   }    // MARK: - Fresh V3 entity defaults
Scramble/ScrambleTests/Persistence/SchemaV4MigrationTests.swift Deleted -135
diff --git a/Scramble/ScrambleTests/Persistence/SchemaV4MigrationTests.swift b/Scramble/ScrambleTests/Persistence/SchemaV4MigrationTests.swiftdeleted file mode 100644index a0ef398..0000000--- a/Scramble/ScrambleTests/Persistence/SchemaV4MigrationTests.swift+++ /dev/null@@ -1,135 +0,0 @@-import Foundation-import SwiftData-import Testing--@testable import Scramble--/// Phase 6 — `SchemaV3 → SchemaV4` lightweight migration coverage.-///-/// The V3 → V4 stage adds a single optional column (`Trip.countryCode`)-/// and is declared `.lightweight` in `AppMigrationPlan`. SwiftData-/// resolves the column on first open via automatic inference because-/// `countryCode` is `Optional` with a `nil` default. These tests round--/// trip an on-disk store: a V3-shaped container seeds two trips, the-/// container is dropped, a V4 container with the migration plan re-opens-/// the same URL and the trips are read back with `countryCode == nil`.-@Suite("SchemaV4 migration", .serialized)-@MainActor-struct SchemaV4MigrationTests {--  // MARK: - Plan shape--  @Test("V3 → V4 stage is registered as lightweight")-  func planExposesV3ToV4LightweightStage() {-    let schemaIDs = AppMigrationPlan.schemas.map(ObjectIdentifier.init)-    #expect(schemaIDs.contains(ObjectIdentifier(SchemaV4.self)))-  }--  @Test("SchemaV4 entity list includes Trip with countryCode")-  func schemaV4ContainsTripCountryCode() {-    let schema = Schema(versionedSchema: SchemaV4.self)-    let trip = try? #require(schema.entities.first(where: { $0.name == "Trip" }))-    let propertyNames = Set(trip?.properties.map(\.name) ?? [])-    #expect(propertyNames.contains("countryCode"))-  }--  // MARK: - On-disk migration round-trip--  @Test(-    "V3-seeded trips load through the V4 migration with countryCode == nil",-    arguments: ["TripsLocal", "Globals"]-  )-  func roundTripPreservesTripsAndSetsCountryCodeNil(containerLabel: String) throws {-    let directoryURL = try Self.makeTempDirectory(label: containerLabel)-    defer { try? FileManager.default.removeItem(at: directoryURL) }-    let storeURL = directoryURL.appendingPathComponent("\(containerLabel).store")--    let firstTripID = UUID()-    let secondTripID = UUID()--    // Seed: write two trips through a `Schema(versionedSchema: SchemaV3.self)`-    // container. This is NOT a true legacy-shape seed — the running-    // `Trip` class in this test process already carries `countryCode`,-    // and SwiftData adds the column on first open regardless of which-    // versioned schema is named. A binary `.store` fixture written by an-    // older build would be required for full V3-origin fidelity.-    //-    // What this test DOES cover: the `AppMigrationPlan` round-trip on-    // the second open — opening the store with the V4 schema + plan-    // does not lose the seeded rows and reads `countryCode == nil` for-    // both. That validates the migration *plan wiring* but not the-    // lightweight stage's column-addition path itself.-    try Self.withV3Container(url: storeURL) { container in-      let context = container.mainContext-      context.insert(-        Trip(-          id: firstTripID,-          name: "Iceland",-          startDate: Date(timeIntervalSince1970: 1_700_000_000),-          endDate: Date(timeIntervalSince1970: 1_700_500_000)-        )-      )-      context.insert(-        Trip(-          id: secondTripID,-          name: "Japan",-          startDate: Date(timeIntervalSince1970: 1_710_000_000),-          endDate: Date(timeIntervalSince1970: 1_710_500_000)-        )-      )-      try context.save()-    }--    // Re-open with the V4 schema + AppMigrationPlan. Lightweight stage-    // runs implicitly; data is preserved and `countryCode == nil`.-    try Self.withV4Container(url: storeURL) { container in-      let context = container.mainContext-      let trips = try context.fetch(FetchDescriptor<Trip>(sortBy: [SortDescriptor(\.name)]))-      #expect(trips.count == 2)-      #expect(-        trips.map(\.id) == [firstTripID, secondTripID].sorted { $0.uuidString < $1.uuidString }-          || trips.map(\.name) == ["Iceland", "Japan"])-      #expect(trips.allSatisfy { $0.countryCode == nil })-    }-  }--  // MARK: - Helpers--  private static func makeTempDirectory(label: String) throws -> URL {-    let base = FileManager.default.temporaryDirectory-      .appendingPathComponent(-        "ScrambleV4Migration-\(label)-\(UUID().uuidString)", isDirectory: true)-    try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)-    return base-  }--  private static func withV3Container(-    url: URL, body: (ModelContainer) throws -> Void-  ) throws {-    let schema = Schema(versionedSchema: SchemaV3.self)-    let config = ModelConfiguration(-      schema: schema,-      url: url,-      cloudKitDatabase: .none-    )-    let container = try ModelContainer(for: schema, configurations: [config])-    try body(container)-  }--  private static func withV4Container(-    url: URL, body: (ModelContainer) throws -> Void-  ) throws {-    let schema = Schema(versionedSchema: SchemaV4.self)-    let config = ModelConfiguration(-      schema: schema,-      url: url,-      cloudKitDatabase: .none-    )-    let container = try ModelContainer(-      for: schema,-      migrationPlan: AppMigrationPlan.self,-      configurations: [config]-    )-    try body(container)-  }-}
Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift Modified +1 / -1
diff --git a/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift b/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swiftindex aaaff24..8bbfc15 100644--- a/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift+++ b/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift@@ -103,7 +103,7 @@ struct PackingItemRowAccessibilityTests {   }    static func makeSetup() throws -> Setup {-    let schema = Schema(versionedSchema: SchemaV4.self)+    let schema = Schema(versionedSchema: SchemaV3.self)     let config = ModelConfiguration(       schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none     )
Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swift Modified +1 / -1
diff --git a/Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swift b/Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swiftindex b1e1c7c..4854a54 100644--- a/Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swift+++ b/Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swift@@ -138,7 +138,7 @@ struct TaskRowAccessibilityTests {   }    static func makeSetup() throws -> Setup {-    let schema = Schema(versionedSchema: SchemaV4.self)+    let schema = Schema(versionedSchema: SchemaV3.self)     let config = ModelConfiguration(       schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none     )
Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swift Modified +1 / -1
diff --git a/Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swift b/Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swiftindex 92202e2..4c295eb 100644--- a/Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swift+++ b/Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swift@@ -206,7 +206,7 @@ struct NotificationsServiceTests {   func makeSetup() throws -> Setup {     var cal = Calendar(identifier: .gregorian)     cal.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt-    let schema = Schema(versionedSchema: SchemaV4.self)+    let schema = Schema(versionedSchema: SchemaV3.self)     let config = ModelConfiguration(       schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none     )
docs/agent-notes/persistence.md Modified +20 / -2
diff --git a/docs/agent-notes/persistence.md b/docs/agent-notes/persistence.mdindex f336020..e013546 100644--- a/docs/agent-notes/persistence.md+++ b/docs/agent-notes/persistence.md@@ -2,8 +2,8 @@  ## Files -- `Scramble/Scramble/Models/Schema.swift` — `SchemaV1` (pre-Phase-3 frozen), `SchemaV2` (Phase 3 — `TripTask.assigneePersonID` + `TripTask.userDeletedOnThisTripRaw` added), `SchemaV3` (Phase 5 — adds `TripPersonSnapshot`, `TripZoneState`, `MigrationJournalEntry` plus `Trip.participantSnapshots`/`tripZoneID`/`ckRecordSystemFields`, `TripPackingItem.personSnapshot`/`ckRecordSystemFields`, and `TripTask.ckRecordSystemFields`). `AppMigrationPlan` runs `.lightweight V1 → V2` then `.custom V2 → V3` (whose `didMigrate` calls `SchemaV3MigrationStage.backfillSnapshots(in:)`). Top-level `TripTask` is the single class shared by V2 and V3 — see "Why TripTask is no longer forked" below. `typealias TripPersonSnapshot = SchemaV3.TripPersonSnapshot` and similar for `TripZoneState`/`MigrationJournalEntry`.-- `Scramble/Scramble/Persistence/Migrations/SchemaV3MigrationStage.swift` — Phase 5 Stage A custom step. Backfills `TripPersonSnapshot` rows from V2 `Trip.participants` and links `TripPackingItem.personSnapshot`. Idempotent and offline-safe (no CloudKit).+- `Scramble/Scramble/Models/Schema.swift` — `SchemaV1` (pre-Phase-3 frozen), `SchemaV2` (Phase 3 — `TripTask.assigneePersonID` + `TripTask.userDeletedOnThisTripRaw` added), `SchemaV3` (Phase 5 — adds `TripPersonSnapshot`, `TripZoneState`, `MigrationJournalEntry` plus `Trip.participantSnapshots`/`tripZoneID`/`ckRecordSystemFields`, `TripPackingItem.personSnapshotID`/`ckRecordSystemFields`, and `TripTask.ckRecordSystemFields`). **`SchemaV3` is the current schema — there is no `SchemaV4`.** Phase 6's `Trip.countryCode` and the `personSnapshot` → `personSnapshotID` value-reference change both ride on V3 via automatic lightweight inference (see "Shared top-level classes can't express property-only schema bumps" below). `AppMigrationPlan` runs `.lightweight V1 → V2` then `.custom V2 → V3` (whose `didMigrate` calls `SchemaV3MigrationStage.backfillSnapshots(in:)`). Top-level `TripTask` is the single class shared by V2 and V3 — see "Why TripTask is no longer forked" below. `typealias TripPersonSnapshot = SchemaV3.TripPersonSnapshot` and similar for `TripZoneState`/`MigrationJournalEntry`.+- `Scramble/Scramble/Persistence/Migrations/SchemaV3MigrationStage.swift` — Phase 5 Stage A custom step. Backfills `TripPersonSnapshot` rows from V2 `Trip.participants` and links `TripPackingItem.personSnapshotID`. Idempotent and offline-safe (no CloudKit). - `Scramble/Scramble/Models/{Trip,Person,MasterTaskItem,MasterPackingItem,TripPackingItem}.swift` — `@Model` entities with Codable-bridge extensions for `attributes`, `conditions`, `state`, `source`. `Trip` and `TripPackingItem` carry the V3 additive fields directly; `TripTask` now lives inside `Schema.swift` as a single top-level class. - `Scramble/Scramble/Persistence/EnvironmentProbe.swift` — value type wrapping `environment` + `arguments`; `production` reads `ProcessInfo.processInfo`. Branches: `isTest`, `isUITestHost`, `isPreview`. - `Scramble/Scramble/Persistence/ModelStore.swift` — `@MainActor enum`. `shared` evaluates `makeContainer(probe: .production)` once. `configuration(probe:)` is `nonisolated` and unit-tested.@@ -59,6 +59,22 @@ If a future schema bump needs a non-additive column change on `TripTask`, the fo  `Trip.participantSnapshots` is declared as `@Relationship(deleteRule: .nullify) var participantSnapshots: [TripPersonSnapshot]? = []` — no `inverse:` keypath. The companion `TripPersonSnapshot.trip` is a one-way `@Relationship var trip: Trip?`. The two are not paired; setting one does not auto-update the other. The design (Decision 7) called for a paired cascade, but SwiftData's cascade traversal on iOS 26.4 panics when the trip-deletion path reaches the snapshot ↔ packing-item nullify chain — the same crash class that drove the `TripTask` consolidation above. Snapshot lifetime is therefore enforced by the snapshot-maintenance routine and an explicit trip-deletion sweep instead of relying on the relationship rule. Code that needs the back-collection on `Trip` can use `participantSnapshots` for reads but must maintain both sides on writes. +SwiftData auto-pairs `participantSnapshots` ↔ `TripPersonSnapshot.trip` by type (one unambiguous candidate on each side), so CloudKit accepts them as a valid inverse pair even without an explicit `inverse:` keypath.++## TripPackingItem.personSnapshot is a value reference, not a relationship (phase 5/6 bugfix — supersedes Decision 7's relationship form)++`TripPackingItem` stores `personSnapshotID: UUID?` (a value reference, like `TripTask.assigneePersonID`), **not** a `@Relationship var personSnapshot`. The `personSnapshot` computed property (in the `extension TripPackingItem` bridge block) resolves the ID in-memory via `trip?.participantSnapshots`, falling back to a `modelContext` fetch by ID when the trip-side array isn't wired (e.g. a freshly decoded `CKRecord`, or a snapshot reachable only via the one-way `TripPersonSnapshot.trip` back-link). The computed bridge lives in an extension so the `@Model` macro never treats it as a stored relationship.++Why it had to change: the original `@Relationship var personSnapshot: TripPersonSnapshot?` had no inverse (`TripPersonSnapshot` has no `[TripPackingItem]` collection, and adding one re-creates the iOS 26.4 snapshot↔item cascade panic). The `globals` container mirrors the *full* model list to CloudKit (`cloudKitDatabase: .private`), and SwiftData's CloudKit mirror **rejects any relationship without an inverse** at store load (`NSCocoaErrorDomain 134060 ... requires that all relationships have an inverse: TripPackingItem: personSnapshot`). That fired only on a real device with an iCloud account (tests and the simulator use `cloudKitDatabase: .none`), where it dropped `globals` to the local-only fallback and silently disabled iCloud sync for `Person`/master lists. A value reference has no inverse requirement, satisfies CloudKit, and sidesteps the cascade panic. Predicates must use the stored `personSnapshotID` (computed properties can't appear in `#Predicate`); writers set `personSnapshotID` (or pass a snapshot to the `init`, which stores `.id`); readers use the `personSnapshot` bridge.++## Shared top-level classes can't express property-only schema bumps (no SchemaV4)++Adding a property to a top-level `@Model` shared across versions (`Trip`, `TripTask`, `TripPackingItem`) changes *every* version's view of that class simultaneously, because they all reference the one live class. A new `SchemaV<N>` whose only difference from `V<N-1>` is such a property is **byte-identical at the metadata level** — SwiftData computes the version checksum purely from structure, not from `versionIdentifier`. Registering both in the migration plan fails with `NSInvalidArgumentException: Duplicate version checksums across stages detected` the moment an older on-disk store forces the plan to traverse that stage (a fresh store at the current version short-circuits the check, which is why the in-memory tests didn't catch it).++Phase 6 originally added `SchemaV4` for `Trip.countryCode` and hit this on-device. The fix: `countryCode` lives on the current `Trip` and is part of `SchemaV3`; SwiftData adds the column via automatic lightweight inference (Optional, `nil` default), the same mechanism already used for `ckRecordSystemFields`. The `personSnapshot` → `personSnapshotID` change rides on V3 the same way. **A schema change that only adds/alters properties on a shared top-level class is NOT a new version — only changes to the `models` array (new/removed entity types, as in V2 → V3) produce a distinct checksum and a valid stage.** A property change that genuinely needs a versioned migration would require either forking the class (blocked by the iOS 26.4 cascade panic — see "Why TripTask is no longer forked") or a `.custom` stage keyed off a new entity type.++This also invalidates the plan, recorded in several frozen Phase-5/5.1 spec docs, to remove the deprecated V2 relationships (`Trip.participants`, `TripPackingItem.person`) "in a future `SchemaV4`": removing a property from a shared top-level class is the same structural-on-a-shared-class change and cannot be a distinct version either. Those columns therefore stay as inert dead storage unless/until a `.custom` migration keyed off a new entity type is warranted — not worth it on their own.+ ## Test environment detection  The probe is injectable; `ModelStore.configuration(probe:)` is `nonisolated` so tests can call it without main-actor isolation. The probe branches:
docs/agent-notes/sync-infrastructure.md Modified +4 / -2
diff --git a/docs/agent-notes/sync-infrastructure.md b/docs/agent-notes/sync-infrastructure.mdindex ac83e87..888fec1 100644--- a/docs/agent-notes/sync-infrastructure.md+++ b/docs/agent-notes/sync-infrastructure.md@@ -60,8 +60,10 @@ surfaces remain unimplemented.   protocol contract + `TranslatorError` + `kRecordBlobSizeCap` (256 KB   per blob field). - `Scramble/Scramble/Sharing/Translators/{Trip,TripTask,TripPackingItem,TripPersonSnapshot}RecordTranslator.swift` —-  one per entity. Relationships encoded as UUID-valued record fields-  (e.g., `personSnapshotID: String`), **never** `CKRecord.Reference`.+  one per entity. Relationships and value references encoded as+  UUID-valued record fields (e.g., `personSnapshotID: String` — now a+  stored value reference on `TripPackingItem`, not a `@Relationship`),+  **never** `CKRecord.Reference`.   Each translator preserves CKRecord system fields via the   `ckRecordSystemFields: Data?` blob on the @Model (decoded with   `decodeSystemFields(from:)`, re-encoded with `encodeSystemFields(of:)`
specs/phase-5-cloudkit-sharing/decision_log.md Modified +36
diff --git a/specs/phase-5-cloudkit-sharing/decision_log.md b/specs/phase-5-cloudkit-sharing/decision_log.mdindex 9ba340b..c8289c7 100644--- a/specs/phase-5-cloudkit-sharing/decision_log.md+++ b/specs/phase-5-cloudkit-sharing/decision_log.md@@ -492,3 +492,39 @@ Codex's iOS 26.4 SDK header reading clarified two points after the initial Decis - **`CKSyncEngine.State.add(pendingRecordZoneChanges:)` takes record IDs**, not `CKRecord`s; the delegate's `nextRecordZoneChangeBatch(_:syncEngine:)` supplies the actual records when the engine asks. Push-notification routing calls `engine.fetchChanges()` (not a non-existent `engine.handleEvent`).  ---++## Decision 14: `TripPackingItem.personSnapshot` is a value reference, not a relationship++**Date**: 2026-06-18+**Status**: accepted (amends Decision 7 — the denormalised-snapshot concept stands; only the storage form on `TripPackingItem` changes)++### Context++`TripPackingItem.personSnapshot` shipped (Decision 7) as `@Relationship var personSnapshot: TripPersonSnapshot?` with no inverse — `TripPersonSnapshot` has no `[TripPackingItem]` collection, and the snapshot↔item inverse is exactly the nullify chain that panics SwiftData's cascade traversal on iOS 26.4. This passed every test and ran on the simulator, where containers use `cloudKitDatabase: .none`. On a real device with an iCloud account, the `globals` container (which mirrors the full model list to CloudKit via `.private`) failed to load: `NSCocoaErrorDomain Code=134060 ... CloudKit integration requires that all relationships have an inverse: TripPackingItem: personSnapshot`. The container fell back to local-only, silently disabling iCloud sync for `Person` and the master lists.++### Decision++Store the reference as `personSnapshotID: UUID?` (a value reference, matching `TripTask.assigneePersonID`, Decision 9). Expose the snapshot through a `personSnapshot` computed bridge that resolves the ID via `trip?.participantSnapshots` and falls back to a `modelContext` fetch. The CKRecord wire format is unchanged (`personSnapshotID: String`); decode stores the bare ID (dangling references tolerated).++### Rationale++CloudKit's "all relationships need an inverse" rule applies to every relationship in a mirrored schema. The two ways to satisfy it were (a) add the inverse collection on `TripPersonSnapshot`, or (b) drop the relationship for a value reference. Option (a) re-creates the iOS 26.4 cascade panic the team already worked around for `participantSnapshots`. Option (b) removes the relationship entirely, so there is nothing for CloudKit to validate, and aligns with the existing `assigneePersonID` precedent. Predicates gain a directly-queryable scalar (`$0.personSnapshotID == id`) instead of a finicky relationship key-path.++### Alternatives Considered++- **Add an inverse collection on `TripPersonSnapshot`**: Satisfies CloudKit - Rejected: reintroduces the iOS 26.4 snapshot↔item cascade-traversal panic.+- **Split `globals` into two `ModelConfiguration`s (CloudKit + local) to exclude trip-zone models from the mirror**: Conceptually matches Decision 13's ownership table - Rejected: the deprecated `Trip.participants → Person` / `Person.tripPackingItems` relationships cross between the two model subsets, and SwiftData relationships can't span configurations.++### Consequences++**Positive:**+- `globals` CloudKit mirror loads on-device; `Person`/master-list iCloud sync restored.+- No cascade-panic exposure; `personSnapshotID` is directly predicate-able.+- Consistent with `assigneePersonID`'s dangling-reference model.++**Negative:**+- Reads go through a computed bridge that may issue a fetch when `participantSnapshots` isn't wired (cheap; production reads hit the in-memory path).+- An existing on-disk store's old `personSnapshot` relationship column is not data-migrated to `personSnapshotID` (acceptable — no production store predates this; the app never launched successfully on-device before the fix).+- `specs/phase-5-cloudkit-sharing/design.md` (frozen historical spec) still depicts `personSnapshot` as a `@Relationship` with a `TripPersonSnapshot.tripPackingItems` inverse — the same inverse the "Alternatives Considered" above rejects, and which was in fact never shipped. This decision and `docs/agent-notes/persistence.md` are the authoritative current state; the design doc is not retro-edited.++---
specs/phase-6-notifications-polish/decision_log.md Modified +33
diff --git a/specs/phase-6-notifications-polish/decision_log.md b/specs/phase-6-notifications-polish/decision_log.mdindex 08df27f..9d7ffe2 100644--- a/specs/phase-6-notifications-polish/decision_log.md+++ b/specs/phase-6-notifications-polish/decision_log.md@@ -574,3 +574,36 @@ The protocol exposes `authorizationStatus() async -> UNAuthorizationStatus` inst - A future field (e.g. quiet-hours setting) requires extending the protocol.  ---++## Decision 18: `Trip.countryCode` rides on `SchemaV3`; there is no `SchemaV4`++**Date**: 2026-06-18+**Status**: accepted (supersedes Decision 5's `SchemaV4` packaging; `Trip.countryCode` itself is unchanged)++### Context++Decision 5 added `Trip.countryCode` as a new `SchemaV4` with a lightweight V3 → V4 stage. `Trip` is a single top-level `@Model` shared by every schema version, so adding `countryCode` to it changed `SchemaV3`'s and `SchemaV4`'s view of `Trip` identically. SwiftData computes the version checksum from structure only (not `versionIdentifier`), so V3 and V4 became byte-identical. On a real device with an existing pre-V4 store, the migration plan was forced to traverse the V3 → V4 stage and crashed: `NSInvalidArgumentException: Duplicate version checksums across stages detected`. The in-memory tests missed it because a fresh store at the current version short-circuits the stage traversal.++### Decision++Remove `SchemaV4` and the V3 → V4 stage. `Trip.countryCode` lives on the current `Trip` and is part of `SchemaV3`; SwiftData adds the column to existing stores via automatic lightweight inference (Optional, `nil` default) — the same mechanism already used for `ckRecordSystemFields`. `AppMigrationPlan` is now `[V1, V2, V3]` with two stages.++### Rationale++A property-only addition to a shared top-level class cannot be a distinct schema version — the checksum collision is unavoidable. Only changes to a version's `models` array (new/removed entity types, as in V2 → V3) yield a distinct checksum. Since V3 already contains `countryCode` structurally, collapsing V4 into V3 is the only correct expression; lightweight inference covers the on-disk column addition.++### Alternatives Considered++- **Make `SchemaV4` structurally distinct by adding a dummy entity or forking `Trip`**: Restores a distinct checksum - Rejected: a dummy entity is dishonest schema noise; forking `Trip` reintroduces the iOS 26.4 two-`@Model`-same-name cascade panic that forced the `TripTask` consolidation.+- **Keep V4 but drop the V3 → V4 stage**: Plan still lists two identical-checksum versions - Rejected: the duplicate-checksum validation fires on the version set, not just the stage; removing the version is cleaner.++### Consequences++**Positive:**+- Container construction no longer crashes on-device; the app launches.+- The migration plan honestly reflects that V3 is the current schema.++**Negative:**+- Violates the "new `SchemaV<N>` per change" policy (Phase 3 Decision 11) for property-only changes — but that policy is physically unachievable under the shared-top-level-class pattern. Documented in `docs/agent-notes/persistence.md` ("Shared top-level classes can't express property-only schema bumps").++---
CLAUDE.md Modified +1 / -1
diff --git a/CLAUDE.md b/CLAUDE.mdindex c8d6dbf..1823aa2 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co  ## Project status -Scramble is a native iOS app (macOS planned later) for trip planning, packing, and shared family coordination via CloudKit. Phases 1–6 have landed: SwiftData model + CloudKit-aware container, Theme system + Midnight Atlas variants, two-tab app shell, Trip CRUD with inline person creation, deterministic rules engine + Master Lists UI, timeline accordion + Trip-level Tasks, the per-person Packing Sheet with pack / repack modes and the `WhyDisclosure` explainability surface, Phase 5's CloudKit sharing infrastructure — `SchemaV3` with `TripPersonSnapshot` / `TripZoneState` / `MigrationJournalEntry`, dual SwiftData containers (globals + tripsLocal), Stage A + Stage B migration pipeline, `TripSyncEngine` over two `CKSyncEngine` instances, `CloudKitSharingService` + `UICloudSharingController` wrapper, the Share toolbar button and Trip Detail Participants section, silent-push routing, and the release-prep CloudKit-schema-promotion checklist. Phase 5.1 then routed Trip CRUD through `tripsLocal` so the sharing pipeline actually carries edits — container topology + chokepoint extension (`LocalWriteHook.commitDeletion`) + view-layer read/write conversion to `TripPersonSnapshot` + the 15-step `ZoneMigrationCoordinator` relocation algorithm with `TripSyncEventBus` multicast and `SignInResumeCoordinator` storm-collapse all landed. Phase 6 added local activation notifications (`SchemaV4` + `Trip.countryCode`, `NotificationIdentifier`/`NotificationPlanner`/`NotificationReconciler` pure primitives, `NotificationsService` orchestrator wired through `PendingChangeBroadcaster` over `LocalWriteHook`, `NotificationRouter` for taps + the `scramble://` URL scheme, `RootView` tap consumption), the deferred Trip Detail flag emoji, and the polish pass (shared `Animation.scrambleStandard`, haptics matrix, accessibility custom actions). See `specs/phase-1-foundation/` through `specs/phase-6-notifications-polish/` for what shipped and `docs/agent-notes/` (`persistence.md`, `rules-engine.md`, `packing-sheet.md`, `sync-infrastructure.md`, `phase-5-ui-surfaces.md`, `notifications.md`, `accessibility.md`) for load-bearing implementation notes.+Scramble is a native iOS app (macOS planned later) for trip planning, packing, and shared family coordination via CloudKit. Phases 1–6 have landed: SwiftData model + CloudKit-aware container, Theme system + Midnight Atlas variants, two-tab app shell, Trip CRUD with inline person creation, deterministic rules engine + Master Lists UI, timeline accordion + Trip-level Tasks, the per-person Packing Sheet with pack / repack modes and the `WhyDisclosure` explainability surface, Phase 5's CloudKit sharing infrastructure — `SchemaV3` with `TripPersonSnapshot` / `TripZoneState` / `MigrationJournalEntry`, dual SwiftData containers (globals + tripsLocal), Stage A + Stage B migration pipeline, `TripSyncEngine` over two `CKSyncEngine` instances, `CloudKitSharingService` + `UICloudSharingController` wrapper, the Share toolbar button and Trip Detail Participants section, silent-push routing, and the release-prep CloudKit-schema-promotion checklist. Phase 5.1 then routed Trip CRUD through `tripsLocal` so the sharing pipeline actually carries edits — container topology + chokepoint extension (`LocalWriteHook.commitDeletion`) + view-layer read/write conversion to `TripPersonSnapshot` + the 15-step `ZoneMigrationCoordinator` relocation algorithm with `TripSyncEventBus` multicast and `SignInResumeCoordinator` storm-collapse all landed. Phase 6 added local activation notifications (`Trip.countryCode` on `SchemaV3` — a post-Phase-6 bugfix collapsed the short-lived `SchemaV4` back into V3 because a property-only addition to a shared top-level class can't be a distinct schema version; see `persistence.md`, `NotificationIdentifier`/`NotificationPlanner`/`NotificationReconciler` pure primitives, `NotificationsService` orchestrator wired through `PendingChangeBroadcaster` over `LocalWriteHook`, `NotificationRouter` for taps + the `scramble://` URL scheme, `RootView` tap consumption), the deferred Trip Detail flag emoji, and the polish pass (shared `Animation.scrambleStandard`, haptics matrix, accessibility custom actions). See `specs/phase-1-foundation/` through `specs/phase-6-notifications-polish/` for what shipped and `docs/agent-notes/` (`persistence.md`, `rules-engine.md`, `packing-sheet.md`, `sync-infrastructure.md`, `phase-5-ui-surfaces.md`, `notifications.md`, `accessibility.md`) for load-bearing implementation notes.  Stack: iOS 26+, Swift 6.0 language mode (Xcode 26 toolchain), SwiftUI, SwiftData, CloudKit (CKShare for per-trip sharing). Bundle ID `me.nore.ig.Scramble`, CloudKit container `iCloud.me.nore.ig.scramble`. Project lives at `Scramble/Scramble.xcodeproj` (nested folder, standard Xcode layout). 
CHANGELOG.md Modified +5
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 46a67cc..0788169 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Fixed +- On-device launch failures (SwiftData migration + CloudKit):+  - `Scramble/Scramble/Models/Schema.swift`, `Scramble/Scramble/Persistence/ModelStore.swift` — removed the short-lived `SchemaV4` and the V3 → V4 migration stage that crashed on-device with `NSInvalidArgumentException: Duplicate version checksums across stages detected`. `SchemaV4` was byte-identical to `SchemaV3` because its only addition (`Trip.countryCode`) lives on the shared top-level `Trip` class that every schema version references, and SwiftData computes the version checksum from structure, not the version number. `Trip.countryCode` now belongs to `SchemaV3` and is added to existing stores via automatic lightweight inference; `AppMigrationPlan` is back to `[V1, V2, V3]`.+  - `Scramble/Scramble/Models/TripPackingItem.swift` (plus `SnapshotMaintenance`, `TripPackingItemRecordTranslator`, `SchemaV3MigrationStage`, `ZoneMigrationCoordinator`, `RecordRepresentable` doc) — converted `TripPackingItem.personSnapshot` from an inverse-less `@Relationship` to a `personSnapshotID: UUID?` value reference (mirroring `TripTask.assigneePersonID`). The `globals` container's SwiftData CloudKit mirror rejected the inverse-less relationship on a real device (`NSCocoaErrorDomain Code=134060 ... requires that all relationships have an inverse: TripPackingItem: personSnapshot`), dropping `globals` to a local-only fallback that silently disabled iCloud sync for `Person` and the master lists. A `personSnapshot` computed bridge keeps every read site unchanged, and the `CKRecord` wire format (`personSnapshotID: String`) is unchanged. Predicates use the stored `personSnapshotID`.+  - `Scramble/ScrambleTests/Persistence/SchemaV4MigrationTests.swift` removed; `SchemaV3MigrationTests.swift` gains `countryCode`-on-V3 coverage and updated plan-shape assertions; three test setups swapped `SchemaV4` → `SchemaV3`. `docs/agent-notes/persistence.md`, `CLAUDE.md`, and the decision logs (Phase 5 Decision 14, Phase 6 Decision 18) document the value-reference change and why a property-only bump on a shared top-level class can't be a distinct schema version.+ - Phase 6 PR #7 review iteration 5 (final nits):   - `Scramble/Scramble/Features/Trips/TripListView.swift` — dropped redundant `@MainActor` annotation from the `Task { ... }` wrapping the auth-request call; the surrounding `@MainActor struct TripListView` body already runs there.   - `Scramble/Scramble/Notifications/NotificationReconciler.swift` — renamed the `for plan in plan` loop variable to `entry` to silence the shadowing warning that strict compiler settings would surface.

Things to double-check

On-device launch is structurally untestable in the suite

Both fixed failures fire only on a real device with an iCloud account; tests use cloudKitDatabase: .none and fresh in-memory stores. The fix was validated by a green build, clean lint, and the value-reference behaviour passing where the suite let it run — but the actual device launch must be confirmed by installing on hardware.

Existing on-device store: clean reinstall recommended

The relationship→value change drops the old personSnapshot relationship column without migrating its data into personSnapshotID. The crash means the old store can't open anyway; deleting the app before reinstalling guarantees a fresh V3 store and avoids any half-migrated state. A data-preserving custom stage would only be needed if a real store with owner links must survive.

Test-suite flakiness is pre-existing, not from this change

make test-quick reports ** TEST FAILED ** with hundreds of 0.000s no-assertion phantom failures — but this reproduces identically on unmodified main (the simulator host crashes/relaunches across clones). Confirmed via baseline run. Not a regression of this commit.