Commit 7c3a4c320b4 for woocommerce

commit 7c3a4c320b4cc95b705523fc47b808d417233475
Author: timur987 <115007291+timur987@users.noreply.github.com>
Date:   Fri Oct 30 04:14:51 2026 +0300

    Add UCP Cart endpoints (#69724)

    Adds the UCP (Universal Commerce Protocol) Cart endpoints to WooCommerce Core. Under the hood they are new Store API routes under the `wc/ucp/v1` namespace:
    - `POST /wc/ucp/v1/carts` for creating a cart,
    - `GET /wc/ucp/v1/carts/:id` for reading a cart,
    - `PUT /wc/ucp/v1/carts/:id` for replacing cart's line items,
    - `/wc/ucp/v1/carts/:id/cancel` for marking  a cart as canceled.

    ---------

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

diff --git a/plugins/woocommerce/changelog/add-ucp-cart-routes b/plugins/woocommerce/changelog/add-ucp-cart-routes
new file mode 100644
index 00000000000..75b732e1166
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-ucp-cart-routes
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add opt-in UCP cart endpoints so AI shopping agents can create, read, update and cancel a cart.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 23bb45c8336..038321c9860 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -673,7 +673,7 @@ final class WooCommerce {
 	}

 	/**
-	 * Returns true if the request is a store REST API request.
+	 * Returns true if the request is a store REST API or UCP API request.
 	 *
 	 * @return bool
 	 */
@@ -682,13 +682,19 @@ final class WooCommerce {
 			return false;
 		}

-		// Pretty permalinks: the Store API namespace is part of the path, e.g. /wp-json/wc/store/v1/cart. Match the
+		$namespaces = array( 'wc/store/', 'wc/ucp/' );
+
+		// Pretty permalinks: the namespace is part of the path, e.g. /wp-json/wc/store/v1/cart. Match the
 		// path only (a leading slash anchors the prefix) so a REST-like query argument such as
 		// /some-page/?arg=/wp-json/wc/store/ is not mistaken for a Store API request.
 		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
 		$path = wp_parse_url( '/' . ltrim( (string) wp_unslash( $_SERVER['REQUEST_URI'] ), '/' ), PHP_URL_PATH );
-		if ( is_string( $path ) && false !== strpos( $path, '/' . trailingslashit( rest_get_url_prefix() ) . 'wc/store/' ) ) {
-			return true;
+		if ( is_string( $path ) ) {
+			foreach ( $namespaces as $namespace ) {
+				if ( false !== strpos( $path, '/' . trailingslashit( rest_get_url_prefix() ) . $namespace ) ) {
+					return true;
+				}
+			}
 		}

 		// Plain permalinks: the route is passed as a query parameter, e.g. ?rest_route=/wc/store/v1/cart.
@@ -696,8 +702,10 @@ final class WooCommerce {
 		if ( isset( $_GET['rest_route'] ) && is_string( $_GET['rest_route'] ) ) {
 			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the route only, no state change.
 			$rest_route = '/' . ltrim( rawurldecode( sanitize_text_field( wp_unslash( $_GET['rest_route'] ) ) ), '/' );
-			if ( 0 === strpos( $rest_route, '/wc/store/' ) ) {
-				return true;
+			foreach ( $namespaces as $namespace ) {
+				if ( 0 === strpos( $rest_route, '/' . $namespace ) ) {
+					return true;
+				}
 			}
 		}

diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCartRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCartRoute.php
new file mode 100644
index 00000000000..70a613ece41
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCartRoute.php
@@ -0,0 +1,537 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+use Automattic\WooCommerce\StoreApi\Routes\V1\AbstractCartRoute as StoreApiCartRoute;
+use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
+use Automattic\WooCommerce\StoreApi\SessionHandler;
+use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils;
+use Automattic\WooCommerce\StoreApi\Utilities\UcpCartMapper;
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
+/**
+ * Shared behaviour of the UCP cart routes.
+ *
+ * Inherits session binding, cart loading and draft order sync from the Store API cart
+ * route. Adds the UCP envelope, and keys the session off the path instead of a header.
+ *
+ * Writes are last-wins, as for every Store API cart: the session row is read when the
+ * request starts and written whole at shutdown, with no lock or version check. Two
+ * callers updating the same cart id at once can overwrite each other's lines.
+ */
+abstract class AbstractCartRoute extends StoreApiCartRoute {
+	/**
+	 * The routes schema.
+	 *
+	 * @var string
+	 */
+	const SCHEMA_TYPE = 'ucp-cart';
+
+	/**
+	 * Session key marking a cart that was cancelled through UCP.
+	 *
+	 * Without it a cancelled cart is indistinguishable from an empty one. Read by the UCP
+	 * routes only, so a storefront session cloned from the cart keeps working.
+	 */
+	const CANCELLED_SESSION_KEY = 'ucp_cart_cancelled';
+
+	/**
+	 * Most lines one request may name.
+	 *
+	 * Each line can cost a product lookup, so an open route needs a ceiling. Matches the
+	 * catalog search page size, so one page of results always fits in one cart request.
+	 */
+	const LINE_ITEMS_MAX = 100;
+
+	/**
+	 * Build the `ucp` envelope for a response from this route.
+	 *
+	 * @param string $status Response status, `success` or `error`.
+	 * @return array
+	 */
+	protected function ucp_metadata( string $status ): array {
+		return UcpUtils::response_metadata( UcpUtils::CAPABILITY_CART, $status );
+	}
+
+	/**
+	 * 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
+	 */
+	public function get_response( \WP_REST_Request $request ) {
+		if ( '' !== $this->route_cart_id( $request ) && '' === $this->validated_cart_id( $request ) ) {
+			return $this->add_response_headers( $this->ucp_not_found() );
+		}
+
+		$inherited_token = CartTokenUtils::get_request_cart_token();
+
+		try {
+			return parent::get_response( $request );
+		} finally {
+			$this->restore_request_cart_token( $inherited_token );
+			remove_filter( 'woocommerce_session_handler', array( self::class, 'session_handler_class' ) );
+		}
+	}
+
+	/**
+	 * Put the request's cart token slot back the way it was found.
+	 *
+	 * @param string $token The token the request arrived with, or '' for none.
+	 */
+	private function restore_request_cart_token( string $token ): void {
+		if ( '' === $token ) {
+			// phpcs:ignore WooCommerceStoreApi.StoreApi.CartTokenSource.ServerSuperglobal -- Clearing the slot, not reading the token from it.
+			unset( $_SERVER['HTTP_CART_TOKEN'] );
+			return;
+		}
+
+		// phpcs:ignore WooCommerceStoreApi.StoreApi.CartTokenSource.ServerSuperglobalWrite -- Restoring the value the request arrived with, already validated by whatever read it.
+		$_SERVER['HTTP_CART_TOKEN'] = $token;
+	}
+
+	/**
+	 * Bind the session to the cart id in the route path.
+	 *
+	 * The parent picks the Store API session handler from a `Cart-Token` header. UCP
+	 * carries that token in the path, so it is promoted to the header first.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return void
+	 */
+	protected function load_cart_session( \WP_REST_Request $request ) {
+		$cart_id = $this->validated_cart_id( $request );
+
+		if ( '' === $cart_id ) {
+			// Creation starts from a fresh guest session: the id is minted here, never
+			// inherited from a Cart-Token header or from the logged-in user. Without this the
+			// token handler would key an empty session on the user id and overwrite the
+			// shopper's own session row on save.
+			$cart_id = CartTokenUtils::get_cart_token( wc_rand_hash( 't_', 30 ) );
+		}
+
+		$request->set_header( 'Cart-Token', $cart_id );
+		// phpcs:ignore WooCommerceStoreApi.StoreApi.CartTokenSource.ServerSuperglobalWrite -- Either the route's own path segment, signature checked in validated_cart_id(), or a token minted just above; promoted to the slot PHP would have filled from a Cart-Token header.
+		$_SERVER['HTTP_CART_TOKEN'] = $cart_id;
+
+		// The parent promotes the token handler only for a request that carries a token,
+		// which would leave cart creation on the cookie handler. UCP has no cookie to
+		// trust: the id is the only credential, so the handler is fixed for every route.
+		add_filter( 'woocommerce_session_handler', array( self::class, 'session_handler_class' ) );
+
+		// An extension that calls wc_load_cart() on init leaves a session already in memory, so
+		// load_cart() below keeps it and the filter above never binds: the request would act on
+		// that cookie or guest session instead of the id it named. Drop it unless it already is
+		// the token handler for this id, so a legitimate request keeps its own loaded cart.
+		$session  = WC()->session;
+		$expected = CartTokenUtils::get_cart_token_payload( $cart_id )['user_id'];
+		if ( ! $session instanceof SessionHandler || (string) $session->get_customer_id() !== (string) $expected ) {
+			$this->discard_preloaded_cart_session();
+		}
+
+		parent::load_cart_session( $request );
+	}
+
+	/**
+	 * Reset to the cart state a REST request starts from, before anything called wc_load_cart().
+	 *
+	 * Clearing the session, cart, customer and the one-time load marker lets load_cart()
+	 * rebuild through the forced token handler, whatever ran earlier in the request.
+	 */
+	private function discard_preloaded_cart_session(): void {
+		WC()->session  = null; // @phpstan-ignore assign.propertyType (Defaults to null; reset so load_cart() rebuilds it.)
+		WC()->customer = null; // @phpstan-ignore assign.propertyType (Same.)
+		WC()->cart     = null; // @phpstan-ignore assign.propertyType (Same.)
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Clears the per-process marker so load_cart() reloads.
+		unset( $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] );
+	}
+
+	/**
+	 * The session handler a UCP cart route binds to.
+	 *
+	 * Token-keyed, so an id this store signed is the only way to reach a cart. Without a
+	 * valid token it mints an empty session, which is what cart creation needs.
+	 *
+	 * @return string
+	 */
+	public static function session_handler_class(): string {
+		return SessionHandler::class;
+	}
+
+	/**
+	 * The request's cart id when its signature checks out, or '' when it does not.
+	 *
+	 * Read from the request every time: the route instance outlives a request, so a
+	 * cached id would bind the next dispatch in the same process to this cart.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return string
+	 */
+	protected function validated_cart_id( \WP_REST_Request $request ): string {
+		$cart_id = $this->route_cart_id( $request );
+
+		return CartTokenUtils::validate_cart_token( $cart_id ) ? $cart_id : '';
+	}
+
+	/**
+	 * Dispatch the request, refusing a cart that was cancelled.
+	 *
+	 * Runs after the session is bound, so one check covers every id-bearing route.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response|\WP_Error
+	 */
+	protected function get_response_by_request_method( \WP_REST_Request $request ) {
+		if ( '' !== $this->route_cart_id( $request ) && $this->cart_is_cancelled() ) {
+			return $this->ucp_not_found();
+		}
+
+		return parent::get_response_by_request_method( $request );
+	}
+
+	/**
+	 * UCP requests are not browser requests, so they carry no nonce.
+	 *
+	 * Safe because `load_cart_session()` binds every route to the token-keyed session
+	 * handler, so no request here reads the browser's session cookie: there is no ambient
+	 * credential for a cross-site request to ride on. Cart creation is left open
+	 * deliberately — it mints a guest session of its own, whoever is logged in.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return bool
+	 */
+	protected function requires_nonce( \WP_REST_Request $request ) {
+		return false;
+	}
+
+	/**
+	 * Read the cart id from the route path.
+	 *
+	 * `get_url_params()` only: `get_param()` merges body and query over it, which would
+	 * let a caller name a session the route never handed them.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return string The path-supplied id, or '' when the route has no `id` segment.
+	 */
+	protected function route_cart_id( \WP_REST_Request $request ): string {
+		$params = $request->get_url_params();
+
+		return isset( $params['id'] ) && is_scalar( $params['id'] ) ? (string) $params['id'] : '';
+	}
+
+	/**
+	 * Mint the cart id for the session the request is bound to.
+	 *
+	 * @return string
+	 */
+	protected function current_cart_id(): string {
+		$session = WC()->session;
+
+		return $session ? CartTokenUtils::get_cart_token( (string) $session->get_customer_id() ) : '';
+	}
+
+	/**
+	 * Whether the bound session's cart was cancelled through UCP.
+	 *
+	 * @return bool
+	 */
+	protected function cart_is_cancelled(): bool {
+		$session = WC()->session;
+
+		return $session ? (bool) $session->get( self::CANCELLED_SESSION_KEY ) : false;
+	}
+
+	/**
+	 * Build a successful cart response.
+	 *
+	 * @param string               $cart_id Cart identifier.
+	 * @param array<string, mixed> $context Optional `messages` to merge into the payload.
+	 * @return \WP_REST_Response
+	 */
+	protected function cart_response( string $cart_id, array $context = array() ): \WP_REST_Response {
+		return new \WP_REST_Response(
+			array_merge(
+				array( 'ucp' => $this->ucp_metadata( 'success' ) ),
+				( new UcpCartMapper() )->build_cart( $this->cart_item_response(), $cart_id, $context )
+			),
+			200
+		);
+	}
+
+	/**
+	 * Build the Store API cart response for the current cart, as nested arrays.
+	 *
+	 * @return array
+	 */
+	private function cart_item_response(): array {
+		// Nested values are stdClass; decode through JSON for uniform array access.
+		return (array) json_decode( (string) wp_json_encode( $this->schema->get_item_response( $this->cart_controller->get_cart_for_response() ) ), true );
+	}
+
+	/**
+	 * Build an error response.
+	 *
+	 * `$status` follows the split UCP draws: a malformed request is a transport error and
+	 * keeps a non-2xx status, while a valid request that ends in a refusal is a business
+	 * outcome and is reported at 200 through `ucp.status` and `messages`.
+	 *
+	 * @param string $code Error code.
+	 * @param string $message Error message.
+	 * @param string $severity Error severity.
+	 * @param int    $status HTTP status.
+	 * @param string $path JSONPath of the offending field.
+	 * @return \WP_REST_Response
+	 */
+	protected function ucp_error( string $code, string $message, string $severity, int $status, string $path = '$' ): \WP_REST_Response {
+		return UcpUtils::error_response( UcpUtils::CAPABILITY_CART, $code, $message, $severity, $status, $path );
+	}
+
+	/**
+	 * Report an error the parent raised in the UCP envelope.
+	 *
+	 * Every failure the Store API base class produces — a session that fails to load,
+	 * a `RouteException` a handler let through, an unexpected exception — funnels through
+	 * here. The cart's own code is kept where UCP has a name for it, and the HTTP status
+	 * is the parent's. The `cart` payload the parent attaches to a 409 conflict is dropped:
+	 * the UCP envelope has no slot for it, and the client re-reads the cart instead.
+	 *
+	 * @param \WP_Error $error WP Error object.
+	 * @return \WP_REST_Response
+	 */
+	protected function error_to_response( $error ) {
+		$data    = $error->get_error_data();
+		$status  = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 500;
+		$message = (string) $error->get_error_message();
+
+		return $this->ucp_error(
+			UcpUtils::cart_error_code( (string) $error->get_error_code(), 'TRANSPORT_ERROR' ),
+			'' !== $message ? $message : __( 'The cart could not be loaded. Please try again.', 'woocommerce' ),
+			$status >= 500 ? 'unrecoverable' : 'recoverable',
+			$status
+		);
+	}
+
+	/**
+	 * Build the response for a cart id that does not resolve.
+	 *
+	 * Never existed, expired and cancelled are reported alike, and at 200: the request was
+	 * valid, so the refusal is a business outcome. See `ucp_error()`.
+	 *
+	 * @return \WP_REST_Response
+	 */
+	protected function ucp_not_found(): \WP_REST_Response {
+		return UcpUtils::error_response( UcpUtils::CAPABILITY_CART, 'not_found', __( 'Cart not found.', 'woocommerce' ), 'unrecoverable', 200, '$.id' );
+	}
+
+	/**
+	 * Parse the `line_items` of a request body into desired quantities.
+	 *
+	 * Two lines can hold the same product and differ only in cart item data, as an add-on
+	 * does, so a payload naming a line is collected per cart item. A line id the cart no
+	 * longer holds falls back to `item.id`: the buyer may have removed it meanwhile.
+	 *
+	 * A malformed payload is refused at 400, an unsellable id at 200 — see `ucp_error()`.
+	 *
+	 * @param mixed $line_items Raw `line_items` value.
+	 * @return array{by_key: array<string, int>, by_product: array<int, int>}|\WP_REST_Response
+	 *         Quantities per cart item key and per product id, or an error response.
+	 */
+	protected function parse_line_items( $line_items ) {
+		if ( ! is_array( $line_items ) ) {
+			return $this->ucp_error( 'VALIDATION_ERROR', __( 'line_items must be an array.', 'woocommerce' ), 'recoverable', 400, '$.line_items' );
+		}
+
+		if ( count( $line_items ) > self::LINE_ITEMS_MAX ) {
+			return $this->ucp_error(
+				'VALIDATION_ERROR',
+				/* translators: %d: maximum number of line items accepted in one request. */
+				sprintf( __( 'line_items may hold at most %d entries.', 'woocommerce' ), self::LINE_ITEMS_MAX ),
+				'recoverable',
+				400,
+				'$.line_items'
+			);
+		}
+
+		$cart_items = $this->cart_controller->get_cart_items();
+		$desired    = array(
+			'by_key'     => array(),
+			'by_product' => array(),
+		);
+
+		foreach ( $line_items as $index => $line_item ) {
+			$path = sprintf( '$.line_items[%d]', (int) $index );
+
+			if ( ! is_array( $line_item ) || ! isset( $line_item['item']['id'] ) || ! is_scalar( $line_item['item']['id'] ) ) {
+				return $this->ucp_error( 'VALIDATION_ERROR', __( 'Each line item requires item.id.', 'woocommerce' ), 'recoverable', 400, $path . '.item.id' );
+			}
+
+			$quantity = isset( $line_item['quantity'] ) ? (int) $line_item['quantity'] : 1;
+
+			if ( $quantity < 1 ) {
+				return $this->ucp_error( 'VALIDATION_ERROR', __( 'Line item quantity must be at least 1.', 'woocommerce' ), 'recoverable', 400, $path . '.quantity' );
+			}
+
+			$item_key = isset( $line_item['id'] ) && is_scalar( $line_item['id'] ) ? (string) $line_item['id'] : '';
+
+			// A named line is addressed directly, so the product behind it is not
+			// re-resolved: one that has since gone out of stock must still be adjustable.
+			if ( '' !== $item_key && isset( $cart_items[ $item_key ] ) ) {
+				$desired['by_key'][ $item_key ] = ( $desired['by_key'][ $item_key ] ?? 0 ) + $quantity;
+				continue;
+			}
+
+			$item_id = (string) $line_item['item']['id'];
+			$product = $this->get_item_product( $item_id );
+
+			// The payload is well formed; the store simply has nothing orderable under that
+			// id. A business outcome, so 200 like every other refusal. An id matching no
+			// product is `not_found`; one matching a product a cart line cannot hold — a
+			// variable parent, grouped, external or otherwise unpurchasable — exists but is
+			// unavailable.
+			if ( ! $product instanceof \WC_Product ) {
+				return $this->ucp_error( 'not_found', __( 'Product not found.', 'woocommerce' ), 'unrecoverable', 200, $path . '.item.id' );
+			}
+
+			if ( $product instanceof \WC_Product_Variable || ! $product->is_purchasable() ) {
+				$message = $product instanceof \WC_Product_Variable
+					? __( 'This product is only orderable as one of its variants. Use a variant id from the catalog.', 'woocommerce' )
+					: __( 'This product cannot be added to a cart.', 'woocommerce' );
+
+				return $this->ucp_error( 'item_unavailable', $message, 'recoverable', 200, $path . '.item.id' );
+			}
+
+			// Repeating an id is additive, matching how a cart treats two adds of the
+			// same product.
+			$product_id                           = (int) $product->get_id();
+			$desired['by_product'][ $product_id ] = ( $desired['by_product'][ $product_id ] ?? 0 ) + $quantity;
+		}
+
+		return $desired;
+	}
+
+	/**
+	 * Load the product an item id names.
+	 *
+	 * Ids only, as the catalog routes resolve them: the catalog hands out no SKU
+	 * identifier, and a SKU fallback would let a numeric SKU resolve as a post id.
+	 *
+	 * @param string $item_id Item identifier.
+	 * @return \WC_Product|null
+	 */
+	private function get_item_product( string $item_id ) {
+		$product_id = ctype_digit( $item_id ) ? (int) $item_id : 0;
+		$product    = $product_id > 0 ? wc_get_product( $product_id ) : null;
+
+		return $product instanceof \WC_Product ? $product : null;
+	}
+
+	/**
+	 * Bring the cart in line with a set of desired quantities.
+	 *
+	 * `line_items` is the complete desired state, so an unaccounted-for line is removed.
+	 * An entry naming a cart item addresses that one; an entry naming only a product takes
+	 * the first line holding it, and further lines of that product go.
+	 *
+	 * A refusal from the cart is returned in the UCP envelope, carrying the cart's own
+	 * message, since the Store API error shape has no `ucp` object to read. Removals run
+	 * before adds, so the contents are restored first: otherwise replacing a cart with
+	 * something out of stock would empty it.
+	 *
+	 * @param array{by_key: array<string, int>, by_product: array<int, int>} $desired Desired quantities.
+	 * @return \WP_REST_Response|null An error response, or null when the cart was updated.
+	 * @throws \Throwable Anything other than a cart refusal, after the cart is restored.
+	 */
+	protected function apply_line_items( array $desired ) {
+		$cart     = $this->cart_controller->get_cart_instance();
+		$snapshot = $cart->get_cart_contents();
+
+		try {
+			$this->reconcile_line_items( $desired );
+		} catch ( \Throwable $error ) {
+			// Whatever threw, including a hook inside the cart, the removals already
+			// applied must not stand.
+			$cart->set_cart_contents( $snapshot );
+			$this->cart_controller->calculate_totals();
+
+			if ( ! $error instanceof RouteException ) {
+				throw $error;
+			}
+
+			return $this->ucp_error(
+				UcpUtils::cart_error_code( $error->getErrorCode() ),
+				$error->getMessage(),
+				'recoverable',
+				200,
+				'$.line_items'
+			);
+		}
+
+		return null;
+	}
+
+	/**
+	 * Reconcile the cart to the desired quantities.
+	 *
+	 * @param array{by_key: array<string, int>, by_product: array<int, int>} $desired Desired quantities.
+	 * @throws RouteException When an item cannot be added or adjusted.
+	 */
+	private function reconcile_line_items( array $desired ): void {
+		$by_key     = $desired['by_key'];
+		$by_product = $desired['by_product'];
+
+		foreach ( $this->cart_controller->get_cart_items() as $key => $cart_item ) {
+			$key        = (string) $key;
+			$product_id = (int) ( $cart_item['variation_id'] ? $cart_item['variation_id'] : $cart_item['product_id'] );
+			$quantity   = (int) $cart_item['quantity'];
+
+			if ( isset( $by_key[ $key ] ) ) {
+				$target = $by_key[ $key ];
+			} elseif ( isset( $by_product[ $product_id ] ) ) {
+				$target = $by_product[ $product_id ];
+				// Claimed, so a second line of the same product is not kept as a duplicate.
+				unset( $by_product[ $product_id ] );
+			} else {
+				$this->cart_controller->get_cart_instance()->remove_cart_item( $key );
+				continue;
+			}
+
+			if ( $target !== $quantity ) {
+				// Totals are recalculated once below, not per changed line.
+				$this->cart_controller->set_cart_item_quantity( $key, $target, false );
+			}
+		}
+
+		foreach ( $by_product as $product_id => $quantity ) {
+			$this->cart_controller->add_to_cart(
+				array(
+					'id'       => $product_id,
+					'quantity' => $quantity,
+				)
+			);
+		}
+
+		$this->cart_controller->calculate_totals();
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php
index 211e8b58a21..7f6769625b0 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php
@@ -139,22 +139,7 @@ abstract class AbstractCatalogRoute extends AbstractRoute {
 	 * @return \WP_REST_Response
 	 */
 	protected function ucp_error( string $code, string $message, string $severity, int $status ): \WP_REST_Response {
-		return new \WP_REST_Response(
-			array(
-				'ucp'      => $this->ucp_metadata( 'error' ),
-				'messages' => array(
-					array(
-						'type'         => 'error',
-						'code'         => $code,
-						'content'      => $message,
-						'severity'     => $severity,
-						'path'         => '$',
-						'content_type' => 'plain',
-					),
-				),
-			),
-			$status
-		);
+		return UcpUtils::error_response( $this->get_capability(), $code, $message, $severity, $status );
 	}

 	/**
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/Carts.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/Carts.php
new file mode 100644
index 00000000000..9756888432f
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/Carts.php
@@ -0,0 +1,99 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+/**
+ * UCP `POST /carts` route.
+ */
+class Carts extends AbstractCartRoute {
+	/**
+	 * The route identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'ucp-carts';
+
+	/**
+	 * Get the path of this REST route.
+	 *
+	 * @return string
+	 */
+	public function get_path() {
+		return self::get_path_regex();
+	}
+
+	/**
+	 * Get the path of this rest route.
+	 *
+	 * @return string
+	 */
+	public static function get_path_regex() {
+		return '/carts';
+	}
+
+	/**
+	 * Get method arguments for this REST route.
+	 *
+	 * @return array An array of endpoints.
+	 */
+	public function get_args() {
+		return array(
+			array(
+				'methods'             => \WP_REST_Server::CREATABLE,
+				'callback'            => array( $this, 'get_response' ),
+				'permission_callback' => '__return_true',
+				'args'                => array(
+					'line_items' => array(
+						'description' => __( 'Items to put in the new cart.', 'woocommerce' ),
+						'type'        => 'array',
+					),
+				),
+			),
+			'schema' => array( $this->schema, 'get_public_item_schema' ),
+		);
+	}
+
+	/**
+	 * Create a cart.
+	 *
+	 * The id is minted from the session the cart was loaded into, so it is the token
+	 * that addresses this cart from now on.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response
+	 */
+	protected function get_route_post_response( \WP_REST_Request $request ) {
+		$line_items = $request->get_param( 'line_items' );
+
+		if ( null !== $line_items ) {
+			$desired = $this->parse_line_items( $line_items );
+
+			if ( $desired instanceof \WP_REST_Response ) {
+				return $desired;
+			}
+
+			$error = $this->apply_line_items( $desired );
+
+			if ( null !== $error ) {
+				return $error;
+			}
+		}
+
+		$cart_id = $this->current_cart_id();
+
+		if ( '' === $cart_id ) {
+			return $this->ucp_error(
+				'TRANSPORT_ERROR',
+				__( 'The cart session could not be created.', 'woocommerce' ),
+				'recoverable',
+				500
+			);
+		}
+
+		return $this->cart_response( $cart_id );
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsById.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsById.php
new file mode 100644
index 00000000000..14e44025271
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsById.php
@@ -0,0 +1,109 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+/**
+ * UCP `GET /carts/{id}` and `PUT /carts/{id}` route.
+ */
+class CartsById extends AbstractCartRoute {
+	/**
+	 * The route identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'ucp-carts-by-id';
+
+	/**
+	 * Get the path of this REST route.
+	 *
+	 * @return string
+	 */
+	public function get_path() {
+		return self::get_path_regex();
+	}
+
+	/**
+	 * Get the path of this rest route.
+	 *
+	 * @return string
+	 */
+	public static function get_path_regex() {
+		return '/carts/(?P<id>[\w\-\.]+)';
+	}
+
+	/**
+	 * Get method arguments for this REST route.
+	 *
+	 * PUT only, as the cart REST binding specifies. PATCH is deliberately not accepted:
+	 * `line_items` is the complete desired state, so a partial payload would delete
+	 * every line it left out.
+	 *
+	 * @return array An array of endpoints.
+	 */
+	public function get_args() {
+		return array(
+			array(
+				'methods'             => \WP_REST_Server::READABLE,
+				'callback'            => array( $this, 'get_response' ),
+				'permission_callback' => '__return_true',
+			),
+			array(
+				'methods'             => 'PUT',
+				'callback'            => array( $this, 'get_response' ),
+				'permission_callback' => '__return_true',
+				'args'                => array(
+					'line_items' => array(
+						'description' => __( 'The complete desired contents of the cart.', 'woocommerce' ),
+						'type'        => 'array',
+					),
+				),
+			),
+			'schema' => array( $this->schema, 'get_public_item_schema' ),
+		);
+	}
+
+	/**
+	 * Read a cart.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response
+	 */
+	protected function get_route_response( \WP_REST_Request $request ) {
+		return $this->cart_response( $this->route_cart_id( $request ) );
+	}
+
+	/**
+	 * Replace the contents of a cart.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response
+	 */
+	protected function get_route_update_response( \WP_REST_Request $request ) {
+		$line_items = $request->get_param( 'line_items' );
+
+		// `line_items` is the whole desired state, so omitting it is not the same as
+		// sending an empty list: it leaves the cart alone.
+		if ( null !== $line_items ) {
+			$desired = $this->parse_line_items( $line_items );
+
+			if ( $desired instanceof \WP_REST_Response ) {
+				return $desired;
+			}
+
+			$error = $this->apply_line_items( $desired );
+
+			if ( null !== $error ) {
+				return $error;
+			}
+		}
+
+		return $this->cart_response( $this->route_cart_id( $request ) );
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsCancel.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsCancel.php
new file mode 100644
index 00000000000..0a6a4f982d5
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsCancel.php
@@ -0,0 +1,86 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+/**
+ * UCP `POST /carts/{id}/cancel` route.
+ */
+class CartsCancel extends AbstractCartRoute {
+	/**
+	 * The route identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'ucp-carts-cancel';
+
+	/**
+	 * Get the path of this REST route.
+	 *
+	 * @return string
+	 */
+	public function get_path() {
+		return self::get_path_regex();
+	}
+
+	/**
+	 * Get the path of this rest route.
+	 *
+	 * @return string
+	 */
+	public static function get_path_regex() {
+		return '/carts/(?P<id>[\w\-\.]+)/cancel';
+	}
+
+	/**
+	 * Get method arguments for this REST route.
+	 *
+	 * @return array An array of endpoints.
+	 */
+	public function get_args() {
+		return array(
+			array(
+				'methods'             => \WP_REST_Server::CREATABLE,
+				'callback'            => array( $this, 'get_response' ),
+				'permission_callback' => '__return_true',
+			),
+			'schema' => array( $this->schema, 'get_public_item_schema' ),
+		);
+	}
+
+	/**
+	 * Cancel a cart.
+	 *
+	 * The response carries the cart as it stood before the cancellation, which the
+	 * spec requires; the cart itself is emptied and the id stops resolving.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response
+	 */
+	protected function get_route_post_response( \WP_REST_Request $request ) {
+		$cart_id = $this->route_cart_id( $request );
+
+		$response = $this->cart_response(
+			$cart_id,
+			array(
+				'messages' => array(
+					array(
+						'type'         => 'info',
+						'code'         => 'CANCELED',
+						'content'      => __( 'Cart canceled.', 'woocommerce' ),
+						'path'         => '$',
+						'content_type' => 'plain',
+					),
+				),
+			)
+		);
+
+		$this->cart_controller->empty_cart();
+		WC()->session->set( self::CANCELLED_SESSION_KEY, true );
+
+		return $response;
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/RoutesController.php b/plugins/woocommerce/src/StoreApi/RoutesController.php
index bd3dda1a49e..8a57702430f 100644
--- a/plugins/woocommerce/src/StoreApi/RoutesController.php
+++ b/plugins/woocommerce/src/StoreApi/RoutesController.php
@@ -88,6 +88,9 @@ class RoutesController {
 				Routes\V1\Ucp\CatalogSearch::IDENTIFIER  => Routes\V1\Ucp\CatalogSearch::class,
 				Routes\V1\Ucp\CatalogLookup::IDENTIFIER  => Routes\V1\Ucp\CatalogLookup::class,
 				Routes\V1\Ucp\CatalogProduct::IDENTIFIER => Routes\V1\Ucp\CatalogProduct::class,
+				Routes\V1\Ucp\Carts::IDENTIFIER          => Routes\V1\Ucp\Carts::class,
+				Routes\V1\Ucp\CartsById::IDENTIFIER      => Routes\V1\Ucp\CartsById::class,
+				Routes\V1\Ucp\CartsCancel::IDENTIFIER    => Routes\V1\Ucp\CartsCancel::class,
 			],
 		];
 	}
diff --git a/plugins/woocommerce/src/StoreApi/SchemaController.php b/plugins/woocommerce/src/StoreApi/SchemaController.php
index 574d85a0a54..27df0aeb1bb 100644
--- a/plugins/woocommerce/src/StoreApi/SchemaController.php
+++ b/plugins/woocommerce/src/StoreApi/SchemaController.php
@@ -61,6 +61,8 @@ class SchemaController {
 				Schemas\V1\ShopperListItemSchema::IDENTIFIER => Schemas\V1\ShopperListItemSchema::class,
 				Schemas\V1\Ucp\UcpCatalogSchema::IDENTIFIER => Schemas\V1\Ucp\UcpCatalogSchema::class,
 				Schemas\V1\Ucp\UcpProductSchema::IDENTIFIER => Schemas\V1\Ucp\UcpProductSchema::class,
+				Schemas\V1\Ucp\UcpCartSchema::IDENTIFIER   => Schemas\V1\Ucp\UcpCartSchema::class,
+				Schemas\V1\Ucp\UcpCartItemSchema::IDENTIFIER => Schemas\V1\Ucp\UcpCartItemSchema::class,
 			],
 		];
 	}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCartItemSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCartItemSchema.php
new file mode 100644
index 00000000000..691a52877a9
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCartItemSchema.php
@@ -0,0 +1,54 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Schemas\V1\Ucp;
+
+use Automattic\WooCommerce\Enums\TaxDisplayMode;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\CartItemSchema;
+
+/**
+ * Cart item schema whose responses carry raw strings and pre-tax prices, for the
+ * UCP cart routes.
+ *
+ * Agents compare titles literally, so the storefront HTML formatter must not
+ * reach them. Same rule as {@see UcpProductSchema}. Prices are always exclusive
+ * of tax: UCP itemises tax in `totals`, so `price` times quantity must equal the
+ * line subtotal whatever the store's cart display setting.
+ *
+ * @since 11.3.0
+ */
+class UcpCartItemSchema extends CartItemSchema {
+	/**
+	 * The schema item name.
+	 *
+	 * @var string
+	 */
+	protected $title = 'ucp-cart-item';
+
+	/**
+	 * The schema item identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'ucp-cart-item';
+
+	/**
+	 * Return content unformatted.
+	 *
+	 * @param string|array $response Data to format.
+	 * @return string|array
+	 */
+	protected function prepare_html_response( $response ) {
+		return $response;
+	}
+
+	/**
+	 * Price item prices before tax, ignoring the cart display setting.
+	 *
+	 * @param string $tax_display_mode Requested mode, ignored.
+	 * @return string
+	 */
+	protected function get_tax_display_mode( $tax_display_mode = '' ) {
+		return TaxDisplayMode::EXCLUSIVE;
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCartSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCartSchema.php
new file mode 100644
index 00000000000..46ffe002685
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCartSchema.php
@@ -0,0 +1,85 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Schemas\V1\Ucp;
+
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema;
+use Automattic\WooCommerce\StoreApi\Utilities\CartController;
+
+/**
+ * Cart schema the UCP cart routes read from.
+ *
+ * The Store API cart response already applies cart tax display, minor units,
+ * image filters and stock validation; the UCP cart mapper reshapes it. Strings
+ * stay raw, and items come from {@see UcpCartItemSchema} for the same reason.
+ *
+ * @since 11.3.0
+ */
+class UcpCartSchema extends CartSchema {
+	/**
+	 * The schema item name.
+	 *
+	 * @var string
+	 */
+	protected $title = 'ucp-cart';
+
+	/**
+	 * The schema item identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'ucp-cart';
+
+	/**
+	 * Constructor.
+	 *
+	 * @param ExtendSchema     $extend Rest Extending instance.
+	 * @param SchemaController $controller Schema Controller instance.
+	 */
+	public function __construct( ExtendSchema $extend, SchemaController $controller ) {
+		parent::__construct( $extend, $controller );
+		/**
+		 * The controller returns the registered subclass; typed for static analysis.
+		 *
+		 * @var UcpCartItemSchema $item_schema
+		 */
+		$item_schema       = $this->controller->get( UcpCartItemSchema::IDENTIFIER );
+		$this->item_schema = $item_schema;
+	}
+
+	/**
+	 * Return content unformatted.
+	 *
+	 * @param string|array $response Data to format.
+	 * @return string|array
+	 */
+	protected function prepare_html_response( $response ) {
+		return $response;
+	}
+
+	/**
+	 * Get cart validation errors, with messages left raw.
+	 *
+	 * The parent runs each error through {@see \Automattic\WooCommerce\StoreApi\Schemas\V1\ErrorSchema},
+	 * which texturizes the message; agents get the plain wording.
+	 *
+	 * @param \WC_Cart $cart Cart class instance.
+	 * @return array Each `{ code, message }`.
+	 */
+	protected function get_cart_errors( $cart ) {
+		$cart_errors = array();
+
+		foreach ( (array) ( new CartController() )->get_cart_errors()->errors as $code => $messages ) {
+			foreach ( (array) $messages as $message ) {
+				$cart_errors[] = array(
+					'code'    => (string) $code,
+					'message' => (string) $message,
+				);
+			}
+		}
+
+		return $cart_errors;
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
index e70c77a0cf9..51523691ded 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
@@ -250,8 +250,10 @@ class CartController {
 	 *
 	 * @param string    $item_id Cart item id.
 	 * @param int|float $quantity Cart quantity.
+	 * @param bool      $refresh_totals Whether to recalculate totals now; pass false when changing several
+	 *                                  lines and calling calculate_totals() once afterwards.
 	 */
-	public function set_cart_item_quantity( $item_id, $quantity = 1 ) {
+	public function set_cart_item_quantity( $item_id, $quantity = 1, $refresh_totals = true ) {
 		$cart_item = $this->get_cart_item( $item_id );

 		if ( empty( $cart_item ) ) {
@@ -271,7 +273,7 @@ class CartController {
 		}

 		$cart = $this->get_cart_instance();
-		$cart->set_quantity( $item_id, $quantity );
+		$cart->set_quantity( $item_id, $quantity, $refresh_totals );
 	}

 	/**
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/UcpCartMapper.php b/plugins/woocommerce/src/StoreApi/Utilities/UcpCartMapper.php
new file mode 100644
index 00000000000..8e03356aa8d
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Utilities/UcpCartMapper.php
@@ -0,0 +1,193 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Utilities;
+
+/**
+ * Maps a Store API cart response onto the UCP cart shape.
+ *
+ * Reads {@see \Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema} output, so
+ * cart tax display, minor units, image filters and stock validation follow the
+ * storefront. Stateless; the catalog's {@see \Automattic\WooCommerce\Internal\ProductFeed\Mapping\UcpProductMapper}
+ * is the same idea for products.
+ */
+class UcpCartMapper {
+	/**
+	 * Build the UCP cart payload.
+	 *
+	 * @param array                $cart Store API cart response, as nested arrays.
+	 * @param string               $cart_id Cart identifier (a Store API cart token).
+	 * @param array<string, mixed> $context Optional `messages` to merge in.
+	 * @return array
+	 */
+	public function build_cart( array $cart, string $cart_id, array $context = array() ): array {
+		$payload = array(
+			'id'         => $cart_id,
+			'currency'   => (string) $cart['totals']['currency_code'],
+			'line_items' => $this->line_items( (array) $cart['items'] ),
+			'totals'     => $this->totals( (array) $cart['totals'] ),
+		);
+
+		$expires_at = $this->expires_at( $cart_id );
+		if ( '' !== $expires_at ) {
+			$payload['expires_at'] = $expires_at;
+		}
+
+		$messages = array_merge(
+			is_array( $context['messages'] ?? null ) ? $context['messages'] : array(),
+			$this->error_messages( (array) ( $cart['errors'] ?? array() ) )
+		);
+		if ( ! empty( $messages ) ) {
+			$payload['messages'] = $messages;
+		}
+
+		return $payload;
+	}
+
+	/**
+	 * Build the UCP line items.
+	 *
+	 * `item.id` is the variation id for variations, matching the ids catalog hands out.
+	 *
+	 * @param array $items Store API cart items.
+	 * @return array
+	 */
+	private function line_items( array $items ): array {
+		$line_items = array();
+
+		foreach ( $items as $cart_item ) {
+			// The Store API keeps variation attributes apart from the name; a UCP title is
+			// one string, so rejoin them as "Parent - a, b". WooCommerce builds stored
+			// variation names the same way but leaves the attributes out past two; here
+			// every attribute is kept, since the agent has no other place to read them.
+			$title  = (string) $cart_item['name'];
+			$values = array_column( (array) $cart_item['variation'], 'value' );
+			if ( ! empty( $values ) ) {
+				$title .= ' - ' . implode( ', ', $values );
+			}
+
+			$item = array(
+				'id'    => (string) $cart_item['id'],
+				'title' => $title,
+				'price' => (int) $cart_item['prices']['price'],
+			);
+
+			$image_url = $cart_item['images'][0]['thumbnail'] ?? '';
+			if ( is_string( $image_url ) && '' !== $image_url ) {
+				$item['image_url'] = $image_url;
+			}
+
+			$line_items[] = array(
+				'id'       => (string) $cart_item['key'],
+				'item'     => $item,
+				'quantity' => (int) $cart_item['quantity'],
+				'totals'   => array(
+					array(
+						'type'   => 'subtotal',
+						'amount' => (int) $cart_item['totals']['line_subtotal'],
+					),
+					array(
+						'type'   => 'total',
+						'amount' => (int) $cart_item['totals']['line_total'],
+					),
+				),
+			);
+		}
+
+		return $line_items;
+	}
+
+	/**
+	 * Build the totals breakdown.
+	 *
+	 * `subtotal`, `tax` and `total` are always present; a zero `tax` is valid (schema
+	 * `minimum` 0) and tells the agent no tax applied. `discount`/`fulfillment`/`fee` are
+	 * dropped when zero — the schema forbids a zero `discount` (`exclusiveMaximum` 0), and a
+	 * zero shipping or fee is nothing to show. Discounts stay order-level: no agent can apply
+	 * a code without the discounts extension, so there is nothing to attribute.
+	 *
+	 * @param array $cart_totals Store API cart totals, already in minor units.
+	 * @return array
+	 */
+	private function totals( array $cart_totals ): array {
+		$totals = array(
+			array(
+				'type'   => 'subtotal',
+				'amount' => (int) $cart_totals['total_items'],
+			),
+		);
+
+		$discount = (int) $cart_totals['total_discount'];
+		if ( $discount > 0 ) {
+			$totals[] = array(
+				'type'   => 'discount',
+				'amount' => -$discount,
+			);
+		}
+
+		// Null until shipping is calculated.
+		$shipping = (int) ( $cart_totals['total_shipping'] ?? 0 );
+		if ( $shipping > 0 ) {
+			$totals[] = array(
+				'type'   => 'fulfillment',
+				'amount' => $shipping,
+			);
+		}
+
+		$fees = (int) $cart_totals['total_fees'];
+		if ( $fees > 0 ) {
+			$totals[] = array(
+				'type'   => 'fee',
+				'amount' => $fees,
+			);
+		}
+
+		$totals[] = array(
+			'type'   => 'tax',
+			'amount' => (int) $cart_totals['total_tax'],
+		);
+
+		$totals[] = array(
+			'type'   => 'total',
+			'amount' => (int) $cart_totals['total_price'],
+		);
+
+		return $totals;
+	}
+
+	/**
+	 * Surface the cart's own validation errors, such as stock that ran out since the
+	 * item was added, as recoverable messages.
+	 *
+	 * @param array $errors Store API cart errors, each `{ code, message }`.
+	 * @return array
+	 */
+	private function error_messages( array $errors ): array {
+		$messages = array();
+
+		foreach ( $errors as $error ) {
+			$messages[] = array(
+				'type'         => 'warning',
+				'code'         => UcpUtils::cart_error_code( (string) ( $error['code'] ?? '' ) ),
+				'content'      => (string) ( $error['message'] ?? '' ),
+				'severity'     => 'recoverable',
+				'path'         => '$.line_items',
+				'content_type' => 'plain',
+			);
+		}
+
+		return $messages;
+	}
+
+	/**
+	 * Resolve the cart's expiry from its token.
+	 *
+	 * @param string $cart_id Cart identifier.
+	 * @return string RFC 3339 timestamp, or '' when the token carries no expiry.
+	 */
+	private function expires_at( string $cart_id ): string {
+		$expiry = (int) ( CartTokenUtils::get_cart_token_payload( $cart_id )['exp'] ?? 0 );
+
+		return $expiry > 0 ? gmdate( 'c', $expiry ) : '';
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php b/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
index 83ccbb12f94..9f77520eaf7 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
@@ -29,6 +29,11 @@ class UcpUtils {
 	 */
 	const CAPABILITY_CATALOG_LOOKUP = 'dev.ucp.shopping.catalog.lookup';

+	/**
+	 * Capability served by the `/carts` routes.
+	 */
+	const CAPABILITY_CART = 'dev.ucp.shopping.cart';
+
 	/**
 	 * Whether the UCP routes are registered.
 	 *
@@ -75,6 +80,11 @@ class UcpUtils {
 				'spec'    => 'https://ucp.dev/' . self::VERSION . '/specification/catalog/lookup',
 				'schema'  => 'https://ucp.dev/' . self::VERSION . '/schemas/shopping/catalog_lookup.json',
 			),
+			self::CAPABILITY_CART           => array(
+				'version' => self::VERSION,
+				'spec'    => 'https://ucp.dev/' . self::VERSION . '/specification/cart',
+				'schema'  => 'https://ucp.dev/' . self::VERSION . '/schemas/shopping/cart.json',
+			),
 		);
 	}

@@ -87,4 +97,66 @@ class UcpUtils {
 	private static function capability_declaration( string $capability ): array {
 		return self::capability_declarations()[ $capability ] ?? array( 'version' => self::VERSION );
 	}
+
+	/**
+	 * Build a UCP error response.
+	 *
+	 * The envelope shape every route shares. `$status` follows the UCP split: a malformed
+	 * request keeps a non-2xx status, a valid request that ends in a refusal is a business
+	 * outcome reported at 200 through `ucp.status` and `messages`.
+	 *
+	 * @param string $capability One of the CAPABILITY_* constants.
+	 * @param string $code Error code.
+	 * @param string $message Error message.
+	 * @param string $severity Error severity.
+	 * @param int    $status HTTP status.
+	 * @param string $path JSONPath of the offending field.
+	 * @return \WP_REST_Response
+	 */
+	public static function error_response( string $capability, string $code, string $message, string $severity, int $status, string $path = '$' ): \WP_REST_Response {
+		return new \WP_REST_Response(
+			array(
+				'ucp'      => self::response_metadata( $capability, 'error' ),
+				'messages' => array(
+					array(
+						'type'         => 'error',
+						'code'         => $code,
+						'content'      => $message,
+						'severity'     => $severity,
+						'path'         => $path,
+						'content_type' => 'plain',
+					),
+				),
+			),
+			$status
+		);
+	}
+
+	/**
+	 * Translate a cart error code into the UCP code closest to it.
+	 *
+	 * The cart names more reasons than UCP does, so the ones an agent can act on
+	 * differently — retry smaller, fix the quantity, or re-read the cart — stay apart
+	 * and the rest collapse.
+	 *
+	 * @param string $code Store API error code.
+	 * @param string $fallback UCP code for a cart error UCP has no closer name for.
+	 * @return string One of `out_of_stock`, `not_found`, `invalid_quantity` or the fallback.
+	 */
+	public static function cart_error_code( string $code, string $fallback = 'item_unavailable' ): string {
+		switch ( $code ) {
+			case 'woocommerce_rest_product_out_of_stock':
+			case 'woocommerce_rest_product_partially_out_of_stock':
+			case 'woocommerce_rest_product_too_many_in_cart':
+				return 'out_of_stock';
+			case 'woocommerce_rest_cart_invalid_key':
+				return 'not_found';
+			case 'woocommerce_rest_product_invalid_quantity':
+			case 'invalid_quantity':
+			case 'readonly_quantity':
+				return 'invalid_quantity';
+			default:
+				return $fallback;
+		}
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/class-woocommerce-test.php b/plugins/woocommerce/tests/php/includes/class-woocommerce-test.php
index 9bd14dd3985..0698ff7c204 100644
--- a/plugins/woocommerce/tests/php/includes/class-woocommerce-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-woocommerce-test.php
@@ -139,6 +139,25 @@ class WooCommerce_Test extends \WC_Unit_Test_Case {
 		$this->assertFalse( WC()->is_store_api_request(), 'A non-Store API ?rest_route= request should not be detected as Store API.' );
 	}

+	/**
+	 * @testdox Should detect a UCP request as a Store API request, since it also returns data rather than pages.
+	 */
+	public function test_is_store_api_request_returns_true_for_ucp_pretty_permalinks(): void {
+		$_SERVER['REQUEST_URI'] = '/wp-json/wc/ucp/v1/carts';
+
+		$this->assertTrue( WC()->is_store_api_request(), 'A /wp-json/wc/ucp/ path should be detected as Store API.' );
+	}
+
+	/**
+	 * @testdox Should detect a plain-permalink UCP request as a Store API request.
+	 */
+	public function test_is_store_api_request_returns_true_for_ucp_plain_permalinks(): void {
+		$_SERVER['REQUEST_URI'] = '/index.php?rest_route=/wc/ucp/v1/carts';
+		$_GET['rest_route']     = '/wc/ucp/v1/carts';
+
+		$this->assertTrue( WC()->is_store_api_request(), 'A ?rest_route=/wc/ucp/ request should be detected as Store API.' );
+	}
+
 	/**
 	 * @testdox Should not detect a Store-API-like value in a query argument as a Store API request.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCart.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCart.php
new file mode 100644
index 00000000000..2659c0a934d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCart.php
@@ -0,0 +1,1170 @@
+<?php
+/**
+ * UCP Cart Route Tests.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
+
+use Automattic\WooCommerce\StoreApi\Routes\V1\Ucp\AbstractCartRoute;
+use Automattic\WooCommerce\StoreApi\SessionHandler;
+use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils;
+
+/**
+ * Tests for the /wc/ucp/v1/carts endpoints.
+ */
+class UcpCart extends ControllerTestCase {
+	/**
+	 * Published product IDs shared by the class, oldest first.
+	 *
+	 * @var int[]
+	 */
+	private static $product_ids = array();
+
+	/**
+	 * A product with exactly one unit in stock.
+	 *
+	 * @var int
+	 */
+	private static $limited_stock_id = 0;
+
+	/**
+	 * Variable parent id and one of its variation ids.
+	 *
+	 * @var array{parent: int, variation: int}
+	 */
+	private static $variable = array(
+		'parent'    => 0,
+		'variation' => 0,
+	);
+
+	/**
+	 * Option behind the `ucp` feature.
+	 */
+	private const FEATURE_OPTION = 'woocommerce_feature_ucp_enabled';
+
+	/**
+	 * Create immutable fixtures before route registration starts.
+	 */
+	public static function wpSetUpBeforeClass(): void {
+		$products = self::create_class_fixture_products(
+			array(
+				array(
+					'name'          => 'UCP Cart Mug',
+					'regular_price' => '19.99',
+					'price'         => '19.99',
+					'sku'           => 'ucp-cart-mug',
+				),
+				array(
+					'name'          => 'UCP Cart Poster',
+					'regular_price' => '5.00',
+					'price'         => '5.00',
+					'sku'           => 'ucp-cart-poster',
+				),
+			)
+		);
+
+		self::$product_ids = array_map(
+			static function ( $product ) {
+				return $product->get_id();
+			},
+			$products
+		);
+
+		$limited = self::create_class_fixture_products(
+			array(
+				array(
+					'name'           => 'UCP Cart Last One',
+					'regular_price'  => '9.00',
+					'price'          => '9.00',
+					'manage_stock'   => true,
+					'stock_quantity' => 1,
+				),
+			)
+		);
+
+		self::$limited_stock_id = $limited[0]->get_id();
+
+		$fixtures  = new \Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData();
+		$parent    = $fixtures->get_variable_product( array( 'name' => 'UCP Cart Tee' ), array( $fixtures->get_product_attribute( 'size', array( 'small', 'large' ) ) ) );
+		$variation = $fixtures->get_variation_product(
+			$parent->get_id(),
+			array( 'pa_size' => 'small' ),
+			array( 'regular_price' => '12.00' )
+		);
+
+		// Sync so the parent reports a price and therefore passes is_purchasable(), as a
+		// real variable product does — that is what makes the parent reach add_to_cart
+		// unless the route refuses it first.
+		\WC_Product_Variable::sync( $parent->get_id() );
+
+		self::$variable = array(
+			'parent'    => $parent->get_id(),
+			'variation' => $variation->get_id(),
+		);
+	}
+
+	/**
+	 * Delete class fixtures.
+	 */
+	public static function wpTearDownAfterClass(): void {
+		self::delete_class_fixture_products(
+			array_merge(
+				self::$product_ids,
+				array( self::$limited_stock_id, self::$variable['variation'], self::$variable['parent'] )
+			)
+		);
+	}
+
+	/**
+	 * 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.
+	 */
+	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.
+	 *
+	 * @param string $method HTTP method.
+	 * @param string $path Route path under /wc/ucp/v1.
+	 * @param array  $body Request body.
+	 * @return \WP_REST_Response
+	 */
+	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' );
+		$request->set_body( wp_json_encode( $body ) );
+
+		return rest_get_server()->dispatch( $request );
+	}
+
+	/**
+	 * Create a cart holding the given line items.
+	 *
+	 * @param array $line_items UCP line items.
+	 * @return array The response data.
+	 */
+	private function create_cart( array $line_items ): array {
+		$response = $this->request( 'POST', '/carts', array( 'line_items' => $line_items ) );
+
+		$this->assertSame( 200, $response->get_status() );
+
+		return $response->get_data();
+	}
+
+	/**
+	 * Build a UCP line item for one of the fixture products.
+	 *
+	 * @param int $index Fixture index.
+	 * @param int $quantity Quantity.
+	 * @return array
+	 */
+	private function line_item( int $index, int $quantity ): array {
+		return array(
+			'item'     => array( 'id' => (string) self::$product_ids[ $index ] ),
+			'quantity' => $quantity,
+		);
+	}
+
+	/**
+	 * Start a request the way a fresh HTTP request would.
+	 *
+	 * The session and cart are loaded once per process. Persist what the current
+	 * request would save on shutdown, then drop them and the "loaded" marker so the
+	 * next route builds its own session handler through `wc_load_cart()`, exactly as a
+	 * real REST request does; a UCP route then binds the token handler to the token it
+	 * minted or was given.
+	 *
+	 * @param string $cart_token Cart token the next request carries, or '' for none.
+	 */
+	private function simulate_new_request( string $cart_token = '' ): void {
+		if ( WC()->session ) {
+			// WC_Session_Handler::save_data() is a no-op without a session cookie, and no
+			// cookie is ever sent in this suite. A real cart request sets one through
+			// woocommerce_set_cart_cookies, so flip the same flag before saving.
+			if ( WC()->session instanceof \WC_Session_Handler ) {
+				$has_cookie = new \ReflectionProperty( WC()->session, '_has_cookie' );
+				$has_cookie->setAccessible( true );
+				$has_cookie->setValue( WC()->session, true );
+			}
+
+			WC()->session->save_data();
+		}
+
+		if ( '' === $cart_token ) {
+			unset( $_SERVER['HTTP_CART_TOKEN'] );
+		} else {
+			$_SERVER['HTTP_CART_TOKEN'] = $cart_token;
+		}
+
+		WC()->session  = null;
+		WC()->customer = null;
+		WC()->cart     = null;
+		// Per-process "already done" markers a real request would not carry over.
+		unset( $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'], $GLOBALS['wp_actions']['woocommerce_after_calculate_totals'] );
+	}
+
+	/**
+	 * Read one entry out of a UCP totals array.
+	 *
+	 * @param array  $totals Totals array.
+	 * @param string $type Total type.
+	 * @return int|null
+	 */
+	private function total( array $totals, string $type ) {
+		foreach ( $totals as $entry ) {
+			if ( $type === $entry['type'] ) {
+				return $entry['amount'];
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * The routes must not exist unless a site opts in.
+	 */
+	public function test_routes_are_not_registered_by_default(): void {
+		delete_option( self::FEATURE_OPTION );
+		$this->initialize_store_api_server();
+
+		$this->assertSame( 404, $this->request( 'POST', '/carts' )->get_status() );
+		$this->assertSame( 404, $this->request( 'GET', '/carts/abc' )->get_status() );
+		$this->assertSame( 404, $this->request( 'POST', '/carts/abc/cancel' )->get_status() );
+	}
+
+	/**
+	 * A UCP cart route never reads the browser's session cookie.
+	 *
+	 * Cart creation carries no token, so the parent would leave the cookie handler in
+	 * place and bind the route to whatever cart the caller's cookie names. With
+	 * `line_items` read as the complete desired state, a cross-site POST would then
+	 * rewrite that shopper's cart, and `requires_nonce()` returning false leaves nothing
+	 * else in the way. The handler is fixed for every route here instead.
+	 */
+	public function test_cart_routes_never_bind_to_the_session_cookie(): void {
+		unset( $_SERVER['HTTP_CART_TOKEN'] );
+
+		$routes = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+		$route  = $routes->get( 'ucp-carts', 'ucp' );
+
+		$bind = new \ReflectionMethod( $route, 'load_cart_session' );
+		$bind->setAccessible( true );
+		$bind->invoke( $route, new \WP_REST_Request( 'POST', '/wc/ucp/v1/carts' ) );
+
+		try {
+			$this->assertSame(
+				SessionHandler::class,
+				apply_filters( 'woocommerce_session_handler', \WC_Session_Handler::class )
+			);
+		} finally {
+			remove_filter( 'woocommerce_session_handler', array( AbstractCartRoute::class, 'session_handler_class' ) );
+		}
+	}
+
+	/**
+	 * A request naming more lines than the ceiling is refused before any product lookup.
+	 */
+	public function test_create_cart_refuses_too_many_line_items(): void {
+		$line_items = array_fill( 0, AbstractCartRoute::LINE_ITEMS_MAX + 1, $this->line_item( 0, 1 ) );
+
+		$response = $this->request( 'POST', '/carts', array( 'line_items' => $line_items ) );
+		$data     = $response->get_data();
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'error', $data['ucp']['status'] );
+		$this->assertSame( 'VALIDATION_ERROR', $data['messages'][0]['code'] );
+		$this->assertSame( '$.line_items', $data['messages'][0]['path'] );
+
+		// Exactly the ceiling is still accepted.
+		$this->create_cart( array_slice( $line_items, 0, AbstractCartRoute::LINE_ITEMS_MAX ) );
+	}
+
+	/**
+	 * A product that exists but no cart line can hold — here a grouped product — is
+	 * reported `item_unavailable`, not `not_found`: the id resolves, it just is not orderable.
+	 */
+	public function test_create_cart_refuses_an_unpurchasable_product_type(): void {
+		$grouped = new \WC_Product_Grouped();
+		$grouped->set_name( 'UCP Grouped' );
+		$grouped_id = $grouped->save();
+
+		$response = $this->request( 'POST', '/carts', array( 'line_items' => array( array( 'item' => array( 'id' => (string) $grouped_id ) ) ) ) );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'error', $data['ucp']['status'] );
+		$this->assertSame( 'item_unavailable', $data['messages'][0]['code'] );
+		$this->assertSame( '$.line_items[0].item.id', $data['messages'][0]['path'] );
+	}
+
+	/**
+	 * An id that matches no product at all stays `not_found`, keeping it apart from a
+	 * product that exists but cannot be ordered.
+	 */
+	public function test_create_cart_reports_an_unknown_product_id_as_not_found(): void {
+		$response = $this->request( 'POST', '/carts', array( 'line_items' => array( array( 'item' => array( 'id' => '99999999' ) ) ) ) );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'error', $data['ucp']['status'] );
+		$this->assertSame( 'not_found', $data['messages'][0]['code'] );
+	}
+
+	/**
+	 * A quantity-limit refusal is reported as `invalid_quantity`, distinct from a gone item.
+	 *
+	 * An agent can act on the two differently — fix the quantity vs. drop the line — so the
+	 * mapping keeps them apart instead of collapsing both to `item_unavailable`.
+	 */
+	public function test_update_cart_reports_a_quantity_limit_as_invalid_quantity(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+		// Cap the product at one per cart, then ask for two.
+		$cap = static function () {
+			return 1;
+		};
+		add_filter( 'woocommerce_store_api_product_quantity_maximum', $cap );
+
+		$response = $this->request( 'PUT', '/carts/' . $created['id'], array( 'line_items' => array( $this->line_item( 0, 2 ) ) ) );
+		$data     = $response->get_data();
+
+		remove_filter( 'woocommerce_store_api_product_quantity_maximum', $cap );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'error', $data['ucp']['status'] );
+		$this->assertSame( 'invalid_quantity', $data['messages'][0]['code'] );
+
+		// A refused update leaves the cart alone.
+		$this->assertCount( 1, $this->request( 'GET', '/carts/' . $created['id'] )->get_data()['line_items'] );
+	}
+
+	/**
+	 * A failure the Store API base class reports in its own shape is re-shaped for UCP.
+	 *
+	 * The parent refuses to load a cart whose session failed earlier in the process; that
+	 * is the same exit a throwing plugin or a session error reaches.
+	 */
+	public function test_errors_from_the_store_api_base_class_wear_the_ucp_envelope(): void {
+		$cart_id = $this->create_cart( array( $this->line_item( 0, 1 ) ) )['id'];
+
+		\WC_Cart_Session::set_updates_enabled_for_cart( WC()->cart, false );
+
+		$response = $this->request( 'GET', '/carts/' . $cart_id );
+		$data     = $response->get_data();
+
+		$this->assertSame( 500, $response->get_status() );
+		$this->assertSame( 'error', $data['ucp']['status'] );
+		$this->assertSame( 'TRANSPORT_ERROR', $data['messages'][0]['code'] );
+		$this->assertSame( 'unrecoverable', $data['messages'][0]['severity'] );
+		$this->assertArrayNotHasKey( 'code', $data, 'The Store API error shape must not leak through.' );
+	}
+
+	/**
+	 * A cart error the parent reports keeps its UCP code and status; only errors UCP has
+	 * no name for become transport errors.
+	 */
+	public function test_parent_errors_keep_their_cart_code(): void {
+		$route  = rest_get_server()->get_routes()['/wc/ucp/v1/carts'][0]['callback'][0];
+		$method = new \ReflectionMethod( $route, 'error_to_response' );
+		$method->setAccessible( true );
+
+		$refused = $method->invoke( $route, new \WP_Error( 'woocommerce_rest_product_out_of_stock', 'Gone.', array( 'status' => 409 ) ) );
+		$this->assertSame( 409, $refused->get_status() );
+		$this->assertSame( 'out_of_stock', $refused->get_data()['messages'][0]['code'] );
+		$this->assertSame( 'recoverable', $refused->get_data()['messages'][0]['severity'] );
+		$this->assertSame( 'Gone.', $refused->get_data()['messages'][0]['content'] );
+
+		$unknown = $method->invoke( $route, new \WP_Error( 'woocommerce_rest_cart_error', 'Session failed.', array( 'status' => 500 ) ) );
+		$this->assertSame( 500, $unknown->get_status() );
+		$this->assertSame( 'TRANSPORT_ERROR', $unknown->get_data()['messages'][0]['code'] );
+		$this->assertSame( 'unrecoverable', $unknown->get_data()['messages'][0]['severity'] );
+	}
+
+	/**
+	 * Creating a cart answers the UCP cart shape, with amounts in minor units.
+	 */
+	public function test_create_cart_returns_ucp_cart(): void {
+		$data = $this->create_cart( array( $this->line_item( 0, 2 ) ) );
+
+		$this->assertSame( '2026-04-08', $data['ucp']['version'] );
+		$this->assertSame( 'success', $data['ucp']['status'] );
+		// The envelope names only the capability relevant to the operation answered.
+		$this->assertSame( array( 'dev.ucp.shopping.cart' ), array_keys( $data['ucp']['capabilities'] ) );
+
+		$this->assertNotEmpty( $data['id'] );
+		$this->assertSame( get_woocommerce_currency(), $data['currency'] );
+
+		$this->assertCount( 1, $data['line_items'] );
+		$line_item = $data['line_items'][0];
+		$this->assertSame( (string) self::$product_ids[0], $line_item['item']['id'] );
+		$this->assertSame( 'UCP Cart Mug', $line_item['item']['title'] );
+		$this->assertSame( 1999, $line_item['item']['price'] );
+		$this->assertSame( 2, $line_item['quantity'] );
+		$this->assertSame( 3998, $this->total( $line_item['totals'], 'subtotal' ) );
+
+		$this->assertSame( 3998, $this->total( $data['totals'], 'subtotal' ) );
+		$this->assertSame( 3998, $this->total( $data['totals'], 'total' ) );
+
+		// `tax` is always present, zero included (schema allows it); a zero `discount` is omitted.
+		$this->assertSame( 0, $this->total( $data['totals'], 'tax' ) );
+		$this->assertNull( $this->total( $data['totals'], 'discount' ) );
+	}
+
+	/**
+	 * A cart reads back with the contents it was created with.
+	 */
+	public function test_get_cart_returns_the_same_contents(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+		$response = $this->request( 'GET', '/carts/' . $created['id'] );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $created['id'], $data['id'] );
+		$this->assertSame( $created['line_items'], $data['line_items'] );
+	}
+
+	/**
+	 * An update is a full replacement: a product left out of `line_items` is removed.
+	 */
+	public function test_update_cart_replaces_line_items(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+		$both = $this->request(
+			'PUT',
+			'/carts/' . $created['id'],
+			array(
+				'line_items' => array(
+					$this->line_item( 0, 3 ),
+					$this->line_item( 1, 1 ),
+				),
+			)
+		)->get_data();
+
+		$this->assertCount( 2, $both['line_items'] );
+		$this->assertSame( array( 3, 1 ), wp_list_pluck( $both['line_items'], 'quantity' ) );
+
+		// Dropping a line must remove it, not merge the two payloads.
+		$dropped = $this->request(
+			'PUT',
+			'/carts/' . $created['id'],
+			array( 'line_items' => array( $this->line_item( 1, 1 ) ) )
+		)->get_data();
+
+		$this->assertCount( 1, $dropped['line_items'] );
+		$this->assertSame( (string) self::$product_ids[1], $dropped['line_items'][0]['item']['id'] );
+		$this->assertSame( 500, $this->total( $dropped['totals'], 'total' ) );
+	}
+
+	/**
+	 * Add a second line of a fixture product, distinguished only by cart item data.
+	 *
+	 * This is the shape a product add-on or personalization field produces: the same
+	 * product on two lines, because the cart item key hashes the extra data.
+	 *
+	 * @param int $index Fixture index.
+	 * @param int $quantity Quantity.
+	 */
+	private function add_personalized_line( int $index, int $quantity ): void {
+		WC()->cart->add_to_cart( self::$product_ids[ $index ], $quantity, 0, array(), array( 'engraving' => 'For Ada' ) );
+		WC()->cart->calculate_totals();
+	}
+
+	/**
+	 * Echoing a cart back unchanged must leave it unchanged.
+	 *
+	 * Two lines of one product are told apart by `line_items[].id`, the cart item key.
+	 * Reconciling on the product alone would write the summed quantity onto one line and
+	 * leave the other standing, so a no-op round trip would grow the cart every time.
+	 */
+	public function test_update_cart_round_trip_does_not_inflate_duplicate_lines(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+		$this->add_personalized_line( 0, 2 );
+
+		$read = $this->request( 'GET', '/carts/' . $created['id'] )->get_data();
+
+		$this->assertCount( 2, $read['line_items'] );
+		$this->assertSame( 5997, $this->total( $read['totals'], 'total' ) );
+		$this->assertNotSame( $read['line_items'][0]['id'], $read['line_items'][1]['id'] );
+
+		$echoed = $this->request( 'PUT', '/carts/' . $created['id'], array( 'line_items' => $read['line_items'] ) )->get_data();
+
+		$this->assertSame( array( 1, 2 ), wp_list_pluck( $echoed['line_items'], 'quantity' ) );
+		$this->assertSame( 5997, $this->total( $echoed['totals'], 'total' ) );
+	}
+
+	/**
+	 * Clearing the cart removes every line, including duplicates of one product.
+	 */
+	public function test_update_cart_clears_duplicate_lines_of_one_product(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+		$this->add_personalized_line( 0, 2 );
+
+		$cleared = $this->request( 'PUT', '/carts/' . $created['id'], array( 'line_items' => array() ) )->get_data();
+
+		$this->assertSame( array(), $cleared['line_items'] );
+	}
+
+	/**
+	 * A payload naming no line ids describes the cart by product, so duplicates collapse.
+	 */
+	public function test_update_cart_collapses_duplicate_lines_when_no_id_is_given(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+		$this->add_personalized_line( 0, 2 );
+
+		$collapsed = $this->request(
+			'PUT',
+			'/carts/' . $created['id'],
+			array( 'line_items' => array( $this->line_item( 0, 4 ) ) )
+		)->get_data();
+
+		$this->assertCount( 1, $collapsed['line_items'] );
+		$this->assertSame( 4, $collapsed['line_items'][0]['quantity'] );
+		$this->assertSame( 7996, $this->total( $collapsed['totals'], 'total' ) );
+	}
+
+	/**
+	 * A line id the cart no longer holds falls back to the product it names.
+	 */
+	public function test_update_cart_ignores_a_stale_line_id(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+		$stale = array_merge( $this->line_item( 0, 2 ), array( 'id' => 'a-line-that-is-gone' ) );
+
+		$updated = $this->request( 'PUT', '/carts/' . $created['id'], array( 'line_items' => array( $stale ) ) )->get_data();
+
+		$this->assertCount( 1, $updated['line_items'] );
+		$this->assertSame( 2, $updated['line_items'][0]['quantity'] );
+	}
+
+	/**
+	 * Omitting `line_items` leaves the cart alone; an empty list clears it.
+	 */
+	public function test_update_cart_distinguishes_omitted_from_empty_line_items(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+		$untouched = $this->request( 'PUT', '/carts/' . $created['id'] )->get_data();
+		$this->assertCount( 1, $untouched['line_items'] );
+
+		$cleared = $this->request( 'PUT', '/carts/' . $created['id'], array( 'line_items' => array() ) )->get_data();
+		$this->assertSame( array(), $cleared['line_items'] );
+	}
+
+	/**
+	 * Only PUT updates a cart, as the cart REST binding specifies.
+	 *
+	 * PATCH matters most: `line_items` is the complete desired state, so a partial
+	 * payload taken as an update would delete every line it left out.
+	 */
+	public function test_only_put_updates_a_cart(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+		foreach ( array( 'POST', 'PATCH', 'DELETE' ) as $method ) {
+			$response = $this->request( $method, '/carts/' . $created['id'], array( 'line_items' => array() ) );
+
+			$this->assertSame( 404, $response->get_status(), $method . ' on a cart' );
+			$this->assertSame( 'rest_no_route', $response->get_data()['code'], $method . ' on a cart' );
+		}
+
+		// None of them emptied the cart.
+		$this->assertCount( 1, $this->request( 'GET', '/carts/' . $created['id'] )->get_data()['line_items'] );
+	}
+
+	/**
+	 * Malformed line items are refused in the envelope, with the offending path.
+	 */
+	public function test_update_cart_reports_invalid_line_items(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+		$missing_id = $this->request(
+			'PUT',
+			'/carts/' . $created['id'],
+			array( 'line_items' => array( array( 'quantity' => 1 ) ) )
+		);
+
+		$this->assertSame( 400, $missing_id->get_status() );
+		$this->assertSame( 'error', $missing_id->get_data()['ucp']['status'] );
+		$this->assertSame( 'VALIDATION_ERROR', $missing_id->get_data()['messages'][0]['code'] );
+		$this->assertSame( '$.line_items[0].item.id', $missing_id->get_data()['messages'][0]['path'] );
+
+		$zero_quantity = $this->request(
+			'PUT',
+			'/carts/' . $created['id'],
+			array( 'line_items' => array( array_merge( $this->line_item( 0, 1 ), array( 'quantity' => 0 ) ) ) )
+		);
+
+		$this->assertSame( 400, $zero_quantity->get_status() );
+		$this->assertSame( '$.line_items[0].quantity', $zero_quantity->get_data()['messages'][0]['path'] );
+
+		// A well-formed payload naming an id the store cannot sell is a business outcome,
+		// reported at 200 like every other `not_found`, not a malformed request.
+		$unknown_product = $this->request(
+			'PUT',
+			'/carts/' . $created['id'],
+			array(
+				'line_items' => array(
+					array(
+						'item'     => array( 'id' => '99999999' ),
+						'quantity' => 1,
+					),
+				),
+			)
+		);
+
+		$this->assertSame( 200, $unknown_product->get_status() );
+		$this->assertSame( 'error', $unknown_product->get_data()['ucp']['status'] );
+		$this->assertSame( 'not_found', $unknown_product->get_data()['messages'][0]['code'] );
+		$this->assertSame( '$.line_items[0].item.id', $unknown_product->get_data()['messages'][0]['path'] );
+
+		// The cart itself is untouched by a refused update.
+		$this->assertCount( 1, $this->request( 'GET', '/carts/' . $created['id'] )->get_data()['line_items'] );
+	}
+
+	/**
+	 * A variable product is bought by naming one of its variants.
+	 *
+	 * The variant id is what catalog hands out, and what the cart echoes back, so an id
+	 * from search round-trips into a cart without translation.
+	 */
+	public function test_create_cart_accepts_a_variation_id(): void {
+		$data = $this->create_cart(
+			array(
+				array(
+					'item'     => array( 'id' => (string) self::$variable['variation'] ),
+					'quantity' => 2,
+				),
+			)
+		);
+
+		$this->assertCount( 1, $data['line_items'] );
+		$this->assertSame( (string) self::$variable['variation'], $data['line_items'][0]['item']['id'] );
+		// The parent title plus the variation's attribute values, as WC names a variation.
+		$this->assertSame( 'UCP Cart Tee - small', $data['line_items'][0]['item']['title'] );
+		$this->assertSame( 1200, $data['line_items'][0]['item']['price'] );
+		$this->assertSame( 2400, $this->total( $data['totals'], 'total' ) );
+	}
+
+	/**
+	 * A variable parent is refused, in the envelope, telling the agent to name a variant.
+	 *
+	 * The parent reports a price so it passes `is_purchasable()`, and letting it through
+	 * would reach `CartController::add_to_cart()` and surface a Store API error shape with
+	 * no `ucp` object for a client to read.
+	 */
+	public function test_create_cart_refuses_a_variable_parent(): void {
+		$response = $this->request(
+			'POST',
+			'/carts',
+			array(
+				'line_items' => array(
+					array(
+						'item'     => array( 'id' => (string) self::$variable['parent'] ),
+						'quantity' => 1,
+					),
+				),
+			)
+		);
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'error', $data['ucp']['status'] );
+		$this->assertSame( 'item_unavailable', $data['messages'][0]['code'] );
+		$this->assertSame( '$.line_items[0].item.id', $data['messages'][0]['path'] );
+	}
+
+	/**
+	 * A stock ceiling is reported as `out_of_stock`, in the envelope, with the cart's message.
+	 *
+	 * The cart distinguishes more refusals than UCP names codes for, so the ones an agent
+	 * can act on differently are kept apart: a stock ceiling invites a smaller quantity,
+	 * where `item_unavailable` would only say "no".
+	 */
+	public function test_update_cart_reports_a_stock_ceiling_as_out_of_stock(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+		$response = $this->request(
+			'PUT',
+			'/carts/' . $created['id'],
+			array(
+				'line_items' => array(
+					array(
+						'item'     => array( 'id' => (string) self::$limited_stock_id ),
+						'quantity' => 5,
+					),
+				),
+			)
+		);
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'error', $data['ucp']['status'] );
+		$this->assertSame( 'out_of_stock', $data['messages'][0]['code'] );
+		// The cart's own wording names the product and what is left, which is actionable.
+		$this->assertStringContainsString( 'not enough stock', $data['messages'][0]['content'] );
+
+		// A refused update leaves the cart alone.
+		$this->assertCount( 1, $this->request( 'GET', '/carts/' . $created['id'] )->get_data()['line_items'] );
+	}
+
+	/**
+	 * A product that is entirely out of stock is refused on add, mapped to `out_of_stock`.
+	 */
+	public function test_add_out_of_stock_product_is_reported_as_out_of_stock(): void {
+		$product = new \WC_Product_Simple();
+		$product->set_name( 'UCP Sold Out' );
+		$product->set_regular_price( '7.00' );
+		$product->set_stock_status( 'outofstock' );
+		$sold_out_id = $product->save();
+
+		$response = $this->request( 'POST', '/carts', array( 'line_items' => array( array( 'item' => array( 'id' => (string) $sold_out_id ) ) ) ) );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'error', $data['ucp']['status'] );
+		$this->assertSame( 'out_of_stock', $data['messages'][0]['code'] );
+		$this->assertSame( '$.line_items', $data['messages'][0]['path'] );
+	}
+
+	/**
+	 * An id this store never signed is reported as not found, in the envelope.
+	 */
+	public function test_unknown_cart_id_is_not_found(): void {
+		$response = $this->request( 'GET', '/carts/not-a-real-token' );
+		$data     = $response->get_data();
+
+		// UCP clients read ucp.status and messages, not the HTTP status.
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'error', $data['ucp']['status'] );
+		$this->assertSame( 'not_found', $data['messages'][0]['code'] );
+		$this->assertArrayNotHasKey( 'line_items', $data );
+		$this->assertSame( 'no-store', $response->get_headers()['Cache-Control'] ?? null, 'A 200 not-found must not be cacheable.' );
+	}
+
+	/**
+	 * A cart an extension preloaded is not written by a UCP create: it mints its own session.
+	 */
+	public function test_preloaded_cookie_session_is_not_written_by_create(): void {
+		// Stand in for an extension that loads the cart on `init`: a cookie session is in
+		// memory and the "loaded" marker is set, so the route's handler swap would be a no-op
+		// unless the route discards it first.
+		WC()->initialize_session();
+		WC()->initialize_cart();
+		WC()->cart->add_to_cart( self::$product_ids[0], 1 );
+		$GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] = 1; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulates an extension that already loaded the cart.
+
+		$this->assertInstanceOf( \WC_Session_Handler::class, WC()->session );
+		$cookie_customer = WC()->session->get_customer_id();
+
+		$response = $this->request( 'POST', '/carts', array( 'line_items' => array( $this->line_item( 1, 5 ) ) ) );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'success', $data['ucp']['status'] );
+
+		// The returned id is signed from the session the create wrote to. It must be a fresh
+		// token, not the preloaded cookie session, or the create clobbered that shopper's cart.
+		$written_customer = CartTokenUtils::get_cart_token_payload( $data['id'] )['user_id'];
+		$this->assertNotSame( $cookie_customer, $written_customer, 'A create must not write the preloaded cookie session.' );
+	}
+
+	/**
+	 * Cancel answers with the cart as it stood, then the id stops resolving.
+	 */
+	public function test_cancel_cart_returns_prior_state_then_not_found(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 2 ) ) );
+
+		$cancelled = $this->request( 'POST', '/carts/' . $created['id'] . '/cancel' );
+		$data      = $cancelled->get_data();
+
+		$this->assertSame( 200, $cancelled->get_status() );
+		$this->assertSame( 'success', $data['ucp']['status'] );
+		// "Business MUST return the cart state before deletion."
+		$this->assertCount( 1, $data['line_items'] );
+		$this->assertSame( 2, $data['line_items'][0]['quantity'] );
+		$this->assertSame( 'CANCELED', $data['messages'][0]['code'] );
+
+		foreach ( array( 'GET', 'PUT' ) as $method ) {
+			$after = $this->request( $method, '/carts/' . $created['id'] )->get_data();
+			$this->assertSame( 'not_found', $after['messages'][0]['code'], $method . ' on a cancelled cart' );
+		}
+
+		$again = $this->request( 'POST', '/carts/' . $created['id'] . '/cancel' )->get_data();
+		$this->assertSame( 'not_found', $again['messages'][0]['code'] );
+	}
+
+	/**
+	 * A caller can still create carts after cancelling one.
+	 *
+	 * Creation never inherits a session, so the sticky cancellation marker on the old
+	 * cart's row cannot reach the new cart.
+	 */
+	public function test_a_cart_can_be_created_after_cancelling_one(): void {
+		$cancelled = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+		$this->request( 'POST', '/carts/' . $cancelled['id'] . '/cancel' );
+
+		$this->simulate_new_request();
+		$fresh = $this->create_cart( array( $this->line_item( 1, 2 ) ) );
+
+		$this->assertCount( 1, $fresh['line_items'] );
+		$this->assertSame( 2, $fresh['line_items'][0]['quantity'] );
+		$this->assertFalse( (bool) WC()->session->get( AbstractCartRoute::CANCELLED_SESSION_KEY ) );
+
+		$this->simulate_new_request( $fresh['id'] );
+		$read_back = $this->request( 'GET', '/carts/' . $fresh['id'] )->get_data();
+		$this->assertSame( $fresh['id'], $read_back['id'] );
+		$this->assertSame( $fresh['line_items'], $read_back['line_items'] );
+	}
+
+	/**
+	 * A read recalculates, so totals match the prices shown.
+	 *
+	 * The session stores the totals from the last request; `prices` come from the live
+	 * product. A price change in between would otherwise show a new unit price next to
+	 * old line and cart totals.
+	 */
+	public function test_reading_a_cart_recalculates_totals(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 2 ) ) );
+		$this->assertSame( 3998, $this->total( $created['totals'], 'total' ) );
+
+		$product = wc_get_product( self::$product_ids[0] );
+		$product->set_regular_price( '10.00' );
+		$product->save();
+
+		try {
+			$this->simulate_new_request( $created['id'] );
+			$data = $this->request( 'GET', '/carts/' . $created['id'] )->get_data();
+		} finally {
+			$product->set_regular_price( '19.99' );
+			$product->save();
+		}
+
+		$this->assertSame( 1000, $data['line_items'][0]['item']['price'] );
+		$this->assertSame( 2000, $this->total( $data['line_items'][0]['totals'], 'subtotal' ) );
+		$this->assertSame( 2000, $this->total( $data['totals'], 'total' ) );
+	}
+
+	/**
+	 * Items are named by id only; a SKU is not an identifier the catalog hands out.
+	 */
+	public function test_create_cart_does_not_resolve_items_by_sku(): void {
+		$data = $this->request( 'POST', '/carts', array( 'line_items' => array( array( 'item' => array( 'id' => 'ucp-cart-mug' ) ) ) ) )->get_data();
+
+		$this->assertSame( 'error', $data['ucp']['status'] );
+		$this->assertSame( 'not_found', $data['messages'][0]['code'] );
+		$this->assertSame( '$.line_items[0].item.id', $data['messages'][0]['path'] );
+	}
+
+	/**
+	 * A failure that is not a cart refusal still leaves the cart as it was.
+	 *
+	 * Removals run before adds, so a hook throwing during an add would otherwise leave
+	 * the cart emptied of what the update was replacing.
+	 */
+	public function test_update_cart_restores_contents_when_a_hook_throws(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 2 ) ) );
+
+		$explode = function () {
+			throw new \RuntimeException( 'hook failure' );
+		};
+		add_action( 'woocommerce_store_api_validate_add_to_cart', $explode );
+
+		try {
+			$response = $this->request( 'PUT', '/carts/' . $created['id'], array( 'line_items' => array( $this->line_item( 1, 1 ) ) ) );
+		} finally {
+			remove_action( 'woocommerce_store_api_validate_add_to_cart', $explode );
+		}
+
+		$this->assertSame( 500, $response->get_status() );
+		$this->assertSame( 'TRANSPORT_ERROR', $response->get_data()['messages'][0]['code'] );
+
+		$this->simulate_new_request( $created['id'] );
+		$data = $this->request( 'GET', '/carts/' . $created['id'] )->get_data();
+
+		$this->assertCount( 1, $data['line_items'] );
+		$this->assertSame( (string) self::$product_ids[0], $data['line_items'][0]['item']['id'] );
+		$this->assertSame( 2, $data['line_items'][0]['quantity'] );
+	}
+
+	/**
+	 * Creating a cart as a logged-in user must not touch that user's own session.
+	 *
+	 * The token handler keys a tokenless session on the user id, so without a minted
+	 * guest id the empty new session would be saved over the shopper's stored cart.
+	 */
+	public function test_create_cart_while_logged_in_leaves_the_user_session_alone(): void {
+		global $wpdb;
+
+		$user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $user_id );
+
+		$stored = maybe_serialize( array( 'cart' => array( 'keep-me' => array( 'quantity' => 3 ) ) ) );
+		$wpdb->insert(
+			$wpdb->prefix . 'woocommerce_sessions',
+			array(
+				'session_key'    => (string) $user_id,
+				'session_value'  => $stored,
+				'session_expiry' => time() + DAY_IN_SECONDS,
+			)
+		);
+
+		try {
+			$this->simulate_new_request();
+			$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+			$this->assertNotSame( (string) $user_id, CartTokenUtils::get_cart_token_payload( $created['id'] )['user_id'] );
+
+			// What shutdown would write.
+			WC()->session->save_data();
+		} finally {
+			wp_set_current_user( 0 );
+		}
+
+		$this->assertSame(
+			$stored,
+			$wpdb->get_var( $wpdb->prepare( "SELECT session_value FROM {$wpdb->prefix}woocommerce_sessions WHERE session_key = %s", (string) $user_id ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		);
+	}
+
+	/**
+	 * A cancelled cart id stays dead on a later request.
+	 *
+	 * Covers what the single-process checks cannot: the marker is written to the
+	 * cancelled cart's own session row, and a request carrying that id loads the row
+	 * back and is refused.
+	 */
+	public function test_a_cancelled_cart_id_is_still_not_found_on_a_later_request(): void {
+		$cancelled = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+		$this->request( 'POST', '/carts/' . $cancelled['id'] . '/cancel' );
+
+		// A later request with no token: a different caller, or the same one without cookies.
+		$this->simulate_new_request();
+		$fresh = $this->create_cart( array( $this->line_item( 1, 1 ) ) );
+		$this->assertNotSame( $cancelled['id'], $fresh['id'] );
+
+		// A request that does carry the cancelled id.
+		$this->simulate_new_request( $cancelled['id'] );
+
+		foreach ( array( 'GET', 'PUT' ) as $method ) {
+			$response = $this->request( $method, '/carts/' . $cancelled['id'] );
+
+			$this->assertSame( 'not_found', $response->get_data()['messages'][0]['code'], $method . ' on a cancelled cart' );
+			$this->assertArrayNotHasKey( 'line_items', $response->get_data() );
+		}
+
+		$this->assertSame(
+			'not_found',
+			$this->request( 'POST', '/carts/' . $cancelled['id'] . '/cancel' )->get_data()['messages'][0]['code']
+		);
+	}
+
+	/**
+	 * Creating a cart with a `Cart-Token` header mints a fresh session instead of
+	 * binding to the named one, so a cancelled cart cannot be revived that way.
+	 */
+	public function test_create_cart_ignores_a_cart_token_header(): void {
+		$cancelled = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+		$this->request( 'POST', '/carts/' . $cancelled['id'] . '/cancel' );
+
+		// As over HTTP, the bootstrap has already bound the session to the header's token.
+		$this->simulate_new_request( $cancelled['id'] );
+		$fresh = $this->create_cart( array( $this->line_item( 1, 1 ) ) );
+
+		$this->assertNotSame(
+			CartTokenUtils::get_cart_token_payload( $cancelled['id'] )['user_id'],
+			CartTokenUtils::get_cart_token_payload( $fresh['id'] )['user_id']
+		);
+
+		$this->simulate_new_request( $cancelled['id'] );
+		$this->assertSame( 'not_found', $this->request( 'GET', '/carts/' . $cancelled['id'] )->get_data()['messages'][0]['code'] );
+	}
+
+	/**
+	 * Cancelling a cart must not break a storefront session cloned from it.
+	 *
+	 * The marker lives in the WooCommerce session, which a storefront session cloned
+	 * from this token would carry too, so only the UCP routes may act on it.
+	 */
+	public function test_cancelling_a_cart_leaves_the_storefront_session_usable(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+		$this->request( 'POST', '/carts/' . $created['id'] . '/cancel' );
+
+		$this->assertTrue( (bool) WC()->session->get( AbstractCartRoute::CANCELLED_SESSION_KEY ) );
+
+		$store_api = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/cart' ) );
+
+		$this->assertSame( 200, $store_api->get_status() );
+		$this->assertSame( array(), $store_api->get_data()['items'] );
+	}
+
+	/**
+	 * A cart request leaves the token slot as it found it.
+	 *
+	 * The slot is per-process, so a route that kept its token there would bind the next
+	 * dispatch in the same process — `rest_do_request()` from other code, or a test — to
+	 * this cart's session.
+	 */
+	public function test_a_cart_request_does_not_leak_its_token_to_the_next_dispatch(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+		$this->assertSame( '', CartTokenUtils::get_request_cart_token(), 'after create' );
+
+		$this->request( 'GET', '/carts/' . $created['id'] );
+		$this->assertSame( '', CartTokenUtils::get_request_cart_token(), 'after read' );
+
+		$this->request( 'PUT', '/carts/' . $created['id'], array( 'line_items' => array( $this->line_item( 0, 2 ) ) ) );
+		$this->assertSame( '', CartTokenUtils::get_request_cart_token(), 'after update' );
+
+		// A request that arrived with its own token keeps it, rather than being cleared.
+		$_SERVER['HTTP_CART_TOKEN'] = $created['id'];
+		$this->request( 'GET', '/carts/' . $created['id'] );
+		$this->assertSame( $created['id'], CartTokenUtils::get_request_cart_token(), 'inherited token preserved' );
+	}
+
+	/**
+	 * The cart id comes from the path only, never from the body or query string.
+	 */
+	public function test_cart_id_is_read_from_the_path_only(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+		$request = new \WP_REST_Request( 'GET', '/wc/ucp/v1/carts/' . $created['id'] );
+		$request->set_query_params( array( 'id' => 'someone-elses-token' ) );
+
+		$this->assertSame( $created['id'], rest_get_server()->dispatch( $request )->get_data()['id'] );
+	}
+
+	/**
+	 * Item prices are pre-tax whatever the cart display setting, so price times
+	 * quantity equals the line subtotal and tax is only in the itemised totals.
+	 */
+	public function test_item_price_is_pre_tax_under_inclusive_cart_display(): void {
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_tax_display_cart', 'incl' );
+		$rate_id = \WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate'       => '10.0000',
+				'tax_rate_name'  => 'UCP VAT',
+				'tax_rate_class' => '',
+			)
+		);
+
+		try {
+			$data = $this->create_cart( array( $this->line_item( 0, 2 ) ) );
+		} finally {
+			\WC_Tax::_delete_tax_rate( $rate_id );
+			update_option( 'woocommerce_calc_taxes', 'no' );
+			update_option( 'woocommerce_tax_display_cart', 'excl' );
+		}
+
+		// 19.99 at 10%: the price stays 19.99, the tax line carries the 10%.
+		$this->assertSame( 1999, $data['line_items'][0]['item']['price'] );
+		$this->assertSame( 3998, $this->total( $data['line_items'][0]['totals'], 'subtotal' ) );
+		$this->assertSame( 3998, $this->total( $data['totals'], 'subtotal' ) );
+		$this->assertSame( 400, $this->total( $data['totals'], 'tax' ) );
+		$this->assertSame( 4398, $this->total( $data['totals'], 'total' ) );
+	}
+
+	/**
+	 * Agents compare titles literally, so the storefront HTML formatter must not
+	 * reach them, the same way catalog keeps product names raw.
+	 */
+	public function test_item_title_is_raw(): void {
+		$product = new \WC_Product_Simple();
+		$product->set_name( 'Frame 10x20 "Classic"' );
+		$product->set_regular_price( '7.00' );
+		$product_id = $product->save();
+
+		$data = $this->create_cart( array( array( 'item' => array( 'id' => (string) $product_id ) ) ) );
+
+		$this->assertSame( 'Frame 10x20 "Classic"', $data['line_items'][0]['item']['title'] );
+	}
+
+	/**
+	 * Errors the Store API cart validation reports, such as an extension refusing the
+	 * cart through `woocommerce_store_api_cart_errors`, reach the agent as recoverable
+	 * messages instead of surfacing at checkout.
+	 */
+	public function test_reading_a_cart_reports_store_api_cart_errors(): void {
+		$created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+		$this->assertArrayNotHasKey( 'messages', $created );
+
+		$refuse = function ( \WP_Error $errors ) {
+			$errors->add( 'woocommerce_rest_product_partially_out_of_stock', 'Only 1 of "UCP Cart Mug" left.' );
+		};
+		add_action( 'woocommerce_store_api_cart_errors', $refuse );
+
+		try {
+			$data = $this->request( 'GET', '/carts/' . $created['id'] )->get_data();
+		} finally {
+			remove_action( 'woocommerce_store_api_cart_errors', $refuse );
+		}
+
+		$this->assertSame( 'success', $data['ucp']['status'] );
+		$this->assertCount( 1, $data['line_items'] );
+		$this->assertSame( 'out_of_stock', $data['messages'][0]['code'] );
+		$this->assertSame( 'warning', $data['messages'][0]['type'] );
+		$this->assertSame( 'recoverable', $data['messages'][0]['severity'] );
+		$this->assertSame( '$.line_items', $data['messages'][0]['path'] );
+		$this->assertSame( 'Only 1 of "UCP Cart Mug" left.', $data['messages'][0]['content'] );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php
index c1a3f2eb58e..4072a7c373f 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php
@@ -72,16 +72,9 @@ class UcpCatalog extends ControllerTestCase {
 	 * Enable the UCP routes before the REST server is built.
 	 */
 	protected function setUp(): void {
-		update_option( self::FEATURE_OPTION, 'yes' );
 		parent::setUp();
-	}
-
-	/**
-	 * Restore the default disabled state.
-	 */
-	protected function tearDown(): void {
-		delete_option( self::FEATURE_OPTION );
-		parent::tearDown();
+		update_option( self::FEATURE_OPTION, 'yes' );
+		$this->initialize_store_api_server();
 	}

 	/**