Commit 695bbeaf4b8 for woocommerce

commit 695bbeaf4b8cf9610ee274d3c956b0509f64f6f3
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Fri Oct 16 17:33:32 2026 -0300

    Add namespaced extension fields to the product and order abilities (#69678)

    * Add namespaced extension fields to the product and order abilities

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

    * Rename the resource parameter of the read methods

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

    * Rename the read methods to match the WordPress REST field methods

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

    * Use did_action() to fire the ability extensions action one time

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

    * Leave the schema unchanged when a resource has no extension fields

    add_fields_schema() added an empty extensions property to every order and product schema. It now returns the schema unchanged when no field is registered for the resource.

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

    * Refuse a field that is registered late, on an unknown resource, or without a type

    A field registered after the init action is returned but not listed in the
    output schema, so every read of the resource fails validation. Refuse it
    with a doing_it_wrong instead. Resources are registered with
    register_resource() first, so a typo in the resource name is reported, and
    a field schema needs a type.

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

    * Register the core resources in AbilityExtensions itself

    The loader no longer registers product, order and order_item before it
    fires woocommerce_ability_extensions_init. AbilityExtensions starts with
    those three resources, so no hook priority can run before they exist.

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

    * Leave out a field whose value fails sanitization

    A value can pass rest_validate_value_from_schema() and still make
    rest_sanitize_value_from_schema() return a WP_Error. For example,
    array( '12', 12 ) with integer items and uniqueItems validates, but
    sanitization casts both to 12 and reports duplicate items. The error
    was stored as the field value.

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

    * Register ability resources in their own action, before the fields

    Core and extensions now register resources inside the new
    woocommerce_ability_resources_init action. It fires before
    woocommerce_ability_extensions_init, so a field can target any
    resource, whatever the hook priorities are.

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

    * Register the core ability resources at priority 0

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

    * Rename the feature to ability extensions and add a prime callback for list reads

    Fold the feature gate into AbilityExtensions::is_enabled() and rename the
    experimental feature to ability_extensions, so the option, the class and the
    init action share one name.

    Add an optional prime_callback to register_field(). The product and order
    list abilities call it one time with every object of the page, before the
    get_callbacks run, so an extension loads its data in one query.

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

    * Remove the prime callback from ability extension fields

    No extension needs it yet. An optional argument can be added later without breaking registered fields.

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

    * Translate the wc_doing_it_wrong messages in AbilityExtensions

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

    * Rename AbilityExtensions to ExtensibleAbilities

    The name now describes Core's abilities, which extensions can extend.
    "Ability extensions" could read as plugins that add abilities.

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

    * Rename woocommerce_ability_resources_init to woocommerce_extensible_abilities_resources_init

    Both hooks of the API now share the feature prefix. The pair follows
    wp_abilities_api_categories_init and wp_abilities_api_init.

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

    * Shorten the changelog entry to an overview of the feature

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

    * Harden extension fields against bad registrations

    - Refuse a field schema that sets required, so a dropped value
      cannot fail the whole ability output.
    - Refuse resource, namespace and field slugs made only of digits,
      which PHP turns into integer keys and JSON encodes as a list.
    - Close the extensions and namespace schema objects with
      additionalProperties false, like the other output objects.
    - Check for registered fields before the feature lookup.
    - Point to the init action when a field is registered before the
      resources exist.
    - Log the left-out field message in English.

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

    ---------

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

diff --git a/plugins/woocommerce/changelog/add-ability-extension-fields b/plugins/woocommerce/changelog/add-ability-extension-fields
new file mode 100644
index 00000000000..23cbc8bf796
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-ability-extension-fields
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the experimental extensible abilities feature. With it on, extensions register fields with ExtensibleAbilities::register_field(), and the abilities return their values under extensions.<namespace>.<field>.
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
new file mode 100644
index 00000000000..d922c69baad
--- /dev/null
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -0,0 +1,301 @@
+<?php
+/**
+ * Extensible abilities class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Abilities;
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * The entry point for extensions in WooCommerce's abilities.
+ *
+ * An extension registers a resource inside the `woocommerce_extensible_abilities_resources_init`
+ * action. It registers a field on a resource, such as `product`, inside the
+ * `woocommerce_extensible_abilities_init` action, which fires after. An ability that formats an
+ * object of that resource returns the field values under
+ * `extensions.<namespace>.<field>`, and lists the fields in its output schema.
+ *
+ * The API is experimental while the `extensible_abilities` feature exists.
+ *
+ * @since 11.3.0
+ */
+class ExtensibleAbilities {
+
+	public const FEATURE_ID = 'extensible_abilities';
+
+	/**
+	 * Fields keyed by resource, then namespace, then field name.
+	 *
+	 * @var array<string, array<string, array<string, array<string, mixed>>>>
+	 */
+	private static array $fields = array();
+
+	/**
+	 * Registered resources, keyed by resource name.
+	 *
+	 * @var array<string, bool>
+	 */
+	private static array $resources = 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.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @return bool
+	 */
+	public static function is_enabled(): bool {
+		return FeaturesUtil::feature_is_enabled( self::FEATURE_ID );
+	}
+
+	/**
+	 * Register a resource that abilities format, so extensions can register fields on it.
+	 *
+	 * Call it inside the `woocommerce_extensible_abilities_resources_init` action. A call after that action is refused.
+	 * Core registers `product`, `order` and `order_item` there. An
+	 * extension registers a resource that its own abilities format, and then calls `add_fields_to_object()`
+	 * and `add_fields_schema()` from those abilities, exactly as Core's abilities do.
+	 * Registering the same resource again does nothing.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string $resource_name Slug of the resource, such as `ticket`.
+	 */
+	public static function register_resource( string $resource_name ): void {
+		if ( did_action( 'woocommerce_extensible_abilities_resources_init' ) && ! doing_action( 'woocommerce_extensible_abilities_resources_init' ) ) {
+			wc_doing_it_wrong( __METHOD__, __( 'Register a resource inside the woocommerce_extensible_abilities_resources_init action, before the fields are registered.', 'woocommerce' ), '11.3.0' );
+			return;
+		}
+		if ( '' === $resource_name || sanitize_key( $resource_name ) !== $resource_name || ctype_digit( $resource_name ) ) {
+			wc_doing_it_wrong( __METHOD__, __( 'The resource name must be a slug of lowercase letters, numbers, dashes and underscores, not only numbers.', 'woocommerce' ), '11.3.0' );
+			return;
+		}
+
+		self::$resources[ $resource_name ] = true;
+	}
+
+	/**
+	 * Register a field that the abilities of a resource return under `extensions.<namespace>.<field>`.
+	 *
+	 * Call it inside the `woocommerce_extensible_abilities_init` action. A call after that action is refused.
+	 * The resource must be registered with `register_resource()` in the earlier `woocommerce_extensible_abilities_resources_init` action.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param array $args {
+	 *     Field arguments.
+	 *
+	 *     @type string   $resource     Resource that an ability formats, for example `product`, `order` or `order_item`.
+	 *     @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    $schema       JSON schema of the value. It needs a `type`. Its `title` is the label clients show.
+	 *                                  It cannot set `required`, because a value that fails its schema is
+	 *                                  left out. For a field that is always present, allow null and return null.
+	 *     @type callable $get_callback Receives the object and returns the value. A value that the schema
+	 *                                  does not allow is left out, so return null when the object has no
+	 *                                  value. A schema that allows null, such as `array( 'integer', 'null' )`,
+	 *                                  keeps it. It reads the object it is given, not the database,
+	 *                                  because an ability can format an object before it is saved.
+	 * }
+	 */
+	public static function register_field( array $args ): void {
+		if ( did_action( 'woocommerce_extensible_abilities_init' ) && ! doing_action( 'woocommerce_extensible_abilities_init' ) ) {
+			wc_doing_it_wrong( __METHOD__, __( 'Register a field inside the woocommerce_extensible_abilities_init action, before the abilities build their schemas.', 'woocommerce' ), '11.3.0' );
+			return;
+		}
+		foreach ( array( 'resource', 'namespace', 'field' ) as $key ) {
+			if ( ! is_string( $args[ $key ] ?? null ) || '' === $args[ $key ] || sanitize_key( $args[ $key ] ) !== $args[ $key ] || ctype_digit( $args[ $key ] ) ) {
+				wc_doing_it_wrong(
+					__METHOD__,
+					sprintf(
+						/* translators: %s: argument name. */
+						__( 'The "%s" argument must be a slug of lowercase letters, numbers, dashes and underscores, not only numbers.', 'woocommerce' ),
+						$key
+					),
+					'11.3.0'
+				);
+				return;
+			}
+		}
+		if ( ! isset( self::$resources[ $args['resource'] ] ) && ! did_action( 'woocommerce_extensible_abilities_resources_init' ) ) {
+			wc_doing_it_wrong( __METHOD__, __( 'Register a field inside the woocommerce_extensible_abilities_init action. The resources are not registered yet.', 'woocommerce' ), '11.3.0' );
+			return;
+		}
+		if ( ! isset( self::$resources[ $args['resource'] ] ) ) {
+			wc_doing_it_wrong(
+				__METHOD__,
+				sprintf(
+					/* translators: %s: resource name. */
+					__( 'The "%s" resource is not registered. Call ExtensibleAbilities::register_resource() first.', 'woocommerce' ),
+					$args['resource']
+				),
+				'11.3.0'
+			);
+			return;
+		}
+		if ( ! is_array( $args['schema'] ?? null ) || ! isset( $args['schema']['type'] ) ) {
+			wc_doing_it_wrong( __METHOD__, __( 'The "schema" argument must be an array with a "type".', 'woocommerce' ), '11.3.0' );
+			return;
+		}
+		if ( true === ( $args['schema']['required'] ?? null ) ) {
+			wc_doing_it_wrong( __METHOD__, __( 'The "schema" argument cannot set "required". A value that fails its schema is left out, so allow null for a field that is always present.', 'woocommerce' ), '11.3.0' );
+			return;
+		}
+		if ( ! is_callable( $args['get_callback'] ?? null ) ) {
+			wc_doing_it_wrong( __METHOD__, __( 'The "get_callback" argument must be callable.', 'woocommerce' ), '11.3.0' );
+			return;
+		}
+		if ( isset( self::$fields[ $args['resource'] ][ $args['namespace'] ][ $args['field'] ] ) ) {
+			wc_doing_it_wrong(
+				__METHOD__,
+				sprintf(
+					/* translators: 1: extension namespace, 2: field name, 3: resource name. */
+					__( 'The field "%1$s.%2$s" of "%3$s" is already registered.', 'woocommerce' ),
+					$args['namespace'],
+					$args['field'],
+					$args['resource']
+				),
+				'11.3.0'
+			);
+			return;
+		}
+
+		self::$fields[ $args['resource'] ][ $args['namespace'] ][ $args['field'] ] = $args;
+	}
+
+	/**
+	 * 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 or returns a
+	 * value that its schema does not allow is left out. A namespace with no
+	 * values is left out, and `extensions` is left out when no namespace has
+	 * values or the feature is off.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param array<string, mixed> $output   Formatted object.
+	 * @param string               $resource_name Resource.
+	 * @param object               $subject  Object to read.
+	 * @return array<string, mixed>
+	 */
+	public static function add_fields_to_object( array $output, string $resource_name, $subject ): array {
+		if ( empty( self::$fields[ $resource_name ] ) || ! self::is_enabled() ) {
+			return $output;
+		}
+
+		$extensions = array();
+		foreach ( self::$fields[ $resource_name ] as $namespace => $fields ) {
+			foreach ( $fields as $field => $args ) {
+				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;
+				}
+				if ( ! self::matches_schema( $args, $value ) ) {
+					continue;
+				}
+				$sanitized = rest_sanitize_value_from_schema( $value, $args['schema'], $field );
+				if ( ! is_wp_error( $sanitized ) ) {
+					$extensions[ $namespace ][ $field ] = $sanitized;
+				}
+			}
+		}
+		if ( ! empty( $extensions ) ) {
+			$output['extensions'] = $extensions;
+		}
+		return $output;
+	}
+
+	/**
+	 * Add the `extensions` property to an object schema when the feature is on.
+	 * It lists each namespace with its fields. Call it after the
+	 * `woocommerce_extensible_abilities_init` action, when the ability builds its
+	 * schema. The schema stays unchanged when no field is registered for the resource.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param array<string, mixed> $schema   Object schema.
+	 * @param string               $resource_name Resource.
+	 * @return array<string, mixed>
+	 */
+	public static function add_fields_schema( array $schema, string $resource_name ): array {
+		if ( ! self::is_enabled() || empty( self::$fields[ $resource_name ] ) ) {
+			return $schema;
+		}
+
+		$extensions = array(
+			'type'                 => 'object',
+			'description'          => __( 'Values that extensions add, keyed by extension namespace, then field.', 'woocommerce' ),
+			'additionalProperties' => false,
+		);
+		foreach ( self::$fields[ $resource_name ] ?? array() as $namespace => $fields ) {
+			$extensions['properties'][ $namespace ] = array(
+				'type'                 => 'object',
+				'properties'           => array_map(
+					static function ( array $args ): array {
+						return $args['schema'];
+					},
+					$fields
+				),
+				'additionalProperties' => false,
+			);
+		}
+		$schema['properties']['extensions'] = $extensions;
+		return $schema;
+	}
+
+	/**
+	 * Whether a value matches its field schema. A value other than null that
+	 * does not match is logged and reported one time for each field.
+	 *
+	 * @param array<string, mixed> $args  Field arguments.
+	 * @param mixed                $value Value that the get_callback returned.
+	 * @return bool
+	 */
+	private static function matches_schema( array $args, $value ): bool {
+		$valid = rest_validate_value_from_schema( $value, $args['schema'], $args['field'] );
+		if ( true === $valid ) {
+			return true;
+		}
+
+		$key = $args['resource'] . '.' . $args['namespace'] . '.' . $args['field'];
+		if ( null === $value || isset( self::$reported[ $key ] ) ) {
+			return false;
+		}
+
+		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' )
+		);
+		wc_doing_it_wrong(
+			__CLASS__ . '::register_field',
+			sprintf(
+				/* translators: 1: extension namespace, 2: field name, 3: resource name, 4: validation error. */
+				__( 'Ability field "%1$s.%2$s" of "%3$s" was left out because its value does not match its schema: %4$s', 'woocommerce' ),
+				$args['namespace'],
+				$args['field'],
+				$args['resource'],
+				$valid->get_error_message()
+			),
+			'11.3.0'
+		);
+		return false;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index f7a09fefb09..55469e16f29 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -8,6 +8,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Internal\Abilities;

 use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrderAddNote;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrderNotesQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrderUpdateStatus;
@@ -78,12 +79,60 @@ class AbilitiesLoader {
 		 * Support both old (pre-6.9) and new (6.9+) action names.
 		 */
 		AbilitiesCategories::init();
+		add_action( 'woocommerce_extensible_abilities_resources_init', array( __CLASS__, 'register_core_resources' ), 0 );
+		add_action( 'abilities_api_init', array( __CLASS__, 'init_extensions' ), 0 );
+		add_action( 'wp_abilities_api_init', array( __CLASS__, 'init_extensions' ), 0 );
 		add_action( 'abilities_api_init', array( __CLASS__, 'register_abilities' ) );
 		add_action( 'wp_abilities_api_init', array( __CLASS__, 'register_abilities' ) );

 		self::$initialized = true;
 	}

+	/**
+	 * Register the resources that Core's abilities format, so extensions can register fields on them.
+	 *
+	 * @internal
+	 *
+	 * @since 11.3.0
+	 */
+	public static function register_core_resources(): void {
+		foreach ( array( 'product', 'order', 'order_item' ) as $resource_name ) {
+			ExtensibleAbilities::register_resource( $resource_name );
+		}
+	}
+
+	/**
+	 * Let extensions register their resources, then their ability fields, before the abilities build their schemas.
+	 *
+	 * @internal
+	 *
+	 * @since 11.3.0
+	 */
+	public static function init_extensions(): void {
+		if ( did_action( 'woocommerce_extensible_abilities_init' ) || ! ExtensibleAbilities::is_enabled() ) {
+			return;
+		}
+
+		/**
+		 * Fires when extensions can register their resources with ExtensibleAbilities.
+		 *
+		 * Fires one time, before `woocommerce_extensible_abilities_init`, only when the
+		 * experimental `extensible_abilities` feature is on.
+		 *
+		 * @since 11.3.0
+		 */
+		do_action( 'woocommerce_extensible_abilities_resources_init' );
+
+		/**
+		 * Fires when extensions can register their fields with ExtensibleAbilities.
+		 *
+		 * Fires one time, only when the experimental `extensible_abilities` feature is on.
+		 *
+		 * @since 11.3.0
+		 */
+		do_action( 'woocommerce_extensible_abilities_init' );
+	}
+
 	/**
 	 * Register all configured ability definitions.
 	 *
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
index 63ea7ba88a7..be62be7cc7e 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
@@ -8,6 +8,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;

 use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
 use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
 use Automattic\WooCommerce\Utilities\OrderUtil;

@@ -125,7 +126,7 @@ trait OrderAbilityTrait {
 	 * @return array
 	 */
 	protected static function get_order_output_schema(): array {
-		return array(
+		$schema = array(
 			'type'                 => 'object',
 			'properties'           => array(
 				'id'                   => array( 'type' => 'integer' ),
@@ -165,23 +166,27 @@ trait OrderAbilityTrait {
 				'line_items'           => array(
 					'type'        => 'array',
 					'description' => __( 'Order line items. Only present when include_line_items is true.', 'woocommerce' ),
-					'items'       => array(
-						'type'                 => 'object',
-						'properties'           => array(
-							'id'           => array( 'type' => 'integer' ),
-							'name'         => array( 'type' => 'string' ),
-							'product_id'   => array( 'type' => 'integer' ),
-							'variation_id' => array( 'type' => 'integer' ),
-							'quantity'     => array( 'type' => 'integer' ),
-							'subtotal'     => array( 'type' => 'string' ),
-							'total'        => array( 'type' => 'string' ),
+					'items'       => ExtensibleAbilities::add_fields_schema(
+						array(
+							'type'                 => 'object',
+							'properties'           => array(
+								'id'           => array( 'type' => 'integer' ),
+								'name'         => array( 'type' => 'string' ),
+								'product_id'   => array( 'type' => 'integer' ),
+								'variation_id' => array( 'type' => 'integer' ),
+								'quantity'     => array( 'type' => 'integer' ),
+								'subtotal'     => array( 'type' => 'string' ),
+								'total'        => array( 'type' => 'string' ),
+							),
+							'additionalProperties' => false,
 						),
-						'additionalProperties' => false,
+						'order_item'
 					),
 				),
 			),
 			'additionalProperties' => false,
 		);
+		return ExtensibleAbilities::add_fields_schema( $schema, 'order' );
 	}

 	/**
@@ -290,18 +295,22 @@ trait OrderAbilityTrait {
 					continue;
 				}

-				$data['line_items'][] = 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(),
+				$data['line_items'][] = 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(),
+					),
+					'order_item',
+					$item
 				);
 			}
 		}

-		return $data;
+		return ExtensibleAbilities::add_fields_to_object( $data, 'order', $order );
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index 56338cfa5fc..462cb938eee 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;

 defined( 'ABSPATH' ) || exit;

@@ -784,7 +785,7 @@ trait ProductAbilityTrait {
 		$stock_quantity = $product->get_stock_quantity();
 		$permalink      = $product->get_permalink();

-		return array(
+		$data = array(
 			'id'                => $product->get_id(),
 			'name'              => $product->get_name(),
 			'slug'              => $product->get_slug(),
@@ -813,6 +814,8 @@ trait ProductAbilityTrait {
 			'date_modified'     => wc_rest_prepare_date_response( $product->get_date_modified(), false ),
 			'date_modified_gmt' => wc_rest_prepare_date_response( $product->get_date_modified() ),
 		);
+
+		return ExtensibleAbilities::add_fields_to_object( $data, 'product', $product );
 	}

 	/**
@@ -821,87 +824,90 @@ trait ProductAbilityTrait {
 	 * @return array
 	 */
 	protected static function get_product_output_schema(): array {
-		return array(
-			'type'                 => 'object',
-			'properties'           => array(
-				'id'                => array( 'type' => 'integer' ),
-				'name'              => array( 'type' => 'string' ),
-				'slug'              => array( 'type' => 'string' ),
-				'permalink'         => array(
-					'type'        => array( 'string', 'null' ),
-					'description' => __( 'Product permalink, or null when no public permalink is available.', 'woocommerce' ),
-					'format'      => 'uri',
-				),
-				'type'              => array(
-					'type'        => 'string',
-					'description' => __( 'Internal product type slug, such as simple, external, grouped, or variable.', 'woocommerce' ),
-					'enum'        => array_keys( wc_get_product_types() ),
-				),
-				'status'            => array(
-					'type' => 'string',
-					'enum' => self::get_product_output_status_slugs(),
-				),
-				'sku'               => array( 'type' => 'string' ),
-				'currency'          => array(
-					'type' => 'string',
-					'enum' => array_keys( get_woocommerce_currencies() ),
-				),
-				'currency_symbol'   => array( 'type' => 'string' ),
-				'price'             => array(
-					'type'        => 'string',
-					'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
-				),
-				'regular_price'     => array(
-					'type'        => 'string',
-					'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
-				),
-				'sale_price'        => array(
-					'type'        => 'string',
-					'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
-				),
-				'stock_status'      => array(
-					'type' => 'string',
-					'enum' => array_keys( wc_get_product_stock_status_options() ),
-				),
-				'stock_quantity'    => array(
-					'type'        => array( self::get_product_stock_quantity_schema_type(), 'null' ),
-					'description' => __( 'Current stock quantity, or null when no stock quantity is set.', 'woocommerce' ),
-				),
-				'manage_stock'      => array( 'type' => 'boolean' ),
-				'virtual'           => array( 'type' => 'boolean' ),
-				'downloadable'      => array( 'type' => 'boolean' ),
-				'external_url'      => array(
-					'type'        => array( 'string', 'null' ),
-					'description' => __( 'External product URL for external products.', 'woocommerce' ),
-					'format'      => 'uri',
-				),
-				'button_text'       => array(
-					'type'        => array( 'string', 'null' ),
-					'description' => __( 'Button text for external products.', 'woocommerce' ),
-				),
-				'grouped_products'  => array(
-					'type'        => 'array',
-					'description' => __( 'Product IDs included as children of a grouped product.', 'woocommerce' ),
-					'items'       => array( 'type' => 'integer' ),
-				),
-				'date_created'      => array(
-					'type'   => array( 'string', 'null' ),
-					'format' => 'date-time',
-				),
-				'date_created_gmt'  => array(
-					'type'   => array( 'string', 'null' ),
-					'format' => 'date-time',
-				),
-				'date_modified'     => array(
-					'type'   => array( 'string', 'null' ),
-					'format' => 'date-time',
-				),
-				'date_modified_gmt' => array(
-					'type'   => array( 'string', 'null' ),
-					'format' => 'date-time',
+		return ExtensibleAbilities::add_fields_schema(
+			array(
+				'type'                 => 'object',
+				'properties'           => array(
+					'id'                => array( 'type' => 'integer' ),
+					'name'              => array( 'type' => 'string' ),
+					'slug'              => array( 'type' => 'string' ),
+					'permalink'         => array(
+						'type'        => array( 'string', 'null' ),
+						'description' => __( 'Product permalink, or null when no public permalink is available.', 'woocommerce' ),
+						'format'      => 'uri',
+					),
+					'type'              => array(
+						'type'        => 'string',
+						'description' => __( 'Internal product type slug, such as simple, external, grouped, or variable.', 'woocommerce' ),
+						'enum'        => array_keys( wc_get_product_types() ),
+					),
+					'status'            => array(
+						'type' => 'string',
+						'enum' => self::get_product_output_status_slugs(),
+					),
+					'sku'               => array( 'type' => 'string' ),
+					'currency'          => array(
+						'type' => 'string',
+						'enum' => array_keys( get_woocommerce_currencies() ),
+					),
+					'currency_symbol'   => array( 'type' => 'string' ),
+					'price'             => array(
+						'type'        => 'string',
+						'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+					),
+					'regular_price'     => array(
+						'type'        => 'string',
+						'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+					),
+					'sale_price'        => array(
+						'type'        => 'string',
+						'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+					),
+					'stock_status'      => array(
+						'type' => 'string',
+						'enum' => array_keys( wc_get_product_stock_status_options() ),
+					),
+					'stock_quantity'    => array(
+						'type'        => array( self::get_product_stock_quantity_schema_type(), 'null' ),
+						'description' => __( 'Current stock quantity, or null when no stock quantity is set.', 'woocommerce' ),
+					),
+					'manage_stock'      => array( 'type' => 'boolean' ),
+					'virtual'           => array( 'type' => 'boolean' ),
+					'downloadable'      => array( 'type' => 'boolean' ),
+					'external_url'      => array(
+						'type'        => array( 'string', 'null' ),
+						'description' => __( 'External product URL for external products.', 'woocommerce' ),
+						'format'      => 'uri',
+					),
+					'button_text'       => array(
+						'type'        => array( 'string', 'null' ),
+						'description' => __( 'Button text for external products.', 'woocommerce' ),
+					),
+					'grouped_products'  => array(
+						'type'        => 'array',
+						'description' => __( 'Product IDs included as children of a grouped product.', 'woocommerce' ),
+						'items'       => array( 'type' => 'integer' ),
+					),
+					'date_created'      => array(
+						'type'   => array( 'string', 'null' ),
+						'format' => 'date-time',
+					),
+					'date_created_gmt'  => array(
+						'type'   => array( 'string', 'null' ),
+						'format' => 'date-time',
+					),
+					'date_modified'     => array(
+						'type'   => array( 'string', 'null' ),
+						'format' => 'date-time',
+					),
+					'date_modified_gmt' => array(
+						'type'   => array( 'string', 'null' ),
+						'format' => 'date-time',
+					),
 				),
+				'additionalProperties' => false,
 			),
-			'additionalProperties' => false,
+			'product'
 		);
 	}

diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 66ab0b744c5..88c0f0eebfb 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -7,6 +7,7 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\Internal\Features;

+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
 use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview;
 use WC_Tracks;
 use WC_Site_Tracking;
@@ -755,6 +756,15 @@ class FeaturesController {
 				'is_experimental'              => true,
 				'disable_ui'                   => false,
 			),
+			ExtensibleAbilities::FEATURE_ID        => array(
+				'name'                         => __( 'Extensible abilities', 'woocommerce' ),
+				'description'                  => __( 'Let extensions add their fields to the output of WooCommerce abilities.', 'woocommerce' ),
+				'is_experimental'              => true,
+				'enabled_by_default'           => false,
+				'disable_ui'                   => false,
+				'skip_compatibility_checks'    => true,
+				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+			),
 		);

 		if ( ! $tracking_enabled ) {
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
new file mode 100644
index 00000000000..6a897028afa
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -0,0 +1,638 @@
+<?php
+/**
+ * ExtensibleAbilitiesTest class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;
+
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
+
+/**
+ * Namespaced extension fields in the output of the product and order abilities, read through the REST route.
+ */
+class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
+
+	private const CODE_SCHEMA = array(
+		'type'  => 'string',
+		'title' => 'Test code',
+	);
+
+	/**
+	 * Shared administrator used as the current user.
+	 *
+	 * @var int
+	 */
+	private static $administrator_id;
+
+	/**
+	 * Original init action count restored in tearDown.
+	 *
+	 * @var int|null
+	 */
+	private $original_init_count;
+
+	/**
+	 * Create immutable class fixtures.
+	 *
+	 * @param \WP_UnitTest_Factory $factory WordPress unit test factory.
+	 */
+	public static function wpSetUpBeforeClass( $factory ): void {
+		self::$administrator_id = $factory->user->create( array( 'role' => 'administrator' ) );
+	}
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		global $wp_actions;
+
+		if ( ! function_exists( 'wp_register_ability' ) ) {
+			require_once WC_ABSPATH . 'vendor/wordpress/abilities-api/includes/bootstrap.php';
+		}
+
+		$this->original_init_count = $wp_actions['init'] ?? null;
+		$wp_actions['init']        = max( 1, (int) ( $wp_actions['init'] ?? 0 ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		parent::setUp();
+
+		if ( class_exists( 'WP_REST_Abilities_Init' ) && ! isset( $this->server->get_routes()['/wp-abilities/v1/abilities'] ) ) {
+			\WP_REST_Abilities_Init::register_routes( $this->server );
+		}
+
+		AbilitiesLoader::init();
+		$this->reset_registries();
+		wp_set_current_user( self::$administrator_id );
+		$this->set_feature( true );
+		add_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_fields' ) );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		global $wp_actions;
+
+		remove_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_fields' ) );
+		$this->set_feature( false );
+		$this->reset_registries();
+		wp_set_current_user( 0 );
+
+		parent::tearDown();
+
+		if ( null !== $this->original_init_count ) {
+			$wp_actions['init'] = $this->original_init_count; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		} else {
+			unset( $wp_actions['init'] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		}
+	}
+
+	/**
+	 * Register the fields of two extensions, the way an extension does inside woocommerce_extensible_abilities_init.
+	 *
+	 * @internal
+	 */
+	public function register_test_fields(): void {
+		ExtensibleAbilities::register_field(
+			array(
+				'resource'     => 'product',
+				'namespace'    => 'test-ext',
+				'field'        => 'code',
+				'schema'       => self::CODE_SCHEMA,
+				'get_callback' => static function ( \WC_Product $product ) {
+					$code = $product->get_meta( '_test_code' );
+					return '' === $code ? null : $code;
+				},
+			)
+		);
+		ExtensibleAbilities::register_field(
+			array(
+				'resource'     => 'product',
+				'namespace'    => 'other_ext',
+				'field'        => 'code',
+				'schema'       => array( 'type' => 'integer' ),
+				'get_callback' => static function ( \WC_Product $product ) {
+					return '' === $product->get_meta( '_test_code' ) ? null : 7;
+				},
+			)
+		);
+		ExtensibleAbilities::register_field(
+			array(
+				'resource'     => 'order',
+				'namespace'    => 'test-ext',
+				'field'        => 'note',
+				'schema'       => array( 'type' => 'string' ),
+				'get_callback' => static function ( \WC_Order $order ) {
+					return 'order-' . $order->get_id();
+				},
+			)
+		);
+		ExtensibleAbilities::register_field(
+			array(
+				'resource'     => 'order_item',
+				'namespace'    => 'test-ext',
+				'field'        => 'gift',
+				'schema'       => array( 'type' => 'string' ),
+				'get_callback' => static function ( \WC_Order_Item $item ) {
+					return 'item-' . $item->get_id();
+				},
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return the fields of each extension under its namespace, and leave out a product with no values.
+	 */
+	public function test_product_reads_return_fields_under_their_namespace(): void {
+		$with_code = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen with code' ) );
+		$with_code->update_meta_data( '_test_code', 'A1' );
+		$with_code->save();
+		$without_code = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen without code' ) );
+		$expected     = array(
+			'test-ext'  => array( 'code' => 'A1' ),
+			'other_ext' => array( 'code' => 7 ),
+		);
+
+		$products = array_column( $this->run_ability( 'woocommerce/products-query', array( 'search' => 'Pen' ) )['products'], null, 'id' );
+		$updated  = $this->run_ability(
+			'woocommerce/product-update',
+			array(
+				'id'   => $with_code->get_id(),
+				'name' => 'Pencil',
+			)
+		);
+
+		$this->assertSame( $expected, $products[ $with_code->get_id() ]['extensions'] );
+		$this->assertArrayNotHasKey( 'extensions', $products[ $without_code->get_id() ] );
+		$this->assertSame( $expected, $updated['product']['extensions'] );
+	}
+
+	/**
+	 * @testdox Should return order and line item fields under their namespace.
+	 */
+	public function test_order_reads_return_fields_under_their_namespace(): void {
+		$order = \WC_Helper_Order::create_order();
+		$item  = current( $order->get_items() );
+
+		$listed = $this->run_ability(
+			'woocommerce/orders-query',
+			array(
+				'per_page'           => 1,
+				'include_line_items' => true,
+			)
+		)['orders'][0];
+		$noted  = $this->run_ability(
+			'woocommerce/order-add-note',
+			array(
+				'id'   => $order->get_id(),
+				'note' => 'Hi',
+			)
+		);
+
+		$this->assertSame( array( 'test-ext' => array( 'note' => 'order-' . $order->get_id() ) ), $listed['extensions'] );
+		$this->assertSame( array( 'test-ext' => array( 'gift' => 'item-' . $item->get_id() ) ), $listed['line_items'][0]['extensions'] );
+		$this->assertSame( array( 'test-ext' => array( 'note' => 'order-' . $order->get_id() ) ), $noted['order']['extensions'] );
+	}
+
+	/**
+	 * @testdox Should list the fields of each namespace in the output schemas, and add nothing for a resource with no fields.
+	 */
+	public function test_output_schemas_list_fields_by_namespace(): void {
+		$products = $this->get_output_schema( 'woocommerce/products-query' )['properties']['products']['items']['properties']['extensions'];
+		$product  = $this->get_output_schema( 'woocommerce/product-update' )['properties']['product']['properties']['extensions'];
+		$order    = $this->get_output_schema( 'woocommerce/orders-query' )['properties']['orders']['items']['properties'];
+
+		$this->assertSame( self::CODE_SCHEMA, $products['properties']['test-ext']['properties']['code'] );
+		$this->assertSame( array( 'type' => 'integer' ), $products['properties']['other_ext']['properties']['code'] );
+		$this->assertSame( self::CODE_SCHEMA, $product['properties']['test-ext']['properties']['code'] );
+		$this->assertSame( array( 'type' => 'string' ), $order['extensions']['properties']['test-ext']['properties']['note'] );
+		$this->assertSame( array( 'type' => 'string' ), $order['line_items']['items']['properties']['extensions']['properties']['test-ext']['properties']['gift'] );
+		$this->assertSame( array( 'type' => 'object' ), ExtensibleAbilities::add_fields_schema( array( 'type' => 'object' ), 'customer' ) );
+		$this->assertFalse( $products['additionalProperties'] );
+		$this->assertFalse( $products['properties']['test-ext']['additionalProperties'] );
+	}
+
+	/**
+	 * @testdox Should fire woocommerce_extensible_abilities_init one time, before the abilities build their schemas.
+	 */
+	public function test_init_action_fires_once_before_the_schemas(): void {
+		$events     = array();
+		$on_init    = static function () use ( &$events ) {
+			$events[] = 'extensions';
+		};
+		$on_ability = static function ( $args, $name ) use ( &$events ) {
+			$events[] = $name;
+			return $args;
+		};
+		add_action( 'woocommerce_extensible_abilities_init', $on_init );
+		add_filter( 'wp_register_ability_args', $on_ability, 10, 2 );
+
+		$this->get_output_schema( 'woocommerce/products-query' );
+		do_action( 'wp_abilities_api_init' );
+
+		remove_action( 'woocommerce_extensible_abilities_init', $on_init );
+		remove_filter( 'wp_register_ability_args', $on_ability, 10 );
+		$this->assertSame( 1, did_action( 'woocommerce_extensible_abilities_init' ) );
+		$this->assertSame( 'extensions', $events[0] );
+		$this->assertSame( 1, count( array_keys( $events, 'extensions', true ) ) );
+		$this->assertContains( 'woocommerce/products-query', $events );
+	}
+
+	/**
+	 * @testdox Should refuse a field that is registered after the init action, and keep the abilities valid.
+	 */
+	public function test_a_field_registered_after_the_init_action_is_refused(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+		$this->get_output_schema( 'woocommerce/products-query' );
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen late' ) );
+
+		ExtensibleAbilities::register_field(
+			array(
+				'resource'     => 'product',
+				'namespace'    => 'late-ext',
+				'field'        => 'code',
+				'schema'       => array( 'type' => 'string' ),
+				'get_callback' => static fn() => 'late',
+			)
+		);
+		$products = array_column( $this->run_ability( 'woocommerce/products-query', array( 'search' => 'Pen late' ) )['products'], null, 'id' );
+
+		$this->assertArrayNotHasKey( 'late-ext', $products[ $product->get_id() ]['extensions'] ?? array() );
+		$this->assertArrayNotHasKey( 'late-ext', ExtensibleAbilities::add_fields_to_object( array(), 'product', $product )['extensions'] ?? array() );
+	}
+
+	/**
+	 * @testdox Should let an extension register fields on a resource that another extension registers, whatever the hook priorities.
+	 */
+	public function test_register_resource_lets_an_extension_register_fields_on_its_own_resource(): void {
+		$register_resource = static function () {
+			ExtensibleAbilities::register_resource( 'ticket' );
+		};
+		$register_field    = static function () {
+			ExtensibleAbilities::register_field(
+				array(
+					'resource'     => 'ticket',
+					'namespace'    => 'test-ext',
+					'field'        => 'code',
+					'schema'       => array( 'type' => 'string' ),
+					'get_callback' => static fn() => 'T1',
+				)
+			);
+		};
+		add_action( 'woocommerce_extensible_abilities_resources_init', $register_resource, 99 );
+		add_action( 'woocommerce_extensible_abilities_init', $register_field, 1 );
+		$this->get_output_schema( 'woocommerce/products-query' );
+		remove_action( 'woocommerce_extensible_abilities_resources_init', $register_resource, 99 );
+		remove_action( 'woocommerce_extensible_abilities_init', $register_field, 1 );
+
+		$output = ExtensibleAbilities::add_fields_to_object( array(), 'ticket', new \stdClass() );
+		$schema = ExtensibleAbilities::add_fields_schema( array( 'type' => 'object' ), 'ticket' );
+
+		$this->assertSame( array( 'test-ext' => array( 'code' => 'T1' ) ), $output['extensions'] );
+		$this->assertSame( array( 'type' => 'string' ), $schema['properties']['extensions']['properties']['test-ext']['properties']['code'] );
+	}
+
+	/**
+	 * @testdox Should refuse a resource that is registered after the resources action.
+	 */
+	public function test_a_resource_registered_after_the_resources_action_is_refused(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_resource' );
+		$this->get_output_schema( 'woocommerce/products-query' );
+
+		ExtensibleAbilities::register_resource( 'late_resource' );
+
+		$resources = new \ReflectionProperty( ExtensibleAbilities::class, 'resources' );
+		$resources->setAccessible( true );
+		$this->assertSame( array( 'product', 'order', 'order_item' ), array_keys( $resources->getValue() ) );
+	}
+
+	/**
+	 * @testdox Should refuse a resource name made only of digits.
+	 */
+	public function test_a_resource_name_of_only_digits_is_refused(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_resource' );
+
+		ExtensibleAbilities::register_resource( '42' );
+
+		$resources = new \ReflectionProperty( ExtensibleAbilities::class, 'resources' );
+		$resources->setAccessible( true );
+		$this->assertSame( array(), $resources->getValue() );
+	}
+
+	/**
+	 * @testdox Should tell an extension to use the init action when it registers a field before the resources exist.
+	 */
+	public function test_a_field_registered_before_the_resources_points_to_the_init_action(): void {
+		$messages = array();
+		$capture  = static function ( $function_name, $message ) use ( &$messages ) {
+			$messages[] = $message;
+		};
+		add_action( 'doing_it_wrong_run', $capture, 10, 2 );
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+
+		ExtensibleAbilities::register_field(
+			array(
+				'resource'     => 'product',
+				'namespace'    => 'test-ext',
+				'field'        => 'code',
+				'schema'       => array( 'type' => 'string' ),
+				'get_callback' => static fn() => 'T1',
+			)
+		);
+		remove_action( 'doing_it_wrong_run', $capture, 10 );
+
+		$this->assertStringContainsString( 'inside the woocommerce_extensible_abilities_init action', $messages[0] ?? '' );
+	}
+
+	/**
+	 * @testdox Should not fire woocommerce_extensible_abilities_init or change the abilities with the feature off.
+	 */
+	public function test_nothing_changes_with_the_feature_off(): void {
+		$this->set_feature( false );
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		$product->update_meta_data( '_test_code', 'A1' );
+		$product->save();
+		\WC_Helper_Order::create_order();
+
+		$products = $this->run_ability( 'woocommerce/products-query', array( 'search' => 'Pen' ) )['products'];
+		$orders   = $this->run_ability(
+			'woocommerce/orders-query',
+			array(
+				'per_page'           => 1,
+				'include_line_items' => true,
+			)
+		)['orders'];
+
+		$this->assertSame( 0, did_action( 'woocommerce_extensible_abilities_resources_init' ) );
+		$this->assertSame( 0, did_action( 'woocommerce_extensible_abilities_init' ) );
+		$this->assertArrayNotHasKey( 'extensions', $this->get_output_schema( 'woocommerce/products-query' )['properties']['products']['items']['properties'] );
+		$this->assertArrayNotHasKey( 'extensions', $products[0] );
+		$this->assertArrayNotHasKey( 'extensions', $orders[0] );
+		$this->assertArrayNotHasKey( 'extensions', $orders[0]['line_items'][0] );
+	}
+
+	/**
+	 * @testdox Should let an ability that is not Core's return the registered fields.
+	 */
+	public function test_an_ability_that_is_not_cores_returns_the_fields(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$product->update_meta_data( '_test_code', 'X9' );
+		$product->save();
+		$register = static function () {
+			wp_register_ability(
+				'test-ext/product-read',
+				array(
+					'label'               => 'Read product',
+					'description'         => 'Reads a product.',
+					'category'            => 'woocommerce',
+					'input_schema'        => array(
+						'type'       => 'object',
+						'properties' => array( 'id' => array( 'type' => 'integer' ) ),
+					),
+					'output_schema'       => ExtensibleAbilities::add_fields_schema(
+						array(
+							'type'       => 'object',
+							'properties' => array( 'id' => array( 'type' => 'integer' ) ),
+						),
+						'product'
+					),
+					'execute_callback'    => static function ( array $input ) {
+						$product = wc_get_product( $input['id'] );
+						return ExtensibleAbilities::add_fields_to_object( array( 'id' => $product->get_id() ), 'product', $product );
+					},
+					'permission_callback' => '__return_true',
+					'meta'                => array(
+						'show_in_rest' => true,
+						'annotations'  => array( 'readonly' => true ),
+					),
+				)
+			);
+		};
+		add_action( 'wp_abilities_api_init', $register, 20 );
+
+		$output = $this->run_ability( 'test-ext/product-read', array( 'id' => $product->get_id() ) );
+
+		remove_action( 'wp_abilities_api_init', $register, 20 );
+		$this->assertSame( array( 'code' => 'X9' ), $output['extensions']['test-ext'] );
+	}
+
+	/**
+	 * @testdox Should refuse a field with invalid arguments, and keep the first registration of a namespace and field.
+	 */
+	public function test_invalid_and_duplicate_fields_are_refused(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+		$valid = array(
+			'resource'     => 'order',
+			'namespace'    => 'test-ext',
+			'field'        => 'note',
+			'schema'       => array( 'type' => 'string' ),
+			'get_callback' => static fn() => 'second',
+		);
+		AbilitiesLoader::register_core_resources();
+		$this->register_test_fields();
+
+		ExtensibleAbilities::register_field( $valid );
+		ExtensibleAbilities::register_field( array_merge( $valid, array( 'namespace' => 'Test Ext' ) ) );
+		ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => '' ) ) );
+		ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => 'other' ), array( 'schema' => null ) ) );
+		ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => 'other' ), array( 'get_callback' => 'not_a_function' ) ) );
+		ExtensibleAbilities::register_field(
+			array_merge(
+				$valid,
+				array(
+					'field'    => 'other',
+					'resource' => 'line_item',
+				)
+			)
+		);
+		ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => 'other' ), array( 'schema' => array( 'description' => 'x' ) ) ) );
+		ExtensibleAbilities::register_field(
+			array_merge(
+				$valid,
+				array( 'field' => 'other' ),
+				array(
+					'schema' => array(
+						'type'     => 'string',
+						'required' => true,
+					),
+				)
+			)
+		);
+		ExtensibleAbilities::register_field( array_merge( $valid, array( 'namespace' => '0' ) ) );
+		ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => '123' ) ) );
+		$order = \WC_Helper_Order::create_order();
+
+		$this->assertSame(
+			array( 'test-ext' => array( 'note' => 'order-' . $order->get_id() ) ),
+			ExtensibleAbilities::add_fields_to_object( array(), 'order', $order )['extensions']
+		);
+	}
+
+	/**
+	 * @testdox Should leave out a field that throws or fails sanitization, cast a value to its schema type, and keep a null that the schema allows.
+	 */
+	public function test_field_values_follow_their_schema(): void {
+		AbilitiesLoader::register_core_resources();
+		$this->register_test_fields();
+		$fields = array(
+			'broken' => array(
+				'schema'       => array( 'type' => 'string' ),
+				'get_callback' => static function () {
+					throw new \RuntimeException( 'Broken' );
+				},
+			),
+			'count'  => array(
+				'schema'       => array( 'type' => 'integer' ),
+				'get_callback' => static fn() => '12',
+			),
+			'empty'  => array(
+				'schema'       => array( 'type' => array( 'string', 'null' ) ),
+				'get_callback' => '__return_null',
+			),
+			'dupes'  => array(
+				'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(
+				array(
+					'resource'  => 'order',
+					'namespace' => 'values',
+					'field'     => $field,
+				) + $args
+			);
+		}
+		$order = \WC_Helper_Order::create_order();
+
+		$output = ExtensibleAbilities::add_fields_to_object( array(), 'order', $order );
+
+		$this->assertSame(
+			array(
+				'count' => 12,
+				'empty' => null,
+			),
+			$output['extensions']['values']
+		);
+	}
+
+	/**
+	 * @testdox Should leave out and report one time a value that does not match its schema, and keep the output valid.
+	 */
+	public function test_a_value_that_does_not_match_its_schema_is_left_out(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+		add_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				ExtensibleAbilities::register_field(
+					array(
+						'resource'     => 'product',
+						'namespace'    => 'test-ext',
+						'field'        => 'count',
+						'schema'       => array( 'type' => 'integer' ),
+						'get_callback' => static fn() => 'many',
+					)
+				);
+			}
+		);
+		$reported = array();
+		add_action(
+			'doing_it_wrong_run',
+			static function ( $function_name, $message ) use ( &$reported ) {
+				$reported[] = $message;
+			},
+			10,
+			2
+		);
+		$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' );
+
+		$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"' )
+		);
+		$this->assertCount( 1, $matches );
+	}
+
+	/**
+	 * Run an ability through the REST route and return its output.
+	 *
+	 * @param string $name  Ability name.
+	 * @param array  $input Ability input.
+	 * @return array
+	 */
+	private function run_ability( string $name, array $input ): array {
+		$readonly = ! empty( wp_get_ability( $name )->get_meta()['annotations']['readonly'] );
+		$request  = new \WP_REST_Request( $readonly ? 'GET' : 'POST', '/wp-abilities/v1/abilities/' . $name . '/run' );
+		if ( $readonly ) {
+			$request->set_query_params( array( 'input' => $input ) );
+		} else {
+			$request->set_header( 'Content-Type', 'application/json' );
+			$request->set_body( wp_json_encode( array( 'input' => $input ) ) );
+		}
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+		return $response->get_data();
+	}
+
+	/**
+	 * Read an ability's output schema through the REST route.
+	 *
+	 * @param string $name Ability name.
+	 * @return array
+	 */
+	private function get_output_schema( string $name ): array {
+		$response = $this->server->dispatch( new \WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . $name ) );
+
+		$this->assertSame( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+		return $response->get_data()['output_schema'];
+	}
+
+	/**
+	 * Turn the feature on or off.
+	 *
+	 * @param bool $enabled Whether the feature is on.
+	 */
+	private function set_feature( bool $enabled ): void {
+		update_option( 'woocommerce_feature_' . ExtensibleAbilities::FEATURE_ID . '_enabled', $enabled ? 'yes' : 'no' );
+	}
+
+	/**
+	 * Drop every registered field and ability, so the next read fires the init actions again.
+	 */
+	private function reset_registries(): void {
+		global $wp_actions;
+
+		foreach ( array( 'fields', 'reported' ) as $property ) {
+			$reflection = new \ReflectionProperty( ExtensibleAbilities::class, $property );
+			$reflection->setAccessible( true );
+			$reflection->setValue( null, array() );
+		}
+
+		$reflection = new \ReflectionProperty( ExtensibleAbilities::class, 'resources' );
+		$reflection->setAccessible( true );
+		$reflection->setValue( null, array() );
+
+		foreach ( array( 'WP_Abilities_Registry', 'WP_Ability_Categories_Registry' ) as $registry ) {
+			if ( class_exists( $registry ) ) {
+				$instance = new \ReflectionProperty( $registry, 'instance' );
+				$instance->setAccessible( true );
+				$instance->setValue( null, null );
+			}
+		}
+		unset( $wp_actions['wp_abilities_api_init'], $wp_actions['wp_abilities_api_categories_init'], $wp_actions['woocommerce_extensible_abilities_resources_init'], $wp_actions['woocommerce_extensible_abilities_init'] );
+	}
+}