Skip to content

Watching Lifecycle

WatchOp turns chant lifecycle snapshot and chant lifecycle diff --live into an Op with a cadence on it. The result is a drift signal that fires between change windows rather than only at the next deploy.

This page is the tutorial home for that pattern. For the underlying CLI commands, see chant lifecycle; for the Op execution model, see Ops.

Drift detection in chant is built from three pieces that compose:

LayerCommandWhat it does
Snapshotchant lifecycle snapshot <env>Query each lexicon’s describeResources() / listArtifacts(), write the result to the chant/lifecycle orphan branch
Live diffchant lifecycle diff <env> --liveQuery the cloud right now, compare against the current build and the last snapshot, group results into seven categories (missing / orphan / disappeared / newly observed / drifted / unchanged / unobserved)
WatchingWatchOp({ env, schedule })Run the two commands above on a cron, and record drift as an outcome on each run

Snapshots without diffs are forensic record. Diffs without scheduling are ad-hoc. The composite is what makes drift a continuous signal rather than a manual chore.

Save anywhere in your project as a *.op.ts file. The composite returns { op }, and the cadence rides on the Op itself:

prod-watch.op.ts
import { WatchOp } from "@intentius/chant/op";
const { op } = WatchOp({
name: "prod-watch",
env: "prod",
schedule: "*/15 * * * *", // every 15 minutes
});
export default op; // discovered by `chant run prod-watch`

That’s the whole declaration. chant build writes dist/ops/prod-watch/op.json, the engine-neutral restatement of the two phases, and schedule lands on it as { cron: "*/15 * * * *", overlap: "skip" }.

Run it by hand whenever you want:

Terminal window
chant run prod-watch # one tick, right now

Three readers turn the cadence into something that fires on its own. The github generator (generateOpsPipeline) synthesizes a cron-triggered workflow that runs chant run prod-watch in CI. chant operator ticks it from a loop on a machine you already have. A fountain Steward that lists the op turns the cron into a Schedule on its teammate, in-thread, so a fire that lands mid-turn is dropped rather than opening a second machine beside the first.

The two phases run in order on each tick:

  1. Snapshot — calls the lifecycleSnapshot activity, which shells out to chant lifecycle snapshot prod. The result is committed to the chant/lifecycle orphan branch. (Concurrent runs are protected by --force-with-lease — see Concurrent snapshots.)
  2. Diff — calls lifecycleDiff with live: true. The activity returns { output, exitCode, drifted }. The drifted boolean is captured onto the run record via outcomeAttribute: { name: "Drift", from: "drifted" }.

Set live: false on the composite to use digest-only diff (faster, no cloud queries) for environments without describeResources() coverage. With live: true (the default) you get external-mutation detection. (See the Runtime observation coverage matrix for which lexicons report what.)

receipts: [myReceipt, ...] (typed EffectReceipt references) adds a read-only Receipts phase that reads each effect receipt through the receipt store and reports absent or differing receipts as findings, recorded as a StaleReceipts outcome — it never runs an effect and never writes a receipt.

WatchOp sets labels on the declaration:

labels: { OpName: "prod-watch", Watch: "true", Env: "prod" }

Those labels ride onto every run record the Op writes, and the Drift outcome lands beside them once the Diff phase settles. The record goes to prod/runs__prod-watch.jsonl on the chant/lifecycle branch, because Env is prod.

Terminal window
chant run log prod-watch # every run, newest first
chant run status prod-watch # the latest one
chant operator log --env prod # every tick in the environment, one timeline

Nothing has to be running to read any of that: the ledger is a branch in your own repo. When the op is hosted, its turn is also a message on the steward’s thread, so the same tick is legible in fountain’s UI as one command somebody could have typed. See The Steward for which store owns what.

To prove the pipeline end-to-end, introduce a real out-of-band mutation and watch it surface.

The simplest path is the Helm lexicon’s listArtifacts(), which lists Helm releases visible to your current kubeconfig context. Install something chant doesn’t know about:

Terminal window
helm install demo-drift bitnami/nginx --set image.tag=hotfix
chant lifecycle diff prod --live

Output (relevant section):

ARTIFACTS ADDED (1)
helm:demo-drift chart=nginx rev=1

lifecycleDiff’s drifted flag is true (the ARTIFACTS ADDED header matches the drift detector). The next tick’s run record carries Drift = "true". Clean up:

Terminal window
helm uninstall demo-drift

The next tick should show Drift = "false" again.

outcomeAttribute is a generic Op feature, not a WatchOp-specific one — see Outcome attributes for the full contract. The composite wires it like this:

phase("Diff", [
{
kind: "activity",
fn: "lifecycleDiff",
args: { env: "prod", live: true },
outcomeAttribute: { name: "Drift", from: "drifted" },
},
]),

The run reads drifted off the activity’s return value and folds it into the record’s outcomes as Drift, so chant run prod-watch --json prints it and every later reader of the ledger sees the same field.

from is a dot-path into the activity’s return value. With lifecycleDiff’s shape { output, exitCode, drifted }, you could just as easily capture the exit code (from: "exitCode") or the raw output (omit from to stringify the whole result). The composite picks drifted because it’s the field most worth filtering on.

Cron expressions follow standard 5-field syntax. A few starting points:

CadenceCronWhen to use
Every 15 min*/15 * * * *Fast feedback on prod, low API load — the default we recommend
Hourly0 * * * *Lower-traffic envs, expensive describeResources()
Nightly0 3 * * *Compliance-style audit, no expectation of human follow-up between ticks

Bias toward less frequent. Each tick re-queries every covered lexicon’s API; rate limits and per-call cost are real concerns at minute-level frequencies for large estates.

lifecycleSnapshot and lifecycleDiff are activities — they pick up the same retry/timeout profiles as any other Op step. Both default to fastIdempotent (5 min timeout, 3 retries) which is right for most environments.

If your snapshot regularly takes longer than five minutes (large multi-region estates, slow kubectl against many resources), declare the activity with profile: "longInfra" directly instead of using the composite — WatchOp is a thin wrapper, you can always emit the equivalent Op({ phases: [...] }) by hand.

The signal tells you something changed. The output tells you what. The next move depends on which category fired:

  • MISSING — declared in source, gone from the cloud. Usually a deletion or stack-rollback. Re-apply your stack or update source if the deletion was intentional.
  • ORPHAN — present in the cloud, not in source. Either a teammate created it manually (move it into source) or it’s untracked tooling output (use chant import to bring it in, or scope it out of the env).
  • DRIFTED — observed in both snapshots; attributes changed. The deltas are listed inline. Decide whether to re-apply (snap back to declared) or update source (accept the new shape). A property-level drift that fires every tick because something else legitimately operates that field, an autoscaler on replicas, say, or a controller stamping an annotation on every sync, is worth declaring heldElsewhere() rather than re-triaging on every run. It stops appearing as drift, and shows up in the plan’s HELD section with who holds it and why.
  • DISAPPEARED / NEWLY OBSERVED — historical context across snapshots. Useful for incident timelines, less actionable per-tick.
  • ARTIFACTS ADDED / REMOVED / CHANGED — context-keyed (Helm releases, Docker containers). Same triage as the resource categories.

Drift remediation is intentionally not a chant primitive — what to do about a drifted security group is a domain decision that doesn’t generalize. The WatchOp pattern stops at “tell me when it happened.”