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

# ADR 0020: Recipes: verified http specs as one-line building blocks

> Architecture decision record 0020. Status: Proposed. Amended by ADR 0023 (Amendment 5's Google Cloud Run recipe is replaced by

Date: 2026-10-11

## Status

Proposed. Amended by [ADR 0023](/contributing/adr/0023-cloud-run-revisions) (Amendment 5's Google Cloud Run recipe is replaced by
the `cloud_run.revision` op).

## Context

The generic `http` adapter ([ADR 0017](/contributing/adr/0017-generic-http-adapter)) can express most rows of the provider
coverage matrix ([docs/coverage.md](/reference/coverage)): plain CRUD on one object, or one entry in a list on a parent.
But "expressible" is not coverage. A team that wants a Cloudflare CNAME per preview still has to find the endpoints,
the response envelope, the id field, the fields that identify a record and the ones worth comparing, and get each
right; a wrong guess shows up as a duplicate record or drift on every plan. Nothing in the repository says which
providers have a known-good spec, what it was checked against, or that it still works.

The coverage target (95% of the matrix, with product, docs and tests agreeing) needs dozens of such specs, written by
several contributors. So the unit of work has to be small, mechanical and checked: one file per provider, nothing
else to edit, and the tests and docs following from the file.

Two things must not move while this happens: ledger identity ([ADR 0005](/contributing/adr/0005-receipts-are-a-ledger-with-write-ahead-intents),
[ADR 0016](/contributing/adr/0016-one-receipts-ref-per-scope)) and the plan staying data ([ADR 0001](/contributing/adr/0001-plan-file-is-yaml-not-a-dsl)).

## Decision

### Recipe files

`packages/adapters/recipes/<provider>.yaml`, shipped in the npm package (`files`), validated by
`schema/recipe.schema.json`:

* **Metadata**: `provider` (the file name), `title`, `category` (a docs/coverage.yaml category), `verified_on`,
  `docs` (the official pages it was verified against), and numbered `assumptions`, each `verified` with a `source`
  or marked unverified.
* **`api`**: the API block defaults, exactly the keys of a `providers.http.<api>` block: `base_url` (left out only
  for per-account APIs), `base_url_env` (`<PROVIDER>_API_URL`), `auth` naming the provider's conventional credential
  variable (its CLI's or SDK's), `headers`, `encoding`.
* **`ops`**: per op, its `kind` (`resource` or `list_item`), `title`, `summary`, the coverage rows it `covers`,
  typed `params` (`type`, `description`, `required`, `default`, `enum`, `pattern`, `example`) and `http`: the op's
  params as ADR 0017 defines them, without `api`, `vars` and `destroy`. A value `{ param: <name> }` stands for a
  param's value (a param without one removes its key); `{<name>}` in a path for its URI-encoded value. Optional
  `sim` hints (`id_from`, `numeric_ids`, `objects` to seed) and `live.params` (the `SPONSON_LIVE_*` variable that
  supplies a param in a live run).

There is one template form, whole-value substitution. No string interpolation inside recipe values, no conditionals:
a recipe is data like a plan.

### Plan syntax: a line of the `http` adapter, not a new adapter

```yaml theme={null}
providers:
  http:
    cloudflare: { recipe: cloudflare }  # optional; override base_url, base_url_env, auth, headers, encoding here
changes:
  - id: preview_dns
    adapter: http
    op: resource                        # the recipe's kind
    recipe: cloudflare.dns_cname
    zone_id: 023e105f4ecef8ad9ca31a8372d0c353
    name: "${ctx.scope}.preview.example.com"
    target: preview-host.example.net
```

* `recipe: <provider>.<op>`; `api` defaults to `<provider>`, and a missing `providers.http.<provider>` block means
  `{ recipe: <provider> }`. A block with `recipe:` takes the recipe's `api` as defaults; its own keys win.
* Every other key of the line is a recipe param, except `api`, `recipe` and `destroy` (which keeps ADR 0017's
  meaning). The op's `outputsFor` expands the line during `prepare`, so an unknown recipe or op (listing the known
  ones), an unknown or missing param, a wrong type, enum or pattern is `PARAM_INVALID` naming the param before any
  request. References and `${ctx.*}` are left to the engine and checked after it resolves them.
* `op` must equal the recipe's kind: the line still says which op's lifecycle it gets, and the ledger records it.

A dedicated `adapter: <provider>` per recipe file was considered and rejected; see Alternatives.

### Identity: what the requests resolve to, never the recipe's text

* `providerFor` returns the **resolved** API block (defaults merged with overrides), without the recipe's name. It is
  what the ledger records and what destroy uses, so destroy needs nothing but the ledger even if a later version drops
  or renames the recipe.
* Keys and record ids are the ones the expanded spec produces (ADR 0017: the find path and match values, or the read
  path; the remove request; a list item's locator).
* Hence: editing a recipe's docs, assumptions, params, defaults that do not reach a path or a match, or its `fields`
  re-identifies nothing. A line written by hand with the same values is the same resource, so a team can move from a
  hand-written line to a recipe (or back) without orphaning anything.
* A recipe change that does alter identity (base URL, credential variable, headers, encoding, a path, a match field,
  the remove request) is caught by an identity lock: `packages/adapters/src/recipes-identity.json` pins, per recipe
  op, the resolved block, key and record id of its example line. Changing it is a deliberate act (an environment
  variable rewrites it) shipped with a **Breaking:** changeset.

### Validation and testing that follow from the file

* `packages/adapters/src/recipes.test.ts`: every file validates against the schema; metadata is complete (category,
  sources, unique assumption ids, rows in docs/coverage.yaml that name the op back); path placeholders use only params
  that always have a value; every param is used; the example expands into a spec ADR 0017's parser accepts, with its
  outputs and provider block; missing, unknown and mistyped params fail as they should; the identity lock holds.
* `scenarios/recipes.test.ts`: for every recipe op, the example line runs through the CLI against the generic REST
  sim, shaped from the recipe itself (the create's collection answers in the recipe's `item_path` and
  `find.list_path` envelopes with its id field; `sim.objects` seeds a list item's parent): create, re-apply with zero
  writes, a declared field edited in the console refused until `--reconcile`, the object or item removed by hand and
  recreated, destroy removing only what Sponson made. The sim gained per-collection styles for this
  (`packages/sim/src/routes/rest.ts`, assumption R2), not per-provider routes.
* Live: under `pnpm test:live` the same file runs create, re-apply and destroy against the real API for each op whose
  credential and `live.params` variables are set, and skips the others.

### Docs and coverage data that follow from the file

* `pnpm docs:gen` writes docs/recipes.md (one section per provider and op: API defaults, sources, assumptions, params
  table, example line, the expansion) and its site page.
* docs/coverage.yaml is the source of truth for the coverage matrix: per row its id, category, provider, side
  effect, API shape, generic fit, priority and `covered_by` (`<adapter>.<op>`, `recipe:<provider>.<op>`, a list,
  `manual` for rows no API reaches, or null). `pnpm docs:gen` regenerates the matrix tables and every coverage number
  in docs/coverage.md from it. `scenarios/docs/coverage.test.ts` checks every `covered_by` resolves to a registered op
  or shipped recipe op, that a recipe's `covers` and the rows naming it agree, that each op is exercised by a scenario
  (recipe ops by the harness above), and that the numbers in the doc are the data's.

### First recipes

`cloudflare.dns_cname` (P1, an object found by name and type) and `turso.database_branch` (P1, an object named by the
client, with outputs). A Supabase redirect-URL recipe was written and dropped: the first-class
`supabase.auth_redirect` covers that row, and a row has one way to be managed. Stripe's webhook endpoint was not
chosen: its signing secret exists only in the create response, which the `http` adapter cannot declare.

## What it deliberately does not do

* **No user-supplied recipe directories** (`SPONSON_RECIPES`): a recipe is useful because it is verified and tested
  here. A team that needs an unshipped one writes the http line by hand, or contributes the file.
* **No recipe versions in the plan** (`recipe: cloudflare.dns_cname@2`): identity already does not depend on the
  recipe's text, and the lock makes the identity-changing edits explicit.
* **No schema per recipe in `schema/release.plan.schema.json`**: an editor checks `recipe:`'s form, `sponson plan`
  checks the params.
* Everything ADR 0017 leaves out stays out (polling, OAuth exchanges, SigV4). Once-only outputs were left out too,
  until Amendment 2 below.

## Alternatives considered

* **`adapter: <provider>` resolved through the recipes in the registry.** Shorter lines, but a recipe name would then
  share the adapter namespace with first-class adapters: shipping a first-class `cloudflare` adapter later would
  change what `adapter: cloudflare` means for existing plans and re-identify their resources (identity starts with
  the adapter name). Under `adapter: http`, moving a row from a recipe to a first-class adapter is an explicit plan
  edit.
* **Identity from the recipe** (its name and op in the provider block or the key). Stable across base URL changes, but
  a hand-written line and a recipe line for the same object would be two resources, and destroy would need the recipe
  file of the version that created the resource.
* **String templates** (`"pr-{scope}.example.com"` inside recipe values). Recipes would become a small language;
  the line already has `${ctx.*}` for that.
* **Plan-file snippets in the docs only.** No validation, no tests, no coverage data: what this replaces.

## Consequences

* Adding a provider operation is one YAML file plus a coverage row's `covered_by`; the schema test, the unit checks,
  the sim lifecycle, the docs page and the coverage numbers follow (CONTRIBUTING.md, "Adding a recipe").
* `@sponson/adapters` depends on `yaml` and ships `recipes/`.
* The rest sim's answers are shaped per collection; the default shape is unchanged.
* The sim proves a recipe consistent with its own declarations, not with the provider: the verified assumptions and
  `pnpm test:live` are what tie a recipe to the real API.

## Amendment 1 (2026-10-11): what the feature-flag recipes needed

Writing the feature-flag recipes (Statsig, GrowthBook, Unleash, PostHog, ConfigCat, Split) found four shapes the
framework could not express. Each was added as a general option, with no provider named in the code:

* **`create.locate: true`** (`http.resource`): the create answers with something other than the object (Statsig's
  add-rule answers with the whole gate, whose own `id` would be taken for the rule's). The new object is located with
  `find`/`read` after the create, as for an empty answer. Identity is unchanged.
* **`parent.item_path`** (`http.list_item`): the parent is read in an envelope (`{ feature: {...} }`) but written
  bare. `list_path` and `send` are relative to it. It joins the locator only when set, so existing record ids are
  unchanged.
* **`parent.send: [fields]`** (`http.list_item`): a `PUT` that resets what it is not sent and is not documented to
  take the read-only fields of its own GET (ConfigCat's value, Split's definition) gets those fields back and nothing
  else.
* **Per-account recipes in the tests and docs**: a recipe without a default `base_url` names a `base_url_example`
  (required then, by the schema); the unit checks, the sim harness and docs/recipes.md write the provider block
  with it. The sim harness also follows `sim.collection` (a create posted to another path than the list: the sim's
  new `aliases`), list and key fields given as JSON pointers, and parent envelopes; the rest sim accepts a bare
  token in `Authorization` (Unleash), and answers a styled object in its `item_path` envelope (assumptions R1, R2).

Flagsmith was not written: its Admin API takes `Authorization: Api-Key <key>` (or `Token <key>`), a scheme prefix the
three auth forms do not send, and Bearer only for short-lived OIDC-exchanged tokens. Adding a prefix form to `auth`
was left for a decision of its own.

## Amendment 2 (2026-10-11): once-only outputs, destroy defaults, and sim shapes

Writing the webhook, API key and release-marker recipes of [docs/coverage.yaml](https://github.com/MishaBear94/Sponson/blob/main/docs/coverage.yaml) found three gaps.
Each is closed by the smallest general change; none names a provider.

* **Once-only outputs in `http.resource`.** An API key's token is in the create answer only. `outputs` now takes
  `{ path, sensitive: true, once: true }`: the op declares the output `once` ([ADR 0018](/contributing/adr/0018-once-only-outputs))
  and takes its value only from the answer of the create that made the object, never from a read, an update, an answer
  without the object (found afterwards), or a create the provider refused as existing (a lost answer, retried). The
  engine's fingerprints and `--recreate` then work as for `planetscale.password`. `once` without `sensitive: true` is
  `PARAM_INVALID`: a value shown once is a credential.
* **Destroy defaults per recipe op.** A release or a deploy record is history shared beyond one scope, and some
  providers offer no way to delete it at all (deploy records). A recipe op may say `destroy: keep` (lines leave what
  they made unless they write `destroy: delete`) or `destroy: never` (no delete request exists: always left, and a
  line's `destroy: delete` is `PARAM_INVALID` naming the recipe). The record id is then `KEEP <path>`, as for a
  hand-written line with `destroy: keep`, so identity follows ADR 0017 unchanged; a `never` op still declares the
  object's URL as `delete.path`, used only as its identity.
* **Sim shapes.** Amendment 1's `aliases` now name their collection for every method, not only for a create, so a
  provider that lists at another URL than its objects' collection (`GET /settings/all`) is served too (the recipe
  harness derives that alias from `find.path`); seeded paths, aliases and create paths ignore a trailing slash
  (`/releases/` is `/releases`); and the sim accepts the credential in a header the seed names (`auth_headers`,
  derived from the recipe's `auth.header`, for header names that do not end in `-Key` or `-Token`, such as
  `X-Honeycomb-Team`). `ProviderSim.authorized` receives the provider's state for this.

Still out of reach for recipes, and recorded on the coverage rows that need them: an API that needs two credentials on
every request, a list request sent as a POST, a delete that names the object in its body, and a credential that is
itself an output of another line (a per-environment server's token).

## Amendment 3 (2026-10-11): `{ join }`, refused credentials, archiving destroys, and the Stripe webhook

The Stripe webhook endpoint row (P0) uses Amendment 2's once-only outputs for its signing secret. It also needed a URL
made of the preview URL plus a path, since `{ from: web.preview_url }` is a whole value in a plan; the Stripe catalog
rows needed a destroy that archives; and a preview must never use a live Stripe key. None of these names a provider
in the code.

* **`{ join: [<part>, …] }`** is a second template form: literal strings and numbers and `{ param: <name> }` parts,
  concatenated into one string. It is not interpolation (no parsing of strings, no expressions): a part that is not
  known yet (a pending reference) makes the whole value that part, so the line shows as pending; a part without a
  value removes the value. This keeps recipes data, as ADR 0001 wants, while letting a recipe take `url` and `path`
  as two params.
* **`api.refuse_credential: { prefixes, unless_env }`** (an API block key, [ADR 0017](/contributing/adr/0017-generic-http-adapter),
  amendment): a credential starting with one of the prefixes is `PROVIDER_AUTH` before any request, in plan, apply and
  destroy, unless the named variable is `1` or `true`. A recipe may declare it (Stripe: `sk_live_`, `rk_live_`, opt-out
  `SPONSON_STRIPE_ALLOW_LIVE`); a plan's block cannot override a recipe's. It complements `production: true`, which
  asks for approval for an API that is production: this refuses a credential a preview must never use.
* **`delete.body`** with `method: POST` (`http.resource`): archive instead of delete (Stripe never deletes prices, or
  products that have prices). The body, literals only, joins the record id as ` body=<canonical JSON>`, so destroy
  needs only the ledger; record ids without a body are unchanged.
* **Sim**: a collection style may name objects by a query parameter (`sim.item_query`: `…/redirect_urls?url=…`, as
  Stytch does; its list at `…/all` is Amendment 2's alias), read form values typed (`form_types`, derived from
  `encoding: form`), show once-only fields only in the create's answer (`once_fields`, derived from `once` outputs),
  and take a POST update to an object of a collection. The harness checks an archiving destroy by the archive fields
  instead of absence.

"Stripe's webhook endpoint was not chosen" (under *First recipes*) is superseded: `stripe.webhook_endpoint` ships with
the catalog ops `stripe.product`, `stripe.price` and `stripe.coupon`.

## Amendment 4 (2026-10-11): headers, URLs in paths, and polling stays out

The deploy, database and queue recipes (Render, Heroku, Vercel domains, Upstash, Prisma Postgres, QStash) needed:

* **A vendor `Accept`.** Heroku refuses requests that do not pin its version in `Accept`; its recipe uses the API
  block's `media_type` (added by another amendment) rather than a header of its own.
* **Per-request headers and fields carried in them.** QStash takes a schedule's cron and id in headers and its message
  as the body. `create` and `update` take `headers` (values from params) and `send_fields: false` (the declared
  `fields` are compared but not put in the body); a path placeholder `{+name}` writes a URL as it is (RFC 6570
  reserved expansion; only `?` and `#` escaped). A delete answered 2xx with a body that is not JSON (`OK`) succeeds.
* **Sim shapes.** A PUT to `<collection>/<id>` that does not exist creates the object (Render's upsert by name); an
  alias of a collection also addresses its objects (`/databases/<id>`), which the harness derives from the read,
  update and delete paths; recipe sim hints `header_fields` and `suffix_field` stand in for an API that takes settings
  in headers and a URL as the path's last segment.

Rows whose lifecycle needs waiting (Algolia's copy task, a Xata branch coming up, an App Platform deployment) got
first-class adapters instead of a polling extension: each waits on a different signal, and polling in a request
template would be a small language. Inngest's sync stays uncovered (a one-off action with nothing to read back or
delete).

## Amendment 5 (2026-10-11): run-time credentials and GraphQL in recipes

The deploy-target rows Railway, Fly.io, AWS Amplify, Google Cloud Run and Azure Static Web Apps were expressible as
hand-written lines (ADR 0017's run-time credentials and GraphQL amendment) but not as recipes: the recipe schema took
only the three static credential forms, and the recipe harness ran every op against the generic REST sim with a fixed
bearer token. Closed without naming a provider in the code:

* **Credential forms.** A recipe's `api.auth` may be `{ command: [argv…] }` (literal arguments only, as for a plan) or
  `{ aws_sigv4: { region, service, access_key_id_env, secret_access_key_env, session_token_env? } }`, under the
  provider CLI's conventional variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`). docs/recipes.md describes both.
* **The harness checks them for real.** For a SigV4 recipe the REST sim's `auth` seed (R7) verifies the signature with
  the sims' AWS key; for a `command` recipe a fake CLI of the command's name, first on PATH, prints the one token the
  REST sim accepts. Neither the secret key nor the token may reach any output.
* **First-class sims for what the REST sim cannot stand in for.** A recipe op may name a sim (`sim.provider: railway`,
  `fly`, `aws`) with its seed, the path its base URL stands for (`base_path`), a JSON pointer into its state that exists
  once the op applied (`present`), and that sim's drift keys that edit a declared field (`changed`) and remove what the
  op made (`missing`). The same lifecycle runs: create, re-apply with zero writes, changed drift refused until
  `--reconcile`, missing drift recreated, destroy. GraphQL recipes need this (the REST sim speaks no GraphQL), and so
  does a map that a POST replaces whole (Amplify's `UpdateBranch`), which the REST sim merges (R4).
* **A read sent as a POST** (`parent.read_method: POST`, ADR 0017 amended), for Azure Resource Manager's list actions.

Decisions per row, recorded on the rows and in the recipes' assumptions:

* **Railway** (`railway.variable`): the environment's id is a param, not looked up (PR environment naming is not
  documented); values are read `unrendered`. Railway redeploys after a write; Sponson does not wait for that deploy.
* **Fly.io** (`fly_machines.app`, `fly.secret`): two files, since the Machines API and the GraphQL API have different
  base URLs. Secret values are never readable, so only presence is compared, and a secret reaches Machines started
  after it is set: order it before the PR's deploy.
* **AWS Amplify** (`amplify.branch`, `amplify.branch_variable`): the regional endpoint and region default to
  `us-east-1`; a block overrides `base_url` and `auth` for another region or temporary credentials.
* **Google Cloud Run** (`cloud_run.traffic_tag`): the per-PR tag is read-modify-write of the service's `traffic`
  with `updateMask=traffic` and the service's `etag` sent back (`send: [etag]`), so a concurrent deploy makes the write
  fail rather than be overwritten. No first-class adapter was needed. The row stays uncovered: the revision and its
  environment variables are made by the deploy, which Sponson does not perform.
* **Azure Static Web Apps** (`azure_swa.build_app_setting`): read with `listAppSettings` (POST), written whole with a
  PUT; the preview environment is named by the pull request's number and exists only after its first deploy.

## Amendment 6 (2026-10-11): OAuth2 and prefixed headers in recipes, and lists written under another name

The identity-provider rows (Auth0 callbacks, Firebase authorized domains, Okta redirect URIs, Kinde callbacks) are
lists on a shared object, which `http.list_item` and its lock ([ADR 0019](/contributing/adr/0019-parent-object-locks)) already handle,
behind credentials ADR 0017's amendment already sends. Amendment 5 let recipes declare `command` and `aws_sigv4`;
three gaps remained for these rows, each closed without naming a provider in the code:

* **Two more credential forms in recipes.** `schema/recipe.schema.json` also accepts `oauth2_client_credentials` (with
  a required `token_url_env`, `<PROVIDER>_TOKEN_URL`, so the tests can point it at the fake cloud, as `base_url_env` is)
  and the header form's `prefix` (`Authorization: SSWS <token>`). The recipe harness checks both for real against the
  REST sim (R7): an OAuth2 client and the audience every issued token was asked for, or the header value behind its
  prefix; every token obtained is checked never to reach output, as the credential is. `command` recipes use
  Amendment 5's fake CLI (one mechanism for Firebase and the deploy targets).
* **A token endpoint and an audience on the account's own host.** A per-account API (an Auth0 tenant, a Kinde
  business) has its token URL and audience on the account's host, which a recipe cannot know. In any API block, an
  OAuth2 `token_url` or `audience` written as an absolute path (`/oauth/token`, `/api/v2/`) is resolved against the
  origin of the block's `base_url` (not its `base_url_env` override: every runner, and the ledger, see one value; the
  live harness therefore writes the real account's URL as `base_url`). A plan names the account once; a block that
  needs something else (an audience on another host) writes the full URL. Blocks with full URLs are unchanged, and the
  block (the ledger identity) keeps the text as written.
* **`parent.write_list_path`** (`http.list_item`): where a write puts the collection, when the API names it otherwise
  than its read (Kinde reads `{ redirect_urls }` and replaces the list with `{ urls }`). It joins the record id only when
  set, so existing record ids are unchanged. The sim stores such a write under the read name (an object style's
  `write_fields`, which the harness derives from the recipe).

Okta's application replace takes the whole object back (`send: parent`, the documented system-assigned fields being
ignored); Okta's trusted origins are objects of their own. Okta's OAuth for its management API needs `private_key_jwt`,
which stays out (ADR 0017's amendment): its recipe uses an `SSWS` API token. Firebase's `x-goog-user-project`, needed
only with user credentials, names a project, so it is left to the plan's block rather than the recipe.

## Amendment 7 (2026-10-11): Digest, several headers and XML in recipes, line credentials, deletes by body

The database, CORS, flag, observability and email rows left (MongoDB Atlas users, CockroachDB databases, GCS and
S3/R2 bucket CORS, Flagsmith, Datadog, Postmark webhooks) needed the rest of ADR 0017's request-level features in
recipes, and two new ones (ADR 0017, "Amendment: line credentials, deletes that name the object, XML lists"). None
names a provider in the code:

* **The last credential forms and XML in recipes.** Besides Amendments 5 and 6's forms, a recipe's `api.auth` may be
  `digest` (Atlas API keys) or several `headers` (Datadog's API and application keys), and `api.encoding` may be
  `xml` (S3's CORS configuration, tested against the AWS sim through Amendment 5's `sim.provider`, which verifies
  SigV4 and speaks S3's XML). The harness checks both forms for real (R7): several headers are accepted only together,
  each with its own value; Digest answers the REST sim's challenge. `env://` secrets in examples (a password input)
  get values the leak check covers.
* **`credential` is a line key, like `api` and `destroy`**, so a recipe line can take another line's sensitive output
  (or a `{ secret }`) as its credential; a recipe param may not be named `credential`. Of Amendment 2's "still out of
  reach", "two credentials on every request" is reached by `headers`, "a credential that is itself an output of
  another line" by `credential` (with `destroy: keep`), and "a delete that names the object in its body" by `{id}` in
  `delete.body`. "A list request sent as a POST" stays out: Datadog's deployment events are listed so, over a time
  window, and are never deleted, so that row is covered by a Synthetic test and the marker is recorded as out of reach.
* **Sim shapes.** The REST sim stores generated fields and accepts them as a credential header (R10: a server's token,
  derived by the harness from a sensitive output that is not `once`), and deletes by body (R11, derived from a delete
  body holding `{id}`).

Decisions per row, recorded on the rows and in the recipes' assumptions: Atlas users are located by their own URL
(`/databaseUsers/admin/{username}`), the password is an input never compared; CockroachDB's Cloud API creates
databases (no SQL needed) and SQL users (with the SQL `admin` role, CR5); GCS CORS entries are identified by their first
origin and written without `ifMetagenerationMatch` (the parent lock serialises Sponson's writers); one S3 recipe serves
R2 too (R2 accepts `us-east-1` as its region); Flagsmith is covered by an environment per preview (identity and
segment overrides are not recipes: Edge identities are addressed by UUID, a segment override is two objects).

## Amendment 8 (2026-10-11): action recipes

(Numbered 8 because Amendment 7 is reserved by a branch in flight.) "Inngest's sync stays uncovered" (Amendment 4)
is superseded by [ADR 0022](/contributing/adr/0022-one-off-actions): a recipe op may be `kind: action` (an `http.action`, one request
sent when its trigger changes), and a recipe whose ops are all actions sent to the app's own `url` has no `api` block
(it needs no credential). `inngest.sync` is the first. The identity lock records an action op's key as
`action:<line id>` (the engine's) and its record id as `FORGET` or the undo request template; the recipe harness runs
an action op as run → unchanged → new trigger → run → destroy, against REST sim endpoints that log every call (R9).

## Amendment 9 (2026-10-12): messaging, realtime and release-marker rows

Mailgun and Twilio were the services `sponson init` reported as unsupported; the realtime and release-marker providers
of a typical US/EU stack were checked for per-preview state too (docs/coverage.md, "Considered and not added"). Three
shapes were missing, each closed without naming a provider in the code:

* **A literal Basic user** (`auth: { basic: { user, password_env } }`, ADR 0017's Basic form): Mailgun's user name is
  always `api` and the key is the password, so a variable for the user would hold a constant. `user` and `user_env`
  are exclusive; the password stays a variable. The harness checks the exact header (R7's `headers`).
* **Lists as repeated form keys** (API block `form_arrays: repeat`, `form` only): Mailgun reads `action=…&action=…`
  and `event_types=…&event_types=…` (OpenAPI's `style: form, explode: true`), not Stripe's `a[0]=…`. The key joins the
  block (identity) only when set, so existing blocks are unchanged. The REST sim reads a repeated key as a list; one
  value arrives as a string, so the harness declares the recipe's list fields (`list_fields`, R4).
* **Fields written under another name than read** (recipe `sim.write_fields`): Twilio takes `FriendlyName` and
  answers `friendly_name`; Mailgun takes `action` and answers `actions`. The adapter already sends a body other than
  the compared fields (`send_fields: false`); the sim now stores a collection object's written fields under their read
  names (R2), and addresses objects whose path carries an extension after the id (`/Applications/{id}.json`,
  `id_suffix`, derived from the recipe's paths, R3). The action harness also runs GraphQL actions (an answer under
  `data`) and gives a `{ secret }` param in an action's body a value the leak check covers.

Decisions per row, recorded on the rows and in the recipes' assumptions:

* **Mailgun** (`mailgun.webhook`, `mailgun.route`): account webhooks, objects with an id, rather than per-domain
  webhooks (a list per event, or a map keyed by URL); every account webhook gets every domain's events and an event
  type takes three URLs, so at most three previews at a time. Routes are account-wide objects forwarding a
  per-preview address. Domains are parent objects.
* **Twilio** (`twilio.twiml_app`): a phone number has one SMS and one voice URL, so pointing a shared number at a
  preview is not per-environment state, and a lock (ADR 0019) would serialise writers that still overwrite each other.
  Subaccounts are closed, never deleted, and need numbers bought per subaccount. The per-preview object is a TwiML
  Application, which the preview's Voice SDK tokens name (`outgoingApplicationSid`).
* **Ably** (`ably.webhook_rule`): an integration rule per preview, found by its channel filter and source type; the
  app and keys stay shared. Pusher Channels (no API for apps or webhooks) and Liveblocks (Enterprise-only private beta
  Management API) have no row; `init` reports them as unmanaged.
* **New Relic, Rollbar, BugSnag** (`newrelic.deployment`, `rollbar.deploy`, `bugsnag.build`): one-off release events,
  `http.action`s (ADR 0022) with no undo: none of the three APIs deletes a marker, deploy or build. BugSnag's Build API
  authenticates by the notifier key in its JSON body, so its recipe has no API block and takes the key as a
  `{ secret }` param sent to an absolute URL.
* **Slack and Discord apps**: no row. A Slack app is a parent object installed by a person, behind a configuration
  token that expires after 12 hours; a Discord application has one interactions URL.

## Amendment 10 (2026-10-12): ids chosen in the query, deletes named in the path, once-only values without an id

The headless-CMS and backend-preview rows (Firebase Hosting preview channels, Convex preview deployments and their
deploy keys; [docs/coverage.yaml](https://github.com/MishaBear94/Sponson/blob/main/docs/coverage.yaml)) are plain CRUD, but three of their shapes were not expressible or
not testable:

* **An id chosen in the query.** Google's `sites.channels.create` takes the channel id as `?channelId=`, not in the
  body. The request already worked (a create path may carry a query); only the REST sim assigned its own id. A recipe
  sim hint `id_query` names the parameter, and the sim's create takes the id from it (R3).
* **A delete named in the path of a `POST`.** Convex deletes a deployment with `POST /deployments/{name}/delete`. A
  `delete: { method: POST, path }` without a body already worked against the real API; the REST sim now lets a
  seeded `deletes` path hold a `{id}` segment that names the object (R11), and the harness derives it from such a
  delete, as it derives the body form.
* **A create that answers only the once-only value.** Convex answers a new deploy key as `{ deployKey }`, with no
  id. The object was already found by `find` after such an answer, but "found so, it reveals no once-only value"
  dropped the key, the one thing the op exists for. The once-only outputs are now taken from the create's own answer
  even when the object is found afterwards: it is still the answer to the create that made it (ADR 0018). Not with
  `create.locate`, whose answer is declared not to be the object.

Nothing else changes: identities, record ids and the plan format are as before. Contentful's environments are a
first-class adapter instead (`contentful.environment`): a new environment is copied asynchronously and is usable only
once its status is `ready` (Amendment 4: waiting stays out of recipes), and granting an API key access to it is a
`PUT` with the version read just before (`X-Contentful-Version`), a precondition no request template carries.


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