Commit b0b8fe3f33a for woocommerce

commit b0b8fe3f33acdac218d79f3c159723a28d705e4c
Author: Christopher Hertel <mail@christopher-hertel.de>
Date:   Tue Nov 10 10:34:04 2026 +0100

    Add global product attribute abilities (#70703)

    * Let product-terms-query read the terms of global attributes

    The taxonomy input takes a pa_* slug next to categories, tags and
    brands, so an agent can page through all the terms of a global
    attribute. Attribute terms return their menu order and none of the
    category fields. A pa_* taxonomy that does not exist is refused with
    woocommerce_ability_product_term_taxonomy_unknown instead of a
    permission error.

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

    * Add the product-attributes-query and product-attribute-create abilities

    No Core ability listed the global attributes or created one, so an agent
    could not discover or set up the attributes that variable products need.
    product-attributes-query returns each attribute with the fields of the
    wc/v3 read, its term count and its first terms, in the attribute's own
    order. product-attribute-create creates an attribute; product writes
    then create its terms from their options.

    The create ability is a plain AbilityDefinition, since a global
    attribute is a table row and not a WC_Data object. It refuses a label
    that matches an existing one ignoring case, because product writes find
    a global attribute by label.

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

    * Refuse a global attribute that product writes could mistake for another

    Product writes resolve an attribute name by its sanitized slug before
    its label. A new slug that matches an existing label, or a new label
    that matches an existing slug, made one attribute shadow the other, so
    product-attribute-create refuses both with
    woocommerce_ability_product_attribute_name_ambiguous.

    The tests now use attribute names that the test database does not
    already hold, and the unknown global attribute test writes a variable
    product, since a simple product does not take attributes.

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

    * Page attribute terms in the attribute's order and accept no input

    product-terms-query forced orderby=name, so paging the terms of a pa_*
    taxonomy after product-attributes-query, which lists them in the order
    set on the attribute, repeated some terms and skipped others. Leave
    orderby unset for attribute taxonomies so WooCommerce applies that order.

    product-attributes-query had no root default, so a call without input
    was validated as null and refused.

    * Test the created attributes through product-create

    product-update refuses to change a product into a variable one, so the
    end-to-end test now creates the variable product with product-create.

    * Check a punctuation-only slug against the slug made from the name

    A slug such as "!!!" sanitizes to nothing, so the slug checks compared
    against an empty string, and wc_create_attribute() then made the slug from
    the name. "Shade!" could so take over the name of an attribute labelled
    "Shade" with another slug. Fall back to the name's slug before the checks.

    * Register a new global attribute's taxonomy for the rest of the request

    WooCommerce registers attribute taxonomies on init only. In a stdio MCP
    session every call runs in one PHP process, so a product write after
    product-attribute-create saved the product without the attribute's
    terms and reported success. Register the taxonomy right after creating
    the attribute, with WooCommerce's filters, as the importers do.

    * Refuse an attribute whose name and slug have no letters or numbers

    {"name": "!!!"} left both slugs empty, and wc_create_attribute() stored
    a row with an empty attribute_name. No attribute read returns such a
    row, so the ability answered with a 500, and every retry added a row.
    Refuse it with woocommerce_ability_product_attribute_slug_empty instead.

    * Compare the product's attribute terms without their order

    With the taxonomy registered as WooCommerce registers it, product terms
    come back in the attribute's sort order, not the order they were sent.

    * Match the attribute search against the pa_ slug the output returns

    ---------

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

diff --git a/plugins/woocommerce/changelog/add-global-product-attribute-abilities b/plugins/woocommerce/changelog/add-global-product-attribute-abilities
new file mode 100644
index 00000000000..b4f4361a29e
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-global-product-attribute-abilities
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Allow external clients to list and create global product attributes, and read their terms, through WooCommerce abilities.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index 80a0dc8ee38..fe4e7666ece 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -17,6 +17,8 @@ use Automattic\WooCommerce\Internal\Abilities\Domain\OrderUpdate;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrderUpdateStatus;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrdersQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\PaymentGatewaysQuery;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductAttributeCreate;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductAttributesQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductCreate;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductDelete;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductUpdate;
@@ -56,6 +58,8 @@ class AbilitiesLoader {
 		ProductsQuery::class,
 		ProductTermsQuery::class,
 		ProductReviewsQuery::class,
+		ProductAttributesQuery::class,
+		ProductAttributeCreate::class,
 		ProductCreate::class,
 		ProductDelete::class,
 		ProductUpdate::class,
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductAttributeCreate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductAttributeCreate.php
new file mode 100644
index 00000000000..244bb13b85f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductAttributeCreate.php
@@ -0,0 +1,279 @@
+<?php
+/**
+ * Product attribute create ability definition.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAttributeAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Creates a global product attribute. Its terms come from the product writes that use it.
+ *
+ * Not an ActionableAbility: a global attribute is a table row, not a WC_Data object.
+ */
+class ProductAttributeCreate extends AbstractDomainAbility implements AbilityDefinition {
+
+	use ProductAttributeAbilityTrait;
+
+	/**
+	 * Error codes of wc_create_attribute() and the ability codes they map to.
+	 */
+	private const CREATE_ERRORS = array(
+		'invalid_product_attribute_slug_too_long'       => 'woocommerce_ability_product_attribute_slug_too_long',
+		'invalid_product_attribute_slug_reserved_name'  => 'woocommerce_ability_product_attribute_slug_reserved',
+		'invalid_product_attribute_slug_already_exists' => 'woocommerce_ability_product_attribute_slug_exists',
+	);
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @since 11.4.0
+	 * @return string
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/product-attribute-create';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @since 11.4.0
+	 * @return array
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => __( 'Create product attribute', 'woocommerce' ),
+			'description'         => __( 'Create a global product attribute, such as Color or Size. Product writes then use it by ID or name, with global set to true, and create its terms from the options they send.', 'woocommerce' ),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => self::get_entity_output_schema( 'attribute', self::get_product_attribute_schema() ),
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_create_attribute' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => false,
+					'idempotent'  => false,
+					'destructive' => false,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Check product attribute create access.
+	 *
+	 * @since 11.4.0
+	 * @return bool
+	 */
+	public static function can_create_attribute(): bool {
+		return self::can_manage_product_attributes( 'create' );
+	}
+
+	/**
+	 * Create a global product attribute.
+	 *
+	 * @since 11.4.0
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 */
+	public static function execute( array $input ) {
+		$name = sanitize_text_field( $input['name'] );
+		if ( '' === $name ) {
+			return new \WP_Error( 'woocommerce_ability_product_attribute_name_empty', __( 'The attribute name is empty.', 'woocommerce' ), array( 'status' => 400 ) );
+		}
+
+		$name_slug = wc_attribute_taxonomy_slug( $name );
+		// A slug of only punctuation sanitizes to nothing, and wc_create_attribute() then makes one from the name.
+		$slug = wc_attribute_taxonomy_slug( $input['slug'] ?? '' );
+		$slug = '' === $slug ? $name_slug : $slug;
+		// wc_create_attribute() stores an empty slug as a row that no attribute read returns.
+		if ( '' === $slug ) {
+			return new \WP_Error( 'woocommerce_ability_product_attribute_slug_empty', __( 'The attribute needs a slug with letters or numbers. Send one, or a name that has some.', 'woocommerce' ), array( 'status' => 400 ) );
+		}
+		$existing_id = wc_attribute_taxonomy_id_by_name( $slug );
+		// wc_create_attribute() only checks registered taxonomies, which misses an attribute created earlier in this request.
+		if ( $existing_id > 0 ) {
+			return new \WP_Error(
+				self::CREATE_ERRORS['invalid_product_attribute_slug_already_exists'],
+				/* translators: %s: attribute slug. */
+				sprintf( __( 'Slug "%s" is already in use. Change it, please.', 'woocommerce' ), $slug ),
+				array(
+					'status' => 400,
+					'id'     => $existing_id,
+				)
+			);
+		}
+
+		// Product writes resolve a name by its sanitized slug first, then by label ignoring case.
+		foreach ( wc_get_attribute_taxonomies() as $attribute ) {
+			if ( 0 === strcasecmp( (string) $attribute->attribute_label, $name ) ) {
+				return new \WP_Error(
+					'woocommerce_ability_product_attribute_name_exists',
+					/* translators: %s: attribute name. */
+					sprintf( __( 'A global attribute named "%s" already exists.', 'woocommerce' ), $attribute->attribute_label ),
+					array(
+						'status' => 400,
+						'id'     => (int) $attribute->attribute_id,
+					)
+				);
+			}
+			if ( wc_attribute_taxonomy_slug( (string) $attribute->attribute_label ) === $slug || $attribute->attribute_name === $name_slug ) {
+				return new \WP_Error(
+					'woocommerce_ability_product_attribute_name_ambiguous',
+					/* translators: 1: attribute name, 2: existing attribute name. */
+					sprintf( __( 'Product writes could not tell "%1$s" apart from the global attribute "%2$s". Choose another name or slug.', 'woocommerce' ), $name, $attribute->attribute_label ),
+					array(
+						'status' => 400,
+						'id'     => (int) $attribute->attribute_id,
+					)
+				);
+			}
+		}
+
+		$id = wc_create_attribute(
+			array(
+				'name'         => $name,
+				'slug'         => $slug,
+				'type'         => $input['type'] ?? 'select',
+				'order_by'     => $input['order_by'] ?? 'menu_order',
+				'has_archives' => $input['has_archives'] ?? false,
+			)
+		);
+		if ( is_wp_error( $id ) ) {
+			$code = self::CREATE_ERRORS[ $id->get_error_code() ] ?? 'woocommerce_ability_product_attribute_create_failed';
+			return new \WP_Error( $code, $id->get_error_message(), $id->get_error_data() );
+		}
+
+		$attributes = wc_get_attribute_taxonomies();
+		if ( ! isset( $attributes[ 'id:' . $id ] ) ) {
+			return new \WP_Error( 'woocommerce_ability_product_attribute_create_failed', __( 'The attribute was created but could not be read back.', 'woocommerce' ), array( 'status' => 500 ) );
+		}
+
+		self::register_attribute_taxonomy( $attributes[ 'id:' . $id ] );
+
+		$attribute = self::format_product_attribute( $attributes[ 'id:' . $id ], 0 );
+		if ( is_wp_error( $attribute ) ) {
+			return $attribute;
+		}
+
+		return array( 'attribute' => $attribute );
+	}
+
+	/**
+	 * Register the attribute taxonomy for the rest of this request.
+	 *
+	 * WooCommerce registers attribute taxonomies on init only. Without this, a product
+	 * write later in the same request, such as the next call of a stdio MCP session,
+	 * saves the product without the attribute's terms and reports no error.
+	 *
+	 * @param \stdClass $attribute Row of wc_get_attribute_taxonomies().
+	 * @return void
+	 */
+	private static function register_attribute_taxonomy( \stdClass $attribute ): void {
+		global $wc_product_attributes;
+
+		$taxonomy = wc_attribute_taxonomy_name( (string) $attribute->attribute_name );
+		if ( taxonomy_exists( $taxonomy ) ) {
+			return;
+		}
+
+		$label  = (string) $attribute->attribute_label;
+		$public = 1 === (int) $attribute->attribute_public;
+		register_taxonomy(
+			$taxonomy,
+			/**
+			 * Filters the object types of a global attribute taxonomy.
+			 *
+			 * @since 2.3.0
+			 * @param array $object_types Object types.
+			 */
+			apply_filters( "woocommerce_taxonomy_objects_{$taxonomy}", array( 'product' ) ),
+			/**
+			 * Filters the registration arguments of a global attribute taxonomy.
+			 *
+			 * @since 2.3.0
+			 * @param array $args Taxonomy arguments.
+			 */
+			apply_filters(
+				"woocommerce_taxonomy_args_{$taxonomy}",
+				array(
+					'hierarchical'          => false,
+					'update_count_callback' => '_update_post_term_count',
+					'labels'                => array(
+						'name'          => $label,
+						'singular_name' => $label,
+					),
+					'show_ui'               => true,
+					'show_in_quick_edit'    => false,
+					'show_in_menu'          => false,
+					'meta_box_cb'           => false,
+					'query_var'             => $public,
+					'rewrite'               => false,
+					'sort'                  => false,
+					'public'                => $public,
+					'show_in_nav_menus'     => false,
+					'capabilities'          => array(
+						'manage_terms' => 'manage_product_terms',
+						'edit_terms'   => 'edit_product_terms',
+						'delete_terms' => 'delete_product_terms',
+						'assign_terms' => 'assign_product_terms',
+					),
+				)
+			)
+		);
+
+		$wc_product_attributes[ $taxonomy ] = $attribute;
+	}
+
+	/**
+	 * Get the input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'name'         => array(
+					'description' => __( 'Label shown to shoppers, such as Color.', 'woocommerce' ),
+					'type'        => 'string',
+					'minLength'   => 1,
+				),
+				'slug'         => array(
+					'description' => __( 'Slug without the pa_ prefix. Defaults to one made from the name.', 'woocommerce' ),
+					'type'        => 'string',
+					'minLength'   => 1,
+				),
+				'type'         => array(
+					'type'    => 'string',
+					'enum'    => array_keys( wc_get_attribute_types() ),
+					'default' => 'select',
+				),
+				'order_by'     => array(
+					'description' => __( 'Default sort order of the terms. name_num sorts names as numbers.', 'woocommerce' ),
+					'type'        => 'string',
+					'enum'        => array( 'menu_order', 'name', 'name_num', 'id' ),
+					'default'     => 'menu_order',
+				),
+				'has_archives' => array(
+					'description' => __( 'Give each term a public archive page.', 'woocommerce' ),
+					'type'        => 'boolean',
+					'default'     => false,
+				),
+			),
+			'required'             => array( 'name' ),
+			'additionalProperties' => false,
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductAttributesQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductAttributesQuery.php
new file mode 100644
index 00000000000..efe1bcaf486
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductAttributesQuery.php
@@ -0,0 +1,179 @@
+<?php
+/**
+ * Product attributes query ability definition.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAttributeAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Queries global product attributes, the `pa_*` taxonomies, with their terms.
+ */
+class ProductAttributesQuery extends AbstractDomainAbility implements AbilityDefinition {
+
+	use ProductAttributeAbilityTrait;
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @since 11.4.0
+	 * @return string
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/product-attributes-query';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @since 11.4.0
+	 * @return array
+	 */
+	public static function get_registration_args(): array {
+		$output_schema                        = self::get_collection_output_schema( 'attributes', self::get_product_attribute_schema() );
+		$output_schema['properties']['total'] = array(
+			'type'        => 'integer',
+			'description' => __( 'Total number of matching attributes across all pages.', 'woocommerce' ),
+		);
+
+		return array(
+			'label'               => __( 'Query product attributes', 'woocommerce' ),
+			'description'         => __( 'Find the global product attributes, such as Color or Size, with their terms. Use include to retrieve specific attribute IDs.', 'woocommerce' ),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => $output_schema,
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_query_attributes' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => true,
+					'idempotent'  => true,
+					'destructive' => false,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Check product attribute read access.
+	 *
+	 * @since 11.4.0
+	 * @return bool
+	 */
+	public static function can_query_attributes(): bool {
+		return self::can_manage_product_attributes( 'read' );
+	}
+
+	/**
+	 * Query global product attributes.
+	 *
+	 * @since 11.4.0
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 */
+	public static function execute( array $input ) {
+		$page       = $input['page'] ?? 1;
+		$per_page   = $input['per_page'] ?? 10;
+		$offset     = $input['offset'] ?? ( $page - 1 ) * $per_page;
+		$attributes = array_values( wc_get_attribute_taxonomies() );
+
+		if ( isset( $input['include'] ) ) {
+			$attributes = array_values(
+				array_filter(
+					$attributes,
+					static fn( $attribute ) => in_array( (int) $attribute->attribute_id, $input['include'], true )
+				)
+			);
+		}
+
+		$search = trim( $input['search'] ?? '' );
+		if ( '' !== $search ) {
+			$attributes = array_values(
+				array_filter(
+					$attributes,
+					static fn( $attribute ) => false !== stripos( (string) $attribute->attribute_label, $search )
+						|| false !== stripos( wc_attribute_taxonomy_name( (string) $attribute->attribute_name ), $search )
+				)
+			);
+		}
+
+		$total     = count( $attributes );
+		$formatted = array();
+		foreach ( array_slice( $attributes, $offset, $per_page ) as $attribute ) {
+			$data = self::format_product_attribute( $attribute, $input['terms_per_attribute'] ?? 25 );
+			if ( is_wp_error( $data ) ) {
+				return $data;
+			}
+			$formatted[] = $data;
+		}
+
+		return array(
+			'attributes'  => $formatted,
+			'total'       => $total,
+			'total_pages' => (int) ceil( $total / $per_page ),
+			'page'        => isset( $input['offset'] ) ? (int) floor( $offset / $per_page ) + 1 : $page,
+			'per_page'    => $per_page,
+		);
+	}
+
+	/**
+	 * Get the input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'include'             => array(
+					'description' => __( 'Limit results to these attribute IDs. Use one ID to retrieve a single attribute.', 'woocommerce' ),
+					'type'        => 'array',
+					'minItems'    => 1,
+					'items'       => array(
+						'type'    => 'integer',
+						'minimum' => 1,
+					),
+				),
+				'search'              => array(
+					'description' => __( 'Match part of the attribute label or slug, ignoring case.', 'woocommerce' ),
+					'type'        => 'string',
+				),
+				'terms_per_attribute' => array(
+					'description' => __( 'Most terms to return per attribute. Use zero for none.', 'woocommerce' ),
+					'type'        => 'integer',
+					'minimum'     => 0,
+					'maximum'     => 100,
+					'default'     => 25,
+				),
+				'page'                => array(
+					'type'    => 'integer',
+					'minimum' => 1,
+					'default' => 1,
+				),
+				'per_page'            => array(
+					'type'    => 'integer',
+					'minimum' => 1,
+					'maximum' => 50,
+					'default' => 10,
+				),
+				'offset'              => array(
+					'type'    => 'integer',
+					'minimum' => 0,
+				),
+			),
+			'additionalProperties' => false,
+			'default'              => array(),
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
index 564d3dbadd8..7b611f89a92 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
@@ -13,7 +13,7 @@ use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
 defined( 'ABSPATH' ) || exit;

 /**
- * Queries product categories, tags and brands.
+ * Queries product categories, tags, brands and the terms of global attributes.
  */
 class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefinition {

@@ -42,7 +42,7 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti

 		return array(
 			'label'               => __( 'Query product terms', 'woocommerce' ),
-			'description'         => __( 'Find product categories, tags or brands. Use include to retrieve specific term IDs.', 'woocommerce' ),
+			'description'         => __( 'Find product categories, tags, brands or the terms of a global attribute, such as pa_color. Use include to retrieve specific term IDs.', 'woocommerce' ),
 			'category'            => 'woocommerce',
 			'input_schema'        => self::get_input_schema(),
 			'output_schema'       => $output_schema,
@@ -72,8 +72,11 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 	 */
 	public static function can_query_terms( $input = array() ): bool {
 		$taxonomy = $input['taxonomy'];
-		return taxonomy_exists( $taxonomy )
-			&& wc_rest_check_product_term_permissions( $taxonomy, 'read' );
+		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' );
 	}

 	/**
@@ -84,14 +87,27 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 	 * @return array|\WP_Error
 	 */
 	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 )
+			);
+		}
+
 		$page     = $input['page'] ?? 1;
 		$per_page = $input['per_page'] ?? 10;
-		$args     = array_merge(
-			array(
-				'hide_empty' => false,
-				'orderby'    => 'name',
-				'order'      => 'asc',
-			),
+		$defaults = array(
+			'hide_empty' => false,
+			'order'      => 'asc',
+		);
+		// Without an orderby, WooCommerce sorts attribute terms in the order set on the attribute.
+		if ( ! taxonomy_is_product_attribute( $input['taxonomy'] ) ) {
+			$defaults['orderby'] = 'name';
+		}
+		$args = array_merge(
+			$defaults,
 			array_intersect_key( $input, array_flip( array( 'taxonomy', 'include', 'exclude', 'search', 'slug', 'parent', 'hide_empty', 'orderby', 'order' ) ) )
 		);
 		if ( isset( $input['slug'] ) ) {
@@ -159,7 +175,9 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 			'description' => $term->description,
 			'count'       => $term->count,
 		);
-		if ( 'product_tag' !== $term->taxonomy ) {
+		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';
@@ -206,9 +224,9 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 			'type'                 => 'object',
 			'properties'           => array(
 				'taxonomy'   => array(
-					'description' => __( 'Product taxonomy to query.', 'woocommerce' ),
+					'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',
-					'enum'        => array( 'product_cat', 'product_tag', 'product_brand' ),
+					'pattern'     => '^(product_cat|product_tag|product_brand|pa_[^\\s]+)$',
 				),
 				'include'    => array(
 					'description' => __( 'Limit results to these term IDs. Use one ID to retrieve a single term.', 'woocommerce' ),
@@ -248,9 +266,9 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
 					'default' => false,
 				),
 				'orderby'    => array(
-					'type'    => 'string',
-					'enum'    => array( 'id', 'include', 'name', 'slug', 'term_group', 'description', 'count' ),
-					'default' => 'name',
+					'description' => __( 'Defaults to name, or for a pa_* taxonomy to the order set on the attribute.', 'woocommerce' ),
+					'type'        => 'string',
+					'enum'        => array( 'id', 'include', 'name', 'slug', 'term_group', 'description', 'count' ),
 				),
 				'order'      => array(
 					'type'    => 'string',
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAttributeAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAttributeAbilityTrait.php
new file mode 100644
index 00000000000..30f4cbfcda4
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAttributeAbilityTrait.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * Global product attribute ability helpers.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Formats global attributes with the fields of the `wc/v3` attribute reads.
+ */
+trait ProductAttributeAbilityTrait {
+
+	/**
+	 * Check product attribute access, as the REST attribute endpoints do.
+	 *
+	 * @param string $context `read` or `create`.
+	 * @return bool
+	 */
+	private static function can_manage_product_attributes( string $context ): bool {
+		return wc_rest_check_manager_permissions( 'attributes', $context );
+	}
+
+	/**
+	 * Format a global attribute and up to `$term_limit` of its terms, in the attribute's own term order.
+	 *
+	 * @param \stdClass $attribute  Row of wc_get_attribute_taxonomies().
+	 * @param int       $term_limit Most terms to return. Zero returns none.
+	 * @return array|\WP_Error
+	 */
+	private static function format_product_attribute( \stdClass $attribute, int $term_limit ) {
+		$taxonomy = wc_attribute_taxonomy_name( (string) $attribute->attribute_name );
+		$output   = array(
+			'id'           => (int) $attribute->attribute_id,
+			'name'         => (string) $attribute->attribute_label,
+			'slug'         => $taxonomy,
+			'type'         => (string) $attribute->attribute_type,
+			'order_by'     => (string) $attribute->attribute_orderby,
+			'has_archives' => (bool) $attribute->attribute_public,
+			'term_count'   => 0,
+			'terms'        => array(),
+		);
+
+		// An attribute created in this request has no taxonomy until the next init.
+		if ( ! taxonomy_exists( $taxonomy ) ) {
+			return $output;
+		}
+
+		$count = wp_count_terms(
+			array(
+				'taxonomy'   => $taxonomy,
+				'hide_empty' => false,
+			)
+		);
+		if ( is_wp_error( $count ) ) {
+			return $count;
+		}
+		$output['term_count'] = (int) $count;
+
+		if ( 0 === $term_limit || 0 === $output['term_count'] ) {
+			return $output;
+		}
+
+		$terms = get_terms(
+			array(
+				'taxonomy'   => $taxonomy,
+				'hide_empty' => false,
+				'number'     => $term_limit,
+			)
+		);
+		if ( is_wp_error( $terms ) ) {
+			return $terms;
+		}
+
+		foreach ( $terms as $term ) {
+			$output['terms'][] = array(
+				'id'          => $term->term_id,
+				'name'        => html_entity_decode( $term->name, ENT_QUOTES | ENT_HTML5 ),
+				'slug'        => $term->slug,
+				'description' => $term->description,
+				'menu_order'  => (int) get_term_meta( $term->term_id, 'order', true ),
+				'count'       => $term->count,
+			);
+		}
+
+		return $output;
+	}
+
+	/**
+	 * Get the attribute output schema.
+	 *
+	 * @return array
+	 */
+	private static function get_product_attribute_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id'           => array( 'type' => 'integer' ),
+				'name'         => array(
+					'type'        => 'string',
+					'description' => __( 'Label. Product writes accept it as the name of a global attribute.', 'woocommerce' ),
+				),
+				'slug'         => array(
+					'type'        => 'string',
+					'description' => __( 'Taxonomy of the attribute terms, such as pa_color.', 'woocommerce' ),
+				),
+				'type'         => array( 'type' => 'string' ),
+				'order_by'     => array(
+					'type'        => 'string',
+					'description' => __( 'Default sort order of the terms.', 'woocommerce' ),
+				),
+				'has_archives' => array( 'type' => 'boolean' ),
+				'term_count'   => array(
+					'type'        => 'integer',
+					'description' => __( 'Number of terms. Use product-terms-query with the slug as taxonomy to page through more terms than terms lists.', 'woocommerce' ),
+				),
+				'terms'        => array(
+					'type'  => 'array',
+					'items' => array(
+						'type'                 => 'object',
+						'properties'           => array(
+							'id'          => array( 'type' => 'integer' ),
+							'name'        => array( 'type' => 'string' ),
+							'slug'        => array( 'type' => 'string' ),
+							'description' => array( 'type' => 'string' ),
+							'menu_order'  => array( 'type' => 'integer' ),
+							'count'       => array( 'type' => 'integer' ),
+						),
+						'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 8d7d186c21d..2f2012a64a3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -34,6 +34,8 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {

 	private const CANONICAL_ABILITY_IDS = array(
 		'woocommerce/product-terms-query',
+		'woocommerce/product-attributes-query',
+		'woocommerce/product-attribute-create',
 		'woocommerce/products-query',
 		'woocommerce/product-create',
 		'woocommerce/product-update',
@@ -268,57 +270,67 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 	 */
 	public function test_canonical_ability_annotations_match_intent(): void {
 		$expectations = array(
-			'woocommerce/products-query'         => array(
+			'woocommerce/products-query'           => array(
 				'readonly'    => true,
 				'idempotent'  => true,
 				'destructive' => false,
 			),
-			'woocommerce/product-create'         => array(
+			'woocommerce/product-attributes-query' => array(
+				'readonly'    => true,
+				'idempotent'  => true,
+				'destructive' => false,
+			),
+			'woocommerce/product-attribute-create' => array(
+				'readonly'    => false,
+				'idempotent'  => false,
+				'destructive' => false,
+			),
+			'woocommerce/product-create'           => array(
 				'readonly'    => false,
 				'idempotent'  => false,
 				'destructive' => false,
 			),
-			'woocommerce/product-update'         => array(
+			'woocommerce/product-update'           => array(
 				'readonly'    => false,
 				'idempotent'  => false,
 				'destructive' => true,
 			),
-			'woocommerce/product-delete'         => array(
+			'woocommerce/product-delete'           => array(
 				'readonly'    => false,
 				'idempotent'  => true,
 				'destructive' => true,
 			),
-			'woocommerce/orders-query'           => array(
+			'woocommerce/orders-query'             => array(
 				'readonly'    => true,
 				'idempotent'  => true,
 				'destructive' => false,
 			),
-			'woocommerce/order-send-email'       => array(
+			'woocommerce/order-send-email'         => array(
 				'readonly'    => false,
 				'idempotent'  => false,
 				'destructive' => false,
 			),
-			'woocommerce/order-update'           => array(
+			'woocommerce/order-update'             => array(
 				'readonly'    => false,
 				'idempotent'  => false,
 				'destructive' => true,
 			),
-			'woocommerce/order-update-status'    => array(
+			'woocommerce/order-update-status'      => array(
 				'readonly'    => false,
 				'idempotent'  => false,
 				'destructive' => true,
 			),
-			'woocommerce/order-add-note'         => array(
+			'woocommerce/order-add-note'           => array(
 				'readonly'    => false,
 				'idempotent'  => false,
 				'destructive' => false,
 			),
-			'woocommerce/order-notes-query'      => array(
+			'woocommerce/order-notes-query'        => array(
 				'readonly'    => true,
 				'idempotent'  => true,
 				'destructive' => false,
 			),
-			'woocommerce/payment-gateways-query' => array(
+			'woocommerce/payment-gateways-query'   => array(
 				'readonly'    => true,
 				'idempotent'  => true,
 				'destructive' => false,
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductAttributeCreateTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductAttributeCreateTest.php
new file mode 100644
index 00000000000..c5beb73532b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductAttributeCreateTest.php
@@ -0,0 +1,257 @@
+<?php
+/**
+ * Product attribute create ability tests.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Internal\Abilities\Ability;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductAttributeCreate;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductCreate;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductAttributesQuery;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests global attribute creation through the registered ability.
+ */
+class ProductAttributeCreateTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var Ability
+	 */
+	private $sut;
+
+	/**
+	 * Taxonomies registered before the test, so the ones it registers can be removed.
+	 *
+	 * @var string[]
+	 */
+	private array $taxonomies_before;
+
+	/**
+	 * Set up the ability and an administrator.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->taxonomies_before = get_taxonomies();
+		update_option( 'woocommerce_feature_extensible_abilities_enabled', 'no' );
+		$this->sut = new Ability( ProductAttributeCreate::get_name(), ProductAttributeCreate::get_registration_args() );
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+	}
+
+	/**
+	 * Unregister the taxonomies the test registered, which the database rollback leaves behind.
+	 */
+	public function tearDown(): void {
+		global $wc_product_attributes;
+		foreach ( array_diff( get_taxonomies(), $this->taxonomies_before ) as $taxonomy ) {
+			unregister_taxonomy( $taxonomy );
+			unset( $wc_product_attributes[ $taxonomy ] );
+		}
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should create an attribute with the defaults of the wc/v3 attribute create.
+	 */
+	public function test_creates_with_defaults(): void {
+		$result = $this->sut->execute( array( 'name' => 'Color' ) );
+
+		$this->assertIsArray( $result );
+		$attribute = $result['attribute'];
+		$this->assertGreaterThan( 0, $attribute['id'] );
+		$this->assertSame( 'Color', $attribute['name'] );
+		$this->assertSame( 'pa_color', $attribute['slug'] );
+		$this->assertSame( 'select', $attribute['type'] );
+		$this->assertSame( 'menu_order', $attribute['order_by'] );
+		$this->assertFalse( $attribute['has_archives'] );
+		$this->assertSame( array(), $attribute['terms'] );
+		$this->assertSame( $attribute['id'], wc_attribute_taxonomy_id_by_name( 'color' ) );
+		$this->assertTrue( taxonomy_exists( 'pa_color' ), 'Later writes in this request can assign its terms.' );
+	}
+
+	/**
+	 * @testdox Should store the slug, sort order and archives it is given, without a pa_ prefix on the slug.
+	 */
+	public function test_creates_with_given_fields(): void {
+		$result = $this->sut->execute(
+			array(
+				'name'         => 'Shoe size',
+				'slug'         => 'pa_shoe-size',
+				'order_by'     => 'name_num',
+				'has_archives' => true,
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertSame( 'pa_shoe-size', $result['attribute']['slug'] );
+		$this->assertSame( 'name_num', $result['attribute']['order_by'] );
+		$this->assertTrue( $result['attribute']['has_archives'] );
+	}
+
+	/**
+	 * @testdox Should refuse an input it cannot store, with an ability error code.
+	 * @testWith ["Other", "test-colour", "woocommerce_ability_product_attribute_slug_exists"]
+	 *           ["Other", "pa_test-colour", "woocommerce_ability_product_attribute_slug_exists"]
+	 *           ["TEST COLOR", "other-color", "woocommerce_ability_product_attribute_name_exists"]
+	 *           ["Shade", "test-color", "woocommerce_ability_product_attribute_name_ambiguous"]
+	 *           ["Test Colour", "shade", "woocommerce_ability_product_attribute_name_ambiguous"]
+	 *           ["Test Color!", "!!!", "woocommerce_ability_product_attribute_name_ambiguous"]
+	 *           ["!!!", "???", "woocommerce_ability_product_attribute_slug_empty"]
+	 *           ["Type", "type", "woocommerce_ability_product_attribute_slug_reserved"]
+	 *           ["Long", "abcdefghijklmnopqrstuvwxyz0123", "woocommerce_ability_product_attribute_slug_too_long"]
+	 *           ["<b></b>", "blank", "woocommerce_ability_product_attribute_name_empty"]
+	 * @param string $name Attribute name.
+	 * @param string $slug Attribute slug.
+	 * @param string $code Expected error code.
+	 */
+	public function test_refuses_invalid_attribute( string $name, string $slug, string $code ): void {
+		$this->assertIsInt(
+			wc_create_attribute(
+				array(
+					'name' => 'Test Color',
+					'slug' => 'test-colour',
+				)
+			)
+		);
+		$before = count( wc_get_attribute_taxonomies() );
+
+		$result = $this->sut->execute(
+			array(
+				'name' => $name,
+				'slug' => $slug,
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( $code, $result->get_error_code() );
+		$this->assertSame( 400, $result->get_error_data()['status'] );
+		$this->assertCount( $before, wc_get_attribute_taxonomies(), 'Nothing is created.' );
+	}
+
+	/**
+	 * @testdox Should refuse a type that the store does not offer.
+	 */
+	public function test_refuses_unknown_type(): void {
+		$result = $this->sut->execute(
+			array(
+				'name' => 'Color',
+				'type' => 'swatch',
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should refuse a customer.
+	 */
+	public function test_refuses_customer(): void {
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+
+		$result = $this->sut->execute( array( 'name' => 'Color' ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+		$this->assertSame( 0, wc_attribute_taxonomy_id_by_name( 'color' ) );
+	}
+
+	/**
+	 * @testdox Should let product-create, in the same request, make a variable product with two new global attributes and four priced variations.
+	 */
+	public function test_product_create_uses_created_attributes(): void {
+		$color      = $this->sut->execute( array( 'name' => 'Test Color' ) )['attribute'];
+		$size       = $this->sut->execute( array( 'name' => 'Test Size' ) )['attribute'];
+		$prices     = array(
+			'red-s'  => '10.00',
+			'red-m'  => '11.00',
+			'blue-s' => '12.00',
+			'blue-m' => '13.00',
+		);
+		$variations = array();
+		foreach ( $prices as $key => $price ) {
+			list( $color_option, $size_option ) = explode( '-', $key );
+			$variations[]                       = array(
+				'attributes'    => array(
+					array(
+						'id'     => $color['id'],
+						'option' => $color_option,
+					),
+					array(
+						'id'     => $size['id'],
+						'option' => $size_option,
+					),
+				),
+				'regular_price' => $price,
+			);
+		}
+		$create = new ProductCreate( ProductCreate::ABILITY_NAME, ProductCreate::get_registration_args() );
+
+		$result = $create->execute(
+			array(
+				'name'       => 'Test Shirt',
+				'type'       => 'variable',
+				'attributes' => array(
+					array(
+						'id'      => $color['id'],
+						'options' => array( 'Red', 'Blue' ),
+					),
+					array(
+						'name'    => 'Test Size',
+						'global'  => true,
+						'options' => array( 'S', 'M' ),
+					),
+				),
+				'variations' => $variations,
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$saved = wc_get_product( $result['product']['id'] );
+		$this->assertInstanceOf( \WC_Product_Variable::class, $saved );
+		$this->assertCount( 4, $saved->get_children() );
+		$this->assertEqualsCanonicalizing( array( 'red', 'blue' ), wc_get_product_terms( $saved->get_id(), $color['slug'], array( 'fields' => 'slugs' ) ) );
+		$saved_prices = array();
+		foreach ( $saved->get_children() as $child_id ) {
+			$variation = wc_get_product( $child_id );
+			$key       = $variation->get_attribute( $color['slug'] ) . '-' . $variation->get_attribute( $size['slug'] );
+			$saved_prices[ strtolower( (string) $key ) ] = $variation->get_regular_price();
+		}
+		ksort( $prices );
+		ksort( $saved_prices );
+		$this->assertSame( $prices, $saved_prices );
+
+		$query = new Ability( ProductAttributesQuery::get_name(), ProductAttributesQuery::get_registration_args() );
+		$read  = $query->execute( array( 'include' => array( $color['id'] ) ) );
+		$this->assertSame( 2, $read['attributes'][0]['term_count'], 'The product write created the terms.' );
+	}
+
+	/**
+	 * @testdox Should refuse a product write that names a global attribute that does not exist.
+	 */
+	public function test_product_create_refuses_unknown_global_attribute(): void {
+		$create = new ProductCreate( ProductCreate::ABILITY_NAME, ProductCreate::get_registration_args() );
+
+		$result = $create->execute(
+			array(
+				'name'       => 'Test Shirt',
+				'type'       => 'variable',
+				'attributes' => array(
+					array(
+						'name'    => 'Missing',
+						'global'  => true,
+						'options' => array( 'A' ),
+					),
+				),
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_attributes_invalid', $result->get_error_code() );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductAttributesQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductAttributesQueryTest.php
new file mode 100644
index 00000000000..797dbdd4fb1
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductAttributesQueryTest.php
@@ -0,0 +1,134 @@
+<?php
+/**
+ * Product attributes query ability tests.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Internal\Abilities\Ability;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductAttributesQuery;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests global attribute reads through the registered ability.
+ */
+class ProductAttributesQueryTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var Ability
+	 */
+	private $sut;
+
+	/**
+	 * Set up the ability and an administrator.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new Ability( ProductAttributesQuery::get_name(), ProductAttributesQuery::get_registration_args() );
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+	}
+
+	/**
+	 * @testdox Should return the fields of the wc/v3 attribute read, with the terms and their count.
+	 */
+	public function test_output_parity_and_terms(): void {
+		$attribute = WC_Helper_Product::create_attribute( 'Test Color', array( 'Red', 'Blue' ) );
+		update_term_meta( $attribute['term_ids'][0], 'order', 2 );
+		update_term_meta( $attribute['term_ids'][1], 'order', 1 );
+		$expected = ( new \WC_REST_Product_Attributes_Controller() )
+			->prepare_item_for_response( wc_get_attribute_taxonomies()[ 'id:' . $attribute['attribute_id'] ], new \WP_REST_Request() )
+			->get_data();
+
+		$result = $this->sut->execute( array( 'include' => array( $attribute['attribute_id'] ) ) );
+
+		$this->assertIsArray( $result );
+		$this->assertCount( 1, $result['attributes'] );
+		$output = $result['attributes'][0];
+		foreach ( $expected as $key => $value ) {
+			$this->assertSame( $value, $output[ $key ], $key );
+		}
+		$this->assertSame( 2, $output['term_count'] );
+		$this->assertSame( array( 'Blue', 'Red' ), wp_list_pluck( $output['terms'], 'name' ), 'Terms follow the menu_order of the attribute.' );
+		$this->assertSame( 1, $output['terms'][0]['menu_order'] );
+		$this->assertSame( 'blue', $output['terms'][0]['slug'] );
+	}
+
+	/**
+	 * @testdox Should cap the terms of each attribute while still counting all of them.
+	 */
+	public function test_caps_terms_per_attribute(): void {
+		$attribute = WC_Helper_Product::create_attribute( 'Test Size', array( 'S', 'M', 'L' ) );
+
+		$capped = $this->sut->execute(
+			array(
+				'include'             => array( $attribute['attribute_id'] ),
+				'terms_per_attribute' => 2,
+			)
+		);
+		$none   = $this->sut->execute(
+			array(
+				'include'             => array( $attribute['attribute_id'] ),
+				'terms_per_attribute' => 0,
+			)
+		);
+
+		$this->assertCount( 2, $capped['attributes'][0]['terms'] );
+		$this->assertSame( 3, $capped['attributes'][0]['term_count'] );
+		$this->assertSame( array(), $none['attributes'][0]['terms'] );
+		$this->assertSame( 3, $none['attributes'][0]['term_count'] );
+	}
+
+	/**
+	 * @testdox Should filter by search and page through the attributes with a total.
+	 */
+	public function test_search_and_pagination(): void {
+		$ids = array();
+		foreach ( array( 'Test Fabric', 'Test Finish', 'Test Pattern' ) as $name ) {
+			$ids[] = WC_Helper_Product::create_attribute( $name, array() )['attribute_id'];
+		}
+
+		$search  = $this->sut->execute( array( 'search' => 'test fin' ) );
+		$by_slug = $this->sut->execute( array( 'search' => 'pa_test-fin' ) );
+		$page    = $this->sut->execute(
+			array(
+				'include'  => $ids,
+				'per_page' => 2,
+				'page'     => 2,
+			)
+		);
+
+		$this->assertSame( array( 'Test Finish' ), wp_list_pluck( $search['attributes'], 'name' ) );
+		$this->assertSame( 1, $search['total'] );
+		$this->assertSame( array( 'Test Finish' ), wp_list_pluck( $by_slug['attributes'], 'name' ), 'The search matches the slug as the output returns it.' );
+		$this->assertSame( 3, $page['total'] );
+		$this->assertSame( 2, $page['total_pages'] );
+		$this->assertSame( array( 'Test Pattern' ), wp_list_pluck( $page['attributes'], 'name' ), 'Attributes are sorted by slug.' );
+	}
+
+	/**
+	 * @testdox Should accept a call without input.
+	 */
+	public function test_accepts_no_input(): void {
+		$result = $this->sut->execute();
+
+		$this->assertIsArray( $result );
+		$this->assertSame( 1, $result['page'] );
+	}
+
+	/**
+	 * @testdox Should refuse a customer.
+	 */
+	public function test_refuses_customer(): void {
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+
+		$result = $this->sut->execute( array() );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
index cd1a8d65e7c..b0384257620 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
@@ -596,4 +596,81 @@ class ProductTermsQueryTest extends \WC_Unit_Test_Case {

 		return $result['terms'];
 	}
+
+	/**
+	 * @testdox Should return the terms of a global attribute with their menu order and no category fields.
+	 */
+	public function test_queries_global_attribute_terms(): void {
+		$attribute = \WC_Helper_Product::create_attribute( 'Color', array( 'Red' ) );
+		update_term_meta( $attribute['term_ids'][0], 'order', 3 );
+		$ability = new Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() );
+
+		$result = $ability->execute( array( 'taxonomy' => 'pa_color' ) );
+
+		$this->assertIsArray( $result );
+		$this->assertSame( 1, $result['total'] );
+		$this->assertSame( 'Red', $result['terms'][0]['name'] );
+		$this->assertSame( 3, $result['terms'][0]['menu_order'] );
+		$this->assertArrayNotHasKey( 'parent', $result['terms'][0] );
+		$this->assertArrayNotHasKey( 'display', $result['terms'][0] );
+	}
+
+	/**
+	 * @testdox Should page through global attribute terms in the order set on the attribute, as product-attributes-query lists them.
+	 */
+	public function test_pages_global_attribute_terms_in_attribute_order(): void {
+		$attribute = \WC_Helper_Product::create_attribute( 'Test Shade', array( 'Amber', 'Bronze', 'Coral' ) );
+		foreach ( $attribute['term_ids'] as $index => $term_id ) {
+			update_term_meta( $term_id, 'order', 3 - $index );
+		}
+		$ability = new Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() );
+
+		$first  = $ability->execute(
+			array(
+				'taxonomy' => 'pa_test-shade',
+				'per_page' => 2,
+			)
+		);
+		$second = $ability->execute(
+			array(
+				'taxonomy' => 'pa_test-shade',
+				'per_page' => 2,
+				'page'     => 2,
+			)
+		);
+		$named  = $ability->execute(
+			array(
+				'taxonomy' => 'pa_test-shade',
+				'orderby'  => 'name',
+			)
+		);
+
+		$this->assertSame( array( 'Coral', 'Bronze' ), wp_list_pluck( $first['terms'], 'name' ) );
+		$this->assertSame( array( 'Amber' ), wp_list_pluck( $second['terms'], 'name' ) );
+		$this->assertSame( array( 'Amber', 'Bronze', 'Coral' ), wp_list_pluck( $named['terms'], 'name' ), 'An explicit orderby still applies.' );
+	}
+
+	/**
+	 * @testdox Should tell a store manager that a global attribute taxonomy does not exist.
+	 */
+	public function test_refuses_unknown_global_attribute_taxonomy(): void {
+		$ability = new Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() );
+
+		$result = $ability->execute( array( 'taxonomy' => 'pa_missing' ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_ability_product_term_taxonomy_unknown', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should refuse a taxonomy that is neither a product taxonomy nor a global attribute.
+	 */
+	public function test_refuses_other_taxonomy(): void {
+		$ability = new Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() );
+
+		$result = $ability->execute( array( 'taxonomy' => 'category' ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+	}
 }