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.
# 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.
| Step | Claim | Held |
|---|---|---|
| 1 | exits 0, prints "a@example.test" | yes |
| 2 | exits 0, prints "- email text NOT NULL" | yes |
| 3 | exits 0, prints "\"allowDestructive\":false" | yes |
| 4 | exits 0, prints "patched" | yes |
| 5 | ptahschema/storefront: Ready=False (PolicyBlocked) | yes |
| 5 | exits 0, prints "Blocked" | yes |
| 6 | exits 0, prints "storefront", prints "true" | yes |
| 7 | exits 0, prints "DROP COLUMN" | yes |
| 8 | exits 0, prints "a@example.test" | yes |
| 8 | ptahschema/storefront: status.phase=Blocked | yes |
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.