Commit 713728a50ec for woocommerce

commit 713728a50ec76ad180d85521e9f6d9d47247feb0
Author: Darren Ethier <darren@roughsmootheng.in>
Date:   Mon May 11 13:54:18 2026 -0400

    Add canonical WooCommerce domain abilities (#64606)

    Add canonical WooCommerce domain abilities for product and order management.

    Introduce domain-backed Abilities API definitions for product query/create/update/delete and order query/status update/note creation, with strict WooCommerce-aware schemas, permission checks, extension registration hooks, reserved WooCommerce namespace handling, and REST/MCP projection metadata. Add coverage for registration, validation, permissions, edge cases, and execution behavior.

diff --git a/plugins/woocommerce/changelog/rsm-1335-domain-abilities b/plugins/woocommerce/changelog/rsm-1335-domain-abilities
new file mode 100644
index 00000000000..61d5946a8ed
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-1335-domain-abilities
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add canonical WooCommerce domain abilities for product and order management.
diff --git a/plugins/woocommerce/src/Abilities/AbilityDefinition.php b/plugins/woocommerce/src/Abilities/AbilityDefinition.php
new file mode 100644
index 00000000000..f7b3f9f9c9a
--- /dev/null
+++ b/plugins/woocommerce/src/Abilities/AbilityDefinition.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Ability definition interface file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Abilities;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Defines a WooCommerce ability registration class.
+ *
+ * @since 10.9.0
+ */
+interface AbilityDefinition {
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @return string
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_name(): string;
+
+	/**
+	 * Get the arguments used to register the ability with the WordPress Abilities API.
+	 *
+	 * @return array
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_registration_args(): array;
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesCategories.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesCategories.php
index 495493fdbc0..95342880d29 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesCategories.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesCategories.php
@@ -33,6 +33,8 @@ class AbilitiesCategories {

 	/**
 	 * Register WooCommerce ability categories.
+	 *
+	 * @since 10.9.0
 	 */
 	public static function register_categories(): void {
 		// Only register if the function exists.
@@ -40,12 +42,22 @@ class AbilitiesCategories {
 			return;
 		}

+		if ( ! function_exists( 'wp_has_ability_category' ) || ! wp_has_ability_category( 'woocommerce' ) ) {
+			wp_register_ability_category(
+				'woocommerce',
+				array(
+					'label'       => __( 'WooCommerce', 'woocommerce' ),
+					'description' => __( 'Canonical store management abilities backed by domain APIs.', 'woocommerce' ),
+				)
+			);
+		}
+
 		if ( ! function_exists( 'wp_has_ability_category' ) || ! wp_has_ability_category( 'woocommerce-rest' ) ) {
 			wp_register_ability_category(
 				'woocommerce-rest',
 				array(
 					'label'       => __( 'WooCommerce REST API', 'woocommerce' ),
-					'description' => __( 'REST API operations for WooCommerce resources including products, orders, and other store data.', 'woocommerce' ),
+					'description' => __( 'REST API operations for store resources including products, orders, and other store data.', 'woocommerce' ),
 				)
 			);
 		}
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
new file mode 100644
index 00000000000..ad6b96c8209
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -0,0 +1,223 @@
+<?php
+/**
+ * Abilities loader class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Internal\Abilities\Domain\OrderAddNote;
+use Automattic\WooCommerce\Internal\Abilities\Domain\OrderUpdateStatus;
+use Automattic\WooCommerce\Internal\Abilities\Domain\OrdersQuery;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductCreate;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductDelete;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductUpdate;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductsQuery;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Hooks WooCommerce ability definitions into the WordPress Abilities API.
+ */
+class AbilitiesLoader {
+
+	/**
+	 * Whether the loader hooks have been registered.
+	 *
+	 * @var bool
+	 */
+	private static bool $initialized = false;
+
+	/**
+	 * Canonical WooCommerce domain ability definition classes.
+	 *
+	 * @var array<int, class-string>
+	 */
+	private const CORE_ABILITY_DEFINITION_CLASSES = array(
+		OrdersQuery::class,
+		OrderAddNote::class,
+		OrderUpdateStatus::class,
+		ProductsQuery::class,
+		ProductCreate::class,
+		ProductDelete::class,
+		ProductUpdate::class,
+	);
+
+	/**
+	 * Log source for ability registration notices.
+	 *
+	 * @var string
+	 */
+	private const LOG_SOURCE = 'woocommerce-abilities';
+
+	/**
+	 * Core ability instances registered by this loader in the current request.
+	 *
+	 * @var array<string, object>
+	 */
+	private static array $registered_core_abilities = array();
+
+	/**
+	 * Initialize ability registration hooks.
+	 *
+	 * @internal
+	 *
+	 * @since 10.9.0
+	 */
+	final public static function init(): void {
+		if ( self::$initialized ) {
+			return;
+		}
+
+		/*
+		 * Register abilities when Abilities API is ready.
+		 * Support both old (pre-6.9) and new (6.9+) action names.
+		 */
+		AbilitiesCategories::init();
+		add_action( 'abilities_api_init', array( __CLASS__, 'register_abilities' ) );
+		add_action( 'wp_abilities_api_init', array( __CLASS__, 'register_abilities' ) );
+
+		AbilitiesRestBridge::init();
+
+		self::$initialized = true;
+	}
+
+	/**
+	 * Register all configured ability definitions.
+	 *
+	 * @since 10.9.0
+	 */
+	public static function register_abilities(): void {
+		if ( ! function_exists( 'wp_register_ability' ) ) {
+			return;
+		}
+
+		foreach ( self::get_ability_definition_classes() as $class_name ) {
+			if ( ! is_string( $class_name ) || ! class_exists( $class_name ) ) {
+				continue;
+			}
+
+			if ( ! is_a( $class_name, AbilityDefinition::class, true ) ) {
+				continue;
+			}
+
+			$ability_name = $class_name::get_name();
+
+			if ( '' === $ability_name ) {
+				continue;
+			}
+
+			$is_core_ability = self::is_core_ability_definition_class( $class_name );
+
+			if ( self::is_reserved_woocommerce_ability_name( $ability_name ) && ! $is_core_ability ) {
+				continue;
+			}
+
+			if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability_name ) ) {
+				$existing_ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( $ability_name ) : null;
+
+				if (
+					$is_core_ability
+					&& isset( self::$registered_core_abilities[ $ability_name ] )
+					&& $existing_ability === self::$registered_core_abilities[ $ability_name ]
+				) {
+					continue;
+				}
+
+				if ( ! $is_core_ability || ! function_exists( 'wp_unregister_ability' ) ) {
+					continue;
+				}
+
+				// Drop stale instance tracking before replacing a shadowed registration.
+				unset( self::$registered_core_abilities[ $ability_name ] );
+				wp_unregister_ability( $ability_name );
+				self::log_replaced_reserved_ability( $ability_name, $class_name );
+			}
+
+			$registered_ability = wp_register_ability( $ability_name, $class_name::get_registration_args() );
+
+			if ( $is_core_ability && null !== $registered_ability ) {
+				self::$registered_core_abilities[ $ability_name ] = $registered_ability;
+			}
+		}
+	}
+
+	/**
+	 * Log when WooCommerce replaces a pre-existing registration in its reserved namespace.
+	 *
+	 * @param string       $ability_name Ability name.
+	 * @param class-string $class_name Ability definition class name.
+	 */
+	private static function log_replaced_reserved_ability( string $ability_name, string $class_name ): void {
+		if ( ! function_exists( 'wc_get_logger' ) ) {
+			return;
+		}
+
+		wc_get_logger()->warning(
+			'WooCommerce unregistered a previously registered ability before registering its canonical definition.',
+			array(
+				'source'           => self::LOG_SOURCE,
+				'ability_name'     => $ability_name,
+				'definition_class' => $class_name,
+				'reserved_prefix'  => 'woocommerce/',
+			)
+		);
+	}
+
+	/**
+	 * Check whether an ability definition class is provided by WooCommerce core.
+	 *
+	 * @param class-string $class_name Ability definition class name.
+	 * @return bool
+	 */
+	private static function is_core_ability_definition_class( string $class_name ): bool {
+		return in_array( $class_name, self::CORE_ABILITY_DEFINITION_CLASSES, true );
+	}
+
+	/**
+	 * Check whether an ability name uses WooCommerce's reserved namespace.
+	 *
+	 * @param string $ability_name Ability name.
+	 * @return bool
+	 */
+	private static function is_reserved_woocommerce_ability_name( string $ability_name ): bool {
+		return 0 === strpos( $ability_name, 'woocommerce/' );
+	}
+
+	/**
+	 * Get all ability definition classes that should be loaded.
+	 *
+	 * @return array<int, class-string>
+	 */
+	private static function get_ability_definition_classes(): array {
+		/**
+		 * Filter WooCommerce ability definition classes.
+		 *
+		 * Extensions can append autoloadable classes that implement
+		 * {@see AbilityDefinition}. The loader will call get_name() and
+		 * get_registration_args() on each definition class and register the ability on the
+		 * Abilities API init hook. Returning a subset will not unregister core abilities;
+		 * core classes are always retained.
+		 *
+		 * @since 10.9.0
+		 *
+		 * @param array<int, class-string> $classes Ability definition class names.
+		 */
+		$classes = apply_filters( 'woocommerce_ability_definition_classes', self::CORE_ABILITY_DEFINITION_CLASSES );
+
+		if ( ! is_array( $classes ) ) {
+			$classes = array();
+		}
+
+		return array_values(
+			array_unique(
+				array_filter(
+					array_merge( self::CORE_ABILITY_DEFINITION_CLASSES, $classes ),
+					'is_string'
+				)
+			)
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesRegistry.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesRegistry.php
index ede6d19a450..18626c512e8 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesRegistry.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesRegistry.php
@@ -17,19 +17,15 @@ defined( 'ABSPATH' ) || exit;
  */
 class AbilitiesRegistry {

-	/**
-	 * Initialize the registry.
-	 */
-	public function __construct() {
-		$this->init_abilities();
-	}
-
 	/**
 	 * Initialize all WooCommerce abilities.
+	 *
+	 * @since 10.9.0
+	 *
+	 * @internal
 	 */
-	private function init_abilities(): void {
-		AbilitiesCategories::init();
-		AbilitiesRestBridge::init();
+	final public function init(): void {
+		AbilitiesLoader::init();
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesRestBridge.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesRestBridge.php
index f7bc674de89..d90b2a77668 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesRestBridge.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesRestBridge.php
@@ -47,7 +47,7 @@ class AbilitiesRestBridge {
 						'id'          => 'woocommerce/products-create',
 						'operation'   => 'create',
 						'label'       => __( 'Create Product', 'woocommerce' ),
-						'description' => __( 'Create a new product in WooCommerce with name, price, description, and other product attributes.', 'woocommerce' ),
+						'description' => __( 'Create a new product with name, price, description, and other product attributes.', 'woocommerce' ),
 					),
 					array(
 						'id'          => 'woocommerce/products-update',
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/AbstractDomainAbility.php b/plugins/woocommerce/src/Internal/Abilities/Domain/AbstractDomainAbility.php
new file mode 100644
index 00000000000..fe8af063dc6
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/AbstractDomainAbility.php
@@ -0,0 +1,96 @@
+<?php
+/**
+ * Domain ability base class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Shared helpers for WooCommerce domain ability definitions.
+ */
+abstract class AbstractDomainAbility {
+
+	/**
+	 * Get a collection output schema.
+	 *
+	 * @param string $collection_key Collection property key.
+	 * @param array  $item_schema    JSON schema describing a single item in the collection.
+	 * @return array
+	 */
+	protected static function get_collection_output_schema( string $collection_key, array $item_schema ): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				$collection_key => array(
+					'type'        => 'array',
+					'description' => sprintf(
+						/* translators: %s: Collection key, such as products or orders. */
+						__( 'Returned %s for the current page.', 'woocommerce' ),
+						$collection_key
+					),
+					'items'       => $item_schema,
+				),
+				'total_pages'   => array(
+					'type'        => 'integer',
+					'description' => __( 'Total number of result pages available for the current query.', 'woocommerce' ),
+				),
+				'page'          => array(
+					'type'        => 'integer',
+					'description' => __( 'Current result page.', 'woocommerce' ),
+				),
+				'per_page'      => array(
+					'type'        => 'integer',
+					'description' => __( 'Maximum number of items requested per page.', 'woocommerce' ),
+				),
+			),
+			'additionalProperties' => false,
+		);
+	}
+
+	/**
+	 * Get an entity output schema.
+	 *
+	 * @param string $entity_key  Entity property key.
+	 * @param array  $item_schema JSON schema describing the entity.
+	 * @return array
+	 */
+	protected static function get_entity_output_schema( string $entity_key, array $item_schema ): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				$entity_key => $item_schema,
+			),
+			'additionalProperties' => false,
+		);
+	}
+
+	/**
+	 * Get a delete output schema.
+	 *
+	 * @return array
+	 */
+	protected static function get_delete_output_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'deleted' => array( 'type' => 'boolean' ),
+				'id'      => array( 'type' => 'integer' ),
+			),
+			'additionalProperties' => false,
+		);
+	}
+
+	/**
+	 * Get an ID value from ability input.
+	 *
+	 * @param mixed $input Ability input.
+	 * @return int
+	 */
+	protected static function get_id_from_input( $input ): int {
+		return is_array( $input ) && ! empty( $input['id'] ) ? (int) $input['id'] : 0;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
new file mode 100644
index 00000000000..caf469eb270
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
@@ -0,0 +1,144 @@
+<?php
+/**
+ * Order add note ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce order add note ability.
+ */
+class OrderAddNote extends AbstractDomainAbility implements AbilityDefinition {
+
+	use OrderAbilityTrait;
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @return string
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/order-add-note';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @return array
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => __( 'Add order note', 'woocommerce' ),
+			'description'         => __(
+				'Add a note to an order.',
+				'woocommerce'
+			),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => self::get_order_note_output_schema(),
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_edit_order' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => false,
+					'idempotent'  => false,
+					'destructive' => false,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Add an order note.
+	 *
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 *
+	 * @since 10.9.0
+	 */
+	public static function execute( array $input ) {
+		$order = self::get_order_from_input( $input );
+
+		if ( is_wp_error( $order ) ) {
+			return $order;
+		}
+
+		$note = isset( $input['note'] ) ? trim( wp_kses_post( (string) $input['note'] ) ) : '';
+
+		if ( '' === $note ) {
+			return new \WP_Error(
+				'woocommerce_order_note_required',
+				__( 'Order note is required.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		$note_id = $order->add_order_note(
+			$note,
+			( (bool) ( $input['customer_note'] ?? false ) ) ? 1 : 0,
+			get_current_user_id() > 0
+		);
+
+		if ( $note_id <= 0 ) {
+			return new \WP_Error(
+				'woocommerce_order_note_create_failed',
+				__( 'Failed to add order note.', 'woocommerce' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		return array(
+			'note_id' => (int) $note_id,
+			'order'   => self::format_order_for_response( $order, false ),
+		);
+	}
+
+	/**
+	 * Get the ability input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id'            => array(
+					'type'    => 'integer',
+					'minimum' => 1,
+				),
+				'note'          => array(
+					'type'        => 'string',
+					'description' => __( 'Order note content. Safe HTML is allowed.', 'woocommerce' ),
+					'minLength'   => 1,
+					'pattern'     => '\S',
+				),
+				'customer_note' => array(
+					'type'        => 'boolean',
+					'description' => __(
+						'Whether the note is visible to the customer. Defaults to false for a private/admin note.',
+						'woocommerce'
+					),
+					'default'     => false,
+				),
+			),
+			'required'             => array( 'id', 'note' ),
+			'additionalProperties' => false,
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
new file mode 100644
index 00000000000..688c084941f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
@@ -0,0 +1,158 @@
+<?php
+/**
+ * Order update status ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce order update status ability.
+ */
+class OrderUpdateStatus extends AbstractDomainAbility implements AbilityDefinition {
+
+	use OrderAbilityTrait;
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @return string
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/order-update-status';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @return array
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => __( 'Update order status', 'woocommerce' ),
+			'description'         => __(
+				'Update an order status.',
+				'woocommerce'
+			),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => self::get_entity_output_schema( 'order', self::get_order_output_schema() ),
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_edit_order' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => false,
+					'idempotent'  => false,
+					'destructive' => true,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Update an order status.
+	 *
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 *
+	 * @since 10.9.0
+	 */
+	public static function execute( array $input ) {
+		$order = self::get_order_from_input( $input );
+
+		if ( is_wp_error( $order ) ) {
+			return $order;
+		}
+
+		if ( empty( $input['status'] ) ) {
+			return new \WP_Error(
+				'woocommerce_order_status_required',
+				__( 'Order status is required.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		$status = OrderUtil::remove_status_prefix( sanitize_key( $input['status'] ) );
+
+		if ( ! in_array( $status, self::get_allowed_order_status_slugs(), true ) ) {
+			return new \WP_Error(
+				'woocommerce_order_status_invalid',
+				__( 'Order status is invalid.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		if ( $status === $order->get_status() ) {
+			return new \WP_Error(
+				'woocommerce_order_status_unchanged',
+				__(
+					'Order already has this status. Use the woocommerce/order-add-note ability to add a note without changing status.',
+					'woocommerce'
+				),
+				array( 'status' => 400 )
+			);
+		}
+
+		$updated = $order->update_status(
+			$status,
+			isset( $input['note'] ) ? wp_kses_post( $input['note'] ) : '',
+			true
+		);
+
+		if ( ! $updated ) {
+			return new \WP_Error(
+				'woocommerce_order_status_update_failed',
+				__( 'Failed to update order status.', 'woocommerce' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		return array(
+			'order' => self::format_order_for_response( $order, false ),
+		);
+	}
+
+	/**
+	 * Get the ability input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id'     => array(
+					'type'    => 'integer',
+					'minimum' => 1,
+				),
+				'status' => array(
+					'type'        => 'string',
+					'description' => __( 'Order status slug without the wc- prefix.', 'woocommerce' ),
+					'enum'        => self::get_allowed_order_status_slugs(),
+				),
+				'note'   => array(
+					'type'        => 'string',
+					'description' => __( 'Optional status change note. Safe HTML is allowed. Use the woocommerce/order-add-note ability for notes without a status change.', 'woocommerce' ),
+				),
+			),
+			'required'             => array( 'id', 'status' ),
+			'additionalProperties' => false,
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
new file mode 100644
index 00000000000..e0ede9e7f9d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
@@ -0,0 +1,356 @@
+<?php
+/**
+ * Orders query ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce orders query ability.
+ */
+class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
+
+	use OrderAbilityTrait;
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @return string
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/orders-query';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @return array
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => __( 'Query orders', 'woocommerce' ),
+			'description'         => __(
+				'Find orders by ID or common order filters.',
+				'woocommerce'
+			),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => self::get_collection_output_schema( 'orders', self::get_order_output_schema() ),
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_query_orders' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => true,
+					'idempotent'  => true,
+					'destructive' => false,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Query orders.
+	 *
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 *
+	 * @since 10.9.0
+	 */
+	public static function execute( array $input ) {
+		$include_line_items = (bool) ( $input['include_line_items'] ?? false );
+
+		if ( ! empty( $input['id'] ) ) {
+			$order = self::get_order_from_input( $input );
+
+			if ( is_wp_error( $order ) ) {
+				return $order;
+			}
+
+			return array(
+				'orders'      => array( self::format_order_for_response( $order, $include_line_items ) ),
+				'total_pages' => 1,
+				'page'        => 1,
+				'per_page'    => 1,
+			);
+		}
+
+		$page     = (int) ( $input['page'] ?? 1 );
+		$per_page = (int) ( $input['per_page'] ?? 10 );
+		$args     = array(
+			'limit'    => $per_page,
+			'page'     => $page,
+			'paginate' => true,
+			'return'   => 'objects',
+			'type'     => 'shop_order',
+		);
+
+		foreach ( array( 'status', 'billing_email', 'order' ) as $field ) {
+			if ( ! empty( $input[ $field ] ) && is_scalar( $input[ $field ] ) ) {
+				$args[ $field ] = wc_clean( (string) $input[ $field ] );
+			}
+		}
+
+		if ( empty( $args['status'] ) ) {
+			$args['status'] = self::get_allowed_order_status_slugs();
+		}
+
+		if ( ! empty( $input['orderby'] ) && is_scalar( $input['orderby'] ) ) {
+			$orderby         = sanitize_text_field( (string) $input['orderby'] );
+			$args['orderby'] = self::prepare_orderby_arg( $orderby );
+		}
+
+		foreach ( array( 'customer_id', 'parent' ) as $field ) {
+			if ( isset( $input[ $field ] ) ) {
+				$args[ $field ] = (int) $input[ $field ];
+			}
+		}
+
+		if ( ! empty( $input['exclude'] ) && is_array( $input['exclude'] ) ) {
+			$args['exclude'] = array_map( 'intval', $input['exclude'] );
+		}
+
+		foreach ( array( 'date_after', 'date_before' ) as $field ) {
+			if ( ! empty( $input[ $field ] ) ) {
+				$args[ $field ] = wc_clean( $input[ $field ] );
+			}
+		}
+
+		$modified_date_query = self::build_modified_date_query_arg( $input );
+		if ( null !== $modified_date_query ) {
+			$args['date_query'][] = $modified_date_query;
+		}
+
+		$results = wc_get_orders( $args );
+		$orders  = is_object( $results ) && isset( $results->orders ) ? $results->orders : array();
+		$orders  = array_values(
+			array_filter(
+				$orders,
+				static function ( $order ): bool {
+					return $order instanceof \WC_Order;
+				}
+			)
+		);
+		$pages   = is_object( $results ) && isset( $results->max_num_pages ) ? (int) $results->max_num_pages : ( count( $orders ) > 0 ? 1 : 0 );
+
+		return array(
+			'orders'      => array_map(
+				static function ( $order ) use ( $include_line_items ) {
+					return self::format_order_for_response( $order, $include_line_items );
+				},
+				$orders
+			),
+			'total_pages' => $pages,
+			'page'        => $page,
+			'per_page'    => $per_page,
+		);
+	}
+
+	/**
+	 * Check order read access.
+	 *
+	 * @param mixed $input Ability input.
+	 * @return bool
+	 *
+	 * @since 10.9.0
+	 */
+	public static function can_query_orders( $input = array() ): bool {
+		$order_id = self::get_id_from_input( $input );
+
+		return wc_rest_check_post_permissions( 'shop_order', 'read', $order_id );
+	}
+
+	/**
+	 * Get the ability input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id'                 => array(
+					'type'    => 'integer',
+					'minimum' => 1,
+				),
+				'status'             => array(
+					'type'        => 'string',
+					'description' => __( 'Filter by order status slug without the wc- prefix.', 'woocommerce' ),
+					'enum'        => self::get_allowed_order_status_slugs(),
+				),
+				'customer_id'        => array(
+					'type'        => 'integer',
+					'description' => __( 'Filter by customer ID. Use 0 to filter guest orders.', 'woocommerce' ),
+					'minimum'     => 0,
+				),
+				'billing_email'      => array(
+					'type'   => 'string',
+					'format' => 'email',
+				),
+				'parent'             => array(
+					'type'        => 'integer',
+					'description' => __( 'Filter by parent order ID.', 'woocommerce' ),
+					'minimum'     => 1,
+				),
+				'exclude'            => array(
+					'type'        => 'array',
+					'description' => __( 'Order IDs to exclude from the results.', 'woocommerce' ),
+					'items'       => array(
+						'type'    => 'integer',
+						'minimum' => 1,
+					),
+				),
+				'date_after'         => array(
+					'type'        => 'string',
+					'description' => __( 'Filter orders created after this date/time.', 'woocommerce' ),
+					'format'      => 'date-time',
+				),
+				'date_before'        => array(
+					'type'        => 'string',
+					'description' => __( 'Filter orders created before this date/time.', 'woocommerce' ),
+					'format'      => 'date-time',
+				),
+				'modified_after'     => array(
+					'type'        => 'string',
+					'description' => __( 'Filter orders modified after this date/time.', 'woocommerce' ),
+					'format'      => 'date-time',
+				),
+				'modified_before'    => array(
+					'type'        => 'string',
+					'description' => __( 'Filter orders modified before this date/time.', 'woocommerce' ),
+					'format'      => 'date-time',
+				),
+				'orderby'            => array(
+					'type' => 'string',
+					'enum' => array( 'id', 'date', 'date_modified', 'total' ),
+				),
+				'order'              => array(
+					'type' => 'string',
+					'enum' => array( 'asc', 'desc' ),
+				),
+				'include_line_items' => array(
+					'type'        => 'boolean',
+					'description' => __(
+						'Whether to include order line items in each returned order. Defaults to false.',
+						'woocommerce'
+					),
+					'default'     => false,
+				),
+				'page'               => array(
+					'type'    => 'integer',
+					'default' => 1,
+					'minimum' => 1,
+				),
+				'per_page'           => array(
+					'type'    => 'integer',
+					'default' => 10,
+					'minimum' => 1,
+					'maximum' => 100,
+				),
+			),
+			'additionalProperties' => false,
+			'default'              => array(),
+		);
+	}
+
+	/**
+	 * Build a modified-date query arg from modified_after/modified_before input.
+	 *
+	 * @param array $input Ability input.
+	 * @return array|null
+	 */
+	private static function build_modified_date_query_arg( array $input ): ?array {
+		$after  = isset( $input['modified_after'] ) && is_string( $input['modified_after'] ) ? sanitize_text_field( $input['modified_after'] ) : '';
+		$before = isset( $input['modified_before'] ) && is_string( $input['modified_before'] ) ? sanitize_text_field( $input['modified_before'] ) : '';
+
+		if ( '' === $after && '' === $before ) {
+			return null;
+		}
+
+		$after_timestamp  = '' !== $after ? self::prepare_date_time_for_query( $after ) : null;
+		$before_timestamp = '' !== $before ? self::prepare_date_time_for_query( $before ) : null;
+
+		if (
+			( '' !== $after && null === $after_timestamp )
+			|| ( '' !== $before && null === $before_timestamp )
+		) {
+			return null;
+		}
+
+		$date_query = array(
+			'column'    => 'post_modified_gmt',
+			'inclusive' => false,
+		);
+
+		if ( null !== $after_timestamp ) {
+			$date_query['after'] = self::format_timestamp_for_date_query( $after_timestamp );
+		}
+
+		if ( null !== $before_timestamp ) {
+			$date_query['before'] = self::format_timestamp_for_date_query( $before_timestamp );
+		}
+
+		return $date_query;
+	}
+
+	/**
+	 * Prepare a date-time string as a timestamp for second-precision order queries.
+	 *
+	 * @param string $date_time Date-time string.
+	 * @return int|null
+	 */
+	private static function prepare_date_time_for_query( string $date_time ): ?int {
+		try {
+			return wc_string_to_datetime( $date_time )->getTimestamp();
+		} catch ( \Exception $exception ) {
+			return null;
+		}
+	}
+
+	/**
+	 * Format a timestamp for a GMT date query.
+	 *
+	 * @param int $timestamp Timestamp.
+	 * @return string
+	 */
+	private static function format_timestamp_for_date_query( int $timestamp ): string {
+		return gmdate( 'Y-m-d H:i:s', $timestamp );
+	}
+
+	/**
+	 * Prepare orderby values for wc_get_orders across HPOS and legacy storage.
+	 *
+	 * Values not present in the map are already accepted by wc_get_orders for
+	 * both storage engines and pass through unchanged.
+	 *
+	 * @param string $orderby Input orderby value.
+	 * @return string
+	 */
+	private static function prepare_orderby_arg( string $orderby ): string {
+		$orderby_map = array(
+			'id' => 'ID',
+		);
+
+		if ( 'date_modified' === $orderby ) {
+			return OrderUtil::custom_orders_table_usage_is_enabled() ? 'date_modified' : 'post_modified';
+		}
+
+		return $orderby_map[ $orderby ] ?? $orderby;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
new file mode 100644
index 00000000000..9a79df1083d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
@@ -0,0 +1,136 @@
+<?php
+/**
+ * Product create ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce product create ability.
+ */
+class ProductCreate extends AbstractDomainAbility implements AbilityDefinition {
+
+	use ProductAbilityTrait;
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @return string
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/product-create';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @return array
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => __( 'Create product', 'woocommerce' ),
+			'description'         => __(
+				'Create a product using supported catalog fields.',
+				'woocommerce'
+			),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => self::get_entity_output_schema( 'product', self::get_product_output_schema() ),
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_create_product' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => false,
+					'idempotent'  => false,
+					'destructive' => false,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Create a product.
+	 *
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 *
+	 * @since 10.9.0
+	 */
+	public static function execute( array $input ) {
+		$product_config = self::get_product_config_for_alias( $input['product_type_alias'] ?? 'physical' );
+
+		if ( is_wp_error( $product_config ) ) {
+			return $product_config;
+		}
+
+		$product = wc_get_product_object( $product_config['wc_type'] );
+
+		if ( ! $product ) {
+			return new \WP_Error(
+				'woocommerce_invalid_product_type',
+				__( 'Invalid product type.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		try {
+			self::apply_product_type_config( $product, $product_config );
+
+			$validation_error = self::set_product_props_from_input( $product, $input, $product_config );
+			if ( is_wp_error( $validation_error ) ) {
+				return $validation_error;
+			}
+		} catch ( \WC_Data_Exception $exception ) {
+			return self::get_product_data_exception_error( $exception );
+		}
+
+		$save_error = self::save_product( $product, 'woocommerce_product_create_failed' );
+		if ( is_wp_error( $save_error ) ) {
+			return $save_error;
+		}
+
+		return array(
+			'product' => self::format_product_for_response( $product ),
+		);
+	}
+
+	/**
+	 * Check product creation access.
+	 *
+	 * @param mixed $input Ability input.
+	 * @return bool
+	 *
+	 * @since 10.9.0
+	 */
+	public static function can_create_product( $input = array() ): bool {
+		// Cap is not object-scoped for create.
+		unset( $input );
+
+		return wc_rest_check_post_permissions( 'product', 'create' );
+	}
+
+	/**
+	 * Get the ability input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		return self::get_product_create_input_schema();
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
new file mode 100644
index 00000000000..1d674a673ab
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
@@ -0,0 +1,172 @@
+<?php
+/**
+ * Product delete ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce product delete ability.
+ */
+class ProductDelete extends AbstractDomainAbility implements AbilityDefinition {
+
+	use ProductAbilityTrait;
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @return string
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/product-delete';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @return array
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => __( 'Delete product', 'woocommerce' ),
+			'description'         => __(
+				'Delete, trash, or restore a product.',
+				'woocommerce'
+			),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => self::get_delete_output_schema(),
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_delete_product' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => false,
+					'idempotent'  => true,
+					'destructive' => true,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Delete a product.
+	 *
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 *
+	 * @since 10.9.0
+	 */
+	public static function execute( array $input ) {
+		$product = self::get_product_from_input( $input );
+
+		if ( is_wp_error( $product ) ) {
+			return $product;
+		}
+
+		if ( $product->is_type( ProductType::VARIATION ) ) {
+			return new \WP_Error(
+				'woocommerce_product_type_unsupported',
+				__( 'Product type is not supported by this ability.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		$product_id = $product->get_id();
+		$force      = (bool) ( $input['force'] ?? false );
+
+		/**
+		 * Filter whether a product supports trashing in WooCommerce domain abilities.
+		 *
+		 * @param bool        $supports_trash Whether the product supports trashing.
+		 * @param \WC_Product $product        The product being considered for trashing.
+		 *
+		 * @since 10.9.0
+		 */
+		$supports_trash = apply_filters( 'woocommerce_product_object_trashable', EMPTY_TRASH_DAYS > 0, $product );
+
+		if ( ! $force && ! $supports_trash ) {
+			return new \WP_Error(
+				'woocommerce_trash_not_supported',
+				__( 'Trash is disabled on this site. Pass force: true to permanently delete.', 'woocommerce' ),
+				array( 'status' => 501 )
+			);
+		}
+
+		$deleted = $product->delete( $force );
+
+		if (
+			! $deleted
+			|| ( $force && null !== get_post( $product_id ) )
+			|| ( ! $force && 'trash' !== get_post_status( $product_id ) )
+		) {
+			return new \WP_Error(
+				'woocommerce_product_delete_failed',
+				__( 'Failed to delete product.', 'woocommerce' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		return array(
+			'deleted' => (bool) $deleted,
+			'id'      => $product_id,
+		);
+	}
+
+	/**
+	 * Check product deletion access.
+	 *
+	 * @param mixed $input Ability input.
+	 * @return bool
+	 *
+	 * @since 10.9.0
+	 */
+	public static function can_delete_product( $input = array() ): bool {
+		$product_id = self::get_id_from_input( $input );
+
+		return $product_id > 0 && wc_rest_check_post_permissions( 'product', 'delete', $product_id );
+	}
+
+	/**
+	 * Get the ability input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id'    => array(
+					'type'    => 'integer',
+					'minimum' => 1,
+				),
+				'force' => array(
+					'type'        => 'boolean',
+					'description' => __(
+						'Permanently delete the product. Defaults to false, which moves the product to trash.',
+						'woocommerce'
+					),
+					'default'     => false,
+				),
+			),
+			'required'             => array( 'id' ),
+			'additionalProperties' => false,
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
new file mode 100644
index 00000000000..a56fa660cd7
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
@@ -0,0 +1,187 @@
+<?php
+/**
+ * Product update ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce product update ability.
+ */
+class ProductUpdate extends AbstractDomainAbility implements AbilityDefinition {
+
+	use ProductAbilityTrait;
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @return string
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/product-update';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @return array
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => __( 'Update product', 'woocommerce' ),
+			'description'         => __(
+				'Update an existing product using supported catalog fields.',
+				'woocommerce'
+			),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => self::get_entity_output_schema( 'product', self::get_product_output_schema() ),
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_update_product' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => false,
+					'idempotent'  => false,
+					'destructive' => true,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Update a product.
+	 *
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 *
+	 * @since 10.9.0
+	 */
+	public static function execute( array $input ) {
+		$product = self::get_product_from_input( $input );
+
+		if ( is_wp_error( $product ) ) {
+			return $product;
+		}
+
+		if ( empty( array_diff( array_keys( $input ), array( 'id' ) ) ) ) {
+			return new \WP_Error(
+				'woocommerce_product_update_no_fields',
+				__( 'At least one product field is required to update a product.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		$product_config = self::get_product_config_for_product( $product );
+
+		if ( is_wp_error( $product_config ) ) {
+			return $product_config;
+		}
+
+		if (
+			isset( $input['status'] )
+			&& in_array(
+				sanitize_key( $input['status'] ),
+				array( ProductStatus::PUBLISH, ProductStatus::FUTURE, ProductStatus::PRIVATE ),
+				true
+			)
+			&& sanitize_key( $input['status'] ) !== $product->get_status()
+			&& ! self::current_user_can_publish_products()
+		) {
+			return new \WP_Error(
+				'woocommerce_product_publish_forbidden',
+				__( 'You are not allowed to publish products.', 'woocommerce' ),
+				array( 'status' => rest_authorization_required_code() )
+			);
+		}
+
+		if ( isset( $input['product_type_alias'] ) ) {
+			$product_config = self::get_product_config_for_alias( $input['product_type_alias'] );
+
+			if ( is_wp_error( $product_config ) ) {
+				return $product_config;
+			}
+
+			$product = wc_get_product_object( $product_config['wc_type'], $product->get_id() );
+
+			if ( ! $product ) {
+				return new \WP_Error(
+					'woocommerce_invalid_product_type',
+					__( 'Invalid product type.', 'woocommerce' ),
+					array( 'status' => 400 )
+				);
+			}
+		}
+
+		try {
+			if ( isset( $input['product_type_alias'] ) ) {
+				self::apply_product_type_config( $product, $product_config );
+			}
+
+			$validation_error = self::set_product_props_from_input( $product, $input, $product_config );
+			if ( is_wp_error( $validation_error ) ) {
+				return $validation_error;
+			}
+		} catch ( \WC_Data_Exception $exception ) {
+			return self::get_product_data_exception_error( $exception );
+		}
+
+		$save_error = self::save_product( $product, 'woocommerce_product_update_failed' );
+		if ( is_wp_error( $save_error ) ) {
+			return $save_error;
+		}
+
+		return array(
+			'product' => self::format_product_for_response( $product ),
+		);
+	}
+
+	/**
+	 * Check product update access.
+	 *
+	 * @param mixed $input Ability input.
+	 * @return bool
+	 *
+	 * @since 10.9.0
+	 */
+	public static function can_update_product( $input = array() ): bool {
+		$product_id = self::get_id_from_input( $input );
+
+		return $product_id > 0 && wc_rest_check_post_permissions( 'product', 'edit', $product_id );
+	}
+
+	/**
+	 * Check whether the current user can publish products.
+	 *
+	 * @return bool
+	 */
+	private static function current_user_can_publish_products(): bool {
+		// phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce registers the publish_products capability.
+		return current_user_can( 'publish_products' );
+	}
+
+	/**
+	 * Get the ability input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		return self::get_product_update_input_schema();
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
new file mode 100644
index 00000000000..3de97939e45
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
@@ -0,0 +1,211 @@
+<?php
+/**
+ * Products query ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce products query ability.
+ */
+class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
+
+	use ProductAbilityTrait;
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @return string
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/products-query';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @return array
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => __( 'Query products', 'woocommerce' ),
+			'description'         => __(
+				'Find products by ID or common catalog filters.',
+				'woocommerce'
+			),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => self::get_collection_output_schema( 'products', self::get_product_output_schema() ),
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_query_products' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => true,
+					'idempotent'  => true,
+					'destructive' => false,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Query products.
+	 *
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 *
+	 * @since 10.9.0
+	 */
+	public static function execute( array $input ) {
+		if ( ! empty( $input['id'] ) ) {
+			$product = self::get_product_from_input( $input );
+
+			if ( is_wp_error( $product ) ) {
+				return $product;
+			}
+
+			if ( $product->is_type( ProductType::VARIATION ) ) {
+				return new \WP_Error(
+					'woocommerce_product_type_unsupported',
+					__( 'Product type is not supported by this ability.', 'woocommerce' ),
+					array( 'status' => 400 )
+				);
+			}
+
+			return array(
+				'products'    => array( self::format_product_for_response( $product ) ),
+				'total_pages' => 1,
+				'page'        => 1,
+				'per_page'    => 1,
+			);
+		}
+
+		$page     = (int) ( $input['page'] ?? 1 );
+		$per_page = (int) ( $input['per_page'] ?? 10 );
+		$args     = array(
+			'limit'    => $per_page,
+			'page'     => $page,
+			'paginate' => true,
+			'return'   => 'objects',
+		);
+
+		foreach ( array( 'status', 'sku', 'stock_status' ) as $field ) {
+			if ( ! empty( $input[ $field ] ) ) {
+				$args[ $field ] = wc_clean( $input[ $field ] );
+			}
+		}
+
+		if ( ! empty( $input['product_type_alias'] ) && is_scalar( $input['product_type_alias'] ) ) {
+			$product_type_alias = sanitize_text_field( (string) $input['product_type_alias'] );
+			$type_args          = self::get_product_query_args_for_alias( $product_type_alias );
+
+			if ( is_wp_error( $type_args ) ) {
+				return $type_args;
+			}
+
+			$args = array_merge( $args, $type_args );
+		}
+
+		if ( ! empty( $input['search'] ) ) {
+			$args['s'] = wc_clean( $input['search'] );
+		}
+
+		$results  = wc_get_products( $args );
+		$products = is_object( $results ) && isset( $results->products ) ? $results->products : array();
+		$pages    = is_object( $results ) && isset( $results->max_num_pages ) ? (int) $results->max_num_pages : ( count( $products ) > 0 ? 1 : 0 );
+
+		return array(
+			'products'    => array_map(
+				static function ( $product ) {
+					return self::format_product_for_response( $product );
+				},
+				$products
+			),
+			'total_pages' => $pages,
+			'page'        => $page,
+			'per_page'    => $per_page,
+		);
+	}
+
+	/**
+	 * Check product read access.
+	 *
+	 * @param mixed $input Ability input.
+	 * @return bool
+	 *
+	 * @since 10.9.0
+	 */
+	public static function can_query_products( $input = array() ): bool {
+		$product_id = self::get_id_from_input( $input );
+
+		return wc_rest_check_post_permissions( 'product', 'read', $product_id );
+	}
+
+	/**
+	 * Get the ability input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id'                 => array(
+					'type'    => 'integer',
+					'minimum' => 1,
+				),
+				'search'             => array( 'type' => 'string' ),
+				'sku'                => array(
+					'type'        => 'string',
+					'description' => __( 'Limit results to products with SKUs that partially match this string. Use * to match products with any non-empty SKU.', 'woocommerce' ),
+				),
+				'status'             => array(
+					'type' => 'string',
+					'enum' => self::get_product_query_status_slugs(),
+				),
+				'product_type_alias' => array(
+					'type'        => 'string',
+					'description' => __(
+						'Filter by supported agent-facing product type alias. physical maps to simple shippable, non-downloadable products; virtual maps to simple non-shipping, non-downloadable products; digital maps to simple virtual/downloadable products; affiliate maps to the external product type; grouped maps to grouped.',
+						'woocommerce'
+					),
+					'enum'        => self::get_supported_product_type_aliases(),
+				),
+				'stock_status'       => array(
+					'type' => 'string',
+					'enum' => array_keys( wc_get_product_stock_status_options() ),
+				),
+				'page'               => array(
+					'type'    => 'integer',
+					'default' => 1,
+					'minimum' => 1,
+				),
+				'per_page'           => array(
+					'type'    => 'integer',
+					'default' => 10,
+					'minimum' => 1,
+					'maximum' => 100,
+				),
+			),
+			'additionalProperties' => false,
+			'default'              => array(),
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
new file mode 100644
index 00000000000..4cad9374209
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
@@ -0,0 +1,237 @@
+<?php
+/**
+ * Order ability trait file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Shared order helpers for WooCommerce domain ability definitions.
+ */
+trait OrderAbilityTrait {
+
+	/**
+	 * Allowed order status slugs (without the `wc-` prefix).
+	 *
+	 * @return array<int, string>
+	 */
+	protected static function get_allowed_order_status_slugs(): array {
+		return array_values(
+			array_diff(
+				array_map(
+					array( OrderUtil::class, 'remove_status_prefix' ),
+					array_keys( wc_get_order_statuses() )
+				),
+				array( OrderStatus::CHECKOUT_DRAFT )
+			)
+		);
+	}
+
+	/**
+	 * Possible order status slugs (without the `wc-` prefix) for ability output.
+	 *
+	 * @return array<int, string>
+	 */
+	protected static function get_order_output_status_slugs(): array {
+		return array_values(
+			array_unique(
+				array_map(
+					array( OrderUtil::class, 'remove_status_prefix' ),
+					array_merge( OrderStatus::get_all(), array_keys( wc_get_order_statuses() ) )
+				)
+			)
+		);
+	}
+
+	/**
+	 * Get an order note output schema.
+	 *
+	 * @return array
+	 */
+	protected static function get_order_note_output_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'note_id' => array( 'type' => 'integer' ),
+				'order'   => self::get_order_output_schema(),
+			),
+			'additionalProperties' => false,
+		);
+	}
+
+	/**
+	 * Get the schema for a single order in a response.
+	 *
+	 * @return array
+	 */
+	protected static function get_order_output_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id'                   => array( 'type' => 'integer' ),
+				'status'               => array(
+					'type' => 'string',
+					'enum' => self::get_order_output_status_slugs(),
+				),
+				'currency'             => array(
+					'type' => 'string',
+					'enum' => array_keys( get_woocommerce_currencies() ),
+				),
+				'currency_symbol'      => array( 'type' => 'string' ),
+				'total'                => array( 'type' => 'string' ),
+				'customer_id'          => array( 'type' => 'integer' ),
+				'billing_email'        => array(
+					'type'   => array( 'string', 'null' ),
+					'format' => 'email',
+				),
+				'payment_method'       => array( 'type' => 'string' ),
+				'payment_method_title' => array( 'type' => 'string' ),
+				'date_created'         => array(
+					'type'   => array( 'string', 'null' ),
+					'format' => 'date-time',
+				),
+				'date_created_gmt'     => array(
+					'type'   => array( 'string', 'null' ),
+					'format' => 'date-time',
+				),
+				'date_modified'        => array(
+					'type'   => array( 'string', 'null' ),
+					'format' => 'date-time',
+				),
+				'date_modified_gmt'    => array(
+					'type'   => array( 'string', 'null' ),
+					'format' => 'date-time',
+				),
+				'line_items'           => array(
+					'type'        => 'array',
+					'description' => __( 'Order line items. Only present when include_line_items is true.', 'woocommerce' ),
+					'items'       => array(
+						'type'                 => 'object',
+						'properties'           => array(
+							'id'           => array( 'type' => 'integer' ),
+							'name'         => array( 'type' => 'string' ),
+							'product_id'   => array( 'type' => 'integer' ),
+							'variation_id' => array( 'type' => 'integer' ),
+							'quantity'     => array( 'type' => 'integer' ),
+							'subtotal'     => array( 'type' => 'string' ),
+							'total'        => array( 'type' => 'string' ),
+						),
+						'additionalProperties' => false,
+					),
+				),
+			),
+			'additionalProperties' => false,
+		);
+	}
+
+	/**
+	 * Get an order from ability input.
+	 *
+	 * @param array $input Ability input.
+	 * @return \WC_Order|\WP_Error
+	 */
+	protected static function get_order_from_input( array $input ) {
+		if ( empty( $input['id'] ) ) {
+			return new \WP_Error(
+				'woocommerce_order_id_required',
+				__( 'Order ID is required.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		$order_id = (int) $input['id'];
+
+		if ( $order_id < 1 ) {
+			return new \WP_Error(
+				'woocommerce_order_id_required',
+				__( 'Order ID is required.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		$order = wc_get_order( $order_id );
+
+		if ( ! $order instanceof \WC_Order ) {
+			return new \WP_Error(
+				'woocommerce_order_not_found',
+				__( 'Order not found.', 'woocommerce' ),
+				array( 'status' => 404 )
+			);
+		}
+
+		return $order;
+	}
+
+	/**
+	 * Check order edit access for an ability input payload.
+	 *
+	 * @param mixed $input Ability input.
+	 * @return bool
+	 *
+	 * @since 10.9.0
+	 */
+	public static function can_edit_order( $input = array() ): bool {
+		$order_id = self::get_id_from_input( $input );
+
+		return $order_id > 0 && wc_rest_check_post_permissions( 'shop_order', 'edit', $order_id );
+	}
+
+	/**
+	 * Format an order for ability output.
+	 *
+	 * @param \WC_Order $order              Order object.
+	 * @param bool      $include_line_items Whether to include line items.
+	 * @return array
+	 */
+	protected static function format_order_for_response( \WC_Order $order, bool $include_line_items ): array {
+		$billing_email = $order->get_billing_email();
+
+		$data = array(
+			'id'                   => $order->get_id(),
+			'status'               => $order->get_status(),
+			'currency'             => $order->get_currency(),
+			'currency_symbol'      => html_entity_decode(
+				get_woocommerce_currency_symbol( $order->get_currency() ),
+				ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
+			),
+			'total'                => $order->get_total(),
+			'customer_id'          => $order->get_customer_id(),
+			'billing_email'        => '' === $billing_email ? null : $billing_email,
+			'payment_method'       => $order->get_payment_method(),
+			'payment_method_title' => $order->get_payment_method_title(),
+			'date_created'         => wc_rest_prepare_date_response( $order->get_date_created(), false ),
+			'date_created_gmt'     => wc_rest_prepare_date_response( $order->get_date_created() ),
+			'date_modified'        => wc_rest_prepare_date_response( $order->get_date_modified(), false ),
+			'date_modified_gmt'    => wc_rest_prepare_date_response( $order->get_date_modified() ),
+		);
+
+		if ( $include_line_items ) {
+			$data['line_items'] = array();
+
+			foreach ( $order->get_items() as $item ) {
+				if ( ! $item instanceof \WC_Order_Item_Product ) {
+					continue;
+				}
+
+				$data['line_items'][] = array(
+					'id'           => $item->get_id(),
+					'name'         => $item->get_name(),
+					'product_id'   => $item->get_product_id(),
+					'variation_id' => $item->get_variation_id(),
+					'quantity'     => $item->get_quantity(),
+					'subtotal'     => $item->get_subtotal(),
+					'total'        => $item->get_total(),
+				);
+			}
+		}
+
+		return $data;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
new file mode 100644
index 00000000000..56338cfa5fc
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -0,0 +1,919 @@
+<?php
+/**
+ * Product ability trait file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;
+
+use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Enums\ProductType;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Shared product helpers for WooCommerce domain ability definitions.
+ */
+trait ProductAbilityTrait {
+
+	/**
+	 * Get the product create input schema.
+	 *
+	 * @return array
+	 */
+	protected static function get_product_create_input_schema(): array {
+		return array(
+			'type'  => 'object',
+			'oneOf' => self::get_product_alias_input_schema_branches( array( 'name' ), false ),
+		);
+	}
+
+	/**
+	 * Get the product update input schema.
+	 *
+	 * Updates without a product type alias can mutate fields shared by all
+	 * supported product aliases. Type-specific fields require an explicit alias.
+	 *
+	 * @return array
+	 */
+	protected static function get_product_update_input_schema(): array {
+		$branches          = self::get_product_alias_input_schema_branches( array( 'id' ), true );
+		$mutation_schemas  = self::get_product_mutation_field_schemas();
+		$common_properties = array(
+			'id' => array(
+				'type'    => 'integer',
+				'minimum' => 1,
+			),
+		);
+
+		foreach ( self::get_common_product_mutation_fields() as $field ) {
+			$common_properties[ $field ] = $mutation_schemas[ $field ];
+		}
+
+		array_unshift(
+			$branches,
+			array(
+				'type'                 => 'object',
+				'properties'           => $common_properties,
+				'required'             => array( 'id' ),
+				'additionalProperties' => false,
+			)
+		);
+
+		return array(
+			'type'  => 'object',
+			'oneOf' => $branches,
+		);
+	}
+
+	/**
+	 * Get alias-specific product input schema branches.
+	 *
+	 * @param array<int, string> $base_required Required fields for every branch.
+	 * @param bool               $require_alias  Whether every branch must include product_type_alias.
+	 * @return array<int, array>
+	 */
+	private static function get_product_alias_input_schema_branches( array $base_required, bool $require_alias ): array {
+		$branches = array();
+
+		foreach ( self::get_product_type_alias_configs() as $product_type_alias => $product_config ) {
+			$required = $base_required;
+
+			if ( $require_alias || 'physical' !== $product_type_alias ) {
+				$required[] = 'product_type_alias';
+			}
+
+			$properties = array();
+
+			if ( in_array( 'id', $base_required, true ) ) {
+				$properties['id'] = array(
+					'type'    => 'integer',
+					'minimum' => 1,
+				);
+			}
+
+			$properties['product_type_alias'] = self::get_product_type_alias_schema( $product_type_alias );
+
+			if ( 'physical' === $product_type_alias && ! $require_alias ) {
+				$properties['product_type_alias']['default'] = 'physical';
+			}
+
+			$properties = array_merge(
+				$properties,
+				self::get_product_mutation_field_schemas_for_fields( $product_config['fields'] )
+			);
+
+			$branches[] = array(
+				'type'                 => 'object',
+				'properties'           => $properties,
+				'required'             => array_values( array_unique( $required ) ),
+				'additionalProperties' => false,
+			);
+		}
+
+		return $branches;
+	}
+
+	/**
+	 * Get the product type alias schema.
+	 *
+	 * @param string|null $product_type_alias Product type alias to restrict to, or null for all aliases.
+	 * @return array
+	 */
+	private static function get_product_type_alias_schema( ?string $product_type_alias = null ): array {
+		return array(
+			'type'        => 'string',
+			'description' => __(
+				'Supported agent-facing product type alias. physical maps to a simple shippable, non-downloadable product; virtual maps to a simple non-shipping, non-downloadable product; digital maps to a simple virtual/downloadable product; affiliate maps to the external product type; grouped maps to grouped.',
+				'woocommerce'
+			),
+			'enum'        => null === $product_type_alias ? self::get_supported_product_type_aliases() : array( $product_type_alias ),
+		);
+	}
+
+	/**
+	 * Get product mutation field schemas keyed by input field.
+	 *
+	 * @return array<string, array>
+	 */
+	private static function get_product_mutation_field_schemas(): array {
+		return array(
+			'name'              => array( 'type' => 'string' ),
+			'sku'               => array( 'type' => 'string' ),
+			'regular_price'     => array(
+				'type'        => 'string',
+				'description' => __( 'Decimal price as a string, without a currency symbol or thousand separators.', 'woocommerce' ),
+				'pattern'     => self::get_product_price_input_pattern(),
+			),
+			'sale_price'        => array(
+				'type'        => 'string',
+				'description' => __( 'Decimal price as a string, without a currency symbol or thousand separators.', 'woocommerce' ),
+				'pattern'     => self::get_product_price_input_pattern(),
+			),
+			'description'       => array(
+				'type'        => 'string',
+				'description' => __( 'Product description content. Safe HTML is allowed.', 'woocommerce' ),
+			),
+			'short_description' => array(
+				'type'        => 'string',
+				'description' => __( 'Short product description content. Safe HTML is allowed.', 'woocommerce' ),
+			),
+			'status'            => array(
+				'type' => 'string',
+				'enum' => self::get_product_mutation_status_slugs(),
+			),
+			'manage_stock'      => array( 'type' => 'boolean' ),
+			'stock_quantity'    => array(
+				'type'        => self::get_product_stock_quantity_schema_type(),
+				'description' => __( 'Available stock quantity when product-level stock management is used.', 'woocommerce' ),
+			),
+			'stock_status'      => array(
+				'type' => 'string',
+				'enum' => array_keys( wc_get_product_stock_status_options() ),
+			),
+			'external_url'      => array(
+				'type'        => 'string',
+				'description' => __( 'External destination URL for affiliate products.', 'woocommerce' ),
+				'format'      => 'uri',
+			),
+			'button_text'       => array(
+				'type'        => 'string',
+				'description' => __( 'Button text for affiliate products.', 'woocommerce' ),
+			),
+			'grouped_products'  => array(
+				'type'        => 'array',
+				'description' => __( 'Product IDs to include as children of a grouped product.', 'woocommerce' ),
+				'items'       => array(
+					'type'    => 'integer',
+					'minimum' => 1,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Get product mutation field schemas for a set of fields.
+	 *
+	 * @param array<int, string> $fields Field names.
+	 * @return array<string, array>
+	 */
+	private static function get_product_mutation_field_schemas_for_fields( array $fields ): array {
+		$schemas = self::get_product_mutation_field_schemas();
+		$result  = array();
+
+		foreach ( $schemas as $field => $schema ) {
+			if ( in_array( $field, $fields, true ) ) {
+				$result[ $field ] = $schema;
+			}
+		}
+
+		return $result;
+	}
+
+	/**
+	 * Supported agent-facing product type aliases.
+	 *
+	 * @return array<int, string>
+	 */
+	protected static function get_supported_product_type_aliases(): array {
+		return array_keys( self::get_product_type_alias_configs() );
+	}
+
+	/**
+	 * Get product configuration by agent-facing product type alias.
+	 *
+	 * @param string $product_type_alias Agent-facing product type alias.
+	 * @return array|\WP_Error
+	 */
+	protected static function get_product_config_for_alias( string $product_type_alias ) {
+		$product_type_alias = sanitize_key( $product_type_alias );
+		$configs            = self::get_product_type_alias_configs();
+
+		if ( ! isset( $configs[ $product_type_alias ] ) ) {
+			return new \WP_Error(
+				'woocommerce_product_type_unsupported',
+				__( 'Product type is not supported by this ability.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		return $configs[ $product_type_alias ];
+	}
+
+	/**
+	 * Get product query arguments for an agent-facing product type alias.
+	 *
+	 * @param string $product_type_alias Agent-facing product type alias.
+	 * @return array|\WP_Error
+	 */
+	protected static function get_product_query_args_for_alias( string $product_type_alias ) {
+		$product_config = self::get_product_config_for_alias( $product_type_alias );
+
+		if ( is_wp_error( $product_config ) ) {
+			return $product_config;
+		}
+
+		$query_args = array(
+			'type' => $product_config['wc_type'],
+		);
+
+		if ( isset( $product_config['query_props'] ) ) {
+			foreach ( $product_config['query_props'] as $field => $value ) {
+				$query_args[ $field ] = (bool) $value;
+			}
+		}
+
+		return $query_args;
+	}
+
+	/**
+	 * Get product configuration for an existing product.
+	 *
+	 * @param \WC_Product $product Product object.
+	 * @return array|\WP_Error
+	 */
+	protected static function get_product_config_for_product( \WC_Product $product ) {
+		$product_type_alias = self::get_product_type_alias_for_product( $product );
+
+		if ( is_wp_error( $product_type_alias ) ) {
+			return $product_type_alias;
+		}
+
+		return self::get_product_config_for_alias( $product_type_alias );
+	}
+
+	/**
+	 * Get the supported agent-facing product type alias for a product.
+	 *
+	 * @param \WC_Product $product Product object.
+	 * @return string|\WP_Error
+	 */
+	protected static function get_product_type_alias_for_product( \WC_Product $product ) {
+		if ( $product->is_type( ProductType::SIMPLE ) ) {
+			if ( $product->get_virtual() && $product->get_downloadable() ) {
+				return 'digital';
+			}
+
+			if ( $product->get_virtual() ) {
+				return 'virtual';
+			}
+
+			return 'physical';
+		}
+
+		if ( $product->is_type( ProductType::EXTERNAL ) ) {
+			return 'affiliate';
+		}
+
+		if ( $product->is_type( ProductType::GROUPED ) ) {
+			return 'grouped';
+		}
+
+		return new \WP_Error(
+			'woocommerce_product_type_unsupported',
+			__( 'Product type is not supported by this ability.', 'woocommerce' ),
+			array( 'status' => 400 )
+		);
+	}
+
+	/**
+	 * Get fields shared by all supported product mutation aliases.
+	 *
+	 * @return array<int, string>
+	 */
+	private static function get_common_product_mutation_fields(): array {
+		return array( 'name', 'sku', 'description', 'short_description', 'status' );
+	}
+
+	/**
+	 * Get product type alias configuration.
+	 *
+	 * The keys are agent-facing product type aliases. Each config maps the alias to a
+	 * WooCommerce product class plus the fields that can be applied to it.
+	 *
+	 * @return array<string, array{wc_type: string, fields: array<int, string>, product_props: array<string, mixed>, query_props?: array<string, mixed>}>
+	 */
+	private static function get_product_type_alias_configs(): array {
+		$common_fields = self::get_common_product_mutation_fields();
+		$simple_fields = array_merge(
+			$common_fields,
+			array( 'regular_price', 'sale_price', 'manage_stock', 'stock_quantity', 'stock_status' )
+		);
+
+		return array(
+			'physical'  => array(
+				'wc_type'       => ProductType::SIMPLE,
+				'fields'        => $simple_fields,
+				'product_props' => array(
+					'virtual'      => false,
+					'downloadable' => false,
+				),
+				'query_props'   => array(
+					'virtual'      => false,
+					'downloadable' => false,
+				),
+			),
+			'virtual'   => array(
+				'wc_type'       => ProductType::SIMPLE,
+				'fields'        => $simple_fields,
+				'product_props' => array(
+					'virtual'      => true,
+					'downloadable' => false,
+				),
+				'query_props'   => array(
+					'virtual'      => true,
+					'downloadable' => false,
+				),
+			),
+			'digital'   => array(
+				'wc_type'       => ProductType::SIMPLE,
+				'fields'        => $simple_fields,
+				'product_props' => array(
+					'virtual'      => true,
+					'downloadable' => true,
+				),
+				'query_props'   => array(
+					'virtual'      => true,
+					'downloadable' => true,
+				),
+			),
+			'affiliate' => array(
+				'wc_type'       => ProductType::EXTERNAL,
+				'fields'        => array_merge(
+					$common_fields,
+					array( 'regular_price', 'sale_price', 'external_url', 'button_text' )
+				),
+				'product_props' => array(
+					'virtual'        => false,
+					'downloadable'   => false,
+					'manage_stock'   => false,
+					'stock_quantity' => '',
+					'stock_status'   => ProductStockStatus::IN_STOCK,
+				),
+			),
+			'grouped'   => array(
+				'wc_type'       => ProductType::GROUPED,
+				'fields'        => array_merge( $common_fields, array( 'grouped_products' ) ),
+				'product_props' => array(
+					'manage_stock'   => false,
+					'stock_quantity' => '',
+				),
+			),
+		);
+	}
+
+	/**
+	 * Allowed product post-status slugs for mutation abilities.
+	 *
+	 * Mirrors the REST products controller mutation enum.
+	 *
+	 * @return array<int, string>
+	 */
+	protected static function get_product_mutation_status_slugs(): array {
+		return array_merge(
+			array_keys( get_post_statuses() ),
+			array( ProductStatus::FUTURE )
+		);
+	}
+
+	/**
+	 * Allowed product post-status slugs for product output.
+	 *
+	 * @return array<int, string>
+	 */
+	protected static function get_product_output_status_slugs(): array {
+		return array_values(
+			array_unique(
+				array_merge(
+					self::get_product_mutation_status_slugs(),
+					array( ProductStatus::AUTO_DRAFT, ProductStatus::TRASH )
+				)
+			)
+		);
+	}
+
+	/**
+	 * Allowed product post-status slugs for query abilities.
+	 *
+	 * @return array<int, string>
+	 */
+	protected static function get_product_query_status_slugs(): array {
+		return array_merge(
+			array( ProductStatus::FUTURE, ProductStatus::TRASH ),
+			array_keys( get_post_statuses() )
+		);
+	}
+
+	/**
+	 * Get a product from ability input.
+	 *
+	 * @param array $input Ability input.
+	 * @return \WC_Product|\WP_Error
+	 */
+	protected static function get_product_from_input( array $input ) {
+		if ( empty( $input['id'] ) ) {
+			return new \WP_Error(
+				'woocommerce_product_id_required',
+				__( 'Product ID is required.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		$product_id = (int) $input['id'];
+
+		if ( $product_id < 1 ) {
+			return new \WP_Error(
+				'woocommerce_product_id_required',
+				__( 'Product ID is required.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		$product = wc_get_product( $product_id );
+
+		if ( ! $product ) {
+			return new \WP_Error(
+				'woocommerce_product_not_found',
+				__( 'Product not found.', 'woocommerce' ),
+				array( 'status' => 404 )
+			);
+		}
+
+		return $product;
+	}
+
+	/**
+	 * Set supported product properties from ability input.
+	 *
+	 * @param \WC_Product $product        Product object.
+	 * @param array       $input          Ability input.
+	 * @param array       $product_config Product type configuration.
+	 * @return null|\WP_Error
+	 */
+	protected static function set_product_props_from_input( \WC_Product $product, array $input, array $product_config ) {
+		$validation_error = self::validate_product_fields_for_config( $input, $product_config );
+		if ( is_wp_error( $validation_error ) ) {
+			return $validation_error;
+		}
+
+		$setters = array(
+			'name'              => 'set_name',
+			'sku'               => 'set_sku',
+			'regular_price'     => 'set_regular_price',
+			'sale_price'        => 'set_sale_price',
+			'description'       => 'set_description',
+			'short_description' => 'set_short_description',
+			'status'            => 'set_status',
+			'manage_stock'      => 'set_manage_stock',
+			'stock_quantity'    => 'set_stock_quantity',
+			'stock_status'      => 'set_stock_status',
+			'external_url'      => 'set_product_url',
+			'button_text'       => 'set_button_text',
+			'grouped_products'  => 'set_children',
+		);
+
+		foreach ( $setters as $field => $setter ) {
+			if (
+				! in_array( $field, $product_config['fields'], true )
+				|| ! array_key_exists( $field, $input )
+				|| ! is_callable( array( $product, $setter ) )
+			) {
+				continue;
+			}
+
+			$prepared_value = self::prepare_product_field_value( $field, $input[ $field ] );
+			if ( is_wp_error( $prepared_value ) ) {
+				return $prepared_value;
+			}
+
+			$product->{$setter}( $prepared_value );
+		}
+
+		return null;
+	}
+
+	/**
+	 * Apply product type configuration to a product object.
+	 *
+	 * @param \WC_Product $product        Product object.
+	 * @param array       $product_config Product type configuration.
+	 */
+	protected static function apply_product_type_config( \WC_Product $product, array $product_config ): void {
+		$prop_setters = array(
+			'virtual'        => 'set_virtual',
+			'downloadable'   => 'set_downloadable',
+			'manage_stock'   => 'set_manage_stock',
+			'stock_quantity' => 'set_stock_quantity',
+			'stock_status'   => 'set_stock_status',
+		);
+
+		foreach ( $product_config['product_props'] as $prop => $value ) {
+			$setter = $prop_setters[ $prop ] ?? '';
+
+			if ( '' !== $setter && is_callable( array( $product, $setter ) ) ) {
+				$product->{$setter}( $value );
+			}
+		}
+	}
+
+	/**
+	 * Validate that input fields are supported for the configured product type.
+	 *
+	 * @param array $input          Ability input.
+	 * @param array $product_config Product type configuration.
+	 * @return null|\WP_Error
+	 */
+	private static function validate_product_fields_for_config( array $input, array $product_config ) {
+		$shared_fields          = array( 'id', 'product_type_alias' );
+		$supported_fields       = array_merge( $shared_fields, $product_config['fields'] );
+		$unsupported_field_keys = array_diff( array_keys( $input ), $supported_fields );
+
+		if ( empty( $unsupported_field_keys ) ) {
+			return null;
+		}
+
+		return new \WP_Error(
+			'woocommerce_product_field_unsupported',
+			sprintf(
+				/* translators: %s is a comma-separated list of unsupported product fields. */
+				__( 'These fields are not supported for the selected product type: %s.', 'woocommerce' ),
+				implode( ', ', $unsupported_field_keys )
+			),
+			array( 'status' => 400 )
+		);
+	}
+
+	/**
+	 * Prepare a product field value before passing it to the product setter.
+	 *
+	 * @param string $field Field name.
+	 * @param mixed  $value Field value.
+	 * @return mixed|\WP_Error
+	 */
+	private static function prepare_product_field_value( string $field, $value ) {
+		if ( in_array( $field, array( 'name', 'description', 'short_description' ), true ) && is_string( $value ) ) {
+			return wp_filter_post_kses( $value );
+		}
+
+		if ( 'status' === $field && is_string( $value ) ) {
+			return sanitize_key( $value );
+		}
+
+		if ( 'external_url' === $field && is_string( $value ) ) {
+			return esc_url_raw( $value );
+		}
+
+		if ( in_array( $field, array( 'regular_price', 'sale_price' ), true ) ) {
+			return self::prepare_product_price_value( $value );
+		}
+
+		if ( 'grouped_products' === $field ) {
+			if ( ! is_array( $value ) ) {
+				return new \WP_Error(
+					'woocommerce_product_grouped_products_invalid',
+					__( 'Grouped product IDs must be positive integers.', 'woocommerce' ),
+					array( 'status' => 400 )
+				);
+			}
+
+			$grouped_product_ids = array();
+
+			foreach ( $value as $product_id ) {
+				if ( ! rest_is_integer( $product_id ) || (int) $product_id < 1 ) {
+					return new \WP_Error(
+						'woocommerce_product_grouped_products_invalid',
+						__( 'Grouped product IDs must be positive integers.', 'woocommerce' ),
+						array( 'status' => 400 )
+					);
+				}
+
+				$grouped_product_ids[] = (int) $product_id;
+			}
+
+			return $grouped_product_ids;
+		}
+
+		if ( is_string( $value ) ) {
+			return wc_clean( $value );
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Prepare a product price before passing it to the product setter.
+	 *
+	 * @param mixed $value Price value.
+	 * @return string|\WP_Error
+	 */
+	private static function prepare_product_price_value( $value ) {
+		if ( ! is_scalar( $value ) || is_bool( $value ) ) {
+			return self::get_invalid_product_price_error();
+		}
+
+		$value = trim( (string) $value );
+
+		if ( 1 !== preg_match( '/' . self::get_product_price_input_pattern() . '/', $value ) ) {
+			return self::get_invalid_product_price_error();
+		}
+
+		return wc_format_decimal( $value );
+	}
+
+	/**
+	 * Get a schema-compatible pattern for product price inputs.
+	 *
+	 * @return string
+	 */
+	private static function get_product_price_input_pattern(): string {
+		$decimal_separators        = array_map(
+			static function ( string $separator ): string {
+				return preg_quote( $separator, '/' );
+			},
+			self::get_product_price_decimal_separators()
+		);
+		$decimal_separator_pattern = implode( '', $decimal_separators );
+
+		return '^(?:-?(?:[0-9]+(?:[' . $decimal_separator_pattern . '][0-9]+)?|[' . $decimal_separator_pattern . '][0-9]+)|)$';
+	}
+
+	/**
+	 * Get decimal separators accepted by WooCommerce decimal formatting.
+	 *
+	 * @return array<int, string>
+	 */
+	private static function get_product_price_decimal_separators(): array {
+		$locale = localeconv();
+
+		return array_values(
+			array_unique(
+				array_filter(
+					array(
+						wc_get_price_decimal_separator(),
+						$locale['decimal_point'] ?? '',
+						$locale['mon_decimal_point'] ?? '',
+					),
+					static function ( $separator ): bool {
+						return is_string( $separator ) && '' !== $separator;
+					}
+				)
+			)
+		);
+	}
+
+	/**
+	 * Get an invalid product price error.
+	 *
+	 * @return \WP_Error
+	 */
+	private static function get_invalid_product_price_error(): \WP_Error {
+		return new \WP_Error(
+			'woocommerce_product_price_invalid',
+			__( 'Product prices must be decimal strings without currency symbols or thousand separators.', 'woocommerce' ),
+			array( 'status' => 400 )
+		);
+	}
+
+	/**
+	 * Convert a WooCommerce data exception to a WordPress error.
+	 *
+	 * @param \WC_Data_Exception $exception Exception.
+	 * @return \WP_Error
+	 */
+	protected static function get_product_data_exception_error( \WC_Data_Exception $exception ): \WP_Error {
+		return new \WP_Error(
+			$exception->getErrorCode(),
+			$exception->getMessage(),
+			$exception->getErrorData()
+		);
+	}
+
+	/**
+	 * Save a product and return a failure if WooCommerce did not persist it.
+	 *
+	 * @param \WC_Product $product      Product object.
+	 * @param string      $failure_code Error code to use when save returns no ID.
+	 * @return null|\WP_Error
+	 */
+	protected static function save_product( \WC_Product $product, string $failure_code ) {
+		try {
+			$product_id = $product->save();
+		} catch ( \WC_Data_Exception $exception ) {
+			return self::get_product_data_exception_error( $exception );
+		} catch ( \Exception $exception ) {
+			if ( function_exists( 'wc_get_logger' ) ) {
+				wc_get_logger()->error(
+					'WooCommerce domain ability failed to save product.',
+					array(
+						'source'        => 'woocommerce-abilities',
+						'failure_code'  => $failure_code,
+						'product_id'    => $product->get_id(),
+						'exception'     => get_class( $exception ),
+						'error_message' => $exception->getMessage(),
+					)
+				);
+			}
+
+			return new \WP_Error(
+				$failure_code,
+				__( 'Failed to save product.', 'woocommerce' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		if ( $product_id <= 0 || $product->get_id() <= 0 ) {
+			return new \WP_Error(
+				$failure_code,
+				__( 'Failed to save product.', 'woocommerce' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		return null;
+	}
+
+	/**
+	 * Format a product for ability output.
+	 *
+	 * @param \WC_Product $product Product object.
+	 * @return array
+	 */
+	protected static function format_product_for_response( \WC_Product $product ): array {
+		$stock_quantity = $product->get_stock_quantity();
+		$permalink      = $product->get_permalink();
+
+		return array(
+			'id'                => $product->get_id(),
+			'name'              => $product->get_name(),
+			'slug'              => $product->get_slug(),
+			'permalink'         => false === $permalink ? null : $permalink,
+			'type'              => $product->get_type(),
+			'status'            => $product->get_status(),
+			'sku'               => $product->get_sku(),
+			'currency'          => get_woocommerce_currency(),
+			'currency_symbol'   => html_entity_decode(
+				get_woocommerce_currency_symbol(),
+				ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
+			),
+			'price'             => $product->get_price(),
+			'regular_price'     => $product->get_regular_price(),
+			'sale_price'        => $product->get_sale_price(),
+			'stock_status'      => $product->get_stock_status(),
+			'stock_quantity'    => null === $stock_quantity ? null : wc_stock_amount( $stock_quantity ),
+			'manage_stock'      => (bool) $product->get_manage_stock(),
+			'virtual'           => (bool) $product->get_virtual(),
+			'downloadable'      => (bool) $product->get_downloadable(),
+			'external_url'      => $product instanceof \WC_Product_External ? $product->get_product_url() : null,
+			'button_text'       => $product instanceof \WC_Product_External ? $product->get_button_text() : null,
+			'grouped_products'  => $product instanceof \WC_Product_Grouped ? array_map( 'absint', $product->get_children( 'edit' ) ) : array(),
+			'date_created'      => wc_rest_prepare_date_response( $product->get_date_created(), false ),
+			'date_created_gmt'  => wc_rest_prepare_date_response( $product->get_date_created() ),
+			'date_modified'     => wc_rest_prepare_date_response( $product->get_date_modified(), false ),
+			'date_modified_gmt' => wc_rest_prepare_date_response( $product->get_date_modified() ),
+		);
+	}
+
+	/**
+	 * Get the schema for a single product in a response.
+	 *
+	 * @return array
+	 */
+	protected static function get_product_output_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id'                => array( 'type' => 'integer' ),
+				'name'              => array( 'type' => 'string' ),
+				'slug'              => array( 'type' => 'string' ),
+				'permalink'         => array(
+					'type'        => array( 'string', 'null' ),
+					'description' => __( 'Product permalink, or null when no public permalink is available.', 'woocommerce' ),
+					'format'      => 'uri',
+				),
+				'type'              => array(
+					'type'        => 'string',
+					'description' => __( 'Internal product type slug, such as simple, external, grouped, or variable.', 'woocommerce' ),
+					'enum'        => array_keys( wc_get_product_types() ),
+				),
+				'status'            => array(
+					'type' => 'string',
+					'enum' => self::get_product_output_status_slugs(),
+				),
+				'sku'               => array( 'type' => 'string' ),
+				'currency'          => array(
+					'type' => 'string',
+					'enum' => array_keys( get_woocommerce_currencies() ),
+				),
+				'currency_symbol'   => array( 'type' => 'string' ),
+				'price'             => array(
+					'type'        => 'string',
+					'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+				),
+				'regular_price'     => array(
+					'type'        => 'string',
+					'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+				),
+				'sale_price'        => array(
+					'type'        => 'string',
+					'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+				),
+				'stock_status'      => array(
+					'type' => 'string',
+					'enum' => array_keys( wc_get_product_stock_status_options() ),
+				),
+				'stock_quantity'    => array(
+					'type'        => array( self::get_product_stock_quantity_schema_type(), 'null' ),
+					'description' => __( 'Current stock quantity, or null when no stock quantity is set.', 'woocommerce' ),
+				),
+				'manage_stock'      => array( 'type' => 'boolean' ),
+				'virtual'           => array( 'type' => 'boolean' ),
+				'downloadable'      => array( 'type' => 'boolean' ),
+				'external_url'      => array(
+					'type'        => array( 'string', 'null' ),
+					'description' => __( 'External product URL for external products.', 'woocommerce' ),
+					'format'      => 'uri',
+				),
+				'button_text'       => array(
+					'type'        => array( 'string', 'null' ),
+					'description' => __( 'Button text for external products.', 'woocommerce' ),
+				),
+				'grouped_products'  => array(
+					'type'        => 'array',
+					'description' => __( 'Product IDs included as children of a grouped product.', 'woocommerce' ),
+					'items'       => array( 'type' => 'integer' ),
+				),
+				'date_created'      => array(
+					'type'   => array( 'string', 'null' ),
+					'format' => 'date-time',
+				),
+				'date_created_gmt'  => array(
+					'type'   => array( 'string', 'null' ),
+					'format' => 'date-time',
+				),
+				'date_modified'     => array(
+					'type'   => array( 'string', 'null' ),
+					'format' => 'date-time',
+				),
+				'date_modified_gmt' => array(
+					'type'   => array( 'string', 'null' ),
+					'format' => 'date-time',
+				),
+			),
+			'additionalProperties' => false,
+		);
+	}
+
+	/**
+	 * Get the schema type for product stock quantities.
+	 *
+	 * WooCommerce stock quantities can support fractional values when the
+	 * stock amount filter is configured to return non-integer amounts.
+	 *
+	 * @return string
+	 */
+	protected static function get_product_stock_quantity_schema_type(): string {
+		return wc_is_stock_amount_integer() ? 'integer' : 'number';
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
new file mode 100644
index 00000000000..3daeac3fecb
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -0,0 +1,2442 @@
+<?php
+/**
+ * AbilitiesLoaderTest class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities;
+
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesCategories;
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
+use Automattic\WooCommerce\Internal\Abilities\Domain\OrderAddNote;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductCreate;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductDelete;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductUpdate;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductsQuery;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
+use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+
+/**
+ * Tests for the canonical WooCommerce domain abilities and their loader.
+ */
+class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
+
+	use HPOSToggleTrait;
+
+	private const CANONICAL_ABILITY_IDS = array(
+		'woocommerce/products-query',
+		'woocommerce/product-create',
+		'woocommerce/product-update',
+		'woocommerce/product-delete',
+		'woocommerce/orders-query',
+		'woocommerce/order-update-status',
+		'woocommerce/order-add-note',
+	);
+
+	/**
+	 * Ability IDs registered by these tests.
+	 *
+	 * @var array
+	 */
+	private $registered_ability_ids = self::CANONICAL_ABILITY_IDS;
+
+	/**
+	 * Category IDs registered by these tests.
+	 *
+	 * @var array
+	 */
+	private $registered_category_ids = array();
+
+	/**
+	 * Product IDs created by these tests.
+	 *
+	 * @var array
+	 */
+	private $created_product_ids = array();
+
+	/**
+	 * Order IDs created by these tests.
+	 *
+	 * @var array
+	 */
+	private $created_order_ids = array();
+
+	/**
+	 * Original action counts captured for restoration in tearDown.
+	 *
+	 * @var array<string, int|null>
+	 */
+	private $original_action_counts = array();
+
+	/**
+	 * Original HPOS/COT state captured before tests that toggle storage.
+	 *
+	 * @var bool|null
+	 */
+	private $original_cot_state = null;
+
+	/**
+	 * Whether HPOS/COT tables were set up by the current test.
+	 *
+	 * @var bool
+	 */
+	private $cot_setup_for_test = false;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		global $wp_actions;
+
+		parent::setUp();
+
+		foreach ( array( 'init', 'wp_abilities_api_init', 'wp_abilities_api_categories_init' ) as $action ) {
+			$this->original_action_counts[ $action ] = $wp_actions[ $action ] ?? null;
+		}
+
+		if ( ! function_exists( 'wp_register_ability' ) ) {
+			$abilities_bootstrap = WC_ABSPATH . 'vendor/wordpress/abilities-api/includes/bootstrap.php';
+			if ( file_exists( $abilities_bootstrap ) ) {
+				require_once $abilities_bootstrap;
+			}
+		}
+
+		// WordPress 6.9+ requires init to have fired before the Abilities API registry can be initialized.
+		$wp_actions['init'] = max( 1, (int) ( $wp_actions['init'] ?? 0 ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		wp_set_current_user(
+			$this->factory->user->create( array( 'role' => 'administrator' ) )
+		);
+
+		$this->register_woocommerce_category();
+		$this->register_domain_abilities();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		global $wp_actions;
+
+		foreach ( $this->created_order_ids as $order_id ) {
+			$order = wc_get_order( $order_id );
+
+			if ( $order ) {
+				$order->delete( true );
+			}
+		}
+
+		foreach ( $this->created_product_ids as $product_id ) {
+			$product = wc_get_product( $product_id );
+
+			if ( $product ) {
+				$product->delete( true );
+			}
+		}
+
+		foreach ( $this->registered_ability_ids as $ability_id ) {
+			if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability_id ) ) {
+				wp_unregister_ability( $ability_id );
+			}
+		}
+
+		foreach ( $this->registered_category_ids as $category_id ) {
+			if ( function_exists( 'wp_has_ability_category' ) && wp_has_ability_category( $category_id ) ) {
+				wp_unregister_ability_category( $category_id );
+			}
+		}
+
+		foreach ( $this->original_action_counts as $action => $original_count ) {
+			if ( null !== $original_count ) {
+				$wp_actions[ $action ] = $original_count; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			} elseif ( isset( $wp_actions[ $action ] ) ) {
+				unset( $wp_actions[ $action ] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			}
+		}
+
+		if ( null !== $this->original_cot_state ) {
+			if ( $this->cot_setup_for_test ) {
+				$this->clean_up_cot_setup();
+			}
+
+			$this->toggle_cot_feature_and_usage( $this->original_cot_state );
+			remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		}
+
+		wp_set_current_user( 0 );
+
+		parent::tearDown();
+	}
+
+	/*
+	 * ---------------------------------------------------------------------
+	 * Registration / Loader
+	 * ---------------------------------------------------------------------
+	 */
+
+	/**
+	 * @testdox Should register every canonical ability with WooCommerce metadata.
+	 */
+	public function test_canonical_abilities_register_with_woocommerce_metadata(): void {
+		foreach ( self::CANONICAL_ABILITY_IDS as $ability_id ) {
+			$ability = wp_get_ability( $ability_id );
+
+			$this->assertNotNull( $ability, "{$ability_id} should be registered." );
+			$this->assertSame( 'woocommerce', $ability->get_category(), "{$ability_id} should belong to the woocommerce category." );
+
+			$meta = $ability->get_meta();
+			$this->assertTrue( $meta['show_in_rest'] ?? false, "{$ability_id} should be exposed in REST." );
+			$this->assertTrue( $meta['mcp']['public'] ?? false, "{$ability_id} should be flagged as MCP-public." );
+			$this->assertSame( 'tool', $meta['mcp']['type'] ?? '', "{$ability_id} should be an MCP tool." );
+			$this->assertArrayHasKey( 'readonly', $meta['annotations'] );
+			$this->assertArrayHasKey( 'destructive', $meta['annotations'] );
+			$this->assertArrayHasKey( 'idempotent', $meta['annotations'] );
+			$this->assertArrayNotHasKey( 'expose_in_deprecated_woocommerce_mcp', $meta );
+		}
+	}
+
+	/**
+	 * @testdox Should mark write abilities as destructive and queries as readonly/idempotent.
+	 */
+	public function test_canonical_ability_annotations_match_intent(): void {
+		$expectations = array(
+			'woocommerce/products-query'      => array(
+				'readonly'    => true,
+				'idempotent'  => true,
+				'destructive' => false,
+			),
+			'woocommerce/product-create'      => array(
+				'readonly'    => false,
+				'idempotent'  => false,
+				'destructive' => false,
+			),
+			'woocommerce/product-update'      => array(
+				'readonly'    => false,
+				'idempotent'  => false,
+				'destructive' => true,
+			),
+			'woocommerce/product-delete'      => array(
+				'readonly'    => false,
+				'idempotent'  => true,
+				'destructive' => true,
+			),
+			'woocommerce/orders-query'        => array(
+				'readonly'    => true,
+				'idempotent'  => true,
+				'destructive' => false,
+			),
+			'woocommerce/order-update-status' => array(
+				'readonly'    => false,
+				'idempotent'  => false,
+				'destructive' => true,
+			),
+			'woocommerce/order-add-note'      => array(
+				'readonly'    => false,
+				'idempotent'  => false,
+				'destructive' => false,
+			),
+		);
+
+		foreach ( $expectations as $ability_id => $annotations ) {
+			$meta = wp_get_ability( $ability_id )->get_meta();
+
+			foreach ( $annotations as $key => $value ) {
+				$this->assertSame(
+					$value,
+					$meta['annotations'][ $key ] ?? null,
+					"{$ability_id} should have annotations.{$key}=" . ( $value ? 'true' : 'false' ) . '.'
+				);
+			}
+		}
+	}
+
+	/**
+	 * @testdox Should advertise product statuses that product responses can return.
+	 */
+	public function test_product_output_schema_allows_response_statuses(): void {
+		$output_schema = wp_get_ability( 'woocommerce/product-create' )->get_output_schema();
+		$status_enum   = $output_schema['properties']['product']['properties']['status']['enum'] ?? array();
+
+		$this->assertContains( 'auto-draft', $status_enum );
+		$this->assertContains( 'trash', $status_enum );
+		$this->assertContains( 'publish', $status_enum );
+	}
+
+	/**
+	 * @testdox Should describe product output primitives using WooCommerce registries.
+	 */
+	public function test_product_output_schema_uses_woocommerce_primitive_constraints(): void {
+		$output_schema = wp_get_ability( 'woocommerce/product-create' )->get_output_schema();
+		$product       = $output_schema['properties']['product']['properties'] ?? array();
+
+		$this->assertSame( 'uri', $product['permalink']['format'] ?? null );
+		$this->assertSame( array( 'string', 'null' ), $product['permalink']['type'] ?? null );
+		$this->assertContains( get_woocommerce_currency(), $product['currency']['enum'] ?? array() );
+		$this->assertSame(
+			array( wc_is_stock_amount_integer() ? 'integer' : 'number', 'null' ),
+			$product['stock_quantity']['type'] ?? null
+		);
+	}
+
+	/**
+	 * @testdox Should describe collection pagination metadata in query outputs.
+	 */
+	public function test_collection_output_schemas_describe_pagination_metadata(): void {
+		$schemas = array(
+			'woocommerce/products-query' => 'products',
+			'woocommerce/orders-query'   => 'orders',
+		);
+
+		foreach ( $schemas as $ability_id => $collection_key ) {
+			$properties = wp_get_ability( $ability_id )->get_output_schema()['properties'] ?? array();
+
+			$this->assertArrayHasKey( $collection_key, $properties );
+			$this->assertArrayHasKey( 'total_pages', $properties );
+			$this->assertArrayHasKey( 'page', $properties );
+			$this->assertArrayHasKey( 'per_page', $properties );
+			$this->assertArrayNotHasKey( 'total', $properties );
+			$this->assertNotEmpty( $properties[ $collection_key ]['description'] ?? '' );
+			$this->assertNotEmpty( $properties['total_pages']['description'] ?? '' );
+			$this->assertNotEmpty( $properties['page']['description'] ?? '' );
+			$this->assertNotEmpty( $properties['per_page']['description'] ?? '' );
+		}
+	}
+
+	/**
+	 * @testdox Should expose agent-friendly product type alias inputs for product operations.
+	 */
+	public function test_product_schema_uses_agent_friendly_product_types(): void {
+		$query_schema  = wp_get_ability( 'woocommerce/products-query' )->get_input_schema();
+		$create_schema = wp_get_ability( 'woocommerce/product-create' )->get_input_schema();
+		$update_schema = wp_get_ability( 'woocommerce/product-update' )->get_input_schema();
+
+		$expected_aliases = array( 'physical', 'virtual', 'digital', 'affiliate', 'grouped' );
+
+		$this->assertSame( $expected_aliases, $query_schema['properties']['product_type_alias']['enum'] ?? array() );
+		$this->assertSame( $expected_aliases, $this->get_product_type_aliases_from_schema_branches( $create_schema ) );
+		$this->assertSame( $expected_aliases, $this->get_product_type_aliases_from_schema_branches( $update_schema ) );
+		$this->assertSame( 'physical', $this->get_product_type_alias_schema_branch( $create_schema, 'physical' )['properties']['product_type_alias']['default'] ?? null );
+		$this->assertArrayNotHasKey( 'default', $this->get_product_type_alias_schema_branch( $update_schema, 'physical' )['properties']['product_type_alias'] ?? array() );
+		$this->assertNotContains( 'variable', $query_schema['properties']['product_type_alias']['enum'] ?? array() );
+		$this->assertNotContains( 'variable', $this->get_product_type_aliases_from_schema_branches( $create_schema ) );
+		$this->assertArrayNotHasKey(
+			'product_type_alias',
+			wp_get_ability( 'woocommerce/product-create' )->get_output_schema()['properties']['product']['properties'] ?? array()
+		);
+		$this->assertArrayNotHasKey( 'default', $create_schema );
+		$this->assertArrayNotHasKey( 'default', $update_schema );
+	}
+
+	/**
+	 * @testdox Should expose only alias-relevant product fields in mutation schema branches.
+	 */
+	public function test_product_mutation_schema_branches_only_include_alias_relevant_fields(): void {
+		$create_schema = wp_get_ability( 'woocommerce/product-create' )->get_input_schema();
+		$update_schema = wp_get_ability( 'woocommerce/product-update' )->get_input_schema();
+
+		$physical_properties  = $this->get_product_type_alias_schema_branch( $create_schema, 'physical' )['properties'];
+		$affiliate_properties = $this->get_product_type_alias_schema_branch( $create_schema, 'affiliate' )['properties'];
+		$grouped_properties   = $this->get_product_type_alias_schema_branch( $create_schema, 'grouped' )['properties'];
+		$common_properties    = $update_schema['oneOf'][0]['properties'];
+
+		$this->assertArrayNotHasKey( 'external_url', $physical_properties );
+		$this->assertArrayNotHasKey( 'button_text', $physical_properties );
+		$this->assertArrayNotHasKey( 'grouped_products', $physical_properties );
+
+		$this->assertArrayNotHasKey( 'manage_stock', $affiliate_properties );
+		$this->assertArrayNotHasKey( 'stock_quantity', $affiliate_properties );
+		$this->assertArrayNotHasKey( 'grouped_products', $affiliate_properties );
+
+		$this->assertArrayNotHasKey( 'regular_price', $grouped_properties );
+		$this->assertArrayNotHasKey( 'sale_price', $grouped_properties );
+		$this->assertArrayNotHasKey( 'stock_status', $grouped_properties );
+
+		$this->assertArrayNotHasKey( 'product_type_alias', $common_properties );
+		$this->assertArrayNotHasKey( 'regular_price', $common_properties );
+		$this->assertArrayNotHasKey( 'external_url', $common_properties );
+		$this->assertArrayNotHasKey( 'grouped_products', $common_properties );
+	}
+
+	/**
+	 * @testdox Should describe order output primitives using WooCommerce registries.
+	 */
+	public function test_order_output_schema_uses_woocommerce_primitive_constraints(): void {
+		$output_schema = wp_get_ability( 'woocommerce/orders-query' )->get_output_schema();
+		$order         = $output_schema['properties']['orders']['items']['properties'] ?? array();
+
+		$this->assertContains( 'auto-draft', $order['status']['enum'] ?? array() );
+		$this->assertContains( 'trash', $order['status']['enum'] ?? array() );
+		$this->assertContains( 'checkout-draft', $order['status']['enum'] ?? array() );
+		$this->assertContains( get_woocommerce_currency(), $order['currency']['enum'] ?? array() );
+		$this->assertSame( array( 'string', 'null' ), $order['billing_email']['type'] ?? null );
+		$this->assertSame( 'email', $order['billing_email']['format'] ?? null );
+		$this->assertNotEmpty( $order['line_items']['description'] ?? '' );
+		$this->assertSame( 'array', $order['line_items']['type'] ?? null );
+		$this->assertSame( 'object', $order['line_items']['items']['type'] ?? null );
+		$this->assertArrayHasKey( 'quantity', $order['line_items']['items']['properties'] ?? array() );
+	}
+
+	/**
+	 * @testdox Should exclude checkout-draft from order status inputs.
+	 */
+	public function test_order_status_input_schemas_exclude_checkout_draft(): void {
+		$query_schema  = wp_get_ability( 'woocommerce/orders-query' )->get_input_schema();
+		$update_schema = wp_get_ability( 'woocommerce/order-update-status' )->get_input_schema();
+
+		$this->assertNotContains( 'checkout-draft', $query_schema['properties']['status']['enum'] ?? array() );
+		$this->assertNotContains( 'checkout-draft', $update_schema['properties']['status']['enum'] ?? array() );
+	}
+
+	/**
+	 * @testdox Should register extension ability classes appended via the loader filter.
+	 */
+	public function test_loader_filter_accepts_valid_extension_classes(): void {
+		add_filter( 'woocommerce_ability_definition_classes', array( $this, 'add_test_extension_ability_definition_class' ) );
+		$this->register_domain_abilities();
+		remove_filter( 'woocommerce_ability_definition_classes', array( $this, 'add_test_extension_ability_definition_class' ) );
+
+		$this->registered_ability_ids[] = TestExtensionAbilityDefinition::ABILITY_ID;
+		$ability                        = wp_get_ability( TestExtensionAbilityDefinition::ABILITY_ID );
+
+		$this->assertNotNull( $ability, 'Extension ability should be registered.' );
+		$this->assertSame( 'woocommerce', $ability->get_category() );
+
+		$result = $ability->execute();
+
+		$this->assertNotWPError( $result );
+		$this->assertTrue( $result['ok'] );
+	}
+
+	/**
+	 * @testdox Should skip extension ability classes that use the reserved WooCommerce namespace.
+	 */
+	public function test_loader_filter_skips_extension_classes_using_reserved_woocommerce_namespace(): void {
+		add_filter( 'woocommerce_ability_definition_classes', array( $this, 'add_reserved_woocommerce_ability_definition_class' ) );
+		$this->register_domain_abilities();
+		remove_filter( 'woocommerce_ability_definition_classes', array( $this, 'add_reserved_woocommerce_ability_definition_class' ) );
+
+		$ability = wp_get_ability( TestReservedWooAbilityDefinition::ABILITY_ID );
+
+		$this->assert_products_query_ability_is_canonical( $ability );
+	}
+
+	/**
+	 * @testdox Should preserve core WooCommerce definitions when the reserved namespace was registered first.
+	 */
+	public function test_loader_preserves_core_definition_when_reserved_namespace_ability_exists(): void {
+		$this->unregister_domain_abilities();
+
+		$shadow_callback = static function (): void {
+			wp_register_ability(
+				TestReservedWooAbilityDefinition::ABILITY_ID,
+				TestReservedWooAbilityDefinition::get_registration_args()
+			);
+		};
+		$logger          = $this->getMockBuilder( \WC_Logger_Interface::class )->getMock();
+		$logger_filter   = static function () use ( $logger ) {
+			return $logger;
+		};
+
+		$logger
+			->expects( $this->once() )
+			->method( 'warning' )
+			->with(
+				$this->stringContains( 'previously registered ability' ),
+				$this->callback(
+					static function ( $context ): bool {
+						return is_array( $context )
+							&& 'woocommerce-abilities' === ( $context['source'] ?? null )
+							&& TestReservedWooAbilityDefinition::ABILITY_ID === ( $context['ability_name'] ?? null )
+							&& ProductsQuery::class === ( $context['definition_class'] ?? null )
+							&& 'woocommerce/' === ( $context['reserved_prefix'] ?? null );
+					}
+				)
+			);
+
+		add_filter( 'woocommerce_logging_class', $logger_filter );
+		add_action( 'wp_abilities_api_init', $shadow_callback, 5 );
+		add_action( 'wp_abilities_api_init', array( AbilitiesLoader::class, 'register_abilities' ), 10 );
+
+		try {
+			do_action( 'wp_abilities_api_init' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Test bootstrap for Abilities API registration.
+		} finally {
+			remove_filter( 'woocommerce_logging_class', $logger_filter );
+			remove_action( 'wp_abilities_api_init', $shadow_callback, 5 );
+			remove_action( 'wp_abilities_api_init', array( AbilitiesLoader::class, 'register_abilities' ), 10 );
+		}
+
+		$ability = wp_get_ability( TestReservedWooAbilityDefinition::ABILITY_ID );
+
+		$this->assert_products_query_ability_is_canonical( $ability );
+	}
+
+	/**
+	 * @testdox Should not replace canonical abilities when registration is called again in the same request.
+	 */
+	public function test_register_abilities_is_idempotent_for_existing_canonical_abilities(): void {
+		$logger        = $this->getMockBuilder( \WC_Logger_Interface::class )->getMock();
+		$logger_filter = static function () use ( $logger ) {
+			return $logger;
+		};
+
+		$logger
+			->expects( $this->never() )
+			->method( 'warning' );
+
+		add_filter( 'woocommerce_logging_class', $logger_filter );
+
+		try {
+			$this->register_domain_abilities();
+		} finally {
+			remove_filter( 'woocommerce_logging_class', $logger_filter );
+		}
+
+		foreach ( self::CANONICAL_ABILITY_IDS as $ability_id ) {
+			$this->assertNotNull( wp_get_ability( $ability_id ), "{$ability_id} should remain registered." );
+		}
+	}
+
+	/**
+	 * @testdox Should ignore filter entries that are not strings or AbilityDefinition implementations.
+	 */
+	public function test_loader_filter_skips_invalid_entries(): void {
+		$callback = static function ( array $classes ): array {
+			$classes[] = '\\Some\\Class\\That\\Does\\Not\\Exist';
+			// Real class but not an AbilityDefinition.
+			$classes[] = \WC_Order::class;
+			// Wrong type.
+			$classes[] = 42;
+
+			return $classes;
+		};
+
+		add_filter( 'woocommerce_ability_definition_classes', $callback );
+		$this->register_domain_abilities();
+		remove_filter( 'woocommerce_ability_definition_classes', $callback );
+
+		// Canonical abilities should still register; nothing extra.
+		foreach ( self::CANONICAL_ABILITY_IDS as $ability_id ) {
+			$this->assertNotNull( wp_get_ability( $ability_id ), "{$ability_id} should remain registered." );
+		}
+	}
+
+	/**
+	 * @testdox Should keep canonical abilities when the loader filter returns an invalid value.
+	 */
+	public function test_loader_filter_cannot_remove_canonical_abilities_with_invalid_return(): void {
+		$this->unregister_domain_abilities();
+
+		$callback = static function () {
+			return 'not-a-class-list';
+		};
+
+		add_filter( 'woocommerce_ability_definition_classes', $callback );
+		$this->register_domain_abilities();
+		remove_filter( 'woocommerce_ability_definition_classes', $callback );
+
+		foreach ( self::CANONICAL_ABILITY_IDS as $ability_id ) {
+			$this->assertNotNull( wp_get_ability( $ability_id ), "{$ability_id} should remain registered." );
+		}
+	}
+
+	/**
+	 * @testdox Should register WooCommerce ability categories idempotently.
+	 */
+	public function test_ability_category_registration_is_idempotent(): void {
+		if ( ! function_exists( 'wp_register_ability_category' ) || ! function_exists( 'wp_has_ability_category' ) ) {
+			$this->markTestSkipped( 'Abilities API category registry is not available.' );
+		}
+
+		AbilitiesCategories::register_categories();
+		AbilitiesCategories::register_categories();
+
+		$this->assertTrue( wp_has_ability_category( 'woocommerce' ) );
+		$this->assertTrue( wp_has_ability_category( 'woocommerce-rest' ) );
+		$this->registered_category_ids[] = 'woocommerce-rest';
+	}
+
+	/*
+	 * ---------------------------------------------------------------------
+	 * Permission negative paths
+	 * ---------------------------------------------------------------------
+	 */
+
+	/**
+	 * @testdox Should reject every canonical ability when no user is authenticated.
+	 *
+	 * @dataProvider provider_canonical_abilities_with_minimal_input
+	 *
+	 * @param string $ability_id Ability ID.
+	 * @param array  $input      Minimal valid-shape input.
+	 */
+	public function test_abilities_reject_unauthenticated_user( string $ability_id, array $input ): void {
+		wp_set_current_user( 0 );
+
+		$result = wp_get_ability( $ability_id )->execute( $input );
+
+		$this->assertWPError( $result, "{$ability_id} should reject unauthenticated users." );
+		$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should reject every canonical ability for a subscriber with no shop caps.
+	 *
+	 * @dataProvider provider_canonical_abilities_with_minimal_input
+	 *
+	 * @param string $ability_id Ability ID.
+	 * @param array  $input      Minimal valid-shape input.
+	 */
+	public function test_abilities_reject_subscriber_without_caps( string $ability_id, array $input ): void {
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'subscriber' ) ) );
+
+		$result = wp_get_ability( $ability_id )->execute( $input );
+
+		$this->assertWPError( $result, "{$ability_id} should reject subscribers without shop caps." );
+		$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+	}
+
+	/**
+	 * Provides ability IDs and minimal valid-shape input for cap negative-path tests.
+	 *
+	 * @return array<string, array{0: string, 1: array}>
+	 */
+	public function provider_canonical_abilities_with_minimal_input(): array {
+		// Valid-shape input only — permission check should fire before execute.
+		return array(
+			'products-query'      => array(
+				'woocommerce/products-query',
+				array(
+					'id' => 1,
+				),
+			),
+			'product-create'      => array(
+				'woocommerce/product-create',
+				array(
+					'name' => 'Forbidden',
+				),
+			),
+			'product-update'      => array(
+				'woocommerce/product-update',
+				array(
+					'id'   => 1,
+					'name' => 'Forbidden',
+				),
+			),
+			'product-delete'      => array(
+				'woocommerce/product-delete',
+				array(
+					'id' => 1,
+				),
+			),
+			'orders-query'        => array(
+				'woocommerce/orders-query',
+				array(
+					'id' => 1,
+				),
+			),
+			'order-update-status' => array(
+				'woocommerce/order-update-status',
+				array(
+					'id'     => 1,
+					'status' => 'processing',
+				),
+			),
+			'order-add-note'      => array(
+				'woocommerce/order-add-note',
+				array(
+					'id'   => 1,
+					'note' => 'denied',
+				),
+			),
+		);
+	}
+
+	/**
+	 * @testdox Should require object edit and delete permissions for product mutations.
+	 */
+	public function test_product_mutations_require_object_permissions(): void {
+		$product_to_update           = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Owned by another user' ) );
+		$product_to_delete           = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Also owned by another user' ) );
+		$this->created_product_ids[] = $product_to_update->get_id();
+		$this->created_product_ids[] = $product_to_delete->get_id();
+		$limited_product_manager_id  = $this->create_user_with_caps(
+			array(
+				'edit_products',
+				'edit_published_products',
+				'delete_products',
+				'delete_published_products',
+			)
+		);
+		wp_set_current_user( $limited_product_manager_id );
+
+		$cases = array(
+			'woocommerce/product-update' => array(
+				'id'   => $product_to_update->get_id(),
+				'name' => 'Unauthorized update',
+			),
+			'woocommerce/product-delete' => array(
+				'id' => $product_to_delete->get_id(),
+			),
+		);
+
+		foreach ( $cases as $ability_id => $input ) {
+			$result = wp_get_ability( $ability_id )->execute( $input );
+
+			$this->assertWPError( $result, "{$ability_id} should reject users without object-level access." );
+			$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+		}
+	}
+
+	/**
+	 * @testdox Should require object edit permissions for order mutations.
+	 */
+	public function test_order_mutations_require_object_edit_permissions(): void {
+		$order                     = \WC_Helper_Order::create_order();
+		$this->created_order_ids[] = $order->get_id();
+		$limited_order_manager_id  = $this->create_user_with_caps(
+			array(
+				'edit_shop_orders',
+				'edit_published_shop_orders',
+			)
+		);
+		wp_set_current_user( $limited_order_manager_id );
+
+		$cases = array(
+			'woocommerce/order-update-status' => array(
+				'id'     => $order->get_id(),
+				'status' => 'processing',
+			),
+			'woocommerce/order-add-note'      => array(
+				'id'   => $order->get_id(),
+				'note' => 'Unauthorized note',
+			),
+		);
+
+		foreach ( $cases as $ability_id => $input ) {
+			$result = wp_get_ability( $ability_id )->execute( $input );
+
+			$this->assertWPError( $result, "{$ability_id} should reject users without object-level access." );
+			$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+		}
+	}
+
+	/**
+	 * @testdox Should require publish_products to transition an existing draft to statuses that publish capability protects.
+	 *
+	 * @dataProvider provider_product_statuses_requiring_publish_cap
+	 *
+	 * @param string $status Product status.
+	 */
+	public function test_product_update_publish_transition_requires_publish_cap( string $status ): void {
+		$product                     = \WC_Helper_Product::create_simple_product( true, array( 'status' => 'draft' ) );
+		$this->created_product_ids[] = $product->get_id();
+
+		$user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+		$user    = get_user_by( 'id', $user_id );
+		$user->add_cap( 'edit_products' );
+		$user->add_cap( 'edit_others_products' );
+		$user->add_cap( 'edit_published_products' );
+		wp_set_current_user( $user_id );
+
+		$result = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'     => $product->get_id(),
+				'status' => $status,
+			)
+		);
+
+		$this->assertWPError( $result, "A user without publish_products should not be able to set status {$status}." );
+		$this->assertSame( 'woocommerce_product_publish_forbidden', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should allow non-status updates to published products without publish_products.
+	 */
+	public function test_product_update_allows_published_product_edits_without_publish_cap_when_status_is_unchanged(): void {
+		$product                     = \WC_Helper_Product::create_simple_product( true, array( 'status' => 'publish' ) );
+		$this->created_product_ids[] = $product->get_id();
+
+		$user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+		$user    = get_user_by( 'id', $user_id );
+		$user->add_cap( 'edit_products' );
+		$user->add_cap( 'edit_others_products' );
+		$user->add_cap( 'edit_published_products' );
+		wp_set_current_user( $user_id );
+
+		$result = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'   => $product->get_id(),
+				'name' => 'Updated Published Product',
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( 'Updated Published Product', $result['product']['name'] );
+		$this->assertSame( 'publish', $result['product']['status'] );
+	}
+
+	/**
+	 * Product statuses that require publish_products for a transition.
+	 *
+	 * @return array<string, array{0: string}>
+	 */
+	public function provider_product_statuses_requiring_publish_cap(): array {
+		return array(
+			'publish' => array( 'publish' ),
+			'future'  => array( 'future' ),
+			'private' => array( 'private' ),
+		);
+	}
+
+	/*
+	 * ---------------------------------------------------------------------
+	 * Input validation
+	 * ---------------------------------------------------------------------
+	 */
+
+	/**
+	 * @testdox Should reject order status updates with an unknown status slug.
+	 */
+	public function test_order_update_status_rejects_invalid_status_slug(): void {
+		$order                     = \WC_Helper_Order::create_order();
+		$this->created_order_ids[] = $order->get_id();
+
+		$result = wp_get_ability( 'woocommerce/order-update-status' )->execute(
+			array(
+				'id'     => $order->get_id(),
+				'status' => 'totally-bogus',
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should reject product creation with a status not in the allowed enum.
+	 */
+	public function test_product_create_rejects_unknown_status(): void {
+		$result = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name'   => 'Bad Status Product',
+				'status' => 'not-a-real-status',
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should reject auto-draft as a product mutation status.
+	 */
+	public function test_product_create_rejects_auto_draft_status(): void {
+		$result = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name'   => 'Auto Draft Product',
+				'status' => 'auto-draft',
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should reject extra unknown fields on product create input.
+	 */
+	public function test_product_create_rejects_unknown_input_field(): void {
+		$result = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name'        => 'Mass Assigned',
+				'invoice_url' => 'https://attacker.example.com',
+				'admin_notes' => 'tampering',
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should reject orders-query orderby values outside the allowed enum.
+	 */
+	public function test_orders_query_rejects_unknown_orderby(): void {
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array( 'orderby' => 'malicious_field' )
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should reject uppercase order directions.
+	 */
+	public function test_orders_query_rejects_uppercase_order_direction(): void {
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array( 'order' => 'ASC' )
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should reject non-numeric product prices during schema validation.
+	 *
+	 * @dataProvider provider_invalid_product_price_inputs
+	 *
+	 * @param string $price Invalid price input.
+	 */
+	public function test_product_create_rejects_non_numeric_prices( string $price ): void {
+		$result = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name'          => 'Invalid Price Product',
+				'regular_price' => $price,
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+	}
+
+	/**
+	 * Provides invalid product price inputs.
+	 *
+	 * @return array<string, array{0: string}>
+	 */
+	public function provider_invalid_product_price_inputs(): array {
+		return array(
+			'text'               => array( 'free' ),
+			'currency-symbol'    => array( '$10.00' ),
+			'thousand-separator' => array( '1,234.56' ),
+		);
+	}
+
+	/**
+	 * @testdox Should accept the configured WooCommerce decimal separator for product prices.
+	 */
+	public function test_product_create_accepts_configured_decimal_separator_for_prices(): void {
+		$original_decimal_separator = get_option( 'woocommerce_price_decimal_sep' );
+		$created                    = null;
+
+		$this->unregister_domain_abilities();
+		update_option( 'woocommerce_price_decimal_sep', ',' );
+
+		try {
+			$this->register_domain_abilities();
+
+			$created = wp_get_ability( 'woocommerce/product-create' )->execute(
+				array(
+					'name'          => 'Localized Price Product',
+					'regular_price' => '10,99',
+				)
+			);
+		} finally {
+			$this->unregister_domain_abilities();
+			update_option( 'woocommerce_price_decimal_sep', $original_decimal_separator );
+			$this->register_domain_abilities();
+		}
+
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$this->assertSame( '10.99', $created['product']['regular_price'] );
+	}
+
+	/**
+	 * @testdox Should reject whitespace-only order notes during schema validation.
+	 */
+	public function test_order_add_note_schema_rejects_whitespace_only_note(): void {
+		$order                     = \WC_Helper_Order::create_order();
+		$this->created_order_ids[] = $order->get_id();
+
+		$result = wp_get_ability( 'woocommerce/order-add-note' )->execute(
+			array(
+				'id'   => $order->get_id(),
+				'note' => '   ',
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should reject negative entity IDs during schema validation.
+	 *
+	 * @dataProvider provider_negative_entity_id_inputs
+	 *
+	 * @param string $ability_id Ability ID.
+	 * @param array  $input      Ability input.
+	 */
+	public function test_entity_id_schemas_reject_negative_ids( string $ability_id, array $input ): void {
+		$result = wp_get_ability( $ability_id )->execute( $input );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+	}
+
+	/**
+	 * Provides negative ID inputs for entity abilities.
+	 *
+	 * @return array<string, array{0: string, 1: array}>
+	 */
+	public function provider_negative_entity_id_inputs(): array {
+		return array(
+			'products-query'      => array(
+				'woocommerce/products-query',
+				array( 'id' => -123 ),
+			),
+			'product-update'      => array(
+				'woocommerce/product-update',
+				array(
+					'id'   => -123,
+					'name' => 'Invalid',
+				),
+			),
+			'product-delete'      => array(
+				'woocommerce/product-delete',
+				array( 'id' => -123 ),
+			),
+			'orders-query'        => array(
+				'woocommerce/orders-query',
+				array( 'id' => -123 ),
+			),
+			'order-update-status' => array(
+				'woocommerce/order-update-status',
+				array(
+					'id'     => -123,
+					'status' => 'processing',
+				),
+			),
+			'order-add-note'      => array(
+				'woocommerce/order-add-note',
+				array(
+					'id'   => -123,
+					'note' => 'Invalid',
+				),
+			),
+		);
+	}
+
+	/**
+	 * @testdox Should reject negative order query filter IDs during schema validation.
+	 *
+	 * @dataProvider provider_negative_order_query_filter_id_inputs
+	 *
+	 * @param array $input Ability input.
+	 */
+	public function test_orders_query_filter_id_schemas_reject_negative_ids( array $input ): void {
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute( $input );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+	}
+
+	/**
+	 * Provides negative order query filter ID inputs.
+	 *
+	 * @return array<string, array{0: array}>
+	 */
+	public function provider_negative_order_query_filter_id_inputs(): array {
+		return array(
+			'customer-id' => array(
+				array( 'customer_id' => -123 ),
+			),
+			'parent'      => array(
+				array( 'parent' => -123 ),
+			),
+		);
+	}
+
+	/**
+	 * @testdox Should allow customer_id=0 to filter guest orders.
+	 */
+	public function test_orders_query_allows_customer_id_zero_for_guest_orders(): void {
+		$email = wp_unique_id( 'abilities-guest-order-' ) . '@example.com';
+		$order = \WC_Helper_Order::create_order();
+		$order->set_customer_id( 0 );
+		$order->set_billing_email( $email );
+		$order->save();
+		$this->created_order_ids[] = $order->get_id();
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'customer_id'   => 0,
+				'billing_email' => $email,
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( array( $order->get_id() ), array_column( $result['orders'], 'id' ) );
+	}
+
+	/**
+	 * Provides order storage engine states.
+	 *
+	 * @return array<string, array{0: bool}>
+	 */
+	public function provider_order_storage_engines(): array {
+		return array(
+			'legacy' => array( false ),
+			'hpos'   => array( true ),
+		);
+	}
+
+	/*
+	 * ---------------------------------------------------------------------
+	 * Execution behaviors
+	 * ---------------------------------------------------------------------
+	 */
+
+	/**
+	 * @testdox Should not force products-query to published products by default.
+	 */
+	public function test_products_query_does_not_force_publish_status_by_default(): void {
+		$published                   = \WC_Helper_Product::create_simple_product(
+			true,
+			array( 'name' => 'Public Item' )
+		);
+		$this->created_product_ids[] = $published->get_id();
+
+		$draft                       = \WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'   => 'Draft Item',
+				'status' => 'draft',
+			)
+		);
+		$this->created_product_ids[] = $draft->get_id();
+
+		$result = wp_get_ability( 'woocommerce/products-query' )->execute( array() );
+
+		$this->assertNotWPError( $result );
+		$ids = array_column( $result['products'], 'id' );
+		$this->assertContains( $published->get_id(), $ids );
+		$this->assertContains( $draft->get_id(), $ids );
+	}
+
+	/**
+	 * @testdox Should return drafts when explicitly filtered by status=draft.
+	 */
+	public function test_products_query_returns_drafts_when_explicitly_requested(): void {
+		$draft                       = \WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'   => 'Hidden Draft',
+				'status' => 'draft',
+				'sku'    => 'hidden-draft-sku',
+			)
+		);
+		$this->created_product_ids[] = $draft->get_id();
+
+		$result = wp_get_ability( 'woocommerce/products-query' )->execute(
+			array(
+				'status' => 'draft',
+				'sku'    => 'hidden-draft-sku',
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( 1, $result['total_pages'] );
+		$this->assertSame( $draft->get_id(), $result['products'][0]['id'] );
+	}
+
+	/**
+	 * @testdox Should include currency context in product responses.
+	 */
+	public function test_product_response_includes_currency_context(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name'          => 'Priced Product',
+				'regular_price' => '12.34',
+			)
+		);
+
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+
+		$this->assertSame( get_woocommerce_currency(), $created['product']['currency'] );
+		$this->assertNotEmpty( $created['product']['currency_symbol'] );
+		$this->assertSame( '12.34', $created['product']['regular_price'] );
+	}
+
+	/**
+	 * @testdox Should map digital products to simple virtual downloadable products.
+	 */
+	public function test_product_create_maps_digital_product_type_to_simple_downloadable_product(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name'               => 'Digital Product',
+				'product_type_alias' => 'digital',
+				'regular_price'      => '19.99',
+			)
+		);
+
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$this->assertSame( 'simple', $created['product']['type'] );
+		$this->assertTrue( $created['product']['virtual'] );
+		$this->assertTrue( $created['product']['downloadable'] );
+	}
+
+	/**
+	 * @testdox Should map virtual products to simple virtual non-downloadable products.
+	 */
+	public function test_product_create_maps_virtual_product_type_alias_to_simple_virtual_product(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name'               => 'Virtual Product',
+				'product_type_alias' => 'virtual',
+				'regular_price'      => '29.99',
+			)
+		);
+
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$this->assertSame( 'simple', $created['product']['type'] );
+		$this->assertTrue( $created['product']['virtual'] );
+		$this->assertFalse( $created['product']['downloadable'] );
+	}
+
+	/**
+	 * @testdox Should query simple products by agent-friendly physical, virtual, and digital product type aliases.
+	 */
+	public function test_products_query_maps_agent_friendly_product_types_to_product_fields(): void {
+		$physical_sku = wp_unique_id( 'abilities-physical-' );
+		$virtual_sku  = wp_unique_id( 'abilities-virtual-' );
+		$digital_sku  = wp_unique_id( 'abilities-digital-' );
+		$hybrid_sku   = wp_unique_id( 'abilities-downloadable-shippable-' );
+
+		$physical = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name'               => 'Physical Query Product',
+				'product_type_alias' => 'physical',
+				'sku'                => $physical_sku,
+			)
+		);
+
+		$this->assertNotWPError( $physical );
+		$this->created_product_ids[] = $physical['product']['id'];
+
+		$virtual = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name'               => 'Virtual Query Product',
+				'product_type_alias' => 'virtual',
+				'sku'                => $virtual_sku,
+			)
+		);
+
+		$this->assertNotWPError( $virtual );
+		$this->created_product_ids[] = $virtual['product']['id'];
+
+		$digital = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name'               => 'Digital Query Product',
+				'product_type_alias' => 'digital',
+				'sku'                => $digital_sku,
+			)
+		);
+
+		$this->assertNotWPError( $digital );
+		$this->created_product_ids[] = $digital['product']['id'];
+
+		$hybrid = \WC_Helper_Product::create_simple_product();
+		$hybrid->set_sku( $hybrid_sku );
+		$hybrid->set_virtual( false );
+		$hybrid->set_downloadable( true );
+		$hybrid->save();
+		$this->created_product_ids[] = $hybrid->get_id();
+
+		$physical_result = wp_get_ability( 'woocommerce/products-query' )->execute(
+			array(
+				'product_type_alias' => 'physical',
+				'sku'                => $physical_sku,
+			)
+		);
+
+		$this->assertNotWPError( $physical_result );
+		$this->assertSame( 1, $physical_result['total_pages'] );
+		$this->assertSame( $physical['product']['id'], $physical_result['products'][0]['id'] );
+
+		$virtual_result = wp_get_ability( 'woocommerce/products-query' )->execute(
+			array(
+				'product_type_alias' => 'virtual',
+				'sku'                => $virtual_sku,
+			)
+		);
+
+		$this->assertNotWPError( $virtual_result );
+		$this->assertSame( 1, $virtual_result['total_pages'] );
+		$this->assertSame( $virtual['product']['id'], $virtual_result['products'][0]['id'] );
+
+		$digital_result = wp_get_ability( 'woocommerce/products-query' )->execute(
+			array(
+				'product_type_alias' => 'digital',
+				'sku'                => $digital_sku,
+			)
+		);
+
+		$this->assertNotWPError( $digital_result );
+		$this->assertSame( 1, $digital_result['total_pages'] );
+		$this->assertSame( $digital['product']['id'], $digital_result['products'][0]['id'] );
+
+		$mismatched_result = wp_get_ability( 'woocommerce/products-query' )->execute(
+			array(
+				'product_type_alias' => 'physical',
+				'sku'                => $digital_sku,
+			)
+		);
+
+		$this->assertNotWPError( $mismatched_result );
+		$this->assertSame( 0, $mismatched_result['total_pages'] );
+
+		$unfiltered_hybrid_result = wp_get_ability( 'woocommerce/products-query' )->execute(
+			array(
+				'sku' => $hybrid_sku,
+			)
+		);
+
+		$this->assertNotWPError( $unfiltered_hybrid_result );
+		$this->assertSame( $hybrid->get_id(), $unfiltered_hybrid_result['products'][0]['id'] );
+
+		$physical_hybrid_result = wp_get_ability( 'woocommerce/products-query' )->execute(
+			array(
+				'product_type_alias' => 'physical',
+				'sku'                => $hybrid_sku,
+			)
+		);
+
+		$this->assertNotWPError( $physical_hybrid_result );
+		$this->assertSame( 0, $physical_hybrid_result['total_pages'] );
+	}
+
+	/**
+	 * @testdox Should map affiliate products to external products.
+	 */
+	public function test_product_create_maps_affiliate_product_type_to_external_product(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name'               => 'Affiliate Product',
+				'product_type_alias' => 'affiliate',
+				'regular_price'      => '49.99',
+				'external_url'       => 'https://example.com/buy',
+				'button_text'        => 'Buy elsewhere',
+			)
+		);
+
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$this->assertSame( 'external', $created['product']['type'] );
+		$this->assertSame( 'https://example.com/buy', $created['product']['external_url'] );
+		$this->assertSame( 'Buy elsewhere', $created['product']['button_text'] );
+	}
+
+	/**
+	 * @testdox Should map grouped products to grouped WooCommerce products with children.
+	 */
+	public function test_product_create_maps_grouped_product_type_to_grouped_product(): void {
+		$child                       = \WC_Helper_Product::create_simple_product();
+		$this->created_product_ids[] = $child->get_id();
+
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name'               => 'Grouped Product',
+				'product_type_alias' => 'grouped',
+				'grouped_products'   => array( $child->get_id() ),
+			)
+		);
+
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$this->assertSame( 'grouped', $created['product']['type'] );
+		$this->assertSame( array( $child->get_id() ), $created['product']['grouped_products'] );
+	}
+
+	/**
+	 * @testdox Should support the WooCommerce SKU wildcard for products with any non-empty SKU.
+	 */
+	public function test_products_query_supports_sku_wildcard_for_products_with_any_sku(): void {
+		$sku_product = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name' => 'SKU Product',
+				'sku'  => wp_unique_id( 'abilities-any-sku-' ),
+			)
+		);
+
+		$this->assertNotWPError( $sku_product );
+		$this->created_product_ids[] = $sku_product['product']['id'];
+
+		$no_sku_product = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'name' => 'No SKU Product',
+			)
+		);
+
+		$this->assertNotWPError( $no_sku_product );
+		$this->created_product_ids[] = $no_sku_product['product']['id'];
+
+		$result = wp_get_ability( 'woocommerce/products-query' )->execute(
+			array(
+				'sku'      => '*',
+				'per_page' => 100,
+			)
+		);
+
+		$this->assertNotWPError( $result );
+
+		$ids = array_column( $result['products'], 'id' );
+		$this->assertContains( $sku_product['product']['id'], $ids );
+		$this->assertNotContains( $no_sku_product['product']['id'], $ids );
+	}
+
+	/**
+	 * @testdox Should reject invalid grouped product IDs before setting children.
+	 */
+	public function test_product_create_rejects_invalid_grouped_product_ids(): void {
+		$result = ProductCreate::execute(
+			array(
+				'name'               => 'Invalid Grouped Product',
+				'product_type_alias' => 'grouped',
+				'grouped_products'   => array( -12 ),
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_grouped_products_invalid', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should reject fields that are unsupported by the selected product type alias.
+	 */
+	public function test_product_create_rejects_fields_not_supported_by_product_type_alias(): void {
+		$result = ProductCreate::execute(
+			array(
+				'name'               => 'Invalid Grouped Product',
+				'product_type_alias' => 'grouped',
+				'regular_price'      => '19.99',
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_field_unsupported', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should return an error when product creation fails to persist.
+	 */
+	public function test_product_create_returns_error_when_save_fails(): void {
+		add_filter( 'wp_insert_post_empty_content', '__return_true' );
+
+		try {
+			$result = wp_get_ability( 'woocommerce/product-create' )->execute(
+				array(
+					'name' => 'Failed Product',
+				)
+			);
+		} finally {
+			remove_filter( 'wp_insert_post_empty_content', '__return_true' );
+		}
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_create_failed', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should support fractional product stock quantities when WooCommerce is configured for them.
+	 */
+	public function test_product_stock_quantity_schema_allows_fractional_stock_amounts(): void {
+		$this->unregister_domain_abilities();
+		remove_filter( 'woocommerce_stock_amount', 'intval' );
+		add_filter( 'woocommerce_stock_amount', array( $this, 'preserve_fractional_stock_amount' ) );
+
+		try {
+			$this->register_domain_abilities();
+
+			$input_schema  = wp_get_ability( 'woocommerce/product-create' )->get_input_schema();
+			$output_schema = wp_get_ability( 'woocommerce/product-create' )->get_output_schema();
+
+			$this->assertSame( 'number', $this->get_product_type_alias_schema_branch( $input_schema, 'physical' )['properties']['stock_quantity']['type'] ?? null );
+			$this->assertSame(
+				array( 'number', 'null' ),
+				$output_schema['properties']['product']['properties']['stock_quantity']['type'] ?? null
+			);
+
+			$created = wp_get_ability( 'woocommerce/product-create' )->execute(
+				array(
+					'name'           => 'Fractional Stock Product',
+					'manage_stock'   => true,
+					'stock_quantity' => 1.5,
+				)
+			);
+
+			$this->assertNotWPError( $created );
+			$this->created_product_ids[] = $created['product']['id'];
+			$this->assertSame( 1.5, $created['product']['stock_quantity'] );
+		} finally {
+			remove_filter( 'woocommerce_stock_amount', array( $this, 'preserve_fractional_stock_amount' ) );
+			add_filter( 'woocommerce_stock_amount', 'intval' );
+		}
+	}
+
+	/**
+	 * @testdox Should update product properties and reflect changes in the response.
+	 */
+	public function test_product_update_changes_props_and_returns_updated_response(): void {
+		$product                     = \WC_Helper_Product::create_simple_product();
+		$this->created_product_ids[] = $product->get_id();
+
+		$result = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'                 => $product->get_id(),
+				'product_type_alias' => 'physical',
+				'name'               => 'Updated Name',
+				'regular_price'      => '99.00',
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( 'Updated Name', $result['product']['name'] );
+		$this->assertSame( '99.00', $result['product']['regular_price'] );
+	}
+
+	/**
+	 * @testdox Should return a structured error when product updates throw during save.
+	 */
+	public function test_product_update_returns_error_when_save_throws_exception(): void {
+		$product                     = \WC_Helper_Product::create_simple_product();
+		$this->created_product_ids[] = $product->get_id();
+		$throw_on_update             = static function (): void {
+			throw new \Exception( 'Generic product save failure.' );
+		};
+		$logger                      = $this->getMockBuilder( \WC_Logger_Interface::class )->getMock();
+		$logger_filter               = static function () use ( $logger ) {
+			return $logger;
+		};
+
+		$logger
+			->expects( $this->once() )
+			->method( 'error' )
+			->with(
+				$this->stringContains( 'failed to save product' ),
+				$this->callback(
+					static function ( $context ) use ( $product ): bool {
+						return is_array( $context )
+							&& 'woocommerce-abilities' === ( $context['source'] ?? null )
+							&& 'woocommerce_product_update_failed' === ( $context['failure_code'] ?? null )
+							&& $product->get_id() === ( $context['product_id'] ?? null )
+							&& \Exception::class === ( $context['exception'] ?? null )
+							&& 'Generic product save failure.' === ( $context['error_message'] ?? null );
+					}
+				)
+			);
+
+		add_filter( 'woocommerce_logging_class', $logger_filter );
+		add_action( 'woocommerce_update_product', $throw_on_update );
+
+		try {
+			$result = wp_get_ability( 'woocommerce/product-update' )->execute(
+				array(
+					'id'   => $product->get_id(),
+					'name' => 'Updated Name',
+				)
+			);
+		} finally {
+			remove_action( 'woocommerce_update_product', $throw_on_update );
+			remove_filter( 'woocommerce_logging_class', $logger_filter );
+		}
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_update_failed', $result->get_error_code() );
+		$this->assertSame( 'Failed to save product.', $result->get_error_message() );
+		$this->assertSame( 500, $result->get_error_data()['status'] );
+	}
+
+	/**
+	 * @testdox Should apply product type configuration when updating a product type alias.
+	 */
+	public function test_product_update_applies_product_type_configuration(): void {
+		$product                     = \WC_Helper_Product::create_simple_product();
+		$this->created_product_ids[] = $product->get_id();
+
+		$result = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'                 => $product->get_id(),
+				'product_type_alias' => 'digital',
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( 'simple', $result['product']['type'] );
+		$this->assertTrue( $result['product']['virtual'] );
+		$this->assertTrue( $result['product']['downloadable'] );
+	}
+
+	/**
+	 * @testdox Should reject product updates that include no changed fields.
+	 */
+	public function test_product_update_requires_at_least_one_mutation_field(): void {
+		$product                     = \WC_Helper_Product::create_simple_product();
+		$this->created_product_ids[] = $product->get_id();
+
+		$result = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id' => $product->get_id(),
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_update_no_fields', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should hard-delete a product when force=true.
+	 */
+	public function test_product_delete_force_true_hard_deletes(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+
+		$result = wp_get_ability( 'woocommerce/product-delete' )->execute(
+			array(
+				'id'    => $product->get_id(),
+				'force' => true,
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertTrue( $result['deleted'] );
+		$this->assertSame( $product->get_id(), $result['id'] );
+		$this->assertNull( get_post( $product->get_id() ) );
+	}
+
+	/**
+	 * @testdox Should reject product variation IDs when deleting products.
+	 */
+	public function test_product_delete_rejects_variation_ids(): void {
+		$variation_id = $this->create_variation_product_id_for_test();
+
+		$result = wp_get_ability( 'woocommerce/product-delete' )->execute(
+			array(
+				'id'    => $variation_id,
+				'force' => true,
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_type_unsupported', $result->get_error_code() );
+		$this->assertNotNull( wc_get_product( $variation_id ) );
+	}
+
+	/**
+	 * @testdox Should trash a product by default.
+	 */
+	public function test_product_delete_defaults_to_trash(): void {
+		$product                     = \WC_Helper_Product::create_simple_product();
+		$this->created_product_ids[] = $product->get_id();
+
+		$result = wp_get_ability( 'woocommerce/product-delete' )->execute(
+			array(
+				'id' => $product->get_id(),
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertTrue( $result['deleted'] );
+		$this->assertSame( 'trash', get_post_status( $product->get_id() ) );
+	}
+
+	/**
+	 * @testdox Should reject soft deletes when product trashing is not supported.
+	 */
+	public function test_product_delete_rejects_soft_delete_when_trash_not_supported(): void {
+		$product                     = \WC_Helper_Product::create_simple_product();
+		$this->created_product_ids[] = $product->get_id();
+
+		add_filter( 'woocommerce_product_object_trashable', '__return_false' );
+
+		try {
+			$result = wp_get_ability( 'woocommerce/product-delete' )->execute(
+				array(
+					'id' => $product->get_id(),
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_product_object_trashable', '__return_false' );
+		}
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_trash_not_supported', $result->get_error_code() );
+		$this->assertNotSame( 'trash', get_post_status( $product->get_id() ) );
+	}
+
+	/**
+	 * @testdox Should return an error when product deletion is blocked.
+	 */
+	public function test_product_delete_returns_error_when_delete_is_blocked(): void {
+		$product                     = \WC_Helper_Product::create_simple_product();
+		$this->created_product_ids[] = $product->get_id();
+
+		$callback = static function () {
+			return false;
+		};
+
+		add_filter( 'woocommerce_pre_delete_product', $callback );
+
+		try {
+			$result = wp_get_ability( 'woocommerce/product-delete' )->execute(
+				array(
+					'id' => $product->get_id(),
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_pre_delete_product', $callback );
+		}
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_delete_failed', $result->get_error_code() );
+		$this->assertNotSame( 'trash', get_post_status( $product->get_id() ) );
+	}
+
+	/**
+	 * @testdox Should return a not-found error when querying an unknown product ID.
+	 */
+	public function test_products_query_returns_not_found_for_unknown_id(): void {
+		$result = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => 999999 ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_not_found', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should reject product mutation unknown IDs without leaking existence.
+	 *
+	 * @dataProvider provider_product_mutation_unknown_id_inputs
+	 *
+	 * @param string $ability_id Ability ID.
+	 * @param array  $input      Ability input.
+	 */
+	public function test_product_mutations_reject_unknown_ids_without_leaking_existence( string $ability_id, array $input ): void {
+		$result = wp_get_ability( $ability_id )->execute( $input );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+	}
+
+	/**
+	 * Provides product mutation inputs with unknown IDs.
+	 *
+	 * @return array<string, array{0: string, 1: array}>
+	 */
+	public function provider_product_mutation_unknown_id_inputs(): array {
+		return array(
+			'product-update' => array(
+				'woocommerce/product-update',
+				array(
+					'id'   => 999999,
+					'name' => 'Unknown Product',
+				),
+			),
+			'product-delete' => array(
+				'woocommerce/product-delete',
+				array(
+					'id' => 999999,
+				),
+			),
+		);
+	}
+
+	/**
+	 * @testdox Should reject product variation IDs when querying products by ID.
+	 */
+	public function test_products_query_rejects_variation_ids(): void {
+		$variation_id = $this->create_variation_product_id_for_test();
+
+		$result = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => $variation_id ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_type_unsupported', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should return products without public permalinks without output validation errors.
+	 */
+	public function test_products_query_handles_products_without_public_permalink(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$product->set_status( 'auto-draft' );
+		$product->save();
+		$this->created_product_ids[] = $product->get_id();
+
+		$filter_permalink = static function ( $permalink, $post ) use ( $product ) {
+			return $post instanceof \WP_Post && $post->ID === $product->get_id() ? false : $permalink;
+		};
+
+		add_filter( 'post_type_link', $filter_permalink, 20, 2 );
+
+		try {
+			$result = wp_get_ability( 'woocommerce/products-query' )->execute(
+				array(
+					'id' => $product->get_id(),
+				)
+			);
+		} finally {
+			remove_filter( 'post_type_link', $filter_permalink, 20 );
+		}
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( 'auto-draft', $result['products'][0]['status'] );
+		$this->assertNull( $result['products'][0]['permalink'] );
+	}
+
+	/**
+	 * @testdox Should return a not-found error when orders-query receives an unknown order ID.
+	 *
+	 * @dataProvider provider_order_storage_engines
+	 *
+	 * @param bool $hpos_enabled Whether HPOS/COT should be enabled for the test.
+	 */
+	public function test_orders_query_returns_not_found_for_unknown_id( bool $hpos_enabled ): void {
+		$this->set_order_storage_for_test( $hpos_enabled );
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute( array( 'id' => 999999 ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_order_not_found', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should query orders by billing email and include line items when requested.
+	 */
+	public function test_orders_query_filters_by_billing_email_with_line_items(): void {
+		$order = \WC_Helper_Order::create_order();
+		$order->set_billing_email( 'domain-order-query@example.com' );
+		$order->save();
+		$this->created_order_ids[] = $order->get_id();
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'billing_email'      => 'domain-order-query@example.com',
+				'include_line_items' => true,
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( 1, $result['total_pages'] );
+		$this->assertSame( $order->get_id(), $result['orders'][0]['id'] );
+		$this->assertNotEmpty( $result['orders'][0]['line_items'] );
+		$this->assertNotEmpty( $result['orders'][0]['currency_symbol'] );
+	}
+
+	/**
+	 * @testdox Should exclude checkout-draft orders from default order queries.
+	 *
+	 * @dataProvider provider_order_storage_engines
+	 *
+	 * @param bool $hpos_enabled Whether HPOS/COT should be enabled for the test.
+	 */
+	public function test_orders_query_excludes_checkout_draft_orders_by_default( bool $hpos_enabled ): void {
+		$this->set_order_storage_for_test( $hpos_enabled );
+
+		$email = wp_unique_id( 'abilities-checkout-draft-' ) . '@example.com';
+
+		$processing_order = \WC_Helper_Order::create_order();
+		$processing_order->set_billing_email( $email );
+		$processing_order->set_status( 'processing' );
+		$processing_order->save();
+		$this->created_order_ids[] = $processing_order->get_id();
+
+		$checkout_draft = \WC_Helper_Order::create_order();
+		$checkout_draft->set_billing_email( $email );
+		$checkout_draft->set_status( 'checkout-draft' );
+		$checkout_draft->save();
+		$this->created_order_ids[] = $checkout_draft->get_id();
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'billing_email' => $email,
+				'per_page'      => 10,
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$ids = array_column( $result['orders'], 'id' );
+		$this->assertContains( $processing_order->get_id(), $ids );
+		$this->assertNotContains( $checkout_draft->get_id(), $ids );
+	}
+
+	/**
+	 * @testdox Should sort orders by ID across order storage engines.
+	 *
+	 * @dataProvider provider_order_storage_engines
+	 *
+	 * @param bool $hpos_enabled Whether HPOS/COT should be enabled for the test.
+	 */
+	public function test_orders_query_sorts_by_id_across_order_storage_engines( bool $hpos_enabled ): void {
+		$this->set_order_storage_for_test( $hpos_enabled );
+
+		$email  = wp_unique_id( 'abilities-order-id-sort-' ) . '@example.com';
+		$first  = $this->create_order_for_query_sorting( $email, '2025-01-01T12:00:00', '2025-01-01T12:00:00' );
+		$second = $this->create_order_for_query_sorting( $email, '2020-01-01T12:00:00', '2020-01-01T12:00:00' );
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'billing_email' => $email,
+				'orderby'       => 'id',
+				'order'         => 'asc',
+				'per_page'      => 2,
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertSame(
+			array( $first->get_id(), $second->get_id() ),
+			array_column( $result['orders'], 'id' )
+		);
+	}
+
+	/**
+	 * @testdox Should sort orders by modified date across order storage engines.
+	 *
+	 * @dataProvider provider_order_storage_engines
+	 *
+	 * @param bool $hpos_enabled Whether HPOS/COT should be enabled for the test.
+	 */
+	public function test_orders_query_sorts_by_modified_date_across_order_storage_engines( bool $hpos_enabled ): void {
+		$this->set_order_storage_for_test( $hpos_enabled );
+
+		$email  = wp_unique_id( 'abilities-order-modified-sort-' ) . '@example.com';
+		$first  = $this->create_order_for_query_sorting( $email, '2020-01-01T12:00:00', '2025-01-01T12:00:00' );
+		$second = $this->create_order_for_query_sorting( $email, '2025-01-01T12:00:00', '2020-01-01T12:00:00' );
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'billing_email' => $email,
+				'orderby'       => 'date_modified',
+				'order'         => 'asc',
+				'per_page'      => 2,
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertSame(
+			array( $second->get_id(), $first->get_id() ),
+			array_column( $result['orders'], 'id' )
+		);
+	}
+
+	/**
+	 * @testdox Should return null for absent billing email values.
+	 */
+	public function test_orders_query_returns_null_for_absent_billing_email(): void {
+		$order = wc_create_order();
+		$this->assertNotWPError( $order );
+		$order->save();
+		$this->created_order_ids[] = $order->get_id();
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'id' => $order->get_id(),
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertNull( $result['orders'][0]['billing_email'] );
+	}
+
+	/**
+	 * @testdox Should narrow orders-query results by date range.
+	 */
+	public function test_orders_query_filters_by_date_range(): void {
+		$old_order = \WC_Helper_Order::create_order();
+		$old_order->set_date_created( '2020-01-15T00:00:00' );
+		$old_order->set_billing_email( 'date-range@example.com' );
+		$old_order->save();
+		$this->created_order_ids[] = $old_order->get_id();
+
+		$new_order = \WC_Helper_Order::create_order();
+		$new_order->set_date_created( '2025-01-15T00:00:00' );
+		$new_order->set_billing_email( 'date-range@example.com' );
+		$new_order->save();
+		$this->created_order_ids[] = $new_order->get_id();
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'billing_email' => 'date-range@example.com',
+				'date_after'    => '2024-01-01T00:00:00',
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$ids = array_column( $result['orders'], 'id' );
+		$this->assertContains( $new_order->get_id(), $ids );
+		$this->assertNotContains( $old_order->get_id(), $ids );
+	}
+
+	/**
+	 * @testdox Should filter modified_before with time precision across order storage engines.
+	 *
+	 * @dataProvider provider_order_storage_engines
+	 *
+	 * @param bool $hpos_enabled Whether HPOS/COT should be enabled for the test.
+	 */
+	public function test_orders_query_filters_modified_before_with_time_precision( bool $hpos_enabled ): void {
+		$this->set_order_storage_for_test( $hpos_enabled );
+
+		$email = wp_unique_id( 'abilities-order-modified-before-' ) . '@example.com';
+		$early = $this->create_order_for_query_sorting( $email, '2025-01-15T09:00:00', '2025-01-15T10:00:00' );
+		$late  = $this->create_order_for_query_sorting( $email, '2025-01-15T09:00:00', '2025-01-15T12:00:00' );
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'billing_email'   => $email,
+				'modified_before' => '2025-01-15T11:00:00',
+				'per_page'        => 2,
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$ids = array_column( $result['orders'], 'id' );
+		$this->assertContains( $early->get_id(), $ids );
+		$this->assertNotContains( $late->get_id(), $ids );
+	}
+
+	/**
+	 * @testdox Should change order status and surface failure when status update fails.
+	 */
+	public function test_order_update_status_changes_status(): void {
+		$order                     = \WC_Helper_Order::create_order();
+		$this->created_order_ids[] = $order->get_id();
+
+		$result = wp_get_ability( 'woocommerce/order-update-status' )->execute(
+			array(
+				'id'     => $order->get_id(),
+				'status' => 'processing',
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( 'processing', $result['order']['status'] );
+		$this->assertSame( 'processing', wc_get_order( $order->get_id() )->get_status() );
+	}
+
+	/**
+	 * @testdox Should reject unchanged order status updates without adding notes.
+	 */
+	public function test_order_update_status_rejects_unchanged_status_without_adding_notes(): void {
+		$order                     = \WC_Helper_Order::create_order();
+		$this->created_order_ids[] = $order->get_id();
+		$note_count_before         = count(
+			wc_get_order_notes(
+				array(
+					'order_id' => $order->get_id(),
+				)
+			)
+		);
+
+		$result = wp_get_ability( 'woocommerce/order-update-status' )->execute(
+			array(
+				'id'     => $order->get_id(),
+				'status' => $order->get_status(),
+				'note'   => 'This should be added through Add order note.',
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_order_status_unchanged', $result->get_error_code() );
+		$this->assertStringContainsString( 'woocommerce/order-add-note', $result->get_error_message() );
+		$this->assertSame(
+			$note_count_before,
+			count(
+				wc_get_order_notes(
+					array(
+						'order_id' => $order->get_id(),
+					)
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should treat order status updates as manual status changes.
+	 */
+	public function test_order_update_status_uses_manual_status_update(): void {
+		$order                     = \WC_Helper_Order::create_order();
+		$this->created_order_ids[] = $order->get_id();
+		$manual_update_observed    = false;
+
+		$callback = static function ( $order_id, $status ) use ( $order, &$manual_update_observed ) {
+			if ( $order->get_id() === $order_id && 'processing' === $status ) {
+				$manual_update_observed = true;
+			}
+		};
+
+		add_action( 'woocommerce_order_edit_status', $callback, 10, 2 );
+
+		try {
+			$result = wp_get_ability( 'woocommerce/order-update-status' )->execute(
+				array(
+					'id'     => $order->get_id(),
+					'status' => 'processing',
+				)
+			);
+		} finally {
+			remove_action( 'woocommerce_order_edit_status', $callback, 10 );
+		}
+
+		$this->assertNotWPError( $result );
+		$this->assertTrue( $manual_update_observed );
+	}
+
+	/**
+	 * @testdox Should reject status updates against unknown order IDs without leaking existence.
+	 */
+	public function test_order_update_status_rejects_unknown_id(): void {
+		$result = wp_get_ability( 'woocommerce/order-update-status' )->execute(
+			array(
+				'id'     => 999999,
+				'status' => 'processing',
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should attribute order notes to the acting user.
+	 */
+	public function test_order_add_note_attributes_to_acting_user(): void {
+		$order                     = \WC_Helper_Order::create_order();
+		$this->created_order_ids[] = $order->get_id();
+
+		$user_id = $this->factory->user->create(
+			array(
+				'role'         => 'shop_manager',
+				'display_name' => 'Audit Trail Admin',
+			)
+		);
+		wp_set_current_user( $user_id );
+
+		$result = wp_get_ability( 'woocommerce/order-add-note' )->execute(
+			array(
+				'id'   => $order->get_id(),
+				'note' => 'Tracked by acting user.',
+			)
+		);
+
+		$this->assertNotWPError( $result );
+
+		$comment = get_comment( $result['note_id'] );
+		$this->assertNotNull( $comment );
+		$this->assertSame( 'Audit Trail Admin', $comment->comment_author );
+	}
+
+	/**
+	 * @testdox Should preserve safe HTML in order notes.
+	 */
+	public function test_order_add_note_preserves_safe_html(): void {
+		$order                     = \WC_Helper_Order::create_order();
+		$this->created_order_ids[] = $order->get_id();
+
+		$result = wp_get_ability( 'woocommerce/order-add-note' )->execute(
+			array(
+				'id'   => $order->get_id(),
+				'note' => 'Packed with <strong>care</strong><script>alert("x")</script>.',
+			)
+		);
+
+		$this->assertNotWPError( $result );
+
+		$comment = get_comment( $result['note_id'] );
+		$this->assertStringContainsString( '<strong>care</strong>', $comment->comment_content );
+		$this->assertStringNotContainsString( '<script>', $comment->comment_content );
+	}
+
+	/**
+	 * @testdox Should reject whitespace-only order notes during execution.
+	 */
+	public function test_order_add_note_execute_rejects_whitespace_only_note(): void {
+		$order                     = \WC_Helper_Order::create_order();
+		$this->created_order_ids[] = $order->get_id();
+
+		$result = OrderAddNote::execute(
+			array(
+				'id'   => $order->get_id(),
+				'note' => '   ',
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_order_note_required', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should mark notes as customer-facing when customer_note=true.
+	 */
+	public function test_order_add_note_persists_customer_note_flag(): void {
+		$order                     = \WC_Helper_Order::create_order();
+		$this->created_order_ids[] = $order->get_id();
+
+		$result = wp_get_ability( 'woocommerce/order-add-note' )->execute(
+			array(
+				'id'            => $order->get_id(),
+				'note'          => 'Visible to customer.',
+				'customer_note' => true,
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( '1', get_comment_meta( $result['note_id'], 'is_customer_note', true ) );
+	}
+
+	/**
+	 * Register the WooCommerce ability category for this test.
+	 */
+	private function register_woocommerce_category(): void {
+		if ( ! function_exists( 'wp_register_ability_category' ) || ! function_exists( 'wp_has_ability_category' ) ) {
+			return;
+		}
+
+		if ( wp_has_ability_category( 'woocommerce' ) ) {
+			return;
+		}
+
+		$callback = static function () {
+			wp_register_ability_category(
+				'woocommerce',
+				array(
+					'label'       => 'WooCommerce',
+					'description' => 'Canonical store management abilities.',
+				)
+			);
+		};
+
+		add_action( 'wp_abilities_api_categories_init', $callback );
+		do_action( 'wp_abilities_api_categories_init' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Test bootstrap for Abilities API registration.
+		remove_action( 'wp_abilities_api_categories_init', $callback );
+
+		$this->registered_category_ids[] = 'woocommerce';
+	}
+
+	/**
+	 * Register canonical domain abilities for this test.
+	 */
+	private function register_domain_abilities(): void {
+		$callback = array( AbilitiesLoader::class, 'register_abilities' );
+
+		add_action( 'wp_abilities_api_init', $callback );
+		do_action( 'wp_abilities_api_init' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Test bootstrap for Abilities API registration.
+		remove_action( 'wp_abilities_api_init', $callback );
+	}
+
+	/**
+	 * Unregister canonical domain abilities for this test.
+	 */
+	private function unregister_domain_abilities(): void {
+		foreach ( self::CANONICAL_ABILITY_IDS as $ability_id ) {
+			if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability_id ) ) {
+				wp_unregister_ability( $ability_id );
+			}
+		}
+	}
+
+	/**
+	 * Assert that a registered ability matches the canonical products-query definition.
+	 *
+	 * @param \WP_Ability|null $ability Ability instance.
+	 */
+	private function assert_products_query_ability_is_canonical( $ability ): void {
+		$this->assertNotNull( $ability, 'Canonical products-query ability should be registered.' );
+
+		$registration_args = ProductsQuery::get_registration_args();
+
+		$this->assertSame( ProductsQuery::get_name(), $ability->get_name() );
+		$this->assertSame( $registration_args['category'], $ability->get_category() );
+		$this->assertSame( $registration_args['input_schema'], $ability->get_input_schema() );
+		$this->assertSame( $registration_args['output_schema'], $ability->get_output_schema() );
+	}
+
+	/**
+	 * Get product type aliases from a branched product input schema.
+	 *
+	 * @param array $schema Input schema.
+	 * @return array<int, string>
+	 */
+	private function get_product_type_aliases_from_schema_branches( array $schema ): array {
+		$aliases = array();
+
+		foreach ( $schema['oneOf'] ?? array() as $branch ) {
+			$alias = $branch['properties']['product_type_alias']['enum'][0] ?? null;
+
+			if ( is_string( $alias ) ) {
+				$aliases[] = $alias;
+			}
+		}
+
+		return array_values( array_unique( $aliases ) );
+	}
+
+	/**
+	 * Get a product input schema branch for a product type alias.
+	 *
+	 * @param array  $schema             Input schema.
+	 * @param string $product_type_alias Product type alias.
+	 * @return array
+	 */
+	private function get_product_type_alias_schema_branch( array $schema, string $product_type_alias ): array {
+		foreach ( $schema['oneOf'] ?? array() as $branch ) {
+			if ( in_array( $product_type_alias, $branch['properties']['product_type_alias']['enum'] ?? array(), true ) ) {
+				return $branch;
+			}
+		}
+
+		$this->fail( "Missing product type alias schema branch: {$product_type_alias}" );
+		return array();
+	}
+
+	/**
+	 * Create a user with specific capabilities.
+	 *
+	 * @param array<int, string> $capabilities Capabilities to grant.
+	 * @return int
+	 */
+	private function create_user_with_caps( array $capabilities ): int {
+		$user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+		$user    = get_user_by( 'id', $user_id );
+
+		if ( $user instanceof \WP_User ) {
+			foreach ( $capabilities as $capability ) {
+				$user->add_cap( $capability );
+			}
+		}
+
+		return $user_id;
+	}
+
+	/**
+	 * Add the test extension ability definition class.
+	 *
+	 * @param array $classes Ability definition class names.
+	 * @return array
+	 */
+	public function add_test_extension_ability_definition_class( array $classes ): array {
+		$classes[] = TestExtensionAbilityDefinition::class;
+
+		return $classes;
+	}
+
+	/**
+	 * Add the test reserved WooCommerce ability definition class.
+	 *
+	 * @param array $classes Ability definition class names.
+	 * @return array
+	 */
+	public function add_reserved_woocommerce_ability_definition_class( array $classes ): array {
+		$classes[] = TestReservedWooAbilityDefinition::class;
+
+		return $classes;
+	}
+
+	/**
+	 * Preserve fractional stock amounts for filtered stock quantity tests.
+	 *
+	 * @param mixed $amount Stock amount.
+	 * @return float
+	 */
+	public function preserve_fractional_stock_amount( $amount ): float {
+		return (float) $amount;
+	}
+
+	/**
+	 * Create a product variation and track it for cleanup.
+	 *
+	 * @return int Variation product ID.
+	 */
+	private function create_variation_product_id_for_test(): int {
+		$parent       = \WC_Helper_Product::create_variation_product();
+		$variation_id = $parent->get_children()[0];
+
+		$this->created_product_ids[] = $variation_id;
+		$this->created_product_ids[] = $parent->get_id();
+
+		return $variation_id;
+	}
+
+	/**
+	 * Set order storage mode for a test.
+	 *
+	 * @param bool $hpos_enabled Whether HPOS/COT should be enabled.
+	 */
+	private function set_order_storage_for_test( bool $hpos_enabled ): void {
+		add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+
+		if ( null === $this->original_cot_state ) {
+			$this->original_cot_state = OrderUtil::custom_orders_table_usage_is_enabled();
+		}
+
+		if ( $hpos_enabled && ! $this->cot_setup_for_test ) {
+			$this->setup_cot();
+			$this->cot_setup_for_test = true;
+			return;
+		}
+
+		$this->toggle_cot_feature_and_usage( $hpos_enabled );
+	}
+
+	/**
+	 * Create an order with controlled dates for query tests.
+	 *
+	 * @param string $billing_email Billing email.
+	 * @param string $date_created  Created date.
+	 * @param string $date_modified Modified date.
+	 * @return \WC_Order
+	 */
+	private function create_order_for_query_sorting( string $billing_email, string $date_created, string $date_modified ): \WC_Order {
+		$order = \WC_Helper_Order::create_order();
+		$order->set_billing_email( $billing_email );
+		$order->set_date_created( $date_created );
+		$order->set_date_modified( $date_modified );
+		$order->save();
+
+		$this->created_order_ids[] = $order->get_id();
+		$this->set_order_modified_date_for_query_test( $order, $date_modified );
+
+		return $order;
+	}
+
+	/**
+	 * Set an order modified date directly in the authoritative storage table.
+	 *
+	 * @param \WC_Order $order         Order object.
+	 * @param string    $date_modified Modified date.
+	 */
+	private function set_order_modified_date_for_query_test( \WC_Order $order, string $date_modified ): void {
+		global $wpdb;
+
+		$timestamp = wc_string_to_datetime( $date_modified )->getTimestamp();
+		$gmt_date  = gmdate( 'Y-m-d H:i:s', $timestamp );
+
+		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+			$wpdb->update(
+				OrdersTableDataStore::get_orders_table_name(),
+				array(
+					'date_updated_gmt' => $gmt_date,
+				),
+				array( 'id' => $order->get_id() )
+			);
+
+			return;
+		}
+
+		$wpdb->update(
+			$wpdb->posts,
+			array(
+				'post_modified_gmt' => $gmt_date,
+				'post_modified'     => get_date_from_gmt( $gmt_date ),
+			),
+			array( 'ID' => $order->get_id() )
+		);
+		clean_post_cache( $order->get_id() );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/TestExtensionAbilityDefinition.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestExtensionAbilityDefinition.php
new file mode 100644
index 00000000000..b3a19084c5b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestExtensionAbilityDefinition.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Test extension ability definition class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+
+/**
+ * Test extension ability definition.
+ */
+class TestExtensionAbilityDefinition implements AbilityDefinition {
+
+	public const ABILITY_ID = 'test-extension/test-extension-ability';
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @return string
+	 */
+	public static function get_name(): string {
+		return self::ABILITY_ID;
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @return array
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => 'Test extension ability',
+			'description'         => 'Test extension ability registered through the ability loader.',
+			'category'            => 'woocommerce',
+			'execute_callback'    => static function (): array {
+				return array(
+					'ok' => true,
+				);
+			},
+			'output_schema'       => array(
+				'type'                 => 'object',
+				'properties'           => array(
+					'ok' => array( 'type' => 'boolean' ),
+				),
+				'additionalProperties' => false,
+			),
+			'permission_callback' => '__return_true',
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => true,
+					'idempotent'  => true,
+					'destructive' => false,
+				),
+			),
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/TestReservedWooAbilityDefinition.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestReservedWooAbilityDefinition.php
new file mode 100644
index 00000000000..824987fb052
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestReservedWooAbilityDefinition.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Test reserved WooCommerce namespace ability definition class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+
+/**
+ * Test ability definition that attempts to use a reserved WooCommerce ability ID.
+ */
+class TestReservedWooAbilityDefinition implements AbilityDefinition {
+
+	public const ABILITY_ID = 'woocommerce/products-query';
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @return string
+	 */
+	public static function get_name(): string {
+		return self::ABILITY_ID;
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @return array
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => 'Shadow products query',
+			'description'         => 'Test ability attempting to shadow a canonical ability.',
+			'category'            => 'woocommerce',
+			'execute_callback'    => static function (): array {
+				return array(
+					'shadowed' => true,
+				);
+			},
+			'output_schema'       => array(
+				'type'                 => 'object',
+				'properties'           => array(
+					'shadowed' => array( 'type' => 'boolean' ),
+				),
+				'additionalProperties' => false,
+			),
+			'permission_callback' => '__return_true',
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => true,
+					'idempotent'  => true,
+					'destructive' => false,
+				),
+			),
+		);
+	}
+}