Commit 789c94805c0 for woocommerce

commit 789c94805c0a3888cab0e3e1f6242d2533d22319
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Fri Nov 6 11:25:32 2026 -0300

    Report all invalid values of a product or order update in one error message (#70709)

    * Return every ability refusal of a write in one message

    A product write stopped at the first refused value, and a Core refusal
    skipped the extension field callbacks and validators. The MCP adapter
    also passes on only the first message of an error, so an agent fixed one
    refused value per retry.

    The product setter loop now keeps going after a refused value. The
    extension field callbacks and validators of the primary object still run
    after a Core refusal, and the first message of the error joins every
    refusal message.

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

    * Return every address and line refusal of an order update together

    The order update stopped at the first bad address field or line. It now
    checks every address field and line, and returns all refusals in one
    error. A repeated refusal shows once in the joined message.

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

    * Keep the saved address fields that an order update refuses

    Validators run after a refused address now, so set_address() checks the
    sent values first and sets only the fields that pass. A bad country keeps
    the saved country, state and postcode, because the other two checks
    depend on it.

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

    * Format the postcode of an order update only when it is sent

    An update that sent other address fields reformatted the saved postcode,
    although fields that are not sent keep their value.

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

    * Check that the city-only update saves in the postcode test

    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/wooai-core-refusals-in-one-message b/plugins/woocommerce/changelog/wooai-core-refusals-in-one-message
new file mode 100644
index 00000000000..67738cf2d2f
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-core-refusals-in-one-message
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Ability errors now list every refused value of a product write in one message, so AI agents can fix them all in one retry.
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 3f461b77963..0049fd33518 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -90,7 +90,7 @@ abstract class ActionableAbility extends Ability {
 	 *
 	 * @param \WC_Data $subject Primary object to change.
 	 * @param array    $input   Ability input.
-	 * @return null|\WP_Error|array<int, array{0: \WC_Data, 1: array, 2?: string}> A WP_Error rejects the change, and nothing is saved. An array lists the secondary objects.
+	 * @return null|\WP_Error|array<int, array{0: \WC_Data, 1: array, 2?: string}> A WP_Error rejects the change, and nothing is saved. The field writes and the validators of the primary object still run, so the client gets their refusals in the same error. An array lists the secondary objects.
 	 */
 	abstract protected function change( $subject, array $input );

@@ -209,13 +209,16 @@ abstract class ActionableAbility extends Ability {
 			return $updates;
 		}

+		$refused = new \WP_Error();
 		try {
 			$changed = $this->change( $subject, $input );
 		} catch ( \WC_Data_Exception $e ) {
-			return new \WP_Error( $e->getErrorCode(), $e->getMessage(), $e->getErrorData() );
+			$changed = new \WP_Error( $e->getErrorCode(), $e->getMessage(), $e->getErrorData() );
 		}
-		if ( is_wp_error( $changed ) ) {
-			return $changed;
+		$core_refused = is_wp_error( $changed );
+		if ( $core_refused ) {
+			$refused->merge_from( $changed );
+			$changed = null;
 		}

 		$objects = array( array( $subject, $updates, $this->get_resource() ) );
@@ -252,12 +255,11 @@ abstract class ActionableAbility extends Ability {
 			$objects[] = array( $secondary[0], $fields, $resource_name );
 		}

-		$refused = new \WP_Error();
 		foreach ( $objects as list( $object, $fields ) ) {
 			$this->apply_fields( $object, $fields, $refused );
 		}

-		$deleting = $this->get_objects_to_delete();
+		$deleting = $core_refused ? array() : $this->get_objects_to_delete();
 		$staged   = array_column( $objects, 0 );
 		$to_check = $objects;
 		foreach ( $deleting as $object ) {
@@ -279,7 +281,7 @@ abstract class ActionableAbility extends Ability {
 			);
 		}
 		if ( $refused->has_errors() ) {
-			return $refused;
+			return self::join_refusals( $refused );
 		}

 		try {
@@ -599,7 +601,8 @@ abstract class ActionableAbility extends Ability {
 	 * `<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.
+	 * first rejection gives the response its code and status, and
+	 * join_refusals() puts every message into the first one.
 	 *
 	 * @param \WP_Error $rejected  Rejection from run_callback().
 	 * @param string    $extension Extension that owns the callback.
@@ -619,6 +622,23 @@ abstract class ActionableAbility extends Ability {
 		}
 	}

+	/**
+	 * Put every refusal message into the first message, once each, because an
+	 * MCP client shows only the first message of an error. The other codes and
+	 * messages stay as they are for the clients that read all of them.
+	 *
+	 * @param \WP_Error $refused Refusals in run order.
+	 * @return \WP_Error
+	 */
+	private static function join_refusals( \WP_Error $refused ): \WP_Error {
+		$messages = $refused->get_error_messages();
+		if ( count( $messages ) > 1 ) {
+			$refused->errors[ $refused->get_error_code() ][0] = implode( ' ', array_unique( $messages ) );
+		}
+
+		return $refused;
+	}
+
 	/**
 	 * Run an extension callback. A WP_Error that it returns, or an exception
 	 * that it throws, becomes an error with the given data. An exception is
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index 439a1b57163..28d853edacc 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -160,7 +160,8 @@ class ExtensibleAbilities {
 	 *                                  null. Required with `set_callback`.
 	 *     @type callable $set_callback Optional. Receives the object and the value, and changes the object
 	 *                                  in memory, when an ability creates or updates the object. It returns a
-	 *                                  WP_Error to reject the value, and then nothing is saved. A field without
+	 *                                  WP_Error to reject the value, and then nothing is saved. It also runs
+	 *                                  after Core refuses another value of the primary object. A field without
 	 *                                  it cannot be written. Required with `input_schema`. Rules:
 	 *                                  1. It only sets values in memory. It does not save, send email
 	 *                                     or make HTTP requests.
@@ -590,8 +591,10 @@ class ExtensibleAbilities {
 	 *                               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:
+	 *                               run after a field callback or Core refuses a value, so the client gets every
+	 *                               refusal together, with all messages in the first one. After a Core refusal
+	 *                               they run on the primary object only, which keeps the values Core refused at
+	 *                               their old values. 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/Domain/OrderUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdate.php
index 5b725172c07..b0e6b4eec1a 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdate.php
@@ -136,7 +136,8 @@ class OrderUpdate extends ActionableAbility {
 	}

 	/**
-	 * Change the order in memory.
+	 * Change the order in memory. A refused address or line does not stop the
+	 * others, and every refusal comes back in one error.
 	 *
 	 * @param \WC_Order $subject Order.
 	 * @param array     $input   Ability input.
@@ -149,10 +150,11 @@ class OrderUpdate extends ActionableAbility {
 		$this->recalculate_after_save = null;
 		$this->products_to_add        = array();

+		$refused = new \WP_Error();
 		foreach ( array( 'billing', 'shipping' ) as $address_type ) {
 			$error = isset( $input[ $address_type ] ) ? self::set_address( $subject, $address_type, $input[ $address_type ] ) : null;
 			if ( is_wp_error( $error ) ) {
-				return $error;
+				$refused->merge_from( $error );
 			}
 		}

@@ -161,11 +163,11 @@ class OrderUpdate extends ActionableAbility {
 		}

 		if ( empty( $input['remove_items'] ) && empty( $input['shipping_lines'] ) && empty( $input['line_items'] ) ) {
-			return null;
+			return $refused->has_errors() ? $refused : null;
 		}

 		if ( ! $subject->is_editable() ) {
-			return new \WP_Error(
+			$refused->add(
 				'woocommerce_ability_order_not_editable',
 				sprintf(
 					/* translators: %s: order status slug. */
@@ -174,9 +176,15 @@ class OrderUpdate extends ActionableAbility {
 				),
 				array( 'status' => 400 )
 			);
+			return $refused;
+		}
+
+		$error = $this->change_lines( $subject, $input );
+		if ( is_wp_error( $error ) ) {
+			$refused->merge_from( $error );
 		}

-		return $this->change_lines( $subject, $input );
+		return $refused->has_errors() ? $refused : null;
 	}

 	/**
@@ -262,7 +270,9 @@ class OrderUpdate extends ActionableAbility {
 	}

 	/**
-	 * Set the sent fields of an address, then check the resulting address.
+	 * Check the sent fields of an address against the order, then set the fields
+	 * that pass. A bad country keeps the saved country, state and postcode, because
+	 * the state and postcode checks depend on the country.
 	 *
 	 * @param \WC_Order $order   Order.
 	 * @param string    $type    Address type: billing or shipping.
@@ -270,56 +280,70 @@ class OrderUpdate extends ActionableAbility {
 	 * @return null|\WP_Error
 	 */
 	private static function set_address( \WC_Order $order, string $type, array $address ): ?\WP_Error {
+		$address = wc_clean( $address );
+		$refused = new \WP_Error();
 		if ( ! empty( $address['email'] ) && ! is_email( $address['email'] ) ) {
-			return self::address_error(
-				$type,
-				'email',
-				__( 'The billing email is not a valid email address. Send a valid address.', 'woocommerce' )
+			$refused->merge_from(
+				self::address_error(
+					$type,
+					'email',
+					__( 'The billing email is not a valid email address. Send a valid address.', 'woocommerce' )
+				)
 			);
+			unset( $address['email'] );
 		}

-		foreach ( $address as $key => $value ) {
-			$order->{"set_{$type}_{$key}"}( wc_clean( $value ) );
-		}
-
-		$country  = $order->{"get_{$type}_country"}();
-		$state    = $order->{"get_{$type}_state"}();
-		$postcode = $order->{"get_{$type}_postcode"}();
+		$country  = $address['country'] ?? $order->{"get_{$type}_country"}();
+		$state    = $address['state'] ?? $order->{"get_{$type}_state"}();
+		$postcode = $address['postcode'] ?? $order->{"get_{$type}_postcode"}();

 		if ( '' !== $country && ! array_key_exists( $country, WC()->countries->get_countries() ) ) {
-			return self::address_error(
-				$type,
-				'country',
-				/* translators: %s: country sent by the client. */
-				sprintf( __( '"%s" is not a valid country. Send an ISO 3166-1 alpha-2 country code, such as PT.', 'woocommerce' ), $country )
-			);
-		}
-
-		$states = WC()->countries->get_states( $country );
-
-		if ( '' !== $state && ! empty( $states ) && ! array_key_exists( $state, $states ) ) {
-			return self::address_error(
-				$type,
-				'state',
-				/* translators: 1: state sent by the client or kept from the order, 2: country code, 3: comma-separated state codes. */
-				sprintf( __( '"%1$s" is not a state of %2$s. Send the state with one of these codes: %3$s.', 'woocommerce' ), $state, $country, implode( ', ', array_keys( $states ) ) )
-			);
-		}
-
-		if ( '' !== $postcode && '' !== $country ) {
-			if ( ! \WC_Validation::is_postcode( $postcode, $country ) ) {
-				return self::address_error(
+			$refused->merge_from(
+				self::address_error(
 					$type,
-					'postcode',
-					/* translators: 1: postcode sent by the client or kept from the order, 2: country code. */
-					sprintf( __( '"%1$s" is not a valid postcode for %2$s. Send a postcode in the format of that country.', 'woocommerce' ), $postcode, $country )
+					'country',
+					/* translators: %s: country sent by the client. */
+					sprintf( __( '"%s" is not a valid country. Send an ISO 3166-1 alpha-2 country code, such as PT.', 'woocommerce' ), $country )
+				)
+			);
+			unset( $address['country'], $address['state'], $address['postcode'] );
+		} else {
+			$states = WC()->countries->get_states( $country );
+
+			if ( '' !== $state && ! empty( $states ) && ! array_key_exists( $state, $states ) ) {
+				$refused->merge_from(
+					self::address_error(
+						$type,
+						'state',
+						/* translators: 1: state sent by the client or kept from the order, 2: country code, 3: comma-separated state codes. */
+						sprintf( __( '"%1$s" is not a state of %2$s. Send the state with one of these codes: %3$s.', 'woocommerce' ), $state, $country, implode( ', ', array_keys( $states ) ) )
+					)
 				);
+				unset( $address['state'] );
 			}

-			$order->{"set_{$type}_postcode"}( wc_format_postcode( $postcode, $country ) );
+			if ( '' !== $postcode && '' !== $country ) {
+				if ( ! \WC_Validation::is_postcode( $postcode, $country ) ) {
+					$refused->merge_from(
+						self::address_error(
+							$type,
+							'postcode',
+							/* translators: 1: postcode sent by the client or kept from the order, 2: country code. */
+							sprintf( __( '"%1$s" is not a valid postcode for %2$s. Send a postcode in the format of that country.', 'woocommerce' ), $postcode, $country )
+						)
+					);
+					unset( $address['postcode'] );
+				} elseif ( isset( $address['postcode'] ) ) {
+					$address['postcode'] = wc_format_postcode( $postcode, $country );
+				}
+			}
 		}

-		return null;
+		foreach ( $address as $key => $value ) {
+			$order->{"set_{$type}_{$key}"}( $value );
+		}
+
+		return $refused->has_errors() ? $refused : null;
 	}

 	/**
@@ -337,7 +361,8 @@ class OrderUpdate extends ActionableAbility {
 	/**
 	 * Remove, change and add lines in memory, then recalculate the totals
 	 * without saving. Coupons apply again after a product is added, because
-	 * they were applied before the new line existed.
+	 * they were applied before the new line existed. Every refused line comes
+	 * back in one error, and then nothing is recalculated.
 	 *
 	 * @param \WC_Order $order Order.
 	 * @param array     $input Ability input.
@@ -345,11 +370,13 @@ class OrderUpdate extends ActionableAbility {
 	 * @throws \WC_Data_Exception When the order class has not adopted a stage method.
 	 */
 	private function change_lines( \WC_Order $order, array $input ): ?\WP_Error {
+		$refused = new \WP_Error();
 		foreach ( $input['remove_items'] ?? array() as $item_id ) {
 			$item = $order->get_item( $item_id, false );

 			if ( ! $item instanceof \WC_Order_Item_Product && ! $item instanceof \WC_Order_Item_Shipping ) {
-				return self::line_not_found( $item_id );
+				$refused->merge_from( self::line_not_found( $item_id ) );
+				continue;
 			}

 			if ( $item instanceof \WC_Order_Item_Product ) {
@@ -362,7 +389,7 @@ class OrderUpdate extends ActionableAbility {
 		foreach ( $input['shipping_lines'] ?? array() as $line ) {
 			$error = self::set_shipping_line( $order, $line );
 			if ( is_wp_error( $error ) ) {
-				return $error;
+				$refused->merge_from( $error );
 			}
 		}

@@ -370,21 +397,27 @@ class OrderUpdate extends ActionableAbility {

 		foreach ( $input['line_items'] ?? array() as $line ) {
 			if ( empty( $line['id'] ) === empty( $line['product_id'] ) ) {
-				return new \WP_Error(
+				$refused->add(
 					'woocommerce_ability_order_line_item_target_invalid',
 					__( 'Each entry in line_items needs exactly one target. Send id to change a line, or product_id to add a line.', 'woocommerce' ),
 					array( 'status' => 400 )
 				);
+				continue;
 			}

 			$error = empty( $line['id'] ) ? $this->add_product_line( $order, $line ) : $this->change_product_line( $order, $line );
 			if ( is_wp_error( $error ) ) {
-				return $error;
+				$refused->merge_from( $error );
+				continue;
 			}

 			$product_added = $product_added || empty( $line['id'] );
 		}

+		if ( $refused->has_errors() ) {
+			return $refused;
+		}
+
 		$recalculate_coupons = $product_added && ! empty( $order->get_items( 'coupon' ) );

 		if ( ! ExtensibleAbilities::is_enabled() ) {
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index c9e6012f8c4..17f684e7a90 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -693,7 +693,7 @@ trait ProductAbilityTrait {
 	}

 	/**
-	 * Set supported product properties from ability input.
+	 * Set supported product properties from ability input. A refused value does not stop the other values, and every refusal comes back in one error.
 	 *
 	 * @param \WC_Product $product        Product object.
 	 * @param array       $input          Ability input.
@@ -701,9 +701,10 @@ trait ProductAbilityTrait {
 	 * @return null|\WP_Error
 	 */
 	protected static function set_product_props_from_input( \WC_Product $product, array $input, array $product_config ) {
+		$refused          = new \WP_Error();
 		$validation_error = self::validate_product_fields_for_config( $product, $input, $product_config );
 		if ( is_wp_error( $validation_error ) ) {
-			return $validation_error;
+			$refused->merge_from( $validation_error );
 		}

 		$setters = array(
@@ -750,10 +751,15 @@ trait ProductAbilityTrait {
 				? self::prepare_product_attributes( $product, $input[ $field ] )
 				: self::prepare_product_field_value( $field, $input[ $field ] );
 			if ( is_wp_error( $prepared_value ) ) {
-				return $prepared_value;
+				$refused->merge_from( $prepared_value );
+				continue;
 			}

-			$product->{$setter}( $prepared_value );
+			try {
+				$product->{$setter}( $prepared_value );
+			} catch ( \WC_Data_Exception $exception ) {
+				$refused->merge_from( self::get_product_data_exception_error( $exception ) );
+			}
 		}

 		foreach ( array( 'images', 'dimensions' ) as $field ) {
@@ -763,7 +769,8 @@ trait ProductAbilityTrait {

 			$prepared_value = self::prepare_product_field_value( $field, $input[ $field ] );
 			if ( is_wp_error( $prepared_value ) ) {
-				return $prepared_value;
+				$refused->merge_from( $prepared_value );
+				continue;
 			}

 			if ( 'images' === $field ) {
@@ -773,7 +780,7 @@ trait ProductAbilityTrait {
 			}
 		}

-		return null;
+		return $refused->has_errors() ? $refused : null;
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index 1d31c6b2e7e..5c9baffb33b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -4166,6 +4166,77 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		$this->assertSame( 'Before', wc_get_order( $order->get_id() )->get_shipping_city() );
 	}

+	/**
+	 * @testdox Should return every address and line refusal of an order update in one message, and save nothing.
+	 */
+	public function test_order_update_returns_every_refusal_in_one_message(): void {
+		$order = $this->create_order_for_update( 'pending' );
+		$order->set_shipping_country( 'US' );
+		$order->set_shipping_city( 'Before' );
+		$order->save();
+
+		$result = wp_get_ability( 'woocommerce/order-update' )->execute(
+			array(
+				'id'           => $order->get_id(),
+				'billing'      => array( 'email' => 'not-an-email' ),
+				'shipping'     => array(
+					'city'     => 'After',
+					'postcode' => 'not a zip',
+				),
+				'remove_items' => array( 999999 ),
+				'line_items'   => array( array( 'quantity' => 2 ), array( 'quantity' => 3 ) ),
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame(
+			array(
+				'woocommerce_ability_order_billing_email_invalid',
+				'woocommerce_ability_order_shipping_postcode_invalid',
+				'woocommerce_ability_order_line_not_found',
+				'woocommerce_ability_order_line_item_target_invalid',
+			),
+			$result->get_error_codes()
+		);
+		$message = $result->get_error_message();
+		foreach ( array( 'valid address', 'format of that country', 'include_line_items', 'product_id' ) as $hint ) {
+			$this->assertStringContainsString( $hint, $message );
+		}
+		$this->assertSame( 1, substr_count( $message, 'Each entry in line_items' ), 'A repeated refusal shows once in the message.' );
+		$this->assertSame( 'Before', wc_get_order( $order->get_id() )->get_shipping_city() );
+	}
+
+	/**
+	 * @testdox Should format a sent postcode, and keep a saved postcode that the update does not send.
+	 */
+	public function test_order_update_formats_only_a_sent_postcode(): void {
+		$order = $this->create_order_for_update( 'pending' );
+		$order->set_shipping_country( 'GB' );
+		$order->set_shipping_postcode( 'sw1a1aa' );
+		$order->save();
+		$ability = wp_get_ability( 'woocommerce/order-update' );
+
+		$result = $ability->execute(
+			array(
+				'id'       => $order->get_id(),
+				'shipping' => array( 'city' => 'London' ),
+			)
+		);
+		$this->assertNotWPError( $result );
+		$saved = wc_get_order( $order->get_id() );
+		$kept  = $saved->get_shipping_postcode();
+		$this->assertSame( 'London', $saved->get_shipping_city() );
+		$ability->execute(
+			array(
+				'id'       => $order->get_id(),
+				'shipping' => array( 'postcode' => 'ec1a1bb' ),
+			)
+		);
+
+		$this->assertSame( 'sw1a1aa', $kept );
+		$this->assertSame( 'EC1A 1BB', wc_get_order( $order->get_id() )->get_shipping_postcode() );
+	}
+
 	/**
 	 * Invalid inputs for the order update ability.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 9e9c6704053..5445589335b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -4333,6 +4333,64 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		$this->assertSame( 'high', $saved->get_meta( '_test_priority' ) );
 	}

+	/**
+	 * @testdox Should show an order validator the saved address fields that Core refuses, and save nothing.
+	 */
+	public function test_an_order_validator_sees_the_saved_values_of_refused_address_fields(): void {
+		$seen = array();
+		$this->register_validator(
+			'order',
+			'test-ext',
+			static function ( \WC_Order $order ) use ( &$seen ) {
+				$seen[] = array( $order->get_shipping_country(), $order->get_shipping_state(), $order->get_shipping_postcode(), $order->get_shipping_city() );
+				return null;
+			}
+		);
+		$this->reset_registries();
+		AbilitiesLoader::init_extensions();
+		$order = \WC_Helper_Order::create_order();
+		$order->set_shipping_country( 'US' );
+		$order->set_shipping_state( 'CA' );
+		$order->set_shipping_postcode( '94110' );
+		$order->set_shipping_city( 'Before' );
+		$order->save();
+
+		$bad_country  = $this->run_ability_response(
+			'woocommerce/order-update',
+			array(
+				'id'       => $order->get_id(),
+				'shipping' => array(
+					'country'  => 'XX',
+					'state'    => 'ZZ',
+					'postcode' => 'nope',
+					'city'     => 'After',
+				),
+			)
+		);
+		$bad_postcode = $this->run_ability_response(
+			'woocommerce/order-update',
+			array(
+				'id'       => $order->get_id(),
+				'shipping' => array(
+					'postcode' => 'not a zip',
+					'city'     => 'After',
+				),
+			)
+		);
+
+		$this->assertSame( 400, $bad_country->get_status() );
+		$this->assertSame( 400, $bad_postcode->get_status() );
+		$this->assertSame(
+			array(
+				array( 'US', 'CA', '94110', 'After' ),
+				array( 'US', 'CA', '94110', 'After' ),
+			),
+			$seen
+		);
+		wp_cache_flush();
+		$this->assertSame( 'Before', wc_get_order( $order->get_id() )->get_shipping_city() );
+	}
+
 	/**
 	 * @testdox Should recalculate with calculate_totals() after the save when the feature is off.
 	 */
@@ -5021,7 +5079,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		$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 refused. Second 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'] );
@@ -5055,7 +5113,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		)->get_data();

 		$this->assertSame( 'first_ext:test_refused', $data['code'] );
-		$this->assertSame( 'first_ext refused.', $data['message'] );
+		$this->assertSame( 'first_ext refused. second_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'] );
@@ -5088,7 +5146,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		)->get_data();

 		$this->assertSame( 'ns:test_refused', $data['code'] );
-		$this->assertSame( 'name refused.', $data['message'] );
+		$this->assertSame( 'name refused. price 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'] );
@@ -5326,6 +5384,62 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
 	}

+	/**
+	 * @testdox Should return every Core refusal with the field and validator refusals in one message, and save nothing.
+	 */
+	public function test_core_refusals_come_with_field_and_validator_refusals(): void {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		\WC_Helper_Product::create_simple_product( true, array( 'sku' => 'TAKEN' ) );
+		$this->add_test_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				ExtensibleAbilities::register_field(
+					array(
+						'resource'      => 'product',
+						'namespace'     => 'test-ext',
+						'field'         => 'note',
+						'output_schema' => array( 'type' => 'string' ),
+						'input_schema'  => array( 'type' => 'string' ),
+						'get_callback'  => '__return_null',
+						'set_callback'  => static fn() => new \WP_Error( 'test_bad_note', 'Note refused.' ),
+					)
+				);
+			}
+		);
+		$seen_name = null;
+		$this->register_validator(
+			'product',
+			'other_ext',
+			static function ( \WC_Product $subject ) use ( &$seen_name ) {
+				$seen_name = $subject->get_name();
+				return new \WP_Error( 'test_refused', 'Validator refused.' );
+			}
+		);
+
+		$response = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'           => $product->get_id(),
+				'name'         => 'Pencil',
+				'sku'          => 'TAKEN',
+				'external_url' => 'https://example.com',
+				'extensions'   => array( 'test-ext' => array( 'note' => 'x' ) ),
+			)
+		);
+
+		$data = $response->get_data();
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame(
+			array( 'woocommerce_product_field_unsupported', 'product_invalid_sku', 'test-ext:test_bad_note', 'other_ext:test_refused' ),
+			array_merge( array( $data['code'] ), array_column( $data['additional_errors'], 'code' ) ),
+			'Core refusals come first, then the field and validator refusals, in run order.'
+		);
+		$this->assertStringStartsWith( 'The simple product type does not take external_url.', $data['message'] );
+		$this->assertStringEndsWith( 'Note refused. Validator refused.', $data['message'], 'The first message names every refusal, because MCP clients show only that one.' );
+		$this->assertSame( 'Pencil', $seen_name, 'The validator sees the values that Core accepted.' );
+		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	}
+
 	/**
 	 * @testdox Should show the data of a refusal once, with no additional_data.
 	 */