§ Concepts

Plans

What a plan contains, how pravo decides which resources a plan reaches, what an unknown value is, how refresh age works and how a saved plan is checked.

Time
18 min

View as Markdown

On this page

A plan is the list of actions that make the real infrastructure match the specification. pravo computes it from the evaluated specification, the records in the state and the answers of the providers.

A plan changes nothing in the infrastructure. It can write to the state: refreshed records, moved records and imported records. Everything else stays as it was.

This page uses six small roots: zones, deferred, ages, reads, target and feed. The global flag -C DIR runs pravo in one of them.

What a plan contains

Each part of a printed plan answers one question.

Part Meaning
# ADDRESS (TYPE) will be created The header of a change: the address of the resource, its provider type and the action in words.
+, ~, -/+, -, <= The action as a glyph.
Attribute lines The planned values. OLD -> NEW is a change, (known after apply) is an unknown value, (sensitive) is a hidden one.
# forces replacement The attribute that the provider cannot change in place.
Plan: N to add, N to change, N to destroy. The counts. A replacement counts as one add and one destroy.
Hint: A create and a delete that look like one object under two addresses.
Graph: How much of the graph the plan walked.
Outputs: The outputs, with (known after apply) for the ones that are not known yet.

The action of a change is one of these.

Action Glyph When
create + The address has no record.
update ~ The planned state differs from the record, and the provider changes it in place.
replace -/+ The provider reports an attribute that cannot change in place, or -replace names the resource.
delete - A record has no address in the specification, or the plan is -destroy.
read <= A data source whose read waits for the apply.
no-op none The planned state equals the record. The plan does not print it.
move none A $from renamed a record. The plan prints # OLD was moved to NEW.

Work in an empty directory. The first root has two resources: a number that the provider draws, and a resource that records it.

zones/providers.cue
package estate

import (
	"pravo.dev/pravo"
	rnd "pravo.dev/providers/hashicorp/random"
	nul "pravo.dev/providers/hashicorp/null:null_"
)

$settings: pravo.#Settings & {refresh: "1h"}

$random: rnd.#Provider
$null:   nul.#Provider
zones/zones.cue
package estate

zones: ["a"]

ports: {
	for z in zones {
		"\(z)": $random.#Resource.random_integer & {min: 20000, max: 29999, seed: z}
	}
}

deploy: {
	for z in zones {
		"\(z)": $null.#Resource.null_resource & {
			triggers: {
				zone: z
				port: "\(ports[z].result)"
			}
		}
	}
}
terminal
$pravo -C zones init | tail -1
pravo has been initialised. Run "pravo plan" to see what it would do.
terminal
$pravo -C zones plan
pravo will perform the following actions:

  # ports.a (random_integer) will be created
  + ports: a: {
  +     id:     (known after apply)
  +     max:    29999
  +     min:    20000
  +     result: (known after apply)
  +     seed:   "a"
    }

  # deploy.a (null_resource) will be created
  + deploy: a: {
  +     id:       (known after apply)
  +     triggers: {
  +         port: (known after apply)
  +         zone: "a"
        }
    }

Plan: 2 to add, 0 to change, 0 to destroy.
Graph: planned 2 of 2.

deploy.a reads ports.a, so port is unknown until ports.a exists. Attribute values print in CUE syntax and are nested by address.

The plan document

pravo plan --json prints the same plan as one JSON document. A policy is judged against this document. It has these top-level fields.

Field Content
summary add, change, destroy, read and blast_radius.
stats The numbers behind the Graph: line.
changes One entry for each node the plan ran. --include-skipped adds an entry for each node it left alone.
outputs The planned outputs.
hints, providers The hints, and the provider version of each provider.
format, created, actor, reason, git_revision, trace_id, destroy The context of the run.

A change always has addr, mode, type, name and action. When they apply, it also has provider, reasons, requires_replace, before, after, deferred, skipped, refreshed, drifted, applied, dependencies, dependents and warnings. A value that is not known until apply is {"$unknown": true}.

The plan ran the nodes it changes, deletes, moves or imports, the ephemeral resources it opens and the functions it calls. A node it left alone has no entry. pravo plan --json --include-skipped lists it too, as an entry with addr, mode, type, name, the action no-op and skipped. A policy is judged against a document that has an entry for every node of the specification, in the order of the specification, with these fields for the nodes the plan left alone.

terminal
$pravo -C zones plan --json | jq '.changes[1]'
{
  "addr": "deploy.a",
  "mode": "managed",
  "type": "null_resource",
  "name": "a",
  "action": "create",
  "provider": "null",
  "reasons": [
    "not in the state: a new resource"
  ],
  "after": {
    "id": {
      "$unknown": true
    },
    "triggers": {
      "port": {
        "$unknown": true
      },
      "zone": "a"
    }
  },
  "dependencies": [
    "ports.a"
  ]
}

The document is not masked. A sensitive attribute appears in it as it is. Secrets and ephemeral values says what that means.

terminal
$pravo -C zones apply -auto-approve | tail -1
Apply complete! Resources: 2 added, 0 changed, 0 destroyed. [txn <id>]

Unknown values

pravo converts the body of a resource into a value that the provider understands before it asks the provider anything. It classifies each attribute of the body.

is the field absent? yes null an optional attribute that is not set no is the value concrete after defaults? yes the value sent as written no does it refer to a resource, data source or call? yes unknown shown as (known after apply) no is it incomplete for another reason? yes error for example a tag with no value no null the provider picks the value
fig. 1 The classification of one attribute. The questions are asked in order.

An attribute that refers to a resource, a data source or a call is unknown, and so is an incomplete expression over one, such as strings.ToUpper(a.id). An attribute that is not concrete and refers to nothing else is null, and the provider picks the value. An attribute that is not concrete for any other reason is an error, because no provider can resolve it. A tag without a value is the usual cause.

A reference is found structurally. It counts inside an interpolation, a function call, an expression and a list. "\(ports[z].result)" is a reference to ports.a, so port was unknown in the plan above.

zones/tagged.cuerejected
package estate

env: string @tag(env)

tagged: $null.#Resource.null_resource & {
	triggers: stage: "x-\(env)"
}
terminalexit 1
$pravo -C zones plan
Error: tagged.triggers["stage"]: value depends on env, which is not concrete (is a -t tag missing?)
terminal
$rm zones/tagged.cue

When pravo plans or applies a resource, it fills the known values back into the value at the address of the resource. Only where the specification was not concrete does the planned value replace it. Every expression that reads the resource is then evaluated again, so it becomes concrete when all that it reads is known. A value that the provider marks unknown stays non-concrete.

Data sources

pravo reads a data source during the plan when two things hold. Its configuration is fully known, and none of the resources it depends on has a pending change. Otherwise the read waits for the apply. The plan shows it as a read with an unknown result, and every resource that depends on it shows an unknown value too.

deferred/main.cue
package estate

import loc "pravo.dev/providers/hashicorp/local"

$local: loc.#Provider

report: $local.#Resource.local_file & {
	filename: "report.txt"
	content:  "quarterly\n"
}

copy: $local.#Data.local_file & {filename: report.filename}
terminal
$pravo -C deferred init | tail -1
pravo has been initialised. Run "pravo plan" to see what it would do.
terminal
$pravo -C deferred plan | grep -E '^( <=| # |Plan)'
  # report (local_file) will be created
  # copy (local_file) will be read (configuration depends on values known after apply)
 <= copy: (known after apply)
Plan: 1 to add, 0 to change, 0 to destroy.

copy reads report.filename, and report has a pending create. The read waits.

A data source whose read returned what its record holds is not a change. The apply does not write it, takes no lock for it and leaves its revision as it is. A resource that reads it gets the value of the record. A saved plan that reads the data source stays current while nothing writes it. When the refresh age of the data source is a duration, the plan records the time of the read and keeps the revision, so the next plan within the age does not read it again. With the age always, the default, the plan reads the data source every time. With never it does not read it until the configuration changes. Neither records the time.

Scoped plans

A plan does not plan every resource, and it does not read every record. Each node in the state records the hash of the configuration that the provider last saw. It also records the provider version, the schema version and the time of the last refresh. A plan converts the body of each resource, with the values of the resources that ran before it, and compares.

A resource is skipped when all of these hold.

  • The converted configuration is fully known, and its hash equals the hash of the record.
  • The provider version, the provider instance and the schema version are the ones that wrote the record.
  • The record has no mark. A read has not found that the object differs from the record or is gone, and the last apply of the resource finished.

A skipped resource is not validated and not planned. Its record stands in for the planned state, and the resources that depend on it see its attributes as known. Every other resource is reached. It goes to its provider.

What a plan reads follows what it reaches. A plan looks at the resources it reaches and at the resources they read, which are their inputs. It reads the record of each from its provider when the record is older than its refresh age. A resource that is neither reached nor an input is not upgraded, not read, not locked and not written, and no provider process starts for it, whatever the age of its record. Refresh age says when a record that the plan looks at is read, and which records a plan reads whatever their position.

A plan that has nothing to do starts no provider. A provider configuration or a credential that is wrong shows in the first plan or apply that reaches a resource of that provider, not in a plan with no changes. pravo plan -refresh=all starts every provider, so it finds such a fault.

The work of a plan follows the change. A change to one resource reaches that resource and the resources whose configuration changed because of it. Two costs follow the size of the estate: pravo evaluates every resource of the specification to find out which ones changed, and it opens every record of the state. State says what this means for a large estate. Take eight zones of the first root. The resources number sixteen.

zones/zones.cue
package estate

zones: ["a", "b", "c", "d", "e", "f", "g", "h"]

ports: {
	for z in zones {
		"\(z)": $random.#Resource.random_integer & {min: 20000, max: *29999 | int, seed: z}
	}
}

deploy: {
	for z in zones {
		"\(z)": $null.#Resource.null_resource & {
			triggers: {
				zone: z
				port: "\(ports[z].result)"
			}
		}
	}
}
terminal
$pravo -C zones plan | grep -E '^(Plan|Graph)'
Plan: 14 to add, 0 to change, 0 to destroy.
Graph: planned 14 of 16, 2 unchanged and skipped, 2 within refresh age.

Fourteen resources are new, so the plan reaches fourteen. ports.a and deploy.a are within their refresh age and skipped.

terminal
$pravo -C zones apply -auto-approve | tail -1
Apply complete! Resources: 14 added, 0 changed, 0 destroyed. [txn <id>]

Now change one number. max has a default, and a second file sets it for one zone.

zones/override.cue
package estate

ports: c: max: 29998
terminal
$pravo -C zones plan
pravo will perform the following actions:

  # ports.c (random_integer) must be replaced
-/+ ports: c: {
  ~     id:      "28969" -> (known after apply)
  ~     max:     29999 -> 29998 # forces replacement
        min:     20000
  ~     result:  28969 -> (known after apply)
        seed:    "c"
    }

  # deploy.c (null_resource) must be replaced
-/+ deploy: c: {
  ~     id:       "<id>" -> (known after apply)
  ~     triggers: {
  ~         port: "28969" -> (known after apply)
            zone: "c"
        } # forces replacement
    }

Plan: 2 to add, 0 to change, 2 to destroy.
Graph: planned 2 of 16, 14 unchanged and skipped, 16 within refresh age.

The provider cannot change max in place, so ports.c is replaced. A replacement makes the computed attributes of the resource unknown again, so deploy.c, which reads ports.c, is reached and replaced too. Fourteen resources are not.

ports deploy ports.a skip deploy.a skip ports.b skip deploy.b skip ports.c -/+ deploy.c -/+ ports.d skip deploy.d skip ports.e skip deploy.e skip ports.f skip deploy.f skip ports.g skip deploy.g skip ports.h skip deploy.h skip max: 29999 -> 29998 reached planned with the provider skipped the recorded state stands in for the planned state Plan: 2 to add, 0 to change, 2 to destroy. Graph: planned 2 of 16, 14 unchanged and skipped
fig. 2 The plan above, drawn over the graph. Two nodes are reached. Fourteen are skipped, and their records stand in.

The Graph: line reports what happened.

Part Counts
planned N of M The resources and data sources that were reached, of all that the specification declares.
N unchanged and skipped The ones that were not reached.
N removed from the specification The records of resources that the specification does not declare and that the plan deletes. They are not among the M.
N refreshed The ones whose real state was read from the provider during the run.
N drifted The refreshed ones that differed from the record.
N within refresh age The ones that were not read and whose record is within its refresh age.
N not read The ones that were not read and whose record is older than its refresh age.
N ephemeral opened, N functions called, N imported, N moved Work of other kinds.

Each resource and data source that has a record is in exactly one of refreshed, within refresh age and not read. The three add up to the number of records, which are those of the declarations and the removed ones, and drifted is a part of refreshed. A resource that has no record, because the plan creates it, is in none of them, and neither is an ephemeral resource or a call. A part with a count of zero is not printed, except planned. With -refresh=false the line has neither within refresh age nor not read, because nothing is read. pravo plan -full plans every resource with its provider.

A plan that reads an input of a reached resource counts the input as refreshed, so the counts are final when the plan ends and not before.

The sentence under a plan with no change says how much the plan can promise. No changes. Your infrastructure matches the configuration. is printed when not read is zero and the refresh is not false: every record was read, or is within its refresh age. Otherwise the plan prints No changes. The specification matches the state. and says how many records it did not read. pravo drift compares them with the infrastructure. With -refresh=false the plan says that nothing was read. Refresh age shows each sentence.

terminal
$pravo -C zones plan -full | grep -E '^(Plan|Graph)'
Plan: 2 to add, 0 to change, 2 to destroy.
Graph: planned 16 of 16, 16 within refresh age.

pravo why prints the reason of each change, and of each skip.

terminal
$pravo -C zones why ports.c deploy.c deploy.a
ports.c: replace
  - the specification changed since the last apply (hash <old> -> <new>)
  - refresh skipped: the record is within its refresh age
  - the provider cannot change max in place; replacement required
  reaches: deploy.c
deploy.c: replace
  - inputs depend on values known only after apply: ports.c
  - refresh skipped: the record is within its refresh age
  - the provider cannot change triggers in place; replacement required
deploy.a: no-op
  - skipped: nothing the provider saw has changed
  - not read: the plan did not reach it; the record is <age> old, its refresh age is 1h

summary.blast_radius in the plan document counts the nodes that a change touches. They are the changed nodes and every node that depends on them, as the state records the edges.

A plan unifies the values of a node it did not reach into the configuration only when something reads them: another resource, the configuration of a provider or an output. A constraint added to a computed attribute of a node that nothing reads is checked when that node is next planned.

Requested replacements

pravo plan -replace ADDRESS destroys a resource and creates it again although nothing in the specification changed. The request is a change in the plan like any other, and it is reviewed like one. It can be repeated, and it cannot be combined with -destroy.

terminal
$pravo -C zones plan -replace deploy.a --json 2>/dev/null | jq -c '.changes[] | select(.addr == "deploy.a") | .reasons'
["replacement requested with -replace","refresh skipped: the record is within its refresh age"]

Refresh age

Refreshing means asking the provider for the real state of a resource before planning against it. It is the most expensive part of a plan. Each node records when the provider last reported its state, and a refresh age says how old that may be. A record is within its refresh age while its age is no greater than the refresh age, and older than it once its age is greater. A record whose age equals the refresh age is within it.

Reach decides what a plan looks at. The refresh age decides whether a record that the plan looks at is read.

The refresh age is a staleness budget. It does three things:

  • It spares a repeated plan the read of a record that it looks at and that was read within the age.
  • On a resource, as $lifecycle.refresh, it is a standing promise: a plan or a sweep reads the record once it is older than that age, whether or not the plan reaches the resource.
  • In a sweep, pravo drift --due and pravo plan -refresh=expired read the records that are past their age, the oldest first.

The saving of a plan that reads one resource of ten thousand comes from reach, not from the age.

Together they keep the calls to the providers in line with the change. A plan asks about the resources it plans and the resources they read, and does not ask again about a record that it read within its age. A change to one resource costs the reads of that resource and its inputs, whether the state holds ten records or ten thousand. The price is trust: a record that a plan does not read is taken to match its object. Choosing a refresh age says how long to trust it.

The refresh age of a resource is $lifecycle.refresh, else $settings.refresh, else "always".

Value Meaning
"always" An age of zero. A plan that looks at the resource reads it.
"never" No limit. A plan reads the resource only when asked.
"30m", "6h", "7d" A duration. A plan that looks at the resource reads it when its record is older.

A plan reads a record in these cases, and in no other.

Record Read when
A resource the plan reaches Its record is older than its refresh age.
A resource that a reached resource reads Its record is older than its refresh age.
A resource that sets its own $lifecycle.refresh Its record is older than that age, reached or not. $settings.refresh is not an age of its own. A resource that sets "always" is read by every plan.
A data source It is not read again when its configuration is unchanged, nothing it depends on has a pending change and its record is within its age. Otherwise it is read, reached or not.
A resource with a mark It is reached, and read when its record is older than its refresh age. The mark stays until an apply writes the resource.
A resource with a stale lock, a record with no time of a read, or whose provider now points somewhere else It is read whatever its age. See Records a plan reads whatever their age.

Ephemeral resources, function calls and built-in values such as pravo.#Env run in every plan.

CUE rejects any other value when it evaluates the specification. The root below has four resources, each with its own age.

ages/main.cue
package estate

import (
	"pravo.dev/pravo"
	rnd "pravo.dev/providers/hashicorp/random"
	loc "pravo.dev/providers/hashicorp/local"
)

$settings: pravo.#Settings & {refresh: "1h"}

$random: rnd.#Provider
$local:  loc.#Provider

a: $random.#Resource.random_integer & {min: 1, max: 9, seed: "a", $lifecycle: refresh: "15m"}
b: $random.#Resource.random_integer & {min: 1, max: 9, seed: "b", $lifecycle: refresh: "never"}
c: $random.#Resource.random_integer & {min: 1, max: 9, seed: "c"}

note: $local.#Resource.local_file & {
	filename: "note.txt"
	content:  "see \(a.result)\n"
	$lifecycle: refresh: "always"
}
terminal
$pravo -C ages init | tail -1
pravo has been initialised. Run "pravo plan" to see what it would do.
terminal
$pravo -C ages apply -auto-approve | tail -1
Apply complete! Resources: 4 added, 0 changed, 0 destroyed. [txn <id>]

Right after the apply, a plan reads only note. The other three records are within their ages.

terminal
$pravo -C ages plan
note: Refreshing state... [id=<sha1>]

No changes. Your infrastructure matches the configuration.
Graph: planned 0 of 4, 4 unchanged and skipped, 1 refreshed, 3 within refresh age.

pravo why says what happened to each resource.

terminal
$pravo -C ages why a b c note
a: no-op
  - skipped: nothing the provider saw has changed
  - not read: the plan did not reach it; the record is <age> old, its refresh age is 15m
b: no-op
  - skipped: nothing the provider saw has changed
  - not read: the plan did not reach it; the record is <age> old, its refresh age is never
c: no-op
  - skipped: nothing the provider saw has changed
  - not read: the plan did not reach it; the record is <age> old, its refresh age is 1h
note: no-op
  - refreshed because the record is older than its refresh age; no drift
  - skipped: nothing the provider saw has changed

The flag -refresh overrides the ages for one run.

Flag Behaviour
-refresh=age The default. A plan reads the records it looks at that are older than their refresh age.
-refresh=expired A plan reads every record that is older than its refresh age, reached or not. Use it for a scheduled sweep.
-refresh=all A plan reads every record, whatever its age.
-refresh=false A plan reads no record and trusts them all. A mark is still honoured. The Graph: line has neither within refresh age nor not read.
terminal
$pravo -C ages plan -refresh=all | tail -2
No changes. Your infrastructure matches the configuration.
Graph: planned 0 of 4, 4 unchanged and skipped, 4 refreshed.
terminal
$pravo -C ages plan -refresh=false | tail -2
No changes. The specification matches the state. Nothing was read from the infrastructure.
Graph: planned 0 of 4, 4 unchanged and skipped.
terminalexit 1
$pravo -C ages plan -refresh=sometimes
Error: invalid -refresh value "sometimes": want age, expired, false or all

A refresh that happens during a plan is written back to the record with a new refresh time, in a transaction. The revision moves only when the attributes differ. The apply that follows a plan does not read the provider again: it uses what the plan read. A refresh does not by itself cause a new plan: a resource whose refresh returns the attributes that are recorded is still skipped. A resource whose attributes changed, or whose object is gone, is drift. The read writes a mark on the record, drifted_at and, for an object that is gone, gone. The resource is planned by this plan and by every later plan, saved plan and apply, until an apply writes it. pravo drift ignores the ages, reads every resource and writes the same marks.

A change reads what it reaches

The root reads has twenty files and one more resource that carries a version. It sets no refresh age, so every record is due.

reads/main.cue
package estate

import (
	"list"
	loc "pravo.dev/providers/hashicorp/local"
)

$local: loc.#Provider

version: *"1" | string @tag(version)

files: {for i in list.Range(0, 20, 1) {
	"f\(i)": $local.#Resource.local_file & {filename: "out/f\(i).txt", content: "item \(i)\n"}
}}

release: $local.#Resource.local_file & {filename: "out/release.txt", content: "version \(version)\n"}
terminal
$mkdir -p reads/out
$pravo -C reads init | tail -1
$pravo -C reads apply -auto-approve | tail -1
pravo has been initialised. Run "pravo plan" to see what it would do.
Apply complete! Resources: 21 added, 0 changed, 0 destroyed. [txn <id>]

Change the version. The plan reaches release and reads it. No resource that it reaches reads one of the files, so it does not look at them.

terminal
$pravo -C reads plan -t version=2 | grep -E '^(.*Refreshing state|Plan|Graph)'
release: Refreshing state... [id=<sha1>]
Plan: 1 to add, 0 to change, 1 to destroy.
Graph: planned 1 of 21, 20 unchanged and skipped, 1 refreshed, 20 not read.

-refresh=expired reads every record that is older than its refresh age. With the age always, that is every record. The two counts below are the lines Refreshing state of the plan above and of the plan with -refresh=expired.

terminal
$pravo -C reads plan -t version=2 | grep -c 'Refreshing state'
$pravo -C reads plan -t version=2 -refresh=expired | grep -c 'Refreshing state'
1
21

A plan that changes nothing reads nothing. It says that it did not read the records, because it cannot promise that the infrastructure matches.

terminal
$pravo -C reads plan | tail -2
No changes. The specification matches the state. 21 records were not read; run pravo drift to compare them with the infrastructure.
Graph: planned 0 of 21, 21 unchanged and skipped, 21 not read.

pravo plan -detailed-exitcode exits 0 for this plan: the plan found no change among what it looked at. It does not detect drift in a resource that it did not read. The next section finds that drift. CI schedules it.

A record that a reached resource reads can differ from its object, or the object can be gone. The read writes the mark, and the plan walks again from the records as they are: the resource is reached now, and the records it reads are read in turn. A chain of resources that each read the one before takes one walk for each resource whose record differs, however long the chain is, and no record is read twice. The plan fails with NODE changed while the plan read it; run pravo plan again only when a record is found changed in a walk after it was found changed in an earlier one.

Drift in a resource the plan does not read

Delete one of the files, as someone with a shell does. A plan does not look at it.

terminal
$rm reads/out/f7.txt
$pravo -C reads plan -detailed-exitcode > /dev/null
$echo "exit $?"
exit 0

pravo drift reads every record and reports the difference. The read writes a mark on the record.

terminalexit 2
$pravo -C reads drift
Drift. 1 of 21 resources changed outside pravo:
  files.f7: create
    - refresh found the real state differs from the state (drift)
terminal
$pravo -C reads state show files.f7 | grep '^// gone'
// gone

The next plan acts on the mark. It reaches files.f7 and reads it again, and the other twenty are still not read. The apply that follows creates the file again, writes the record and ends the mark.

terminal
$pravo -C reads plan | grep -E '^(Plan|Graph)'
$pravo -C reads apply -auto-approve | tail -1
Plan: 1 to add, 0 to change, 0 to destroy.
Graph: planned 1 of 21, 20 unchanged and skipped, 1 refreshed, 1 drifted, 20 not read.
Apply complete! Resources: 1 added, 0 changed, 0 destroyed. [txn <id>]

A sweep of a large estate that reads every record can take long. pravo drift --due --limit N reads only the records that are older than their refresh age, the oldest first and at most N of them, and plans nothing. A job that runs it often covers the estate in turn, and each run costs what it reads. It exits 2 when it finds drift.

terminal
$pravo -C reads drift --due --limit 5
No drift. 5 of 21 records read; 16 more are older than their refresh age.

Choosing a refresh age

The refresh age is the time for which a plan trusts a record that it looks at. A longer age means fewer calls to the provider and a plan that waits for fewer answers. It also means that a change made outside pravo stays unseen for longer. Choose it for the object and not for the estate.

  • A resource that only pravo changes can have a long age, "6h" or "7d". Set it once with $settings: refresh. A plan reads such a record only when it reaches the resource or a resource that reads it, and only when the record is older than the age.
  • A resource that people or other systems change, and whose current state a plan must see, sets $lifecycle: refresh on itself. The age on a resource is standing: every plan reads the record once it is older than that age, whether or not the plan reaches the resource. "always" makes every plan read it.
  • The default, "always", reads every record that a plan looks at, so a plan sees the current state of everything it plans. It also reads every data source on every plan, so an estate with many data sources sets an age.

Records that no plan looks at are read by a job that you schedule. pravo drift --due --limit N reads the records that are older than their age, the oldest first, and pravo plan -refresh=expired reads all of them while it plans. With a long age a job reads each record rarely, and with a short one often. CI shows the job.

Records a plan reads whatever their age

A plan reads three kinds of record that it does not reach, whatever their refresh age. The refresh age says how long a record may be trusted when nothing suggests it is wrong. These suggest it.

An apply stopped while it held the node. A command that is killed does not release its locks, and it may have changed the object before it stopped. The lock stays, and once its holder is gone or has not renewed it for ten minutes, the lock is stale. A plan that finds a stale lock on a node reads the node, and the read removes the lock. With -refresh=false the plan reads nothing and prints a warning for each such node. The lock below was left by an apply of alice; the root ages is the one above.

terminal
$mkdir -p ages/pravo.state/locks
$cat > ages/pravo.state/locks/c.lock <<'EOF'
${"addr":"c","pid":4242,"host":"ci-1","user":"alice","command":"pravo apply","txn":"T1","nonce":"n1","since":"2026-01-01T00:00:00Z","kind":"lock","renewed_at":"2026-01-01T00:00:00Z"}
$EOF
$pravo -C ages why c
$pravo -C ages state locks
c: no-op
  - an apply (txn T1, alice on host ci-1) stopped while this node was locked; its real state is read again whatever the refresh age
  - skipped: nothing the provider saw has changed
No locks or reservations held.

The provider points somewhere else. A record carries a digest of the configuration of the provider instance that wrote it. The digest covers the attributes that say where the provider points, such as a region, an endpoint or a profile. It leaves out an attribute that is sensitive, one that is not known yet and one that is built from an ephemeral value, so a credential that rotates changes nothing. A plan reads the records whose digest differs from the digest of their provider now. It plans one only when the read finds drift. A record written without a digest is not read for this; its next write or read adds one, and a provider with no such attribute leaves none. The root target configures the tls provider with a proxy.

target/main.cue
package estate

import (
	"pravo.dev/pravo"
	tls "pravo.dev/providers/hashicorp/tls"
)

$settings: pravo.#Settings & {refresh: "6h"}

$tls: tls.#Provider & {proxy: [{url: "http://proxy-a.example:3128", username: "ci", password: "one"}]}

key: $tls.#Resource.tls_private_key & {algorithm: "ECDSA"}
terminal
$pravo -C target init | tail -1
$pravo -C target apply -auto-approve | tail -1
$jq 'has("provider_config")' target/pravo.state/resources/key.json
pravo has been initialised. Run "pravo plan" to see what it would do.
Apply complete! Resources: 1 added, 0 changed, 0 destroyed. [txn <id>]
true

A new password for the proxy changes nothing the digest covers, and the record is within its age.

target/main.cue
package estate

import (
	"pravo.dev/pravo"
	tls "pravo.dev/providers/hashicorp/tls"
)

$settings: pravo.#Settings & {refresh: "6h"}

$tls: tls.#Provider & {proxy: [{url: "http://proxy-a.example:3128", username: "ci", password: "two"}]}

key: $tls.#Resource.tls_private_key & {algorithm: "ECDSA"}
terminal
$pravo -C target plan | tail -2
No changes. Your infrastructure matches the configuration.
Graph: planned 0 of 1, 1 unchanged and skipped, 1 within refresh age.

Another proxy address does.

target/main.cue
package estate

import (
	"pravo.dev/pravo"
	tls "pravo.dev/providers/hashicorp/tls"
)

$settings: pravo.#Settings & {refresh: "6h"}

$tls: tls.#Provider & {proxy: [{url: "http://proxy-b.example:3128", username: "ci", password: "two"}]}

key: $tls.#Resource.tls_private_key & {algorithm: "ECDSA"}
terminal
$pravo -C target why key
$pravo -C target plan | tail -1
key: no-op
  - the non-sensitive configuration of its provider changed
  - skipped: nothing the provider saw has changed
Graph: planned 0 of 1, 1 unchanged and skipped, 1 within refresh age.

The first command read the record, found no drift and wrote the digest of the new configuration. The plan that follows has nothing to read.

An apply could not tell what it did to the node. A provider can fail without returning a state, and an apply can exit without waiting for a provider. Interrupting an apply describes both. The apply writes the record of the node again without refreshed_at. The next plan reads the node whatever its refresh age, also when the specification has gone back to what the record says and the plan does not reach the node. The read writes refreshed_at. With -refresh=false the plan reads nothing and prints a warning for each such node.

Saved plans

pravo plan -out FILE writes the plan to a file, and pravo apply FILE applies those actions without a prompt. The reviewed file is what runs.

The file records the change of every node the plan ran, with its configuration, its prior and its planned values, unknown values included. It records the private data of the providers and the refresh and skip decisions. It records the tags of the plan, the provider versions and a fingerprint of every .cue file of the root. It records the lineage of the state, the revision of every node that the plan changes and the revision of every record that those changes read. A node that the plan left alone has no entry. Values in it can be secrets. pravo creates the file with owner-only permissions.

plan -out pravo plan plan file mode 0600 apply FILE pravo apply judge with --policy plan again each resource exit 5 when refused checks, in order configuration fingerprint state lineage provider versions revision of nodes it uses stale exit 3, no change differs compare with saved change same action known values unchanged provider changes it refused before the change
fig. 3 What apply does with a saved plan, in order.

The next plan is the pending change of ports.c and deploy.c.

terminal
$pravo -C zones plan -out c.plan | tail -3
Saved the plan to c.plan. To apply exactly these actions, run:
  pravo apply c.plan

pravo show FILE prints a saved plan. It adds a header with the time, the command and the actor.

terminal
$pravo -C zones show c.plan | head -3
Saved plan created <time> by "pravo -C zones plan --out c.plan"

pravo will perform the following actions:

A plan can be stale

Before anything is applied, pravo checks the file against the root. Four things must match.

  • The fingerprint of the .cue files. Any edit, a comment included, changes it.
  • The lineage of the state.
  • The versions of the providers.
  • The revision of every node that the plan changes, and of every record that those changes read.

A difference makes the plan stale. The apply refuses with the reasons and exit code 3, and nothing changes.

terminal
$echo '// a comment' >> zones/override.cue
terminalexit 3
$pravo -C zones apply c.plan
Error: saved plan is stale; run "pravo plan" again:
  - the configuration changed since the plan was made

Restore the file. The plan is current again.

zones/override.cue
package estate

ports: c: max: 29998

Applying part of a plan

pravo apply FILE -only ADDR,ADDR applies the named changes and marks them in the file. The rest waits for a later pravo apply FILE.

Each name must be a change in the file. A node that the plan left alone has no entry in the file, and naming it is an error. The selection must be closed under dependencies. Every pending change that a selected change depends on must be selected too, or already applied. So must a data source whose read returned something other than its record, because the change was planned from what it returned. A selected delete needs every pending delete that depends on it. When the selection is not closed, nothing runs, and the command prints the smallest closed selection.

terminalexit 1
$pravo -C zones apply c.plan -only deploy.c
Error: the selection is not closed under dependencies; it also needs ports.c
  (the smallest closed selection is -only deploy.c,ports.c)

ports.c alone is closed.

terminal
$pravo -C zones apply c.plan -only ports.c
Applying 1 of 2 remaining changes from c.plan.
ports.c: Destroying... [id=28969]
ports.c: Creating replacement...
ports.c: Replacement complete after 0s [id=21730]

Apply complete! Resources: 1 added, 0 changed, 1 destroyed. [txn <id>]
1 change remains in c.plan; apply with "pravo apply c.plan".

The apply wrote ports.c and left deploy.c. The file records which change is done.

terminal
$pravo -C zones show c.plan | grep -E '^ # '
  # ports.c (random_integer) must be replaced (already applied in txn <id>)
  # deploy.c (null_resource) must be replaced

During a staged apply, a resource outside the selection is neither planned nor touched. The selected resources see it through its record, which the closure rule makes enough. An output that reads a resource outside the selection, one that has no record yet, is not recorded by that apply. The apply that finishes the plan records it. pravo apply shows it. A saved destroy plan is staged in the same way: a part removes the outputs that read what it destroys, leaves the others, and the part that destroys the last resources removes the rest. See Outputs of a partial destroy.

terminal
$pravo -C zones apply c.plan | tail -1
Apply complete! Resources: 1 added, 0 changed, 1 destroyed. [txn <id>]

A plan can be stale through the state

The revision of a node changes whenever a command writes it. Plan the creation of a ninth zone, then let another apply create it first.

zones/zones.cue
package estate

zones: ["a", "b", "c", "d", "e", "f", "g", "h", "i"]

ports: {
	for z in zones {
		"\(z)": $random.#Resource.random_integer & {min: 20000, max: *29999 | int, seed: z}
	}
}

deploy: {
	for z in zones {
		"\(z)": $null.#Resource.null_resource & {
			triggers: {
				zone: z
				port: "\(ports[z].result)"
			}
		}
	}
}
terminal
$pravo -C zones plan -out i.plan | grep -E '^(Plan|Graph)'
Plan: 2 to add, 0 to change, 0 to destroy.
Graph: planned 2 of 18, 16 unchanged and skipped, 16 within refresh age.
terminal
$pravo -C zones apply -auto-approve | tail -1
Apply complete! Resources: 2 added, 0 changed, 0 destroyed. [txn <id>]
terminalexit 3
$pravo -C zones apply i.plan
Error: saved plan is stale; run "pravo plan" again:
  - ports.i changed in state (revision 1, plan expected 0)
  - deploy.i changed in state (revision 1, plan expected 0)

The plan expected no record for ports.i and deploy.i. The other apply wrote them.

An apply that writes a node which the plan neither changes nor reads leaves the plan current. A record that a change reads is listed in the file with its revision, and so are the records that it reads in turn, because a field of a record can be an expression over them. When one of them was written since, the plan is stale, and the reason ends with the plan reads it.

A saved plan keeps what it read

A saved plan holds the answer that each data source gave when the plan was made. The apply uses it and does not read the source again. The root below copies a file that a data source reads.

feed/main.cue
package estate

import loc "pravo.dev/providers/hashicorp/local"

$local: loc.#Provider

src: $local.#Data.local_file & {filename: "feed.txt"}

copy: $local.#Resource.local_file & {
	filename: "copy.txt"
	content:  src.content
}
terminal
$echo one > feed/feed.txt
$pravo -C feed init | tail -1
$pravo -C feed apply -auto-approve | tail -1
pravo has been initialised. Run "pravo plan" to see what it would do.
Apply complete! Resources: 1 added, 0 changed, 0 destroyed. [txn <id>]

Change the file and save a plan. The plan reads two. Change the file again before the plan is applied.

terminal
$echo two > feed/feed.txt
$pravo -C feed plan -out feed.plan | grep -E '^(Plan|Graph)'
$echo three > feed/feed.txt
Plan: 1 to add, 0 to change, 1 to destroy.
Graph: planned 2 of 2, 2 refreshed.

The apply writes what the plan read.

terminal
$pravo -C feed apply feed.plan | tail -1
$cat feed/copy.txt
Apply complete! Resources: 1 added, 0 changed, 1 destroyed. [txn <id>]
two

The next plan reads the file again and finds three.

terminal
$pravo -C feed plan | grep -E '^(Plan|Graph)'
Plan: 1 to add, 0 to change, 1 to destroy.
Graph: planned 2 of 2, 2 refreshed.

What apply checks while it runs

Once an apply holds the locks of the nodes it writes, it reads the revision of each of them, and of each record it reads that the plan lists, from the state itself and not from what it loaded when it started. A revision that differs from the one in the plan means that another command wrote the node while the plan waited. A record that was removed and created again while the plan waited starts at revision 1 again, so the apply compares the generation of the record as well, and a record in another generation is refused whatever its revision. The apply stops with exit code 3 before it changes anything, whether the plan is a file or the plan that pravo apply printed. A saved plan lists the reasons as it does when it is loaded. A plain apply says that the plan changed while it waited and lists them. A record that the apply only reads is not locked, so a write to it after this check is not seen.

An apply plans each change of the plan again with the values that are known by then, as an apply always does. It compares the result with the change in the plan before it touches the resource. The action must be the same, or the resource has nothing left to do. A resource that has nothing left to do must already hold every value that the plan would have given it. Every value that the plan already knew must be unchanged. A provider that resolves an unknown is accepted. A provider that changes an answer it gave stops the apply at that resource, which is not touched. The resources the apply changed before it reached that one keep their changes. pravo apply without a file makes the same comparison with the plan it has just printed. A difference is exit code 3. The message of a plain apply says that the plan changed while it waited. The message of an apply of a file names the resource and what the provider planned instead.

A value that an expression builds from a container of resources is the same in the plan and in the apply. A comprehension over a container visits the resources in the order in which the specification declares them, whatever order the providers finish in.

A data source that the plan read is not read again. The apply uses the answer the plan holds, and the resources that read the source are applied with it, whether the source answers the same now or not. A later plan reads the source as its refresh age says.

The comparison runs as the apply reaches each resource, and resources that do not depend on each other are applied at the same time. When one resource differs, the apply stops. The resources it applied by then keep their changes, and the plan file is not marked. The next pravo apply FILE finds those resources written since the plan was made and is stale. Make a new plan.

While it runs, the apply renews its locks every two minutes, and before it asks a provider to change an object it renews them if more than five minutes have passed since it last knew that they were its own. A lock that another command took over meanwhile, which can happen after the computer was suspended and the lock went stale, stops the apply with lock lost and exit code 4. A renewal that fails for another reason, so that the locks cannot be confirmed, stops it with exit code 1. Either way the apply makes no further change, and the changes it made are recorded.

The apply walks the changes of the plan and the nodes they read, and no others. A node that a change reads is taken from its record, as the plan saw it. A node that no change reads is not planned again, not read and not checked: how far its record can be from the infrastructure is decided when the plan is made, by the reach of the plan and the refresh age.

What a saved plan does not do

A saved plan is not signed. pravo checks that the file still matches the root, the state and what the providers plan. It does not check who made it.

pravo apply FILE --policy POLICY judges the changes that remain in the file, all of them, before any provider is asked to change anything. The file records the command that made it. Nothing reads that line as a requirement. A policy file is not part of the fingerprint. Policies covers the rest.

Recap

  • A plan is a list of actions with reasons. It is printed for people and available as JSON for programs.
  • A value that depends on a resource that does not exist yet is unknown. The plan shows it as (known after apply).
  • A plan reaches the resources that changed and the resources that read them. The rest is skipped, and the Graph: line counts both.
  • A saved plan is checked against the files, the state and the providers, and then each resource is planned again.

Next: State describes the records that a plan compares with.