---
title: 'Plan and Apply Workflow'
source: 'https://academia.sh/en/courses/infrastructure-as-code/plan-and-apply-workflow'
course: 'Infrastructure as Code'
language: en
updated: '2026-09-13T05:08:23+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Writes a planner that compares the record against state: add, update, recreate, delete'
    - 'Compares the plan against the real apply and counts the predictive accuracy and recreation hit rate'
    - 'Counts the cost of reading the plan alongside scale: lines, review minutes, the share requiring a decision'
---

# Plan and Apply Workflow

A planner is written that compares the record against state and produces an add, update, recreate, or delete decision item by item, and then the plan itself is measured: how many items are not in the plan but happen at apply, how many are in the plan but do not happen, how many items marked for recreation actually get recreated, and how many lines and minutes it takes to review the plan.

The previous lesson counted plan items according to the record's form, but it always took the plan
itself as a given: the items came from somewhere. This lesson builds that somewhere. The
**planner** is declarative provisioning's most concrete promise — a list that shows what will
happen before a change is applied. The way to measure that promise is clear too: produce the list,
then apply, then compare the two.

Earlier, a continuously running **reconciliation loop** was measured: a controller that compares
the desired state against the real state every round and closes the gap on its own. The flow here
is its opposite. Apply is **triggered**; in the time between two triggers, reality changes on its
own, without asking anyone. The plan gets produced right in that gap, and every deviation in this
lesson comes out of that gap.

The measurement runs on the **regional measurement network**'s record and is **fictional**; what
runs is a **planner model** written with `node`, and no call goes to any provider.

**DP37.** The planner compares the record against **state**, not reality. State is the ledger the
last apply wrote; reality is what is standing at the provider right now. The two are separate
sources.
**DP38.** State carries thirteen resources and is left over from the last apply.
**DP39.** The record diverges from state at eleven points: one new resource, one deleted resource,
three image upgrades, and six field changes.
**DP40.** Reality has drifted from state at three points; all three are out-of-record, hand-made
changes.
**DP41.** The planner's schema and the running provider's behavior diverge at three points: the
planner counts a `size` change as recreation, but the provider updates that field in place; the
provider stores strings without case sensitivity; and it adds a field to a newly created machine
that is not in the record. A computed field's value is not known before apply; it can only be
predicted from whether the resource it looks at is being recreated.
**DP42.** A plan item writes one title line; an add, delete, or recreate item lists the resource's
entire field set, an update item lists only the changed fields. Careful reading speed is 25 lines a
minute.

## The Planner

```js
// plan.mjs — a planner that compares the record against state and produces a plan, and a
// measurement that compares the plan against the real apply. Model; no call goes to a provider.

const REF = (resource) => ({ ref: resource });      // a computed field: its value is not known before apply

// DP38: state — the record the last apply wrote. Thirteen resources.
const STATE = {
  "network.metrics": { block: "10.20.0.0/16", region: "north" },
  "subnet.collector": { network: "network-01", block: "10.20.1.0/24" },
  "security_group.collector": { network: "network-01", ingress: "10.20.1.0/24" },
  "data_store.meter": { subnet: "subnet-01", size: "medium", version: "4.2" },
  "queue.batch": { subnet: "subnet-01", retention: 3 },
  "queue.old_batch": { subnet: "subnet-01", retention: 3 },
  "machine.collector_north_1": { subnet: "subnet-01", image: "base-7", size: "medium" },
  "machine.collector_north_2": { subnet: "subnet-01", image: "base-7", size: "medium" },
  "machine.collector_east_1": { subnet: "subnet-01", image: "base-7", size: "medium" },
  "object.collector_config": { store: "metrics-config", data_address: "meter-01.data" },
  "load_balancer.collector": { subnet: "subnet-01", algorithm: "round_robin" },
  "health_check.collector": { target: "load_balancer-01", interval: 30 },
  "dns_record.collector": { address: "load_balancer-01.address", ttl: 300 },
};

// DP39: record — desired. Diverges from state at eleven points.
const RECORD = {
  ...STATE,
  "data_store.meter": { subnet: "subnet-01", size: "large", version: "4.2" },
  "queue.batch": { subnet: "subnet-01", retention: 7 },
  "machine.collector_north_1": { subnet: "subnet-01", image: "base-9", size: "medium" },
  "machine.collector_north_2": { subnet: "subnet-01", image: "base-9", size: "medium" },
  "machine.collector_east_1": { subnet: "subnet-01", image: "base-9", size: "medium" },
  "machine.collector_east_2": { subnet: "subnet-01", image: "base-9", size: "medium" },
  "object.collector_config": { store: "metrics-config", data_address: REF("data_store.meter") },
  "load_balancer.collector": { subnet: "subnet-01", algorithm: "ROUND_ROBIN" },
  "health_check.collector": { target: "load_balancer-01", interval: 10 },
  "dns_record.collector": { address: "load_balancer-01.address", ttl: 60 },
};
delete RECORD["queue.old_batch"];

// DP40: actual — what is really sitting at the provider. Diverges from state at three points; the plan never sees these.
const ACTUAL = {
  ...STATE,
  "network.metrics": { block: "10.20.0.0/17", region: "north" },          // manually narrowed
  "security_group.collector": { network: "network-01", ingress: "0.0.0.0/0" },  // manually opened
  "health_check.collector": { target: "load_balancer-01", interval: 5 },  // manually lowered
};

const RECREATE_PLAN = ["image", "size", "subnet"];   // DP41: the schema the planner has
const RECREATE_ACTUAL = ["image", "subnet"];          // the provider updates size in place
const NORMALIZE = (v) => (typeof v === "string" ? v.toLowerCase() : v);
const DEFAULT = { machine: { monitoring: "on" } };  // the provider adds this to a new resource on its own
const K = (v) => JSON.stringify(v);

// Shared comparator. real=false looks through the planner's eyes, true through apply's.
function compare(record, baseline, { real }) {
  const forcing = real ? RECREATE_ACTUAL : RECREATE_PLAN;
  const equal = real ? (a, b) => K(NORMALIZE(a)) === K(NORMALIZE(b)) : (a, b) => K(a) === K(b);
  const items = new Map();
  for (const [address, y] of Object.entries(record)) {
    const e = baseline[address];
    const total = Object.keys(y).length;
    if (!e) { items.set(address, { decision: "add", fields: Object.keys(y), total }); continue; }
    const fields = Object.keys(y).filter((k) => !y[k]?.ref && !equal(y[k], e[k]));
    const decision = fields.length
      ? (fields.some((k) => forcing.includes(k)) ? "recreate" : "update") : null;
    items.set(address, { decision, fields, total });
  }
  for (const address of Object.keys(baseline))
    if (!record[address]) items.set(address, { decision: "delete", fields: [], total: Object.keys(baseline[address]).length });
  // A computed field: its value changes if the resource it looks at is being recreated.
  for (const [address, y] of Object.entries(record))
    for (const [k, v] of Object.entries(y))
      if (v?.ref && items.get(v.ref)?.decision === "recreate") {
        const it = items.get(address);
        it.fields.push(k);
        it.decision = it.fields.some((a) => forcing.includes(a)) ? "recreate" : "update";
      }
  for (const [address, it] of items) if (!it.decision) items.delete(address);
  return items;
}

const PLAN = compare(RECORD, STATE, { real: false });
const APPLY = compare(RECORD, ACTUAL, { real: true });
// Provider default: an out-of-plan field gets added when a new resource is created.
let defaultFields = 0;
for (const [address, it] of APPLY)
  if (it.decision === "add") defaultFields += Object.keys(DEFAULT[address.split(".")[0]] ?? {}).length;

const count = (m, k) => [...m.values()].filter((v) => v.decision === k).length;
console.log("decision       plan  apply");
const print = (name, a, b) =>
  console.log(`${name.padEnd(15)}${String(a).padStart(4)}${String(b).padStart(10)}`);
for (const k of ["add", "update", "recreate", "delete"])
  print(k, count(PLAN, k), count(APPLY, k));
print("total", PLAN.size, APPLY.size);

const notInPlan = [...APPLY.keys()].filter((a) => !PLAN.has(a));
const notInApply = [...PLAN.keys()].filter((a) => !APPLY.has(a));
const common = [...PLAN.keys()].filter((a) => APPLY.has(a));
const decisionMismatch = common.filter((a) => PLAN.get(a).decision !== APPLY.get(a).decision);
const planRecreate = [...PLAN].filter(([, v]) => v.decision === "recreate").map(([a]) => a);
const actualRecreate = planRecreate.filter((a) => APPLY.get(a)?.decision === "recreate");
const staleValueWrong = common.filter((a) => STATE[a] && ACTUAL[a] && K(STATE[a]) !== K(ACTUAL[a]));

console.log("\nthe plan's accuracy at predicting the apply");
console.log(`  not in plan, happened in apply  : ${notInPlan.length}  (${notInPlan.join(", ")})`);
console.log(`  in plan, did not happen in apply: ${notInApply.length}  (${notInApply.join(", ")})`);
console.log(`  in both                         : ${common.length}, decision mismatch on ` +
  `${decisionMismatch.length} (${decisionMismatch.join(", ")})`);
console.log(`  recreation hit rate             : ${actualRecreate.length}/${planRecreate.length}`);
console.log(`  plan's stated old value is wrong: ${staleValueWrong.length} item ` +
  `(${staleValueWrong.join(", ")})`);
console.log(`  field never shown in the plan   : ${defaultFields} (provider default)`);
const intersection = common.length - decisionMismatch.length;
const union = new Set([...PLAN.keys(), ...APPLY.keys()]).size;
console.log(`  exact hit ${intersection}/${union} = ${Math.round((intersection / union) * 100)}%`);

// DP42: a plan item writes 1 title line; the add/delete/recreate item lists the resource's
// full field set, the update item lists only the changed fields. Careful reading is 25 lines/minute.
const FULL_LISTING = ["add", "delete", "recreate"];
const lineCount = (m) => [...m.values()].reduce((s, v) =>
  s + 1 + (FULL_LISTING.includes(v.decision) ? v.total : v.fields.length), 0);
const decisionLines = (m) => [...m.values()]
  .filter((v) => v.decision === "recreate" || v.decision === "delete")
  .reduce((s, v) => s + 1 + v.total, 0);

// The same record repeated across N regions: addresses and computed-field references get prefixed.
const scaleOut = (obj, n) => Object.fromEntries(Array.from({ length: n }, (_, i) =>
  Object.entries(obj).map(([address, y]) => [`r${i}_${address}`, Object.fromEntries(
    Object.entries(y).map(([k, v]) => [k, v?.ref ? REF(`r${i}_${v.ref}`) : v]))])).flat());

console.log("\nthe cost of reading the plan");
console.log("region  items  lines  minutes  decision lines   %");
for (const n of [1, 3, 10, 30]) {
  const pl = compare(scaleOut(RECORD, n), scaleOut(STATE, n), { real: false });
  const t = lineCount(pl), kr = decisionLines(pl);
  console.log(`${String(n).padStart(6)}${String(pl.size).padStart(7)}${String(t).padStart(7)}` +
    `${(t / 25).toFixed(1).padStart(9)}${String(kr).padStart(17)}` +
    `${String(Math.round((kr / t) * 100)).padStart(4)}`);
}
```

```
decision       plan  apply
add               1         1
update            5         6
recreate          4         3
delete            1         1
total            11        11

the plan's accuracy at predicting the apply
  not in plan, happened in apply  : 2  (network.metrics, security_group.collector)
  in plan, did not happen in apply: 2  (object.collector_config, load_balancer.collector)
  in both                         : 9, decision mismatch on 1 (data_store.meter)
  recreation hit rate             : 3/4
  plan's stated old value is wrong: 1 item (health_check.collector)
  field never shown in the plan   : 1 (provider default)
  exact hit 8/13 = 62%

the cost of reading the plan
region  items  lines  minutes  decision lines   %
     1     11     33      1.3               19  58
     3     33     99      4.0               57  58
    10    110    330     13.2              190  58
    30    330    990     39.6              570  58
```

The top table is reassuring at first glance: the plan has eleven items, apply has eleven items.
The numbers hold. The section below removes that comfort — **two lists of the same size are not
the same list.** Plan and apply agree on both item and decision for eight of thirteen distinct
addresses. The exact hit rate is 62 percent.

The source of the deviation is a single structural choice: the planner compares the record against
state, while apply touches reality. Every difference between state and reality cannot enter the
plan, **by definition**. Three items on the measurement network are like this: the network block
has been narrowed by someone, the security group's ingress rule has been opened all the way, the
health check's interval has been manually lowered. The plan writes none of them. Apply silently
reverts the first two — two items the plan never showed — and for the third it shows the right item
but the **wrong old value**: the plan says "30 to 10," reality goes from 5 to 10.

This third type is especially insidious, because it does not throw off the item count. The person
reading the plan sees the right number of items, sees the right decisions, and is only misled about
where each item **starts from**. The security group's being opened all the way also gets reverted
this way: it is corrected without anyone asking, closed without anyone noticing, and how long that
rule stayed open is written nowhere.

## Items in the Plan That Do not Happen

The two items in the opposite direction come from a different mechanism. `load_balancer.collector`
is in the plan because the record writes the algorithm value in uppercase, and the planner compares
strings literally. Because the provider stores the same value without case sensitivity, nothing
happens at apply. The plan promised a change, apply did zero work.

This item looks harmless, but it has a cost: it comes back on every plan run. A gap between the
record and state that never closes becomes a permanent, unreconciled item, and the person reading
the plan has to review it every single time. Over time, items like this get learned as "always
shows up, ignore it" — and that habit dismisses a **real** item in the plan with the same reflex.

The second item runs deeper. `object.collector_config`'s record carries the data store's address,
and this address is a **computed field**: its value is known only after apply. The planner cannot
predict this field; it knows only one thing — if the resource it looks at is going to be recreated,
the address will change too. The data store is a recreation item in the plan, so the config object
enters the plan too. At apply, the data store is not recreated, its address does not change, and the
config object is untouched. Here the planner did not write wrong data — it wrote the **consequence
of a wrong assumption**.

## Items Marked for Recreation

The recreation row is this course's most expensive decision: destroying and recreating is
irreversible and, as measured in the previous lesson, produces downtime. The plan's hit rate on
this item is three of four.

The missed item is the data store, and the reason is not a record error. The schema in the
planner's hands says the `size` field requires recreation; the running provider, though, can update
that field in place. This single-line gap between schema and behavior showed the person looking at
the plan that forty-one days of meter data would be deleted. The direction of the error is lucky —
the plan promised something worse than what actually happened — but direction is not a guarantee; a
schema gap in the opposite direction turns an item the plan marks "will change in place" into a
recreation at apply.

This single item's ripple effect enters the count too: the **only** reason the config object
entered the plan was the mistaken belief that the data store would be recreated. One wrong
recreation decision produced at least one more item beyond itself. This multiplier grows as the
number of resources looking at a computed field grows.

There is also an item that never shows up at all. The provider adds a field to the newly created
machine that is not written in the record. The plan says it will create a resource with three
fields; what comes out is a resource with four. The fourth field does not enter the plan on the next
apply either, because the record does not know about it at all. The infrastructure the record
does not cover is, here, exactly one field wide, and nobody manages that field.

## The Cost of Reading the Plan

The bottom table gives the plan's own cost. For a single region, the plan is 33 lines and 1.3
minutes with careful reading. This is text that can be reviewed. When the measurement network grows
to thirty regions, the same plan rises to 990 lines and 39.6 minutes — and this is the plan for a
**single image upgrade**.

It is worth noting that the ratio stays fixed: the lines requiring a decision — that is, recreation
and delete items — are 58 percent of the plan at every scale. As the plan grows, the signal ratio
does not drop, but the **absolute review load** grows linearly. In a thirty-region plan, 570 lines
carry an irreversible decision, and every one of them scrolls by on the same screen, in the same
format, in the same color.

The operating rule that follows from this rests on the number. Nobody reads a forty-minute plan
line by line; and a plan that is not read does not deliver its promise. For the plan to stay
reviewable, either the record's unit of apply gets shrunk — thirty regions applied region by
region, not all at once — or only the recreation and delete items get filtered out of the plan and
read first. Both are about **splitting the record**, and that topic is outside this lesson's
boundary.

Finally, the plan's predictive accuracy and the cost of reading it should be read together. A list
with 62 percent accuracy does not describe the whole of apply even when read for thirty minutes.
**The plan's value is not certainty, it is decidability**: showing the irreversible items before
they are applied. It does that job with three-of-four accuracy, and the reason for the quarter it
misses is not in the record — it is in the gap between the planner's schema and the provider's
behavior.

## Summary

- The planner compares the record against state, apply touches reality; every difference between
  state and reality cannot enter the plan by definition. This single structural choice is the
  source of most of the deviations.
- The plan has 11 items, apply has 11 items, but item and decision agree on only 8 of 13
  addresses: exact hit rate 62 percent.
- Not in the plan, happened in apply: 2 items (a manually narrowed network block, an ingress rule
  opened all the way). 1 more item looked correct but showed the wrong old value: the plan says
  30, reality is 5.
- In the plan, not in apply: 2 items — one from case normalization (comes back on every run), one
  from a computed field (the ripple effect of a wrong recreation assumption).
- Recreation hit rate: 3/4. The missed item came from a one-field gap between the planner's schema
  and the provider's behavior, and it alone produced one more item.
- The plan is 33 lines and 1.3 minutes for 1 region, 990 lines and 39.6 minutes for 30; the share
  of lines requiring a decision stays at 58 percent at every scale, meaning the review load grows
  linearly.

## Next Step

This lesson measured the plan for resources written in the record. On the measurement network,
though, there are also resources never written in the record at all: an object store opened by
hand at initial setup, a DNS zone, two old machines. They never enter the plan, because the planner
only compares what the record and state know. The next lesson brings these resources under
management — **import** — and counts its cost: how many fields get hand-matched per item, how many
items the first plan after import shows as needless changes, and what the difference is between
leaving a resource out of the record and bringing it under management.
