Skip to content

Drift Detection

Drift is the gap between what you declared in source and what’s actually deployed. Most IaC tools track this through an authoritative state file — chant doesn’t. This page explains the model, the diff vocabulary, and when (and when not) to invest in continuous drift detection. For where this sits in the broader model — the dial from observe to reconcile to authoritative — see Lifecycle Models.

chant lifecycle snapshot <env> writes a record of what was observed in the cloud at a point in time. It’s stored on a chant/lifecycle orphan branch in your repo. There’s no central state server, no lock file, no encrypted backend.

This is the opposite of how Terraform, Pulumi, and CDK/CloudFormation think about state. Those tools treat state as authoritative — the state file is the truth, and apply reconciles the cloud to match. The state file must be locked during writes, secured against tampering, and protected from corruption because deployments depend on it.

chant’s snapshots are observational. They’re a forensic record: “this is what we saw at 10:42 UTC on Tuesday.” Snapshots don’t drive deployments — they exist to be diffed against. Three consequences fall out:

Authoritative state (Terraform, Pulumi, CDK)Observational state (chant)
State must be locked; concurrent writes corruptA stale snapshot is inconvenient, not dangerous
Sensitive data leaks into state fileThin snapshots record metadata only; deep property trees mask secret-bearing names before anything is written
Bad state breaks deploysBad snapshot is a record problem; deploys query live APIs
apply knows exactly what to changePrecise change set comes from live projection (lifecycle plan); chant computes it but never auto-applies

For the broader trade-off comparison, see State: authoritative vs. observational in the comparison guide.

Drift can mean two structurally different things, and chant tracks them through two different plugin contracts:

Resources are 1:1 cloud equivalents of declared chant entities — an AWS CloudFormation resource, a K8s Deployment, an ARM resource group, a fountain environment. Each declaration has a name, the cloud version has a name, and they’re correlated. Lexicons that fit this model implement describeResources() (entity-keyed: “look up the live state of these declared things”).

Artifacts are runtime concepts created by tooling outside chant’s entity model. A Helm release isn’t declared in chant — chant declares Chart.yaml + templates, and helm install later creates the release. Same story for Docker containers. Lexicons that fit this model implement listArtifacts() (context-keyed: “tell me what artifacts exist in this environment right now”).

The diff engine treats them differently because they are different. Resources have a “declared” axis to compare against; artifacts don’t.

A lexicon can implement both. The helm lexicon lists every release in the cluster as an artifact, and also resolves each declared Helm::Chart to its release and reports what that release holds as per-resource rows — helm get manifest plus helm get hooks (chant #1246). See Helm — Live Observation.

For the implementer-side walkthrough, see Implementing Observation.

chant lifecycle diff <env> --live returns eleven categories — seven for resources, four for artifacts.

The diff engine compares three axes: what’s declared in source now, what was observed in the previous snapshot, and what the live API reports right now.

CategoryDeclared nowIn last snapshotObserved nowMeaning
missingDeclared, and the provider reported it absent — never deployed, manually deleted, or stack rolled back
orphanIn cloud but not declared — manual creation, untracked tooling, or imported-pending
disappearedWas there at last snapshot, gone now
newly observedObserved now, not in any prior snapshot
driftedPresent in both, but status, physicalId, or attributes.* changed
unchangedPresent in both, metadata identical
unobserved?Declared, and chant could not look — no reader for the kind, the read failed, no credentials, no cluster binding

Drifted entries include attribute-level deltas so you can see what changed, not just that something changed.

missing and unobserved are different facts

Section titled “missing and unobserved are different facts”

An unobserved entity is not drift and not absence: it is a hole in the report. It carries a reason (read-failed, no-credentials, no-binding, unsupported-kind, filtered) and it never becomes a create in the plan — chant proposes creating a resource only when a provider actually said it isn’t there. When any entity is unobserved, the “no drift detected” line says so: no drift over an incomplete read is not a clean estate.

These categories collapse into the create/update/delete/adopt/noop/unobserved actions chant lifecycle plan emits — see the observation-to-action crosswalk.

Those seven categories are entity-level — they answer whether a resource is there and whether its metadata moved. A lexicon that implements observeResourcesDeep() adds a second, deeper comparison of the same three axes at property granularity, with a fourth axis subtracted: an accepted baseline.

Property categoryMeaning
changedSource and cloud both carry the path, with different values
undeclaredThe cloud carries a property source never declared — a console edit, an org-policy default, another tool’s field
absentSource declares a property the live resource is not carrying
acceptedA deviation somebody recorded in the baseline, at the value they accepted. Not drift, but reported, so what is being held back stays visible
heldThe property is declared heldElsewhere(). Never drift and never proposed for update, whatever the live value is. Reported with its holder and reason instead

undeclared is the interesting one and the reason the deep read exists. CloudFormation’s own drift detection compares only the properties it was told about, so a property nobody declared is invisible to it no matter who changed it.

A raw live model is mostly fields nobody declared and nobody changed, so both trees are normalized with the lexicon’s own rules before the comparison: server-populated fields dropped, set-like arrays (tags, policy statements) canonicalized, provider defaults subtracted where source is silent. On Kubernetes, where the API server records per-field ownership (metadata.managedFields), each reported drift also names the manager that owns the field live — “owned by hpa-controller” and “owned by kubectl-edit” are the same drift kind and very different answers. Ownership names the writer; it does not silence the line. A field another manager owns that source never declared is undeclared drift like any other (a kubectl label from the console is the canonical case). Only the metadata Kubernetes itself stamps on every object of a kind (deployment.kubernetes.io/revision, pod-template-hash, client-side apply’s last-applied-configuration) is subtracted statically, with status and the server-minted fields.

A field source declares and then hands over, like an HPA that takes spec.replicas after the first apply, belongs in the declaration itself. See heldElsewhere() below. The accepted baseline stays useful for a value someone blessed, and for an undeclared field with no marker of its own to attach one to.

Depth is opt-in per lexicon and changes nothing for the others. All five resource-observing lexicons implement it — see the coverage matrix for the mechanism each one reads through.

The accepted baseline — --update-baseline

Section titled “The accepted baseline — --update-baseline”

Some deviations are permanent facts of the account: a mandatory tag another team stamps on everything, a bucket setting an org policy flips on, a role an operator attached by hand that everyone agreed to keep. Without somewhere to record “yes, we know, leave it”, a deep diff is a report nobody reads twice.

chant lifecycle diff <env> --live --update-baseline records every property deviation that run reported as accepted, into <env>/observation-baseline.json on the same chant/lifecycle orphan branch the snapshots use. Accepting is an explicit act with a git commit behind it, and it is value-bound: what you accept is the live value at that moment, not the path. Accept VersioningConfiguration.Status = Enabled and a later flip to Suspended is drift again, reported with all three values (declared, live, accepted) so the reader sees what moved and from what.

Two boundaries keep the baseline honest:

  • An absent finding is never accepted. The cloud not carrying a declared property is something to fix in source or in the cloud, not a value to bless — only a value that is actually live can enter the baseline.
  • The baseline is not state. It never tells a deploy what to do and is never read on the write path; deleting it costs noise suppression and nothing else. Accepted deviations are also still reported, in their own section and count, so what is being held back stays visible.

heldElsewhere(), a field declared once, then handed over

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

The baseline above is value-bound, saying “this exact value is fine” so a later change away from it drifts again. That fits a value that should stay put once somebody blessed it: a mandatory tag, an org-policy default.

A field something else operates continuously is a different case. An autoscaler changes spec.replicas on its own schedule, so accepting today’s value into the baseline just means tomorrow’s scale-up drifts again, at a value nobody accepted yet. Terraform’s lifecycle { ignore_changes = [...] } avoids the re-accepting treadmill, but stays a bare list of strings. No reason attached, and silent forever regardless of who ends up touching the field.

heldElsewhere() is chant’s declaration for this case, a typed, per-property marker naming who holds the field and why, written where the property’s own value would go:

import { heldElsewhere } from "@intentius/chant";
export const web = new Deployment({
metadata: { name: "web" },
spec: {
replicas: heldElsewhere<number>({
by: "hpa",
reason: "the autoscaler owns replicas after the first apply",
}),
selector: { matchLabels: { app: "web" } },
template: { /* ... */ },
},
});

Chant checks it in exactly the position replicas: 3 would sit, against number on this property. The marker is generic over the property’s own type. Attaching it to a property that does not exist is a type error. At synth time the manifest omits replicas entirely. Every apply leaves the field unset. The API server defaults it once at creation, and the HPA owns it from there. A later chant lifecycle plan never proposes an update that would fight the autoscaler back.

From then on a difference on that path reports as held with its holder and reason. Both lifecycle diff --live’s HELD section and lifecycle plan’s carry it. So does --json output on either command:

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 flagged suspicious. Nothing read confirms the claimed hand-over ever happened. Maybe the holder never ran; maybe this is the wrong field.

The comparison worth drawing is to #2111’s chant-ignore comment. That comment suppresses a lint finding about source, a rule that would otherwise flag something wrong in the declaration itself. heldElsewhere() instead records a fact about who operates a field at runtime, true even when the declaration is entirely correct. A suppression-comment form was never the right shape for that fact, and the typed declaration is: a lexicon’s generated types check it there, and lifecycle plan can render it.

Reach for heldElsewhere() when a field is handed to another writer for good: an autoscaler, a controller, an operator’s deliberate knob. Reach for the accepted baseline when one specific value is fine and a different one should still alert.

A deep read pulls real property values, and some property names carry secrets. Any property whose name matches a narrow, key-name-based pattern list — password, secret, token, private key, credential, connection string — is masked to [REDACTED] during normalization, before the value can reach a diff line, a CI log, a snapshot, or the baseline. Both sides are masked with the same rule, so a secret-bearing property can still report (an undeclared one shows up as [REDACTED]), but its plaintext never does — a rotated secret does not leak into CI output.

The list is deliberately narrow. Broadening it to, say, anything matching key would mask an AWS tag’s Key field and every *KeyName reference, at which point the masking becomes its own drift signal. The same list backs the thin snapshot path, so the two paths cannot disagree about what counts as a secret.

An orphan is a resource in the cloud that source doesn’t know about. Detection is the first position on the dial; resolving it is the next. There are two moves:

  • Adopt it into source. Regenerate the resource as chant TypeScript with live import: chant import --from <env> --name <orphan>. The orphan stops being a surprise and starts being declared — the ReconcileOp workflow automates this as a PR.
  • Delete it. Only ever for a chant-owned orphan — one carrying the ownership marker. A foreign orphan (no marker) is never auto-deleted; it escalates to adopt-or-review. chant lifecycle plan classifies which is which, and ApplyOp deletes only the owned ones.

When a whole environment is done rather than one resource, chant lifecycle teardown <env> deletes everything carrying that environment’s marker in one marker-scoped pass — also available as the envTeardown Op step for a durable run behind a human gate.

There’s no “declared” axis, so the engine just compares now-vs-then:

CategoryIn last snapshotObserved nowMeaning
artifacts addedNewly created in the cloud since last snapshot
artifacts removedExisted at last snapshot, gone now
artifacts changedPresent in both, metadata changed
artifacts unchangedPresent in both, metadata identical

Same lexicon may emit both — for instance a future K8s lexicon could report Deployments as resources and in-cluster Pods (created by the Deployment, not by chant) as artifacts. lifecycle diff --live shows them in separate sections.

Drift detection is most valuable in environments where the gap between source and reality has a real chance of opening:

It helps when:

  • Multiple humans have cluster/cloud access. Someone scaled an ASG by hand to ride out an incident, didn’t get back to update source — drift detection catches the gap on the next snapshot.
  • Coordination across teams. Platform team owns the VPC, app teams own services. A platform-side change (subnet CIDR, tag policy) shows up as drift in app-team snapshots before it surfaces as a deploy failure.
  • Long-running infrastructure between change windows. Anything declared once and expected to stay put — IAM roles, Cloud DNS zones, KMS keys, service accounts. The longer the gap between intentional changes, the higher the chance of out-of-band ones.
  • Audit and incident timelines. Snapshots in git give you a forensic record: “the bucket policy was permissive on Tuesday morning, restrictive by Wednesday afternoon.” Useful at compliance review time.

It doesn’t help when:

  • Every deploy is a full teardown + redeploy. If the env is rebuilt from scratch each release, there’s no surface for drift to accumulate on.
  • Single operator, ephemeral envs. A solo developer’s dev cluster that’s destroyed nightly doesn’t need a drift cron.
  • Stateless apps with no persistent infra. If the only thing chant manages is an ECS task definition that’s redeployed on every CI run, drift detection adds noise without signal.
  • Tools outside chant own the resource. If a third-party operator owns and reconciles a CRD, observed drift will be the operator’s normal behavior, not a problem.

The pragmatic test: would you act on a drift signal if it fired right now? If yes, snapshot+diff is worth the cost. If no, skip it for that env.

chant’s observational model is a deliberate choice, not a missing feature. The costs:

  • No automatic remediation. Drift tells you something changed; it doesn’t snap the cloud back. That’s an agent or a human decision because the right answer is domain-specific.
  • It plans, but never applies. chant computes a precise create/update/delete change set against live — chant lifecycle plan reads ownership from the live marker, not the snapshot — but it stops at the artifact. Executing it is your tooling’s job: a CI job, an agent, or a ReconcileOp / ApplyOp. The diff is informational; the plan is actionable; neither mutates the cloud.
  • No locking. Two operators snapshotting the same env at the same time race on the orphan branch. The push uses --force-with-lease so the second writer fails fast rather than silently overwriting (see Concurrent snapshots).
  • Coverage is per-lexicon. A lexicon without describeResources() / listArtifacts() is warn-skipped. The Runtime observation coverage matrix lists where coverage exists today.

Read these trade-offs in context in How chant compares.