Commit 83377d22a84 for woocommerce

commit 83377d22a841a092e47e49e7da6c25bfc4e19930
Author: timur987 <115007291+timur987@users.noreply.github.com>
Date:   Sun Nov 8 04:48:53 2026 +0300

    Add UCP Idempotency (#69850)

    This PR is a part of the initiative of adding UCP Cart endpoints into WooCommerce core. The overall idea is to make sure the competitive requests produced by agents will have only first request applied.

    The changes are connected to write actions like creating/changing/canceling a cart.

    The PR adds a new table to store the cached responses (e.g. a new cart was created, we store that request and its response to reply with the same response without any new changes when the same idempotency key is used).

    ---------

    Co-authored-by: Timur Gogolev <timur.gogolev@automattic.com>
    Co-authored-by: sergeymitr <sergeymitr@gmail.com>

diff --git a/plugins/woocommerce/changelog/add-ucp-idempotency-key b/plugins/woocommerce/changelog/add-ucp-idempotency-key
new file mode 100644
index 00000000000..c4d5291c013
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-ucp-idempotency-key
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add Idempotency-Key support to the UCP cart endpoints so a retried agent request is not applied twice.
diff --git a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
index 23e585fc7de..d31f0cc44cb 100644
--- a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
+++ b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
@@ -81,6 +81,9 @@
 - [woocommerce_store_api_rate_limit_options](#woocommerce_store_api_rate_limit_options)
 - [woocommerce_thankyou_order_failed_text](#woocommerce_thankyou_order_failed_text)
 - [woocommerce_thankyou_order_received_title](#woocommerce_thankyou_order_received_title)
+- [woocommerce_ucp_idempotency_enabled](#woocommerce_ucp_idempotency_enabled)
+- [woocommerce_ucp_idempotency_retention_seconds](#woocommerce_ucp_idempotency_retention_seconds)
+- [woocommerce_ucp_idempotency_strict](#woocommerce_ucp_idempotency_strict)
 - [woocommerce_use_block_notices_in_classic_theme](#woocommerce_use_block_notices_in_classic_theme)
 - [woocommerce_valid_order_statuses_for_payment](#woocommerce_valid_order_statuses_for_payment)
 - [woocommerce_variation_option_name](#woocommerce_variation_option_name)
@@ -2035,6 +2038,77 @@ apply_filters( 'woocommerce_thankyou_order_received_title', string $title, \WC_O

 ---

+## woocommerce_ucp_idempotency_enabled
+
+
+Filters whether UCP idempotency handling runs at all.
+
+```php
+apply_filters( 'woocommerce_ucp_idempotency_enabled', bool $enabled )
+```
+
+### Parameters
+
+| Argument | Type | Description |
+| -------- | ---- | ----------- |
+| $enabled | bool | Whether the `Idempotency-Key` header is honoured. Default true. |
+
+### Source
+
+- [StoreApi/Utilities/Ucp/IdempotencyClaim.php](../../../../../../src/StoreApi/Utilities/Ucp/IdempotencyClaim.php)
+
+---
+
+## woocommerce_ucp_idempotency_retention_seconds
+
+
+Filters how long a completed UCP idempotency record is kept, in seconds.
+
+```php
+apply_filters( 'woocommerce_ucp_idempotency_retention_seconds', int $seconds )
+```
+
+### Description
+
+How long a retry carrying the same key is answered from storage. The [UCP spec](https://ucp.dev/2026-04-08/specification/checkout-rest/) requires at least 24 hours.
+
+### Parameters
+
+| Argument | Type | Description |
+| -------- | ---- | ----------- |
+| $seconds | int | Retention window. Default one day. |
+
+### Source
+
+- [StoreApi/Utilities/Ucp/IdempotencyStore.php](../../../../../../src/StoreApi/Utilities/Ucp/IdempotencyStore.php)
+
+---
+
+## woocommerce_ucp_idempotency_strict
+
+
+Filters whether a UCP write must carry an `Idempotency-Key`.
+
+```php
+apply_filters( 'woocommerce_ucp_idempotency_strict', bool $strict )
+```
+
+### Description
+
+True, the default, matches the UCP OpenAPI document, which declares the header required on every mutating cart operation: a missing or malformed key is refused at 400. False honours a key when one is sent and lets a keyless write through.
+
+### Parameters
+
+| Argument | Type | Description |
+| -------- | ---- | ----------- |
+| $strict | bool | Whether a key is required. Default true. |
+
+### Source
+
+- [StoreApi/Utilities/Ucp/IdempotencyClaim.php](../../../../../../src/StoreApi/Utilities/Ucp/IdempotencyClaim.php)
+
+---
+
 ## woocommerce_use_block_notices_in_classic_theme


diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index d3625299757..b39d48124e9 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -2113,6 +2113,19 @@ CREATE TABLE {$wpdb->prefix}wc_rate_limits (
   PRIMARY KEY  (rate_limit_id),
   UNIQUE KEY rate_limit_key (rate_limit_key($max_index_length))
 ) $collate;
+CREATE TABLE {$wpdb->prefix}wc_ucp_idempotency_keys (
+  idempotency_key_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  idempotency_key varchar(191) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+  request_fingerprint char(64) NOT NULL,
+  state varchar(16) NOT NULL DEFAULT 'in_progress',
+  response_status smallint(5) unsigned NULL,
+  response_body longtext NULL,
+  created_at datetime NOT NULL,
+  completed_at datetime NULL,
+  PRIMARY KEY  (idempotency_key_id),
+  UNIQUE KEY idempotency_key (idempotency_key),
+  KEY created_at (created_at)
+) $collate;
 $product_attributes_lookup_table_creation_sql
 CREATE TABLE {$wpdb->prefix}wc_product_download_directories (
 	url_id bigint(20) unsigned NOT NULL auto_increment,
@@ -2262,6 +2275,7 @@ $stock_notifications_table_schema;
 			"{$wpdb->prefix}woocommerce_tax_rates",
 			"{$wpdb->prefix}wc_reserved_stock",
 			"{$wpdb->prefix}wc_rate_limits",
+			"{$wpdb->prefix}wc_ucp_idempotency_keys",
 			"{$wpdb->prefix}wc_product_attributes_lookup",
 			"{$wpdb->prefix}wc_stock_notifications",
 			"{$wpdb->prefix}wc_stock_notificationmeta",
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 7c3650600d8..8c03b51600d 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -54,6 +54,7 @@ use Automattic\WooCommerce\Internal\Caches\ProductVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\Caches\OrdersVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\Caches\TaxRateVersionStringInvalidator;
 use Automattic\WooCommerce\Internal\CustomerEmailVerification\CustomerEmailVerification;
+use Automattic\WooCommerce\StoreApi\Utilities\Ucp\IdempotencyStore;
 use Automattic\WooCommerce\Internal\StructuredData\OpenGraph;

 /**
@@ -375,6 +376,7 @@ final class WooCommerce {
 		add_action( 'woocommerce_newly_installed', 'wc_set_hooked_blocks_version', 10 );
 		add_action( 'update_option_woocommerce_allow_tracking', array( $this, 'get_tracking_history' ), 10, 2 );
 		add_action( 'update_option_woocommerce_allow_tracking', array( $this, 'handle_tracking_setting_change' ), 10, 2 );
+		add_action( IdempotencyStore::CLEANUP_HOOK, array( IdempotencyStore::class, 'cleanup' ) );
 		add_action( 'action_scheduler_ensure_recurring_actions', array( $this, 'register_recurring_actions' ) );
 		ActionSchedulerUtil::when_ready( array( $this, 'add_recurring_action_wrappers' ) );

@@ -1826,6 +1828,8 @@ final class WooCommerce {

 		ActionSchedulerUtil::schedule_recurring( $tomorrow_3am, DAY_IN_SECONDS, 'woocommerce_cleanup_rate_limits_wrapper', array(), 'woocommerce', true );

+		ActionSchedulerUtil::schedule_recurring( $tomorrow_3am, DAY_IN_SECONDS, IdempotencyStore::CLEANUP_HOOK, array(), 'woocommerce', true );
+
 		ActionSchedulerUtil::schedule_recurring( $tomorrow_3am, DAY_IN_SECONDS, 'wc_admin_daily_wrapper', array(), 'woocommerce', true );

 		// Note: this is potentially redundant when the core package exists.
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCartRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCartRoute.php
index 6d3fed42d56..de4f807d8af 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCartRoute.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCartRoute.php
@@ -8,6 +8,7 @@ use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
 use Automattic\WooCommerce\StoreApi\SessionHandler;
 use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils;
 use Automattic\WooCommerce\StoreApi\Utilities\Ucp\CartMapper;
+use Automattic\WooCommerce\StoreApi\Utilities\Ucp\IdempotencyClaim;
 use Automattic\WooCommerce\StoreApi\Utilities\Ucp\Utils;

 /**
@@ -55,10 +56,12 @@ abstract class AbstractCartRoute extends StoreApiCartRoute {
 	}

 	/**
-	 * Refuse an id this store never signed, and restore the token slot afterwards.
+	 * Answer the request, under the `Idempotency-Key` it carries.
 	 *
-	 * The slot is per-process, so leaving it set would bind the next dispatch in the same
-	 * process — an internal `rest_do_request()`, or a test — to this cart's session.
+	 * A payload the route cannot read is refused before the key is claimed, so a 415 never
+	 * touches the store. After that the key is settled before anything else runs: a replay
+	 * must not load a session or resolve a cart id, since a retried `cancel` has to return the
+	 * stored response rather than the `not_found` its own first run left behind.
 	 *
 	 * @param \WP_REST_Request $request Request object.
 	 *
@@ -72,6 +75,31 @@ abstract class AbstractCartRoute extends StoreApiCartRoute {
 			return $this->add_response_headers( $refusal );
 		}

+		$claim = IdempotencyClaim::for_request( $request, Utils::CAPABILITY_CART );
+
+		if ( $claim instanceof \WP_REST_Response ) {
+			// The gate answers without reaching the parent, so it stamps the cache directive itself.
+			return $this->add_response_headers( $claim );
+		}
+
+		$response = $this->dispatch_ucp_request( $request );
+
+		return null === $claim ? $response : $claim->settle( $response );
+	}
+
+	/**
+	 * Refuse an id this store never signed, and restore the token slot afterwards.
+	 *
+	 * The slot is per-process, so leaving it set would bind the next dispatch in the same
+	 * process — an internal `rest_do_request()`, or a test — to this cart's session.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response
+	 */
+	private function dispatch_ucp_request( \WP_REST_Request $request ): \WP_REST_Response {
 		if ( '' !== $this->route_cart_id( $request ) && '' === $this->validated_cart_id( $request ) ) {
 			return $this->add_response_headers( $this->ucp_not_found() );
 		}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/Ucp/IdempotencyClaim.php b/plugins/woocommerce/src/StoreApi/Utilities/Ucp/IdempotencyClaim.php
new file mode 100644
index 00000000000..fe1830686f9
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Utilities/Ucp/IdempotencyClaim.php
@@ -0,0 +1,264 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Utilities\Ucp;
+
+/**
+ * The `Idempotency-Key` a UCP write runs under.
+ *
+ * `for_request()` settles a request before anything else runs, or hands back the claim the request
+ * now owns, which `settle()` stores or releases according to the response.
+ */
+final class IdempotencyClaim {
+	/**
+	 * Seconds a client is told to wait before retrying when the key store is unavailable.
+	 */
+	const RETRY_AFTER_SECONDS = 30;
+
+	/**
+	 * Shape of an acceptable key: 22 characters of this alphabet carry the 128 bits the spec
+	 * asks for, and 191 is the column width. https://ucp.dev/2026-04-08/specification/signatures/#replay-protection
+	 */
+	const KEY_PATTERN = '/^[A-Za-z0-9_-]{22,191}\z/';
+
+	/**
+	 * Key this request owns.
+	 *
+	 * @var string
+	 */
+	private $key;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param string $key Key this request owns.
+	 */
+	private function __construct( string $key ) {
+		$this->key = $key;
+	}
+
+	/**
+	 * Settle the `Idempotency-Key` a request carries, before the request runs.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 * @param string           $capability UCP capability the route serves, for a refusal's envelope.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response|self|null A response settling the request on its own, the
+	 *         claim the request now owns, or null when it runs under no key.
+	 */
+	public static function for_request( \WP_REST_Request $request, string $capability ) {
+		if ( in_array( strtoupper( (string) $request->get_method() ), array( 'GET', 'HEAD' ), true ) ) {
+			return null;
+		}
+
+		/**
+		 * Filters whether UCP idempotency handling runs at all.
+		 *
+		 * @since 11.4.0
+		 *
+		 * @param bool $enabled Whether the `Idempotency-Key` header is honoured. Default true.
+		 */
+		if ( ! apply_filters( 'woocommerce_ucp_idempotency_enabled', true ) ) {
+			return null;
+		}
+
+		/**
+		 * Filters whether a UCP write must carry an `Idempotency-Key`.
+		 *
+		 * True, the default, matches the UCP specs, and returns 400 for a missing or malformed key.
+		 * False honors a key when one is sent and lets a keyless write through.
+		 *
+		 * @since 11.4.0
+		 *
+		 * @param bool $strict Whether a key is required. Default true.
+		 */
+		$strict = (bool) apply_filters( 'woocommerce_ucp_idempotency_strict', true );
+
+		// Case counts: a 22-character key only carries 128 bits when it is not folded.
+		$key = trim( (string) $request->get_header( 'Idempotency-Key' ) );
+
+		if ( 1 !== preg_match( self::KEY_PATTERN, $key ) ) {
+			if ( ! $strict ) {
+				// Not stored when malformed: a key longer than the column would be truncated and
+				// could then collide with an unrelated request's.
+				return null;
+			}
+
+			return Utils::error_response(
+				$capability,
+				'' === $key ? 'IDEMPOTENCY_KEY_REQUIRED' : 'IDEMPOTENCY_KEY_INVALID',
+				'' === $key
+					? __( 'The Idempotency-Key header is required.', 'woocommerce' )
+					: __( 'The Idempotency-Key header must be 22 to 191 letters, digits, hyphens or underscores.', 'woocommerce' ),
+				'recoverable',
+				400
+			);
+		}
+
+		$fingerprint = RequestFingerprint::compute( $request );
+
+		$claimed = IdempotencyStore::try_begin( $key, $fingerprint );
+
+		if ( true === $claimed ) {
+			return new self( $key );
+		}
+
+		// A key that cannot be stored fails closed, per https://ucp.dev/2026-04-08/specification/signatures/#replay-protection.
+		if ( null === $claimed ) {
+			return self::store_unavailable( $capability );
+		}
+
+		$existing = IdempotencyStore::find( $key );
+
+		// The row went away between the two statements, which a refused write does as it releases
+		// its key. It is free again, so the claim is retried rather than reported as held.
+		if ( null === $existing ) {
+			return self::claim_again( $key, $fingerprint, $capability );
+		}
+
+		// Past retention the record no longer answers for its key, whatever it was used for, so
+		// this request takes the row over. The sweep that would have dropped it runs daily.
+		if ( IdempotencyStore::has_expired( $existing ) ) {
+			if ( IdempotencyStore::try_reclaim_expired( $key, $fingerprint ) ) {
+				return new self( $key );
+			}
+
+			// Zero rows affected is either a competing retry that won, or the sweep deleting
+			// the row first. Claiming once more tells them apart.
+			return self::claim_again( $key, $fingerprint, $capability );
+		}
+
+		if ( (string) $existing['request_fingerprint'] !== $fingerprint ) {
+			return Utils::error_response(
+				$capability,
+				'IDEMPOTENCY_KEY_REUSED',
+				__( 'The Idempotency-Key was already used for a different request.', 'woocommerce' ),
+				'unrecoverable',
+				409
+			);
+		}
+
+		if ( IdempotencyStore::STATE_COMPLETED !== (string) $existing['state'] ) {
+			// A claim past the orphan window belonged to a process that died, so it may be taken
+			// over rather than waiting for the sweep. Only one of several callers wins.
+			if ( IdempotencyStore::try_take_over_stale( $key, IdempotencyStore::STALE_CLAIM_SECONDS ) ) {
+				return new self( $key );
+			}
+
+			// Same race as above: the sweep may have removed the orphan between the read and the update.
+			return self::claim_again( $key, $fingerprint, $capability );
+		}
+
+		return self::replay( $existing );
+	}
+
+	/**
+	 * Claim a key that was just found free, once; a loss here means another retry holds it.
+	 *
+	 * @param string $key Idempotency key.
+	 * @param string $fingerprint Request fingerprint.
+	 * @param string $capability UCP capability the route serves.
+	 * @return \WP_REST_Response|self
+	 */
+	private static function claim_again( string $key, string $fingerprint, string $capability ) {
+		$claimed = IdempotencyStore::try_begin( $key, $fingerprint );
+
+		if ( true === $claimed ) {
+			return new self( $key );
+		}
+
+		return null === $claimed ? self::store_unavailable( $capability ) : self::in_flight( $capability );
+	}
+
+	/**
+	 * Record the outcome of the request this claim belongs to, and hand the response back.
+	 *
+	 * @param \WP_REST_Response $response Response the request produced.
+	 * @return \WP_REST_Response The same response.
+	 */
+	public function settle( \WP_REST_Response $response ): \WP_REST_Response {
+		if ( self::is_replayable( $response ) ) {
+			IdempotencyStore::finalize( $this->key, $response->get_status(), $response->get_data() );
+
+			return $response;
+		}
+
+		// A failed request is released.
+		IdempotencyStore::delete( $this->key );
+
+		return $response;
+	}
+
+	/**
+	 * Whether a response may be stored for replay.
+	 *
+	 * @param \WP_REST_Response $response Response to inspect.
+	 * @return bool
+	 */
+	private static function is_replayable( \WP_REST_Response $response ): bool {
+		$status = $response->get_status();
+
+		if ( $status < 200 || $status >= 300 ) {
+			return false;
+		}
+
+		$data = $response->get_data();
+
+		return ! is_array( $data ) || ! isset( $data['ucp']['status'] ) || 'error' !== $data['ucp']['status'];
+	}
+
+	/**
+	 * Replay a stored response.
+	 *
+	 * @param array<string, mixed> $existing Stored record.
+	 * @return \WP_REST_Response
+	 */
+	private static function replay( array $existing ): \WP_REST_Response {
+		$response = new \WP_REST_Response(
+			json_decode( (string) $existing['response_body'], true ),
+			isset( $existing['response_status'] ) ? (int) $existing['response_status'] : 200
+		);
+
+		$response->header( 'Idempotency-Replayed', 'true' );
+
+		return $response;
+	}
+
+	/**
+	 * Refuse a key another request is still working on.
+	 *
+	 * @param string $capability UCP capability the route serves.
+	 * @return \WP_REST_Response
+	 */
+	private static function in_flight( string $capability ): \WP_REST_Response {
+		return Utils::error_response(
+			$capability,
+			'IDEMPOTENCY_CONFLICT',
+			__( 'A request with this Idempotency-Key is still being processed.', 'woocommerce' ),
+			'recoverable',
+			409
+		);
+	}
+
+	/**
+	 * Refuse a write whose key could not be recorded, rather than run it undeduplicated.
+	 *
+	 * @param string $capability UCP capability the route serves.
+	 * @return \WP_REST_Response
+	 */
+	private static function store_unavailable( string $capability ): \WP_REST_Response {
+		$response = Utils::error_response(
+			$capability,
+			'IDEMPOTENCY_STORE_UNAVAILABLE',
+			__( 'The Idempotency-Key could not be recorded. Retry the request later.', 'woocommerce' ),
+			'recoverable',
+			503
+		);
+
+		$response->header( 'Retry-After', (string) self::RETRY_AFTER_SECONDS );
+
+		return $response;
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/Ucp/IdempotencyStore.php b/plugins/woocommerce/src/StoreApi/Utilities/Ucp/IdempotencyStore.php
new file mode 100644
index 00000000000..e53a2e3506c
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Utilities/Ucp/IdempotencyStore.php
@@ -0,0 +1,281 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Utilities\Ucp;
+
+/**
+ * Persists UCP `Idempotency-Key` records.
+ */
+class IdempotencyStore {
+	/**
+	 * A request owning the key is still running.
+	 */
+	const STATE_IN_PROGRESS = 'in_progress';
+
+	/**
+	 * The response is stored and may be replayed.
+	 */
+	const STATE_COMPLETED = 'completed';
+
+	/**
+	 * Action Scheduler hook purging expired records.
+	 */
+	const CLEANUP_HOOK = 'woocommerce_cleanup_ucp_idempotency_keys';
+
+	/**
+	 * How long a claim may sit unanswered before another request may take it over.
+	 *
+	 * Bounds a claim orphaned by a process that died mid-request, which would otherwise answer
+	 * `409` until the next sweep. Far above any PHP execution limit, so live work is never taken.
+	 */
+	const STALE_CLAIM_SECONDS = 300;
+
+	/**
+	 * Rows the cleanup sweep deletes per statement.
+	 */
+	const CLEANUP_BATCH_SIZE = 100;
+
+	/**
+	 * Claim a key for processing.
+	 *
+	 * Three outcomes: an `INSERT IGNORE` that inserts nothing cannot say by itself whether the
+	 * key was already held or the write never landed, and a caller has to tell those apart.
+	 *
+	 * @param string $key Idempotency key.
+	 * @param string $fingerprint Request fingerprint.
+	 * @return bool|null True when this caller owns processing, false when the key is already
+	 *         held, null when the record could not be written at all.
+	 */
+	public static function try_begin( string $key, string $fingerprint ) {
+		global $wpdb;
+
+		$written = self::write(
+			$wpdb->prepare(
+				"INSERT IGNORE INTO {$wpdb->prefix}wc_ucp_idempotency_keys
+					(idempotency_key, request_fingerprint, state, created_at)
+					VALUES (%s, %s, %s, %s)",
+				$key,
+				$fingerprint,
+				self::STATE_IN_PROGRESS,
+				gmdate( 'Y-m-d H:i:s' )
+			)
+		);
+
+		if ( ! $written ) {
+			return null;
+		}
+
+		return 1 === (int) $wpdb->rows_affected;
+	}
+
+	/**
+	 * Run a write against the record table, reporting a failure instead of swallowing it.
+	 *
+	 * @param string $sql Prepared statement to run.
+	 * @return bool False when the query itself failed.
+	 */
+	private static function write( string $sql ): bool {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared by the caller; these writes must reach the database.
+		if ( false !== $wpdb->query( $sql ) ) {
+			return true;
+		}
+
+		wc_get_logger()->error(
+			'UCP idempotency record could not be written: ' . $wpdb->last_error,
+			array( 'source' => 'ucp-idempotency' )
+		);
+
+		return false;
+	}
+
+	/**
+	 * Take over a claim whose owner has gone away.
+	 *
+	 * @param string $key Idempotency key.
+	 * @param int    $stale_after_seconds Age past which an unanswered claim may be taken over.
+	 * @return bool True when this caller now owns the claim.
+	 */
+	public static function try_take_over_stale( string $key, int $stale_after_seconds ): bool {
+		global $wpdb;
+
+		$taken = self::write(
+			$wpdb->prepare(
+				"UPDATE {$wpdb->prefix}wc_ucp_idempotency_keys
+					SET created_at = %s
+					WHERE idempotency_key = %s AND state = %s AND created_at < %s",
+				gmdate( 'Y-m-d H:i:s' ),
+				$key,
+				self::STATE_IN_PROGRESS,
+				gmdate( 'Y-m-d H:i:s', time() - max( 1, $stale_after_seconds ) )
+			)
+		);
+
+		return $taken && 1 === (int) $wpdb->rows_affected;
+	}
+
+	/**
+	 * Reclaim a completed record past retention for a new request.
+	 *
+	 * @param string $key Idempotency key.
+	 * @param string $fingerprint Fingerprint of the request now claiming the key.
+	 * @return bool True when this caller now owns the claim.
+	 */
+	public static function try_reclaim_expired( string $key, string $fingerprint ): bool {
+		global $wpdb;
+
+		$taken = self::write(
+			$wpdb->prepare(
+				"UPDATE {$wpdb->prefix}wc_ucp_idempotency_keys
+					SET request_fingerprint = %s, state = %s, created_at = %s,
+						response_status = NULL, response_body = NULL, completed_at = NULL
+					WHERE idempotency_key = %s AND state = %s AND completed_at < %s",
+				$fingerprint,
+				self::STATE_IN_PROGRESS,
+				gmdate( 'Y-m-d H:i:s' ),
+				$key,
+				self::STATE_COMPLETED,
+				gmdate( 'Y-m-d H:i:s', time() - self::retention_seconds() )
+			)
+		);
+
+		return $taken && 1 === (int) $wpdb->rows_affected;
+	}
+
+	/**
+	 * Read a record back.
+	 *
+	 * @param string $key Idempotency key.
+	 * @return array<string, mixed>|null The row, or null when the key is unknown.
+	 */
+	public static function find( string $key ) {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reading a claim another request may have just written; a cache would answer stale.
+		$row = $wpdb->get_row(
+			$wpdb->prepare(
+				"SELECT request_fingerprint, state, response_status, response_body, completed_at
+					FROM {$wpdb->prefix}wc_ucp_idempotency_keys
+					WHERE idempotency_key = %s",
+				$key
+			),
+			ARRAY_A
+		);
+
+		return is_array( $row ) ? $row : null;
+	}
+
+	/**
+	 * Store the response a completed request produced.
+	 *
+	 * @param string $key Idempotency key.
+	 * @param int    $status HTTP status of the response.
+	 * @param mixed  $data Response body, JSON encoded on the way in.
+	 * @return void
+	 */
+	public static function finalize( string $key, int $status, $data ): void {
+		global $wpdb;
+
+		self::write(
+			$wpdb->prepare(
+				"UPDATE {$wpdb->prefix}wc_ucp_idempotency_keys
+					SET state = %s, response_status = %d, response_body = %s, completed_at = %s
+					WHERE idempotency_key = %s",
+				self::STATE_COMPLETED,
+				$status,
+				(string) wp_json_encode( $data ),
+				gmdate( 'Y-m-d H:i:s' ),
+				$key
+			)
+		);
+	}
+
+	/**
+	 * Drop a record, releasing the key.
+	 *
+	 * @param string $key Idempotency key.
+	 * @return void
+	 */
+	public static function delete( string $key ): void {
+		global $wpdb;
+
+		self::write(
+			$wpdb->prepare(
+				"DELETE FROM {$wpdb->prefix}wc_ucp_idempotency_keys WHERE idempotency_key = %s",
+				$key
+			)
+		);
+	}
+
+	/**
+	 * How long a completed record answers a retry, in seconds.
+	 *
+	 * @return int
+	 */
+	public static function retention_seconds(): int {
+		/**
+		 * Filters how long a completed UCP idempotency record is kept, in seconds.
+		 * The UCP spec requires at least 24 hours.
+		 *
+		 * @since 11.4.0
+		 *
+		 * @param int $seconds Retention window. Default one day.
+		 */
+		$retention = (int) apply_filters( 'woocommerce_ucp_idempotency_retention_seconds', DAY_IN_SECONDS );
+
+		return $retention > 0 ? $retention : DAY_IN_SECONDS;
+	}
+
+	/**
+	 * Whether a completed record is past retention and no longer answers for its key.
+	 *
+	 * @param array<string, mixed> $record Row as `find()` returns it.
+	 * @return bool
+	 */
+	public static function has_expired( array $record ): bool {
+		if ( self::STATE_COMPLETED !== (string) ( $record['state'] ?? '' ) || empty( $record['completed_at'] ) ) {
+			return false;
+		}
+
+		return strtotime( (string) $record['completed_at'] . ' UTC' ) < time() - self::retention_seconds();
+	}
+
+	/**
+	 * Delete expired records.
+	 *
+	 * @return void
+	 */
+	public static function cleanup(): void {
+		global $wpdb;
+
+		$completed_cutoff = gmdate( 'Y-m-d H:i:s', time() - self::retention_seconds() );
+		$claim_cutoff     = gmdate( 'Y-m-d H:i:s', time() - self::STALE_CLAIM_SECONDS );
+
+		// Deleted in batches, after WC_Session_Handler::cleanup_sessions(): a row here holds a
+		// whole stored response, so a single unbounded DELETE over a busy day's records would
+		// hold locks and generate replication lag far longer than a sweep needs to.
+		do {
+			$written = self::write(
+				$wpdb->prepare(
+					"DELETE FROM {$wpdb->prefix}wc_ucp_idempotency_keys
+						WHERE ( state = %s AND completed_at < %s )
+							OR ( state = %s AND created_at < %s )
+						ORDER BY created_at
+						LIMIT %d",
+					self::STATE_COMPLETED,
+					$completed_cutoff,
+					self::STATE_IN_PROGRESS,
+					$claim_cutoff,
+					self::CLEANUP_BATCH_SIZE
+				)
+			);
+
+			if ( ! $written ) {
+				return;
+			}
+
+			$deleted = (int) $wpdb->rows_affected;
+		} while ( self::CLEANUP_BATCH_SIZE === $deleted );
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/Ucp/RequestFingerprint.php b/plugins/woocommerce/src/StoreApi/Utilities/Ucp/RequestFingerprint.php
new file mode 100644
index 00000000000..8c781e3c472
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Utilities/Ucp/RequestFingerprint.php
@@ -0,0 +1,29 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Utilities\Ucp;
+
+/**
+ * Fingerprints a UCP request, binding an `Idempotency-Key` to the payload it was first used with.
+ *
+ * Covers the method, the resolved route — which carries the cart id — and the body. The body
+ * enters the hash as the raw bytes `Content-Digest` covers, per
+ * https://ucp.dev/2026-04-08/specification/signatures/#replay-protection.
+ */
+class RequestFingerprint {
+	/**
+	 * Fingerprint a request.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return string 64 character hex digest.
+	 */
+	public static function compute( \WP_REST_Request $request ): string {
+		return hash(
+			'sha256',
+			strtoupper( (string) $request->get_method() ) . "\n" . (string) $request->get_route() . "\n" . (string) $request->get_body()
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCart.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCart.php
index 8e8adb657f9..f1acb7d29a6 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCart.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCart.php
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils;
 /**
  * Tests for the /wc/ucp/v1/carts endpoints.
  */
-class UcpCart extends ControllerTestCase {
+class UcpCart extends UcpCartTestCase {
 	/**
 	 * Published product IDs shared by the class, oldest first.
 	 *
@@ -39,11 +39,6 @@ class UcpCart extends ControllerTestCase {
 		'variation' => 0,
 	);

-	/**
-	 * Option behind the `ucp` feature.
-	 */
-	private const FEATURE_OPTION = 'woocommerce_feature_ucp_enabled';
-
 	/**
 	 * Create immutable fixtures before route registration starts.
 	 */
@@ -118,71 +113,14 @@ class UcpCart extends ControllerTestCase {
 	}

 	/**
-	 * The session, customer and cart the harness set up, so `simulate_new_request()`
-	 * can swap them for a test and tearDown can put them back.
-	 *
-	 * @var array{session: \WC_Session|null, customer: \WC_Customer|null, cart: \WC_Cart|null, loaded: int|null, totals: int|null}
-	 */
-	private $wc_globals_backup = array();
-
-	/**
-	 * Enable the UCP routes and rebuild the REST server so they register.
+	 * Start every test from a fresh request, as a UCP client would.
 	 */
 	protected function setUp(): void {
 		parent::setUp();

-		// Inside the per-test transaction, so the rollback turns the flag off again;
-		// the routes register on the rebuilt server.
-		update_option( self::FEATURE_OPTION, 'yes' );
-		$this->initialize_store_api_server();
-
-		// A per-test DB transaction rolls back rows but not the WC() singletons, so a test
-		// that swaps them would leak into the next one. Keep the originals to restore.
-		$this->wc_globals_backup = array(
-			'session'  => WC()->session,
-			'customer' => WC()->customer,
-			'cart'     => WC()->cart,
-			'loaded'   => $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] ?? null,
-			'totals'   => $GLOBALS['wp_actions']['woocommerce_after_calculate_totals'] ?? null,
-		);
-
 		$this->simulate_new_request();
 	}

-	/**
-	 * Restore the cancellation marker and the WC() singletons.
-	 */
-	protected function tearDown(): void {
-		unset( $_SERVER['HTTP_CART_TOKEN'] );
-
-		if ( WC()->session ) {
-			WC()->session->set( AbstractCartRoute::CANCELLED_SESSION_KEY, null );
-		}
-
-		if ( WC()->cart instanceof \WC_Cart ) {
-			\WC_Cart_Session::set_updates_enabled_for_cart( WC()->cart, true );
-		}
-
-		// Undo any `simulate_new_request()` swap so the next test gets the cart, session and
-		// customer the harness built, not a bare one left behind here.
-		WC()->session  = $this->wc_globals_backup['session'];
-		WC()->customer = $this->wc_globals_backup['customer'];
-		WC()->cart     = $this->wc_globals_backup['cart'];
-		$markers       = array(
-			'woocommerce_load_cart_from_session' => 'loaded',
-			'woocommerce_after_calculate_totals' => 'totals',
-		);
-		foreach ( $markers as $action => $key ) {
-			if ( null === $this->wc_globals_backup[ $key ] ) {
-				unset( $GLOBALS['wp_actions'][ $action ] );
-			} else {
-				$GLOBALS['wp_actions'][ $action ] = $this->wc_globals_backup[ $key ]; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the per-process markers the harness cleared.
-			}
-		}
-
-		parent::tearDown();
-	}
-
 	/**
 	 * Dispatch a request with a JSON body.
 	 *
@@ -194,6 +132,9 @@ class UcpCart extends ControllerTestCase {
 	private function request( string $method, string $path, array $body = array() ) {
 		$request = new \WP_REST_Request( $method, '/wc/ucp/v1' . $path );
 		$request->set_header( 'content-type', 'application/json' );
+		// A write needs a key of its own, since idempotency is enforced by default. The
+		// key is what these tests are not about: see the UcpIdempotency suite for that.
+		$request->set_header( 'Idempotency-Key', wp_generate_uuid4() );
 		$request->set_body( wp_json_encode( $body ) );

 		return rest_get_server()->dispatch( $request );
@@ -478,6 +419,7 @@ class UcpCart extends ControllerTestCase {
 	public function test_line_items_in_the_query_string_are_ignored(): void {
 		$request = new \WP_REST_Request( 'POST', '/wc/ucp/v1/carts' );
 		$request->set_header( 'content-type', 'application/json' );
+		$request->set_header( 'Idempotency-Key', wp_generate_uuid4() );
 		$request->set_query_params( array( 'line_items' => array( $this->line_item( 0, 2 ) ) ) );
 		$request->set_body( '{}' );

diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCartTestCase.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCartTestCase.php
new file mode 100644
index 00000000000..369251d908e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCartTestCase.php
@@ -0,0 +1,84 @@
+<?php
+/**
+ * Base test case for the UCP cart routes.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
+
+use Automattic\WooCommerce\StoreApi\Routes\V1\Ucp\AbstractCartRoute;
+
+/**
+ * Enables the UCP routes for each test and undoes the session binding they leave behind.
+ */
+abstract class UcpCartTestCase extends ControllerTestCase {
+	/**
+	 * Option behind the `ucp` feature.
+	 */
+	protected const FEATURE_OPTION = 'woocommerce_feature_ucp_enabled';
+
+	/**
+	 * The session, customer and cart the harness set up, so tearDown can put them back
+	 * after a route binds its own.
+	 *
+	 * @var array{session: \WC_Session|null, customer: \WC_Customer|null, cart: \WC_Cart|null, loaded: int|null, totals: int|null}
+	 */
+	private $wc_globals_backup = array();
+
+	/**
+	 * Enable the UCP routes and rebuild the REST server so they register.
+	 */
+	protected function setUp(): void {
+		parent::setUp();
+
+		// Inside the per-test transaction, so the rollback turns the flag off again;
+		// the routes register on the rebuilt server.
+		update_option( self::FEATURE_OPTION, 'yes' );
+		$this->initialize_store_api_server();
+
+		// A per-test DB transaction rolls back rows but not the WC() singletons, so a route
+		// that swaps them would leak into the next test. Keep the originals to restore.
+		$this->wc_globals_backup = array(
+			'session'  => WC()->session,
+			'customer' => WC()->customer,
+			'cart'     => WC()->cart,
+			'loaded'   => $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] ?? null,
+			'totals'   => $GLOBALS['wp_actions']['woocommerce_after_calculate_totals'] ?? null,
+		);
+	}
+
+	/**
+	 * Restore the cancellation marker and the WC() singletons.
+	 */
+	protected function tearDown(): void {
+		unset( $_SERVER['HTTP_CART_TOKEN'] );
+
+		if ( WC()->session ) {
+			WC()->session->set( AbstractCartRoute::CANCELLED_SESSION_KEY, null );
+		}
+
+		if ( WC()->cart instanceof \WC_Cart ) {
+			\WC_Cart_Session::set_updates_enabled_for_cart( WC()->cart, true );
+		}
+
+		// Undo the route's swap so the next test gets the cart, session and customer the
+		// harness built, not the token-bound one left behind here.
+		WC()->session  = $this->wc_globals_backup['session'];
+		WC()->customer = $this->wc_globals_backup['customer'];
+		WC()->cart     = $this->wc_globals_backup['cart'];
+		$markers       = array(
+			'woocommerce_load_cart_from_session' => 'loaded',
+			'woocommerce_after_calculate_totals' => 'totals',
+		);
+		foreach ( $markers as $action => $key ) {
+			if ( null === $this->wc_globals_backup[ $key ] ) {
+				unset( $GLOBALS['wp_actions'][ $action ] );
+			} else {
+				$GLOBALS['wp_actions'][ $action ] = $this->wc_globals_backup[ $key ]; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the per-process markers the harness cleared.
+			}
+		}
+
+		parent::tearDown();
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpIdempotency.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpIdempotency.php
new file mode 100644
index 00000000000..e85e3b8f297
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpIdempotency.php
@@ -0,0 +1,670 @@
+<?php
+/**
+ * UCP Idempotency-Key Tests.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
+
+use Automattic\WooCommerce\StoreApi\Utilities\Ucp\IdempotencyClaim;
+use Automattic\WooCommerce\StoreApi\Utilities\Ucp\IdempotencyStore;
+use Automattic\WooCommerce\StoreApi\Utilities\Ucp\RequestFingerprint;
+
+/**
+ * Tests for `Idempotency-Key` handling on the /wc/ucp/v1/carts writes.
+ */
+class UcpIdempotency extends UcpCartTestCase {
+	/**
+	 * Published product IDs shared by the class, oldest first.
+	 *
+	 * @var int[]
+	 */
+	private static $product_ids = array();
+
+	/**
+	 * Create the fixtures the whole class shares.
+	 */
+	public static function wpSetUpBeforeClass(): void {
+		$products = self::create_class_fixture_products(
+			array(
+				array(
+					'name'          => 'UCP Idempotency Mug',
+					'regular_price' => '19.99',
+					'price'         => '19.99',
+					'sku'           => 'ucp-idempotency-mug',
+				),
+				array(
+					'name'          => 'UCP Idempotency Poster',
+					'regular_price' => '9.99',
+					'price'         => '9.99',
+					'sku'           => 'ucp-idempotency-poster',
+				),
+			)
+		);
+
+		self::$product_ids = array_map(
+			static function ( $product ) {
+				return $product->get_id();
+			},
+			$products
+		);
+	}
+
+	/**
+	 * Remove the class fixtures.
+	 */
+	public static function wpTearDownAfterClass(): void {
+		self::delete_class_fixture_products( self::$product_ids );
+		self::$product_ids = array();
+	}
+
+	/**
+	 * Dispatch a request with a JSON body and, optionally, an idempotency key.
+	 *
+	 * @param string $method HTTP method.
+	 * @param string $path Route path under /wc/ucp/v1.
+	 * @param array  $body Request body.
+	 * @param string $key Idempotency key, or '' to send none.
+	 * @return \WP_REST_Response
+	 */
+	private function request( string $method, string $path, array $body = array(), string $key = '' ) {
+		$request = new \WP_REST_Request( $method, '/wc/ucp/v1' . $path );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body( wp_json_encode( $body ) );
+
+		if ( '' !== $key ) {
+			$request->set_header( 'Idempotency-Key', $key );
+		}
+
+		return rest_get_server()->dispatch( $request );
+	}
+
+	/**
+	 * A body creating a cart holding one of the fixture products.
+	 *
+	 * @param int $index Fixture index.
+	 * @param int $quantity Quantity.
+	 * @return array
+	 */
+	private function cart_body( int $index, int $quantity = 1 ): array {
+		return array(
+			'line_items' => array(
+				array(
+					'item'     => array( 'id' => (string) self::$product_ids[ $index ] ),
+					'quantity' => $quantity,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Read the message codes out of a UCP response.
+	 *
+	 * @param \WP_REST_Response $response Response to read.
+	 * @return string[]
+	 */
+	private function message_codes( \WP_REST_Response $response ): array {
+		$data = $response->get_data();
+
+		return array_map(
+			static function ( array $message ) {
+				return $message['code'];
+			},
+			$data['messages'] ?? array()
+		);
+	}
+
+	/**
+	 * How many records the store holds.
+	 *
+	 * @return int
+	 */
+	private function stored_record_count(): int {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Asserting on the table under test.
+		return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_ucp_idempotency_keys" );
+	}
+
+	/**
+	 * A write carrying no key is refused, and nothing is created.
+	 */
+	public function test_a_write_without_a_key_is_refused(): void {
+		$response = $this->request( 'POST', '/carts', $this->cart_body( 0 ) );
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( array( 'IDEMPOTENCY_KEY_REQUIRED' ), $this->message_codes( $response ) );
+		$this->assertSame( 0, $this->stored_record_count() );
+	}
+
+	/**
+	 * A malformed key is refused.
+	 *
+	 * @dataProvider provider_malformed_keys
+	 *
+	 * @param string $key Key to send.
+	 */
+	public function test_a_malformed_key_is_refused( string $key ): void {
+		$response = $this->request( 'POST', '/carts', $this->cart_body( 0 ), $key );
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( array( 'IDEMPOTENCY_KEY_INVALID' ), $this->message_codes( $response ) );
+		$this->assertSame( 0, $this->stored_record_count() );
+	}
+
+	/**
+	 * Keys outside the accepted shape.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public function provider_malformed_keys(): array {
+		return array(
+			'too short'          => array( str_repeat( 'a', 21 ) ),
+			'outside alphabet'   => array( str_repeat( 'a', 21 ) . '.' ),
+			'longer than column' => array( str_repeat( 'a', 192 ) ),
+		);
+	}
+
+	/**
+	 * A key need not be a UUID: any 22 characters of the alphabet are accepted and replay.
+	 */
+	public function test_a_non_uuid_key_is_accepted_and_replays(): void {
+		$key  = 'Ab3_dE-7Gh9Jk1Lm3Np5Qr';
+		$body = $this->cart_body( 0 );
+
+		$first = $this->request( 'POST', '/carts', $body, $key );
+
+		$this->assertSame( 200, $first->get_status() );
+
+		$second = $this->request( 'POST', '/carts', $body, $key );
+
+		$this->assertSame( 'true', $second->get_headers()['Idempotency-Replayed'] ?? '' );
+		$this->assertSame( 1, $this->stored_record_count() );
+	}
+
+	/**
+	 * A keyless write goes through once a site turns strict mode off.
+	 */
+	public function test_a_keyless_write_passes_when_strict_mode_is_off(): void {
+		add_filter( 'woocommerce_ucp_idempotency_strict', '__return_false' );
+
+		$response = $this->request( 'POST', '/carts', $this->cart_body( 0 ) );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 0, $this->stored_record_count() );
+	}
+
+	/**
+	 * Retrying a create replays the first response instead of making a second cart.
+	 */
+	public function test_a_retry_replays_the_stored_response(): void {
+		$key   = wp_generate_uuid4();
+		$body  = $this->cart_body( 0, 2 );
+		$first = $this->request( 'POST', '/carts', $body, $key );
+
+		$this->assertSame( 200, $first->get_status() );
+
+		$second = $this->request( 'POST', '/carts', $body, $key );
+
+		$this->assertSame( $first->get_status(), $second->get_status() );
+		$this->assertSame( 'true', $second->get_headers()['Idempotency-Replayed'] ?? '' );
+		$this->assertSame( $first->get_data(), $second->get_data() );
+		$this->assertSame( 1, $this->stored_record_count() );
+	}
+
+	/**
+	 * A completed record past retention is dropped and the write runs again.
+	 */
+	public function test_a_record_past_retention_is_not_replayed(): void {
+		global $wpdb;
+
+		$key   = wp_generate_uuid4();
+		$body  = $this->cart_body( 0 );
+		$first = $this->request( 'POST', '/carts', $body, $key );
+
+		$this->assertSame( 'success', $first->get_data()['ucp']['status'] );
+
+		// Age the record past retention, as a retry arriving before the daily sweep finds it.
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Ageing a row the test just created.
+		$wpdb->query(
+			$wpdb->prepare(
+				"UPDATE {$wpdb->prefix}wc_ucp_idempotency_keys SET completed_at = %s WHERE idempotency_key = %s",
+				gmdate( 'Y-m-d H:i:s', time() - ( IdempotencyStore::retention_seconds() + 60 ) ),
+				$key
+			)
+		);
+
+		$second = $this->request( 'POST', '/carts', $body, $key );
+
+		$this->assertSame( 200, $second->get_status() );
+		$this->assertArrayNotHasKey( 'Idempotency-Replayed', $second->get_headers() );
+		$this->assertNotSame( $first->get_data()['id'], $second->get_data()['id'], 'An expired key runs the write again.' );
+		$this->assertSame( 1, $this->stored_record_count() );
+	}
+
+	/**
+	 * Reusing a key for a different payload is a conflict.
+	 */
+	public function test_reusing_a_key_for_another_payload_is_a_conflict(): void {
+		$key = wp_generate_uuid4();
+
+		$this->assertSame( 200, $this->request( 'POST', '/carts', $this->cart_body( 0 ), $key )->get_status() );
+
+		$response = $this->request( 'POST', '/carts', $this->cart_body( 1 ), $key );
+
+		$this->assertSame( 409, $response->get_status() );
+		$this->assertSame( array( 'IDEMPOTENCY_KEY_REUSED' ), $this->message_codes( $response ) );
+	}
+
+	/**
+	 * A key another request still holds is a conflict, not a replay.
+	 */
+	public function test_a_key_still_in_flight_is_a_conflict(): void {
+		$key     = wp_generate_uuid4();
+		$body    = $this->cart_body( 0 );
+		$request = new \WP_REST_Request( 'POST', '/wc/ucp/v1/carts' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body( wp_json_encode( $body ) );
+
+		// Stand in for a request that claimed the key and has not answered yet.
+		$this->assertTrue( IdempotencyStore::try_begin( $key, RequestFingerprint::compute( $request ) ) );
+
+		$response = $this->request( 'POST', '/carts', $body, $key );
+
+		$this->assertSame( 409, $response->get_status() );
+		$this->assertSame( array( 'IDEMPOTENCY_CONFLICT' ), $this->message_codes( $response ) );
+	}
+
+	/**
+	 * A key the store cannot record fails closed, so the write never runs undeduplicated.
+	 */
+	public function test_an_unavailable_store_refuses_the_write(): void {
+		global $wpdb;
+
+		$table        = $wpdb->prefix . 'wc_ucp_idempotency_keys';
+		$query_filter = static function ( $query ) use ( $table ) {
+			return false !== strpos( $query, $table ) ? 'INVALID SQL' : $query;
+		};
+
+		$logger = $this->createMock( \WC_Logger_Interface::class );
+		$logger->expects( $this->once() )
+			->method( 'error' )
+			->with( $this->stringContains( 'UCP idempotency record could not be written' ) );
+		$logger_filter = static function () use ( $logger ) {
+			return $logger;
+		};
+
+		$previous_suppress_errors = $wpdb->suppress_errors( true );
+		add_filter( 'query', $query_filter );
+		add_filter( 'woocommerce_logging_class', $logger_filter );
+
+		try {
+			$response = $this->request( 'POST', '/carts', $this->cart_body( 0 ), wp_generate_uuid4() );
+		} finally {
+			remove_filter( 'query', $query_filter );
+			remove_filter( 'woocommerce_logging_class', $logger_filter );
+			$wpdb->suppress_errors( $previous_suppress_errors );
+		}
+
+		$this->assertSame( 503, $response->get_status() );
+		$this->assertSame( (string) IdempotencyClaim::RETRY_AFTER_SECONDS, $response->get_headers()['Retry-After'] ?? '' );
+		$this->assertSame( 'error', $response->get_data()['ucp']['status'] );
+		$this->assertSame( array( 'IDEMPOTENCY_STORE_UNAVAILABLE' ), $this->message_codes( $response ) );
+		$this->assertSame( 0, $this->stored_record_count() );
+	}
+
+	/**
+	 * A malformed write is not stored, so the same key can carry the corrected one.
+	 */
+	public function test_a_non_2xx_response_is_not_stored(): void {
+		$key      = wp_generate_uuid4();
+		$response = $this->request( 'POST', '/carts', array( 'line_items' => 'not-an-array' ), $key );
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 0, $this->stored_record_count() );
+		$this->assertNull( IdempotencyStore::find( $key ) );
+	}
+
+	/**
+	 * A refusal reported at 200 is not stored either: nothing happened, so a retry has to
+	 * be processed again rather than answered with the refusal.
+	 */
+	public function test_a_refusal_reported_at_200_is_not_stored(): void {
+		$key      = wp_generate_uuid4();
+		$body     = array(
+			'line_items' => array(
+				array(
+					'item'     => array( 'id' => '99999999' ),
+					'quantity' => 1,
+				),
+			),
+		);
+		$response = $this->request( 'POST', '/carts', $body, $key );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'error', $response->get_data()['ucp']['status'] );
+		$this->assertSame( array( 'not_found' ), $this->message_codes( $response ) );
+		$this->assertSame( 0, $this->stored_record_count() );
+	}
+
+	/**
+	 * Reading a cart needs no key and stores nothing.
+	 */
+	public function test_a_read_needs_no_key(): void {
+		$created = $this->request( 'POST', '/carts', $this->cart_body( 0 ), wp_generate_uuid4() );
+		$cart_id = $created->get_data()['id'];
+
+		$response = $this->request( 'GET', '/carts/' . $cart_id );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $cart_id, $response->get_data()['id'] );
+		$this->assertSame( 1, $this->stored_record_count() );
+	}
+
+	/**
+	 * A retried cancel replays its own response, rather than the `not_found` the first
+	 * call left behind.
+	 */
+	public function test_a_retried_cancel_replays_the_prior_state(): void {
+		$created = $this->request( 'POST', '/carts', $this->cart_body( 0 ), wp_generate_uuid4() );
+		$cart_id = $created->get_data()['id'];
+		$key     = wp_generate_uuid4();
+
+		$first = $this->request( 'POST', '/carts/' . $cart_id . '/cancel', array(), $key );
+
+		$this->assertSame( 200, $first->get_status() );
+		$this->assertSame( array( 'CANCELED' ), $this->message_codes( $first ) );
+
+		$second = $this->request( 'POST', '/carts/' . $cart_id . '/cancel', array(), $key );
+
+		$this->assertSame( 'true', $second->get_headers()['Idempotency-Replayed'] ?? '' );
+		$this->assertSame( $first->get_data(), $second->get_data() );
+	}
+
+	/**
+	 * The kill switch takes the whole mechanism out of the way.
+	 */
+	public function test_the_kill_switch_stores_nothing(): void {
+		add_filter( 'woocommerce_ucp_idempotency_enabled', '__return_false' );
+
+		$key = wp_generate_uuid4();
+
+		$this->assertSame( 200, $this->request( 'POST', '/carts', $this->cart_body( 0 ), $key )->get_status() );
+		$this->assertSame( 0, $this->stored_record_count() );
+	}
+
+	/**
+	 * Cleanup removes expired records, both completed and orphaned, and keeps fresh ones.
+	 */
+	public function test_cleanup_removes_expired_records_of_both_kinds(): void {
+		global $wpdb;
+
+		$fresh    = wp_generate_uuid4();
+		$orphaned = wp_generate_uuid4();
+		$finished = wp_generate_uuid4();
+
+		IdempotencyStore::try_begin( $fresh, str_repeat( 'a', 64 ) );
+		IdempotencyStore::try_begin( $orphaned, str_repeat( 'b', 64 ) );
+		IdempotencyStore::try_begin( $finished, str_repeat( 'c', 64 ) );
+		IdempotencyStore::finalize( $finished, 200, array( 'ok' => true ) );
+
+		$stale = gmdate( 'Y-m-d H:i:s', time() - ( 2 * DAY_IN_SECONDS ) );
+
+		// An orphan ages by its claim, a finished record by its completion.
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Ageing a row the test just created.
+		$wpdb->query(
+			$wpdb->prepare(
+				"UPDATE {$wpdb->prefix}wc_ucp_idempotency_keys SET created_at = %s WHERE idempotency_key = %s",
+				$stale,
+				$orphaned
+			)
+		);
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Same.
+		$wpdb->query(
+			$wpdb->prepare(
+				"UPDATE {$wpdb->prefix}wc_ucp_idempotency_keys SET completed_at = %s WHERE idempotency_key = %s",
+				$stale,
+				$finished
+			)
+		);
+
+		IdempotencyStore::cleanup();
+
+		$this->assertNotNull( IdempotencyStore::find( $fresh ) );
+		$this->assertNull( IdempotencyStore::find( $orphaned ) );
+		$this->assertNull( IdempotencyStore::find( $finished ) );
+	}
+
+	/**
+	 * Only one of several concurrent claims on a key wins.
+	 */
+	public function test_only_one_claim_on_a_key_wins(): void {
+		$key         = wp_generate_uuid4();
+		$fingerprint = str_repeat( 'd', 64 );
+
+		$this->assertTrue( IdempotencyStore::try_begin( $key, $fingerprint ) );
+		$this->assertFalse( IdempotencyStore::try_begin( $key, $fingerprint ) );
+		$this->assertSame( IdempotencyStore::STATE_IN_PROGRESS, IdempotencyStore::find( $key )['state'] );
+	}
+
+	/**
+	 * A stored response keeps the status the first call answered with.
+	 */
+	public function test_a_stored_response_keeps_its_status(): void {
+		$key = wp_generate_uuid4();
+
+		IdempotencyStore::try_begin( $key, str_repeat( 'e', 64 ) );
+		IdempotencyStore::finalize( $key, 201, array( 'id' => 'cart-1' ) );
+
+		$record = IdempotencyStore::find( $key );
+
+		$this->assertSame( IdempotencyStore::STATE_COMPLETED, $record['state'] );
+		$this->assertSame( 201, (int) $record['response_status'] );
+		$this->assertSame( array( 'id' => 'cart-1' ), json_decode( $record['response_body'], true ) );
+	}
+
+	/**
+	 * A different payload, route or method is a different fingerprint, and so is the same
+	 * payload serialized differently: the body is compared as raw bytes, like `Content-Digest`.
+	 */
+	public function test_a_fingerprint_follows_body_bytes_route_and_method(): void {
+		$base = new \WP_REST_Request( 'PUT', '/wc/ucp/v1/carts/abc' );
+		$base->set_header( 'content-type', 'application/json' );
+		$base->set_body( '{"currency":"USD","quantity":1}' );
+
+		$other_payload = new \WP_REST_Request( 'PUT', '/wc/ucp/v1/carts/abc' );
+		$other_payload->set_header( 'content-type', 'application/json' );
+		$other_payload->set_body( '{"currency":"USD","quantity":2}' );
+
+		$other_bytes = new \WP_REST_Request( 'PUT', '/wc/ucp/v1/carts/abc' );
+		$other_bytes->set_header( 'content-type', 'application/json' );
+		$other_bytes->set_body( '{ "quantity" : 1 , "currency" : "USD" }' );
+
+		$other_route = new \WP_REST_Request( 'PUT', '/wc/ucp/v1/carts/def' );
+		$other_route->set_header( 'content-type', 'application/json' );
+		$other_route->set_body( '{"currency":"USD","quantity":1}' );
+
+		$other_method = new \WP_REST_Request( 'POST', '/wc/ucp/v1/carts/abc' );
+		$other_method->set_header( 'content-type', 'application/json' );
+		$other_method->set_body( '{"currency":"USD","quantity":1}' );
+
+		$fingerprint = RequestFingerprint::compute( $base );
+
+		$this->assertNotSame( $fingerprint, RequestFingerprint::compute( $other_payload ) );
+		$this->assertNotSame( $fingerprint, RequestFingerprint::compute( $other_bytes ) );
+		$this->assertNotSame( $fingerprint, RequestFingerprint::compute( $other_route ) );
+		$this->assertNotSame( $fingerprint, RequestFingerprint::compute( $other_method ) );
+	}
+
+	/**
+	 * A read dispatched after a write must not touch the write's stored response.
+	 *
+	 * One route instance serves every dispatch in the process, and `CartsById` answers both
+	 * GET and PUT from it, so a key held on the instance would be reused by the next request.
+	 */
+	public function test_a_later_read_does_not_overwrite_a_stored_write(): void {
+		$created = $this->request( 'POST', '/carts', $this->cart_body( 0 ), wp_generate_uuid4() );
+		$cart_id = $created->get_data()['id'];
+		$key     = wp_generate_uuid4();
+		$body    = $this->cart_body( 0, 3 );
+
+		$updated = $this->request( 'PUT', '/carts/' . $cart_id, $body, $key );
+		$this->assertSame( 200, $updated->get_status() );
+
+		// Same route instance, same process: the GET must own no key.
+		$this->request( 'GET', '/carts/' . $cart_id );
+
+		$replay = $this->request( 'PUT', '/carts/' . $cart_id, $body, $key );
+
+		$this->assertSame( 'true', $replay->get_headers()['Idempotency-Replayed'] ?? '' );
+		$this->assertSame( $updated->get_data(), $replay->get_data() );
+	}
+
+	/**
+	 * Two spellings of a key are two keys: a case-folding index would hand the second request
+	 * the first one's response.
+	 */
+	public function test_key_case_is_significant(): void {
+		$key  = wp_generate_uuid4();
+		$body = $this->cart_body( 0 );
+
+		$this->request( 'POST', '/carts', $body, strtolower( $key ) );
+		$second = $this->request( 'POST', '/carts', $body, strtoupper( $key ) );
+
+		$this->assertSame( 200, $second->get_status() );
+		$this->assertArrayNotHasKey( 'Idempotency-Replayed', $second->get_headers() );
+		$this->assertSame( 2, $this->stored_record_count() );
+	}
+
+	/**
+	 * A claim left behind by a dead process is taken over, not refused forever.
+	 */
+	public function test_a_stale_claim_is_taken_over(): void {
+		global $wpdb;
+
+		$key  = wp_generate_uuid4();
+		$body = $this->cart_body( 0 );
+
+		$request = new \WP_REST_Request( 'POST', '/wc/ucp/v1/carts' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body( wp_json_encode( $body ) );
+
+		$this->assertTrue( IdempotencyStore::try_begin( $key, RequestFingerprint::compute( $request ) ) );
+
+		// Age the claim past the orphan window, as a process that died would leave it.
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Ageing a row the test just created.
+		$wpdb->query(
+			$wpdb->prepare(
+				"UPDATE {$wpdb->prefix}wc_ucp_idempotency_keys SET created_at = %s WHERE idempotency_key = %s",
+				gmdate( 'Y-m-d H:i:s', time() - ( IdempotencyStore::STALE_CLAIM_SECONDS + 60 ) ),
+				$key
+			)
+		);
+
+		$response = $this->request( 'POST', '/carts', $body, $key );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( IdempotencyStore::STATE_COMPLETED, IdempotencyStore::find( $key )['state'] );
+	}
+
+	/**
+	 * A claim younger than the orphan window is still refused.
+	 */
+	public function test_a_fresh_claim_is_not_taken_over(): void {
+		$key  = wp_generate_uuid4();
+		$body = $this->cart_body( 0 );
+
+		$request = new \WP_REST_Request( 'POST', '/wc/ucp/v1/carts' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body( wp_json_encode( $body ) );
+
+		IdempotencyStore::try_begin( $key, RequestFingerprint::compute( $request ) );
+
+		$response = $this->request( 'POST', '/carts', $body, $key );
+
+		$this->assertSame( 409, $response->get_status() );
+		$this->assertSame( array( 'IDEMPOTENCY_CONFLICT' ), $this->message_codes( $response ) );
+	}
+
+	/**
+	 * A shortened retention window must not free a claim whose request is still running:
+	 * that would let a retry run the same write a second time.
+	 */
+	public function test_cleanup_spares_a_running_claim_under_short_retention(): void {
+		global $wpdb;
+
+		add_filter( 'woocommerce_ucp_idempotency_retention_seconds', static fn () => 1 );
+
+		$running  = wp_generate_uuid4();
+		$finished = wp_generate_uuid4();
+
+		IdempotencyStore::try_begin( $running, str_repeat( 'a', 64 ) );
+		IdempotencyStore::try_begin( $finished, str_repeat( 'b', 64 ) );
+		IdempotencyStore::finalize( $finished, 200, array( 'ok' => true ) );
+
+		// Older than the one-second window the filter sets, so the completed record is due
+		// for deletion while the claim beside it is seconds old.
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Ageing a row the test just created.
+		$wpdb->query(
+			$wpdb->prepare(
+				"UPDATE {$wpdb->prefix}wc_ucp_idempotency_keys SET completed_at = %s WHERE idempotency_key = %s",
+				gmdate( 'Y-m-d H:i:s', time() - 30 ),
+				$finished
+			)
+		);
+
+		IdempotencyStore::cleanup();
+
+		$this->assertNotNull( IdempotencyStore::find( $running ), 'A running claim must survive a shortened retention window.' );
+		$this->assertNull( IdempotencyStore::find( $finished ) );
+	}
+
+	/**
+	 * A server error releases the claim, so the retry is processed rather than refused.
+	 */
+	public function test_a_server_error_releases_the_claim(): void {
+		$fail = static function () {
+			throw new \RuntimeException( 'Storage went away mid-write.' );
+		};
+		add_action( 'woocommerce_add_to_cart', $fail );
+
+		$key      = wp_generate_uuid4();
+		$body     = $this->cart_body( 0 );
+		$response = $this->request( 'POST', '/carts', $body, $key );
+
+		$this->assertGreaterThanOrEqual( 500, $response->get_status() );
+		$this->assertNull( IdempotencyStore::find( $key ) );
+
+		remove_action( 'woocommerce_add_to_cart', $fail );
+
+		$retry = $this->request( 'POST', '/carts', $body, $key );
+
+		$this->assertSame( 200, $retry->get_status() );
+		$this->assertSame( 'success', $retry->get_data()['ucp']['status'] );
+		$this->assertArrayNotHasKey( 'Idempotency-Replayed', $retry->get_headers() );
+	}
+
+	/**
+	 * A replayed cart body is not cacheable.
+	 */
+	public function test_a_replay_is_not_cacheable(): void {
+		$key  = wp_generate_uuid4();
+		$body = $this->cart_body( 0 );
+
+		$this->request( 'POST', '/carts', $body, $key );
+		$replay = $this->request( 'POST', '/carts', $body, $key );
+
+		$this->assertSame( 'no-store', $replay->get_headers()['Cache-Control'] ?? '' );
+	}
+
+	/**
+	 * A refusal the gate produces is not cacheable either.
+	 */
+	public function test_a_gate_refusal_is_not_cacheable(): void {
+		$response = $this->request( 'POST', '/carts', $this->cart_body( 0 ) );
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'no-store', $response->get_headers()['Cache-Control'] ?? '' );
+	}
+}