ADR-0180: Held operations are sealed, claimed once and replayed as the requester
- Status: accepted
- Date: 2026-10-07
- Deciders: Mahdi Amirabdollahi
Context
A held operation (ADR-0179) may wait days before it runs, on any replica. It must run exactly as requested, at most once, and only if the requester could still run it. Plugins share Studio's database role (ADR-0103), so constraints alone do not stop a row from being edited. Some parameters are secret (a new user's password), and some results are secret (a new API token). A replica can crash in the middle of a purge, and nobody can tell how far it got except from the audit row (ADR-0078).
Decision
- Parameters are bound by a hash. Parameters are written as canonical JSON (sorted keys, nulls dropped, floats refused, instants in UTC, lowercase UUIDs, NFC strings, at most 64 KB), and
params_hash = SHA-256(type ‖ LF ‖ version ‖ LF ‖ canonical). - The request and the decision are sealed.
sealed_payloadholds the canonical parameters, hash, requester, auth kind, token id, policy digest and state key;sealed_decisionholds the approver, vote and time. Both are AES-GCM seals fromSecretVaultwith the row id in the associated data, under a key kept outside the database, and take part in key rotation (ADR-0132). Before a run both are opened and the hash recomputed; any mismatch refuses withintegrity. The payload is wiped at a terminal state; approvers see a redacted copy. - Triggers keep the state machine.
HELD→APPROVED|REJECTED|CANCELLED|EXPIRED,APPROVED→EXECUTING|CANCELLED|EXPIRED,EXECUTING→SUCCEEDED|FAILED|REFUSED|OUTCOME_UNKNOWN; identity columns are immutable; the payload may only become NULL, and only at a terminal state. An append-only event table is the timeline and a gap-free outbox for providers. Both tables are managed stores (ADR-0134); terminal rows are kept 90 days, open rows never purged. - A vote is one conditional update. It must echo the stored hash and version, so the approver proves they saw exactly this request; the update requires
state='HELD', the version andexpires_at > now(). No row back is409. - A run is claimed once.
UPDATE … SET state='EXECUTING' … WHERE state='APPROVED' AND run_deadline > now(): under READ COMMITTED one claimer wins. The approving replica starts the run; a ShedLock job (ADR-0125) picks up any it missed. - The run replays as the requester. The provider must still be armed and the type version unchanged; the requester's principal is rebuilt (empty for a disabled user or a revoked token, which is
REFUSED); the effect is estimated again and itsstateKeymust be equal; the provider'scheckRunmust agree. The same public service method then runs under a one-use replay ticket, with the request's audit row as parent and the approval on the new row, so a permission revoked meanwhile also ends inREFUSED. - Two modes.
ON_APPROVALruns in the background as above.BY_REQUESTER, for an operation whose result is secret, runs only when the requester resubmits the same operation before the run deadline, inside their own request. - An interrupted run is never rerun. An
EXECUTINGrow whose replica is gone and whose 15 minute lease has passed becomesOUTCOME_UNKNOWN. The requester asks again. - All time is the database's. Votes, claims and expiry compare with
now(); providers return durations, never instants.
Consequences
- Tampering with a row, replaying a vote and racing two approvers or two replicas all fail closed.
- A requester who lost a permission, was disabled or revoked the token cannot have it used for them.
- A crash mid-run leaves a visible
OUTCOME_UNKNOWNinstead of a guessed second purge, at the cost of a manual retry. - A Studio upgrade that changes a type's version refuses its approved requests ("request again").
- Secrets in parameters live only in the seal and only until the operation ends.
- Every gated parameter type must canonicalize; a float parameter is a design error caught early.
Alternatives considered
- Integrity from database constraints alone. Rejected: the role is shared, so constraints are not a boundary.
- Run as the approver. Rejected: the approver may hold permissions the requester lacks, which turns approval into escalation.
- Retry an interrupted run. Rejected: a destructive operation run twice is worse than one that must be requested again.
- Return the secret result to the approver or store it. Rejected:
BY_REQUESTERkeeps the secret in the requester's own response and nowhere else.