git:20260720.5eb0c7e to git:20260720.90fdb56

1 added, 0 removed. Audit A to A.

---
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
```bash
# 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 --team-id "$CK_TEAM_ID" "$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 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:
1. Bring Development fully in sync first (`import-schema --environment development` above).
2. 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
1. **`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).
2. **`validate-schema` requires `--environment` explicitly** — omitting it is a hard error, not
a default.
3. **`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.
4. **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.
5. **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.
6. **`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
1. **Assuming `import-schema --environment production` works** because it's syntactically
accepted-looking — it is Development-only; Production is Console-only.
2. **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.
3. **Leaving the management token in `cktool`'s keychain store** after a session — purge it
even on script failure via a `trap`.
4. **Hand-editing `.ckdb` opportunistically** without re-validating against the live
Development container before importing.
5. **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.
6. **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
- [ ] `.ckdb` is committed under version control; the management token is not.
- [ ] `export` was run after a Development-seeding build, not against a partially-provisioned container.
- [ ] `validate-schema` was run with an explicit `--environment` before any `import`.
- [ ] The management token is purged from `cktool`'s keychain store, even on failure paths.
- [ ] No tooling assumes `import-schema` can target Production — the Console step is documented as the only path.
- [ ] A hand-authored or reviewed `.ckdb` declares only the indexes the app's actual queries need.
- [ ] Production's exported schema has been diffed against the code's write surface at least once since the last Console promotion.
## 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.