Commit 9229f5d638b for woocommerce

commit 9229f5d638b05d93fc2a15c217b6a19cc2692124
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Mon Nov 2 19:23:04 2026 -0300

    Separate the read and write schemas of extension ability fields (#70442)

    * Spike: accept registered product types in the product abilities

    Replace product_type_alias with the product type registry, and add
    virtual and downloadable as fields of their own.

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

    * Use one flat input schema for product-update

    The update schema lists id, type and the fields of every supported
    product type. A client no longer sends type to change a per-type field
    such as regular_price. After load() resolves the product, the update
    refuses a field that the product type does not support, with
    woocommerce_product_field_unsupported. product-create keeps one oneOf
    branch for each product type.

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

    * Let the owner of a product type choose its Core fields

    The product class still decides the default list of Core fields, and the
    new woocommerce_ability_product_type_fields filter lets the owner of a
    type add or remove one, such as a price for a bundle.

    Also from the review of the spike:

    - Leave a type without a class out of the create and update schemas
      instead of listing it and refusing it at run time.
    - Read the config of a loaded product from its class, not from its type.
    - Sanitize the virtual and downloadable query filters with
      rest_sanitize_boolean(), so "false" is false.
    - Drop the two config checks in ProductUpdate::load() that change()
      repeats.
    - Say in the create schema that a product without a type is simple.

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

    * List the fields of each product type in one flat input schema

    product-create had one oneOf branch per product type. A wrong field
    gave the message "input[type] is not simple", and every branch carried
    a stub for the extensions property. product-create now has the same
    flat schema as product-update. The description of type lists the fields
    that each type takes, and the run-time check names the type, the fields
    it refused, and the fields the type takes.

    The woocommerce_ability_product_type_fields filter also carries the
    writable extension fields of the product resource, as namespace.field.
    The owner of an extension field removes it from the types it does not
    apply to, so the schema and the error name the right fields for each
    type. register_field() stays resource-neutral. The filter no longer
    receives the class name, because the owner of a type knows its class.

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

    * Keep the field list out of the type description

    The description of type listed the fields of every product type. That
    is a long sentence an agent has to parse, and it repeats what the
    run-time error already says. The schema now lists every field once,
    and the error names the fields the type takes.

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

    * Let a field name the object types it applies to

    An extension field that applies to some types of a resource had no
    structured way to say so. The product filter carried extension fields
    as namespace.field strings, which was product-specific and loose.

    ExtensibleAbilities::register_field() takes object_types: the types,
    as get_type() of the object returns them, that the field applies to.
    Core leaves the field out of other objects and refuses a write to them
    with woocommerce_ability_field_object_type, for every resource. The
    woocommerce_ability_product_type_fields filter goes back to Core
    fields only.

    Also: document why a type whose class is WC_Product_Simple itself is
    refused, and cast the boolean query filters with wc_string_to_bool so
    phpstan passes.

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

    * Keep the object_types diagnostic untranslated

    Developer diagnostics passed to wc_doing_it_wrong() stay in English, as
    the lint rule requires.

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

    * Shorten the changelog entry

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

    * Refuse variations in product-update and restricted fields on unknown types

    A product-update call with a variation ID could edit the variation with the
    simple product fields, and with a type it could write a simple product type
    on the variation post. product-update now refuses variations with the same
    woocommerce_product_type_unsupported error as products-query and
    product-delete, and the product class config refuses variation classes.

    A field with object_types no longer applies to an object whose type is
    unknown, and get_type() is only called when it is callable and the field is
    restricted.

    Addresses the CodeRabbit review on #70050.

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

    * Rename the changelog entry

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

    * Refuse a type change on a variable product

    product-update replaced the loaded product with one of the requested
    type before checking that the current type is supported, so a variable
    product could be saved as simple and lose its variations. The current
    type is checked first, as CodeRabbit pointed out.

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

    * Apply forced product properties only when the type changes

    A repeated `type` cleared the stock of a stocked grouped product. The
    forced properties now apply only when `type` differs from the stored type.

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

    * Tell agents which fields each product type takes

    The description of `type` lists the Core fields of each type, and an extension
    field with `object_types` says which types it applies to. The field list was
    removed in b669c71c74 because it looked long and repeated the error. A test with
    6 models and 8 tasks showed that it helps: 87% of calls were right on the first
    try with this guidance and 74% without it, with no wrong saves. Refusals stay
    the same.

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

    * Say on each Core field which product types take it

    A field that not every type takes now says "Applies to: ..." in its description.
    This is the shape that won a final test with 6 models and 10 clear tasks, set
    against the `type` field list alone: 96% of calls were right on the first try
    with both, and 92% with the list alone, with no wrong saves. The rule for
    replacing the list alone was fixed before the run.

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

    * Remove checks that repeat the input schema in the product abilities

    The input schema already limits the product type, so the abilities no
    longer check it again after validation. The product type fields filter
    result and get_type() of a subject are used as returned.

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

    * Separate the read and write schemas of extension ability fields

    A field declares `get_callback` and `output_schema`. A writable field also declares `set_callback` and `input_schema`. Core publishes each schema as declared and no longer adds null to the input. Registration refuses an output schema with null or more than one type at any depth, and a malformed field or query filter schema.

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

    * Allow write-only extension ability fields

    A field now declares get_callback and output_schema, set_callback and
    input_schema, or both pairs. An incomplete pair is refused. A write-only field
    stays out of the output, and a namespace with no readable field stays out of
    the output schema, because empty properties encode as a list.

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

    * Keep the new field registration notices in English

    Trunk now forbids translation calls in wc_doing_it_wrong() messages.

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

    * Keep the field schema error messages in English

    get_schema_error() builds these messages only for wc_doing_it_wrong(), so they follow the same rule as the notices. PHPCS cannot see them because they reach the call through a variable.

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

    * Refuse allOf in field schemas and check enum types on output only

    WordPress does not run allOf, so a field that declared string through it
    accepted any value. The enum type check protects the one-type rule of
    output_schema, and on input the type check already refuses a wrong value.

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

    * Refuse only field schemas that the MCP client cannot compile

    Drop the enum type check on output schemas. Require a top-level type in
    get_schema_error() for every field and query filter schema, and remove
    the separate query filter check.

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

    * Keep the object_types diagnostic message in English

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

    * Refuse field schema keywords that an MCP client cannot compile

    `required` must be a list of strings, not any array. Keywords such as
    `minLength`, `minimum`, `pattern` and `uniqueItems` must have the kind of
    value that JSON Schema defines. `exclusiveMinimum` and `exclusiveMaximum`
    must equal `minimum` and `maximum`, the one form that WordPress and Ajv
    read the same way. A test encodes every WooCommerce ability schema as JSON
    and checks the keyword value types that Ajv compiles.

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

    * Refuse sparse lists and zero exclusive bounds in field schemas

    JSON publishes an array with gaps in its keys as an object, so a sparse
    `required`, `enum`, `type`, `anyOf` or `oneOf` does not compile in the MCP
    client. WordPress reads an exclusive bound of 0 as false and accepts 0, and
    the client refuses it.

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

    * Fix the lint warnings in the schema checks and the refusal test

    Compare the numeric bounds as floats with strict comparisons, which
    also makes the phpcs ignore comments unnecessary, and align the arrays.

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

    * Accept a map of properties with numeric names in field schemas

    wp_is_numeric_array() refused a map such as array( '1' => ..., '2' => ... ),
    which JSON publishes as an object. Refuse only a list, as the other list
    checks of the schema do.

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

    * Check only that extension field schemas have a type

    Core publishes the schemas as declared. The extension validates them.

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

    * Leave out a null field value even when its output schema allows null

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

    * Remove the docblock lines about schema checks that Core no longer runs

    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-1627-separate-field-schemas b/plugins/woocommerce/changelog/wooai-1627-separate-field-schemas
new file mode 100644
index 00000000000..f808a98cf56
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1627-separate-field-schemas
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Split the schema of an extension ability field into `output_schema` and `input_schema`, rename `update_callback` to `set_callback`, and publish both schemas as the extension declares them.
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 97461fb49a9..6feefa4897c 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -12,8 +12,8 @@ defined( 'ABSPATH' ) || exit;
 /**
  * An ability that changes one object and saves it one time. Register it with
  * a subclass as the `ability_class`. The execute callback loads the object,
- * checks the `extensions` input against the field schemas, changes the object
- * in memory, writes the fields with their `update_callback`, runs the validators
+ * 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.
  *
@@ -161,9 +161,9 @@ abstract class ActionableAbility extends \WP_Ability {
 	}

 	/**
-	 * Check `extensions.<namespace>.<field>` input values against the schema of
-	 * each field, and cast them. No callback runs here. A null value is kept as
-	 * is, to delete the value.
+	 * Check `extensions.<namespace>.<field>` input values against the input
+	 * schema of each field, and cast them. No callback runs here. A null value
+	 * passes only where the input schema declares null.
 	 *
 	 * @param array<string, mixed> $values Values keyed by namespace, then field.
 	 * @return array|\WP_Error A list of field arguments and value pairs, or a WP_Error when a value is rejected.
@@ -174,7 +174,7 @@ abstract class ActionableAbility extends \WP_Ability {
 		foreach ( $values as $namespace => $fields ) {
 			foreach ( (array) $fields as $field => $value ) {
 				$args = $registered[ $namespace ][ $field ] ?? array();
-				if ( ! isset( $args['update_callback'] ) ) {
+				if ( ! isset( $args['set_callback'] ) ) {
 					return new \WP_Error(
 						'woocommerce_ability_field_not_writable',
 						/* translators: 1: Extension namespace. 2: Field name. */
@@ -182,25 +182,23 @@ abstract class ActionableAbility extends \WP_Ability {
 						array( 'status' => 400 )
 					);
 				}
-				if ( null !== $value ) {
-					$param = 'extensions.' . $namespace . '.' . $field;
-					$valid = rest_validate_value_from_schema( $value, $args['schema'], $param );
-					if ( is_wp_error( $valid ) ) {
-						return self::with_data( $valid, array( 'field' => $param ) );
-					}
-					$value = rest_sanitize_value_from_schema( $value, $args['schema'], $param );
-					if ( is_wp_error( $value ) ) {
-						return self::with_data( $value, array( 'field' => $param ) );
-					}
+				$param = 'extensions.' . $namespace . '.' . $field;
+				$valid = rest_validate_value_from_schema( $value, $args['input_schema'], $param );
+				if ( is_wp_error( $valid ) ) {
+					return self::with_data( $valid, array( 'field' => $param ) );
 				}
-				$updates[ 'extensions.' . $namespace . '.' . $field ] = array( $args, $value );
+				$value = rest_sanitize_value_from_schema( $value, $args['input_schema'], $param );
+				if ( is_wp_error( $value ) ) {
+					return self::with_data( $value, array( 'field' => $param ) );
+				}
+				$updates[ $param ] = array( $args, $value );
 			}
 		}
 		return $updates;
 	}

 	/**
-	 * Write the checked values to the object in memory with the update_callback
+	 * Write the checked values to the object in memory with the set_callback
 	 * of each field, also after one rejects its value. Each rejection goes into
 	 * the given errors, as add_refusal() describes.
 	 *
@@ -223,7 +221,7 @@ abstract class ActionableAbility extends \WP_Ability {
 					array( 'status' => 400 )
 				);
 			} else {
-				$rejected = self::run_callback( $args['update_callback'], array( $subject, $value ), array( 'field' => $param ) );
+				$rejected = self::run_callback( $args['set_callback'], array( $subject, $value ), array( 'field' => $param ) );
 			}
 			if ( null !== $rejected ) {
 				self::add_refusal( $rejected, explode( '.', $param )[1], $refused );
@@ -278,7 +276,7 @@ abstract class ActionableAbility extends \WP_Ability {
 	 * logged and becomes a 500 with a fixed message, so its text never reaches
 	 * the client.
 	 *
-	 * @param callable             $callback Field update_callback or validator.
+	 * @param callable             $callback Field set_callback or validator.
 	 * @param array                $args     Callback arguments.
 	 * @param array<string, mixed> $data     Data that names the field or the namespace.
 	 * @return \WP_Error|null
@@ -304,8 +302,9 @@ abstract class ActionableAbility extends \WP_Ability {

 	/**
 	 * Add the `extensions` property one time, at the top level of the input
-	 * schema. It lists the fields that have an update_callback, allows null for
-	 * each one, and says which object types a field with `object_types` applies to. Each `oneOf`, `anyOf` and `allOf` branch only accepts the
+	 * schema. It lists the fields that have a set_callback, each with its input
+	 * schema as declared, and says which object types a field with `object_types`
+	 * applies to. Each `oneOf`, `anyOf` and `allOf` branch only accepts the
 	 * key, so a branch with `additionalProperties` set to false does not reject
 	 * it, and the schema does not grow with the number of branches.
 	 *
@@ -315,14 +314,14 @@ abstract class ActionableAbility extends \WP_Ability {
 	private function add_extensions_input_schema( array $schema ): array {
 		$extensions = array(
 			'type'                 => 'object',
-			'description'          => __( 'Values to write to the fields that extensions add, keyed by extension namespace, then field. A null value deletes the value.', 'woocommerce' ),
+			'description'          => __( 'Values to write to the fields that extensions add, keyed by extension namespace, then field.', 'woocommerce' ),
 			'additionalProperties' => false,
 		);
 		foreach ( ExtensibleAbilities::get_fields( $this->get_resource() ) as $namespace => $fields ) {
 			$writable = array_filter(
 				$fields,
 				static function ( array $args ): bool {
-					return isset( $args['update_callback'] );
+					return isset( $args['set_callback'] );
 				}
 			);
 			if ( empty( $writable ) ) {
@@ -332,13 +331,7 @@ abstract class ActionableAbility extends \WP_Ability {
 				'type'                 => 'object',
 				'properties'           => array_map(
 					static function ( array $args ): array {
-						$field_schema = $args['schema'];
-						if ( isset( $field_schema['type'] ) ) {
-							$field_schema['type'] = array_values( array_unique( array_merge( (array) $field_schema['type'], array( 'null' ) ) ) );
-						}
-						if ( isset( $field_schema['enum'] ) && ! in_array( null, $field_schema['enum'], true ) ) {
-							$field_schema['enum'][] = null;
-						}
+						$field_schema = $args['input_schema'];
 						if ( ! empty( $args['object_types'] ) ) {
 							/* translators: %s: Comma-separated list of object types. */
 							$applies_to                  = sprintf( __( 'Applies to: %s.', 'woocommerce' ), implode( ', ', $args['object_types'] ) );
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index ae7b72189a7..4860d8055d3 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -20,7 +20,7 @@ defined( 'ABSPATH' ) || exit;
  * object of that resource returns the field values under
  * `extensions.<namespace>.<field>`, and lists the fields in its output schema.
  * An ActionableAbility writes the same `extensions` input to the fields that
- * have an `update_callback`, runs the validators of the resource, and saves
+ * have a `set_callback`, runs the validators of the resource, and saves
  * the object one time.
  * An extension also registers a query filter, which the list ability of the
  * resource accepts under `filters.extensions.<namespace>.<filter>`.
@@ -115,6 +115,8 @@ class ExtensibleAbilities {
 	 *
 	 * 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.
+	 * A field declares `get_callback` and `output_schema`, `set_callback` and `input_schema`, or both pairs. The
+	 * first pair makes it readable and the second makes it writable. An incomplete pair is refused.
 	 *
 	 * @since 11.3.0
 	 *
@@ -124,29 +126,32 @@ class ExtensibleAbilities {
 	 *     @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.
-	 *                                  For a field that applies only to some objects, set `object_types`, or do not
-	 *                                  allow null and return null for the other objects. The field is then left
-	 *                                  out of them, with no error.
+	 *     @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
+	 *                                  `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
 	 *                                  `line_item` for an order item. The object must have a `get_type()` method.
 	 *                                  The abilities leave the field out of the other objects and refuse a write
 	 *                                  to them. Omit it for every type.
-	 *     @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.
-	 *     @type callable $update_callback Optional. Receives the object and the value, and changes the object
-	 *                                     in memory. It returns a WP_Error to reject the value, and then
-	 *                                     nothing is saved. A field without it cannot be written. Rules:
-	 *                                     1. It only sets values in memory. It does not save, send email
-	 *                                        or make HTTP requests.
-	 *                                     2. It receives the value already cast to the field schema.
-	 *                                     3. A null value deletes the value of the field.
+	 *     @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.
+	 *     @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`.
+	 *     @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
+	 *                                  it cannot be written. Required with `input_schema`. Rules:
+	 *                                  1. It only sets values in memory. It does not save, send email
+	 *                                     or make HTTP requests.
+	 *                                  2. It receives the value already cast to `input_schema`.
+	 *                                  3. A null value, where `input_schema` declares null, deletes the value
+	 *                                     of the field.
 	 * }
 	 */
 	public static function register_field( array $args ): void {
@@ -182,21 +187,31 @@ class ExtensibleAbilities {
 			);
 			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".', '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.', '11.3.0' );
+		$readable = isset( $args['get_callback'] ) || isset( $args['output_schema'] );
+		$writable = isset( $args['set_callback'] ) || isset( $args['input_schema'] );
+		if ( ! $readable && ! $writable ) {
+			wc_doing_it_wrong( __METHOD__, 'A field needs "get_callback" and "output_schema", "set_callback" and "input_schema", or both pairs.', '11.4.0' );
 			return;
 		}
-		if ( ! is_callable( $args['get_callback'] ?? null ) ) {
-			wc_doing_it_wrong( __METHOD__, 'The "get_callback" argument must be callable.', '11.3.0' );
-			return;
+		if ( $readable ) {
+			if ( ! is_callable( $args['get_callback'] ?? null ) ) {
+				wc_doing_it_wrong( __METHOD__, 'The "get_callback" argument must be callable, and a field with an "output_schema" needs it.', '11.3.0' );
+				return;
+			}
+			if ( ! is_array( $args['output_schema'] ?? null ) || ! isset( $args['output_schema']['type'] ) ) {
+				wc_doing_it_wrong( __METHOD__, 'The "output_schema" argument must be an array with a "type".', '11.4.0' );
+				return;
+			}
 		}
-		if ( isset( $args['update_callback'] ) && ! is_callable( $args['update_callback'] ) ) {
-			wc_doing_it_wrong( __METHOD__, 'The "update_callback" argument must be callable.', '11.3.0' );
-			return;
+		if ( $writable ) {
+			if ( ! is_callable( $args['set_callback'] ?? null ) ) {
+				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' );
+				return;
+			}
 		}
 		if ( isset( $args['object_types'] ) && ( ! is_array( $args['object_types'] ) || empty( $args['object_types'] ) || array_filter( $args['object_types'], static fn( $type ) => ! is_string( $type ) ) ) ) {
 			wc_doing_it_wrong( __METHOD__, 'The "object_types" argument must be a list of object types.', '11.4.0' );
@@ -468,7 +483,7 @@ class ExtensibleAbilities {
 	 * Call it inside the `woocommerce_extensible_abilities_init` action. Validators
 	 * run only when the `extensible_abilities` feature is on.
 	 *
-	 * Use a field `update_callback` to refuse a bad value of one field. It runs
+	 * Use a field `set_callback` to refuse a bad value of one field. It runs
 	 * only when the input sends that field. Use a validator to refuse a change as
 	 * a whole. It runs on every change of the resource, also when the input sends
 	 * no extension fields. For example, a validator refuses a product change that
@@ -542,8 +557,8 @@ 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 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 throws, returns null
+	 * 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.
 	 *
@@ -562,7 +577,7 @@ class ExtensibleAbilities {
 		$extensions = array();
 		foreach ( self::$fields[ $resource_name ] as $namespace => $fields ) {
 			foreach ( $fields as $field => $args ) {
-				if ( ! self::field_applies_to( $args, $subject ) ) {
+				if ( ! isset( $args['get_callback'] ) || ! self::field_applies_to( $args, $subject ) ) {
 					continue;
 				}
 				try {
@@ -574,10 +589,10 @@ class ExtensibleAbilities {
 					);
 					continue;
 				}
-				if ( ! self::matches_schema( $args, $value ) ) {
+				if ( null === $value || ! self::matches_schema( $args, $value ) ) {
 					continue;
 				}
-				$sanitized = rest_sanitize_value_from_schema( $value, $args['schema'], $field );
+				$sanitized = rest_sanitize_value_from_schema( $value, $args['output_schema'], $field );
 				if ( ! is_wp_error( $sanitized ) ) {
 					$extensions[ $namespace ][ $field ] = $sanitized;
 				}
@@ -618,7 +633,7 @@ class ExtensibleAbilities {
 	 * 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.
+	 * schema. The schema stays unchanged when the resource has no readable field.
 	 *
 	 * @since 11.3.0
 	 *
@@ -627,7 +642,7 @@ class ExtensibleAbilities {
 	 * @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 ] ) ) {
+		if ( ! self::is_enabled() ) {
 			return $schema;
 		}

@@ -637,17 +652,29 @@ class ExtensibleAbilities {
 			'additionalProperties' => false,
 		);
 		foreach ( self::$fields[ $resource_name ] ?? array() as $namespace => $fields ) {
+			$readable = array_filter(
+				$fields,
+				static function ( array $args ): bool {
+					return isset( $args['get_callback'] );
+				}
+			);
+			if ( empty( $readable ) ) {
+				continue;
+			}
 			$extensions['properties'][ $namespace ] = array(
 				'type'                 => 'object',
 				'properties'           => array_map(
 					static function ( array $args ): array {
-						return $args['schema'];
+						return $args['output_schema'];
 					},
-					$fields
+					$readable
 				),
 				'additionalProperties' => false,
 			);
 		}
+		if ( empty( $extensions['properties'] ) ) {
+			return $schema;
+		}
 		$schema['properties']['extensions'] = $extensions;
 		return $schema;
 	}
@@ -679,7 +706,7 @@ class ExtensibleAbilities {
 	}

 	/**
-	 * Whether a value matches its field schema. A value other than null that
+	 * 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.
 	 *
 	 * @param array<string, mixed> $args  Field arguments.
@@ -687,7 +714,7 @@ class ExtensibleAbilities {
 	 * @return bool
 	 */
 	private static function matches_schema( array $args, $value ): bool {
-		$valid = rest_validate_value_from_schema( $value, $args['schema'], $args['field'] );
+		$valid = rest_validate_value_from_schema( $value, $args['output_schema'], $args['field'] );
 		if ( true === $valid ) {
 			return true;
 		}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
index bde2c4e8c7e..e9ffea889e4 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
@@ -408,14 +408,14 @@ class ProductTermsQueryTest extends \WC_Unit_Test_Case {
 			static function () {
 				ExtensibleAbilities::register_field(
 					array(
-						'resource'     => 'product_term',
-						'namespace'    => 'test-ext',
-						'field'        => 'taxonomy',
-						'schema'       => array(
+						'resource'      => 'product_term',
+						'namespace'     => 'test-ext',
+						'field'         => 'taxonomy',
+						'output_schema' => array(
 							'type'  => 'string',
 							'title' => 'Taxonomy',
 						),
-						'get_callback' => static function ( \WP_Term $term ) {
+						'get_callback'  => static function ( \WP_Term $term ) {
 								return $term->taxonomy;
 						},
 					)
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 88c395c8d53..8adbbc172d7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -128,11 +128,11 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	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 ) {
+				'resource'      => 'product',
+				'namespace'     => 'test-ext',
+				'field'         => 'code',
+				'output_schema' => self::CODE_SCHEMA,
+				'get_callback'  => static function ( \WC_Product $product ) {
 					$code = $product->get_meta( '_test_code' );
 					return '' === $code ? null : $code;
 				},
@@ -140,33 +140,33 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		);
 		ExtensibleAbilities::register_field(
 			array(
-				'resource'     => 'product',
-				'namespace'    => 'other_ext',
-				'field'        => 'code',
-				'schema'       => array( 'type' => 'integer' ),
-				'get_callback' => static function ( \WC_Product $product ) {
+				'resource'      => 'product',
+				'namespace'     => 'other_ext',
+				'field'         => 'code',
+				'output_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 ) {
+				'resource'      => 'order',
+				'namespace'     => 'test-ext',
+				'field'         => 'note',
+				'output_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 ) {
+				'resource'      => 'order_item',
+				'namespace'     => 'test-ext',
+				'field'         => 'gift',
+				'output_schema' => array( 'type' => 'string' ),
+				'get_callback'  => static function ( \WC_Order_Item $item ) {
 					return 'item-' . $item->get_id();
 				},
 			)
@@ -187,14 +187,15 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		foreach ( $writable as list( $resource, $namespace, $field, $schema ) ) {
 			ExtensibleAbilities::register_field(
 				array(
-					'resource'        => $resource,
-					'namespace'       => $namespace,
-					'field'           => $field,
-					'schema'          => $schema,
-					'get_callback'    => static function ( \WC_Data $data ) use ( $field ) {
+					'resource'      => $resource,
+					'namespace'     => $namespace,
+					'field'         => $field,
+					'output_schema' => $schema,
+					'input_schema'  => self::nullable( $schema ),
+					'get_callback'  => static function ( \WC_Data $data ) use ( $field ) {
 						return $data->meta_exists( '_test_' . $field ) ? $data->get_meta( '_test_' . $field ) : null;
 					},
-					'update_callback' => static function ( \WC_Data $data, $value ) use ( $field ) {
+					'set_callback'  => static function ( \WC_Data $data, $value ) use ( $field ) {
 						self::$received[ $field ] = $value;
 						if ( 'reject' === $value || -1 === $value ) {
 							return new \WP_Error( 'test_rejected', 'Rejected.' );
@@ -217,6 +218,20 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		}
 	}

+	/**
+	 * The schema of a writable test field, which declares null to delete the value.
+	 *
+	 * @param array<string, mixed> $schema Schema of the value.
+	 * @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;
+	}
+
 	/**
 	 * Register a product filter and an order filter, the way an extension does inside woocommerce_extensible_abilities_init.
 	 *
@@ -930,11 +945,11 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {

 		ExtensibleAbilities::register_field(
 			array(
-				'resource'     => 'product',
-				'namespace'    => 'late-ext',
-				'field'        => 'code',
-				'schema'       => array( 'type' => 'string' ),
-				'get_callback' => static fn() => 'late',
+				'resource'      => 'product',
+				'namespace'     => 'late-ext',
+				'field'         => 'code',
+				'output_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' );
@@ -953,11 +968,11 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		$register_field    = static function () {
 			ExtensibleAbilities::register_field(
 				array(
-					'resource'     => 'ticket',
-					'namespace'    => 'test-ext',
-					'field'        => 'code',
-					'schema'       => array( 'type' => 'string' ),
-					'get_callback' => static fn() => 'T1',
+					'resource'      => 'ticket',
+					'namespace'     => 'test-ext',
+					'field'         => 'code',
+					'output_schema' => array( 'type' => 'string' ),
+					'get_callback'  => static fn() => 'T1',
 				)
 			);
 		};
@@ -1014,11 +1029,11 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {

 		ExtensibleAbilities::register_field(
 			array(
-				'resource'     => 'product',
-				'namespace'    => 'test-ext',
-				'field'        => 'code',
-				'schema'       => array( 'type' => 'string' ),
-				'get_callback' => static fn() => 'T1',
+				'resource'      => 'product',
+				'namespace'     => 'test-ext',
+				'field'         => 'code',
+				'output_schema' => array( 'type' => 'string' ),
+				'get_callback'  => static fn() => 'T1',
 			)
 		);
 		remove_action( 'doing_it_wrong_run', $capture, 10 );
@@ -1106,11 +1121,11 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	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',
+			'resource'      => 'order',
+			'namespace'     => 'test-ext',
+			'field'         => 'note',
+			'output_schema' => array( 'type' => 'string' ),
+			'get_callback'  => static fn() => 'second',
 		);
 		AbilitiesLoader::register_core_resources();
 		$this->register_test_fields();
@@ -1118,7 +1133,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		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( 'output_schema' => null ) ) );
 		ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => 'other' ), array( 'get_callback' => 'not_a_function' ) ) );
 		ExtensibleAbilities::register_field(
 			array_merge(
@@ -1129,19 +1144,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 				)
 			)
 		);
-		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( 'field' => 'other' ), array( 'output_schema' => array( 'description' => 'x' ) ) ) );
 		ExtensibleAbilities::register_field( array_merge( $valid, array( 'namespace' => '0' ) ) );
 		ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => '123' ) ) );
 		$order = \WC_Helper_Order::create_order();
@@ -1153,33 +1156,535 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @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.
+	 * @testdox Should refuse an output schema without a type.
+	 * @dataProvider provider_refused_output_schemas
+	 * @param array $schema Output schema.
+	 */
+	public function test_a_refused_output_schema_does_not_register_the_field( array $schema ): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+		AbilitiesLoader::register_core_resources();
+
+		ExtensibleAbilities::register_field(
+			array(
+				'resource'      => 'order',
+				'namespace'     => 'refused',
+				'field'         => 'value',
+				'output_schema' => $schema,
+				'get_callback'  => '__return_null',
+			)
+		);
+
+		$this->assertArrayNotHasKey( 'refused', ExtensibleAbilities::get_fields( 'order' ) );
+	}
+
+	/**
+	 * Output schemas that register_field() refuses.
+	 *
+	 * @return array<string, array{0: array<string, mixed>}>
+	 */
+	public function provider_refused_output_schemas(): array {
+		return array(
+			'no type' => array( array( 'description' => 'x' ) ),
+			'anyOf'   => array( array( 'anyOf' => array( array( 'type' => 'string' ) ) ) ),
+			'oneOf'   => array( array( 'oneOf' => array( array( 'type' => 'string' ) ) ) ),
+			'allOf'   => array( array( 'allOf' => array( array( 'type' => 'string' ) ) ) ),
+		);
+	}
+
+	/**
+	 * @testdox Should refuse an input schema and a query filter schema without a type.
+	 * @dataProvider provider_malformed_schemas
+	 * @param array $schema Schema.
+	 */
+	public function test_a_malformed_schema_is_refused( array $schema ): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_query_filter' );
+		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'  => $schema,
+				'set_callback'  => '__return_null',
+			)
+		);
+		ExtensibleAbilities::register_query_filter(
+			array(
+				'resource'  => 'order',
+				'namespace' => 'refused',
+				'filter'    => 'value',
+				'schema'    => $schema,
+				'callback'  => '__return_empty_array',
+			)
+		);
+
+		$this->assertArrayNotHasKey( 'refused', ExtensibleAbilities::get_fields( 'order' ) );
+		$this->assertSame(
+			array( 'type' => 'object' ),
+			ExtensibleAbilities::add_query_filters_schema( array( 'type' => 'object' ), 'order' )
+		);
+	}
+
+	/**
+	 * Schemas without a type, 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' ) ) ) ),
+		);
+	}
+
+	/**
+	 * @testdox Should publish an input schema with null, a combinator or a branch without a type as declared, and an output schema as declared.
+	 */
+	public function test_valid_schemas_are_published_as_declared(): void {
+		$input_schemas = array(
+			'type_list'   => array( 'type' => array( 'string', '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 ),
+			),
+			'enum_mixed'  => array(
+				'type' => 'string',
+				'enum' => array( 'a', 1 ),
+			),
+			'branch'      => array(
+				'type'  => 'object',
+				'anyOf' => array( array( 'required' => array( 'a' ) ), array( 'required' => array( 'b' ) ) ),
+			),
+			'nested_null' => array(
+				'type'       => 'object',
+				'properties' => array( 'a' => array( 'type' => array( 'string', 'null' ) ) ),
+				'required'   => array( 'a' ),
+			),
+			'different'   => array(
+				'type'                 => 'object',
+				'properties'           => array( 'delete' => array( 'type' => 'boolean' ) ),
+				'additionalProperties' => false,
+			),
+		);
+		$output_schema = array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'tags' => array(
+					'type'  => 'array',
+					'items' => array(
+						'type' => 'string',
+						'enum' => array( 'a', 'b' ),
+					),
+				),
+			),
+			'additionalProperties' => false,
+		);
+		add_action(
+			'woocommerce_extensible_abilities_init',
+			static function () use ( $input_schemas, $output_schema ) {
+				foreach ( $input_schemas as $field => $input_schema ) {
+					ExtensibleAbilities::register_field(
+						array(
+							'resource'      => 'product',
+							'namespace'     => 'declared',
+							'field'         => $field,
+							'output_schema' => $output_schema,
+							'get_callback'  => '__return_null',
+							'input_schema'  => $input_schema,
+							'set_callback'  => '__return_null',
+						)
+					);
+				}
+			}
+		);
+		$this->reset_registries();
+
+		$published_input  = $this->get_ability( 'woocommerce/product-update' )['input_schema']['properties']['extensions']['properties']['declared']['properties'];
+		$published_output = $this->get_output_schema( 'woocommerce/product-update' )['properties']['product']['properties']['extensions']['properties']['declared']['properties'];
+
+		$this->assertSame( $input_schemas, $published_input );
+		$this->assertSame( array_fill_keys( array_keys( $input_schemas ), $output_schema ), $published_output );
+	}
+
+	/**
+	 * @testdox Should publish every WooCommerce ability schema as JSON whose keywords have the value types that an MCP client compiles.
+	 */
+	public function test_published_schemas_have_the_keyword_types_that_mcp_clients_compile(): void {
+		$schema = array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'code'   => array(
+					'type'      => 'string',
+					'minLength' => 1,
+					'maxLength' => 20,
+					'pattern'   => '^[a-z]+$',
+					'format'    => 'hex-color',
+				),
+				'amount' => array(
+					'type'             => 'number',
+					'minimum'          => 1,
+					'exclusiveMinimum' => 1,
+					'maximum'          => 100.5,
+					'multipleOf'       => 0.5,
+				),
+				'tags'   => array(
+					'type'        => 'array',
+					'items'       => array(
+						'type' => 'string',
+						'enum' => array( 'a', 'b' ),
+					),
+					'minItems'    => 0,
+					'uniqueItems' => true,
+				),
+			),
+			'required'             => array( 'code' ),
+			'additionalProperties' => false,
+		);
+		add_action(
+			'woocommerce_extensible_abilities_init',
+			static function () use ( $schema ) {
+				foreach ( array( 'product', 'order', 'order_item' ) as $resource ) {
+					ExtensibleAbilities::register_field(
+						array(
+							'resource'      => $resource,
+							'namespace'     => 'compiled',
+							'field'         => 'value',
+							'output_schema' => $schema,
+							'get_callback'  => '__return_null',
+							'input_schema'  => $schema,
+							'set_callback'  => '__return_null',
+						)
+					);
+				}
+			}
+		);
+		$this->reset_registries();
+		$this->get_ability( 'woocommerce/product-update' );
+
+		$errors = array();
+		$names  = array();
+		foreach ( wp_get_abilities() as $ability ) {
+			if ( 0 !== strpos( $ability->get_name(), 'woocommerce/' ) ) {
+				continue;
+			}
+			$names[] = $ability->get_name();
+			foreach ( array(
+				'input'  => $ability->get_input_schema(),
+				'output' => $ability->get_output_schema(),
+			) as $kind => $ability_schema ) {
+				$json   = json_decode( (string) wp_json_encode( $ability_schema ) );
+				$errors = array_merge( $errors, self::get_compile_errors( $json, $ability->get_name() . ' ' . $kind ) );
+			}
+		}
+
+		$this->assertContains( 'woocommerce/product-update', $names );
+		$this->assertContains( 'woocommerce/orders-query', $names );
+		$this->assertEmpty( $errors, implode( "\n", $errors ) );
+	}
+
+	/**
+	 * Find the keywords in a decoded JSON schema whose value has a type that the MCP SDK client
+	 * refuses when it compiles the schema with Ajv. The types come from the keyword definitions of Ajv.
+	 *
+	 * @param mixed  $schema Schema decoded from JSON, with maps as objects and lists as arrays.
+	 * @param string $path   Path of the schema, used in the message.
+	 * @return string[] The problems.
+	 */
+	private static function get_compile_errors( $schema, string $path ): array {
+		if ( is_bool( $schema ) ) {
+			return array();
+		}
+		if ( ! is_object( $schema ) ) {
+			return array( $path . ' is not a schema object' );
+		}
+		$is_number = static fn( $value ) => is_int( $value ) || is_float( $value );
+		$checks    = array(
+			'type'                 => static fn( $value ) => is_string( $value ) || ( is_array( $value ) && ! empty( $value ) ),
+			'required'             => static fn( $value ) => is_array( $value ) && empty( array_filter( $value, static fn( $name ) => ! is_string( $name ) ) ),
+			'enum'                 => static fn( $value ) => is_array( $value ) && ! empty( $value ),
+			'properties'           => 'is_object',
+			'patternProperties'    => 'is_object',
+			'additionalProperties' => static fn( $value ) => is_bool( $value ) || is_object( $value ),
+			'items'                => static fn( $value ) => is_bool( $value ) || is_object( $value ) || is_array( $value ),
+			'anyOf'                => static fn( $value ) => is_array( $value ) && ! empty( $value ),
+			'oneOf'                => static fn( $value ) => is_array( $value ) && ! empty( $value ),
+			'allOf'                => static fn( $value ) => is_array( $value ) && ! empty( $value ),
+			'minLength'            => $is_number,
+			'maxLength'            => $is_number,
+			'minItems'             => $is_number,
+			'maxItems'             => $is_number,
+			'minProperties'        => $is_number,
+			'maxProperties'        => $is_number,
+			'minimum'              => $is_number,
+			'maximum'              => $is_number,
+			'exclusiveMinimum'     => $is_number,
+			'exclusiveMaximum'     => $is_number,
+			'multipleOf'           => $is_number,
+			'pattern'              => 'is_string',
+			'format'               => 'is_string',
+			'uniqueItems'          => 'is_bool',
+		);
+
+		$errors = array();
+		foreach ( $checks as $keyword => $check ) {
+			if ( property_exists( $schema, $keyword ) && ! $check( $schema->$keyword ) ) {
+				$errors[] = $path . '.' . $keyword . ' has a value that does not compile';
+			}
+		}
+
+		$children = array();
+		foreach ( array( 'properties', 'patternProperties' ) as $keyword ) {
+			if ( isset( $schema->$keyword ) && is_object( $schema->$keyword ) ) {
+				foreach ( get_object_vars( $schema->$keyword ) as $key => $child ) {
+					$children[ $path . '.' . $keyword . '.' . $key ] = $child;
+				}
+			}
+		}
+		foreach ( array( 'additionalProperties', 'items' ) as $keyword ) {
+			if ( isset( $schema->$keyword ) && is_object( $schema->$keyword ) ) {
+				$children[ $path . '.' . $keyword ] = $schema->$keyword;
+			}
+		}
+		foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $keyword ) {
+			if ( isset( $schema->$keyword ) && is_array( $schema->$keyword ) ) {
+				foreach ( $schema->$keyword as $index => $child ) {
+					$children[ $path . '.' . $keyword . '.' . $index ] = $child;
+				}
+			}
+		}
+		foreach ( $children as $child_path => $child ) {
+			$errors = array_merge( $errors, self::get_compile_errors( $child, $child_path ) );
+		}
+		return $errors;
+	}
+
+	/**
+	 * @testdox Should need at least one complete pair of callback and schema, and refuse the old arguments.
+	 */
+	public function test_a_field_needs_complete_arguments(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+		AbilitiesLoader::register_core_resources();
+		$base = array(
+			'resource'  => 'order',
+			'namespace' => 'refused',
+			'field'     => 'value',
+		);
+		$read = array(
+			'get_callback'  => '__return_null',
+			'output_schema' => array( 'type' => 'string' ),
+		);
+		$sets = array(
+			array(),
+			array( 'get_callback' => '__return_null' ),
+			array( 'output_schema' => array( 'type' => 'string' ) ),
+			array(
+				'get_callback' => '__return_null',
+				'schema'       => array( 'type' => 'string' ),
+			),
+			array( 'set_callback' => '__return_null' ),
+			array( 'input_schema' => array( 'type' => 'string' ) ),
+			array(
+				'set_callback' => 'not_a_function',
+				'input_schema' => array( 'type' => 'string' ),
+			),
+			$read + array( 'set_callback' => '__return_null' ),
+			$read + array( 'input_schema' => array( 'type' => 'string' ) ),
+			$read + array(
+				'input_schema' => array( 'type' => 'string' ),
+				'set_callback' => 'not_a_function',
+			),
+		);
+		foreach ( $sets as $args ) {
+			ExtensibleAbilities::register_field( $base + $args );
+		}
+
+		$this->assertArrayNotHasKey( 'refused', ExtensibleAbilities::get_fields( 'order' ) );
+	}
+
+	/**
+	 * @testdox Should list a read-only field only in the output, and refuse a write to it.
+	 */
+	public function test_a_read_only_field_is_only_in_the_output(): void {
+		add_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_fields' ) );
+		$this->reset_registries();
+		$product = \WC_Helper_Product::create_simple_product();
+
+		$response = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'extensions' => array( 'test-ext' => array( 'code' => 'x' ) ),
+			)
+		);
+
+		$input_schema = $this->get_ability( 'woocommerce/product-update' )['input_schema']['properties']['extensions']['properties'];
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertArrayNotHasKey( 'code', $input_schema['test-ext']['properties'] );
+		$this->assertArrayHasKey( 'code', $this->get_output_schema( 'woocommerce/product-update' )['properties']['product']['properties']['extensions']['properties']['test-ext']['properties'] );
+	}
+
+	/**
+	 * @testdox Should accept a write-only field in the input and leave it out of the output and its schema.
+	 */
+	public function test_a_write_only_field_is_only_in_the_input(): void {
+		$received = array();
+		add_action(
+			'woocommerce_extensible_abilities_init',
+			static function () use ( &$received ) {
+				ExtensibleAbilities::register_field(
+					array(
+						'resource'     => 'product',
+						'namespace'    => 'write-ext',
+						'field'        => 'secret',
+						'input_schema' => array( 'type' => 'string' ),
+						'set_callback' => static function ( $product, $value ) use ( &$received ) {
+							$received[] = $value;
+						},
+					)
+				);
+			}
+		);
+		$this->reset_registries();
+		$product = \WC_Helper_Product::create_simple_product();
+
+		$output = $this->run_ability(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'extensions' => array( 'write-ext' => array( 'secret' => 'x' ) ),
+			)
+		);
+
+		$input_schema = $this->get_ability( 'woocommerce/product-update' )['input_schema']['properties']['extensions']['properties'];
+		$this->assertSame( array( 'x' ), $received );
+		$this->assertArrayNotHasKey( 'write-ext', $output['product']['extensions'] ?? array() );
+		$this->assertArrayHasKey( 'secret', $input_schema['write-ext']['properties'] );
+		$this->assertArrayNotHasKey( 'write-ext', $this->get_output_schema( 'woocommerce/product-update' )['properties']['product']['properties']['extensions']['properties'] );
+	}
+
+	/**
+	 * @testdox Should leave a namespace with no readable field out of the output schema, and keep the schema when no namespace has one.
+	 */
+	public function test_a_namespace_with_only_write_only_fields_is_not_in_the_output_schema(): void {
+		AbilitiesLoader::register_core_resources();
+		$schema = array( 'type' => 'object' );
+		ExtensibleAbilities::register_field(
+			array(
+				'resource'     => 'order',
+				'namespace'    => 'write-ext',
+				'field'        => 'secret',
+				'input_schema' => array( 'type' => 'string' ),
+				'set_callback' => '__return_null',
+			)
+		);
+
+		$this->assertSame( $schema, ExtensibleAbilities::add_fields_schema( $schema, 'order' ) );
+
+		ExtensibleAbilities::register_field(
+			array(
+				'resource'      => 'order',
+				'namespace'     => 'read-ext',
+				'field'         => 'code',
+				'output_schema' => array( 'type' => 'string' ),
+				'get_callback'  => '__return_null',
+			)
+		);
+
+		$this->assertSame( array( 'read-ext' ), array_keys( ExtensibleAbilities::add_fields_schema( $schema, 'order' )['properties']['extensions']['properties'] ) );
+	}
+
+	/**
+	 * @testdox Should refuse a null input unless the input schema declares it, at the top level and nested.
+	 */
+	public function test_a_null_input_passes_only_where_it_is_declared(): void {
+		$received = array();
+		add_action(
+			'woocommerce_extensible_abilities_init',
+			static function () use ( &$received ) {
+				$fields = array(
+					'plain'           => array( 'type' => 'string' ),
+					'nested_declared' => array(
+						'type'       => 'object',
+						'properties' => array( 'a' => array( 'type' => array( 'string', 'null' ) ) ),
+					),
+					'nested_refused'  => array(
+						'type'       => 'object',
+						'properties' => array( 'a' => array( 'type' => 'string' ) ),
+					),
+				);
+				foreach ( $fields as $field => $schema ) {
+					ExtensibleAbilities::register_field(
+						array(
+							'resource'      => 'product',
+							'namespace'     => 'nulls',
+							'field'         => $field,
+							'output_schema' => array( 'type' => 'string' ),
+							'get_callback'  => '__return_null',
+							'input_schema'  => $schema,
+							'set_callback'  => static function ( \WC_Data $data, $value ) use ( &$received, $field ) {
+								$received[ $field ] = $value;
+							},
+						)
+					);
+				}
+			}
+		);
+		$this->reset_registries();
+		$product = \WC_Helper_Product::create_simple_product();
+		$write   = fn( array $fields ) => $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'extensions' => array( 'nulls' => $fields ),
+			)
+		);
+
+		$this->assertSame( 400, $write( array( 'plain' => null ) )->get_status() );
+		$this->assertSame( 400, $write( array( 'nested_refused' => array( 'a' => null ) ) )->get_status() );
+		$this->assertSame( 200, $write( array( 'nested_declared' => array( 'a' => null ) ) )->get_status() );
+		$this->assertSame( array( 'nested_declared' => array( 'a' => null ) ), $received );
+	}
+
+	/**
+	 * @testdox Should leave out a field that throws, returns null or fails sanitization, and cast a value to its schema type.
 	 */
 	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 () {
+				'output_schema' => array( 'type' => 'string' ),
+				'get_callback'  => static function () {
 					throw new \RuntimeException( 'Broken' );
 				},
 			),
 			'count'  => array(
-				'schema'       => array( 'type' => 'integer' ),
-				'get_callback' => static fn() => '12',
+				'output_schema' => array( 'type' => 'integer' ),
+				'get_callback'  => static fn() => '12',
 			),
 			'empty'  => array(
-				'schema'       => array( 'type' => array( 'string', 'null' ) ),
-				'get_callback' => '__return_null',
+				'output_schema' => array( 'type' => 'string' ),
+				'get_callback'  => '__return_null',
+			),
+			'none'   => array(
+				'output_schema' => array( 'type' => array( 'string', 'null' ) ),
+				'get_callback'  => '__return_null',
 			),
 			'dupes'  => array(
-				'schema'       => array(
+				'output_schema' => array(
 					'type'        => 'array',
 					'items'       => array( 'type' => 'integer' ),
 					'uniqueItems' => true,
 				),
-				'get_callback' => static fn() => array( '12', 12 ),
+				'get_callback'  => static fn() => array( '12', 12 ),
 			),
 		);
 		foreach ( $fields as $field => $args ) {
@@ -1195,13 +1700,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {

 		$output = ExtensibleAbilities::add_fields_to_object( array(), 'order', $order );

-		$this->assertSame(
-			array(
-				'count' => 12,
-				'empty' => null,
-			),
-			$output['extensions']['values']
-		);
+		$this->assertSame( array( 'count' => 12 ), $output['extensions']['values'] );
 	}

 	/**
@@ -1214,11 +1713,11 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 			static function () {
 				ExtensibleAbilities::register_field(
 					array(
-						'resource'     => 'product',
-						'namespace'    => 'test-ext',
-						'field'        => 'count',
-						'schema'       => array( 'type' => 'integer' ),
-						'get_callback' => static fn() => 'many',
+						'resource'      => 'product',
+						'namespace'     => 'test-ext',
+						'field'         => 'count',
+						'output_schema' => array( 'type' => 'integer' ),
+						'get_callback'  => static fn() => 'many',
 					)
 				);
 			}
@@ -1335,7 +1834,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	/**
 	 * @testdox Should refuse the change and save nothing when an update callback throws.
 	 */
-	public function test_an_update_callback_that_throws_saves_nothing(): void {
+	public function test_a_set_callback_that_throws_saves_nothing(): void {
 		$product  = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
 		$expected = array(
 			'throw' => array( 400, 'test-ext:test_thrown' ),
@@ -1361,7 +1860,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	/**
 	 * @testdox Should check every extension value before any update callback runs, when a later value fails its schema.
 	 */
-	public function test_a_value_that_fails_its_schema_runs_no_update_callback(): void {
+	public function test_a_value_that_fails_its_schema_runs_no_set_callback(): void {
 		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );

 		$response = $this->run_ability_response(
@@ -1634,13 +2133,14 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		$register = static function () {
 			ExtensibleAbilities::register_field(
 				array(
-					'resource'        => 'product',
-					'namespace'       => 'test-ext',
-					'field'           => 'plan',
-					'schema'          => array( 'type' => 'string' ),
-					'object_types'    => array( 'simple' ),
-					'get_callback'    => static fn() => 'monthly',
-					'update_callback' => static function ( \WC_Product $product, $value ) {
+					'resource'      => 'product',
+					'namespace'     => 'test-ext',
+					'field'         => 'plan',
+					'output_schema' => array( 'type' => 'string' ),
+					'input_schema'  => array( 'type' => 'string' ),
+					'object_types'  => array( 'simple' ),
+					'get_callback'  => static fn() => 'monthly',
+					'set_callback'  => static function ( \WC_Product $product, $value ) {
 						$product->update_meta_data( '_test_plan', $value );
 					},
 				)
@@ -1713,7 +2213,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should name the field and keep the message when an update_callback rejects a value.
+	 * @testdox Should name the field and keep the message when a set_callback rejects a value.
 	 */
 	public function test_a_rejected_value_names_the_field(): void {
 		$product = \WC_Helper_Product::create_simple_product();
@@ -2033,7 +2533,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should return a fixed message and the field or namespace when an update_callback or a validator throws, and save nothing.
+	 * @testdox Should return a fixed message and the field or namespace when a set_callback or a validator throws, and save nothing.
 	 */
 	public function test_a_callback_that_throws_returns_a_clear_error(): void {
 		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
@@ -2042,12 +2542,13 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 			static function () {
 				ExtensibleAbilities::register_field(
 					array(
-						'resource'        => 'product',
-						'namespace'       => 'test-ext',
-						'field'           => 'broken',
-						'schema'          => array( 'type' => 'string' ),
-						'get_callback'    => '__return_null',
-						'update_callback' => static function () {
+						'resource'      => 'product',
+						'namespace'     => 'test-ext',
+						'field'         => 'broken',
+						'input_schema'  => array( 'type' => 'string' ),
+						'get_callback'  => '__return_null',
+						'output_schema' => array( 'type' => 'string' ),
+						'set_callback'  => static function () {
 							throw new \RuntimeException( 'Field broke.' );
 						},
 					)
@@ -2102,12 +2603,13 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 				foreach ( array( 'first', 'second' ) as $field ) {
 					ExtensibleAbilities::register_field(
 						array(
-							'resource'        => 'product',
-							'namespace'       => 'test-ext',
-							'field'           => $field,
-							'schema'          => array( 'type' => 'string' ),
-							'get_callback'    => '__return_null',
-							'update_callback' => static function () use ( $field ) {
+							'resource'      => 'product',
+							'namespace'     => 'test-ext',
+							'field'         => $field,
+							'output_schema' => array( 'type' => 'string' ),
+							'input_schema'  => array( 'type' => 'string' ),
+							'get_callback'  => '__return_null',
+							'set_callback'  => static function () use ( $field ) {
 								return new \WP_Error( 'test_bad_value', $field . ' refused.', array( 'status' => 422 ) );
 							},
 						)