Skip to content

Effect Receipts

llms.txt lists every page for an agent

Some operations leave nothing behind. A database migration, a cache purge, a one-shot bootstrap call — each changes the world, and nothing in the live system records that it happened. There is no resource to observe, no marker to read back, no attribute to diff.

chant Ops run exactly these effects today: shell steps, migrations, pipeline triggers. A run’s own ledger record remembers them within a run, but nothing remembered across runs. The release ledger records what deployed but never gates anything. So before receipts, every Op run re-ran every effect, or teams hand-rolled guards in scripts, and nothing at plan time could say “this apply will fire db-migration.”

An effect receipt records that the effect ran. It is a declared resource — discovered, diffed, and observed like any other — whose stored value is the proof that one effect ran. The memory lives in the live system, not in chant.

A receipt is declared with the EffectReceipt factory:

import { EffectReceipt } from "@intentius/chant";
export const dbMigration = EffectReceipt("dbMigration", {
effect: "db-migration",
flavor: "hash",
inputs: { schemaDir: "migrations/", version: "0042" },
});

Two flavors. Existence — the receipt’s presence is the witness; “will be created” means “will fire.” Hash — the witness is a digest of the inputs, so changed inputs re-propose the effect: bump version and the next plan shows the migration firing again.

The declaration is core, but the storage is per lexicon. A materialization row turns the declaration into a real resource in the estate, and two of them exist today.

LexiconMaterialized asAddressWhere the value lives
awsAWS::SSM::Parameter, plain String/chant-receipts/<stack>/<env>/<effect>the parameter’s Value
k8sK8s::Core::ConfigMapchant-receipt.<stack>.<env>.<effect>, in k8s.receipts.namespace (default when unset)data.expectation

Both addresses derive from the same ownership-block fields that stamp ownership markers, with the environment explicit, so receipt identity and resource identity come from one source. The separator differs only because the substrate’s naming does: an SSM path is /-separated and a ConfigMap name is a DNS subdomain, and in each case the separator is a character no segment may contain, so two different identities can never render the same address. A lexicon that cannot observe its receipt type reports staleness as unobserved, loudly, and never as a wrong answer.

One more store sits outside the table because nothing declares its receipts. The sql lexicon’s rebuild migration copies a ClickHouse table one partition at a time, and the partitions are only known when the run reads the table, so each partition’s receipt is written by the backfill itself, through the same ReceiptStore seam and in the same order (read, compare, copy, write last), to a chant_receipts.receipts table on the server the copy runs on (on a Replicated database, a replicated __chant_receipts table in that database, so every replica reads it). It lives beside the data it witnesses because the rebuild’s onFailure drops that data, and a receipt kept anywhere else would outlive it. The Postgres column migration does the same per batch of the primary key, in a __chant_receipts table in the migrated table’s schema, and goes one step further: each receipt commits in its batch’s own transaction, so a run killed at any point leaves a batch with its receipt or neither.

A project that uses only the sql lexicon has no receipt row to declare, so its own effect() steps keep their receipts the same way: rows of chant_receipts.receipts on the server the environment names, a ClickHouse database or a Postgres schema of that name (Data Batches with Receipts). The sql lexicon provides the receipt activities only as a fallback, so a project that also lists aws or k8s keeps them in that lexicon’s row.

A receipt is never in any apply set, which is exactly the shape a prune deletes. The k8s row therefore carries a chant.intentius.io/effect-receipt label beside the ownership marker, and delete: "owned-only" reports the receipt retained rather than sweeping it, the same treatment a generated-once Secret gets.

Staleness is then an ordinary observation: the live value differs from the expected one, or the receipt is absent. lifecycle plan renders that as an effect row — “effect will fire: db-migration” — the reviewable signal that an apply is about to run something, not just write something.

The receipt is written on success only, and last. The effect() step wraps its nested steps in a read-compare-run-write cycle: read the live receipt, skip the steps on a match, otherwise run them and write the receipt only after every one succeeded.

The ordering is the semantics. A crash between the effect and the write leaves the receipt stale, so the next plan proposes the same fire again and the next run re-runs the effect. That is deliberate: the failure mode is running an effect twice, never skipping one silently. Under-running does not happen. The price is that effects wrapped in effect() should tolerate a re-run — which a migration runner or an idempotent bootstrap already does.

Receipts and the release ledger are not the same thing

Section titled “Receipts and the release ledger are not the same thing”

They sit at opposite corners of one contrast. A receipt gates one effect and audits nothing — it exists so the next run knows whether to fire, and it says nothing about what deployed when. The release ledger records deploys and gates nothing — it exists so you can answer “what is running where,” and no run ever consults it before acting. Collapsing them would give you a record that both blocks execution and claims audit authority, which is a state file with extra steps. They stay distinct.

Where a receipt’s expected value is computed follows the Where Values Come From boundary exactly:

  • Static inputs hash at synthesis. A fully static receipt’s expectation — a sha256 over the canonical JSON of the effect name and its inputs — is stamped when the artifact is written. Same source, same digest.
  • Reference inputs resolve at plan and at run. An attr-ref or other intrinsic is kept in placeholder form at synthesis and resolved in the plan engine (which is already the impure layer and already reads live) and again in the effect step at execution. Synthesis resolves nothing: the core factory refuses to hash a receipt that still carries references, rather than hashing a placeholder and producing a digest that could never match.

A reference the plan cannot resolve yet is rendered as an effect row with an unresolved-input note — never a guessed digest.

Receipts are declared, diffed, and observed like any resource, but the generic apply path never writes one. The effect() step is the sole writer, on success, last. At serialization, receipts are split out of the apply-bound documents before any lexicon code runs — a receipt can never appear in a template an applier writes from, and no desired-vs-live prune can sweep one in. In the plan, a receipt is never a create, never an update, never a prune candidate — only an effect row or a clean noop.

The rule exists because any other writer breaks the semantics. If the generic apply “repaired” an absent receipt the way it creates any other declared-but-absent resource, it would record an effect that never ran — and the next run would skip the migration forever. Anything other than write-on-success-last silently converts at-least-once into never.

Four guards keep a receipt from becoming state:

  • Leaf rule (COR022) — nothing may reference a receipt’s attributes, const indirection included. The moment another resource’s plan depends on a receipt value, the receipt is authoritative state again.
  • Plain store (COR023) — a receipt never materializes into a Secret kind or a secret-capable variant (SSM SecureString is the canonical refusal). A hash is not a secret, and storing it as one invites treating it as one.
  • Secrets by pointer (COR024) — receipt inputs reference a secret’s name and version, never its value. No chant code path hashes or compares secret material; a rotation re-proposes the effect through an explicit version bump.
  • Baseline refusal — the accepted-deviation baseline refuses receipt paths outright. Blessing a stale migration receipt would silently skip a migration forever, so the acceptance aborts before anything is recorded.

A receipt is a readable parameter in your own account, not an entry in a tool’s database. With the aws row:

Terminal window
aws ssm get-parameter --name /chant-receipts/my-stack/prod/db-migration

Or with the k8s row:

Terminal window
kubectl get configmap chant-receipt.my-stack.prod.db-migration -o jsonpath='{.data.expectation}'

Read-only IAM, no chant binary, no export step. Delete chant tomorrow and every receipt is still there, still legible, still telling you which effects ran against which inputs. The same walk-away-zero property the rest of the lifecycle model holds.

From the epic (#1703), verbatim:

  • no chant receipt verbs, ever. A receipt is a plain declared resource, inspected and fixed like any other. Receipt-specific tooling is state operations under a new name.
  • receipts never enter a chant-internal store. Memory lives in the live system.
  • the ledger and the receipt stay distinct: the release ledger records what deployed and gates nothing, a receipt gates one effect and audits nothing.
  • the invariant extends rather than bends: a mutation reads ownership, and now staleness, only from live.
  • Where Values Come From — the determinism boundary the resolution split follows, and the secret-provenance taxonomy COR024 composes with
  • lifecycle plan — the plan actions, including the effect row and its reasons
  • Ops Reference — the effect() step builder, effects: "gated" on ApplyOp, and WatchOp staleness reporting
  • EffectReceipt and SecretDeclaration — the type reference for both declaration shapes
  • Observability — the release ledger receipts stay distinct from

chant