
Current Architecture
Data Models SwiftData @Model
- Home → has many
StorageLocations (cascade delete) - StorageLocation → belongs to
Home, has parent/child locations (tree), has manyInventoryItems - InventoryItem → belongs to
StorageLocation, has title, description, tags, emoji, photo filename
CloudKit Integration
- Uses SwiftData's built-in CloudKit sync via
ModelConfiguration(cloudKitDatabase: .private(...)) - Backed by NSPersistentCloudKitContainer under the hood
- Syncs to the private database only — single-user sync across devices
- No
CKSyncEngineusage — automatic SwiftData/CloudKit integration CloudSyncCoordinatorpolls CloudKit availability status; does NOT manage sync itself
Photos
- Stored locally in
Documents/ItemPhotos/as JPEG files - Referenced by
photoFileNameonInventoryItem - NOT synced via CloudKit — photos are device-local only
- This is already a problem for single-user multi-device sync
Entitlements
- CloudKit enabled:
iCloud.com.barronroth.Cubby - Push notifications (aps-environment: production)
- App Sandbox enabled
Architecture Options, Ranked
🥇 Option 1: Core Data + NSPersistentCloudKitContainer Sharing Recommended
Drop down from SwiftData to Core Data for the persistence layer, use NSPersistentCloudKitContainer's built-in sharing APIs (CKShare, shared zones).
- Apple's officially supported path for CloudKit sharing with a local database
- Mature (available since iOS 15, improved in iOS 16+)
- Handles the hard parts: conflict resolution, zone management, share lifecycle
- Apple's own sample code demonstrates this pattern
- DTS engineers explicitly recommend this over SwiftData for sharing
Effort: High — requires migrating from SwiftData to Core Data. But the sharing infrastructure is provided.
🥈 Option 2: Direct CloudKit API (CKShare + CKRecord + Custom Sync)
Use CloudKit framework directly. Create CKShare objects for shared zones, manage CKRecords manually, build your own sync engine.
Maximum control, but you're building a sync engine from scratch — conflict resolution, error handling, retry logic, offline support — all on you. Months of work.
🥉 Option 3: Hybrid — SwiftData (Private) + Core Data (Shared)
Keep SwiftData for private data, add a parallel Core Data stack specifically for shared homes.
Two persistence stacks is complex. Data duplication risk. If a home transitions from private to shared, you need to migrate records between stores.
Option 4: SharePlay / Group Activities Not Suitable
SharePlay is for real-time collaborative sessions (watching movies, whiteboarding). Requires FaceTime/Messages. Does NOT provide persistent shared state.
Option 5: SwiftData Shared Database Not Possible
As of iOS 19 / WWDC 2025, SwiftData still does NOT support CloudKit shared or public databases. Apple DTS explicitly confirms this.
Option 6: Custom Server (Firebase, Supabase, etc.) Overkill
Adds server costs, account management, and a dependency outside Apple's ecosystem. Only consider this if you need sharing with non-Apple users.
Option 7: iCloud Family Sharing Not Applicable
Family Sharing shares purchases, subscriptions, and storage plans. It does not provide a shared CloudKit database or any mechanism for sharing app data.

Recommended Approach
Core Data + NSPersistentCloudKitContainer Sharing
This is the only Apple-supported path for sharing structured data between iCloud accounts with automatic sync.
Key Concepts
Three CloudKit Database Scopes
- Private — User's own data (current Cubby behavior)
- Shared — Data shared with this user by others (received shares)
- Public — App-wide data visible to all users (not relevant here)
CKShare
A CKShare is a CloudKit record that represents a sharing relationship. It lives in a shared zone in the owner's private database, contains participant info, has a url property for generating share links, and supports read-only or read-write permissions per participant.
How NSPersistentCloudKitContainer Sharing Works
- Owner creates a
CKSharefor a Core Data object (theHome) - The container automatically creates a shared CloudKit zone for that object and its relationships
- Owner sends the share URL to the participant
- Participant opens the URL, triggering
userDidAcceptCloudKitShareWithin the app - The container automatically mirrors shared records into the participant's shared persistent store
- Both users see the same data; changes sync automatically
Architecture Design
You need two persistent store descriptions: one for the private CloudKit database (user's own data) and one for the shared CloudKit database (data shared by others).
Setting Up the Container
Swiftimport CoreData
import CloudKit
class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
// The store that maps to the private CloudKit database
private var privatePersistentStore: NSPersistentStore?
// The store that maps to the shared CloudKit database
private var sharedPersistentStore: NSPersistentStore?
init() {
container = NSPersistentCloudKitContainer(name: "Cubby")
// Private store
guard let privateDescription = container.persistentStoreDescriptions.first else {
fatalError("No store descriptions found")
}
privateDescription.url = Self.privateStoreURL
privateDescription.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.barronroth.Cubby"
)
privateDescription.cloudKitContainerOptions?.databaseScope = .private
// Shared store — separate SQLite file
let sharedDescription = NSPersistentStoreDescription(url: Self.sharedStoreURL)
sharedDescription.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.barronroth.Cubby"
)
sharedDescription.cloudKitContainerOptions?.databaseScope = .shared
container.persistentStoreDescriptions = [privateDescription, sharedDescription]
// Enable persistent history tracking (required)
for description in container.persistentStoreDescriptions {
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
}
container.loadPersistentStores { description, error in
if let error { fatalError("Failed to load store: \(error)") }
}
// Assign store references
for store in container.persistentStoreCoordinator.persistentStores {
let scope = store.cloudKitContainerOptions?.databaseScope
if scope == .private { privatePersistentStore = store }
else if scope == .shared { sharedPersistentStore = store }
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
Creating a Share
Swiftextension PersistenceController {
func shareHome(
_ home: Home,
completion: @escaping (CKShare?, Error?) -> Void
) {
guard let privatePersistentStore else { return }
// Shares the Home AND all relationships (StorageLocations, Items)
container.share(
[home],
to: nil, // nil = create new share
completion: { objectIDs, share, container, error in
if let error {
completion(nil, error)
return
}
share?[CKShare.SystemFieldKey.title] = home.name
share?.publicPermission = .none
self.container.persistCKSharesAndWait()
completion(share, nil)
}
)
}
}
Presenting the Share UI
Swiftstruct CloudSharingView: UIViewControllerRepresentable {
let share: CKShare
let container: NSPersistentCloudKitContainer
func makeUIViewController(context: Context) -> UICloudSharingController {
let controller = UICloudSharingController(
share: share,
container: CKContainer(identifier: "iCloud.com.barronroth.Cubby")
)
controller.delegate = context.coordinator
controller.availablePermissions = [.allowReadWrite, .allowPrivate]
return controller
}
// ... coordinator with UICloudSharingControllerDelegate
}
Accepting a Share
Swift@main
struct CubbyApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext,
persistenceController.container.viewContext)
.onOpenURL { url in
acceptShare(from: url)
}
}
}
private func acceptShare(from url: URL) {
let ckContainer = CKContainer(identifier: "iCloud.com.barronroth.Cubby")
CKContainer.default().fetchShareMetadata(with: url) { metadata, error in
guard let metadata else { return }
persistenceController.container.acceptShareInvitations(
from: [metadata],
into: persistenceController.sharedPersistentStore!
) { _, error in
if let error { print("Failed to accept share: \(error)") }
}
}
}
}
Querying Across Both Stores
Swift// Fetch all homes (both private and shared)
let request = NSFetchRequest<Home>(entityName: "Home")
// Only shared homes:
request.affectedStores = [sharedPersistentStore!]
// Only private homes:
request.affectedStores = [privatePersistentStore!]
// Is a specific object shared?
func isShared(_ object: NSManagedObject) -> Bool {
guard let store = object.objectID.persistentStore else { return false }
return store == sharedPersistentStore
}
// Determine edit permissions
func canEdit(_ home: Home) -> Bool {
guard let share = share(for: home) else { return true }
if share.currentUserParticipant?.role == .owner { return true }
return share.currentUserParticipant?.permission == .readWrite
}
Alternative: Direct CloudKit
If you want to avoid Core Data entirely, you could use CKSyncEngine (iOS 17+) with shared zones. However:
CKSyncEnginesupports shared zones viaCKSyncEngine.State.PendingZoneShare- You'd need to manually map between SwiftData models and
CKRecords - You lose all automatic relationship handling that
NSPersistentCloudKitContainerprovides - Apple's documentation on
CKSyncEngine+ sharing is sparse
Unless you have a specific reason to avoid Core Data, this path means building your own sync engine — a significant and error-prone undertaking.

User Flow Design
Sharing a Home (Owner)
- Owner taps "Share" button on a Home's detail/settings screen
- App creates a
CKShareviaNSPersistentCloudKitContainer.share(_:to:completion:) - System presents
UICloudSharingController— Apple's standard share sheet - Owner chooses sharing method: iMessage, Mail, AirDrop, copy link
- Owner sets permissions: Read Only or Read & Write
- Share link is sent to the participant
Accepting a Share (Participant)
- Participant taps the share link (e.g., in iMessage)
- iOS opens the Cubby app (or App Store if not installed)
- App handles the URL in
onOpenURL/userDidAcceptCloudKitShareWith acceptShareInvitations(from:into:)processes it- Shared home data syncs into participant's shared store
- Home appears in the home list with a Shared badge
Managing a Share
- Owner can add/remove participants via
UICloudSharingController - Owner can change permissions per participant
- Owner can stop sharing entirely (removes from all participants)
- Participant can leave a share (removes it from their device)
Required Codebase Changes
1. Migration from SwiftData to Core Data Major
This is the biggest change:
- Create a
.xcdatamodeldCore Data model matching current SwiftData models - Replace
@Modelclasses withNSManagedObjectsubclasses - Replace
ModelContainer/ModelContextwithNSPersistentCloudKitContainer/NSManagedObjectContext - Replace SwiftData
@Querywith@FetchRequestorNSFetchedResultsController
Model Mapping
| SwiftData (@Model) | Core Data (NSManagedObject) |
|---|---|
Home | CDHome entity |
StorageLocation | CDStorageLocation entity |
InventoryItem | CDInventoryItem entity |
2. Dual Persistent Store Setup
- Configure private + shared
NSPersistentStoreDescriptions - Set
databaseScopeon CloudKit container options - Enable persistent history tracking on both stores
3. Share UI
- Add a "Share" button on Home detail/settings
- Wrap
UICloudSharingControllerinUIViewControllerRepresentable - Handle share URL acceptance in app lifecycle
- Show share status/participants in UI
- Add Shared badge on received homes
4. Permission-Aware UI
- Check
canEdit()before allowing modifications - Show read-only UI for participants without write permission
- Handle share revocation gracefully (home disappears)
5. Entitlements
Current entitlements (CloudKit, push notifications, container identifier) are all that's needed. Verify that CKSharingSupported key is in Info.plist.
Photo Sharing Strategy
Option B: Core Data Binary Data with External Storage Recommended
Use Core Data's "Allows External Storage" option for the photo attribute. NSPersistentCloudKitContainer syncs these as CKAssets automatically. Path of least resistance.
Option A: CKAsset (Simpler alternative)
Store photos as CKAsset on the InventoryItem CloudKit record. Automatically shared when the parent is shared. 250 MB limit per asset. Increases CloudKit storage usage.
Option C: Separate Photo Sync Service
Upload to a shared location (CloudKit assets, S3, etc.), store URL on the item. More complex but more control over caching and bandwidth.
Limitations & Gotchas
SwiftData (as of iOS 19 / 2025) does not support CloudKit shared or public databases. Apple DTS has confirmed this repeatedly. You must use Core Data.
NSPersistentCloudKitContainer Quirks
- Entire zone is shared, not individual records. When you share a
Home, ALL records in that zone are shared. This is actually what you want. - Conflict resolution is last-writer-wins. No merge strategy for field-level conflicts. For a household inventory app, this is acceptable.
- Shared store is read-only from the container's perspective. Write operations for shared data go through the same context, but under the hood they write to the owner's zone.
- No real-time sync. Eventually consistent. Changes typically propagate in seconds to minutes.
- CloudKit quotas. ~25 MB free per user. Large photo libraries could hit limits.
- Offline behavior. Changes queue locally and sync on reconnect. Conflicts resolved by last-writer-wins.
- Share acceptance requires the app. If not installed, share link goes to App Store. Participant must tap link again after install.
- UICloudSharingController is UIKit. Needs a
UIViewControllerRepresentablewrapper. Not SwiftUI-native. - Testing is painful. Two different iCloud accounts on two devices. Simulator support is limited.
- Schema migration. Adding fields is easy (additive). Removing/changing types requires careful handling.
What You Lose by Moving to Core Data
- SwiftData's
@Modelmacro convenience - SwiftData's
@Queryproperty wrapper - SwiftData's simpler
ModelContainersetup - SwiftData's automatic schema generation
What You Gain
- Full CloudKit sharing support
- Battle-tested persistence layer (Core Data is 20+ years old)
- More control over sync behavior
- Access to
NSPersistentCloudKitContainer's sharing APIs
Migration Strategy
Given that Cubby is relatively early-stage and the model layer is simple (3 entities, straightforward relationships), the cleanest path is a full Core Data migration:
- Create Core Data model (
.xcdatamodeld) matching current SwiftData models - Build
PersistenceControllerwith dual private/shared stores - Create
NSManagedObjectsubclasses (or use Xcode codegen) - Port views from
@Queryto@FetchRequest/@SectionedFetchRequest - Add sharing UI (share button,
UICloudSharingControllerwrapper, share acceptance) - Add data migration for existing users (read SwiftData → write Core Data → delete SwiftData store)
- Port photo storage to Core Data binary attribute with external storage
- Test thoroughly with two iCloud accounts
Timeline Estimate
| Phase | Effort |
|---|---|
| Core Data model + persistence controller | 2–3 days |
| Port views from SwiftData to Core Data | 3–5 days |
| Sharing implementation (CKShare, UI, acceptance) | 3–4 days |
| Photo sync via CKAsset | 1–2 days |
| Data migration for existing users | 1–2 days |
| Testing (2 accounts, edge cases, offline) | 3–5 days |
| Total | ~2–3 weeks |

Subscription Sharing: How Participants Get Pro
Added 2026-02-16 — How does the owner's Pro subscription extend to participants in a shared home?
This is the most nuanced architectural decision in the sharing feature. Three viable approaches exist, each with distinct security/complexity tradeoffs.
Option A: RevenueCat Backend Verification
Owner's RevenueCat app_user_id is stored in the CloudKit Home metadata. Participant's app calls a lightweight Cloud Function, which checks the owner's entitlement via RC's REST API.
Pros
- Server-side verification — can't be spoofed client-side
- RevenueCat handles all subscription lifecycle (renewals, cancellations, refunds, grace periods)
- Single Cloud Function (~50 lines), essentially free to run
- RC free tier covers up to $2.5k MTR
Cons
- Requires a server component (Cloud Function)
- RC REST API rate limit ~60 req/min per API key (fine for indie scale)
- Adds RevenueCat as a dependency
Edge Cases
- Cancellation: Entitlement stays active until period end (correct behavior)
- Refund: Entitlement revoked immediately
- Offline: Cache last-known status with TTL locally (4–12 hours)
- Owner changes RC account: Update CloudKit Home metadata on identity change
- Grace period (billing issues): RC still reports active during 6–16 day grace period
Security
| Attack | Risk | Mitigation |
|---|---|---|
| Participant spoofs owner's RC ID | Medium | Validate requesting user is a participant of that Home |
| Owner shares RC ID for free Pro without home | Low | Only honor checks for users in the same CloudKit sharing group |
| Replay cached "active" after cancel | Low | Short TTL caches (1–4 hours) |
| Direct Cloud Function calls | Medium | Firebase Auth / App Check |
Option B: CloudKit Metadata + JWS Verification (No Server)
Owner stores their StoreKit 2 signed transaction (JWS — Apple-signed JWT) in the shared CloudKit record. Participant verifies Apple's signature locally.
How It Works
- Owner subscribes → gets
Transaction.jwsRepresentation(signed by Apple) - Owner writes JWS +
ownerUserRecordNameto CloudKit shared record - Participant reads the record and:
- Decodes the JWS (standard JWT — header.payload.signature)
- Verifies signature against Apple's public keys (x5c chain in header)
- Checks
bundleIdmatches your app - Checks
productIdmatches your Pro subscription - Checks
expiresDateis in the future - Checks
CKRecord.creatorUserRecordIDmatches theownerUserRecordNamefield
Swift// Owner side — after purchase
func writeSubscriptionToCloudKit(transaction: Transaction) async {
let record = // fetch or create subscription CKRecord in shared zone
record["subscriptionJWS"] = transaction.jwsRepresentation
record["productId"] = transaction.productID
record["expiresDate"] = transaction.expirationDate
// save to CloudKit
}
// Participant side — checking entitlement
func verifySharedSubscription(record: CKRecord) -> Bool {
guard let jws = record["subscriptionJWS"] as? String else { return false }
// 1. Parse JWT structure
// 2. Verify x5c certificate chain roots to Apple
// 3. Verify signature
// 4. Check bundleId == your app
// 5. Check productId is a Pro subscription
// 6. Check expiresDate > now
// 7. Check environment == "Production"
return isValid
}
Spoofing Analysis: Can This Be Gamed?
Without JWS verification, a plain boolean field in CloudKit is trivially spoofable. Anyone creates an iCloud account, writes subscriptionActive = true, shares the zone. No purchase needed. This is the #1 threat unique to the CloudKit sharing approach.
Attack Vector Analysis
| Attack Vector | Typical User | Determined Attacker | Overall Risk |
|---|---|---|---|
Fake owner account (write true, share zone) |
Low–Med | Trivial | Critical |
| Local SQLite cache modification | Low | High | Medium-High |
| Jailbreak runtime hooks | Very Low | Very High | High (universal) |
| MITM CloudKit traffic | None | Very Low | Very Low |
| CloudKit API direct modification | Low | Medium | Medium |
What JWS Verification Defeats
- ✅ Fake owner — can't produce a valid Apple-signed JWS for your bundle ID without purchasing
- ✅ Local cache modification — can't forge Apple's JWS signature
- ✅ API modification — same reason
- ❌ Jailbreak runtime hooks — attacker can hook verification code itself (true of ALL client-side checks)
How Other Apps Handle This
- Apple Family Sharing: Server-side entitlement checking. Not spoofable via CloudKit.
- Serious apps: Own server as the authority on subscription status
- RevenueCat users: Server-side receipt validation
- Small indie apps: Honor system (trust the CloudKit field)
No major app relies on CloudKit record fields alone for subscription verification.
Mitigations Deep Dive
Tier 1: JWS + Bundle ID + Owner Identity Binding No Server
Best practical approach without a server. Store the Apple-signed JWS in CloudKit, verify on the participant side. Bind to owner identity via CKRecord.creatorUserRecordID.
Tier 2: Lightweight Server Validation Strongest
Add a Cloud Function that validates via App Store Server API and returns a short-lived signed token. Owner stores this in CloudKit. Adds revocation checking and real-time validity.
Tier 3: Apple Family Sharing Limited
If the sharing model maps to Apple's Family concept (≤6 people, one payer), enable Family Sharing for the IAP. Zero custom code. But limited to Apple family groups — not roommates, friends, or nannies.
Option C: Apple Family Sharing Deprioritized
- Requires users to be in the same Apple Family group (max 6)
- Most households sharing Cubby (roommates, partners, nannies) are NOT on the same Apple Family plan
- Zero implementation complexity, but the UX requirement is a dealbreaker for most users
Subscription Sharing Recommendation
| Approach | Security | Complexity | Server? | UX Friction |
|---|---|---|---|---|
| RevenueCat + Cloud Function | Strong | Medium | Yes (lightweight) | Low |
| CloudKit + JWS local verification | Good | Medium | No | Low |
| Apple Family Sharing | Strong | Low | No | High |
| Plain CloudKit boolean | Trivially spoofable | Low | No | Low |
Start with CloudKit + JWS (no server dependency, good security). If abuse appears later, upgrade to RevenueCat + Cloud Function. Skip Apple Family Sharing as the primary mechanism — too much user friction.

Summary
| Approach | Feasibility | Effort | Recommendation |
|---|---|---|---|
| Core Data + NSPersistentCloudKitContainer sharing | Proven | High (2–3 weeks) | 🥇 Recommended |
| Direct CloudKit (CKSyncEngine + CKShare) | Possible | Very High | 🥈 Only if avoiding Core Data |
| SwiftData shared DB | Not supported | N/A | Not possible |
| Hybrid SwiftData + Core Data | Complex | High | 🥉 Compromise option |
| Custom server (Firebase, etc.) | Possible | Very High | Overkill |
| SharePlay | Wrong tool | N/A | Not for persistent data |
| Family Sharing | Not applicable | N/A | Doesn't share app data |
Bottom line: Migrate to Core Data + NSPersistentCloudKitContainer with dual private/shared stores. It's the only Apple-supported path for CloudKit sharing with a local database, and it's mature enough for production use. The migration from SwiftData is the main cost, but Cubby's simple 3-entity model makes this manageable.
Implementation Checklist
- Set up RevenueCat with identified users (not anonymous)
- Store owner's
Purchases.shared.appUserIDin CloudKit Home CKRecord - Create Cloud Function: owner RC ID → RC REST API → active/inactive
- Secure Cloud Function (Firebase App Check or Auth token)
- Participant app: on opening shared Home, call Cloud Function with owner's RC ID
- Cache result locally with TTL (4–12 hours suggested)
- Handle edge cases: owner changes account, refunds, offline mode
- Optional: Set up RC webhooks for faster lookups
- Test in sandbox with short subscription periods