Commit a91efb2dae3 for woocommerce

commit a91efb2dae3a7cee28c4b0c620938ea6bf46a855
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Wed Nov 4 15:36:24 2026 -0300

    Let a nullable extension field declare its input with anyOf in abilities (#70608)

    * Let a nullable extension input field use anyOf instead of a type list

    register_field() required a top-level type in input_schema, so a field
    that takes null had to declare a type list such as ["string", "null"].
    MCP clients that allow one type per field can reject a type list. Accept
    an anyOf where each branch has one type in its place.

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

    * Refuse a type list in an extension field input schema, and name the anyOf types in refusals

    A top-level list of types in input_schema is now refused, so every
    nullable field uses anyOf, which more MCP clients accept.

    WordPress refuses a value that matches no anyOf branch with "does not
    match any of the expected formats", which hides what the field takes.
    describe_bad_fields() now names the branch types for a top-level anyOf,
    or returns the refusal of the branch the value matches by type.

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

    * Refuse unknown and non-list anyOf types for extension fields

    Registration now refuses unknown JSON type names and an anyOf with
    named keys. The anyOf refusal message falls back to the WordPress
    message when the branches do not each have their own type name, which
    also stops a TypeError for a branch with a list of types.

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

    * Keep the WordPress message for anyOf refusals except a type mismatch

    The anyOf helper now only names the branch types when the value matches
    none of them. Other refusals, such as an enum miss, use the WordPress
    message, which already names the reason.

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

    * Name each anyOf type once in a type mismatch refusal

    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-1654-nullable-field-anyof b/plugins/woocommerce/changelog/wooai-1654-nullable-field-anyof
new file mode 100644
index 00000000000..4638b00919a
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1654-nullable-field-anyof
@@ -0,0 +1,5 @@
+Significance: patch
+Type: dev
+Comment: Let experimental extensible ability fields declare nullable input with anyOf; feature is off by default.
+
+
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 062588099e5..61de9b642f0 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -443,9 +443,10 @@ abstract class ActionableAbility extends \WP_Ability {
 					/* translators: 1: Extension namespace. 2: Field name. */
 					$refusals[] = sprintf( __( 'The extension field "%1$s.%2$s" is read-only. Remove it from the input.', 'woocommerce' ), $namespace, $field );
 				} else {
-					$valid = rest_validate_value_from_schema( $value, $args['input_schema'], 'extensions.' . $namespace . '.' . $field );
+					$param = 'extensions.' . $namespace . '.' . $field;
+					$valid = rest_validate_value_from_schema( $value, $args['input_schema'], $param );
 					if ( is_wp_error( $valid ) ) {
-						$refusals[] = $valid->get_error_message();
+						$refusals[] = ( isset( $args['input_schema']['anyOf'] ) ? self::describe_any_of_refusal( $value, $args['input_schema']['anyOf'], $param ) : null ) ?? $valid->get_error_message();
 					}
 				}
 			}
@@ -453,6 +454,24 @@ abstract class ActionableAbility extends \WP_Ability {
 		return $refusals;
 	}

+	/**
+	 * Name the types an `anyOf` field takes when the value matches none of them, because the
+	 * WordPress message then names no type. Other refusals keep the WordPress message.
+	 *
+	 * @param mixed                            $value    Value from the input.
+	 * @param array<int, array<string, mixed>> $branches The `anyOf` branches.
+	 * @param string                           $param    Path of the field in the input.
+	 * @return string|null The refusal, or null to keep the WordPress message.
+	 */
+	private static function describe_any_of_refusal( $value, array $branches, string $param ): ?string {
+		$types = array_column( $branches, 'type' );
+		if ( count( array_filter( $types, 'is_string' ) ) !== count( $branches ) || '' !== rest_get_best_type_for_value( $value, $types ) ) {
+			return null;
+		}
+		/* translators: 1: Path of the field in the input. 2: Comma-separated list of JSON types. */
+		return sprintf( __( '%1$s is not of type %2$s.', 'woocommerce' ), $param, implode( ',', array_unique( $types ) ) );
+	}
+
 	/**
 	 * Say that a namespace has no such field, and list the fields that the agent can write.
 	 *
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index ea18ca77d72..439a1b57163 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -85,6 +85,11 @@ class ExtensibleAbilities {
 	 */
 	private const QUERY_ARG_KEYS = array( 'meta_query', 'tax_query', 'include_ids', 'exclude_ids' );

+	/**
+	 * Type names that the REST schema check knows. It accepts any value for another name.
+	 */
+	private const JSON_TYPES = array( 'array', 'boolean', 'integer', 'null', 'number', 'object', 'string' );
+
 	/**
 	 * Register a resource that abilities format, so extensions can register fields on it.
 	 *
@@ -148,8 +153,11 @@ class ExtensibleAbilities {
 	 *                                  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`.
+	 *                                  one `type` name, or an `anyOf` where each branch has one `type` name. A
+	 *                                  list of types is refused at the top level, because some clients refuse it:
+	 *                                  a nullable field uses `[{"type":"string"},{"type":"null"}]` as its `anyOf`.
+	 *                                  Core publishes it as declared. A value can be null only where it declares
+	 *                                  null. Required with `set_callback`.
 	 *     @type callable $set_callback Optional. Receives the object and the value, and changes the object
 	 *                                  in memory, when an ability creates or updates the object. It returns a
 	 *                                  WP_Error to reject the value, and then nothing is saved. A field without
@@ -225,8 +233,8 @@ class ExtensibleAbilities {
 				wc_doing_it_wrong( __METHOD__, 'The "set_callback" argument must be callable, and a field with an "input_schema" needs it.', '11.4.0' );
 				return;
 			}
-			if ( ! is_array( $args['input_schema'] ?? null ) || ! isset( $args['input_schema']['type'] ) ) {
-				wc_doing_it_wrong( __METHOD__, 'The "input_schema" argument must be an array with a "type".', '11.4.0' );
+			if ( ! is_array( $args['input_schema'] ?? null ) || ! self::input_schema_has_type( $args['input_schema'] ) ) {
+				wc_doing_it_wrong( __METHOD__, 'The "input_schema" argument must be an array with one "type" name, or with an "anyOf" where each branch has one "type" name. For a nullable field, use an "anyOf" with a {"type":"null"} branch instead of a list of types.', '11.4.0' );
 				return;
 			}
 		}
@@ -251,6 +259,30 @@ class ExtensibleAbilities {
 		self::$fields[ $args['resource'] ][ $args['namespace'] ][ $args['field'] ] = $args;
 	}

+	/**
+	 * Whether a field input schema has one top-level JSON type name, or a non-empty
+	 * `anyOf` list where each branch has one JSON type name. The REST schema check
+	 * needs a known type for every branch that a value can match.
+	 *
+	 * @param array<string, mixed> $schema Field input schema.
+	 * @return bool
+	 */
+	private static function input_schema_has_type( array $schema ): bool {
+		if ( isset( $schema['type'] ) ) {
+			return in_array( $schema['type'], self::JSON_TYPES, true );
+		}
+		$branches = $schema['anyOf'] ?? null;
+		if ( ! is_array( $branches ) || empty( $branches ) || array_values( $branches ) !== $branches ) {
+			return false;
+		}
+		foreach ( $branches as $branch ) {
+			if ( ! is_array( $branch ) || ! in_array( $branch['type'] ?? null, self::JSON_TYPES, true ) ) {
+				return false;
+			}
+		}
+		return true;
+	}
+
 	/**
 	 * Register a filter that the list ability of a resource accepts under `filters.extensions.<namespace>.<filter>`.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 00605cdf098..ef24c44d244 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -226,11 +226,8 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	 * @return array<string, mixed>
 	 */
 	private static function nullable( array $schema ): array {
-		$schema['type'] = array( $schema['type'], 'null' );
-		if ( isset( $schema['enum'] ) ) {
-			$schema['enum'][] = null;
-		}
-		return $schema;
+		$top = array_intersect_key( $schema, array( 'description' => true ) );
+		return $top + array( 'anyOf' => array( array_diff_key( $schema, $top ), array( 'type' => 'null' ) ) );
 	}

 	/**
@@ -1298,14 +1295,216 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * Schemas without a type, for a field input and for a query filter.
+	 * Schemas without a type, or with an anyOf branch without one type name, for a field input and for a query filter.
 	 *
 	 * @return array<string, array{0: array<string, mixed>}>
 	 */
 	public function provider_malformed_schemas(): array {
 		return array(
-			'no type'            => array( array( 'description' => 'x' ) ),
-			'anyOf without type' => array( array( 'anyOf' => array( array( 'type' => 'string' ), array( 'type' => 'null' ) ) ) ),
+			'no type'                => array( array( 'description' => 'x' ) ),
+			'empty anyOf'            => array( array( 'anyOf' => array() ) ),
+			'anyOf branch type list' => array( array( 'anyOf' => array( array( 'type' => array( 'string', 'null' ) ), array( 'type' => 'integer' ) ) ) ),
+			'anyOf branch no type'   => array( array( 'anyOf' => array( array( 'type' => 'string' ), array( 'enum' => array( 'a' ) ) ) ) ),
+			'anyOf branch bad type'  => array( array( 'anyOf' => array( array( 'type' => 'integre' ), array( 'type' => 'null' ) ) ) ),
+			'anyOf with named keys'  => array(
+				array(
+					'anyOf' => array(
+						'a' => array( 'type' => 'string' ),
+						'b' => array( 'type' => 'null' ),
+					),
+				),
+			),
+		);
+	}
+
+	/**
+	 * @testdox Should refuse a field whose input schema has a list of types or an unknown type at the top level.
+	 * @testWith [["string", "null"]]
+	 *           ["integre"]
+	 *
+	 * @param mixed $type Top-level type of the input schema.
+	 */
+	public function test_a_top_level_type_list_in_an_input_schema_is_refused( $type ): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+		AbilitiesLoader::register_core_resources();
+
+		ExtensibleAbilities::register_field(
+			array(
+				'resource'      => 'order',
+				'namespace'     => 'refused',
+				'field'         => 'value',
+				'output_schema' => array( 'type' => 'string' ),
+				'get_callback'  => '__return_null',
+				'input_schema'  => array( 'type' => $type ),
+				'set_callback'  => '__return_null',
+			)
+		);
+
+		$this->assertArrayNotHasKey( 'refused', ExtensibleAbilities::get_fields( 'order' ) );
+	}
+
+	/**
+	 * @testdox Should keep the WordPress message for an anyOf under a top-level type whose branches do not each have their own type name.
+	 * @testWith [[{"enum":["small"]},{"enum":["large"]}]]
+	 *           [[{"type":["integer","null"]},{"type":"null"}]]
+	 *           [[{"type":"string","enum":["small"]},{"type":"string","pattern":"^x"}]]
+	 *
+	 * @param array $any_of The anyOf branches.
+	 */
+	public function test_an_any_of_branch_without_a_type_keeps_the_wordpress_message( array $any_of ): void {
+		add_action(
+			'woocommerce_extensible_abilities_init',
+			static function () use ( $any_of ) {
+				ExtensibleAbilities::register_field(
+					array(
+						'resource'     => 'product',
+						'namespace'    => 'untyped',
+						'field'        => 'size',
+						'input_schema' => array(
+							'type'  => 'string',
+							'anyOf' => $any_of,
+						),
+						'set_callback' => '__return_null',
+					)
+				);
+			}
+		);
+		$this->reset_registries();
+		$product = \WC_Helper_Product::create_simple_product();
+
+		$refused = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'extensions' => array( 'untyped' => array( 'size' => 'huge' ) ),
+			)
+		);
+
+		$this->assertSame( 400, $refused->get_status() );
+		$this->assertSame( 'extensions.untyped.size does not match any of the expected formats.', $refused->get_data()['message'] );
+	}
+
+	/**
+	 * @testdox Should name each anyOf type once when several branches share a type and the value matches none.
+	 */
+	public function test_an_any_of_type_mismatch_names_each_type_once(): void {
+		add_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				ExtensibleAbilities::register_field(
+					array(
+						'resource'     => 'product',
+						'namespace'    => 'shared',
+						'field'        => 'size',
+						'input_schema' => array(
+							'anyOf' => array(
+								array(
+									'type' => 'string',
+									'enum' => array( 'small' ),
+								),
+								array(
+									'type'    => 'string',
+									'pattern' => '^x',
+								),
+								array( 'type' => 'null' ),
+							),
+						),
+						'set_callback' => '__return_null',
+					)
+				);
+			}
+		);
+		$this->reset_registries();
+		$product = \WC_Helper_Product::create_simple_product();
+
+		$refused = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'extensions' => array( 'shared' => array( 'size' => 7 ) ),
+			)
+		);
+
+		$this->assertSame( 400, $refused->get_status() );
+		$this->assertSame( 'extensions.shared.size is not of type string,null.', $refused->get_data()['message'] );
+	}
+
+	/**
+	 * @testdox Should register a field whose input schema is an anyOf of single types, publish it as declared and pass null or a typed value to the set_callback.
+	 */
+	public function test_an_any_of_input_schema_takes_null_and_a_typed_value(): void {
+		$input_schema = array(
+			'description' => 'Days of the trial.',
+			'anyOf'       => array( array( 'type' => 'integer' ), array( 'type' => 'null' ) ),
+		);
+		$received     = array();
+		add_action(
+			'woocommerce_extensible_abilities_init',
+			static function () use ( $input_schema, &$received ) {
+				ExtensibleAbilities::register_field(
+					array(
+						'resource'     => 'product',
+						'namespace'    => 'nullable',
+						'field'        => 'days',
+						'input_schema' => $input_schema,
+						'set_callback' => static function ( \WC_Data $data, $value ) use ( &$received ) {
+							unset( $data );
+							$received[] = $value;
+						},
+					)
+				);
+			}
+		);
+		$this->reset_registries();
+		$product = \WC_Helper_Product::create_simple_product();
+		$write   = fn( $value ) => $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'extensions' => array( 'nullable' => array( 'days' => $value ) ),
+			)
+		);
+
+		$published = $this->get_ability( 'woocommerce/product-update' )['input_schema']['properties']['extensions']['properties']['nullable']['properties']['days'];
+		$this->assertSame( $input_schema, $published );
+		$this->assertSame( 200, $write( 7 )->get_status() );
+		$this->assertSame( 200, $write( null )->get_status() );
+		$refused = $write( 'many' );
+		$this->assertSame( 400, $refused->get_status() );
+		$this->assertStringContainsString( 'extensions.nullable.days', $refused->get_data()['message'] );
+		$this->assertSame( array( 7, null ), $received );
+
+		$received = array();
+		$result   = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'         => $product->get_id(),
+				'extensions' => array( 'nullable' => array( 'days' => '8' ) ),
+			)
+		);
+		$this->assertNotWPError( $result );
+		$this->assertSame( array( 8 ), $received, 'A direct call gets the value cast to the integer branch.' );
+	}
+
+	/**
+	 * @testdox Should refuse a query filter schema that has an anyOf and no top-level type.
+	 */
+	public function test_a_query_filter_schema_needs_a_type_even_with_an_any_of(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_query_filter' );
+		AbilitiesLoader::register_core_resources();
+
+		ExtensibleAbilities::register_query_filter(
+			array(
+				'resource'  => 'order',
+				'namespace' => 'refused',
+				'filter'    => 'value',
+				'schema'    => array( 'anyOf' => array( array( 'type' => 'string' ), array( 'type' => 'null' ) ) ),
+				'callback'  => '__return_empty_array',
+			)
+		);
+
+		$this->assertSame(
+			array( 'type' => 'object' ),
+			ExtensibleAbilities::add_query_filters_schema( array( 'type' => 'object' ), 'order' )
 		);
 	}

@@ -1314,14 +1513,19 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	 */
 	public function test_valid_schemas_are_published_as_declared(): void {
 		$input_schemas = array(
-			'type_list'   => array( 'type' => array( 'string', 'null' ) ),
+			'nullable'    => array( 'anyOf' => array( array( 'type' => 'string' ), array( 'type' => 'null' ) ) ),
 			'any_of'      => array(
 				'type'       => 'object',
 				'properties' => array( 'a' => array( 'anyOf' => array( array( 'type' => 'string' ), array( 'type' => 'null' ) ) ) ),
 			),
 			'enum_null'   => array(
-				'type' => array( 'string', 'null' ),
-				'enum' => array( 'a', null ),
+				'anyOf' => array(
+					array(
+						'type' => 'string',
+						'enum' => array( 'a' ),
+					),
+					array( 'type' => 'null' ),
+				),
 			),
 			'enum_mixed'  => array(
 				'type' => 'string',
@@ -2200,7 +2404,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	 *           [{"nope":{}}, "No extension registered the namespace \"nope\" for this ability. Remove it from the input, or use a namespace from the input schema."]
 	 *           [{"other_ext":{"cuont":3}}, "The extension namespace \"other_ext\" has no field \"cuont\". The fields you can write in this namespace are: count, size. Use one of them, or remove \"other_ext.cuont\" from the input."]
 	 *           [{"test-ext":{"code":"A2"}}, "The extension field \"test-ext.code\" is read-only. Remove it from the input."]
-	 *           [{"other_ext":{"size":"huge"}}, "extensions.other_ext.size is not one of small, large, and null."]
+	 *           [{"other_ext":{"size":"huge"}}, "extensions.other_ext.size does not match the expected format. Reason: extensions.other_ext.size is not one of small and large."]
 	 *
 	 * @param array  $extensions Value of the `extensions` input.
 	 * @param string $message    Expected error message.
@@ -2321,7 +2525,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		$result = wp_get_ability( $ability )->execute( $input );

 		$this->assertSame(
-			'extensions.other_ext.size is not one of small, large, and null. variations[0]: extensions.other_ext.count is not of type integer,null. variations[1]: No extension registered the namespace "nope" for this ability. Remove it from the input, or use a namespace from the input schema.',
+			'extensions.other_ext.size does not match the expected format. Reason: extensions.other_ext.size is not one of small and large. variations[0]: extensions.other_ext.count is not of type integer,null. variations[1]: No extension registered the namespace "nope" for this ability. Remove it from the input, or use a namespace from the input schema.',
 			$result->get_error_message()
 		);
 		$this->assertSame( 0, $validated );
@@ -3276,7 +3480,8 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 			)
 		);

-		$this->assertSame( array( 'small', 'large', null ), $schema['enum'] );
+		$this->assertSame( array( 'small', 'large' ), $schema['anyOf'][0]['enum'] );
+		$this->assertSame( array( 'type' => 'null' ), $schema['anyOf'][1] );
 		$this->assertSame( 400, $rejected->get_status() );
 		$this->assertFalse( wc_get_product( $product->get_id() )->meta_exists( '_test_size' ) );
 	}
@@ -3298,7 +3503,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 			)
 		);

-		$this->assertSame( array( 'string', 'null' ), $schema['properties']['test-ext']['properties']['color']['type'] );
+		$this->assertSame( array( 'string', 'null' ), array_column( $schema['properties']['test-ext']['properties']['color']['anyOf'], 'type' ) );
 		$this->assertArrayNotHasKey( 'code', $schema['properties']['test-ext']['properties'] );
 		$this->assertSame( 'Pencil', wc_get_product( $product->get_id() )->get_name() );
 		$this->assertSame( array( 'color' => 'green' ), $output['extensions']['test-ext'] );
@@ -3443,14 +3648,14 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 			$schema     = $this->get_ability( $name )['input_schema'];
 			$extensions = $schema['properties']['extensions']['properties'];

-			$this->assertSame( array( 'string', 'null' ), $extensions['test-ext']['properties']['color']['type'] );
-			$this->assertSame( array( 'integer', 'null' ), $extensions['other_ext']['properties']['count']['type'] );
+			$this->assertSame( array( 'string', 'null' ), array_column( $extensions['test-ext']['properties']['color']['anyOf'], 'type' ) );
+			$this->assertSame( array( 'integer', 'null' ), array_column( $extensions['other_ext']['properties']['count']['anyOf'], 'type' ) );
 			$this->assertArrayNotHasKey( 'code', $extensions['test-ext']['properties'] );
 			$this->assertSame( 2, substr_count( wp_json_encode( $schema ), '"color"' ) );
 			$this->assertArrayNotHasKey( 'oneOf', $schema );
 		}
 		$order = $this->get_ability( 'woocommerce/order-update-status' )['input_schema'];
-		$this->assertSame( array( 'string', 'null' ), $order['properties']['extensions']['properties']['test-ext']['properties']['gift_message']['type'] );
+		$this->assertSame( array( 'string', 'null' ), array_column( $order['properties']['extensions']['properties']['test-ext']['properties']['gift_message']['anyOf'], 'type' ) );
 		$delete = $this->get_ability( 'woocommerce/product-delete' )['input_schema'];
 		$this->assertArrayNotHasKey( 'extensions', $delete['properties'], 'A delete writes no extension fields.' );
 	}