Read a plan
The SQL the operator applies is not in a status field or a log line. It is a
document the operator publishes into immutable ConfigMaps, bound to a
PtahSchemaPlan by index, key, size and digest, and read back only after every
one of those bindings has held.
kubectl ptah is how you read it. It reads Kubernetes objects and never
creates or changes a resource, starts a Job, or connects to your database.
kubectl ptah plan storefront -n applicationThree verbs read three different things: plan reads the SQL a stored plan
holds, schema reads where a PtahSchema stands and what its last observation
found, and migration reads a PtahMigration.
Install it
Section titled “Install it”kubectl runs any executable named kubectl-<verb> on your PATH as
kubectl <verb>, so installing the plugin is putting one file there.
Each release publishes a binary for the supported client platforms and a
SHA256SUMS beside them:
version=<release tag>platform=darwin-arm64 # or linux-amd64, linux-arm64, darwin-amd64base=https://github.com/stokaro/ptah-operator/releases/download/$version
curl -fsSLO "$base/kubectl-ptah-$platform"curl -fsSLO "$base/SHA256SUMS"shasum -a 256 --check --ignore-missing SHA256SUMS
install -m 0755 "kubectl-ptah-$platform" /usr/local/bin/kubectl-ptahkubectl ptah --versionkubectl plugin list shows it once it is on PATH. It needs nothing else: no
checkout of this repository, no Helm release on your machine, and no files
beside it.
Which plan
Section titled “Which plan”Current and applied are two different plans, and neither stands in for the other.
| Flag | What it reads |
|---|---|
--current (the default) |
the plan the operator would run next, which is what status.plan names |
--applied |
the plan the last confirmed apply ran, which is what status.applied records |
A converged schema usually has no current plan, because there is nothing left to do; a schema that has never applied anything has no applied plan. Either way the command says so and exits with status 3 rather than printing empty SQL.
--applied shows the plan that ran. It is not a log of what each statement did
in the database, and it does not read the database again.
What comes out
Section titled “What comes out”Without -o you get what the plan is, and then the SQL it holds:
kubectl ptah plan storefront --applied -n applicationSchema: application/storefrontPlan: applied (ptah-plan-4b44084123f0958600629632)Fingerprint: sha256:4b44084123f095860062963255f9f84c2921946524e187186eca71ca88cc366eContent digest: sha256:99a821c0f52b1eb6d643633d40a7db7467b81e10a3be16982626807ef6c692adDialect: postgresStatements: 1Destructive: falseStored: 2026-09-13T08:04:05ZApplied: 2026-09-13T08:04:24Z
-- POSTGRES TABLE: customers --CREATE TABLE "customers" ( "id" bigint PRIMARY KEY NOT NULL, "email" text NOT NULL);The statements come out as the planner wrote them, comments and all.
-o sql prints the statements alone, in the order the plan holds them, each
terminated once. Nothing is re-split on a semicolon: a statement may carry one
inside a string, a comment or a function body.
-o json prints the stored plan document byte for byte, so what you save still
matches the content digest printed above it. It is the executable plan, not the
Kubernetes object:
kubectl ptah plan storefront --applied -n application -o json > plan.jsonDiagnostics go to standard error. Nothing reaches standard output until every check has held, so a plan whose last chunk is corrupt leaves you with an error rather than half its SQL.
Exit status: 0 printed, 1 could not read or verify, 2 the command line,
3 nothing stored to print.
Where a schema stands
Section titled “Where a schema stands”A plan is what the operator would run. It does not say what the operator saw, and a converged schema has no plan at all, which is exactly when you want to know what the last reading found.
kubectl ptah schema storefront -n applicationSchema: application/storefrontPhase: AwaitingApprovalArtifact: oci://registry.example/acme/storefront@sha256:...Verified: trueObserved: 2026-09-16T06:00:00ZDrift: 6 in 3 categories, highest destructiveReference data: 2 to insert, 1 to update, 3 to delete data_rows_deleted 3 destructive data_rows_inserted 2 safe data_rows_updated 1 destructiveApprovalRequired: True (AwaitingApproval)Ready: False (AwaitingApproval)
Plan ptah-plan-4b44084123f0958600629632, 4 statements, stored 2026-09-16T06:00:00Z not approved
kubectl ptah plan prints the SQL this plan holds.Observed: not yet and Drift: none are different answers. The first is a
schema nothing has read; the second is a database that matches its declaration.
The reference-data line
Section titled “The reference-data line”Reference data is how far the declared rows sit from the database, in counts:
how many rows the declaration has and the database is missing, how many the
database holds with a different value in a managed column, and how many it
holds that the declaration no longer has.
It carries no key, no column name and no value. Which rows those are is in the plan, and a data plan contains values, so reading one is data access.
The line appears only when the observation carried a reference-data category. A schema that declares no rows says nothing here, rather than reporting three zeroes about a comparison that never ran.
-o json prints the same view as a document, including referenceData, for a
script that wants the counts without parsing text.
Reading a schema needs get on ptahschemas in the namespace and nothing
else: no plan object, no ConfigMap, and no Secret.
A migration, not a plan
Section titled “A migration, not a plan”A PtahMigration runs SQL that is already written. There is no generated plan
to print: the statements live in the artifact, and what the operator publishes
is which versions it would run, against which reading of the database’s own
history.
kubectl ptah migration orders -n applicationMigration: application/ordersPhase: AwaitingApprovalArtifact: oci://registry.example/acme/orders-migrations@sha256:...History read: 2026-08-30T12:00:00ZCurrent version:2Applied: 2Pending: 1ApprovalRequired: True (AwaitingApproval)
Plan ptah-mplan-0123456789abcdef01234567, 1 migration from version 2: 3 add the orders index-o json prints the same view as a document, for a script that wants the
pending count or the last run’s outcome without parsing text.
What it never prints is SQL or a table row. A migration plan records versions, descriptions and checksums; reading the statements means reading the artifact, which this command does not fetch. The same is true of reference data: a row this operator writes is never printed back to you here, in the status, or in an Event.
Three states are worth recognizing in the output:
- Dirty – a failed or interrupted run left a revision row behind. Nothing applies while one exists, and the operator never removes it.
- Modified – an applied migration’s file no longer accounts for it, and the versions are listed. This is the refusal a versioned workflow exists to make.
- Last run: Partial – a migration committed some of its statements and not the rest. Retrying the file would run them twice, so the operator stops and the recovery is yours to choose.
Reading a migration needs get on ptahmigrations and ptahmigrationplans in
the namespace, and nothing else – no ConfigMap, because there is no chunk
store behind it.
What it needs to be allowed to do
Section titled “What it needs to be allowed to do”Reading a plan is reading three kinds of object in one namespace. No Secret, no
Pod log, no pods/exec, no Job, and nothing cluster-wide:
apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: name: ptah-plan-reader namespace: applicationrules: - apiGroups: [operator.ptah.run] resources: [ptahschemas, ptahschemaplans] verbs: [get] - apiGroups: [""] resources: [configmaps] verbs: [get]Add list on ptahschemaplans for a schema whose last apply predates the
status.applied.planRef field: without the reference the command has to find
the plan by the fingerprint the record does carry, and finding it means listing
the namespace’s plans. It refuses to guess – no match is an absence, and more
than one match is reported rather than resolved by taking the first.
How the plan is stored
Section titled “How the plan is stored”Worth knowing when you are diagnosing the store rather than reading a plan.
A plan document is split into 512 KiB chunks by bytes, up to 8 MiB in total.
The split is of the serialized document, so a boundary falls wherever 512 KiB
falls – possibly inside a SQL string, inside a JSON escape, or inside a
multi-byte character. Each chunk is an immutable ConfigMap; the plan’s
spec.chunks binds every one by name, key, index, size and digest, and
spec.contentDigest binds the whole document.
Reading one chunk on its own is therefore not reading a plan, and decoding chunks by hand is reproducing bindings that already exist. The command does what the operator does: read every chunk, check each against its binding, concatenate in index order, check the whole against the content digest, and only then parse.