cloudkit-schema-source-of-truth · git:20260720.90fdb56 · 2026-07-20 · sha256 f23f11dafb644030
cloudkit-schema-source-of-truth git:20260720.90fdb56A
Immutable. This exact content is served forever at /api/v1/blob/f23f11dafb644030.
--- 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.