| name | cloudkit-schema-source-of-truth |
| description | Use when a CloudKit-backed app's persistence layer adds or edits a record type, field, or index and the schema needs to reach a container, or when asked "how do I push CloudKit schema to Production", "why can't cktool deploy to prod", or "why is a field silently missing in Production". Covers one committed `.ckdb` per app as the schema source of truth, `xcrun cktool` export/validate/deploy against Development (management token from an env file, passed positionally since cktool rejects piped stdin, purged after use), Production promotion as an irreversible Console-button-only gate `cktool` cannot reach, Production fields/indexes being add-only, and Just-In-Time schema existing only in Development โ a field the code writes that Production was never seeded with fails silently. |
CloudKit Schema Source of Truth
CloudKit has no migration-file system like a SQL database. The schema lives in Apple's
CloudKit Dashboard/Console, and xcrun cktool (Apple's official CLI, ships with Xcode) can
export, validate, and import it โ but only against the Development environment.
Production promotion is a manual, irreversible Console action. This skill makes the
Development side of that workflow scriptable and commit-trackable while keeping the
Production gate correctly user-owned.
When to invoke
- A persistence change adds, renames, or edits a CloudKit record type, field, or index.
- You need to push schema to a container (Development or Production).
- Before any Production schema deploy โ read the safety gate below first.
- Asked why a
.ckdb file is committed to the repo, or why a field the app writes is missing
from Production data.
Scope
Owns: the .ckdb-as-source-of-truth workflow, cktool invocations against Development, and
the Production promotion gate. Does not own: the Swift-side persistence/service code that
reads and writes CloudKit records โ swift-dependency-injection for how that seam is
injected and faked in tests; secret storage for the management token itself โ
apple-public-repo-security / build-time-secret-injection.
Prerequisites (one-time, user-owned)
Two credentials, kept in a gitignored env file (e.g. secrets/.env, with a committed
.env.example template):
- A CloudKit management token โ generated by a human in CloudKit Dashboard โ Settings โ
Tokens โ Create Token (Management). This is a privileged credential; treat it like an API
key with schema-write access, not like a build-time public identifier.
- The Apple Developer Team ID (10 characters).
The container identifier itself (e.g. iCloud.com.example.myapp) is not secret and can be
hardcoded in tooling.
Workflow
set -a; source secrets/.env; set +a
xcrun cktool save-token --team-id "$CK_TEAM_ID" "$CK_MANAGEMENT_TOKEN"
xcrun cktool export-schema \
--team-id "$CK_TEAM_ID" --container-id "$CK_CONTAINER_ID" \
--environment development > cloudkit/myapp.ckdb
xcrun cktool validate-schema \
--team-id "$CK_TEAM_ID" --container-id "$CK_CONTAINER_ID" \
--environment development --file cloudkit/myapp.ckdb
xcrun cktool import-schema \
--team-id "$CK_TEAM_ID" --container-id "$CK_CONTAINER_ID" \
--environment development --file cloudkit/myapp.ckdb
xcrun cktool delete-token --team-id "$CK_TEAM_ID"
Run step 5 in a shell trap ... EXIT around steps 1โ4 so the token is purged even if a step
fails midway.
Inputs / outputs
- Input: the credentials above, plus either the live Development container (
export) or
the committed cloudkit/<app>.ckdb (validate / import).
- Output:
export overwrites cloudkit/<app>.ckdb โ review the diff, then commit it as
the schema source of truth. import mutates the named container's live schema.
.ckdb files are not secrets โ they contain schema definitions only, no data or tokens
โ so they're committed like any other source file, distinct from the token itself.
Safety gate โ Production promotion is user-owned, Console-only, irreversible
cktool cannot push schema to Production. import-schema --environment production
rejects with an "endpoint not applicable in this environment" style error, and there is no
promote subcommand. The Development โ Production promotion happens only in the CloudKit
Console:
- Bring Development fully in sync first (
import-schema --environment development above).
- Console โ your container โ environment Development โ Schema โ "Deploy Schema Changes
to Productionโฆ" โ review the generated field/index diff โ confirm the deploy.
CloudKit Production fields and indexes are add-only by Apple's own rule โ once deployed
they can never be removed or renamed, only added to. Restricting the promotion path to the
Console keeps it naturally user-owned: automation prepares and validates the .ckdb and the
Development deploy; a human clicks the actual Production button.
export / validate / import --environment development are all reversible and safe to run
repeatedly without asking anyone.
Live-run gotchas
save-token takes the token as a positional argument, not piped stdin. Non-interactive
stdin piping fails with an "interaction was required" style error. Brief command-line
argv exposure of the token is the tradeoff; purge it from the keychain store immediately
after (see the trap note above).
validate-schema requires --environment explicitly โ omitting it is a hard error, not
a default.
import-schema only ever targets Development. Don't assume a script that "runs
import-schema --environment production" has ever actually been exercised โ smoke-test any
such tooling against real credentials before trusting it; a plausible-looking Production
import path that was never live-tested can sit broken for a long time undetected.
- Just-in-time (JIT) schema exists only in Development. A debug build auto-creates record
types and fields the first time it writes them, in Development only โ Production never does
this. Corollary: any field the app code writes that was never JIT-seeded in Development
before the last Console promotion is missing in Production, and live writes of it fail
silently โ typically only surfacing through an error-reporting funnel much later, not at the
write call site. Audit method:
export-schema --environment production to a scratch
file and diff its field set against every field the code actually writes.
- JIT marks every field it creates
QUERYABLE SEARCHABLE SORTABLE. A hand-authored
.ckdb should declare the minimal index set the app's actual queries need instead
(e.g. only the one field a specific equality query filters on, as QUERYABLE) โ because
Production indexes are add-only, starting minimal and extending later is reversible; starting
maximal is not.
import-schema is a declarative import, so a .ckdb can be hand-authored from scratch โ
no Dashboard clicking, no live seed build required. Use one export's output as the syntax
template (it includes the system "___*" fields and the GRANT block a hand-written file
also needs).
Idempotency
export: re-running always overwrites cloudkit/<app>.ckdb with the current Development
schema โ treat the file as generated + reviewed, not hand-edited, whenever a live export is
the intended source.
import: CloudKit's import is declarative โ re-applying the same unchanged .ckdb is a
no-op.
Rationale
Treating one .ckdb per app as the schema source of truth gives CloudKit the same
review-before-merge discipline a SQL migration file gets, despite CloudKit having no native
migration mechanism. Restricting the token to Development-only tooling, and Production to a
Console click, matches Apple's own irreversibility constraint (add-only fields) to a
correspondingly irreversible, deliberately manual approval step.
Deviation considerations
- A container with no meaningful schema evolution (fixed at launch, never touched again):
a single manual export is enough; the ongoing export/validate/import loop isn't worth
automating for a container that never changes.
- Multiple apps sharing one CloudKit container: keep one
.ckdb per container (not per
app) and make the ownership of shared record types explicit in its surrounding docs, so two
apps don't independently "fix" the same field in diverging ways.
Common Mistakes
- Assuming
import-schema --environment production works because it's syntactically
accepted-looking โ it is Development-only; Production is Console-only.
- Skipping the Development JIT-seed step before an export โ the export then reflects an
incomplete schema, and the gap resurfaces later as a silent Production write failure.
- Leaving the management token in
cktool's keychain store after a session โ purge it
even on script failure via a trap.
- Hand-editing
.ckdb opportunistically without re-validating against the live
Development container before importing.
- Over-indexing a hand-authored
.ckdb (marking every field QUERYABLE SEARCHABLE SORTABLE out of caution) when Production indexes can only be added to later, never removed.
- Never diffing Production's actual schema against the code's write surface โ the
silent-missing-field failure mode is only caught by an explicit audit, not by normal testing.
Review Checklist
Related skills
swift-dependency-injection โ how CloudKit access is injected and faked, keeping schema concerns out of call sites.
swift-testing-baseline โ gate live CloudKit/Game Center access behind a test-only suppression seam; constructing a live container or auth handler inside a test blocks the unentitled SwiftPM runner indefinitely (its "unentitled runner" section covers this landmine โ this skill's schema is only ever tested against, never through a live container in CI).
apple-public-repo-security โ why the management token is a stricter secret class than a build-time public identifier.
build-time-secret-injection โ the general env-file-based secret pattern this workflow's token handling follows.