Cubby
What it does How it works Engineering Privacy Support Join the beta
Cubby · engineering

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.

July 2026 · Abhijit Bansal · built from Cubby's incident logs

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 (NSPersistentCloudKitContainer under 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 NSPersistentCloudKitContainer over 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. (.dryRun validates 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 cktool and 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: .none explicitly 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 initializeCloudKitSchema bridge 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_entityName and CD_moveReceipt are 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.

Cubby
Home Engineering Privacy Support
© 2026 Abhijit Bansal