Status
Proposed. Introduced with theplanetscale 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 andnull 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
onceoutput 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 statekeptwith 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, asdiffValuedoes) would need the value itself. The line is refused with the new codeOUTPUT_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_UNAVAILABLEand 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.
- Fingerprints, never values. When a line’s write returns a
onceoutput, 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 recordonceInputs: { "<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 (holdsCurrentValueinpackages/core/src/engine/once.ts); otherwise it stays pending and the line is refused withOUTPUT_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 ofapply, so the three commands of ADR 0002 stay three; the MCPsponson_applytool takesrecreateas 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 notesrecreated: true, and the deletion is not reported asmissingdrift.planwith the same option shows the line ascreateand 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_UNAVAILABLEsays 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 toSecretSource(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.
- 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
--recreateas 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.