Commit 067306f6351 for woocommerce

commit 067306f635195d4f526cd5079380b191c999745d
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Fri Oct 16 20:34:26 2026 -0300

    Filter the product and order list abilities by extension filters (#69684)

    * Filter the product and order list abilities by extension filters

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Rename AbilityExtensions to ExtensibleAbilities after the rebase

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Apply Core's exclude next to the IDs of extension filters, and create the HPOS tables once per class

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Refuse a filter namespace or name that is only numbers, like register_field()

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Cast the exclude list to an array for PHPStan

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/add-ability-extension-query-filters b/plugins/woocommerce/changelog/add-ability-extension-query-filters
new file mode 100644
index 00000000000..5ad02f8e187
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-ability-extension-query-filters
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add ExtensibleAbilities::register_query_filter(). With the experimental extensible abilities feature on, the product and order list abilities accept extension filters under filters.extensions.<namespace>.<filter>.
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index 91ee7228376..9be9ff482ad 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -22,6 +22,8 @@ defined( 'ABSPATH' ) || exit;
  * An ActionableAbility writes the same `extensions` input to the fields that
  * have an `update_callback`, runs the validators of the resource, and saves
  * the object one time.
+ * An extension also registers a query filter, which the list ability of the
+ * resource accepts under `filters.extensions.<namespace>.<filter>`.
  *
  * The API is experimental while the `extensible_abilities` feature exists.
  *
@@ -70,6 +72,18 @@ class ExtensibleAbilities {
 		return FeaturesUtil::feature_is_enabled( self::FEATURE_ID );
 	}

+	/**
+	 * Query filters keyed by resource, then namespace, then filter name.
+	 *
+	 * @var array<string, array<string, array<string, array<string, mixed>>>>
+	 */
+	private static array $filters = array();
+
+	/**
+	 * Query arguments that a filter callback can return.
+	 */
+	private const QUERY_ARG_KEYS = array( 'meta_query', 'tax_query', 'include_ids', 'exclude_ids' );
+
 	/**
 	 * Register a resource that abilities format, so extensions can register fields on it.
 	 *
@@ -196,6 +210,248 @@ class ExtensibleAbilities {
 		self::$fields[ $args['resource'] ][ $args['namespace'] ][ $args['field'] ] = $args;
 	}

+	/**
+	 * Register a filter that the list ability of a resource accepts under `filters.extensions.<namespace>.<filter>`.
+	 *
+	 * Call it inside the `woocommerce_extensible_abilities_init` action. A call after that action is refused.
+	 * The resource must be registered with `register_resource()`.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param array $args {
+	 *     Filter arguments.
+	 *
+	 *     @type string   $resource  Resource whose list ability applies the filters. Core's `products-query` and
+	 *                               `orders-query` apply the filters of `product` and `order`. The list ability
+	 *                               of another resource must call `get_query_args()` and `add_query_filters_schema()` itself.
+	 *     @type string   $namespace Slug of the extension that owns the filter, such as `subscriptions`.
+	 *     @type string   $filter    Filter name inside the namespace, such as `subscribable`.
+	 *     @type array    $schema    JSON schema of the value. Its `description` tells agents how to use the filter.
+	 *     @type callable $callback  Receives the value, sanitized with the schema, and returns query arguments:
+	 *                               a `meta_query`, for products also a `tax_query`, `include_ids` or
+	 *                               `exclude_ids`. Core adds each one to the query with AND. Return a
+	 *                               WP_Error to refuse the value. A `tax_query` is accepted only for
+	 *                               `product`. Any other key, or a `tax_query` for another resource, fails the call.
+	 *                               `include_ids` and `exclude_ids` are lists of object IDs. Use them for
+	 *                               data that is not meta, such as order items or a table of the extension:
+	 *                               query that data and return the IDs that match. Core keeps the IDs that
+	 *                               every `include_ids` filter returns, then drops the IDs that any
+	 *                               `exclude_ids` filter returns. An empty `include_ids` matches nothing.
+	 * }
+	 */
+	public static function register_query_filter( array $args ): void {
+		if ( did_action( 'woocommerce_extensible_abilities_init' ) && ! doing_action( 'woocommerce_extensible_abilities_init' ) ) {
+			wc_doing_it_wrong( __METHOD__, __( 'Register a filter inside the woocommerce_extensible_abilities_init action, before the abilities build their schemas.', 'woocommerce' ), '11.3.0' );
+			return;
+		}
+		foreach ( array( 'resource', 'namespace', 'filter' ) as $key ) {
+			if ( ! is_string( $args[ $key ] ?? null ) || '' === $args[ $key ] || sanitize_key( $args[ $key ] ) !== $args[ $key ] || ctype_digit( $args[ $key ] ) ) {
+				wc_doing_it_wrong(
+					__METHOD__,
+					sprintf(
+						/* translators: %s: argument name. */
+						__( 'The "%s" argument must be a slug of lowercase letters, numbers, dashes and underscores, not only numbers.', 'woocommerce' ),
+						$key
+					),
+					'11.3.0'
+				);
+				return;
+			}
+		}
+		if ( ! isset( self::$resources[ $args['resource'] ] ) ) {
+			wc_doing_it_wrong(
+				__METHOD__,
+				sprintf(
+					/* translators: %s: resource name. */
+					__( 'The "%s" resource is not registered. Call ExtensibleAbilities::register_resource() first.', 'woocommerce' ),
+					$args['resource']
+				),
+				'11.3.0'
+			);
+			return;
+		}
+		if ( ! is_array( $args['schema'] ?? null ) || ! isset( $args['schema']['type'] ) ) {
+			wc_doing_it_wrong( __METHOD__, __( 'The "schema" argument must be an array with a "type".', 'woocommerce' ), '11.3.0' );
+			return;
+		}
+		if ( ! is_callable( $args['callback'] ?? null ) ) {
+			wc_doing_it_wrong( __METHOD__, __( 'The "callback" argument must be callable.', 'woocommerce' ), '11.3.0' );
+			return;
+		}
+		if ( isset( self::$filters[ $args['resource'] ][ $args['namespace'] ][ $args['filter'] ] ) ) {
+			wc_doing_it_wrong(
+				__METHOD__,
+				sprintf(
+					/* translators: 1: extension namespace, 2: filter name, 3: resource name. */
+					__( 'The filter "%1$s.%2$s" of "%3$s" is already registered.', 'woocommerce' ),
+					$args['namespace'],
+					$args['filter'],
+					$args['resource']
+				),
+				'11.3.0'
+			);
+			return;
+		}
+
+		self::$filters[ $args['resource'] ][ $args['namespace'] ][ $args['filter'] ] = $args;
+	}
+
+	/**
+	 * Add the `filters.extensions` property to the input schema of a list
+	 * ability, when the feature is on and the resource has filters. It does
+	 * not allow other filters, because a filter that the query ignores
+	 * returns wrong results.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param array<string, mixed> $schema        Input schema.
+	 * @param string               $resource_name Resource.
+	 * @return array<string, mixed>
+	 */
+	public static function add_query_filters_schema( array $schema, string $resource_name ): array {
+		if ( empty( self::$filters[ $resource_name ] ) || ! self::is_enabled() ) {
+			return $schema;
+		}
+
+		$extensions = array(
+			'type'                 => 'object',
+			'description'          => __( 'Filters that extensions add, keyed by extension namespace, then filter.', 'woocommerce' ),
+			'properties'           => array(),
+			'additionalProperties' => false,
+		);
+		foreach ( self::$filters[ $resource_name ] as $namespace => $filters ) {
+			$extensions['properties'][ $namespace ] = array(
+				'type'                 => 'object',
+				'properties'           => array_map(
+					static function ( array $args ): array {
+						return $args['schema'];
+					},
+					$filters
+				),
+				'additionalProperties' => false,
+			);
+		}
+		$schema['properties']['filters'] = array(
+			'type'                 => 'object',
+			'properties'           => array( 'extensions' => $extensions ),
+			'additionalProperties' => false,
+		);
+		return $schema;
+	}
+
+	/**
+	 * Get the query arguments of the filters in the input of a list ability.
+	 * Each key holds one clause for each filter, so `array_merge_recursive()`
+	 * adds them to WP_Query or wc_get_orders() arguments with AND. `post__in`
+	 * holds the IDs that every `include_ids` filter returned, without the IDs
+	 * that any `exclude_ids` filter returned, or `0` when none remain, because
+	 * an empty `post__in` does not filter. Without `include_ids`, the excluded
+	 * IDs go to `post__not_in`, which WP_Query ignores next to `post__in`.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string               $resource_name Resource.
+	 * @param array<string, mixed> $input         Ability input.
+	 * @return array<string, array>|\WP_Error The error of a filter that refused its value or failed.
+	 */
+	public static function get_query_args( string $resource_name, array $input ) {
+		$query_args = array();
+		$include    = null;
+		$exclude    = array();
+		if ( ! self::is_enabled() ) {
+			return $query_args;
+		}
+
+		foreach ( self::$filters[ $resource_name ] ?? array() as $namespace => $filters ) {
+			foreach ( $filters as $filter => $args ) {
+				if ( ! is_array( $input['filters']['extensions'][ $namespace ] ?? null ) || ! array_key_exists( $filter, $input['filters']['extensions'][ $namespace ] ) ) {
+					continue;
+				}
+
+				$value = rest_sanitize_value_from_schema( $input['filters']['extensions'][ $namespace ][ $filter ], $args['schema'], $filter );
+				try {
+					$clauses = call_user_func( $args['callback'], $value );
+				} catch ( \Throwable $e ) {
+					wc_get_logger()->error(
+						sprintf( 'Ability filter "%s.%s" of "%s" failed: %s', $namespace, $filter, $resource_name, $e->getMessage() ),
+						array( 'source' => 'extensible-abilities' )
+					);
+					$clauses = null;
+				}
+				if ( is_wp_error( $clauses ) ) {
+					return $clauses;
+				}
+				if (
+					! is_array( $clauses )
+					|| ! empty( array_diff_key( $clauses, array_flip( self::QUERY_ARG_KEYS ) ) )
+					|| ( 'product' !== $resource_name && isset( $clauses['tax_query'] ) )
+					|| in_array( false, array_map( 'is_array', $clauses ), true )
+				) {
+					return new \WP_Error(
+						'woocommerce_ability_extension_filter_failed',
+						/* translators: %s: filter name, such as subscriptions.subscribable. */
+						sprintf( __( 'The "%s" filter failed.', 'woocommerce' ), $namespace . '.' . $filter ),
+						array( 'status' => 500 )
+					);
+				}
+				if ( isset( $clauses['include_ids'] ) ) {
+					$filter_ids = self::get_filter_ids( $clauses['include_ids'], $namespace . '.' . $filter );
+					if ( is_wp_error( $filter_ids ) ) {
+						return $filter_ids;
+					}
+					$include = null === $include ? $filter_ids : array_values( array_intersect( $include, $filter_ids ) );
+					unset( $clauses['include_ids'] );
+				}
+				if ( isset( $clauses['exclude_ids'] ) ) {
+					$filter_ids = self::get_filter_ids( $clauses['exclude_ids'], $namespace . '.' . $filter );
+					if ( is_wp_error( $filter_ids ) ) {
+						return $filter_ids;
+					}
+					$exclude = array_merge( $exclude, $filter_ids );
+					unset( $clauses['exclude_ids'] );
+				}
+				foreach ( $clauses as $key => $clause ) {
+					if ( empty( $clause ) ) {
+						continue;
+					}
+					$query_args[ $key ][] = $clause;
+				}
+			}
+		}
+		if ( null !== $include ) {
+			$include                = array_values( array_diff( $include, $exclude ) );
+			$query_args['post__in'] = empty( $include ) ? array( 0 ) : $include;
+		} elseif ( ! empty( $exclude ) ) {
+			$query_args['post__not_in'] = array_values( array_unique( $exclude ) );
+		}
+		return $query_args;
+	}
+
+	/**
+	 * The IDs that a filter returned, as integers.
+	 *
+	 * @param array  $ids    IDs from the filter callback.
+	 * @param string $filter Filter name, such as `subscriptions.related_to`.
+	 * @return int[]|\WP_Error An error when an ID is not a positive integer.
+	 */
+	private static function get_filter_ids( array $ids, string $filter ) {
+		$valid = array_filter(
+			array_map(
+				static fn( $id ) => filter_var( $id, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ),
+				$ids
+			)
+		);
+		if ( count( $valid ) !== count( $ids ) ) {
+			return new \WP_Error(
+				'woocommerce_ability_extension_filter_failed',
+				/* translators: %s: filter name, such as subscriptions.subscribable. */
+				sprintf( __( 'The "%s" filter failed.', 'woocommerce' ), $filter ),
+				array( 'status' => 500 )
+			);
+		}
+		return array_values( array_unique( $valid ) );
+	}
+
 	/**
 	 * Register a validator that can refuse a change to an object of a resource.
 	 *
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index b6797381329..a1ab40593f6 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -63,6 +63,13 @@ class AbilitiesLoader {
 	 */
 	private static array $registered_core_abilities = array();

+	/**
+	 * Query var that holds the extension filter clauses of a list ability.
+	 *
+	 * @internal
+	 */
+	public const EXTENSION_QUERY_VAR = 'woocommerce_extensible_abilities_query_args';
+
 	/**
 	 * Initialize ability registration hooks.
 	 *
@@ -85,6 +92,8 @@ class AbilitiesLoader {
 		add_action( 'wp_abilities_api_init', array( __CLASS__, 'init_extensions' ), 0 );
 		add_action( 'abilities_api_init', array( __CLASS__, 'register_abilities' ) );
 		add_action( 'wp_abilities_api_init', array( __CLASS__, 'register_abilities' ) );
+		add_filter( 'woocommerce_product_data_store_cpt_get_products_query', array( __CLASS__, 'add_extension_query_args' ), 10, 2 );
+		add_filter( 'woocommerce_order_data_store_cpt_get_orders_query', array( __CLASS__, 'add_extension_query_args' ), 10, 2 );

 		self::$initialized = true;
 	}
@@ -102,6 +111,30 @@ class AbilitiesLoader {
 		}
 	}

+	/**
+	 * Add the extension filter clauses of a list ability to its WP_Query arguments.
+	 *
+	 * The posts storage ignores a meta_query argument, so a list ability puts the
+	 * clauses in the EXTENSION_QUERY_VAR query var. Other queries do not have it,
+	 * so a query that a hook runs inside the ability's query stays the same.
+	 *
+	 * @internal
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param array $wp_query_args WP_Query arguments.
+	 * @param array $query_vars    Query vars of wc_get_products() or wc_get_orders().
+	 * @return array
+	 */
+	public static function add_extension_query_args( $wp_query_args, $query_vars ) {
+		if ( empty( $query_vars[ self::EXTENSION_QUERY_VAR ] ) || ! is_array( $wp_query_args ) ) {
+			return $wp_query_args;
+		}
+
+		unset( $wp_query_args[ self::EXTENSION_QUERY_VAR ] );
+		return array_merge_recursive( $wp_query_args, $query_vars[ self::EXTENSION_QUERY_VAR ] );
+	}
+
 	/**
 	 * Let extensions register their resources, then their ability fields, before the abilities build their schemas.
 	 *
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
index 1a17c1ed1dd..6e2a60d372b 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
@@ -8,6 +8,8 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Internal\Abilities\Domain;

 use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
 use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
 use Automattic\WooCommerce\Utilities\OrderUtil;

@@ -76,6 +78,14 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 	public static function execute( array $input ) {
 		$include_line_items = (bool) ( $input['include_line_items'] ?? false );

+		if ( ! empty( $input['id'] ) && ! empty( $input['filters'] ) ) {
+			return new \WP_Error(
+				'woocommerce_ability_filters_with_id',
+				__( 'Send either an id or filters, not both.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
 		if ( ! empty( $input['id'] ) ) {
 			$order = self::get_order_from_input( $input );

@@ -146,6 +156,26 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 			$args['date_query'][] = $modified_date_query;
 		}

+		$extension_args = ExtensibleAbilities::get_query_args( 'order', $input );
+		if ( is_wp_error( $extension_args ) ) {
+			return $extension_args;
+		}
+
+		if ( isset( $extension_args['post__not_in'] ) ) {
+			$args['exclude'] = array_merge( (array) ( $args['exclude'] ?? array() ), $extension_args['post__not_in'] );
+			unset( $extension_args['post__not_in'] );
+		}
+		if ( isset( $extension_args['post__in'] ) && ! empty( $args['exclude'] ) ) {
+			$include                    = array_values( array_diff( $extension_args['post__in'], (array) $args['exclude'] ) );
+			$extension_args['post__in'] = empty( $include ) ? array( 0 ) : $include;
+		}
+
+		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+			$args = array_merge_recursive( $args, $extension_args );
+		} else {
+			$args[ AbilitiesLoader::EXTENSION_QUERY_VAR ] = $extension_args;
+		}
+
 		$results = wc_get_orders( $args );
 		$orders  = is_object( $results ) && isset( $results->orders ) ? $results->orders : array();
 		$orders  = array_values(
@@ -209,7 +239,7 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 	 * @return array
 	 */
 	private static function get_input_schema(): array {
-		return array(
+		$schema = array(
 			'type'                 => 'object',
 			'properties'           => array(
 				'id'                 => array(
@@ -311,6 +341,7 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 			'additionalProperties' => false,
 			'default'              => array(),
 		);
+		return ExtensibleAbilities::add_query_filters_schema( $schema, 'order' );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
index 3de97939e45..a9f471534ef 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
@@ -8,7 +8,9 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Internal\Abilities\Domain;

 use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
 use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
 use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;

 defined( 'ABSPATH' ) || exit;
@@ -74,6 +76,14 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 	 * @since 10.9.0
 	 */
 	public static function execute( array $input ) {
+		if ( ! empty( $input['id'] ) && ! empty( $input['filters'] ) ) {
+			return new \WP_Error(
+				'woocommerce_ability_filters_with_id',
+				__( 'Send either an id or filters, not both.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
 		if ( ! empty( $input['id'] ) ) {
 			$product = self::get_product_from_input( $input );

@@ -127,6 +137,13 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 			$args['s'] = wc_clean( $input['search'] );
 		}

+		$extension_args = ExtensibleAbilities::get_query_args( 'product', $input );
+		if ( is_wp_error( $extension_args ) ) {
+			return $extension_args;
+		}
+
+		$args[ AbilitiesLoader::EXTENSION_QUERY_VAR ] = $extension_args;
+
 		$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 );
@@ -164,7 +181,7 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 	 * @return array
 	 */
 	private static function get_input_schema(): array {
-		return array(
+		$schema = array(
 			'type'                 => 'object',
 			'properties'           => array(
 				'id'                 => array(
@@ -207,5 +224,6 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 			'additionalProperties' => false,
 			'default'              => array(),
 		);
+		return ExtensibleAbilities::add_query_filters_schema( $schema, 'product' );
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 0ffe14a7d20..bb3fe9e63fc 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -10,12 +10,18 @@ namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;
 use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
 use Automattic\WooCommerce\Abilities\ActionableAbility;
 use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
+use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use Automattic\WooCommerce\Utilities\OrderUtil;

 /**
  * Namespaced extension fields in the product and order abilities, read and written through the REST route.
  */
 class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {

+	use HPOSToggleTrait;
+
 	private const CODE_SCHEMA = array(
 		'type'  => 'string',
 		'title' => 'Test code',
@@ -56,6 +62,17 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	 */
 	public static function wpSetUpBeforeClass( $factory ): void {
 		self::$administrator_id = $factory->user->create( array( 'role' => 'administrator' ) );
+
+		$previous_hpos_state = OrderUtil::custom_orders_table_usage_is_enabled();
+		add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		try {
+			self::setup_cot_tables();
+			if ( OrderUtil::custom_orders_table_usage_is_enabled() !== $previous_hpos_state ) {
+				OrderHelper::toggle_cot_feature_and_usage( $previous_hpos_state );
+			}
+		} finally {
+			remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		}
 	}

 	/**
@@ -83,6 +100,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		wp_set_current_user( self::$administrator_id );
 		$this->set_feature( true );
 		add_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_fields' ) );
+		add_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_filters' ) );
 	}

 	/**
@@ -92,6 +110,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		global $wp_actions;

 		remove_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_fields' ) );
+		remove_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_filters' ) );
 		foreach ( $this->test_actions as list( $hook, $callback, $priority ) ) {
 			remove_action( $hook, $callback, $priority );
 		}
@@ -206,6 +225,577 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		}
 	}

+	/**
+	 * Register a product filter and an order filter, the way an extension does inside woocommerce_extensible_abilities_init.
+	 *
+	 * @internal
+	 */
+	public function register_test_filters(): void {
+		ExtensibleAbilities::register_query_filter(
+			array(
+				'resource'  => 'product',
+				'namespace' => 'test-ext',
+				'filter'    => 'has_code',
+				'schema'    => array( 'type' => 'boolean' ),
+				'callback'  => static function ( bool $has_code ) {
+					return array(
+						'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+							array(
+								'key'     => '_test_code',
+								'compare' => $has_code ? 'EXISTS' : 'NOT EXISTS',
+							),
+						),
+					);
+				},
+			)
+		);
+		ExtensibleAbilities::register_query_filter(
+			array(
+				'resource'  => 'product',
+				'namespace' => 'test-ext',
+				'filter'    => 'category',
+				'schema'    => array( 'type' => 'string' ),
+				'callback'  => static function ( string $slug ) {
+					return array(
+						'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+							array(
+								'taxonomy' => 'product_cat',
+								'field'    => 'slug',
+								'terms'    => $slug,
+							),
+						),
+					);
+				},
+			)
+		);
+		ExtensibleAbilities::register_query_filter(
+			array(
+				'resource'  => 'order',
+				'namespace' => 'test-ext',
+				'filter'    => 'tag',
+				'schema'    => array( 'type' => 'string' ),
+				'callback'  => static function ( string $tag ) {
+					if ( 'bad' === $tag ) {
+						return new \WP_Error( 'test_bad_tag', 'Bad tag.', array( 'status' => 400 ) );
+					}
+					return array(
+						'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+							array(
+								'key'   => '_test_tag',
+								'value' => $tag,
+							),
+						),
+					);
+				},
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return only the products that match an extension filter sent as a query string, with correct pagination.
+	 */
+	public function test_product_filter_returns_matching_products(): void {
+		$with_code = array();
+		foreach ( array( 'A1', 'A2', 'A3' ) as $code ) {
+			$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen ' . $code ) );
+			$product->update_meta_data( '_test_code', $code );
+			$product->save();
+			$with_code[] = $product->get_id();
+		}
+		$without_code = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen plain' ) );
+		$filters      = array( 'extensions' => array( 'test-ext' => array( 'has_code' => 'true' ) ) );
+
+		$page_one = $this->run_ability(
+			'woocommerce/products-query',
+			array(
+				'search'   => 'Pen',
+				'per_page' => 2,
+				'filters'  => $filters,
+			)
+		);
+		$page_two = $this->run_ability(
+			'woocommerce/products-query',
+			array(
+				'search'   => 'Pen',
+				'per_page' => 2,
+				'page'     => 2,
+				'filters'  => $filters,
+			)
+		);
+		$plain    = $this->run_ability(
+			'woocommerce/products-query',
+			array(
+				'search'  => 'Pen',
+				'filters' => array( 'extensions' => array( 'test-ext' => array( 'has_code' => 'false' ) ) ),
+			)
+		);
+
+		$this->assertSame( 2, $page_one['total_pages'] );
+		$this->assertEqualsCanonicalizing( $with_code, array_merge( array_column( $page_one['products'], 'id' ), array_column( $page_two['products'], 'id' ) ) );
+		$this->assertSame( array( $without_code->get_id() ), array_column( $plain['products'], 'id' ) );
+	}
+
+	/**
+	 * @testdox Should add a tax_query filter to Core's own product type clauses with AND.
+	 */
+	public function test_product_tax_filter_adds_to_core_clauses(): void {
+		$category = wp_insert_term( 'Gifts', 'product_cat' )['term_id'];
+		$simple   = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen simple' ) );
+		$external = \WC_Helper_Product::create_external_product();
+		foreach ( array( $simple, $external ) as $product ) {
+			$product->set_category_ids( array( $category ) );
+			$product->save();
+		}
+		\WC_Helper_Product::create_external_product();
+
+		$output = $this->run_ability(
+			'woocommerce/products-query',
+			array(
+				'product_type_alias' => 'affiliate',
+				'filters'            => array( 'extensions' => array( 'test-ext' => array( 'category' => 'gifts' ) ) ),
+			)
+		);
+
+		$this->assertSame( array( $external->get_id() ), array_column( $output['products'], 'id' ) );
+	}
+
+	/**
+	 * @testdox Should not filter a product query that a hook runs inside the ability's query.
+	 */
+	public function test_product_filter_does_not_reach_a_nested_query(): void {
+		$with_code = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen coded' ) );
+		$with_code->update_meta_data( '_test_code', 'A1' );
+		$with_code->save();
+		$without_code = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen plain' ) );
+		$nested       = null;
+		$run_nested   = static function ( $wp_query_args ) use ( &$nested ) {
+			if ( null === $nested ) {
+				$nested = array();
+				$nested = wc_get_products(
+					array(
+						'limit'  => -1,
+						'return' => 'ids',
+					)
+				);
+			}
+			return $wp_query_args;
+		};
+		add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $run_nested, 5 );
+
+		$output = $this->run_ability(
+			'woocommerce/products-query',
+			array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'has_code' => 'true' ) ) ) )
+		);
+
+		remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $run_nested, 5 );
+		$this->assertSame( array( $with_code->get_id() ), array_column( $output['products'], 'id' ) );
+		$this->assertContains( $without_code->get_id(), $nested );
+	}
+
+	/**
+	 * @testdox Should return only the orders that match an extension filter, with HPOS and with posts storage, with and without sync.
+	 * @testWith [true, false]
+	 *           [true, true]
+	 *           [false, false]
+	 *           [false, true]
+	 *
+	 * @param bool $hpos Whether HPOS is on.
+	 * @param bool $sync Whether HPOS and posts storage sync.
+	 */
+	public function test_order_filter_returns_matching_orders( bool $hpos, bool $sync ): void {
+		$original = OrderUtil::custom_orders_table_usage_is_enabled();
+		add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		$this->toggle_cot_feature_and_usage( $hpos );
+		if ( $sync ) {
+			$this->enable_cot_sync();
+		} else {
+			$this->disable_cot_sync();
+		}
+
+		try {
+			$tagged = array();
+			foreach ( array( 'gift', 'gift', 'plain' ) as $tag ) {
+				$order = \WC_Helper_Order::create_order();
+				$order->update_meta_data( '_test_tag', $tag );
+				$order->save();
+				if ( 'gift' === $tag ) {
+					$tagged[] = $order->get_id();
+				}
+			}
+
+			$output = $this->run_ability(
+				'woocommerce/orders-query',
+				array(
+					'per_page' => 1,
+					'filters'  => array( 'extensions' => array( 'test-ext' => array( 'tag' => 'gift' ) ) ),
+				)
+			);
+
+			$this->assertSame( $hpos, OrderUtil::custom_orders_table_usage_is_enabled() );
+			$this->assertSame( $sync, OrderUtil::is_custom_order_tables_in_sync() );
+			$this->assertSame( 2, $output['total_pages'] );
+			$this->assertContains( $output['orders'][0]['id'], $tagged );
+		} finally {
+			remove_all_filters( 'pre_option_' . DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION );
+			$this->toggle_cot_feature_and_usage( $original );
+			remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		}
+	}
+
+	/**
+	 * @testdox Should keep the orders whose IDs every filter returns, from data that is not meta, and still apply Core's exclude, with HPOS and with posts storage.
+	 * @testWith [true, false]
+	 *           [true, true]
+	 *           [false, false]
+	 *           [false, true]
+	 *
+	 * @param bool $hpos Whether HPOS is on.
+	 * @param bool $sync Whether HPOS and posts storage sync.
+	 */
+	public function test_order_ids_filter_intersects_with_other_filters( bool $hpos, bool $sync ): void {
+		$original = OrderUtil::custom_orders_table_usage_is_enabled();
+		add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		$this->toggle_cot_feature_and_usage( $hpos );
+		if ( $sync ) {
+			$this->enable_cot_sync();
+		} else {
+			$this->disable_cot_sync();
+		}
+		add_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_contains_product_filter' ) );
+
+		try {
+			$coffee = \WC_Helper_Product::create_simple_product();
+			$orders = array();
+			foreach ( array( 'gift', 'plain' ) as $tag ) {
+				$order = \WC_Helper_Order::create_order( 0, $coffee );
+				$order->update_meta_data( '_test_tag', $tag );
+				$order->save();
+				$orders[ $tag ] = $order->get_id();
+			}
+			$other = \WC_Helper_Order::create_order();
+			$other->save();
+			$filters = static fn( array $values ) => array( 'filters' => array( 'extensions' => array( 'test-ext' => $values ) ) );
+
+			$contains = $this->run_ability( 'woocommerce/orders-query', $filters( array( 'contains_product' => $coffee->get_id() ) ) );
+			$both     = $this->run_ability(
+				'woocommerce/orders-query',
+				$filters(
+					array(
+						'contains_product' => $coffee->get_id(),
+						'tag'              => 'gift',
+					)
+				)
+			);
+			$none     = $this->run_ability( 'woocommerce/orders-query', $filters( array( 'contains_product' => 999999 ) ) );
+			$without  = $this->run_ability( 'woocommerce/orders-query', $filters( array( 'without' => array( $orders['plain'] ) ) ) );
+			$excluded = $this->run_ability( 'woocommerce/orders-query', array( 'exclude' => array( $orders['gift'] ) ) + $filters( array( 'contains_product' => $coffee->get_id() ) ) );
+			$both_out = $this->run_ability( 'woocommerce/orders-query', array( 'exclude' => array( $other->get_id() ) ) + $filters( array( 'without' => array( $orders['plain'] ) ) ) );
+
+			$this->assertEqualsCanonicalizing( array_values( $orders ), array_column( $contains['orders'], 'id' ) );
+			$this->assertSame( array( $orders['gift'] ), array_column( $both['orders'], 'id' ) );
+			$this->assertSame( array(), $none['orders'] );
+			$this->assertEqualsCanonicalizing( array( $orders['gift'], $other->get_id() ), array_column( $without['orders'], 'id' ) );
+			$this->assertSame( array( $orders['plain'] ), array_column( $excluded['orders'], 'id' ) );
+			$this->assertSame( array( $orders['gift'] ), array_column( $both_out['orders'], 'id' ) );
+		} finally {
+			remove_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_contains_product_filter' ) );
+			remove_all_filters( 'pre_option_' . DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION );
+			$this->toggle_cot_feature_and_usage( $original );
+			remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		}
+	}
+
+	/**
+	 * Register an order filter that reads order items, which are not meta, and returns order IDs,
+	 * and one that excludes the order IDs it receives.
+	 *
+	 * @internal
+	 */
+	public function register_contains_product_filter(): void {
+		ExtensibleAbilities::register_query_filter(
+			array(
+				'resource'  => 'order',
+				'namespace' => 'test-ext',
+				'filter'    => 'without',
+				'schema'    => array(
+					'type'  => 'array',
+					'items' => array( 'type' => 'integer' ),
+				),
+				'callback'  => static fn( array $ids ) => array( 'exclude_ids' => $ids ),
+			)
+		);
+		ExtensibleAbilities::register_query_filter(
+			array(
+				'resource'  => 'order',
+				'namespace' => 'test-ext',
+				'filter'    => 'contains_product',
+				'schema'    => array( 'type' => 'integer' ),
+				'callback'  => static function ( int $product_id ) {
+					global $wpdb;
+					return array(
+						'include_ids' => $wpdb->get_col(
+							$wpdb->prepare(
+								"SELECT DISTINCT items.order_id FROM {$wpdb->prefix}woocommerce_order_items AS items INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS meta ON meta.order_item_id = items.order_item_id WHERE meta.meta_key = '_product_id' AND meta.meta_value = %d",
+								$product_id
+							)
+						),
+					);
+				},
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should keep the products whose IDs a filter includes, drop the IDs a filter excludes, and refuse IDs that are not integers.
+	 */
+	public function test_product_ids_filters_include_and_exclude(): void {
+		$coded = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen coded' ) );
+		$coded->update_meta_data( '_test_code', 'A1' );
+		$coded->save();
+		$plain = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen plain' ) );
+		add_action(
+			'woocommerce_extensible_abilities_init',
+			static function () use ( $plain ) {
+				$callbacks = array(
+					'picked'    => static fn( array $ids ) => array( 'include_ids' => $ids ),
+					'not_plain' => static fn() => array( 'exclude_ids' => array( $plain->get_id() ) ),
+					'not_ids'   => static fn() => array( 'include_ids' => array( 'x' ) ),
+				);
+				foreach ( $callbacks as $filter => $callback ) {
+					ExtensibleAbilities::register_query_filter(
+						array(
+							'resource'  => 'product',
+							'namespace' => 'test-ext',
+							'filter'    => $filter,
+							'schema'    => 'picked' === $filter ? array(
+								'type'  => 'array',
+								'items' => array( 'type' => 'integer' ),
+							) : array( 'type' => 'boolean' ),
+							'callback'  => $callback,
+						)
+					);
+				}
+			}
+		);
+		$filters = static fn( array $values ) => array(
+			'search'  => 'Pen',
+			'filters' => array( 'extensions' => array( 'test-ext' => $values ) ),
+		);
+
+		$picked   = $this->run_ability( 'woocommerce/products-query', $filters( array( 'picked' => array( $coded->get_id(), $plain->get_id() ) ) ) );
+		$both     = $this->run_ability(
+			'woocommerce/products-query',
+			$filters(
+				array(
+					'picked'   => array( $coded->get_id(), $plain->get_id() ),
+					'has_code' => true,
+				)
+			)
+		);
+		$empty    = $this->run_ability( 'woocommerce/products-query', $filters( array( 'picked' => array() ) ) );
+		$excluded = $this->run_ability( 'woocommerce/products-query', $filters( array( 'not_plain' => true ) ) );
+		$narrowed = $this->run_ability(
+			'woocommerce/products-query',
+			$filters(
+				array(
+					'not_plain' => true,
+					'picked'    => array( $coded->get_id(), $plain->get_id() ),
+				)
+			)
+		);
+		$nothing  = $this->run_ability(
+			'woocommerce/products-query',
+			$filters(
+				array(
+					'not_plain' => true,
+					'picked'    => array( $plain->get_id() ),
+				)
+			)
+		);
+		$not_ids  = $this->run_ability_response( 'woocommerce/products-query', $filters( array( 'not_ids' => true ) ) );
+
+		$this->assertEqualsCanonicalizing( array( $coded->get_id(), $plain->get_id() ), array_column( $picked['products'], 'id' ) );
+		$this->assertSame( array( $coded->get_id() ), array_column( $both['products'], 'id' ) );
+		$this->assertSame( array(), $empty['products'] );
+		$this->assertSame( array( $coded->get_id() ), array_column( $excluded['products'], 'id' ) );
+		$this->assertSame( array( $coded->get_id() ), array_column( $narrowed['products'], 'id' ) );
+		$this->assertSame( array(), $nothing['products'] );
+		$this->assertSame( 'woocommerce_ability_extension_filter_failed', $not_ids->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Should list the filters in the input schemas, and refuse a filter that is not registered.
+	 */
+	public function test_input_schemas_list_filters_and_refuse_unknown_ones(): void {
+		$products = $this->get_ability( 'woocommerce/products-query' )['input_schema']['properties']['filters']['properties']['extensions'];
+		$orders   = $this->get_ability( 'woocommerce/orders-query' )['input_schema']['properties']['filters']['properties']['extensions'];
+
+		$response = $this->run_ability_response(
+			'woocommerce/orders-query',
+			array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'unknown' => 'x' ) ) ) )
+		);
+
+		$this->assertSame( array( 'type' => 'boolean' ), $products['properties']['test-ext']['properties']['has_code'] );
+		$this->assertSame( array( 'type' => 'string' ), $orders['properties']['test-ext']['properties']['tag'] );
+		$this->assertSame( 400, $response->get_status() );
+	}
+
+	/**
+	 * @testdox Should return the error of a filter that refuses its value, or that returns arguments Core does not support.
+	 */
+	public function test_a_filter_that_refuses_or_fails_returns_an_error(): void {
+		add_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				$callbacks = array(
+					array( 'order', 'broken', static fn() => array( 'post__in' => array( 1 ) ) ),
+					array( 'order', 'taxed', static fn() => array( 'tax_query' => array( array( 'taxonomy' => 'product_cat' ) ) ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+					array( 'product', 'scalar', static fn() => array( 'meta_query' => 'x' ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+				);
+				foreach ( $callbacks as list( $resource, $filter, $callback ) ) {
+					ExtensibleAbilities::register_query_filter(
+						array(
+							'resource'  => $resource,
+							'namespace' => 'test-ext',
+							'filter'    => $filter,
+							'schema'    => array( 'type' => 'boolean' ),
+							'callback'  => $callback,
+						)
+					);
+				}
+			}
+		);
+
+		$refused = $this->run_ability_response( 'woocommerce/orders-query', array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'tag' => 'bad' ) ) ) ) );
+		$failed  = $this->run_ability_response( 'woocommerce/orders-query', array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'broken' => true ) ) ) ) );
+		$taxed   = $this->run_ability_response( 'woocommerce/orders-query', array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'taxed' => true ) ) ) ) );
+		$scalar  = $this->run_ability_response( 'woocommerce/products-query', array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'scalar' => true ) ) ) ) );
+
+		$this->assertSame( 'test_bad_tag', $refused->get_data()['code'] );
+		$this->assertSame( 'woocommerce_ability_extension_filter_failed', $failed->get_data()['code'] );
+		$this->assertSame( 'woocommerce_ability_extension_filter_failed', $taxed->get_data()['code'] );
+		$this->assertSame( 'woocommerce_ability_extension_filter_failed', $scalar->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Should accept a filter that returns an empty clause, and not filter the query.
+	 */
+	public function test_a_filter_that_returns_an_empty_clause_does_not_filter(): void {
+		add_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				ExtensibleAbilities::register_query_filter(
+					array(
+						'resource'  => 'product',
+						'namespace' => 'test-ext',
+						'filter'    => 'empty_clause',
+						'schema'    => array( 'type' => 'boolean' ),
+						'callback'  => static fn() => array( 'meta_query' => array() ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+					)
+				);
+			}
+		);
+		$this->get_ability( 'woocommerce/products-query' )['input_schema'];
+
+		$this->assertSame( array(), ExtensibleAbilities::get_query_args( 'product', array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'empty_clause' => true ) ) ) ) ) );
+	}
+
+	/**
+	 * @testdox Should refuse a filter on a resource that is not registered, and accept one on a registered resource.
+	 */
+	public function test_a_filter_on_an_unsupported_resource_is_refused(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_query_filter' );
+		$input  = array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'gift' => true ) ) ) );
+		$filter = array(
+			'resource'  => 'ticket',
+			'namespace' => 'test-ext',
+			'filter'    => 'gift',
+			'schema'    => array( 'type' => 'boolean' ),
+			'callback'  => static fn() => array( 'meta_query' => array( array( 'key' => 'gift' ) ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+		);
+
+		ExtensibleAbilities::register_query_filter( $filter );
+		$this->assertSame( array(), ExtensibleAbilities::get_query_args( 'ticket', $input ) );
+
+		$register_resource = static function () {
+			ExtensibleAbilities::register_resource( 'ticket' );
+		};
+		$register          = static function () use ( $filter ) {
+			ExtensibleAbilities::register_query_filter( $filter );
+		};
+		add_action( 'woocommerce_extensible_abilities_resources_init', $register_resource );
+		add_action( 'woocommerce_extensible_abilities_init', $register );
+		$this->get_ability( 'woocommerce/products-query' )['input_schema'];
+		remove_action( 'woocommerce_extensible_abilities_resources_init', $register_resource );
+		remove_action( 'woocommerce_extensible_abilities_init', $register );
+
+		$this->assertSame( array( 'meta_query' => array( array( array( 'key' => 'gift' ) ) ) ), ExtensibleAbilities::get_query_args( 'ticket', $input ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+	}
+
+	/**
+	 * @testdox Should refuse a filter whose namespace or name is only numbers.
+	 */
+	public function test_a_filter_with_a_numeric_slug_is_refused(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_query_filter' );
+		$register = static function () {
+			foreach ( array( array( '0', 'gift' ), array( 'test-ext', '42' ) ) as list( $extension_namespace, $filter ) ) {
+				ExtensibleAbilities::register_query_filter(
+					array(
+						'resource'  => 'product',
+						'namespace' => $extension_namespace,
+						'filter'    => $filter,
+						'schema'    => array( 'type' => 'boolean' ),
+						'callback'  => static fn() => array( 'meta_query' => array( array( 'key' => 'gift' ) ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+					)
+				);
+			}
+		};
+		add_action( 'woocommerce_extensible_abilities_init', $register );
+		$schema = $this->get_ability( 'woocommerce/products-query' )['input_schema'];
+		remove_action( 'woocommerce_extensible_abilities_init', $register );
+
+		$this->assertSame( array( 'test-ext' ), array_keys( $schema['properties']['filters']['properties']['extensions']['properties'] ) );
+		$this->assertSame( array( 'has_code', 'category' ), array_keys( $schema['properties']['filters']['properties']['extensions']['properties']['test-ext']['properties'] ) );
+	}
+
+	/**
+	 * @testdox Should refuse a filter that is registered after the init action.
+	 */
+	public function test_a_filter_registered_after_the_init_action_is_refused(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_query_filter' );
+		$this->get_ability( 'woocommerce/products-query' )['input_schema'];
+
+		ExtensibleAbilities::register_query_filter(
+			array(
+				'resource'  => 'product',
+				'namespace' => 'late-ext',
+				'filter'    => 'gift',
+				'schema'    => array( 'type' => 'boolean' ),
+				'callback'  => static fn() => array( 'meta_query' => array( array( 'key' => 'gift' ) ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+			)
+		);
+
+		$this->assertSame( array(), ExtensibleAbilities::get_query_args( 'product', array( 'filters' => array( 'extensions' => array( 'late-ext' => array( 'gift' => true ) ) ) ) ) );
+	}
+
+	/**
+	 * @testdox Should refuse an id sent together with filters.
+	 */
+	public function test_an_id_with_filters_is_refused(): void {
+		$input = array(
+			'id'      => 1,
+			'filters' => array( 'extensions' => array( 'test-ext' => array( 'has_code' => true ) ) ),
+		);
+
+		$products = $this->run_ability_response( 'woocommerce/products-query', $input );
+		$orders   = $this->run_ability_response( 'woocommerce/orders-query', array( 'id' => 1 ) + array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'tag' => 'gift' ) ) ) ) );
+
+		$this->assertSame( 400, $products->get_status() );
+		$this->assertSame( 'woocommerce_ability_filters_with_id', $products->get_data()['code'] );
+		$this->assertSame( 400, $orders->get_status() );
+		$this->assertSame( 'woocommerce_ability_filters_with_id', $orders->get_data()['code'] );
+	}
+
 	/**
 	 * @testdox Should return the fields of each extension under its namespace, and leave out a product with no values.
 	 */
@@ -431,6 +1021,8 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {

 		$this->assertSame( 0, did_action( 'woocommerce_extensible_abilities_resources_init' ) );
 		$this->assertSame( 0, did_action( 'woocommerce_extensible_abilities_init' ) );
+		$this->assertArrayNotHasKey( 'filters', $this->get_ability( 'woocommerce/products-query' )['input_schema']['properties'] );
+		$this->assertSame( 400, $this->run_ability_response( 'woocommerce/products-query', array( 'filters' => array( 'extensions' => array( 'test-ext' => array( 'has_code' => true ) ) ) ) )->get_status() );
 		$this->assertArrayNotHasKey( 'extensions', $this->get_output_schema( 'woocommerce/products-query' )['properties']['products']['items']['properties'] );
 		$this->assertArrayNotHasKey( 'extensions', $products[0] );
 		$this->assertArrayNotHasKey( 'extensions', $orders[0] );
@@ -1482,7 +2074,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	private function reset_registries(): void {
 		global $wp_actions;

-		foreach ( array( 'fields', 'reported', 'validators' ) as $property ) {
+		foreach ( array( 'fields', 'reported', 'validators', 'filters' ) as $property ) {
 			$reflection = new \ReflectionProperty( ExtensibleAbilities::class, $property );
 			$reflection->setAccessible( true );
 			$reflection->setValue( null, array() );