A Terraform refactor can produce the most comforting plan imaginable: no resources to add, none to destroy, and a neat message saying an object “has moved.” That is valuable evidence, but it answers only part of the operational question.
The acceptance question is stronger:
Did Terraform merely change which address represents the existing object, or did the real object disappear and get replaced at some point during the change?
HashiCorp documents the core mechanism clearly. A moved block tells Terraform that an object formerly associated with one address should now be associated with another. Before producing the plan, Terraform checks state for the old address and treats the object as belonging to the destination address, avoiding the normal interpretation that an absent old configuration means destroy and a new address means create.
That documented behavior establishes Terraform's intended state transition. It does not, by itself, constitute historical evidence that a particular real-world object retained its identity through a particular run.
This distinction becomes more important for a Terraform cross-type state move. Resource types have different schemas, so moving state between types works only where the provider explicitly supports the transformation. Terraform's Plugin Framework supports provider-defined cross-type state moves for Terraform 1.8 and later; unsupported conversions produce an error rather than silently treating arbitrary resource schemas as interchangeable.
The proof model in this article therefore uses three independent layers: Terraform's planned action, Terraform's persisted state association, and an identity record outside Terraform state. The proposed synthetic provider also writes an append-only operation log. That fourth artifact turns an endpoint comparison into evidence about what occurred between the endpoints.
The objective is not another Terraform primer. It is an evidence procedure for an address migration whose acceptance criterion is identity continuity.
Scope and acceptance target
The scope is deliberately narrow: one disposable object already managed by Terraform, one or more address changes, and a requirement to demonstrate that the state refactor preserved the same provider-side object.
Without a moved block or another explicit state-migration mechanism, changing a resource address normally looks to Terraform like the old object should be destroyed and a new object created. HashiCorp recommends declarative moved blocks for configuration refactoring, while terraform state mv remains an imperative state-manipulation mechanism for situations where declarative configuration is not the appropriate vehicle.
This article intentionally does not repeat general IaC concepts, Terraform-versus-Ansible comparisons, generic module organization, state-backend setup, or CI/CD tool selection. Those subjects already belong in Refonte Learning's Terraform and Ansible IaC guide and its separate infrastructure-from-code discussion.
The acceptance target is stricter than “plan contains zero destroys.”
Evidence question | Required answer | Why it matters |
Did Terraform recognize an address migration? | Yes | Establishes that the refactor mechanism was actually engaged. |
Did the plan contain create, delete, or replacement actions for the object? | No | Rejects an explicit replacement plan. |
Did persisted state move from the old address to the intended destination? | Yes | Proves the Terraform-side association changed. |
Is the provider-side immutable identity unchanged? | Yes | Connects pre- and post-refactor state to the same external object. |
Did an external operation record show a new create or delete during the refactor window? | No | Detects transient replacement that endpoint snapshots alone could miss. |
Does a fresh post-apply plan converge with no unintended changes? | Yes | Demonstrates the new configuration/state pairing is stable. |
Was the evidence captured before rerunning a failure? | Yes | Prevents a later green run from overwriting the history being investigated. |
A pass therefore means more than “Terraform did not plan to destroy anything.” It means the address changed while the independently recorded object identity did not.
Documented behavior versus engineering inference
It is important to keep four classes of statement separate.
Documented behavior comes directly from Terraform or Plugin Framework documentation. Engineering inference is a conclusion drawn from those mechanics. A proposed experiment describes what this article recommends running. An actual observation describes something genuinely seen during execution.
There are no claimed lab observations in this article. The lab has not been executed for publication, so strings such as <OBJECT_ID> represent evidence that a future run must fill in, not results that were observed.
HashiCorp documents that a moved block changes a resource address and that Terraform uses the previous-state object under the destination address when planning. It also documents that each resource type has its own schema and that some providers can support moves from one managed resource type to another. A managed resource cannot simply be moved into a data resource.
For Plugin Framework providers, cross-type state movement is explicitly provider-controlled. The target resource implements state-move support, checks characteristics such as the source provider address, source resource type, and source schema version, and converts the old state into the target schema. When no suitable state mover exists, Terraform reports an error; error diagnostics from the state-move operation cause Terraform to retain the source resource state rather than accepting a failed conversion.
Statement | Classification | Strength |
moved associates a prior address with a new address during planning. | Documented behavior | High |
A normal address rename without migration can imply destroy/create. | Documented behavior | High |
Cross-type moves require provider support for converting incompatible resource schemas. | Documented behavior | High |
An unsupported cross-type conversion should be rejected instead of guessed. | Documented behavior | High |
A zero-create/zero-delete plan strongly indicates Terraform intends no replacement. | Engineering inference | Strong but incomplete |
The same external immutable ID before and after provides stronger continuity evidence than the plan alone. | Engineering inference | Strong |
An external create/delete audit record is needed when historical continuity matters. | Engineering inference | Strong |
The proposed fixture will preserve its identity through the supported move. | Proposed experiment | Unverified until run |
The intentionally unsupported move will leave all fixture evidence unchanged. | Proposed experiment | Must be verified during the run |
“The lab passed.” | Actual observation | Not claimed |
The central limitation follows directly from Terraform's own description of planning: terraform plan proposes actions; it does not execute them. HashiCorp also cautions that conditions may change between an earlier speculative plan and the operation that is finally applied.
Therefore:
A clean plan proves planned behavior. It does not prove the historical identity of the remote object after execution.
That conclusion is engineering inference, not a claim that Terraform's planner is unreliable. It simply recognizes that intent evidence and execution evidence answer different questions.
Lab fixture and version matrix
A useful fixture should make recreation observable without requiring a cloud account, credentials, billing, or a long-lived environment.
The proposed lab uses a tiny synthetic Terraform provider called refactor backed by a disposable directory outside Terraform state. The provider manages JSON objects under a temporary object-store/ directory. Terraform state knows the object's ID, but the object store is independently readable, making it the fixture's simulated provider-side control plane.
Creation assigns two immutable values:
id = provider-generated object identifier
birth_token = second immutable token generated only during Create
The store also records a creation timestamp and an append-only operation stream:
object-store/
├── objects/
│ └── <OBJECT_ID>.json
└── events.ndjson
A conceptual object looks like this:
{
"id": "<OBJECT_ID>",
"birth_token": "<BIRTH_TOKEN>",
"content": "identity-probe",
"created_at": "<CREATION_TIMESTAMP>"
}
The provider appends CREATE, UPDATE, and DELETE operations to events.ndjson. Its state-move implementation must perform only schema conversion; it must not append an infrastructure operation.
This makes accidental recreation conspicuous. A recreation should generate a different immutable identity and another CREATE; a delete/recreate sequence should additionally leave DELETE evidence.
For the supported cross-type test, the provider exposes:
Resource type | Role | Cross-type behavior |
refactor_record | Original type | Creates and reads the synthetic object. |
refactor_item | Supported destination | Implements provider state conversion from refactor_record. |
refactor_archive | Unsupported destination | Deliberately does not accept state from the other type. |
refactor_record stores the configured value as payload. refactor_item calls it content. That deliberate schema difference prevents the test from reducing to a type-label change: the provider must transform the old state into the target schema while carrying forward id and birth_token.
HashiCorp's Plugin Framework provides the ResourceWithMoveState/state-mover mechanism for this kind of target-side conversion. The provider is expected to validate which source provider, source type, and schema version it accepts rather than treating all source state as interchangeable.
Version and environment matrix
As of September 22, 2026, HashiCorp's installation page lists Terraform 1.16.3, released September 16, 2026, and the corresponding GitHub release is marked latest. The current Terraform Plugin Framework release is v1.19.0. Go's current featured stable download is 1.27.1.
The proposed fixture should pin those exact versions rather than relying on “latest” at execution time.
Component | Article pin | Evidence to retain |
Terraform CLI | 1.16.3 | terraform version |
Terraform Plugin Framework | v1.19.0 | go.mod, go.sum |
Go toolchain | go1.27.1 | go version |
Synthetic fixture provider | 0.1.0-lab proposed local build | source revision plus binary SHA-256 |
Terraform configuration | exact Git commit | commit SHA |
OS/kernel | Do not invent one | capture uname -a and /etc/os-release on the machine actually used |
Provider installation | local disposable binary | binary digest and Terraform provider selection evidence |
The proposed fixture provider version is a lab revision defined for this procedure, not a claim that a Registry package with that version exists.
At the beginning of a real run, capture:
mkdir -p evidence
terraform version > evidence/00-terraform-version.txt
go version > evidence/00-go-version.txt
uname -a > evidence/00-uname.txt
cat /etc/os-release > evidence/00-os-release.txt
Do not fill in those operating-system fields until the experiment is actually executed.
Baseline state and evidence model
The baseline must establish identity before touching the resource address.
Start with one disposable object:
resource "refactor_record" "original" {
payload = "identity-probe"
}
output "object_id" {
value = refactor_record.original.id
}
output "birth_token" {
value = refactor_record.original.birth_token
}
Apply this baseline, then capture evidence immediately. The critical rule is that the external identity record comes from the fixture's object store, not merely from another representation of Terraform state.
terraform state show can inspect an individual resource, and in Terraform 1.16 it supports JSON output suitable for evidence processing. terraform state pull retrieves the complete state, including remote state where applicable. HashiCorp notes that state pull upgrades output to a format understood by the locally installed Terraform, so that file must not be misused to infer which Terraform version last wrote the backend; capture terraform version separately.
The proposed evidence capture is:
terraform apply
BASE_ID="$(terraform output -raw object_id)"
BASE_TOKEN="$(terraform output -raw birth_token)"
terraform state list \
> evidence/01-state-list-before.txt
terraform state show -json refactor_record.original \
> evidence/01-resource-before.json
terraform state pull \
> evidence/01-state-before.json
cp "object-store/objects/${BASE_ID}.json" \
evidence/01-provider-object-before.json
cp object-store/events.ndjson \
evidence/01-provider-events-before.ndjson
Do not substitute an example UUID; it could misleadingly resemble an observed result.
The evidence model is:
Artifact | What it can prove | What it cannot prove alone |
Terraform state address | Which address currently represents the object | Whether the provider object was replaced historically |
State id | Identity value Terraform recorded | Whether that ID was independently present provider-side |
Provider-side object | Independent ID, birth token, creation metadata | Which Terraform address represented it |
Provider operation log | Create/delete/update history during the test | Terraform's planning intent |
Saved plan | Exact actions Terraform proposed | What eventually happened externally |
Post-apply state | Final association | Intermediate operations |
Final no-change plan | Configuration converged | Cleanup/history of an earlier failed attempt |
Identity continuity is established by combining these artifacts rather than elevating one of them into universal proof.
Normal-path validation
The normal path has two stages: an ordinary resource-address rename and a supported cross-type migration. The same external object should survive both.
First rename the resource while retaining its type:
resource "refactor_record" "current" {
payload = "identity-probe"
}
moved {
from = refactor_record.original
to = refactor_record.current
}
HashiCorp documents this as the canonical purpose of moved: instead of interpreting the old address as removed and the new one as a distinct object, Terraform uses the existing object at the destination address.
Save, inspect, and then apply the exact plan:
terraform plan -out=evidence/02-address-move.tfplan
terraform show -json evidence/02-address-move.tfplan \
> evidence/02-address-move.plan.json
terraform apply evidence/02-address-move.tfplan \
| tee evidence/03-address-move-apply.txt
Terraform's JSON plan format exposes previous_address when a resource's address changed, including changes caused by a moved block. It separately exposes the change.actions array, where possible actions include no-op, create, update, delete/create replacement combinations, and delete.
For a pure fixture rename, the expected, not observed, evidence is:
Check | Expected proposed result |
previous_address | refactor_record.original |
destination address | refactor_record.current |
create action | Absent |
delete action | Absent |
replacement sequence | Absent |
external id | Equal to BASE_ID |
external birth_token | Equal to BASE_TOKEN |
extra provider CREATE | Absent |
provider DELETE | Absent |
Next perform the supported Terraform cross-type state move:
resource "refactor_item" "current" {
content = "identity-probe"
}
moved {
from = refactor_record.original
to = refactor_record.current
}
moved {
from = refactor_record.current
to = refactor_item.current
}
The destination resource's MoveState implementation accepts only the expected source provider and refactor_record type, decodes its source state, then writes target state containing the same immutable id and birth_token, mapping payload to content.
Cross-type support is not automatic merely because the HCL parses. HashiCorp's module-refactoring documentation explicitly says resource types have different schemas and that providers must support suitable moves between types; the Plugin Framework gives providers a mechanism to implement that transformation on Terraform 1.8 and later.
Repeat the saved-plan workflow:
terraform plan -out=evidence/04-cross-type-move.tfplan
terraform show -json evidence/04-cross-type-move.tfplan \
> evidence/04-cross-type-move.plan.json
terraform apply evidence/04-cross-type-move.tfplan \
| tee evidence/05-cross-type-apply.txt
terraform state show -json refactor_item.current \
> evidence/06-resource-after.json
terraform state pull \
> evidence/06-state-after.json
Then retrieve the provider-side object again using the baseline ID:
cp "object-store/objects/${BASE_ID}.json" \
evidence/06-provider-object-after.json
cp object-store/events.ndjson \
evidence/06-provider-events-after.ndjson
Finally create a fresh convergence plan:
terraform plan -detailed-exitcode \
> evidence/07-convergence-plan.txt
HashiCorp documents -detailed-exitcode as 0 for a successful empty diff, 1 for error, and 2 for a successful plan containing changes.
A normal-path pass requires all evidence layers to agree. A zero-delete plan with a changed birth token fails. An unchanged birth token with the old state address still present fails. A clean final plan does not retroactively repair missing evidence from the migration window.
Boundary-condition experiment
A proof procedure is more credible when it demonstrates that an invalid move is rejected rather than merely showing the happy path.
Use a fresh disposable copy of the baseline fixture, with its own state and its own object-store directory. Do not reuse the already migrated normal-path instance; independent fixtures keep the evidence histories separable.
The incompatible destination is refactor_archive. It can manage objects normally, but it deliberately does not implement a state mover accepting refactor_record state.
Configure:
resource "refactor_archive" "current" {
content = "identity-probe"
}
moved {
from = refactor_record.original
to = refactor_archive.current
}
The important expectation comes from the provider protocol, not from guessing at a specific wording of an error message. For a cross-type transformation without applicable provider support, the operation should fail rather than have Terraform reinterpret arbitrary source bytes as the new resource schema.
Boundary assertion | Required evidence |
Planning succeeds | No: this is the deliberate failure |
Exit status | Non-zero |
Exact diagnostic wording | Preserve it, but do not make the test depend on cosmetic wording |
Source state association | Must still be recoverable as the source after failure |
External ID | Must still equal baseline |
Birth token | Must still equal baseline |
External create/delete | Neither should appear for the failed state conversion |
Failed plan/log | Must be retained before any retry |
Deliberately failing boundary test
Run the failure while preserving stdout, stderr, and the exit code:
set +e
terraform plan \
-out=evidence/10-incompatible.tfplan \
> evidence/10-incompatible.stdout.txt \
2> evidence/10-incompatible.stderr.txt
printf '%s\n' "$?" \
> evidence/10-incompatible.exitcode.txt
set -e
terraform state pull \
> evidence/10-state-after-failure.json
cp object-store/events.ndjson \
evidence/10-provider-events-after-failure.ndjson
Do not present an invented diagnostic such as “unsupported move from X to Y” as captured output. Terraform and framework versions can change diagnostic presentation. The acceptance criterion is semantic: planning is rejected and the evidence does not show a successful target-state transition.
Most importantly, do not replace the incompatible destination with the supported one, rerun until green, and use that green rerun as proof that the failed attempt was harmless.
A successful second run proves only that the system reached an acceptable state during the second run. It cannot tell you what happened during the interval covered by the first failed attempt. Logs may have been rotated; an object could theoretically have been created and then cleaned up; state could have been restored; or some unrelated actor could have changed the provider-side object.
That is why the failed run's state snapshot, external object snapshot, operation log, diagnostics, and timestamps must be retained before recovery begins.
Recovery and rollback behavior
Recovery starts from evidence, not from another apply.
For an incompatible state-move failure, first freeze the failed-run artifacts and determine which state address still owns the object. The Plugin Framework documents that errors during the provider state-move response prevent Terraform from accepting the target state and retain the source resource state. That is the expected recovery basis, but the actual failed-run state must still be inspected rather than assumed.
Before risky state operations, HashiCorp's state-refactoring guidance demonstrates pulling a state backup. Direct manipulation between state files is explicitly treated as riskier, and HashiCorp recommends safer declarative/import-style workflows where applicable rather than casual manual state surgery.
Situation | Recovery action | Acceptance requirement |
Unsupported cross-type plan fails before apply | Restore compatible configuration or add the correct provider-supported move | Original identity and state ownership confirmed |
Saved plan was never applied | Discard plan after archiving it | No claim that the migration occurred |
Apply failed | Stop and inspect current state plus external object | Never infer rollback from exit code alone |
Old address still owns object | Keep source configuration or correct migration declaration | No competing destination binding |
Destination state was committed | Verify external identity before removing compatibility code | Same immutable identity |
State evidence is ambiguous | Quarantine the rollout | Manual reconstruction before another write |
A reverse moved declaration can be reasoned about using the same address-association mechanism, but it should not be advertised as a universal transactional rollback facility. A refactor can coincide with schema upgrades, provider behavior, unrelated configuration changes, or external modifications. That makes “swap from and to” an engineering technique that requires a fresh plan and identity check, not a documented guarantee that every migration can be undone losslessly.
Historical moved blocks also have compatibility value. HashiCorp warns module authors that removing old move declarations can be a breaking change for consumers that upgrade across versions and have not yet traversed the migration.
Concurrency, lifecycle, or version interaction
Address refactoring should be isolated from unrelated lifecycle changes because otherwise the evidence becomes difficult to interpret.
Terraform automatically locks state for operations that can write it when the selected backend supports locking. HashiCorp explicitly discourages -lock=false, because concurrent writers can corrupt or race on state. Not every backend supports locking, so “Terraform uses locking” is conditional on backend capability.
A lock does not freeze the provider's external control plane. An administrator, another Terraform state, another automation system, or an API client can still modify the same real object while your migration is being reviewed.
Terraform also has lifecycle features that independently influence replacement behavior. create_before_destroy, prevent_destroy, replace_triggered_by, explicit -replace, and ordinary ForceNew-style provider behavior can all alter the action graph. HashiCorp documents that some configuration changes require destroy/recreate and that lifecycle rules change how Terraform constructs that graph.
Interaction | Refactor rule |
Concurrent Terraform writer | Block through supported state locking |
-lock=false | Reject for the proof run |
External console/API mutation | Freeze or monitor during the evidence window |
-refresh=false | Reject; it can hide external changes |
-replace=... | Reject unless replacement is intentionally being tested |
-target=... | Avoid for normal acceptance; it narrows Terraform's view |
Lifecycle-rule changes | Move to a separate change set |
Provider upgrade | Prefer separate rollout unless the type migration explicitly requires it |
Terraform upgrade | Pin and record the exact binary used to create and apply the evidence plan |
HashiCorp specifically warns that -refresh=false can make a plan incomplete or incorrect because Terraform skips its normal synchronization with remote objects. It also positions -target as an exceptional recovery tool rather than a routine workflow.
For pipeline lifecycle concerns beyond this narrow state-refactor proof, Refonte Learning maintains separate coverage of GitHub Actions runner lifecycle management and broader CI/CD tool practices. Keeping those topics separate avoids turning an identity-verification procedure into another generic pipeline guide.
Observability and evidence retention
A Terraform resource address is not an identity proof. It is a Terraform-side name for the object association.
Good evidence therefore follows the migration across boundaries:
old Terraform address
│
▼
old Terraform state ───────► provider-side immutable identity
│ │
│ moved/state conversion │ must remain constant
▼ ▼
new Terraform state ───────► same provider-side object
│
▼
fresh convergence plan
Terraform's plan JSON is particularly useful because previous_address exposes address movement and change.actions exposes the operation Terraform intends to perform. The JSON state representation also includes resource address, provider name, schema version, and resource-specific values such as id.
Those machine-readable artifacts should be retained alongside the external identity record, not instead of it.
Evidence that must survive a rerun
A rerun can make the current system healthy while destroying evidence about how it became healthy. Therefore the first-attempt artifacts need immutable or at least append-only retention before anyone retries.
Artifact | Before | Plan | Apply/failure | Convergence |
Terraform version | ✓ | |||
Provider binary/source digest | ✓ | |||
Configuration commit | ✓ | ✓ | ✓ | ✓ |
Complete state capture | ✓ | ✓ | ✓ | |
Per-resource JSON | ✓ | ✓ | ✓ | |
Saved binary plan | ✓ | |||
Plan JSON | ✓ | |||
Apply stdout/stderr | ✓ | |||
External object record | ✓ | ✓ where practical | ✓ | ✓ |
External operation/audit log | ✓ | ✓ | ✓ | ✓ |
Exit code | ✓ | ✓ | ✓ | |
Artifact SHA-256 manifest | ✓ | ✓ | ✓ | ✓ |
Saved plans and state evidence must be treated as potentially sensitive in real environments. HashiCorp warns that saved plan files can contain configuration and sensitive values in cleartext, and terraform show -json can expose sensitive state or plan values even when normal terminal presentation hides them.
The synthetic fixture deliberately contains no credentials or secrets. This preserves the structure of a production evidence procedure without exposing sensitive values.
A simple evidence manifest can be generated after each phase:
find evidence -type f -print0 \
| sort -z \
| xargs -0 sha256sum \
> evidence/SHA256SUMS
For broader automation-observability patterns, continuous measurement is covered separately in Refonte Learning's continuous performance testing article. Here, the observability target is much narrower: state association and object identity during a single Terraform refactor.
Negative tests and false confidence
The most dangerous evidence is not false evidence. It is valid evidence being asked to prove something it cannot prove.
Consider a post-refactor terraform plan that returns an empty diff. HashiCorp defines that result as meaning Terraform currently sees no required actions. It is excellent convergence evidence. It says nothing about whether an earlier failed attempt briefly deleted an object and later restored it.
Likewise, the destination address appearing in state proves that Terraform currently associates state with that address. It does not independently establish remote history.
Use deliberate negative controls:
Test | Result that must not be accepted as identity proof | Why |
Remove the moved block while renaming | Plan proposes destroy/create | Demonstrates what the migration mechanism is preventing |
Unsupported cross-type move | Plan fails | Demonstrates provider compatibility is required |
Run only terraform plan | Clean move output | Intent only; no post-run identity evidence |
Check only final state ID | Same ID | State is not an independent external witness |
Check only final external object | Same object exists | Says nothing about Terraform's state association |
Run plan -refresh=false | Appears clean | External divergence may be concealed |
Retry a failed migration until green | Green final run | Cannot reconstruct the failed attempt |
Change resource arguments while moving it | Update or replacement may appear | Refactor and semantic change become inseparable |
Add -replace accidentally | Replacement is planned | Refactor proof is invalid even with correct moved syntax |
One especially useful control is to temporarily remove the moved declaration from a copy of the configuration and create a speculative plan only. The expected contrast is that Terraform now interprets the old state object as lacking corresponding configuration and the destination configuration as requiring a new object. Do not apply this negative-control plan.
That contrast demonstrates why the moved block matters. It still does not prove the real migration succeeded; it validates the experiment's ability to detect the obvious replacement case.
Another false-confidence pattern is using prevent_destroy as the proof. Terraform documents prevent_destroy as a lifecycle protection that rejects certain plans containing destruction while the rule remains in configuration. It is useful protection, but it is not an object-identity audit log and does not substitute for provider-side verification.
Operational rollout and rollback criteria
For production, treat a moved-block refactor as a small migration with an explicit evidence gate rather than as a harmless source-code rename.
The safest rollout separates the address change from semantic resource changes. That makes every unexpected update, create, delete, or replacement action a reason to stop rather than something reviewers must mentally disentangle from legitimate functional work.
Use a saved plan for the accepted run. HashiCorp documents terraform plan -out=FILE specifically so that a generated plan can later be passed to terraform apply, rather than asking apply to construct a different automatic plan.
Phase | Continue when | Stop when |
Preflight | Exact CLI/provider/config versions captured | Version evidence missing |
Baseline | State and external identity agree | ID cannot be independently verified |
Plan | Move recognized; no prohibited actions | Create/delete/replacement appears |
Cross-type validation | Provider support for the source type is verified | Support is assumed only from HCL syntax |
Apply | Exact reviewed saved plan is used | Plan was regenerated unexpectedly |
Immediate verification | External ID and immutable identity marker match | Identity differs or object disappeared |
Audit verification | No refactor-window create/delete events | Any unexplained operation appears |
Convergence | Fresh plan returns expected empty diff | Further mutation is required |
Cleanup | Evidence archived and migration compatibility understood | Old workspaces may still need historical moves |
For reusable modules, do not rush to delete old moved blocks after the first successful workspace. HashiCorp describes removal of previously published move declarations as a potentially breaking change because users can upgrade from versions whose state still uses earlier addresses.
Rollback should likewise have a stop condition. When state, provider-side identity, and audit evidence disagree, another automated apply is not rollback; it is another write against an unresolved system.
Acceptance matrix
Acceptance should be mechanical enough that two reviewers looking at the same artifact bundle reach the same conclusion.
The strongest result uses independent evidence domains:
plan intent → state transition → provider identity → operation history → convergence
No single domain substitutes for all the others.
Pass criteria
Criterion | Evidence | Pass |
Baseline object exists | Provider-side object snapshot | Exact immutable ID captured |
Baseline state owns object | state show -json | State ID matches external ID |
Terraform recognized move | Plan JSON | previous_address matches source |
Destination is correct | Plan/state JSON | Address matches intended target |
No creation planned | change.actions | No create, including replacement sequence |
No deletion planned | change.actions | No delete, including replacement sequence |
Cross-type conversion is supported | Provider implementation/docs | Matching state mover exists |
Apply used reviewed artifact | Apply record | Saved plan applied |
Identity survived | Before/after provider snapshots | ID and birth token identical |
No hidden recreation | External operation log | No refactor-window DELETE or additional CREATE |
State migration persisted | Post-apply state | Old address absent; new address owns same ID |
Configuration converged | Fresh detailed-exit-code plan | Exit code 0 for intended no-change case |
Evidence history preserved | Artifact manifest | Failed and successful attempts remain distinguishable |
An ordinary address rename can pass without exercising provider cross-type conversion. A cross-type migration cannot pass merely because from and to are syntactically valid: the provider's state-move support is part of the acceptance chain.
Hold, refactor, or quarantine conditions
Finding | Decision | Reason |
Plan contains delete/create | Hold | The operation is replacement, not a pure identity-preserving move |
Plan has unexpected in-place update | Hold | Separate semantic change from address migration |
Cross-type support is undocumented and implementation cannot be verified | Refactor test approach | Do not infer schema compatibility |
Unsupported boundary test unexpectedly succeeds | Quarantine | Fixture/provider assumptions are wrong |
Immutable external ID changes | Quarantine | Identity continuity failed |
Birth token changes while ID appears identical | Quarantine | ID may have been recycled or fixture contract violated |
Extra create/delete appears in external audit | Quarantine | Possible transient recreation |
Failed-run artifacts were overwritten | Hold | Historical cleanup cannot now be proven from final green status |
Backend locking was disabled amid concurrent writers | Hold | State-transition provenance is uncertain |
-refresh=false was used | Repeat under controlled conditions | Plan may have ignored external changes |
Final plan still has unexplained changes | Hold | New state/configuration pair has not converged |
All evidence layers agree | Pass | Address changed while independently observed identity remained continuous |
This matrix intentionally distinguishes “hold” from “failed identity.” A missing artifact does not prove recreation occurred. It means the team lacks sufficient evidence to prove that it did not.
That distinction matters operationally. Absence of proof is an acceptance failure, not automatically proof of infrastructure replacement.
Common implementation mistakes
The most common mistake is treating Terraform's human-readable plan summary as the complete evidence record.
“0 to add, 0 to destroy” is useful, but machine-readable plan JSON tells you more: the destination address, previous_address, and action list can be preserved and evaluated systematically. HashiCorp explicitly documents those JSON fields for programmatic consumption.
Another mistake is reaching immediately for terraform state mv. HashiCorp still documents the command and states that it changes which resource address is associated with an existing real-world object, but its own command documentation directs users toward configuration-driven refactoring for most cases.
Mistake | Why it weakens proof | Better practice |
Looking only at plan summary counts | Loses address/action detail | Save plan and JSON representation |
Calling same state ID “provider proof” | Both values originate inside Terraform | Query independent provider-side identity |
Assuming every resource type can move to every other type | Schemas differ | Verify explicit provider state-move support |
Combining rename with argument changes | Update/replacement becomes ambiguous | Refactor address separately |
Applying a regenerated plan | Reviewed and executed artifacts differ | Apply the saved reviewed plan |
Removing old moved blocks immediately | Older states may still require migration path | Preserve compatibility deliberately |
Using state mv as default | Creates an imperative migration outside configuration review | Prefer declarative refactoring where suitable |
Running with -refresh=false | Hides relevant external state | Use normal refresh behavior |
Using -target routinely | Produces a deliberately partial graph | Reserve targeting for exceptional recovery |
Rerunning before collecting failure evidence | Destroys historical context | Freeze evidence first |
Publishing placeholder IDs as terminal output | Implies execution that did not occur | Clearly mark proposed/expected evidence |
Keeping plan/state JSON in unrestricted CI artifacts | May expose sensitive values | Apply normal secret-grade retention controls |
A subtler mistake is asking Terraform state to authenticate itself. State is one part of the thing being changed. A rigorous migration therefore needs a witness outside that state: cloud resource ID plus creation metadata, Kubernetes UID, database instance identifier plus creation record, provider audit log, or, in this disposable fixture, the external object store and operation history.
The exact identity primitive depends on the provider. The principle does not: choose something created with the real object and expected not to change merely because Terraform's address changes.
Final decision: require identity continuity
A Terraform moved-block refactor is accepted only when the evidence demonstrates an address transition without an object-identity transition.
The hierarchy of evidence matters. Terraform's documented moved-block behavior establishes why a correctly recognized migration should avoid the ordinary destroy/create interpretation. Plan JSON demonstrates what Terraform intends. Persisted state demonstrates that the association moved. Provider-side identity demonstrates that the destination state still refers to the pre-existing object. Audit history closes the remaining gap by looking for transient create/delete activity.
Cross-type moves require an additional gate: provider-supported state conversion. Terraform 1.8+ and the Plugin Framework provide the machinery, but the destination provider resource must explicitly implement a compatible migration rather than leaving Terraform to guess how one resource schema maps to another.
The deliberately incompatible test is not optional ceremony. It demonstrates that the fixture can distinguish supported migration from unsupported schema conversion. Its failed-run artifacts must be preserved before recovery because a later successful run proves current convergence, not historical cleanup.
Final question | Decision rule |
Did the address move? | Require plan/state evidence |
Did Terraform intend create/delete? | Reject if yes |
Did persisted state reach the target address? | Require yes |
Did external immutable identity change? | Reject if yes |
Did external audit history record recreation? | Reject if yes |
Was cross-type conversion explicitly supported? | Require yes for cross-type move |
Did a failed attempt lose its evidence before rerun? | Do not claim proven recovery |
Did the final configuration converge? | Require a clean verification plan |
Is any identity evidence ambiguous? | Hold or quarantine rather than declare success |
Decision record
Use a compact decision record with the evidence bundle:
Change:
Terraform resource address migration
Source address:
<OLD_ADDRESS>
Destination address:
<NEW_ADDRESS>
Move class:
same-type | supported-cross-type
Terraform:
1.16.3
Provider implementation:
<VERSION_OR_BINARY_SHA256>
Configuration revision:
<GIT_COMMIT>
Baseline external identity:
id=<OBJECT_ID>
birth_token=<BIRTH_TOKEN>
Post-refactor external identity:
id=<OBJECT_ID>
birth_token=<BIRTH_TOKEN>
Plan:
previous_address=<OLD_ADDRESS>
destination=<NEW_ADDRESS>
prohibited create/delete/replacement actions=none
Provider audit interval:
additional CREATE=none
DELETE=none
Post-apply state:
old address absent=yes
new address present=yes
identity matches baseline=yes
Fresh convergence plan:
detailed exit code=0
Failed attempts preserved:
yes | no | not applicable
Decision:
PASS | HOLD | QUARANTINE
Reviewer:
<IDENTITY>
Evidence manifest:
<SHA256SUMS>
A PASS says something precise: within the retained evidence, Terraform changed the object's state address while the independently observed real-resource identity remained continuous. It does not turn a green plan into a stronger claim than the evidence can support.
Explore the Refonte Learning DevOps Engineer Program for its current program details. Verify the published syllabus before making an enrollment decision.
