Commit c9da0f786a1 for woocommerce

commit c9da0f786a1514fb94193d2c8fbf05ba7968dfdc
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Tue Nov 3 15:28:06 2026 -0300

    Send empty extension objects as {} and return amounts as strings (#70445)

    * Send empty extension objects as {} and return amounts as strings

    An extension field sends an empty array for an `object` as `{}`, at any
    depth. A get_callback value that does not match the output schema fails
    the call with woocommerce_ability_field_output_invalid, and a callback
    that throws fails it with woocommerce_ability_extension_failed. The
    output schema needs one type other than null. Product and order amounts
    are returned as strings. An error after the save names the saved object.

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

    * Name the field error builders with an _error suffix

    Rename field_output_invalid() and field_callback_failed() to match
    saved_write_error(). Report the new output check with version 11.4.0.

    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-1628-extension-field-json-rules b/plugins/woocommerce/changelog/wooai-1628-extension-field-json-rules
new file mode 100644
index 00000000000..0270e9a180a
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1628-extension-field-json-rules
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Send an empty extension field object as `{}`, and return Core ability amount fields as strings to match their output schema. An extension field value that does not match its schema, or a field callback that throws, now fails the ability call with a named error instead of being left out.
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 021b781b597..3dd1da798b6 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -14,8 +14,11 @@ defined( 'ABSPATH' ) || exit;
  * a subclass as the `ability_class`. The execute callback loads the object,
  * checks the `extensions` input against the field input schemas, changes the object
  * in memory, writes the fields with their `set_callback`, runs the validators
- * that ExtensibleAbilities::register_validator() adds, and then saves it. A
- * rejection from any step saves nothing.
+ * that ExtensibleAbilities::register_validator() adds, saves it, and then
+ * prepares the response. A rejection before the save saves nothing. When
+ * prepare_response() returns a WP_Error, the change is already saved, so the
+ * error message names the saved object, and the error data gets `saved` set
+ * to true and the `id` of the object.
  *
  * The input schema gets the `extensions` property of the resource, so the
  * ability accepts the fields with no extra code.
@@ -70,7 +73,7 @@ abstract class ActionableAbility extends \WP_Ability {
 	 * @since 11.3.0
 	 *
 	 * @param \WC_Data $subject Saved object.
-	 * @return mixed
+	 * @return mixed|\WP_Error A WP_Error fails the call, but the change stays saved.
 	 */
 	abstract protected function prepare_response( $subject );

@@ -148,7 +151,12 @@ abstract class ActionableAbility extends \WP_Ability {
 			return $saved;
 		}

-		return $this->prepare_response( $subject );
+		$response = $this->prepare_response( $subject );
+		if ( is_wp_error( $response ) ) {
+			return ExtensibleAbilities::saved_write_error( $response, $this->get_resource(), $subject->get_id() );
+		}
+
+		return $response;
 	}

 	/**
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index a50ba416cbf..26878c0c66a 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -54,13 +54,6 @@ class ExtensibleAbilities {
 	 */
 	private static array $validators = array();

-	/**
-	 * Fields already reported for a value that does not match the schema, keyed by resource, namespace and field.
-	 *
-	 * @var array<string, bool>
-	 */
-	private static array $reported = array();
-
 	/**
 	 * Whether the store enabled the experimental feature.
 	 *
@@ -127,7 +120,8 @@ class ExtensibleAbilities {
 	 *     @type string   $namespace    Slug of the extension that owns the field, such as `subscriptions`.
 	 *     @type string   $field        Field name inside the namespace, such as `trial_length`.
 	 *     @type array    $output_schema Optional. JSON schema of the value that `get_callback` returns. Required
-	 *                                  with `get_callback`. It needs a `type`. Core publishes it as declared. Its
+	 *                                  with `get_callback`. It needs one `type` other than null, because clients
+	 *                                  expect one type for each field. Core publishes it as declared. Its
 	 *                                  `title` is the label clients show.
 	 *     @type string[] $object_types Optional. The object types the field applies to, as `get_type()` of the
 	 *                                  object returns them, such as `simple` and `subscription` for a product or
@@ -137,9 +131,14 @@ class ExtensibleAbilities {
 	 *     @type callable $get_callback Optional. Receives the object and returns the value. A field without it
 	 *                                  is not in the output. Required with `output_schema`. Return null when the
 	 *                                  object has no value, and the field is left out with no error. Any other
-	 *                                  value that `output_schema` does not allow is left out and logged. It reads
-	 *                                  the object it is given, not the database, because an ability can format
-	 *                                  an object before it is saved.
+	 *                                  value that `output_schema` does not allow fails the call with
+	 *                                  `woocommerce_ability_field_output_invalid`. A callback that throws fails
+	 *                                  the call with `woocommerce_ability_extension_failed`. It reads the object
+	 *                                  it is given, not the database, because an ability can format an object
+	 *                                  before it is saved. An empty array for an `object` is sent as `{}`. The
+	 *                                  callback must not return a list for an `object` or a `date-time` without
+	 *                                  a UTC offset: the schema check accepts them, and a strict client refuses
+	 *                                  the whole result.
 	 *     @type array    $input_schema Optional. JSON schema of the value that `set_callback` receives. It needs
 	 *                                  a `type`. Core publishes it as declared. A value can be null only where it
 	 *                                  declares null. Required with `set_callback`.
@@ -202,6 +201,10 @@ class ExtensibleAbilities {
 				wc_doing_it_wrong( __METHOD__, 'The "output_schema" argument must be an array with a "type".', '11.4.0' );
 				return;
 			}
+			if ( ! is_string( $args['output_schema']['type'] ) || 'null' === $args['output_schema']['type'] ) {
+				wc_doing_it_wrong( __METHOD__, 'The "type" of the "output_schema" argument must be one type name other than "null". Return null to leave the field out.', '11.4.0' );
+				return;
+			}
 		}
 		if ( $writable ) {
 			if ( ! is_callable( $args['set_callback'] ?? null ) ) {
@@ -559,8 +562,11 @@ class ExtensibleAbilities {
 	/**
 	 * Add the field values of an object to its formatted output, under
 	 * `extensions.<namespace>.<field>`. A value is cast to its schema type, so
-	 * `'12'` becomes `12` for an integer field. A field that throws, returns null
-	 * or returns a value that its schema does not allow is left out. A namespace with no
+	 * `'12'` becomes `12` for an integer field. A field that returns null is
+	 * left out. Any other value that its output schema
+	 * does not allow returns a `woocommerce_ability_field_output_invalid` error,
+	 * and a get_callback that throws returns a `woocommerce_ability_extension_failed`
+	 * error. The ability returns the error. A namespace with no
 	 * values is left out, and `extensions` is left out when no namespace has
 	 * values or the feature is off.
 	 *
@@ -569,9 +575,9 @@ class ExtensibleAbilities {
 	 * @param array<string, mixed> $output   Formatted object.
 	 * @param string               $resource_name Resource.
 	 * @param object               $subject  Object to read.
-	 * @return array<string, mixed>
+	 * @return array<string, mixed>|\WP_Error
 	 */
-	public static function add_fields_to_object( array $output, string $resource_name, $subject ): array {
+	public static function add_fields_to_object( array $output, string $resource_name, $subject ) {
 		if ( empty( self::$fields[ $resource_name ] ) || ! self::is_enabled() ) {
 			return $output;
 		}
@@ -585,19 +591,19 @@ class ExtensibleAbilities {
 				try {
 					$value = call_user_func( $args['get_callback'], $subject );
 				} catch ( \Throwable $e ) {
-					wc_get_logger()->error(
-						sprintf( 'Ability field "%s.%s" of "%s" failed: %s', $namespace, $field, $resource_name, $e->getMessage() ),
-						array( 'source' => 'extensible-abilities' )
-					);
-					continue;
+					return self::field_callback_failed_error( $args, $e );
 				}
-				if ( null === $value || ! self::matches_schema( $args, $value ) ) {
+				if ( null === $value ) {
 					continue;
 				}
-				$sanitized = rest_sanitize_value_from_schema( $value, $args['output_schema'], $field );
-				if ( ! is_wp_error( $sanitized ) ) {
-					$extensions[ $namespace ][ $field ] = $sanitized;
+				$valid = rest_validate_value_from_schema( $value, $args['output_schema'], $field );
+				if ( true === $valid ) {
+					$valid = rest_sanitize_value_from_schema( $value, $args['output_schema'], $field );
+				}
+				if ( is_wp_error( $valid ) ) {
+					return self::field_output_invalid_error( $args, $valid );
 				}
+				$extensions[ $namespace ][ $field ] = self::prepare_json_value( $valid, $args['output_schema'] );
 			}
 		}
 		if ( ! empty( $extensions ) ) {
@@ -708,40 +714,129 @@ class ExtensibleAbilities {
 	}

 	/**
-	 * Whether a value matches the output schema of its field. A value other than null that
-	 * does not match is logged and reported one time for each field.
+	 * Mark an error from a write that saved before it failed. The message names
+	 * the saved object, because MCP clients get the message and not the data.
+	 * The data gets `saved` set to true and the `id` of the object.
 	 *
-	 * @param array<string, mixed> $args  Field arguments.
-	 * @param mixed                $value Value that the get_callback returned.
-	 * @return bool
+	 * @internal
+	 *
+	 * @param \WP_Error            $error         Error after the save.
+	 * @param string               $resource_name Resource, such as product.
+	 * @param int                  $id            ID of the saved object.
+	 * @param array<string, mixed> $data          More data to add.
+	 * @return \WP_Error
 	 */
-	private static function matches_schema( array $args, $value ): bool {
-		$valid = rest_validate_value_from_schema( $value, $args['output_schema'], $args['field'] );
-		if ( true === $valid ) {
-			return true;
-		}
+	public static function saved_write_error( \WP_Error $error, string $resource_name, int $id, array $data = array() ): \WP_Error {
+		$current = $error->get_error_data();
+		return new \WP_Error(
+			$error->get_error_code(),
+			sprintf(
+				/* translators: 1: Error message. 2: Resource, such as product. 3: Object ID. */
+				__( '%1$s The change is saved on the %2$s %3$d, so do not send it again.', 'woocommerce' ),
+				$error->get_error_message(),
+				$resource_name,
+				$id
+			),
+			array_merge(
+				array( 'status' => 400 ),
+				is_array( $current ) ? $current : array(),
+				$data,
+				array(
+					'saved' => true,
+					'id'    => $id,
+				)
+			)
+		);
+	}

-		$key = $args['resource'] . '.' . $args['namespace'] . '.' . $args['field'];
-		if ( null === $value || isset( self::$reported[ $key ] ) ) {
-			return false;
-		}
+	/**
+	 * The error for a field value that does not match its schema. It is also
+	 * reported with wc_doing_it_wrong(), so the extension developer sees it in the log.
+	 *
+	 * @param array<string, mixed> $args  Field arguments.
+	 * @param \WP_Error            $error Error of the schema check.
+	 * @return \WP_Error
+	 */
+	private static function field_output_invalid_error( array $args, \WP_Error $error ): \WP_Error {
+		$message = sprintf(
+			/* translators: 1: Extension namespace. 2: Field name. 3: Resource, such as product. 4: What the field schema expected. */
+			__( 'The "%1$s" extension returned a value for the "%2$s" field of the %3$s that does not match the field schema: %4$s', 'woocommerce' ),
+			$args['namespace'],
+			$args['field'],
+			$args['resource'],
+			$error->get_error_message()
+		);
+		wc_doing_it_wrong( __CLASS__ . '::register_field', $message, '11.4.0' );
+		return new \WP_Error(
+			'woocommerce_ability_field_output_invalid',
+			$message,
+			array(
+				'status'    => 500,
+				'namespace' => $args['namespace'],
+				'field'     => $args['field'],
+				'details'   => $error->get_error_message(),
+			)
+		);
+	}

-		self::$reported[ $key ] = true;
-		wc_get_logger()->error(
-			sprintf( 'Ability field "%s.%s" of "%s" was left out because its value does not match its schema: %s', $args['namespace'], $args['field'], $args['resource'], $valid->get_error_message() ),
-			array( 'source' => 'extensible-abilities' )
+	/**
+	 * The error for a get_callback that throws. The exception text goes to the
+	 * log through wc_doing_it_wrong(), and stays out of the response.
+	 *
+	 * @param array<string, mixed> $args      Field arguments.
+	 * @param \Throwable           $exception Exception that the get_callback threw.
+	 * @return \WP_Error
+	 */
+	private static function field_callback_failed_error( array $args, \Throwable $exception ): \WP_Error {
+		$message = sprintf(
+			/* translators: 1: Extension namespace. 2: Field name. 3: Resource, such as product. */
+			__( 'The "%1$s" extension failed while reading the "%2$s" field of the %3$s.', 'woocommerce' ),
+			$args['namespace'],
+			$args['field'],
+			$args['resource']
 		);
-		wc_doing_it_wrong(
-			__CLASS__ . '::register_field',
-			sprintf(
-				'Ability field "%1$s.%2$s" of "%3$s" was left out because its value does not match its schema: %4$s',
-				$args['namespace'],
-				$args['field'],
-				$args['resource'],
-				$valid->get_error_message()
-			),
-			'11.3.0'
+		wc_doing_it_wrong( __CLASS__ . '::register_field', $message . ' ' . $exception->getMessage(), '11.3.0' );
+		return new \WP_Error(
+			'woocommerce_ability_extension_failed',
+			$message,
+			array(
+				'status'    => 500,
+				'namespace' => $args['namespace'],
+				'field'     => $args['field'],
+			)
 		);
-		return false;
+	}
+
+	/**
+	 * Send an empty array for an `object` as an empty object, at any depth.
+	 * rest_sanitize_value_from_schema() turns every object into an array, so a
+	 * callback cannot send `{}` by itself. A part with no schema stays as it is.
+	 *
+	 * @param mixed                $value  Sanitized value.
+	 * @param array<string, mixed> $schema Schema of the value.
+	 * @return mixed The value for the JSON output.
+	 */
+	private static function prepare_json_value( $value, array $schema ) {
+		$type = rest_get_best_type_for_value( $value, (array) ( $schema['type'] ?? array() ) );
+		if ( 'array' === $type ) {
+			$children = array_fill_keys( array_keys( $value ), $schema['items'] ?? null );
+		} elseif ( 'object' === $type ) {
+			if ( empty( $value ) ) {
+				return new \stdClass();
+			}
+			$children = array();
+			foreach ( array_keys( $value ) as $key ) {
+				$children[ $key ] = $schema['properties'][ $key ] ?? rest_find_matching_pattern_property_schema( (string) $key, $schema ) ?? ( $schema['additionalProperties'] ?? null );
+			}
+		} else {
+			return $value;
+		}
+
+		foreach ( $children as $key => $child_schema ) {
+			if ( is_array( $child_schema ) ) {
+				$value[ $key ] = self::prepare_json_value( $value[ $key ], $child_schema );
+			}
+		}
+		return $value;
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
index c23fc6c6e16..2e80fa8c8f7 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
@@ -8,6 +8,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Internal\Abilities\Domain;

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

 defined( 'ABSPATH' ) || exit;
@@ -103,9 +104,14 @@ class OrderAddNote extends AbstractDomainAbility implements AbilityDefinition {
 			);
 		}

+		$formatted = self::format_saved_order_for_response( $order );
+		if ( is_wp_error( $formatted ) ) {
+			return ExtensibleAbilities::saved_write_error( $formatted, 'order', $order->get_id(), array( 'note_id' => (int) $note_id ) );
+		}
+
 		return array(
 			'note_id' => (int) $note_id,
-			'order'   => self::format_saved_order_for_response( $order ),
+			'order'   => $formatted,
 		);
 	}

diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
index 6e25a9eac62..de34d6a7b85 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
@@ -206,12 +206,15 @@ class OrderUpdateStatus extends ActionableAbility {
 	 * The ability output for the saved order.
 	 *
 	 * @param \WC_Order $subject Saved order.
-	 * @return array
+	 * @return array|\WP_Error
 	 */
-	public function prepare_response( $subject ): array {
-		return array(
-			'order' => self::format_saved_order_for_response( $subject ),
-		);
+	public function prepare_response( $subject ) {
+		$order = self::format_saved_order_for_response( $subject );
+		if ( is_wp_error( $order ) ) {
+			return $order;
+		}
+
+		return array( 'order' => $order );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
index 28a9aa2e4f3..3892bb54b43 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
@@ -93,8 +93,13 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 				return $order;
 			}

+			$orders = self::format_orders_for_response( array( $order ), $include_line_items );
+			if ( is_wp_error( $orders ) ) {
+				return $orders;
+			}
+
 			return array(
-				'orders'      => array( self::format_order_for_response( $order, $include_line_items ) ),
+				'orders'      => $orders,
 				'total_pages' => 1,
 				'page'        => 1,
 				'per_page'    => 1,
@@ -182,13 +187,13 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 		);
 		$pages   = is_object( $results ) && isset( $results->max_num_pages ) ? (int) $results->max_num_pages : ( count( $orders ) > 0 ? 1 : 0 );

+		$orders = self::format_orders_for_response( $orders, $include_line_items );
+		if ( is_wp_error( $orders ) ) {
+			return $orders;
+		}
+
 		return array(
-			'orders'      => array_map(
-				static function ( $order ) use ( $include_line_items ) {
-					return self::format_order_for_response( $order, $include_line_items );
-				},
-				$orders
-			),
+			'orders'      => $orders,
 			'total_pages' => $pages,
 			'page'        => $page,
 			'per_page'    => $per_page,
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
index 60bb2a1bc65..3a352336ff3 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
@@ -112,12 +112,15 @@ class ProductCreate extends ActionableAbility {
 	 * The ability output for the saved product.
 	 *
 	 * @param \WC_Product $subject Saved product.
-	 * @return array
+	 * @return array|\WP_Error
 	 */
-	protected function prepare_response( $subject ): array {
-		return array(
-			'product' => self::format_saved_product_for_response( $subject ),
-		);
+	protected function prepare_response( $subject ) {
+		$product = self::format_saved_product_for_response( $subject );
+		if ( is_wp_error( $product ) ) {
+			return $product;
+		}
+
+		return array( 'product' => $product );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
index b734816355e..db9bdb88afc 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
@@ -129,8 +129,17 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 			_prime_post_caches( $image_ids );
 		}

+		$formatted = array();
+		foreach ( $terms as $term ) {
+			$data = self::format_term( $term );
+			if ( is_wp_error( $data ) ) {
+				return $data;
+			}
+			$formatted[] = $data;
+		}
+
 		return array(
-			'terms'       => array_map( array( __CLASS__, 'format_term' ), array_values( $terms ) ),
+			'terms'       => $formatted,
 			'total'       => (int) $total,
 			'total_pages' => (int) ceil( (int) $total / $per_page ),
 			'page'        => isset( $input['offset'] ) ? (int) floor( $args['offset'] / $per_page ) + 1 : $page,
@@ -142,9 +151,9 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 	 * Format a term with the fields of the WooCommerce term reads.
 	 *
 	 * @param \WP_Term $term Term to format.
-	 * @return array
+	 * @return array|\WP_Error A WP_Error when an extension field value does not match its schema.
 	 */
-	private static function format_term( \WP_Term $term ): array {
+	private static function format_term( \WP_Term $term ) {
 		$output = array(
 			'id'          => $term->term_id,
 			'name'        => html_entity_decode( $term->name, ENT_QUOTES | ENT_HTML5 ),
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
index 649191ea868..d15e7ddfdea 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
@@ -161,12 +161,15 @@ class ProductUpdate extends ActionableAbility {
 	 * The ability output for the saved product.
 	 *
 	 * @param \WC_Product $subject Saved product.
-	 * @return array
+	 * @return array|\WP_Error
 	 */
-	protected function prepare_response( $subject ): array {
-		return array(
-			'product' => self::format_saved_product_for_response( $subject ),
-		);
+	protected function prepare_response( $subject ) {
+		$product = self::format_saved_product_for_response( $subject );
+		if ( is_wp_error( $product ) ) {
+			return $product;
+		}
+
+		return array( 'product' => $product );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
index 21d0587b568..d38e284805f 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
@@ -108,8 +108,13 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 				);
 			}

+			$products = self::format_products_for_response( array( $product ) );
+			if ( is_wp_error( $products ) ) {
+				return $products;
+			}
+
 			return array(
-				'products'    => array( self::format_product_for_response( $product ) ),
+				'products'    => $products,
 				'total_pages' => 1,
 				'page'        => 1,
 				'per_page'    => 1,
@@ -171,13 +176,13 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 			_prime_post_caches( $image_ids );
 		}

+		$products = self::format_products_for_response( $products );
+		if ( is_wp_error( $products ) ) {
+			return $products;
+		}
+
 		return array(
-			'products'    => array_map(
-				static function ( $product ) {
-					return self::format_product_for_response( $product );
-				},
-				$products
-			),
+			'products'    => $products,
 			'total_pages' => $pages,
 			'page'        => $page,
 			'per_page'    => $per_page,
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php
index 349eda893e1..669ae4d1c3f 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php
@@ -33,6 +33,17 @@ trait DomainAbilityHelpers {
 		return $date->format( DATE_ATOM );
 	}

+	/**
+	 * Get a price or total as a string, because the output schema declares Core
+	 * amount fields as strings. The getters can return an int or a float.
+	 *
+	 * @param mixed $amount Value from a getter of the object.
+	 * @return mixed
+	 */
+	protected static function format_amount_for_response( $amount ) {
+		return is_int( $amount ) ? (string) $amount : wc_float_to_string( $amount );
+	}
+
 	/**
 	 * Get a collection output schema.
 	 *
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
index cf17c3c421c..6097c081835 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
@@ -281,22 +281,41 @@ trait OrderAbilityTrait {
 	 * Format an order after a save, retaining the saved object if reloading fails.
 	 *
 	 * @param \WC_Order $order Saved order object.
-	 * @return array
+	 * @return array|\WP_Error
 	 */
-	protected static function format_saved_order_for_response( \WC_Order $order ): array {
+	protected static function format_saved_order_for_response( \WC_Order $order ) {
 		$stored = wc_get_order( $order->get_id() );

 		return self::format_order_for_response( $stored instanceof \WC_Order ? $stored : $order, false );
 	}

+	/**
+	 * Format orders for ability output. One order that fails returns its error.
+	 *
+	 * @param \WC_Order[] $orders             Order objects.
+	 * @param bool        $include_line_items Whether to include line items.
+	 * @return array|\WP_Error
+	 */
+	protected static function format_orders_for_response( array $orders, bool $include_line_items ) {
+		$formatted = array();
+		foreach ( $orders as $order ) {
+			$data = self::format_order_for_response( $order, $include_line_items );
+			if ( is_wp_error( $data ) ) {
+				return $data;
+			}
+			$formatted[] = $data;
+		}
+		return $formatted;
+	}
+
 	/**
 	 * Format an order for ability output.
 	 *
 	 * @param \WC_Order $order              Order object.
 	 * @param bool      $include_line_items Whether to include line items.
-	 * @return array
+	 * @return array|\WP_Error A WP_Error when an extension field value does not match its schema.
 	 */
-	protected static function format_order_for_response( \WC_Order $order, bool $include_line_items ): array {
+	protected static function format_order_for_response( \WC_Order $order, bool $include_line_items ) {
 		$billing_email = $order->get_billing_email();

 		$data = array(
@@ -307,7 +326,7 @@ trait OrderAbilityTrait {
 				get_woocommerce_currency_symbol( $order->get_currency() ),
 				ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
 			),
-			'total'                => $order->get_total(),
+			'total'                => self::format_amount_for_response( $order->get_total() ),
 			'customer_id'          => $order->get_customer_id(),
 			'payment_method'       => $order->get_payment_method(),
 			'payment_method_title' => $order->get_payment_method_title(),
@@ -321,19 +340,23 @@ trait OrderAbilityTrait {
 					continue;
 				}

-				$data['line_items'][] = ExtensibleAbilities::add_fields_to_object(
+				$line_item = ExtensibleAbilities::add_fields_to_object(
 					array(
 						'id'           => $item->get_id(),
 						'name'         => $item->get_name(),
 						'product_id'   => $item->get_product_id(),
 						'variation_id' => $item->get_variation_id(),
 						'quantity'     => $item->get_quantity(),
-						'subtotal'     => $item->get_subtotal(),
-						'total'        => $item->get_total(),
+						'subtotal'     => self::format_amount_for_response( $item->get_subtotal() ),
+						'total'        => self::format_amount_for_response( $item->get_total() ),
 					),
 					'order_item',
 					$item
 				);
+				if ( is_wp_error( $line_item ) ) {
+					return $line_item;
+				}
+				$data['line_items'][] = $line_item;
 			}
 		}

diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index 88a5d13e467..d27b9fe2768 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -745,21 +745,39 @@ trait ProductAbilityTrait {
 	 * Format a product after a save, retaining the saved object if reloading fails.
 	 *
 	 * @param \WC_Product $product Saved product object.
-	 * @return array
+	 * @return array|\WP_Error
 	 */
-	protected static function format_saved_product_for_response( \WC_Product $product ): array {
+	protected static function format_saved_product_for_response( \WC_Product $product ) {
 		$stored = wc_get_product( $product->get_id() );

 		return self::format_product_for_response( $stored instanceof \WC_Product ? $stored : $product );
 	}

+	/**
+	 * Format products for ability output. One product that fails returns its error.
+	 *
+	 * @param \WC_Product[] $products Product objects.
+	 * @return array|\WP_Error
+	 */
+	protected static function format_products_for_response( array $products ) {
+		$formatted = array();
+		foreach ( $products as $product ) {
+			$data = self::format_product_for_response( $product );
+			if ( is_wp_error( $data ) ) {
+				return $data;
+			}
+			$formatted[] = $data;
+		}
+		return $formatted;
+	}
+
 	/**
 	 * Format a product for ability output.
 	 *
 	 * @param \WC_Product $product Product object.
-	 * @return array
+	 * @return array|\WP_Error A WP_Error when an extension field value does not match its schema.
 	 */
-	protected static function format_product_for_response( \WC_Product $product ): array {
+	protected static function format_product_for_response( \WC_Product $product ) {
 		$stock_quantity   = $product->get_stock_quantity();
 		$low_stock_amount = $product->get_low_stock_amount();
 		$permalink        = $product->get_permalink();
@@ -776,9 +794,9 @@ trait ProductAbilityTrait {
 				get_woocommerce_currency_symbol(),
 				ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
 			),
-			'price'             => $product->get_price(),
-			'regular_price'     => $product->get_regular_price(),
-			'sale_price'        => $product->get_sale_price(),
+			'price'             => self::format_amount_for_response( $product->get_price() ),
+			'regular_price'     => self::format_amount_for_response( $product->get_regular_price() ),
+			'sale_price'        => self::format_amount_for_response( $product->get_sale_price() ),
 			'on_sale'           => (bool) $product->is_on_sale(),
 			'stock_status'      => $product->get_stock_status(),
 			'manage_stock'      => (bool) $product->get_manage_stock(),
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index e345a49e6f2..bfb29b013b0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -1211,6 +1211,8 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 			)
 		);
 		ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => 'other' ), array( 'output_schema' => array( 'description' => 'x' ) ) ) );
+		ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => 'other' ), array( 'output_schema' => array( 'type' => array( 'string', 'null' ) ) ) ) );
+		ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => 'other' ), array( 'output_schema' => array( 'type' => 'null' ) ) ) );
 		ExtensibleAbilities::register_field( array_merge( $valid, array( 'namespace' => '0' ) ) );
 		ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => '123' ) ) );
 		$order = \WC_Helper_Order::create_order();
@@ -1720,38 +1722,131 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should leave out a field that throws, returns null or fails sanitization, and cast a value to its schema type.
+	 * @testdox Should send an empty array for an object as an empty object at any depth, and keep free-form parts as they are.
+	 */
+	public function test_an_empty_array_for_an_object_is_an_empty_object(): void {
+		$output = $this->read_field_values(
+			array(
+				'flat'    => array( array( 'type' => 'object' ), array() ),
+				'nested'  => array(
+					array(
+						'type'       => 'object',
+						'properties' => array( 'inner' => array( 'type' => 'object' ) ),
+					),
+					array( 'inner' => array() ),
+				),
+				'in_list' => array(
+					array(
+						'type'  => 'array',
+						'items' => array( 'type' => 'object' ),
+					),
+					array( array() ),
+				),
+				'free'    => array( array( 'type' => 'object' ), array( 'list' => array() ) ),
+			)
+		);
+
+		$this->assertSame(
+			'{"flat":{},"nested":{"inner":{}},"in_list":[{}],"free":{"list":[]}}',
+			wp_json_encode( $output )
+		);
+	}
+
+	/**
+	 * @testdox Should keep an empty array for an array as an empty list.
+	 */
+	public function test_an_empty_array_for_an_array_is_an_empty_list(): void {
+		$output = $this->read_field_values(
+			array(
+				'flat'       => array( array( 'type' => 'array' ), array() ),
+				'of_objects' => array(
+					array(
+						'type'  => 'array',
+						'items' => array( 'type' => 'object' ),
+					),
+					array(),
+				),
+				'in_object'  => array(
+					array(
+						'type'       => 'object',
+						'properties' => array(
+							'list' => array(
+								'type'  => 'array',
+								'items' => array( 'type' => 'string' ),
+							),
+						),
+					),
+					array( 'list' => array() ),
+				),
+			)
+		);
+
+		$this->assertSame( '{"flat":[],"of_objects":[],"in_object":{"list":[]}}', wp_json_encode( $output ) );
+	}
+
+	/**
+	 * @testdox Should return product amounts as strings when the getters return an int or a float, and save the write.
+	 */
+	public function test_product_amounts_are_returned_as_strings(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$this->add_test_filter( 'woocommerce_product_get_price', static fn() => 12.5 );
+		$this->add_test_filter( 'woocommerce_product_get_regular_price', static fn() => 15 );
+		$this->add_test_filter( 'woocommerce_product_get_sale_price', static fn() => 12.5 );
+
+		$query = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => $product->get_id() ) );
+		$write = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'   => $product->get_id(),
+				'name' => 'Saved',
+			)
+		);
+
+		$this->assertNotWPError( $query );
+		$this->assertSame( '12.5', $query['products'][0]['price'] );
+		$this->assertSame( '15', $query['products'][0]['regular_price'] );
+		$this->assertSame( '12.5', $query['products'][0]['sale_price'] );
+		$this->assertNotWPError( $write );
+		$this->assertSame( '12.5', $write['product']['price'] );
+		$this->assertSame( 'Saved', get_post( $product->get_id() )->post_title );
+	}
+
+	/**
+	 * @testdox Should return the order total and line item amounts as strings when the getters return an int or a float.
+	 */
+	public function test_order_amounts_are_returned_as_strings(): void {
+		$order = \WC_Helper_Order::create_order();
+		$this->add_test_filter( 'woocommerce_order_get_total', static fn() => 40 );
+		$this->add_test_filter( 'woocommerce_order_item_get_subtotal', static fn() => 30 );
+		$this->add_test_filter( 'woocommerce_order_item_get_total', static fn() => 25.5 );
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'id'                 => $order->get_id(),
+				'include_line_items' => true,
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( '40', $result['orders'][0]['total'] );
+		$this->assertSame( '30', $result['orders'][0]['line_items'][0]['subtotal'] );
+		$this->assertSame( '25.5', $result['orders'][0]['line_items'][0]['total'] );
+	}
+
+	/**
+	 * @testdox Should cast a value to its schema type, and leave out a null.
 	 */
 	public function test_field_values_follow_their_schema(): void {
 		AbilitiesLoader::register_core_resources();
 		$this->register_test_fields();
 		$fields = array(
-			'broken' => array(
-				'output_schema' => array( 'type' => 'string' ),
-				'get_callback'  => static function () {
-					throw new \RuntimeException( 'Broken' );
-				},
-			),
-			'count'  => array(
+			'count' => array(
 				'output_schema' => array( 'type' => 'integer' ),
 				'get_callback'  => static fn() => '12',
 			),
-			'empty'  => array(
+			'empty' => array(
 				'output_schema' => array( 'type' => 'string' ),
 				'get_callback'  => '__return_null',
 			),
-			'none'   => array(
-				'output_schema' => array( 'type' => array( 'string', 'null' ) ),
-				'get_callback'  => '__return_null',
-			),
-			'dupes'  => array(
-				'output_schema' => array(
-					'type'        => 'array',
-					'items'       => array( 'type' => 'integer' ),
-					'uniqueItems' => true,
-				),
-				'get_callback'  => static fn() => array( '12', 12 ),
-			),
 		);
 		foreach ( $fields as $field => $args ) {
 			ExtensibleAbilities::register_field(
@@ -1770,46 +1865,210 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should leave out and report one time a value that does not match its schema, and keep the output valid.
+	 * @testdox Should fail products-query and product-update with a named error when a product field value does not match its schema.
 	 */
-	public function test_a_value_that_does_not_match_its_schema_is_left_out(): void {
+	public function test_a_product_field_value_that_does_not_match_its_schema_fails_the_call(): void {
 		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
-		add_action(
+		$this->register_invalid_field( 'product', array( 'type' => 'integer' ), 'many' );
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen one' ) );
+		\WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen two' ) );
+
+		$query = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'search' => 'Pen' ) );
+		$write = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'   => $product->get_id(),
+				'name' => 'Pencil',
+			)
+		);
+
+		$this->assert_field_output_invalid( $query, 'product' );
+		$this->assert_field_output_invalid( $write, 'product' );
+		$this->assertTrue( $write->get_error_data()['saved'] );
+		$this->assertSame( $product->get_id(), $write->get_error_data()['id'] );
+		$this->assertStringContainsString( 'The change is saved on the product ' . $product->get_id() . ',', $write->get_error_message() );
+		$this->assertSame( 'Pencil', wc_get_product( $product->get_id() )->get_name() );
+	}
+
+	/**
+	 * @testdox Should fail orders-query with a named error when an order or line item field value does not match its schema.
+	 * @dataProvider invalid_order_field_values
+	 *
+	 * @param string $resource_name Resource.
+	 * @param array  $schema        Field schema.
+	 * @param mixed  $value         Value that the get_callback returns.
+	 */
+	public function test_an_order_field_value_that_does_not_match_its_schema_fails_the_call( string $resource_name, array $schema, $value ): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+		$this->register_invalid_field( $resource_name, $schema, $value );
+		$order = \WC_Helper_Order::create_order();
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'id'                 => $order->get_id(),
+				'include_line_items' => true,
+			)
+		);
+
+		$this->assert_field_output_invalid( $result, $resource_name );
+	}
+
+	/**
+	 * @testdox Should name the saved order and note in the error when order-add-note saves and an order field value then fails its schema.
+	 */
+	public function test_an_order_note_that_saves_before_a_field_fails_names_the_saved_note(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+		$this->register_invalid_field( 'order', array( 'type' => 'integer' ), 'many' );
+		$order = \WC_Helper_Order::create_order();
+
+		$result = wp_get_ability( 'woocommerce/order-add-note' )->execute(
+			array(
+				'id'   => $order->get_id(),
+				'note' => 'Packed.',
+			)
+		);
+
+		$this->assert_field_output_invalid( $result, 'order' );
+		$this->assertStringContainsString( 'The change is saved on the order ' . $order->get_id() . ',', $result->get_error_message() );
+		$this->assertTrue( $result->get_error_data()['saved'] );
+		$this->assertSame( $order->get_id(), $result->get_error_data()['id'] );
+		$note = wc_get_order_note( $result->get_error_data()['note_id'] );
+		$this->assertSame( $order->get_id(), (int) get_comment( $note->id )->comment_post_ID );
+		$this->assertSame( 'Packed.', $note->content );
+	}
+
+	/**
+	 * @testdox Should name the saved order in the error when order-update-status saves and an order field value then fails its schema.
+	 */
+	public function test_an_order_status_that_saves_before_a_field_fails_names_the_saved_order(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+		$this->register_invalid_field( 'order', array( 'type' => 'integer' ), 'many' );
+		$order = \WC_Helper_Order::create_order();
+
+		$result = wp_get_ability( 'woocommerce/order-update-status' )->execute(
+			array(
+				'id'     => $order->get_id(),
+				'status' => 'completed',
+			)
+		);
+
+		$this->assert_field_output_invalid( $result, 'order' );
+		$this->assertStringContainsString( 'The change is saved on the order ' . $order->get_id() . ',', $result->get_error_message() );
+		$this->assertTrue( $result->get_error_data()['saved'] );
+		$this->assertSame( $order->get_id(), $result->get_error_data()['id'] );
+		$this->assertSame( 'completed', wc_get_order( $order->get_id() )->get_status() );
+	}
+
+	/**
+	 * @testdox Should fail orders-query with a named error that keeps the exception text out when a get_callback throws.
+	 */
+	public function test_a_get_callback_that_throws_fails_the_call(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+		$this->add_test_action(
 			'woocommerce_extensible_abilities_init',
 			static function () {
 				ExtensibleAbilities::register_field(
 					array(
-						'resource'      => 'product',
-						'namespace'     => 'test-ext',
-						'field'         => 'count',
-						'output_schema' => array( 'type' => 'integer' ),
-						'get_callback'  => static fn() => 'many',
+						'resource'      => 'order',
+						'namespace'     => 'invalid-ext',
+						'field'         => 'bad',
+						'output_schema' => array( 'type' => 'string' ),
+						'get_callback'  => static function () {
+							throw new \RuntimeException( 'Secret failure' );
+						},
 					)
 				);
 			}
 		);
-		$reported = array();
-		add_action(
-			'doing_it_wrong_run',
-			static function ( $function_name, $message ) use ( &$reported ) {
-				$reported[] = $message;
-			},
-			10,
-			2
+		$order = \WC_Helper_Order::create_order();
+
+		$result = wp_get_ability( 'woocommerce/orders-query' )->execute( array( 'id' => $order->get_id() ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_ability_extension_failed', $result->get_error_code(), $result->get_error_message() );
+		$this->assertSame( 'The "invalid-ext" extension failed while reading the "bad" field of the order.', $result->get_error_message() );
+		$this->assertSame(
+			array(
+				'status'    => 500,
+				'namespace' => 'invalid-ext',
+				'field'     => 'bad',
+			),
+			$result->get_error_data()
 		);
-		$first = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen one' ) );
-		$first->update_meta_data( '_test_code', 'A1' );
-		$first->save();
-		\WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen two' ) );
+	}

-		$products = array_column( $this->run_ability( 'woocommerce/products-query', array( 'search' => 'Pen' ) )['products'], null, 'id' );
+	/**
+	 * Field values that do not match their schema.
+	 *
+	 * @return array<string, array{0: string, 1: array, 2: mixed}>
+	 */
+	public function invalid_order_field_values(): array {
+		return array(
+			'order field of the wrong type' => array( 'order', array( 'type' => 'integer' ), 'many' ),
+			'nested object on a line item'  => array(
+				'order_item',
+				array(
+					'type'       => 'object',
+					'properties' => array(
+						'inner' => array(
+							'type'       => 'object',
+							'properties' => array( 'count' => array( 'type' => 'integer' ) ),
+						),
+					),
+				),
+				array( 'inner' => array( 'count' => 'many' ) ),
+			),
+			'list that fails sanitization'  => array(
+				'order',
+				array(
+					'type'        => 'array',
+					'items'       => array( 'type' => 'integer' ),
+					'uniqueItems' => true,
+				),
+				array( '12', 12 ),
+			),
+		);
+	}

-		$this->assertSame( array( 'code' => 'A1' ), $products[ $first->get_id() ]['extensions']['test-ext'] );
-		$matches = array_filter(
-			$reported,
-			static fn( $message ) => false !== strpos( $message, '"test-ext.count" of "product"' )
+	/**
+	 * Register the field invalid-ext.bad on a resource, with a get_callback that returns a fixed value.
+	 *
+	 * @param string $resource_name Resource.
+	 * @param array  $schema        Field schema.
+	 * @param mixed  $value         Value that the get_callback returns.
+	 */
+	private function register_invalid_field( string $resource_name, array $schema, $value ): void {
+		$this->add_test_action(
+			'woocommerce_extensible_abilities_init',
+			static function () use ( $resource_name, $schema, $value ) {
+				ExtensibleAbilities::register_field(
+					array(
+						'resource'      => $resource_name,
+						'namespace'     => 'invalid-ext',
+						'field'         => 'bad',
+						'output_schema' => $schema,
+						'get_callback'  => static fn() => $value,
+					)
+				);
+			}
 		);
-		$this->assertCount( 1, $matches );
+	}
+
+	/**
+	 * Assert that a result is the error for the invalid-ext.bad field.
+	 *
+	 * @param mixed  $result        Ability result.
+	 * @param string $resource_name Resource.
+	 */
+	private function assert_field_output_invalid( $result, string $resource_name ): void {
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_ability_field_output_invalid', $result->get_error_code(), $result->get_error_message() );
+		$this->assertStringContainsString( '"invalid-ext" extension', $result->get_error_message() );
+		$this->assertStringContainsString( '"bad" field of the ' . $resource_name, $result->get_error_message() );
+		$data = $result->get_error_data();
+		$this->assertSame( 500, $data['status'] );
+		$this->assertSame( 'invalid-ext', $data['namespace'] );
+		$this->assertSame( 'bad', $data['field'] );
+		$this->assertNotEmpty( $data['details'] );
 	}

 	/**
@@ -3103,6 +3362,43 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		$this->assertEmpty( ExtensibleAbilities::get_validators( 'order' ) );
 	}

+	/**
+	 * Register fields on the order resource and read their values.
+	 *
+	 * @param array<string, array{0: array<string, mixed>, 1: mixed}> $fields Schema and value, keyed by field.
+	 * @return array<string, mixed> The values under the namespace.
+	 */
+	private function read_field_values( array $fields ): array {
+		AbilitiesLoader::register_core_resources();
+		foreach ( $fields as $field => list( $schema, $value ) ) {
+			ExtensibleAbilities::register_field(
+				array(
+					'resource'      => 'order',
+					'namespace'     => 'values',
+					'field'         => $field,
+					'output_schema' => $schema,
+					'get_callback'  => static fn() => $value,
+				)
+			);
+		}
+
+		$output = ExtensibleAbilities::add_fields_to_object( array(), 'order', \WC_Helper_Order::create_order() );
+		return $output['extensions']['values'];
+	}
+
+	/**
+	 * Add a filter and remove it in tearDown.
+	 *
+	 * @param string   $hook          Hook name.
+	 * @param callable $callback      Callback.
+	 * @param int      $priority      Priority.
+	 * @param int      $accepted_args Number of arguments.
+	 */
+	private function add_test_filter( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 ): void {
+		add_filter( $hook, $callback, $priority, $accepted_args );
+		$this->test_actions[] = array( $hook, $callback, $priority );
+	}
+
 	/**
 	 * Add an action that tearDown removes.
 	 *
@@ -3273,7 +3569,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	private function reset_registries(): void {
 		global $wp_actions;

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