Skip to main content
Date: 2026-10-11

Status

Proposed. Amended by ADR 0023 (Amendment 5’s Google Cloud Run recipe is replaced by the cloud_run.revision op).

Context

The generic http adapter (ADR 0017) can express most rows of the provider coverage matrix (docs/coverage.md): 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, ADR 0016) and the plan staying data (ADR 0001).

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

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