Status
Proposed. Amended by ADR 0023 (Amendment 5’s Google Cloud Run recipe is replaced by thecloud_run.revision op).
Context
The generichttp 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 numberedassumptions, eachverifiedwith asourceor marked unverified. api: the API block defaults, exactly the keys of aproviders.http.<api>block:base_url(left out only for per-account APIs),base_url_env(<PROVIDER>_API_URL),authnaming the provider’s conventional credential variable (its CLI’s or SDK’s),headers,encoding.ops: per op, itskind(resourceorlist_item),title,summary, the coverage rows itcovers, typedparams(type,description,required,default,enum,pattern,example) andhttp: the op’s params as ADR 0017 defines them, withoutapi,varsanddestroy. 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. Optionalsimhints (id_from,numeric_ids,objectsto seed) andlive.params(theSPONSON_LIVE_*variable that supplies a param in a live run).
Plan syntax: a line of the http adapter, not a new adapter
recipe: <provider>.<op>;apidefaults to<provider>, and a missingproviders.http.<provider>block means{ recipe: <provider> }. A block withrecipe:takes the recipe’sapias defaults; its own keys win.- Every other key of the line is a recipe param, except
api,recipeanddestroy(which keeps ADR 0017’s meaning). The op’soutputsForexpands the line duringprepare, so an unknown recipe or op (listing the known ones), an unknown or missing param, a wrong type, enum or pattern isPARAM_INVALIDnaming the param before any request. References and${ctx.*}are left to the engine and checked after it resolves them. opmust equal the recipe’s kind: the line still says which op’s lifecycle it gets, and the ledger records it.
adapter: <provider> per recipe file was considered and rejected; see Alternatives.
Identity: what the requests resolve to, never the recipe’s text
providerForreturns 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
fieldsre-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.jsonpins, 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’sitem_pathandfind.list_pathenvelopes with its id field;sim.objectsseeds 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:livethe same file runs create, re-apply and destroy against the real API for each op whose credential andlive.paramsvariables are set, and skips the others.
Docs and coverage data that follow from the file
pnpm docs:genwrites 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,manualfor rows no API reaches, or null).pnpm docs:genregenerates the matrix tables and every coverage number in docs/coverage.md from it.scenarios/docs/coverage.test.tschecks everycovered_byresolves to a registered op or shipped recipe op, that a recipe’scoversand 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 checksrecipe:’s form,sponson planchecks 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-classcloudflareadapter later would change whatadapter: cloudflaremeans for existing plans and re-identify their resources (identity starts with the adapter name). Underadapter: 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/adaptersdepends onyamland shipsrecipes/.- 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:liveare 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 ownidwould be taken for the rule’s). The new object is located withfind/readafter 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_pathandsendare relative to it. It joins the locator only when set, so existing record ids are unchanged.parent.send: [fields](http.list_item): aPUTthat 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_urlnames abase_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 followssim.collection(a create posted to another path than the list: the sim’s newaliases), list and key fields given as JSON pointers, and parent envelopes; the rest sim accepts a bare token inAuthorization(Unleash), and answers a styled object in itsitem_pathenvelope (assumptions R1, R2).
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.outputsnow takes{ path, sensitive: true, once: true }: the op declares the outputonce(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--recreatethen work as forplanetscale.password.oncewithoutsensitive: trueisPARAM_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 writedestroy: delete) ordestroy: never(no delete request exists: always left, and a line’sdestroy: deleteisPARAM_INVALIDnaming the recipe). The record id is thenKEEP <path>, as for a hand-written line withdestroy: keep, so identity follows ADR 0017 unchanged; aneverop still declares the object’s URL asdelete.path, used only as its identity. - Sim shapes. Amendment 1’s
aliasesnow 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 fromfind.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’sauth.header, for header names that do not end in-Keyor-Token, such asX-Honeycomb-Team).ProviderSim.authorizedreceives the provider’s state for this.
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 takeurlandpathas two params.api.refuse_credential: { prefixes, unless_env }(an API block key, ADR 0017, amendment): a credential starting with one of the prefixes isPROVIDER_AUTHbefore any request, in plan, apply and destroy, unless the named variable is1ortrue. A recipe may declare it (Stripe:sk_live_,rk_live_, opt-outSPONSON_STRIPE_ALLOW_LIVE); a plan’s block cannot override a recipe’s. It complementsproduction: true, which asks for approval for an API that is production: this refuses a credential a preview must never use.delete.bodywithmethod: POST(http.resource): archive instead of delete (Stripe never deletes prices, or products that have prices). The body, literals only, joins the record id asbody=<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…/allis Amendment 2’s alias), read form values typed (form_types, derived fromencoding: form), show once-only fields only in the create’s answer (once_fields, derived fromonceoutputs), 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.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 inAccept; its recipe uses the API block’smedia_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.
createandupdatetakeheaders(values from params) andsend_fields: false(the declaredfieldsare 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 hintsheader_fieldsandsuffix_fieldstand in for an API that takes settings in headers and a URL as the path’s last segment.
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.authmay 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
authseed (R7) verifies the signature with the sims’ AWS key; for acommandrecipe 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’sUpdateBranch), 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.
- Railway (
railway.variable): the environment’s id is a param, not looked up (PR environment naming is not documented); values are readunrendered. 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 tous-east-1; a block overridesbase_urlandauthfor another region or temporary credentials. - Google Cloud Run (
cloud_run.traffic_tag): the per-PR tag is read-modify-write of the service’strafficwithupdateMask=trafficand the service’setagsent 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 withlistAppSettings(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, whichhttp.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.jsonalso acceptsoauth2_client_credentials(with a requiredtoken_url_env,<PROVIDER>_TOKEN_URL, so the tests can point it at the fake cloud, asbase_url_envis) and the header form’sprefix(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.commandrecipes 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_urloraudiencewritten as an absolute path (/oauth/token,/api/v2/) is resolved against the origin of the block’sbase_url(not itsbase_url_envoverride: every runner, and the ledger, see one value; the live harness therefore writes the real account’s URL asbase_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’swrite_fields, which the harness derives from the recipe).
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.authmay bedigest(Atlas API keys) or severalheaders(Datadog’s API and application keys), andapi.encodingmay bexml(S3’s CORS configuration, tested against the AWS sim through Amendment 5’ssim.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. credentialis a line key, likeapianddestroy, so a recipe line can take another line’s sensitive output (or a{ secret }) as its credential; a recipe param may not be namedcredential. Of Amendment 2’s “still out of reach”, “two credentials on every request” is reached byheaders, “a credential that is itself an output of another line” bycredential(withdestroy: keep), and “a delete that names the object in its body” by{id}indelete.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}).
/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 bekind: 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 servicessponson 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 alwaysapiand the key is the password, so a variable for the user would hold a constant.useranduser_envare exclusive; the password stays a variable. The harness checks the exact header (R7’sheaders). - Lists as repeated form keys (API block
form_arrays: repeat,formonly): Mailgun readsaction=…&action=…andevent_types=…&event_types=…(OpenAPI’sstyle: form, explode: true), not Stripe’sa[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 takesFriendlyNameand answersfriendly_name; Mailgun takesactionand answersactions. 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 underdata) and gives a{ secret }param in an action’s body a value the leak check covers.
- 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;initreports 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.createtakes 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 hintid_querynames 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 withPOST /deployments/{name}/delete. Adelete: { method: POST, path }without a body already worked against the real API; the REST sim now lets a seededdeletespath 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 byfindafter 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 withcreate.locate, whose answer is declared not to be the object.
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.