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
# Load credentials for this shell session only.
set -a; source secrets/.env; set +a
# 1. Authenticate cktool for this session (positional arg — see gotcha 1 below).
xcrun cktool save-token --type management --force "$CK_MANAGEMENT_TOKEN"
# 2. Export the live Development schema to the committed source-of-truth file.
# Seed step first: run a debug build once so the app's JIT schema provisions
# the Development container, THEN export.
xcrun cktool export-schema \
--team-id "$CK_TEAM_ID" --container-id "$CK_CONTAINER_ID" \
--environment development > cloudkit/myapp.ckdb
# 3. Pre-flight: validate the committed .ckdb against the live container before importing.
xcrun cktool validate-schema \
--team-id "$CK_TEAM_ID" --container-id "$CK_CONTAINER_ID" \
--environment development --file cloudkit/myapp.ckdb
# 4. Deploy to Development — freely runnable and reversible.
xcrun cktool import-schema \
--team-id "$CK_TEAM_ID" --container-id "$CK_CONTAINER_ID" \
--environment development --file cloudkit/myapp.ckdb
# 5. Always clear the token from cktool's keychain store when done.
xcrun cktool remove-token --type management --force
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 the server returns a
CKError for the unknown field when the app tries to save it — this only looks silent if
the app's own save-completion handling swallows or ignores that error instead of surfacing
it. Audit method: export-schema --environment production to a scratch file and diff
its field set against every field the code actually writes, and confirm every CloudKit save
call actually surfaces its error instead of discarding it.
- 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 Production write returning
CKError
for the unknown field, which looks like a silent failure only if that error is discarded.
- 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
missing-field write returns a
CKError that many apps never surface anywhere visible, so
the 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.
1---2name: cloudkit-schema-source-of-truth3description: 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.4---56# CloudKit Schema Source of Truth78CloudKit has no migration-file system like a SQL database. The schema lives in Apple's9CloudKit Dashboard/Console, and `xcrun cktool` (Apple's official CLI, ships with Xcode) can10export, validate, and import it — but only against the **Development** environment.11Production promotion is a manual, irreversible Console action. This skill makes the12Development side of that workflow scriptable and commit-trackable while keeping the13Production gate correctly user-owned.1415## When to invoke1617- A persistence change adds, renames, or edits a CloudKit record type, field, or index.18- You need to push schema to a container (Development or Production).19- Before any Production schema deploy — read the safety gate below first.20- Asked why a `.ckdb` file is committed to the repo, or why a field the app writes is missing21 from Production data.2223## Scope2425Owns: the `.ckdb`-as-source-of-truth workflow, `cktool` invocations against Development, and26the Production promotion gate. Does **not** own: the Swift-side persistence/service code that27reads and writes CloudKit records → `swift-dependency-injection` for how that seam is28injected and faked in tests; secret storage for the management token itself →29`apple-public-repo-security` / `build-time-secret-injection`.3031## Prerequisites (one-time, user-owned)3233Two credentials, kept in a gitignored env file (e.g. `secrets/.env`, with a committed34`.env.example` template):3536- **A CloudKit management token** — generated by a human in CloudKit Dashboard → Settings →37 Tokens → Create Token (Management). This is a privileged credential; treat it like an API38 key with schema-write access, not like a build-time public identifier.39- **The Apple Developer Team ID** (10 characters).4041The container identifier itself (e.g. `iCloud.com.example.myapp`) is not secret and can be42hardcoded in tooling.4344## Workflow4546```bash47# Load credentials for this shell session only.48set -a; source secrets/.env; set +a4950# 1. Authenticate cktool for this session (positional arg — see gotcha 1 below).51xcrun cktool save-token --type management --force "$CK_MANAGEMENT_TOKEN"5253# 2. Export the live Development schema to the committed source-of-truth file.54# Seed step first: run a debug build once so the app's JIT schema provisions55# the Development container, THEN export.56xcrun cktool export-schema \57 --team-id "$CK_TEAM_ID" --container-id "$CK_CONTAINER_ID" \58 --environment development > cloudkit/myapp.ckdb5960# 3. Pre-flight: validate the committed .ckdb against the live container before importing.61xcrun cktool validate-schema \62 --team-id "$CK_TEAM_ID" --container-id "$CK_CONTAINER_ID" \63 --environment development --file cloudkit/myapp.ckdb6465# 4. Deploy to Development — freely runnable and reversible.66xcrun cktool import-schema \67 --team-id "$CK_TEAM_ID" --container-id "$CK_CONTAINER_ID" \68 --environment development --file cloudkit/myapp.ckdb6970# 5. Always clear the token from cktool's keychain store when done.71xcrun cktool remove-token --type management --force72```7374Run step 5 in a shell `trap ... EXIT` around steps 1–4 so the token is purged even if a step75fails midway.7677## Inputs / outputs7879- **Input**: the credentials above, plus either the live Development container (`export`) or80 the committed `cloudkit/<app>.ckdb` (`validate` / `import`).81- **Output**: `export` overwrites `cloudkit/<app>.ckdb` — review the diff, then commit it as82 the schema source of truth. `import` mutates the named container's live schema.83- `.ckdb` files are **not secrets** — they contain schema definitions only, no data or tokens84 — so they're committed like any other source file, distinct from the token itself.8586## Safety gate — Production promotion is user-owned, Console-only, irreversible8788**`cktool` cannot push schema to Production.** `import-schema --environment production`89rejects with an "endpoint not applicable in this environment" style error, and there is no90promote subcommand. The Development → Production promotion happens **only** in the CloudKit91Console:92931. Bring Development fully in sync first (`import-schema --environment development` above).942. Console → your container → environment **Development** → Schema → **"Deploy Schema Changes95 to Production…"** → review the generated field/index diff → confirm the deploy.9697CloudKit Production fields and indexes are **add-only** by Apple's own rule — once deployed98they can never be removed or renamed, only added to. Restricting the promotion path to the99Console keeps it naturally user-owned: automation prepares and validates the `.ckdb` and the100Development deploy; a human clicks the actual Production button.101102`export` / `validate` / `import --environment development` are all reversible and safe to run103repeatedly without asking anyone.104105## Live-run gotchas1061071. **`save-token` takes the token as a positional argument, not piped stdin.** Non-interactive108 stdin piping fails with an "interaction was required" style error. Brief command-line109 argv exposure of the token is the tradeoff; purge it from the keychain store immediately110 after (see the `trap` note above).1112. **`validate-schema` requires `--environment` explicitly** — omitting it is a hard error, not112 a default.1133. **`import-schema` only ever targets Development.** Don't assume a script that "runs114 `import-schema --environment production`" has ever actually been exercised — smoke-test any115 such tooling against real credentials before trusting it; a plausible-looking Production116 import path that was never live-tested can sit broken for a long time undetected.1174. **Just-in-time (JIT) schema exists only in Development.** A debug build auto-creates record118 types and fields the first time it writes them, in Development only — Production never does119 this. Corollary: any field the app code writes that was never JIT-seeded in Development120 *before* the last Console promotion is **missing in Production**, and the server returns a121 `CKError` for the unknown field when the app tries to save it — this only *looks* silent if122 the app's own save-completion handling swallows or ignores that error instead of surfacing123 it. **Audit method**: `export-schema --environment production` to a scratch file and diff124 its field set against every field the code actually writes, and confirm every CloudKit save125 call actually surfaces its error instead of discarding it.1265. **JIT marks every field it creates `QUERYABLE SEARCHABLE SORTABLE`.** A hand-authored127 `.ckdb` should declare the **minimal** index set the app's actual queries need instead128 (e.g. only the one field a specific equality query filters on, as `QUERYABLE`) — because129 Production indexes are add-only, starting minimal and extending later is reversible; starting130 maximal is not.1316. **`import-schema` is a declarative import**, so a `.ckdb` can be hand-authored from scratch —132 no Dashboard clicking, no live seed build required. Use one `export`'s output as the syntax133 template (it includes the system `"___*"` fields and the `GRANT` block a hand-written file134 also needs).135136## Idempotency137138- `export`: re-running always overwrites `cloudkit/<app>.ckdb` with the current Development139 schema — treat the file as generated + reviewed, not hand-edited, whenever a live export is140 the intended source.141- `import`: CloudKit's import is declarative — re-applying the same unchanged `.ckdb` is a142 no-op.143144## Rationale145146Treating one `.ckdb` per app as the schema source of truth gives CloudKit the same147review-before-merge discipline a SQL migration file gets, despite CloudKit having no native148migration mechanism. Restricting the token to Development-only tooling, and Production to a149Console click, matches Apple's own irreversibility constraint (add-only fields) to a150correspondingly irreversible, deliberately manual approval step.151152## Deviation considerations153154- **A container with no meaningful schema evolution** (fixed at launch, never touched again):155 a single manual export is enough; the ongoing export/validate/import loop isn't worth156 automating for a container that never changes.157- **Multiple apps sharing one CloudKit container**: keep one `.ckdb` per container (not per158 app) and make the ownership of shared record types explicit in its surrounding docs, so two159 apps don't independently "fix" the same field in diverging ways.160161## Common Mistakes1621631. **Assuming `import-schema --environment production` works** because it's syntactically164 accepted-looking — it is Development-only; Production is Console-only.1652. **Skipping the Development JIT-seed step before an export** — the export then reflects an166 incomplete schema, and the gap resurfaces later as a Production write returning `CKError`167 for the unknown field, which looks like a silent failure only if that error is discarded.1683. **Leaving the management token in `cktool`'s keychain store** after a session — purge it169 even on script failure via a `trap`.1704. **Hand-editing `.ckdb` opportunistically** without re-validating against the live171 Development container before importing.1725. **Over-indexing a hand-authored `.ckdb`** (marking every field `QUERYABLE SEARCHABLE173 SORTABLE` out of caution) when Production indexes can only be added to later, never removed.1746. **Never diffing Production's actual schema against the code's write surface** — the175 missing-field write returns a `CKError` that many apps never surface anywhere visible, so176 the failure mode is only caught by an explicit audit, not by normal testing.177178## Review Checklist179180- [ ] `.ckdb` is committed under version control; the management token is not.181- [ ] `export` was run after a Development-seeding build, not against a partially-provisioned container.182- [ ] `validate-schema` was run with an explicit `--environment` before any `import`.183- [ ] The management token is purged from `cktool`'s keychain store, even on failure paths.184- [ ] No tooling assumes `import-schema` can target Production — the Console step is documented as the only path.185- [ ] A hand-authored or reviewed `.ckdb` declares only the indexes the app's actual queries need.186- [ ] Production's exported schema has been diffed against the code's write surface at least once since the last Console promotion.187188## Related skills189190- `swift-dependency-injection` — how CloudKit access is injected and faked, keeping schema concerns out of call sites.191- `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).192- `apple-public-repo-security` — why the management token is a stricter secret class than a build-time public identifier.193- `build-time-secret-injection` — the general env-file-based secret pattern this workflow's token handling follows.