Contents

Reverse

An operator-only manual undo of a prior posting (distinct from reversing a payout).

Source src/operations/reverse.ts#L44-L75reversesrc/contract.ts#L169-L177kind 'reverse'

API reverse

reverse undoes an earlier transaction by posting its exact opposite: every leg of the original, with its sign flipped.

It’s an operator’s manual correction tool. You name the transaction to undo by its txnId and supply a written reason. The handler then posts a new, balanced transaction that cancels the original out.

A reverse of txn_1, posted by an operator, with the reason recorded for the audit trail:

const outcome = await economy.submit({
  kind: 'reverse',
  idempotencyKey: 'idem_0',
  actor: { kind: 'operator', operatorId: 'op_1' },
  txnId: 'txn_1',
  reason: 'reconciliation: duplicate posting',
});
// → { status: "committed", transaction: { id: "txn_…", … } }
curl -s https://economy.example/submit \
  -H 'content-type: application/json' \
  -d '{
    "kind": "reverse",
    "idempotencyKey": "idem_0",
    "actor": { "kind": "operator", "operatorId": "op_1" },
    "txnId": "txn_1",
    "reason": "reconciliation: duplicate posting"
  }'

This is the general undo for any past posting. To undo a payout that hasn’t disbursed yet, reach for reverse-payout instead, which unwinds the payout saga rather than flipping ledger legs.

Parameters

The payload fields, beyond the kind tag, are:

FieldTypeDefaultDescription
idempotencyKeystring(required)A retried submit with the same key runs at most once. See idempotency.
actorPrincipal(required)Who is asking. Must be an operator principal.
txnIdstring(required)The id of the transaction to undo.
reasonstring(required)Why the reversal happened, recorded on the reversing transaction. Must be non-empty.

There is no amount field: the handler flips the sign of each of the original transaction’s legs.

Returns

reverse resolves to an Outcome.

  • committed: a first reverse of a transaction, carrying the reversing transaction.
  • duplicate: a second reverse of the same txnId, carrying the first reversal’s transaction. No money moves the second time.

reverse returns no rejected outcome. A malformed request throws instead; see reason codes.

Postings

The reversal posts the original transaction’s legs again, with every amount’s sign flipped, same accounts, opposite direction.

Because the original legs already sum to zero per currency, flipping every sign keeps that sum at zero, so the reversing transaction balances without any recomputation. See double-entry and the balanced posting.

For an original txn_1 that debited account A and credited account B, the reversal mirrors it:

AccountSideAmount
Acreditamount
Bdebitamount

The reversing transaction records kind: "reverse", the txnId it undoes, and the operator’s reason in its metadata, so an audit can see who undid what and why.

Authorization

Only an operator may call reverse. It’s one of the privileged manual corrections, alongside adjust, that an end user can never run.

The framework’s privileged gate runs before the handler and throws AUTH.UNAUTHORIZED for a user actor. See actors and authorization.

A system actor clears that gate. The handler then rechecks the actor itself and throws OP.MALFORMED for any non-operator principal, so a system caller is refused too. That recheck also holds when the handler is called directly, outside the framework.

Reason codes

reverse returns no reason codes: it has no rejected path. It fails only by throwing. A well-formed operator request either commits, or repeats a prior reverse as a duplicate.

The handler throws an OP.MALFORMED fault for each caller mistake it guards against; none is returned as a rejection:

CodeWhen
OP.MALFORMEDThe actor is not an operator.
OP.MALFORMEDThe reason is blank or whitespace-only.
OP.MALFORMEDThe txnId names no existing posting.
OP.MALFORMEDThe txnId names a posting that is itself a reversal.

These are operator errors. Compare refund, where an unknown order id is an everyday “no” and comes back as a normal rejection. The full throw-vs-decline split lives on outcomes and reason codes.

Preconditions and invariants

A transaction is reversed at most once. The handler stakes a shared reversed:${txnId} key before posting. This is the same reversed:${id} idempotency family that refund and clawback use. The first reverse claims the key and posts the inverse. A second reverse loses the claim and gets the first reversal back as a duplicate.

The claim is written inside this posting’s database transaction, so a rollback releases it and a later retry can succeed.

Before posting, the handler locks every account the original transaction touched. A reverse request names only a txnId, so the handler can’t know the accounts up front. It discovers them from the loaded transaction and locks each one, keeping any other operation from changing those balances while the reversal posts.

The reversing transaction is balanced, like every posting in the system. Flipping the sign of legs that already net to zero leaves them netting to zero.

See also