CKRecordZone deleted when second user accepts zone-wide CKShare

I'm seeing a critical issue where a custom CKRecordZone is consistently deleted server-side when a second iCloud account interacts with a zone-wide CKShare. I've reproduced this 20+ times across two days and have exhausted every client-side fix I can think of. Looking for guidance on what might be going wrong.

Setup

  • Container: iCloud.com.cohencooks (production app on App Store)
  • Custom CKRecordZone in owner's private database
  • Zone-wide CKShare(recordZoneID:) (iOS 15+ zone sharing)
  • SwiftData with ModelConfiguration(cloudKitDatabase: .none) — no automatic CloudKit mirroring
  • Acceptance via CKFetchShareMetadataOperationCKContainer.accept(metadata) (no UICloudSharingController)

Minimal reproduction

// 1. Owner creates zone + share
let zone = CKRecordZone(zoneName: "MyZone")
try await privateDB.save(zone)

let share = CKShare(recordZoneID: zone.zoneID)
share[CKShare.SystemFieldKey.title] = "My Share" as CKRecordValue
share.publicPermission = .readWrite
let (results, _) = try await privateDB.modifyRecords(saving: [share], deleting: [])

// 2. Owner pushes ~500 records to zone — all succeed

// 3. Second user (different iCloud account) accepts share
let metadata = try await container.shareMetadata(for: shareURL)
try await container.accept(metadata)  

// 4. Owner's next CKFetchRecordZoneChangesOperation → zoneNotFound (code 26)
// Zone is permanently gone. allRecordZones() confirms deletion.

What I observe

Three distinct failure patterns depending on configuration:

Pattern 1 — publicPermission = .readWrite, no addParticipant: Zone dies instantly after acceptance. First push notification shows cloudkit.share changed (zone alive), second push notification returns zoneNotFound. The non-owner never successfully wrote anything.

Pattern 2 — publicPermission = .none with explicit addParticipant: Zone survives acceptance and 2-3 minutes of bidirectional sync (non-owner pulls 578 records, pushes meal plans back). Then a push notification arrives and the zone is gone. This is dramatically better than Pattern 1 but still fails.

Pattern 3 — Container destabilization after repeated testing: After 20+ create/delete cycles in one day, zones die from the owner's own push notifications — no second device involved at all. The container appears to enter an unstable state.

What I've ruled out

HypothesisTestResult
publicPermission = .readWriteChanged to .none + explicit addParticipantZone survived longer but still eventually deleted
Zone name tombstoningTested 6 fresh names never used in this containerAll eventually deleted
Non-owner writes causing deletionGated ALL non-owner push methods (recipe, meal plan, grocery, photo, event)Zone still deleted
database.save(share) vs modifyRecordsSwitched to modifyRecords(saving:deleting:)Zone still deleted
NSPersistentCloudKitContainer interferenceRemoved all Core Data CloudKit codeZone still deleted
Double share acceptanceFresh app install, single acceptance onlyZone still deleted
Advanced Data ProtectionNeither account has ADP enabledNot the cause
Programmatic vs system acceptanceTested both container.accept() and tapping share linkZone still deleted

CloudKit Dashboard

No ZoneDelete operation is visible in the logs. All operations are ZoneFetch, ZoneChanges, RecordQuery, RecordFetch. I do see EphemeralGroup operations targeting the custom zone — not sure what generates those.

Comparison with working apps

I compared my implementation with another app (Spotbook) that uses the exact same zone-wide CKShare(recordZoneID:) pattern with publicPermission = .readWrite and programmatic acceptance — and it works. The main difference is that app uses CKSyncEngine (iOS 17+) rather than raw CKFetchRecordZoneChangesOperation / CKModifyRecordsOperation. Could CKSyncEngine be handling something internally that prevents this issue?

Questions

  1. Is there a known interaction between zone-wide CKShare(recordZoneID:) acceptance and zone lifecycle that could cause zone deletion?
  2. Does CKSyncEngine handle zone-wide sharing differently than manual CKFetchRecordZoneChangesOperation + CKModifyRecordsOperation?
  3. What generates EphemeralGroup operations in CloudKit Dashboard? Could these trigger a zone delete?
  4. After 20+ zone create/delete cycles in a container, is there a server-side rate limit or tombstone mechanism that would destabilize new zones?
  5. Is the custom programmatic acceptance flow (CKFetchShareMetadataOperationcontainer.accept()) fully supported for zone-wide shares, or does it require UICloudSharingController?

Any guidance would be greatly appreciated. This is blocking multi-user functionality for our app (mesa, a meal planning app on the App Store). Single-user sync works perfectly — the issue only manifests when a second iCloud account is involved.

Environment: iOS 18.4.1, Xcode 16+, Swift, SwiftUI

Before diving into the your questions, would you mind to share how you deliver the share URL (CKShare.url) to a participant?

To accept a CloudKit share, I'd expect the following flow:

  1. You manage to deliver the share URL to a participant (via email, message, etc).
  2. The participant taps the URL on their device, which triggers an authentication at the system level.
  3. Your app is launched to accept the share.
  4. In your app, the system triggers windowScene(_:userDidAcceptCloudKitShareWith:), and you accept the share using CKContainer.accept(_:) or friends.

If you are not using this flow, I'd be curious how you do differently.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Hi Ziqiao, thanks for looking into this. I'll answer your question about our share flow, then share some significant findings from today's debugging.

We use a custom token-based invite flow rather than the standard system share URL tap → userDidAcceptCloudKitShareWith path. Here's the full flow:

How we deliver the share URL

  1. Owner creates an invite token — stored as an InviteToken record in the public CloudKit database. The token record contains the CKShare.url string, the household ID, and a 7-day expiration.

  2. Owner sends a deep link — the invite UI generates a mesa://invite?token=<token> URL and presents a share sheet (Messages, AirDrop, etc.). This is a custom URL scheme, not the CKShare.url directly.

  3. Participant opens the deep link — the mesa:// URL opens our app. The app extracts the token, queries the public database for the InviteToken record, and reads the shareURL field to get the CKShare.url.

How we accept the share (programmatic path)

Once we have the CKShare.url from the token lookup:

// 1. Fetch share metadata
let operation = CKFetchShareMetadataOperation(shareURLs: [shareURL])
operation.perShareMetadataResultBlock = { _, result in
    // Store metadata on success
}
container.add(operation)

// 2. Accept the share
try await container.accept(metadata)

We do not route through UICloudSharingController or the system share URL handler. The participant never taps the raw CKShare.url — they tap our mesa://invite deep link, and we handle acceptance programmatically.

We do implement userDidAcceptCloudKitShareWith

We have CKSharingSupported = YES in Info.plist and implement userDidAcceptCloudKitShareWith in our AppDelegate:

func application(_ application: UIApplication, 
                 userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
    Task { @MainActor in
        let success = await syncEngine.handleShareAcceptance(metadata: cloudKitShareMetadata)
        // ...
    }
}

However, this delegate method is not triggered in our flow because the participant never taps the CKShare.url directly — they tap our custom mesa://invite URL instead.

Why we use this flow

  • We need to pass additional context with the invite (household name, inviter name, linked member preferences) that can't be embedded in a CKShare.url
  • We want invites to be revocable (we can delete the InviteToken record)
  • We need invites to work even when the participant doesn't have the app installed yet (the mesa://invite URL has a universal link fallback to the App Store)

The key question

Is the programmatic CKFetchShareMetadataOperationcontainer.accept(metadata) path fully supported for zone-wide CKShare(recordZoneID:) shares? Or does zone-wide sharing require the system-level authentication that happens when a user taps the CKShare.url directly (triggering userDidAcceptCloudKitShareWith)?

If the system-level authentication is required, that would explain why our flow fails — we're bypassing it entirely. We could potentially switch to delivering the raw CKShare.url as a universal link and relying on userDidAcceptCloudKitShareWith, but I wanted to confirm whether that's the expected fix before restructuring the invite system.

Additional context from today's debugging

Since posting, I've made significant progress. I built a minimal test app (zone + share + accept, no SwiftData, no sync logic) against the same container, and the zone survived using this exact programmatic acceptance path. This told me the issue was in my app code, not the acceptance flow itself.

After systematically adding features back one at a time, I found the primary trigger: an orphan zone cleanup function that runs on every app launch. It enumerates private DB zones, compares against a cached "active zone name" in UserDefaults, and deletes non-matching zones. On multi-device setups (iPhone + iPad), each device independently caches the zone name. When the iPad woke from a background push notification (triggered by share activity on the iPhone), its stale cache caused it to delete the active zone as an "orphan."

I've fixed this by checking for an active CKRecordNameZoneWideShare before any zone deletion — zones with active CKShares are never deleted.

However, after 20+ zone create/delete cycles during debugging, the container now appears to be in a degraded state — new zones (even with completely fresh names never used before) are deleted by the server within seconds of creation, on a single device with no other apps running. I'm waiting for the container to stabilize (tombstone TTL) before further testing.

Thank you, Josh

Thanks for sharing the details of your sharing flow, and nice to know that you made great progress. To your following question:

Is the programmatic CKFetchShareMetadataOperation → container.accept(metadata) path fully supported for zone-wide CKShare(recordZoneID:) shares? Or does zone-wide sharing require the system-level authentication that happens when a user taps the CKShare.url directly (triggering userDidAcceptCloudKitShareWith)?

Yes, the programmatic path, which CloudKit folks call "in-process" share acceptance, is fully supported. The user interaction is not required. Given that, I don't see the flow you described has anything wrong.

However, after 20+ zone create/delete cycles during debugging, the container now appears to be in a degraded state — new zones (even with completely fresh names never used before) are deleted by the server within seconds of creation, on a single device with no other apps running. I'm waiting for the container to stabilize (tombstone TTL) before further testing.

Resetting your CloudKit development environment should clean up the state and give you an environment the same as the production one. If appropriate (meaning your data allows), you can probably give it a try.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

CKRecordZone deleted when second user accepts zone-wide CKShare
 
 
Q