Skip to content

ADR-077 — A creation carries its own key

Status: accepted Date: 2026-09-23 Continues ADR-076, which settled what a change is; this settles what a repeat is.

Context

Of the thirty-one mutating operations, twenty-nine are already safe to repeat. Nine carry If-Match: a replay sends a version the trip has moved past and takes a 412. The rest either write nothing, converge, or are refused by a business rule.

Two are not. POST /trips and POST /trips/{id}/duplicate mint the identifier server-side, so there is nothing for If-Match to pin, and the trip table has no unique constraint a second identical creation would violate. A client that lost the response — a dropped connection, a timeout, a killed app, a double tap — and asked again received a second complete trip: new row, new pipeline, new Redis keys. The only thing in the way was a per-user rate limiter, which caps the rate of duplicates rather than preventing any.

That is not a coincidence about those two endpoints. It is what a creation is: the operation where the client cannot name what it is creating.

Decision

Idempotency-Key on the operations that create a trip, and nowhere else.

An opaque client-minted string, 16 to 255 characters. The server never interprets it, only compares it. Replaying it returns the trip the first call created rather than making another.

The key identifies the intent, never the payload

It is not a hash of the body, and this is the load-bearing distinction. Two deliberate imports of the same route must both succeed — someone who wants two trips from one GPX file is asking for two trips. A body-derived key would silently collapse them into one and call it a feature.

The body fingerprint exists separately, as request_digest, and does one thing: the same key arriving with a different body is a client contradicting itself, and is answered 409. It never identifies the request.

Required, not optional

An optional header nobody sends is a guarantee that exists on paper. Nothing is deployed, so the break costs nothing today and never will be this cheap again.

Missing or malformed answers 400, not 428. "Precondition required" means make your request conditional, and a client that receives it will go looking for an If-Match it cannot supply on a creation. Two required headers, two distinct refusals.

Postgres, not the cache

The five cache pools fall back to an array adapter under test, one per process, so a cache-backed guarantee could not be tested at all — and a guarantee nothing can prove is not one.

The unique index on (user_id, operation, idempotency_key) is the mechanism, not the lookup that precedes it. Concurrent calls carrying the same key both insert; one loses; the loser reads the winner's row and answers with that trip. This is TripShareCreateProcessor's pattern without the window its pre-check leaves open — a pattern whose own constraint, incidentally, existed only in the SQL baseline and is now declared on the entity that depends on it.

The row is inserted through the connection, not persisted through the ORM. UnitOfWork::commit() closes the entity manager in its finally before a unique violation propagates, so a flush cannot survive the one event this design is built around: the recovery read, and everything the processor still has to do to answer, would throw EntityManagerClosed and the loser would get a 500 instead of the winner's trip. The connection is rolled back, never closed. IdempotencyKey therefore declares the table and hydrates rows without ever building one.

The loser keeps the trip it committed before the race was decided. It is unreachable — the client holds the winner's identifier — and its pipeline is never dispatched, which is the same cost the crash window below already charges, not a new one.

Scope is (user, operation, key). Not global, because the key belongs to the client that minted it and two clients picking the same string must not be handed each other's trip. Not per trip, because a creation has no trip yet — which is the entire reason this exists.

The replayed answer is rebuilt, not remembered

Storing the serialised response would mean versioning that serialisation for as long as the keys live. It is rebuilt from the recorded identifier instead, which costs one read and reports the computation statuses as they stand now rather than a snapshot of the instant of creation — strictly more useful to a client that is retrying precisely because it does not know what happened.

Retention is 24 hours, purged by app:idempotency:purge. Past that window a repeat is no longer a retry, it is a new intent.

The trip and its key are committed by two flushes, not one, and that is a deliberate trade. Wrapping both would close the window where a process dies between them — and open a worse one: recovering from a concurrent insert means catching the unique violation, and a failed flush inside a wrapping transaction closes the entity manager and marks it rollback-only. Atomicity there would cost the concurrency guarantee the index exists for. The order therefore fails in the safe direction: the trip commits first, so a crash costs a duplicate — the behaviour every creation had before this — where the reverse order would hand a retry an identifier for a trip that was never committed.

Applied in the processors, not a decorator

The lock and the precondition are write-chain decorators, and this was meant to be the third. It cannot be: no position in that chain is handed the trip that was created — even the innermost receives an already-serialised Response. A rule that only works from one position in a chain is a rule that breaks the day someone inserts another decorator, so it is called explicitly from the two creating processors, and IdempotencyCoverageTest binds the flag to them in both directions.

Consequences

The rule that makes it worth anything is client-side: one key per user intent, sent again unchanged on every retry of it. Minted per HTTP attempt it protects nothing. Both clients generate it at the call site for that reason, so a caller that owns a retry loop can hold on to its own and pass it back in.

POST /trips/gpx-upload is left out. It is a plain Symfony route, outside the operation metadata this is declared through, and it remains a third door to creating a trip — a replayable one only once it goes through API Platform.

This is also the first piece of the API built for a consumer that does not exist yet. An MCP agent (ADR-064) is episodic by nature: it calls, disappears, and comes back in another conversation with no memory of whether its last call landed. The header is a draft standard (draft-ietf-httpapi-idempotency-key-header), so such a client sends it without being taught.