Commit 3a27e2ea173 for woocommerce

commit 3a27e2ea17360577c77cf8a92651f20ac97fa841
Author: Christopher Hertel <mail@christopher-hertel.de>
Date:   Fri Nov 13 09:23:25 2026 +0100

    Add product term create and update abilities (#70821)

    * Add product term create and update abilities

    Add woocommerce/product-term-create and woocommerce/product-term-update for
    product categories, tags, brands and global attribute terms. They take the
    same taxonomy input as product-terms-query and return its term shape,
    extension fields included, through a shared ProductTermAbilityTrait.

    Both refuse writes WordPress would store differently than asked: a slug in
    use (which wp_insert_term() would rename), a parent from another taxonomy or
    on a taxonomy without hierarchy, a parent loop, and a name already used at
    the same level. Errors carry the colliding term's id.

    * Refuse a term slug that sanitizes to nothing

    A slug such as "!!!" passed minLength but sanitized to an empty string,
    which both abilities treated as no slug: update reported success without a
    change, and create used a slug made from the name. Both now return
    woocommerce_ability_product_term_slug_empty, matching name_empty.

    * Give the term writes a lighter output schema

    The write abilities return the query's term fields, but their schema no
    longer carries the read-oriented field descriptions or the image
    breakdown, which only cost tokens: a write echoes the agent's own input.
    The two abilities add 558 tokens to the tool list instead of 806.

    * Run the term update ability over POST

    The Abilities REST controller requires DELETE for an ability that is both
    destructive and idempotent, so the update travelled as DELETE with its input,
    description included, in the query string. Marking it not idempotent, as
    product-update is, keeps it on POST with the input in the body.

    * Harden the term writes against WordPress's slashing and empty checks

    - Slash the name and description before wp_insert_term and wp_update_term,
      which unslash them, so a backslash survives; leave name out of an update
      that does not send one.
    - Refuse a "0" slug, which WordPress treats as missing and replaces.
    - Return a saved-write error naming the term when an extension field fails
      after the term is stored.
    - Cover both write abilities in the unauthenticated and subscriber checks.

    * Cover every canonical ability in the permission refusal tests

    The term and attribute queries and the attribute create were missing from
    the provider behind the logged-out and subscriber refusal tests. A new test
    keeps the provider in step with the canonical ability list.

    * Leave an omitted parent and slug to WordPress on term update

    wp_update_term merges omitted fields from the stored term, so passing the
    parent read earlier could undo a concurrent move. The slug's "defaults to
    one made from the name" now describes only create; on update an omitted
    slug is kept.

    * Check the canonical ability tests against the loader's Core list

    The review, shipping zone and tax rate queries were registered by the loader
    but missing from the canonical list, so the metadata, annotation, output and
    permission refusal tests skipped them and teardown left them registered. The
    coverage test now reads the loader's Core classes, so the next ability left
    out of either list fails.

    * Name actionable abilities by ABILITY_NAME in the coverage test

    Write abilities extend ActionableAbility, whose get_name() is an instance
    method, so the static call threw. Follow the loader's rule instead.

    * Use a kses fixture that WordPress 7.0 and 7.2 strip alike

    WordPress 7.2 drops the text inside a script element, where 7.0 kept it, so the
    term create test failed on the pre-release job.

    * Refuse term writes to a pa_ taxonomy that is not a global attribute

    The input pattern lets through any pa_ taxonomy, including one another plugin
    registers, which taxonomy_exists() alone accepted.

diff --git a/plugins/woocommerce/changelog/add-product-term-create-update b/plugins/woocommerce/changelog/add-product-term-create-update
new file mode 100644
index 00000000000..9421c9f3567
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-term-create-update
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add abilities that let AI agents create and update product categories, tags, brands and attribute terms.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index c662515471b..c8c0e6d4525 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -24,8 +24,10 @@ 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;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductTermCreate;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductTermsQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductReviewReply;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductTermUpdate;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductReviewsQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductReviewUpdate;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ShippingZonesQuery;
@@ -61,6 +63,8 @@ class AbilitiesLoader {
 		PaymentGatewaysQuery::class,
 		ProductsQuery::class,
 		ProductTermsQuery::class,
+		ProductTermCreate::class,
+		ProductTermUpdate::class,
 		ProductReviewsQuery::class,
 		ProductReviewUpdate::class,
 		ProductReviewReply::class,
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermCreate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermCreate.php
new file mode 100644
index 00000000000..6fec586ea61
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermCreate.php
@@ -0,0 +1,141 @@
+<?php
+/**
+ * Product term create ability definition.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductTermAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Creates a product category, tag, brand or global attribute term.
+ */
+class ProductTermCreate extends AbstractDomainAbility implements AbilityDefinition {
+
+	use ProductTermAbilityTrait;
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @since 11.4.0
+	 * @return string
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/product-term-create';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @since 11.4.0
+	 * @return array
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => __( 'Create product term', 'woocommerce' ),
+			'description'         => __( 'Create a product category, tag, brand or a term of a global attribute, such as pa_color. Product writes then assign it by ID.', 'woocommerce' ),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => self::get_entity_output_schema( 'term', self::get_written_term_output_schema() ),
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_create_term' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => false,
+					'idempotent'  => false,
+					'destructive' => false,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Check product term create access.
+	 *
+	 * @since 11.4.0
+	 * @param mixed $input Ability input.
+	 * @return bool
+	 */
+	public static function can_create_term( $input = array() ): bool {
+		return self::can_manage_product_terms( $input['taxonomy'], 'create' );
+	}
+
+	/**
+	 * Create a product term.
+	 *
+	 * @since 11.4.0
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 */
+	public static function execute( array $input ) {
+		$taxonomy = $input['taxonomy'];
+		if ( ! self::is_writable_product_taxonomy( $taxonomy ) ) {
+			return self::get_unknown_taxonomy_error( $taxonomy );
+		}
+
+		$name   = sanitize_text_field( $input['name'] );
+		$slug   = isset( $input['slug'] ) ? sanitize_title( $input['slug'] ) : null;
+		$parent = $input['parent'] ?? 0;
+		$error  = self::check_term_write( $taxonomy, $name, $slug, $parent );
+		if ( $error ) {
+			return $error;
+		}
+
+		$args = array(
+			'parent'      => $parent,
+			'description' => wp_kses_post( $input['description'] ?? '' ),
+		);
+		if ( null !== $slug ) {
+			$args['slug'] = $slug;
+		}
+		$ids = wp_insert_term( wp_slash( $name ), $taxonomy, wp_slash( $args ) );
+		if ( is_wp_error( $ids ) ) {
+			return new \WP_Error( 'woocommerce_ability_product_term_create_failed', $ids->get_error_message(), array( 'status' => 500 ) );
+		}
+
+		$term = get_term( $ids['term_id'], $taxonomy );
+		if ( ! $term instanceof \WP_Term ) {
+			return new \WP_Error( 'woocommerce_ability_product_term_create_failed', __( 'The term was created but could not be read back.', 'woocommerce' ), array( 'status' => 500 ) );
+		}
+
+		$output = self::format_term( $term );
+		if ( is_wp_error( $output ) ) {
+			return ExtensibleAbilities::saved_write_error( $output, 'product_term', $term->term_id );
+		}
+
+		return array( 'term' => $output );
+	}
+
+	/**
+	 * Get the input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		$properties                        = self::get_term_write_properties();
+		$properties['slug']['description'] = __( 'Defaults to one made from the name.', 'woocommerce' );
+
+		return array(
+			'type'                 => 'object',
+			'properties'           => array_merge(
+				array(
+					'taxonomy' => self::get_taxonomy_input_schema( __( 'Product taxonomy: product_cat, product_tag, product_brand, or the slug of a global attribute from product-attributes-query.', 'woocommerce' ) ),
+				),
+				$properties
+			),
+			'required'             => array( 'taxonomy', 'name' ),
+			'additionalProperties' => false,
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermUpdate.php
new file mode 100644
index 00000000000..1ab9b6c96ab
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermUpdate.php
@@ -0,0 +1,168 @@
+<?php
+/**
+ * Product term update ability definition.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductTermAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Renames, re-slugs, moves or redescribes a product category, tag, brand or global attribute term.
+ */
+class ProductTermUpdate extends AbstractDomainAbility implements AbilityDefinition {
+
+	use ProductTermAbilityTrait;
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @since 11.4.0
+	 * @return string
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/product-term-update';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @since 11.4.0
+	 * @return array
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => __( 'Update product term', 'woocommerce' ),
+			'description'         => __( 'Change the name, slug, parent or description of a product category, tag, brand or global attribute term. Fields left out keep their value.', 'woocommerce' ),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => self::get_entity_output_schema( 'term', self::get_written_term_output_schema() ),
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_update_term' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => false,
+					'idempotent'  => false,
+					'destructive' => true,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Check product term edit access.
+	 *
+	 * @since 11.4.0
+	 * @param mixed $input Ability input.
+	 * @return bool
+	 */
+	public static function can_update_term( $input = array() ): bool {
+		return self::can_manage_product_terms( $input['taxonomy'], 'edit', $input['id'] );
+	}
+
+	/**
+	 * Update a product term.
+	 *
+	 * @since 11.4.0
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 */
+	public static function execute( array $input ) {
+		$taxonomy = $input['taxonomy'];
+		if ( ! self::is_writable_product_taxonomy( $taxonomy ) ) {
+			return self::get_unknown_taxonomy_error( $taxonomy );
+		}
+
+		$term = get_term( $input['id'], $taxonomy );
+		if ( ! $term instanceof \WP_Term ) {
+			return new \WP_Error(
+				'woocommerce_ability_product_term_not_found',
+				/* translators: 1: term ID, 2: taxonomy name. */
+				sprintf( __( 'There is no term %1$d in "%2$s".', 'woocommerce' ), $input['id'], $taxonomy ),
+				array( 'status' => 404 )
+			);
+		}
+
+		if ( ! array_intersect_key( $input, self::get_term_write_properties() ) ) {
+			return new \WP_Error(
+				'woocommerce_ability_product_term_no_fields',
+				__( 'No change was sent. Send at least one term field to change, in addition to the id.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		$name   = isset( $input['name'] ) ? sanitize_text_field( $input['name'] ) : wp_specialchars_decode( $term->name, ENT_QUOTES );
+		$slug   = isset( $input['slug'] ) ? sanitize_title( $input['slug'] ) : null;
+		$parent = $input['parent'] ?? $term->parent;
+		// Only a new name or parent can collide with a sibling's name.
+		$checked_name = isset( $input['name'] ) || isset( $input['parent'] ) ? $name : null;
+		$error        = self::check_term_write( $taxonomy, $checked_name, $slug, $parent, $term->term_id );
+		if ( $error ) {
+			return $error;
+		}
+
+		$args = array();
+		if ( isset( $input['name'] ) ) {
+			$args['name'] = $name;
+		}
+		if ( isset( $input['parent'] ) ) {
+			$args['parent'] = $parent;
+		}
+		if ( null !== $slug ) {
+			$args['slug'] = $slug;
+		}
+		if ( isset( $input['description'] ) ) {
+			$args['description'] = wp_kses_post( $input['description'] );
+		}
+		$result = wp_update_term( $term->term_id, $taxonomy, wp_slash( $args ) );
+		if ( is_wp_error( $result ) ) {
+			return new \WP_Error( 'woocommerce_ability_product_term_update_failed', $result->get_error_message(), array( 'status' => 500 ) );
+		}
+
+		$term = get_term( $term->term_id, $taxonomy );
+		if ( ! $term instanceof \WP_Term ) {
+			return new \WP_Error( 'woocommerce_ability_product_term_update_failed', __( 'The term was updated but could not be read back.', 'woocommerce' ), array( 'status' => 500 ) );
+		}
+
+		$output = self::format_term( $term );
+		if ( is_wp_error( $output ) ) {
+			return ExtensibleAbilities::saved_write_error( $output, 'product_term', $term->term_id );
+		}
+
+		return array( 'term' => $output );
+	}
+
+	/**
+	 * Get the input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array_merge(
+				array(
+					'taxonomy' => self::get_taxonomy_input_schema( __( 'Product taxonomy of the term: product_cat, product_tag, product_brand, or the slug of a global attribute.', 'woocommerce' ) ),
+					'id'       => array(
+						'type'    => 'integer',
+						'minimum' => 1,
+					),
+				),
+				self::get_term_write_properties()
+			),
+			'required'             => array( 'taxonomy', 'id' ),
+			'additionalProperties' => false,
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
index bb391be9739..57b12d34bb7 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
@@ -8,7 +8,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Internal\Abilities\Domain;

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

 defined( 'ABSPATH' ) || exit;

@@ -17,6 +17,8 @@ defined( 'ABSPATH' ) || exit;
  */
 class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefinition {

+	use ProductTermAbilityTrait;
+
 	/**
 	 * Get the ability name.
 	 *
@@ -34,7 +36,7 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 	 * @return array
 	 */
 	public static function get_registration_args(): array {
-		$output_schema                        = self::get_collection_output_schema( 'terms', ExtensibleAbilities::add_fields_schema( self::get_term_schema(), 'product_term' ) );
+		$output_schema                        = self::get_collection_output_schema( 'terms', self::get_product_term_output_schema() );
 		$output_schema['properties']['total'] = array(
 			'type'        => 'integer',
 			'description' => __( 'Total number of matching terms across all pages.', 'woocommerce' ),
@@ -71,12 +73,7 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 	 * @return bool
 	 */
 	public static function can_query_terms( $input = array() ): bool {
-		$taxonomy = $input['taxonomy'];
-		if ( ! taxonomy_exists( $taxonomy ) ) {
-			// Let execute() tell a store manager that the attribute does not exist, rather than refuse access.
-			return wc_rest_check_manager_permissions( 'attributes', 'read' );
-		}
-		return wc_rest_check_product_term_permissions( $taxonomy, 'read' );
+		return self::can_manage_product_terms( $input['taxonomy'], 'read' );
 	}

 	/**
@@ -88,12 +85,7 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 	 */
 	public static function execute( array $input ) {
 		if ( ! taxonomy_exists( $input['taxonomy'] ) ) {
-			return new \WP_Error(
-				'woocommerce_ability_product_term_taxonomy_unknown',
-				/* translators: %s: taxonomy name. */
-				sprintf( __( 'The taxonomy "%s" does not exist. Use product-attributes-query to list the global attributes.', 'woocommerce' ), $input['taxonomy'] ),
-				array( 'status' => 404 )
-			);
+			return self::get_unknown_taxonomy_error( $input['taxonomy'] );
 		}

 		$page     = $input['page'] ?? 1;
@@ -184,59 +176,6 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 		return $clauses;
 	}

-	/**
-	 * Format a term with the fields of the WooCommerce term reads.
-	 *
-	 * @param \WP_Term $term Term to format.
-	 * @return array|\WP_Error A WP_Error when an extension field value does not match its schema.
-	 */
-	private static function format_term( \WP_Term $term ) {
-		$output = array(
-			'id'          => $term->term_id,
-			'name'        => html_entity_decode( $term->name, ENT_QUOTES | ENT_HTML5 ),
-			'slug'        => $term->slug,
-			'description' => $term->description,
-			'count'       => $term->count,
-		);
-		if ( taxonomy_is_product_attribute( $term->taxonomy ) ) {
-			$output['menu_order'] = (int) get_term_meta( $term->term_id, 'order', true );
-		} elseif ( 'product_tag' !== $term->taxonomy ) {
-			$output['parent']     = $term->parent;
-			$display              = get_term_meta( $term->term_id, 'display_type', true );
-			$output['display']    = $display ? $display : 'default';
-			$output['menu_order'] = (int) get_term_meta( $term->term_id, 'order', true );
-			$image_id             = (int) get_term_meta( $term->term_id, 'thumbnail_id', true );
-			$image                = $image_id ? get_post( $image_id ) : null;
-			$image_url            = $image ? wp_get_attachment_url( $image->ID ) : false;
-			if ( $image && 'attachment' === $image->post_type && $image_url ) {
-				$output['image'] = array(
-					'id'   => $image->ID,
-					'src'  => $image_url,
-					'name' => $image->post_title,
-					'alt'  => get_post_meta( $image->ID, '_wp_attachment_image_alt', true ),
-				);
-				foreach ( array(
-					'date_created'  => 'date',
-					'date_modified' => 'modified',
-				) as $field => $source ) {
-					foreach ( array(
-						''     => 'local',
-						'_gmt' => 'gmt',
-					) as $suffix => $timezone ) {
-						$date = get_post_datetime( $image, $source, $timezone );
-						if ( $date ) {
-							if ( 'gmt' === $timezone ) {
-								$date = $date->setTimezone( new \DateTimeZone( 'UTC' ) );
-							}
-							$output['image'][ $field . $suffix ] = $date->format( DATE_RFC3339 );
-						}
-					}
-				}
-			}
-		}
-		return ExtensibleAbilities::add_fields_to_object( $output, 'product_term', $term );
-	}
-
 	/**
 	 * Get the input schema.
 	 *
@@ -246,11 +185,7 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 		return array(
 			'type'                 => 'object',
 			'properties'           => array(
-				'taxonomy'   => array(
-					'description' => __( 'Product taxonomy to query: product_cat, product_tag, product_brand, or the slug of a global attribute from product-attributes-query.', 'woocommerce' ),
-					'type'        => 'string',
-					'pattern'     => '^(product_cat|product_tag|product_brand|pa_[^\\s]+)$',
-				),
+				'taxonomy'   => self::get_taxonomy_input_schema( __( 'Product taxonomy to query: product_cat, product_tag, product_brand, or the slug of a global attribute from product-attributes-query.', 'woocommerce' ) ),
 				'include'    => array(
 					'description' => __( 'Limit results to these term IDs. Use one ID to retrieve a single term.', 'woocommerce' ),
 					'type'        => 'array',
@@ -318,55 +253,4 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 			'additionalProperties' => false,
 		);
 	}
-
-	/**
-	 * Get the term output schema, including taxonomy-specific fields.
-	 *
-	 * @return array
-	 */
-	private static function get_term_schema(): array {
-		return array(
-			'type'                 => 'object',
-			'properties'           => array(
-				'id'          => array( 'type' => 'integer' ),
-				'name'        => array( 'type' => 'string' ),
-				'slug'        => array( 'type' => 'string' ),
-				'description' => array( 'type' => 'string' ),
-				'count'       => array(
-					'type'        => 'integer',
-					'description' => __( 'Number of published, catalog-visible products. hide_empty uses stored counts and also retains parents with non-empty children, so a returned term can have count zero.', 'woocommerce' ),
-				),
-				'parent'      => array( 'type' => 'integer' ),
-				'display'     => array( 'type' => 'string' ),
-				'menu_order'  => array( 'type' => 'integer' ),
-				'image'       => array(
-					'type'                 => 'object',
-					'properties'           => array(
-						'id'                => array( 'type' => 'integer' ),
-						'src'               => array( 'type' => 'string' ),
-						'name'              => array( 'type' => 'string' ),
-						'alt'               => array( 'type' => 'string' ),
-						'date_created'      => array(
-							'type'   => 'string',
-							'format' => 'date-time',
-						),
-						'date_created_gmt'  => array(
-							'type'   => 'string',
-							'format' => 'date-time',
-						),
-						'date_modified'     => array(
-							'type'   => 'string',
-							'format' => 'date-time',
-						),
-						'date_modified_gmt' => array(
-							'type'   => 'string',
-							'format' => 'date-time',
-						),
-					),
-					'additionalProperties' => false,
-				),
-			),
-			'additionalProperties' => false,
-		);
-	}
 }
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductTermAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductTermAbilityTrait.php
new file mode 100644
index 00000000000..67682a735f5
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductTermAbilityTrait.php
@@ -0,0 +1,318 @@
+<?php
+/**
+ * Product term ability helpers.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;
+
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Shares the taxonomy input, access check and term output of the product term abilities.
+ */
+trait ProductTermAbilityTrait {
+
+	/**
+	 * Get the taxonomy input schema, limited to product taxonomies.
+	 *
+	 * @param string $description Field description.
+	 * @return array
+	 */
+	private static function get_taxonomy_input_schema( string $description ): array {
+		return array(
+			'description' => $description,
+			'type'        => 'string',
+			'pattern'     => '^(product_cat|product_tag|product_brand|pa_[^\\s]+)$',
+		);
+	}
+
+	/**
+	 * Check product term access, as the REST term endpoints do.
+	 *
+	 * @param string $taxonomy  Taxonomy name.
+	 * @param string $context   `read`, `create` or `edit`.
+	 * @param int    $object_id Term ID, for `edit`.
+	 * @return bool
+	 */
+	private static function can_manage_product_terms( string $taxonomy, string $context, int $object_id = 0 ): bool {
+		if ( ! taxonomy_exists( $taxonomy ) ) {
+			// Let execute() tell a store manager that the attribute does not exist, rather than refuse access.
+			return wc_rest_check_manager_permissions( 'attributes', 'read' );
+		}
+		return wc_rest_check_product_term_permissions( $taxonomy, $context, $object_id );
+	}
+
+	/**
+	 * Get the error for a taxonomy that is not registered, such as a deleted attribute.
+	 *
+	 * @param string $taxonomy Taxonomy name.
+	 * @return \WP_Error
+	 */
+	private static function get_unknown_taxonomy_error( string $taxonomy ): \WP_Error {
+		return new \WP_Error(
+			'woocommerce_ability_product_term_taxonomy_unknown',
+			/* translators: %s: taxonomy name. */
+			sprintf( __( 'The taxonomy "%s" does not exist. Use product-attributes-query to list the global attributes.', 'woocommerce' ), $taxonomy ),
+			array( 'status' => 404 )
+		);
+	}
+
+	/**
+	 * Check that a write targets a product taxonomy, where a `pa_` one must be a registered global attribute.
+	 *
+	 * Another plugin can register its own `pa_` taxonomy, which the input pattern alone would let through.
+	 *
+	 * @param string $taxonomy Taxonomy name.
+	 * @return bool
+	 */
+	private static function is_writable_product_taxonomy( string $taxonomy ): bool {
+		return 0 === strpos( $taxonomy, 'pa_' ) ? taxonomy_is_product_attribute( $taxonomy ) : taxonomy_exists( $taxonomy );
+	}
+
+	/**
+	 * Get the input schemas of the term fields that create and update both write.
+	 *
+	 * @return array
+	 */
+	private static function get_term_write_properties(): array {
+		return array(
+			'name'        => array(
+				'type'      => 'string',
+				'minLength' => 1,
+			),
+			'slug'        => array(
+				'type'      => 'string',
+				'minLength' => 1,
+			),
+			'parent'      => array(
+				'description' => __( 'Parent term ID, for categories and brands only. Zero makes a top-level term.', 'woocommerce' ),
+				'type'        => 'integer',
+				'minimum'     => 0,
+			),
+			'description' => array( 'type' => 'string' ),
+		);
+	}
+
+	/**
+	 * Refuse a write that WordPress would store differently than asked.
+	 *
+	 * WordPress accepts a parent from any taxonomy, stores a parent on taxonomies without
+	 * hierarchy, and renames a slug in use to a free one instead of failing.
+	 *
+	 * @param string      $taxonomy  Taxonomy name.
+	 * @param string|null $name      Term name, sanitized, or null to skip the name checks.
+	 * @param string|null $slug      Term slug, sanitized, or null when none was sent.
+	 * @param int         $parent_id Parent term ID.
+	 * @param int         $term_id   ID of the term being updated, or zero for a new term.
+	 * @return \WP_Error|null
+	 */
+	private static function check_term_write( string $taxonomy, ?string $name, ?string $slug, int $parent_id, int $term_id = 0 ): ?\WP_Error {
+		if ( '' === $name ) {
+			return new \WP_Error( 'woocommerce_ability_product_term_name_empty', __( 'The term name is empty.', 'woocommerce' ), array( 'status' => 400 ) );
+		}
+		// WordPress treats a "0" slug as missing and generates one from the name instead.
+		if ( '' === $slug || '0' === $slug ) {
+			return new \WP_Error( 'woocommerce_ability_product_term_slug_empty', __( 'The term needs a slug with letters or numbers, other than "0". Send one, or leave slug out to make one from the name.', 'woocommerce' ), array( 'status' => 400 ) );
+		}
+
+		if ( $parent_id > 0 ) {
+			if ( ! is_taxonomy_hierarchical( $taxonomy ) ) {
+				return new \WP_Error(
+					'woocommerce_ability_product_term_parent_unsupported',
+					/* translators: %s: taxonomy name. */
+					sprintf( __( 'Terms of "%s" have no parent. Leave parent out.', 'woocommerce' ), $taxonomy ),
+					array( 'status' => 400 )
+				);
+			}
+			if ( ! get_term( $parent_id, $taxonomy ) instanceof \WP_Term ) {
+				return new \WP_Error(
+					'woocommerce_ability_product_term_parent_not_found',
+					/* translators: 1: term ID, 2: taxonomy name. */
+					sprintf( __( 'There is no term %1$d in "%2$s" to use as the parent.', 'woocommerce' ), $parent_id, $taxonomy ),
+					array( 'status' => 400 )
+				);
+			}
+			if ( $term_id > 0 && ( $parent_id === $term_id || in_array( $term_id, get_ancestors( $parent_id, $taxonomy, 'taxonomy' ), true ) ) ) {
+				return new \WP_Error( 'woocommerce_ability_product_term_parent_loop', __( 'A term cannot be its own parent or the parent of one of its ancestors.', 'woocommerce' ), array( 'status' => 400 ) );
+			}
+		}
+
+		if ( null !== $slug ) {
+			$existing = get_term_by( 'slug', $slug, $taxonomy );
+			if ( $existing instanceof \WP_Term && $existing->term_id !== $term_id ) {
+				return new \WP_Error(
+					'woocommerce_ability_product_term_slug_exists',
+					/* translators: %s: term slug. */
+					sprintf( __( 'Slug "%s" is already in use. Change it, please.', 'woocommerce' ), $slug ),
+					array(
+						'status' => 400,
+						'id'     => $existing->term_id,
+					)
+				);
+			}
+		}
+
+		if ( null === $name ) {
+			return null;
+		}
+
+		$args = array(
+			'taxonomy'   => $taxonomy,
+			'name'       => $name,
+			'hide_empty' => false,
+		);
+		// WordPress allows a repeated name in different branches of a hierarchy, and nowhere else.
+		if ( is_taxonomy_hierarchical( $taxonomy ) ) {
+			$args['parent'] = $parent_id;
+		}
+		$matches = get_terms( $args );
+		foreach ( is_array( $matches ) ? $matches : array() as $match ) {
+			if ( $match->term_id !== $term_id && 0 === strcasecmp( wp_specialchars_decode( $match->name, ENT_QUOTES ), $name ) ) {
+				return new \WP_Error(
+					'woocommerce_ability_product_term_name_exists',
+					/* translators: %s: term name. */
+					sprintf( __( 'A term named "%s" already exists here.', 'woocommerce' ), $name ),
+					array(
+						'status' => 400,
+						'id'     => $match->term_id,
+					)
+				);
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * Get the term output schema, with the fields extensions register on product terms.
+	 *
+	 * @return array
+	 */
+	private static function get_product_term_output_schema(): array {
+		return ExtensibleAbilities::add_fields_schema( self::get_term_schema(), 'product_term' );
+	}
+
+	/**
+	 * Get the query's term fields without descriptions or the image breakdown.
+	 *
+	 * A write echoes the agent's own input, so those would cost tokens on every request and tell it nothing.
+	 *
+	 * @return array
+	 */
+	private static function get_written_term_output_schema(): array {
+		$schema = self::get_term_schema();
+		foreach ( $schema['properties'] as $field => $property ) {
+			unset( $schema['properties'][ $field ]['description'] );
+		}
+		$schema['properties']['image'] = array( 'type' => 'object' );
+		return ExtensibleAbilities::add_fields_schema( $schema, 'product_term' );
+	}
+
+	/**
+	 * Format a term with the fields of the WooCommerce term reads.
+	 *
+	 * @param \WP_Term $term Term to format.
+	 * @return array|\WP_Error A WP_Error when an extension field value does not match its schema.
+	 */
+	private static function format_term( \WP_Term $term ) {
+		$output = array(
+			'id'          => $term->term_id,
+			'name'        => html_entity_decode( $term->name, ENT_QUOTES | ENT_HTML5 ),
+			'slug'        => $term->slug,
+			'description' => $term->description,
+			'count'       => $term->count,
+		);
+		if ( taxonomy_is_product_attribute( $term->taxonomy ) ) {
+			$output['menu_order'] = (int) get_term_meta( $term->term_id, 'order', true );
+		} elseif ( 'product_tag' !== $term->taxonomy ) {
+			$output['parent']     = $term->parent;
+			$display              = get_term_meta( $term->term_id, 'display_type', true );
+			$output['display']    = $display ? $display : 'default';
+			$output['menu_order'] = (int) get_term_meta( $term->term_id, 'order', true );
+			$image_id             = (int) get_term_meta( $term->term_id, 'thumbnail_id', true );
+			$image                = $image_id ? get_post( $image_id ) : null;
+			$image_url            = $image ? wp_get_attachment_url( $image->ID ) : false;
+			if ( $image && 'attachment' === $image->post_type && $image_url ) {
+				$output['image'] = array(
+					'id'   => $image->ID,
+					'src'  => $image_url,
+					'name' => $image->post_title,
+					'alt'  => get_post_meta( $image->ID, '_wp_attachment_image_alt', true ),
+				);
+				foreach ( array(
+					'date_created'  => 'date',
+					'date_modified' => 'modified',
+				) as $field => $source ) {
+					foreach ( array(
+						''     => 'local',
+						'_gmt' => 'gmt',
+					) as $suffix => $timezone ) {
+						$date = get_post_datetime( $image, $source, $timezone );
+						if ( $date ) {
+							if ( 'gmt' === $timezone ) {
+								$date = $date->setTimezone( new \DateTimeZone( 'UTC' ) );
+							}
+							$output['image'][ $field . $suffix ] = $date->format( DATE_RFC3339 );
+						}
+					}
+				}
+			}
+		}
+		return ExtensibleAbilities::add_fields_to_object( $output, 'product_term', $term );
+	}
+
+	/**
+	 * Get the term output schema, including taxonomy-specific fields.
+	 *
+	 * @return array
+	 */
+	private static function get_term_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id'          => array( 'type' => 'integer' ),
+				'name'        => array( 'type' => 'string' ),
+				'slug'        => array( 'type' => 'string' ),
+				'description' => array( 'type' => 'string' ),
+				'count'       => array(
+					'type'        => 'integer',
+					'description' => __( 'Number of published, catalog-visible products. hide_empty uses stored counts and also retains parents with non-empty children, so a returned term can have count zero.', 'woocommerce' ),
+				),
+				'parent'      => array( 'type' => 'integer' ),
+				'display'     => array( 'type' => 'string' ),
+				'menu_order'  => array( 'type' => 'integer' ),
+				'image'       => array(
+					'type'                 => 'object',
+					'properties'           => array(
+						'id'                => array( 'type' => 'integer' ),
+						'src'               => array( 'type' => 'string' ),
+						'name'              => array( 'type' => 'string' ),
+						'alt'               => array( 'type' => 'string' ),
+						'date_created'      => array(
+							'type'   => 'string',
+							'format' => 'date-time',
+						),
+						'date_created_gmt'  => array(
+							'type'   => 'string',
+							'format' => 'date-time',
+						),
+						'date_modified'     => array(
+							'type'   => 'string',
+							'format' => 'date-time',
+						),
+						'date_modified_gmt' => array(
+							'type'   => 'string',
+							'format' => 'date-time',
+						),
+					),
+					'additionalProperties' => false,
+				),
+			),
+			'additionalProperties' => false,
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index a1dd63d3398..de848f95d03 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -7,6 +7,7 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\Tests\Internal\Abilities;

+use Automattic\WooCommerce\Abilities\ActionableAbility;
 use Automattic\WooCommerce\Internal\Abilities\Ability;
 use Automattic\WooCommerce\Internal\Abilities\AbilitiesCategories;
 use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
@@ -34,6 +35,8 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {

 	private const CANONICAL_ABILITY_IDS = array(
 		'woocommerce/product-terms-query',
+		'woocommerce/product-term-create',
+		'woocommerce/product-term-update',
 		'woocommerce/product-attributes-query',
 		'woocommerce/product-attribute-create',
 		'woocommerce/products-query',
@@ -50,6 +53,9 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		'woocommerce/customers-query',
 		'woocommerce/product-review-update',
 		'woocommerce/product-review-reply',
+		'woocommerce/product-reviews-query',
+		'woocommerce/shipping-zones-query',
+		'woocommerce/tax-rates-query',
 	);

 	/**
@@ -278,6 +284,21 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 				'idempotent'  => true,
 				'destructive' => false,
 			),
+			'woocommerce/product-reviews-query'    => array(
+				'readonly'    => true,
+				'idempotent'  => true,
+				'destructive' => false,
+			),
+			'woocommerce/shipping-zones-query'     => array(
+				'readonly'    => true,
+				'idempotent'  => true,
+				'destructive' => false,
+			),
+			'woocommerce/tax-rates-query'          => array(
+				'readonly'    => true,
+				'idempotent'  => true,
+				'destructive' => false,
+			),
 			'woocommerce/product-attributes-query' => array(
 				'readonly'    => true,
 				'idempotent'  => true,
@@ -288,6 +309,16 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 				'idempotent'  => false,
 				'destructive' => false,
 			),
+			'woocommerce/product-term-create'      => array(
+				'readonly'    => false,
+				'idempotent'  => false,
+				'destructive' => false,
+			),
+			'woocommerce/product-term-update'      => array(
+				'readonly'    => false,
+				'idempotent'  => false,
+				'destructive' => true,
+			),
 			'woocommerce/product-create'           => array(
 				'readonly'    => false,
 				'idempotent'  => false,
@@ -962,6 +993,21 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
 	}

+	/**
+	 * @testdox Should give every canonical ability an input in the permission refusal provider.
+	 */
+	public function test_permission_refusal_provider_covers_every_canonical_ability(): void {
+		$classes = ( new \ReflectionClassConstant( AbilitiesLoader::class, 'CORE_ABILITY_DEFINITION_CLASSES' ) )->getValue();
+		$core    = array_map(
+			static fn( string $class_name ): string => is_a( $class_name, ActionableAbility::class, true ) ? $class_name::ABILITY_NAME : $class_name::get_name(),
+			$classes
+		);
+		$covered = array_column( $this->provider_canonical_abilities_with_minimal_input(), 0 );
+
+		$this->assertEqualsCanonicalizing( $core, self::CANONICAL_ABILITY_IDS, 'CANONICAL_ABILITY_IDS should list every Core ability.' );
+		$this->assertEqualsCanonicalizing( $core, $covered, 'The permission refusal provider should cover every Core ability.' );
+	}
+
 	/**
 	 * Provides ability IDs and minimal valid-shape input for cap negative-path tests.
 	 *
@@ -970,93 +1016,136 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 	public function provider_canonical_abilities_with_minimal_input(): array {
 		// Valid-shape input only — permission check should fire before execute.
 		return array(
-			'products-query'         => array(
+			'product-terms-query'      => array(
+				'woocommerce/product-terms-query',
+				array(
+					'taxonomy' => 'product_cat',
+				),
+			),
+			'product-term-create'      => array(
+				'woocommerce/product-term-create',
+				array(
+					'taxonomy' => 'product_cat',
+					'name'     => 'Forbidden',
+				),
+			),
+			'product-term-update'      => array(
+				'woocommerce/product-term-update',
+				array(
+					'taxonomy' => 'product_cat',
+					'id'       => 1,
+					'name'     => 'Forbidden',
+				),
+			),
+			'product-attributes-query' => array(
+				'woocommerce/product-attributes-query',
+				array(),
+			),
+			'product-attribute-create' => array(
+				'woocommerce/product-attribute-create',
+				array(
+					'name' => 'Forbidden',
+				),
+			),
+			'products-query'           => array(
 				'woocommerce/products-query',
 				array(
 					'id' => 1,
 				),
 			),
-			'product-create'         => array(
+			'product-create'           => array(
 				'woocommerce/product-create',
 				array(
 					'name' => 'Forbidden',
 				),
 			),
-			'product-update'         => array(
+			'product-update'           => array(
 				'woocommerce/product-update',
 				array(
 					'id'   => 1,
 					'name' => 'Forbidden',
 				),
 			),
-			'product-delete'         => array(
+			'product-delete'           => array(
 				'woocommerce/product-delete',
 				array(
 					'id' => 1,
 				),
 			),
-			'orders-query'           => array(
+			'orders-query'             => array(
 				'woocommerce/orders-query',
 				array(
 					'id' => 1,
 				),
 			),
-			'order-send-email'       => array(
+			'order-send-email'         => array(
 				'woocommerce/order-send-email',
 				array(
 					'id'    => 1,
 					'email' => 'invoice',
 				),
 			),
-			'order-update'           => array(
+			'order-update'             => array(
 				'woocommerce/order-update',
 				array(
 					'id'       => 1,
 					'shipping' => array( 'city' => 'Denied' ),
 				),
 			),
-			'order-update-status'    => array(
+			'order-update-status'      => array(
 				'woocommerce/order-update-status',
 				array(
 					'id'     => 1,
 					'status' => 'processing',
 				),
 			),
-			'product-review-update'  => array(
+			'product-review-update'    => array(
 				'woocommerce/product-review-update',
 				array(
 					'id'     => 1,
 					'status' => 'approved',
 				),
 			),
-			'product-review-reply'   => array(
+			'product-review-reply'     => array(
 				'woocommerce/product-review-reply',
 				array(
 					'id'      => 1,
 					'content' => 'denied',
 				),
 			),
-			'order-add-note'         => array(
+			'order-add-note'           => array(
 				'woocommerce/order-add-note',
 				array(
 					'id'   => 1,
 					'note' => 'denied',
 				),
 			),
-			'order-notes-query'      => array(
+			'order-notes-query'        => array(
 				'woocommerce/order-notes-query',
 				array(
 					'id' => 1,
 				),
 			),
-			'payment-gateways-query' => array(
+			'payment-gateways-query'   => array(
 				'woocommerce/payment-gateways-query',
 				array(),
 			),
-			'customers-query'        => array(
+			'customers-query'          => array(
 				'woocommerce/customers-query',
 				array(),
 			),
+			'product-reviews-query'    => array(
+				'woocommerce/product-reviews-query',
+				array(),
+			),
+			'shipping-zones-query'     => array(
+				'woocommerce/shipping-zones-query',
+				array(),
+			),
+			'tax-rates-query'          => array(
+				'woocommerce/tax-rates-query',
+				array(),
+			),
 		);
 	}

diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermWritesTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermWritesTest.php
new file mode 100644
index 00000000000..97977926258
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermWritesTest.php
@@ -0,0 +1,699 @@
+<?php
+/**
+ * Product term create and update ability tests.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
+use Automattic\WooCommerce\Internal\Abilities\Ability;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductAttributeCreate;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductTermCreate;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductTermsQuery;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductTermUpdate;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests product term writes through the registered abilities.
+ */
+class ProductTermWritesTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Extension state restored after each test.
+	 *
+	 * @var array
+	 */
+	private array $saved_state = array();
+
+	/**
+	 * Taxonomies registered before the test, so the ones it registers can be removed.
+	 *
+	 * @var string[]
+	 */
+	private array $taxonomies_before;
+
+	/**
+	 * Set up an administrator with extension fields switched off.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		foreach ( array( 'resources', 'fields' ) as $name ) {
+			$property = new \ReflectionProperty( ExtensibleAbilities::class, $name );
+			$property->setAccessible( true );
+			$this->saved_state[ $name ] = $property->getValue();
+			$property->setValue( null, array() );
+		}
+		$this->taxonomies_before = get_taxonomies();
+		update_option( 'woocommerce_feature_extensible_abilities_enabled', 'no' );
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+	}
+
+	/**
+	 * Restore extension state and unregister the attribute taxonomies the test registered.
+	 */
+	public function tearDown(): void {
+		global $wc_product_attributes;
+		try {
+			foreach ( $this->saved_state as $name => $value ) {
+				$property = new \ReflectionProperty( ExtensibleAbilities::class, $name );
+				$property->setAccessible( true );
+				$property->setValue( null, $value );
+			}
+			foreach ( array_diff( get_taxonomies(), $this->taxonomies_before ) as $taxonomy ) {
+				unregister_taxonomy( $taxonomy );
+				unset( $wc_product_attributes[ $taxonomy ] );
+			}
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * @testdox Should create a term that the term query returns in the same shape.
+	 * @testWith ["product_cat"]
+	 *           ["product_tag"]
+	 *           ["product_brand"]
+	 * @param string $taxonomy Taxonomy.
+	 */
+	public function test_creates_term( string $taxonomy ): void {
+		$result = $this->create(
+			array(
+				'taxonomy'    => $taxonomy,
+				'name'        => 'Tom & Jerry',
+				'description' => 'Cartoon <em onclick="x()">classics</em>',
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$term = $result['term'];
+		$this->assertSame( 'Tom & Jerry', $term['name'] );
+		$this->assertSame( 'tom-jerry', $term['slug'] );
+		$this->assertSame( 'Cartoon <em>classics</em>', $term['description'] );
+		$this->assertSame( 0, $term['count'] );
+		$query = ( new Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() ) )->execute(
+			array(
+				'taxonomy' => $taxonomy,
+				'include'  => array( $term['id'] ),
+			)
+		);
+		$this->assertSame( array( $term ), $query['terms'] );
+	}
+
+	/**
+	 * @testdox Should declare the query's term fields in both write schemas, without their descriptions.
+	 */
+	public function test_write_schemas_declare_the_query_fields(): void {
+		$query = ProductTermsQuery::get_registration_args()['output_schema']['properties']['terms']['items']['properties'];
+
+		foreach ( array( ProductTermCreate::get_registration_args(), ProductTermUpdate::get_registration_args() ) as $args ) {
+			$written = $args['output_schema']['properties']['term']['properties'];
+			$this->assertSame( array_keys( $query ), array_keys( $written ) );
+			$this->assertSame( 'object', $written['image']['type'] );
+			$this->assertSame( array(), array_filter( array_column( $written, 'description' ) ), 'No field carries a description.' );
+		}
+	}
+
+	/**
+	 * @testdox Should create a category under a parent with the slug it is given.
+	 */
+	public function test_creates_child_category(): void {
+		$parent = self::factory()->term->create( array( 'taxonomy' => 'product_cat' ) );
+
+		$result = $this->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Shoes',
+				'slug'     => 'Summer Shoes',
+				'parent'   => $parent,
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertSame( 'summer-shoes', $result['term']['slug'] );
+		$this->assertSame( $parent, $result['term']['parent'] );
+	}
+
+	/**
+	 * @testdox Should create a term of a global attribute created earlier in the same request.
+	 */
+	public function test_creates_attribute_term(): void {
+		$attribute = ( new Ability( ProductAttributeCreate::get_name(), ProductAttributeCreate::get_registration_args() ) )->execute( array( 'name' => 'Color' ) );
+		$this->assertIsArray( $attribute );
+
+		$result = $this->create(
+			array(
+				'taxonomy' => 'pa_color',
+				'name'     => 'Red',
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertSame( 'red', $result['term']['slug'] );
+		$this->assertSame( 0, $result['term']['menu_order'] );
+		$this->assertInstanceOf( \WP_Term::class, get_term( $result['term']['id'], 'pa_color' ) );
+	}
+
+	/**
+	 * Inputs that would collide with the category Summer or be stored differently than asked.
+	 *
+	 * @return array
+	 */
+	public function provide_conflicting_creates(): array {
+		return array(
+			'same name'                   => array( array( 'name' => 'SUMMER' ), 'woocommerce_ability_product_term_name_exists', true ),
+			'same name with a new slug'   => array(
+				array(
+					'name' => 'Summer',
+					'slug' => 'summer-two',
+				),
+				'woocommerce_ability_product_term_name_exists',
+				true,
+			),
+			'slug in use'                 => array(
+				array(
+					'name' => 'Holidays',
+					'slug' => 'summer',
+				),
+				'woocommerce_ability_product_term_slug_exists',
+				true,
+			),
+			'name empty after sanitizing' => array( array( 'name' => '<b></b>' ), 'woocommerce_ability_product_term_name_empty', false ),
+			'slug empty after sanitizing' => array(
+				array(
+					'name' => 'Holidays',
+					'slug' => '!!!',
+				),
+				'woocommerce_ability_product_term_slug_empty',
+				false,
+			),
+			'slug zero'                   => array(
+				array(
+					'name' => 'Holidays',
+					'slug' => '0',
+				),
+				'woocommerce_ability_product_term_slug_empty',
+				false,
+			),
+			'parent missing'              => array(
+				array(
+					'name'   => 'Holidays',
+					'parent' => 99999999,
+				),
+				'woocommerce_ability_product_term_parent_not_found',
+				false,
+			),
+		);
+	}
+
+	/**
+	 * @testdox Should refuse a create that collides with a category or names a missing parent, and create nothing.
+	 * @dataProvider provide_conflicting_creates
+	 * @param array  $input     Create input, without the taxonomy.
+	 * @param string $code      Expected error code.
+	 * @param bool   $has_id    Whether the error carries the ID of the category it collides with.
+	 */
+	public function test_refuses_conflicting_create( array $input, string $code, bool $has_id ): void {
+		$existing = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Summer',
+				'slug'     => 'summer',
+			)
+		);
+		$before   = wp_count_terms( array( 'taxonomy' => 'product_cat' ) );
+
+		$result = $this->create( array( 'taxonomy' => 'product_cat' ) + $input );
+
+		$this->assertWPError( $result );
+		$this->assertSame( $code, $result->get_error_code() );
+		$this->assertSame( 400, $result->get_error_data()['status'] );
+		if ( $has_id ) {
+			$this->assertSame( $existing, $result->get_error_data()['id'], 'The agent can use the existing term.' );
+		}
+		$this->assertSame( $before, wp_count_terms( array( 'taxonomy' => 'product_cat' ) ), 'Nothing is created.' );
+	}
+
+	/**
+	 * @testdox Should allow a category name that only repeats one in another branch.
+	 */
+	public function test_creates_same_name_under_another_parent(): void {
+		self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Shoes',
+			)
+		);
+		$parent = self::factory()->term->create( array( 'taxonomy' => 'product_cat' ) );
+
+		$result = $this->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Shoes',
+				'parent'   => $parent,
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertSame( $parent, $result['term']['parent'] );
+	}
+
+	/**
+	 * @testdox Should refuse a parent on a tag, and a category of another taxonomy as parent.
+	 */
+	public function test_refuses_invalid_parent(): void {
+		$tag      = self::factory()->term->create( array( 'taxonomy' => 'product_tag' ) );
+		$category = self::factory()->term->create( array( 'taxonomy' => 'product_cat' ) );
+
+		$on_tag = $this->create(
+			array(
+				'taxonomy' => 'product_tag',
+				'name'     => 'Sale',
+				'parent'   => $tag,
+			)
+		);
+		$cross  = $this->create(
+			array(
+				'taxonomy' => 'product_brand',
+				'name'     => 'Acme',
+				'parent'   => $category,
+			)
+		);
+
+		$this->assertWPError( $on_tag );
+		$this->assertSame( 'woocommerce_ability_product_term_parent_unsupported', $on_tag->get_error_code() );
+		$this->assertWPError( $cross );
+		$this->assertSame( 'woocommerce_ability_product_term_parent_not_found', $cross->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should report an attribute taxonomy that does not exist to a store manager, for both abilities.
+	 */
+	public function test_refuses_unknown_taxonomy(): void {
+		$create = $this->create(
+			array(
+				'taxonomy' => 'pa_missing',
+				'name'     => 'Red',
+			)
+		);
+		$update = $this->update(
+			array(
+				'taxonomy' => 'pa_missing',
+				'id'       => 1,
+				'name'     => 'Red',
+			)
+		);
+
+		foreach ( array( $create, $update ) as $result ) {
+			$this->assertWPError( $result );
+			$this->assertSame( 'woocommerce_ability_product_term_taxonomy_unknown', $result->get_error_code() );
+			$this->assertSame( 404, $result->get_error_data()['status'] );
+		}
+	}
+
+	/**
+	 * @testdox Should refuse to write a pa_ taxonomy that is not a global attribute, and leave its terms alone.
+	 */
+	public function test_refuses_pa_taxonomy_that_is_not_an_attribute(): void {
+		register_taxonomy( 'pa_other_plugin', 'product' );
+		$term = self::factory()->term->create_and_get(
+			array(
+				'taxonomy' => 'pa_other_plugin',
+				'name'     => 'Kept',
+			)
+		);
+
+		$create = $this->create(
+			array(
+				'taxonomy' => 'pa_other_plugin',
+				'name'     => 'Added',
+			)
+		);
+		$update = $this->update(
+			array(
+				'taxonomy' => 'pa_other_plugin',
+				'id'       => $term->term_id,
+				'name'     => 'Renamed',
+			)
+		);
+
+		foreach ( array( $create, $update ) as $result ) {
+			$this->assertWPError( $result );
+			$this->assertSame( 'woocommerce_ability_product_term_taxonomy_unknown', $result->get_error_code() );
+		}
+		$this->assertFalse( get_term_by( 'name', 'Added', 'pa_other_plugin' ) );
+		$this->assertSame( 'Kept', get_term( $term->term_id, 'pa_other_plugin' )->name );
+	}
+
+	/**
+	 * @testdox Should rename, re-slug and move a category, and keep the fields it is not sent.
+	 */
+	public function test_updates_category(): void {
+		$parent = self::factory()->term->create( array( 'taxonomy' => 'product_cat' ) );
+		$id     = self::factory()->term->create(
+			array(
+				'taxonomy'    => 'product_cat',
+				'name'        => 'Shoes',
+				'description' => 'All shoes',
+			)
+		);
+
+		$result = $this->update(
+			array(
+				'taxonomy' => 'product_cat',
+				'id'       => $id,
+				'name'     => 'Sneakers',
+				'slug'     => 'sneakers',
+				'parent'   => $parent,
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertSame( $id, $result['term']['id'] );
+		$this->assertSame( 'Sneakers', $result['term']['name'] );
+		$this->assertSame( 'sneakers', $result['term']['slug'] );
+		$this->assertSame( $parent, $result['term']['parent'] );
+		$this->assertSame( 'All shoes', $result['term']['description'] );
+	}
+
+	/**
+	 * @testdox Should keep a name with an ampersand when the update leaves the name out.
+	 */
+	public function test_update_keeps_encoded_name(): void {
+		$id = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_tag',
+				'name'     => 'Tom & Jerry',
+			)
+		);
+
+		$result = $this->update(
+			array(
+				'taxonomy' => 'product_tag',
+				'id'       => $id,
+				'slug'     => 'cartoons',
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertSame( 'Tom & Jerry', $result['term']['name'] );
+		$this->assertSame( 'cartoons', $result['term']['slug'] );
+	}
+
+	/**
+	 * @testdox Should keep backslashes in a term's name and description on create and update.
+	 */
+	public function test_keeps_backslashes(): void {
+		$created = $this->create(
+			array(
+				'taxonomy'    => 'product_tag',
+				'name'        => 'C:\\Temp',
+				'description' => 'Path C:\\Temp',
+			)
+		);
+		$this->assertIsArray( $created );
+		$this->assertSame( 'C:\\Temp', $created['term']['name'] );
+		$this->assertSame( 'Path C:\\Temp', $created['term']['description'] );
+
+		$id      = $created['term']['id'];
+		$updated = $this->update(
+			array(
+				'taxonomy'    => 'product_tag',
+				'id'          => $id,
+				'description' => 'Path D:\\Data',
+			)
+		);
+		$this->assertIsArray( $updated );
+		$this->assertSame( 'C:\\Temp', $updated['term']['name'] );
+		$this->assertSame( 'Path D:\\Data', $updated['term']['description'] );
+
+		$renamed = $this->update(
+			array(
+				'taxonomy' => 'product_tag',
+				'id'       => $id,
+				'name'     => 'D:\\Data',
+			)
+		);
+		$this->assertIsArray( $renamed );
+		$this->assertSame( 'D:\\Data', $renamed['term']['name'] );
+		$this->assertSame( 'Path D:\\Data', $renamed['term']['description'] );
+	}
+
+	/**
+	 * @testdox Should change the description of a tag whose name another tag already uses.
+	 */
+	public function test_updates_description_of_a_duplicate_name(): void {
+		$id = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_tag',
+				'name'     => 'Sale',
+				'slug'     => 'sale',
+			)
+		);
+		self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_tag',
+				'name'     => 'Sale',
+				'slug'     => 'sale-2',
+			)
+		);
+
+		$result = $this->update(
+			array(
+				'taxonomy'    => 'product_tag',
+				'id'          => $id,
+				'description' => 'Discounted',
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertSame( 'Discounted', $result['term']['description'] );
+	}
+
+	/**
+	 * @testdox Should refuse an update that would collide, loop, or change nothing, and leave the term as it was.
+	 */
+	public function test_refuses_invalid_update(): void {
+		$parent = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Clothing',
+			)
+		);
+		$id     = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Shirts',
+				'parent'   => $parent,
+			)
+		);
+		$other  = self::factory()->term->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Pants',
+				'slug'     => 'pants',
+				'parent'   => $parent,
+			)
+		);
+		$tag    = self::factory()->term->create( array( 'taxonomy' => 'product_tag' ) );
+		$cases  = array(
+			'woocommerce_ability_product_term_name_exists' => array(
+				'id'   => $id,
+				'name' => 'pants',
+			),
+			'woocommerce_ability_product_term_slug_exists' => array(
+				'id'   => $id,
+				'slug' => 'pants',
+			),
+			'woocommerce_ability_product_term_slug_empty'  => array(
+				'id'   => $id,
+				'slug' => '0',
+			),
+			'woocommerce_ability_product_term_parent_loop' => array(
+				'id'     => $parent,
+				'parent' => $id,
+			),
+			'woocommerce_ability_product_term_no_fields'   => array( 'id' => $id ),
+			'woocommerce_ability_product_term_not_found'   => array(
+				'id'   => $tag,
+				'name' => 'Tees',
+			),
+		);
+		$before = get_term( $id, 'product_cat' );
+
+		foreach ( $cases as $code => $input ) {
+			$result = $this->update( array( 'taxonomy' => 'product_cat' ) + $input );
+
+			$this->assertWPError( $result, $code );
+			$this->assertSame( $code, $result->get_error_code() );
+		}
+		$collision = $this->update(
+			array(
+				'taxonomy' => 'product_cat',
+				'id'       => $id,
+				'name'     => 'Pants',
+			)
+		);
+		$this->assertSame( $other, $collision->get_error_data()['id'], 'The error names the term it collides with.' );
+		$this->assertEquals( $before, get_term( $id, 'product_cat' ), 'The term is unchanged.' );
+		$this->assertSame( 0, get_term( $parent, 'product_cat' )->parent, 'The parent is unchanged.' );
+	}
+
+	/**
+	 * @testdox Should refuse customers both abilities.
+	 */
+	public function test_refuses_customer(): void {
+		$id = self::factory()->term->create( array( 'taxonomy' => 'product_cat' ) );
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+
+		$create = $this->create(
+			array(
+				'taxonomy' => 'product_cat',
+				'name'     => 'Summer',
+			)
+		);
+		$update = $this->update(
+			array(
+				'taxonomy' => 'product_cat',
+				'id'       => $id,
+				'name'     => 'Summer',
+			)
+		);
+
+		$this->assertWPError( $create );
+		$this->assertSame( 'ability_invalid_permissions', $create->get_error_code() );
+		$this->assertWPError( $update );
+		$this->assertSame( 'ability_invalid_permissions', $update->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should return extension fields from both abilities only while the feature is enabled.
+	 */
+	public function test_extension_fields(): void {
+		$id     = self::factory()->term->create( array( 'taxonomy' => 'product_tag' ) );
+		$schema = ProductTermCreate::get_registration_args()['output_schema'];
+		update_option( 'woocommerce_feature_extensible_abilities_enabled', 'yes' );
+		$this->assertSame( $schema, ProductTermCreate::get_registration_args()['output_schema'] );
+		add_action( 'woocommerce_extensible_abilities_resources_init', array( AbilitiesLoader::class, 'register_core_resources' ), 0 );
+		do_action( 'woocommerce_extensible_abilities_resources_init' );
+		add_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				ExtensibleAbilities::register_field(
+					array(
+						'resource'      => 'product_term',
+						'namespace'     => 'test-ext',
+						'field'         => 'taxonomy',
+						'output_schema' => array( 'type' => 'string' ),
+						'get_callback'  => static fn( \WP_Term $term ) => $term->taxonomy,
+					)
+				);
+			}
+		);
+		do_action( 'woocommerce_extensible_abilities_init' );
+
+		$created = $this->create(
+			array(
+				'taxonomy' => 'product_tag',
+				'name'     => 'Sale',
+			)
+		);
+		$updated = $this->update(
+			array(
+				'taxonomy' => 'product_tag',
+				'id'       => $id,
+				'name'     => 'Clearance',
+			)
+		);
+
+		$this->assertSame( 'product_tag', $created['term']['extensions']['test-ext']['taxonomy'] );
+		$this->assertSame( 'product_tag', $updated['term']['extensions']['test-ext']['taxonomy'] );
+		$this->assertSame( 'string', ProductTermUpdate::get_registration_args()['output_schema']['properties']['term']['properties']['extensions']['properties']['test-ext']['properties']['taxonomy']['type'] );
+		update_option( 'woocommerce_feature_extensible_abilities_enabled', 'no' );
+		$plain = $this->update(
+			array(
+				'taxonomy' => 'product_tag',
+				'id'       => $id,
+				'name'     => 'Outlet',
+			)
+		);
+		$this->assertArrayNotHasKey( 'extensions', $plain['term'] );
+	}
+
+	/**
+	 * @testdox Should name the saved term in the error when a write saves and an extension field value then fails its schema.
+	 */
+	public function test_write_that_saves_before_a_field_fails_names_the_saved_term(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+		$id = self::factory()->term->create( array( 'taxonomy' => 'product_tag' ) );
+		update_option( 'woocommerce_feature_extensible_abilities_enabled', 'yes' );
+		add_action( 'woocommerce_extensible_abilities_resources_init', array( AbilitiesLoader::class, 'register_core_resources' ), 0 );
+		do_action( 'woocommerce_extensible_abilities_resources_init' );
+		add_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				ExtensibleAbilities::register_field(
+					array(
+						'resource'      => 'product_term',
+						'namespace'     => 'invalid-ext',
+						'field'         => 'bad',
+						'output_schema' => array( 'type' => 'integer' ),
+						'get_callback'  => static fn() => 'many',
+					)
+				);
+			}
+		);
+		do_action( 'woocommerce_extensible_abilities_init' );
+
+		$created = $this->create(
+			array(
+				'taxonomy' => 'product_tag',
+				'name'     => 'Sale',
+			)
+		);
+		$updated = $this->update(
+			array(
+				'taxonomy' => 'product_tag',
+				'id'       => $id,
+				'name'     => 'Clearance',
+			)
+		);
+
+		$created_term = get_term_by( 'name', 'Sale', 'product_tag' );
+		$this->assertInstanceOf( \WP_Term::class, $created_term );
+		$results = array(
+			$created_term->term_id => $created,
+			$id                    => $updated,
+		);
+		foreach ( $results as $term_id => $result ) {
+			$this->assertWPError( $result );
+			$this->assertSame( 'woocommerce_ability_field_output_invalid', $result->get_error_code() );
+			$this->assertStringContainsString( 'The change is saved on the product_term ' . $term_id . ',', $result->get_error_message() );
+			$this->assertTrue( $result->get_error_data()['saved'] );
+			$this->assertSame( $term_id, $result->get_error_data()['id'] );
+		}
+		$this->assertSame( 'Clearance', get_term( $id, 'product_tag' )->name );
+	}
+
+	/**
+	 * Run the create ability.
+	 *
+	 * @param array $input Ability input.
+	 * @return mixed
+	 */
+	private function create( array $input ) {
+		return ( new Ability( ProductTermCreate::get_name(), ProductTermCreate::get_registration_args() ) )->execute( $input );
+	}
+
+	/**
+	 * Run the update ability.
+	 *
+	 * @param array $input Ability input.
+	 * @return mixed
+	 */
+	private function update( array $input ) {
+		return ( new Ability( ProductTermUpdate::get_name(), ProductTermUpdate::get_registration_args() ) )->execute( $input );
+	}
+}