---
name: cloudkit-deploy-schema
description: "Deploy this app's CloudKit record schema from the Development environment to Production, safely — includes preflight checks and mandatory human-confirmation gates before anything destructive or production-affecting. Use when promoting a new or changed CloudKit schema to Production (including the first-ever/greenfield deploy for a new container), or when asked to update/sync the Production CloudKit schema."
---

# Deploy CloudKit schema to Production

A narrow, safety-first operational skill for one specific task: getting this
app's CloudKit **schema** (record types, fields, indexes — never data) from
the Development environment into Production. Production is what TestFlight
and App Store builds actually use (see `docs/tech-design-doc.md` §4.3), so an
undeployed or wrong schema means sync silently fails for exactly the builds
that matter, with no visible error to the end user.

**This skill is deliberately conservative.** CloudKit Production schema
changes are (practically) one-directional and permanent: fields can be
*added* to Production at any time, but **cannot be deleted** once deployed
(`docs/tech-design-doc.md` §4.3). A careless deploy bakes in cruft forever.
Treat every deploy — the first one and every subsequent one — as something to
slow down for, not a routine mechanical step.

## Model preference

**Run this skill on the latest Opus-tier model (or its contemporary
equivalent), not Sonnet.** Unlike the checklist-walking orchestration in
`appstore-prepare-for-release` (deliberately Sonnet — mechanical steps, no
linguistic/architectural judgment), this skill's entire job *is* judgment:
deciding whether a schema diff is intentional or stale cruft, whether a field
removed from the Swift model was ever actually synced to Development, and
whether a given action is the safe direction or the destructive one. The
consequence of a wrong call here is a permanent, unfixable mutation to a
production system serving real users — that is exactly the profile of a
release-checklist item worth the extra reasoning depth (same rationale as
`appstore-prepare-for-release`'s P1.6/P1.7/P1.8/P6.1 escalations, which this
skill supersedes for schema work specifically at P3.7). If invoked from a
Sonnet-orchestrated session (e.g. mid-checklist), spawn this skill's work on
an Opus subagent rather than running it inline on Sonnet.

## Hard rules

- **Never treat this as a fire-and-forget script.** Every step below that
  writes anything (Reset Environment, Deploy Schema Changes, `cktool
  import-schema` to *either* environment) requires an explicit, freshly-stated
  human confirmation immediately before running it — even if the user
  approved the overall plan earlier in the conversation. Re-confirm at the
  point of action, not just at the start.
- **"Reset Environment" and "Deploy Schema Changes" are opposite directions —
  never conflate them.**
  - **Reset Environment** (Development only) wipes Development's schema *and*
    data back to match whatever is *currently* in Production. If Production
    is empty or behind Development, this is destructive to Development's
    working state.
  - **Deploy Schema Changes** (Development → Production) is additive-only:
    it pushes Development's current schema to Production. It never touches
    data in either direction.
  - Before either action, state out loud (to the user) which direction you
    believe you're going and why, and get their explicit go-ahead.
- **Never hand-author or hand-edit a CKSL (`.ckdb`) file's field types from
  guesswork about how SwiftData encodes a property.** SwiftData's CloudKit
  mirroring has its own internal encoding (e.g. for `Decimal`, optional
  arrays, nested `Codable` structs) that isn't safe to reverse-engineer.
  The reliable source of truth for "what should the schema contain" is
  **the Development schema as generated by the app's own real persistence
  code** — never a schema file typed up by hand.
- **Data and schema are entirely separate stores.** Clearing/reviewing
  Development *data* has zero effect on what a schema deploy pushes to
  Production, and vice versa. Don't conflate a data-hygiene concern with a
  schema-deploy concern.
- **The CloudKit Console's "Deploy Schema Changes" button is the *only* way
  to promote a schema to Production — there is no `cktool` equivalent,
  despite appearances.** `cktool import-schema` accepts `--environment
  production` as a syntactically valid flag value (and Apple's own man page
  lists it without caveat), but the server-side endpoint rejects it at
  request time: `BadRequestException: endpoint not applicable in the
  environment 'production'` (confirmed empirically, not just documented —
  don't trust the flag's accepted-values list over what the API actually
  does). `import-schema` only actually works against `--environment
  development`. Budget for a manual Console click at the deploy step every
  time; don't try to script around it.

## Preflight checks (every invocation)

Run these before touching anything, and report the results before asking for
any confirmation:

1. **Identify the container and team.** Read
   `com.apple.developer.icloud-container-identifiers` from
   `simple-recurring-budgets/Resources/simple_recurring_budgets.entitlements`
   for the container id; the team id comes from the Apple Developer account
   in use (ask the user if not obvious from `fastlane/.env` or Xcode
   settings).
2. **Confirm `cktool` auth.** Schema management commands need a **Management
   Token** (different from the ASC API key used elsewhere in this repo):
   ```bash
   xcrun cktool save-token --type management
   ```
   This is interactive (opens a browser/prompts for the token) — tell the
   user to run it themselves if not already authenticated; don't attempt to
   script around it.
3. **Snapshot both environments before changing anything:**
   ```bash
   xcrun cktool export-schema --team-id <TEAM> --container-id <CONTAINER> --environment development --output-file tmp/schema-dev.ckdb
   xcrun cktool export-schema --team-id <TEAM> --container-id <CONTAINER> --environment production --output-file tmp/schema-prod.ckdb
   ```
   Show the user a summary of each (record types present, field counts) and
   the diff between them. This is your evidence base for every subsequent
   question — don't ask the user to characterize the current state from
   memory when you can just read it.
4. **Full bidirectional model-vs-schema field audit — mandatory before every
   deploy, not optional.** Cross-reference the Development schema (exported
   above) against the *current* Swift model source
   (`simple-recurring-budgets/Models/*.swift`) in **both** directions and
   present the result as a table (one row per `@Model` class):
   - **Stale fields** (in the CloudKit schema, absent from the current
     model): a candidate for removal from Development before deploying —
     see "Handling stale fields" below. Don't rely on memory of what used to
     exist; `git log -p -- path/to/Model.swift` is the authoritative record.
   - **Missing fields** (a stored property on the current model with **no**
     corresponding `CD_<field>` in the exported Development schema): this
     means the field never got a non-nil value written anywhere in whatever
     generated the current Development data, so CloudKit never created it —
     it will silently be absent from Production too if you deploy now. This
     is the more dangerous direction to miss, because it doesn't show up as
     a "diff" against anything; it's an absence. Block the deploy and go
     back to generating more complete Development data (see step 2 in
     Recipe A, or the equivalent regeneration for Recipe B) until every
     stored property has a corresponding field.
   - When listing "current model" properties, get the list directly from
     each `@Model` class's stored properties (`var`/`let` declarations that
     aren't computed), not from memory or from what a test/preview happens
     to touch. Exclude the model's own to-many relationship arrays from the
     "should appear on this record type" expectation — SwiftData represents
     a to-many relationship via the inverse foreign key (`CD_<parentType>`)
     on the *child* record type, not as a field on the parent. Confirm each
     relationship's inverse key is present on the child type instead of
     expecting it on the parent.
   - Do this for **every** `@Model` class in the schema's `models` array
     (check the `VersionedSchema` conformer, e.g. `SchemaV1.swift`, for the
     authoritative list), not just the ones that seem most relevant.

## Recipe

### A. First-ever ("greenfield") deploy for a container

Use when Production's schema is empty or near-empty (only Apple's built-in
system record types, e.g. `Users`) and this is the first time this
container's schema is being established.

1. Run the preflight checks above. Confirm with the user that Production is
   indeed empty/greenfield — don't assume it from a prior conversation turn.
2. **Generate a complete, deterministic Development schema from real app
   code** — do not rely on ad-hoc manual UI exercise, which risks missing a
   field. The reliable path:
   - Check whether a comprehensive fixture/seed routine already exists in
     the codebase (e.g. `DebugData.seed(into:)` or equivalent — search for
     `#if DEBUG` seed helpers under `Models/` or `Previews/`). If one exists
     and its coverage of model fields looks complete (cross-check against
     the current model's stored properties), prefer reusing it.
   - Wire it to a **throwaway** trigger (a `#if DEBUG`-gated button, or a
     temporary call in the app-launch path) on a **separate throwaway git
     branch that is never merged to main** — never commit temporary
     seed-wiring to a real feature branch or main.
   - Have the user run a plain Debug/Development-signed build (Xcode Run,
     simulator or physical device — what matters is Development code
     signing, not device vs. simulator) signed into a real iCloud account
     with network on, and trigger the seed once.
   - Confirm the seed actually reached CloudKit (not just the local SwiftData
     store) — ask the user to check the CloudKit Console's Development data
     browser for the new records, or re-run `export-schema --environment
     development` and confirm the expected record types/fields now appear.
   - Don't delete the throwaway branch yet — keep it until the deploy is
     verified (step 5/7 below), in case a gap surfaces in the audit and you
     need to re-seed.
3. Re-export the Development schema and **re-run the full field audit from
   preflight check 4** (not just a diff against empty Production — every
   model field must be confirmed present, not just "different from
   before"). Present the audit table and the diff against the empty
   Production baseline to the user. **Explicit confirmation required**
   before proceeding.
4. Deploy: tell the user to go to CloudKit Console → container → Schema →
   **Deploy Schema Changes**. This step is manual, always — `cktool
   import-schema --environment production` is not a real alternative (the
   API rejects it; see the hard rule above). Have the user confirm once
   they've clicked through the Console's diff preview and committed.
5. Verify: `export-schema --environment production` again, confirm it now
   matches Development's record types/fields.
6. Record what was deployed (record types, field count, date, method used)
   in the release checklist snapshot's P3.7 item (see
   `docs/app-store-release-checklist.md`) and/or `docs/tech-design-doc.md`
   §4.3 if the constraint notes need updating.
7. **Clean up the throwaway branch now that the deploy is verified** — don't
   leave it lying around "just in case." Confirm its only commits are the
   temporary seed-wiring (`git log --oneline main..<branch>`, sanity-check
   the diff touches only the throwaway trigger, nothing else), then delete
   it:
   ```bash
   git branch -D <throwaway-branch>
   ```
   (force-delete, since it was deliberately never merged — a plain `-d`
   will refuse). If it was ever pushed to a remote, delete it there too
   (`git push origin --delete <throwaway-branch>`) — though per the rule
   above it should have stayed local-only.

### B. Incremental deploy (schema already established in Production)

Use for any release after the first, when the SwiftData model has changed
(new fields/record types) since the last deploy.

1. Run the preflight checks. The diff between the two exported schemas *is*
   the change set — present it to the user explicitly, field by field.
2. **If preflight check 4 flags any "missing fields"** (a new stored property
   on the model with no corresponding field yet in Development — the normal
   case right after adding a field to an `@Model` class, before any record
   has ever been saved with a non-nil value for it), the same problem as
   Recipe A step 2 applies: don't guess, generate real data. Follow that
   step's method — reuse/extend a `#if DEBUG` seed fixture on a throwaway
   branch, run a Debug build signed into a real iCloud account, confirm the
   field materializes via a re-export — before proceeding. Skip this step
   only if the audit found no missing fields.
3. Confirm every new field in the diff is *intentional* (traces to an actual
   model change, not stale cruft) — cross-check against `git log` for the
   relevant model files if anything looks unexpected.
4. **Explicit confirmation required** before deploying, quoting the exact
   diff being promoted.
5. Deploy: tell the user to go to CloudKit Console → container → Schema →
   **Deploy Schema Changes** (manual, always — see the hard rule above).
6. Verify with a post-deploy `export-schema --environment production`.
7. Record the deploy in the current release's checklist snapshot P3.7 item.
8. If step 2 required a throwaway seeding branch, **clean it up now** per
   Recipe A step 7 (sanity-check its commits, then `git branch -D`).

## Handling stale fields in Development

If the preflight check finds Development schema fields with no corresponding
property on the current model (e.g. a renamed or removed field from an
earlier development iteration):

- These **can** be deleted directly in the CloudKit Console (Development
  schema, unlike Production, supports field deletion) — confirm with the
  user which specific fields to remove, one by one, before doing so.
- **Never** use Reset Environment as a shortcut to "clean up" Development
  stale fields unless Production is confirmed empty/behind — Reset
  Environment wipes Development back to match Production's *current* state,
  which is destructive if Production isn't already a superset of what you
  want to keep.
- Removing a stale field from Development has no effect on Production unless
  it was already deployed there — in which case it's stuck permanently
  (Production can't lose fields) and the right move is just to stop adding
  data to it going forward, not to attempt removal.

## Where this fits in a release

This skill is invoked from the App Store release checklist's **P3.7**
(`docs/app-store-release-checklist.md`, driven by `/appstore:prepare-for-release`) —
that item's Note should reference back to what this skill did (deploy method,
fields involved, verification result) rather than duplicating the full
recipe.
