Skip to main content
Date: 2026-10-11

Status

Proposed. Introduced with the planetscale adapter; amended (see “Amendment: proof by fingerprint, and --recreate” below) before release.

Context

Some providers reveal a value only in the answer that creates a resource. PlanetScale returns a branch password’s plaintext in the create answer and null everywhere else; the same holds for Stripe webhook signing secrets and many API keys (docs/coverage.md, “Once-only outputs”). Outputs flow between lines through the engine: an unchanged line’s outputs come from its read, and sensitive outputs are never written to receipts (ADR 0009), so nothing carries them from one run to the next. A from: reference to an output the producing line no longer has stays a pending marker. Before this decision that had two bad outcomes for a password referenced by a Vercel variable: the second apply of an unchanged plan reached apply with a pending marker (INTERNAL), and a plan showed the dependent as pending for ever. The tempting fixes are both wrong. Storing the plaintext somewhere (receipts, a side file) breaks invariant 1. Re-creating or renewing the password whenever a later run needs the value rotates a credential that running deployments may still use, behind everyone’s back.

Decision

OutputSpec gets once?: boolean: the provider reveals the value only when the resource is created. An op declares it together with sensitive.
  • In the run that creates the resource, a once output is an ordinary output: dependent lines receive it.
  • When the producing line’s resource already exists and the run did not reveal the output (in apply: always after the line is processed; in plan: when its diff creates nothing), a from: reference to the output resolves to { keep: true } (ADR 0007), recorded as input state kept with the reference. A dependent that already holds the value is unchanged, so re-applying an unchanged plan writes nothing.
  • A dependent that has nothing to keep (its adapter rejects the keep marker with PARAM_INVALID, as diffValue does) would need the value itself. The line is refused with the new code OUTPUT_UNAVAILABLE, before anything is written, and the message says how to get a new value: delete the producing resource in the provider (missing drift, re-created by the next apply, which then passes the new value on) or rename it in the plan.
  • Nothing in the engine or the adapters re-creates a resource to get a once-only value back.

Consequences

  • Dependents must be in the same plan and applied in the same run as the resource they read a once-only output from. A line added later that references it is refused until the resource is deliberately re-created.
  • A console edit of a dependent that holds a once-only value is not detected: the value is kept, whatever it is. Sponson cannot know the value, so it cannot compare it.
  • If a run creates the resource and then dies before a dependent is written, the value is lost; the next run refuses the dependent with OUTPUT_UNAVAILABLE and the user re-creates the resource. A rollback after a failed line deletes what the run created, so the usual failure leaves nothing half-done.
  • Adding the field is backwards compatible: ops that do not declare it behave exactly as before.

Amendment: proof by fingerprint, and --recreate

Resolving every later reference to { keep: true } trusts the dependent blindly. { keep: true } means “whatever is live”, which is right for adopted values but wrong here in four cases, each of which the first version accepted silently:
  • a dependent added after the producer was created (a second variable reading the same password): if it happens to exist already, it is kept with whatever it holds, which was never this password;
  • an adopted producer (the password existed before Sponson managed it): no dependent ever received its value from Sponson;
  • a dependent edited in the console since it was written: its hash changed, but { keep: true } keeps the edit, even under --reconcile;
  • a dependent that must be (re)created: caught only when its adapter happens to reject the keep marker with PARAM_INVALID.
Decision:
  • Fingerprints, never values. When a line’s write returns a once output, the engine records a keyed fingerprint of it (ledger.keyed(sha256(value)), the scope’s HMAC key, as for secret fingerprints in ADR 0014) on the producer’s ledger entries, onceFingerprints: { <output> }. When a dependent is written with the value, its entries record onceInputs: { "<line>.<output>": <fingerprint> }. Runs that cannot see the value carry both forward; a create starts a new fingerprint.
  • Keep only what is proven. In a later run, a reference resolves to { keep: true } only when every resource of the dependent carries the producer’s current fingerprint (holdsCurrentValue in packages/core/src/engine/once.ts); otherwise it stays pending and the line is refused with OUTPUT_UNAVAILABLE. The line is also refused when its live hash moved since Sponson wrote it (edited outside Sponson: keeping would keep the edit), when its resource does not exist, when its diff would create a resource, or when its adapter rejects the keep marker. Nothing is written.
  • --recreate <line> (RunOptions.recreate, a flag of apply, so the three commands of ADR 0002 stay three; the MCP sponson_apply tool takes recreate as a comma-separated string) deletes the named line’s resources and creates them again in the same run, so the new value reaches every dependent. It refuses (USAGE) to delete anything Sponson did not create in this scope (adopted or another scope’s resources), and names only lines active in the environment. A recreated resource replaces one that existed before the run, so rollback does not remove it (invariant 2 holds by resource identity); the line notes recreated: true, and the deletion is not reported as missing drift. plan with the same option shows the line as create and its dependents as pending. Recreating rotates a credential running deployments may still use: it is only ever done when a human asks.
  • Error message and hint. OUTPUT_UNAVAILABLE says why the line cannot keep the value and lists the ways out: recreate the producer, delete it in the provider, rename it, or keep the value in a secret manager and reference it with { secret: … }. The CLI hint names --recreate.
  • Not done: persisting the value to a secret sink. An opt-in “write the revealed value to a configured secret manager” (Doppler, 1Password, AWS Secrets Manager) would let later runs and new dependents read it by { secret: … }. It needs write access to the secret manager from CI and a new interface next to SecretSource (SecretSink.put(ref, value)), and makes Sponson a writer of secrets, which ADR 0014 avoids. Left for its own record; the refusal message already points at the manual equivalent.
Consequences of the amendment:
  • The four cases above are refused instead of silently kept. Plans with a dependent written in the same run as its producer behave exactly as before; the PlanetScale scenarios needed no change of expectations (their only new step uses --recreate as the remedy).
  • Ledgers written by the first version have no fingerprints, so their dependents are refused once; the first version was never released.
  • A pull request scope that inherits its head branch’s ledger (succession) cannot compare fingerprints keyed with the other scope’s key: its dependents are refused until recreated. Rare (an agent applied locally before the pull request existed), and safe.