chant lifecycle
Synopsis
Section titled “Synopsis”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]Description
Section titled “Description”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:
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.
Subcommands
Section titled “Subcommands”lifecycle snapshot <env>
Section titled “lifecycle snapshot <env>”Query the provider API for the current deployed state, then save a snapshot to the chant/lifecycle orphan branch.
chant lifecycle snapshot devchant lifecycle snapshot prod aws # snapshot only the aws lexiconchant lifecycle snapshot prod --deepchant lifecycle snapshot prod --ambient| Flag | Meaning |
|---|---|
--deep | Also 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 |
--ambient | Also 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 |
lifecycle show <env>
Section titled “lifecycle show <env>”Print the latest saved snapshot for an environment.
chant lifecycle show devlifecycle diff <env>
Section titled “lifecycle diff <env>”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.
chant lifecycle diff devReports 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.).
chant lifecycle diff prod --liveOutput is grouped into seven categories per lexicon:
| Category | Meaning |
|---|---|
| missing | Declared in source, and the provider reported it absent from the cloud |
| orphan | Present in the cloud, but not declared in source |
| disappeared | In the previous snapshot, but gone now |
| newly observed | Declared and observed now, but not in any prior snapshot |
| drifted | Observed in both snapshots; status, physical ID, or attributes changed |
| unchanged | Observed in both snapshots; metadata identical |
| unobserved | Declared, 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).
The resolved query address
Section titled “The resolved query address”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:
chant lifecycle diff prod --live --namespace app-bchant lifecycle plan prod --namespace app-bIt 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.
Property-level drift
Section titled “Property-level drift”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.
Three ways a live value classifies
Section titled “Three ways a live value classifies”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:
| Classification | Where it appears | What chant does with it |
|---|---|---|
| declared and equal | counted in unchanged | Nothing. Source and cloud agree. |
| declared and different | PROPERTY DRIFT | Drift. The only classification that may become an update. |
| undeclared | UNCLAIMED | Reported 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.
What a drifted field proposes
Section titled “What a drifted field proposes”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.
--update-baseline — accept a deviation
Section titled “--update-baseline — accept a deviation”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.
chant lifecycle diff prod --live # see what's therechant lifecycle diff prod --live --update-baseline # accept it, with a commit behind itAcceptance 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.
chant lifecycle log prod # find the two commitschant lifecycle diff prod --between <shaA> <shaB>chant lifecycle diff prod --between <shaA> <shaB> aws --jsonPer 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.
lifecycle plan <env>
Section titled “lifecycle plan <env>”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:
chant lifecycle plan prodchant lifecycle plan prod --json # emit the ChangeSet as JSON| Action | Meaning |
|---|---|
| create | Declared in source, and the provider confirmed it absent from the cloud |
| update | Declared and live, but the live config drifted — carries a disruption verdict saying whether applying it replaces the resource or mutates it in place |
| delete | A chant-owned resource that is live but no longer declared |
| adopt | Live but undeclared, ownership not established — a candidate to pull back into source, never an auto-delete |
| noop | Declared and live with no drift, or already reconciled |
| effect | A declared effect receipt whose live value is absent or stale — rendered as effect will fire: <effect> |
| unobserved | Declared, 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 |
|---|---|---|
| missing | create | Declared, and the provider confirmed it absent |
| unobserved | unobserved | Declared, and nobody looked — absence was never established |
| drifted | update (when declared) | Declared and live, attributes changed since the snapshot |
| orphan | adopt, or delete when the live ownership marker confirms it’s chant’s | The one decision the marker makes — never the snapshot |
| newly observed | noop | Declared and live; no snapshot baseline to drift against yet |
| unchanged | noop (when declared) | Declared and live, identical |
| disappeared | create if still declared, else noop | A 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.
Disruption: how much an update costs
Section titled “Disruption: how much an update costs”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:
| Verdict | Meaning |
|---|---|
| in-place | The provider mutates the existing resource. No new identity, no window where it is absent |
| rolling | The resource survives, but its workload is replaced incrementally — a Deployment’s pod template changing |
| replace | A new resource is built and the old one removed. The physical id changes, so anything holding the old one has to be updated |
| destroy | Replacement that removes the old resource first. There is a window with nothing in it, and whatever the old one held is gone |
| unknown | Nobody 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 unobservedDisruption: 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 → 40Replacing 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.
The --json shape
Section titled “The --json shape”--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" } ]}| Field | Meaning |
|---|---|
name | The 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. |
lexicon | The lexicon whose observation produced the entry. |
physicalId | The 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, ownership | The classification and the three-way evidence behind it, as in the table above. |
deltas | Attribute-level changes, on update. |
queried | The resolved read address, when the lexicon reported one (#1620). |
runtimeOwner | The declared entity a runtime entry’s owner chain resolves to. |
unobservedReason, unobservedDetail | Why an unobserved entry could not be read. |
disruption | How 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. |
disruptionBecause | The attribute paths that forced the verdict — which of five changed properties is the one that replaces the resource. |
disruptionDetail | The 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.
Reporting to a GitLab merge request
Section titled “Reporting to a GitLab merge request”--report gitlab-mr emits the plan as the JSON GitLab reads for its merge-request plan widget:
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.
Reporting to a pull request
Section titled “Reporting to a pull request”--report markdown emits the plan as markdown, sized for a PR/MR comment rather than a terminal:
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 environmentUnlike 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.
Resources vs artifacts
Section titled “Resources vs artifacts”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):
| Category | Meaning |
|---|---|
| artifacts added | Observed now, not in last snapshot |
| artifacts removed | In last snapshot, gone now |
| artifacts changed | In both; metadata changed |
| artifacts unchanged | In both; metadata identical |
For the conceptual distinction between resources and artifacts, see Drift Detection — Resources and artifacts.
Coverage today
Section titled “Coverage today”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.
Concurrent snapshots
Section titled “Concurrent snapshots”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.
lifecycle affected
Section titled “lifecycle affected”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.
chant lifecycle affected --base origin/mainchant 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:
chant lifecycle affected --base "$BASE_SHA" --include-dependents --json \ | jq -r '.changed + .dependents | unique[]' \ | while read stack; do chant lifecycle plan "$ENV" "$stack"; doneThe 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.
| Flag | Meaning |
|---|---|
--base <ref> | Base git ref to diff against (required). |
--head <ref> | Head ref (default: the working tree). |
--include-dependents | Add downstream consumers of changed stacks. |
--json | Emit the result as JSON. |
lifecycle whoami <env>
Section titled “lifecycle whoami <env>”Report the identity chant would act as in each configured lexicon, and the scope that identity resolves to, before anything acts.
chant lifecycle whoami prodchant lifecycle whoami prod aws # one lexiconchant lifecycle whoami prod --jsonLEXICON IDENTITY SCOPE SOURCEaws arn:aws:sts::4915:assumed-role/deploy/ci 4915 us-east-1 env AWS_ACCESS_KEY_ID; stacks[].regionk8s 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 identityA 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.
| Verdict | Meaning |
|---|---|
| an identity | The substrate was asked and answered. The principal is what it returned, not what your config calls it. |
could not determine | The 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 reported | The 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 path — credential 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:
- aws —
sts:GetCallerIdentity. The principal ARN, scoped to the account and the region the read transport actually resolves. That region comes fromstacks[].regionand otherwise defaults tous-east-1; the native transport does not consultAWS_REGION, and theSOURCEcolumn says so when your shell sets one that is not being used. Credentials resolve fromAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYonly —AWS_PROFILEalone signs nothing, and is reported asno identity is configurednaming the profile. - k8s —
SelfSubjectReviewagainst the bound cluster. The subject the API server authenticated, scoped to the resolved context and its default namespace, sourced fromk8s.profiles.<env>.contextor, 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) reportscould not determinerather 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.
| Flag | Meaning |
|---|---|
--json | Emit { environment, identities: [...] }, one row per lexicon, with the same verdicts. |
--strict | Exit 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. |
lifecycle teardown <env>
Section titled “lifecycle teardown <env>”Plan — and with --yes, execute — the deletion of an environment’s marker-scoped resources.
chant lifecycle teardown dev # plan only: print the would-delete setchant lifecycle teardown dev --jsonchant lifecycle teardown dev --yes # execute the planned setchant lifecycle teardown prod --yes --confirm-prodSelection 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.
Execution (--yes)
Section titled “Execution (--yes)”--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 viaDescribeStacks, and admits only stacks whose tags carry the requested marker identity — one candidate of typeAWS::CloudFormation::Stackper verified stack. Execution re-verifies the tags, thenDeleteStackpolled toDELETE_COMPLETE. The apply paths stamp the marker as stack tags on create/update (from the template’sMetadata["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.
The prod guard
Section titled “The prod guard”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.
As an Op step
Section titled “As an Op step”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.
lifecycle rollback [env] --to <ref>
Section titled “lifecycle rollback [env] --to <ref>”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.
chant lifecycle rollback prod --to v1.4.2chant lifecycle rollback prod --to 3f9c1a2 --dry-run| Flag | Meaning |
|---|---|
--to <ref> | The git revision to restore sourceDir to. Required |
--dry-run | Compute 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.
lifecycle log [env]
Section titled “lifecycle log [env]”List the history of snapshots. Omit env to show all environments.
chant lifecycle log # all environmentschant lifecycle log dev # just devExit Codes
Section titled “Exit Codes”| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Error (invalid environment, API failure, no snapshot found) |
See Also
Section titled “See Also”chant build— build current output for diffing- Configuration — declare
environmentsinchant.config.ts - Watching Lifecycle — wire
lifecycle snapshot+lifecycle diff --liveto a cadence via theWatchOpcomposite - Implementing Observation — for lexicon authors, how to plug into
--liveviadescribeResources()/listArtifacts() - Implementing Observation — for lexicon authors, how to answer
whoamiviadescribeIdentity()