SwiftData + CloudKit in production: the sharp edges Apple doesn't document
Cubby is a small, privacy-first inventory app with optional iCloud sync.
Shipping that sync to TestFlight produced two production incidents, one
binary-forensics investigation, and a set of rules I wish someone had
written down before me. This is that writeup: what actually happens
between your @Model types and the CloudKit Console, where
it silently fails, and the automated gate that keeps it from failing again.
Why sync at all?
Cubby's whole point is answering "where is it?" without a trip to the basement — and that question gets asked from more than one device. You catalog bins on your iPhone standing at the rack, then want the same inventory on the iPad in the kitchen, or on a new phone after an upgrade. Without sync, the answer lives on exactly one device; lose it and you lose the catalog too.
The constraint: Cubby's core promise is no accounts, no cloud backend, no telemetry. A classic sync service — our server, your data on it — would break that promise on day one. We needed multi-device sync where we never run a server and never see a single item name.
Why CloudKit
CloudKit is Apple's answer to exactly that shape of problem, and it's why sync in Cubby could stay a privacy feature instead of becoming a privacy exception:
- It's the user's own iCloud. Data syncs through the private database of your personal iCloud account — per user, in storage you already own, counted against your iCloud quota. There is no Cubby account, and no shared pool.
- The developer can't read it. A private-database record is reachable only by the Apple ID that wrote it. We can see our app's schema — the field names and types you'll meet throughout this article — but never your records.
- No third-party code. Cubby has a zero-dependency
rule; CloudKit is an Apple framework with first-party SwiftData
integration (
NSPersistentCloudKitContainerunder the hood), so sync didn't cost us an SDK we'd have to trust. - It can be genuinely optional. Sync ships OFF by default. Until you flip the toggle, nothing leaves the device — a property this article will complicate shortly.
How CloudKit sync works, in one minute
The mental model you need for everything that follows: your app's local
models get mirrored to CloudKit record types (prefixed
CD_), whose definitions live in a server-side
schema. A sync engine ships record changes both ways in
the background; conflicts resolve last-writer-wins per field. Two
details matter enormously in practice:
- There are two environments. Development — used by debug builds, where schema is created automatically as records sync — and Production — used by TestFlight and App Store builds, where schema is read-only and must be promoted by hand. Most of this postmortem lives in the gap between them.
- It's per user, not between users. Private-database
sync moves your data between your devices. Sharing
with another person is a separate mechanism (
CKShare) with its own machinery — including, as it turns out, a mystery field we'll meet immediately.
That's the setup. Here's where it cut us.
The field nobody wrote
Start with the mystery, because it's the best illustration of how opaque this stack is. Preparing a schema deploy, I opened the CloudKit Console's "Deploy Schema Changes to Production" diff and found that every single record type carried a field I had never defined:
CD_moveReceipt BYTES
CD_moveReceipt_ckAsset (its overflow companion)
No property called moveReceipt exists in any Cubby model. It
isn't anywhere in git history. And CloudKit Production schema is
add-only and permanent — you don't deploy a field you can't
explain.
First theory: an orphan from some past experiment in the Development
environment. Refuted — a cktool reset-schema plus
re-initialization brought it straight back. Second theory: something in
our generated model. Refuted with a diagnostic unit test proving the
NSManagedObjectModel we hand to CloudKit contains
zero attributes matching receipt.
What settled it was going below the API: running strings
against the CoreData framework binary itself — both the device build and
the Simulator-runtime copy, which carries symbols the public stub lacks.
Both contain NSCKRecordZoneMoveReceipt, the Apple-internal
source path Sources/Persistence/NSCKRecordZoneMoveReceipt.m,
methods like createEncodedMoveReceiptData: and
mergeMoveReceiptsWithData:, and a CD_FAKE_
synthetic-record marker.
So: CD_moveReceipt is Core Data's own record-zone-move
bookkeeping (share/unshare support), injected by the CloudKit serializer
at CKRecord-generation time — the same category as the documented
CD_entityName discriminator. It is invisible at the
NSManagedObjectModel stage, which is why a
model-completeness test shows clean while the schema export shows the
field. Both results are correct; there is no contradiction. It is safe to
deploy — and actually necessary if you ever plan to use
CKShare, because Production never creates fields on demand
and a zone move would hard-fail without it.
Rules that fall out of this:
1. Never name a @Model property moveReceipt,
moveReceipts, or entityName — they collide with
the framework-injected fields. (isDeleted is separately
reserved by NSManagedObject.)
2. When a mystery CD_ field appears, don't chase it as an
orphan. Cheap checks first (schema reset, model test), then
strings/nm on the real framework binary — and
independently re-verify before any irreversible Production deploy.
How the schema lifecycle actually works
None of the incidents below make sense without this background, and no single Apple doc states it end-to-end:
- Development builds create schema; Production builds can't. A debug build with sync enabled auto-creates record types in the CloudKit Development environment as records sync. TestFlight and App Store builds talk to Production, where schema is read-only.
- Promotion is a manual step. "Deploy Schema Changes to
Production" in the CloudKit Console (or
cktool). Nothing in your build or release pipeline does this for you, and nothing warns you when you forget. - Production is add-only. You can never rename, delete, or retype a live Production field. A wrong field stays forever (harmlessly), so plan names before the first deploy.
Everything that went wrong for us lives in the gap between "my dev build works" and "the schema my TestFlight users need actually exists in Production."
The .automatic trap
Before any schema trouble, the first sharp edge is on-device:
the sync mode, not the entitlement, decides whether data leaves
the device. The moment your app has an iCloud container
entitlement, SwiftData's default cloudKitDatabase: .automatic
starts syncing — whether or not the user opted in.
Cubby's sync is opt-in and off by default, so local-only mode passes
.none explicitly:
// The entitlement exists either way — the MODE is the privacy boundary.
let config = isICloudSyncEnabled
? ModelConfiguration(schema: schema, cloudKitDatabase: .private(containerID))
: ModelConfiguration(schema: schema, cloudKitDatabase: .none)
This extends further than you'd think: every in-memory test and
preview configuration must also pass .none. Forget
one, and a unit-test run on a machine signed into iCloud can start
talking to your real container.
Model rules and the rename-safety boundary
CloudKit-backed SwiftData imposes model constraints: every property needs
a default value and all relationships must be optional.
Retrofitting that onto an existing model invites a tempting refactor: keep
the API, rename the stored property — x becomes stored
xStorage plus a computed x.
That wrapper rename is migration-safe only when an unchanged to-one foreign key on the child side anchors reconstruction of the renamed to-many side during lightweight migration. A many-to-many with both ends renamed has no unchanged anchor — join rows can silently orphan, with nothing to rebuild them from. On a store already shipped to users, keep the original stored property name and add the optionality in place. In Cubby this exact hazard was caught in review twice before it could ship; "the migration ran without errors" would have been the only symptom.
Incident one: the sync that silently stopped
Cubby v0.4.0 shipped a feature wave that touched the data model in eight
separate commits — eleven new @Model fields in all. Dev
builds synced perfectly. The Production schema deploy was recorded as
done in the release log — but it was incomplete, which is worse than
forgotten: there was no open checklist item left to catch it. TestFlight
users then hit per-record export rejections for anything touching the new
fields — surfaced as the maximally unhelpful:
CKErrorDomain error 2
That's partialFailure, wrapped generically. The visible
symptom was worse than a crash: items that silently never
synced. From the user's chair it looks like iCloud "lost" data —
in reality the records were never accepted for upload at all, and
everything was still safe locally.
The error message will not tell you this. We got lucky: this one was root-caused from release logs and git history alone. When you do need the on-device truth — as we did in an earlier incident chasing the optional-relationships requirement — the real diagnostics come from Core Data's CloudKit debug logging, launched on-device:
xcrun devicectl device process launch --console --device <device-id> <bundle-id> -- \
-com.apple.CoreData.CloudKitDebug 3 -com.apple.CoreData.Logging.stderr 1
Note the bare -- separator — without it, devicectl's own
argument parser swallows the dash-prefixed app arguments and you'll
wonder why the flags did nothing.
Incident two: the diff that lied
After incident one, the obvious safeguard is "diff Development against Production before every release." We built that — and it would not have caught the very next problem.
A record type's CloudKit schema materializes lazily, on the first sync of a record of that type. If no dev build ever created and synced an instance of some type, that type is absent from both environments. The Dev↔Prod diff reads clean while the type is entirely undeployed.
When we audited Cubby, only 6 of 10 registered model types actually existed in Production. The other four — newer types like custom fields, saved searches, and the move-session pair — had simply never been exercised against CloudKit by a dev build. Any TestFlight user's records of those types would fail per-record export, silently, forever.
Schema verification therefore needs two checks, and the second one is the one nobody writes:
- Dev↔Prod diff — catches materialized-but-not-promoted changes.
- Model-coverage check — diff Production against your app's own source-of-truth type list (the Swift array you register the schema from). This is the check that catches never-materialized types, because the app model is the only artifact that knows they should exist.
Force-materializing the schema: a DEBUG initializer
The durable fix for lazy materialization is to stop depending on organic
usage traffic to create schema. Core Data has the API —
initializeCloudKitSchema — it's just on
NSPersistentCloudKitContainer, not SwiftData. The bridge is
small:
- Generate the managed object model from the same authoritative type
list SwiftData uses:
NSManagedObjectModel.makeManagedObjectModel(for:). One source of truth, no hand-maintained.xcdatamodel. - Stand up a transient
NSPersistentCloudKitContainerover a throwaway scratch store in its own temp directory — never the app's live store. - Call
initializeCloudKitSchema(options:). Every entity and field materializes in Development in one shot. (.dryRunvalidates without uploading.) - Tear down and delete the scratch directory — including Core Data's
hidden
.<store>_SUPPORT/sibling.
The whole file is wrapped in #if DEBUG, so the initializer —
and with it any ability to push schema — is compiled out of Release
builds entirely. It runs from a debug maintenance menu; a human still
performs the Dev→Production deploy in the Console, deliberately.
The release gate: automate the step humans forget
Both incidents share one root cause: a manual Console step with no feedback loop. It shipped broken twice — once believed-done but incomplete, once with nothing visibly left to do at all — and the hand-maintained "fields to deploy" checklist item drifted out of date across sessions. Checklists don't fix this. A release gate does.
Cubby's release pipeline now runs a verify script whenever the diff since the last release tag touches a model file:
- It exports both environments' schema with
cktooland runs both checks from the previous section — model coverage against the registered-type list, then the Dev↔Prod diff. - The release hook triggers it only for model-touching releases; a pure UI release skips it automatically.
- The gate fails closed: no token, or any drift, and
the release aborts. The escape hatch —
SKIP_CLOUDKIT_SCHEMA_CHECK=1, for machines without a CloudKit management token or a hand-verified schema — must be set explicitly up front, so bypassing the gate is a deliberate, visible choice rather than a silent fallback. - It is read-only by design: it exports and diffs, never writes schema. Deploying stays a deliberate human action.
The full script, as it runs in Cubby's pipeline (swap the team/container IDs at the top):
verify-cloudkit-schema.sh — the full release-gate script
#!/usr/bin/env bash
#
# Verify Cubby's CloudKit PRODUCTION schema matches DEVELOPMENT — i.e. that every
# @Model schema change has actually been deployed to Production.
#
# Why this exists: TestFlight/App Store builds talk to CloudKit **Production**; dev
# builds auto-create schema in **Development** from the current SwiftData model. So
# Development is the app-model ground truth, and any record type / field present in
# Development but MISSING from Production is a change that was never deployed —
# exactly the BUG-039 failure (undeployed field → per-record export rejection →
# generic "CKErrorDomain error 2" → items that silently never sync). Run this as a
# release preflight whenever a release's diff touches a @Model.
#
# Read-only: it EXPORTS schema and diffs. It never writes schema, never touches
# records, never touches data. (Deploying the fix stays a Console "Deploy Schema
# Changes to Production" action, or `cktool import-schema` — add-only, deliberate,
# not automated here.)
#
# Ground-truth caveat: Development only reflects the CURRENT model if a current dev
# build has actually run against CloudKit Development (sync ON) and materialized the
# schema. Run the app once on the simulator/device with sync enabled before trusting
# a "clean" result.
#
# Setup (once): create a CloudKit **management token** and save it to the keychain.
# 1. https://icloud.developer.apple.com/dashboard → gear / Settings → "Tokens"
# → create a "CloudKit Management Token", copy it.
# 2. xcrun cktool save-token --type management # secure prompt, keychain-stored
# The token is a powerful credential (can modify/reset schema). Keep it in the
# keychain (the default) — never commit it, never pass it on an argv the shell logs.
# cktool also accepts it via the CLOUDKIT_MANAGEMENT_TOKEN env var if you prefer.
#
# Usage:
# ./scripts/verify-cloudkit-schema.sh
# TEAM_ID=XXXX CONTAINER_ID=iCloud.foo ./scripts/verify-cloudkit-schema.sh # override
#
# Exit codes: 0 = Production matches Development · 1 = drift (undeployed changes) ·
# 2 = setup problem (cktool missing, no token).
#
set -euo pipefail
TEAM_ID="${TEAM_ID:-XDTAU7RN57}"
CONTAINER_ID="${CONTAINER_ID:-iCloud.com.abhijitbansal.Cubby}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT_DIR="${REPO_ROOT}/.scratch" # gitignored
DEV_FILE="${OUT_DIR}/cloudkit-schema-development.ckdb"
PROD_FILE="${OUT_DIR}/cloudkit-schema-production.ckdb"
SCHEMA_SWIFT="$(find "${REPO_ROOT}/Cubby" -name CubbyModelSchema.swift 2>/dev/null | head -1)"
fail() { echo "error: $*" >&2; exit 2; }
command -v xcrun >/dev/null 2>&1 || fail "xcrun not found (need Xcode command-line tools)."
xcrun --find cktool >/dev/null 2>&1 || fail "cktool not found (ships with Xcode)."
# Token check: any authorized command proves the token resolves. Guard the pipe so
# set -e doesn't kill us before we can print the helpful message.
if ! xcrun cktool get-teams >/dev/null 2>&1; then
cat >&2 <<EOF
error: no CloudKit management token available.
Create one at https://icloud.developer.apple.com/dashboard (Settings → Tokens),
then: xcrun cktool save-token --type management
(or export CLOUDKIT_MANAGEMENT_TOKEN=<token> for this shell only)
EOF
exit 2
fi
mkdir -p "${OUT_DIR}"
echo "Exporting schema for ${CONTAINER_ID} (team ${TEAM_ID})…"
xcrun cktool export-schema --team-id "${TEAM_ID}" --container-id "${CONTAINER_ID}" \
--environment development --output-file "${DEV_FILE}"
xcrun cktool export-schema --team-id "${TEAM_ID}" --container-id "${CONTAINER_ID}" \
--environment production --output-file "${PROD_FILE}"
problems=0
# ── Check 1: model coverage — every registered @Model type has a CD_ record type
# in PRODUCTION. Stronger than the dev↔prod diff: it compares against the app's
# own source-of-truth type list (CubbyModelSchema), so it catches types whose
# CloudKit schema has NEVER materialized in EITHER environment (a record type is
# created lazily, the first time a record of that type syncs — until then it's
# absent from Development too, so the diff below reads "clean" while a whole type
# is silently undeployed and will fail per-record export on TestFlight).
echo "── Check 1: model types present in Production schema ──"
if [[ -n "${SCHEMA_SWIFT}" && -f "${SCHEMA_SWIFT}" ]]; then
# Extract the `Foo.self, Bar.self, …` type names from persistentModelTypes.
model_types="$(sed -n '/persistentModelTypes/,/]/p' "${SCHEMA_SWIFT}" \
| grep -oE '[A-Za-z_][A-Za-z0-9_]*\.self' | sed 's/\.self//' | sort -u)"
missing=""
for t in ${model_types}; do
if ! grep -qE "RECORD TYPE CD_${t}([[:space:]]|\()" "${PROD_FILE}"; then
missing+=" - CD_${t}"$'\n'
fi
done
if [[ -n "${missing}" ]]; then
problems=1
echo "✗ Model types with NO CloudKit schema in Production (will fail to sync on TestFlight):"
printf '%s' "${missing}"
echo " Cause: no record of these types has synced from a dev build yet, so the"
echo " schema was never materialized. Fix: run a dev build (sync ON) that creates"
echo " one record of each, re-run this script (Development will now show them),"
echo " then Console → Deploy Schema Changes to Production."
else
echo "✓ All registered @Model types have a CD_ record type in Production."
fi
else
echo "? Could not locate CubbyModelSchema.swift — skipped model-coverage check."
fi
# ── Check 2: no drift between what a dev build materialized (Development) and
# Production. Catches a change present in Dev but not yet deployed.
echo
echo "── Check 2: Production matches Development ──"
if diff -u "${PROD_FILE}" "${DEV_FILE}" >/dev/null 2>&1; then
echo "✓ Production schema matches Development — no undeployed drift."
else
problems=1
echo "✗ Production DIFFERS from Development — some changes are NOT deployed:"
# Lines Development has that Production lacks ('>' in this diff order).
diff "${PROD_FILE}" "${DEV_FILE}" | grep -E '^> ' | sed 's/^> / + /' || true
echo " ── full unified diff (prod ↴ dev) ──"
diff -u "${PROD_FILE}" "${DEV_FILE}" || true
echo " Deploy via Console → Deploy Schema Changes to Production (add-only), then re-run."
fi
echo
echo " dev: ${DEV_FILE}"
echo " prod: ${PROD_FILE}"
[[ "${problems}" -eq 0 ]] && { echo; echo "✓ Schema fully deployed."; exit 0; }
echo; echo "✗ Schema NOT fully deployed — see above."
exit 1
The rules, in one place
- The mode, not the entitlement, is the privacy boundary.
Pass
cloudKitDatabase: .noneexplicitly everywhere sync is off — including every test and preview configuration. - All properties defaulted, all relationships optional — and on a shipped store, add optionality in place; never rename both ends of a many-to-many.
- Dev builds create schema in Development only. TestFlight talks Production. Promotion is manual and add-only.
- Schema materializes lazily. A clean Dev↔Prod diff proves nothing about types that never synced — check coverage against your registered-type list.
- Force-materialize with a DEBUG-only
initializeCloudKitSchemabridge instead of relying on usage traffic. - Gate releases on schema verification whenever the diff touches a model — automated, read-only, with a loud escape hatch.
CD_entityNameandCD_moveReceiptare Apple's. Expected on every record type; deploy them; never name model properties after them.- When sync fails generically, launch with
-com.apple.CoreData.CloudKitDebug 3— the wrapper error always lies.
Cubby's sync remains what it was designed to be: opt-in, private-database only, off by default — see the privacy policy. These are the edges we hit making that promise hold in production. Questions or war stories: contact@abhijitbansal.com.