> ## Documentation Index
> Fetch the complete documentation index at: https://sponson.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR 0019: Locks on shared parent objects

> Architecture decision record 0019. Status: Accepted. Extends ADR 0008 (leases) and

Date: 2026-10-11

## Status

Accepted. Extends [ADR 0008](/contributing/adr/0008-scopes-succession-and-leases) (leases) and
[ADR 0016](/contributing/adr/0016-one-receipts-ref-per-scope) (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's `uri_allow_list`, Firebase's
authorized domains, CORS origins on a bucket ([docs/coverage.md](/reference/coverage), 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](/contributing/adr/0008-scopes-succession-and-leases)) 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:

| Option | Why not (alone) |
| - | - |
| provider preconditions (ETag / `If-Match`, GCS `ifMetagenerationMatch`) in the adapter contract | the right tool where it exists, and adapters should use it, but the providers that need this most (Auth0, Supabase's auth config, Firebase's config) offer none |
| re-read after write and retry when the item is gone | narrows the window but cannot close it: the other writer's item can be lost after it re-read |
| a scope lock wide enough to cover every scope (one lock per environment, or per repository) | serialises everything, including the many lines that have objects of their own; and still not across environments |
| a lock per provider object, held in the receipts store | chosen: it serialises exactly the writers that conflict, and the store already offers leases with fencing, takeover and per-ref isolation |

## 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 not `unchanged`) 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 (`Lease` in `packages/core/src/engine/lease.ts`, behind a `LockTarget`):
  the same TTL, renewal every TTL/3, the holder's own deadline (`LOCK_LOST` before the lease could have been taken
  over), takeover of an expired lock, and release in `finally`, 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** with `LOCK_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_LOST` with a
  `parent` detail) 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 and `apply --destroy` lock 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.

**Stores keep one lock-only ref per object.** `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 only `README.md` and
  `_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.json` under its root, with the same `link(2)` creation and mutex.

A store that does not implement the methods gets the same behaviour from its scope-lock methods under that reserved
environment, so custom stores keep working. A plan may still declare an environment named `_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.ts` runs 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 `missing` drift 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 with `LOCK_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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.