AI agents

A Dry-Run Endpoint for Reversals: Testing the Undo First

Antonio Evans8 min read

A split scene: on the left, a server rack labeled LIVE with a locked door; on the right, an identical rack labeled TEST with an open door and a technician holding a printed checklist.
Key points

Key points

  1. 1A dry-run reversal endpoint runs the exact compensating code path production would use, but against fixtures only.
  2. 2CI calls the write test first, then calls the reversal endpoint on the same fixture and asserts the fixture returns to its original snapshot.
  3. 3The endpoint must be isolated by host and routing so a misconfigured client cannot reach live systems.
  4. 4Pairing the dry run with idempotency keys makes the reversal safe to retry, the same way retried writes are made safe.

Why the undo deserves its own test

Most teams test the forward write until it passes, then document the undo in a runbook and trust an operator to run it by hand under pressure. That is backwards. The compensating action is a second, separate business transaction with its own rules, and it fails in its own ways: it can double-apply, it can fail after partial success, and it can assume state that no longer exists. Microsoft's pattern guidance is explicit that you often cannot simply restore the original state, because concurrent work may have changed the data, so the undo has to apply business rules of its own the Compensating Transaction pattern.

Sagas make the same point from the orchestration side: there is no automatic rollback across services, and a developer must design the compensating transactions that explicitly undo earlier steps the Saga pattern. Temporal frames compensations as a first-class part of the long-running transaction: either the forward steps complete or the executed operations are undone as if they never happened, and how to compensate each operation depends on the scenario compensating actions.

If the compensation is real code that runs in production, it should be tested like real code. The dry-run endpoint is how you do that without pointing it at a live order or a live payment.

A finance architecture walkthrough draws the line that matters here: compensation is a new business action, not an erase. A void, refund, credit note or reversing entry has its own rules and evidence, and it can itself fail, so it needs an operation ID, idempotency behavior, workflow state and an escalation path idempotency, compensation and reconciliation. Testing the inverse call is testing that new action.

The dry-run contract

Treat the endpoint as a contract with four parts: who can call it, what it accepts, what it may touch, and what it returns. The safest shape is a dedicated route rather than a flag on the live endpoint, because a separate path is easier to block at the gateway. That trade-off is the classic one in API design: a flag keeps one interface but risks a stray destructive call, while a dedicated endpoint keeps live and dry-run code physically apart dry run strategy for REST API.

A workable contract:

ElementRule
RoutePOST /test/reversals/{kind} on a test-only host, never on the production host
AuthService token scoped to the CI runner; the production credential cannot call it
InputThe fixture snapshot reference plus the original operation ID, not free-form parameters
TargetFixture store only; the adapter that resolves live systems is not mounted
OutputJSON with the applied compensation, the post-state snapshot and a pass/fail verdict
IdempotencyA key derived from run, step and target, so a retried dry run does not apply twice

That last row borrows from the payment world. An idempotency key should represent the intent, not an individual network attempt, and every retry of the same operation reuses it idempotency for agents. Google's guidance is the same: generate a unique key, check a store for it, and on a duplicate return the stored result instead of re-running the business logic what is idempotency.

The response body should carry three things a machine can assert on: compensation_applied, post_state (the fixture after the inverse), and equals_baseline (a boolean the endpoint computes itself). Do not make CI parse free text; make the endpoint do the comparison and say the answer. For example, a cancelled order releases its reservation, and the endpoint returns the reservation record with status: released and released_on_cancel: true. A captured payment issues a void, and the endpoint returns the payment record with state: voided and the void reference.

Building fixtures that can be returned to baseline

A fixture is not just seed data. For this test it must be a snapshot you can hash before the write, after the write and after the reversal. The pattern is simple: write the fixture state to a canonical JSON form, hash it, store the hash, and compare at the end.

import hashlib, json

def snapshot(store, fixture_id):
    state = store.read(fixture_id)          # deterministic read
    blob = json.dumps(state, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(blob.encode()).hexdigest()

baseline = snapshot(store, "order-42")
run_forward("order-42")                      # creates order, reserves stock
written  = snapshot(store, "order-42")       # must differ from baseline
run_reversal("order-42")                     # tests the compensate path
restored = snapshot(store, "order-42")       # must equal baseline
assert written != baseline, "write had no effect"
assert restored == baseline, "compensation did not return to baseline"

Two details make this reliable. First, exclude volatile fields such as updated_at, request IDs and trace headers from the canonical form, or the hash will never match even when the business state is correct. Idempotency implementations make the same distinction: build a business signature from immutable fields only, never timestamps or metadata idempotency keys in production.

Second, version the fixtures alongside the schema. The compensation under test is a real code path, so a schema change that breaks the inverse should break the fixture build loudly, not silently pass with a missing field. Azure's guidance that compensating transactions usually need application-specific business rules is the reason a naive restore-the-row fixture will not catch the interesting bugs compensating transactions.

Running the compensation in CI

Order matters. Run the tests in this sequence so a failure points at one stage:

  1. Fixture load. Seed the store, capture the baseline hash, fail if the fixture is not readable.
  2. Forward write test. Call the write endpoint, assert it succeeded, capture the post-write hash.
  3. Reversal dry run. Call the test-mode endpoint with the operation ID from step 2, assert compensation_applied is true.
  4. Baseline assertion. Compare the restored hash to the baseline. Fail with the diff if they differ.
  5. Idempotency check. Call the reversal a second time with the same key, assert the second call returns the stored result and changes nothing.

The idempotency check in step 5 is the one most teams skip, and it is the one that catches the real incident. A duplicated approval or a retried webhook is normal in distributed systems, and without protection at the execution point, a single duplicate can execute the same action twice retries, idempotency and observability. A durable execution engine can run a step again on replay, so AWS recommends idempotent operations or at-most-once semantics that record an interruption instead of re-executing idempotency and retries. Calling the reversal twice in a test is how you prove your implementation survives that.

# ci pipeline, abbreviated
- name: fixtures
  run: ./fixtures load --set refunds-v3
- name: write
  run: ./tests run write_order --fixture order-42
- name: reversal dry run
  run: ./tests run reversal --fixture order-42 --kind cancel_order
- name: reversal idempotency
  run: ./tests run reversal --fixture order-42 --kind cancel_order --repeat 1

Keep the run short and deterministic. If the reversal calls an external provider in any mode, point the adapter at a sandbox or a recorded stub for this test. The test is about your compensation logic, not the provider's uptime.

Worked example: reservation, capture, void

Walk two cases end to end with numbers, so the assertions are concrete.

Case A, cancelled order releases its reservation. A fixture starts with stock of 100 units for SKU-7. The write test places an order for 3 units, so stock goes to 97 and a reservation row appears with quantity: 3, status: held. The dry-run reversal runs the cancel path and should set the reservation to released and return stock to 100. The baseline hash must match. If the compensation instead sets stock to 103 (releasing twice, or releasing a reservation that was already released), the hash differs and CI fails. That is the bug you want caught in a pull request, not at 2 a.m.

Case B, captured payment issues a void. A fixture starts with a payment in authorized for 49.99 USD. The write test captures it, so state becomes captured and the ledger gains one capture entry. The reversal endpoint runs the void path and should add a void entry referencing the capture, set state to voided, and keep the original capture entry as history rather than deleting it, because compensation is a new action, not an erase idempotency, compensation and reconciliation. The baseline hash here includes a rule: the ledger is append-only, so baseline equality means the net effect is zero, not that no rows exist. Encode that rule in the canonicalizer so the assertion stays honest.

A useful decision rule: if the compensation changes state by appending, compare net effects; if it changes state by replacing a field, compare the field. Never mix the two in one fixture, or a passing test tells you little.

Keeping the endpoint away from live systems

The endpoint is only safe if a misconfiguration cannot reach production. Enforce that at more than one layer:

LayerControl
DNS and hostnameTest host resolves to the fixture service; production host has no route to it
Gateway/test/* routes block by default and allow only the CI runner's identity
RuntimeThe fixture adapter is the only data source mounted; the live client is absent, not just disabled
CredentialsThe CI token has no scopes on production systems
LoggingThe endpoint writes an audit record naming the run, step and fixture

The reason to remove the live client rather than flag it off is that a flag is one config typo away from a production write. Risk classification guidance recommends gating actions by blast radius and reversibility, and a test endpoint should sit outside the tier system entirely, in its own sandbox classifying agent actions by risk. A published account of production access for agents notes that the fix after a destructive incident is a gate in front of the write, not a stronger instruction in a prompt should an AI agent have production access.

If you are wiring models into this loop, a single gateway for every model kind keeps the number of credentials and routes you must isolate small. Quanta Inference exposes text, image, video, audio and embedding models behind one OpenAI-compatible, prepaid API key, which makes it easier to keep test and production credentials apart.

What the test does not prove

Be honest about the limits. A dry run against fixtures proves that the inverse call exists, runs, returns to baseline and is idempotent. It does not prove that the live system accepts the compensation, that the provider honors the void within its time window, or that the operator knows when to run it. Providers can reject or delay reversals, and the only durable answer is reconciliation against independent evidence after the fact idempotency, compensation and reconciliation.

So use the dry run as the gate and reconciliation as the safety net. The test catches the code defect; reconciliation catches the business exception. A human approval step before irreversible writes catches both, and the same tiering guidance that separates reversible from destructive actions tells you where to put it classifying agent actions by risk.

For teams that want the reversal and its approval step delivered as a working service rather than assembled piece by piece, Quanta takes a description of the endpoint, fixtures and CI checks and builds it with you.

FAQ

FAQ

Why test the undo instead of just documenting it?

Because the compensation is separate code with its own failure modes. Across services there is no automatic rollback, so someone has to design and verify the inverse call the Saga pattern. A test proves it ran against a fixture; a runbook only proves someone wrote instructions.

Should the dry-run endpoint be a flag on the live route or its own route?

Prefer its own route, on a test-only host, blocked at the gateway. A flag keeps one interface but leaves a live endpoint able to write if the flag is misread dry run strategy for REST API. Separate routes are easier to restrict to CI identities.

What should the endpoint return so CI can assert on it?

Return the applied compensation, the post-state snapshot and a computed equality verdict against the baseline. CI should not reimplement comparison logic or parse prose. The endpoint owns the rule for what back to baseline means for that fixture.

How do I stop the reversal from applying twice if CI retries?

Use an idempotency key derived from the run, step and target, store the result under a unique constraint, and return the stored result on repeats idempotency keys in production. Then add a CI step that calls the reversal twice and asserts the second call changes nothing.

Can I reuse one fixture for many reversal tests?

Yes, if each test reloads the fixture and re-hashes the baseline, and if you keep replace-style and append-style compensations in separate fixtures. Mixing them into one baseline makes a passing test ambiguous and hides real defects.

Sources

Sources

  1. Compensating Transaction Pattern - Azure Architecture ...
  2. Pattern: Saga
  3. Saga Compensating Transactions
  4. Idempotency, Consistency, Compensation, and Reconciliation
  5. Dry run strategy for REST API
  6. Idempotency for Agents: The Production-Safe Pattern You' ...
  7. What is Idempotency? A guide to API reliability
  8. Idempotency Keys: What Most Tutorials Don't Tell You
  9. Retries, Idempotency and Observability in Agentic Loops, AI ...
  10. Idempotency and retries - AWS Durable Execution SDK ...
  11. How to Classify AI Agent Actions by Risk: A Four-Tier ...
  12. should an AI agent have production access?
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles