# Plan structure

A plan is an ordered list of steps, each targeting hosts and running tools. Here are the parts, and the rules for variables, conditions and failure.

Source: https://opafra.com/docs/plans/structure

---

A plan is an ordered list of **steps**. Each step names the hosts it targets and the
tools to run against them. Running a plan executes its steps in order, and for each step,
its work on every target.

```yaml title="a two-step plan"
name: Patch nginx
steps:
  - name: Back up the current config
    targets: [production]
    tool: file.backup
    args:
      remote_path: /etc/nginx/nginx.conf
      backup_path: /etc/nginx/nginx.conf.bak
  - name: Reload
    targets: [production]
    tool: nginx.reload
```

## Steps and targets

Every step carries its own target list, so a plan can touch different hosts at different
stages: back up on one host, deploy on three, verify on a load balancer.

A target is an environment name, a server name, or a `{{ vars.x }}` reference resolved
when the run starts. A step runs against each of its targets in turn.

## Variables

Variables come from three places, and the later ones win:

1. **Environment values**, belonging to the environment each host is in
2. **Plan variables**, defined on the plan
3. **Run overrides**, supplied when the run is started

> **Important**
>
> Environment values are resolved **per host**, not once per run. A single run against both
> staging and production gives each host its own environment's values for the same key.
> This is what lets one plan be the same plan everywhere instead of a forked copy per
> environment.

Reference a variable as `{{ vars.name }}`. Three narrower namespaces exist when you need
to be specific about where a value came from:

| Reference | Resolves to |
|---|---|
| `{{ vars.x }}` | The merged value, run override over plan over environment |
| `{{ env.x }}` | This host's environment values only |
| `{{ inputs.x }}` | Run overrides only |
| `{{ outputs.x }}` | A value captured from an earlier step |
| `{{ facts.x }}` | What Opafra observed about this host, such as the OS family |

A variable that never resolves is empty at run time. A [dry run](/docs/running/dry-run)
lists every unresolved reference it found, which is the cheapest way to catch one.

## Typed inputs

A plan can declare the inputs it expects, each with a type, whether it is required, a
default, and optionally a fixed set of choices. Whoever starts a run is then prompted for
them rather than having to know which variable names matter.

An input marked secret is treated as one: it is not echoed back and it is redacted in
previews and logs.

Plans without declared inputs still accept free-form run variables, so this is an
addition rather than a requirement.

## Capturing output

A step can store its output into a variable that later steps read:

```yaml title="capture and branch"
- name: Read the running version
  targets: [web-01]
  tool: ssh.cmd
  args:
    command: nginx -v 2>&1
  capture:
    var: nginx_version
    mode: regex
    pattern: "nginx/([0-9.]+)"
```

Four modes: `raw` keeps stdout as text, `json` parses it and can drill in with a dotted
path, `lines` splits into an array, and `regex` extracts the first capture group.

A capture that fails to parse is a warning, not a failure. The variable is left undefined
and the run continues, because a plan should not stop because a version string was
formatted unexpectedly.

## Conditions

Any step or item can carry a condition, and runs only when it evaluates true. An empty
condition always runs.

```yaml
condition: "{{ facts.os_family }} == 'debian' && {{ outputs.needs_restart }} == true"
```

The supported grammar is deliberately small: `&&`, `||`, the comparisons `==` `!=` `>`
`>=` `<` `<=`, and `contains` for substring or array membership, over literals and
`{{ }}` references, with parentheses.

> **Note**
>
> Conditions are evaluated by a hand-written parser. There is no `eval`, no `new Function`,
> and no sandbox to escape, because no dynamic code execution exists in the path at all.

A malformed condition **fails open**: the step runs, and a warning is logged. That is the
deliberate choice of the two available failures. A broken condition that silently skips a
step means production automation quietly stops happening and nobody notices, which is
worse than a step running when it should not have.

## When something fails

By default a run halts at the first failed step. Later steps do not execute, and the run
is recorded as failed. The second half of a plan usually assumes the first half worked.

Three per-item settings change that, and they are properties of the plan rather than
decisions made at run time:

| Setting | Effect |
|---|---|
| `continue_on_error` | The item may fail without failing the run. It is still recorded as failed. |
| `retries` | Extra attempts after the first. |
| `retry_delay_seconds` | Pause between attempts. |

An item can also carry its own `timeout_seconds`, and a plan can carry a whole-run
wall-clock cap.

> **Warning**
>
> `continue_on_error` does not make a failure invisible. The item is still recorded as
> failed and still appears in the run record. It only stops that failure from halting
> everything after it.

## Revisions

Every edit to a plan increments its revision. The number matters at the
[approval gate](/docs/running/approval-gates): the revision is pinned when approval is
requested and compared at the moment of decision, so a plan edited between the two is
surfaced rather than quietly approved.

## Plans from git

A plan can be owned by a git repository instead of the builder. A managed plan is
read-only in the UI: edits go through the repository and arrive on the next sync, so the
repository stays the single source of truth rather than one of two.

## Next steps

- [Commands and scripts](/docs/plans/commands-and-scripts) for dropping out of tools, and what it costs
- [Tool catalogue](/docs/reference/tools) for what a step can call
