The Contract Migration Gate for AI-Built Apps
A founder protocol for changing database fields, APIs, webhooks, and events without breaking old consumers, corrupting data, or mistaking generated code for a safe rollout.
An AI coding agent is asked to rename plan to subscription_tier. It updates the database model, the API response, and every reference it can find. Tests pass. The preview looks right. The founder approves the change.
The next morning, an older mobile build still sends plan; a billing webhook writes the old field; a nightly export selects the old column; and a delayed job replays an event produced before deployment. Nothing was wrong with the generated syntax. The release failed because the field was a contract shared across versions, not merely a name inside one repository.
This guide is for founders and small teams shipping AI-built web apps with live users, stored data, integrations, background jobs, or clients that cannot all update at once. Its central judgment is: a contract change is ready only when old and new producers, consumers, and stored records can coexist for a measured compatibility window. A code diff, migration file, passing build, or successful deploy does not prove that.
You will leave with a consumer inventory, an expand–migrate–contract rollout, a compatibility test matrix, a machine-readable migration receipt, and release/stop rules. The method fits ordinary SaaS fields, REST/GraphQL responses, webhooks, queues, analytics events, feature configuration, and file exports. It does not replace specialist review for regulated records, financial ledgers, safety-critical systems, very large databases, or changes whose old meaning cannot be reconstructed. In those cases, use the same evidence structure, but require experienced database, security, compliance, or domain owners.
Expand-and-contract is an established migration pattern. The contribution here is the acceptance gate around it: the team must connect a named consumer register, a compatibility window derived from real lag, cross-version outcome tests, production invariants, and a phase-specific receipt before any destructive cleanup receives authority.
Treat the field as a contract, not a token
A schema describes the allowed shape and types of data. A database schema might say that subscription_tier is a nullable text column. An API schema might say it is an optional string with three documented values.
A contract is broader. It includes the schema plus the meaning, timing, defaults, ownership, and compatibility promises that producers and consumers rely on. If plan: "pro" used to mean “the account may create ten projects,” a rename is not safe until every decision-maker agrees whether subscription_tier: "pro" carries exactly the same meaning. Syntax compatibility can preserve bytes while semantic compatibility quietly changes the product.
A producer writes, emits, or returns the data: the browser form, API server, webhook handler, import job, admin tool, event publisher, or model-generated workflow. A consumer reads or acts on it: the UI, mobile app, billing task, email rule, analytics query, partner integration, cached worker, support export, or another AI agent.
A compatibility window is the period when multiple contract versions may exist in production. It should be derived from reality: longest job delay, webhook retry horizon, client update lag, cache lifetime, export cadence, restoration horizon, and partner adoption time. “One deploy completed” is not a compatibility window.
The distinction has mature precedents. GitHub lists renaming a response field, adding a required parameter, changing a type, or removing an enum value as breaking API changes and supports the previous REST API version for at least 24 months after a new version is released in its official breaking-change policy. Your early-stage app probably does not need a 24-month promise. It does need the same discipline: name the contract, version the change, and define how long old consumers remain valid.
Start with the authority question AI cannot answer from code
An agent can search call sites. It cannot infer every operational or commercial commitment from source code. Before asking it to edit, write a one-sentence authority statement:
We are replacing the account classification fieldplanwithsubscription_tier; meanings remain unchanged; old readers and writers must work through the measured compatibility window; no pricing, entitlement, historical reporting, or partner payload behavior may change.
That sentence separates four possible changes that a casual prompt could merge:
| Change | Example | Required owner |
|---|---|---|
| Representation | Rename plan to subscription_tier | Engineering/product owner |
| Meaning | Redefine what pro includes | Product and commercial owner |
| Data | Backfill missing account values | Data/business owner |
| Enforcement | Use the field to allow a feature | Product, security, billing owner |
If meaning or enforcement changes, treat it as a separate product decision even when the same commit implements it. Otherwise, a founder may approve a harmless-looking rename that also changes who can access a paid feature.
Do not invent the window in this sentence. Fill it after the consumer register shows the longest real lag, then record both the duration and its basis in the receipt.
Define non-goals too: “No deletion of the old field in this release. No new required input. No change to entitlements. No rewrite of historical invoices.” Non-goals make an AI agent’s output reviewable. They also give tests something precise to reject.
Finally, name the authoritative contract artifact. It may be an OpenAPI document, Prisma schema, SQL migration, Protobuf file, event schema, or typed interface. The OpenAPI specification, for example, gives operations, parameters, and schemas explicit deprecated markers rather than assuming removal follows from a code comment; its normative specification says a deprecated parameter should be transitioned out of usage. Marking a contract is useful only if the running product and the migration plan obey it.
Build a consumer register before touching production
Repository search finds visible references. A consumer register finds obligations. Start from every place the data can be written, stored, copied, queued, cached, exported, or interpreted.
| Surface | Producer | Consumer | Version signal | Maximum lag | Owner | Evidence |
|---|---|---|---|---|---|---|
| Browser API | Current web app | API server | app build ID | Minutes | Product | request logs |
| Mobile API | Installed app | API server | app version | Weeks | Mobile | active-version report |
| Database | API and admin jobs | API and reporting | migration ID | Restore horizon | Backend | schema snapshot |
| Billing webhook | Provider | webhook worker | event/API version | Retry horizon | Billing | webhook fixtures |
| Queue | API worker | email worker | event schema version | Oldest visible message | Operations | queue age metric |
| Warehouse | replication job | dashboards/export | table revision | Daily/weekly | Data | query inventory |
| Partner export | scheduled job | partner | file version | Contractual | Partnerships | sample acceptance |
| Support tool | admin UI | support team | UI release | Days | Support | workflow check |
Do not confuse “no code reference found” with “no consumer.” SQL saved in a dashboard, a Zapier step, a no-code automation, a spreadsheet import, an old app binary, and a customer’s webhook parser may live outside the repository. Ask people who own billing, support, marketing operations, analytics, and partnerships. Inspect logs for field use where privacy policy and retention allow it.
The register also prevents unlimited caution. If a surface truly has no active consumers, record the evidence and remove it from the migration. The goal is a bounded compatibility claim, not a ritual inventory of hypothetical systems.
Classify each consumer as known-compatible, must-upgrade, unknown, or retired-with-evidence. An unknown high-consequence consumer blocks contraction. It need not block the additive first phase if the additive change preserves old behavior.
Separate four kinds of compatibility
“Backward compatible” is too vague unless the team says who reads what.
Reader compatibility: can the new reader understand old records and old events? This matters during deployment, replay, restore, and backfill. Writer compatibility: can the old writer send data that the new reader accepts? This matters for cached pages, mobile clients, retries, partner systems, and rolling deploys. Round-trip compatibility: can data pass through the new representation and return to the old one without losing meaning? Kubernetes makes round-trip preservation a formal rule across API versions in its deprecation policy. A small app can use the same test on representative records even without a formal versioned API server. Semantic compatibility: does the same value still trigger the same business outcome? A parser can accept"pro" while a new entitlement rule interprets it differently. Schema tools rarely prove this layer.
Message systems make the reader/writer distinction concrete. Confluent defines backward compatibility as a new schema reading old data, forward compatibility as an old schema reading new data, and full compatibility as both; transitive modes compare against more than the latest version in its official schema-evolution documentation. Even if your app uses JSON and a simple queue, the questions travel well:
- Will new code read every still-live old event?
- Will still-live old code ignore or safely handle the new field?
- Are defaults explicit when the field is absent?
- Can old and new values disagree, and which wins?
- Does reserialization preserve unknown data?
Use expand–migrate–contract as three releases
For a field rename, the safest common pattern is not an in-place rename. It is three observable phases.
Expand: add without removing
Add subscription_tier as optional while keeping plan. Make new readers accept both. Choose and document a conflict rule. If the new field is present, it may win; if only the old field exists, translate it; if both disagree, alert or quarantine rather than silently guessing.
New writers can dual-write both fields or write the new field while a compatibility adapter maintains the old one. Dual-write is not automatically safe: partial failure can create divergence. Put both writes in one database transaction where possible, or make the adapter idempotent and reconcile mismatches.
Prisma’s official expand-and-contract guide demonstrates introducing a new structure, migrating data and application code, and removing the old structure only later. The useful principle is time separation. Additive introduction, data conversion, reader switch, and destructive cleanup should not be one opaque deployment.
Migrate: backfill and move consumers
Backfill existing records in bounded, restartable batches. Record a cursor, rate, error count, and reconciliation result. Do not infer completion from “job exited 0.” Compare counts and semantics: eligible rows, converted rows, unchanged rows, conflicts, failures, and records created during the run.
Move readers to the new field, but keep reading the old field as fallback during the compatibility window. Then move every registered writer. Instrument reads of the deprecated field so you can see whether old consumers still exist. A deprecation warning without usage evidence turns the contraction date into a guess.
Contract: remove only after evidence
Stop dual-writing the old field. Observe for the full compatibility window. Only then remove adapters, API fields, event variants, and database columns in a deliberately separate change. Preserve a reversible release for code; preserve a tested restoration or forward-repair plan for data.
PostgreSQL’s current ALTER TABLE documentation warns that lock levels vary, with ACCESS EXCLUSIVE acquired unless otherwise noted, and that some type changes or defaults can rewrite a table and its indexes; it also explains techniques such as NOT VALID followed by later validation to reduce concurrent-update impact for certain constraints in the official command reference. That is why “the SQL is valid” does not establish operational safety. Measure the actual table, operation, lock behavior, storage headroom, and recovery procedure for your database version.
Make a migration receipt that a founder can inspect
The release needs a compact artifact joining the intended contract to observed evidence. Here is a reusable starting point:
contract_migration:
id: "account-plan-to-subscription-tier-2026-08"
owner: "named-person"
authority:
old_field: "account.plan"
new_field: "account.subscription_tier"
semantic_change: false
entitlement_change: false
versions:
old: "account-contract-v3"
expanded: "account-contract-v4"
contracted: "account-contract-v5"
compatibility_window:
starts_at: "ISO-8601"
minimum_days: 30
basis:
mobile_active_version_p99_days: 21
webhook_retry_days: 3
queue_max_age_days: 7
restore_test_horizon_days: 30
consumers:
total: 8
compatible: 8
unknown: 0
register_hash: "sha256"
data:
eligible_rows: 12840
converted_rows: 12840
conflicts: 0
read_after_write_sample: "receipt-link"
production_signals:
old_field_reads_last_7d: 0
old_field_writes_last_7d: 0
divergence_rows: 0
parse_errors: 0
gates:
expand: "approved | held"
migrate: "approved | held"
contract: "approved | held"
recovery:
code_revert_test: "evidence-link"
data_forward_repair_test: "evidence-link"
owner: "named-person"
These numbers are illustrative fields, not YBuild customer data or universal thresholds. Replace them with actual observations. Hash or link the consumer register so the receipt cannot quietly claim eight compatible consumers after the list changes to nine.
Keep “unknown” explicit. Do not turn missing telemetry into zero use. If old-field reads are not observable, the receipt should say not_measured, and the contraction gate should require another form of evidence such as active client versions, synthetic replays, partner acknowledgements, or a longer compatibility period.
Test a version matrix, not one happy path
A current client talking to a current server proves only one cell. Test the coexistence states your rollout creates.
| Producer | Stored/event shape | Consumer | Expected result |
|---|---|---|---|
| Old | Old only | New | New reader translates without meaning change |
| New | Both fields equal | Old | Old reader behaves normally |
| New | New only | New | Normal new behavior |
| New | Both fields conflict | New | Reject, quarantine, or alert by documented rule |
| Delayed old job | Old only | New | Accepted within window; source version recorded |
| New | Unknown enum/value | Old | Safe fallback, explicit error, or held rollout |
| Restored snapshot | Old records | New | Read and repair succeeds |
| Replay | Mixed historical events | New | Deterministic outcome, no duplicate side effect |
| Backfill plus live write | Mixed | New | No lost update; reconciliation reaches zero |
| Contracted | Old request | New | Documented version error after the window |
Use representative production-shaped records with secrets and personal data removed or synthetically generated. Include nulls, absent fields, malformed values, largest records, Unicode, old enum values, and records created at phase boundaries.
Protocol Buffers illustrates why format-specific fixtures matter. Its proto3 guide says field numbers must not change after use and removed numbers should be reserved; old binaries can ignore new fields in the binary format, while ProtoJSON has different safe-change rules. A generated type check can pass while a JSON client, enum switch, or reserialization path loses information.
Test business outcomes after parsing. Does an old pro record receive the same project limit? Does a refunded account remain refunded? Does a retry send one email rather than two? Contract testing stops too early if it asserts only that deserialization succeeded.
Measure migration invariants in production
An invariant is a condition that must remain true throughout the rollout. Choose a small set that reflects user harm, not just infrastructure health.
For the example migration:
- Every account with
plan = prohassubscription_tier = proafter its backfill batch. - When both fields exist, their normalized meanings agree.
- No entitlement decision reads the new field before its reader has a tested old-field fallback.
- Each webhook event is applied at most once across retries and replays.
- Deprecated-field reads decline by identified consumer, not only in aggregate.
- The backfill error set is bounded, retained, and retryable.
- User-facing access, price, and invoice history do not change because of representation alone.
Use canary rollout for application behavior, but do not treat it as a substitute for compatibility. AWS API Gateway’s official canary release documentation explains how a small share of traffic can use a new API deployment with separate logs and metrics before promotion. A random canary may still miss a weekly export or a partner webhook. Route named synthetic and known high-risk consumers through the candidate version in addition to sampling ordinary traffic.
Define stop, rollback, and forward-repair rules before launch
A release gate needs decisions, not a dashboard no one is authorized to use.
Stop expansion if the additive field unexpectedly changes responses, a supposedly optional input is enforced as required, database lock time exceeds the tested budget, or an unknown high-consequence consumer appears. Stop migration if divergence grows, backfill retries are not idempotent, live writes can be overwritten, user outcomes change, or the eligible-row denominator cannot be reproduced. Stop contraction if any old-field read/write remains unexplained, any supported client version still depends on the old contract, the restore drill requires the old field, partner acknowledgement is missing, or the compatibility window has not elapsed.Code rollback and data rollback are different. Reverting application code may restore an old reader, but it cannot resurrect a dropped column, undo an external webhook, or recover overwritten meaning. For additive phases, code rollback is often possible because the old field still exists. After backfill, the safer recovery may be a forward repair that reconciles the new field from an immutable mapping or backup. After destructive contraction, restoration may require downtime and lose writes made after the backup.
Stripe’s API upgrade documentation offers a useful operational pattern: account API versions control response and webhook behavior, major releases contain incompatible changes, monthly releases are backward-compatible, and an account-level upgrade has a documented 72-hour rollback period. The exact mechanism is Stripe-specific. The transferable lesson is to bound rollback by version and time, then test the consumers that observe that version.
Write who can stop each phase and how. “The team will monitor” is weaker than “the on-call founder stops the backfill when any entitlement mismatch occurs; the migration owner investigates; restart requires a new receipt.”
Walk through a realistic small-app migration
Imagine CourseDock, a small AI-built app that sells cohort courses. Its database stores plan; the web API returns it; Stripe webhooks update it; a daily email job uses it to choose templates; and a weekly CSV export goes to an instructor. The founder asks an agent to rename it to subscription_tier because a new annual option is coming.
The agent finds the TypeScript references and generates a database rename. The build passes. The consumer register reveals four missed obligations: an old mobile wrapper cached for some users, a serverless email function in a different project, a Stripe webhook endpoint pinned to an older API behavior, and the instructor’s spreadsheet formula looking for a plan header.
CourseDock first freezes semantics: annual billing will be a separate future change; the current rename must not alter entitlement. It expands the database and API with an optional subscription_tier, dual-reads both, and returns both fields for supported old clients. The webhook adapter normalizes its input into one internal contract version. The CSV keeps plan and adds subscription_tier with a communicated transition date.
The team backfills in restartable batches and checks three invariants: equal normalized values, unchanged entitlements, and no duplicate email jobs. It tags logs with client and contract version. After active old mobile use falls to zero, the email function deploys, the instructor accepts the new CSV, and the longest retry/restore window elapses, CourseDock stops writing plan. Only after another observation period does a separate migration remove it.
The result takes longer than a rename. That is the point. The extra time exposes commitments the repository could not show. AI remains useful: it drafts adapters, inventories code references, generates fixtures, compares schemas, and prepares reconciliation queries. The human gate controls meaning, external obligations, thresholds, and destructive authority.
Avoid nine failure modes that look efficient
Rename in place. Old code, old events, and outside consumers lose the field instantly. Expand first unless every producer and consumer can truly update atomically. Add the new field as required. Existing rows and old writers cannot satisfy it. Start optional, backfill, validate, and require only after evidence. Dual-write without reconciliation. Two fields create two truths. Define conflict authority and continuously measure divergence. Search only one repository. Dashboards, automations, mobile builds, partner code, saved queries, and exports remain invisible. Maintain the consumer register. Test only new-to-new. Rolling deployments and retries create old-to-new and new-to-old combinations. Test the matrix. Assume additive means harmless. Strict clients can reject unknown fields; enums and validation rules can break consumers; a database default may rewrite or lock data depending on the operation and version. Test the actual path. Use traffic percentage as proof. A canary can miss rare but important consumers. Add named fixtures and scheduled-path tests. Drop when the backfill finishes. Backfill completion says nothing about old writers, restores, or delayed events. Wait for the compatibility window and zero unexplained use. Call code revert a rollback plan. Destructive data and external actions survive a Git revert. Preserve old representation until contraction, then document restore and forward repair.Run a bounded 48-hour shadow exercise
Do not compress a real 30-day compatibility window into two days. Use 48 hours to prepare and shadow-test the gate without performing destructive contraction.
Hours 0–4: write the authority statement and non-goals; name the old/new contract versions; identify the data owner and stop authority; classify the change as representation, meaning, data, and/or enforcement. Hours 4–12: build the consumer register; search repositories and infrastructure; ask billing, support, data, and partnership owners; record retry, queue, cache, client, export, and restore horizons. Hours 12–24: generate the additive migration and adapters in a branch; create the version matrix and production-shaped synthetic fixtures; benchmark database locks and rewrites on an appropriately sized non-production copy; write invariants and reconciliation queries. Hours 24–36: deploy to staging or shadow mode; replay old and new records; run backfill dry-runs; simulate partial failure, retry, conflict, and rollback; verify user outcomes rather than parsing alone. Hours 36–48: assemble the migration receipt with real test evidence; decide approve/hold for expansion only; schedule the true compatibility window; assign consumer upgrades; and write the later contraction conditions.The exercise is successful even if the decision is hold. Discovering an unowned partner export before production is a result, not a delay to conceal.
Know when this framework is insufficient
Use the gate directly for ordinary early-stage SaaS changes where the team can inventory consumers, create additive compatibility, observe use, and repair data.
Escalate when records are legally immutable; the field affects money movement, tax, medical care, safety, identity, access control, or regulatory reporting; the table size or availability target makes lock behavior specialized; a partner contract fixes the payload; or the old and new concepts are not semantically equivalent. A rename from gender to sex_at_birth, for example, is not a representation migration. It changes meaning and should not be “backfilled” by assumption.
Some systems cannot afford dual-write complexity. If every consumer is controlled and can be stopped for a maintenance window, a coordinated migration may be simpler and safer. Document the shutdown boundary, backup, verification, and restart order. Confluent’s compatibility modes explicitly recognize that deployment order and control over producers/consumers affect what evolution is safe; no single pattern fits every system.
Also avoid adding migration machinery to prototype data with no users, no external consumers, and no preservation requirement. Export what matters, rebuild cleanly, and record that the old contract was intentionally disposable. Recovery discipline should reduce unknown risk, not create ceremony around data that has no promised continuity.
Use the founder release checklist
Before approving expand:
- [ ] Authority statement separates representation, meaning, data, and enforcement.
- [ ] Non-goals forbid accidental entitlement, pricing, privacy, or policy changes.
- [ ] Consumer register includes code, clients, jobs, queues, webhooks, exports, analytics, support, and partners.
- [ ] Old/new contract versions and conflict authority are explicit.
- [ ] Additive database operation was tested for locks, rewrite, space, and duration.
- [ ] Old-to-new and new-to-old fixtures pass on user outcomes.
- [ ] Stop authority and recovery path are named.
- [ ] Backfill is bounded, idempotent, restartable, and reconciled against an eligible denominator.
- [ ] Live-write races and dual-write divergence are tested.
- [ ] Deprecated reads/writes are observable by consumer and version.
- [ ] Restore, replay, retry, and delayed-job paths pass.
- [ ] No unknown high-consequence consumer remains.
- [ ] Every supported consumer is upgraded, retired with evidence, or explicitly versioned away.
- [ ] The longest justified compatibility window has elapsed.
- [ ] Old-field reads and writes are zero or individually explained.
- [ ] Restore and forward-repair procedures no longer depend on the old representation.
- [ ] Destructive SQL and its operational impact have separate approval.
- [ ] The final receipt links real evidence and reports unknowns honestly.
References
- GitHub Docs — Breaking changes in the REST API
- Stripe Docs — API upgrades
- Prisma Docs — Expand-and-contract data migrations
- PostgreSQL 18 Docs — ALTER TABLE
- Protocol Buffers Docs — Proto3 language guide and message updates
- Confluent Docs — Schema evolution and compatibility
- Kubernetes Docs — API deprecation policy
- OpenAPI Initiative — OpenAPI Specification 3.0.4
- AWS Docs — API Gateway canary releases