sponson plan or sponson apply, and the
handful of ideas the engine is built on. Read it once before your first change; it takes about fifteen minutes. The
reasons behind each idea are in the architecture decision records; the file format is specified
in docs/plan-format.md.
The shape of the system
Sponson readsrelease.plan.yaml, a flat list of lines, each one adapter op (such as neon.branch; the built-in ones
are listed under built-in ops). It compares each line with live state at the provider and with the ledger of what it
already manages, then either reports the difference (plan) or makes it real (apply). Every writing run leaves a
receipt, stored outside the working tree, which the next run starts from.
Packages and the direction of dependencies
Why this direction:
coreknows no provider. The engine sees adapters only throughOpSpec(packages/core/src/types.ts), so it can be tested with an in-memory fake (packages/core/src/testing/fake.ts, published as@sponson/core/testing) and third-party adapters plug in without touching it.coreknows no CLI. It speaks in run options (approvedBy,reconcile,wait), never in flags; the CLI maps error codes to flags throughcliHintinERROR_CODES. The MCP server reuses the same engine calls.simshares no code withadapters. The fake cloud is an independent model of the providers’ APIs. If adapters and sim shared types or helpers, a misunderstanding of an API would be encoded twice and could never be caught. The sim’s assumptions are numbered at the top of each file inpackages/sim/src/routes/, each marked verified against the providers’ published specifications or not (docs/api-verification.md);scenarios/contract.test.tspins the critical ones, against the sim by default and against real accounts withpnpm test:live.cliis the composition root. It is the only place that decides which adapters exist (createRegistry()plusSPONSON_PLUGINS), which receipt store to use, and how to detect the context from CI or git.
scenarios/, property/) import the packages by name; vitest.config.ts and
tsconfig.test.json map the names to the sources.
The lifecycle of sponson plan
- Entry.
packages/cli/src/bin.tscallsrun()inpackages/cli/src/main.ts, which creates oneRedactorfor the whole command and wraps stdout and stderr so every byte passes through it. - Invocation.
commands/plan.ts→buildInvocation()incontext.ts:loadPlan()/parsePlan()(packages/core/src/plan.ts): YAML → zod schema → semantic checks (unique ids, declared environments,depends_onandfrom:ids exist, secret-looking literals, pasted display text).detectCtx()(packages/cli/src/detect.ts): flags, thenSPONSON_CTX_*, then the CI host (GitHub Actions, GitLab CI/CD, CircleCI or Bitbucket Pipelines), then local git andgh. The scope is derived from branch and pull request byscopeFor()inpackages/core/src/ctx.ts.selectStore(): the git-branch store when there is a remote, otherwise the local one.- the registry: built-in adapters plus plugins (
packages/cli/src/registry.ts).
- Prepare (
engine/prepare.ts, no I/O): check--envis declared; keep the lines whoseenvironmentsinclude it (changesFor); order them by references anddepends_on(orderChangesingraph.ts, which also reportsREF_CYCLEandREF_FILTERED); look up each op in the registry; check everyfrom:names a declared output; interpolate${ctx.*}(interpolateinctx.ts); apply op defaults; collectsecret:references; decide whether approval is required (--env production, or an op’swritesEnvironment()saysproduction, judging the line’s params and the provider block it writes through: a LaunchDarkly block naming a production environment). - Load (
engine/run-context.ts,RunContext): register every credential-looking environment variable with the redactor; read this scope’s receipt and build theLedgerfrom it; list the other scopes’ ledgers in this environment (resources they manage are foreign); inherit the head branch’s scope when this is a pull request (see Scopes). ThenresolveSecrets()resolves every secret the active lines use and registers its value with the redactor, so a CLI-backed secret source runs its CLI inplantoo; a secret that fails is remembered and blocks its line in step 5. - Inspect each line, in order (
engine/plan.ts→inspectLineinengine/inspect.ts): resolve references against the outputs of earlier lines (resolveParamsinresolve.ts), call the op’sreadanddiff, scrub the diff of anything matching a registered secret, then judge it against the ledger: driftmissingorchanged, refusalDRIFT_CHANGED,OWNED_BY_OTHER_SCOPEorSECRET_UNRESOLVED. Ops with external outputs get a read-onlyawaitExternalcheck, so a finished deploy turnspendingintocreate. - Line status:
blocked(a refusal, or a dependency in error),error(inspect threw),pending(an input is not known yet;waitingOn,waitingFor),todo(a manual step a person has not done;manualcarries its instructions),run(a one-off action whose trigger has not run in this scope, ADR 0022), elsecreate,updateorunchangedfrom the diff. - Scope drift (
engine/drift.ts):orphanentries (in the ledger, declared by no line) andunmanagedresources (listed by an op’slistScope, managed by no scope). - Output.
planJson()/renderPlan()inpackages/cli/src/render.ts; JSON goes throughserialize()inoutput.ts. Exit code 1 if any line iserrororblocked, else 0.
plan never writes: not to providers, not to the store (invariant I4, checked after every scenario step).
The lifecycle of sponson apply
Steps 1–3 are the same. Then applyRun() (engine/apply.ts):
- Approval.
requireApproval()refuses withENV_NOT_APPROVEDbefore anything else when the run can write to production andapprovedByis blank. - Lease.
Lease.acquire()(engine/lease.ts) takes the scope’s lock or fails withLOCK_HELD(exit 3); with--waitit polls. The lease renews itself in the background. - Load as in plan, but without secrets. If this commit is older than the last one applied to the scope
(
staleness()inengine/history.ts, using receipt history andgit merge-base --is-ancestor), the run writes nothing and returns a receipt withstale: true, without resolving a single secret. Only after this staleness check doesresolveSecrets()resolve and register every secret, as in plan. - Each line, in order (
ApplyRun.processLine):- a dependency failed →
skipped(DEPENDENCY_BLOCKED); a dependency is waiting →waitingfor the same event; - inspect (as in plan, against live state read now, not the plan’s snapshot);
- an input is an external output not yet available →
awaitExternal: without--wait, the line iswaitingand the run will endpartial; with--wait, poll until it arrives, fails (EXTERNAL_FAILED) or times out (WAIT_TIMEOUT); - a refusal →
blocked, and the run stops and rolls back exactly as on a failure (step 5); - every diff
unchanged→unchanged, no adapter call; - otherwise
op.apply(). Before sending any create, the adapter callsactx.intend(keys); the engine writes those keys to the ledger asintentand checkpoints the receipt (a fenced write) before the request leaves. Thenrecord()updates the ledger from the result, and outputs (including external ones that already exist) are stored on the ledger entries; - when the op’s
lockOn()names a shared parent object (one callback list on an Auth0 application), the write happens holding that object’s lease: the line is inspected again under it and applied from that fresh state (underParentLock, ADR 0019); - a manual step (an op with
manual(), such asmanual.step;engine/manual.ts, ADR 0021) is never sent anywhere: it is done when its verifyreadsees it or the ledger records a person’s confirmation of these instructions; a todo is recorded only when the run confirms it (confirm,confirmedBy), and otherwise waits (waitingFor: confirmation,MANUAL_STEP_PENDING) with its dependents, never rolled back; the CLI then exits 2 and prints the instructions; - a one-off action (an op with
action(), such ashttp.action;engine/action.ts, ADR 0022) is judged against the ledger only (readis never called): its trigger’s hash was recorded, so it isunchanged, or it is sent (apply) and recorded afterwards, never as an intent (at least once, with an idempotency key where the API takes one), and never rolled back;
- a dependency failed →
- On failure or refusal: remaining lines are
skipped;rollback()first re-reads lines with unresolved intents (a create whose answer was lost may have succeeded), then destroys what this run created, newest first. A destroy that fails leavesrollback_failedwith the resources named. - Settle (
settleOrphans): ledger entries no current line declares becomeorphan(if Sponson created them) or are forgotten (if adopted). Lines that did not run keep their entries untouched. - Write: status
complete,partial(something iswaiting) orfailed; append the commit tohistory;lease.write()(fenced: fails withLOCK_LOSTif another run took the lock); hand resources over from a predecessor scope; release the lease.
apply --destroy runs destroyRun() (engine/destroy.ts) instead: it walks the ledger, not the plan, in reverse
creation order, using the provider block recorded with each entry. Entries created by Sponson are destroyed, adopted
ones are forgotten, intents are located first (or reported as INTENT_UNRESOLVED); a manual step’s entry is
forgotten once a person confirms its undo (kept in the entry), and waits for that until then. It resolves the plan’s secrets
too, only so that a provider error echoing one is masked.
The ledger
A receipt (Receipt in types.ts, format version RECEIPT_VERSION) has two halves: lines says what this run did per plan line; ledger
says what Sponson manages in this environment and scope after the run, independent of lines and of any single run.
- Identity is
adapter + provider block + key(identity()inengine/ledger.ts). The key comes from the adapter and includes every dimension that distinguishes two resources at the provider:env:preview:feat/x:NAMEfor Vercel (target, git branch, name),branch:<name>for Neon,redirect:<url>for Clerk. The provider block is part of identity so the same key in two projects is two resources. An op whose block holds several independent configurations narrows it per line withOpSpec.providerFor(the generichttpadapter: one block per API, ADR 0017);RunContext.provider(change)applies it. createdByissponson(destroyed with the scope),adopted(existed before; never destroyed) orintent(about to be created; claimed assponsonif found live, dropped if not).idis the provider’s id. Same key with a different id means the resource was deleted and re-created outside Sponson: it is reported as replaced and is not Sponson’s.hashis an HMAC of the value’s sha256 with the scope’s randomhashKey, so a receipt cannot be used to look up a low-entropy secret.lineis the line that declares it;orphanmarks entries no line declares any more;outputskeeps the line’s non-sensitive outputs, including external ones such aspreview_url;parentnames the shared provider object it lives in, when its op declares one (lockOn), so rollback and destroy lock that object even for a line the plan no longer has;onceFingerprintsandonceInputsare keyed fingerprints of values a provider shows only on create (OutputSpec.once), on the producer and on each dependent written with them, which decide whether a later run may keep the dependent as it is (ADR 0018);manualrecords a manual step’s title and undo, and who confirmed it and when, or when a verify request first saw it done (ADR 0021);actionsays when a one-off action last ran (itshashis the trigger’s, itsidthe undo request, its keyaction:<line id>; ADR 0022).- Every run starts from the previous ledger and changes only entries it observed or wrote, so failed, skipped, waiting and refused lines never make Sponson forget what it owns.
ReceiptStore (types.ts) at <environment>/<scope>/latest.json, one <runId>.json per run,
and lock.json (receipts/layout.ts); locks on shared parent objects live in the same layout under the reserved
environment _locks (_locks/<hash>/lock.json). GitBranchReceiptStore (receipts/git.ts) commits them to one orphan branch
per environment and scope, sponson-receipts/<env>/<scope> (receiptsBranch()), so unrelated scopes never race for
a ref, and uses git’s non-fast-forward rejection as compare-and-swap. A scope with no branch yet is read from the
legacy shared branch sponson/receipts and moves to its own branch with its first write
(ADR 0016); each parent-object lock gets a lock-only branch,
sponson-receipts/_locks/<hash>. The store holds the policy (locks, fencing, retries,
migration); the git plumbing under it (the working clone, credential-free errors, what a push’s failure means) is
GitWorkdir in receipts/git-workdir.ts. LocalReceiptStore (receipts/local.ts) uses link(2)
and a short-lived mutex. parseReceipt() migrates version 1 receipts in memory
and refuses newer versions (RECEIPT_VERSION).
Scopes, leases and succession
- A scope is the lifecycle unit:
pr-<n>,branch-<name>ormain(scopeFor()inctx.ts). Receipts, locks and ledgers are per environment and scope. Destroying a scope removes what Sponson created in it. - Foreign resources: what another scope’s ledger holds in the same environment. A line may rely on a foreign
resource but not create or change it (
OWNED_BY_OTHER_SCOPE), and it is never reported as unmanaged. - Lease: the scope lock is held as a lease with a TTL (default 15 minutes), renewed every TTL/3, each renewal
scheduled after the previous one finishes. Every receipt write passes the holder, and the store checks it in the
same atomic step (fencing). A run that lost its lease stops with
LOCK_LOSTbefore writing again. An expired lock is taken over and the receipt recordslockPreempted. - The holder’s own deadline: providers cannot be fenced, so before every write to a provider the holder checks
its own view of when the lease runs out (measured from when each acquire/renew was sent) and stops itself
(
LOCK_LOST) once less than TTL/5 is left — before another run could legitimately take the lock. Receipt writes do not use that guess: the store fences them atomically, so a run whose renewals lagged but whose lock nobody took still records everything it did. Releasing the lock is retried; if it still fails, the run’s warnings say so and until when the scope stays locked. - Parent-object locks (ADR 0019): scope locks cannot serialise two
scopes (or two environments) that read-modify-write one list on a shared provider object. An op that writes such a
list declares the object with
lockOn(); the engine then takes a lease on it in the receipts store (one lock-only ref per object, across every scope and environment) around the line’s write, and around rollback and destroy of its resources, with the scope lease’s TTL, renewal, deadline, takeover and release-with-warning. A held one fails the line withLOCK_HELD, or is waited for with--wait; rollback always waits. A run holds at most one, for one line at a time, so runs cannot deadlock. - Succession: when a pull request scope runs and the head branch’s own scope (
branch-<name>) has a ledger (an agent applied locally before the PR existed), the PR scope inherits those entries. After a successful apply it rewrites the predecessor’s receipt without them and withsupersededBy, so exactly one scope owns each resource.
References and markers
resolveParams() (resolve.ts) replaces each { from }, { secret } and { keep: true } leaf. What the adapter
receives is either a concrete value or a marker string; adapters classify markers only with markerKind():
The same function records each input’s state (
literal, resolved, pending, secret, kept) for plan --json.
desiredSide, diffValue and assertNoPending in packages/adapters/src/common.ts implement the contract for
adapter authors.
Each op declares its outputs as immediate or external (with an event name, such as deploy) and optionally
sensitive. That one declaration decides where apply stops: there are no phases in the file.
Drift
Redaction: one choke point
Redactor (packages/core/src/redact.ts) is created once per command in main.ts and passed down in RunOptions.
The engine registers into it, before any output exists: credential-looking environment variables, every resolved
secret, and every sensitive output (and the password inside a URL or DSN). Each value is registered in the encodings a
provider or serializer may produce (raw, JSON-escaped, URL-encoded, base64, line by line, head and tail fragments).
Everything leaves through it: text streams are wrapped in main.ts; adapter logs and error messages go through
actx.redact and RunContext.errorText; JSON is redacted field by field by redactDeep and only then serialized
(serialize() in packages/cli/src/output.ts), skipping enum fields such as status so a secret can never corrupt the
structure. Diffs that would display a registered value are turned into sensitive sides. Before that, a diff whose
adapter names the param it comes from (ResourceDiff.input, such as values.DATABASE_URL) has its new side described
by what that param is in the plan (describeInput in inspect.ts): secret for a { secret }, sensitive with the
reference for a sensitive { from } output, the value for a literal. Adapters see resolved values only and cannot tell
these apart, so renderers label a value (secret) only when it is one. Values shorter than four
characters cannot be masked safely; the CLI warns about them.
The output envelope
Every command, every failure path and every MCP tool answers with one JSON document (packages/cli/src/output.ts):
ok is true exactly when the exit code is 0. Every thrown value becomes a SponsonError with a code from
ERROR_CODES (packages/core/src/errors.ts); the table decides the exit code (0 ok, 1 failed, 2 refused before
running, 3 lock) and the CLI hint. docs/errors.md is generated from it.
Testing layers
See ADR 0011 for why.
Where to start
- A new adapter: the checklist in CONTRIBUTING.md; the contract is
OpSpecinpackages/core/src/types.ts; the smallest complete example ispackages/core/src/testing/fake.ts. - A new error code:
ERROR_CODES, thenpnpm docs:gen. - A change to the plan format:
packages/core/src/plan.ts,schema/release.plan.schema.jsonanddocs/plan-format.mdtogether;scenarios/docs/schema.test.tschecks the parser and the schema agree. - A bug: reproduce it as a scenario first. If the property suite found it, its counterexample is the scenario.