A change says what happens to the real object and, separately, what happens to the tool's record of it
Decision record 0007
Amended by 0046:
changedKeysandreplaceKeyshold property paths as the tool reports them (values.controller.image.tag), not top-level names. Still names, list indexes and map keys, never values.Amended by 0052: a change has one optional field more,
values, with the old and new value at the changed paths thatdashboard.showValueslists, as display text. The diff hash leaves it out.Amended by 0053: the table from OpenTofu’s plan actions to
opandtracking, settled from recordings of v1.11.0 and v1.12.6, is in that record.Amended by 0055: the drift check’s changes use two ops,
update(a property changed outside the code) anddelete(the object is gone), and live inDiff.drift, never inchanges.
The brief’s diff has one op with four values. Both tools also emit steps that leave the real object alone and only change what the tool tracks: adopting an existing object, letting go of one that survives, renaming one in state. OpenTofu can combine these with a real change in one step, such as import and update. A flat list of seven ops was rejected because it cannot say that without inventing combined values, and because a “forget” sitting next to “delete” in one list invites a destroy warning on something that is not destroyed.
So every change carries two fields. op is what a deploy does to the real object. tracking is optional and is what it does to the tool’s record of the object.
type Op = "create" | "update" | "replace" | "delete" | "none";type Tracking = "import" | "forget" | "move";
interface Change { address: string; // opaque, adapter-defined, unique within the diff type: string; // for display, supplied by the adapter name: string; // for display, supplied by the adapter op: Op; tracking?: Tracking; previousAddress?: string; // only with tracking "move" changedKeys: string[]; // top-level property names, never values replaceKeys: string[]; // the changed keys that forced a replace}
interface Diff { stackId: string; changes: Change[];}Consequences
- A change has an
opother thannone, or atrackingvalue, or both. A pure import isnonewithimport. OpenTofu’s import and update isupdatewithimport. Its forget and create iscreatewithforget, with no special case. - Every warning about destroyed things reads
opalone:replaceanddelete. A tracking change can never raise one and can never hide one. addressreplaces the brief’surn. Core sorts, hashes and compares it and never looks inside. What a person sees istypeandname, which the adapter supplies. For Pulumi these are the type token and the logical name, so a URN never reaches the dashboard. Core never builds display text by parsing an address.- Changed keys are top-level property names. This is the only depth both Pulumi sources can give:
detailedDiffhas paths but is null on create, replace and delete, whilediffReasonsandreplaceReasonshave top-level names only. The Pulumi adapter takes the first segment of eachdetailedDiffpath when it is there anddiffReasonsotherwise, so a key means the same thing on an update and on a replace. The cost is that one changed env var and all of them both read asenvironment. - Creates and deletes list no keys. Address and op are the whole signal.
- A replace carries
changedKeysandreplaceKeys, from Pulumi’sreplaceReasonsor OpenTofu’sreplace_paths, so the row can say what forced it.replaceKeysis empty when the tool gives no reason. - All folding is the adapter’s job. It drops steps that change nothing (
same,no-op, data source reads, refresh steps) and folds both replace orders intoreplace. A step it does not recognise fails that stack’s preview and gives a preview failure row. It is never rendered as in sync. - Row counts such as
+2 ~1 -0come fromoponly. Changes that have only a tracking change get their own count, so they stay visible. - The summary counts and the rendered text from the brief leave the type. Both are derived from
changesby the core (see 0002). - Not decided here: how to show a change that touches only a stack’s outputs. The address is opaque and may name something that is not a resource, so either answer fits this shape. Decided in 0036: not shown in v1, and the shape it would take later is fixed there.
The Pulumi table, settled from the recordings
The build plan left the table from Pulumi’s step ops to op and tracking to the build. Slice 1.5 settled it from what CLI v3.229.0 and v3.263.0 printed for every scenario of the example project.
| Step op | Becomes |
|---|---|
same |
dropped |
read, refresh |
dropped. No recording holds one. They are here because this record names them as steps that change nothing |
create |
create |
update |
update |
replace |
replace. The tool prints one step for either replace order, because the adapter never passes --show-replacement-steps |
delete |
delete, or none with forget when the old state of the resource says retainOnDelete |
import |
none with import |
| anything else | the preview fails with the reason “the tool reported a step Sluiceway does not know” |
What the recordings showed that this record did not expect:
- The Pulumi adapter never gives
move. A resource renamed with an alias has no step in the document at all, so such a stack is in sync (docs/later.md).moveandpreviousAddressstay in the shape for OpenTofu’smovedblocks. - The tool calls a forget a
delete. OnlyretainOnDeleteon the old state tells the two apart, so that one flag is read from the state. It is an option of the resource, not a property value (0021). - A replace of a resource with
retainOnDeletestays areplace. No recording holds one, and a destroy warning too many is the safe side. - On a replace the tool’s
diffReasonsalso names computed properties that will differ, such as the hashes of a file’s content and itsid. They are listed as changed keys, as the rule above says.replaceKeysholds what forced the replace, and every replace key is also a changed key. - Two changes at one address are refused as output that cannot be read. Steps that are dropped do not count.
Research: