Refuse a destructive change
The third revision drops a column. The policy says no, and says so before anything runs.
What it shows. How a policy stops a plan, and where to read what it stopped.
Where it starts. Where Change the schema ends: storefront converged on schemas/v2.sql, published as an artifact, under apply Always with allowDestructive false.
A replay of a verified run, not a live cluster. 11 checks held while it was recorded. Play types it out in the frame below; the transcript is the same session, already there.
# Converged on v2, and the email column holds data.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "INSERT INTO customers (id, email) VALUES (1, 'a@example.test') RETURNING id, email" id | email----+---------------- 1 | a@example.test(1 row) INSERT 0 1 # The next revision removes that column. Someone meant it; the database does not know that.$ diff -u demo/schemas/v2.sql demo/schemas/v3.sql || true--- demo/schemas/v2.sql 2026-11-06 15:43:50.132249928 +0000+++ demo/schemas/v3.sql 2026-11-06 15:43:50.132249928 +0000@@ -1,6 +1,5 @@ CREATE TABLE customers ( id bigint NOT NULL PRIMARY KEY,- email text NOT NULL, signed_up_at timestamptz ); # The policy in force allows nothing destructive, and the schema says so.$ kubectl ptah schema storefront -n "$NAMESPACE"Schema: demo/storefrontPhase: InSyncReady: True, InSync: Schema is in syncPolicy: apply Always, allowDestructive false, driftSeverity allArtifact: oci://e2e-registry.demo.svc.cluster.local:5000/schemas/demo@sha256:48a955b1c06f6098aff4bbc618cb184e659d97c70b577eaaa799ad4c71c4a611Verified: trueObserved: <time>Drift: noneApplied: ptah-plan-184821b9b344ce8f57d552ce at <time> No plan is published. Approvals: none Conditions: TYPE STATUS REASON SINCE MESSAGE EngineSupported True SupportedEngine <time> Database engine "PostgreSQL" is supported Ready True InSync <time> Schema is in sync ReconciliationFailed False Succeeded <time> The latest operation completed ArtifactResolved True DigestPinned <time> Desired OCI reference resolved to immutable content ArtifactVerified True PolicySatisfied <time> Artifact type and verification policy were satisfied DatabaseReachable True Observed <time> Database schema was observed DriftDetected False ScopedConverged <time> The authoritative managed scope has no changes InSync True ScopedConverged <time> A stable scoped plan proves convergence ApprovalRequired False Satisfied <time> Current plan approval requirements are satisfied PlanReady False NoChanges <time> No executable plan is required Applying False JobCompleted <time> Apply Job completed; convergence observation is pending Suspended False Active <time> Reconciliation is active kubectl events -n demo --for ptahschema/storefront prints its Events. # Publish v3 and point the resource at it. The wait ends where the policy stops the plan.$ REF=$(ptah schema push "oci://$PTAH_OCI_REGISTRY/schemas/demo:v3-$$" \ --schema-file demo/schemas/v3.sql --dialect postgres --plain-http --format reference)kubectl ptah set-source schema/storefront -n "$NAMESPACE" --wait \ --oci "oci://$REGISTRY_IN_CLUSTER/${REF#"oci://$PTAH_OCI_REGISTRY/"}"Source set for ptahschema/storefront in namespace demo. Requested: spec.desired.ociRef oci://e2e-registry.demo.svc.cluster.local:5000/schemas/demo@sha256:e13912df5506f0e69ac064420b0efd81537a614b1fd3a5ab14a2484f72aa35ae, generation 2 Replaced: oci://e2e-registry.demo.svc.cluster.local:5000/schemas/demo@sha256:48a955b1c06f6098aff4bbc618cb184e659d97c70b577eaaa799ad4c71c4a611 Kept: verificationPolicyFrom demo-verification-policy key policy.yaml, registryAuthFrom demo-registry, transport.plainHTTP true Observed: generation 1, phase InSync Resolved: oci://e2e-registry.demo.svc.cluster.local:5000/schemas/demo@sha256:48a955b1c06f6098aff4bbc618cb184e659d97c70b577eaaa799ad4c71c4a611, verified Apply: spec.policy.apply AlwaysOnce the operator observes generation 2 it resolves the new reference, checks it against the verification policy, reads the database and plans from what it finds. A plan or an approval from the previous artifact does not carry over.Under spec.policy.apply Always the operator applies that plan without a person, unless it is destructive or changes privileges: those wait for an approval.Waiting up to 5m0s for ptahschema/storefront in namespace demo to reach approval or in-sync.kubectl-ptah: ptahschema/storefront in namespace demo will not reach approval or in-sync: PolicyBlocked. Phase: Blocked Why: spec.policy holds plan ptah-plan-cd86a29c807088ae947b1623 (DestructiveChangesDisabled): Plan contains destructive changes and policy disallows them; read them with kubectl ptah plan, then either narrow the desired schema or set spec.policy.allowDestructivekubectl ptah schema storefront -n demo shows where it stands. # Why. The plan is destructive, and allowDestructive is false.$ kubectl ptah schema storefront -n "$NAMESPACE"Schema: demo/storefrontPhase: BlockedReady: False, PolicyBlocked: Plan is blocked by destructive-change policyPolicy: apply Always, allowDestructive false, driftSeverity allArtifact: oci://e2e-registry.demo.svc.cluster.local:5000/schemas/demo@sha256:e13912df5506f0e69ac064420b0efd81537a614b1fd3a5ab14a2484f72aa35aeVerified: trueObserved: <time>Drift: 1 in 1 categories, highest destructive columns_removed 1 destructive Plan ptah-plan-cd86a29c807088ae947b1623, 1 statement, stored <time> destructive not approved ApprovalRequired: False, DestructiveChangesDisabled: Plan contains destructive changes and policy disallows them; read them with kubectl ptah plan, then either narrow the desired schema or set spec.policy.allowDestructive Approvals: none Conditions: TYPE STATUS REASON SINCE MESSAGE EngineSupported True SupportedEngine <time> Database engine "PostgreSQL" is supported Ready False PolicyBlocked <time> Plan is blocked by destructive-change policy ReconciliationFailed False Succeeded <time> The latest operation completed ArtifactResolved True DigestPinned <time> Desired OCI reference resolved to immutable content ArtifactVerified True PolicySatisfied <time> Artifact type and verification policy were satisfied DatabaseReachable True Observed <time> Database schema was observed DriftDetected True ScopedChanges <time> Managed scope requires 1 schema statements; highest severity destructive InSync False ScopedChanges <time> The authoritative managed scope differs from the verified artifact ApprovalRequired False DestructiveChangesDisabled <time> Plan contains destructive changes and policy disallows them; read them with kubectl ptah plan, then either narrow the desired schema or set spec.policy.allowDestructive PlanReady True Published <time> Exact plan bytes are stored in immutable chunks Applying False JobCompleted <time> Apply Job completed; convergence observation is pending (written for generation 1) Suspended False Active <time> Reconciliation is active (written for generation 1) kubectl ptah plan storefront -n demo prints the SQL this plan holds.kubectl events -n demo --for ptahschema/storefront prints its Events. # And this is the statement the policy refused to run, with the reason Ptah gave.$ kubectl ptah plan storefront --current -n "$NAMESPACE"Schema: demo/storefrontPlan: current (ptah-plan-cd86a29c807088ae947b1623)Fingerprint: sha256:cd86a29c807088ae947b1623df5d6db126a9d2864007367a25db8e28bf91e20fContent digest: sha256:cc1574e26441daaacf412206b3682ba0ef0e824421a7dbac283b6418a68b34c7Dialect: postgresStatements: 1Destructive: truePrivileges: noneStored: <time> Statement 1 of 1: destructive (DROP COLUMN removes existing column data)-- Remove columns from table: customersALTER TABLE "customers" DROP COLUMN "email" CASCADE-- WARNING: Dropping column customers.email with CASCADE - This will delete data and dependent objects!; # The column and its data are still there. A refusal that let it run would not be one.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "SELECT id, email FROM customers" id | email----+---------------- 1 | a@example.test(1 row)
What was checked
Every step states what has to hold before its output may be published. A condition is read as its type, status and reason on the live object, never as a phrase in a message.
| Step | Claim | Held |
|---|---|---|
| 1 | exits 0, prints "a@example.test" | yes |
| 2 | exits 0, prints "- email text NOT NULL" | yes |
| 3 | exits 0, prints "apply Always, allowDestructive false" | yes |
| 3 | ptahschema/storefront: spec.policy.allowDestructive=false | yes |
| 4 | exits 8, prints "Source set for", reports "will not reach approval or in-sync: PolicyBlocked.", reports "Phase:", reports "Why: spec.policy holds plan", reports "DestructiveChangesDisabled" | yes |
| 4 | ptahschema/storefront: ApprovalRequired=False (DestructiveChangesDisabled) | yes |
| 5 | exits 0, prints "PolicyBlocked", prints "destructive", prints "DestructiveChangesDisabled" | yes |
| 5 | ptahschema/storefront: ApprovalRequired=False (DestructiveChangesDisabled) | yes |
| 6 | exits 0, prints "DROP COLUMN", prints "Destructive: true", prints "destructive" | yes |
| 7 | exits 0, prints "a@example.test" | yes |
| 7 | ptahschema/storefront: Ready=False (PolicyBlocked) | yes |
What it ran against
- Kubernetes1.37.0
- Operatore41a04412bb1
- Ptahv0.18.0
- Executorsha256:9f8797986124
- Recorded2026-11-06
- Scenarios ate41a04412bb1
What the commands read
A session is repeated, not admired, so every name in it is one your own environment can carry. There is nothing else: no helper of ours stands between a command and the cluster, and the recorder refuses a step that reads a name this list does not hold. The commands arekubectl, ptah and kubectl ptah, each of which you install once; the last one is the operator’s own client. They run from the root of a clone of this repository, which holds the files they name: the SQL under demo/schemas, demo/migrations,demo/migrations-checkpoint and demo/mysql, the reference data underdemo/reference, and the manifests under demo/manifests.
- KUBECONFIGthe cluster the commands run against
- NAMESPACEthe namespace the resources and their database credentials live in
- OPERATOR_NAMESPACEwhere the chart installed the operator
- CONTROLLERthe controller Deployment’s name, from that release
- EXECUTOR_IMAGEthe executor image the chart runs, its
executorImagevalue - REGISTRY_IN_CLUSTERthe registry address a Pod in the cluster resolves
- PTAH_OCI_REGISTRYthe registry address the push goes to
- PTAH_OCI_USERNAMEthe account that push uses, read by
ptahitself - PTAH_OCI_PASSWORDits password, which no command prints
The last two are how a CI job hands ptah a registry credential. On a workstation,ptah oci login stores it once instead, reading the password from a prompt or from standard input rather than from the command line.
The registry has two addresses, the way a registry in an ordinary deployment does: a push goes to PTAH_OCI_REGISTRY, and the resource names REGISTRY_IN_CLUSTER with the same repository and digest. The registry Secret a resource names says which address its credential is for, and the operator sends it to no other.
The lab itself appears in one command, demo/bin/lab versions in Stand up the lab, which says what the lab is made of. What a run starts from is its preparation, which is not recorded; Where it starts above says what that is and which run ends there. Rundemo/acceptance/reproduce.sh to watch Approve a plan repeated from a directory where the lab’s scripts do not exist, by an account that may not create a Job, with the plan approved by a second account that may create neither a schema nor a Job, and then the same schema published with ptah schema push and moved to its next revision withkubectl ptah set-source.
The same changes, as edits
Each kubectl ptah command that changes a resource changes one field of it, so the same change is an ordinary edit, with kubectl edit ptahschema storefront -n "$NAMESPACE"or in a manifest you apply; the runs that keep their resources as files underdemo/manifests make their changes that way. What the commands add is checking: set-source takes only a reference pinned by digest, changes nothing while an Apply may be running, and changes nothing another writer changed since it read the resource.
| Command | The line it changes |
|---|---|
kubectl ptah suspend | spec.suspend: true |
kubectl ptah resume | spec.suspend: false |
kubectl ptah set-source --oci | spec.desired.ociRef of a schema, spec.artifact.ociRef of a migration |
kubectl ptah refresh | the annotation operator.ptah.run/read-requested-at, set to the time of the request |
An approval is not one of them. It is an object of its own, created once by the person who decided: kubectl ptah approve --dry-run client prints it as YAML for a repository, to be created with kubectl create, and never edited afterwards.
The scenario is demo/scenarios/destructive-block.yaml, and make demo replays the whole set against a lab of your own. All recorded runs.