Part of #171. Depends on #172 (storage) and #173 (middleware).
Goal
Add an idempotency: block to Altair YAML specs, wire the parser / validator to accept it, and update the action emitter so a spec carrying the block produces an Action whose pipeline includes IdempotencyKeyMiddleware configured with the requested TTL and scope.
Why
The middleware (#173) is the runtime; this issue is the spec-driven entry point. Without it, host applications have to register the middleware by hand on each action — that's the boilerplate Univeros exists to eliminate, and it's also what breaks "agent edits the spec, re-scaffolds, done."
Shape
YAML spec block
idempotency:
ttl: 24h # required; pattern ^[0-9]+(ms|s|m|h|d)$ — same as x-altair-idempotency
scope: tenant # optional; default 'tenant'
mode: optional # optional; one of 'optional' | 'required'; default 'optional'
When absent: no middleware wired, no behaviour change. Backward-compatible.
AST node
Altair\Scaffold\Spec\Ast\IdempotencySpec:
final readonly class IdempotencySpec
{
public function __construct(
public string $ttl, // raw string ('24h'); the runtime parses it
public string $scope,
public string $mode,
) {}
}
Add ?IdempotencySpec $idempotency = null to Altair\Scaffold\Spec\Ast\Spec.
Parser / validator changes
- Parser reads the block if present, builds
IdempotencySpec, attaches it to Spec.
- Validator:
ttl matches ^[0-9]+(ms|s|m|h|d)$.
scope is non-empty.
mode is optional or required.
Action emitter changes
When $spec->idempotency is non-null, the generated Action constructor receives an IdempotencyKeyMiddleware and inserts it into the pipeline ahead of the input-validation middleware (so a replayed response short-circuits before we re-validate the body — which would be redundant work).
The middleware is constructed from the host application's IdempotencyConfiguration (DI-bound), parametrised by the spec's TTL / scope / mode.
Acceptance criteria
Out of scope
IdempotencyConfiguration for the host container — lands here as a stub; the production-ready binding (InMemoryStore for dev, RedisStore for prod via env) belongs in univeros/idempotency/Configuration/IdempotencyConfiguration
- The OpenAPI extension round-trip — own issue
--idempotency flag for bin/altair openapi:import — could be added when there's a use case; the spec block reaching from x-altair-idempotency (own issue) covers the round-trip
Notes
Naming chosen to match the rest of the spec: idempotency: is the spec block, x-altair-idempotency is the OpenAPI extension that carries it across a round-trip (#163 already reserved the key + schema).
The middleware-in-pipeline ordering matters: idempotency BEFORE input validation. A cached replay should not re-run validation; otherwise an idempotent request whose schema changes between attempts would silently 422 the replay.
Part of #171. Depends on #172 (storage) and #173 (middleware).
Goal
Add an
idempotency:block to Altair YAML specs, wire the parser / validator to accept it, and update the action emitter so a spec carrying the block produces an Action whose pipeline includesIdempotencyKeyMiddlewareconfigured with the requested TTL and scope.Why
The middleware (#173) is the runtime; this issue is the spec-driven entry point. Without it, host applications have to register the middleware by hand on each action — that's the boilerplate Univeros exists to eliminate, and it's also what breaks "agent edits the spec, re-scaffolds, done."
Shape
YAML spec block
When absent: no middleware wired, no behaviour change. Backward-compatible.
AST node
Altair\Scaffold\Spec\Ast\IdempotencySpec:Add
?IdempotencySpec $idempotency = nulltoAltair\Scaffold\Spec\Ast\Spec.Parser / validator changes
IdempotencySpec, attaches it toSpec.ttlmatches^[0-9]+(ms|s|m|h|d)$.scopeis non-empty.modeisoptionalorrequired.Action emitter changes
When
$spec->idempotencyis non-null, the generated Action constructor receives anIdempotencyKeyMiddlewareand inserts it into the pipeline ahead of the input-validation middleware (so a replayed response short-circuits before we re-validate the body — which would be redundant work).The middleware is constructed from the host application's
IdempotencyConfiguration(DI-bound), parametrised by the spec's TTL / scope / mode.Acceptance criteria
IdempotencySpecAST node lands;Specgrows?IdempotencySpec $idempotency = nullwith a backwards-compatible defaultParserreads the block;Validatorrejects malformed values with actionable messagesIdempotencyKeyMiddlewareinto the pipeline when the spec carries the blockParser+Validatorkeeps the new field working (PersistenceInferrer-style integration test)Out of scope
IdempotencyConfigurationfor the host container — lands here as a stub; the production-ready binding (InMemoryStorefor dev,RedisStorefor prod via env) belongs inuniveros/idempotency/Configuration/IdempotencyConfiguration--idempotencyflag forbin/altair openapi:import— could be added when there's a use case; the spec block reaching fromx-altair-idempotency(own issue) covers the round-tripNotes
Naming chosen to match the rest of the spec:
idempotency:is the spec block,x-altair-idempotencyis the OpenAPI extension that carries it across a round-trip (#163 already reserved the key + schema).The middleware-in-pipeline ordering matters: idempotency BEFORE input validation. A cached replay should not re-run validation; otherwise an idempotent request whose schema changes between attempts would silently 422 the replay.