DevOps engineer reviewing Terraform moved block evidence to verify resource identity continuity

Prove Terraform State Refactoring Did Not Recreate the Real Resource

Wed, Sep 23, 2026

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.