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

# ADR 0022: One-off actions: http.action

> Architecture decision record 0022. Status: Proposed. Builds on ADR 0017 (the generic adapter) and ADR 0020

Date: 2026-10-11

## Status

Proposed. Builds on [ADR 0017](/contributing/adr/0017-generic-http-adapter) (the generic adapter) and [ADR 0020](/contributing/adr/0020-recipes)
(recipes); follows the precedent of [ADR 0021](/contributing/adr/0021-manual-steps) for a line whose state the engine owns.

## Context

Some per-environment side effects are not resources. Syncing an Inngest app registers a preview deployment's
functions in its branch environment; a CDN purge empties a cache; a deploy notification tells a chat or an APM tool
that this commit is live. Each is one request, sent at the right moment. There is nothing to read back afterwards
(a sync leaves no object Sponson could list; a purge leaves nothing at all), usually nothing to delete, and doing it
again is the point when its inputs change (a new deployment must be synced again), and wasteful or noisy when they
have not.

None of that fits the resource model. `http.resource` needs a request that reads the object back (drift is a hash
of what `read` returns) and one that deletes it; `manual.step` is for what no API can do. So these rows stayed
uncovered, among them [docs/coverage.yaml](https://github.com/MishaBear94/Sponson/blob/main/docs/coverage.yaml)'s `inngest-sync` (P1), and teams wired them as
post-deploy CI steps outside the plan: no receipt, no way for `plan` to say whether this deployment was synced, and
re-run jobs that sync again or forget to.

What such a line needs from Sponson: decide whether it must run, from the plan and the receipt alone; run it once
it should and its inputs exist (often an external output, the preview URL); remember that it ran; let other lines
read what it answered; and, when the scope is destroyed, send an undo if one exists. What it must not get: drift
detection it cannot have, or rollback, which would mean sending some "opposite" request because an unrelated later
line failed.

Options considered:

| Option | Why not |
| - | - |
| `http.resource` with a `read` that always 404s and `destroy: keep` | Every plan would show a create; the ledger would record a "resource" with a made-up id; a failed later line would try to roll it back |
| CI steps after `sponson apply` | What this replaces: no receipt, no `plan`, no waiting on the deploy, no record per scope |
| An `exec` adapter | Rejected by ADR 0017 for the invariants it cannot keep; most of these are one HTTP request anyway |
| Run on every apply | A re-run job or an unrelated plan edit would sync, purge or notify again |
| Run when any resolved param changes | Values that do not matter (a header, a timeout) would re-run it; the line should say what matters |

## Decision

**An engine hook for one-off actions, `OpSpec.action(params, ctx)`,** and a third op of the generic adapter that
uses it, `http.action`. Like `manual` ([ADR 0021](/contributing/adr/0021-manual-steps)), the engine then owns the line's state.

### The hook

`action(params, ctx)` returns `{ trigger, label }` from resolved params (null while a trigger value is pending):
the values whose change makes the action run again, and what it does, for display. Then:

* **Ledger.** One entry per line, key `action:<line id>`, in the line's provider block (`providerFor`), hash the
  keyed `sha256(canonicalJson(trigger))` (keyed like every hash, so a low-entropy or secret trigger value cannot be
  looked up from a receipt), id the record the op returned: how to undo it. `LedgerEntry.action: { at }` says when it
  last ran. The key is the line's id because nothing else identifies an action: the request target may change with
  the trigger (a per-deployment URL), and two actions may call the same endpoint.
* **Plan.** A new line status and diff kind, `run`: no run of the current trigger is recorded in the scope (never
  ran, or its trigger changed). Otherwise `unchanged`. `pending` as usual while a trigger value, or any value the
  request needs, is not known: an action on a preview URL waits for the deploy like any line. There is no drift:
  nothing can be read back, so "never ran for this trigger" is the only thing to report. `read` is never called.
* **Apply.** `run` calls `apply` with `live` set to the entry as the engine sees it (key, previous record id, the
  raw trigger hash), then records the hash, the returned record id and the outputs. `unchanged` sends nothing and
  passes the recorded outputs to the lines that read them; therefore an action's outputs may not be sensitive (they
  would not be in the ledger). `--recreate <line>` runs an unchanged action again.
* **Crash between sending and recording: at least once.** An action is not a create: it makes nothing Sponson
  could later find and claim, so an intent (ADR 0005) would have nothing to resolve. Nothing is recorded before the
  request is sent; the adapter is given no way to announce one (`intend` rejects). A run that dies after the request
  left, or whose answer is lost or fails its checks, has recorded nothing, and the next apply sends it again. Where
  the API takes one, the request carries an `Idempotency-Key` derived from the scope, the ledger key and the trigger
  hash (`request.idempotency_key: true`): the same for every resend of this action for this trigger, so the API
  answers it once. Without one, the action must tolerate being sent twice; every recipe says whether it does.
* **Rollback never undoes an action.** It is never in a run's rollback set: a purge cannot be un-purged, and a sync
  of a deployment that is live is still right when a later line fails. (The same rule as for manual steps.)
* **Destroy** passes the entry to `destroy` (which sends the undo, if any) and forgets it, from the ledger alone,
  so a line removed from the plan is still undone.
* **Scopes.** Another scope's record of the same key does not block the line: `OWNED_BY_OTHER_SCOPE` protects what a
  line owns at a provider, and an action owns nothing there.

### `http.action`

```yaml theme={null}
- id: purge
  adapter: http
  op: action
  api: cdn
  trigger: { sha: "${ctx.git.sha}", url: { from: web.preview_url } }
  vars: { zone: z1 }
  request: { method: POST, path: "/zones/{zone}/purge", body: { everything: true }, idempotency_key: true }
  expect: { /result/ok: true }
  outputs: { purge_id: /result/id }
  undo: { method: DELETE, path: "/zones/{zone}/purges/{purge_id}" }
```

* `trigger` (required): the declared set of values. Only it decides; a value used by the request but left out of the
  trigger does not make the action run again.
* `request` (required): a method and exactly one of `path` (relative to the API's `base_url`, with its credential and
  headers), `graphql` (ADR 0017's GraphQL requests) or `url`: an absolute URL, typically the app's own endpoint on a
  preview deployment. A `url` request is sent with **no credential and none of the API's headers**, and its line
  names no `api` (an empty provider block): an API's credential must never reach a URL that a reference resolves at
  run time. Its own `headers` may carry a `{ secret }` (a deployment protection bypass).
* Success: any 2xx, plus the statuses listed in `ok_status` (400–499, an "already done" answer); then every `expect`
  JSON pointer must equal its value, or the line fails with `PROVIDER_RESPONSE` and nothing is recorded.
* `outputs`: JSON pointers into the answer, never sensitive.
* `undo` (optional): a request of the same form, its path placeholders filled from `vars` and the outputs when the
  action runs. The record id is `UNDO <canonical JSON request>` (or `FORGET`), so destroy needs only the ledger; it
  may therefore hold no secret (`PARAM_INVALID` at apply, before anything is recorded). A 404 to an undo is done.

Recipes may declare `kind: action`. A recipe whose ops are all `url` actions has no `api` block at all
(`schema/recipe.schema.json`), and its docs say the op needs no credential. The recipe harness
(`scenarios/recipes.test.ts`) runs every action recipe op as run → re-apply unchanged with zero writes → a trigger
param changed in the plan → run again → destroy (undo sent or nothing), against an endpoint of the REST sim that
answers what the op expects and logs every call (assumption R9); a `url` op's calls must carry no credential.

### Inngest

`inngest.sync` covers `inngest-sync`: `PUT {url}{path}` (default `/api/inngest`), trigger `{ url, path, revision }`.
The serve handler registers the app with Inngest itself, signed with the app's own key, into the environment its
SDK is configured for (`INNGEST_ENV`, detected automatically on Vercel); a branch environment is created on its first
sync. Sponson holds no Inngest credential. The REST API's sync endpoint was not used: it is beta and its operation
has no way to select a branch environment. The recipe's numbered assumptions say which facts are verified and which
are not (whether syncing the same deployment twice is harmless; Vercel deployment protection).

### Invariants

| Invariant | How it is kept |
| - | - |
| `plan` writes nothing (I4) | `plan` only reads the ledger for an action line; no request at all. |
| Idempotent apply | An unchanged trigger sends nothing; the second apply in a row performs zero writes. |
| Nothing created is forgotten | An action creates nothing to forget; what it did is recorded right after it succeeded, and at-least-once resending covers the gap. |
| Destroy only what Sponson created | Only an action this scope ran is undone, from its own record. |
| Secrets never reach output (I1) | Trigger values are hashed (keyed), shown as `sensitive` in diffs when they hold a secret; labels and errors go through the redactor; the undo record refuses secrets; credentials are never sent to a `url`. |
| Markers never reach apply | The line is `pending` while a trigger or request value is pending; `apply` asserts none is left. |

## Consequences

* `OpSpec.action`, `ActionTrigger`, `LedgerEntry.action`, the line status `run` and the diff kind `run` are new and
  additive. Code that switches over `PlanLineStatus` or `DiffKind` exhaustively must handle `run` (the CLI renders it
  `>` "to run"). An older Sponson reading a receipt with action entries keeps them in the ledger as it keeps any
  entry; its destroy cannot find the op `http.action` and reports the line `destroy_failed`, leaving the entry for
  a current version.
* Coverage: `inngest-sync` is covered (row counts in docs/coverage.md).
* A trigger is hashed in canonical JSON, so changing what a recipe puts in its trigger runs each applied action once
  more after upgrading; it re-identifies nothing (the key is the line's).
* Renaming an action line makes its old record an orphan (destroy still sends its undo) and runs the action once
  under the new name.
* "Runs at least once" is the honest guarantee. An action whose provider takes no idempotency key and is not
  idempotent by nature (a chat notification) may be sent twice after a crash in the narrow window between its
  answer and the receipt write.

## Not done

* Polling for the action's own completion (an async purge or sync whose result arrives later): the answer is checked
  once; a first-class adapter is the place for waiting (ADR 0020, Amendment 4).
* Running on a schedule, or "at most once" delivery.
* Sensitive outputs of an action (they would need storing to survive an unchanged run).


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