Summary
When a resource is replaced, OpenTofu also replaces any dependent whose ForceNew input reads one of the replaced resource's computed attributes — the decision is made at plan time, while the attribute is unknown. pulumi-hcl makes the decision at registration time, after the attribute has resolved to a concrete value. When the provider recomputes the same value (stable ARNs, deterministic names, constant tokens), the dependent's diff is empty and pulumi-hcl leaves it in place, while OpenTofu replaces it.
Found by #413, which carries a failing tfcompat test (TestL2CbdDependentReplaceOrder).
Reproduction
resource "anchor" "a" {
knob = "v1" # ForceNew input; edited to "v2" to force a replace
# token: computed output — provider returns the constant "tok" on every create
lifecycle { create_before_destroy = true }
}
resource "child" "b" {
parent = anchor.a.token # ForceNew input reading a's computed attribute
# no lifecycle → default delete-before-create
}
Apply once, then change knob to "v2" and apply again.
OpenTofu (plan on unknowns): a is replaced, so planned a.token is (known after apply); an unknown value in b's ForceNew parent forces b's replacement. Apply order: destroy(b_old) → create(a_new) → create(b_new) → destroy(a_old).
pulumi-hcl (diff on concrete values): a's replacement completes before b registers, so b.parent diffs "tok" vs "tok" — no change, b is untouched. Final state diverges: OpenTofu replaced b, pulumi-hcl did not.
Why it happens
The Pulumi engine already implements OpenTofu's plan semantics for the delete-before-replace branch: calculateDependentReplacements walks the old snapshot, substitutes unknowns into each dependent's properties that depend on the condemned resource, and asks the provider's Diff whether that forces replacement — then eagerly deletes the condemned dependents. Dropping create_before_destroy from a in the program above produces identical behavior to OpenTofu.
The create-before-delete branch (which every create_before_destroy resource takes, and which is pulumi-hcl's mapping for lifecycle { create_before_destroy = true }) has no dependent handling at all. That is the hole.
What a full fix looks like (engine-side, pulumi/pulumi)
-
Replacement-set parity. In the create-before-delete replace branch, run the same calculateDependentReplacements computation, but record the condemned dependents in a forced-replacement set instead of deleting them. When a dependent later registers with an unchanged value, force the replace; its own deleteBeforeReplace goal orders its delete/create. This fixes the final-state divergence. Because Pulumi's value-based diffing is intentional for the other SDKs, this likely ships gated behind a per-resource opt-in flag that pulumi-hcl sets on every resource.
-
Operation-order parity. OpenTofu deterministically destroys a delete-before-create dependent before creating the dependency's replacement (DestroyEdgeTransformer connects the dependency's create to the dependent's destroy). The engine cannot do this today because the dependent's deleteBeforeReplace preference is a goal option, not persisted in state, and the dependent has not registered yet when the root's steps are generated. Fix: persist deleteBeforeReplace in the checkpoint (precedent: ReplaceWith), then eagerly emit the dependent's DeleteReplacement before the root's CreateReplacement. Without this piece, final states match but the interleaving differs (the old dependent briefly coexists with the new dependency).
A langhost-side stopgap was prototyped on the PR branch (mark computed outputs, translate schema WillReplaceOnChanges consumption into ReplaceWith) but rejected: it re-implements the provider's replace decision approximately in the language runtime (top-level properties only; misses nested ForceNew fields, Optional+Computed attributes left unset in config, and chains through input echoes).
Impact
Bites only when all of these hold: a resource is replaced, a dependent reads one of its computed attributes into a ForceNew input, and the recomputed value is identical to the old one. The dependent then silently keeps running against the new upstream instance where OpenTofu would have recreated it. Deferred until this shows up in a real migration.
Summary
When a resource is replaced, OpenTofu also replaces any dependent whose ForceNew input reads one of the replaced resource's computed attributes — the decision is made at plan time, while the attribute is unknown. pulumi-hcl makes the decision at registration time, after the attribute has resolved to a concrete value. When the provider recomputes the same value (stable ARNs, deterministic names, constant tokens), the dependent's diff is empty and pulumi-hcl leaves it in place, while OpenTofu replaces it.
Found by #413, which carries a failing tfcompat test (
TestL2CbdDependentReplaceOrder).Reproduction
Apply once, then change
knobto"v2"and apply again.OpenTofu (plan on unknowns):
ais replaced, so planneda.tokenis(known after apply); an unknown value inb's ForceNewparentforcesb's replacement. Apply order:destroy(b_old) → create(a_new) → create(b_new) → destroy(a_old).pulumi-hcl (diff on concrete values):
a's replacement completes beforebregisters, sob.parentdiffs"tok"vs"tok"— no change,bis untouched. Final state diverges: OpenTofu replacedb, pulumi-hcl did not.Why it happens
The Pulumi engine already implements OpenTofu's plan semantics for the delete-before-replace branch:
calculateDependentReplacementswalks the old snapshot, substitutes unknowns into each dependent's properties that depend on the condemned resource, and asks the provider'sDiffwhether that forces replacement — then eagerly deletes the condemned dependents. Droppingcreate_before_destroyfromain the program above produces identical behavior to OpenTofu.The create-before-delete branch (which every
create_before_destroyresource takes, and which is pulumi-hcl's mapping forlifecycle { create_before_destroy = true }) has no dependent handling at all. That is the hole.What a full fix looks like (engine-side, pulumi/pulumi)
Replacement-set parity. In the create-before-delete replace branch, run the same
calculateDependentReplacementscomputation, but record the condemned dependents in a forced-replacement set instead of deleting them. When a dependent later registers with an unchanged value, force the replace; its owndeleteBeforeReplacegoal orders its delete/create. This fixes the final-state divergence. Because Pulumi's value-based diffing is intentional for the other SDKs, this likely ships gated behind a per-resource opt-in flag that pulumi-hcl sets on every resource.Operation-order parity. OpenTofu deterministically destroys a delete-before-create dependent before creating the dependency's replacement (
DestroyEdgeTransformerconnects the dependency's create to the dependent's destroy). The engine cannot do this today because the dependent'sdeleteBeforeReplacepreference is a goal option, not persisted in state, and the dependent has not registered yet when the root's steps are generated. Fix: persistdeleteBeforeReplacein the checkpoint (precedent:ReplaceWith), then eagerly emit the dependent'sDeleteReplacementbefore the root'sCreateReplacement. Without this piece, final states match but the interleaving differs (the old dependent briefly coexists with the new dependency).A langhost-side stopgap was prototyped on the PR branch (mark computed outputs, translate schema
WillReplaceOnChangesconsumption intoReplaceWith) but rejected: it re-implements the provider's replace decision approximately in the language runtime (top-level properties only; misses nested ForceNew fields, Optional+Computed attributes left unset in config, and chains through input echoes).Impact
Bites only when all of these hold: a resource is replaced, a dependent reads one of its computed attributes into a ForceNew input, and the recomputed value is identical to the old one. The dependent then silently keeps running against the new upstream instance where OpenTofu would have recreated it. Deferred until this shows up in a real migration.