packages/core/src/plan.ts; where this page and the parser
disagree, the parser is right and this page has a bug.
- Editor support
- Top-level keys
- Change keys
- Values
- Context interpolation:
${ctx.*} - Environments
- Ordering: references and
depends_on - Built-in ops
- What is checked when
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:
$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 isREF_UNKNOWNat parse time; an unknown output isREF_OUTPUT_UNKNOWNwhen 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:planshows the line aspendingwithwaitingOn(andwaitingFor, such asdeploy, when the output depends on an external event).applynever sends a pending value to a provider. - A sensitive output (for example
neon.branch’sconnection_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:
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.envline withDATABASE_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 (missingdrift: the next apply creates a new one and passes it on in that run) or change itsnamein the plan.
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,audienceandscopewhen set, theextraparameters, and the client’s credentials in the form (auth_method: client_secret_post, the default) or as HTTP Basic (client_secret_basic).private_key_jwtis not supported (Okta’s own management API requires it for OAuth; itsSSWSAPI tokens use the header form withprefix: "SSWS ").token_url_envoverridestoken_urlwhen set, asbase_url_envdoes. Atoken_urloraudiencewritten as a path (/oauth/token,/api/v2/) is resolved against the origin of the block’s ownbase_url(not itsbase_url_envoverride), so a per-account API names its account once:base_url: https://acme.eu.auth0.com/api/v2withtoken_url: /oauth/tokenandaudience: /api/v2/sendshttps://acme.eu.auth0.com/api/v2/as the audience tohttps://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 isPROVIDER_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 getContent-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
Withencoding: 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 apostmark.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
authmust be a single static credential (bearer_env, orheaderwithvalue_env). - A
frommust name an output declaredsensitive(it would otherwise reach receipts) and notonce(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 offind, 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):
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 verifiedhttp.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_textisunchanged, whatever value the plan has. A new value in the plan (a rotated{ secret }) is not written unless the line setsrewrite_secrets: true, which sends every secret of the line on every apply (one PATCH; never reported as drift). Without it, rotate by settingrewrite_secrets: truefor one apply. - a secret’s value changed in the dashboard is not detected; deleting it is
missingdrift (recreated), and turning it into plain text, or a plain variable into a secret, ischangeddrift.
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 outputonce (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 tosponson 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.