Skip to main content
A map for contributors: where things live, what happens when someone runs 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 reads release.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:
  • core knows no provider. The engine sees adapters only through OpSpec (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.
  • core knows no CLI. It speaks in run options (approvedBy, reconcile, wait), never in flags; the CLI maps error codes to flags through cliHint in ERROR_CODES. The MCP server reuses the same engine calls.
  • sim shares no code with adapters. 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 in packages/sim/src/routes/, each marked verified against the providers’ published specifications or not (docs/api-verification.md); scenarios/contract.test.ts pins the critical ones, against the sim by default and against real accounts with pnpm test:live.
  • cli is the composition root. It is the only place that decides which adapters exist (createRegistry() plus SPONSON_PLUGINS), which receipt store to use, and how to detect the context from CI or git.
Root-level suites (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

  1. Entry. packages/cli/src/bin.ts calls run() in packages/cli/src/main.ts, which creates one Redactor for the whole command and wraps stdout and stderr so every byte passes through it.
  2. Invocation. commands/plan.ts → buildInvocation() in context.ts:
    • loadPlan() / parsePlan() (packages/core/src/plan.ts): YAML → zod schema → semantic checks (unique ids, declared environments, depends_on and from: ids exist, secret-looking literals, pasted display text).
    • detectCtx() (packages/cli/src/detect.ts): flags, then SPONSON_CTX_*, then the CI host (GitHub Actions, GitLab CI/CD, CircleCI or Bitbucket Pipelines), then local git and gh. The scope is derived from branch and pull request by scopeFor() in packages/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).
  3. Prepare (engine/prepare.ts, no I/O): check --env is declared; keep the lines whose environments include it (changesFor); order them by references and depends_on (orderChanges in graph.ts, which also reports REF_CYCLE and REF_FILTERED); look up each op in the registry; check every from: names a declared output; interpolate ${ctx.*} (interpolate in ctx.ts); apply op defaults; collect secret: references; decide whether approval is required (--env production, or an op’s writesEnvironment() says production, judging the line’s params and the provider block it writes through: a LaunchDarkly block naming a production environment).
  4. Load (engine/run-context.ts, RunContext): register every credential-looking environment variable with the redactor; read this scope’s receipt and build the Ledger from 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). Then resolveSecrets() resolves every secret the active lines use and registers its value with the redactor, so a CLI-backed secret source runs its CLI in plan too; a secret that fails is remembered and blocks its line in step 5.
  5. Inspect each line, in order (engine/plan.ts → inspectLine in engine/inspect.ts): resolve references against the outputs of earlier lines (resolveParams in resolve.ts), call the op’s read and diff, scrub the diff of anything matching a registered secret, then judge it against the ledger: drift missing or changed, refusal DRIFT_CHANGED, OWNED_BY_OTHER_SCOPE or SECRET_UNRESOLVED. Ops with external outputs get a read-only awaitExternal check, so a finished deploy turns pending into create.
  6. 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; manual carries its instructions), run (a one-off action whose trigger has not run in this scope, ADR 0022), else create, update or unchanged from the diff.
  7. Scope drift (engine/drift.ts): orphan entries (in the ledger, declared by no line) and unmanaged resources (listed by an op’s listScope, managed by no scope).
  8. Output. planJson() / renderPlan() in packages/cli/src/render.ts; JSON goes through serialize() in output.ts. Exit code 1 if any line is error or blocked, 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):
  1. Approval. requireApproval() refuses with ENV_NOT_APPROVED before anything else when the run can write to production and approvedBy is blank.
  2. Lease. Lease.acquire() (engine/lease.ts) takes the scope’s lock or fails with LOCK_HELD (exit 3); with --wait it polls. The lease renews itself in the background.
  3. Load as in plan, but without secrets. If this commit is older than the last one applied to the scope (staleness() in engine/history.ts, using receipt history and git merge-base --is-ancestor), the run writes nothing and returns a receipt with stale: true, without resolving a single secret. Only after this staleness check does resolveSecrets() resolve and register every secret, as in plan.
  4. Each line, in order (ApplyRun.processLine):
    • a dependency failed → skipped (DEPENDENCY_BLOCKED); a dependency is waiting → waiting for 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 is waiting and the run will end partial; 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 calls actx.intend(keys); the engine writes those keys to the ledger as intent and checkpoints the receipt (a fenced write) before the request leaves. Then record() 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 as manual.step; engine/manual.ts, ADR 0021) is never sent anywhere: it is done when its verify read sees 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 as http.action; engine/action.ts, ADR 0022) is judged against the ledger only (read is never called): its trigger’s hash was recorded, so it is unchanged, 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;
  5. 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 leaves rollback_failed with the resources named.
  6. Settle (settleOrphans): ledger entries no current line declares become orphan (if Sponson created them) or are forgotten (if adopted). Lines that did not run keep their entries untouched.
  7. Write: status complete, partial (something is waiting) or failed; append the commit to history; lease.write() (fenced: fails with LOCK_LOST if 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() in engine/ledger.ts). The key comes from the adapter and includes every dimension that distinguishes two resources at the provider: env:preview:feat/x:NAME for 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 with OpSpec.providerFor (the generic http adapter: one block per API, ADR 0017); RunContext.provider(change) applies it.
  • createdBy is sponson (destroyed with the scope), adopted (existed before; never destroyed) or intent (about to be created; claimed as sponson if found live, dropped if not).
  • id is 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.
  • hash is an HMAC of the value’s sha256 with the scope’s random hashKey, so a receipt cannot be used to look up a low-entropy secret.
  • line is the line that declares it; orphan marks entries no line declares any more; outputs keeps the line’s non-sensitive outputs, including external ones such as preview_url; parent names 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; onceFingerprints and onceInputs are 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); manual records a manual step’s title and undo, and who confirmed it and when, or when a verify request first saw it done (ADR 0021); action says when a one-off action last ran (its hash is the trigger’s, its id the undo request, its key action:<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.
Receipts are stored by 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> or main (scopeFor() in ctx.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_LOST before writing again. An expired lock is taken over and the receipt records lockPreempted.
  • 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 with LOCK_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 with supersededBy, 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 OpSpec in packages/core/src/types.ts; the smallest complete example is packages/core/src/testing/fake.ts.
  • A new error code: ERROR_CODES, then pnpm docs:gen.
  • A change to the plan format: packages/core/src/plan.ts, schema/release.plan.schema.json and docs/plan-format.md together; scenarios/docs/schema.test.ts checks 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.