Read a plan
The SQL the operator applies is not in a status field or the manager’s log. It
is a document the operator publishes into immutable PtahSchemaPlanChunk objects, bound to a
PtahSchemaPlan by index, key, size and digest, and read back only after every
one of those bindings has held. The Plan runner first delivers the result over
TLS to the receiver, which persists it in confidential PtahResultRecord
objects before acknowledging it. The controller validates that stored result
before publishing the plan chunks. Plan results stay out of logs
describes this default delivery path and the separate legacy log transport. An
Apply projects the verified chunks into immutable ConfigMaps for its Pod to
mount. Those projections are created only when the Apply is dispatched.
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: falsePrivileges: noneStored: 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.
Privileges names the kinds of authority the plan records, or says none. A
plan that records any waited for an approval even under apply: Always, and
Plans that change privileges
says what each kind covers. A kind the plugin reads in the SQL that the plan
does not record is listed after them, marked (read by this plugin, not recorded): the plugin and the manager that published the plan are different
builds, and the manager did not hold the plan for it.
-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.
A change the drift report has no category for, such as a grant, prints
Drift: found, in no category the report counts; the plan says what changes.
The report saw a difference and counted nothing, so there is no count and no
severity to show; the plan below names the privilege kinds it changes.
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 chunk, because a migration plan stores no
SQL of its own.
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, all of them
this operator’s, through one rule. No Secret, no ConfigMap, 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, ptahschemaplanchunks] verbs: [get]The rule names no object, so it reads the next plan as well as this one.
examples/approver-plan-reader-role.yaml is the same Role with a binding, and
the chart’s approver ClusterRole carries the same read.
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 PtahSchemaPlanChunk the plan
owns; the plan’s spec.chunks binds every one by name, index, size and digest,
status.publishedChunks by UID, and spec.contentDigest binds the whole
document.
A plan that was applied is also in the ConfigMaps its Apply Pod mounted: the operator copies the chunks into immutable ConfigMaps of the same names just before it creates the Apply Job, because the Pod holds no Kubernetes credential and the kubelet mounts no custom resource. Those copies are transport, not a second place to read a plan from, and a plan that was never applied has none.
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.