When Migration Releases Arrive Faster Than Reconciliation
Follow cumulative migration releases through one ordered plan, exact approval, Apply, and fresh history verification in Ptah Operator and Console.
Ptah helps you plan, review, and apply database migrations. Try in your browser
The orders database has migration 010. CI publishes v11, v12, and
v13 before Ptah Operator reads the updated artifact reference. Each release
adds a migration. The database must apply 011, 012, and 013 in order,
even if the operator never observes the intermediate releases.
We follow that sequence with Ptah 0.13.0, Ptah Operator 0.2.4, and PostgreSQL 17.11. Real Ptah Console views show the decision, execution, and recovery states. The example files retain the SQL, manifests, recorded history, and reproduction steps.
A cumulative artifact carries the work
Section titled “A cumulative artifact carries the work”A PtahMigration selects an OCI artifact reference and a database. It reads
the artifact’s migration files against the database’s revision history.
It does not keep a FIFO queue of CI releases.
Our v11 artifact contains 010 and 011. The next release contains
010–012, and v13 contains 010–013. Once stable points to v13,
that artifact has everything needed to advance the database from 010.
The operator can miss intermediate artifact releases. Their required migration files still belong in the artifact it observes. Open any figure for the full-size version.
The files add a tracking column, a shipment timestamp, and an index:
| Migration | Change | Present in |
|---|---|---|
010 |
Create orders |
v10–v13 |
011 |
Add tracking_code |
v11–v13 |
012 |
Add shipped_at |
v12–v13 |
013 |
Create orders_tracking_idx |
v13 |
For example, migration 011 contains:
ALTER TABLE orders ADD COLUMN tracking_code text;We hash and publish each cumulative directory. Here is the final publication:
ptah migrations hash --dir releases/v13ptah migrations push "oci://$REGISTRY/orders-migrations:v13" --migrations-dir releases/v13 --dir-format ptah --verify-sum --version v13 --tag stable --plain-http--version writes the release tag; --tag stable moves the alias. The
fixture’s isolated registry uses HTTP, hence --plain-http. Registry access
and the artifact verification policy are separate inputs in the
complete resource.
The source reference decides which publication can be discovered:
| Reference | What a new publication changes |
|---|---|
orders-migrations:stable |
A later Resolve can discover the moved tag without a CR spec update. |
orders-migrations@sha256:… |
The selected bytes stay fixed. Publishing another tag does not change the CR; update its reference to select another digest. |
The operator pins the resolved digest before verification and history reads. A tag move does not rewrite the bytes of a plan or an active Job. The delivery and planning guide explains the artifact and resource setup in more detail.
Read history, then approve one sequence
Section titled “Read history, then approve one sequence”Before publishing v11–v13, Console shows that the database matches v10:
This is the starting state. The selected artifact contains 010; the direct
revision-table read below confirms that no later version has run.
Our before-and-after query is:
SELECT version, state, applied, totalFROM schema_migrationsORDER BY version;At the start, the result contains version 10 alone. After the tag moves,
the operator resolves and verifies v13, reads history, and publishes one
immutable PtahMigrationPlan with the ordered sequence 011 → 012 → 013.
Planning uses the history already read. There is no separate migration Plan Job. Approval records a decision against the published plan.
Console’s Approver view presents that single plan:
The plan carries versions and checksums. Migration SQL remains in the artifact; Console states that the operator’s migration-plan API does not expose it.
The plan’s identity binds the observed history and ordered sequence to the artifact digest, database target, coordination identity, verification policy, apply policy, and execution binding. An approval names that exact plan and fingerprint. It does not approve a tag or future migrations.
spec.policy.apply controls the decision:
| Policy | What happens to the pending sequence |
|---|---|
OnApproval (default) |
Publish the plan and wait for an exact-plan approval. |
Always |
Publish the plan and proceed without a human approval. |
Never |
Publish the plan, report ApplyDisabled, and execute nothing. |
In Console, choose Approve this plan…, review the confirmation, and confirm. One decision authorizes all three migrations in this plan.
One Apply, followed by a fresh history read
Section titled “One Apply, followed by a fresh history read”The operator starts one Apply Job for the approved sequence. Ptah takes its
native migration lock, checks the selected versions with --expect-sequence,
and executes the files in order. It does not create one Apply Job per CI
release.
Execution and verification are separate stages. The recorded plan and Job identities are retained in the supporting files.
Once Apply finishes, another History Job reads the database. That read
establishes InSync:
The current Console view summarizes verification. It does not yet show the revision table; the migration detail work tracks that view. We read the actual table separately.
| Revision | Before publication | After fresh verification |
|---|---|---|
010 |
applied |
applied |
011 |
absent | applied |
012 |
absent | applied |
013 |
absent | applied |
The original orders row remains queued. An independent CLI read confirms
the final version and pending count:
ptah migrations status --db-url "$DATABASE_URL" --migrations-dir releases/v13Recorded output excerpt:
Current Version: 13Total Migrations: 4Applied Migrations: 4Pending Migrations: 0Out-of-order Migrations: 0Status: ✅ Database is up to dateA successful Job exit alone would not establish this result. The database’s history must account for the selected sequence.
A newer release can replace a waiting plan
Section titled “A newer release can replace a waiting plan”In a second copy of the database, orders-waiting first discovers v11.
Its plan contains 011, and an approver opens the confirmation. Before they
confirm, CI publishes cumulative v12 and v13 to the same mutable tag.
The next interval refreshes Resolve, Verify, and History before the operator
consults an approval. The new digest and pending sequence produce a new plan
for 011–013. Confirming the older dialog does not approve it:
The dialog held the plan the person reviewed. A later plan requires a separate decision.
Following Review the new plan shows the replacement. Approval stays disabled until the person chooses I am reading the new plan:
A previously admitted approval also cannot authorize a replacement plan. Admission and dispatch check the plan’s bindings, rather than treating an approval object as continuing permission.
Refresh timing matters. Publishing a new tag does not notify an already pinned plan instantly. An approval that is still valid may dispatch the older digest before the next refresh discovers the new publication. That Apply accounts for its own sequence; the later reconciliation handles what remains.
A running Apply keeps its selected files
Section titled “A running Apply keeps its selected files”The orders-running copy approves 011 from v11. While its Apply is
active, we publish v12 and v13. The active operation retains its Job and
pinned digest. No SQL is appended and no second Apply starts alongside it.
After 011 is accounted for, a later Resolve observes v13. Fresh history
now finds only 012 and 013 pending, so the next plan contains those
versions and needs its own approval.
These examples move a registry tag while the CR spec stays unchanged. Changing the CR spec during Apply is a different input change and can make result attribution uncertain. Let the active run finish before changing its resource.
The timing evidence
records the active operation, and the
next plan selects 012–013.
The operator accounts for an active operation before starting another.
A failure stops the sequence
Section titled “A failure stops the sequence”A third copy uses a deliberately broken 012: it adds shipped_at, then
tries to write to a table that does not exist. PostgreSQL runs each file in
a transaction. Migration 011 stays committed; the changes in 012 roll
back; 013 does not run.
The revision table records the attempt as failed, with zero of two
statements committed. That leaves history dirty. Console shows the refusal
and the evidence retained for the failed Apply:
Transactional SQL rollback does not erase the failed revision record. The next Apply remains blocked until the history is repaired. The Job completed its reporting work, but its structured result still reports a failed migration; a green Job does not override that result.
| Migration | Recorded result | Database effect |
|---|---|---|
010 |
applied |
Existing orders table remains. |
011 |
applied |
tracking_code exists. |
012 |
failed / dirty |
shipped_at is absent after rollback. |
013 |
absent | The index was not created. |
MySQL and MariaDB DDL commits as it goes. Their migrations use an explicit
transactionMode: none; a file can leave earlier statements committed when
a later statement fails. A partial run needs assessment of those effects,
not an assumption that the file rolled back.
Account for an unknown run before continuing
Section titled “Account for an unknown run before continuing”An interrupted result is a different problem. In orders-unknown, migration
011 sleeps before adding its column. We remove its active Apply Job while
it sleeps. The operator records Unknown and an unresolved run. It does
not take the missing Job as proof that SQL succeeded or rolled back.
No automatic replay follows this state. First establish what reached the database and whether any executor can still write.
For this disposable example, we confirm that the sleeping session is gone,
that tracking_code was not created, and that the original row is unchanged.
Only the unfinished 011 revision remains. We remove that specific unfinished
record after assessment; the recovery evidence
records the checks. This is a decision about this fixture’s effects, not a
general repair recipe.
Console then offers Acknowledge this run…. Its confirmation makes the scope explicit:
Acknowledgment settles the run record. It neither repairs SQL nor approves the next plan.
After admission records the decision, the operator reads history again.
The unresolved record clears, and OnApproval leaves the pending sequence
waiting for a fresh approval:
Here the bound inputs match the original plan, so its immutable identity is reused. The earlier approval is consumed. Another Apply still needs a fresh decision.
A clean history that accounts for every migration can also settle an unresolved run on the same database. Pending work after an uncertain run requires a person’s decision; the operator does not guess which SQL to replay.
Recognize the next supported action
Section titled “Recognize the next supported action”The reason beside a stopped resource explains what prevents execution. These boundaries come from the operator’s migration workflow, plan bindings, and recovery contract.
| Situation | Visible state or reason | Does Apply run? | Next supported action |
|---|---|---|---|
Clean pending sequence, OnApproval |
AwaitingApproval |
After approval of that exact plan. | Review its sequence and bindings. |
Clean pending sequence, Always |
Planning, then Applying | Automatically for the selected plan. | Follow Apply and fresh history verification. |
Clean pending sequence, Never |
Planning / ApplyDisabled |
No. | Change policy deliberately if execution is wanted; review the replacement binding. |
| Same migration files, a new build | Compare the resolved digest and current plan. | Only while the selected plan remains authorized. | Unchanged bindings reuse the plan. A changed artifact digest changes its fingerprint even if SQL is identical; review the replacement. A Git commit outside the published artifact alone changes nothing. |
012 fails after 011 |
Failed outcome; fresh history may report HistoryDirty |
013 does not run. |
Assess the failed file and database, then repair the cause and revision history. |
| Dirty or partially applied file | HistoryDirty; a partial run also retains an unresolved record |
No blind replay. | Assess committed effects, repair deliberately, and settle any unresolved run. |
| Lost Job or unreadable result | Unknown, unresolved run; the latest history can add another refusal |
No automatic replay. | Establish effects and remaining writers. Acknowledge the exact run only after assessment, or let fully matched history settle it. |
Late 011 after 012 |
HistoryOutOfOrder, named versions |
No. | Deliberately resolve the ordering; publish a valid linear history. |
| Applied file checksum changed | HistoryModified, named versions |
No. | Restore the applied files or correct the artifact to match recorded history. |
| Database newer than the artifact | HistoryAhead |
No rollback. | Select an artifact that accounts for the database’s versions. |
| Existing schema, empty history | Every file appears pending. | Approval would execute the files again. | Suspend execution and use native ptah migrations baseline with the exact files and a disposable shadow database. |
| Artifact contains a checkpoint | History accounts for checkpoint coverage. | The selected pending sequence decides. | Empty databases bootstrap at the applicable checkpoint. Already migrated databases retain their history; inspect versions, not counts alone. |
| Another actor advances or restores history after planning | Replacement history binding; an execution-time sequence mismatch reports Failed |
No unapproved selection executes. | Read fresh history and review the new plan. Ptah checks --expect-sequence under its migration lock. |
| More than 256 pending migrations | Plan publication refused as an operation failure | No. | Bring history forward through reviewed cumulative artifacts with bounded pending sequences. |
| Several resources address one database | Shared-target refusal unless explicitly allowed; coordination Lease serializes allowed claimants. | Only an authorized claimant may execute. | Use a consistent coordination key or realm and explicit shared-target opt-in. Separate CRs do not merge their files or create a global version queue. |
An acknowledgment cannot make a dirty or mismatched history safe. The next history read still has to pass the normal guards.
Cumulative releases preserve the required migration files. The operator plans the sequence missing from the observed database and binds the decision to those inputs. Review the exact plan, let its Apply finish, and use fresh history to decide what remains.
Put Ptah to work
Example files
- diagrams/history-safety.svg
- diagrams/lifecycle.svg
- diagrams/README.md
- diagrams/release-timeline.svg
- diagrams/release-timing.svg
- history.sql
- implementation.md
- measured/applying-history.txt
- measured/applying-job.json
- measured/applying-plan.json
- measured/applying.json
- measured/audit-history.txt
- measured/audit-rows.txt
- measured/before-history.txt
- measured/before.json
- measured/commands.json
- measured/console-01-before.txt
- measured/console-02-pending.txt
- measured/console-03-plan.txt
- measured/console-04-approved.txt
- measured/console-05-applying.txt
- measured/console-06-finished.txt
- measured/console-07-old-plan.txt
- measured/console-08-stale-decision.txt
- measured/console-09-replaced-plan.txt
- measured/console-10-running-tag-move.txt
- measured/console-11-next-plan.txt
- measured/console-12-failed.txt
- measured/console-13-unknown.txt
- measured/console-14-acknowledge.txt
- measured/console-15-acknowledged.txt
- measured/console-16-recovered-plan.txt
- measured/console-failed-capture-decisions.json
- measured/console-initial-decisions.json
- measured/console-main-decisions.json
- measured/console-running-decisions.json
- measured/console-stale-decisions.json
- measured/console-unknown-decisions.json
- measured/decisions.json
- measured/delete-active-job.stderr.txt
- measured/delete-active-job.txt
- measured/failed-columns.txt
- measured/failed-history.txt
- measured/failed-indexes.txt
- measured/failed-job.json
- measured/failed-rows.txt
- measured/failed.json
- measured/finished-compare.stderr.txt
- measured/finished-compare.txt
- measured/finished-history.txt
- measured/finished-job.json
- measured/finished-rows.txt
- measured/finished-status-excerpt.txt
- measured/finished-status.stderr.txt
- measured/finished-status.txt
- measured/finished.json
- measured/first-pass-finished-compare.stderr.txt
- measured/first-pass-finished-compare.txt
- measured/first-pass-finished-history.txt
- measured/first-pass-finished-job.json
- measured/first-pass-finished-rows.txt
- measured/first-pass-finished-status.stderr.txt
- measured/first-pass-finished-status.txt
- measured/first-pass-finished.json
- measured/hash-releases-v10.stderr.txt
- measured/hash-releases-v10.txt
- measured/hash-releases-v11.stderr.txt
- measured/hash-releases-v11.txt
- measured/hash-releases-v12.stderr.txt
- measured/hash-releases-v12.txt
- measured/hash-releases-v13.stderr.txt
- measured/hash-releases-v13.txt
- measured/hash-variants-failed.stderr.txt
- measured/hash-variants-failed.txt
- measured/hash-variants-unknown.stderr.txt
- measured/hash-variants-unknown.txt
- measured/initial.json
- measured/kubernetes-version.stderr.txt
- measured/kubernetes-version.txt
- measured/main-apply-events.json
- measured/main-job-inventory.json
- measured/main-v10.stderr.txt
- measured/main-v10.txt
- measured/main-v11.stderr.txt
- measured/main-v11.txt
- measured/main-v12.stderr.txt
- measured/main-v12.txt
- measured/main-v13.stderr.txt
- measured/main-v13.txt
- measured/orders-active-writer.json
- measured/orders-always-create.stderr.txt
- measured/orders-always-create.txt
- measured/orders-always-initial-migration.stderr.txt
- measured/orders-always-initial-migration.txt
- measured/orders-capture-initial-migration.stderr.txt
- measured/orders-capture-initial-migration.txt
- measured/orders-create.stderr.txt
- measured/orders-create.txt
- measured/orders-failed-create.stderr.txt
- measured/orders-failed-create.txt
- measured/orders-failed-initial-migration.stderr.txt
- measured/orders-failed-initial-migration.txt
- measured/orders-failed-pending-history.txt
- measured/orders-failed-pending-plan.json
- measured/orders-failed-pending.json
- measured/orders-failed-v13.stderr.txt
- measured/orders-failed-v13.txt
- measured/orders-initial-migration.stderr.txt
- measured/orders-initial-migration.txt
- measured/orders-never-create.stderr.txt
- measured/orders-never-create.txt
- measured/orders-never-initial-migration.stderr.txt
- measured/orders-never-initial-migration.txt
- measured/orders-running-active-writer.json
- measured/orders-running-create.stderr.txt
- measured/orders-running-create.txt
- measured/orders-running-initial-migration.stderr.txt
- measured/orders-running-initial-migration.txt
- measured/orders-running-pending-history.txt
- measured/orders-running-pending-job.json
- measured/orders-running-pending-plan.json
- measured/orders-running-pending.json
- measured/orders-running-v11.stderr.txt
- measured/orders-running-v11.txt
- measured/orders-unknown-create.stderr.txt
- measured/orders-unknown-create.txt
- measured/orders-unknown-initial-migration.stderr.txt
- measured/orders-unknown-initial-migration.txt
- measured/orders-unknown-pending-history.txt
- measured/orders-unknown-pending-plan.json
- measured/orders-unknown-pending.json
- measured/orders-unknown-v13.stderr.txt
- measured/orders-unknown-v13.txt
- measured/orders-waiting-create.stderr.txt
- measured/orders-waiting-create.txt
- measured/orders-waiting-initial-migration.stderr.txt
- measured/orders-waiting-initial-migration.txt
- measured/orders-waiting-pending-history.txt
- measured/orders-waiting-pending-plan.json
- measured/orders-waiting-pending.json
- measured/orders-waiting-v11.stderr.txt
- measured/orders-waiting-v11.txt
- measured/pending-history.txt
- measured/pending-plan.json
- measured/pending.json
- measured/policy-always-compare.stderr.txt
- measured/policy-always-compare.txt
- measured/policy-always-history.txt
- measured/policy-always-job.json
- measured/policy-always-rows.txt
- measured/policy-always-status.stderr.txt
- measured/policy-always-status.txt
- measured/policy-always.json
- measured/policy-never-history.txt
- measured/policy-never-plan.json
- measured/policy-never.json
- measured/postgres-version.txt
- measured/ptah-version.stderr.txt
- measured/ptah-version.txt
- measured/recovered-pending-history.txt
- measured/recovered-pending-job.json
- measured/recovered-pending-plan.json
- measured/recovered-pending.json
- measured/replay-alias-v11.stderr.txt
- measured/replay-alias-v11.txt
- measured/replay-alias-v12.stderr.txt
- measured/replay-alias-v12.txt
- measured/replay-alias-v13.stderr.txt
- measured/replay-alias-v13.txt
- measured/replay-initial-alias.stderr.txt
- measured/replay-initial-alias.txt
- measured/running-after-publication-history.txt
- measured/running-after-publication-job.json
- measured/running-after-publication-plan.json
- measured/running-after-publication.json
- measured/running-before-publication-history.txt
- measured/running-before-publication-job.json
- measured/running-before-publication-plan.json
- measured/running-before-publication.json
- measured/running-next-plan-history.txt
- measured/running-next-plan-job.json
- measured/running-next-plan-plan.json
- measured/running-next-plan.json
- measured/running-v12.stderr.txt
- measured/running-v12.txt
- measured/running-v13.stderr.txt
- measured/running-v13.txt
- measured/same-files-after-history.txt
- measured/same-files-after-plan.json
- measured/same-files-after.json
- measured/same-files-before-history.txt
- measured/same-files-before-plan.json
- measured/same-files-before.json
- measured/same-files-rebuild.stderr.txt
- measured/same-files-rebuild.txt
- measured/unknown-active.json
- measured/unknown-blocked.json
- measured/unknown-columns-before-repair.txt
- measured/unknown-history.txt
- measured/unknown-job.json
- measured/unknown-repaired-history.txt
- measured/unknown-rows-before-repair.txt
- measured/unknown.json
- measured/waiting-replaced-history.txt
- measured/waiting-replaced-plan.json
- measured/waiting-replaced.json
- measured/waiting-v12.stderr.txt
- measured/waiting-v12.txt
- measured/waiting-v13.stderr.txt
- measured/waiting-v13.txt
- orders-always.yaml
- orders-failed.yaml
- orders-never.yaml
- orders-running.yaml
- orders-unknown.yaml
- orders-waiting.yaml
- orders.yaml
- README.md
- releases/v10/0000000010_create_orders.down.sql
- releases/v10/0000000010_create_orders.up.sql
- releases/v10/ptah.sum
- releases/v11/0000000010_create_orders.down.sql
- releases/v11/0000000010_create_orders.up.sql
- releases/v11/0000000011_add_tracking.down.sql
- releases/v11/0000000011_add_tracking.up.sql
- releases/v11/ptah.sum
- releases/v12/0000000010_create_orders.down.sql
- releases/v12/0000000010_create_orders.up.sql
- releases/v12/0000000011_add_tracking.down.sql
- releases/v12/0000000011_add_tracking.up.sql
- releases/v12/0000000012_add_shipped_at.down.sql
- releases/v12/0000000012_add_shipped_at.up.sql
- releases/v12/ptah.sum
- releases/v13/0000000010_create_orders.down.sql
- releases/v13/0000000010_create_orders.up.sql
- releases/v13/0000000011_add_tracking.down.sql
- releases/v13/0000000011_add_tracking.up.sql
- releases/v13/0000000012_add_shipped_at.down.sql
- releases/v13/0000000012_add_shipped_at.up.sql
- releases/v13/0000000013_index_tracking.down.sql
- releases/v13/0000000013_index_tracking.up.sql
- releases/v13/ptah.sum
- schema.sql
- screenshots/01-before.png
- screenshots/02-pending.png
- screenshots/03-plan.png
- screenshots/04-approved.png
- screenshots/05-applying.png
- screenshots/06-finished.png
- screenshots/07-old-plan.png
- screenshots/08-stale-decision.png
- screenshots/09-replaced-plan.png
- screenshots/10-running-tag-move.png
- screenshots/11-next-plan.png
- screenshots/12-failed-full.png
- screenshots/12-failed.png
- screenshots/13-unknown-full.png
- screenshots/13-unknown.png
- screenshots/14-acknowledge.png
- screenshots/15-acknowledged.png
- screenshots/16-recovered-plan.png
- screenshots/capture-failed-capture.json
- screenshots/capture-initial.json
- screenshots/capture-main.json
- screenshots/capture-running.json
- screenshots/capture-stale.json
- screenshots/capture-unknown.json
- variants/failed/0000000010_create_orders.down.sql
- variants/failed/0000000010_create_orders.up.sql
- variants/failed/0000000011_add_tracking.down.sql
- variants/failed/0000000011_add_tracking.up.sql
- variants/failed/0000000012_add_shipped_at.down.sql
- variants/failed/0000000012_add_shipped_at.up.sql
- variants/failed/0000000013_index_tracking.down.sql
- variants/failed/0000000013_index_tracking.up.sql
- variants/failed/ptah.sum
- variants/unknown/0000000010_create_orders.down.sql
- variants/unknown/0000000010_create_orders.up.sql
- variants/unknown/0000000011_add_tracking.down.sql
- variants/unknown/0000000011_add_tracking.up.sql
- variants/unknown/0000000012_add_shipped_at.down.sql
- variants/unknown/0000000012_add_shipped_at.up.sql
- variants/unknown/0000000013_index_tracking.down.sql
- variants/unknown/0000000013_index_tracking.up.sql
- variants/unknown/ptah.sum
- verification-policy.yaml
- verified.json









