Skip to main content

Reading a Validate Report

A Terraform plan answers one question well: which addresses will change. The two questions a reviewer actually has are different. Did the thing I asked for happen, and did anything else. A plan cannot answer the first, because it does not know what was asked; and it cannot answer the second, because every line in it looks the same whether somebody requested it or not.

octo validate runs the plan and then reports it against your intent. This page is how to read what comes back.

npm run octo:validate

It creates nothing, and needs no permission to change anything. Run it as often as you like.

The shape of a report​

Every report has the same skeleton, and sections that have nothing to say are left out entirely.

shop — octo validate — changes (approximate)      <- app, mode, outcome, confidence
2026-08-19 01:38:21 UTC · 3ms octo · 9.7s terraform

You added region=eu-west-1 (+1 other change). <- the headline
That creates 3 new things.
- 0 destroy ± 0 replace + 3 create ~ 0 update <- the counters

[a paragraph, when confidence is approximate]

FAILURES — octo could not read these, so everything else is incomplete
WHAT WILL CHANGE
DATA LOSS — destroying these loses the data they hold
MOVED BETWEEN MODULE FOLDERS — their terraform state moves with them
DRIFT — nothing in your intent explains this
UNCERTAIN — planned against a placeholder, so not judged as drift
NOTES

The outcome on the first line is one of clean, changes, drift, or failed, in increasing order of how much you should care. The counters use one symbol per kind, and they are ordered by how hard each is to undo:

-destroy
±replace, meaning destroy and create
+create
~update in place
?something Terraform reported that maps to none of the above
⚠drift

Drift and uncertain changes are deliberately kept out of the counters. Those count what your change does, and a change nobody made must not be attributed to you.

What will change​

This is the report the shop produced when checkout was given access to the receipts bucket:

WHAT WILL CHANGE
+ added server=checkout [server=checkout,app=shop | checkout-server-module | +4]
+ iam-role-ServerRole-checkout
+ aws_iam_policy.iam-role-ServerRole-checkout_aws-ecs-server-s3-access-overlay-937a53bb02a4
+ aws_iam_role_policy_attachment.iam-role-ServerRole-checkout_AmazonECSTaskExecutionRolePolicy
+ aws_iam_role_policy_attachment.iam-role-ServerRole-checkout_aws-ecs-server-s3-access-overlay-937a53bb02a4_attach
+ aws_iam_role.iam-role-ServerRole-checkout
+ added service=shop-receipts-s3-storage [service=shop-receipts-s3-storage,region=eu-west-1,account=123456789012,app=shop | receipts-module | +1]
+ bucket-shop-receipts
+ aws_s3_bucket_policy.bucket-shop-receipts-policy

Three levels, and each is one of Octo's three vocabularies.

The card heading is a piece of your intent, with its full context, the module that owns it, and a tally of the Terraform changes underneath. Cards are ordered by how destructive they are, so anything being destroyed is at the top.

The middle line is an Octo resource. bucket-shop-receipts is the record Octo keeps of that bucket, and ~ or + next to it says what is happening to the resource as a whole.

The indented lines are Terraform addresses, which is what you would have read in a plan. Their symbols come from Terraform.

So the second card reads: the receipts service, owned by receipts-module, has one Terraform change underneath it, and it is a bucket policy being created. That policy exists because checkout asked for access, which is on the card above.

What the card verb means

A card's verb says what happens to the Octo resources under it: added when every one is created, removed when every one is destroyed, replaced when every one is replaced, and changed otherwise.

When something is replaced​

Change the shop's vpcCidrBlock from 10.0.0.0/16 to 10.1.0.0/16 and the report changes shape:

You changed region=eu-west-1. That destroys or replaces 1 thing — check those before anything else.
- 0 destroy ± 1 replace + 0 create ~ 1 update

WHAT WILL CHANGE
~ changed region=eu-west-1 [region=eu-west-1,account=123456789012,app=shop | region-module | ±1 ~1]
± vpc-eu-west-1
why: cidr_block is force-new on aws_vpc; a change recreates the VPC
± aws_vpc.vpc-eu-west-1
arn: arn:aws:ec2:eu-west-1:123456789012:vpc/vpc-0d3099895f7b48db1 → (known after apply)
assign_generated_ipv6_cidr_block: false → (none)
cidr_block: 10.0.0.0/16 → 10.1.0.0/16
default_network_acl_id: acl-088931691364d585e → (known after apply)
default_route_table_id: rtb-02ef2366b9195690b → (known after apply)
default_security_group_id: sg-050561d184c86b8b6 → (known after apply)
dhcp_options_id: dopt-cc920faa → (known after apply)
enable_network_address_usage_metrics: false → (known after apply)
id: vpc-0d3099895f7b48db1 → (known after apply)
ipv6_association_id: → (known after apply)
ipv6_cidr_block: → (known after apply)
ipv6_cidr_block_network_border_group: → (known after apply)
... 4 more field(s) changed
~ igw-eu-west-1
~ aws_internet_gateway.igw-eu-west-1
vpc_id: vpc-0d3099895f7b48db1 → (known after apply)

Three things in there a plan does not give you.

The why: line is a sentence the resource's author wrote into the class, and it is why a report can explain a recreate rather than only display one. Somebody knew that cidr_block forces a new VPC on this provider, wrote it down once, and every reader of every future report gets it.

The field lines say which argument moved. On a replace, that is the whole question: cidr_block is the one that caused it, and everything else is a consequence. A card lists twelve fields and then counts the rest, so a list ending without that last line is the complete one.

And the internet gateway is in the report because the VPC's ID is about to change under it. Nothing in the definition file mentions the gateway; Octo attributes the change to the VPC whose identity moved, and shows it under the same card.

Field values are rendered and truncated rather than dumped, and anything Terraform marked sensitive prints as (sensitive). A report is meant to be read, not replayed.

Data loss​

DATA LOSS — destroying these loses the data they hold

Every destroy Terraform plans of something that holds data Octo cannot put back from your definition file: a table's rows, a bucket's objects, a filesystem's files, a repository's images. It lists them whatever the cause, a module you removed or a change that forces a replacement, because the cause does not change what is lost. The same destroys are already counted under - and ± above; this section only says which of them cost more than the infrastructure.

Moved between module folders​

MOVED BETWEEN MODULE FOLDERS — their terraform state moves with them

Resources now generated into a different module folder than the one holding their Terraform state, because you renamed a moduleId or a resource moved from one module to another. Octo moves their Terraform state into the new folder before Terraform plans, so they show up here and nowhere else: no destroy, no create.

Confidence​

The first line ends in (exact) or (approximate), and it decides how much of the rest to trust.

Exact means your intent did not change on this run. Every folder plans against real state, nothing is a placeholder, and anything Terraform wants to do is therefore something the world did without you. Drift detection is reliable here.

Approximate means your intent did change, and the report says so:

Your intent changed, so each folder was planned on its own against its dependencies' last
applied outputs. A cross-folder consequence of this change is invisible until apply. This
report can both understate what apply will do and overstate drift.

The reason is in the shop's own generated Terragrunt. When the receipts folder is planned before checkout's role exists, it plans against a placeholder:

mock_outputs = {
"iam-role-ServerRole-checkout-Arn" = "arn:aws:iam::000000000000:role/mock-role"
}

The bucket policy in that plan names a role that does not exist yet. The plan is still worth reading, but it is not the last word.

The practical habit that follows: apply, commit, then run validate again. The second one is exact, and it is the one that tells you whether anything is out of place.

Drift​

Drift is a Terraform change that nothing in your intent explains. This is the shop's, after somebody widened the receipts bucket policy by hand in the console:

shop — octo validate — drift (exact)

1 change to your infrastructure is not explained by anything in your intent — start there.
- 0 destroy ± 0 replace + 0 create ~ 0 update ⚠ 1 drift

WHAT WILL CHANGE
nothing

DRIFT — nothing in your intent explains this
~ aws_s3_bucket_policy.bucket-shop-receipts-policy [receipts-module]
Your infrastructure no longer matches your intent, and nothing you changed explains it.
→ run apply to bring it back in line with your intent, or
→ change intent to accept reality, then generate + apply

Nothing changed in octo.yaml, so there is nothing under "what will change", and Terraform still wants to update that policy. The only explanation is that the policy in AWS is not the one Octo generated.

The mechanism is the mapping Octo records at commit: every Terraform address it produces is listed against the resource that produces it. An address Terraform wants to change that appears in none of those lists came from somewhere else.

Both suggested fixes are real choices. Applying reverts the console change. Changing intent means finding the module input that would produce what is now there, which is the right answer when the console change was correct and the definition file is what is out of date.

Drift on an approximate report is worth less, for the reason above. Fix your intent first, get to exact, then look again.

Uncertain​

UNCERTAIN — planned against a placeholder, so not judged as drift

A change planned against a mock_outputs value, where the difference may be the placeholder rather than reality. These are reported separately and never counted as drift, because accusing somebody of an unexplained change on the strength of a mock is worse than saying nothing. They usually disappear on the next exact run.

Failures​

FAILURES — octo could not read these, so everything else is incomplete

A folder Terraform could not plan. This is the only outcome that exits non-zero, printing Validation could not complete! on stderr, and the reason is simple: validate cannot report on something it could not read, so the whole report is incomplete rather than only that folder.

Everything else, drift included, exits zero. A report is a report, not a gate. What you build on top of it is your pipeline's decision.

Notes​

NOTES

The opposite end from failures: observations that judge nothing, such as a folder in the output directory that Octo does not track, or a delete it cannot line up with a Terraform address. Read them and carry on.

One kind names something that already exists in your cloud account. An external resource is published by an action that runs in the middle of an apply, and Octo records what it produced separately until octo commit folds it into the resource graph. A run that stopped before its commit leaves that record behind, and validate names it:

NOTES
External resource "ecr-image-tag-eu-west-1-shop/checkout-0-0-2" is published but not recorded in octo state; the next successful commit records it.

Nothing to do beyond finishing the run that stopped, which Recovering a Failed Run covers: its commit picks the record up, without publishing anything a second time.

The same note ends differently when the record has been orphaned, with and neither intent nor the committed graph declares it any more, so octo will never delete it. Something is then in your cloud account that no run will remove, because Octo deletes only what it has recorded. That happens when the resource left your definition file, or when its resource ID changed, before any commit recorded the first version. Troubleshooting has the two ways out.