# Br Idempotency

> Configure better-route idempotent write behavior. Use

- **Type:** Skill
- **Install:** `agentstack add skill-lonsdale201-wp-agent-skills-br-idempotency`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [Lonsdale201](https://agentstack.voostack.com/s/lonsdale201)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [Lonsdale201](https://github.com/Lonsdale201)
- **Source:** https://github.com/Lonsdale201/wp-agent-skills/tree/main/better-route/br-idempotency

## Install

```sh
agentstack add skill-lonsdale201-wp-agent-skills-br-idempotency
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# better-route: Idempotent writes

For developers protecting POST / PUT / PATCH endpoints from duplicate writes — payment captures, order creation, anything that should run once even if the client retries due to network errors. The middleware honors the `Idempotency-Key` request header pattern (Stripe / Visa Acceptance / many SaaS APIs use the same convention).

## Misconception this skill corrects

> "Classic `IdempotencyMiddleware` is enough to prevent concurrent double execution."

Not for high-side-effect endpoints. Classic `IdempotencyMiddleware` checks the store, runs the handler, then stores the response. Two identical requests arriving at the same time can both see an empty store and both execute. For payment/order/subscription/account-like writes, use **`br-atomic-idempotency`** and `AtomicIdempotencyMiddleware`, which reserves before handler execution.

> "I'll use `ArrayIdempotencyStore` since it's the default and simplest."

`ArrayIdempotencyStore` keeps state in PHP memory FOR ONE REQUEST ONLY. The next request starts with an empty store. So duplicate detection works only within a single PHP process — not across requests, which is the whole point. It's a test fixture, not a production store.

The production options:

| Store | Survives | When to pick |
|---|---|---|
| `ArrayIdempotencyStore` | Single process | Unit tests only. |
| `TransientIdempotencyStore` | Until cache flush | OK if your object cache is persistent (Redis); risky on standard hosts where transients live in `wp_options` and a cache plugin can clear them. |
| `WpdbIdempotencyStore` | DB-persistent | **Production default.** Survives object-cache flushes and PHP restarts. |

For concurrent duplicate prevention, the production option is `WpdbAtomicIdempotencyStore` with `AtomicIdempotencyMiddleware`.

Other AI-prone misconceptions:

- "I'll set up `WpdbIdempotencyStore` once and it just works." Wrong — call `installSchema()` once on plugin activation. Without the table, every `INSERT` / `SELECT` fails. Verified at [WpdbIdempotencyStore.php:76](WpdbIdempotencyStore.php).
- "I can use a cross-database table by passing `'other_db.idempotency'`." Wrong — the table-name guard at [WpdbIdempotencyStore.php:104-110](WpdbIdempotencyStore.php) rejects names containing `.`. This prevents accidental cross-DB writes; if you genuinely need cross-DB, write a custom store.
- "Default v0.3.0 idempotency key is global." Wrong — it's identity-aware: `{provider}:user:{userId}` → `{provider}:sub:{subject}` → `'guest'`. Two different users sending the same `Idempotency-Key` get separate buckets, which is what you want.

## When to use this skill

Trigger when ANY of the following is true:

- The diff instantiates `IdempotencyMiddleware`, `WpdbIdempotencyStore`, `TransientIdempotencyStore`.
- The route needs retry replay but not pre-handler reservation.
- Setting up a payment / billing endpoint.
- The user asks "how do I prevent duplicate orders" / "Stripe-style idempotency".
- Plugin-activation code that needs `installSchema()`.

## Workflow

### 1. Choose the store

```php
use \BetterRoute\Middleware\Write\IdempotencyMiddleware;
use \BetterRoute\Middleware\Write\WpdbIdempotencyStore;

// Plugin activation: ensure the table exists
register_activation_hook(__FILE__, function (): void {
    (new WpdbIdempotencyStore())->installSchema();
});

// Runtime: instantiate the store + middleware
$store = new WpdbIdempotencyStore();
// or with a custom table name:
$store = new WpdbIdempotencyStore(table: 'myapp_idempotency');

$middleware = new IdempotencyMiddleware(
    store: $store,
    ttlSeconds: 600,        // 10-minute window
    requireKey: true,       // reject requests without Idempotency-Key
);
```

`installSchema()` runs `CREATE TABLE IF NOT EXISTS` for the idempotency table. Safe to call repeatedly. Best place is plugin activation (runs once per install), but you can also call it from a one-off setup script.

### 2. Apply to write routes

```php
$router->post('/payments', $createPaymentHandler)
    ->protectedByMiddleware('jwt')
    ->middleware([$jwt, $middleware]);
```

The middleware runs only for `POST` / `PUT` / `PATCH`. GET / DELETE / HEAD pass through unchanged.

### 3. Client-side header

```http
POST /wp-json/myapp/v1/payments HTTP/1.1
Authorization: Bearer ...
Idempotency-Key: 7c3a9b2e-4f81-4a23-8d1e-9c2b5f8e1d4a
Content-Type: application/json

{"amount": 1000, "currency": "USD"}
```

The client generates a unique key per logical operation (UUID v4 is canonical). On the FIRST request, the middleware:

1. Reads `Idempotency-Key`.
2. Computes the storage key: `{routePath}:{idempotencyKey}:{identityKey}`.
3. Calls `$store->get($storageKey)` — returns null (nothing cached yet).
4. Runs `$next($context)` → handler executes the payment.
5. Stores `$response` under the storage key with `ttlSeconds` TTL.

On the SECOND request with the SAME key:

1. Reads `Idempotency-Key` (same value).
2. Computes the same storage key.
3. `$store->get(...)` returns the cached response.
4. Middleware returns the cached response WITHOUT calling `$next` — handler doesn't run twice.

### 4. The TTL window

```php
new IdempotencyMiddleware($store, ttlSeconds: 600);   // 10 minutes
```

Verified at [IdempotencyMiddleware.php:31](IdempotencyMiddleware.php) — default is 300 seconds (5 minutes). Pick the TTL based on:

- **How long do clients reasonably retry?** Network hiccups → seconds. User-driven retries → minutes. Background job retries → hours.
- **Storage cost.** Long TTLs accumulate idempotency entries.
- **Replay-attack window.** Anyone with the cached response can replay it within the TTL — usually fine, since the response is the EXPECTED output anyway.

For payment endpoints, 24h+ is common (Stripe uses 24h). For low-stakes writes, 5-10 minutes is enough.

### 5. requireKey vs optional

```php
// Hard requirement — reject anything without Idempotency-Key
new IdempotencyMiddleware($store, requireKey: true);

// Optional — apply idempotency when key is present, pass through otherwise
new IdempotencyMiddleware($store, requireKey: false);
```

`requireKey: true` ([IdempotencyMiddleware.php:52-53](IdempotencyMiddleware.php)) throws `ApiException('Idempotency key is required.', 400, 'idempotency_key_required')` when the header is missing. Use for high-stakes writes (payments) where a missing key is a client bug.

`requireKey: false` (default) silently runs the handler when no key is present. Use for backwards-compatible adoption — clients that don't send the header still work; clients that do get duplicate protection.

### 6. Custom keyResolver

```php
$middleware = new IdempotencyMiddleware(
    store: $store,
    ttlSeconds: 600,
    requireKey: true,
    keyResolver: function ($request, string $idempotencyKey) {
        // Bucket by tenant ID instead of user ID
        return 'tenant:' . get_current_tenant_id() . ':' . $idempotencyKey;
    },
);
```

Use when the default identity-aware key (user ID / sub / guest) doesn't match your bucketing strategy — e.g. multi-tenant SaaS where the same user can act on behalf of multiple orgs.

### 7. Per-route TTL via separate middleware

```php
$paymentIdempotency = new IdempotencyMiddleware($store, ttlSeconds: 86400);  // 24h for payments
$generalIdempotency = new IdempotencyMiddleware($store, ttlSeconds: 600);    // 10m elsewhere

$router->post('/payments', $handler)
    ->protectedByMiddleware('jwt')
    ->middleware([$jwt, $paymentIdempotency]);

$router->post('/comments', $handler)
    ->protectedByMiddleware('jwt')
    ->middleware([$jwt, $generalIdempotency]);
```

One store, two middleware instances with different TTL configs.

## Critical rules

- **`WpdbIdempotencyStore::installSchema()` once on plugin activation.** Without the table, every operation fails.
- **Cross-database table names rejected** — table parameter must NOT contain `.`. Verified at [WpdbIdempotencyStore.php:104-110](WpdbIdempotencyStore.php). For cross-DB, write a custom store implementing `IdempotencyStoreInterface`.
- **`ArrayIdempotencyStore` is for tests only** — single-process, lost on next request.
- **Apply middleware to write routes only.** GET / HEAD / DELETE pass through unchanged but still cost the middleware overhead.
- **`requireKey: true` rejects with `400 idempotency_key_required`** when header missing. Use for high-stakes writes.
- **v0.3.0 default key is identity-aware.** Same `Idempotency-Key` from different users → different buckets. Pre-v0.3 was global; pass `keyResolver` to preserve old behavior.
- **TTL pruning is on-access.** Expired rows aren't garbage-collected by a cron; they're removed when read after expiration. So a row idle for years just sits there until someone tries the same key.
- **Cached response includes the original status code.** A 201 is returned 201 on retry; a 4xx error on the first request is also "remembered" and returned again. Decide if that's what you want for failed-but-deterministic flows.
- **Combine with auth middleware (auth FIRST).** Otherwise the idempotency key resolver can't distinguish users — everything keys on `'guest'`.

## Common mistakes

```php
// WRONG — ArrayIdempotencyStore in production
$store = new ArrayIdempotencyStore();   // 🔴 single-process; not what you want
$middleware = new IdempotencyMiddleware($store);

// RIGHT
$store = new WpdbIdempotencyStore();

// WRONG — forgetting installSchema
$store = new WpdbIdempotencyStore();
$middleware = new IdempotencyMiddleware($store);
// First request fails: table doesn't exist.

// RIGHT
register_activation_hook(__FILE__, function (): void {
    (new WpdbIdempotencyStore())->installSchema();
});

// WRONG — cross-DB table
$store = new WpdbIdempotencyStore(table: 'analytics_db.idempotency');   // 🔴 rejected

// RIGHT — same DB
$store = new WpdbIdempotencyStore(table: 'better_route_idempotency');

// WRONG — requireKey: true on backwards-compat upgrade
new IdempotencyMiddleware($store, requireKey: true);
// → existing clients (which don't send Idempotency-Key) suddenly fail with 400.

// RIGHT — phase in
// Stage 1 (release): requireKey: false; clients gradually adopt the header.
new IdempotencyMiddleware($store, requireKey: false);
// Stage 2 (after observed adoption): requireKey: true.
new IdempotencyMiddleware($store, requireKey: true);

// WRONG — middleware order
->middleware([$idempotency, $jwt])
// Idempotency keys on 'guest' for everyone (auth hasn't run); two different users with the
// same Idempotency-Key collide.

// RIGHT — auth first
->middleware([$jwt, $idempotency])

// WRONG — applying to GET endpoints "for safety"
$router->get('/orders', $listOrders)->middleware([$idempotency]);
// Middleware skips GET (correct), so this is no-op overhead.

// RIGHT — write methods only
$router->post('/orders', $createOrder)->middleware([$idempotency]);

// WRONG — short TTL on payment endpoint
new IdempotencyMiddleware($store, ttlSeconds: 60);
// Client retries after a 5-minute network outage → middleware no longer remembers,
// runs handler again, charges card twice.

// RIGHT — generous TTL for payments
new IdempotencyMiddleware($store, ttlSeconds: 86400);   // 24h

// WRONG — assuming the cached error response WON'T be cached
// Client sends bad request, gets 400. Retries with same key, gets the cached 400 — but in
// the meantime they fixed the request. Cached response returns the OLD 400.

// RIGHT — accept that idempotency caches whatever happened. If you want fresh attempts after
// the client fixes their bug, the client should rotate the Idempotency-Key for each new attempt.
// Or only cache 2xx via a custom store implementation.

// WRONG — no garbage collection plan for the table
// 24h TTLs + 1000 RPS → 86.4M rows after 24h, never auto-cleaned (pruned on access only).

// RIGHT — schedule a cron to delete expired rows:
add_action('myapp_idempotency_cleanup', function () {
    global $wpdb;
    $wpdb->query("DELETE FROM {$wpdb->prefix}better_route_idempotency WHERE expires_at protectedByMiddleware()` or `->permission()`.
- Run **`br-auth-middleware`** for the auth middleware that should run BEFORE idempotency.
- Run **`br-rate-limiting`** for combining throttle + idempotency on the same endpoint (orthogonal concerns).
- Run **`br-error-contract`** for the `400 idempotency_key_required` and `409 idempotency_conflict` envelope shapes.
- Run **`br-atomic-idempotency`** for high-side-effect writes where concurrent duplicate execution must be blocked before the handler starts.

## What this skill does NOT cover

- Idempotency-Key generation on the client side. UUID v4 is the convention; any unique string works.
- Atomic reservation semantics. Use `AtomicIdempotencyMiddleware` / `br-atomic-idempotency` for that.
- Multi-region replication of the idempotency table. WP isn't multi-region native; replicate via DB-level mechanisms.
- Cleanup automation beyond on-access pruning. Schedule a WP cron if needed.
- Stripe-style idempotency-replay headers (`Idempotent-Replayed: true`). Better-route returns the cached response indistinguishably from a fresh response. Add a custom header if your client cares.

## References

- IdempotencyMiddleware: [libraries/better-route/src/Middleware/Write/IdempotencyMiddleware.php:29-33](IdempotencyMiddleware.php) — `__construct(store, ttlSeconds: 300, requireKey: false)`. Required-key throw at line 52-53 (`idempotency_key_required`).
- WpdbIdempotencyStore: [libraries/better-route/src/Middleware/Write/WpdbIdempotencyStore.php:11-15](WpdbIdempotencyStore.php) — `__construct(table: 'better_route_idempotency', prefix: null)`.
- Schema install: [WpdbIdempotencyStore.php:76+](WpdbIdempotencyStore.php) — `installSchema()` runs `CREATE TABLE IF NOT EXISTS` with TTL index.
- Cross-DB rejection: [WpdbIdempotencyStore.php:104-110](WpdbIdempotencyStore.php) — `tableName()` throws on `str_contains($table, '.')`.
- IdempotencyStoreInterface: [libraries/better-route/src/Middleware/Write/IdempotencyStoreInterface.php](IdempotencyStoreInterface.php) — `get(string): mixed`, `set(string, mixed, int)`, `delete(string)` for custom stores.
- Other stores: [libraries/better-route/src/Middleware/Write/ArrayIdempotencyStore.php](ArrayIdempotencyStore.php), [libraries/better-route/src/Middleware/Write/TransientIdempotencyStore.php](TransientIdempotencyStore.php).

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [Lonsdale201](https://github.com/Lonsdale201)
- **Source:** [Lonsdale201/wp-agent-skills](https://github.com/Lonsdale201/wp-agent-skills)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-lonsdale201-wp-agent-skills-br-idempotency
- Seller: https://agentstack.voostack.com/s/lonsdale201
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
