Commit d007dcbcba5 for woocommerce

commit d007dcbcba57fa844af2571c1fb8a40f8c4b6547
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Fri Oct 16 18:07:46 2026 -0300

    Save Core and extension fields in one write (#69681)

    * Save Core and extension fields of a product or order in one write

    Add ActionableAbility, which loads an object, changes it in memory, writes
    the extensions input with each field's update_callback, and saves one time.
    With ability contracts on, product create, product update and order status
    update run as actionable abilities.

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

    * Move the extension field write into ActionableAbility

    ActionableAbility writes the fields and builds the extensions input schema.
    AbilityExtensions only registers and reads the fields, and gives them to
    ActionableAbility with an internal getter.

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

    * Refuse an order status that the order does not accept

    WC_Order::set_status() changes a status that is not valid for the object to pending. The ability now returns a 400 error when the order does not get the requested status.

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

    * Call update_status() with the feature off, and check a status before setting it

    With the feature off, the order status ability calls WC_Order::update_status()
    again, as on trunk, so an order class applies its own rules. With the feature
    on, change() checks the status on a clone first, so a refused status fires no
    woocommerce_order_edit_status action. ActionableAbility checks the extension
    values before the Core change.

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

    * Fire woocommerce_order_edit_status only for an accepted status change, and allow null for enum fields

    With the feature on, order-update-status changes the status with
    set_status() and the manual flag, so the transition note names the
    user. That flag also fires woocommerce_order_edit_status at once, before
    the extension field callbacks run. A change that an extension refused
    still fired the action, and listeners such as the order.updated webhook
    acted on a change that was never saved. change() now holds the action
    back during set_status(), and save() fires it right before the save.

    The write schema added null to the type of each writable field but left
    its enum alone, so null failed the enum check with a 400 before the
    deletion callback ran. The schema now appends null to the enum as well.

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

    * Use AbilityExtensions::is_enabled() after the feature rename

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

    * Keep one status path for order-update-status

    Remove the feature-off path that called WC_Order::update_status() so an
    order class could apply its own rules, and the check that asked the order
    whether it accepts a status. Core's abilities do not special-case one
    extension's order class. An extension refuses a status change with a
    validator, and the custom order types PR carries the status check.

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

    * Keep the trunk status path with the feature off

    With the feature off, order-update-status runs as on trunk and changes the
    status with WC_Order::update_status(). A feature flag that is off must not
    change behavior. With the feature on, CoreActionableAbility runs change()
    and save(), so extension fields and validators run before the one save.

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

    * Keep one blank line after the namespace declaration

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

    * Make the product write abilities ActionableAbility classes

    ProductCreate and ProductUpdate now extend ActionableAbility, and the
    loader registers them with their own class. That removes
    CoreActionableAbility, AbstractChangeAbility and the class swap. The
    order status ability goes back to its trunk write through
    update_status(), so extension fields are written on products only.

    The schema helpers of AbstractDomainAbility move to a trait, so the
    product classes can use them without that parent class.

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

    * Close the gaps in ActionableAbility

    Name the real return types of change(), save() and execute_change(),
    use the ability_extensions feature name, and describe the product
    abilities as ability classes.

    The default save() now catches an exception or a missing ID, logs it,
    and returns a fixed woocommerce_ability_save_failed error, so the
    exception text stays out of the response. A field value that fails
    sanitizing returns its error instead of reaching the update_callback.

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

    * Translate the wc_doing_it_wrong messages in AbilityExtensions

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

    * Keep is_valid_status() from trunk

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

    * Rename AbilityExtensions to ExtensibleAbilities after the rebase

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

    * Refuse the change when an extension update_callback throws

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

    ---------

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

diff --git a/plugins/woocommerce/changelog/add-ability-actionable-writes b/plugins/woocommerce/changelog/add-ability-actionable-writes
new file mode 100644
index 00000000000..6411adf75ad
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-ability-actionable-writes
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add ActionableAbility to the experimental extensible abilities feature. With it on, product create and product update accept extension fields under extensions.<namespace>.<field>, and save Core and extension fields one time.
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
new file mode 100644
index 00000000000..0a13efc65d3
--- /dev/null
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -0,0 +1,308 @@
+<?php
+/**
+ * Actionable ability class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Abilities;
+
+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`, and then saves
+ * it. A rejection from any step saves nothing.
+ *
+ * The input schema gets the `extensions` property of the resource, so the
+ * ability accepts the fields with no extra code.
+ *
+ * With the feature off, the input schema gets no `extensions` property and the
+ * input is ignored. An ability whose own input schema sets
+ * `additionalProperties` to false rejects it instead.
+ *
+ * Only save() saves. The other steps must not save, send email or make HTTP
+ * requests.
+ *
+ * The API is experimental while the `extensible_abilities` feature exists.
+ *
+ * @since 11.3.0
+ */
+abstract class ActionableAbility extends \WP_Ability {
+
+	/**
+	 * Resource that the ability changes, for example `product` or `order`. The fields of this resource are written.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @return string
+	 */
+	abstract public function get_resource(): string;
+
+	/**
+	 * Load the object to change.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param array $input Ability input.
+	 * @return \WC_Data|\WP_Error
+	 */
+	abstract public function load( array $input );
+
+	/**
+	 * Change the object in memory.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param \WC_Data $subject Object to change.
+	 * @param array    $input   Ability input.
+	 * @return null|\WP_Error A WP_Error rejects the change, and nothing is saved.
+	 */
+	abstract public function change( $subject, array $input );
+
+	/**
+	 * The ability output for the saved object. It must match the output schema
+	 * of the ability.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param \WC_Data $subject Saved object.
+	 * @return mixed
+	 */
+	abstract public function prepare_response( $subject );
+
+	/**
+	 * Save the changed object. The default calls WC_Data::save(). When the save
+	 * throws or leaves the object without an ID, it logs the error and returns
+	 * a WP_Error with a fixed message, so the exception text stays out of the
+	 * response.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param \WC_Data $subject Changed object.
+	 * @return null|\WP_Error Null when the object was saved, a WP_Error when it was not.
+	 */
+	public function save( $subject ) {
+		try {
+			$subject->save();
+		} catch ( \Exception $e ) {
+			wc_get_logger()->error(
+				sprintf( 'Ability "%s" failed to save: %s', $this->get_name(), $e->getMessage() ),
+				array( 'source' => 'woocommerce-abilities' )
+			);
+			return self::save_failed();
+		}
+
+		return $subject->get_id() > 0 ? null : self::save_failed();
+	}
+
+	/**
+	 * Run the change. This is the execute callback of the ability.
+	 *
+	 * @internal
+	 *
+	 * @param mixed $input Ability input.
+	 * @return mixed|\WP_Error The output of prepare_response(), or the WP_Error of the step that refused the change.
+	 */
+	public function execute_change( $input = null ) {
+		$input   = is_array( $input ) ? $input : array();
+		$subject = $this->load( $input );
+		if ( is_wp_error( $subject ) ) {
+			return $subject;
+		}
+
+		$updates = array();
+		if ( ExtensibleAbilities::is_enabled() ) {
+			$extensions = is_array( $input['extensions'] ?? null ) ? $input['extensions'] : array();
+			$updates    = $this->prepare_fields( $extensions );
+			if ( is_wp_error( $updates ) ) {
+				return $updates;
+			}
+		}
+
+		$changed = $this->change( $subject, $input );
+		if ( is_wp_error( $changed ) ) {
+			return $changed;
+		}
+
+		$rejected = $this->apply_fields( $subject, $updates );
+		if ( null !== $rejected ) {
+			return $rejected;
+		}
+
+		$saved = $this->save( $subject );
+		if ( is_wp_error( $saved ) ) {
+			return $saved;
+		}
+
+		return $this->prepare_response( $subject );
+	}
+
+	/**
+	 * Use the change as the execute callback, and add the `extensions` input
+	 * when the feature is on.
+	 *
+	 * @param array $args Ability arguments.
+	 * @return array
+	 */
+	protected function prepare_properties( array $args ): array {
+		$args['execute_callback'] = array( $this, 'execute_change' );
+		if ( is_array( $args['input_schema'] ?? null ) && ExtensibleAbilities::is_enabled() ) {
+			$args['input_schema'] = $this->add_extensions_input_schema( $args['input_schema'] );
+		}
+		return parent::prepare_properties( $args );
+	}
+
+	/**
+	 * 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.
+	 *
+	 * @param array<string, mixed> $values Values keyed by namespace, then field.
+	 * @return array|\WP_Error A list of update_callback and value pairs, or a WP_Error when a value is rejected.
+	 */
+	private function prepare_fields( array $values ) {
+		$registered = ExtensibleAbilities::get_fields( $this->get_resource() );
+		$updates    = array();
+		foreach ( $values as $namespace => $fields ) {
+			foreach ( (array) $fields as $field => $value ) {
+				$args = $registered[ $namespace ][ $field ] ?? array();
+				if ( ! isset( $args['update_callback'] ) ) {
+					return new \WP_Error(
+						'woocommerce_ability_field_not_writable',
+						/* translators: 1: Extension namespace. 2: Field name. */
+						sprintf( __( 'The extension field "%1$s.%2$s" cannot be written.', 'woocommerce' ), $namespace, $field ),
+						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_status( $valid );
+					}
+					$value = rest_sanitize_value_from_schema( $value, $args['schema'], $param );
+					if ( is_wp_error( $value ) ) {
+						return self::with_status( $value );
+					}
+				}
+				$updates[] = array( $args['update_callback'], $value );
+			}
+		}
+		return $updates;
+	}
+
+	/**
+	 * Write the checked values to the object in memory with the update_callback
+	 * of each field.
+	 *
+	 * @param \WC_Data $subject Object to change.
+	 * @param array    $updates Pairs of update_callback and value from prepare_fields().
+	 * @return \WP_Error|null A WP_Error when a callback rejects a value or throws.
+	 */
+	private function apply_fields( $subject, array $updates ): ?\WP_Error {
+		foreach ( $updates as list( $callback, $value ) ) {
+			try {
+				$updated = call_user_func( $callback, $subject, $value );
+			} catch ( \WC_Data_Exception $e ) {
+				return self::with_status( new \WP_Error( $e->getErrorCode(), $e->getMessage(), $e->getErrorData() ) );
+			} catch ( \Throwable $e ) {
+				wc_get_logger()->error(
+					sprintf( 'Ability "%s" failed to write an extension field: %s', $this->get_name(), $e->getMessage() ),
+					array( 'source' => 'woocommerce-abilities' )
+				);
+				return new \WP_Error(
+					'woocommerce_ability_field_update_failed',
+					__( 'An extension field could not be written.', 'woocommerce' ),
+					array( 'status' => 500 )
+				);
+			}
+			if ( is_wp_error( $updated ) ) {
+				return self::with_status( $updated );
+			}
+		}
+		return null;
+	}
+
+	/**
+	 * Add the `extensions` property to the input schema. It lists the fields
+	 * that have an update_callback, and allows null for each one. A schema with
+	 * `oneOf` gets the property in each branch.
+	 *
+	 * @param array<string, mixed> $schema Input schema.
+	 * @return array<string, mixed>
+	 */
+	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' ),
+			'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'] );
+				}
+			);
+			if ( empty( $writable ) ) {
+				continue;
+			}
+			$extensions['properties'][ $namespace ] = array(
+				'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;
+						}
+						return $field_schema;
+					},
+					$writable
+				),
+				'additionalProperties' => false,
+			);
+		}
+
+		if ( isset( $schema['oneOf'] ) ) {
+			foreach ( array_keys( $schema['oneOf'] ) as $index ) {
+				$schema['oneOf'][ $index ]['properties']['extensions'] = $extensions;
+			}
+		} else {
+			$schema['properties']['extensions'] = $extensions;
+		}
+		return $schema;
+	}
+
+	/**
+	 * The error for a save that failed.
+	 *
+	 * @return \WP_Error
+	 */
+	private static function save_failed(): \WP_Error {
+		return new \WP_Error(
+			'woocommerce_ability_save_failed',
+			__( 'The change could not be saved.', 'woocommerce' ),
+			array( 'status' => 500 )
+		);
+	}
+
+	/**
+	 * Give an error the 400 status when it has none.
+	 *
+	 * @param \WP_Error $error Error.
+	 * @return \WP_Error
+	 */
+	private static function with_status( \WP_Error $error ): \WP_Error {
+		$data = $error->get_error_data();
+		if ( ! isset( $data['status'] ) ) {
+			$error->add_data( array_merge( is_array( $data ) ? $data : array(), array( 'status' => 400 ) ) );
+		}
+		return $error;
+	}
+}
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index d922c69baad..9b909df366a 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -19,6 +19,8 @@ defined( 'ABSPATH' ) || exit;
  * `woocommerce_extensible_abilities_init` action, which fires after. An ability that formats an
  * object of that resource returns the field values under
  * `extensions.<namespace>.<field>`, and lists the fields in its output schema.
+ * An ActionableAbility writes the same `extensions` input to the fields that
+ * have an `update_callback`, and saves the object one time.
  *
  * The API is experimental while the `extensible_abilities` feature exists.
  *
@@ -108,6 +110,13 @@ class ExtensibleAbilities {
 	 *                                  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.
 	 * }
 	 */
 	public static function register_field( array $args ): void {
@@ -157,6 +166,10 @@ class ExtensibleAbilities {
 			wc_doing_it_wrong( __METHOD__, __( 'The "get_callback" argument must be callable.', 'woocommerce' ), '11.3.0' );
 			return;
 		}
+		if ( isset( $args['update_callback'] ) && ! is_callable( $args['update_callback'] ) ) {
+			wc_doing_it_wrong( __METHOD__, __( 'The "update_callback" argument must be callable.', 'woocommerce' ), '11.3.0' );
+			return;
+		}
 		if ( isset( self::$fields[ $args['resource'] ][ $args['namespace'] ][ $args['field'] ] ) ) {
 			wc_doing_it_wrong(
 				__METHOD__,
@@ -260,6 +273,20 @@ class ExtensibleAbilities {
 		return $schema;
 	}

+	/**
+	 * Fields of a resource, keyed by namespace, then field.
+	 *
+	 * @internal
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string $resource_name Resource.
+	 * @return array<string, array<string, array<string, mixed>>>
+	 */
+	public static function get_fields( string $resource_name ): array {
+		return self::$fields[ $resource_name ] ?? array();
+	}
+
 	/**
 	 * Whether a value matches its field schema. A value other than null that
 	 * does not match is logged and reported one time for each field.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index 55469e16f29..b6797381329 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -9,6 +9,7 @@ namespace Automattic\WooCommerce\Internal\Abilities;

 use Automattic\WooCommerce\Abilities\AbilityDefinition;
 use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Abilities\ActionableAbility;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrderAddNote;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrderNotesQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrderUpdateStatus;
@@ -148,11 +149,14 @@ class AbilitiesLoader {
 				continue;
 			}

-			if ( ! is_a( $class_name, AbilityDefinition::class, true ) ) {
+			// Core's product write abilities extend ActionableAbility, so WordPress builds them from their own class.
+			$is_actionable = is_a( $class_name, ActionableAbility::class, true ) && self::is_core_ability_definition_class( $class_name );
+
+			if ( ! $is_actionable && ! is_a( $class_name, AbilityDefinition::class, true ) ) {
 				continue;
 			}

-			$ability_name = $class_name::get_name();
+			$ability_name = $is_actionable ? $class_name::ABILITY_NAME : $class_name::get_name();

 			if ( '' === $ability_name ) {
 				continue;
@@ -185,7 +189,12 @@ class AbilitiesLoader {
 				self::log_replaced_reserved_ability( $ability_name, $class_name );
 			}

-			$registered_ability = wp_register_ability( $ability_name, $class_name::get_registration_args() );
+			$args = $class_name::get_registration_args();
+			if ( $is_actionable ) {
+				$args['ability_class'] = $class_name;
+			}
+
+			$registered_ability = wp_register_ability( $ability_name, $args );

 			if ( $is_core_ability && null !== $registered_ability ) {
 				self::$registered_core_abilities[ $ability_name ] = $registered_ability;
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/AbstractDomainAbility.php b/plugins/woocommerce/src/Internal/Abilities/Domain/AbstractDomainAbility.php
index fe8af063dc6..9f456c4eb06 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/AbstractDomainAbility.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/AbstractDomainAbility.php
@@ -7,6 +7,8 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\Internal\Abilities\Domain;

+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
+
 defined( 'ABSPATH' ) || exit;

 /**
@@ -14,83 +16,5 @@ defined( 'ABSPATH' ) || exit;
  */
 abstract class AbstractDomainAbility {

-	/**
-	 * Get a collection output schema.
-	 *
-	 * @param string $collection_key Collection property key.
-	 * @param array  $item_schema    JSON schema describing a single item in the collection.
-	 * @return array
-	 */
-	protected static function get_collection_output_schema( string $collection_key, array $item_schema ): array {
-		return array(
-			'type'                 => 'object',
-			'properties'           => array(
-				$collection_key => array(
-					'type'        => 'array',
-					'description' => sprintf(
-						/* translators: %s: Collection key, such as products or orders. */
-						__( 'Returned %s for the current page.', 'woocommerce' ),
-						$collection_key
-					),
-					'items'       => $item_schema,
-				),
-				'total_pages'   => array(
-					'type'        => 'integer',
-					'description' => __( 'Total number of result pages available for the current query.', 'woocommerce' ),
-				),
-				'page'          => array(
-					'type'        => 'integer',
-					'description' => __( 'Current result page.', 'woocommerce' ),
-				),
-				'per_page'      => array(
-					'type'        => 'integer',
-					'description' => __( 'Maximum number of items requested per page.', 'woocommerce' ),
-				),
-			),
-			'additionalProperties' => false,
-		);
-	}
-
-	/**
-	 * Get an entity output schema.
-	 *
-	 * @param string $entity_key  Entity property key.
-	 * @param array  $item_schema JSON schema describing the entity.
-	 * @return array
-	 */
-	protected static function get_entity_output_schema( string $entity_key, array $item_schema ): array {
-		return array(
-			'type'                 => 'object',
-			'properties'           => array(
-				$entity_key => $item_schema,
-			),
-			'additionalProperties' => false,
-		);
-	}
-
-	/**
-	 * Get a delete output schema.
-	 *
-	 * @return array
-	 */
-	protected static function get_delete_output_schema(): array {
-		return array(
-			'type'                 => 'object',
-			'properties'           => array(
-				'deleted' => array( 'type' => 'boolean' ),
-				'id'      => array( 'type' => 'integer' ),
-			),
-			'additionalProperties' => false,
-		);
-	}
-
-	/**
-	 * Get an ID value from ability input.
-	 *
-	 * @param mixed $input Ability input.
-	 * @return int
-	 */
-	protected static function get_id_from_input( $input ): int {
-		return is_array( $input ) && ! empty( $input['id'] ) ? (int) $input['id'] : 0;
-	}
+	use DomainAbilityHelpers;
 }
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
index 9a79df1083d..dd161bfaa7a 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
@@ -1,34 +1,30 @@
 <?php
 /**
- * Product create ability definition file.
+ * Product create ability class file.
  */

 declare( strict_types=1 );

 namespace Automattic\WooCommerce\Internal\Abilities\Domain;

-use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ActionableAbility;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
 use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;

 defined( 'ABSPATH' ) || exit;

 /**
- * Registers the WooCommerce product create ability.
+ * The WooCommerce product create ability. It runs the steps of ActionableAbility.
  */
-class ProductCreate extends AbstractDomainAbility implements AbilityDefinition {
+class ProductCreate extends ActionableAbility {

+	use DomainAbilityHelpers;
 	use ProductAbilityTrait;

 	/**
-	 * Get the ability name.
-	 *
-	 * @return string
-	 *
-	 * @since 10.9.0
+	 * Ability name.
 	 */
-	public static function get_name(): string {
-		return 'woocommerce/product-create';
-	}
+	public const ABILITY_NAME = 'woocommerce/product-create';

 	/**
 	 * Get the ability registration arguments.
@@ -45,9 +41,8 @@ class ProductCreate extends AbstractDomainAbility implements AbilityDefinition {
 				'woocommerce'
 			),
 			'category'            => 'woocommerce',
-			'input_schema'        => self::get_input_schema(),
+			'input_schema'        => self::build_input_schema(),
 			'output_schema'       => self::get_entity_output_schema( 'product', self::get_product_output_schema() ),
-			'execute_callback'    => array( __CLASS__, 'execute' ),
 			'permission_callback' => array( __CLASS__, 'can_create_product' ),
 			'meta'                => array(
 				'show_in_rest' => true,
@@ -65,14 +60,21 @@ class ProductCreate extends AbstractDomainAbility implements AbilityDefinition {
 	}

 	/**
-	 * Create a product.
+	 * Resource that the ability changes.
 	 *
-	 * @param array $input Ability input.
-	 * @return array|\WP_Error
+	 * @return string
+	 */
+	public function get_resource(): string {
+		return 'product';
+	}
+
+	/**
+	 * Create the product object of the input type, without saving it.
 	 *
-	 * @since 10.9.0
+	 * @param array $input Ability input.
+	 * @return \WC_Product|\WP_Error
 	 */
-	public static function execute( array $input ) {
+	public function load( array $input ) {
 		$product_config = self::get_product_config_for_alias( $input['product_type_alias'] ?? 'physical' );

 		if ( is_wp_error( $product_config ) ) {
@@ -89,24 +91,51 @@ class ProductCreate extends AbstractDomainAbility implements AbilityDefinition {
 			);
 		}

+		return $product;
+	}
+
+	/**
+	 * Set the product properties in memory.
+	 *
+	 * @param \WC_Product $subject Product.
+	 * @param array       $input   Ability input.
+	 * @return null|\WP_Error
+	 */
+	public function change( $subject, array $input ) {
+		$product_config = self::get_product_config_for_alias( $input['product_type_alias'] ?? 'physical' );
+
+		if ( is_wp_error( $product_config ) ) {
+			return $product_config;
+		}
+
 		try {
-			self::apply_product_type_config( $product, $product_config );
+			self::apply_product_type_config( $subject, $product_config );

-			$validation_error = self::set_product_props_from_input( $product, $input, $product_config );
-			if ( is_wp_error( $validation_error ) ) {
-				return $validation_error;
-			}
+			return self::set_product_props_from_input( $subject, $input, $product_config );
 		} catch ( \WC_Data_Exception $exception ) {
 			return self::get_product_data_exception_error( $exception );
 		}
+	}

-		$save_error = self::save_product( $product, 'woocommerce_product_create_failed' );
-		if ( is_wp_error( $save_error ) ) {
-			return $save_error;
-		}
+	/**
+	 * Save the product.
+	 *
+	 * @param \WC_Product $subject Changed product.
+	 * @return null|\WP_Error
+	 */
+	public function save( $subject ) {
+		return self::save_product( $subject, 'woocommerce_product_create_failed' );
+	}

+	/**
+	 * The ability output for the saved product.
+	 *
+	 * @param \WC_Product $subject Saved product.
+	 * @return array
+	 */
+	public function prepare_response( $subject ): array {
 		return array(
-			'product' => self::format_product_for_response( $product ),
+			'product' => self::format_product_for_response( $subject ),
 		);
 	}

@@ -130,7 +159,7 @@ class ProductCreate extends AbstractDomainAbility implements AbilityDefinition {
 	 *
 	 * @return array
 	 */
-	private static function get_input_schema(): array {
+	private static function build_input_schema(): array {
 		return self::get_product_create_input_schema();
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
index a56fa660cd7..f80c5828b86 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
@@ -1,35 +1,31 @@
 <?php
 /**
- * Product update ability definition file.
+ * Product update ability class file.
  */

 declare( strict_types=1 );

 namespace Automattic\WooCommerce\Internal\Abilities\Domain;

-use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ActionableAbility;
 use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
 use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;

 defined( 'ABSPATH' ) || exit;

 /**
- * Registers the WooCommerce product update ability.
+ * The WooCommerce product update ability. It runs the steps of ActionableAbility.
  */
-class ProductUpdate extends AbstractDomainAbility implements AbilityDefinition {
+class ProductUpdate extends ActionableAbility {

+	use DomainAbilityHelpers;
 	use ProductAbilityTrait;

 	/**
-	 * Get the ability name.
-	 *
-	 * @return string
-	 *
-	 * @since 10.9.0
+	 * Ability name.
 	 */
-	public static function get_name(): string {
-		return 'woocommerce/product-update';
-	}
+	public const ABILITY_NAME = 'woocommerce/product-update';

 	/**
 	 * Get the ability registration arguments.
@@ -46,9 +42,8 @@ class ProductUpdate extends AbstractDomainAbility implements AbilityDefinition {
 				'woocommerce'
 			),
 			'category'            => 'woocommerce',
-			'input_schema'        => self::get_input_schema(),
+			'input_schema'        => self::build_input_schema(),
 			'output_schema'       => self::get_entity_output_schema( 'product', self::get_product_output_schema() ),
-			'execute_callback'    => array( __CLASS__, 'execute' ),
 			'permission_callback' => array( __CLASS__, 'can_update_product' ),
 			'meta'                => array(
 				'show_in_rest' => true,
@@ -66,14 +61,21 @@ class ProductUpdate extends AbstractDomainAbility implements AbilityDefinition {
 	}

 	/**
-	 * Update a product.
+	 * Resource that the ability changes.
 	 *
-	 * @param array $input Ability input.
-	 * @return array|\WP_Error
+	 * @return string
+	 */
+	public function get_resource(): string {
+		return 'product';
+	}
+
+	/**
+	 * Load the product to change, with the input type.
 	 *
-	 * @since 10.9.0
+	 * @param array $input Ability input.
+	 * @return \WC_Product|\WP_Error
 	 */
-	public static function execute( array $input ) {
+	public function load( array $input ) {
 		$product = self::get_product_from_input( $input );

 		if ( is_wp_error( $product ) ) {
@@ -129,26 +131,55 @@ class ProductUpdate extends AbstractDomainAbility implements AbilityDefinition {
 			}
 		}

+		return $product;
+	}
+
+	/**
+	 * Set the product properties in memory.
+	 *
+	 * @param \WC_Product $subject Product.
+	 * @param array       $input   Ability input.
+	 * @return null|\WP_Error
+	 */
+	public function change( $subject, array $input ) {
+		$product_config = isset( $input['product_type_alias'] )
+			? self::get_product_config_for_alias( $input['product_type_alias'] )
+			: self::get_product_config_for_product( $subject );
+
+		if ( is_wp_error( $product_config ) ) {
+			return $product_config;
+		}
+
 		try {
 			if ( isset( $input['product_type_alias'] ) ) {
-				self::apply_product_type_config( $product, $product_config );
+				self::apply_product_type_config( $subject, $product_config );
 			}

-			$validation_error = self::set_product_props_from_input( $product, $input, $product_config );
-			if ( is_wp_error( $validation_error ) ) {
-				return $validation_error;
-			}
+			return self::set_product_props_from_input( $subject, $input, $product_config );
 		} catch ( \WC_Data_Exception $exception ) {
 			return self::get_product_data_exception_error( $exception );
 		}
+	}

-		$save_error = self::save_product( $product, 'woocommerce_product_update_failed' );
-		if ( is_wp_error( $save_error ) ) {
-			return $save_error;
-		}
+	/**
+	 * Save the product.
+	 *
+	 * @param \WC_Product $subject Changed product.
+	 * @return null|\WP_Error
+	 */
+	public function save( $subject ) {
+		return self::save_product( $subject, 'woocommerce_product_update_failed' );
+	}

+	/**
+	 * The ability output for the saved product.
+	 *
+	 * @param \WC_Product $subject Saved product.
+	 * @return array
+	 */
+	public function prepare_response( $subject ): array {
 		return array(
-			'product' => self::format_product_for_response( $product ),
+			'product' => self::format_product_for_response( $subject ),
 		);
 	}

@@ -181,7 +212,7 @@ class ProductUpdate extends AbstractDomainAbility implements AbilityDefinition {
 	 *
 	 * @return array
 	 */
-	private static function get_input_schema(): array {
+	private static function build_input_schema(): array {
 		return self::get_product_update_input_schema();
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php
new file mode 100644
index 00000000000..4246a92d4c3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php
@@ -0,0 +1,96 @@
+<?php
+/**
+ * Domain ability helpers trait file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Shared helpers for WooCommerce domain abilities.
+ */
+trait DomainAbilityHelpers {
+
+	/**
+	 * Get a collection output schema.
+	 *
+	 * @param string $collection_key Collection property key.
+	 * @param array  $item_schema    JSON schema describing a single item in the collection.
+	 * @return array
+	 */
+	protected static function get_collection_output_schema( string $collection_key, array $item_schema ): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				$collection_key => array(
+					'type'        => 'array',
+					'description' => sprintf(
+						/* translators: %s: Collection key, such as products or orders. */
+						__( 'Returned %s for the current page.', 'woocommerce' ),
+						$collection_key
+					),
+					'items'       => $item_schema,
+				),
+				'total_pages'   => array(
+					'type'        => 'integer',
+					'description' => __( 'Total number of result pages available for the current query.', 'woocommerce' ),
+				),
+				'page'          => array(
+					'type'        => 'integer',
+					'description' => __( 'Current result page.', 'woocommerce' ),
+				),
+				'per_page'      => array(
+					'type'        => 'integer',
+					'description' => __( 'Maximum number of items requested per page.', 'woocommerce' ),
+				),
+			),
+			'additionalProperties' => false,
+		);
+	}
+
+	/**
+	 * Get an entity output schema.
+	 *
+	 * @param string $entity_key  Entity property key.
+	 * @param array  $item_schema JSON schema describing the entity.
+	 * @return array
+	 */
+	protected static function get_entity_output_schema( string $entity_key, array $item_schema ): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				$entity_key => $item_schema,
+			),
+			'additionalProperties' => false,
+		);
+	}
+
+	/**
+	 * Get a delete output schema.
+	 *
+	 * @return array
+	 */
+	protected static function get_delete_output_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'deleted' => array( 'type' => 'boolean' ),
+				'id'      => array( 'type' => 'integer' ),
+			),
+			'additionalProperties' => false,
+		);
+	}
+
+	/**
+	 * Get an ID value from ability input.
+	 *
+	 * @param mixed $input Ability input.
+	 * @return int
+	 */
+	protected static function get_id_from_input( $input ): int {
+		return is_array( $input ) && ! empty( $input['id'] ) ? (int) $input['id'] : 0;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index 462cb938eee..d3d43b4bc44 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -567,7 +567,7 @@ trait ProductAbilityTrait {
 	 * @return null|\WP_Error
 	 */
 	private static function validate_product_fields_for_config( array $input, array $product_config ) {
-		$shared_fields          = array( 'id', 'product_type_alias' );
+		$shared_fields          = array( 'id', 'product_type_alias', 'extensions' );
 		$supported_fields       = array_merge( $shared_fields, $product_config['fields'] );
 		$unsupported_field_keys = array_diff( array_keys( $input ), $supported_fields );

diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index b791fddae4d..b701f2450e0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -10,7 +10,6 @@ namespace Automattic\WooCommerce\Tests\Internal\Abilities;
 use Automattic\WooCommerce\Internal\Abilities\AbilitiesCategories;
 use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrderAddNote;
-use Automattic\WooCommerce\Internal\Abilities\Domain\ProductCreate;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductDelete;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductUpdate;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductsQuery;
@@ -1674,7 +1673,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 	 * @testdox Should reject invalid grouped product IDs before setting children.
 	 */
 	public function test_product_create_rejects_invalid_grouped_product_ids(): void {
-		$result = ProductCreate::execute(
+		$result = wp_get_ability( 'woocommerce/product-create' )->execute_change(
 			array(
 				'name'               => 'Invalid Grouped Product',
 				'product_type_alias' => 'grouped',
@@ -1690,7 +1689,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 	 * @testdox Should reject fields that are unsupported by the selected product type alias.
 	 */
 	public function test_product_create_rejects_fields_not_supported_by_product_type_alias(): void {
-		$result = ProductCreate::execute(
+		$result = wp_get_ability( 'woocommerce/product-create' )->execute_change(
 			array(
 				'name'               => 'Invalid Grouped Product',
 				'product_type_alias' => 'grouped',
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 6a897028afa..3981e3796fb 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -8,10 +8,11 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;

 use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Abilities\ActionableAbility;
 use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;

 /**
- * Namespaced extension fields in the output of the product and order abilities, read through the REST route.
+ * Namespaced extension fields in the product and order abilities, read and written through the REST route.
  */
 class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {

@@ -27,6 +28,13 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	 */
 	private static $administrator_id;

+	/**
+	 * Values that the update callbacks received, keyed by field.
+	 *
+	 * @var array<string, mixed>
+	 */
+	private static $received = array();
+
 	/**
 	 * Original init action count restored in tearDown.
 	 *
@@ -64,6 +72,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {

 		AbilitiesLoader::init();
 		$this->reset_registries();
+		self::$received = array();
 		wp_set_current_user( self::$administrator_id );
 		$this->set_feature( true );
 		add_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_fields' ) );
@@ -140,6 +149,50 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 				},
 			)
 		);
+		$writable = array(
+			array( 'product', 'test-ext', 'color', array( 'type' => 'string' ) ),
+			array( 'product', 'other_ext', 'count', array( 'type' => 'integer' ) ),
+			array(
+				'product',
+				'other_ext',
+				'size',
+				array(
+					'type' => 'string',
+					'enum' => array( 'small', 'large' ),
+				),
+			),
+		);
+		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 ) {
+						return $data->meta_exists( '_test_' . $field ) ? $data->get_meta( '_test_' . $field ) : null;
+					},
+					'update_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.' );
+						}
+						if ( 'throw' === $value ) {
+							throw new \WC_Data_Exception( 'test_thrown', 'Thrown.' );
+						}
+						if ( 'crash' === $value ) {
+							throw new \RuntimeException( 'Crashed.' );
+						}
+						if ( null === $value ) {
+							$data->delete_meta_data( '_test_' . $field );
+							return null;
+						}
+						$data->update_meta_data( '_test_' . $field, $value );
+						return null;
+					},
+				)
+			);
+		}
 	}

 	/**
@@ -565,6 +618,363 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		$this->assertCount( 1, $matches );
 	}

+	/**
+	 * @testdox Should save a change to Core fields and the fields of two extensions one time, with each value cast to its schema.
+	 */
+	public function test_a_write_saves_core_and_extension_fields_one_time(): void {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		$saves   = did_action( 'woocommerce_update_product' );
+
+		$output = $this->run_ability(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'name'       => 'Pencil',
+				'extensions' => array(
+					'test-ext'  => array( 'color' => 'red' ),
+					'other_ext' => array( 'count' => '12' ),
+				),
+			)
+		);
+
+		$saved = wc_get_product( $product->get_id() );
+		$this->assertSame( 1, did_action( 'woocommerce_update_product' ) - $saves );
+		$this->assertSame( 12, self::$received['count'] );
+		$this->assertSame( 'Pencil', $saved->get_name() );
+		$this->assertSame( 'red', $saved->get_meta( '_test_color' ) );
+		$this->assertSame( '12', (string) $saved->get_meta( '_test_count' ) );
+		$this->assertSame(
+			array(
+				'test-ext'  => array( 'color' => 'red' ),
+				'other_ext' => array( 'count' => 12 ),
+			),
+			$output['product']['extensions']
+		);
+	}
+
+	/**
+	 * @testdox Should accept extension fields in product create, and none in order status update.
+	 */
+	public function test_product_create_writes_fields_and_order_status_update_does_not(): void {
+		$order = \WC_Helper_Order::create_order();
+
+		$created = $this->run_ability(
+			'woocommerce/product-create',
+			array(
+				'name'       => 'Mug',
+				'extensions' => array( 'test-ext' => array( 'color' => 'blue' ) ),
+			)
+		);
+		$schema  = $this->get_ability( 'woocommerce/order-update-status' )['input_schema'];
+
+		$this->assertSame( 'blue', wc_get_product( $created['product']['id'] )->get_meta( '_test_color' ) );
+		$this->assertArrayNotHasKey( 'extensions', $schema['properties'] );
+	}
+
+	/**
+	 * @testdox Should save nothing when a value is rejected, does not match its schema, or belongs to a read-only field.
+	 */
+	public function test_a_rejected_value_saves_nothing(): void {
+		$product  = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		$rejected = array(
+			array( 'other_ext' => array( 'count' => -1 ) ),
+			array( 'other_ext' => array( 'count' => 'many' ) ),
+			array( 'other_ext' => array( 'code' => 7 ) ),
+			array( 'unknown' => array( 'color' => 'red' ) ),
+		);
+
+		foreach ( $rejected as $extensions ) {
+			$response = $this->run_ability_response(
+				'woocommerce/product-update',
+				array(
+					'id'         => $product->get_id(),
+					'name'       => 'Pencil',
+					'extensions' => array( 'test-ext' => array( 'color' => 'red' ) ) + $extensions,
+				)
+			);
+
+			$this->assertSame( 400, $response->get_status(), wp_json_encode( $extensions ) );
+		}
+		$saved = wc_get_product( $product->get_id() );
+		$this->assertSame( 'Pen', $saved->get_name() );
+		$this->assertFalse( $saved->meta_exists( '_test_color' ) );
+	}
+
+	/**
+	 * @testdox Should refuse the change and save nothing when an update callback throws.
+	 */
+	public function test_an_update_callback_that_throws_saves_nothing(): void {
+		$product  = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		$expected = array(
+			'throw' => array( 400, 'test_thrown' ),
+			'crash' => array( 500, 'woocommerce_ability_field_update_failed' ),
+		);
+
+		foreach ( $expected as $value => list( $status, $code ) ) {
+			$response = $this->run_ability_response(
+				'woocommerce/product-update',
+				array(
+					'id'         => $product->get_id(),
+					'name'       => 'Pencil',
+					'extensions' => array( 'test-ext' => array( 'color' => $value ) ),
+				)
+			);
+
+			$this->assertSame( $status, $response->get_status(), $value );
+			$this->assertSame( $code, $response->get_data()['code'], $value );
+		}
+		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	}
+
+	/**
+	 * @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 {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+
+		$response = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'name'       => 'Pencil',
+				'extensions' => array(
+					'test-ext'  => array( 'color' => 'red' ),
+					'other_ext' => array( 'count' => 'many' ),
+				),
+			)
+		);
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( array(), self::$received );
+		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	}
+
+	/**
+	 * @testdox Should delete the value of a field when the input value is null.
+	 */
+	public function test_a_null_value_deletes_the_field_value(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$product->update_meta_data( '_test_color', 'red' );
+		$product->save();
+
+		$output = $this->run_ability(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'extensions' => array( 'test-ext' => array( 'color' => null ) ),
+			)
+		);
+
+		$this->assertFalse( wc_get_product( $product->get_id() )->meta_exists( '_test_color' ) );
+		$this->assertArrayNotHasKey( 'extensions', $output['product'] );
+	}
+
+	/**
+	 * @testdox Should call WC_Order::update_status() with the feature off and on.
+	 */
+	public function test_the_feature_off_path_calls_update_status(): void {
+		$calls        = 0;
+		$order_class  = get_class(
+			new class() extends \WC_Order {
+				/**
+				 * Calls of update_status().
+				 *
+				 * @var int
+				 */
+				public static $calls = 0;
+
+				/**
+				 * Count the call, then update the status.
+				 *
+				 * @param string $new_status    Status to change the order to.
+				 * @param string $note          Optional note.
+				 * @param bool   $manual_update Whether a person made the change.
+				 * @return bool
+				 */
+				public function update_status( $new_status, $note = '', $manual_update = false ) {
+					++self::$calls;
+					return parent::update_status( $new_status, $note, $manual_update );
+				}
+			}
+		);
+		$filter_class = static fn() => $order_class;
+		add_filter( 'woocommerce_order_class', $filter_class, 100 );
+
+		foreach ( array( false, true ) as $enabled ) {
+			$this->set_feature( $enabled );
+			$this->reset_registries();
+			$order_class::$calls = 0;
+			$order               = \WC_Helper_Order::create_order();
+
+			$this->run_ability(
+				'woocommerce/order-update-status',
+				array(
+					'id'     => $order->get_id(),
+					'status' => 'completed',
+				)
+			);
+
+			$calls = $order_class::$calls;
+			$this->assertSame( 1, $calls, $enabled ? 'feature on' : 'feature off' );
+			$this->assertSame( 'completed', wc_get_order( $order->get_id() )->get_status() );
+		}
+
+		remove_filter( 'woocommerce_order_class', $filter_class, 100 );
+	}
+
+	/**
+	 * @testdox Should accept null for a field with an enum, to delete its value, and still refuse a value outside the enum.
+	 */
+	public function test_a_null_value_deletes_the_value_of_an_enum_field(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$product->update_meta_data( '_test_size', 'small' );
+		$product->save();
+
+		$schema   = $this->get_ability( 'woocommerce/product-update' )['input_schema']['oneOf'][0]['properties']['extensions']['properties']['other_ext']['properties']['size'];
+		$rejected = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'extensions' => array( 'other_ext' => array( 'size' => 'medium' ) ),
+			)
+		);
+		$this->run_ability(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'extensions' => array( 'other_ext' => array( 'size' => null ) ),
+			)
+		);
+
+		$this->assertSame( array( 'small', 'large', null ), $schema['enum'] );
+		$this->assertSame( 400, $rejected->get_status() );
+		$this->assertFalse( wc_get_product( $product->get_id() )->meta_exists( '_test_size' ) );
+	}
+
+	/**
+	 * @testdox Should let an ActionableAbility of an extension accept the writable fields with no extra code.
+	 */
+	public function test_an_extension_actionable_ability_writes_the_fields(): void {
+		$product  = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		$register = static function () {
+			wp_register_ability(
+				'test-ext/product-rename',
+				array(
+					'label'               => 'Rename product',
+					'description'         => 'Renames a product.',
+					'category'            => 'woocommerce',
+					'input_schema'        => array(
+						'type'                 => 'object',
+						'properties'           => array(
+							'id'   => array( 'type' => 'integer' ),
+							'name' => array( 'type' => 'string' ),
+						),
+						'additionalProperties' => false,
+					),
+					'output_schema'       => ExtensibleAbilities::add_fields_schema( array( 'type' => 'object' ), 'product' ),
+					'permission_callback' => '__return_true',
+					'ability_class'       => TestProductRenameAbility::class,
+					'meta'                => array( 'show_in_rest' => true ),
+				)
+			);
+		};
+		add_action( 'wp_abilities_api_init', $register, 20 );
+
+		$schema = $this->get_ability( 'test-ext/product-rename' )['input_schema']['properties']['extensions'];
+		$output = $this->run_ability(
+			'test-ext/product-rename',
+			array(
+				'id'         => $product->get_id(),
+				'name'       => 'Pencil',
+				'extensions' => array( 'test-ext' => array( 'color' => 'green' ) ),
+			)
+		);
+
+		remove_action( 'wp_abilities_api_init', $register, 20 );
+		$this->assertSame( array( 'string', 'null' ), $schema['properties']['test-ext']['properties']['color']['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'] );
+	}
+
+	/**
+	 * @testdox Should return a fixed 500 error when the default save of an ActionableAbility throws.
+	 */
+	public function test_a_failed_default_save_returns_a_fixed_error(): void {
+		$product  = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		$register = static function () {
+			wp_register_ability(
+				'test-ext/product-rename',
+				array(
+					'label'               => 'Rename product',
+					'description'         => 'Renames a product.',
+					'category'            => 'woocommerce',
+					'input_schema'        => array(
+						'type'       => 'object',
+						'properties' => array(
+							'id'   => array( 'type' => 'integer' ),
+							'name' => array( 'type' => 'string' ),
+						),
+					),
+					'output_schema'       => array( 'type' => 'object' ),
+					'permission_callback' => '__return_true',
+					'ability_class'       => TestProductRenameAbility::class,
+					'meta'                => array( 'show_in_rest' => true ),
+				)
+			);
+		};
+		$throw    = static function () {
+			throw new \RuntimeException( 'Storage text.' );
+		};
+		add_action( 'wp_abilities_api_init', $register, 20 );
+		add_action( 'woocommerce_before_product_object_save', $throw );
+
+		$response = $this->run_ability_response(
+			'test-ext/product-rename',
+			array(
+				'id'   => $product->get_id(),
+				'name' => 'Pencil',
+			)
+		);
+
+		remove_action( 'woocommerce_before_product_object_save', $throw );
+		remove_action( 'wp_abilities_api_init', $register, 20 );
+		$this->assertSame( 500, $response->get_status() );
+		$this->assertSame( 'woocommerce_ability_save_failed', $response->get_data()['code'] );
+		$this->assertStringNotContainsString( 'Storage text.', wp_json_encode( $response->get_data() ) );
+		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	}
+
+	/**
+	 * @testdox Should not accept extension fields in the write abilities with the feature off.
+	 */
+	public function test_writes_do_not_change_with_the_feature_off(): void {
+		$this->set_feature( false );
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+
+		$schema   = $this->get_ability( 'woocommerce/product-update' )['input_schema'];
+		$rejected = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'extensions' => array( 'test-ext' => array( 'color' => 'red' ) ),
+			)
+		);
+		$output   = $this->run_ability(
+			'woocommerce/product-update',
+			array(
+				'id'   => $product->get_id(),
+				'name' => 'Pencil',
+			)
+		);
+
+		$this->assertInstanceOf( ActionableAbility::class, wp_get_ability( 'woocommerce/product-update' ) );
+		$this->assertArrayNotHasKey( 'extensions', $schema['oneOf'][0]['properties'] );
+		$this->assertSame( 400, $rejected->get_status() );
+		$this->assertSame( 'Pencil', $output['product']['name'] );
+		$this->assertFalse( wc_get_product( $product->get_id() )->meta_exists( '_test_color' ) );
+	}
+
 	/**
 	 * Run an ability through the REST route and return its output.
 	 *
@@ -573,6 +983,20 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	 * @return array
 	 */
 	private function run_ability( string $name, array $input ): array {
+		$response = $this->run_ability_response( $name, $input );
+
+		$this->assertSame( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+		return $response->get_data();
+	}
+
+	/**
+	 * Run an ability through the REST route.
+	 *
+	 * @param string $name  Ability name.
+	 * @param array  $input Ability input.
+	 * @return \WP_REST_Response
+	 */
+	private function run_ability_response( string $name, array $input ): \WP_REST_Response {
 		$readonly = ! empty( wp_get_ability( $name )->get_meta()['annotations']['readonly'] );
 		$request  = new \WP_REST_Request( $readonly ? 'GET' : 'POST', '/wp-abilities/v1/abilities/' . $name . '/run' );
 		if ( $readonly ) {
@@ -582,10 +1006,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 			$request->set_body( wp_json_encode( array( 'input' => $input ) ) );
 		}

-		$response = $this->server->dispatch( $request );
-
-		$this->assertSame( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
-		return $response->get_data();
+		return $this->server->dispatch( $request );
 	}

 	/**
@@ -595,10 +1016,20 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	 * @return array
 	 */
 	private function get_output_schema( string $name ): array {
+		return $this->get_ability( $name )['output_schema'];
+	}
+
+	/**
+	 * Read an ability through the REST route.
+	 *
+	 * @param string $name Ability name.
+	 * @return array
+	 */
+	private function get_ability( string $name ): array {
 		$response = $this->server->dispatch( new \WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . $name ) );

 		$this->assertSame( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
-		return $response->get_data()['output_schema'];
+		return $response->get_data();
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestProductRenameAbility.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestProductRenameAbility.php
new file mode 100644
index 00000000000..a42a503d35c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestProductRenameAbility.php
@@ -0,0 +1,59 @@
+<?php
+/**
+ * TestProductRenameAbility class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;
+
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Abilities\ActionableAbility;
+
+/**
+ * An extension's write ability that renames a product.
+ */
+class TestProductRenameAbility extends ActionableAbility {
+
+	/**
+	 * Resource that the ability changes.
+	 *
+	 * @return string
+	 */
+	public function get_resource(): string {
+		return 'product';
+	}
+
+	/**
+	 * Load the product.
+	 *
+	 * @param array $input Ability input.
+	 * @return \WC_Product|\WP_Error
+	 */
+	public function load( array $input ) {
+		$product = wc_get_product( $input['id'] );
+		return $product ? $product : new \WP_Error( 'test_not_found', 'Not found.', array( 'status' => 404 ) );
+	}
+
+	/**
+	 * Rename the product in memory.
+	 *
+	 * @param \WC_Product $subject Product.
+	 * @param array       $input   Ability input.
+	 * @return null
+	 */
+	public function change( $subject, array $input ) {
+		$subject->set_name( $input['name'] );
+		return null;
+	}
+
+	/**
+	 * The renamed product with its extension fields.
+	 *
+	 * @param \WC_Product $subject Saved product.
+	 * @return array
+	 */
+	public function prepare_response( $subject ) {
+		return ExtensibleAbilities::add_fields_to_object( array( 'id' => $subject->get_id() ), 'product', $subject );
+	}
+}