Single Source Of Truth: SwiftData, MCP, iCloud

Get Bananas has three callers that can write to the same shopping list. A human tapping rows in the iOS app. Apple Intelligence routing a Siri request through an AppIntent. A Claude Code session calling an MCP tool over stdio. The list is one canonical thing in the user’s head; the question is where it lives in storage and which caller wins when they disagree.

The synthesis post named the three surfaces of an iOS app: human, Apple Intelligence, agent. Each surface needs to read and write the same domain state. That demand is what produces the architectural mistake that ships in too many apps: each surface gets its own store, the surfaces drift, and the user sees three different versions of their list depending on which they last touched. The pattern that survives is a single source of truth with explicit synchronization paths between the surfaces and the substrate.

The post names the substrate choices, the conflict-resolution rules each one forces, and the architecture that holds when all three caller classes can write. The walkthrough uses Get Bananas’s actual layout: SwiftData for in-app state, an iCloud Drive JSON file for cross-process sync, an MCP server reading and writing the same file from outside the iOS sandbox.1

TL;DR

  • Three substrates compose: SwiftData (in-process, fast, schema-typed), iCloud Drive (cross-process, file-based, syncable), NSUbiquitousKeyValueStore (cross-device, key-value, small-payload only).
  • Pick which substrate is the source of truth per domain. Conflict resolution is a forced consequence of the choice, not a separate problem.
  • The three surfaces (human, Apple Intelligence, agent) each interact with the substrate through the domain layer. The domain layer is where conflict-resolution policy lives.
  • A lastModified timestamp on every mutable entity is the cheap conflict-resolution primitive; “last writer wins” is the cheap policy. Apps that need stronger guarantees pay for them with explicit merge logic.
  • An MCP server outside the app process cannot read SwiftData. The bridge is a serialization layer (JSON file in iCloud Drive, App Group container, etc.) that both the in-app SwiftData reader and the out-of-process MCP server can talk to.

The Three Substrates

Apple gives shipped iOS apps three native persistence substrates that show up in cross-process and cross-device patterns. Each has a specific shape; mixing them without a plan produces the drift problem.

SwiftData. In-process persistent storage backed by Core Data.2 Fast, schema-typed, queryable through @Query, integrated with SwiftUI’s observation system. The store is owned by the app process. App extensions (widgets, intents, share extensions) can share a SwiftData container via an App Group with explicit configuration; an arbitrary external MCP process running on a developer’s machine outside the app’s signing context cannot safely reach into the SwiftData container. Rows are addressable through PersistentIdentifier (in-process) or @Attribute(.unique) natural keys (cross-process, cross-device). Migrations are declarative through VersionedSchema and MigrationPlan (covered in SwiftData’s Real Cost Is Schema Discipline).

iCloud Drive. File-based cross-device sync, surfaced through FileManager URLs in the user’s iCloud container.3 Files appear on every device the user is signed into. Conflict resolution is at the file level: iCloud uses NSFileVersion to track concurrent edits, and the app reads through the conflict log to decide what to keep. Files in iCloud Drive are addressable from outside the iOS app process: a Mac MCP server can open the same JSON file the iOS app reads. The substrate is what makes Get Bananas’s MCP integration work.

NSUbiquitousKeyValueStore. Cross-device key-value storage. Apple’s current public limits are 1MB total per app, 1MB per value, 1024 keys, 128 UTF-16 characters per key, with throttled write rates.4 Conflict resolution is built in: the system serializes writes and notifies all devices on change. Appropriate for small, low-frequency state (settings, last-selected-tab, an integer counter); inappropriate for high-write-rate data or workloads where the throttle becomes the bottleneck. Return uses it for cross-device timer state (the user starts a timer on iPhone, sees it on Apple Watch); covered in Five Apple Platforms, Three Shared Files.

The fourth substrate, CloudKit, is the obvious-looking choice that the cluster’s apps have explicitly rejected for cross-process MCP integration. CloudKit gives strong cross-device sync with conflict-aware records, and Apple does ship CloudKit JS and CloudKit Web Services for non-Apple environments to talk to public and private CloudKit databases. The honest tradeoff is cost of integration, not impossibility: a Node.js MCP server reaching a private CloudKit database has to wire up CloudKit Web Services authentication, schema definitions, and request signing, which is material engineering work compared to “open a JSON file.” Get Bananas chose iCloud Drive plus a JSON file because the MCP server is a Node.js process that needs to read and write the same data the iOS app sees, and ordinary file I/O is the path of least resistance.1

The Decision: Which Substrate Holds Truth

The question is not which substrate to use. The question is which substrate is the source of truth for which domain. The other substrates either cache it, mirror it, or stay out of the way.

The decision matrix that survives production for cluster apps:

Domain shape Source of truth Why
Settings, preferences, simple flags NSUbiquitousKeyValueStore Cross-device sync is automatic; collisions are serialized; small payload fits
Per-device transient state UserDefaults (no sync) Device-local; should not survive a fresh install on a different device
In-process queryable collection SwiftData Fast @Query, SwiftUI observation, schema-typed; in-process only
In-process collection that must reach external processes iCloud Drive JSON file (export to disk) Both the iOS SwiftData reader and the external MCP server can read the file
Large per-user content (photos, audio, documents) iCloud Drive (per-file) The user’s iCloud is the natural store; CloudKit can layer on top for richer sync
Cross-device session-level state (timer running on iPhone, viewable on Watch) NSUbiquitousKeyValueStore Fits the size envelope; needs the cross-device push semantics

The decision shapes the conflict-resolution policy. For the local-app-plus-external-MCP bridge, SwiftData has no inherent conflict resolution between processes; if two callers write to the same row, the last try context.save() wins. SwiftData backed by CloudKit and using persistent history can carry richer cross-device semantics, but that surface is iOS-side and does not help the external Node.js MCP case. iCloud Drive surfaces conflicts as NSFileVersion entries; the app has to walk them and pick a winner. NSUbiquitousKeyValueStore has built-in conflict resolution at the value level.

The Get Bananas Architecture

Get Bananas’s actual layout:

                     ┌────────────────────────────────────┐
                     │           User's mental model       │
                     │         "my shopping list"          │
                     └─────────────────┬──────────────────┘
                                       │
                          ┌────────────┴───────────┐
                          │                        │
                  ┌───────▼────────┐      ┌───────▼─────────┐
                  │   iOS app      │      │  MCP server      │
                  │  (Get Bananas) │      │  (Node.js)       │
                  └───────┬────────┘      └───────┬─────────┘
                          │                        │
              ┌───────────┴────────┐               │
              │                    │               │
       ┌──────▼──────┐    ┌────────▼──────────┐   │
       │  SwiftData   │    │  iCloud Drive     │◀──┘
       │ (in-process) │◀──▶│  shopping_list.   │
       │              │    │       json        │
       └──────────────┘    └───────────────────┘

  In-app reads/writes flow through SwiftData.
  Cross-process reads/writes flow through the JSON file.
  An iCloud sync layer (iCloudBackupManager) reconciles the two.

The architecture has three rules.

SwiftData is the source of truth for in-process queries. The iOS app reads from SwiftData for every UI render, every @Query-backed list, every search. Writes go through SwiftData first; the model context saves; SwiftUI re-renders.

The JSON file is the source of truth for cross-process state. Whenever the iOS app saves to SwiftData, an iCloud backup manager exports the current state to a JSON file in the user’s iCloud Drive container. Whenever the MCP server writes, it writes to the same JSON file. The file is the bridge.

A sync pass runs on iOS app launch and after every cross-process write. The sync logic in production today (SyncManager.applyExport) treats the JSON backup as authoritative on each pass: it reads the JSON file, matches each row to SwiftData by UUID, overwrites existing rows with the backup’s values, adds rows the backup has but SwiftData does not, and deletes rows SwiftData has but the backup does not (with a corruption guard against an empty backup file wiping the local database). The policy is backup wins at sync time, not per-row last-writer-wins by timestamp. Combined with the iOS app re-exporting after every save, the steady-state convergence is fast in practice: whichever process wrote most recently produced the JSON the next sync reads.

The architecture trades complexity for cross-process reach. A pure SwiftData app does not need any of this; an app with no MCP server does not need the JSON bridge; an app with no cross-device sync does not need the reconciler. Get Bananas needs all three because all three caller classes (human via iOS, Apple Intelligence via App Intents on iOS, agent via MCP from a Mac developer’s machine) all touch the same shopping list.

The Upgrade Path: Per-Row Last-Writer-Wins

The shipping policy of “backup wins at sync time” is cheap and works for the single-user, single-active-writer-at-a-time case. It struggles when both the iOS app and the MCP server write to the JSON file in close succession: whichever process wrote most recently overwrites the other’s changes, including for rows that did not actually conflict. The mitigation today is the iOS app re-exporting after every SwiftData save, which keeps the JSON file roughly aligned with the most recent in-app state. Steady state is fine; the genuinely concurrent case can lose work.

The cheapest upgrade is per-row last-writer-wins keyed on a lastModified: Date? column. The ShoppingItem model already has the field for migration safety (covered in SwiftData’s Real Cost Is Schema Discipline), but the JSON export and the MCP server do not currently serialize or honor it. Threading lastModified through the export and through applyExport would change the merge policy from “backup wins” to “newer row wins”:

  • Both sides have a value, one is more recent. Most recent wins. The other side’s row is updated.
  • Both sides have a value, they tie. Tie-breaking by primary key, or by surface (the iOS app wins ties to favor the user’s most recent in-app interaction).
  • One side has a value, the other does not. The side with the value wins.
  • Neither side has a value. Both rows are pre-lastModified-era data. The reconciler stamps Date() for next time.

The policy is cheap, easy to reason about, and wrong in roughly 1% of cases (concurrent edits to different fields of the same row). For a shopping list, the 1% does not matter; for a document editor, it absolutely does. Apps that need stronger guarantees layer field-level merging, CRDTs, or operational transforms on top of this base; Get Bananas has not needed that complexity yet, which is why per-row LWW is on the roadmap and richer merging is not.

The Three Caller Classes And The Substrate

Mapping the caller classes from Three Surfaces onto the substrate decisions:

The human surface writes through SwiftData. A user tapping a checkbox in the iOS app fires through the SwiftUI layer to a domain function that mutates the SwiftData row, stamps lastModified = Date() on the model, and saves the model context. The iCloud export writes the current state to the JSON file. The MCP server picks up the new state on its next read.

The Apple Intelligence surface writes through SwiftData. An AppIntent invoked through Siri runs in the iOS app process and reaches the same domain function the human surface uses. SwiftData state mutates, the model’s lastModified updates, and the JSON export captures the new state.

The agent surface writes through the JSON file. An MCP tool call from a Claude Code session on a Mac mutates the JSON file directly (with file-locking to handle concurrent writes from the iOS app). The next time the iOS app launches or syncs, SyncManager.applyExport reads the file, walks the items by UUID, updates rows that exist on both sides from the backup’s values, adds rows the backup has, and deletes rows the backup omits (with the empty-backup guard). The shipping policy is backup wins at sync time; the upgrade path adds lastModified to the JSON so the policy can shift to newer row wins.

The asymmetry is real and intentional. The human and Apple Intelligence surfaces both run inside the iOS app process and use SwiftData natively. The agent surface runs outside the iOS app process and uses the JSON file because that is the substrate it can reach. The reconciler is what holds the two halves together.

When This Pattern Is The Wrong Answer

A few cases where the JSON-bridge pattern is wrong.

High-write-rate data. A live document editor with many edits per second cannot pay the cost of serializing the entire collection to a JSON file on every write. The right answer is operational transforms or CRDTs against a real backend.

Strong consistency requirements. A financial-transactions ledger cannot tolerate last-writer-wins on the JSON file. The right answer is CloudKit (or a server-side database) with explicit transaction semantics.

Multi-user collaboration where users see each other’s edits in real time. iCloud Drive sync is eventual, not real-time. The user closing the app on one device and opening it on another sees state that synced; the user seeing another user’s cursor moving across a document does not. The right answer is a real-time collaboration framework (yjs, automerge, or a custom WebSocket layer).

Cases where the agent and the user are different identities. The Get Bananas pattern assumes the agent (the MCP caller) and the human user (the iOS app user) are the same person, just operating across processes. If the agent is acting on behalf of a different identity (a shared list, an admin, an automated bot), the JSON file in the user’s iCloud Drive is the wrong substrate; multi-user persistence with explicit auth is required.

The pattern fits the single-user, eventually-consistent, cross-process case. Most apps with MCP integrations are exactly that case; some are not.

What I Would Build Differently

Three patterns the cluster’s apps have either shipped or wish they had.

Make the JSON serialization explicit, not implicit. The first version of Get Bananas exported every SwiftData write to JSON in a save hook. The second version made the export an explicit step the app calls when state has stabilized. The change reduced redundant writes and made it clear when the cross-process state had been published. An implicit save-on-every-mutation hook produces too much I/O for any non-trivial collection.

Version the JSON file’s schema. The JSON file has its own schema independent of SwiftData’s VersionedSchema. When the SwiftData schema changes (say, adding a field), the JSON serialization has to follow. The cheap fix is to put a schemaVersion: Int field at the top of the JSON; the reconciler reads it and applies the right interpretation. Without versioning, a v2 iOS app reading a v1 JSON file written by an old MCP server hits silent data corruption.

File-lock the JSON, do not assume coordination. The iOS app and the MCP server both write to the JSON file. Without an NSFileCoordinator (in-process, iOS-side) and a file lock (out-of-process, on the developer’s machine), concurrent writes can produce a corrupted file. Get Bananas’s MCP server uses an flock-style file lock on the JSON; the iOS app uses NSFileCoordinator for its writes; the file is rarely contended in practice but the safety belt is cheap.

What The Pattern Means For Apps Shipping On iOS 26+

Three takeaways.

  1. Pick one source of truth per domain. The other substrates cache, mirror, or stay out of the way. SwiftData for in-process queries, iCloud Drive JSON for cross-process bridges, NSUbiquitousKeyValueStore for small cross-device state. Conflict resolution is a downstream consequence of the choice.

  2. lastModified plus last-writer-wins is the cheap base case. Most apps do not need stronger guarantees. The 1% of cases that need field-level merging or CRDTs are expensive to add; do not pay the cost until the data shape demands it.

  3. The reconciler is the load-bearing piece. When SwiftData and the JSON file disagree, the reconciler decides. The reconciler runs on app launch, after cross-process writes, and after iCloud sync events. The rules are simple; the discipline is to actually run it.

The full Apple Ecosystem cluster: typed App Intents for the Apple Intelligence surface; MCP servers for the agent surface; the routing question between them; Foundation Models for in-app on-device LLM features; the runtime vs tooling LLM distinction; the three surfaces of an iOS app synthesis; Live Activities for the iOS Lock Screen state machine; the watchOS runtime contract on Apple Watch; SwiftUI internals for the human surface’s substrate; RealityKit’s spatial mental model for visionOS scenes; SwiftData schema discipline for persistence; Liquid Glass patterns for the visual layer; multi-platform shipping for cross-device reach. The hub is at the Apple Ecosystem Series. For broader iOS-with-AI-agents context, see the iOS Agent Development guide.

FAQ

Why not use CloudKit for cross-process sync?

CloudKit gives strong cross-device sync with conflict-aware records, and Apple’s CloudKit JS / CloudKit Web Services do let non-Apple stacks reach a private CloudKit database. The constraint is integration cost: a Node.js MCP server using CloudKit has to handle CloudKit’s auth (server-to-server keys or user-level tokens), schema declarations, and signed requests. iCloud Drive plus a JSON file is ordinary file I/O, which is the universal translator. CloudKit is the right choice when the team is willing to pay the integration cost and wants CloudKit’s stronger sync and conflict semantics; the JSON bridge is the right choice when “open a file” is enough for the data shape.

How do you handle conflicts when two callers write at the same time?

Get Bananas’s shipping policy is “backup wins at sync time”: SyncManager.applyExport walks items by UUID and overwrites local rows from the backup, with a guard against an empty backup wiping good local data. The upgrade path is per-row last-writer-wins keyed on lastModified, which the model already carries but which is not yet serialized through the JSON bridge. Adding it would resolve the ~99% of conflicts where one side genuinely is newer; the remaining 1% (concurrent edits to different fields of the same row) is rare enough for the apps so far to skip field-level merging or CRDTs. Apps with stronger consistency requirements layer richer merging on top.

Where does SwiftData fit if iCloud Drive is the source of truth for cross-process state?

SwiftData is the source of truth for in-process queries. The iOS app reads SwiftData for every UI render, every @Query, every search. SwiftData is fast, schema-typed, and integrated with SwiftUI’s observation system. When the iOS app writes, the change goes to SwiftData first, then is exported to the JSON file. The JSON file is the source of truth for cross-process reads (the MCP server’s view); SwiftData is the source of truth for in-process reads (the iOS UI’s view). They stay aligned through the reconciler.

What about NSUbiquitousKeyValueStore for the shopping list itself?

NSUbiquitousKeyValueStore is capped at 1MB total per app and 1MB per value with throttled writes, and serializes on a 1024-key dictionary. A shopping list with hundreds of items plus history may fit by byte count, but writing per-item changes through the throttle is the wrong shape; bulk collection updates compete for the rate budget against everything else the app stores. The right substrate for collections is either SwiftData (in-process) or iCloud Drive (cross-process). Reserve NSUbiquitousKeyValueStore for small low-frequency key-value state: settings, the last-selected-tab, a counter, a feature-flag override.

How do I know which substrate to pick for a new domain in my app?

Three questions in order. First: does anything outside the iOS app process need to read or write this domain? If yes, you need iCloud Drive (file-based, ordinary file I/O) or CloudKit (via Apple’s frameworks or CloudKit Web Services from non-Apple stacks) or a server you control. If no, SwiftData is the default. Second: does this need to sync across the user’s devices? If yes, the substrate has to support that (iCloud Drive does, SwiftData does not unless paired with iCloud syncing). Third: how big is the payload and how frequently does it change? Small + low-frequency lives in NSUbiquitousKeyValueStore; everything else needs a real persistence layer.

References


  1. Author’s analysis in Two Agent Ecosystems, One Shopping List, April 29, 2026, and the Get Bananas project’s iCloud Drive JSON sync layer in Banana List/iCloudBackupManager.swift. The architecture pairs SwiftData with a JSON file in the user’s iCloud Drive container that an external MCP server reads and writes. 

  2. Apple Developer, “SwiftData” and “Adding and editing persistent data in your app”. The @Model macro, @Attribute constraints, and the relationship to Core Data’s NSManagedObjectModel. Author’s analysis in SwiftData’s Real Cost Is Schema Discipline covers VersionedSchema and MigrationPlan for safe schema evolution. 

  3. Apple Developer, “Synchronizing documents in the iCloud environment”. File-based cross-device sync, conflict resolution through NSFileVersion, and the NSFileCoordinator API for safe in-process writes against shared files. 

  4. Apple Developer, “NSUbiquitousKeyValueStore”. Cross-device key-value storage. Apple’s current limits: 1MB total per app, 1MB per value, 1024 keys, 128 UTF-16 characters per key, throttled write rate. Author’s analysis in Five Apple Platforms, Three Shared Files covers the cross-device timer pattern Return ships using this API. 

Artículos relacionados

Two Agent Ecosystems, One Shopping List: An MCP Server Living Alongside an iOS App

Get Bananas runs on iOS, macOS, watchOS, visionOS. It also lives inside Claude Desktop as an MCP server. The bridge is i…

19 min de lectura

App Intents vs MCP: The Routing Question

Two protocols, one app. App Intents expose your app to Apple Intelligence. MCP exposes the same domain to Claude, ChatGP…

16 min de lectura

Your Agent Has a Middleman You Didn't Vet

Researchers tested 28 LLM API routers. 17 touched AWS canary credentials. One drained ETH from a private key. The router…

15 min de lectura