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:
- 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).
- Confirm
cktool auth. Schema management commands need a Management
Token (different from the ASC API key used elsewhere in this repo):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.
- Snapshot both environments before changing anything:
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.
- 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.
- Run the preflight checks above. Confirm with the user that Production is
indeed empty/greenfield — don't assume it from a prior conversation turn.
- 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.
- 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.
- 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.
- Verify:
export-schema --environment production again, confirm it now
matches Development's record types/fields.
- 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.
- 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: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.
- Run the preflight checks. The diff between the two exported schemas is
the change set — present it to the user explicitly, field by field.
- 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.
- 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.
- Explicit confirmation required before deploying, quoting the exact
diff being promoted.
- Deploy: tell the user to go to CloudKit Console → container → Schema →
Deploy Schema Changes (manual, always — see the hard rule above).
- Verify with a post-deploy
export-schema --environment production.
- Record the deploy in the current release's checklist snapshot P3.7 item.
- 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.
1---2name: cloudkit-deploy-schema3description: 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.4---56# Deploy CloudKit schema to Production78A narrow, safety-first operational skill for one specific task: getting this9app's CloudKit **schema** (record types, fields, indexes — never data) from10the Development environment into Production. Production is what TestFlight11and App Store builds actually use (see `docs/tech-design-doc.md` §4.3), so an12undeployed or wrong schema means sync silently fails for exactly the builds13that matter, with no visible error to the end user.1415**This skill is deliberately conservative.** CloudKit Production schema16changes are (practically) one-directional and permanent: fields can be17*added* to Production at any time, but **cannot be deleted** once deployed18(`docs/tech-design-doc.md` §4.3). A careless deploy bakes in cruft forever.19Treat every deploy — the first one and every subsequent one — as something to20slow down for, not a routine mechanical step.2122## Model preference2324**Run this skill on the latest Opus-tier model (or its contemporary25equivalent), not Sonnet.** Unlike the checklist-walking orchestration in26`appstore-prepare-for-release` (deliberately Sonnet — mechanical steps, no27linguistic/architectural judgment), this skill's entire job *is* judgment:28deciding whether a schema diff is intentional or stale cruft, whether a field29removed from the Swift model was ever actually synced to Development, and30whether a given action is the safe direction or the destructive one. The31consequence of a wrong call here is a permanent, unfixable mutation to a32production system serving real users — that is exactly the profile of a33release-checklist item worth the extra reasoning depth (same rationale as34`appstore-prepare-for-release`'s P1.6/P1.7/P1.8/P6.1 escalations, which this35skill supersedes for schema work specifically at P3.7). If invoked from a36Sonnet-orchestrated session (e.g. mid-checklist), spawn this skill's work on37an Opus subagent rather than running it inline on Sonnet.3839## Hard rules4041- **Never treat this as a fire-and-forget script.** Every step below that42 writes anything (Reset Environment, Deploy Schema Changes, `cktool43 import-schema` to *either* environment) requires an explicit, freshly-stated44 human confirmation immediately before running it — even if the user45 approved the overall plan earlier in the conversation. Re-confirm at the46 point of action, not just at the start.47- **"Reset Environment" and "Deploy Schema Changes" are opposite directions —48 never conflate them.**49 - **Reset Environment** (Development only) wipes Development's schema *and*50 data back to match whatever is *currently* in Production. If Production51 is empty or behind Development, this is destructive to Development's52 working state.53 - **Deploy Schema Changes** (Development → Production) is additive-only:54 it pushes Development's current schema to Production. It never touches55 data in either direction.56 - Before either action, state out loud (to the user) which direction you57 believe you're going and why, and get their explicit go-ahead.58- **Never hand-author or hand-edit a CKSL (`.ckdb`) file's field types from59 guesswork about how SwiftData encodes a property.** SwiftData's CloudKit60 mirroring has its own internal encoding (e.g. for `Decimal`, optional61 arrays, nested `Codable` structs) that isn't safe to reverse-engineer.62 The reliable source of truth for "what should the schema contain" is63 **the Development schema as generated by the app's own real persistence64 code** — never a schema file typed up by hand.65- **Data and schema are entirely separate stores.** Clearing/reviewing66 Development *data* has zero effect on what a schema deploy pushes to67 Production, and vice versa. Don't conflate a data-hygiene concern with a68 schema-deploy concern.69- **The CloudKit Console's "Deploy Schema Changes" button is the *only* way70 to promote a schema to Production — there is no `cktool` equivalent,71 despite appearances.** `cktool import-schema` accepts `--environment72 production` as a syntactically valid flag value (and Apple's own man page73 lists it without caveat), but the server-side endpoint rejects it at74 request time: `BadRequestException: endpoint not applicable in the75 environment 'production'` (confirmed empirically, not just documented —76 don't trust the flag's accepted-values list over what the API actually77 does). `import-schema` only actually works against `--environment78 development`. Budget for a manual Console click at the deploy step every79 time; don't try to script around it.8081## Preflight checks (every invocation)8283Run these before touching anything, and report the results before asking for84any confirmation:85861. **Identify the container and team.** Read87 `com.apple.developer.icloud-container-identifiers` from88 `simple-recurring-budgets/Resources/simple_recurring_budgets.entitlements`89 for the container id; the team id comes from the Apple Developer account90 in use (ask the user if not obvious from `fastlane/.env` or Xcode91 settings).922. **Confirm `cktool` auth.** Schema management commands need a **Management93 Token** (different from the ASC API key used elsewhere in this repo):94 ```bash95 xcrun cktool save-token --type management96 ```97 This is interactive (opens a browser/prompts for the token) — tell the98 user to run it themselves if not already authenticated; don't attempt to99 script around it.1003. **Snapshot both environments before changing anything:**101 ```bash102 xcrun cktool export-schema --team-id <TEAM> --container-id <CONTAINER> --environment development --output-file tmp/schema-dev.ckdb103 xcrun cktool export-schema --team-id <TEAM> --container-id <CONTAINER> --environment production --output-file tmp/schema-prod.ckdb104 ```105 Show the user a summary of each (record types present, field counts) and106 the diff between them. This is your evidence base for every subsequent107 question — don't ask the user to characterize the current state from108 memory when you can just read it.1094. **Full bidirectional model-vs-schema field audit — mandatory before every110 deploy, not optional.** Cross-reference the Development schema (exported111 above) against the *current* Swift model source112 (`simple-recurring-budgets/Models/*.swift`) in **both** directions and113 present the result as a table (one row per `@Model` class):114 - **Stale fields** (in the CloudKit schema, absent from the current115 model): a candidate for removal from Development before deploying —116 see "Handling stale fields" below. Don't rely on memory of what used to117 exist; `git log -p -- path/to/Model.swift` is the authoritative record.118 - **Missing fields** (a stored property on the current model with **no**119 corresponding `CD_<field>` in the exported Development schema): this120 means the field never got a non-nil value written anywhere in whatever121 generated the current Development data, so CloudKit never created it —122 it will silently be absent from Production too if you deploy now. This123 is the more dangerous direction to miss, because it doesn't show up as124 a "diff" against anything; it's an absence. Block the deploy and go125 back to generating more complete Development data (see step 2 in126 Recipe A, or the equivalent regeneration for Recipe B) until every127 stored property has a corresponding field.128 - When listing "current model" properties, get the list directly from129 each `@Model` class's stored properties (`var`/`let` declarations that130 aren't computed), not from memory or from what a test/preview happens131 to touch. Exclude the model's own to-many relationship arrays from the132 "should appear on this record type" expectation — SwiftData represents133 a to-many relationship via the inverse foreign key (`CD_<parentType>`)134 on the *child* record type, not as a field on the parent. Confirm each135 relationship's inverse key is present on the child type instead of136 expecting it on the parent.137 - Do this for **every** `@Model` class in the schema's `models` array138 (check the `VersionedSchema` conformer, e.g. `SchemaV1.swift`, for the139 authoritative list), not just the ones that seem most relevant.140141## Recipe142143### A. First-ever ("greenfield") deploy for a container144145Use when Production's schema is empty or near-empty (only Apple's built-in146system record types, e.g. `Users`) and this is the first time this147container's schema is being established.1481491. Run the preflight checks above. Confirm with the user that Production is150 indeed empty/greenfield — don't assume it from a prior conversation turn.1512. **Generate a complete, deterministic Development schema from real app152 code** — do not rely on ad-hoc manual UI exercise, which risks missing a153 field. The reliable path:154 - Check whether a comprehensive fixture/seed routine already exists in155 the codebase (e.g. `DebugData.seed(into:)` or equivalent — search for156 `#if DEBUG` seed helpers under `Models/` or `Previews/`). If one exists157 and its coverage of model fields looks complete (cross-check against158 the current model's stored properties), prefer reusing it.159 - Wire it to a **throwaway** trigger (a `#if DEBUG`-gated button, or a160 temporary call in the app-launch path) on a **separate throwaway git161 branch that is never merged to main** — never commit temporary162 seed-wiring to a real feature branch or main.163 - Have the user run a plain Debug/Development-signed build (Xcode Run,164 simulator or physical device — what matters is Development code165 signing, not device vs. simulator) signed into a real iCloud account166 with network on, and trigger the seed once.167 - Confirm the seed actually reached CloudKit (not just the local SwiftData168 store) — ask the user to check the CloudKit Console's Development data169 browser for the new records, or re-run `export-schema --environment170 development` and confirm the expected record types/fields now appear.171 - Don't delete the throwaway branch yet — keep it until the deploy is172 verified (step 5/7 below), in case a gap surfaces in the audit and you173 need to re-seed.1743. Re-export the Development schema and **re-run the full field audit from175 preflight check 4** (not just a diff against empty Production — every176 model field must be confirmed present, not just "different from177 before"). Present the audit table and the diff against the empty178 Production baseline to the user. **Explicit confirmation required**179 before proceeding.1804. Deploy: tell the user to go to CloudKit Console → container → Schema →181 **Deploy Schema Changes**. This step is manual, always — `cktool182 import-schema --environment production` is not a real alternative (the183 API rejects it; see the hard rule above). Have the user confirm once184 they've clicked through the Console's diff preview and committed.1855. Verify: `export-schema --environment production` again, confirm it now186 matches Development's record types/fields.1876. Record what was deployed (record types, field count, date, method used)188 in the release checklist snapshot's P3.7 item (see189 `docs/app-store-release-checklist.md`) and/or `docs/tech-design-doc.md`190 §4.3 if the constraint notes need updating.1917. **Clean up the throwaway branch now that the deploy is verified** — don't192 leave it lying around "just in case." Confirm its only commits are the193 temporary seed-wiring (`git log --oneline main..<branch>`, sanity-check194 the diff touches only the throwaway trigger, nothing else), then delete195 it:196 ```bash197 git branch -D <throwaway-branch>198 ```199 (force-delete, since it was deliberately never merged — a plain `-d`200 will refuse). If it was ever pushed to a remote, delete it there too201 (`git push origin --delete <throwaway-branch>`) — though per the rule202 above it should have stayed local-only.203204### B. Incremental deploy (schema already established in Production)205206Use for any release after the first, when the SwiftData model has changed207(new fields/record types) since the last deploy.2082091. Run the preflight checks. The diff between the two exported schemas *is*210 the change set — present it to the user explicitly, field by field.2112. **If preflight check 4 flags any "missing fields"** (a new stored property212 on the model with no corresponding field yet in Development — the normal213 case right after adding a field to an `@Model` class, before any record214 has ever been saved with a non-nil value for it), the same problem as215 Recipe A step 2 applies: don't guess, generate real data. Follow that216 step's method — reuse/extend a `#if DEBUG` seed fixture on a throwaway217 branch, run a Debug build signed into a real iCloud account, confirm the218 field materializes via a re-export — before proceeding. Skip this step219 only if the audit found no missing fields.2203. Confirm every new field in the diff is *intentional* (traces to an actual221 model change, not stale cruft) — cross-check against `git log` for the222 relevant model files if anything looks unexpected.2234. **Explicit confirmation required** before deploying, quoting the exact224 diff being promoted.2255. Deploy: tell the user to go to CloudKit Console → container → Schema →226 **Deploy Schema Changes** (manual, always — see the hard rule above).2276. Verify with a post-deploy `export-schema --environment production`.2287. Record the deploy in the current release's checklist snapshot P3.7 item.2298. If step 2 required a throwaway seeding branch, **clean it up now** per230 Recipe A step 7 (sanity-check its commits, then `git branch -D`).231232## Handling stale fields in Development233234If the preflight check finds Development schema fields with no corresponding235property on the current model (e.g. a renamed or removed field from an236earlier development iteration):237238- These **can** be deleted directly in the CloudKit Console (Development239 schema, unlike Production, supports field deletion) — confirm with the240 user which specific fields to remove, one by one, before doing so.241- **Never** use Reset Environment as a shortcut to "clean up" Development242 stale fields unless Production is confirmed empty/behind — Reset243 Environment wipes Development back to match Production's *current* state,244 which is destructive if Production isn't already a superset of what you245 want to keep.246- Removing a stale field from Development has no effect on Production unless247 it was already deployed there — in which case it's stuck permanently248 (Production can't lose fields) and the right move is just to stop adding249 data to it going forward, not to attempt removal.250251## Where this fits in a release252253This skill is invoked from the App Store release checklist's **P3.7**254(`docs/app-store-release-checklist.md`, driven by `/appstore:prepare-for-release`) —255that item's Note should reference back to what this skill did (deploy method,256fields involved, verification result) rather than duplicating the full257recipe.