packages/adapters/src/) and the fake cloud (packages/sim/src/routes/) were written
without real provider accounts. This page records what was checked, without an account, against each provider’s
published API specification: every HTTP call an adapter makes, and every numbered assumption at the top of a sim
routes file. What a specification cannot settle still needs a live run of pnpm test:live
(scenarios/contract.test.ts).
Legend: ✅ matches the spec · ⚠️ differed from the spec (fixed in adapter and sim unless noted) · ❓ the spec is
silent; needs a live account.
Sources
All fetched on 2026-10-10 (LaunchDarkly, PlanetScale, Supabase, Netlify: 2026-10-11) and treated as data; none is vendored into the repository.securitySchemes), which is what the shared HTTP client sends
by default: ✅. LaunchDarkly takes the access token as the whole Authorization value, without Bearer; the adapter
sends it that way (authHeader: "header:authorization"): ✅. PlanetScale service tokens are not bearer tokens
either; see below. Supabase’s Management API and Netlify’s API use bearer tokens: ✅. Cloudflare’s API token is a bearer token
too (“The preferred authorization scheme”): ✅.
Vercel
Calls the adapter makes
| Call | What the adapter sends and reads | Spec | Notes |
|---|---|---|---|
GET /v10/projects/{idOrName}/env | teamId; reads envs[].id, key, value, target (string or list), gitBranch, type, decrypted | ⚠️ → ✅ | Was /v9/…, which the spec no longer lists. decrypt is listed but deprecated, and checked live on 2026-10-11 it no longer decrypts (decrypted: false, an opaque value; V3), so the adapter no longer sends it: a value with decrypted not true is fetched from the call below instead of being hashed as ciphertext. |
| same, pagination | follows pagination.next as ?until= | ❓ | The response has the spec’s Pagination object (count, next, prev), but this endpoint documents no page parameter. The adapter drops repeated records, so an API that ignores until costs one request, not duplicates. |
GET /v1/projects/{idOrName}/env/{id} | teamId; reads value, decrypted, type | ✅ (live ✅) | New. “Retrieve the decrypted value of an environment variable”; live on 2026-10-11 it answered decrypted: true with the plaintext, the only way to read an encrypted value. A sensitive value is never returned (the response variant without value). |
POST /v10/projects/{idOrName}/env | upsert=true, teamId; body: list of { key, value, type: "encrypted", target: [t], gitBranch? } | ✅ | Body matches the array form of the request schema. |
| same, answer | 201 { created, failed }; created one object or a list; failed[].error | ⚠️ → ✅ (live ✅) | The failure names the variable as key or envVarKey; the adapter read only key. The sim answered 200; the spec says 201. Live on 2026-10-11, an upsert of an existing key and target answered 201 with created a list holding the same id, createdAt unchanged, a newer updatedAt, and failed: [] (V4). |
same, gitBranch without a Git connection | 400 { error: { code: "BAD_REQUEST", message: "Project \"<name>\" does not have a connected Git repository.", key: "project", envVarKey, project } } → PARAM_INVALID | live ✅ | Not in the spec; found live on 2026-10-11 (V9). Branch-bound preview values (the default for target: preview) need a project connected to a repository; the adapter’s message names both fixes: connect it, or branch: "*" for project-wide values. |
| same, conflicts | an overlapping target set refused with 400 ENV_CONFLICT | ❓ | Not described. Without upsert, a duplicate is documented as 403 (“cannot be created because it already exists”); the sim now answers that. A 400 for “an ongoing env update is already happening” is documented; it is reported as PROVIDER_INVALID, not retried. |
DELETE /v9/projects/{idOrName}/env/{id} | teamId; 404 is success | ✅ | 409 (project being transferred) surfaces as PROVIDER_CONFLICT. |
GET /v7/deployments | projectId, sha, target, limit=20, teamId; reads uid, url, readyState, createdAt, target | ⚠️ → ✅ | Was /v6/deployments?sha=: sha is a documented filter of /v7 only, and v6 is not in the spec. The adapter required state, which is optional; readyState is the required field. url is null until an upload completes, which made the whole list a shape error. Lookups were also by commit only, so a production line could take the commit’s preview build; they now pass the documented target filter and re-check each item’s target (null = preview), see V8. |
| deployment states | READY done; ERROR, CANCELED failed; others in progress | ⚠️ → ✅ | The spec’s states add INITIALIZING (in progress), BLOCKED and DELETED; the last two never become ready by themselves and now fail the waiting lines. |
GET /v9/projects/{idOrName} | teamId; reads link.type, link.repoId / projectId / uuid, workspaceUuid | ✅ | New, to build gitSource (next row). |
POST /v13/deployments | forceNew=1, teamId; body { name, project, gitSource, target? } | ⚠️ → ✅ | gitSource was { type: "github", ref, sha }; every GitHub variant of the schema also requires repoId (or org and repo), so the call would have been refused. It now carries the connected repository from the project’s link (GitHub, GitLab, Bitbucket); a project with no Git connection is PROVIDER_INVALID. project overriding name matches the spec. forceNew=1 because Vercel may otherwise answer with an earlier deployment of the same commit, which defeats a redeploy. |
| same, answer | reads id, readyState, url, createdAt | ⚠️ → ✅ | One documented answer variant has no url; the adapter now polls until url is set. |
GET /v13/deployments/{idOrUrl} | teamId; reads id, url, readyState | ✅ |
Sim assumptions (packages/sim/src/routes/vercel.ts)
| Id | Assumption | Status |
|---|---|---|
| V1 | env writes visible to the next deployment and list at once | ✅ verified live 2026-10-11 for the list (a variable written with ?upsert=true is in the next GET list); a deployment’s view not checked separately (pinned by the contract suite) |
| V2 | GET /v7/deployments?sha= filters by commit, newest first | sha filter verified; order ❓ (the adapter sorts anyway) |
| V8 | GET /v7/deployments?target= filters by environment; items carry target (production, a custom environment, or null for previews) | ✅ verified (parameter and item schema) |
| V3 | the list never decrypts an encrypted value (even with decrypt=true); GET /v1/projects/{id}/env/{id} does; sensitive values never returned | ⚠️ contradicted live 2026-10-11 (the sim assumed decrypt=true decrypts; it answers decrypted: false and an opaque value): sim fixed, the adapter no longer sends decrypt; the per-variable GET verified live (pinned) |
| V4 | upsert answer { created, failed }; ENV_CONFLICT on overlapping targets | answer shape and status verified; created listing an update with the same id, createdAt kept and a newer updatedAt ✅ verified live 2026-10-11 (pinned); ENV_CONFLICT ❓ |
| V5 | project overrides name; gitSource identity; states; one URL per deployment | verified, except auto-cancel of a branch’s older builds ❓ |
| V6 | { envs, pagination: { count, next, prev } }, ?until= | envelope verified; the page parameter ❓ |
| V7 | GET /v9/projects/{id} carries link | verified |
| V9 | a gitBranch value on a project with no connected Git repository answers 400 BAD_REQUEST (“does not have a connected Git repository”) and writes nothing | ✅ verified live 2026-10-11 (new; pinned) |
Neon
Calls the adapter makes
| Call | What the adapter sends and reads | Spec | Notes |
|---|---|---|---|
GET /projects/{project_id}/branches | cursor from pagination.next; reads branches[].id, name, parent_id, default (and the deprecated primary) | ✅ | CursorParam, CursorPagination. A paged read starts with limit; the adapter sends none and follows next if present. |
POST /projects/{project_id}/branches | { branch: { name, parent_id }, endpoints: [{ type: "read_write" }] } | ✅ | Matches the spec’s own branch_with_endpoint example. |
| same, answer | 201; reads branch, endpoints[].host, connection_uris[].connection_uri | ✅ | connection_uris is absent when the parent has several databases or roles; the adapter then reads them with the calls below. |
| same, refusals | 409 duplicate name → re-read and claim; 423 retried | 423 ✅, 409 live ✅ | The generic error says 423 Locked is always safe to retry. A duplicate name is not documented; live on 2026-10-11 it answered 409 { code: "BRANCH_ALREADY_EXISTS", message: "branch already exists; branch_name:…" } (N5). |
same, parent omitted | forks from the branch listed default: true | live ✅ | Was main by default. A new Neon project’s root branch is called production (checked live 2026-10-11, N6), so a first apply with the default failed; the default branch is now found by its flag, whatever its name. |
GET /projects/{project_id}/branches/{branch_id}/endpoints | reads endpoints[].host, preferring type: read_write | ✅ | |
GET /projects/{project_id}/branches/{branch_id}/databases | reads databases[].name, owner_name | ✅ | New (next row). |
GET /projects/{project_id}/connection_uri | branch_id, database_name, role_name; reads uri | ⚠️ → ✅ | Both names are required parameters. They were hard-coded to neondb / neondb_owner, a new project’s defaults, so any other project failed to read. They now come from the branch’s database list (neondb if present, else the first; its owner is the role). |
DELETE /projects/{project_id}/branches/{branch_id} | 404 is success | ✅ path and status; timing live ✅ | “The deletion completes after all operations finish”: not synchronous. Live on 2026-10-11, right after DELETE answered 200 (operations still running or scheduling) the branch was already absent from the list (N1), which is all the adapter relies on. |
503 is always safe to retry; the shared HTTP client retries 5xx only for idempotent
methods, which is more cautious than needed but not wrong.
Sim assumptions (packages/sim/src/routes/neon.ts)
| Id | Assumption | Status |
|---|---|---|
| N1 | a deleted branch is gone from the list when DELETE returns | ✅ verified live 2026-10-11 (absent from the list at once, while the delete’s operations still run; pinned) |
| N2 | a create runs operations; endpoint usable at once; 423 only as the opt-in chaos fault neon_op_ms | ⚠️ contradicted in part live 2026-10-11: a second create in the same project right after a create (operations running) answered 201, not 423; the sim no longer locks creates by default. 423 retry-safety verified (the adapter retries it); endpoint readiness ❓ (pinned) |
| N3 | neondb / neondb_owner; default: true on the root branch | the adapter no longer depends on the names (verified calls above); default verified |
| N4 | { branches, pagination: { next } }, ?cursor= | verified |
| N5 | a duplicate branch name answers 409 { code: "BRANCH_ALREADY_EXISTS", message: "branch already exists; branch_name:…" } | ✅ verified live 2026-10-11 (sim body aligned; pinned) |
| N6 | a new project’s root branch is production and listed default: true | ✅ verified live 2026-10-11 (new; the adapter forks from default when parent is omitted; pinned) |
Clerk
Calls the adapter makes
| Call | What the adapter sends and reads | Spec | Notes |
|---|---|---|---|
GET /redirect_urls | paginated=true, limit=100, offset; reads data[].id, url, total_count (a bare array also accepted) | ⚠️ → ✅ | The adapter sent no parameters. limit defaults to 10 (maximum 500), and the documented answer is a bare array with no way to tell it was cut short. The adapter now asks for pages the way Clerk’s own SDKs do; the { data, total_count } envelope is not in the spec but is what both SDKs decode. |
POST /redirect_urls | { url }; reads id, url | ✅ | Answer is the spec’s RedirectURL (object, id, url, created_at, updated_at). |
| same, refusals | duplicate → re-read and claim | ❓ → hardened | The spec documents 400 and 422 (ClerkErrors) without saying which a duplicate gets or how it is worded. The adapter now checks the list after any 400/422, not only one whose body says “exists”. |
DELETE /redirect_urls/{id} | 404 is success | ✅ | 200 answers a DeletedObject; 404 is documented. |
Sim assumptions (packages/sim/src/routes/clerk.ts)
| Id | Assumption | Status |
|---|---|---|
| C1 | a duplicate answers 422 | ❓ (the adapter no longer depends on it) |
| C2 | bare array; with paginated=true, { data, total_count } by offset/limit | array and parameters verified; envelope from the SDK sources (pinned by the contract suite) |
| C3 | RedirectURL and DeletedObject shapes | verified |
LaunchDarkly
Calls the adapter makes
| Call | What the adapter sends and reads | Spec | Notes |
|---|---|---|---|
GET /api/v2/flags/{projectKey}/{featureFlagKey} | env=<environment key>; reads variations[]._id, value, name, and environments.<key>.targets[] / contextTargets[] (contextKind, values, variation index) | ✅ | env restricts the answer to one environment, as the reference recommends. targets holds kind user, contextTargets the other kinds; the adapter reads both and defaults a missing contextKind to user. 404 (unknown project or flag) fails the line; destroy treats it as already gone. |
| same, unknown environment | an environment the answer lacks is PROVIDER_NOT_FOUND naming it | ❓ | Whether LaunchDarkly answers 404 or omits the environment is not stated; the adapter reports either as not found. |
PATCH /api/v2/flags/{projectKey}/{featureFlagKey} | Content-Type: application/json; domain-model=launchdarkly.semanticpatch; body { environmentKey, comment, instructions } with addTargets and/or removeTargets { contextKind, values: [key], variationId } | ✅ | Without the domain-model parameter the body is read as a JSON patch and refused with 400. Moving a target sends removeTargets (old variation) and addTargets (new) in one patch: instructions are all or nothing, and addTargets is an error when the key would be targeted by two variations. The shared client gained WriteOptions.contentType for this. |
| same, answer | 200 with the whole flag; the adapter re-reads the target from it | ✅ | A target not served the wanted variation afterwards is PROVIDER_RESPONSE. |
| same, refusals | 405 (environment requires approvals) → PROVIDER_INVALID saying so; 409 (concurrent change) → re-read and retry, up to 3 times; 429 retried by the client | ✅ | Sponson does not open approval requests; use a preview or test environment without required approvals. 400 for a conflict with a pending scheduled change or approval is reported, not bypassed with ignoreConflicts. |
Sim assumptions (packages/sim/src/routes/launchdarkly.ts)
| Id | Assumption | Status |
|---|---|---|
| LD1 | the access token is the whole Authorization value; base URL https://app.launchdarkly.com/api/v2 | verified (the base URL from the OpenAPI document’s location) |
| LD2 | GET /flags/{p}/{f}?env= answers variations and environments restricted to that environment; unknown project or flag 404 | verified, except how an unknown environment is answered ❓ |
| LD3 | targets (kind user) and contextTargets (other kinds), items { contextKind, values, variation }; contextTargets also carries empty user placeholders | lists and items verified; the placeholders ❓ (the adapter ignores empty entries either way) |
| LD4 | semantic patch media type; { environmentKey, instructions, comment }; 200 with the flag; all or nothing | verified |
| LD5 | addTargets refused when the key would be in two variations; re-adding a key to the variation that already serves it succeeds and changes nothing; removeTargets of an absent key does nothing | refusal verified (its status ❓); re-adding ❓ (pinned by the contract suite); removal verified |
| LD6 | 405 when approvals are required; 409 for a concurrent change; error body { code, message } | verified |
| LD7 | a patch is visible to the next GET | ❓ (pinned by the contract suite) |
PlanetScale
Paths are relative tohttps://api.planetscale.com/v1 (the spec’s servers) and, below the first row, to
/organizations/{organization}/databases/{database}.
Calls the adapter makes
| Call | What the adapter sends and reads | Spec | Notes |
|---|---|---|---|
| every request | Authorization: <PLANETSCALE_SERVICE_TOKEN_ID>:<PLANETSCALE_SERVICE_TOKEN> | ✅ | Service tokens page; the spec’s securitySchemes lists only OAuth. Variable names as in the CLI docs. Accesses needed: create_branch, read_branch, delete_branch, connect_branch, delete_branch_password (each endpoint’s “Service Token Accesses”). |
GET /branches (list_branches) | page from next_page; reads data[].id, name, parent_branch, production, ready | ✅ | Default per_page 25. Used by listScope only. |
GET /branches/{branch} (get_branch) | by name; 404 → absent; reads id, name, parent_branch, production, ready | ✅ | state (pending … ready) is also returned; the adapter relies on the boolean ready. |
POST /branches (create_branch) | { name, parent_branch }; 201 | ✅ | parent_branch is optional in the spec (defaults to the database’s default branch); the adapter always sends it, after checking it exists with get_branch. |
| same, duplicate name | any 409 or 422 → get_branch by name, claim it if found | ❓ | The spec lists 422 and no 409; the wording is undocumented (PS5). |
DELETE /branches/{branch} (delete_branch) | by name, after get_branch confirmed the id the ledger recorded; 404 is success | ✅ path and 204; ❓ timing | Whether deletion is synchronous needs a live run (PS7). |
GET /branches/{branch}/passwords (list_passwords) | page from next_page; reads data[].id, name, role, username, access_host_url | ✅ | plain_text is “Null except in the response from the create endpoint”. The q search is not used (its matching rules are undocumented); the adapter filters names itself. |
POST /branches/{branch}/passwords (create_password) | { name, role }; reads id, role, username, access_host_url, plain_text | ✅ | Roles reader, writer, admin, readwriter. name is “optional”; uniqueness is not documented (PS9). |
DELETE /branches/{branch}/passwords/{id} (delete_password) | 404 is success | ✅ | 204. |
| connection string | mysql://<username>:<plain_text>@<access_host_url>/<database>?ssl={"rejectUnauthorized":true} | ✅ | The Node.js tutorial’s form; Prisma’s sslaccept=strict via connection_params. |
PATCH …/passwords/{id} takes only name and cidrs (✅), so a role cannot change in place (PS10), and
the adapter never calls …/passwords/{id}/renew, which would rotate the plaintext.
Sim assumptions (packages/sim/src/routes/planetscale.ts)
| Id | Assumption | Status |
|---|---|---|
| PS1 | Authorization: <id>:<token>; the sim refuses Bearer | verified (pinned by the contract suite) |
| PS2 | paths under organization and database; branches addressed by name | verified |
| PS3 | { data, next_page }, ?page=, 25 per page | verified |
| PS4 | a created branch is ready: false at first and becomes ready later | fields verified; initial state through the CLI’s --wait only; duration ❓ (pinned) |
| PS5 | a duplicate branch name answers 422 | ❓ (the adapter accepts 409 or 422) |
| PS6 | an unknown parent answers 404 | ❓ (the adapter checks the parent first) |
| PS7 | branch deletion is synchronous and takes the passwords with it | 204 verified; timing ❓ |
| PS8 | plain_text only in the create answer | verified (pinned) |
| PS9 | password names are not unique | ❓ (the adapter refuses two of one name) |
| PS10 | a password’s role cannot change in place | verified |
| PS11 | password delete 204; 404 when gone | 204 verified; 404 ❓ |
| PS12 | a password on a branch that is not ready is refused | ❓ (the adapter waits for ready first, so either answer is fine) |
| PS13 | error bodies are { code, message } | ❓ (never read by the adapter) |
| PS14 | branch name characters | ❓ (the default name uses only a-z, 0-9, -) |
The generic http adapter
The http adapter makes the calls a plan line declares, so there is no provider to verify it against: a line is
checked by running sponson plan (it only reads) against the real API before the first apply. The plans in
examples/ that use it are illustrative and say so.
Recipes are the exception: each is verified against the provider pages it cites, its assumptions are
numbered and marked verified or not in its file (and listed in docs/recipes.md), and pnpm test:live runs it against
the real API when its credential and SPONSON_LIVE_* variables are set (ADR 0020).
Sim assumptions (packages/sim/src/routes/rest.ts)
They describe a conventional JSON REST API, not one provider; the adapter’s tests and scenarios run against them.
| Id | Assumption | Status |
|---|---|---|
| R1 | credentials as Authorization: Bearer, Authorization: Basic, a bare token in Authorization (Unleash) or a header ending in -Api-Key, -Key or -Token | convention |
| R2 | { data } envelopes for collection objects and lists, cursor in next_cursor; other objects bare; a collection’s style changes the envelopes, cursor and id field; an object’s style may give its reads an item_path envelope; a create may be posted to an alias of its collection | convention (the plan line names its own envelope with item_path, list_path, find.next; the recipe tests shape the style from the recipe) |
| R3 | unknown paths are 404 until seeded or created; client-chosen ids; duplicate name → 409; a style may name objects by a query parameter (item_query: ?url=), and let a create choose the id by one (id_query: ?channelId=) | convention (exists_status covers providers that answer otherwise; the queries follow Stytch’s management API, SY4 in its recipe, and Firebase Hosting’s channels.create, FH2 in its recipe) |
| R4 | PATCH (and POST to an object, as Stripe updates) is a JSON merge patch (RFC 7396); PUT replaces; JSON or form bodies, form values typed under the style’s form_types; no preconditions | RFC 7396 for PATCH; the rest convention; Stripe’s typed form parameters (ST1) |
| R5 | DELETE answers { id, deleted }; unknown is 404 | convention (gone_status covers providers that answer otherwise) |
| R6 | a style’s once_fields appear only in the create’s answer, never stored or listed | Stripe’s webhook signing secret (ST5–ST6 in its recipe) |
| R7 | with an auth seed, credentials are checked for real: an OAuth2 client-credentials token endpoint, fixed bearer tokens, HTTP Digest, SigV4 | RFC 6749 §4.4, RFC 7616 (its §3.9.1 example is a test vector), AWS SigV4 (its published test suite is a test vector; ../auth.ts is written independently of the client) |
| R8 | a path the seed lists in reads is a read sent as a POST: it answers the object it names and is not a write | Azure Resource Manager list actions (POST …/builds/{environmentName}/listAppSettings, https://learn.microsoft.com/en-us/rest/api/appservice/static-sites/list-static-site-build-app-settings) |
| R10 | a style’s generated fields are generated on create and stored; with the auth seed’s generated_header, that header is accepted when it carries a value the sim generated | Postmark’s server token (ApiTokens, PM4 in its recipe), which authenticates the Server API (PS1) |
| R11 | a seeded deletes path takes a POST naming objects of a collection in its body (or, with a {id} segment, in its path) and removes them; 404 when none exists | Datadog’s POST /synthetics/tests/delete (DD5 in its recipe); Convex’s POST /deployments/{name}/delete (CX5 in its recipe) |
Authentication forms (packages/adapters/src/http-adapter-auth.ts)
Checked on 2026-10-11 against the providers’ docs and official clients; the sims and scenarios encode them.
| Provider | Form | Source | Status |
|---|---|---|---|
| Auth0 Management API | POST https://{domain}/oauth/token, form grant_type=client_credentials, client_id, client_secret, audience=https://{domain}/api/v2/; answer { access_token, expires_in, token_type: Bearer } | https://auth0.com/docs/secure/tokens/access-tokens/management-api-access-tokens/get-management-api-access-tokens-for-production | ✅ verified |
| Kinde Management API | POST https://{sub}.kinde.com/oauth2/token, form with audience=https://{sub}.kinde.com/api | https://docs.kinde.com/developer-tools/kinde-api/access-token-for-api/ | ✅ verified |
| Okta | Custom authorization server /oauth2/default/v1/token with client_secret_basic: ✅. The org server’s okta.* (management API) scopes accept only private_key_jwt, which is not supported | https://developer.okta.com/docs/guides/implement-grant-type/clientcreds/main/, https://developer.okta.com/docs/guides/implement-oauth-for-okta-serviceapp/main/ | ✅ verified; management API ⚠️ not covered |
| MongoDB Atlas | Admin API v2 https://cloud.mongodb.com/api/atlas/v2, Accept: application/vnd.atlas.2023-01-01+json (406 without); API keys by HTTP Digest (OpenAPI DigestAuth); service accounts by client credentials at https://cloud.mongodb.com/api/oauth/token (Basic) | https://github.com/mongodb/openapi (openapi/v2.json), https://www.mongodb.com/docs/atlas/api/api-authentication/ | ✅ verified; the challenge’s algorithm=MD5, qop="auth" ❓ (seen in a third-party report only; both algorithms are supported) |
| Google Cloud | gcloud auth print-access-token as a bearer token; Identity Toolkit PATCH admin/v2/projects/{p}/config?updateMask=authorizedDomains; user credentials also need x-goog-user-project | https://cloud.google.com/identity-platform/docs/reference/rest/v2/projects/updateConfig, https://cloud.google.com/docs/authentication/troubleshoot-adc | ✅ verified (quota-project header from the troubleshooting page) |
| Azure | az account get-access-token --query accessToken -o tsv | https://learn.microsoft.com/en-us/cli/azure/account#az-account-get-access-token | ✅ verified |
| AWS | SigV4 header signing; S3 requires x-amz-content-sha256 | https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html; test vectors from https://github.com/awslabs/aws-c-auth/tree/main/tests/aws-signing-test-suite/v4 (get-vanilla, get-vanilla-query-order-key-case, post-vanilla, post-x-www-form-urlencoded) | ✅ verified |
Sim assumptions (packages/sim/src/routes/aws.ts)
| Id | Assumption | Status |
|---|---|---|
| A1 | requests signed with SigV4 for the path’s service and region; S3 requests carry x-amz-content-sha256 | ✅ verified (IAM signing guide; S3 common request headers) |
| A2 | PUT /?cors takes 1–100 CORSRules, each with an AllowedOrigin and an AllowedMethod, requires Content-MD5 (or an x-amz-checksum-*), replaces the whole configuration, answers 200 with no body; empty is MalformedXML | ✅ verified (https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketCors.html; botocore requestChecksumRequired) |
| A3 | GET /?cors answers the configuration as XML, 404 NoSuchCORSConfiguration without one; DELETE /?cors answers 204 | ✅ verified for GET and DELETE (API_GetBucketCors, API_DeleteBucketCors); the 404 code ❓ |
| A4 | Amplify GetBranch / UpdateBranch (GET/POST /apps/{appId}/branches/{branchName}) answer { branch }; environmentVariables replaces the whole map; errors are JSON with x-amzn-ErrorType | ✅ verified (https://docs.aws.amazon.com/amplify/latest/APIReference/API_UpdateBranch.html, API_GetBranch.html; signing name amplify from botocore) |
Sim assumptions (packages/sim/src/routes/railway.ts, packages/sim/src/routes/fly.ts)
| Id | Assumption | Status |
|---|---|---|
| RW1 | one endpoint https://backboard.railway.com/graphql/v2; account or workspace tokens as Authorization: Bearer, project tokens as Project-Access-Token | ✅ verified (https://docs.railway.com/reference/public-api) |
| RW2 | failures are HTTP 200 with errors[]; a refused token is INTERNAL_SERVER_ERROR “Not Authorized”; parse and validation errors are 400 | ✅ verified (same page) |
| RW3 | variables(projectId, environmentId, serviceId, unrendered) answers the EnvironmentVariables scalar, a flat name → value map | ✅ verified (https://github.com/railwayapp/cli, src/gql/schema.json) |
| RW4 | variableUpsert(input: VariableUpsertInput!): Boolean! and variableDelete(input: VariableDeleteInput!): Boolean! with projectId, environmentId, serviceId, name (and value) | ✅ verified (same schema); deleting an absent variable answers true ❓ |
| RW5 | an unknown environment is an error “Environment not found” | ❓ wording |
| F1 | Authorization: Bearer <token>, or FlyV1 <macaroon> for tokens from fly tokens create | ✅ verified (https://github.com/superfly/fly-go, tokens/tokens.go) |
| F2 | app(name) { secrets { name digest createdAt } }; values never returned | ✅ verified (fly-go resource_secrets.go, schema.graphql) |
| F3 | setSecrets(input: { appId, secrets: [{ key, value }], replaceAll }) and unsetSecrets(input: { appId, keys }) answer { app, release } | ✅ verified (fly-go schema.graphql); unsetting an absent key ❓ |
| F4 | failures are HTTP 200 with errors[]; an unknown app is “Could not find App” | ❓ wording. Current flyctl sets secrets through the Machines API (api.machines.dev/v1/apps/{app}/secrets, documented only in fly-go’s source); the GraphQL mutations are not marked deprecated |
Supabase
Written against the spec from the start (the adapter is newer than this page), so there is no ⚠️ column history: every call below was checked before the first commit. Base URLhttps://api.supabase.com/v1; the spec’s paths carry the
/v1 prefix.
Calls the adapter makes
| Call | What the adapter sends and reads | Spec | Notes |
|---|---|---|---|
GET /v1/projects/{ref}/branches | reads [].name, project_ref, is_default | ✅ | v1-list-all-branches: a bare array of BranchResponse, no page parameters. The default branch is skipped and never managed. |
POST /v1/projects/{ref}/branches | { branch_name }; reads name, project_ref from the 201 | ✅ | CreateBranchBody requires only branch_name; git_branch, persistent, with_data, region and the rest are optional and not sent. |
| same, refusals | a duplicate name → re-read and claim; any other refusal reported | ❓ | Only 201, 401, 403, 429 and 500 are documented. The adapter checks the list after a 409, 400 or 422 before reporting (S5). A 500 (“Failed to create database branch”) is not retried: the create may have happened, and the intent plus find-by-name recovers it on the next run. |
GET /v1/branches/{branch_id_or_ref} | the branch’s ref; reads status, db_host, db_port, db_user, db_pass | ✅ fields; ❓ readiness | BranchDetailResponse. status is the project status enum; ACTIVE_HEALTHY is taken as ready and INIT_FAILED, REMOVED, GOING_DOWN, INACTIVE, PAUSING, PAUSE_FAILED, RESTORE_FAILED as never-ready. db_user and db_pass are optional in the spec; without them the line fails with PROVIDER_RESPONSE rather than outputting a broken URI. A 404 while the branch comes up is treated as “not yet”. The ref form of the path parameter is used: the uuid form is marked deprecated. |
DELETE /v1/branches/{branch_id_or_ref} | the branch’s ref; 404 is success | ✅ path, 200; ❓ 404 | ”By default, deletes immediately” (force=false would schedule it). No 404 is documented; an unknown ref’s status needs a live run. The parent project’s own ref is never sent. |
GET /v1/projects/{ref}/config/auth | reads uri_allow_list (nullable string) | ✅ | AuthConfigResponse; uri_allow_list is required and nullable. |
PATCH /v1/projects/{ref}/config/auth | { uri_allow_list } alone; retried as idempotent | ✅ | UpdateAuthConfigBody has no required field, so a body with one field touches one field. Sending only that field also avoids the reported failure when re-sending a whole config with auth hooks enabled (supabase/supabase#36861). No ETag or version: the write is unconditional, so Sponson’s writers hold a lock on the list (lockOn, ADR 0019), and the adapter re-reads and repeats (three rounds) when a writer outside Sponson overwrote it. |
api_url is https://<ref>.supabase.co (the project URL format in the API docs) and
connection_string is postgresql://<db_user>:<db_pass>@<db_host>:<db_port>/postgres, matching the documented
direct connection string (postgres database; the password percent-encoded). The direct host resolves over IPv6
unless the project has the IPv4 add-on.
Sim assumptions (packages/sim/src/routes/supabase.ts)
| Id | Assumption | Status |
|---|---|---|
| S1 | create takes { branch_name } and answers 201 BranchResponse with the branch’s own project_ref | verified (schema); that project_ref is usable before provisioning finishes ❓ (pinned) |
| S2 | the list is a bare unpaged array, and includes the project’s own default branch | array verified; the default branch’s presence ❓ (pinned; the adapter skips it either way) |
| S3 | the detail answers 200 with COMING_UP until ready, then ACTIVE_HEALTHY | fields and enum verified; the progression and the 200 while coming up ❓ (pinned) |
| S4 | detail carries db_user/db_pass; database postgres; API URL https://<ref>.supabase.co | optional fields verified; that a personal access token gets db_pass ❓ (pinned); database name and URL verified in the docs |
| S5 | a duplicate branch name answers 409 | ❓ (pinned; the adapter accepts 400/409/422) |
| S6 | delete answers 200 { message: "ok" }, gone from the list at once; unknown ref 404 | 200 and immediacy of the default verified; list visibility and 404 ❓ (pinned) |
| S7 | uri_allow_list nullable string; a one-field PATCH changes one field; no precondition | verified |
| S8 | the allow-list is comma-separated; stored verbatim | format verified (Auth’s URI_ALLOW_LIST); verbatim storage ❓ (pinned) |
| S9 | a branch has its own Auth config at /v1/projects/{branch ref}/config/auth | ❓ |
Netlify
Written against the specification and docs from the start (no earlier version to correct), so the table records what each call relies on rather than fixes.Calls the adapter makes
| Call | What the adapter sends and reads | Spec | Notes |
|---|---|---|---|
GET /sites/{site_id} | reads name, account_id (else account_slug) | ✅ | getSite. Only to name the account when the plan does not, and to build URLs from name. That name is the <name>.netlify.app subdomain is the docs’ example (mysitename.netlify.app), not a statement (NL2, pinned). |
GET /accounts/{account_id}/env | site_id; reads [].key, values[].id, value, context, context_parameter, is_secret, updated_at | ✅ | getEnvVars; “If specified, only return environment variables set on this site”, so shared (account) variables never enter a line’s scope. {account_id} may be the slug (API guide). No page parameters are documented; the adapter reads one answer. |
POST /accounts/{account_id}/env | site_id; body [{ key, values: [{ context, value, context_parameter? }] }]; reads 201 list of envVar | ✅ | createEnvVars. scopes and is_secret are left out: “By default, environment variables apply to all scopes” (overview); a variable Sponson creates is not secret, so its values stay comparable. |
| same, an existing key | re-list, then PATCH instead | ❓ | The status is not documented (NL5); the adapter treats 400, 409 and 422 alike and only falls back when the re-list shows every key. Between Sponson runs this cannot happen: lines lock netlify:<site>:env (ADR 0019); it covers a person or another tool creating the variable meanwhile. |
PATCH /accounts/{account_id}/env/{key} | site_id; body { context, value, context_parameter? }; reads 201 envVar | ✅ | setEnvVarValue: “updates or creates a new value for an existing environment variable”. Sent as idempotent (it sets state). That it leaves other contexts’ values and keeps the replaced value’s id is ❓ (NL3, pinned). |
DELETE /accounts/{account_id}/env/{key}/value/{id} | site_id; 404 is success | ✅ | deleteEnvVarValue, 204. Destroy removes only the line’s values. |
GET /accounts/{account_id}/env/{key} | site_id; reads values | ✅ | getEnvVar, after a value delete: is anything left? |
DELETE /accounts/{account_id}/env/{key} | site_id; 404 is success | ✅ | deleteEnvVar, 204; only when the variable has no value left (NL6). Netlify offers no precondition, so the check and the delete run under the netlify:<site>:env lock: another Sponson scope cannot add a value in between. |
GET /sites/{site_id}/deploys | page=1&per_page=100; reads [].id, state, context, commit_ref, review_id, created_at | ✅ fields; ❓ order and context values | listSiteDeploys; page/per_page from the API guide. state values from the operation’s state filter enum (ready; error, rejected treated as failed; the rest in progress). context and review_id have no enum or description in the spec (NL7, pinned). The adapter sorts by created_at and filters by commit itself. |
POST /sites/{site_id}/builds | branch (absent: production) | ✅ | createSiteBuild: “No branch means production … Otherwise it’s a branch deploy.” Used to rebuild a production or branch deploy that predates a write. Whether it builds a branch whose branch deploys are off is ❓ (NL11). |
context: deploy-preview line reports the stale deploy (notes.stale_deploy) and waits for a newer one.
Sim assumptions (packages/sim/src/routes/netlify.ts)
| Id | Assumption | Status |
|---|---|---|
| NL1 | base URL https://api.netlify.com/api/v1, bearer | ✅ verified |
| NL2 | env paths, statuses (201, 204); account slug accepted; site name, account_id, account_slug | ✅ verified; name = subdomain ❓ (pinned) |
| NL3 | envVar / envVarValue shapes and contexts; branch needs context_parameter; default scopes | ✅ verified; PATCH leaves other values and keeps the id ❓ (pinned) |
| NL4 | secret values write-only except dev | ✅ verified (Secrets Controller); how a masked value is returned ❓ (the adapter ignores it) |
| NL5 | duplicate key on POST → 400; all cannot sit with contextual values | ❓ (pinned: 400 or 409) |
| NL6 | a variable keeps existing with no values | ❓ (pinned: the adapter deletes it either way) |
| NL7 | deploy list newest first, its fields; context values; review_id = PR number | fields ✅; order and values ❓ (pinned) |
| NL8 | deploy state values | ✅ verified (enum of the state filter) |
| NL9 | permalink and Deploy Preview URL formats | ✅ verified (deploy overview) |
| NL10 | builds read the values set when they started; updated_at changes with a value | first ✅ verified; that PATCH bumps updated_at ❓ (pinned) |
| NL11 | POST /sites/{site_id}/builds with and without branch | ✅ verified; branch deploys turned off ❓ |
| NL12 | a branch value reaches that branch’s Deploy Previews | ✅ verified (“used for deploy permalinks, Deploy Previews, and branch deploys for the specified branch”) |
Cloudflare
Calls the adapter makes
| Call | What the adapter sends and reads | Spec | Notes |
|---|---|---|---|
| base URL and auth | https://api.cloudflare.com/client/v4, Authorization: Bearer <CLOUDFLARE_API_TOKEN> | ✅ | Make API calls. The token needs the account permission “Cloudflare Pages Edit” (permissions; schema token groups Pages Read/Pages Write). |
GET /accounts/{account_id}/pages/projects/{project_name} | reads success, result.deployment_configs.{preview,production}.env_vars: NAME → { type, value } | ✅ | Schema pages_project (required deployment_configs, each with env_vars, a nullable map of pages_plain_text_env_var / pages_secret_text_env_var). A null env_vars or entry is read as empty / absent. |
| same, secrets | a secret_text value is never used, whatever the answer holds | ❓ → hardened | The docs say a secret cannot be seen after it is set (bindings); the schema’s example is {"type":"secret_text","value":""} (marked x-sensitive). Not stated whether value is "", null or absent; the adapter ignores it in every case (CF3). |
PATCH of the same path | body { deployment_configs: { <target>: { env_vars: { NAME: { type, value } | null } } } }, sent as idempotent | ✅ null, ❓ merge | ”To delete an environment variable, set its key to null” (schema and edit reference). That keys not in the body are kept is implied (the rule, and no required body field) but not stated; the adapter re-reads after every write and fails with PROVIDER_RESPONSE naming any key it did not send that disappeared (CF4, pinned). Answer 200 with the project: ✅. |
| same, concurrency | none | ✅ (none exists) | No If-Match/ETag parameter and no 412 on this operation (CF5). Sponson’s own writers of one map are serialised by the parent lock cloudflare:<account>:pages:<project>:<target>:env (ADR 0019); writers outside Sponson race, last write wins. |
| failures | 4XX { success: false, errors: [{ code, message }] }; unknown project → 404 | ✅ shape, ❓ 404 | Schema pages_api-response-common-failure; it declares only 4XX. 404 with code 8000007 comes from Wrangler’s error reports, not the spec (CF6). The shared client classifies by status. |
| rate limit | 429 retried, honouring Retry-After | ✅ | “1,200 requests per five minute period per user”; Retry-After in seconds (limits). |
Sim assumptions (packages/sim/src/routes/cloudflare.ts)
| Id | Assumption | Status |
|---|---|---|
| CF1 | base URL, bearer token, “Pages Edit” permission | ✅ verified |
| CF2 | GET envelope and deployment_configs.<env>.env_vars shape | ✅ verified |
| CF3 | a secret’s value is never returned; the entry keeps its type | partly verified (docs and schema example); exact form ❓ (pinned by the contract suite) |
| CF4 | PATCH merges the map; null deletes; 200 with the project | null and 200 ✅; merge ❓ (pinned) |
| CF5 | no precondition on the PATCH | ✅ verified |
| CF6 | failure envelope; unknown project 404 / 8000007 | envelope ✅; status and code ❓ |
| CF7 | a write is visible to the next GET | ❓ (pinned) |
| CF8 | one set of preview variables for every preview deployment | ✅ verified: “This will set the configuration for all preview deployments, not just the deployments from a specific branch” (Wrangler configuration) |
| CF9 | no documented pattern for variable names | ✅ (schema map keys are free-form) |
| CF10 | a variable reaches only later deployments | indirectly documented (“Redeploy your project for the binding to take effect”, bindings); the adapter does not redeploy |
Not built, and why
- Preview URL output. Preview deployments
documents the branch alias
<alias>.<project>.pages.devand that “Branch name aliases are lowercased and non-alphanumeric characters are replaced with a hyphen”, but not the shortening of long names (a 28-character limit appears only in community answers) or what happens on collisions, and the host is the project’ssubdomain, which may differ from its name. Computing it from the branch would sometimes be wrong, sopages_envhas no outputs. The deployment object’s documentedaliaseslist is the reliable source, for a later deployment op.
Algolia
Calls the adapter makes
| Call | What the adapter sends and reads | Spec | Notes |
|---|---|---|---|
| base URL and auth | https://<application_id>.algolia.net, headers X-Algolia-Application-Id and X-Algolia-API-Key: <ALGOLIA_API_KEY> | ✅ | spec.yml servers and security schemes; the CLI reads ALGOLIA_API_KEY (AL2). |
GET /1/indexes/{name}/settings | existence only; 404 means absent | ✅ | settings.yml documents 404 “Index not found” (AL4). Used for the line’s index and to refuse a template that does not exist. |
POST /1/indexes/{copy_from}/operation | { operation: "copy", destination, scope? }; reads taskID | ✅ | operationIndex.yml (AL3). Overwrites an existing destination; copying a missing source makes an empty index, hence the adapter’s check. Sent as idempotent: a repeated copy writes the same template again. |
GET /1/indexes/{name}/task/{taskID} | status until published | ✅ | getTask.yml (AL5). Whether the destination is visible before then is not stated ❓; the sim shows it only when published. |
DELETE /1/indexes/{name} | answer not read | ✅ | Deleting a missing index “is ignored without warning” (AL6); a 404 is treated as gone too. |
Sim assumptions (packages/sim/src/routes/algolia.ts)
| Id | Assumption | Status |
|---|---|---|
| AL1 | the index name is its identity in the application | ✅ verified |
| AL2 | host and the two authentication headers; ALGOLIA_API_KEY | ✅ verified |
| AL3 | copy answers { taskID }; scope; overwrite; a missing source makes an empty index | ✅ verified |
| AL4 | settings of a missing index answer 404 | ✅ verified |
| AL5 | task status published / notPublished; destination absent until published | status ✅; visibility ❓ (pinned) |
| AL6 | deleting a missing index is ignored | ✅ verified (status ❓) |
| AL7 | the index list without page is unpaginated | ✅ verified (not used by the adapter) |
| AL8 | a copy is published within minutes | ❓ (the wait is bounded by SPONSON_ALGOLIA_TASK_TIMEOUT_MS) |
Xata
Calls the adapter makes
| Call | What the adapter sends and reads | Spec | Notes |
|---|---|---|---|
| base URL and auth | https://api.xata.tech, Authorization: Bearer <XATA_API_KEY> | ✅ | Spec security scheme apiKey; the CLI reads XATA_API_KEY (XA1). |
GET /organizations/{org}/projects/{project}/branches | branches[].id, .name | ✅ | Unpaginated (XA3). Name uniqueness is not documented ❓: more than one match is refused. |
POST of the same path | { mode: "inherit", parentID, name }; reads id, name | ✅ | 201, no 409 documented (XA2), hence the lookup by name before every create. |
GET …/branches/{branchID} | status.statusType until it ends in HEALTHY; one ending in FAULT fails | ✅ field, ❓ spelling | The CLI’s branch wait-ready “blocks until the branch is healthy” (XA5). |
GET …/branches/{branchID}/credentials?username=xata | connectionString | ✅ | (XA6). The branch’s own connectionString field is deprecated; not used. |
DELETE …/branches/{branchID} | answer not read; 404 is gone | ✅ | Permanent (XA4). |
Sim assumptions (packages/sim/src/routes/xata.ts)
| Id | Assumption | Status |
|---|---|---|
| XA1 | base URL, bearer API key, XATA_API_KEY | ✅ verified |
| XA2 | create a child by parentID with mode: inherit; 201 with the branch | ✅ verified |
| XA3 | the list is { branches }, unpaginated; names may repeat | list ✅; uniqueness ❓ |
| XA4 | detail with status; 404; permanent DELETE (204) | ✅ verified |
| XA5 | ready when healthy, broken on a fault | field ✅; enum spelling ❓ (pinned) |
| XA6 | credentials readable at any time | endpoint ✅; “any time” inferred |
| XA7 | ready within minutes | ❓ (bounded by SPONSON_XATA_READY_TIMEOUT_MS) |
Contentful
Calls the adapter makes
| Call | What the adapter sends and reads | Spec | Notes |
|---|---|---|---|
| base URL and auth | https://api.contentful.com, Authorization: Bearer <CONTENTFUL_MANAGEMENT_TOKEN>, Content-Type: application/vnd.contentful.management.v1+json | ✅ | The variable is the one Contentful’s Terraform provider and migration tool read (CF1, CF3). |
GET /spaces/{space}/environments/{id} | sys.id, sys.status.sys.id; 404 is absent | ✅ | Polled until ready (failed fails), as the CLI’s --await-processing does (CF4, CF5). |
PUT of the same path | { name }, X-Contentful-Source-Environment: <source>; 201 with status queued | ✅ | A 409 (it exists: a create whose answer was lost) is read and waited for, claimed by the engine (CF2, CF6 ❓). |
DELETE of the same path | 204; 404 is gone | ✅ | Never master (CF5). |
GET /spaces/{space}/api_keys/{id} | name, description, environments[].sys.id, sys.version | ✅ | (CF7) |
PUT of the same path | { name, description, environments } with X-Contentful-Version; reads the answer’s environments | ✅ | The fields the official SDK sends; a 409 is read and written again, three rounds at most (CF6, CF7). |
Sim assumptions (packages/sim/src/routes/contentful.ts)
| Id | Assumption | Status |
|---|---|---|
| CF1 | base URL, bearer token, CONTENTFUL_MANAGEMENT_TOKEN | ✅ verified (EU host ❓) |
| CF2 | PUT with an id creates, copied from the source header (default master); 201, queued | ✅ verified |
| CF3 | the CMA media type on every body | ✅ verified (whether plain JSON is refused ❓; the sim refuses it) |
| CF4 | queued → inProgress → ready / failed, read on the environment | ✅ verified (pinned) |
| CF5 | 404 NotFound; DELETE 204; master cannot be deleted | ✅ verified |
| CF6 | an update needs the current version, else 409 VersionMismatch | rule ✅; the status for a create on an existing id ❓ |
| CF7 | an API key reads only the environments it links; PUT with the version replaces them | ✅ verified (pinned) |
| CF8 | the environment list envelope | ✅ verified (not used by the adapter) |
| CF9 | 151 environments per space, 12 creations per 5 minutes | ✅ verified (per-plan counts ❓) |
| CF10 | a copy is ready within minutes | ❓ (bounded by SPONSON_CONTENTFUL_READY_TIMEOUT_MS) |
DigitalOcean
Calls the adapter makes
| Call | What the adapter sends and reads | Spec | Notes |
|---|---|---|---|
| base URL and auth | https://api.digitalocean.com, Authorization: Bearer <DIGITALOCEAN_ACCESS_TOKEN> | ✅ | (DO1) |
GET /v2/apps?per_page=100&page= | apps[].id, .spec, .live_url; follows links.pages.next | ✅ | No name filter; the adapter matches spec.name as DigitalOcean’s own GitHub action does (DO2). |
POST /v2/apps | { spec }; reads app.id, app.pending_deployment.id | ✅ | Duplicate name: unique per account, status undocumented ❓; any conflict is re-read and claimed (DO3). |
PUT /v2/apps/{id} | { spec }, the whole spec | ✅ | Replaces the spec; starts a deployment (DO4, inferred). |
GET /v2/apps/{app_id}/deployments/{deployment_id} | deployment.phase until ACTIVE or SUPERSEDED; ERROR/CANCELED fail | ✅ | (DO5) |
DELETE /v2/apps/{id} | answer not read; 404 is gone | ✅ | (DO8) |
Sim assumptions (packages/sim/src/routes/digitalocean.ts)
| Id | Assumption | Status |
|---|---|---|
| DO1 | base URL, bearer token, DIGITALOCEAN_ACCESS_TOKEN | ✅ verified |
| DO2 | create/get/list shapes; no name filter | ✅ verified |
| DO3 | names unique per account; duplicate status | uniqueness ✅; status ❓ |
| DO4 | PUT replaces the whole spec and deploys | ✅ replace; deploy inferred |
| DO5 | pending_deployment; deployment phases | ✅ verified |
| DO6 | stored spec adds defaults, keeps sent fields and list order | ❓ (pinned) |
| DO7 | SECRET values read back encrypted (EV[…]) | encryption ✅; read-back form ❓ |
| DO8 | DELETE removes the app | ✅ verified |
Google Cloud Run
Calls the adapter makes
| Call | What the adapter sends and reads | Spec | Notes |
|---|---|---|---|
| base URL and auth | https://run.googleapis.com/v2/…, Authorization: Bearer <the token gcloud auth print-access-token prints> | ✅ | cloud-platform scope on every method (CR1). |
GET /v2/projects/{p}/locations/{r}/services/{s} | etag, template, traffic, trafficStatuses[].tag/.uri, latestReadyRevision | ✅ | Revision names taken by their last path segment: full or short is undocumented ❓ (CR2, CR7). |
PATCH …/services/{s}?updateMask=template[,traffic] | { template, traffic?, etag }: the service’s template with revision, the container’s image and env set; every LATEST target bound to latestReadyRevision (only with pin_latest: true; otherwise refused before this call) | ✅ | Answers an Operation (CR3); a template change makes a revision (CR4); the pin keeps traffic where it is, as --no-traffic does (CR5). Etag as precondition ❓ (CR8). |
PATCH …/services/{s}?updateMask=traffic | { traffic, etag }: the list as read, the tag moved onto the revision (tag, add) or removed (destroy); shares untouched | ✅ | (CR5, CR6) |
GET /v2/{operation name} | done, error.message/.code | ✅ | Polled until done (CR9); when a template change’s operation ends is ❓. |
GET …/services/{s}/revisions/{rev} | containers[].name/.image/.env[].value, conditions[type=Ready].state | ✅ | (CR10) |
Sim assumptions (packages/sim/src/routes/cloud_run.ts)
| Id | Assumption | Status |
|---|---|---|
| CR1 | base URL, bearer token from gcloud, cloud-platform scope | ✅ verified |
| CR2 | Service fields; generation as a JSON string | ✅ verified |
| CR3 | PATCH with updateMask answers an Operation, writes only masked fields | ✅ verified |
| CR4 | a template change makes a revision; template.revision names it; naming rules | ✅ verified; refusal of a taken name ❓ |
| CR5 | TrafficTarget shape; LATEST follows the latest ready revision; --no-traffic pins it | ✅ verified |
| CR6 | a tag’s URL is trafficStatuses[].uri, not to be parsed | ✅ verified |
| CR7 | short or full revision names in latest*Revision and traffic | ❓ (both accepted) |
| CR8 | etag in the body is a precondition; a stale one is refused (409 ABORTED in the sim) | ❓ (pinned) |
| CR9 | Operation shape; a template change’s operation ends when the revision is ready, or with an error | shape ✅; timing ❓ (pinned; the adapter also waits for Ready) |
| CR10 | Revision containers, conditions with type Ready | ✅ verified |
| CR11 | after the operation, traffic and trafficStatuses are current | ✅ verified (reconciling) |
| CR12 | Cloud Run deletes non-addressable revisions past 1000 per service, untrafficked tags past 2000 per region | ✅ verified; whether a tag makes a revision addressable ❓ |
| CR13 | tags: lowercase DNS labels, unique | lowercase ✅; the rest ❓ |
Secret sources
What each built-in secret source sends and reads (packages/adapters/src/secrets.ts). The CLI-backed ones are
tested with injected output and fake executables shaped like the documented output; the HTTP ones against a local
server and the generic REST sim (assumption R7’s exact headers and fixed tokens) serving the documented answers.
| Source | Call | Verified | Notes |
|---|---|---|---|
| HashiCorp Vault | GET $VAULT_ADDR/v1/{mount}/data/{path}, X-Vault-Token, X-Vault-Namespace when VAULT_NAMESPACE is set; reads data.data[key] | ✅ | KV v2 “Read secret version”; without ?version= the latest is returned. The token header and the namespace header are in the API overview. A 404 means “invalid path”, which “can both mean that the path truly doesn’t exist” or that the token may not see it, so the message says both. A 503 is a sealed or overloaded Vault (retried by the HTTP client as any GET). Errors are { "errors": [...] } for any status ≥ 400 and carry no values. What a soft-deleted latest version answers (404 with metadata, or 200 with data: null) ❓; both are refused. The mount is the first segment: a mount whose path has a / cannot be named. |
| Infisical | infisical secrets get NAME --projectId P --env E --path /F --output json --silent | ✅ | Flags and INFISICAL_TOKEN / INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN from the CLI reference; --silent and --domain (INFISICAL_DOMAIN) are root flags (root.go). An unknown name: the CLI fetches the folder and, for a name it lacks, prints *not found* as its value and exits 0 (getSecretsByNames); with --plain it prints nothing. Hence JSON ([{ secretKey, secretValue }]) and *not found* refused. A secret whose real value is the literal *not found* cannot be told apart and is refused too. |
| Azure Key Vault | az keyvault secret show --vault-name V --name N [--version X] --query value --output json | ✅ | Parameters from the CLI reference (--version: “If omitted, uses the latest version”). Names checked against the documented rules: vault 3–24 of 0-9a-zA-Z and non-consecutive -, secret 1–127 of 0-9a-zA-Z-, version 32 characters. The CLI rather than REST: no Entra ID token step, and the CLI picks the vault DNS suffix of the signed-in cloud. A missing secret’s exit status and text (SecretNotFound) ❓; any non-zero exit is SECRET_UNRESOLVED with the CLI’s text. |
| 1Password Connect | GET $OP_CONNECT_HOST/v1/vaults/{vault}/items/{item}, Authorization: Bearer $OP_CONNECT_TOKEN; by name, first GET /v1/vaults?filter=name eq "…" and GET /v1/vaults/{vault}/items?filter=title eq "…" | ✅ | Paths, bearer header, filters (vaults by name, items by title), item fields[] (id, label, value, section.id) and sections[] (id, label) from the API reference; 401 invalid token, 403 no access, 404 not found. The reference calls ids UUIDs while its examples show 26-character lowercase strings; the source treats a 26-character [a-z0-9] segment as an id and anything else as a name, and compares names exactly itself, so a server that ignored filter would not change the result. OP_CONNECT_HOST (a URL such as http://localhost:8080) and OP_CONNECT_TOKEN are the variables 1Password’s Connect SDKs, op and the Kubernetes operator read: ✅ (connect-cli, connect-sdk-go). |
| Bitwarden Secrets Manager | bws secret get ID --output json --color no | ✅ | Syntax, json as the default output, the object, id, organizationId, projectId, key, value, note, creationDate, revisionDate fields and BWS_ACCESS_TOKEN from the CLI docs; BWS_SERVER_URL and the UUID-typed id from cli.rs; one object, pretty-printed with a trailing newline, from render.rs. --color no because auto colours JSON whenever supports_color says so (FORCE_COLOR in CI). A missing id’s exit status ❓; any non-zero exit is SECRET_UNRESOLVED. |
What still needs a live account
-
Vercel: V1 for a deployment (the list was checked), the deployment list’s order, how overlapping target sets
are refused, whether the env list pages with
until, branch auto-cancel, and that agitSourcedeployment from the project’slinkbuilds. -
Neon: N2 (whether any request answers
423in practice, endpoint readiness). - Clerk: C1 (status and wording of a duplicate), C2 (the envelope, confirmed only through the SDKs).
-
LaunchDarkly: LD5 (re-adding a target the variation already serves; the status of a two-variation refusal), LD7
(read-after-write), LD2 (an unknown environment), LD3 (the
userplaceholders incontextTargets). - PlanetScale: PS4 (a new branch is not ready at first; how long provisioning takes), PS5 (status of a duplicate branch name), PS7 (deletion timing), PS9 (duplicate password names), PS12 (a password on a branch still provisioning), and PS1 and PS8 end to end.
-
Supabase: S1 and S3 (a branch’s ref is usable at once; the detail’s status while it comes up), S4 (
db_passfor a personal access token), S5 (status of a duplicate name), S6 (404for an unknown ref, list visibility after delete), S8 (verbatim storage of the list), S9 (a branch’s own Auth config through the same endpoint). The Supabase block of the contract suite is optional in a live run (branching needs a paid plan). -
Netlify: NL2 (
nameis the subdomain), NL3 (PATCH leaves other values and keeps ids), NL5 (status of a duplicate key), NL6 (an emptied variable), NL7 (deploy order,contextvalues,review_id), NL10 (updated_aton PATCH), NL11 (a build of a branch whose branch deploys are off). -
Cloudflare: CF3 (what a secret’s
valuelooks like in GET), CF4 (that PATCH merges the map), CF6 (status of an unknown project), CF7 (a write is readable at once). - Algolia: AL5 (whether a copy’s destination is visible before its task is published), AL6 (status of deleting a missing index), AL8 (how long a copy takes).
-
Xata: XA3 (whether branch names are unique), XA5 (the spelling of
statusType), XA6 (credentials of a branch still coming up), XA7 (how long a branch takes). - DigitalOcean: DO3 (status of a duplicate name), DO6 (that the stored spec keeps every sent field in order), DO7 (what a secret reads back as).
- Google Cloud Run: CR4 (status of a taken revision name), CR7 (short or full revision names), CR8 (whether a stale etag is refused, and how), CR9 (when a template change’s operation ends), CR13 (tag rules beyond lowercase).
-
Secret sources: Vault’s answer for a soft-deleted latest version; the exact failure texts of
az,bwsandinfisicalfor a missing secret. - The generic adapter’s authentication forms and GraphQL examples: every ❓ above, and each provider end to end (the examples run against sims, not the live APIs).
pnpm test:live; see the header of scenarios/contract.test.ts.
Live runs
Runs ofpnpm test:live (or a part of it) against real accounts. Every other check in this document is against
published specifications and documentation only.
| Date | What ran | Result |
|---|---|---|
| 2026-10-11 | recipe:github.repo_webhook against api.github.com (a public repository; the hook pointed at a reserved example.com name): create, re-apply, destroy | passed: re-apply wrote nothing (GH8 verified), destroy left no hook |
| 2026-10-11 | Vercel env vars and Neon branches, checked by hand against the real APIs through a logged-in dashboard session on throwaway projects (not pnpm test:live) | Vercel: V1 (list) and V4 verified, V3 contradicted (decrypt=true no longer decrypts; the per-variable GET does), V9 new (a gitBranch write without a connected repository is 400). Neon: N1 and N5 verified, N2 contradicted in part (a second create meanwhile answered 201), N6 new (a new project’s default branch is production). Sim, adapters and the contract suite updated to match |
| 2026-10-11 | Release 0.6.1 through scripts/publish-release.sh | passed: all four packages carry SLSA v1 provenance (Sigstore transparency log), all four tags on origin; the first release with both |
| 2026-10-12 | First pnpm test:live run (live.yml) against the throwaway Vercel, Neon and Clerk projects | 67 of 68 passed (V1, V3, V4, V9, N1, N2, N5, N6, C2 hold on the real APIs); “second apply writes nothing” failed: Neon’s create answer gives a connection string without channel_binding=require that GET /connection_uri adds (N7, confirmed by hand), so the second apply rewrote the Vercel variable. Fixed by reading outputs only through GET /connection_uri |
| 2026-10-12 | pnpm test:live again after the N7 fix | passed: 68 of 68 live (Vercel, Neon and Clerk), including “second apply writes nothing” |