Last-writer-wins is a schema decision
When Endeo eventually syncs a note across an iPad and a phone, the hard part is not uploading bytes. It is what happens when both devices edited while offline and the stack has to pick a survivor. SwiftData's CloudKit path — and most last-writer-wins syncs — will not ask you which paragraph to keep. They keep one record version. That is fine only if your schema made losing a field cheap, and losing a whole note rare.
The merge policy you do not get to customize
Enable CloudKit on a SwiftData container and you inherit NSPersistentCloudKitContainer's default: the later change wins at the record level. There is no public SwiftData API that hands you an NSMergeConflict and lets you field-merge two handwritten note bodies. If the product needs that, you have already left the happy path — Core Data bridge, CKSyncEngine, or your own sync protocol.
That is not an Apple-specific gotcha. Supabase, custom REST, and most "just sync the table" backends end in the same place unless you invent CRDTs. Treat last-writer-wins as the merge policy you ship with, not as a temporary limitation until the SDK grows up.
Mutating one row on two devices is the bug
Conflicts hurt when two writers update the same mutable record. A single Note row with a growing body string is the classic shape: iPad appends a margin note, phone fixes a typo in the first sentence, both save, and sync keeps whichever write finished last. One edit evaporates. The user experiences that as the app "ate my note," which is worse than a crash.
On Endeo the local store is already the source of truth — SwiftData first, remote sync optional. That architecture only stays honest under multi-device use if the things people edit concurrently are not one fat mutable blob. Offline-default buys you continuity; it does not buy you a merge algorithm.
Prefer appends, events, and tombstones
Design so concurrent work becomes inserts. Capture a pen stroke batch, a highlight, a session event, or a correction as its own record with a stable id and a createdAt. Two devices inserting different events never collide. Ordering is a query problem; conflict becomes a non-event.
Deletes need the same honesty. A hard delete that a lagging device never saw comes back as a resurrection when that device uploads an older version. Soft-delete with a tombstone flag (and a retention window long enough for every device to sync once) is dull and reliable. Physical purge later is a batch job, not part of the interactive path.
Keep a short list of truly mutable fields — display title, sort order, a preference flag — and accept last-writer-wins there with an updatedAt the UI can show. Do not put the sermon notes body in that list.
CloudKit shape constraints are part of the schema
If you do use SwiftData's CloudKit integration, every non-relationship attribute on a synced model must be optional or have a default. CloudKit enforces that at sync time, not compile time. A required String with no default can exist locally and silently refuse to upload — device A has the row, device B never will, and nothing obvious appears in the console until you go looking.
Unique constraints and non-optional relationships are similarly hostile to the sync path. Schema reviews for a syncing app are not style nits; they are whether the record will leave the device. We treat "CloudKit-safe defaults" as a structural test on the model types, the same way we treat the no-backend build as a structural test for offline-default.
How we prove it
Two physical devices, same iCloud or sync account, airplane mode on both. Edit different append-only pieces on each device, then edit the same mutable preference on both. Bring them online. The appends should all appear; the preference should resolve to one value without resurrecting a deleted note or dropping an event.
That test is the acceptance criteria for "we added sync," not a green unit suite against an in-memory store. Simulators lie about CloudKit timing. If the product cannot spare two devices for an afternoon, it is not ready to promise multi-device.
Background entry points are a separate budget — App Intents do not get forever to drain a queue. Conflict design does not replace a resumable sync unit; it decides what that unit is allowed to overwrite when it finally runs.