Goal
Ship a first-class Idempotency-Key primitive: a small univeros/idempotency sub-package providing a storage contract + adapters + a PSR-15 middleware + an idempotency: spec block the scaffolder wires automatically. Mirrors Stripe's idempotency model — clients pass an Idempotency-Key header on mutating requests, the framework returns the cached response when the same key is replayed (and refuses with 409 when the key is replayed with a different request body).
The x-altair-idempotency OpenAPI extension key already exists (#163); this epic delivers the runtime that consumes it and the spec block that produces it.
Why
From the strategy notes: idempotency is one of the four "Laravel AI-era property-gaps" the framework targets (alongside determinism, reversibility, and webhooks/idempotency, agent-operable payments). Without a first-class primitive every mutating request an agent retries is a foot-gun — a duplicate row, a double charge, a re-dispatched job. With one, "retry the call" stays safe by default.
Stripe pioneered the pattern; every payments API now expects something like it. PHP frameworks don't ship this out of the box (Laravel has no native primitive, Symfony has none, Slim has none). Shipping it as a sub-package — pluggable storage, agent-readable spec block, round-trips through OpenAPI — is a credible, useful, framework-agnostic differentiator.
What success looks like
A user adds to their spec:
idempotency:
ttl: 24h
scope: tenant
Re-scaffolds. The emitted Action now consumes an Idempotency-Key middleware that:
- Reads
Idempotency-Key from request headers.
- Hashes the request body.
- Looks up the key (scoped to the tenant) in the configured store.
- If found with matching hash → returns the cached response (same status, same headers, same body).
- If found with different hash → returns
409 Conflict with { error: "Idempotency-Key reused with a different payload" }.
- If not found → marks the key in-progress, lets the handler run, captures the response, persists it under the key, returns it.
The spec:emit-openapi chain writes x-altair-idempotency: { ttl: 24h, scope: tenant } on the operation; openapi:import recovers the spec block on the reverse path; openapi:roundtrip (#164) gains the extension to its compared set so dropping it in a refactor fails the gate.
Sub-issues
Acceptance criteria
Out of scope
- Webhook-specific idempotency. Webhooks have their own dedupe model (event id + delivery TTL) that should live alongside the webhook framework rather than here.
- Saga / multi-step idempotency. Single-request scope only in v1.
- Cross-region / multi-write replication of the store. Adapters target single-region clusters; multi-region is the host application's call.
- Response replay for streaming endpoints. The middleware skips the cache when the response advertises
transfer-encoding: chunked or content-type text/event-stream.
Dependencies
univeros/http (PSR-15 middleware contract)
univeros/cache (the Redis / APCu adapter can defer to existing CacheItemStorage implementations rather than re-inventing connection management)
x-altair-idempotency extension key + schema (already shipped — #163)
Notes
Sub-package layout follows univeros/cookie, univeros/session, etc.:
src/Altair/Idempotency/
├── Contracts/
│ ├── IdempotencyStoreInterface.php
│ └── IdempotencyKeyResolverInterface.php
├── Storage/
│ ├── InMemoryStore.php
│ ├── ApcuStore.php
│ └── RedisStore.php
├── Middleware/
│ └── IdempotencyKeyMiddleware.php
├── Hash/
│ └── RequestBodyHasher.php
├── Exception/
│ ├── IdempotencyConflictException.php
│ └── IdempotencyException.php
├── Configuration/
│ └── IdempotencyConfiguration.php
└── composer.json
Then the scaffold sub-package gains an IdempotencySpec AST node and the action emitter wires the middleware into the generated pipeline when the spec carries the block.
Suggested order
#172 → #173 → #174 → #175 → #176. Each depends on the prior. #176 is the wrap-up and can land in parallel with #175 if scope allows.
Goal
Ship a first-class
Idempotency-Keyprimitive: a smalluniveros/idempotencysub-package providing a storage contract + adapters + a PSR-15 middleware + anidempotency:spec block the scaffolder wires automatically. Mirrors Stripe's idempotency model — clients pass anIdempotency-Keyheader on mutating requests, the framework returns the cached response when the same key is replayed (and refuses with 409 when the key is replayed with a different request body).The
x-altair-idempotencyOpenAPI extension key already exists (#163); this epic delivers the runtime that consumes it and the spec block that produces it.Why
From the strategy notes: idempotency is one of the four "Laravel AI-era property-gaps" the framework targets (alongside determinism, reversibility, and webhooks/idempotency, agent-operable payments). Without a first-class primitive every mutating request an agent retries is a foot-gun — a duplicate row, a double charge, a re-dispatched job. With one, "retry the call" stays safe by default.
Stripe pioneered the pattern; every payments API now expects something like it. PHP frameworks don't ship this out of the box (Laravel has no native primitive, Symfony has none, Slim has none). Shipping it as a sub-package — pluggable storage, agent-readable spec block, round-trips through OpenAPI — is a credible, useful, framework-agnostic differentiator.
What success looks like
A user adds to their spec:
Re-scaffolds. The emitted Action now consumes an Idempotency-Key middleware that:
Idempotency-Keyfrom request headers.409 Conflictwith{ error: "Idempotency-Key reused with a different payload" }.The
spec:emit-openapichain writesx-altair-idempotency: { ttl: 24h, scope: tenant }on the operation;openapi:importrecovers the spec block on the reverse path;openapi:roundtrip(#164) gains the extension to its compared set so dropping it in a refactor fails the gate.Sub-issues
univeros/idempotency: storage contract + adapters (InMemory, APCu, Redis)IdempotencyKeyMiddleware) with request-hash + in-progress lifecycleidempotency:spec block: AST node, parser, validator, scaffolder wires the middlewarex-altair-idempotencyround-trip activation: forward emit, reverse import, drift-gate watches itAcceptance criteria
composer require univeros/idempotencyships the storage interface + at least one adapter that does not require external infrastructure (in-memory for tests, APCu for single-host production)idempotency: { ttl: 24h }scaffolds an Action whose pipeline includesIdempotencyKeyMiddlewareconfigured with that TTLIdempotency-Keysee one execute and the other(s) get either the cached response or a409, never a duplicate side-effectIdempotency-Keyheader on an idempotency-protected endpoint either falls through (configurable: optional) or returns400(configurable: required) — defaultoptionalso adopting the middleware doesn't break existing clientsopenapi:roundtrip(#164) catches a regression wherex-altair-idempotencywould be droppedOut of scope
transfer-encoding: chunkedor content-typetext/event-stream.Dependencies
univeros/http(PSR-15 middleware contract)univeros/cache(the Redis / APCu adapter can defer to existing CacheItemStorage implementations rather than re-inventing connection management)x-altair-idempotencyextension key + schema (already shipped — #163)Notes
Sub-package layout follows
univeros/cookie,univeros/session, etc.:Then the scaffold sub-package gains an
IdempotencySpecAST node and the action emitter wires the middleware into the generated pipeline when the spec carries the block.Suggested order
#172 → #173 → #174 → #175 → #176. Each depends on the prior. #176 is the wrap-up and can land in parallel with #175 if scope allows.