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 health <env> [lexicon] [--json] [--src <dir>]chant lifecycle teardown <env> [--yes] [--confirm-prod] [--json]chant lifecycle log [env]chant lifecycle gc [--yes] [--push] [--json] [--verbose]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. A snapshot lets diff compare the current build against what was last deployed and, with --live, against what is in the cloud 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 project with chant infrastructure in src/ beside 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.
Drift Detection explains the model behind these commands, including observational versus authoritative state and resources versus artifacts. It also says when drift detection is worth running.
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 and offline, so it suits a CI check. It catches only changes made in source and never reads the cloud.
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 mode catches changes made outside chant (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 it is the exact request path, including any defaulting the client applied. The address 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 diagnostic only: verdicts are the same with or without it, and a lexicon that reports no addresses 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.
The declared definition of a missing entity
Section titled “The declared definition of a missing entity”Each missing entity’s row also carries [definition <sha256 hex>], a fingerprint of its declared type and build props (#3652). A missing entity is what applying the diff would create, and ApplyOp’s gate binds the digest of this output: with the name alone, an approval for “create events” stood after events was edited, as long as events did not exist yet. With the fingerprint, editing what would be created changes the plan digest, and the next run stops at the gate and names both the approved digest and the new one.
MISSING (declared, provider reports not in cloud): - events [queried http://127.0.0.1:8123 yodel.events] [definition 3f9c…]In --json the fingerprints arrive as resources.definitions, a name → fingerprint map over the missing entities. lifecycle plan --json carries the same fingerprint as definition on each create entry, so a change-set part’s plan digest covers it too.
--namespace <ns> — where to read scope-less entities
Section titled “--namespace <ns> — where to read scope-less entities”When a controller stamps an object’s namespace instead of the source declaring it (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 sets a default. 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 show the namespace each read 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:
| 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 silences an autoscaler without recording anything: a field chant never set is not a finding, from the first read it appears in. It also narrows what the accepted baseline is for, since --update-baseline records drift only 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 is 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 brings the suppressed alerts back and changes 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. The comparison is read-only and runs no build and no cloud query. It answers “what changed in the cloud between Tuesday and Thursday” without reference to 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>”Turn the live diff into 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 marks a hole in the plan. 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. An absent or differing receipt renders effect will fire: <effect>, with the reason (receipt-absent, receipt-stale, or unresolved-input when a reference could not resolve, in which case 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 keeps the at-least-once guarantee.
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 and never from the snapshot. The delete decision is identical whether or not a snapshot exists. A mutation that trusted the snapshot would turn it into an authoritative state file, which chant avoids.
lifecycle plan is strictly read-only: it builds, queries, and classifies. It never mutates the cloud and never deploys, and 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, and 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. It is live and undeclared whatever the snapshot history says, 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 come from the spec (CloudFormation’s registry schema declares createOnlyProperties per type, Kubernetes’ SSA schema knows which field changes roll a workload), and a lexicon publishes them through classifyDisruption, the same way it publishes postSynthChecks. A replacement table in core would have to be kept in step by hand with providers chant 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 sees them. --report markdown bolds the verdict on every row instead and puts the same counts in the header. A reviewer then sees a replace or destroy without reading 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 says “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). |
definition | On a create, the fingerprint of the declared type and build props (#3652), so the plan’s digest moves when what it would create does. |
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. Any tool that emits this JSON gets the widget, 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”, which is GitLab’s fixed string. 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 carries the whole plan: every action has its own section, entries are attributed to the lexicon that observed them, and both holes the other two shapes leave to stderr are in the body. The unobserved count gets its own banner and its own section, and every update bolds its disruption verdict. A group past 20 entries folds into a <details> block so a large plan doesn’t bury the comment. The output is deterministic, with no ANSI and the 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 has it too, since 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 skipped with a warning, and --live carries on with the rest.
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.
Grafana is bound the same way, through grafana.profiles.<env> (a URL, and a token or basic-auth pair named by environment variable), and falls back to GRAFANA_URL when no profile is declared. See Drift and Live Export.
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, shaped like fountain.profiles.<env>) when one is declared, 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 uses whatever context is ambient and logs a 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 kept, and the second runs 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. It is read-only and 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”) rather than 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.
Stack names, when the project declares its stacks. A project that sets stacks in chant.config.ts gets each declared stack’s own source built and the answer keyed by stack name. Without it, one build of the source root can only answer by lexicon partition, so an aws-only estate of fifteen stacks reports “aws changed” whichever one moved, and nothing downstream can join to that. Stack names are what a component’s deploy step uses, so chant components fan-out can consume this output. In that mode --include-dependents has nothing to add and prints a note saying so: the build holds cross-lexicon edges, not edges between deployed stacks, and the relation between two deployed stacks is stated by the components’ dependsOn, which the fan-out walks for itself. An empty dependents there means “not computed”, never “nothing downstream”.
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 returns the set, and you write the fan-out around it. chant never applies the set itself.
| 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 without this command the answer arrives as a failure (an unobserved entry with reason no-credentials) or, when the binding is 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.
| 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 the way the AWS CLI resolves them. Environment keys (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, plusAWS_SESSION_TOKEN) come first and win overAWS_PROFILE, as they do in the CLI. Otherwise chant reads theAWS_PROFILEprofile (defaultwhen unset) from the filesAWS_CONFIG_FILEandAWS_SHARED_CREDENTIALS_FILEname, by default~/.aws/configand~/.aws/credentials. Static keys andcredential_processsign directly. Arole_arnis assumed with the keys of itssource_profileor, forcredential_source = Environment, the environment’s. SSO sessions (sso_sessionor the oldersso_start_url) go through the IAM Identity Center portal. TheSOURCEcolumn names what answered, for exampleprofile loom (sso session acme), role Deployer in 491500000000.An SSO profile reads the token
aws sso logincached under~/.aws/sso/cache/. An expired token is refreshed when the cache holds a refresh token, and the refreshed token is not written back. When no usable token is cached or the portal refuses it, the row isno identity is configuredwithrun aws sso login --profile <p>in its detail. A profile chant cannot resolve is alsono identity is configured, with the reason in the detail.mfa_serialandweb_identity_token_fileare not supported, andcredential_sourceworks only asEnvironment. Instance metadata and container credentials are not consulted. Against an endpoint override the profile is not read at all, since the emulator call is unsigned.The read transport behind
diff --live, the receipt store and the AgentCore trace fetch signs with the same chain, so it acts as the principal whoami reports (#3958). It walks the chain once per process and walks it again five minutes before role, SSO orcredential_processcredentials expire. When nothing resolves, the request goes out unsigned and AWS refuses it. -
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 health <env>
Section titled “lifecycle health <env>”Whether each declared resource that has a health read is up, read separately from drift (#4177). diff --live says what changed; health says whether it is serving.
chant lifecycle health prodchant lifecycle health prod aws # one lexiconchant lifecycle health prod --json| Lexicon | Type | What decides the state |
|---|---|---|
| aws | AWS::Route53::HealthCheck | GetHealthCheckStatus. Healthy when more than 18% of the checkers report success, the rule Route 53 applies. The detail gives the count and the latest status text; checkedAt is the latest checker’s time. |
| aws | AWS::ECS::Service | DescribeServices: healthy when runningCount reaches desiredCount, unhealthy below it or when the service is not ACTIVE, unknown when desiredCount is 0. |
| azure | Microsoft.App/containerApps | The active revisions: unhealthy when there is none, or one is Failed, Degraded or Stopped, or reports Unhealthy; healthy when every active revision is Running; unknown while one is still provisioning. When the revisions cannot be listed, the app’s own runningStatus answers, and the detail says so. |
| gcp | GCP::Run::Service | The Ready condition: terminalCondition on a Cloud Run v2 body, status.conditions otherwise. |
| gcp | GCP::Compute::BackendService | getHealth for each backend group: unhealthy when any endpoint is UNHEALTHY, healthy when every endpoint of every group is HEALTHY, unknown otherwise. A serverless NEG reports no endpoint health, so a backend service in front of Cloud Run reads unknown. |
| gcp | GCP::Iam::PolicyMember | The target’s IAM policy: healthy when it grants the member the role. A member missing from the policy is listed under absent. |
Each entity lands in exactly one of three places. entities holds what was read, with state (healthy, unhealthy or unknown), a short detail, observedAt and, when the provider says, checkedAt. unobserved holds a read that failed, with the same reasons diff --live uses (read-failed, no-credentials, no-binding, unsupported-kind); a failed read is never unhealthy. absent lists what the provider says is not deployed.
The aws read maps logical ids to physical ids through the stack’s resource list, so a stack that does not exist yet reports every entity absent. Endpoints follow the other live reads: AWS_ENDPOINT_URL, AZURE_ENDPOINT_URL, GCP_ENDPOINT_URL, or the environment’s declared endpoint. Against an emulator, a Route 53 health check reports what the emulator’s checkers report, which means nothing until the emulator probes the endpoint.
With --json the output is a chant-health/v1 document. Its JSON Schema ships in the package as @intentius/chant/health.schema.json, with $id https://intentius.io/chant/schemas/health/v1/health.schema.json. Readers ignore fields they do not know; a field is only ever added within a version.
{ "schema": "chant-health/v1", "chantVersion": "0.124.33", "environment": "prod", "observedAt": "2026-10-20T21:00:00.000Z", "lexicons": { "aws": { "entities": { "web": { "type": "AWS::ECS::Service", "state": "healthy", "detail": "2/2 running, rollout COMPLETED", "observedAt": "2026-10-20T21:00:00.000Z" } }, "unobserved": {}, "absent": [] } }, "summary": { "healthy": 1, "unhealthy": 0, "unknown": 0, "unobserved": 0, "absent": 0 }}Entity names are the graph’s node ids. A multi-stack project nests the lexicons under stacks.<name>. Routing is not modelled. A consumer that wants rotation reads the graph’s edges, for example a record whose HealthCheckId names an unhealthy check. The command exits 0 whatever the states are, and 1 only when the build fails.
lifecycle teardown <env>
Section titled “lifecycle teardown <env>”Plan the deletion of an environment’s marker-scoped resources, and with --yes, delete them.
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 uses live markers only, with no build and 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, and a plan with holes says it is incomplete. 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, since teardown is idempotent), failed (the delete errored), not-prunable (refused, with the reason, for example the live object’s marker changed since planning), retained (owned and no longer declared, but kept on purpose: 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). 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) and reads each stack’s own tags viaDescribeStacks. It admits only stacks whose tags carry the requested marker identity. There is 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. This command touches no cloud. 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 devlifecycle gc
Section titled “lifecycle gc”Prune the stored records that grow chant/lifecycle without bound, by a retention policy (#4036). On the branch it prunes stored change sets in _change-sets/ and releases in _releases/. A pruned release takes with it the template bytes in _releases/templates/ that no kept release names. On this host it deletes old run event files in <git-common-dir>/chant/run-events/.
chant lifecycle gc # dry run: what would go, and the sizes before and afterchant lifecycle gc --yes # prune: one commit on the local chant/lifecyclechant lifecycle gc --yes --push # prune and push the commit| Flag | Meaning |
|---|---|
--yes | Prune. Without it the command is a dry run and changes nothing |
--push | Push the prune commit to the ledger remote. Without it the commit stays local until the next push of chant/lifecycle |
--json | Print the report as JSON: the policy, every file removed and kept with the reasons it is kept, the tree’s size before and after, and the run event files |
--verbose | List each file removed |
The command reads the whole branch, every member’s ledger at once, from anywhere in the repository. It fetches chant/lifecycle first, so the plan sees the remote’s newest gate facts, and refuses to prune when the local branch and the remote’s have forked or the remote did not answer.
The policy
Section titled “The policy”| Kind | Default | Kept while |
|---|---|---|
changeSets | 14 days | it was stored, or named by a gate fact, within days |
releases | 14 days, newest 10 | it is promotable: cut within days, or among the newest keep of its member, and its commit has no ci/revoked tag |
runEvents | 14 days, newest 100 | its file was written within days, or it is among the newest keep files |
A workspace overrides any of these in the retention block of its declaration, read at base. See Retention. A project with no declaration uses the defaults.
What is never pruned
Section titled “What is never pruned”Whatever the policy says, gc keeps what these rules name. The --json report gives each kept file its reasons, by the code in the first column.
| Code | Kept |
|---|---|
deployed | A release that the latest deploy record of a component in an environment names, by its releaseDigest or by the release gate approval its admission names. A promotion takes what the step before runs, so this keeps every release a promotion picks up by default |
pending-gate | The change set and release a pending fact binds, until the fact expires |
newest, within-window | A release still promotable, as the table above defines it. For a change set, within-window is its window |
kept-release | A template that a kept release names, and a change set that an approval, an empty resolution or a pending fact names with a kept release |
unreadable | A file in _releases/ that can’t be read as a release, and then every template of its member |
What a prune changes
Section titled “What a prune changes”A prune is one new commit on top of the branch that removes files. It never rewrites history and never force-pushes. Other clones and tools read chant/lifecycle, and every removed file stays in the commits before the prune, so git log chant/lifecycle -- <path> still finds it. A prune therefore bounds the tree a reader walks at the tip. The repository’s history goes on growing by what chant writes, plus the small prune commit.
No .jsonl file on the branch is pruned or edited. That covers _releases/cuts.jsonl, <env>/releases.jsonl, _gates/<op>.jsonl and the run ledgers. They are append-only. The rules above read them to learn what an environment runs and which gate is pending. A cut line stays when its release is pruned. Its label still names that digest and can’t be given to another release. After a prune, chant release list no longer lists a pruned release, and chant change-set show reports a pruned change set as missing. Snapshots, _plans/ and _builds/ have no policy yet and are not pruned.
retention-growth.e2e.test.ts writes a month at chant’s own rate to a test repository: 28.5 runs, 25 pull-request gates and 4.7 release cuts a day. Pruned with the defaults at the end of the month, its tree goes from 2277 files (5174 KiB) to 1477 files (3036 KiB). The packed history grows from 8643 KiB to 8662 KiB. The run event files go from 855 (6264 KiB) to 399 (2925 KiB). The ledgers stay at 771 files (1142 KiB). retention-growth.e2e.test.ts writes that month.
Exit 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()