Plan Scenarios
Everything chant validates before this feature is about shape. Lint rules, post-synth checks, EVL — all of it answers “is this declaration well-formed.” None of it answers “what will this change do.” A composite refactor that was supposed to be plan-neutral can quietly 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 closes that gap: a declared expectation about the resulting change set, checkable without a cluster, without credentials, in CI, on every PR.
The three inputs, and which one moves
Section titled “The three inputs, and which one moves”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.
That’s the property that makes this possible: not a new capability bolted onto chant’s plan engine, but the existing one run against recorded data instead of a live read.
Declaring a scenario
Section titled “Declaring a scenario”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
.jsonsuffix ("fixtures/prod-baseline.json") is a fixture file — a checked-in JSON file in the same shapechant lifecycle snapshotwrites. Copy a snapshot out of thechant/lifecyclebranch 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 thechant/lifecyclebranch, read the same waychant lifecycle planreads 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.
expect: the assertion vocabulary
Section titled “expect: the assertion vocabulary”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 aseffect(a pending fire — see below). Probably the majority-value case on its own: refactors, lexicon upgrades, no-op deploys should all shownoop, and a scenario turns “should” into “does, checked on every PR.”create/update/delete:<number>— an exact count for that action. Omitted means unconstrained;0is 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 marksforeignis never classified as a delete bybuildChangeSetin the first place (onlyadopt), so adeletesexpectation 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 byUnobservedReason— 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).
The update count’s real limit
Section titled “The update count’s real limit”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.”
Effect receipts in a scenario
Section titled “Effect receipts in a scenario”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 inresources, not named inunobserved) reads as “looked, and it’s not there,” the same claim a live read makes. - Value comes off the live entry’s
attributes.value(orattributes.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 planresolves 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-guessingresolveReceiptExpectationdoes 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.
chant scenario check
Section titled “chant scenario check”chant scenario checkBuilds 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.
What this is not
Section titled “What this is not”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.
See also
Section titled “See also”- Drift Detection — the change-set model, the unobserved gate, and the thin-vs-deep diff distinction a scenario inherits
lifecycle plan— the live command a scenario mirrors offlinescenario check— the CLI reference- Effect Receipts and Core Type System — the other non-resource Declarables
Scenariois modeled on