> ## Documentation Index
> Fetch the complete documentation index at: https://sponson.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Plan format

> Every key of release.plan.yaml, the value forms, environments and the built-in ops.

This is the reference for the file. For a guided introduction read the [README](/); for worked plans see
[examples/](/reference/examples). The parser is `packages/core/src/plan.ts`; where this page and the parser
disagree, the parser is right and this page has a bug.

* [Editor support](/reference/plan-format#editor-support)
* [Top-level keys](/reference/plan-format#top-level-keys)
* [Change keys](/reference/plan-format#change-keys)
* [Values](/reference/plan-format#values)
* [Context interpolation: `${ctx.*}`](/reference/plan-format#context-interpolation-\$ctx-)
* [Environments](/reference/plan-format#environments)
* [Ordering: references and `depends_on`](/reference/plan-format#ordering-references-and-depends_on)
* [Built-in ops](/reference/plan-format#built-in-ops)
* [What is checked when](/reference/plan-format#what-is-checked-when)

## Editor support

[`schema/release.plan.schema.json`](https://github.com/MishaBear94/Sponson/blob/main/schema/release.plan.schema.json) is a JSON Schema (draft 2020-12) for the
file. Editors with the YAML language server (VS Code's YAML extension, Neovim's yamlls, JetBrains IDEs) pick it up
from a comment on the first line:

```yaml theme={null}
# yaml-language-server: $schema=https://raw.githubusercontent.com/MishaBear94/Sponson/main/schema/release.plan.schema.json
```

Inside this repository you can point at the file instead: `$schema=../schema/release.plan.schema.json` (relative to
the plan). The schema gives completion and hover text for every key below and flags most mistakes as you type. It
cannot check rules that compare one part of the file with another; see
[What the schema does not check](/reference/plan-format#what-the-schema-does-not-check).

## Top-level keys

```yaml theme={null}
version: 1                                # required
environments: [preview, production]       # optional
providers:                                # optional
  vercel: { project: prj_xxx, team: team_xxx }
  neon: { project: proj_xxx }
receipts: git-branch                      # optional
changes: []                               # required
```

| Key | Type | Default | Meaning |
| - | - | - | - |
| `version` | `1` | (required) | Format version. Anything else is `PLAN_INVALID`. |
| `environments` | non-empty list of names | `preview`, `production`, plus every name a line's `environments:` uses | The environments `--env` may name. An unknown `--env` is `ENV_UNKNOWN`; a line naming an undeclared environment is `PLAN_INVALID`. |
| `providers` | map: adapter name → map | `{}` | Static configuration per adapter, handed to the adapter as `providers.<adapter>`. Built-in: `vercel.project` (required by the Vercel ops), `vercel.team` (optional), `neon.project` (required by the Neon op). Clerk needs none. `http.<api>` holds one block per API (see [the generic `http` adapter](/reference/plan-format#the-generic-http-adapter)). A missing required key fails the line with `PLAN_INVALID` when the adapter runs. |
| `receipts` | `git-branch` \| `local` | `git-branch` | Where receipts are stored. `git-branch`: orphan branches `sponson-receipts/<env>/<scope>` (one per environment and scope, plus a lock-only `sponson-receipts/_locks/<hash>` per shared provider object a line writes, [ADR 0019](/contributing/adr/0019-parent-object-locks)) on the `origin` remote (override with `--receipts-remote` or `SPONSON_RECEIPTS_REMOTE`); with no remote, Sponson warns and uses `local`. `local`: `.sponson/receipts/` (override with `--receipts-dir` or `SPONSON_RECEIPTS_DIR`). The `--receipts` flag overrides this key. |
| `changes` | list of changes | (required) | The plan lines. Order in the file does not matter. |

The parser ignores top-level keys it does not know; the schema reports them, because they are almost always typos.

## Change keys

Each change ("line") is one adapter op:

```yaml theme={null}
- id: env                      # required
  adapter: vercel              # required
  op: env                      # required
  environments: [preview]      # optional
  depends_on: [db]             # optional
  target: preview              # every other key is a parameter of the op
  values:
    DATABASE_URL: { from: db.connection_string }
```

| Key | Type | Meaning |
| - | - | - |
| `id` | string matching `^[a-z][a-z0-9_-]*$` | Unique within the plan. Other lines refer to it in `from:` and `depends_on`. Receipts key line results by it. |
| `adapter` | non-empty string | A [built-in adapter](/reference/plan-format#built-in-ops) (`neon`, `vercel`, `clerk`, `http`, `manual`, …), or one added by a plugin (`SPONSON_PLUGINS`). Unknown: `ADAPTER_UNKNOWN`. |
| `op` | non-empty string | An op of that adapter. Unknown: `OP_UNKNOWN`. |
| `environments` | non-empty list of declared environment names | The line applies only in these environments. Absent: every environment. |
| `depends_on` | list of ids | Run after these lines although no value flows between them. Prefer `from:` references, which imply the order. |
| anything else | see [Values](/reference/plan-format#values) | A parameter of the op. The built-in ops' parameters are listed under [Built-in ops](/reference/plan-format#built-in-ops). |

## Values

Every parameter, at any depth (inside maps and lists too), takes one of these forms:

| Form | Example | Meaning |
| - | - | - |
| literal | `preview`, `3`, `true` | A string, number or boolean written in the file. |
| `{ from: <id>.<output> }` | `{ from: db.connection_string }` | Another line's output. Implies that this line runs after `<id>`. |
| `{ secret: "<scheme>://<ref>" }` | `{ secret: "env://STRIPE_SECRET_KEY" }` | A secret, resolved by Sponson at run time and never written anywhere. |
| `{ keep: true }` | `SENTRY_DSN: { keep: true }` | Whatever value the resource has live. Sponson manages that it exists and who owns it, not its value. |

### `{ from }` references

* The part before the first `.` must be the id of another line; the rest must be an output that line's op declares
  (see [the outputs table](/reference/plan-format#outputs)). An unknown id is `REF_UNKNOWN` at parse time; an unknown output is
  `REF_OUTPUT_UNKNOWN` when the plan is prepared, before any provider is called, and the message lists the valid
  outputs.
* A reference is the whole value. `{ from: env.preview_url }` cannot be combined with other text.
* Until the referenced output exists, the value is `pending`: `plan` shows the line as `pending` with `waitingOn`
  (and `waitingFor`, such as `deploy`, when the output depends on an external event). `apply` never sends a pending
  value to a provider.
* A sensitive output (for example `neon.branch`'s `connection_string`) can be referenced like any other, but its value
  is never displayed, logged or written to a receipt.

### `{ secret }` references

#### Secret schemes

The schemes that ship with Sponson (`createRegistry()` in `@sponson/adapters`):

| Scheme | Form | Resolved by |
| - | - | - |
| `env` | `env://NAME` | the process environment; unset or empty is `SECRET_UNRESOLVED` |
| `doppler` | `doppler://project/config/NAME` | `doppler secrets get NAME --project project --config config --plain` (Doppler CLI) |
| `op` | `op://vault/item/field` | `op read op://vault/item/field --no-newline` (1Password CLI) |
| `aws-sm` | `aws-sm://secret-id` or `aws-sm://secret-id#KEY` | `aws secretsmanager get-secret-value --secret-id secret-id --query SecretString --output json` (AWS CLI; region and credentials from `AWS_REGION`, `AWS_PROFILE` and the CLI's other conventions). `secret-id` is a name or an ARN; `#KEY` picks one key of a JSON key/value secret. Binary secrets are not supported. |
| `gcp-sm` | `gcp-sm://project/secret` or `gcp-sm://project/secret/version` | `gcloud secrets versions access version --secret=secret --project=project --format=json` (Google Cloud CLI; credentials from `gcloud auth` and the CLI's other conventions). `version` is a number or `latest` (the default). The payload must be UTF-8 text. |
| `vault` | `vault://mount/path#KEY` | HashiCorp Vault KV v2, `GET $VAULT_ADDR/v1/mount/data/path` with `X-Vault-Token: $VAULT_TOKEN` (and `X-Vault-Namespace: $VAULT_NAMESPACE` when set); the latest version's `KEY`. The mount is the first segment. |
| `infisical` | `infisical://projectId/env/NAME` or `infisical://projectId/env/folder/…/NAME` | `infisical secrets get NAME --projectId projectId --env env --path /folder/… --output json --silent` (Infisical CLI; credentials from `INFISICAL_TOKEN` or the CLI's login, the instance from `INFISICAL_DOMAIN`). A name the folder does not hold is `SECRET_UNRESOLVED`, never a value. |
| `azure-kv` | `azure-kv://vault/name` or `azure-kv://vault/name/version` | `az keyvault secret show --vault-name vault --name name [--version version] --query value --output json` (Azure CLI; credentials and cloud from `az login` and the CLI's other conventions). The version defaults to the latest. |
| `op-connect` | `op-connect://vault/item/field` or `op-connect://vault/item/section/field` | a 1Password Connect server, `GET $OP_CONNECT_HOST/v1/vaults/vault/items/item` with `Authorization: Bearer $OP_CONNECT_TOKEN`. `vault` and `item` are ids or unique names (looked up with `?filter=`); `section` and `field` are ids or labels. |
| `bws` | `bws://secret-id` | `bws secret get secret-id --output json --color no` (Bitwarden Secrets Manager CLI; the machine account's token from `BWS_ACCESS_TOKEN`, a self-hosted server from `BWS_SERVER_URL`). `secret-id` is the secret's UUID. |

Plugins can add schemes. A `secret:` that is not a URL (`scheme://…`) is `PLAN_INVALID`. Every command (`plan` and
`apply --destroy` included) resolves the secrets of the active lines first, so their values can be masked in all
output before anything is printed; a scheme backed by a CLI therefore runs that CLI on `plan` too. Only `apply`
passes the values to an adapter. A secret that cannot be resolved makes its line `blocked` (`SECRET_UNRESOLVED`).

**Secret-looking names take references, never literals.** A parameter whose name ends in `_KEY`, `_SECRET`,
`_TOKEN`, `PASSWORD`, `_PASS`, `PASSWD` or `PRIVATE` (any case) and whose value is a literal string or number is
rejected at parse time with `SECRET_LITERAL`. `{ from }`, `{ secret }` and `{ keep: true }` are allowed.

**Display text is not a value.** `plan` prints unresolved values as `(pending ← db.connection_string)` or
`(secret ← env://X)`. A string that starts like that (or like `<pending:` / `<secret>`) is rejected with
`PLAN_INVALID`: write the reference instead.

### `{ keep: true }`

`sponson init` writes `{ keep: true }` when it adopts existing variables, so that values are never copied into the
plan. Adapters treat it as "equal to whatever is live": the diff is `unchanged` and `apply` leaves the live value
alone. If the resource does not exist, there is nothing to keep and the line fails with `PARAM_INVALID`. Replace
`{ keep: true }` with a literal or a reference to let Sponson manage the value.

### Value states in `--json`

`plan --json` reports every input of every line under `lines[].inputs`, keyed by its dotted path (for example
`values.DATABASE_URL`), with an explicit state:

| `state` | `value` | When |
| - | - | - |
| `literal` | the value | written in the file |
| `resolved` | the value, or `null` when `sensitive: true` | a `from:` whose output exists now |
| `pending` | `null` | a `from:` whose output does not exist yet; `ref` names it |
| `secret` | `null` (always) | a `secret:`; `ref` is the URL |
| `kept` | `null` | `{ keep: true }` |

Unknown, secret and sensitive values are `null` next to their `state`, never placeholder text.

## Context interpolation: `${ctx.*}`

Strings in parameters may contain `${ctx.<name>}`. Interpolation happens before the adapter sees the parameters, in
values only (not in map keys, ids, `adapter`, `op`, `environments` or `providers`). These are the only variables;
there are no user-defined ones.

| Variable | Value |
| - | - |
| `${ctx.env}` | the run's environment (`--env`, default `preview`) |
| `${ctx.scope}` | the lifecycle unit: `pr-<n>` inside a pull request, `main` on `main`, `master` or the repository's default branch, `branch-<name>` otherwise (characters outside `[A-Za-z0-9._-]` become `-`) |
| `${ctx.git.branch}` | the git branch |
| `${ctx.git.sha}` | the full commit sha |
| `${ctx.git.short_sha}` | its first 7 characters |
| `${ctx.pr.number}` | the pull request number; **null outside a pull request**, which stops the command with `CTX_NULL` before any provider is called. Use `${ctx.scope}` when the plan also runs outside pull requests. |

An unknown variable is `CTX_NULL` too, and the message lists the valid ones.

The context comes from, in order of precedence: the flags `--env`, `--branch`, `--sha`, `--pr`; the variables
`SPONSON_CTX_ENV`, `SPONSON_CTX_BRANCH`, `SPONSON_CTX_SHA`, `SPONSON_CTX_PR` (`SPONSON_CTX_PR=""` means "no pull
request"); the CI host's predefined variables (GitHub Actions' variables and event payload; GitLab CI/CD's
`CI_MERGE_REQUEST_IID`, `CI_MERGE_REQUEST_SOURCE_BRANCH_NAME`/`_SHA`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_SHA` and
`CI_DEFAULT_BRANCH`; CircleCI's `CIRCLE_PULL_REQUEST`, `CIRCLE_BRANCH` (`CIRCLE_TAG` in a tag build),
`CIRCLE_SHA1`; Bitbucket Pipelines' `BITBUCKET_PR_ID`, `BITBUCKET_BRANCH` (`BITBUCKET_TAG` in a tag pipeline),
`BITBUCKET_COMMIT`); the local git checkout; and, for the pull request number
outside CI, `gh pr view`. The environment is never inferred: without `--env` it is `preview`.

## Environments

A plan is one flat list. `environments:` on a line filters it: `sponson plan --env production` sees only the lines
whose `environments` include `production`, plus the lines without `environments`. Write the same variable twice, once
per environment, rather than reaching for anchors or expressions:

```yaml theme={null}
- id: env-preview
  adapter: vercel
  op: env
  target: preview
  values: { DATABASE_URL: { from: db.connection_string } }
  environments: [preview]

- id: env-production
  adapter: vercel
  op: env
  target: production
  values: { DATABASE_URL: { secret: "env://PRODUCTION_DATABASE_URL" } }
  environments: [production]
```

A line may not reference a line that the filter removed: that is `REF_FILTERED`, with a message saying which filter to
change.

**Approval.** A run needs an approver (`--approved-by <who>` or `SPONSON_APPROVED_BY`) when it can write to
production: when `--env production`, or when any active line writes to production whatever the run's environment.
An op decides that from the line or from the provider block it writes through, before any provider call:

| Op | Writes production when |
| - | - |
| `vercel.env` | `target: production` (or no `target` under `--env production`) |
| `vercel.deploy` | `--env production` |
| `launchdarkly.flag_target` | `providers.launchdarkly.production: true`, or, without that key, an `environment` key containing `prod` in any case (`production`, `prod-eu`); `production: false` opts a key such as `preprod` out |
| `netlify.env` | `context: production` (or no `context` under `--env production`) |
| `cloudflare.pages_env` | `target: production` (or no `target` under `--env production`) |
| `http.resource`, `http.list_item` | the line's API block says `production: true` (a recipe line's resolved block too) |

Without an approver, `apply` is refused with `ENV_NOT_APPROVED` before any provider is called. `plan --json` reports
`requiresApproval: true` in both cases. A blank name is no name.

The other ops never decide by themselves: `neon.branch`, `supabase.branch` and `planetscale.branch` create preview
branches, never the production one. Lines that write a production object through a credential or a name the plan
cannot judge (a Clerk production instance's `sk_live_` key, a `planetscale.password` on the production branch, the
parent project's `supabase.auth_redirect` allow-list) are gated only when they run under `--env production`: give
such lines `environments: [production]`.

YAML anchors and aliases are allowed but produce the warning `YAML_ANCHOR`: repeated lines are easier to review.

## Ordering: references and `depends_on`

Sponson orders the active lines so that each comes after every line it references with `from:` and every line in its
`depends_on`. A cycle is `REF_CYCLE`, and the message prints it (`a → b → a`). Ties are broken deterministically, so
the same plan always runs in the same order. Lines whose input depends on an external event (a deploy) are not
special in the file: Sponson stops at them and the next `apply` continues (see
[ADR 0004](/contributing/adr/0004-apply-phases-inferred-from-output-availability)).

## Built-in ops

The adapters that ship with Sponson (`createRegistry()` in `@sponson/adapters`) and their ops. Plugins loaded with
`SPONSON_PLUGINS` can add more. Credentials come from each provider's own variable; Sponson has no credential store.
The base URL override replaces the provider's API base URL (the test suites point it at the local fake cloud).

| Adapter | Ops | Credential | Base URL override |
| - | - | - | - |
| `neon` | [`neon.branch`](/reference/plan-format#neon-branch) | `NEON_API_KEY` | `NEON_API_URL` |
| `vercel` | [`vercel.env`](/reference/plan-format#vercel-env), [`vercel.deploy`](/reference/plan-format#vercel-deploy) | `VERCEL_TOKEN` | `VERCEL_API_URL` |
| `clerk` | [`clerk.redirect_allow`](/reference/plan-format#clerk-redirect_allow) | `CLERK_SECRET_KEY` | `CLERK_API_URL` |
| `planetscale` | [`planetscale.branch`](/reference/plan-format#planetscale-branch), [`planetscale.password`](/reference/plan-format#planetscale-password) | `PLANETSCALE_SERVICE_TOKEN_ID` + `PLANETSCALE_SERVICE_TOKEN` | `PLANETSCALE_API_URL` |
| `launchdarkly` | [`launchdarkly.flag_target`](/reference/plan-format#launchdarkly-flag_target) | `LAUNCHDARKLY_ACCESS_TOKEN` | `LAUNCHDARKLY_API_URL` |
| `http` | [`http.resource`](/reference/plan-format#http-resource), [`http.list_item`](/reference/plan-format#http-list_item), [`http.action`](/reference/plan-format#http-action) | `providers.http.<api>.auth` | `providers.http.<api>.base_url_env` |
| `supabase` | [`supabase.branch`](/reference/plan-format#supabase-branch), [`supabase.auth_redirect`](/reference/plan-format#supabase-auth_redirect) | `SUPABASE_ACCESS_TOKEN` | `SUPABASE_API_URL` |
| `netlify` | [`netlify.env`](/reference/plan-format#netlify-env) | `NETLIFY_AUTH_TOKEN` | `NETLIFY_API_URL` |
| `cloudflare` | [`cloudflare.pages_env`](/reference/plan-format#cloudflare-pages_env) | `CLOUDFLARE_API_TOKEN` | `CLOUDFLARE_API_URL` |
| `manual` | [`manual.step`](/reference/plan-format#manual-step) | `none (verify: providers.manual.<api>.auth, optional)` | `providers.manual.<api>.base_url_env` |
| `algolia` | [`algolia.index`](/reference/plan-format#algolia-index) | `ALGOLIA_API_KEY` | `ALGOLIA_API_URL` |
| `xata` | [`xata.branch`](/reference/plan-format#xata-branch) | `XATA_API_KEY` | `XATA_API_URL` |
| `digitalocean` | [`digitalocean.app`](/reference/plan-format#digitalocean-app) | `DIGITALOCEAN_ACCESS_TOKEN` | `DIGITALOCEAN_API_URL` |
| `cloud_run` | [`cloud_run.revision`](/reference/plan-format#cloud_run-revision) | the token `gcloud auth print-access-token` prints (bearer) | `CLOUD_RUN_API_URL` |
| `contentful` | [`contentful.environment`](/reference/plan-format#contentful-environment), [`contentful.api_key_access`](/reference/plan-format#contentful-api_key_access) | `CONTENTFUL_MANAGEMENT_TOKEN` | `CONTENTFUL_API_URL` |

A missing credential fails the line with `PROVIDER_AUTH`. Every resource has a key that identifies it within the
adapter and provider block; the ledger treats two resources with the same key as the same resource.

### `neon.branch`

A Neon branch. Requires `providers.neon.project`.

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `name` | string | `sponson/${ctx.env}/${ctx.scope}` | Branch name; the branch's identity (key `branch:<name>`). |
| `parent` | string | the project's default branch | Parent branch, by name or id. Omitted: the branch the project marks `default` (a new Neon project calls it `production`; older projects often `main`), whatever its name. Used only when the branch is created; a different parent on an existing branch is not a change. An unknown parent is `PARAM_INVALID`. |

`connection_string` is the URI for the branch's database `neondb` (a new Neon project's default) or, when it has no
such database, its first one, connecting as the role that owns that database.
Destroy deletes the branch. For drift and adoption, every branch except the project's root branch is in scope.

### `vercel.env`

Environment variables of one Vercel target and, for `preview`, one git branch. Requires `providers.vercel.project`;
`providers.vercel.team` is sent as `teamId` when present.

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `target` | `preview` \| `production` \| `development` | `production` when `--env production`, else `preview` | The Vercel target. Anything else is `PARAM_INVALID`. `production` requires approval (see [Environments](/reference/plan-format#environments)). |
| `branch` | string | the current git branch, for `target: preview` | The git branch the variables apply to. `"*"` (or `""`) means project-wide: every preview deployment sees them. A specific branch is only valid with `target: preview`, and only on a project connected to a Git repository: Vercel refuses branch-bound values otherwise, and the line fails with `PARAM_INVALID` (connect the repository, or set `branch: "*"`). |
| `values` | map: NAME → value | `{}` | The variables. Each value is a literal, `{ from }`, `{ secret }` or `{ keep: true }`. All values are written as encrypted and shown as sensitive in diffs. |

Each variable is a resource with key `env:<target>:<branch or *>:<NAME>`. A dashboard variable that one record shares
between several targets cannot be managed by a line that owns one target; the line fails with `PROVIDER_CONFLICT` and
asks you to split it. After writing, if the newest deployment of this commit for the line's environment (production for
`target: production`, a preview otherwise) started before the write (and has not failed), the adapter triggers a
redeploy so the build sees the new values, and the receipt line gets `notes.redeployed: true`.

`preview_url` and `deployment_id` are external outputs: they become available when the newest deployment of the
current commit for the line's environment is `READY` — a commit's preview build is never taken for its production
build, or the other way round. `target: development` variables belong to no deployment: writing them never
triggers a redeploy, and a line that reads their `preview_url` fails with `PARAM_INVALID`. A deployment that ends `ERROR`, `CANCELED`, `BLOCKED` or `DELETED` fails the lines waiting on it
(`EXTERNAL_FAILED`). Project-wide variables belong to the scope that applied them first and are destroyed with it;
see [examples/README.md](/reference/examples#vercel-shared-and-branch-vars-plan-yaml).

### `vercel.deploy`

A deployment of the current commit, started by Sponson through the project's Git connection (GitHub, GitLab or
Bitbucket: the connected repository at the context's branch and sha; target `production` when `--env production`).
A project with no Git connection fails with `PROVIDER_INVALID`. Takes no parameters. Requires
`providers.vercel.project`. Its resource key is `deployment:<production|preview>:<sha>`: a commit's production and
preview deployments are different resources.

If a deployment of this commit for the run's environment exists and has not failed, the op watches it instead of starting another. `apply`
waits until it is `READY`, for at most `SPONSON_DEPLOY_TIMEOUT_MS` (default 120000), then fails with `WAIT_TIMEOUT`.
Its outputs are immediate, so a plan with an explicit deploy line has no external barrier. Destroy leaves deployments
alone: they are history.

### `clerk.redirect_allow`

One URL on the Clerk instance's redirect allow-list (the instance `CLERK_SECRET_KEY` belongs to).

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `url` | string | (required) | The URL; its identity (key `redirect:<url>`). Usually `{ from: <vercel.env line>.preview_url }`. |

A URL that is already on the list is taken over, not duplicated. Destroy removes the URL. For drift and adoption,
every URL on the instance's list is in scope.

### `netlify.env`

Environment variable values of one Netlify deploy context. Requires `providers.netlify.site` (the site's Project ID,
`site_id`); `providers.netlify.account` (the team's slug or id) is optional, and looked up from the site when absent.

Netlify keeps one variable per name with one value per deploy context. A line owns **one context's value** of each
variable it declares, never the variable: values someone set for other contexts are left alone, and so is the
variable itself unless the line created it and nothing else holds a value in it.

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `context` | `production` \| `deploy-preview` \| `branch-deploy` \| `dev` \| `dev-server` \| `branch` | `production` when `--env production`, else `branch` | The deploy context. `branch` is a value for one branch, which Netlify uses for that branch's Deploy Previews, branch deploys and permalinks, so two pull requests never see each other's values. `deploy-preview` is one value for every Deploy Preview of the site. `production` requires approval (see [Environments](/reference/plan-format#environments)). One value for all contexts (`all`) cannot be owned by a line. |
| `branch` | string | the current git branch, for `context: branch` | The branch (Netlify's `context_parameter`; a trailing `*` matches a prefix, e.g. `release/*`). Only valid with `context: branch`. |
| `values` | map: NAME → value | `{}` | The variables. Each value is a literal, `{ from }`, `{ secret }` or `{ keep: true }`. Values are shown as sensitive in diffs. |

Each value is a resource with key `env:<context>:<branch or *>:<NAME>`; drift compares a hash of the value. A
variable created by Sponson is not marked secret (its values stay readable to the token, which is what lets
Sponson see drift). A variable already marked *Contains secret values* can be written, but Netlify never shows its
values again (except in `dev`), so such a value is written on every apply. A variable that has one value for all
deploy contexts fails the line with `PROVIDER_CONFLICT`: give it per-context values in the Netlify UI first.
Destroy deletes the line's values one by one, then deletes a variable only when no value is left in it.

A variable is shared by every scope that writes a value of it, so `netlify.env` lines lock the site's variables,
parent object `netlify:<site>:env` ([ADR 0019](/contributing/adr/0019-parent-object-locks)): lines of different pull requests
on one site write, roll back and destroy one at a time. Two pull requests creating the same variable at once, or one
destroying the variable's last value while another adds its own, therefore never lose a value. The lock is per site,
not per variable, because a lock names one object per line and a line declares several variables; lines of other
sites never wait for each other.

`preview_url`, `deploy_id` and `deploy_preview_url` are external outputs: they become available when the newest
deploy of the current commit that reads the line's context (production deploys for `production`, Deploy Previews
for `deploy-preview`, branch deploys for `branch-deploy`, and both for `branch`) is `ready` **and started after the
line's variables last changed** — a build reads the values set when it started, so an older deploy is never
reported. `preview_url` is the deploy's permalink, `https://<deploy id>--<site name>.netlify.app`;
`deploy_preview_url` is the pull request's stable URL, `https://deploy-preview-<n>--<site name>.netlify.app`, for a
Deploy Preview, and the permalink otherwise. A deploy that ends `error` or `rejected` fails the lines waiting on it
(`EXTERNAL_FAILED`); `dev` and `dev-server` values belong to no deploy, so reading their `preview_url` is
`PARAM_INVALID`.

After writing, if a deploy of this commit for the line's context started before the write, the adapter starts a new
build (`POST /sites/{site_id}/builds`: production for `production`, a branch deploy of the branch otherwise) and the
receipt line gets `notes.redeployed: true`. Netlify's API cannot rebuild a Deploy Preview, so a `deploy-preview`
line records `notes.stale_deploy: <deploy id>` instead and waits for a newer deploy: retry the deploy in the
Netlify UI or push a commit. For drift and adoption, every value of the line's context (and branch) is in scope.

### `planetscale.branch`

A development branch of a PlanetScale database (Vitess / MySQL), one per scope. Requires
`providers.planetscale.organization` and `providers.planetscale.database`; the credential is a service token, two
variables sent together as `Authorization: <PLANETSCALE_SERVICE_TOKEN_ID>:<PLANETSCALE_SERVICE_TOKEN>`. The token
needs the `create_branch`, `read_branch`, `delete_branch`, `connect_branch` and `delete_branch_password` accesses on
the database.

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `name` | string | `sponson-${ctx.env}-${ctx.scope}`, lowercased, anything but letters, digits and dashes turned into `-` | Branch name; the branch's identity (key `branch:<name>`). |
| `parent` | string | `main` | Parent branch, by name. Used only when the branch is created; a different parent on an existing branch is not a change. An unknown parent is `PARAM_INVALID`. |

PlanetScale provisions a new branch asynchronously: `apply` polls it every `SPONSON_PLANETSCALE_POLL_MS` (default
2000\) until it reports `ready`, for at most `SPONSON_PLANETSCALE_READY_TIMEOUT_MS` (default 600000), then fails with
`WAIT_TIMEOUT` (the run's rollback deletes the branch it created). A create that meets a branch of the same name (the
answer to an earlier create of ours was lost, or someone else made it) takes that branch instead of failing; whether
it is Sponson's is decided by the ledger's intent, as for every adapter. Outputs: `name` (pass it to
`planetscale.password`) and `branch_id`.

A branch deleted in the console is `missing` drift and re-created by the next apply; one deleted and re-created
under the same name is `changed` drift (a new id), refused until `--reconcile`, which takes it over as adopted.
Destroy deletes the branch by name, and only if it is still the one the ledger recorded. For drift and adoption,
every non-production branch of the database is in scope.

### `planetscale.password`

A password (credential) on a PlanetScale branch, and the connection string built from it.

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `branch` | string | (required) | The branch, by name. Usually `{ from: <planetscale.branch line>.name }`. |
| `name` | string | `sponson-${ctx.env}-${ctx.scope}`, as for the branch | The password's name on that branch; with the branch, its identity (key `password:<branch>/<name>`). |
| `role` | `reader` \| `writer` \| `readwriter` \| `admin` | `admin` (as `pscale password create`) | The password's database role. |
| `connection_params` | string | `ssl={"rejectUnauthorized":true}` | The query of `connection_string` (the form PlanetScale documents for Node.js drivers). Prisma wants `sslaccept=strict`. |

`connection_string` is `mysql://<username>:<password>@<access host>/<database>?<connection_params>`. Before
creating the password, `apply` waits for the branch to be ready (an earlier run may have left it provisioning).

**The plaintext exists once.** PlanetScale returns a password's plaintext only in the answer that creates it; no
later request can read it, and Sponson never stores it. So `password` and `connection_string` are sensitive
[once-only outputs](/reference/plan-format#once-only-outputs):

* In the run that creates the password, they reach the lines that reference them, like any output. Put those
  lines in the same plan (a `vercel.env` line with `DATABASE_URL: { from: dbpw.connection_string }`).
* In every later run they are `{ keep: true }` for those lines: a variable that already holds the value is unchanged,
  so re-applying an unchanged plan writes nothing.
* A line that would have to write the value again (its variable was deleted, or a new line references the password
  after it was created) is **refused** with `OUTPUT_UNAVAILABLE`, nothing written. Sponson never re-creates or
  renews a password on its own to get a value back: that would rotate a credential something may still be using.
  To rotate deliberately, delete the password in PlanetScale (`missing` drift: the next apply creates a new one and
  passes it on in that run) or change its `name` in the plan.

A different `role` on an existing password is `PARAM_INVALID` at plan time: PlanetScale cannot change a role in
place, and replacing the password is the plan's decision (a new `name`), not Sponson's. Two passwords with the line's
name on one branch are `PROVIDER_CONFLICT` (PlanetScale does not keep names unique). Destroy deletes the password;
destroying the branch deletes the rest. Passwords are not listed for drift or adoption. PlanetScale Postgres
databases use roles instead of passwords and are not supported by this op.

### `launchdarkly.flag_target`

Serves one variation of a LaunchDarkly feature flag to one individual target, a context key of a context kind, in one
environment: typically a flag turned on for a pull request's preview. Requires `providers.launchdarkly.project` (the
project key) and `providers.launchdarkly.environment` (the environment key, usually a preview or test environment).
The flag must already exist; Sponson never creates or deletes flags, and never turns a flag on or off.

A block whose environment is production needs [approval](/reference/plan-format#environments) for every run, even under `--env preview`:
`production: true` in the block says so; without the key, an environment key containing `prod` (any case) counts as
production, and `production: false` says a key such as `preprod` is not. The provider block is part of each target's
identity, so set `production` before the first apply (adding it later re-identifies the targets, as editing any
provider key does).

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `flag` | string | (required) | The flag key. An unknown flag, or an environment the project lacks, is `PROVIDER_NOT_FOUND`. |
| `key` | string | `${ctx.scope}` (`pr-42`) | The context key to target, e.g. `{ from: <vercel.env line>.preview_url }`. |
| `context_kind` | string | `user` | The context kind of `key` (for a URL, a kind such as `url` that your application evaluates). |
| `variation` | any | `true` | The variation to serve, by name (`"Treatment"`) or by value (`true`, `"treatment"`, `42`). One that matches no variation, or more than one, is `PARAM_INVALID` at plan time. `{ keep: true }` keeps whatever variation the target is served. |

The resource key is `target:<flag>:<context_kind>:<key>`; the project and environment are the provider block, which
the ledger keys every resource by, so the same target in another environment is another resource. The resource's hash
covers the variation (by its id): a human who moves the target to another variation in the LaunchDarkly console makes
`changed` drift, refused until `--reconcile`; a human who removes it makes `missing` drift, and the next apply adds it
back. A target that already exists is taken over, not duplicated; one in another variation is moved (a single
semantic patch removes it from the old variation and adds it to the new one). Destroy removes exactly this target,
from whichever variation serves it; the flag's other targets and rules are untouched. An environment that requires
approvals for flag changes fails the line with `PROVIDER_INVALID` (Sponson does not open approval requests). For drift
and adoption, every individual target of the line's flag in the environment is in scope.

### `supabase.branch`

A Supabase preview branch ([Branching](https://supabase.com/docs/guides/deployment/branching)) of the project
`providers.supabase.project` (the parent project's ref; branching must be enabled on it). Each branch is a project of
its own, with its own ref, API URL and database.

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `name` | string | `sponson-${ctx.env}-${ctx.scope}` | Branch name; the branch's identity (key `branch:<name>`). The project's default branch is never managed: naming it is `PARAM_INVALID`. |

`apply` creates the branch, then waits until its project is `ACTIVE_HEALTHY`, for at most
`SPONSON_SUPABASE_TIMEOUT_MS` (default 600000), and fails with `WAIT_TIMEOUT` after that; the next `apply` keeps
waiting for the same branch instead of creating another. A branch that ends `INIT_FAILED` (or another state it does
not leave by itself) fails the line with `PROVIDER_INVALID`.

Outputs: `project_ref` (the branch's own ref), `api_url` (`https://<project_ref>.supabase.co`, for
`NEXT_PUBLIC_SUPABASE_URL`), `db_host`, and `connection_string`, sensitive: the branch's direct connection,
`postgresql://<db_user>:<db_pass>@<db_host>:<db_port>/postgres`. Supabase's direct host resolves over IPv6 unless the
project has the IPv4 add-on. Destroy deletes the branch. For drift and adoption, every branch except the project's
default one is in scope; a branch deleted and re-created under the same name is `changed` drift (a new ref).

### `supabase.auth_redirect`

One URL in a project's Auth redirect allow-list ("Redirect URLs"), which the Management API stores as one
comma-separated string, `uri_allow_list`.

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `url` | string | (required) | The URL or pattern (Supabase accepts wildcards such as `https://*-acme.vercel.app/**`); its identity (key `redirect:<url>`). A comma or surrounding whitespace is `PARAM_INVALID`. Usually `{ from: <vercel.env line>.preview_url }`. |
| `project` | string | `providers.supabase.project` | The ref of the project whose allow-list to edit. A preview branch is a project with its own Auth config, so a preview that talks to its branch sets `project: { from: db.project_ref }`. When set, it is part of the key (`redirect:<project>:<url>`). |

`apply` reads the list, appends the URL and writes the whole field back (a `PATCH` carrying only `uri_allow_list`),
then reads it again to confirm. The API offers no precondition, so every pull request and environment adding a URL
to one project's list writes it holding a lock on that list, `supabase:<project ref>:auth-uri-allow-list`
([ADR 0019](/contributing/adr/0019-parent-object-locks)): Sponson's writers never lose each other's entries. An edit made in the
dashboard at the same moment can still overwrite the write; the re-read notices and the round is repeated (three
times, then `PROVIDER_CONFLICT`). A URL already on the list is taken over, not duplicated. Destroy removes exactly that entry and leaves every other entry in place; when the project is
already gone (a branch deleted first), there is nothing to remove. For drift and adoption, every URL on the list of
`providers.supabase.project` is in scope.

### The generic `http` adapter

For the long tail of APIs that have no adapter of their own (feature flags, webhooks, allow-lists, CORS origins,
per-environment config): the plan describes the requests, and Sponson keeps every rule it keeps for the built-in
adapters (intents before creates, idempotent apply, drift by hash, destroy only what it created, secrets as
references). Why it exists and what it deliberately does not do: [ADR 0017](/contributing/adr/0017-generic-http-adapter).
It speaks JSON (or form-encoded, or XML) REST and GraphQL; an API whose lifecycle is more than create, read, update
and delete wants a first-class adapter.

Each API is a block under `providers.http`, and each line names one with `api:`. A block is part of the identity of
the resources made through it: editing it (a new base URL, another credential variable, a header) re-identifies
them, as moving a Vercel line to another project would.

```yaml theme={null}
providers:
  http:
    statsig:
      base_url: https://statsigapi.net/console/v1
      auth: { header: STATSIG-API-KEY, value_env: STATSIG_CONSOLE_KEY }
    stripe:
      base_url: https://api.stripe.com/v1
      auth: { bearer_env: STRIPE_SECRET_KEY }
      encoding: form
```

| Key | Type | Meaning |
| - | - | - |
| `base_url` | http(s) URL | Request paths are relative to it. |
| `base_url_env` | variable name | Optional. When this variable is set, it replaces `base_url` (a staging API; the test suites point it at the local fake cloud). |
| `auth` | map | How requests are authenticated: one of the forms under [Authentication](/reference/plan-format#authentication). Always variable NAMES, never values; a credential's name must end in `TOKEN`, `SECRET`, `PASSWORD`, `PASSWD`, `_KEY` or `APIKEY` so that its value is masked in every output. Unset: `PROVIDER_AUTH`. |
| `headers` | map: header → string | Optional static headers (an API version pin, Google's `x-goog-user-project`). `Authorization`, `Accept` and `Content-Type` are Sponson's. |
| `encoding` | `json` \| `form` \| `xml` | Request bodies as JSON (default), `application/x-www-form-urlencoded` (nested as `a[b]=c` and `a[0]=c`), or XML. Answers are read as JSON, except with `xml`; see [XML APIs](/reference/plan-format#xml-apis). |
| `form_arrays` | `brackets` \| `repeat` | Optional, `form` only: a list as `a[0]=x&a[1]=y` (`brackets`, the default, as Stripe reads it) or as its key repeated, `a=x&a=y` (`repeat`, OpenAPI's `style: form, explode: true`, as Mailgun reads `action` and `event_types`). |
| `media_type` | media type | Optional, JSON only: a versioned media type sent as `Accept` and as the `Content-Type` of bodies (MongoDB Atlas: `application/vnd.atlas.2023-01-01+json`). |
| `production` | boolean | Optional. `true` for a production system (a live Stripe account, a production tenant): every line through this API needs [approval](/reference/plan-format#environments), whatever the run's environment. |
| `refuse_credential` | `{ prefixes, unless_env }` | Optional. A credential whose value starts with one of `prefixes` (Stripe's `sk_live_`, `rk_live_`) is refused with `PROVIDER_AUTH` before any request (plan, apply and destroy alike), unless the variable `unless_env` is `1` or `true`. A recipe's refusal cannot be overridden by the block. |

A malformed block, or a line naming an API that is not configured, is `PLAN_INVALID` before any request is sent;
a malformed line is `PARAM_INVALID` naming the field. Paths take `{id}` (the object's id, once known) and
`{name}` placeholders filled from the line's `vars:` (literals or references; URI-encoded as one path segment, or
written as they are with `{+name}`, for an API that takes a URL in the path). Fields are a top-level
name (`url`) or a JSON pointer (`/config/url`); write a key that is literally `from`, `secret` or `keep` as a pointer
(`/from`), since a map holding one of those keys is a [reference](/reference/plan-format#values).

#### Authentication

| Form | Sends | For |
| - | - | - |
| `{ bearer_env: NAME }` | `Authorization: Bearer <value>` | most APIs |
| `{ header: X-Api-Key, value_env: NAME, prefix? }` | the value in that header, after `prefix` when given (literal text: `"Api-Key "`, `"Token "`) | API-key headers; `Authorization: Api-Key <key>` (Flagsmith); `Authorization` with a token that carries its own scheme (Fly.io's `FlyV1 …`) |
| `{ headers: [{ header, value_env, prefix? }, …] }` | each of those headers | APIs that want two keys at once (Datadog's `DD-API-KEY` and `DD-APPLICATION-KEY`) |
| `{ basic: { user_env, password_env } }` or `{ basic: { user, password_env } }` | HTTP Basic | `user` is a literal user name, for an API whose documented user is fixed (Mailgun: `api`, the key as the password) |
| `{ oauth2_client_credentials: { token_url, client_id_env, client_secret_env, audience?, scope?, extra?, auth_method?, token_url_env? } }` | `Authorization: Bearer <access token>` from the token endpoint | Auth0 Management API, Kinde, Okta custom authorization servers, MongoDB Atlas service accounts |
| `{ command: [argv…] }` | `Authorization: Bearer <what the command prints>` | Google Cloud (`gcloud auth print-access-token`), Azure (`az account get-access-token --query accessToken -o tsv`) |
| `{ aws_sigv4: { region, service, access_key_id_env, secret_access_key_env, session_token_env? } }` | every request signed with AWS Signature Version 4 | S3, Amplify, any AWS REST API |
| `{ digest: { user_env, password_env } }` | HTTP Digest (RFC 7616: MD5 or SHA-256, `qop=auth`), answering the server's challenge | MongoDB Atlas Admin API keys |

* **OAuth2 client credentials** (RFC 6749 §4.4): the token endpoint gets a form with `grant_type=client_credentials`,
  `audience` and `scope` when set, the `extra` parameters, and the client's credentials in the form
  (`auth_method: client_secret_post`, the default) or as HTTP Basic (`client_secret_basic`). `private_key_jwt` is not
  supported (Okta's own management API requires it for OAuth; its `SSWS` API tokens use the header form with
  `prefix: "SSWS "`). `token_url_env` overrides `token_url` when set, as `base_url_env` does. A `token_url` or
  `audience` written as a path (`/oauth/token`, `/api/v2/`) is resolved against the origin of the block's own
  `base_url` (not its `base_url_env` override), so a per-account API names its account once: `base_url:
  https://acme.eu.auth0.com/api/v2` with `token_url: /oauth/token` and `audience: /api/v2/` sends
  `https://acme.eu.auth0.com/api/v2/` as the audience to `https://acme.eu.auth0.com/oauth/token`.
* **Command**: a list of literal arguments, run without a shell with the run's environment; the first word is a
  program on `PATH`. An argument cannot be a reference, a `${…}` or a `$VAR`: nothing in it is expanded, so it is
  refused rather than passed on as text. Its standard output, trimmed, is the token; nothing printed, more than one
  word, a non-zero exit or a missing program is `PROVIDER_AUTH`.
* **SigV4**: the signature covers the method, path, query, every header Sponson sends and the body's SHA-256
  (`X-Amz-Content-Sha256`); S3 requests with a body also get `Content-MD5`, which its configuration writes require.
  No AWS SDK, no profile or instance-role lookup: the keys come from the variables named.
* **Digest**: the first request of a run goes without credentials; the server's 401 brings the challenge, which
  every later request of the run answers up front.
* Tokens obtained at run time (an access token, a printed token) are fetched once per run and API block, kept in
  memory only, registered with the redactor before first use (so no output, receipt or error can contain them), and
  fetched again, once, when the API answers 401. None of these forms puts anything secret in the block, which is the
  ledger identity of the API's resources.

```yaml theme={null}
providers:
  http:
    auth0:                       # Management API: callbacks, logout URLs, web origins (http.list_item)
      base_url: https://acme.eu.auth0.com/api/v2
      auth:
        oauth2_client_credentials:
          token_url: https://acme.eu.auth0.com/oauth/token
          client_id_env: AUTH0_CLIENT_ID
          client_secret_env: AUTH0_CLIENT_SECRET
          audience: https://acme.eu.auth0.com/api/v2/
    kinde:
      base_url: https://acme.kinde.com/api/v1
      auth:
        oauth2_client_credentials:
          token_url: https://acme.kinde.com/oauth2/token
          client_id_env: KINDE_M2M_CLIENT_ID
          client_secret_env: KINDE_M2M_CLIENT_SECRET
          audience: https://acme.kinde.com/api
    firebase:                    # Identity Toolkit: authorized domains
      base_url: https://identitytoolkit.googleapis.com/admin/v2
      auth: { command: [gcloud, auth, print-access-token] }
      headers: { x-goog-user-project: my-project }   # needed with user credentials, not with a service account
    azure:
      base_url: https://management.azure.com
      auth: { command: [az, account, get-access-token, --query, accessToken, -o, tsv] }
    atlas:
      base_url: https://cloud.mongodb.com/api/atlas/v2
      media_type: application/vnd.atlas.2023-01-01+json
      auth: { digest: { user_env: ATLAS_PUBLIC_KEY, password_env: ATLAS_PRIVATE_KEY } }
    amplify:
      base_url: https://amplify.us-east-1.amazonaws.com
      auth:
        aws_sigv4: { region: us-east-1, service: amplify, access_key_id_env: AWS_ACCESS_KEY_ID, secret_access_key_env: AWS_SECRET_ACCESS_KEY }
```

#### XML APIs

With `encoding: xml`, bodies are written and answers read as XML mapped to the shapes the rest of this section uses:
an element with children is a map, a repeated child is a list, text is a string (XML has no numbers or booleans:
write `MaxAgeSeconds: "3000"`), an attribute is an `@name` key and an element's own text next to attributes is
`#text`. A body is a map with one key, the root element. A child that appears once reads back as a value, not a
list of one. `http.list_item` treats a lone element where it expects a list as a list of one: the collection itself,
and a field its map item declares as a list (`AllowedMethod: [GET]` compares equal to one `<AllowedMethod>`); in
`http.resource`, declare a single value as a value. DTDs and entity declarations are refused. The S3 bucket CORS rule of a
preview, kept with `http.list_item`:

```yaml theme={null}
providers:
  http:
    s3:
      base_url: https://assets.s3.us-east-1.amazonaws.com
      encoding: xml
      auth:
        aws_sigv4: { region: us-east-1, service: s3, access_key_id_env: AWS_ACCESS_KEY_ID, secret_access_key_env: AWS_SECRET_ACCESS_KEY }
changes:
  - id: cors
    adapter: http
    op: list_item
    api: s3
    # GET answers 404 until a bucket has a configuration; S3 refuses an empty one, so the last rule is removed with DELETE.
    parent: { path: "/?cors", method: PUT, if_missing: empty, when_empty: delete }
    list_path: /CORSConfiguration/CORSRule
    key_field: ID
    item: { ID: "sponson-${ctx.scope}", AllowedOrigin: "https://${ctx.scope}.preview.example.app", AllowedMethod: [GET, HEAD], MaxAgeSeconds: "3000" }
```

#### Line credentials

Some APIs authenticate with a credential that another line makes: Postmark's Server API takes the token of the
server a `postmark.server` line creates per environment. A line of either op may take `credential: { from: <line>.<output> }` (or `credential: { secret: … }`), which replaces the value of its API block's variable for that line
only; the block's header name and `prefix` stay. Rules, all checked before any request
([ADR 0017](/contributing/adr/0017-generic-http-adapter), "Amendment: line credentials"):

* The block's `auth` must be a single static credential (`bearer_env`, or `header` with `value_env`).
* A `from` must name an output declared `sensitive` (it would otherwise reach receipts) and not `once` (later runs
  would only have `{ keep: true }`, which cannot authenticate a request). A literal is refused: it would be a
  credential in the plan file. The value is masked like any secret.
* The line must say `destroy: keep`. Destroy works from the ledger alone, which never holds the credential: what the
  line made goes away with the object the credential belongs to (deleting a Postmark server removes its webhooks),
  or stays when that object is kept.
* It is not part of the line's identity (the API block, the key and the record id are those of the same line without
  it), so one plan must not manage the same key through two different credentials.
* While the producing line is still to be created, the credential is pending: nothing is read with it (nothing can
  exist under a credential that does not exist yet), and the line is applied after its producer.

```yaml theme={null}
changes:
  - id: server
    adapter: http
    op: resource
    recipe: postmark.server
    name: "sponson-${ctx.env}-${ctx.scope}"
  - id: hook
    adapter: http
    op: resource
    recipe: postmark_server.webhook
    url: "https://${ctx.scope}.preview.example.app/api/postmark"
    credential: { from: server.server_token }
    destroy: keep
```

### `http.resource`

One object per item: a feature flag, a webhook endpoint, a DNS record.

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `api` | string | (required) | The API block under `providers.http`. |
| `vars` | map | `{}` | Values for `{name}` placeholders in paths. |
| `find` | `{ path, list_path?, match, next?, cursor_param? }` | — | Locate the object by natural key: GET `path`, take the array at `list_path` (default: the whole body), keep the objects whose fields equal every `match` value. More than one is `PARAM_INVALID`. With `next` (a pointer to the next page's cursor), pages are followed, sending the cursor as `cursor_param` (default `cursor`); without it only the first page is read. |
| `read` | `{ path }` | — | GET the object. With `find`, the path takes `{id}` and fetches the found object's detail; without `find`, it must be a path that names the object (a client-chosen id or key). One of `find` and `read` is required. 404 means it does not exist. |
| `create` | `{ method?, path, body?, content_type?, idempotency_key?, locate?, headers?, send_fields? }` | (required) | `POST` (or `PUT`, `PATCH`) to create. The body is `match`, then `fields`, then `body` merged (`send_fields: false`: `body` alone, for an API that takes the declared fields in headers). `headers` are sent with this request only (values may be references; not `Authorization`, `Accept`, `Content-Type` or `Idempotency-Key`). `idempotency_key: true` sends an `Idempotency-Key` derived from the resource key, the commit and the body, and lets a create whose connection dropped be sent again. `locate: true` for an API whose create answers with something other than the object (the parent it was added to): the new object is then located with `find` (or `read`) instead. |
| `update` | `{ method?, path?, body?, content_type?, headers?, send_fields? }` | none | `PATCH` (or `PUT`, `POST`) when `fields` differ from live; path defaults to `read.path`. `PATCH` and `POST` send the declared fields; `PUT` sends the whole declared state (`match`, `fields`, kept values); `send_fields: false` sends `body` alone. `headers` as for `create`. Without `update`, a difference is `PARAM_INVALID`. |
| `delete` | `{ method?, path?, body? }` | `DELETE read.path` | How the object is removed (`DELETE` or `POST`). A `POST` with a `body` of literal values archives instead (`{ active: false }`, for objects a provider never deletes), or, when a string in it holds `{id}`, deletes the object the body names (Datadog's `{ public_ids: ["{id}"] }`); the body, `{id}` filled in, is part of the record id. The answer is not read: any 2xx is success, JSON or not (`OK`). |
| `credential` | `{ from }` or `{ secret }` | — | This line's own credential, in place of the API block's variable: see [Line credentials](/reference/plan-format#line-credentials). |
| `destroy` | `delete` \| `keep` | `delete` | `keep` leaves the object in place when the scope is destroyed. |
| `item_path` | JSON pointer | `""` | Where the object is in read, create and update answers (`/data` for a `{ data: {...} }` envelope). |
| `id_path` | JSON pointer | `/id` | Where the id is in the object. |
| `fields` | map: field → value | `{}` | The desired state, compared for drift and sent on create and update. Values are literals, `{ from }`, `{ secret }` or `{ keep: true }`. |
| `outputs` | map: name → pointer, or `{ path, sensitive: true, once?: true }` | `{}` | Outputs read from the object, besides `id` (always there). A sensitive one is never shown or written to a receipt. `once: true` (with `sensitive: true`) is a value the provider shows only in the answer to the create, such as an API key's token: it is taken from that answer only (also when the answer holds nothing else and the object is then found with `find`), never from a read, and later runs treat references to it as [once-only outputs](/reference/plan-format#once-only-outputs). |
| `exists_status` | list of statuses | `[]` | Besides 409, the statuses a create answers when the object exists already: it is then found and taken over, not created. |
| `gone_status` | list of statuses | `[]` | Besides 404, the statuses that mean "gone" on read and delete. |

Key: `<find.path>[<field>=<value>,…]` when located by `find`, else `read.path` (for GraphQL, see below). The record
id is the request that removes the object (`DELETE /flags/<id>`), so destroy needs only the ledger; a new id means it
was deleted and re-created outside Sponson. **Drift** covers the declared `fields` only, hashed as canonical JSON: a console edit of
a declared field is `changed`, an edit of anything else is not Sponson's business, a deleted object is `missing`.
Write values with the type the API returns (`true`, not `"true"`), or every plan shows an update. A value the API
never returns (a write-only secret) belongs in `create.body`, which is sent but not compared. An object that exists
before the first apply is taken over as adopted, never deleted. Not listed for drift or adoption: a collection holds
much that is not this release's.

#### GraphQL requests

Any of `find`, `read`, `create`, `update` and `delete` may be `{ graphql: { query, variables?, data_path?,
idempotent? } }` instead of a method and a path (nothing else next to it). The request is a `POST` of
`{ query, variables }` as JSON to the API's `base_url` (the GraphQL endpoint), whatever the block's `encoding`.
What it answers is `data`; a non-empty `errors` array fails the request with a provider error classified from the
message and `extensions.code` (not found, conflict, not authorised — Railway answers a refused token with
`INTERNAL_SERVER_ERROR` "Not Authorized" — rate limited, otherwise rejected).

| Key | Meaning |
| - | - |
| `query` | The document: one operation, its inputs passed as `$variables` (never written inline). |
| `variables` | The operation's variables: literals, `${ctx.*}`, `{ from }`, `{ secret }`. A string that is exactly `"{id}"` is the object's id (in `read` after `find`, `update` and `delete`). |
| `data_path` | JSON pointer into `data`: the object (`read`, `create`, `update`; default `item_path`) or the list (`find`; default `find.list_path`). A scalar there is read as `{ value: <scalar> }`, so `fields: { value: … }` compares it; nothing there means the object does not exist. |
| `idempotent` | `true` when the mutation sets state (an upsert): a dropped connection is then retried. Updates and deletes always are. |

The key of a GraphQL-located object is `graphql:<root field>(<variables as canonical JSON>)<data path>`, followed by
`[<field>=<value>,…]` for a `find`: rewording a query does not re-identify it, changing its variables does. The
record id is the delete request itself, `GRAPHQL {"query":…,"variables":…}`. A `{ secret }` in the variables of
`find`, `read` or `delete` is refused: they become the key or the record id, which receipts keep. An object with no
id at `id_path` (a variable is named by the request) uses its key as its `id`, unless a request needs `{id}`. A
`find` by GraphQL reads one answer: there are no pages. Fields of the mutation's variables are not filled from
`fields`: write each value in both places (the plan stays plain YAML).

Railway (variables of the PR environment Railway created; queries from Railway's public API and CLI):

```yaml theme={null}
providers:
  http:
    railway:
      base_url: https://backboard.railway.com/graphql/v2
      auth: { bearer_env: RAILWAY_TOKEN }
changes:
  - id: api_url
    adapter: http
    op: resource
    api: railway
    read:
      graphql:
        query: "query variables($projectId: String!, $environmentId: String!, $serviceId: String) { variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId) }"
        variables: { projectId: prj_123, environmentId: env_pr42, serviceId: svc_web }
        data_path: /variables/API_URL
    create:
      graphql:
        query: "mutation variableUpsert($input: VariableUpsertInput!) { variableUpsert(input: $input) }"
        variables: { input: { projectId: prj_123, environmentId: env_pr42, serviceId: svc_web, name: API_URL, value: "https://api-${ctx.scope}.example.app" } }
    update:
      graphql:
        query: "mutation variableUpsert($input: VariableUpsertInput!) { variableUpsert(input: $input) }"
        variables: { input: { projectId: prj_123, environmentId: env_pr42, serviceId: svc_web, name: API_URL, value: "https://api-${ctx.scope}.example.app" } }
    delete:
      graphql:
        query: "mutation variableDelete($input: VariableDeleteInput!) { variableDelete(input: $input) }"
        variables: { input: { projectId: prj_123, environmentId: env_pr42, serviceId: svc_web, name: API_URL } }
    fields: { value: "https://api-${ctx.scope}.example.app" }
```

Fly.io (an app secret; operations from Fly's Go client). Fly never returns a secret's value, so `fields` is empty
and a changed value is not detected: rotate it by destroying and applying the line.

```yaml theme={null}
providers:
  http:
    fly:
      base_url: https://api.fly.io/graphql
      auth: { header: Authorization, value_env: FLY_API_TOKEN }   # "FlyV1 fm2_…" as `fly tokens create` prints it
changes:
  - id: database_url
    adapter: http
    op: resource
    api: fly
    find:
      graphql:
        query: "query ($appName: String!) { app(name: $appName) { secrets { name digest createdAt } } }"
        variables: { appName: "web-${ctx.scope}" }
        data_path: /app/secrets
      match: { name: DATABASE_URL }
    create:
      graphql:
        query: "mutation($input: SetSecretsInput!) { setSecrets(input: $input) { release { id version } } }"
        variables: { input: { appId: "web-${ctx.scope}", secrets: [{ key: DATABASE_URL, value: { from: db.connection_string } }] } }
    delete:
      graphql:
        query: "mutation($input: UnsetSecretsInput!) { unsetSecrets(input: $input) { release { id version } } }"
        variables: { input: { appId: "web-${ctx.scope}", keys: [DATABASE_URL] } }
```

### `http.list_item`

One value kept in a collection on a parent object: an allowed origin, a callback URL, a config var. The parent is
read, the collection changed, and the result written back; then the parent is read again, and the write counts only
once the item is there as planned (written again, up to three times, if another writer dropped it).

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `api` | string | (required) | The API block under `providers.http`. |
| `vars` | map | `{}` | Values for `{name}` placeholders in paths. |
| `parent` | `{ path, read_path?, read_method?, method?, send?, item_path?, content_type?, if_missing?, when_empty?, write_list_path? }` | (required) | GET `read_path` (default `path`; `read_method: POST` reads it with a POST without a body, for an API whose read is an action that changes nothing, such as Azure Resource Manager's `listAppSettings`), write with `method` (`PATCH` by default, `PUT`, `POST`) to `path`. `item_path` is where the parent is in the read answer (`/feature` for `{ feature: {...} }`, Amplify's `/branch`; default the whole body); `list_path` and `send` are relative to it. `send: field` (default) sends only the collection at `list_path` (a `map` entry alone, as a merge patch); `send: collection` sends only the collection, a `map` whole (an API that replaces the map it is sent, as Amplify's `UpdateBranch` does); `send: parent` sends the whole object read just before, with the collection replaced; a list of fields (names or pointers) sends those fields of it as read, with the collection: for a `PUT` that resets what it is not sent but refuses or ignores read-only fields. `if_missing: empty` reads a parent answering 404 as an empty collection, and `when_empty: delete` removes the collection with `DELETE path` instead of writing it empty (S3 CORS, which has no configuration until the first rule and refuses an empty one). `write_list_path` puts the collection at another pointer of the write than `list_path` (Kinde reads `{ redirect_urls }` and replaces them with `{ urls }`); it joins the record id only when set. |
| `list_path` | JSON pointer | (required except for `map`) | Where the collection is in the parent. `""` is the whole parent (a map of config vars). |
| `shape` | `array` \| `delimited` \| `map` | `array` | A JSON array; one string of values joined by `separator` (a comma-separated allow-list); or a map of name → value, where an entry is written as a merge patch and `null` deletes it (config vars). |
| `separator` | string | `,` | For `delimited`. |
| `item` | value or map | (required) | `array`: the value, or a map whose `key_field` identifies it; `delimited`: the string; `map`: the entry's name. |
| `key_field` | field | — | Required when an array `item` is a map: the field that identifies it (`name`, `/match/host`). |
| `value` | value | — | Required for `map`: the entry's value (a literal, `{ from }`, `{ secret }`, `{ keep: true }`). |
| `destroy` | `delete` \| `keep` | `delete` | `keep` leaves the item in place when the scope is destroyed. |
| `credential` | `{ from }` or `{ secret }` | — | This line's own credential, in place of the API block's variable: see [Line credentials](/reference/plan-format#line-credentials). |

Key: `<parent.path>#<list_path>=<item>`. **Drift**: the item missing from the collection is `missing`; for map items
and keyed entries, a declared field or value edited in the console is `changed`. Items no line declares are left
exactly as they are, and are reported as `unmanaged`. Writes replace the whole collection (except keyed maps sent
as `field`), so they are sent as idempotent and retried after a dropped connection. There is no precondition, so
every write holds a lock on the parent object ([ADR 0019](/contributing/adr/0019-parent-object-locks)), named
`http:<base_url><parent.path>` (the block's `base_url` without user info, query or fragment, and the filled path;
never a credential): runs in other pull requests and environments that edit the same parent wait for each other
and never lose an item. A writer outside Sponson editing the parent at the same moment can still drop one; the
re-read puts it back, or reports `PROVIDER_CONFLICT` after three rounds.

### `http.action`

One request sent when its trigger changes, for a side effect with nothing to read back or delete: an Inngest sync of
a preview deployment, a cache purge, a deploy notification. The line declares the values that should make it run
again (`trigger`); the ledger records the hash of the last trigger that ran in the scope, and `apply` sends the
request only when the current one has not ([ADR 0022](/contributing/adr/0022-one-off-actions)).

```yaml theme={null}
- id: purge
  adapter: http
  op: action
  api: cdn
  trigger: { sha: "${ctx.git.sha}", url: { from: web.preview_url } }
  vars: { zone: z1 }
  request: { method: POST, path: "/zones/{zone}/purge", body: { everything: true }, idempotency_key: true }
  expect: { /result/ok: true }
  outputs: { purge_id: /result/id }
  undo: { method: DELETE, path: "/zones/{zone}/purges/{purge_id}" }

- id: sync                            # a request to the app itself: no `api`, no credential
  adapter: http
  op: action
  trigger: { url: { from: web.preview_url }, sha: "${ctx.git.sha}" }
  request: { method: PUT, url: { from: web.preview_url } }
```

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `trigger` | map | (required) | The values whose change makes it run again: literals, `${ctx.*}`, `{ from }` (the line is `pending` until they are known, so an action on a preview URL waits for the deploy), even `{ secret }` (hashed, never shown). Hashed into the ledger (keyed, like every hash); the values are never stored. Only the trigger decides: a value used in the request but not in the trigger does not make it run again. |
| `request` | map | (required) | `method` (`POST` by default, `GET`, `PUT`, `PATCH`, `DELETE`) and exactly one of `path` (relative to the API's `base_url`, sent with its credential and headers), `url` (an absolute http(s) URL, usually built from a preview URL, sent with **no** credential and none of the API's headers) or `graphql` (`{ query, variables, data_path? }`, a POST to the API's `base_url`). Optional `body` (values may be references), `headers` (values may be references and secrets; not `Authorization`, `Accept`, `Content-Type`, `Idempotency-Key`), `content_type`, and `idempotency_key: true`: an `Idempotency-Key` header derived from the scope, the ledger key and the trigger hash, so a run sent again after a crash is answered once. |
| `api` | string | — | The API block under `providers.http`, for `path` and `graphql` requests. A line whose requests all use `url` takes none (`PARAM_INVALID` if it names one): a URL resolved at run time never receives an API's credential. |
| `vars` | map | `{}` | Values for `{name}` placeholders in paths. |
| `ok_status` | list of statuses 400–499 | `[]` | Statuses that mean success too (an "already done" 409); there is then no answer to read. Any 2xx succeeds. |
| `expect` | map: JSON pointer → value | `{}` | What the answer must hold (`{ /data/status: success }`); otherwise the action failed (`PROVIDER_RESPONSE`) and nothing is recorded. |
| `outputs` | map: name → JSON pointer | `{}` | Values of the answer other lines can read with `{ from }`. Never sensitive: an unchanged line passes on the values it recorded. |
| `undo` | map | none | A request of the same form (without `idempotency_key`) that `apply --destroy` sends; its `path` may use the outputs as placeholders (`{purge_id}`). Resolved when the action runs and kept in the record, so it may hold no secret. Without it, destroy only forgets the record. |

Key: `action:<line id>` in the line's API block (or in no block, for a `url` line); renaming the line makes its old
record an orphan, whose undo `apply --destroy` still sends. What each command does:

| Command | An action line |
| - | - |
| `plan` | `run` (symbol `>`) when no run of the current trigger is recorded in the scope (the diff's `after` is the trigger, `sensitive` if it holds a secret), `unchanged` when it is, `pending` while a trigger value (or a value the request needs) is not known. There is no drift: nothing can be read back. Exit 0. |
| `apply` | `run`: the request is sent, the answer checked (status, `expect`), then the trigger hash, the undo and the outputs recorded (`applied`, with `notes.action.ranAt`). `unchanged`: nothing is sent; the recorded outputs reach the lines that read them. `--recreate <line>` sends it again although its trigger ran. |
| failure | Nothing is recorded before the request is sent, and no intent either: a run that crashes, or whose answer fails `expect`, sends it again on the next apply (at least once; use `idempotency_key` where the API takes one). Rollback never undoes an action, whatever fails after it. |
| `apply --destroy` | sends `undo` if there is one (a 404 counts as done), then forgets the record. |

Another scope's record of the same action does not block it (`OWNED_BY_OTHER_SCOPE` is for what a line owns at a
provider; an action owns nothing). Recipes may be actions too: [`inngest.sync`](/reference/recipes#inngest-sync).

### `manual.step`

A change a person makes by hand, because no API can: a Google OAuth web client's redirect URIs, a Clerk webhook
endpoint, a Stripe sandbox. Sponson cannot do the step; it says exactly what to do for this environment, holds back
the lines that depend on it, records who did it and when, notices when it must be done again, and asks for it to be
undone when the scope is destroyed ([ADR 0021](/contributing/adr/0021-manual-steps)).

```yaml theme={null}
- id: google-redirect
  adapter: manual
  op: step
  title: "Allow ${ctx.scope}'s OAuth callback on the Google OAuth client"
  vars: { url: { from: web.preview_url } }
  instructions: |
    In Google Cloud console > APIs & Services > Credentials, open the OAuth client "acme-web" and add
    {url}/api/auth/callback/google under "Authorized redirect URIs". Save.
  undo: Remove {url}/api/auth/callback/google from the client's "Authorized redirect URIs".
```

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `title` | string | (required) | One line naming the step; its identity (key `step:<title>`). |
| `instructions` | Markdown | (required) | What to do, shown whole to the person. A `{ from }` reference works too. |
| `undo` | Markdown | none | What to do when the scope is destroyed. Without it, destroy forgets the step. |
| `vars` | map | `{}` | Values for `{name}` placeholders in `title`, `instructions`, `undo` and `verify.path`: literals or `{ from }` references. A placeholder not in `vars` is `PARAM_INVALID`. |
| `verify` | `{ api, path, match?, absent_status? }` | none | A GET that tells whether the step is done: `path` on the API block `providers.manual.<api>`; done when it answers 2xx and every `match` (JSON pointer → value) holds; 404, or a status in `absent_status`, means not yet. |

`${ctx.*}` works in every text. A `{ secret }` or `{ keep: true }` anywhere in the line is `PARAM_INVALID`: the text
is shown to people and kept in receipts. The step has no outputs; other lines wait for it with `depends_on`.

`providers.manual.<api>` blocks have the [`http` adapter's format](/reference/plan-format#the-generic-http-adapter) (`base_url`,
`base_url_env`, `headers`, `encoding`), except that `auth` is optional, for public endpoints such as a DNS-over-HTTPS
resolver. Steps without `verify` need no block.

**How a step is done.** The step's hash covers its title and rendered instructions. It is done when its verify
request sees it, or when a person confirmed these very instructions: `sponson apply --confirm <line>` (repeatable,
or comma-separated; the MCP tool's `confirm`). The confirmation is recorded in the ledger with who confirmed it
(`--approved-by`, or else the git user's `user.name` or `user.email`; with neither, `USAGE`) and when. Confirming a
line that is not a manual step is `USAGE`. Instructions that change (a new preview URL) make it a step to do again.

| Command | A step that is not done |
| - | - |
| `plan` | `todo`, with `manual: { line, title, action: do, instructions, observable }`; exit 0. While an input is pending, `pending` as usual. |
| `apply` | without `--confirm`: the line is `waiting` (`waitingFor: confirmation`, `MANUAL_STEP_PENDING`), so are the lines that depend on it, every other line is applied, nothing is rolled back; the receipt is `partial`, and the command exits 2 with `error.code: MANUAL_STEP_PENDING` and `manual` (each step's instructions). With `--confirm <line>`: recorded, `applied`. |
| `apply --destroy` | a recorded step with `undo` waits the same way (`action: undo`) until `apply --destroy --confirm <line>`; one without `undo` is forgotten. Nothing is deleted at any provider. |

A verified step that its verify request no longer sees is `missing` drift, and a todo again; a person's confirmation
counts even when the verify request does not (yet) agree. A failed run never rolls a manual step back, and
`--recreate` refuses one. Agents show the instructions to a person and never confirm on their own (see
[SKILL.md](/guides/agents)).

### Recipes

A recipe is a verified `http.resource`, `http.list_item` or `http.action` spec for one provider operation, shipped with Sponson:
the line names it and fills in typed params instead of writing requests. Every recipe, its params, an example line
and what it was verified against: [docs/recipes.md](/reference/recipes).

```yaml theme={null}
- id: preview_dns
  adapter: http
  op: resource                        # the recipe's kind
  recipe: cloudflare.dns_cname        # <provider>.<op>
  zone_id: 023e105f4ecef8ad9ca31a8372d0c353
  name: "${ctx.scope}.preview.example.com"
  target: preview-host.example.net
```

`api` defaults to the recipe's provider, and its block under `providers.http` to `{ recipe: <provider> }`: the
recipe's base URL, credential variable, headers and encoding. Write the block to override any of them
(`cloudflare: { recipe: cloudflare, auth: { bearer_env: MY_CLOUDFLARE_TOKEN } }`); a hand-written line may name such a
block too. Every key of the line other than `api`, `recipe`, `destroy` and `credential` is a recipe param; an unknown recipe, op or
param, a missing required one or a wrong value is `PARAM_INVALID` naming it before any request. The ledger records
the resolved block and the keys the expanded requests produce, never the recipe's text
([ADR 0020](/contributing/adr/0020-recipes)).

### `cloudflare.pages_env`

Variables of a Cloudflare Pages project's `preview` or `production` deployment configuration. Requires
`providers.cloudflare.account` (the account id) and `providers.cloudflare.project` (the Pages project name), and an
API token in `CLOUDFLARE_API_TOKEN` with the account permission "Cloudflare Pages Edit".

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `target` | `preview` \| `production` | `production` when `--env production`, else `preview` | The variable set. It must be the run's environment (`--env`); anything else is `PARAM_INVALID` (see below). `production` requires approval (see [Environments](/reference/plan-format#environments)). |
| `vars` | map: NAME → value | `{}` | Plain-text variables (`plain_text`). Each value is a literal, `{ from }`, `{ secret }` or `{ keep: true }`. Diffs show the new value; the old one is shown as sensitive. |
| `secrets` | map: NAME → value | `{}` | Secret variables (`secret_text`), shown as sensitive. A name may not be in both maps. |
| `rewrite_secrets` | boolean | `false` | Send every secret on every apply (see "Secrets" below). |

Each variable is a resource with key `env:<target>:<NAME>`. The variables are one map on the project, so `apply`
reads the project, PATCHes only the keys this line writes (Cloudflare merges the map; a key set to `null` is deleted),
then reads it again and fails with `PROVIDER_RESPONSE` if a key it sent is not there as sent or a key it did not send
has disappeared. Keys this line does not declare are never written. Destroy sets this line's keys to `null`; a key or
project that is already gone is success. For drift and adoption, every variable of the line's environment is in
scope; `sponson init` adopts them as `{ keep: true }`, plain ones under `vars` and secret ones under `secrets`.

**Preview variables are shared.** Pages has one set of preview variables for every preview deployment of the
project, whatever the branch; there are no per-branch values. A key is therefore one resource in the `preview`
environment, not one per pull request. The first scope that writes it owns it; another scope (a second pull request)
that declares the same key with the same value relies on it unchanged, and one that declares a different value is
refused with `OWNED_BY_OTHER_SCOPE` until the owner's scope is destroyed. So per-PR values (`${ctx.pr.number}`, a PR's
database URL) do not belong in Pages preview variables: two open pull requests would conflict. The owner's destroy
removes a key a second scope relied on. Because ownership is decided among the scopes of one Sponson environment, the
line's `target` must equal the run's `--env`: a run of any other environment fails the line with `PARAM_INVALID`.

**Locked map.** The variables of one environment are one map on one project object, written by read-modify-write, so
the line names it as a shared parent object (`lockOn`, [ADR 0019](/contributing/adr/0019-parent-object-locks)):
`cloudflare:<account>:pages:<project>:<target>:env`. Runs of every scope that write that map (apply, rollback,
`apply --destroy`) take turns, so one run's re-read never mistakes another's concurrent write or destroy for a lost
key. Writers outside Sponson are not serialised: Cloudflare offers no precondition (ETag) on the PATCH.

**Secrets.** Cloudflare never returns a `secret_text` value, so a secret is compared by presence and type only:

* a secret that exists as `secret_text` is `unchanged`, whatever value the plan has. A new value in the plan (a
  rotated `{ secret }`) is not written unless the line sets `rewrite_secrets: true`, which sends every secret of the
  line on every apply (one PATCH; never reported as drift). Without it, rotate by setting `rewrite_secrets: true` for
  one apply.
* a secret's value changed in the dashboard is not detected; deleting it is `missing` drift (recreated), and turning it
  into plain text, or a plain variable into a secret, is `changed` drift.

Plain variables are compared by type and value: a dashboard edit is `changed` drift, refused until `--reconcile`.

Variables apply to the next deployment; Sponson does not redeploy a Pages project, and this op has no outputs. The
branch alias URL of a preview (`<alias>.<project>.pages.dev`) is not an output: Cloudflare documents that the alias is
the branch name lowercased with other characters replaced by `-`, but not how long names are shortened or collisions
resolved.

### `algolia.index`

An Algolia index per scope, copied from a template index (`POST /1/indexes/{copy_from}/operation`). The copy is an
asynchronous task: `apply` polls it until it is published, so the index is ready when the line is applied (a copy
not published within `SPONSON_ALGOLIA_TASK_TIMEOUT_MS`, five minutes by default, is `WAIT_TIMEOUT`; the next run
finds the copy). Requires `providers.algolia.application_id` and `ALGOLIA_API_KEY` with the addObject, deleteIndex
and settings ACLs.

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `name` | string | (required) | The new index, such as `products_${ctx.scope}`; its identity (key `index:<name>`). |
| `copy_from` | string | (required) | The template index. It must exist (copying a missing index would make an empty one), and differ from `name`. |
| `scope` | list of `settings`, `synonyms`, `rules` | everything | Copy only these, without records. |

**Drift**: only existence. An index does not record what it was copied from, so changing `copy_from` or `scope`
later copies nothing, and an edit of the copy's records or settings is not drift; a deleted copy is `missing` and
is copied again. Destroy deletes the copy, never the template. Not listed for drift or adoption.

### `xata.branch`

A copy-on-write Postgres branch per scope on the Xata platform, a child of a parent branch
(`POST /organizations/{org}/projects/{project}/branches`, `mode: inherit`). A new branch takes a moment to come up:
`apply` polls it until its status is healthy (a fault fails the line; not healthy within
`SPONSON_XATA_READY_TIMEOUT_MS`, ten minutes by default, is `WAIT_TIMEOUT`, and the next run finds the branch by
name), then reads its credentials. Requires `providers.xata.organization` and `providers.xata.project` (ids) and
`XATA_API_KEY`.

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `name` | string | (required) | The branch's name, such as `${ctx.scope}`; its identity (key `branch:<name>`). Xata does not keep names unique, so two branches with this name are refused (`PARAM_INVALID`), never guessed between. |
| `parent_id` | string | (required) | The id of the branch to copy (`XATA_BRANCH_ID` of the parent), not its name. |

Outputs: `branch_id`, and `connection_string` (sensitive), read from the credentials endpoint on every run, so a
dependent added later gets it too. **Drift**: only existence. The parent is used when the branch is created and a
branch cannot be moved, so changing `parent_id` later changes nothing; a deleted branch is `missing` and is created
again (with a new connection string, passed on in that run). Destroy deletes the branch, never the parent. Not
listed for drift or adoption.

### `digitalocean.app`

A DigitalOcean App Platform app per scope, created from an app spec the line carries whole, its environment
variables included (App Platform has no pull request previews of its own; this is the app per pull request that
DigitalOcean's GitHub action otherwise makes). Every create and update starts a deployment, which `apply` waits for
until it is `ACTIVE` (`ERROR` or `CANCELED` fails the line; not live within `SPONSON_DIGITALOCEAN_DEPLOY_TIMEOUT_MS`,
twenty minutes by default, is `WAIT_TIMEOUT`). Requires `DIGITALOCEAN_ACCESS_TOKEN` with the app scopes; no
provider block.

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `spec` | map | (required) | The whole [app spec](https://docs.digitalocean.com/products/app-platform/reference/app-spec/). `spec.name` (unique in the account) is the app's identity (key `app:<name>`), such as `web-${ctx.scope}`. Values may be literals, `${ctx.*}`, `{ from }` or `{ secret }`, at any depth. |
| `wait` | boolean | `true` | Wait for the deployment. |

Outputs: `app_id`, and `live_url` once the app has been deployed. **Drift** covers the fields the spec declares, read
back from the live spec: the platform's defaults and anything else not declared are ignored, and the values of
`type: SECRET` variables are not compared (they read back encrypted), so a console edit of a declared field is
`changed`, an edit of a secret's value is not seen. An update sends the whole spec (`PUT`): a field set only in the
console is removed by the next update. Destroy deletes the app. Not listed for drift or adoption.

### `cloud_run.revision`

A revision of an existing Google Cloud Run service per scope, with the line's image and environment variables,
reachable at its own tag URL and serving no traffic: what `gcloud run deploy --no-traffic --tag pr-42 --update-env-vars …` does ([ADR 0023](/contributing/adr/0023-cloud-run-revisions)). Requires `providers.cloud_run.project` and
`providers.cloud_run.region`, and `gcloud` on PATH, logged in to an account allowed to update the service (the
adapter sends the token `gcloud auth print-access-token` prints; in GitHub Actions, `google-github-actions/auth`
followed by `setup-gcloud`). The service must exist: Sponson makes revisions of it, never the service.

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `service` | string | (required) | The service's name. |
| `image` | string | (required) | The container image, such as `us-docker.pkg.dev/<project>/<repo>/app:${ctx.git.sha}` or a `{ from }`. |
| `env` | map: NAME → value | `{}` | Variables set on the revision's container, over the ones the service's template has. Each value is a literal, `{ from }` or `{ secret }` (resolved and set as a plain value, which anyone who can view the service sees; for Secret Manager references, keep them in the service's template). `{ keep: true }` is refused: a revision is made whole. |
| `tag` | string | the scope (`pr-42`) | The traffic tag, the prefix of the revision's URL: lowercase letters, digits and `-`. With the service's name it names the revision, `<service>-<tag>-<hash>`, at most 63 characters. |
| `container` | string | the only container, or the one with `ports` | Which container of a multi-container service gets the image and variables. |
| `pin_latest` | boolean | `false` | Allow pinning a share of traffic on LATEST to the revision serving it (see below). Without it, a service whose traffic follows LATEST is refused. |

The revision is made from the service's current template with the image and variables set (everything else, other
variables included, as the template has it), by a `PATCH` with `updateMask=template` and the service's `etag`, then
tagged by a `PATCH` of `traffic`; `apply` waits for each long-running operation and for the revision to be ready
(failing to start fails the line with Cloud Run's message; not done within `SPONSON_CLOUD_RUN_TIMEOUT_MS`, ten
minutes by default, is `WAIT_TIMEOUT`, and the next run carries on). A patch refused because the service changed
meanwhile is read and tried again. Key `revision:<service>:<tag>`.

**Traffic is never moved.** Every share stays where it was. When traffic is on named revisions, nothing more is
needed. When a share follows LATEST (or `traffic` is empty, which means 100% to LATEST), a new revision would take it,
so the line is refused by `plan` and `apply` before any write (`PARAM_INVALID` on `pin_latest`) unless it sets
`pin_latest: true`. With it, the share on LATEST is bound to the revision serving it first, exactly as `--no-traffic`
does, so the new revision gets none, with the consequence gcloud's has: later production deploys no longer receive
traffic by themselves (run `gcloud run services update-traffic <service> --to-latest`, or deploy with `--no-traffic`
and send traffic explicitly). The alternative is to pin it yourself once
(`gcloud run services update-traffic <service> --to-revisions=<revision>=100`). `pin_latest` is a permission, not
state: it is not part of the resource's hash, and a line with nothing to write is not refused. The service's template is left as the preview revision's,
as after any `gcloud run deploy --tag`, so a production deploy that sets only some variables (`--update-env-vars`)
inherits the preview's others: set every variable in production deploys (`--set-env-vars`, or
`gcloud run services replace`). A line writes no environment, so it needs no approval, unless the block says
`production: true` (a service that is production for this plan), which makes every run with the line need approval.

Outputs: `revision` (its name) and `url`, the tag's URL exactly as Cloud Run answers it in `trafficStatuses`. **Drift**
covers the tagged revision's image and the values of the declared variables. Revisions are immutable, so a console
edit is a redeploy under the tag, which is `changed`; reconciling puts the tag back on the plan's revision (found by
its name, not made again). A removed tag is `missing`, and the next apply tags the revision again (Cloud Run also
deletes tags without a traffic share by itself past 2000 per project and region). **Destroy** removes the tag, leaving
traffic shares and other tags alone; the revision is left for Cloud Run, which deletes "non-addressable" revisions
past 1000 per service, oldest first. Every line on a service locks it (`cloud_run:<project>/<region>:service:<service>`).
Not listed for drift or adoption.

### `contentful.environment`

A sandbox environment of a Contentful space per scope, copied from another environment (entries, assets, content
types, locales and the rest of what Contentful copies). Requires `providers.contentful.space` (the space id) and a
personal access token in `CONTENTFUL_MANAGEMENT_TOKEN` whose role may create and delete environments;
`CONTENTFUL_API_URL` points it at another host (`https://api.eu.contentful.com` for EU data residency).

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `name` | string | (required) | The environment id, 1 to 40 letters, digits, `-`, `_` or `.` (`${ctx.scope}`); its identity (key `environment:<id>`). `master` is refused. |
| `source` | string | `master` | The environment it is copied from, when it is created. |

Contentful copies an environment asynchronously, so `apply` waits until its status is `ready` (polling every 3
seconds, up to `SPONSON_CONTENTFUL_READY_TIMEOUT_MS`, ten minutes by default; then `WAIT_TIMEOUT`, and the next
`plan` shows the line as an update to `ready` that `apply` waits for without writing). A copy that ends `failed` is
refused with what to do. Outputs: `environment_id` (for the app's `CONTENTFUL_ENVIRONMENT` and an
`api_key_access` line) and `status`. **Drift**: only the environment's existence; its content is the sandbox's to
change, and `source` is used only when it is created. A deleted environment is `missing`, and the next apply copies it
again. **Destroy** deletes the environment (never `master`). Contentful limits a space to 151 environments and 12
creations per 5 minutes, and plans include fewer sandbox environments than that. Not listed for drift or adoption.

### `contentful.api_key_access`

Lets a delivery API key, and with it its preview API key, read an environment: Contentful answers 404 to a key for
any environment not in its `environments` list, so a new sandbox is unreadable to the app's keys until it is added.
Requires `providers.contentful.space`.

| Parameter | Type | Default | Meaning |
| - | - | - | - |
| `api_key_id` | string | (required) | The delivery API key's id (Settings, API keys; the id in the key's URL). |
| `environment` | string | (required) | The environment id, usually `{ from: <environment line>.environment_id }`. |

Key `api_key:<key id>:environment:<environment id>`. The key's environments are one list on a shared object: the line
reads the key, adds its environment and writes the list back with the version it read (`X-Contentful-Version`), again
when another writer changed the key in between (up to three rounds), and every line on the key locks it
(`contentful:<space>:api_key:<key id>`, [ADR 0019](/contributing/adr/0019-parent-object-locks)). **Drift**: the environment
missing from the key's list is `missing`, added again by the next apply. **Destroy** removes only this environment
from the key; the key's other environments stay. Not listed for drift or adoption.

### Outputs

The outputs each built-in op declares. `immediate` outputs exist once the line is applied; `external` ones only after
the named event. Sensitive outputs are never displayed, logged or written to receipts.

| Op | Output | Available | Sensitive |
| - | - | - | - |
| `neon.branch` | `branch_id` | immediate | no |
| `neon.branch` | `connection_string` | immediate | yes |
| `neon.branch` | `host` | immediate | no |
| `vercel.env` | `preview_url` | external (`deploy`) | no |
| `vercel.env` | `deployment_id` | external (`deploy`) | no |
| `vercel.deploy` | `preview_url` | immediate | no |
| `vercel.deploy` | `deployment_id` | immediate | no |
| `clerk.redirect_allow` | `id` | immediate | no |
| `planetscale.branch` | `name` | immediate | no |
| `planetscale.branch` | `branch_id` | immediate | no |
| `planetscale.password` | `id` | immediate | no |
| `planetscale.password` | `username` | immediate | no |
| `planetscale.password` | `host` | immediate | no |
| `planetscale.password` | `role` | immediate | no |
| `planetscale.password` | `password` | immediate, [once](/reference/plan-format#once-only-outputs) | yes |
| `planetscale.password` | `connection_string` | immediate, [once](/reference/plan-format#once-only-outputs) | yes |
| `launchdarkly.flag_target` | `variation_id` | immediate | no |
| `launchdarkly.flag_target` | `variation_name` | immediate | no |
| `launchdarkly.flag_target` | `variation_value` | immediate | no |
| `http.resource` | `id` | immediate | no |
| `http.list_item` | (none) | | |
| `http.action` | (none) | | |
| `supabase.branch` | `project_ref` | immediate | no |
| `supabase.branch` | `api_url` | immediate | no |
| `supabase.branch` | `db_host` | immediate | no |
| `supabase.branch` | `connection_string` | immediate | yes |
| `supabase.auth_redirect` | `url` | immediate | no |
| `netlify.env` | `preview_url` | external (`deploy`) | no |
| `netlify.env` | `deploy_id` | external (`deploy`) | no |
| `netlify.env` | `deploy_preview_url` | external (`deploy`) | no |
| `cloudflare.pages_env` | (none) | | |
| `manual.step` | (none) | | |
| `algolia.index` | `name` | immediate | no |
| `xata.branch` | `branch_id` | immediate | no |
| `xata.branch` | `connection_string` | immediate | yes |
| `digitalocean.app` | `app_id` | immediate | no |
| `digitalocean.app` | `live_url` | immediate | no |
| `cloud_run.revision` | `url` | immediate | no |
| `cloud_run.revision` | `revision` | immediate | no |
| `contentful.environment` | `environment_id` | immediate | no |
| `contentful.environment` | `status` | immediate | no |
| `contentful.api_key_access` | (none) | | |

### Once-only outputs

Some providers reveal a value only in the answer that creates a resource (a database password's plaintext). An op
declares such an output `once` (always together with `sensitive`); an `http.resource` line or recipe declares it in
`outputs` as `{ path, sensitive: true, once: true }`. It reaches the lines that reference it in the
run that creates the resource; the value itself is never stored, only a keyed fingerprint of it in the ledger, on
the producer and on each dependent written with it. In a later run, when the resource already exists, a reference to
it resolves to `{ keep: true }` only when those fingerprints show the dependent holds the current value: it is then
unchanged (or updated in its other fields only). Otherwise the dependent is refused with `OUTPUT_UNAVAILABLE` before
anything is written: a line added after the producer was created, a producer Sponson adopted, a dependent edited
outside Sponson since (even with `--reconcile`), or one whose resource has to be created again. A plan run shows such
a dependent as unchanged or blocked, never pending forever.

Nothing is re-created to get the value back unless you ask: `apply --recreate <line>` (repeatable, or
comma-separated) deletes what that line created and creates it again in the same run, so its new value reaches every
dependent. It never deletes a resource Sponson did not create in this scope (`USAGE`), and a later failure in the run
does not roll the new resource back. Recreating rotates the credential, so deployments still using the old one stop
working. The alternative is to keep the value in a secret manager and reference it with `{ secret: … }`. See
[ADR 0018](/contributing/adr/0018-once-only-outputs).

## What is checked when

| When | Checks | Codes |
| - | - | - |
| Editing, with the schema | shape of every key; id format; value forms; secret-looking literals; display text; the built-in ops' parameter names and `target` values | (editor diagnostics) |
| Parsing (every command) | everything above that the parser enforces, plus unique ids, declared environments, `depends_on` and `from:` ids exist | `PLAN_PARSE`, `PLAN_INVALID`, `SECRET_LITERAL`, `REF_UNKNOWN` |
| Preparing a run (before any provider call) | `--env` declared; adapters and ops exist; references survive the environment filter; no cycles; referenced outputs exist; `${ctx.*}` resolves; recipe lines' recipe, op and params; approval | `ENV_UNKNOWN`, `ADAPTER_UNKNOWN`, `OP_UNKNOWN`, `REF_FILTERED`, `REF_CYCLE`, `REF_OUTPUT_UNKNOWN`, `CTX_NULL`, `PARAM_INVALID`, `PLAN_INVALID`, `ENV_NOT_APPROVED` (apply only) |
| Reading live state (per line) | parameter values; credentials; secrets resolve; drift; ownership by another scope; once-only outputs a line would need again | `PARAM_INVALID`, `PROVIDER_*`, `SECRET_UNRESOLVED`, `DRIFT_CHANGED`, `OWNED_BY_OTHER_SCOPE`, `OUTPUT_UNAVAILABLE` |

Every code is listed in [errors.md](/reference/errors).

### What the schema does not check

The schema checks each part of the file on its own. These need the whole document and are left to `sponson plan`
(`scenarios/docs/schema.test.ts` pins the list): duplicate ids; a line environment the top-level `environments` does
not declare; `depends_on` or `from:` naming an id that does not exist; referenced outputs; cycles; `${ctx.*}` names.

The schema is stricter than the parser in these places, each a likely typo the runtime would ignore or report later:
unknown top-level keys; unknown parameters of the built-in ops (for example `from: main` instead of `parent: main` on
`neon.branch`); and extra keys next to `from`, `secret` or `keep`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.