Skip to content

Change-Set Document

A run that plans several members gets one change set (#3181). Each planner’s output becomes the same typed document: chant’s lifecycle plan, terraform, tofu and choudoufu plans, and a warden’s reconcile plan. A reader asks one question of all of them, and the run’s gate approves the document as a whole.

The document is part of the workspace read contract, version 1. Its schema ships in @intentius/chant at src/workspace/change-set.schema.json, exported as @intentius/chant/workspace/change-set.schema.json, with the $id https://intentius.io/chant/schemas/workspace/change-set/v1/change-set.schema.json. Within the version, fields are only added.

FieldHolds
$schemathe schema’s $id
contract1
chantthe chant that composed it, when known
digestwhat a gate over the whole run binds, described below
membersone per member, sorted by name
entriesone per resource change, sorted by member, address, deposed key and action
sideEffectsthe Terraform actions an apply runs, { member, address, type, trigger, event }; absent when none
summarycounts and the named deletes and replacements
FieldHolds
memberthe workspace member, or the root a choudoufu set plan names
lexiconthe lexicon its entries belong to
plannerterraform, tofu, choudoufu, chant or warden
scopethe estate, environment or org it plans into
statusplanned, or failed with error
planDigestwhat a gate on this member alone binds, or null when it failed
nativeDigestthe planner’s own hash of the same plan, such as choudoufu’s per-root value
holeswhat the planner could not read, each { address, type, reason }
provisionaltrue for a preview planned before what it reads applied, such as a Terragrunt dependent planned on mock_outputs; absent otherwise

A member with holes has an incomplete plan. Its entries say nothing about the addresses listed there.

A provisional member is left out of the document’s digest, so approving the document never approves it. A wave’s set digest refuses it, and the grouped summary never puts it in a group with real plans.

FieldHolds
member, lexicon, planneras on the member
addressthe planner’s address. With deposed, it is unique within the member
typethe resource type
name, index, modulethe parts of a terraform address, so a reader need not parse it
deposeda deposed object’s key
idthe provider’s id, when the planner reports one
actioncreate, update, replace, delete, read, no-op or forget
importingtrue when an import block also brings the object into state; action is what else the plan does to it
disruptionon update, replace and delete: in-place, rolling, replace, destroy or unknown
region, scopewhere the change lands
attributesthe top-level attributes the change writes

Each attribute is { path, before, after }. unknown: true replaces after when the value is known only after apply. sensitive: true replaces both values when the planner marked either side sensitive. forcesReplacement: true marks the attributes that force a replace. Redaction follows the planner’s marks. A value the planner leaves unmarked is carried as the plan JSON carries it.

FieldHolds
members, entriesthe counts
actionsevery action with its count, zero included
typesper resource type, the count of each action it has
byMemberper member, the count of each action it has
deletes, replacementsevery delete and every replace, each { member, address, type, deposed, disruption }
failedthe members that failed to plan
holesthe number of holes across members

A reviewer reads a large document through chant change-set summary, which groups the members that take the same change and names every destroy.

Each member carries the value a gate on that member alone binds (#2300).

PlannerMember planDigest
terraform, tofu, choudoufuterraformPlanDigest over the show -json plan, as TerraformApplyOp binds
chantcomputePlanDigest("lifecycle-plan", plan) over the lifecycle plan --json document
wardencomputePlanDigest("reconcile-plan", …) over each change set’s org and entries, sorted

The set digest is computePlanDigest("change-set", pairs), where pairs is { member, planDigest } for every member that is not provisional, sorted by member. changeSetDigest(members) computes it. A wave of gated waves is a subset of members, and the wave’s gate value is the set digest over that subset.

The document’s digest is computePlanDigest("change-set-document", { set, members, entries, sideEffects }). It covers the set digest and the entries and side effects. For each member it also covers the member name, status and holes. Provisional members and their entries are left out. changeSetDocumentDigest(doc) computes it. Members are sorted by name, entries and side effects as the document sorts them, so order makes no difference. The digest has the jcs1-sha256 prefix plans carry since #2547. It changes when any member’s planDigest changes. A changed entry, hole or side effect changes it too. A set that names a member twice is refused.

An approval of the document therefore binds what the document says about each plan, so a resumed pull-request apply checks a re-planned member against entries the approval covers (#3555). The summary is computed from the entries and is not hashed. A change to how chant projects a plan into entries moves the digest, so an approval given under one chant release can need giving again under the next. verifyChangeSetDigest(doc) recomputes the value from the document.

PlannerAdapterReads
terraform, tofu, choudoufuterraformChangeSetPart in @intentius/chant-lexicon-terraform/change-setshow -json of a saved plan
choudoufu set planchoudoufuSetPlanParts in the same modulechoudoufu live-plan-set -json, one part per root
chantlifecyclePlanPart in @intentius/chant/change-setchant lifecycle plan <env> --json
wardenreconcilePlanPart in the same modulethe reconcile ChangeSet from @intentius/chant/reconcile, or an array of them; github-warden reconcile --plan-json <file> writes that array

Each adapter returns a part: { member, entries }. composeChangeSet(parts) joins them. It refuses two parts for one member.

The chant adapter maps create, update, delete and noop across. An update whose lexicon classifies it replace or destroy becomes a replace. An effect entry becomes the receipt write its effect step makes: a create when the receipt is absent, else an update. An unobserved entry becomes a hole. adopt and runtime entries propose nothing and are left out.

A terraform plan that stock marks errored is a failed member that keeps its entries. A choudoufu set plan root that did not plan is a failed member with no entries and no plan digest.

A combined run plans each member in its own step, composes the parts and gates on the digest.

import { Op, phase, gate, composeChangeSet, lifecyclePlanChangeSet, readChangeSetPart } from "@intentius/chant/op";
import { terraformPlan, terraformApply } from "@intentius/chant-lexicon-terraform";
const estate = terraformPlan("estate", { id: "estate" });
const delivery = lifecyclePlanChangeSet({ member: "delivery", env: "prod", cwd: "delivery", id: "delivery" });
const warden = readChangeSetPart({ member: "warden", planner: "warden", file: "warden.plan.json", id: "warden" });
const changeSet = composeChangeSet({ parts: [estate.out.changeSet, delivery.out.part, warden.out.part], id: "change-set" });
export default Op({
name: "release",
overview: "Plan every member, approve one change set, apply.",
phases: [
phase("Plan", [estate, delivery, warden, changeSet]),
phase("Approve", [gate("approve-release", { plan: changeSet.out.digest })]),
phase("Apply", [terraformApply("estate", { planFile: estate.out.planFile })]),
],
});
ActivityReturns
terraformPlanchangeSet, the root’s part, beside planDigest. The root’s name is the member
lifecyclePlanChangeSetpart, from chant lifecycle plan <env> --json in cwd
readChangeSetPartpart, from a plan file a chant or warden run wrote
composeChangeSetdocument, digest and summary

All four only read. Approve with chant approve release approve-release --plan <digest>. When a re-run composes a document with another value, the run stops at the gate and names both values.

@intentius/chant/change-set and @intentius/chant-lexicon-terraform/change-set import chant’s plan hashing code and nothing else. Bundled with esbuild for node, the two come to about 19 KB. change-set-bundle.test.ts lists every module in that bundle and fails when TypeScript or chant’s lint code joins it. A tool that runs them in CI from one bundled file, such as terragucci, needs no TypeScript toolchain.