Skip to content

idempotency: spec block + scaffolder integration (AST + parser + validator + emitter wiring) #174

@tonydspaniard

Description

@tonydspaniard

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

  • IdempotencySpec AST node lands; Spec grows ?IdempotencySpec $idempotency = null with a backwards-compatible default
  • Parser reads the block; Validator rejects malformed values with actionable messages
  • Action emitter wires IdempotencyKeyMiddleware into the pipeline when the spec carries the block
  • A spec without the block scaffolds the same Action as before (verified by snapshot test)
  • Round-trip via existing Parser + Validator keeps the new field working (PersistenceInferrer-style integration test)
  • At least one scaffold golden snapshot is added for a spec that uses the block, asserting the wiring is correct
  • Determinism: same spec → byte-identical scaffold

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions