JSON is canonical. Scenario matching is deterministic and allows provider, operation, amount bounds and exact metadata matching. Arbitrary JavaScript is forbidden.
Built-in scenarios live only as canonical files under scenarios/<provider>/*.json. npm run scenario:compile validates the safe DSL and generates apps/api/src/modules/scenarios/default-scenarios.ts as a runtime representation. npm run build, npm run typecheck and npm test run the compiler first, preventing silent drift between JSON and runtime behavior.
Custom scenarios can be persisted through POST /scenarios with { "key": "team/high-value-rejection", "definition": { ... } }. The response returns an immutable-style reference such as team/high-value-rejection@1. Custom sessions must use the explicit @version reference so later versions cannot silently change an existing test.
The DSL intentionally supports a small deterministic rule set. Unknown action keys are rejected; no JavaScript/eval/function execution is available. Redis compilation/cache is a later performance optimization, not the source of truth.
#then.after — a payment that moves on its own
{
"provider": "mercadopago",
"version": 1,
"when": { "operation": "payment.create" },
"then": {
"outcome": "requires_action",
"after": { "seconds": 5, "outcome": "captured" },
"webhook": { "enabled": true }
}
}
A ticket paid at a shop counter, a transfer that clears overnight, a wallet that redirects and comes back. In every one of them the payment changes while the integration does nothing, and the integration is told by webhook — which is the case it is most likely to get wrong, and the hardest to reproduce against a real provider, because there is no request you can send to make somebody walk into a shop.
| Rule | Why |
|---|---|
Only on a payment that ends in requires_action |
That is the one state where it waits for somebody who is not the integration. Everywhere else the integration is the one who acts, and a timer moving it would be the emulator acting on its behalf |
outcome is captured or failed |
A payment left waiting can complete or expire. Refunding or disputing on a timer would be the emulator performing an operation that belongs to the integration |
seconds is 1–3600 |
A scenario is something a test waits for; one that fires tomorrow is a row nobody will ever see move |
The money is always external |
Nobody signed in, and a ticket paid at a shop is cash arriving from the world. Debiting a balance would be inventing a payer |
The worker sweeps it on a clock. The control plane can run the sweep on demand —
POST /applications/{id}/payments/run-timed-transitions, with an optional
asOf — which is what lets a test observe a five-minute ticket without waiting
five minutes. The same reason run-settlement exists beside it.
Shipped with three: mercadopago/ticket-paid-later, mercadopago/ticket-expired
and stripe/bank-transfer-clears.
#Current DSL gap
then.httpStatus and then.fault.once still exist in the TypeScript scenario
shape, but GatewayService does not consume them. Do not use either in a new
scenario until runtime behaviour and tests are added. Use
then.fault: { "type": "http_error", "status": 429 } for an HTTP failure; it is
the implemented path.