# Interpolation and check failures

> Unset ${VAR_NAME} behavior on plan versus apply, rules skipped by removeRulesWithMissingEnv, and plan --check exit 1 on drift.

- Repository: jaredpalmer/vwaffle
- GitHub: https://github.com/jaredpalmer/vwaffle
- Human docs: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581
- Complete Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/llms-full.txt

## Source Files

- `src/config.ts`
- `src/config.test.ts`
- `src/index.ts`
- `src/diff.ts`

---

---
title: "Interpolation and check failures"
description: "Unset ${VAR_NAME} behavior on plan versus apply, rules skipped by removeRulesWithMissingEnv, and plan --check exit 1 on drift."
---

`loadDesiredConfig` in `src/config.ts` is the gate for every `plan` and `apply` run. It interpolates `${VAR_NAME}` placeholders from `process.env`, then either throws, or warns and drops matching custom rules, before `cmdPlan` / `cmdApply` compare or PUT. `plan --check` is a separate failure: after the (possibly reduced) desired config is diffed against live, `process.exitCode` is set to `1` when the result is not `No drift detected.`

## Command outcomes

| Command | `loadDesiredConfig` `strict` | Unset `${VAR_NAME}` | Drift vs live |
| --- | --- | --- | --- |
| `vwaffle plan` | `false` | stderr warning; drop matching `rules` | print diff; exit `0` |
| `vwaffle plan --check` | `false` | same as `plan` | print diff; `process.exitCode = 1` when not `NO_DRIFT` |
| `vwaffle apply --dry-run` | `false` | same skip path as `plan` | print redacted payload; no API call |
| `vwaffle apply --yes` | `true` | throw; no PUT | never reached if any placeholder is unset |

`--check` is parsed globally but only read in `cmdPlan`. Passing it to `apply` does not change apply behavior.

```mermaid
flowchart TD
  subgraph load ["loadDesiredConfig"]
    I["interpolate string values"] --> M{"missing.size > 0?"}
    M -->|no| OK["use interpolated config"]
    M -->|"yes and strict"| ERR["throw missing environment variables"]
    M -->|"yes and not strict"| WARN["warn then removeRulesWithMissingEnv"]
  end
  subgraph callers ["CLI callers"]
    P["cmdPlan: strict false"] --> load
    D["cmdApply --dry-run: strict false"] --> load
    A["cmdApply --yes: strict true"] --> load
  end
  subgraph check ["cmdPlan after diff"]
    R{"options.check and result !== NO_DRIFT?"}
    R -->|yes| E1["process.exitCode = 1"]
    R -->|no| E0["leave exit code unchanged"]
  end
  P --> check
```

## Unset `${VAR_NAME}`

`interpolate` walks the parsed JSON (strings, arrays, objects). Object keys, numbers, booleans, and `null` are left unchanged.

<ParamField body="placeholder" type="string">
  Only `${NAME}` matches `/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g`. Hyphens, dots, and names that start with a digit are not expanded and are not recorded as missing.
</ParamField>

<ParamField body="env lookup" type="process.env">
  A name is missing only when `process.env[name] === undefined`. An empty string is a set value: it replaces the placeholder and is stored in `secrets`.
</ParamField>

<ParamField body="missing entry" type="string">
  Format is `NAME ($.json.path)` — for example `NOPE ($.a[0])` or `GONE ($.rules[1])`. The same name at two paths produces two set entries.
</ParamField>

Unset placeholders stay in the value as the original `${NAME}` text. Set placeholders are replaced and recorded in the `secrets` map used later by `redacted` / `redactText`.

### Strict apply

`cmdApply` calls `loadDesiredConfig(..., { strict: !options.dryRun })`. A real apply (`--yes`, not `--dry-run`) throws on the first missing set, before `resolveContext` or `putConfig`.

<ResponseExample>

```text
vwaffle: missing environment variables: INTERNAL_TOKEN ($.rules[1].conditionGroup[0].conditions[0].value). Set them before applying the firewall configuration.
```

</ResponseExample>

`main` prefixes every thrown error with `vwaffle: ` and sets `process.exitCode = 1`. The throw fires for a missing name anywhere in the file — `rules`, `ips`, `managedRules`, or any other string field.

### Non-strict plan and dry-run

`cmdPlan` always uses `strict: false`. `apply --dry-run` does too. Missing names do not abort the command:

<ResponseExample>

```text
vwaffle: warning: skipping rules that reference unset variables: GONE ($.rules[1].conditionGroup[0].conditions[0].value)
```

</ResponseExample>

The warning is written with `console.warn` (stderr). The command then continues with the config returned by `removeRulesWithMissingEnv`.

## Rules skipped by `removeRulesWithMissingEnv`

Non-strict loads call `removeRulesWithMissingEnv(config, missing)` only when `missing.size > 0`.

```
FirewallConfig after interpolate
  firewallEnabled     kept as interpolated
  managedRules        kept; leftover ${NAME} stays in strings
  ips[]               kept; leftover ${NAME} stays in strings
  rules[]             drop a rule if JSON.stringify(rule) contains ${UNSET_NAME}
  other keys          kept
```

Behavior:

- Variable names are recovered as `entry.split(' ')[0]`, so `GONE ($.rules[1])` becomes `GONE`.
- A custom rule is dropped when its serialized JSON still contains `` `${GONE}` `` (the unreplaced placeholder).
- Sibling rules that do not mention any missing name stay.
- If every matching rule is removed, `rules` becomes `[]` (an empty array, not omitted).
- If `rules` is missing or not an array, the function returns the config unchanged.
- `ips`, `managedRules`, `crs`, and top-level strings are never removed. Leftover placeholders in those fields remain in the object that `plan` diffs and that `apply --dry-run` prints.

<Warning>
`plan --check` diffs the **post-skip** desired config. A CI job that does not export the same `${VAR_NAME}` values used in `rules` drops those rules, then reports drift against live even when the committed file is correct.
</Warning>

Apply still refuses that file: `--yes` is strict and fails on any missing name, including placeholders that live only on `ips` or `managedRules`.

## `plan --check` exit 1 on drift

After interpolation (and any rule skip), `cmdPlan` fetches live via `getActiveConfig` and calls `diff(live, config)`.

| Diff result | Printed | `--check` |
| --- | --- | --- |
| identical JSON (`JSON.stringify` with tab indent) | `No drift detected.` (`NO_DRIFT`) | exit `0` |
| any line difference | unified live-vs-desired text from `src/diff.ts` | `process.exitCode = 1` |

`cmdPlan` does not call `process.exit(1)`. It assigns `process.exitCode` and returns; Node then exits with that code. Errors thrown earlier (bad JSON, missing file, missing `VERCEL_TOKEN`, unresolved project, Firewall API failure) also set `process.exitCode = 1` through `main().catch`.

<RequestExample>

```bash
npx vwaffle plan --check
npx vwaffle plan --check --config firewall.config.json
```

</RequestExample>

A matching file prints:

<ResponseExample>

```text
No drift detected.
```

</ResponseExample>

A mismatch prints a header plus context-limited `+` / `-` lines:

<ResponseExample>

```text
--- live firewall configuration
+++ desired firewall configuration
  {
- 	"firewallEnabled": false,
+ 	"firewallEnabled": true,
```

</ResponseExample>

`plan` prints the raw `diff(...)` string. It does not run `redacted`. Interpolated secret values that remain after a non-strict load can appear on the desired side of the plan output.

## Failure catalog

| Signal | Typical cause | Exit |
| --- | --- | --- |
| `vwaffle: warning: skipping rules that reference unset variables: NAME ($.path)` | `plan` or `apply --dry-run`; `process.env.NAME` is `undefined` | `0` unless `--check` then sees drift |
| `vwaffle: missing environment variables: NAME ($.path). Set them before applying...` | `apply --yes` with any unset placeholder | `1` |
| `No drift detected.` then exit `1` | does not happen; `NO_DRIFT` leaves the check branch off | `0` |
| live-vs-desired diff then exit `1` | `plan --check` and serialized live ≠ desired (including after skipped rules) | `1` |
| `vwaffle: apply requires --yes. Use --dry-run...` | `apply` with neither `--yes` nor `--dry-run` | `1` |
| `SyntaxError` / unexpected token via `vwaffle: ...` | `firewall.config.json` is not valid JSON (`readJson`) | `1` |
| `ENOENT: no such file or directory, open '...'` | `--config` path missing | `1` |

Authentication and API errors (`VERCEL_TOKEN is required`, unresolved project, `Vercel API <status> ...`) also exit `1` but are not interpolation failures.

## Recover unset interpolation

<Steps>
<Step title="Read the missing name and JSON path">
The warning or throw lists `NAME ($.path)`. Confirm the placeholder is `${NAME}` with a legal identifier, not `${name-with-dashes}`.
</Step>
<Step title="Export a defined value">
`export NAME=...` in the same process as the CLI. An unset variable is `undefined`; `NAME=` (empty) counts as set and will be interpolated.
</Step>
<Step title="Re-run the same command">
`vwaffle plan` should no longer warn. `vwaffle apply --yes` should get past `loadDesiredConfig` and print the live-vs-desired diff (then PUT if the configs differ).
</Step>
<Step title="For CI check jobs, export every rule secret">
`plan --check` must see the same environment as a successful apply. Otherwise skipped `rules` shrink desired and the job fails on drift.
</Step>
</Steps>

<Tip>
Use `vwaffle apply --dry-run` to print the post-interpolation, post-skip payload without calling Vercel. That payload is what a non-strict load would have planned.
</Tip>

## Related pages

<CardGroup>
<Card title="Secret interpolation" href="/secret-interpolation">
How `${VAR_NAME}` is expanded and how set values are redacted as `[REDACTED]`.
</Card>
<Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
`plan` versus `apply`, `--dry-run`, and the `--yes` confirmation required to PUT.
</Card>
<Card title="Detect drift in CI" href="/detect-drift-in-ci">
Run `plan --check` so a dashboard edit that diverges from the versioned file fails the build.
</Card>
<Card title="Environment variables" href="/environment-variables">
`VERCEL_TOKEN`, project/team IDs, and arbitrary interpolation values.
</Card>
<Card title="Authentication and context errors" href="/authentication-errors">
Missing token, unresolved project or team, and failed Firewall API requests.
</Card>
<Card title="Diff output" href="/diff-output">
How `diff` / `NO_DRIFT` render live-versus-desired changes.
</Card>
</CardGroup>
