Skip to content

Plan Scenarios

llms.txt lists every page for an agent

Lint rules, post-synth checks and EVL answer “is this declaration well-formed.” None of them answers “what will this change do.” A composite refactor that was supposed to be plan-neutral can propose replacing a database, and nothing catches it until someone runs chant lifecycle plan and reads the output — or worse, until chant lifecycle apply runs it.

A plan scenario is a declared expectation about the resulting change set. CI checks it on every PR, with no cluster and no credentials.

A plan is a function of three inputs: declared source, the prior snapshot, and a live observation. Two of the three are already inert, versioned data chant controls — declared source is deterministic by construction (EVL), and snapshots are already stored as data on the chant/lifecycle orphan branch. The third — live observation — is the only impure one, and it arrives through one substitutable interface: describeResources(). Feed that interface a fixture instead of a cluster, and the whole plan becomes a pure function of data.

Plan scenarios run chant’s existing plan engine against recorded data instead of a live read.

import { Scenario, snapshot } from "@intentius/chant";
export const compositeIsNeutral = Scenario("extracting the WebApp composite is plan-neutral", {
given: snapshot("fixtures/prod-baseline.json"),
expect: { noop: true },
});
export const legacyBucketDrop = Scenario("dropping the legacy bucket deletes exactly one owned resource", {
given: snapshot("prod"),
expect: {
deletes: [{ name: "legacyBucket", ownership: "owned" }],
create: 0,
},
});

A Scenario is a Declarable, modeled on EffectReceipt and SecretDeclaration: discovery collects it and chant list shows it, but it carries no resource payload, so no serializer ever emits it. partitionByLexicon excludes it from every lexicon’s output the same way it excludes a secret declaration — a scenario is data core and the CLI read, never something a lexicon writes.

given: what stands in for live observation

Section titled “given: what stands in for live observation”

snapshot() takes one string and classifies it structurally, offline — no filesystem access, no git, at declaration time:

  • A string with a path separator or a .json suffix ("fixtures/prod-baseline.json") is a fixture file — a checked-in JSON file in the same shape chant lifecycle snapshot writes. Copy a snapshot out of the chant/lifecycle branch and commit it, and it’s a valid fixture.
  • Anything else ("prod") is an environment name — replayed from the last snapshot recorded for that environment on the chant/lifecycle branch, read the same way chant lifecycle plan reads its own prior snapshot.

Either way, chant scenario check (below) reads the fixture and substitutes it for observeLexicon’s live call. No plugin ever makes a network request; no credentials are ever asked for.

Every clause is independently optional, and clauses compose in one expect object — at least one is required:

  • noop: true — the plan proposes no create, update, or delete, and no declared effect receipt classifies as effect (a pending fire — see below). Probably the majority-value case on its own: refactors, lexicon upgrades, no-op deploys should all show noop, and a scenario turns “should” into “does, checked on every PR.”
  • create / update / delete: <number> — an exact count for that action. Omitted means unconstrained; 0 is a real assertion, not “don’t check this.”
  • deletes: [{ name, ownership }] — exactly these resources must be proposed for deletion, each with the stated ownership verdict, and no other delete may be proposed. This is arguably the single highest-value assertion in the vocabulary: “this plan deletes nothing foreign” — and it’s meaningful specifically because chant’s ownership marker is a live read, not a state-file record. A resource the fixture marks foreign is never classified as a delete by buildChangeSet in the first place (only adopt), so a deletes expectation against one fails loudly as “not proposed” — the safety property is structural, upstream of the scenario check.
  • unobserved: "refuse" | { allow: [names] } — how the scenario treats the change set’s unobserved rows. "refuse" fails on any hole; { allow: [...] } names the specific entities allowed to be unobserved (by entity name, not by UnobservedReason — a scenario is written against one fixture, and “this resource has no data in it” is a per-resource fact, not a class of reason to trust everywhere).
  • cost: { maxPerHour, currency, entity? } bounds one predicted figure in the fixture’s recorded prediction to a stated rate per hour. See below for which figure, and for why a fixture with no prediction, or a refused one, fails the clause rather than passing on nothing.

A scenario has exactly one fixture — not a before-and-after pair. chant lifecycle plan’s update action means drift since the last recorded snapshot (the live read now vs. the live read then), and a single-fixture scenario has no “then” to diff against. In practice this means update in a scenario’s expect is only ever meaningfully 0. That’s not a placeholder gap to be filled in later — it’s what a single-fixture model can express. create, delete, and ownership are fully expressive; drift since an earlier live read isn’t, because there is no earlier live read here, only the one fixture standing in for “now.”

A scenario can also bound what the change would cost to run, offline and with no engine in the loop, the same way it bounds what the change would do. The fixture carries the answer: a LifecycleSnapshot may hold a behaviour block, the whole BehaviourResult a lexicon’s predictBehaviour() returned for that estate, and expect.cost reads one figure out of it.

export const peakStaysUnder = Scenario("the checkout path stays under 12.50 USD an hour at peak", {
given: snapshot("fixtures/prod-baseline.json"),
expect: { cost: { maxPerHour: 12.5, currency: "USD" } },
});

The bound is a rate for one hypothetical hour at the fixture’s own stated traffic level, and a scenario has one fixture, so this bounds a single figure and never a delta. With entity named, the figure is that entity’s own rate. Without it the figure is the estate’s. That means the engine’s stated total when it states one, and chant’s own sum over every predicted entity when it does not. The verdict’s detail says which of the three it read, on a pass as well as a failure, and names every entity the engine declined, because a sum over an estate with declined entities is a sum over less than the estate.

currency is required and compared exactly. chant converts nothing, and a bound in USD says nothing about a figure in EUR.

Three fixtures fail the clause by name rather than passing on nothing. A fixture with no behaviour block fails because nothing was predicted. A fixture whose block is a refusal fails with that refusal’s own text in the detail, whichever of the five refusal causes it carries. A fixture whose named entity the engine declined fails with the decline’s reason. A bound cannot be checked against no figure, and a pass there would be the faked number the behaviour contract exists to prevent.

The block rides on the snapshot rather than in a fixture of its own for one reason: a scenario’s given is one stand-in for everything a live read would have answered. Putting the prediction in a second fixture would make a scenario choose between asserting the plan’s shape and asserting its rate, when the point of the clause is to assert both against one recorded state.

A declared EffectReceipt joins a scenario’s change set the same way it joins chant lifecycle plan’s: pulled out of the generic create/update/delete axis entirely and classified on its own terms — absent or stale proposes an effect row (“the fire is pending”), a live value matching the resolved expectation is a clean noop, and a receipt the fixture can’t answer for is unobserved, loudly, never guessed. chant scenario check builds that classification from the SAME fixture data that stands in for a live read everywhere else in this model:

  • Presence comes from the fixture’s resources — a receipt named there is “live,” the same as any other resource; a receipt confirmed absent (not in resources, not named in unobserved) reads as “looked, and it’s not there,” the same claim a live read makes.
  • Value comes off the live entry’s attributes.value (or attributes.Value, the same fallback the live path accepts) — the stored digest or existence marker a real receipt row would carry.
  • Reference inputs (an attr-ref into another declared entity’s attribute) resolve against the fixture’s OWN recorded resources, merged across every lexicon the fixture covers — exactly the pool chant lifecycle plan resolves against, just fixture data standing in for the live one. A reference the fixture doesn’t carry an answer for doesn’t fail the scenario outright: the receipt proposes its fire with an “unresolved input” note, the same non-guessing resolveReceiptExpectation does at plan time — never a digest hashed over a placeholder.

The two failure modes this closes: a receipt that hasn’t fired yet is not a create (there is nothing to create — a receipt is observe-only to the generic apply path), and a receipt present in the fixture with the WRONG value is not a clean noop either, even though a scenario has no observedThen to detect drift with — its live value simply doesn’t match what the receipt’s inputs resolve to. noop: true fails on either: an unfired or stale receipt classifies as effect, and effect is not “nothing,” even though it never joins the create/update/delete triad the deletes and exact-count clauses read.

Terminal window
chant scenario check

Builds the project, then for every declared Scenario: resolves given’s fixture data per lexicon, builds a ChangeSet exactly the way chant lifecycle plan does — substituting the fixture for observeLexicon’s live call — and evaluates expect against it. Fully offline: no plugin’s describeResources ever runs, no credentials are read.

A lexicon the fixture has no data for is never silently read as “nothing declared, all absent” — every entity it declares lands in the change set as unobserved, the same #1089 discipline the rest of the plan engine follows. A hole the fixture can’t fill is a hole, not a guess.

Output is per-scenario pass/fail, with a legible detail on every failing clause — what was expected, what the plan actually proposes, and the resource names for a deletes or unobserved failure:

extracting the WebApp composite is plan-neutral [compositeIsNeutral] — given fixtures/prod-baseline.json, env prod: FAIL
noop: expected noop (no create/update/delete/effect) but the plan proposes 1 create, 0 update, 0 delete, 0 effect — newDatabase (AWS::RDS::DBInstance) [create]

--json emits the same verdicts as structured data. The command exits nonzero if any scenario fails — the shape a CI gate needs.

A scenario is not a state file. It asserts against the SAME three-input plan model every other lifecycle feature here reads from — declared source, a recorded snapshot, live observation — with the third substituted for a checked-in fixture. Delete chant tomorrow and the fixture is still a plain JSON file, still legible, still telling you what was recorded.

It’s also not a full declarative diff engine. chant lifecycle plan’s thin change set doesn’t compare declared properties against live config either — that’s deep drift detection’s job, opt-in and separate. A scenario inherits the same shape and the same limit.

chant