Status
Accepted. Extends ADR 0008 (leases) and ADR 0016 (one ref per lock).Context
Many of the side effects Sponson manages are not objects of their own but entries in one list that lives on a long-lived provider object every environment shares: the callback, logout and web-origin URLs of one Auth0 application (and the same on Okta, Kinde and Google OAuth clients), Supabase’suri_allow_list, Firebase’s
authorized domains, CORS origins on a bucket (docs/coverage.md, the list write model). The API
offers only “read the object, change the array, write the object back”, usually with no precondition (no ETag, no
version).
Scope locks (ADR 0008) serialise runs of one scope. Two pull requests have
two scopes, so both can read the list, each add its own URL, and each write the list back: the second write drops the
first URL. Both runs report success; the lost item is found only on the next apply of the first pull request
(missing drift) or by a user whose login redirect fails. The same race removes items on destroy, and between
preview and production runs, since environments do not share scopes either.
Options considered:
Decision
The op names the object.OpSpec.lockOn(params, provider) returns a stable identity for the shared object a
line’s resources live in, such as auth0:<tenant>:client:<client id>, or null when the line’s resources are objects
of their own. It is pure (no provider call) and is computed from resolved params and the provider block. The identity
names the object, never a credential: the engine refuses one that contains a registered secret (INTERNAL), because
it is written to receipts and messages.
The engine holds a lease on it around every write.
- In
apply, a line whose inspection would write (any diff notunchanged) and whose op returns an identity takes that object’s lease, then inspects the line again under the lease (the read made before may already be stale) and applies from that fresh state. Unchanged lines take no lock, so steady-state runs never contend. - The lease is the scope lease’s machinery (
Leaseinpackages/core/src/engine/lease.ts, behind aLockTarget): the same TTL, renewal every TTL/3, the holder’s own deadline (LOCK_LOSTbefore the lease could have been taken over), takeover of an expired lock, and release infinally, retried, with a warning naming the object and until when it stays held when the release keeps failing. The holder is the run’s id. - Without
--wait, a held lock fails the line withLOCK_HELD(the run then fails and rolls back what it created, exit 1); with--wait, the run polls for it like a scope lock. Losing a parent lease (LOCK_LOSTwith aparentdetail) fails the line, not the run: the scope and its receipt are still the run’s. - The identity is recorded on the ledger entries the line manages (
LedgerEntry.parent), and on its intents before the create is sent. Rollback andapply --destroylock the object found in the ledger, so a line removed from the plan, or an intent left by a crash, is still removed under the lock. Rollback always waits for the lock (bounded by the wait timeout): giving up would leave resources behind. - A run holds at most one parent lease at a time, only while it writes one line, and never waits for a scope lock while holding one, so two runs cannot deadlock on each other.
ReceiptStore gains optional acquireParentLock,
renewParentLock, readParentLock and releaseParentLock. Both built-in stores implement them with their scope-lock
code under a reserved environment, _locks, and the first 32 hex digits of the identity’s sha256 as the scope
(PARENT_LOCKS_ENVIRONMENT, parentLockScope() in receipts/layout.ts):
- the git-branch store uses one branch per object,
sponson-receipts/_locks/<hash>, holding onlyREADME.mdand_locks/<hash>/lock.json(no legacy migration, a README saying what it is). As in ADR 0016, unrelated objects never share a ref, and the compare-and-swap of the push decides who holds the lock; - the local store uses
_locks/<hash>/lock.jsonunder its root, with the samelink(2)creation and mutex.
_locks: its scopes are
always pr-<n>, branch-<name> or main, never a bare hash.
Consequences
- Concurrent pull requests (and environments) adding or removing entries of one list never lose one. The journey
scenarios/journeys/concurrency/parent-objects.test.tsruns several scopes against both stores, with a control case showing the loss without the lock, and the property suite checks it on random sets of scopes with failures and destroys. - Only Sponson’s writers are serialised. A human editing the list in the console during a run can still lose an
item or have one lost; the next apply reports it as
missingdrift and recreates it. Adapters should still send a precondition when the provider has one. - A write to a busy object waits (with
--wait) or fails the line withLOCK_HELD. Locks are held for one line’s write, usually well under a second, so contention is short; a crashed holder’s lock expires after the TTL. - Each locked object adds one branch to the repository, listed in the hosting UI next to the receipts branches and
covered by the same
sponson-receipts/**rules and permissions. Deleting it is safe when no apply is running. - Receipts carry the identity in
ledger[].parent. Older Sponson versions ignore the field; mixing versions on one repository would let an older run write the object without the lock. - Adapter authors opt in with one function,
lockOn; nothing changes for ops whose resources are objects of their own.