Commit 9097f34a851 for woocommerce

commit 9097f34a8510850b7dd36f4ee351f7045ccb8a28
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Mon Nov 2 10:54:08 2026 -0300

    Run every validator and return all refusals together (#70308)

    * Run every validator and return all refusals together

    A write stopped at the first validator that refused. When several
    extensions refused the same change, the client saw one refusal per
    call. Now every validator runs, and the refusals are merged into one
    WP_Error. The REST response lists the first refusal at the top level
    and the others in additional_errors.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Prefix each validator refusal code with its namespace

    Two validators can return the same error code. WP_Error keeps one data
    array per code, so the second refusal replaced the data of the first,
    and the client saw the wrong namespace. Now each refusal gets the code
    <namespace>:<code>, with its own message and data. The code is
    prefixed for every refusal, so the client can always predict it.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Add the namespace and status to every code of a refusal

    A validator can return one WP_Error with several codes. Only the first
    code got the namespace and the 400 status. The other codes reached the
    client in additional_errors with no data. Now every code gets them, so
    the docblock of register_validator() is true for each refusal.

    The new test also checks that the other validators still run after one
    throws.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * List a crashed validator after the refusals

    The HTTP status of the response comes from the first entry of the
    combined error. When a validator that throws ran before a validator
    that refused, the client got a 500, although another extension gave a
    clear refusal. Crashes now go after the refusals, so the status is 400
    whenever a validator refused on purpose.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Give a repeated validator code a numeric suffix

    Two validators in the same namespace can return the same code. WP_Error
    keeps only the last data for one code, so the client saw the later
    refusal's data and status on both messages. Now the second refusal gets
    the code `<namespace>:<code>:2`, the third `:3`, and so on. Each refusal
    keeps its own data.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Reserve refusal codes across refusals and crashes

    The suffix check only looked at the group the entry was going into.
    A refusal and a crash in the same namespace with the same code could
    still land on one code after the merge, so the later data replaced the
    earlier one. The check now looks at both groups.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Return the field refusals and the validator refusals together

    A refused field value stopped the call before the validators ran, so an
    agent fixed one error per call. Now every field callback runs, the
    validators run after them, and one error holds all refusals with the code
    `<namespace>:<code>`. Nothing saves when any refusal exists.

    A validator then sees the object without the refused value, so it can refuse
    only because of that. Its refusal gets `unconfirmed` in its data. A validator
    that crashes on such an object is logged and not returned.

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

    * Replace the data of a refusal instead of keeping it twice

    WP_Error::add_data() moves the old data of a code to additional_data, so the
    client saw the same data twice.

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

    * Keep prefixed error codes in the ability tracking

    AbilitiesTracking recorded every `<namespace>:<code>` refusal as `other`,
    because the slug check did not allow `:`. The check now allows it, so the code
    is recorded, with `:` as `_` like every other character that normalize_value()
    replaces. A code over 64 characters still becomes `other`.

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

    * Return every rejection in run order from one error

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Record separator-only error codes as other

    Codes such as ":" pass the slug check but normalize to "none", the
    value that marks a success.

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

    * Fix the PHPCS warnings in ActionableAbility

    Rename the $namespace parameter of add_refusal() to $extension, because
    namespace is a reserved keyword, and align the assignments in with_data().

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

    ---------

    Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/wooai-run-all-validators b/plugins/woocommerce/changelog/wooai-run-all-validators
new file mode 100644
index 00000000000..95cb32cdeb0
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-run-all-validators
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Extensible abilities: run every field callback and validator on a change and return all refusals in one error.
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 0366f0f0754..0baeed3f339 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -128,12 +128,13 @@ abstract class ActionableAbility extends \WP_Ability {
 			return $changed;
 		}

-		$rejected = $this->apply_fields( $subject, $updates );
-		if ( null === $rejected && ExtensibleAbilities::is_enabled() ) {
-			$rejected = $this->validate( $subject );
+		$refused = new \WP_Error();
+		$this->apply_fields( $subject, $updates, $refused );
+		if ( ExtensibleAbilities::is_enabled() ) {
+			$this->validate( $subject, $refused );
 		}
-		if ( null !== $rejected ) {
-			return $rejected;
+		if ( $refused->has_errors() ) {
+			return $refused;
 		}

 		$saved = $this->save( $subject );
@@ -200,36 +201,61 @@ abstract class ActionableAbility extends \WP_Ability {

 	/**
 	 * Write the checked values to the object in memory with the update_callback
-	 * of each field.
+	 * of each field, also after one rejects its value. Each rejection goes into
+	 * the given errors, as add_refusal() describes.
 	 *
-	 * @param \WC_Data $subject Object to change.
-	 * @param array    $updates Pairs of update_callback and value from prepare_fields().
-	 * @return \WP_Error|null A WP_Error when a callback rejects a value or throws.
+	 * @param \WC_Data  $subject Object to change.
+	 * @param array     $updates Pairs of update_callback and value from prepare_fields().
+	 * @param \WP_Error $refused Collects the refusals.
 	 */
-	private function apply_fields( $subject, array $updates ): ?\WP_Error {
+	private function apply_fields( $subject, array $updates, \WP_Error $refused ): void {
 		foreach ( $updates as $param => list( $callback, $value ) ) {
 			$rejected = self::run_callback( $callback, array( $subject, $value ), array( 'field' => $param ) );
 			if ( null !== $rejected ) {
-				return $rejected;
+				self::add_refusal( $rejected, explode( '.', $param )[1], $refused );
 			}
 		}
-		return null;
 	}

 	/**
-	 * Run the validators of the resource on the changed object.
+	 * Run every validator of the resource on the changed object, also after a
+	 * field or another validator refuses.
 	 *
-	 * @param \WC_Data $subject Changed object.
-	 * @return \WP_Error|null A WP_Error when a validator refuses the change.
+	 * @param \WC_Data  $subject Changed object.
+	 * @param \WP_Error $refused Collects the refusals.
 	 */
-	private function validate( $subject ): ?\WP_Error {
+	private function validate( $subject, \WP_Error $refused ): void {
 		foreach ( ExtensibleAbilities::get_validators( $this->get_resource() ) as $args ) {
 			$rejected = self::run_callback( $args['callback'], array( $subject, $this ), array( 'namespace' => $args['namespace'] ) );
 			if ( null !== $rejected ) {
-				return $rejected;
+				self::add_refusal( $rejected, $args['namespace'], $refused );
+			}
+		}
+	}
+
+	/**
+	 * Add the codes of a rejection to the errors of the call. Each code becomes
+	 * `<namespace>:<code>`, so refusals with the same code keep their own data.
+	 * When the code is already taken in the same namespace, it gets a numeric
+	 * suffix, as `<namespace>:<code>:2`. The errors keep the run order, so the
+	 * first rejection gives the response its code, message and status.
+	 *
+	 * @param \WP_Error $rejected  Rejection from run_callback().
+	 * @param string    $extension Extension that owns the callback.
+	 * @param \WP_Error $refused   Collects the refusals.
+	 */
+	private static function add_refusal( \WP_Error $rejected, string $extension, \WP_Error $refused ): void {
+		foreach ( $rejected->get_error_codes() as $code ) {
+			$key = $extension . ':' . $code;
+			$n   = 1;
+			while ( in_array( $key, $refused->get_error_codes(), true ) ) {
+				$key = $extension . ':' . $code . ':' . ( ++$n );
+			}
+			foreach ( $rejected->get_error_messages( $code ) as $message ) {
+				$refused->add( $key, $message );
 			}
+			$refused->add_data( array_merge( $rejected->get_error_data( $code ), array( 'namespace' => $extension ) ), $key );
 		}
-		return null;
 	}

 	/**
@@ -333,15 +359,19 @@ abstract class ActionableAbility extends \WP_Ability {
 	}

 	/**
-	 * Add data to an error, and the 400 status when it has none.
+	 * Add data to every code of an error, and the 400 status to a code that has
+	 * none. It replaces the data of the code, because WP_Error::add_data() would
+	 * also keep the old data, and the client would see it in `additional_data`.
 	 *
 	 * @param \WP_Error            $error Error.
 	 * @param array<string, mixed> $data  Data to add.
 	 * @return \WP_Error
 	 */
 	private static function with_data( \WP_Error $error, array $data ): \WP_Error {
-		$current = $error->get_error_data();
-		$error->add_data( array_merge( array( 'status' => 400 ), is_array( $current ) ? $current : array(), $data ) );
+		foreach ( $error->get_error_codes() as $code ) {
+			$current                    = $error->get_error_data( $code );
+			$error->error_data[ $code ] = array_merge( array( 'status' => 400 ), is_array( $current ) ? $current : array(), $data );
+		}
 		return $error;
 	}
 }
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index 15b5f8a4162..9584b815dcb 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -475,9 +475,15 @@ class ExtensibleAbilities {
 	 *     @type string   $namespace Slug of the extension that owns the validator, such as `subscriptions`.
 	 *     @type callable $callback  Receives the changed object and the ability, after Core's changes and the
 	 *                               field callbacks, before the one save. It returns a WP_Error to refuse the
-	 *                               change, and then nothing is saved. The error gets the namespace in its
-	 *                               data, and the 400 status when it has no status. Its message can name the
-	 *                               ability or screen to use instead. Rules:
+	 *                               change, and then nothing is saved. The other validators still run, and
+	 *                               the client gets every refusal in one error. The client sees the code
+	 *                               prefixed with the namespace, as `<namespace>:<code>`. A code that another
+	 *                               validator in the same namespace already used gets a numeric suffix, as
+	 *                               `<namespace>:<code>:2`. The error gets the
+	 *                               namespace in its data, and the 400 status when it has no status. Its
+	 *                               message can name the ability or screen to use instead. Validators also
+	 *                               run after a field callback refuses a value, so the client gets the field
+	 *                               refusals and the validator refusals together. Rules:
 	 *                               1. It only reads. It does not change the object, save, send email or
 	 *                                  make HTTP requests.
 	 *                               2. It checks the ability name when the extension's own abilities
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php
index 06923ef2a89..3498200caec 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php
@@ -810,15 +810,19 @@ class AbilitiesTracking {
 	/**
 	 * Get the error code to record for an error.
 	 *
-	 * Codes are normally fixed slugs, but any filter can return an error whose code was built from input, so codes
-	 * that don't look like slugs are recorded as `other`.
+	 * Codes are normally fixed slugs, or slugs joined as `<namespace>:<code>`, but any filter can return an error whose
+	 * code was built from input, so other codes, and codes over 64 characters, are recorded as `other`.
 	 *
 	 * @param WP_Error $error The error.
 	 * @return string
 	 */
 	private static function get_error_code( WP_Error $error ): string {
 		$code = (string) $error->get_error_code();
-		return 1 === preg_match( '/^[A-Za-z0-9_-]{1,64}$/', $code ) ? self::normalize_value( $code ) : 'other';
+		if ( 1 !== preg_match( '/^[A-Za-z0-9_:-]{1,64}$/', $code ) ) {
+			return 'other';
+		}
+		$normalized = self::normalize_value( $code );
+		return self::NONE === $normalized ? 'other' : $normalized;
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php
index d5a75f02d63..ed8ee673402 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php
@@ -558,8 +558,15 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
 	 * @testdox Should record only slug-like error codes.
 	 * @testWith ["tracking_test_failure", "tracking_test_failure"]
 	 *           ["Tracking-Test-Failure", "tracking_test_failure"]
+	 *           ["first_ext:test_refused", "first_ext_test_refused"]
+	 *           ["first-ext:test_refused:2", "first_ext_test_refused_2"]
+	 *           ["first_ext:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "other"]
+	 *           ["first_ext:order 1234", "other"]
 	 *           ["customer@example.com", "other"]
 	 *           ["order 1234 for Jane", "other"]
+	 *           [":", "other"]
+	 *           ["::", "other"]
+	 *           ["-_", "other"]
 	 *
 	 * @param string $code     Error code returned by the ability.
 	 * @param string $expected Recorded error code.
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 68cb5b41185..f2de3cedffc 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -1338,8 +1338,8 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	public function test_an_update_callback_that_throws_saves_nothing(): void {
 		$product  = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
 		$expected = array(
-			'throw' => array( 400, 'test_thrown' ),
-			'crash' => array( 500, 'woocommerce_ability_extension_failed' ),
+			'throw' => array( 400, 'test-ext:test_thrown' ),
+			'crash' => array( 500, 'test-ext:woocommerce_ability_extension_failed' ),
 		);

 		foreach ( $expected as $value => list( $status, $code ) ) {
@@ -1676,9 +1676,10 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		);

 		$this->assertSame( 400, $response->get_status() );
-		$this->assertSame( 'test_rejected', $response->get_data()['code'] );
+		$this->assertSame( 'other_ext:test_rejected', $response->get_data()['code'] );
 		$this->assertSame( 'Rejected.', $response->get_data()['message'] );
 		$this->assertSame( 'extensions.other_ext.count', $response->get_data()['data']['field'] );
+		$this->assertSame( 'other_ext', $response->get_data()['data']['namespace'] );
 	}

 	/**
@@ -1719,7 +1720,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		);

 		$this->assertSame( 400, $response->get_status() );
-		$this->assertSame( 'test_locked', $response->get_data()['code'] );
+		$this->assertSame( 'test-ext:test_locked', $response->get_data()['code'] );
 		$this->assertSame( 'Use test-ext/order-unlock first.', $response->get_data()['message'] );
 		$this->assertSame( 'test-ext', $response->get_data()['data']['namespace'] );
 		$this->assertSame( 1, did_action( 'woocommerce_order_status_changed' ) - $changes );
@@ -1759,6 +1760,197 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		$this->assertFalse( $saved->meta_exists( '_test_color' ) );
 	}

+	/**
+	 * @testdox Should run every validator and return all refusals in one error, and save nothing.
+	 */
+	public function test_every_validator_runs_and_all_refusals_are_returned(): void {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		$this->register_validator(
+			'product',
+			'first_ext',
+			static function () {
+				return new \WP_Error( 'test_first_refused', 'First refused.' );
+			}
+		);
+		$this->register_validator(
+			'product',
+			'second_ext',
+			static function () {
+				return new \WP_Error( 'test_second_refused', 'Second refused.', array( 'status' => 409 ) );
+			}
+		);
+
+		$response = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'   => $product->get_id(),
+				'name' => 'Pencil',
+			)
+		);
+
+		$data = $response->get_data();
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'first_ext:test_first_refused', $data['code'] );
+		$this->assertSame( 'First refused.', $data['message'] );
+		$this->assertSame( 'first_ext', $data['data']['namespace'] );
+		$this->assertCount( 1, $data['additional_errors'] );
+		$this->assertSame( 'second_ext:test_second_refused', $data['additional_errors'][0]['code'] );
+		$this->assertSame( 'Second refused.', $data['additional_errors'][0]['message'] );
+		$this->assertSame( 'second_ext', $data['additional_errors'][0]['data']['namespace'] );
+		$this->assertSame( 409, $data['additional_errors'][0]['data']['status'] );
+		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	}
+
+	/**
+	 * @testdox Should keep the namespace of each refusal when validators of two namespaces return the same code.
+	 */
+	public function test_refusals_with_the_same_code_keep_their_namespace(): void {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		foreach ( array( 'first_ext', 'second_ext' ) as $extension_namespace ) {
+			$this->register_validator(
+				'product',
+				$extension_namespace,
+				static function () use ( $extension_namespace ) {
+					return new \WP_Error( 'test_refused', $extension_namespace . ' refused.' );
+				}
+			);
+		}
+
+		$data = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'   => $product->get_id(),
+				'name' => 'Pencil',
+			)
+		)->get_data();
+
+		$this->assertSame( 'first_ext:test_refused', $data['code'] );
+		$this->assertSame( 'first_ext refused.', $data['message'] );
+		$this->assertSame( 'first_ext', $data['data']['namespace'] );
+		$this->assertCount( 1, $data['additional_errors'] );
+		$this->assertSame( 'second_ext:test_refused', $data['additional_errors'][0]['code'] );
+		$this->assertSame( 'second_ext refused.', $data['additional_errors'][0]['message'] );
+		$this->assertSame( 'second_ext', $data['additional_errors'][0]['data']['namespace'] );
+		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	}
+
+	/**
+	 * @testdox Should give a repeated code in the same namespace a numeric suffix, so each refusal keeps its own data.
+	 */
+	public function test_refusals_with_the_same_code_in_one_namespace_keep_their_data(): void {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		foreach ( array( 'name', 'price' ) as $field ) {
+			$this->register_validator(
+				'product',
+				'ns',
+				static function () use ( $field ) {
+					return new \WP_Error( 'test_refused', $field . ' refused.', array( 'field' => $field ) );
+				}
+			);
+		}
+
+		$data = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'   => $product->get_id(),
+				'name' => 'Pencil',
+			)
+		)->get_data();
+
+		$this->assertSame( 'ns:test_refused', $data['code'] );
+		$this->assertSame( 'name refused.', $data['message'] );
+		$this->assertSame( 'name', $data['data']['field'] );
+		$this->assertCount( 1, $data['additional_errors'] );
+		$this->assertSame( 'ns:test_refused:2', $data['additional_errors'][0]['code'] );
+		$this->assertSame( 'price refused.', $data['additional_errors'][0]['message'] );
+		$this->assertSame( 'price', $data['additional_errors'][0]['data']['field'] );
+		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	}
+
+	/**
+	 * @testdox Should give a refusal the suffix when a crash in the same namespace already uses its code.
+	 */
+	public function test_a_crash_does_not_share_a_code_with_a_refusal_in_the_same_namespace(): void {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		$this->register_validator(
+			'product',
+			'ns',
+			static function () {
+				throw new \RuntimeException( 'Validator broke.' );
+			}
+		);
+		$this->register_validator(
+			'product',
+			'ns',
+			static function () {
+				return new \WP_Error( 'woocommerce_ability_extension_failed', 'Name refused.' );
+			}
+		);
+
+		$response = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'   => $product->get_id(),
+				'name' => 'Pencil',
+			)
+		);
+		$data     = $response->get_data();
+
+		$this->assertSame( 500, $response->get_status() );
+		$this->assertSame( 'ns:woocommerce_ability_extension_failed', $data['code'] );
+		$this->assertSame( 500, $data['data']['status'] );
+		$this->assertCount( 1, $data['additional_errors'] );
+		$this->assertSame( 'ns:woocommerce_ability_extension_failed:2', $data['additional_errors'][0]['code'] );
+		$this->assertSame( 'Name refused.', $data['additional_errors'][0]['message'] );
+		$this->assertSame( 400, $data['additional_errors'][0]['data']['status'] );
+		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	}
+
+	/**
+	 * @testdox Should run the other validators after one throws, keep the run order, and give every code of a refusal its namespace and status.
+	 */
+	public function test_validators_run_after_a_throw_and_every_code_gets_its_namespace(): void {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		$this->register_validator(
+			'product',
+			'first_ext',
+			static function () {
+				throw new \RuntimeException( 'Validator broke.' );
+			}
+		);
+		$this->register_validator(
+			'product',
+			'second_ext',
+			static function () {
+				$error = new \WP_Error( 'test_name_refused', 'Name refused.' );
+				$error->add( 'test_price_refused', 'Price refused.' );
+				return $error;
+			}
+		);
+
+		$response = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'   => $product->get_id(),
+				'name' => 'Pencil',
+			)
+		);
+		$data     = $response->get_data();
+
+		$this->assertSame( 500, $response->get_status() );
+		$this->assertSame( 'first_ext:woocommerce_ability_extension_failed', $data['code'] );
+		$this->assertSame( 'first_ext', $data['data']['namespace'] );
+		$this->assertSame( 500, $data['data']['status'] );
+		$this->assertCount( 2, $data['additional_errors'] );
+		$this->assertSame( 'second_ext:test_name_refused', $data['additional_errors'][0]['code'] );
+		$this->assertSame( 'second_ext', $data['additional_errors'][0]['data']['namespace'] );
+		$this->assertSame( 400, $data['additional_errors'][0]['data']['status'] );
+		$this->assertSame( 'second_ext:test_price_refused', $data['additional_errors'][1]['code'] );
+		$this->assertSame( 'second_ext', $data['additional_errors'][1]['data']['namespace'] );
+		$this->assertSame( 400, $data['additional_errors'][1]['data']['status'] );
+		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	}
+
 	/**
 	 * @testdox Should run a validator in an ActionableAbility of an extension, with that ability.
 	 */
@@ -1784,7 +1976,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		);

 		$this->assertSame( 400, $response->get_status() );
-		$this->assertSame( 'test_rename_refused', $response->get_data()['code'] );
+		$this->assertSame( 'other_ext:test_rename_refused', $response->get_data()['code'] );
 		$this->assertSame( 'other_ext', $response->get_data()['data']['namespace'] );
 		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
 	}
@@ -1839,7 +2031,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		);

 		$this->assertSame( 500, $field->get_status() );
-		$this->assertSame( 'woocommerce_ability_extension_failed', $field->get_data()['code'] );
+		$this->assertSame( 'test-ext:woocommerce_ability_extension_failed', $field->get_data()['code'] );
 		$this->assertSame( 'An extension failed while checking this change.', $field->get_data()['message'] );
 		$this->assertSame( 'extensions.test-ext.broken', $field->get_data()['data']['field'] );
 		$this->assertSame( 500, $validator->get_status() );
@@ -1848,6 +2040,91 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
 	}

+	/**
+	 * @testdox Should return every field refusal and the validator refusals in one error, and save nothing.
+	 */
+	public function test_field_and_validator_refusals_come_together(): void {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		$this->add_test_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				foreach ( array( 'first', 'second' ) as $field ) {
+					ExtensibleAbilities::register_field(
+						array(
+							'resource'        => 'product',
+							'namespace'       => 'test-ext',
+							'field'           => $field,
+							'schema'          => array( 'type' => 'string' ),
+							'get_callback'    => '__return_null',
+							'update_callback' => static function () use ( $field ) {
+								return new \WP_Error( 'test_bad_value', $field . ' refused.', array( 'status' => 422 ) );
+							},
+						)
+					);
+				}
+			}
+		);
+		$this->register_validator(
+			'product',
+			'other_ext',
+			static fn() => new \WP_Error( 'test_bad_size', 'Size refused.' )
+		);
+
+		$response = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'name'       => 'Pencil',
+				'extensions' => array(
+					'test-ext' => array(
+						'first'  => 'x',
+						'second' => 'y',
+					),
+				),
+			)
+		);
+
+		$data = $response->get_data();
+		$this->assertSame( 422, $response->get_status() );
+		$this->assertSame( 'test-ext:test_bad_value', $data['code'] );
+		$this->assertSame( 'extensions.test-ext.first', $data['data']['field'] );
+		$this->assertSame( 'test-ext:test_bad_value:2', $data['additional_errors'][0]['code'] );
+		$this->assertSame( 'extensions.test-ext.second', $data['additional_errors'][0]['data']['field'] );
+		$this->assertSame( 'other_ext:test_bad_size', $data['additional_errors'][1]['code'] );
+		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	}
+
+	/**
+	 * @testdox Should show the data of a refusal once, with no additional_data.
+	 */
+	public function test_refusal_data_is_not_repeated(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$this->register_validator(
+			'product',
+			'test-ext',
+			static function () {
+				$error = new \WP_Error( 'test_refused', 'First message.', array( 'status' => 409 ) );
+				$error->add( 'test_refused', 'Second message.' );
+				return $error;
+			}
+		);
+
+		$response = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'   => $product->get_id(),
+				'name' => 'Pencil',
+			)
+		);
+
+		$data = $response->get_data();
+		$this->assertSame( 409, $response->get_status() );
+		$this->assertSame( 'test-ext', $data['data']['namespace'] );
+		$this->assertArrayNotHasKey( 'additional_data', $data );
+		$this->assertSame( 'Second message.', $data['additional_errors'][0]['message'] );
+		$this->assertArrayNotHasKey( 'additional_data', $data['additional_errors'][0] );
+	}
+
 	/**
 	 * @testdox Should not run validators with the feature off.
 	 */