Skip to main content
This is the reference for the file. For a guided introduction read the README; for worked plans see 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

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:
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.

Top-level keys

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:

Values

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

{ 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). 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): 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: 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. 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:
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: 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).

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). 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. 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. 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.

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). 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. 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): 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. 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. 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:
  • 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 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). 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) 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. 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. 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): 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. 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.
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.

Authentication

  • 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.

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:

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, “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.

http.resource

One object per item: a feature flag, a webhook endpoint, a DNS record. 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). 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):
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.

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). 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), 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).
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: 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.

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).
${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 (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. 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).

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.
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).

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”. 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): 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. 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. 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. 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). 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. 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). 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. 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). 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.

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.

What is checked when

Every code is listed in errors.md.

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.