Skip to content

Change the schema

A second revision, a new digest, and the difference applied as one plan.

What it shows. How a change reaches a database, and why the plan is the thing to review.

Where it starts. Where Apply a schema ends: storefront converged on schemas/v1.sql, published as an artifact, under apply Always.

A replay of a verified run, not a live cluster. 7 checks held while it was recorded. Play types it out in the frame below; the transcript is the same session, already there.

sh · schema-update
# Where it starts -- one table, converged.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "\dt"             List of relations Schema |   Name    | Type  |     Owner--------+-----------+-------+--------------- public | customers | table | ptah_external(1 row) # The next revision adds a column and a table that references the first.$ diff -u demo/schemas/v1.sql demo/schemas/v2.sql || true--- demo/schemas/v1.sql	2026-11-02 14:04:31.818273373 +0000+++ demo/schemas/v2.sql	2026-11-02 14:04:31.818273373 +0000@@ -1,4 +1,11 @@ CREATE TABLE customers (   id bigint NOT NULL PRIMARY KEY,-  email text NOT NULL+  email text NOT NULL,+  signed_up_at timestamptz+);++CREATE TABLE orders (+  id bigint NOT NULL PRIMARY KEY,+  customer_id bigint NOT NULL REFERENCES customers (id),+  total_cents bigint NOT NULL ); # Publish the next revision, and point the same resource at it. One field changes.$ REF=$(ptah schema push "oci://$PTAH_OCI_REGISTRY/schemas/demo:v2-$$" \  --schema-file demo/schemas/v2.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:2e40b018b31d12ee6525f412956eb560dfdb2d79ec4d904a1a75030dc03f50ba, generation 2  Replaced:  oci://e2e-registry.demo.svc.cluster.local:5000/schemas/demo@sha256:fe9fa1afe83df299db46bf100ed6e0c0f6f3115f95e05c712ae60a393319f1bf  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:fe9fa1afe83df299db46bf100ed6e0c0f6f3115f95e05c712ae60a393319f1bf, 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.ptahschema/storefront in namespace demo reached in-sync.  Phase:      InSync  Generation: 2, observed by the operator  Database:   last read at <time>kubectl ptah schema storefront -n demo shows where it stands. # The SQL it applied. An ALTER and a CREATE, not a second CREATE TABLE customers.$ kubectl ptah plan storefront --applied -n "$NAMESPACE" -o sqlCREATE TABLE "orders" (  "id" bigint PRIMARY KEY NOT NULL,  "customer_id" bigint NOT NULL,  "total_cents" bigint NOT NULL);-- Add/modify columns for table: customersALTER TABLE "customers" ADD COLUMN "signed_up_at" timestamptz;ALTER TABLE "orders" ADD CONSTRAINT "orders_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id"); # And the database has both, with the foreign key the declaration asked for.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "\d orders"                 Table "public.orders"   Column    |  Type  | Collation | Nullable | Default-------------+--------+-----------+----------+--------- id          | bigint |           | not null | customer_id | bigint |           | not null | total_cents | bigint |           | not null |Indexes:    "orders_pkey" PRIMARY KEY, btree (id)Foreign-key constraints:    "orders_customer_id_fkey" FOREIGN KEY (customer_id) REFERENCES customers(id)

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 "customers", never prints "orders"yes
2exits 0, prints "+ signed_up_at timestamptz", prints "+CREATE TABLE orders"yes
3exits 0, prints "Source set for", prints "Kept:", prints "demo-verification-policy", prints "reached in-sync"yes
3ptahschema/storefront: InSync=True (ScopedConverged)yes
4exits 0, prints "ALTER TABLE \"customers\"", prints "CREATE TABLE \"orders\""yes
5exits 0, prints "FOREIGN KEY (customer_id) REFERENCES customers"yes
5ptahschema/storefront: status.applied.artifactDigest is setyes

What it ran against

  • Kubernetes1.37.0
  • Operator42522be3fd5a
  • Ptahv0.17.0
  • Executorsha256:181e27972091
  • Recorded2026-11-02
  • Scenarios at42522be3fd5a

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/schema-update.yaml, and make demo replays the whole set against a lab of your own. All recorded runs.