Skip to content
PtahOperator

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.

A replay of a verified run, not a live cluster. 10 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-09-17 13:40:07.292461477 +0200+++ demo/schemas/v3.sql	2026-09-17 13:40:07.292516852 +0200@@ -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.$ kubectl -n "$NAMESPACE" get ptahschema storefront -o jsonpath='{.spec.policy}{"\n"}'{"allowDestructive":false,"apply":"Always","driftSeverity":"all","lockTimeout":"30s","transactionMode":"file"} # Publish v3 and point the resource at it.$ eval "$(demo/bin/lab credentials)"export PTAH_OCI_USERNAME PTAH_OCI_PASSWORD PTAH_OCI_REGISTRYDIGEST=$(ptah schema push "oci://$PTAH_OCI_REGISTRY/schemas/demo:v3-$$" \  --schema-file demo/schemas/v3.sql --dialect postgres --plain-http \  | sed -n 's/^Digest: //p')kubectl -n "$NAMESPACE" patch ptahschema storefront --type=merge \  -p "{\"spec\":{\"desired\":{\"ociRef\":\"oci://$REGISTRY_IN_CLUSTER/schemas/demo@$DIGEST\"}}}"ptahschema.operator.ptah.run/storefront patched # It plans, marks the plan destructive, and stops there.$ kubectl -n "$NAMESPACE" get ptahschema storefront -o json \  | jq -r '.status.phase, (.status.conditions[] | select(.type == "Ready") | .message)'BlockedPlan is blocked by destructive-change policy # The plan exists and says what it is. Destructive is a property of the plan, not a guess.$ kubectl -n "$NAMESPACE" get ptahschemaplanNAME                                 SCHEMA       FINGERPRINT                                                               STATEMENTS   DESTRUCTIVE   AGEptah-plan-5fd78947ca55a03c6e35dc35   storefront   sha256:5fd78947ca55a03c6e35dc3590a971d344ceaa28e4a36cc806617a1a9d0a113a   2            true          1sptah-plan-df827928f4bd415c374288a1   storefront   sha256:df827928f4bd415c374288a1cbb8f39806965c7d15e28f547b9464690903a05b   3            false         45s # And this is the statement the policy refused to run.$ kubectl ptah plan storefront --current -n "$NAMESPACE" -o json \  | jq -r '.statements[] | "\(.severity)  \(.sql)  -- \(.reason)"'destructive  -- Remove columns from table: customers ---- ALTER statements: --ALTER TABLE "customers" DROP COLUMN "email" CASCADE  -- DROP COLUMN removes existing column datasafe  -- WARNING: Dropping column customers.email with CASCADE - This will delete data and dependent objects! --  -- does not remove data or tighten constraints # 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 "\"allowDestructive\":false"yes
4exits 0, prints "patched"yes
5ptahschema/storefront: Ready=False (PolicyBlocked)yes
5exits 0, prints "Blocked"yes
6exits 0, prints "storefront", prints "true"yes
7exits 0, prints "DROP COLUMN"yes
8exits 0, prints "a@example.test"yes
8ptahschema/storefront: status.phase=Blockedyes

What it ran against

  • Kubernetes1.37.0
  • Operator8c7fe187707f
  • Ptahv0.6.0
  • Executorsha256:91ec0a6fa27f
  • Recorded2026-09-17
  • Scenarios at8c7fe187707f

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 read-only client.

  • KUBECONFIGthe cluster the commands run against
  • NAMESPACEthe namespace the schema and its database credentials live in
  • OPERATOR_NAMESPACEwhere the chart installed the operator
  • CONTROLLERthe controller Deployment’s name, from that release
  • 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
  • PTAH_OCI_PASSWORDits password, which no command prints

A session also uses demo/bin/lab in two places, and both are the lab handing over what it generated rather than doing the demonstration’s work:lab credentials prints the three registry values above, and lab manifestfills the published template with values the step states. Rundemo/acceptance/reproduce.sh to watch the same scenario repeated from a directory where neither exists, by an account that may not create a Job.

The scenario is demo/scenarios/destructive-block.yaml, and make demo replays the whole set against a lab of your own. All recorded runs.