skip to content

Planned Values and Actions

Plan JSON keeps the values a resource will have apart from the action being taken on it, so a rule that ignores the action fires on resources nobody touched.

on this pageshow

questions

4

In a Terraform plan JSON, how do `planned_values` and `resource_changes` differ for a policy rule?

level: juniorimportance: must knowfreq 72%

answer

  1. one is a result, one is a diff
  2. which one carries an action verb?
  3. untouched resources still show up somewhere
  4. post-apply state versus per-resource change
  5. no-op is an action, not an absence

basics

~20 s

planned_values is the whole post-apply state, every managed resource including untouched ones. resource_changes is the per-resource diff, carrying the action and the before and after values. A rule over planned_values judges the estate; a rule over resource_changes judges this run.

solid answer

~50 s

`planned_values` is a result: the state as it will look after apply, containing every resource the configuration manages, with no information about what is happening to any of them. `resource_changes` is a diff: a flat array with one entry per resource carrying `change.actions`, `change.before` and `change.after`. The choice decides what your rule means. Over `planned_values`, a database backup-retention rule says "the estate must be compliant after this apply", so it fires on a database created three years ago and blocks a pull request that only changed a tag elsewhere. Over `resource_changes`, filtered to entries whose actions include `create` or `update`, it says "what this run touches must be compliant" — the old database is present but as a `no-op`, so it is skipped. For a change gate, that scoping is almost always what you want.

code

json · 22 lines
json
{
  "planned_values": {
    "root_module": {
      "resources": [
        { "address": "aws_db_instance.orders",
          "values": { "backup_retention_period": 1, "deletion_protection": true } },
        { "address": "aws_db_instance.legacy_reports",
          "values": { "backup_retention_period": 0, "deletion_protection": false } }
      ]
    }
  },
  "resource_changes": [
    { "address": "aws_db_instance.orders",
      "change": { "actions": ["update"],
                  "before": { "backup_retention_period": 30 },
                  "after":  { "backup_retention_period": 1 } } },
    { "address": "aws_db_instance.legacy_reports",
      "change": { "actions": ["no-op"],
                  "before": { "backup_retention_period": 0 },
                  "after":  { "backup_retention_period": 0 } } }
  ]
}

go deeper

for a junior

Be ready to say in one sentence that one section is the state after apply and the other is the list of per-resource changes, and that only the second one tells you whether a resource is being created, edited or left alone.

for a middle

Explain the shapes: a nested module tree of resources with values versus a flat array of entries each holding actions, before and after. Know that untouched resources appear in both, and that destroyed ones appear in only one.

for a senior

Show that the section you pick defines the gate's scope. An interviewer wants to hear you predict the consequence: a rule over the whole post-apply state blocks engineers for resources their change never touched.

for a principal

Own the call about when a gate should judge the change versus the estate, and what has to be true organisationally before you widen it. The technical difference is small; the adoption consequences are not.

## Two views of the same proposed change A machine-readable Terraform plan is a single JSON document describing one proposed change, split into several parallel sections. The two that a policy rule cares about are `planned_values` and `resource_changes`, and picking between them is the first decision you make when you author any infrastructure gate. **`planned_values` is a result.** It is the state as Terraform expects it to look *after* a successful apply: a tree of `root_module.resources` plus nested `child_modules`, where each resource carries an `address`, its `type` and `name`, and a `values` object holding attributes. It contains **every** resource the configuration manages, not only the ones this run touches. A database created three years ago and untouched by this pull request sits in there with its current attribute values. Resources scheduled for destruction are *absent*, because they will not exist afterwards. And there is no action information anywhere in the section: nothing in `planned_values` tells you whether a resource is being created, edited, or left completely alone. **`resource_changes` is a diff.** It is a flat array with one entry per resource, each carrying `address`, `module_address`, `mode`, `type`, `name`, `provider_name`, and a `change` object. Inside `change` you find `actions` (an array of verbs — `no-op`, `create`, `read`, `update`, `delete`, or a two-element pair encoding a replacement), `before` (attribute values as they stand today, `null` for a create), `after` (values after apply, `null` for a delete), and companion structures such as `after_unknown` and `replace_paths`. Everything the plan knows about *what is happening* lives here, and nowhere else. ## Why the choice changes what your rule means Take a rule requiring managed relational databases to keep at least a seven-day backup window and to have deletion protection on. Written over `planned_values`, the rule reads "the estate must be compliant after this apply". It iterates every database the configuration manages. That sounds stricter and therefore better, until the first engineer changes a tag on an unrelated queue and the gate blocks their pull request over a reporting database somebody else created before the policy existed. The author of the change cannot fix the finding — it is not theirs, it is not in their diff, and often not in their repository's blast radius. Gates that block people for things they did not do lose their mandate quickly. Written over `resource_changes`, the same rule reads "the resources this run touches must be compliant". You filter to the entries whose `actions` include `create` or `update`, read `change.after`, and deny on a retention window below seven days or deletion protection left off. The old reporting database still appears in the array, but with `actions: ["no-op"]` — the filter skips it, and the pull request passes. New databases and edited databases are held to the standard; nothing else is re-litigated. Note the subtlety in that last sentence: `resource_changes` is not a list of *changes*, it is a list of *resources with an action attached*, and one of the actions is "nothing is happening". A rule that assumes every entry represents work will happily fire on untouched infrastructure and reproduce the exact problem you switched sections to avoid. ## The other two sections `prior_state` is a full state representation as things stood before the plan — the same shape as `planned_values`, but describing the past rather than the future. `configuration` holds the module's resource blocks as *expressions* rather than as evaluated values, which is where you look when you need to know what a human actually wrote. Most rules never touch either one; `change.before` already gives you the prior value of the specific resource you are ruling on. ## The practical shape of a rule The common pattern is: iterate `resource_changes`; filter on `type` to select the resource kind you care about; filter on `actions` to select the operations you care about; read the values from `change.after`; deny with a message naming `address` so the engineer can find the block to edit. You rarely need to join back to `planned_values`, because `change.after` carries the same post-apply values that `planned_values` records for that resource. ## What people get wrong - Believing `planned_values` contains only changed resources. It contains all of them; that is the entire point of the section. - Believing `resource_changes` contains only real changes. `no-op` entries are there too. - Reading `planned_values` for the values *and* for the scoping, then being surprised the gate blocks pull requests that touch nothing relevant. - Expecting to find an action verb in `planned_values`. There is none — if your rule needs to know what is happening, it must read `resource_changes`. - Looking in `planned_values` for a resource that is being destroyed. It is gone; its only trace is a `resource_changes` entry with `actions: ["delete"]` and `after: null`.

  • Does a resource that is being destroyed appear in planned_values?
    No. `planned_values` models the state after a successful apply, so a destroyed resource is simply absent. Its only trace in the document is a `resource_changes` entry with `actions: ["delete"]`, a populated `before` and `after` set to null. If your rule needs to reason about deletions at all, it has to read the diff section.
  • If a rule must judge the end state of only the resources a change touches, do you need both sections?
    Usually not. Filter `resource_changes` on the action, then read the values straight out of `change.after` — those are the same post-apply values `planned_values` records for that resource. You only reach for `planned_values` when you deliberately want to evaluate the whole estate rather than the diff.
  • What is in the plan's configuration section, and when would a rule read it?
    It holds the module's resource blocks as expressions rather than as evaluated values — what a human actually wrote. Most rules never touch it, because `change.after` already gives the resolved values. You go there when the question is about the written configuration itself rather than the outcome.

planned_values is the photograph of the finished building; resource_changes is the punch list of what the crew is doing today. You inspect the punch list to judge today's work.

saying these in an interview costs you the question

  • Thinks planned_values contains only the changed resources
  • Assumes every resource_changes entry represents real work
  • Looks for a create or update verb inside planned_values
  • Expects a resource being destroyed to appear in planned_values
  • Reads planned_values then wonders why old resources fail the gate

context

open as a page

A Terraform plan rule matching only the `create` action let a database retention downgrade ship. Why?

level: middleimportance: must knowfreq 62%

basics

~20 s

Lowering a backup window on an existing database is planned as an in-place edit, so change.actions is ["update"], not ["create"]. The filter never matched, so the rule never ran. Match when the actions array contains create or update.

open as a page

A change-scoped Terraform plan gate never sees a legacy database. How do you close that gap?

level: principalimportance: should knowfreq 44%

basics

~20 s

Keep the blocking rule scoped to changed resources, and add a scheduled evaluation over the full recorded state that reports rather than blocks. Give each finding an owner and a date; widen the blocking rule once the backlog is drained.

open as a page

In a Terraform plan, how do you gate a change that lowers a database's backup retention window?

level: seniorimportance: nice to knowfreq 36%

basics

~20 s

Compare both sides of the diff. Deny when change.after's retention is lower than change.before's, so an already sub-standard database can be edited for unrelated reasons but never made worse. Creates have no before, so apply the absolute minimum.

open as a page