Commit 1e1114ad2da for woocommerce

commit 1e1114ad2daff0d8814c343c50592a829e84138f
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Wed Nov 4 16:41:51 2026 -0300

    Convert ability input to the schema types before the callbacks run (#70602)

    * Convert ability input to the schema types before the callbacks run

    WordPress validates ability input against the schema, but only the REST
    route converts it to the schema types. A direct execute() call, such as
    one from the MCP adapter, passed "false" on to the callbacks, and an
    empty() or (bool) read took it as true. "force": "false" deleted a
    product for good, and "customer_note": "false" showed a note to the
    customer.

    The new Ability class converts valid input in normalize_input(), the same
    way the REST route does since WordPress 7.1. ActionableAbility extends it,
    and the loader registers the query abilities with it. The casts and type
    checks in the abilities that the schema already guarantees are removed.

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

    * Refuse a Core ability that skips the Ability class in PHPStan

    Two rules keep every WooCommerce ability on the input conversion: a class
    must not extend WP_Ability directly, and only AbilitiesLoader may call
    wp_register_ability(). The error names the class to extend.

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

    * Read ability booleans with ?? and drop sanitizers the schema covers

    The input now arrives as real booleans, so the flags read with ?? false
    instead of a truthiness check. Fields with an enum or a format are
    already limited by validation or cleaned by the conversion, so status,
    stock_status, order, orderby, billing_email, the order dates and
    external_url lose their extra sanitizers. Free text keeps them.

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

    * Pass the customer note flag to add_order_note() as an int

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

    * Drop the order status checks that the status enum makes unreachable

    The status enum lists the unprefixed query statuses, so a wc- prefix or
    any other status fails validation before change() runs.

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

    * Drop ability checks for required fields and enums that validation enforces

    The order and product id helpers, the order-update-status status, the
    order-add-note note and the product-terms-query taxonomy are required, or
    limited by an enum, so the checks for a missing or unknown value never
    fail once validation has passed.

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

    * Make Ability::normalize_input() final

    A subclass that overrides it could drop the conversion to the schema types.
    A subclass changes what counts as valid input through validate_input() instead.

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

    * Backport WP_Ability::coerce_input() from WordPress 7.2

    WordPress 7.2 converts valid ability input in execute(). Ability now
    converts only before 7.2, through a coerce_input() that matches core,
    and refuses input that validates but cannot be converted.

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

    * Name the merchant impact in the changelog and when to remove the backport

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

    * Note when to remove the backport and make the changelog general

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

    * Tell agents not to repeat schema checks in ability callbacks

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

    * Skip markdownlint on the abilities CLAUDE.md shim

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

    * Convert the input when check_permissions() is called directly

    The MCP adapter calls check_permissions() with raw arguments before
    execute(), so permission callbacks got a numeric string id or null.

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

    * Make Ability::check_permissions() final

    A subclass that overrides it could pass raw input to its permission callback.

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

    ---------

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

diff --git a/.markdownlintignore b/.markdownlintignore
index 2232847196b..73532d966cb 100644
--- a/.markdownlintignore
+++ b/.markdownlintignore
@@ -8,3 +8,4 @@ packages/php/woocommerce-analytics/CHANGELOG.md
 /CLAUDE.md
 plugins/woocommerce/packages/action-scheduler/CLAUDE.md
 plugins/woocommerce/tests/php/src/CLAUDE.md
+plugins/woocommerce/src/Internal/Abilities/CLAUDE.md
diff --git a/plugins/woocommerce/changelog/wooai-1653-convert-ability-input b/plugins/woocommerce/changelog/wooai-1653-convert-ability-input
new file mode 100644
index 00000000000..bb63dadfd05
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1653-convert-ability-input
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix WooCommerce abilities reading values that AI agents send as text, such as "false" or "5", as the wrong type.
diff --git a/plugins/woocommerce/phpstan.neon b/plugins/woocommerce/phpstan.neon
index d51c662512d..ebdabb21e3f 100644
--- a/plugins/woocommerce/phpstan.neon
+++ b/plugins/woocommerce/phpstan.neon
@@ -17,6 +17,8 @@ parameters:
 	bootstrapFiles:
 		- vendor/autoload.php
 		- tests/Tools/phpstan/StagedWriteRule.php
+		- tests/Tools/phpstan/AbilityClassRule.php
+		- tests/Tools/phpstan/AbilityRegistrationRule.php
 	scanDirectories:
 		- vendor/wordpress/abilities-api
 		- packages/action-scheduler
@@ -96,3 +98,11 @@ services:
 		class: Automattic\WooCommerce\Testing\Tools\phpstan\StagedWriteRule
 		tags:
 			- phpstan.rules.rule
+	-
+		class: Automattic\WooCommerce\Testing\Tools\phpstan\AbilityClassRule
+		tags:
+			- phpstan.rules.rule
+	-
+		class: Automattic\WooCommerce\Testing\Tools\phpstan\AbilityRegistrationRule
+		tags:
+			- phpstan.rules.rule
diff --git a/plugins/woocommerce/src/Abilities/Ability.php b/plugins/woocommerce/src/Abilities/Ability.php
new file mode 100644
index 00000000000..30439e87b3d
--- /dev/null
+++ b/plugins/woocommerce/src/Abilities/Ability.php
@@ -0,0 +1,134 @@
+<?php
+/**
+ * Ability class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Abilities;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * The base class of the WooCommerce abilities.
+ *
+ * WordPress 7.2 converts valid ability input to the schema types with
+ * WP_Ability::coerce_input() before the callbacks run. This class backports
+ * that method, so the callbacks get converted input on WordPress 7.0 and 7.1 too.
+ *
+ * @since 11.4.0
+ */
+class Ability extends \WP_Ability {
+
+	/**
+	 * Apply the schema default. Before WordPress 7.2, also convert valid input to
+	 * the schema types, as WordPress 7.2 does in execute().
+	 *
+	 * Invalid input stays unchanged, so validate_input() refuses it as before.
+	 *
+	 * Remove this override, coerce_input() and get_input_error() when WooCommerce
+	 * requires WordPress 7.2, because WP_Ability::execute() converts the input there.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param mixed $input Optional. The raw input provided for the ability. Default `null`.
+	 * @return mixed The input, converted before WordPress 7.2, or a WP_Error.
+	 */
+	final public function normalize_input( $input = null ) {
+		$input = parent::normalize_input( $input );
+		if ( method_exists( \WP_Ability::class, 'coerce_input' ) || null === $input || is_wp_error( $input ) || is_wp_error( $this->validate_input( $input ) ) ) {
+			return $input;
+		}
+
+		return $this->coerce_input( $input );
+	}
+
+	/**
+	 * Apply the schema default, validate and convert the input, then check the permissions.
+	 *
+	 * Callers such as the MCP adapter call this method directly with raw input. The
+	 * permission callback then gets the same input as through execute().
+	 *
+	 * Remove this override when WooCommerce requires a WordPress version that fixes
+	 * https://core.trac.wordpress.org/ticket/66482.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param mixed $input Optional. The raw input provided for the ability. Default `null`.
+	 * @return bool|\WP_Error Whether the ability has the necessary permission, or the input error.
+	 */
+	final public function check_permissions( $input = null ) {
+		$input = $this->normalize_input( $input );
+		if ( is_wp_error( $input ) ) {
+			return $input;
+		}
+
+		$is_valid = $this->validate_input( $input );
+		if ( is_wp_error( $is_valid ) ) {
+			return $is_valid;
+		}
+
+		$input = $this->coerce_input( $input );
+		if ( is_wp_error( $input ) ) {
+			return $input;
+		}
+
+		return parent::check_permissions( $input );
+	}
+
+	/**
+	 * Convert validated input to the schema types, the same way WordPress 7.2 does.
+	 *
+	 * Converting can fail on input that validated, such as items that are unique
+	 * as strings but not as integers. The input is then refused.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param mixed $input Validated ability input.
+	 * @return mixed|\WP_Error The converted input, or a WP_Error when it cannot be converted.
+	 */
+	public function coerce_input( $input ) {
+		$schema = $this->get_input_schema();
+		if ( null === $input || empty( $schema ) ) {
+			return $input;
+		}
+
+		$sanitized = rest_sanitize_value_from_schema( $input, $schema, 'input' );
+
+		$error = self::get_input_error( $sanitized );
+		if ( null !== $error ) {
+			return new \WP_Error(
+				'ability_invalid_input',
+				sprintf(
+					/* translators: %1$s: ability name, %2$s: error message. */
+					__( 'Ability "%1$s" has invalid input. Reason: %2$s', 'woocommerce' ),
+					$this->name,
+					$error->get_error_message()
+				)
+			);
+		}
+
+		return $sanitized;
+	}
+
+	/**
+	 * Get the first WP_Error in a converted value.
+	 *
+	 * @param mixed $value Converted value.
+	 * @return \WP_Error|null
+	 */
+	private static function get_input_error( $value ): ?\WP_Error {
+		if ( is_wp_error( $value ) ) {
+			return $value;
+		}
+		if ( is_array( $value ) ) {
+			foreach ( $value as $item ) {
+				$error = self::get_input_error( $item );
+				if ( null !== $error ) {
+					return $error;
+				}
+			}
+		}
+		return null;
+	}
+}
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 61de9b642f0..947249b4092 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -54,7 +54,7 @@ defined( 'ABSPATH' ) || exit;
  *
  * @since 11.3.0
  */
-abstract class ActionableAbility extends \WP_Ability {
+abstract class ActionableAbility extends Ability {

 	/**
 	 * Resource that the ability changes, for example `product` or `order`. The fields of this resource are written.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AGENTS.md b/plugins/woocommerce/src/Internal/Abilities/AGENTS.md
new file mode 100644
index 00000000000..47a0633b92b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/AGENTS.md
@@ -0,0 +1,23 @@
+# WooCommerce abilities
+
+The Core abilities live in `Domain/`. `AbilitiesLoader` registers them with `Automattic\WooCommerce\Abilities\Ability` or a subclass of `ActionableAbility`. Two PHPStan rules keep it that way.
+
+## Trust the input schema
+
+`WP_Ability::execute()` validates the input against the input schema before it calls the permission or execute callback. `Ability` also converts the input to the schema types. So a callback gets `true`, not `"true"`, and `5`, not `"5"`.
+
+Do not repeat in a callback what the schema already checks:
+
+- No casts or type checks on input fields: `(int)`, `(string)`, `(bool)`, `is_array()`, `is_string()`.
+- No string-boolean helpers on boolean fields: `wc_string_to_bool()`, `wp_validate_boolean()`. Read a boolean with `$input['field'] ?? false`.
+- No checks for required fields, `minimum`, `maximum`, `enum` or `pattern`.
+- No sanitizers on `enum` fields, or on the `email`, `date-time` and `uri` formats. The conversion already sanitizes those formats.
+
+Put a new rule in the schema when the schema can express it. Check in the callback only what the schema cannot express, such as "send `id` or `filters`, not both".
+
+Keep:
+
+- Sanitizers on free text, such as `wp_kses_post()` on a note or `wc_clean()` on a SKU.
+- Calls that normalize a valid value, such as `sanitize_title()` on a slug or `wc_format_decimal()` on a price.
+- A cast that a WooCommerce function needs, such as `(int)` for `add_order_note()`.
+
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index 111105b9120..b4bdebab1db 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -7,6 +7,7 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\Internal\Abilities;

+use Automattic\WooCommerce\Abilities\Ability;
 use Automattic\WooCommerce\Abilities\AbilityDefinition;
 use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
 use Automattic\WooCommerce\Abilities\ActionableAbility;
@@ -223,6 +224,8 @@ class AbilitiesLoader {
 			$args = $class_name::get_registration_args();
 			if ( $is_actionable ) {
 				$args['ability_class'] = $class_name;
+			} elseif ( $is_core_ability ) {
+				$args['ability_class'] = Ability::class;
 			}

 			$registered_ability = wp_register_ability( $ability_name, $args );
diff --git a/plugins/woocommerce/src/Internal/Abilities/CLAUDE.md b/plugins/woocommerce/src/Internal/Abilities/CLAUDE.md
new file mode 100644
index 00000000000..43c994c2d36
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
index 2e80fa8c8f7..d7a6aa70710 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
@@ -80,7 +80,7 @@ class OrderAddNote extends AbstractDomainAbility implements AbilityDefinition {
 			return $order;
 		}

-		$note = isset( $input['note'] ) ? trim( wp_kses_post( (string) $input['note'] ) ) : '';
+		$note = trim( wp_kses_post( $input['note'] ) );

 		if ( '' === $note ) {
 			return new \WP_Error(
@@ -92,7 +92,7 @@ class OrderAddNote extends AbstractDomainAbility implements AbilityDefinition {

 		$note_id = $order->add_order_note(
 			$note,
-			( (bool) ( $input['customer_note'] ?? false ) ) ? 1 : 0,
+			(int) ( $input['customer_note'] ?? false ),
 			get_current_user_id() > 0
 		);

diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
index 32853f8e30e..07ba4c64446 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
@@ -11,7 +11,6 @@ use Automattic\WooCommerce\Abilities\ActionableAbility;
 use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
 use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
 use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
-use Automattic\WooCommerce\Utilities\OrderUtil;

 defined( 'ABSPATH' ) || exit;

@@ -95,23 +94,7 @@ class OrderUpdateStatus extends ActionableAbility {
 	 * @return null|\WP_Error
 	 */
 	public function change( $subject, array $input ) {
-		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_query_order_status_slugs(), true ) ) {
-			return new \WP_Error(
-				'woocommerce_order_status_invalid',
-				__( 'Order status is invalid.', 'woocommerce' ),
-				array( 'status' => 400 )
-			);
-		}
+		$status = $input['status'];

 		if ( $status === $subject->get_status() ) {
 			return new \WP_Error(
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
index d36d413bc0b..e6da404add0 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
@@ -76,7 +76,7 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 	 * @since 10.9.0
 	 */
 	public static function execute( array $input ) {
-		$include_line_items = (bool) ( $input['include_line_items'] ?? false );
+		$include_line_items = $input['include_line_items'] ?? false;

 		if ( ! empty( $input['id'] ) && ! empty( $input['filters'] ) ) {
 			return new \WP_Error(
@@ -106,8 +106,8 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 			);
 		}

-		$page     = (int) ( $input['page'] ?? 1 );
-		$per_page = (int) ( $input['per_page'] ?? 10 );
+		$page     = $input['page'] ?? 1;
+		$per_page = $input['per_page'] ?? 10;
 		$args     = array(
 			'limit'    => $per_page,
 			'page'     => $page,
@@ -117,12 +117,12 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 		);

 		foreach ( array( 'billing_email', 'order' ) as $field ) {
-			if ( ! empty( $input[ $field ] ) && is_scalar( $input[ $field ] ) ) {
-				$args[ $field ] = wc_clean( (string) $input[ $field ] );
+			if ( ! empty( $input[ $field ] ) ) {
+				$args[ $field ] = $input[ $field ];
 			}
 		}

-		$args['status'] = array_map( 'wc_clean', array_map( 'strval', wp_parse_list( $input['status'] ?? array() ) ) );
+		$args['status'] = $input['status'] ?? array();

 		if ( empty( $args['status'] ) ) {
 			$args['status'] = 'shop_order' === $args['type'] ? self::get_allowed_order_status_slugs() : self::get_query_order_status_slugs();
@@ -130,19 +130,18 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {

 		$args['status'] = array_map( array( __CLASS__, 'prepare_status_arg' ), (array) $args['status'] );

-		if ( ! empty( $input['orderby'] ) && is_scalar( $input['orderby'] ) ) {
-			$orderby         = sanitize_text_field( (string) $input['orderby'] );
-			$args['orderby'] = self::prepare_orderby_arg( $orderby );
+		if ( ! empty( $input['orderby'] ) ) {
+			$args['orderby'] = self::prepare_orderby_arg( $input['orderby'] );
 		}

 		foreach ( array( 'customer_id', 'parent' ) as $field ) {
 			if ( isset( $input[ $field ] ) ) {
-				$args[ $field ] = (int) $input[ $field ];
+				$args[ $field ] = $input[ $field ];
 			}
 		}

-		if ( ! empty( $input['exclude'] ) && is_array( $input['exclude'] ) ) {
-			$args['exclude'] = array_map( 'intval', $input['exclude'] );
+		if ( ! empty( $input['exclude'] ) ) {
+			$args['exclude'] = $input['exclude'];
 		}

 		$created_date_arg = self::build_created_date_arg( $input );
@@ -215,7 +214,7 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 			return self::check_order_permission( $order_id, 'read' );
 		}

-		$type = is_array( $input ) ? self::get_order_type_from_input( $input ) : 'shop_order';
+		$type = self::get_order_type_from_input( $input );

 		return wc_rest_check_post_permissions( $type, 'read' );
 	}
@@ -227,9 +226,7 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 	 * @return string
 	 */
 	private static function get_order_type_from_input( array $input ): string {
-		$type = $input['type'] ?? 'shop_order';
-
-		return is_string( $type ) && in_array( $type, self::get_order_type_slugs(), true ) ? $type : 'shop_order';
+		return $input['type'] ?? 'shop_order';
 	}

 	/**
@@ -400,8 +397,8 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 	 * @return array{0: int|null, 1: int|null}|null Null when neither field is set, or when a provided value is not a valid date-time.
 	 */
 	private static function resolve_date_range_timestamps( array $input, string $after_field, string $before_field ): ?array {
-		$after  = isset( $input[ $after_field ] ) && is_string( $input[ $after_field ] ) ? sanitize_text_field( $input[ $after_field ] ) : '';
-		$before = isset( $input[ $before_field ] ) && is_string( $input[ $before_field ] ) ? sanitize_text_field( $input[ $before_field ] ) : '';
+		$after  = $input[ $after_field ] ?? '';
+		$before = $input[ $before_field ] ?? '';

 		if ( '' === $after && '' === $before ) {
 			return null;
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/PaymentGatewaysQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/PaymentGatewaysQuery.php
index 9beacc436af..9ef500ff6d4 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/PaymentGatewaysQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/PaymentGatewaysQuery.php
@@ -74,7 +74,7 @@ class PaymentGatewaysQuery extends AbstractDomainAbility implements AbilityDefin
 		$gateways = WC()->payment_gateways()->payment_gateways();

 		if ( isset( $input['id'] ) ) {
-			$id = (string) $input['id'];
+			$id = $input['id'];

 			if ( ! isset( $gateways[ $id ] ) ) {
 				return new \WP_Error(
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
index 27d7ba1bc0c..350430709ce 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
@@ -105,7 +105,7 @@ class ProductCreate extends ActionableAbility {

 		$product_type = $input['type'] ?? ProductType::SIMPLE;

-		if ( ProductType::VARIATION !== $product_type && '' === trim( (string) ( $input['name'] ?? '' ) ) ) {
+		if ( ProductType::VARIATION !== $product_type && '' === trim( $input['name'] ?? '' ) ) {
 			return new \WP_Error(
 				'woocommerce_product_name_required',
 				__( 'A product needs a name.', 'woocommerce' ),
@@ -114,7 +114,7 @@ class ProductCreate extends ActionableAbility {
 		}

 		if ( ProductType::VARIATION === $product_type ) {
-			$parent = wc_get_product( (int) ( $input['parent_id'] ?? 0 ) );
+			$parent = wc_get_product( $input['parent_id'] ?? 0 );
 			if ( ! $parent instanceof \WC_Product_Variable ) {
 				return new \WP_Error(
 					'woocommerce_product_parent_invalid',
@@ -230,7 +230,7 @@ class ProductCreate extends ActionableAbility {
 			return true;
 		}

-		$parent_id = (int) ( $input['parent_id'] ?? 0 );
+		$parent_id = $input['parent_id'] ?? 0;

 		return $parent_id > 0 && wc_rest_check_post_permissions( 'product', 'edit', $parent_id );
 	}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
index b0046242d92..76dfa59b35a 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
@@ -132,7 +132,7 @@ class ProductDelete extends ActionableAbility {
 	 */
 	protected function load( array $input ) {
 		$this->product_id = 0;
-		$this->force      = wc_string_to_bool( $input['force'] ?? false );
+		$this->force      = $input['force'] ?? false;
 		$this->parent     = null;
 		$this->deleting   = array();
 		$this->deleted    = array();
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
index db9bdb88afc..564d3dbadd8 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
@@ -71,9 +71,8 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 	 * @return bool
 	 */
 	public static function can_query_terms( $input = array() ): bool {
-		$taxonomy = $input['taxonomy'] ?? '';
-		return in_array( $taxonomy, array( 'product_cat', 'product_tag', 'product_brand' ), true )
-			&& taxonomy_exists( $taxonomy )
+		$taxonomy = $input['taxonomy'];
+		return taxonomy_exists( $taxonomy )
 			&& wc_rest_check_product_term_permissions( $taxonomy, 'read' );
 	}

@@ -85,9 +84,9 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 	 * @return array|\WP_Error
 	 */
 	public static function execute( array $input ) {
-		$page               = (int) ( $input['page'] ?? 1 );
-		$per_page           = (int) ( $input['per_page'] ?? 10 );
-		$args               = array_merge(
+		$page     = $input['page'] ?? 1;
+		$per_page = $input['per_page'] ?? 10;
+		$args     = array_merge(
 			array(
 				'hide_empty' => false,
 				'orderby'    => 'name',
@@ -95,7 +94,6 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 			),
 			array_intersect_key( $input, array_flip( array( 'taxonomy', 'include', 'exclude', 'search', 'slug', 'parent', 'hide_empty', 'orderby', 'order' ) ) )
 		);
-		$args['hide_empty'] = wp_validate_boolean( $input['hide_empty'] ?? false );
 		if ( isset( $input['slug'] ) ) {
 			$args['slug'] = wp_parse_slug_list( $input['slug'] );
 		}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
index 09b66ae53d4..01c55219b66 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
@@ -113,11 +113,11 @@ class ProductUpdate extends ActionableAbility {
 			return $error;
 		}

-		if ( isset( $input['type'] ) && (string) $input['type'] !== $product->get_type() ) {
+		if ( isset( $input['type'] ) && $input['type'] !== $product->get_type() ) {
 			$is_variable = static function ( $class_or_object ): bool {
 				return is_a( $class_or_object, \WC_Product_Variable::class, true ) || is_a( $class_or_object, \WC_Product_Variation::class, true );
 			};
-			if ( $is_variable( \WC_Product_Factory::get_product_classname( $product->get_id(), (string) $input['type'] ) ) || $is_variable( $product ) ) {
+			if ( $is_variable( \WC_Product_Factory::get_product_classname( $product->get_id(), $input['type'] ) ) || $is_variable( $product ) ) {
 				return new \WP_Error(
 					'woocommerce_product_type_unsupported',
 					__( 'A product cannot change into or out of a variable product or a variation through this ability. WooCommerce would delete its variations.', 'woocommerce' ),
@@ -248,7 +248,7 @@ class ProductUpdate extends ActionableAbility {
 	 */
 	public static function can_update_product( $input = array() ): bool {
 		$product_id = self::get_product_permission_id( self::get_id_from_input( $input ) );
-		$deletes    = is_array( $input ) && is_array( $input['variations'] ?? null ) && ! empty( array_filter( array_map( 'wc_string_to_bool', array_column( $input['variations'], 'delete' ) ) ) );
+		$deletes    = ! empty( array_filter( array_column( $input['variations'] ?? array(), 'delete' ) ) );

 		return $product_id > 0
 			&& wc_rest_check_post_permissions( 'product', 'edit', $product_id )
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
index f50c7e30b8b..51de9a32358 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
@@ -118,8 +118,8 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 			);
 		}

-		$page     = (int) ( $input['page'] ?? 1 );
-		$per_page = (int) ( $input['per_page'] ?? 10 );
+		$page     = $input['page'] ?? 1;
+		$per_page = $input['per_page'] ?? 10;
 		$args     = array(
 			'limit'    => $per_page,
 			'page'     => $page,
@@ -127,12 +127,16 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 			'return'   => 'objects',
 		);

-		foreach ( array( 'status', 'sku', 'stock_status' ) as $field ) {
+		foreach ( array( 'status', 'stock_status' ) as $field ) {
 			if ( ! empty( $input[ $field ] ) ) {
-				$args[ $field ] = wc_clean( $input[ $field ] );
+				$args[ $field ] = $input[ $field ];
 			}
 		}

+		if ( ! empty( $input['sku'] ) ) {
+			$args['sku'] = wc_clean( $input['sku'] );
+		}
+
 		if ( isset( $input['type'] ) ) {
 			$args['type'] = $input['type'];
 			$parent_types = self::get_variation_subclass_parent_types()[ $input['type'] ] ?? array();
@@ -144,13 +148,13 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 		}

 		if ( ! empty( $input['parent_id'] ) ) {
-			$args['parent'] = (int) $input['parent_id'];
+			$args['parent'] = $input['parent_id'];
 			$args['type']   = $args['type'] ?? ProductType::VARIATION;
 		}

 		foreach ( array( 'virtual', 'downloadable' ) as $field ) {
 			if ( isset( $input[ $field ] ) ) {
-				$args[ $field ] = wc_string_to_bool( $input[ $field ] );
+				$args[ $field ] = $input[ $field ];
 			}
 		}

@@ -164,11 +168,11 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 		}

 		foreach ( self::TERM_FILTERS as $field => $taxonomy ) {
-			if ( ! empty( $input[ $field ] ) && is_array( $input[ $field ] ) ) {
+			if ( ! empty( $input[ $field ] ) ) {
 				$extension_args['tax_query'][] = array(
 					'taxonomy' => $taxonomy,
 					'field'    => 'term_id',
-					'terms'    => array_map( 'absint', $input[ $field ] ),
+					'terms'    => $input[ $field ],
 				);
 			}
 		}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php
index 669ae4d1c3f..70aaabe15ba 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php
@@ -121,6 +121,6 @@ trait DomainAbilityHelpers {
 	 * @return int
 	 */
 	protected static function get_id_from_input( $input ): int {
-		return is_array( $input ) && ! empty( $input['id'] ) ? (int) $input['id'] : 0;
+		return $input['id'] ?? 0;
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
index fe00879ed92..f5d4c74008d 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
@@ -221,25 +221,7 @@ trait OrderAbilityTrait {
 	 * @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 );
+		$order = wc_get_order( $input['id'] );

 		if ( ! $order instanceof \WC_Order ) {
 			return new \WP_Error(
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index c9a721e63aa..9101b651cc7 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -606,25 +606,7 @@ trait ProductAbilityTrait {
 	 * @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 );
+		$product = wc_get_product( $input['id'] );

 		if ( ! $product ) {
 			return new \WP_Error(
@@ -702,7 +684,7 @@ trait ProductAbilityTrait {
 	 * @return null|\WP_Error
 	 */
 	protected static function check_publish_permission( ?\WC_Product $product, array $input ) {
-		$status = isset( $input['status'] ) ? sanitize_key( (string) $input['status'] ) : '';
+		$status = $input['status'] ?? '';
 		if (
 			! in_array( $status, array( ProductStatus::PUBLISH, ProductStatus::FUTURE, ProductStatus::PRIVATE ), true )
 			|| ( $product && $product->get_status() === $status )
@@ -834,8 +816,8 @@ trait ProductAbilityTrait {

 		foreach ( array_values( $items ) as $index => $item ) {
 			$position = $index + 1;
-			$id       = (int) ( $item['id'] ?? 0 );
-			$delete   = wc_string_to_bool( $item['delete'] ?? false );
+			$id       = $item['id'] ?? 0;
+			$delete   = $item['delete'] ?? false;

 			if ( $delete && ( $id <= 0 || count( $item ) > 2 ) ) {
 				return new \WP_Error(
@@ -1064,11 +1046,11 @@ trait ProductAbilityTrait {
 				if ( isset( $item['options'] ) ) {
 					return self::get_invalid_product_attributes_error();
 				}
-				$key = empty( $item['id'] ) ? sanitize_title( (string) ( $item['name'] ?? '' ) ) : wc_attribute_taxonomy_name_by_id( (int) $item['id'] );
+				$key = empty( $item['id'] ) ? sanitize_title( $item['name'] ?? '' ) : wc_attribute_taxonomy_name_by_id( $item['id'] );
 				if ( '' === $key ) {
 					return self::get_invalid_product_attributes_error();
 				}
-				$attributes[ $key ] = isset( $item['option'] ) ? wc_clean( (string) $item['option'] ) : '';
+				$attributes[ $key ] = isset( $item['option'] ) ? wc_clean( $item['option'] ) : '';
 			}
 			return $attributes;
 		}
@@ -1077,14 +1059,14 @@ trait ProductAbilityTrait {
 		foreach ( $value as $position => $item ) {
 			$options  = array_map( 'sanitize_text_field', $item['options'] ?? array() );
 			$taxonomy = self::get_global_attribute_taxonomy( $item );
-			$name     = '' === $taxonomy ? sanitize_text_field( trim( (string) ( $item['name'] ?? '' ) ) ) : $taxonomy;
-			if ( '' === $taxonomy && ( ! empty( $item['id'] ) || wc_string_to_bool( $item['global'] ?? false ) ) ) {
+			$name     = '' === $taxonomy ? sanitize_text_field( trim( $item['name'] ?? '' ) ) : $taxonomy;
+			if ( '' === $taxonomy && ( ! empty( $item['id'] ) || ( $item['global'] ?? false ) ) ) {
 				return new \WP_Error(
 					'woocommerce_product_attributes_invalid',
 					sprintf(
 						/* translators: %s: attribute name or id. */
 						__( 'There is no global attribute "%s".', 'woocommerce' ),
-						(string) ( $item['id'] ?? $item['name'] ?? '' )
+						$item['id'] ?? $item['name'] ?? ''
 					),
 					array( 'status' => 400 )
 				);
@@ -1131,11 +1113,11 @@ trait ProductAbilityTrait {
 	 */
 	private static function get_global_attribute_taxonomy( array $item ): string {
 		if ( ! empty( $item['id'] ) ) {
-			return wc_attribute_taxonomy_name_by_id( (int) $item['id'] );
+			return wc_attribute_taxonomy_name_by_id( $item['id'] );
 		}

 		$name = trim( $item['name'] ?? '' );
-		if ( ! wc_string_to_bool( $item['global'] ?? false ) || '' === $name ) {
+		if ( ! ( $item['global'] ?? false ) || '' === $name ) {
 			return '';
 		}

@@ -1323,49 +1305,19 @@ trait ProductAbilityTrait {
 	 *
 	 * @param string $field Field name.
 	 * @param mixed  $value Field value.
-	 * @return mixed|\WP_Error
+	 * @return mixed
 	 */
 	private static function prepare_product_field_value( string $field, $value ) {
-		if ( in_array( $field, array( 'name', 'description', 'short_description' ), true ) && is_string( $value ) ) {
+		if ( in_array( $field, array( 'name', 'description', 'short_description' ), true ) ) {
 			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( 'status', 'stock_status', 'external_url' ), true ) ) {
+			return $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;
+			return wc_format_decimal( $value );
 		}

 		if ( is_string( $value ) ) {
@@ -1375,26 +1327,6 @@ trait ProductAbilityTrait {
 		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.
 	 *
@@ -1436,19 +1368,6 @@ trait ProductAbilityTrait {
 		);
 	}

-	/**
-	 * 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.
 	 *
diff --git a/plugins/woocommerce/tests/Tools/phpstan/AbilityClassRule.php b/plugins/woocommerce/tests/Tools/phpstan/AbilityClassRule.php
new file mode 100644
index 00000000000..98921eaaf6d
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpstan/AbilityClassRule.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * AbilityClassRule class file.
+ *
+ * @package WooCommerce\Tests
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Testing\Tools\phpstan;
+
+use PhpParser\Node;
+use PHPStan\Analyser\Scope;
+use PHPStan\Node\InClassNode;
+use PHPStan\Rules\Rule;
+use PHPStan\Rules\RuleErrorBuilder;
+
+/**
+ * Reports a class that extends WP_Ability directly. A WooCommerce ability extends
+ * Automattic\WooCommerce\Abilities\Ability or ActionableAbility, so it gets its input
+ * converted to the schema types.
+ *
+ * @implements Rule<InClassNode>
+ */
+final class AbilityClassRule implements Rule {
+
+	private const ALLOWED_CLASSES = array(
+		'Automattic\\WooCommerce\\Abilities\\Ability',
+		'Automattic\\WooCommerce\\Internal\\Abilities\\REST\\RestAbility',
+	);
+
+	/**
+	 * Node type this rule checks.
+	 *
+	 * @return string
+	 */
+	public function getNodeType(): string {
+		return InClassNode::class;
+	}
+
+	/**
+	 * Report the class when its parent is WP_Ability.
+	 *
+	 * @param Node  $node  Class node.
+	 * @param Scope $scope Scope of the class.
+	 * @return array
+	 */
+	public function processNode( Node $node, Scope $scope ): array {
+		$class  = $node->getClassReflection();
+		$parent = $class->getParentClass();
+
+		if ( null === $parent || 'WP_Ability' !== $parent->getName() || in_array( $class->getName(), self::ALLOWED_CLASSES, true ) ) {
+			return array();
+		}
+
+		return array(
+			RuleErrorBuilder::message( sprintf( 'Class %s extends WP_Ability. Extend Automattic\WooCommerce\Abilities\Ability, or ActionableAbility for an ability that saves, so the input is converted to the schema types.', $class->getName() ) )
+				->identifier( 'woocommerce.abilityClass' )
+				->build(),
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/Tools/phpstan/AbilityRegistrationRule.php b/plugins/woocommerce/tests/Tools/phpstan/AbilityRegistrationRule.php
new file mode 100644
index 00000000000..2ba687b5cfe
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpstan/AbilityRegistrationRule.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * AbilityRegistrationRule class file.
+ *
+ * @package WooCommerce\Tests
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Testing\Tools\phpstan;
+
+use PhpParser\Node;
+use PhpParser\Node\Expr\FuncCall;
+use PhpParser\Node\Name;
+use PHPStan\Analyser\Scope;
+use PHPStan\Rules\Rule;
+use PHPStan\Rules\RuleErrorBuilder;
+
+/**
+ * Reports a wp_register_ability() call outside AbilitiesLoader. The loader registers
+ * every WooCommerce ability with Automattic\WooCommerce\Abilities\Ability or
+ * ActionableAbility, so it gets its input converted to the schema types.
+ *
+ * @implements Rule<FuncCall>
+ */
+final class AbilityRegistrationRule implements Rule {
+
+	private const LOADER_CLASS = 'Automattic\\WooCommerce\\Internal\\Abilities\\AbilitiesLoader';
+
+	/**
+	 * Node type this rule checks.
+	 *
+	 * @return string
+	 */
+	public function getNodeType(): string {
+		return FuncCall::class;
+	}
+
+	/**
+	 * Report the call when it registers an ability outside the loader.
+	 *
+	 * @param Node  $node  Function call node.
+	 * @param Scope $scope Scope of the call.
+	 * @return array
+	 */
+	public function processNode( Node $node, Scope $scope ): array {
+		if ( ! $node->name instanceof Name || 'wp_register_ability' !== $node->name->toLowerString() ) {
+			return array();
+		}
+
+		$class = $scope->getClassReflection();
+		if ( null !== $class && self::LOADER_CLASS === $class->getName() ) {
+			return array();
+		}
+
+		return array(
+			RuleErrorBuilder::message( 'Register a WooCommerce ability through AbilitiesLoader, not with wp_register_ability(), so it uses the Ability class and gets its input converted to the schema types.' )
+				->identifier( 'woocommerce.abilityRegistration' )
+				->build(),
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Abilities/AbilityTest.php b/plugins/woocommerce/tests/php/src/Abilities/AbilityTest.php
new file mode 100644
index 00000000000..866671ed9f3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Abilities/AbilityTest.php
@@ -0,0 +1,184 @@
+<?php
+/**
+ * AbilityTest class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Abilities;
+
+use Automattic\WooCommerce\Abilities\Ability;
+
+/**
+ * Tests for the Ability class.
+ */
+class AbilityTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * Input that the execute and permission callbacks of the test ability received.
+	 *
+	 * @var array
+	 */
+	private $received = array();
+
+	/**
+	 * Build a test ability with a boolean, an integer, a list of unique integers and a nested boolean field.
+	 *
+	 * @return Ability
+	 */
+	private function get_ability(): Ability {
+		$this->received = array();
+
+		return new Ability(
+			'test/convert-input',
+			array(
+				'label'               => 'Convert input',
+				'description'         => 'Returns its input.',
+				'category'            => 'woocommerce',
+				'input_schema'        => array(
+					'type'                 => 'object',
+					'additionalProperties' => false,
+					'properties'           => array(
+						'flag'  => array( 'type' => 'boolean' ),
+						'count' => array( 'type' => 'integer' ),
+						'ids'   => array(
+							'type'        => 'array',
+							'uniqueItems' => true,
+							'items'       => array( 'type' => 'integer' ),
+						),
+						'items' => array(
+							'type'  => 'array',
+							'items' => array(
+								'type'       => 'object',
+								'properties' => array( 'delete' => array( 'type' => 'boolean' ) ),
+							),
+						),
+					),
+				),
+				'output_schema'       => array( 'type' => 'object' ),
+				'execute_callback'    => function ( array $input ) {
+					$this->received['execute'] = $input;
+					return $input;
+				},
+				'permission_callback' => function ( array $input ) {
+					$this->received['permission'] = $input;
+					return true;
+				},
+			)
+		);
+	}
+
+	/**
+	 * Boolean flag values that the schema accepts, with the boolean each one means.
+	 *
+	 * @return array<string, array{0: bool|string|int, 1: bool}>
+	 */
+	public function provider_boolean_flags(): array {
+		return array(
+			'boolean false' => array( false, false ),
+			'string false'  => array( 'false', false ),
+			'string zero'   => array( '0', false ),
+			'integer zero'  => array( 0, false ),
+			'boolean true'  => array( true, true ),
+			'string true'   => array( 'true', true ),
+			'string one'    => array( '1', true ),
+			'integer one'   => array( 1, true ),
+		);
+	}
+
+	/**
+	 * @testdox Should pass a boolean field to the callbacks as a boolean.
+	 * @dataProvider provider_boolean_flags
+	 *
+	 * @param bool|string|int $flag     Flag value the client sends.
+	 * @param bool            $expected Boolean the callbacks get.
+	 */
+	public function test_converts_a_boolean_field( $flag, bool $expected ): void {
+		$result = $this->get_ability()->execute(
+			array(
+				'flag'  => $flag,
+				'items' => array( array( 'delete' => $flag ) ),
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( $expected, $this->received['permission']['flag'], 'The permission callback gets the converted flag.' );
+		$this->assertSame( $expected, $this->received['execute']['flag'], 'The execute callback gets the converted flag.' );
+		$this->assertSame( $expected, $this->received['execute']['items'][0]['delete'], 'A nested flag is converted too.' );
+	}
+
+	/**
+	 * @testdox Should pass an integer field that arrives as a numeric string as an integer.
+	 */
+	public function test_converts_a_numeric_string_integer(): void {
+		$result = $this->get_ability()->execute( array( 'count' => '5' ) );
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( 5, $this->received['execute']['count'] );
+	}
+
+	/**
+	 * @testdox Should refuse invalid input with the validation error and not run the callbacks.
+	 */
+	public function test_refuses_invalid_input(): void {
+		$result = $this->get_ability()->execute( array( 'flag' => 'maybe' ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+		$this->assertArrayNotHasKey( 'execute', $this->received );
+	}
+
+	/**
+	 * @testdox Should refuse input that validates but cannot be converted, and not run the callbacks.
+	 */
+	public function test_refuses_input_that_cannot_be_converted(): void {
+		$result = $this->get_ability()->execute( array( 'ids' => array( '1', '01' ) ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+		$this->assertArrayNotHasKey( 'permission', $this->received );
+		$this->assertArrayNotHasKey( 'execute', $this->received );
+	}
+
+	/**
+	 * @testdox Should pass converted input to the permission callback when check_permissions() is called directly.
+	 */
+	public function test_check_permissions_converts_the_input(): void {
+		$result = $this->get_ability()->check_permissions(
+			array(
+				'flag'  => 'false',
+				'count' => '5',
+			)
+		);
+
+		$this->assertTrue( $result );
+		$this->assertSame( false, $this->received['permission']['flag'] );
+		$this->assertSame( 5, $this->received['permission']['count'] );
+	}
+
+	/**
+	 * @testdox Should refuse invalid input in check_permissions() and not run the permission callback.
+	 */
+	public function test_check_permissions_refuses_invalid_input(): void {
+		$result = $this->get_ability()->check_permissions( array( 'flag' => 'maybe' ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+		$this->assertArrayNotHasKey( 'permission', $this->received );
+	}
+
+	/**
+	 * @testdox Should return input that is already converted unchanged.
+	 */
+	public function test_converting_twice_changes_nothing(): void {
+		$ability = $this->get_ability();
+		$once    = $ability->normalize_input(
+			array(
+				'flag'  => 'false',
+				'count' => '5',
+			)
+		);
+
+		$this->assertSame( $once, $ability->normalize_input( $once ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index 35259762df8..b4a870cc3b9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -7,6 +7,7 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\Tests\Internal\Abilities;

+use Automattic\WooCommerce\Abilities\Ability;
 use Automattic\WooCommerce\Internal\Abilities\AbilitiesCategories;
 use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrderAddNote;
@@ -1341,6 +1342,42 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
 	}

+	/**
+	 * @testdox Should add a private order note when customer_note is the string false.
+	 */
+	public function test_order_add_note_reads_string_false_as_a_private_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'          => 'Packed.',
+				'customer_note' => 'false',
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertCount(
+			0,
+			wc_get_order_notes(
+				array(
+					'order_id' => $order->get_id(),
+					'type'     => 'customer',
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should register every Core ability with the WooCommerce Ability class.
+	 */
+	public function test_core_abilities_use_the_ability_class(): void {
+		foreach ( array( 'woocommerce/products-query', 'woocommerce/order-add-note', 'woocommerce/product-update' ) as $name ) {
+			$this->assertInstanceOf( Ability::class, wp_get_ability( $name ), $name );
+		}
+	}
+
 	/**
 	 * @testdox Should reject negative entity IDs during schema validation.
 	 *
@@ -2647,6 +2684,17 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		$this->assertSame( 'trash', get_post_status( $product->get_id() ) );
 	}

+	/**
+	 * @testdox Should check the permissions of a direct call with a numeric string id or no input, as the MCP adapter sends them.
+	 */
+	public function test_check_permissions_converts_raw_input(): void {
+		$order   = \WC_Helper_Order::create_order();
+		$ability = wp_get_ability( 'woocommerce/orders-query' );
+
+		$this->assertTrue( $ability->check_permissions( array( 'id' => (string) $order->get_id() ) ), 'A numeric string id is converted.' );
+		$this->assertTrue( $ability->check_permissions( null ), 'No input gets the schema default.' );
+	}
+
 	/**
 	 * @testdox Should read string false on the attribute flags of a product as false.
 	 */
@@ -3120,7 +3168,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 	 * @testdox Should reject invalid grouped product IDs before setting children.
 	 */
 	public function test_product_create_rejects_invalid_grouped_product_ids(): void {
-		$result = wp_get_ability( 'woocommerce/product-create' )->execute_change(
+		$result = wp_get_ability( 'woocommerce/product-create' )->execute(
 			array(
 				'name'             => 'Invalid Grouped Product',
 				'type'             => 'grouped',
@@ -3129,7 +3177,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		);

 		$this->assertWPError( $result );
-		$this->assertSame( 'woocommerce_product_grouped_products_invalid', $result->get_error_code() );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
index 3bca1673393..49074b1240c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
@@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
 use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductTermsQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductsQuery;
+use Automattic\WooCommerce\Abilities\Ability;
 use Automattic\WooCommerce\Abilities\ExtensibleAbilities;

 /**
@@ -301,7 +302,7 @@ class ProductTermsQueryTest extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should parse boolean strings from older Abilities REST controllers.
+	 * @testdox Should read a boolean string on hide_empty as a boolean.
 	 * @testWith [false, 1]
 	 *           ["false", 1]
 	 *           [true, 0]
@@ -310,8 +311,9 @@ class ProductTermsQueryTest extends \WC_Unit_Test_Case {
 	 * @param int         $expected_count Expected number of empty terms.
 	 */
 	public function test_hide_empty_boolean_input( $hide_empty, int $expected_count ): void {
-		$id     = self::factory()->term->create( array( 'taxonomy' => 'product_tag' ) );
-		$result = ProductTermsQuery::execute(
+		$id      = self::factory()->term->create( array( 'taxonomy' => 'product_tag' ) );
+		$ability = new Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() );
+		$result  = $ability->execute(
 			array(
 				'taxonomy'   => 'product_tag',
 				'include'    => array( $id ),