Held actions & execute-on-approve
Held actions & execute-on-approve
One code path, two moments
A held action is a small command implementing ApprovableAction. The invariant that makes the
two-person rule sound is that execute() is the one and only place the mutation happens, and
it is reached through the exact same call whether the action runs directly (below threshold) or on
approval. There is no second, un-gated code path — an approved refund and a direct refund run
identical code and produce identical audit events.
- Direct run (policy does not require approval): the gate calls
validate()thenexecute()immediately. - Held run: the action's
type()+payload()are persisted on theApprovalRequest. On the quorum-reaching approval, the executor rebuilds the action from the stored payload via the registry factory, callsvalidate()again (the world may have moved since capture), thenexecute().
Exactly-once execution
Execution reads the request under a row lock (lockForUpdate) inside a transaction and
no-ops if the request is already executed. So a double-submit or a re-approval can never run the
money effect twice. This status guard is the first line of defence; the held action's own
engine-level idempotency is the second:
- A refund carries a stable action id (
op-refund:<key>); the engine refunder is idempotent on it and caps the cumulative refund at the amount charged, so even a retried execution never issues a second credit note. - A wallet adjustment and other actions rely on the status-guarded exactly-once execution.
Re-approving an already-executed request returns its stored result unchanged — a genuine no-op.
Validate before you run — twice
validate() re-checks the action can still run in the current world (the invoice is still
refundable, the target still exists), throwing a domain *ActionDenied the console surfaces. It
runs before a direct execute and again at approval time, because a request may sit in the queue
while the world changes.
Expiry sweep
When expire_after_days is set, a pending request past its expiry can be lapsed to expired by
ApprovalService::expire(). An expired request never executes; the expiry is recorded on the audit
trail. Wire the sweep into the scheduler if you use TTLs.
Audit
The engine records approval.requested, approval.approved, approval.rejected,
approval.executed, approval.canceled, and approval.expired on the tamper-evident trail, in
addition to the underlying action's own event (e.g. invoice.refunded, wallet.adjusted,
customer.suspended, plan.archived) which the held action records on execute — so both the money
effect and its governance are auditable.