Skip to content

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.

sh · destructive-block
# 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.

StepClaimHeld
1exits 0, prints "a@example.test"yes
2exits 0, prints "- email text NOT NULL"yes
3exits 0, prints "apply Always, allowDestructive false"yes
3ptahschema/storefront: spec.policy.allowDestructive=falseyes
4exits 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
4ptahschema/storefront: ApprovalRequired=False (DestructiveChangesDisabled)yes
5exits 0, prints "PolicyBlocked", prints "destructive", prints "DestructiveChangesDisabled"yes
5ptahschema/storefront: ApprovalRequired=False (DestructiveChangesDisabled)yes
6exits 0, prints "DROP COLUMN", prints "Destructive: true", prints "destructive"yes
7exits 0, prints "a@example.test"yes
7ptahschema/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 executorImage value
  • 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 ptah itself
  • 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.

CommandThe line it changes
kubectl ptah suspendspec.suspend: true
kubectl ptah resumespec.suspend: false
kubectl ptah set-source --ocispec.desired.ociRef of a schema, spec.artifact.ociRef of a migration
kubectl ptah refreshthe 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.