Status
Proposed. Builds on ADR 0017 (the generic adapter) and ADR 0020 (recipes); follows the precedent of ADR 0021 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’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:
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), 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 keyedsha256(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). Otherwiseunchanged.pendingas 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.readis never called. - Apply.
runcallsapplywithliveset 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.unchangedsends 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 (
intendrejects). 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 anIdempotency-Keyderived 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_SCOPEprotects what a line owns at a provider, and an action owns nothing there.
http.action
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 ofpath(relative to the API’sbase_url, with its credential and headers),graphql(ADR 0017’s GraphQL requests) orurl: an absolute URL, typically the app’s own endpoint on a preview deployment. Aurlrequest is sent with no credential and none of the API’s headers, and its line names noapi(an empty provider block): an API’s credential must never reach a URL that a reference resolves at run time. Its ownheadersmay carry a{ secret }(a deployment protection bypass).- Success: any 2xx, plus the statuses listed in
ok_status(400–499, an “already done” answer); then everyexpectJSON pointer must equal its value, or the line fails withPROVIDER_RESPONSEand nothing is recorded. outputs: JSON pointers into the answer, never sensitive.undo(optional): a request of the same form, its path placeholders filled fromvarsand the outputs when the action runs. The record id isUNDO <canonical JSON request>(orFORGET), so destroy needs only the ledger; it may therefore hold no secret (PARAM_INVALIDat apply, before anything is recorded). A 404 to an undo is done.
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
Consequences
OpSpec.action,ActionTrigger,LedgerEntry.action, the line statusrunand the diff kindrunare new and additive. Code that switches overPlanLineStatusorDiffKindexhaustively must handlerun(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 ophttp.actionand reports the linedestroy_failed, leaving the entry for a current version.- Coverage:
inngest-syncis 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).