compensating before the compensation runs and compensated or compensation_failed after.
Rules
- A step is never undone while a step that depends on it is still standing. Dependencies come from the manifest’s
requiresand fromdepends_onon a call. A stuck step is reported asdeferred; everything independent is still undone. - Irreversible steps are reported as cannot be undone. Reversi never claims otherwise.
- Steps whose outcome was never reported (
intended,uncertain) are unresolved: Reversi will not guess. See uncertain steps. - Calls with no manifest (observe mode only) are reported as
unmanifested. - A step whose compensation arguments could not be built (a
$resultvalue was missing) isunrecoverable. - Re-running a rollback is safe: compensated steps are skipped, failed ones retried with the same idempotency key, which you pass to the downstream API so retries deduplicate.
- A halt or kill switch stops new calls only. Rollbacks keep working.
reversi plan <run> (or GET /v1/runs/{id}/plan, or reversi.plan_rollback over MCP) is a dry run. It exits 2 when
the run is not fully reversible.
What the plan tells you before you run it
kind is one of compensate, skip (reads and idempotent tools need no undo), unrecoverable, unresolved,
unmanifested or defer (with blocked_by). For a compensate step the plan shows:
residue: what the undo cannot take back, from the manifest. The plain-textreversi planoutput does not print it; use--jsonorGET /v1/runs/{id}/plan.compensation_kind:inverse(restores exact state) orcompensation(makes amends).verified: the undo will be read back before it counts as done.needs_approval: the compensation waits for an approval.
fully_reversible is true when every standing step is compensate or skip, assuming each compensation succeeds. Residue
does not make it false: a refund that leaves a statement line is still a refund. Read residue to decide whether that is
acceptable.
Retries
--retries n(1 to 10) retries a failing compensation with exponential backoff (and jitter) and the same idempotency key. A step that still fails iscompensation_failed, with the error and the attempt count.--verify-cmdruns after each successful compensation with the same JSON on stdin. Exit 0 confirms the undo is visible downstream; anything else makes the compensation fail. This catches APIs that say “ok” and do nothing.
run.rollback({ compensate, retry: { attempts, base_ms, max_ms }, verify, verifier }).
Verification
A manifest can declare its own read-back: after the undo, call a read-only tool and require the answer to match. Reversi does it for you:- The compensation runs (with retries, if you asked).
- The verification tool is called with the arguments resolved from the original call and result.
- The answer is compared with
expect. On a mismatch it waitsbackoff_ms, doubling each time, and tries again, up toretries. - A match journals
verifiedand the step becomescompensated. No match journalsverification_failedwith the first mismatch (`status` is "succeeded", expected "canceled") and the step becomescompensation_failed.
verification_failed too (cannot verify: arguments unavailable): Reversi does not skip a check it cannot make.
Unverified. If nothing is available to run the read-back, the step is still compensated, but Reversi journals unverified
(no verifier is available to run the declared read-back), lists the step under unverified in the report, and the report is
not complete. It will not call an unchecked undo a checked one.
expect, the step fails with verification failed: <tool> did not show the expected state after the undo (exit 2). If the reader exits non-zero or prints something that is not JSON, that counts as a failed
read. Without --reader-cmd the text output names the steps:
--json shows "unverified": [0]. Run again and the step, already compensated, is skipped, so the second report says
complete. Check the unverified event in reversi export --run <id> if that matters to you.
The rollback report
rollback (CLI --json, run.rollback, reversi.execute_rollback) returns:
Uncertain steps
A timeout or crash leaves a step whose effect is unknown: the gateway journals an upstream timeout or transport error asuncertain, and a step journaled intended that never completed is unknown too. The plan says so and the rollback refuses to
guess:
--result lets the step be rolled back: without it the
compensation arguments that come from $result are missing and the step is unrecoverable. --status failed settles a
step that never took effect. Over HTTP: POST /v1/runs/{id}/steps/{n}/resolve; in the console, the Resolve form
on the run page. In code, run.reconcile(probe) asks a probe function about each unresolved step and settles those it can
answer, leaving the rest.
Compensation approval
A compensation withrequires_approval: true waits for an approval. The requirement is
snapshotted when the step runs, so editing a manifest later cannot change what a past step’s rollback needs.
--approve-compensations (CLI) or approve_compensations: true (gateway) approves them in one go: an operator decision.
Duplicate protection: idempotency keys
Two kinds, easy to mix up:- Forward calls. A manifest’s
idempotencykey makes Reversi refuse a repeat of the same logical call (same order id, say) while the first still stands. - Compensations. Each step’s undo has a key derived from the run, the step and the tool. It is identical on every retry and
every re-run, and is in the compensation call as
idempotency_key. The gateway also sends it to the upstream in the MCP call metadata. Pass it to the downstream API so a retry after a lost response does nothing twice.
Stopping new calls: kill switches and scoped halts
A halt blocks new calls withkill switch: <reason> while rollbacks continue. It can be narrowed.
- A scope is any of
tool,actor,tenantandenvironment(globs). No scope halts everything. A call must match every field named. - Halts live in the data store, so every process sharing it sees them at once.
- A file works for everything at once:
reversi kill-switch on <file>creates the file named byREVERSI_KILL_FILE(orkill_switch_file:in the config), and every gateway and server watching it blocks new calls untilkill-switch off. - Over HTTP an admin uses
POST /v1/halt(body:reasonand optional scope) andPOST /v1/resume;GET /v1/haltslists them. The MCP toolsreversi.haltandreversi.resumeare unscoped.
Continuing a run
An agent that reconnects (a new process, a dropped connection) can name its run to continue it, so approvals and the rollback plan stay together. Over HTTP sendReversi-Run-Id: <id>; over stdio pass --run-id <id> to reversi-gateway. If the run
exists it is continued, not restarted; otherwise it is created under that id.
- Only the principal that opened a run may continue it. Another key gets an error (
belongs to another principal). - The run keeps the mode and scope it was opened with.
- The id must be a valid run id; a bad one is a 400.
Who runs the compensation
The call JSON:

