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.
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.