Skip to content

The kill switch

The kill switch stops future spending on command. Freeze a scope and every subsequent reserve against it is denied before a signer is reached — no signature, no payment. It lives in the shared store’s admin plane, so it works across a whole fleet from one command, and a data-plane worker cannot un-freeze itself.

This is a day-2 control for a shared store; against the default in-process MemorySpendStore freeze exists but only affects that one process.

From the CLI, with an admin credential configured (operator verbs):

Terminal window
export TX402_SPEND_STORE="https://gateway.example" # or a rediss://admin DSN
export TX402_SPEND_STORE_ADMIN="$ADMIN_TOKEN"
tx402 freeze api.merchant.example # stop paying this merchant
tx402 unfreeze api.merchant.example # resume
tx402 freeze "*" # stop paying everyone (see the topology note below)

Or from code, through the admin store:

await admin.freeze("api.merchant.example", Date.now());
await admin.unfreeze("api.merchant.example", Date.now());

Freeze is keyed by the normalized merchant host — the same scope key the budget uses — or the sentinel "*" for the whole store. It is asset-independent: freezing api.merchant.example stops payments in every asset to that host.

A reserve against a frozen scope raises SpendScopeFrozenError (TX402_SPEND_FROZEN, exit 3), carrying { scope, frozenScope }, and the client emits a spend.frozen event at warn. Because the refusal happens in reserve, before the signer, a frozen fleet produces zero signatures — exactly like a policy refusal. A store outage is never mistaken for a freeze: an unreachable store is a retryable TransportError, not this error.

  • It cannot revoke a signature already on the wire. Freeze is a stop-future-authorization control, not a chain rollback. A payment whose signature was already transmitted is the merchant’s now; freezing does not recall it.
  • It does not release existing reservations. Reservations already taken — including exposed ones from a maybe-settled payment — remain and keep counting against the caps. Unfreeze preserves all committed and exposed accounting exactly; freezing and unfreezing is not a way to clear the ledger. To resolve an exposed reservation you reconcile it.

Global "*" freeze is a topology capability

Section titled “Global "*" freeze is a topology capability”

Freezing one scope is atomic on every backend. Freezing everything with "*" is only atomic where the global flag and a reservation share one coordination domain — single-instance Redis and the single-coordinator Durable Object topology. There, capabilities.atomicGlobalFreeze is true and freeze("*") blocks every scope atomically.

On Redis Cluster and the id-per-scope DO topology the global flag lives in a different slot or storage domain from a scope’s reservation, so atomicGlobalFreeze is false and freeze("*") fails closed with ConfigurationError (reason: "global-freeze-unsupported") rather than silently freezing nothing. The message tells the operator to run single-instance Redis or the coordinator DO if fleet-wide-atomic freeze is required. Per-scope freeze is unconditional and works on all four.

If you need a reliable panic button on a topology that cannot do atomic global freeze, freeze the handful of scopes your fleet actually pays, or put a single-coordinator backend behind the gateway. tx402 budget <host> --network … reports "frozen": true/false per scope so you can confirm the state.