The problem
A payment authorization is captured, intercepted, and re-submitted. Without replay protection, the governance system would approve it again — producing a second valid attestation for the same transaction.
Replay protection ensures each execution happens exactly once.
How it works
Every POST /execute request includes an executionId. Before any evaluation happens, the runtime checks the replay store:
- If the
executionId has never been seen: proceed, mark it as RESERVED
- If the
executionId is in a terminal state (CONFIRMED, OVERRIDDEN): throw [INV-013@replay]
- If the
executionId is in a transient state (RESERVED, EXECUTING, FAILED, EXPIRED): depends on whether the state is retryable
The replay key used in the store is the executionId you provide. This is different from the execution_fingerprint (the semantic hash of the inputs) — two different executionId values can have the same execution_fingerprint if they present identical policy and signals.
Replay state machine
States:
| State | Meaning |
|---|
RESERVED | Execution slot claimed, evaluation in progress |
EXECUTING | Token issued, signing in progress |
CONFIRMED | Execution complete — terminal |
OVERRIDDEN | Override approved — terminal |
FAILED | Execution failed — retryable after TTL |
EXPIRED | TTL elapsed — retryable |
CONFIRMED and OVERRIDDEN are terminal. An executionId in either state will never execute again.
Replay store implementations
MemoryReplayStore (development only)
import { MemoryReplayStore } from "@parmanasystems/core";
const store = new MemoryReplayStore({
warnInProduction: true, // logs a warning if NODE_ENV === "production"
maxSize: 1_000_000, // max entries before throwing
reservationTtlSeconds: 300, // TTL for RESERVED state
failedTtlSeconds: 30, // TTL for FAILED state
});
MemoryReplayStore does not persist across process restarts and does not work across multiple instances. Use it only for development and single-process integration tests.
RedisReplayStore (production)
import { RedisReplayStore } from "@parmanasystems/core";
const store = new RedisReplayStore(
"redis://localhost:6379", // Redis URL — required
{
reservationTtlSeconds: 300, // default
failedTtlSeconds: 30, // default
}
);
Redis stores replay state durably. State survives process restarts and is shared across multiple server instances.
Redis is required for production. The server will not start without a reachable REDIS_URL.
Implementing the ReplayStore interface
You can implement ReplayStore for any backend:
import type { ReplayStore, ReplayState } from "@parmanasystems/core";
class DynamoReplayStore implements ReplayStore {
async reserve(executionId: string): Promise<void> { ... }
async startExecution(executionId: string): Promise<void> { ... }
async confirm(executionId: string): Promise<void> { ... }
async fail(executionId: string): Promise<void> { ... }
async override(executionId: string): Promise<void> { ... }
async expire(executionId: string): Promise<void> { ... }
async getReplayState(executionId: string): Promise<ReplayState | null> { ... }
async hasExecuted(executionId: string): Promise<boolean> { ... }
async markExecuted(executionId: string): Promise<void> { ... }
}
What happens on replay
When an executionId is already in a terminal state (CONFIRMED or OVERRIDDEN):
{
"error": "[INV-013@replay] Replay detected: execution_fingerprint a3f8... has already been consumed"
}
HTTP status: 422. Do not retry with the same executionId. If you need to re-evaluate (different signals or policy version), use a new executionId.
Troubleshooting
Legitimate retry blocked by replay — If your application retried a failed request with the same executionId, and the first attempt succeeded, the retry is correctly blocked. Check the audit database for the existing decision. Do not retry authority verification outcomes with the same ID.
[SYS-REPLAY-001] REDIS_URL is required — Redis is not configured. The server will not start. Set REDIS_URL in your environment.
FAILED state not recovering — The FAILED TTL defaults to 30 seconds. After 30 seconds, the state transitions to EXPIRED and the executionId can be retried. If you want to retry sooner, use a different executionId.
State appears stuck in RESERVED — The reservation TTL defaults to 300 seconds. If the server crashed between RESERVED and EXECUTING, the slot expires after 5 minutes. After expiry, the executionId can be retried.