Manage reference data
Rows declared beside the tables that hold them, reconciled through the same plan.
What it shows. What a declared row set owns, how a change to rows alone reaches the database, and why removing a declaration is not a delete.
Where it starts. An empty database and a namespace with the lab's Secrets, as Stand up the lab leaves them. The rows are declared in Go source, which ptah schema push publishes from its directory.
A replay of a verified run, not a live cluster. 19 checks held while it was recorded. Play types it out in the frame below; the transcript is the same session, already there.
# A declaration names the table, the column that identifies a row, and the file the rows live in.$ cat demo/reference/v1/catalog.go// Package catalog is the storefront's reference data: the tables whose rows the// application reads and never writes, declared beside the tables themselves.//// The module compiles it and nothing imports it: what reads it is Ptah, which// parses the annotations out of the source. It lives under demo/ rather than// testdata/ because a reader publishes it by typing the path, and the path is// part of what the demonstration shows.//// Two tables joined by a foreign key, because a reference set is usually// several tables that have to arrive in an order the constraint allows.package catalog //ptah:schema:table name="regions"//ptah:schema:data table="regions" key="code" file="regions.yaml"type Region struct { //ptah:schema:field name="code" type="VARCHAR(8)" primary="true" Code string //ptah:schema:field name="name" type="VARCHAR(64)" not_null="true" Name string} // The country rows arrive in the next revision rather than here. Ptah emits// declared rows grouped by table, in an order that ignores the dependency// order it uses for the tables themselves, so a child row can be offered// before the parent row it references and the foreign key refuses it// (stokaro/ptah#3252). Declaring the parent first, and the child once the// parent rows exist, is the sequence that works today.////ptah:schema:table name="countries"type Country struct { //ptah:schema:field name="code" type="VARCHAR(2)" primary="true" Code string //ptah:schema:field name="name" type="VARCHAR(64)" not_null="true" Name string //ptah:schema:field name="region_code" type="VARCHAR(8)" not_null="true" foreign="regions(code)" foreign_key_name="fk_countries_region" RegionCode string} # The rows themselves are a list, each one a map from column name to value.$ cat demo/reference/v1/regions.yaml- code: emea name: Europe, Middle East and Africa- code: amer name: Americas # Publish the directory, and create a resource that names it by digest.$ REF=$(ptah schema push "oci://$PTAH_OCI_REGISTRY/schemas/catalog:v1-$$" \ --root-dir demo/reference/v1 --dialect postgres --plain-http --format reference)PULL="oci://$REGISTRY_IN_CLUSTER/${REF#"oci://$PTAH_OCI_REGISTRY/"}"kubectl apply -n "$NAMESPACE" -f - <<EOFapiVersion: operator.ptah.run/v1alpha1kind: PtahSchemametadata: name: catalogspec: target: engine: PostgreSQL coordinationKey: demo/storefront/primary urlFrom: {name: demo-database, key: url} desired: ociRef: $PULL registryAuthFrom: {name: demo-registry} verificationPolicyFrom: {name: demo-verification-policy, key: policy.yaml} transport: {plainHTTP: true} policy: apply: Always allowDestructive: false driftSeverity: all interval: 1mEOFptahschema.operator.ptah.run/catalog created # The tables are created and the declared rows are put in them.$ kubectl ptah wait schema/catalog -n "$NAMESPACE" --for in-syncptahschema/catalog in namespace demo reached in-sync. Phase: InSync Generation: 1, observed by the operator Database: last read at <time>kubectl ptah schema catalog -n demo shows where it stands. # The declared rows are in the table.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "SELECT code, name FROM regions ORDER BY code" code | name------+-------------------------------- amer | Americas emea | Europe, Middle East and Africa(2 rows) # The child table exists and holds nothing. Its rows come in the next revision.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "SELECT count(*) FROM countries" count------- 0(1 row) # The second revision adds rows and no column. One marker, nothing else.$ diff -u demo/reference/v1/catalog.go demo/reference/v2/catalog.go || true--- demo/reference/v1/catalog.go 2026-11-02 14:04:31.816855155 +0000+++ demo/reference/v2/catalog.go 2026-11-02 14:04:31.816976091 +0000@@ -28,6 +28,7 @@ // parent rows exist, is the sequence that works today. // //ptah:schema:table name="countries"+//ptah:schema:data table="countries" key="code" file="countries.yaml" type Country struct { //ptah:schema:field name="code" type="VARCHAR(2)" primary="true" Code string # Publish it, and point the resource at it. The rows are planned and applied.$ REF=$(ptah schema push "oci://$PTAH_OCI_REGISTRY/schemas/catalog:v2-$$" \ --root-dir demo/reference/v2 --dialect postgres --plain-http --format reference)kubectl ptah set-source schema/catalog -n "$NAMESPACE" --wait \ --oci "oci://$REGISTRY_IN_CLUSTER/${REF#"oci://$PTAH_OCI_REGISTRY/"}"Source set for ptahschema/catalog in namespace demo. Requested: spec.desired.ociRef oci://e2e-registry.demo.svc.cluster.local:5000/schemas/catalog@sha256:df17e97316e0af3f8c90946147bbd17781638dbfccc875ab77d43ae32a7d9a8f, generation 2 Replaced: oci://e2e-registry.demo.svc.cluster.local:5000/schemas/catalog@sha256:ebaea6d9287b9005ecb8aef78b40ea3a099a0b04baa1f5248aebb3fb741a1070 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/catalog@sha256:ebaea6d9287b9005ecb8aef78b40ea3a099a0b04baa1f5248aebb3fb741a1070, 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/catalog in namespace demo reached in-sync. Phase: InSync Generation: 2, observed by the operator Database: last read at <time>kubectl ptah schema catalog -n demo shows where it stands. # The countries are there.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "SELECT code, name FROM countries ORDER BY code" code | name------+---------------- CA | Canada CZ | Czech Republic US | United States(3 rows) # Fault injection: a value edited outside the operator is drift, not a new declaration.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "UPDATE countries SET name = 'Edited by hand' WHERE code = 'US'"UPDATE 1 # The next reading sees it, plans the difference, and the apply puts the declared value back.$ kubectl ptah wait schema/catalog -n "$NAMESPACE" --for in-syncptahschema/catalog in namespace demo reached in-sync. Phase: InSync Generation: 2, observed by the operator Database: last read at <time>kubectl ptah schema catalog -n demo shows where it stands. # The declared value is back.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "SELECT code, name FROM countries WHERE code = 'US'" code | name------+--------------- US | United States(1 row) # The third revision removes the declaration on countries and keeps the table.$ diff -u demo/reference/v2/catalog.go demo/reference/v3/catalog.go || true--- demo/reference/v2/catalog.go 2026-11-02 14:04:31.816976091 +0000+++ demo/reference/v3/catalog.go 2026-11-02 14:04:31.817065369 +0000@@ -6,8 +6,7 @@ // testdata/ because a reader publishes it by typing the path, and the path is // part of what the demonstration shows. //-// Two tables joined by a foreign key, because a reference set is usually-// several tables that have to arrive in an order the constraint allows.+// The declaration on countries is gone; the table and its rows are not. package catalog //ptah:schema:table name="regions"@@ -20,15 +19,11 @@ Name string } -// The country rows arrive in the next revision rather than here. Ptah emits-// declared rows grouped by table, in an order that ignores the dependency-// order it uses for the tables themselves, so a child row can be offered-// before the parent row it references and the foreign key refuses it-// (stokaro/ptah#3252). Declaring the parent first, and the child once the-// parent rows exist, is the sequence that works today.+// This revision drops the row declaration on countries and keeps the table.+// Ending management is not deleting rows: what the operator stops doing is+// reconciling them, and what it leaves behind is exactly what was there. // //ptah:schema:table name="countries"-//ptah:schema:data table="countries" key="code" file="countries.yaml" type Country struct { //ptah:schema:field name="code" type="VARCHAR(2)" primary="true" Code string # Publish it, and point the resource at it.$ REF=$(ptah schema push "oci://$PTAH_OCI_REGISTRY/schemas/catalog:v3-$$" \ --root-dir demo/reference/v3 --dialect postgres --plain-http --format reference)kubectl ptah set-source schema/catalog -n "$NAMESPACE" --wait \ --oci "oci://$REGISTRY_IN_CLUSTER/${REF#"oci://$PTAH_OCI_REGISTRY/"}"Source set for ptahschema/catalog in namespace demo. Requested: spec.desired.ociRef oci://e2e-registry.demo.svc.cluster.local:5000/schemas/catalog@sha256:ceb2b37b036be3be91ae1305cd440c976c03b00b124b06252f8e07d857a1d2bf, generation 3 Replaced: oci://e2e-registry.demo.svc.cluster.local:5000/schemas/catalog@sha256:df17e97316e0af3f8c90946147bbd17781638dbfccc875ab77d43ae32a7d9a8f Kept: verificationPolicyFrom demo-verification-policy key policy.yaml, registryAuthFrom demo-registry, transport.plainHTTP true Observed: generation 2, phase InSync Resolved: oci://e2e-registry.demo.svc.cluster.local:5000/schemas/catalog@sha256:df17e97316e0af3f8c90946147bbd17781638dbfccc875ab77d43ae32a7d9a8f, verified Apply: spec.policy.apply AlwaysOnce the operator observes generation 3 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/catalog in namespace demo reached in-sync. Phase: InSync Generation: 3, observed by the operator Database: last read at <time>kubectl ptah schema catalog -n demo shows where it stands. # Ending management is not deleting rows. They stay exactly as they were.$ kubectl -n "$NAMESPACE" exec deploy/demo-psql -- psql -c "SELECT code, name FROM countries ORDER BY code" code | name------+---------------- CA | Canada CZ | Czech Republic US | United States(3 rows)
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 "ptah:schema:data table=\"regions\"", prints "key=\"code\"", prints "file=\"regions.yaml\"" | yes |
| 2 | exits 0, prints "emea", prints "amer" | yes |
| 3 | exits 0, prints "catalog created" | yes |
| 4 | exits 0, prints "reached in-sync" | yes |
| 4 | ptahschema/catalog: InSync=True (ScopedConverged) | yes |
| 5 | exits 0, prints "amer", prints "Americas", prints "emea" | yes |
| 6 | exits 0, prints " 0\n(1 row)" | yes |
| 7 | exits 0, prints "+//ptah:schema:data table=\"countries\"" | yes |
| 8 | exits 0, prints "Source set for", prints "reached in-sync" | yes |
| 8 | ptahschema/catalog: InSync=True (ScopedConverged) | yes |
| 9 | exits 0, prints "CZ", prints "Czech Republic", prints "US", prints "CA" | yes |
| 10 | exits 0, prints "UPDATE 1" | yes |
| 11 | exits 0, prints "reached in-sync" | yes |
| 11 | ptahschema/catalog: DriftDetected=False (ScopedConverged) | yes |
| 12 | exits 0, prints "United States", never prints "Edited by hand" | yes |
| 13 | exits 0, prints "-//ptah:schema:data table=\"countries\"" | yes |
| 14 | exits 0, prints "Source set for", prints "reached in-sync" | yes |
| 14 | ptahschema/catalog: InSync=True (ScopedConverged) | yes |
| 15 | exits 0, prints "(3 rows)", prints "United States" | yes |
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
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/reference-data.yaml, and make demo replays the whole set against a lab of your own. All recorded runs.