Skip to content

chant lifecycle

chant lifecycle snapshot <env> [lexicon] [--deep] [--ambient] [--src <dir>]
chant lifecycle show <env>
chant lifecycle diff <env> [--live] [--namespace <ns>] [--update-baseline] [--src <dir>]
chant lifecycle diff <env> --between <refA> <refB> [lexicon] [--json]
chant lifecycle plan <env> [lexicon] [--json] [--owned] [--namespace <ns>] [--report gitlab-mr|markdown] [--src <dir>]
chant lifecycle rollback [env] --to <ref> [--dry-run]
chant lifecycle affected --base <ref> [--head <ref>] [--include-dependents] [--json]
chant lifecycle whoami <env> [lexicon] [--json] [--strict]
chant lifecycle teardown <env> [--yes] [--confirm-prod] [--json]
chant lifecycle log [env]

chant lifecycle captures point-in-time snapshots of deployed infrastructure by querying the cloud provider API and saving the result to a git orphan branch. Snapshots enable drift detection — comparing what’s currently built against what was last deployed, and (with --live) against what’s actually in the cloud right now.

The environment name (e.g. dev, staging, prod) must be declared in chant.config.ts under environments.

snapshot, diff, and plan build your declarations to compare against live state. By default they build the project root (.). For a mixed-layout project — chant infrastructure in src/ alongside application code that has import side effects (starts a server, opens a connection) — set sourceDir in chant.config.ts so the lifecycle build only synthesizes the infra:

chant.config.ts
export default { lexicons: ["k8s"], sourceDir: "src" };

The --src <dir> flag overrides sourceDir for a single invocation. Use the same source root for snapshot and diff so their build digests stay comparable.

For the conceptual model behind these commands — observational vs. authoritative state, resources vs. artifacts, when drift detection earns its keep — see Drift Detection.

Query the provider API for the current deployed state, then save a snapshot to the chant/lifecycle orphan branch.

Terminal window
chant lifecycle snapshot dev
chant lifecycle snapshot prod aws # snapshot only the aws lexicon
chant lifecycle snapshot prod --deep
chant lifecycle snapshot prod --ambient
FlagMeaning
--deepAlso record each resource’s normalized property tree, not just its identity. This is what a fold over topology needs, and what a snapshot-backed query needs to answer a property question at all. Costs more provider calls and a larger record, so it is opt-in. Only lexicons with a deep reader contribute property trees
--ambientAlso record resources of a kind this project manages that exist in the account without being declared, so a later chant search --at <ref> --ambient filters a set that was actually recorded. Opt-in for the same reason as on search: it asks the provider what exists, which is a broader read than resolving out from what is declared
--src <dir>Build root override for this invocation

Print the latest saved snapshot for an environment.

Terminal window
chant lifecycle show dev

Two modes, depending on whether --live is set.

Default (digest mode). Build the current project, fingerprint the resource declarations, and compare against the digest stored in the last snapshot. Fast, offline, and useful as a fast-feedback check inside CI — but only catches changes you’ve made in source. Ignores the cloud entirely.

Terminal window
chant lifecycle diff dev

Reports added, removed, changed, and unchanged resources at the declaration level.

--live (drift mode). Query the cloud provider API right now via each lexicon’s describeResources() plugin method, then compare the live result against both the previous snapshot and the current build. This is the path that actually catches external mutations (someone scaled a node pool by hand, deleted a bucket, etc.).

Terminal window
chant lifecycle diff prod --live

Output is grouped into seven categories per lexicon:

CategoryMeaning
missingDeclared in source, and the provider reported it absent from the cloud
orphanPresent in the cloud, but not declared in source
disappearedIn the previous snapshot, but gone now
newly observedDeclared and observed now, but not in any prior snapshot
driftedObserved in both snapshots; status, physical ID, or attributes changed
unchangedObserved in both snapshots; metadata identical
unobservedDeclared, and chant could not read live state for it — status unknown

Each unobserved entry names a reason: read failed, no credentials, no binding for this environment, no reader for this resource kind, or withheld by the --owned filter. They are not counted as drift and never as missing, and when any are present the summary line says the estate is unknown rather than clean.

Drifted entries include attribute-level deltas (status, physicalId, lastUpdated, and each attributes.* key).

When the lexicon reports one, each entity also carries the resolved address its live read was issued against — for k8s the exact request path, with any defaulting the client applied made visible. This is what separates “confirmed not there” from “looked in the wrong place”: a k8s object declared without metadata.namespace (a Flux-managed one, say, whose namespace is stamped at apply time) is read from the default namespace, and only the address shows that.

MISSING (declared, provider reports not in cloud):
- web [queried /apis/apps/v1/namespaces/default/deployments/web]

In --json, the addresses arrive as resources.queried per lexicon — a name → address map covering every entity a read was issued for — and each resources.unobserved[] row carries its own queried field inline:

{
"k8s": {
"resources": {
"missing": ["web"],
"queried": { "web": "/apis/apps/v1/namespaces/default/deployments/web" },
"unobserved": [
{ "name": "api", "reason": "read-failed", "detail": "", "queried": "/api/v1/namespaces/default/services/api" }
]
}
}
}

The field is optional and purely diagnostic: verdicts are unchanged, and a lexicon that reports no addresses simply omits it. lifecycle plan --json carries the same address per entry as queried on the change set (a create’s address is the one the provider confirmed absent). The k8s lexicon is the first implementer.

--namespace <ns> — where to read scope-less entities

Section titled “--namespace <ns> — where to read scope-less entities”

The address above is also the fix’s starting point. When an object’s namespace is stamped by a controller rather than declared — a GitOps estate where one project declares the Kustomization with spec.targetNamespace and another declares the bare objects — the read falls through to default and reports a running estate as absent. --namespace <ns> replaces that fallback:

Terminal window
chant lifecycle diff prod --live --namespace app-b
chant lifecycle plan prod --namespace app-b

It is a default, not a rewrite: an entity that declares its own metadata.namespace is still read from the one it declares, and cluster-scoped kinds are unaffected. The queried addresses reflect whichever namespace the read actually used. Lexicons with no namespace-like scope ignore the option.

A lexicon that also implements observeResourcesDeep() (coverage) adds a second section per lexicon, comparing property trees rather than metadata. It reports what the categories above cannot: a property that changed, a property source never declared at all (the console edit CloudFormation’s own drift detection does not see), and a declared property the cloud is not carrying.

aws (properties)
1 property drift across 1 resource(s), 0 accepted, 3 unchanged, 1 unclaimed, 1 unobserved
PROPERTY DRIFT (declared vs live; baseline shown where one exists):
- Assets (AWS::S3::Bucket)
VersioningConfiguration.Status: Enabled → Suspended
UNCLAIMED (live values on properties chant never declared; not drift):
- Assets (AWS::S3::Bucket)
Tags[#cost-center].Value: platform [not in this declaration's claimed fields]

The tree is normalized before it is compared — server-populated fields dropped, tags and policy statements canonicalized, provider defaults subtracted where source is silent, secret-bearing property names masked. A resource whose properties could not be read is listed as unobserved with a reason, exactly like the thin read; it is never reported as clean.

A declaration’s props are a claim: they are exactly the fields chant set. Flattening them gives the claimed-field set, in the same path grammar the diff addresses properties by (spec.replicas, Tags[#env].Value, ingress[0].fromPort). Every live value then falls into one of three buckets rather than two:

ClassificationWhere it appearsWhat chant does with it
declared and equalcounted in unchangedNothing. Source and cloud agree.
declared and differentPROPERTY DRIFTDrift. The only classification that may become an update.
undeclaredUNCLAIMEDReported with its live value. Never drift, never counted in the drift total, never proposed for change, and never written by --update-baseline.

Where the substrate records who wrote a field, the unclaimed row names them instead: Kubernetes’ metadata.managedFields gives spec.replicas: 7 [held by hpa-controller], which is the difference between a controller doing its job and somebody running kubectl edit. Everywhere else the declaration is the only witness and the row says the path is not in this declaration’s claimed fields. --json carries both under unclaimed, with source set to field-manager or claimed-fields so a consumer can tell which answered. It is a different array from held, which is the heldElsewhere() set: unclaimed is a field source never declared, held is a field source declared and then handed over.

This is what makes an autoscaler quiet without anything being recorded: a field chant never set stops being a finding on the first read it appears in. It also narrows what the accepted baseline is for, since --update-baseline now only ever records drift on a path source declares.

A path inside a list is claimed by its exact position or key, so a lexicon that supplies an orderKey for a set-like list gets the sharper answer: a tag added in the console is Tags[#other], unclaimed, while a positional list reports the new element by index instead.

Drift on a declared path is the only thing that may become a change, and how it becomes one depends on where the field came from. chant build records that per emitted property (see Composite Resources), and the report prints a verdict under any row where editing the declared value in place would be the wrong move:

PROPERTY DRIFT (declared vs live; baseline shown where one exists):
- webBucket (AWS::S3::Bucket)
BucketName: data → data-renamed [from: composite WebService parameter name]
change parameter `name` of the `web` call of composite WebService in src/main.ts so `BucketName` moves from "data" to "data-renamed". The composite stays.
VersioningConfiguration.Status: Enabled → Suspended [from: composite WebService literal]
refused: `VersioningConfiguration.Status` is fixed by the `web` call of composite WebService in src/main.ts, so no argument at the call site moves it. Parameterize the field, or stop using the composite here.

A field the author declared directly gets no extra line, since editing it in place is already what happens. A field whose origin the build could not determine says so and falls back to the same behaviour. Every verdict is a description and nothing here edits a source file. --json carries them all under reconcile, a sibling of deep.

heldElsewhere(), a field declared, then handed over

Section titled “heldElsewhere(), a field declared, then handed over”

A property declared heldElsewhere() never reports as drift, whatever the live value is, and gets its own section instead, naming who holds it and why:

HELD (declared heldElsewhere(); not drift, never proposed for update):
- web (K8s::Apps::Deployment)
spec.replicas: held by hpa — 5 (the autoscaler owns replicas after the first apply)

No live value at all, not even a provider default, gets marked [SUSPICIOUS: ...]: the declared hand-over has no evidence it ever happened. In --json, the held set rides under the lexicon’s deep.held key, the same as drifted/accepted/unchanged. chant lifecycle plan carries the same set under the change set’s top-level held key, described below. Held properties are never actions, so they never appear inside entries.

Some deviations are permanent facts of the account: a mandatory tag another team stamps on everything, a setting an org policy flips on. --update-baseline records every property deviation the run reported as accepted, into <env>/observation-baseline.json on the chant/lifecycle orphan branch.

Terminal window
chant lifecycle diff prod --live # see what's there
chant lifecycle diff prod --live --update-baseline # accept it, with a commit behind it

Acceptance is value-bound. The accepted value stops alerting; a later change away from it is drift again, reported with all three axes (declared, live, and the accepted baseline). The baseline is never authority for a deploy — deleting it costs noise suppression and nothing else.

--between <refA> <refB> — two saved snapshots

Section titled “--between <refA> <refB> — two saved snapshots”

Every snapshot is a commit on the chant/lifecycle orphan branch (list them with lifecycle log). --between diffs two of them against each other — a purely historical, read-only comparison (no build, no cloud query). It answers “what changed in the cloud between Tuesday and Thursday” independent of source.

Terminal window
chant lifecycle log prod # find the two commits
chant lifecycle diff prod --between <shaA> <shaB>
chant lifecycle diff prod --between <shaA> <shaB> aws --json

Per lexicon (all declared, or the one you name), it reports a two-way delta: added (in B, not A), removed (in A, not B), changed (in both, metadata differs, with the attribute paths), and unchanged. --json emits { environment, refA, refB, lexicons }. This feeds the deployment-lanes frame-pair diff.

Promote the live diff to a typed, read-only change set. Where lifecycle diff --live reports seven observation categories, lifecycle plan classifies each resource into one action you can act on:

Terminal window
chant lifecycle plan prod
chant lifecycle plan prod --json # emit the ChangeSet as JSON
ActionMeaning
createDeclared in source, and the provider confirmed it absent from the cloud
updateDeclared and live, but the live config drifted — carries a disruption verdict saying whether applying it replaces the resource or mutates it in place
deleteA chant-owned resource that is live but no longer declared
adoptLive but undeclared, ownership not established — a candidate to pull back into source, never an auto-delete
noopDeclared and live with no drift, or already reconciled
effectA declared effect receipt whose live value is absent or stale — rendered as effect will fire: <effect>
unobservedDeclared, and chant could not read live state — no action is proposed, and the reason is printed

unobserved is not a proposal; it is the plan admitting a hole. A resource chant could not look at is never a create and never a delete — a plan is only as complete as the read behind it, and when entities are unobserved the command says so on stderr (including under --json and --report gitlab-mr, whose shapes have no column for it). --report markdown carries the same warning in the body instead, plus its own section — see Reporting to a pull request below.

effect is the receipt row. An effect receipt is declared, diffed, and observed like any resource, but it is observe-only to the generic apply path: the effect() step is the sole writer, on success, last. The plan resolves the receipt’s expectation (references resolve against observed values at plan time) and compares it to the live value — absent or differing renders effect will fire: <effect>, with the reason (receipt-absent, receipt-stale, or unresolved-input when a reference could not resolve — the fire is proposed rather than guessed away). A matching receipt is a clean noop; a receipt nobody could read is unobserved, loudly. A receipt is never a create, never an update, and never a prune candidate: a stale receipt after a crash between the effect and its write re-proposes the fire on the next plan, which is the at-least-once guarantee the whole classification exists to preserve.

create and update are precise from declared-vs-live. delete is only ever proposed for an undeclared resource whose live ownership marker (ownership marking) confirms it is chant’s; a foreign orphan is adopt, and an orphan with no marker data is adopt, never delete. Pass --owned to restrict the query to chant-owned resources.

An update is bounded by the claimed-field set: a property the declaration never set is somebody else’s field, so it is reported as unclaimed by diff --live and never becomes an update here. The plan proposes closing the gap on fields chant claims and nothing more, which is why an autoscaler’s replica count or a controller’s annotation cannot turn into a proposal to overwrite it.

The classification reads ownership from the live marker, never from the snapshot, so the snapshot never becomes load-bearing: the delete decision is identical whether or not a snapshot exists. The moment a mutation trusted the snapshot, the snapshot would have become an authoritative state file under a different name — which chant deliberately avoids.

lifecycle plan is strictly read-only: it builds, queries, and classifies. It never mutates the cloud and never deploys — chant build stays pure.

For every lexicon that implements observeResourcesDeep(), the plan also carries a held array, one entry per entity with at least one property declared heldElsewhere(). A held property is not an action, never appearing inside entries and never the reason an entity classifies as update. The human render prints its own HELD section, --json carries the array as held beside entries, and --report markdown folds it into the same section shown for lifecycle diff --live.

Crosswalk: observation categories -> plan actions

Section titled “Crosswalk: observation categories -> plan actions”

The diff --live categories and the plan actions describe the same signal on different axes. Observation categories key on three sets — declared in source, observed now, in the last snapshot. The plan collapses the snapshot axis (it’s evidence, never authority) and adds an ownership axis. The mapping:

Observation category (diff --live)Plan action (plan)Why
missingcreateDeclared, and the provider confirmed it absent
unobservedunobservedDeclared, and nobody looked — absence was never established
driftedupdate (when declared)Declared and live, attributes changed since the snapshot
orphanadopt, or delete when the live ownership marker confirms it’s chant’sThe one decision the marker makes — never the snapshot
newly observednoopDeclared and live; no snapshot baseline to drift against yet
unchangednoop (when declared)Declared and live, identical
disappearedcreate if still declared, else noopA snapshot-history signal; the plan reads live + source, not snapshot age

The “when declared” qualifiers are where the axes diverge: an undeclared resource that also drifted or is unchanged is still an orphan to the plan — the snapshot history doesn’t change that it’s live-but-undeclared, so ownership decides between adopt and delete.

The action says what changes. It does not say what applying it costs, and an update that flips a tag reads exactly like an update that rebuilds a database. Every update entry therefore carries a disruption verdict:

VerdictMeaning
in-placeThe provider mutates the existing resource. No new identity, no window where it is absent
rollingThe resource survives, but its workload is replaced incrementally — a Deployment’s pod template changing
replaceA new resource is built and the old one removed. The physical id changes, so anything holding the old one has to be updated
destroyReplacement that removes the old resource first. There is a window with nothing in it, and whatever the old one held is gone
unknownNobody could say

The verdict comes from the lexicon, never from core. Replacement semantics are spec knowledge — CloudFormation’s registry schema declares createOnlyProperties per type, Kubernetes’ SSA schema knows which field changes roll a workload — and a lexicon publishes it through classifyDisruption, the same way it publishes postSynthChecks. Core hardcoding a per-provider replacement table would be a table the tool has to keep in step with a provider it does not generate from.

unknown is the default and the only fallback. A lexicon that ships no classifier, a classifier that says nothing about an entry, one that throws, one that returns a level outside the vocabulary — all of them land on unknown. There is no path by which a change accidentally reads as in-place: that claim is only ever a lexicon’s deliberate one, which is what makes it worth gating on. Read unknown as “nobody looked at the spec”, in the same spirit as unobserved — not as “probably fine”.

The plan says it three ways. A count on the header, the verdict and its reason on the row, and a ! on the specific delta the verdict rests on:

Plan for prod: 0 create, 2 update, 0 effect, 0 delete, 0 adopt, 0 runtime, 4 noop, 0 unobserved
Disruption: 1 in-place, 1 destroy
UPDATE (disruption from the lexicon that owns the spec; unknown means nobody could say, not that it is safe):
api (AWS::EC2::Instance) — in-place: no create-only property of AWS::EC2::Instance changed
attributes.InstanceType: t3.micro → t3.small
db (AWS::RDS::DBInstance) — destroy: DBInstanceIdentifier is create-only and AWS::RDS::DBInstance replaces by deleting first — the resource is gone before the new one exists
! attributes.DBInstanceIdentifier: app-db → app-db-2
attributes.AllocatedStorage: 20 → 40

Replacing and unclassified counts are also printed on stderr, so a --json or --report gitlab-mr consumer — whose shapes have no column for disruption — still hears about them. --report markdown bolds the verdict on every row instead, and rolls the same counts into the header — a replace or destroy reaches the reviewer without them having to read every line.

The AWS lexicon is the first implementer, reading the CloudFormation Registry schema the codegen already compiles into lexicon-aws.json: a changed createOnlyProperties entry is a replace, and replacementStrategy: delete_then_create makes it a destroy. A changed conditionalCreateOnlyProperties entry is unknown on purpose — the schema is saying “depends on the value”, and reporting a maybe as in-place is worse than reporting nothing. AWS never returns rolling; nothing in the Registry schema expresses a workload roll.

--json emits { env, entries }. Every lexicon’s change set is merged into the one entries[], and each entry says which lexicon observed it:

{
"env": "prod",
"entries": [
{
"name": "web",
"type": "K8s::Apps::Deployment",
"lexicon": "k8s",
"physicalId": "default/web",
"action": "noop",
"evidence": { "declared": true, "inSnapshot": true, "live": true, "observed": true },
"ownership": "owned",
"queried": "/apis/apps/v1/namespaces/default/deployments/web"
},
{
"name": "db",
"type": "AWS::RDS::DBInstance",
"lexicon": "aws",
"physicalId": "app-db",
"action": "update",
"evidence": { "declared": true, "inSnapshot": true, "live": true, "observed": true },
"deltas": [
{ "path": "attributes.DBInstanceIdentifier", "oldValue": "app-db", "newValue": "app-db-2" }
],
"ownership": "unknown",
"disruption": "destroy",
"disruptionBecause": ["attributes.DBInstanceIdentifier"],
"disruptionDetail": "DBInstanceIdentifier is create-only and AWS::RDS::DBInstance replaces by deleting first — the resource is gone before the new one exists"
},
{
"name": "sg-0abc123",
"type": "AWS::EC2::SecurityGroup",
"lexicon": "aws",
"physicalId": "sg-0abc123",
"action": "adopt",
"evidence": { "declared": false, "inSnapshot": false, "live": true, "observed": true },
"ownership": "foreign"
}
]
}
FieldMeaning
nameThe chant entity name for a declared entity. For an undeclared live resource (adopt, delete, runtime) it is the lexicon’s live key, which is not a name you can join to the graph IR.
lexiconThe lexicon whose observation produced the entry.
physicalIdThe provider-assigned id (ResourceMetadata.physicalId) from the live read, or from the snapshot when the resource is gone. Absent when neither reported one.
action, evidence, ownershipThe classification and the three-way evidence behind it, as in the table above.
deltasAttribute-level changes, on update.
queriedThe resolved read address, when the lexicon reported one (#1620).
runtimeOwnerThe declared entity a runtime entry’s owner chain resolves to.
unobservedReason, unobservedDetailWhy an unobserved entry could not be read.
disruptionHow much applying an update costs, from the lexicon that owns the spec. Present on every update and on nothing else. unknown means nobody could say.
disruptionBecauseThe attribute paths that forced the verdict — which of five changed properties is the one that replaces the resource.
disruptionDetailThe spec knowledge behind the call, or why there is none.

lexicon and physicalId are what let a consumer route an entry to the right lexicon and tell a declared entity name apart from the live key of an undeclared resource without inferring it from the action.

--report gitlab-mr emits the plan as the JSON GitLab reads for its merge-request plan widget:

Terminal window
chant lifecycle plan prod --report gitlab-mr > tfplan.json
# {"create":3,"update":1,"delete":0}

Declare the file as artifacts:reports:terraform and GitLab renders “3 to add, 1 to change, 0 to delete” on the MR. The widget format is generic — any tool that emits this JSON gets it — so the plan maps onto it directly. Only the mutating actions count: adopt and noop are excluded, since the widget has no column for live-but-undeclared or no-change.

The widget label always reads “Terraform” — that is GitLab’s fixed string, not a claim chant makes. The GitLab lexicon ships an MrPlanReport composite that wires the job for you.

--report markdown emits the plan as markdown, sized for a PR/MR comment rather than a terminal:

Terminal window
chant lifecycle plan prod --report markdown
## Plan for `prod`
1 create, 1 update, 0 effect, 0 delete, 0 adopt, 0 runtime, 4 noop, 1 unobserved
**Disruption:** 1 destroy
> **1 declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.**
### CREATE
- `new-bucket` (AWS::S3::Bucket) `aws`
### UPDATE (disruption from the lexicon that owns the spec; unknown means nobody could say, not that it is safe)
- `db` (AWS::RDS::DBInstance) `aws`**destroy**: DBInstanceIdentifier is create-only and AWS::RDS::DBInstance replaces by deleting first — the resource is gone before the new one exists
```
! attributes.DBInstanceIdentifier: app-db → app-db-2
attributes.AllocatedStorage: 20 → 40
```
### UNOBSERVED (declared; chant could not read live state — no action proposed)
- `crd-widget` — no binding for this environment

Unlike gitlab-mr, this is not a floor: every action carries its own section, entries are attributed to the lexicon that observed them, and both holes the other two shapes leave to stderr ride the body instead — the unobserved count gets its own banner and its own section, and every update bolds its disruption verdict rather than staying silent about a replace or a destroy. A group past 20 entries folds into a <details> block so a large plan doesn’t bury the comment. Deterministic and pure — no ANSI, same ordering as the terminal render.

The github lexicon ships a PrPlanReport composite that runs the plan and posts (or updates) one sticky PR comment per environment, reusing the marker-and-gh api mechanism examples/github-pr-preview proved. The forgejo lexicon gets it for free — it inherits every github composite through its dialect re-export.

A single lexicon may report both resources (entity-keyed, via describeResources()) and artifacts (context-keyed, via listArtifacts()); lifecycle diff --live shows them in separate sections. Artifacts use a four-category two-way diff (no “declared” axis):

CategoryMeaning
artifacts addedObserved now, not in last snapshot
artifacts removedIn last snapshot, gone now
artifacts changedIn both; metadata changed
artifacts unchangedIn both; metadata identical

For the conceptual distinction between resources and artifacts, see Drift Detection — Resources and artifacts.

The Runtime observation coverage matrix on the Lexicons overview page is the canonical per-lexicon table, with the query mechanism and ownership channel for each. It is the one place a new lexicon adds its row.

Lexicons that implement neither method are warn-skipped — --live doesn’t fail the whole command for them.

The fountain lexicon resolves its connection via the chant config: lifecycle snapshot <env> (or --live against <env>) looks up fountain.profiles.<env> in your chant.config.ts and falls back to fountain.defaultProfile. The same profile model is used by chant run <op> --on fountain. Every declared fountain resource is mapped back to its chant entity name when the declared props.name matches the server-side identifier.

The K8s lexicon reads through a typed API client (chant #1074, details) rather than a kubectl subprocess. Every generated resource type is addressable — the 165 core kinds plus every bundled CRD — because the entity-type->address table comes out of the same codegen pass as the declarable classes, and is confirmed against the cluster’s own API discovery before anything is read. Reads run concurrently, so a hundred declared entities are not a hundred serial round trips, and failures carry the API server’s own code and reason rather than a parsed stderr line.

The cluster context comes from a k8s.profiles.<env>.context binding in chant.config.ts (chant #1100) when one is declared — mirroring fountain.profiles.<env> — and every request is made against it explicitly. If the kubeconfig’s own current-context disagrees with the bound one, describeResources refuses with an error naming the environment, the expected context, and the ambient one, instead of silently reading the wrong cluster. An environment with no binding keeps the old behavior — whatever context is ambient — but logs a visible warning that nothing is pinned. See K8sChantConfig in the k8s lexicon for the config shape.

On EKS, AKS and GKE, kubeconfig authentication is an exec credential plugin — aws eks get-token, kubelogin, gke-gcloud-auth-plugin — so the client still spawns a process there, once for a token rather than once per read. Those three (plus kubectl) are allowed by default and anything else the kubeconfig names is refused unless listed in k8s.execCredentialPlugins.

Snapshots are pushed to the orphan chant/lifecycle branch with git push --force-with-lease. If two operators (or a CI job + a human) take a snapshot for the same remote at the same time, the second push is rejected rather than silently overwriting the first. You’ll see:

error: Another snapshot completed for chant/lifecycle after this run started (env: prod).
hint: Pull and retry: `git fetch origin chant/lifecycle:chant/lifecycle` && `chant lifecycle snapshot prod`.

Pull the remote ref and re-run — the first snapshot is preserved, the second proceeds against the updated baseline.

In a multi-stack project, answer “which stacks does this change affect?” without planning everything. Read-only — it returns the set; fanning lifecycle plan / an ApplyOp over it is an Op you compose.

Terminal window
chant lifecycle affected --base origin/main
chant lifecycle affected --base origin/main --include-dependents --json

“Affected” is two distinct things, because chant serializes cross-stack references symbolically:

  • Directly changed — the stack’s built artifact differs between base and head. Caught by artifact diff over deterministic builds, so a comment-only or refactor-with-no-output-change edit is correctly excluded.
  • Operationally affected (dependents) — a stack whose own artifact is unchanged but which consumes an upstream export whose value changed. Its bytes don’t move, yet it may need re-apply. Caught by walking the cross-stack graph (chant graph --stacks) from the directly-changed set, with --include-dependents.

A stack whose inputs come from outside synthesis (deploy-time parameters) cannot be judged from a source diff — it is reported as indeterminate (“external-input — cannot confirm from source”), not silently included or excluded.

Builds are obtained lazily: head is the in-place build of the working tree (or --head <ref>); base is built from --base <ref> in a single throwaway worktree (auto-removed), or skipped entirely if you supply pre-built sources via the library. Because build is deterministic, a cached or supplied base is as trustworthy as a rebuild.

--json emits { changed, dependents, indeterminate }. The same logic is exported as affectedStacks() for programmatic use.

Composing it. chant returns the set; you drive the fan-out. For example, plan only the affected stacks in CI:

Terminal window
chant lifecycle affected --base "$BASE_SHA" --include-dependents --json \
| jq -r '.changed + .dependents | unique[]' \
| while read stack; do chant lifecycle plan "$ENV" "$stack"; done

The same shape works from an Op you write — affected is the primitive; the fan-out (plan, gate, apply) is yours to compose. chant never blindly applies the set.

FlagMeaning
--base <ref>Base git ref to diff against (required).
--head <ref>Head ref (default: the working tree).
--include-dependentsAdd downstream consumers of changed stacks.
--jsonEmit the result as JSON.

Report the identity chant would act as in each configured lexicon, and the scope that identity resolves to, before anything acts.

Terminal window
chant lifecycle whoami prod
chant lifecycle whoami prod aws # one lexicon
chant lifecycle whoami prod --json
LEXICON IDENTITY SCOPE SOURCE
aws arn:aws:sts::4915:assumed-role/deploy/ci 4915 us-east-1 env AWS_ACCESS_KEY_ID; stacks[].region
k8s system:serviceaccount:chant:deployer prod-eks-a ns=chant k8s.profiles.prod.context; credential exec-plugin (aws)
github not reported — this lexicon does not answer for an identity

A project spanning several substrates reaches each one with its own credentials, and until this command the answer arrived as a failure — an unobserved entry with reason no-credentials — or, when the binding was wrong rather than missing, as a clean read of the wrong account. Every observer already resolves an identity and a scope inside its bind and throws both away. This asks for them first.

Read-only and never a gate. It builds nothing, mutates nothing, and exits 0 whatever the rows say unless you pass --strict. Each lexicon answers with its own cheap self-query — sts:GetCallerIdentity for aws, SelfSubjectReview (the call behind kubectl auth whoami) for k8s — issued on the same transport, endpoint override and region resolution the live read uses, so the target reported is the target read.

Three verdicts, and the difference between them matters.

VerdictMeaning
an identityThe substrate was asked and answered. The principal is what it returned, not what your config calls it.
could not determineThe lexicon tried and could not, with a typed reason: no identity is configured (nothing to act as), this environment resolves to no target (no binding), or the substrate was reached and the self-query failed.
not reportedThe lexicon implements no identity self-query. It answers for nothing, which is never rendered as an empty identity.

“No identity is configured” and “I could not find out” are different answers and stay apart in both the table and the JSON.

No credential ever reaches the output. An identity is a principal and a scope — an account id, a region, a project, a cluster context, a service-account subject. Where a substrate’s only identity signal is a secret (a kubeconfig holding a static bearer token), the report names the credential pathcredential token — and stops there. Core also scrubs any value the process holds in a credential-named environment variable, plus PEM blocks, JWTs and Bearer … headers, from every field before it prints.

What each lexicon answers with today:

  • awssts:GetCallerIdentity. The principal ARN, scoped to the account and the region the read transport actually resolves. That region comes from stacks[].region and otherwise defaults to us-east-1; the native transport does not consult AWS_REGION, and the SOURCE column says so when your shell sets one that is not being used. Credentials resolve from AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY only — AWS_PROFILE alone signs nothing, and is reported as no identity is configured naming the profile.
  • k8sSelfSubjectReview against the bound cluster. The subject the API server authenticated, scoped to the resolved context and its default namespace, sourced from k8s.profiles.<env>.context or, when the environment is unbound, from the ambient current-context with the missing binding named. A cluster too old to serve the review API (pre-1.26) reports could not determine rather than falling back to the kubeconfig’s local user alias, which is a name you chose, not the subject the cluster resolves it to.

Every other lexicon reports not reported until it implements the capability — see Implementing Observation.

The environment’s declared endpoint is applied for the identity query exactly as it is for diff --live, so pointing an environment at a local emulator reports the emulator’s identity rather than the cloud’s.

FlagMeaning
--jsonEmit { environment, identities: [...] }, one row per lexicon, with the same verdicts.
--strictExit 1 when any lexicon could not determine an identity. A not reported row never fails, since a lexicon that implements nothing is not a failure.

Plan — and with --yes, execute — the deletion of an environment’s marker-scoped resources.

Terminal window
chant lifecycle teardown dev # plan only: print the would-delete set
chant lifecycle teardown dev --json
chant lifecycle teardown dev --yes # execute the planned set
chant lifecycle teardown prod --yes --confirm-prod

Selection is marker-scoped by construction: a resource is in the plan only when its own ownership marker carries this project’s ownership.stack and the requested environment. Unmarked resources, another stack’s resources, and another environment’s resources are never candidates. The read is stateless — live markers only, no build, no snapshot.

The environment must be declared in chant.config.ts under environments; an unknown name exits nonzero. A project with no ownership.stack is refused: teardown has nothing to select on.

Per lexicon, the plan comes from the teardownOwned capability where a lexicon implements one, falling back to describeResources plus the per-resource marker identity otherwise. Kinds a lexicon stamps but cannot read back are reported as holes — a plan with holes is incomplete, not clean, and says so. An empty would-delete set is also reported loudly rather than exiting silently.

--yes deletes the planned set through each lexicon’s executeTeardown capability and prints one outcome per candidate: deleted (already-gone counts — teardown is idempotent), failed (the delete errored), not-prunable (deliberately refused, with the reason — for example the live object’s marker changed since planning), retained (owned and no longer declared, but deliberately kept — a k8s generated-once secret is never swept, because the stored bytes are the only copy of material chant never held; delete it explicitly if you mean to), or skipped (the lexicon enumerates but does not implement execution yet). Nothing is ever silent: every planned entry gets a row, the summary line counts retained separately with a warning that the environment is not clean while retained resources exist, and the command exits nonzero when any candidate is still failed after the retry pass.

Ordering is per lexicon, and each lexicon deletes in the order its target requires — there is no global reverse-dependency ordering. After the first pass, core runs one bounded retry pass over that lexicon’s failures, which covers most ordering hiccups (a dependent that had not finished deleting yet). Failures that survive the retry are reported as failures.

Implemented today:

  • k8s — deletes by the marker label selector through the typed API client (the same delete the ownership-scoped prune uses), re-reading and re-verifying each object’s marker immediately before deleting. Namespaces are deleted last, so their members get individual outcomes before the namespace cascade.
  • fly — destroys marker-carrying machines (lease -> destroy -> wait) first, then deletes whole apps last. An app is deleted whole only when every live machine in it carries the requested identity — the app-boundary rule from the fly ownership convention; volumes, IPs, and certificates go with their app. An app also hosting a foreign or other-env machine is never deleted whole.
  • aws — STACK granularity: CloudFormation’s thin read returns no tags, so per-resource selection is impossible, and the stack is the deploy boundary anyway. The plan resolves the environment’s stacks (the project’s stacks[], else the stack named after the environment), reads each stack’s own tags via DescribeStacks, and admits only stacks whose tags carry the requested marker identity — one candidate of type AWS::CloudFormation::Stack per verified stack. Execution re-verifies the tags, then DeleteStack polled to DELETE_COMPLETE. The apply paths stamp the marker as stack tags on create/update (from the template’s Metadata["chant:ownership"] block the build writes), so every stack deployed from a marked build is teardown-eligible; a stack deployed before stack tagging carries no tags to verify and is reported as a hole (unverified-ownership) — re-deploy it once to stamp it. It is never deleted.

gcp and azure enumerate (via the fallback read) but do not execute yet — their candidates are reported as skipped.

An environment whose name looks production-like (prod, production, prod-eu, us-prod, …) is never torn down on --yes alone. Interactively, chant asks you to re-type the environment name; non-interactively (CI, scripts), pass --yes --confirm-prod explicitly. Planning a production environment needs no confirmation — only deletion does.

Both halves are exported from @intentius/chant for programmatic use (a test-environment harness tearing down in afterAll, for example): planTeardown() enumerates, executeTeardown() deletes and returns the same per-candidate report the CLI prints.

The same engine runs as a step with the envTeardown builder — an Op that tears an environment down behind a gate instead of from a terminal:

import { Op, phase, gate, envTeardown } from "@intentius/chant/op";
export default Op({
name: "retire-staging",
overview: "Tear down the staging environment behind an approval",
phases: [
phase("Teardown", [
gate("approve-teardown", { description: "Release the staging teardown" }),
envTeardown("staging"),
]),
],
});

Steps run in authored order, so a gate(...) placed before envTeardown stops the run until someone records the resolution with chant approve retire-staging approve-teardown, and the next run walks through and deletes. The guards are the CLI’s guards: an undeclared environment and a missing ownership.stack fail the step before any live read, and a production-like name needs envTeardown("prod", { confirmProd: true }) — the authored counterpart of --confirm-prod, since an Op never prompts. The step fails when any candidate is still failed after the retry pass, naming the survivors; a fully clean run returns the same per-candidate report the CLI prints. See the Ops reference for the builder row.

Open a pull request that restores the project’s sourceDir to a prior git revision. The rollback happens in source, where it can be reviewed. No cloud is touched by this command. Once a human merges the PR, the project’s own gated apply rolls the environment back from the restored source. The optional env positional only names the environment in the PR title.

Terminal window
chant lifecycle rollback prod --to v1.4.2
chant lifecycle rollback prod --to 3f9c1a2 --dry-run
FlagMeaning
--to <ref>The git revision to restore sourceDir to. Required
--dry-runCompute the rollback delta and print it as a patch on stdout. No PR is opened, nothing is pushed, no branch is left behind

When sourceDir already matches the target revision the command says so and exits zero without opening anything. On success the PR URL is the only thing on stdout, so a consumer can read it; the summary goes to stderr.

List the history of snapshots. Omit env to show all environments.

Terminal window
chant lifecycle log # all environments
chant lifecycle log dev # just dev
CodeMeaning
0Success
1Error (invalid environment, API failure, no snapshot found)