From a4dce323a6e24e0dc6b28cb6a93d77a13fbafa1f Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Wed, 27 May 2026 11:49:24 -0700 Subject: [PATCH 1/2] fix: specify idempotency payload-mismatch contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UCP's idempotency requirements table previously said only "On duplicate: Return cached response, do not re-execute", with no qualifier for whether the payload of the duplicate-key request matches the original. The overview.md Protocol Errors table correctly mapped HTTP 409 to "Idempotency key reused with different payload", but the normative contract for detecting and rejecting mismatched payloads was missing. Industry convention is to reject same-key + mismatched-payload as a conflict error, not silently return the stale cached response. This change: - Splits the "On duplicate" requirements-table row in signatures.md into matching-payload (cached response) and mismatched-payload (409 reject). - Adds a "Payload Matching" paragraph that leans on Content-Digest (RFC 9530, already mandated for any signed request with a body) as the natural comparison primitive, with a fallback for unsigned requests. - Tightens parallel idempotency text in checkout-rest.md / cart-rest.md ("different parameters" → "mismatched body") with back-links to the normative contract in signatures.md. - Reframes the split-payments idempotency note (added during PR #409 review) as descriptive context rather than a fresh MUST. The note now positions retry semantics as derived from the complete-submission model, links idempotency-key generation to the base contract, and matches the spec's existing language ("split-payments state does not persist between requests"). --- docs/specification/cart-rest.md | 6 ++++-- docs/specification/checkout-rest.md | 6 ++++-- docs/specification/signatures.md | 15 ++++++++++++++- docs/specification/split-payments.md | 8 ++++++-- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/docs/specification/cart-rest.md b/docs/specification/cart-rest.md index 38dd6956d..20ed4c7f3 100644 --- a/docs/specification/cart-rest.md +++ b/docs/specification/cart-rest.md @@ -483,8 +483,10 @@ operations unless otherwise noted. * **Idempotency-Key**: Operations that modify state **SHOULD** support idempotency. When provided, the server **MUST**: 1. Store the key with the operation result for at least 24 hours. - 2. Return the cached result for duplicate keys. - 3. Return `409 Conflict` if the key is reused with different parameters. + 2. Return the cached result for duplicate keys whose request body matches the original. + 3. Return `409 Conflict` if the key is reused with a mismatched body. + See [Message Signatures — Idempotency Key Requirements](signatures.md#replay-protection) + for the full payload-matching contract. ## Protocol Mechanics diff --git a/docs/specification/checkout-rest.md b/docs/specification/checkout-rest.md index 3ef19d30b..cb2e748d0 100644 --- a/docs/specification/checkout-rest.md +++ b/docs/specification/checkout-rest.md @@ -1256,8 +1256,10 @@ operations unless otherwise noted. * **Idempotency-Key**: Operations that modify state **SHOULD** support idempotency. When provided, the server **MUST**: 1. Store the key with the operation result for at least 24 hours. - 2. Return the cached result for duplicate keys. - 3. Return `409 Conflict` if the key is reused with different parameters. + 2. Return the cached result for duplicate keys whose request body matches the original. + 3. Return `409 Conflict` if the key is reused with a mismatched body. + See [Message Signatures — Idempotency Key Requirements](signatures.md#replay-protection) + for the full payload-matching contract. ## Protocol Mechanics diff --git a/docs/specification/signatures.md b/docs/specification/signatures.md index fdb45f135..5bb411d16 100644 --- a/docs/specification/signatures.md +++ b/docs/specification/signatures.md @@ -483,9 +483,22 @@ Signature: sig1=:MEUCIQD...: | **Entropy** | Minimum 128 bits (e.g., UUID v4, 22+ char alphanumeric) | | **Uniqueness** | Per-client, per-operation type | | **Server storage** | Minimum 24 hours, recommended 48 hours | -| **On duplicate** | Return cached response, do not re-execute | +| **On duplicate (matching payload)** | Return cached response, do not re-execute | +| **On duplicate (mismatched payload)** | Reject with `409 Conflict` (REST) / `-32000` (MCP); do not execute | | **On storage failure** | Fail closed (reject request with 503) | +**Payload Matching:** Businesses **MUST** detect whether the payload of a +duplicate-key request matches the payload of the original. Because +`Content-Digest` (RFC 9530) is a SHA-256 hash over the raw body bytes and +is already required on any signed request with a body, businesses +**SHOULD** persist the `Content-Digest` alongside the idempotency key and +compare on duplicate-key requests. For unsigned requests, businesses +**MUST** use an equivalent deterministic payload-comparison mechanism +(e.g., hashing the raw body). Platforms therefore **MUST** generate a +fresh idempotency key whenever they modify the request payload — +including retries with modified payment instruments, updated shipping +addresses, swapped line items, or any other change to the request body. + **Note:** The RFC 9421 `created` parameter is **OPTIONAL**. UCP handles replay protection at the business layer through idempotency keys, not signature timestamps. Key rotation (removing compromised keys from `signing_keys`) provides the mechanism diff --git a/docs/specification/split-payments.md b/docs/specification/split-payments.md index 164ae785d..86e948139 100644 --- a/docs/specification/split-payments.md +++ b/docs/specification/split-payments.md @@ -173,8 +173,12 @@ MUST process each request as a fresh, full submission, without reference to prior requests or responses. > [!NOTE] -> **Idempotency & Correlation during Recovery Retries:** -> When retrying a partially authorized split payment with a modified set of payment instruments (e.g., replacing a declined card), the request payload changes and therefore **MUST** use a new idempotency key. Downstream systems cannot rely on the idempotency key to correlate the retry with previous partial authorizations; they must use the stable Checkout Session `id` to correlate and reconcile these distinct attempts. +> Each split payments submission is processed as a complete, +> self-contained request: a modified instrument set is a new submission, +> requiring a fresh [idempotency key](signatures.md#replay-protection). +> Split-payments state — including authorizations — does not persist +> between submissions; the unwind-on-failure requirement above +> enforces this. **Per-instrument reporting:** when a split is incomplete or has failed, the business MUST emit a `payment_failed` error for each failed From 99973898bc112df4e4fcc32e550d2e9118957cf7 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Fri, 29 May 2026 09:03:35 -0700 Subject: [PATCH 2/2] reframe payload matching around RFC 9530 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payload identity is defined as one mechanism: SHA-256 over raw body bytes — the same digest RFC 9530 mandates as Content-Digest. Signed vs unsigned is a delivery distinction, not a mechanism distinction: signed requests have the digest supplied in the Content-Digest header; unsigned requests have businesses compute the same digest themselves. The Intermediary Warning above already prohibits intermediaries from re-serializing signed bodies, which guarantees byte fidelity end-to-end. Whitespace/key-order fragility — a real concern when hashing raw bytes — is therefore addressed by the spec's existing intermediary rules for signed requests. For unsigned requests, byte stability depends on intermediary behavior the protocol cannot constrain; that is a consequence of opting out of signing, not a defect in the comparison mechanism. This keeps the spec aligned with its own design choice that JSON canonicalization is not required, and avoids splitting the contract into separate signed- and unsigned-path treatments with different normative weights. --- docs/specification/signatures.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/specification/signatures.md b/docs/specification/signatures.md index 5bb411d16..5b02d55dd 100644 --- a/docs/specification/signatures.md +++ b/docs/specification/signatures.md @@ -487,17 +487,18 @@ Signature: sig1=:MEUCIQD...: | **On duplicate (mismatched payload)** | Reject with `409 Conflict` (REST) / `-32000` (MCP); do not execute | | **On storage failure** | Fail closed (reject request with 503) | -**Payload Matching:** Businesses **MUST** detect whether the payload of a -duplicate-key request matches the payload of the original. Because -`Content-Digest` (RFC 9530) is a SHA-256 hash over the raw body bytes and -is already required on any signed request with a body, businesses -**SHOULD** persist the `Content-Digest` alongside the idempotency key and -compare on duplicate-key requests. For unsigned requests, businesses -**MUST** use an equivalent deterministic payload-comparison mechanism -(e.g., hashing the raw body). Platforms therefore **MUST** generate a -fresh idempotency key whenever they modify the request payload — -including retries with modified payment instruments, updated shipping -addresses, swapped line items, or any other change to the request body. +**Payload Matching:** Businesses **MUST** detect whether the payload of +a duplicate-key request matches the payload of the original by +comparing the SHA-256 hash of the raw body bytes — the same digest +RFC 9530 mandates as `Content-Digest`. When signing is in use, this +value is supplied in the `Content-Digest` header and the Intermediary +Warning above guarantees byte fidelity end-to-end; businesses persist +it alongside the idempotency key. For unsigned requests, businesses +compute the same digest from the received body bytes. Platforms +therefore **MUST** generate a fresh idempotency key whenever they +modify the request payload — including retries with modified payment +instruments, updated shipping addresses, swapped line items, or any +other change to the request body. **Note:** The RFC 9421 `created` parameter is **OPTIONAL**. UCP handles replay protection at the business layer through idempotency keys, not signature timestamps.