Commit 1a043a4b3f0 for woocommerce

commit 1a043a4b3f05b36e4427736ff017a989a2c7fdc3
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Wed May 13 15:39:04 2026 +0200

    Implement a metadata mechanism for the dual code+GraphQL API (#64800)

diff --git a/plugins/woocommerce/changelog/64730-add-schema-metadata-to-dual-api b/plugins/woocommerce/changelog/64730-add-schema-metadata-to-dual-api
new file mode 100644
index 00000000000..210556538e6
--- /dev/null
+++ b/plugins/woocommerce/changelog/64730-add-schema-metadata-to-dual-api
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a metadata mechanism to the dual code+GraphQL API
diff --git a/plugins/woocommerce/src/Api/Attributes/Experimental.php b/plugins/woocommerce/src/Api/Attributes/Experimental.php
new file mode 100644
index 00000000000..8e4f6af87be
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Experimental.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Marks a code-API element as experimental: present in the schema, but not
+ * stable enough to be relied on in production.
+ *
+ * Discoverable through the `_apiMetadata` GraphQL field as an entry with
+ * `name = "experimental"` and `value = true`. The marking is informational:
+ * it does not gate access in any way.
+ *
+ * When a class, property or enum case has this attribute, the generated
+ * GraphQL `description` is prefixed with `[Experimental] `, and
+ * when the element has no `#[Description]` at all, a default body
+ * (`[Experimental] Not to be used in production environments.`) is emitted
+ * so the marker still reaches stock introspection.
+ *
+ * `#[Experimental]` on a class marks only that class: its fields and enum
+ * cases are not implicitly marked too. A tool that wants to treat the
+ * contents of an experimental type as experimental by association must
+ * apply that rule itself when it reads the metadata.
+ *
+ * The attribute targets classes, properties, and enum cases; see
+ * {@see Metadata} for the reasoning behind excluding methods.
+ */
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS_CONSTANT )]
+class Experimental extends Metadata {
+	/**
+	 * Construct an `experimental` metadata entry with value `true`.
+	 */
+	public function __construct() {
+		parent::__construct( 'experimental', true );
+	}
+
+	/**
+	 * Prepend `[Experimental] ` to the description, supplying a default body
+	 * when the element has no `#[Description]` of its own. See
+	 * {@see Metadata::transform_description()} for the contract.
+	 *
+	 * @param string $description Incoming description (empty when no `#[Description]`).
+	 */
+	public function transform_description( string $description ): string {
+		if ( '' === $description ) {
+			$description = 'Not to be used in production environments.';
+		}
+		return '[Experimental] ' . $description;
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Internal.php b/plugins/woocommerce/src/Api/Attributes/Internal.php
new file mode 100644
index 00000000000..74cf0e3c2aa
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Internal.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Marks a code-API element as for WooCommerce internal use.
+ *
+ * Discoverable through the `_apiMetadata` GraphQL field as an entry with
+ * `name = "internal"` and `value = true`. The marking is informational —
+ * authorization remains the job of {@see PublicAccess}, {@see RequiredCapability},
+ * and any plugin-supplied authorization attributes.
+ *
+ * When a class, property or enum case has this attribute, the generated
+ * GraphQL `description` is prefixed with `[Internal] `, and
+ * when the element has no `#[Description]` at all, a default body
+ * (`[Internal] For WooCommerce core internal usage only.`) is emitted so the
+ * marker still reaches stock introspection.
+ *
+ * `#[Internal]` on a class marks only that class — its fields and enum cases
+ * are not implicitly marked too. A tool that wants to treat the contents of
+ * an internal type as internal by association must apply that rule itself
+ * when it reads the metadata.
+ *
+ * The attribute targets classes, properties, and enum cases; see
+ * {@see Metadata} for the reasoning behind excluding methods.
+ */
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS_CONSTANT )]
+class Internal extends Metadata {
+	/**
+	 * Construct an `internal` metadata entry with value `true`.
+	 */
+	public function __construct() {
+		parent::__construct( 'internal', true );
+	}
+
+	/**
+	 * Prepend `[Internal] ` to the description, supplying a default body when
+	 * the element has no `#[Description]` of its own. See
+	 * {@see Metadata::transform_description()} for the contract.
+	 *
+	 * @param string $description Incoming description (empty when no `#[Description]`).
+	 */
+	public function transform_description( string $description ): string {
+		if ( '' === $description ) {
+			$description = 'For WooCommerce core internal usage only.';
+		}
+		return '[Internal] ' . $description;
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Metadata.php b/plugins/woocommerce/src/Api/Attributes/Metadata.php
new file mode 100644
index 00000000000..46595fe5e19
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/Metadata.php
@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Attaches a name/value metadata entry to a code-API element.
+ * (class, class property, method parameter, method parameter, or enum case).
+ *
+ * Metadata entries are harvested by ApiBuilder and emitted into the generated
+ * schema, where they can be queried at runtime through the top-level
+ * `_apiMetadata` GraphQL field. The mechanism is intentionally open: subclass
+ * this attribute to ship a category of metadata (e.g. {@see Internal} for
+ * marking elements as for WooCommerce internal use). Tooling discovers metadata
+ * by name; the value is scalar-only so it can flow through GraphQL without
+ * additional encoding.
+ *
+ * Two metadata entries with the same name on the same element produce a
+ * build-time error, see ApiBuilder for the duplicate-name detection. Multiple
+ * distinct names on one element are allowed.
+ */
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::TARGET_CLASS_CONSTANT | Attribute::IS_REPEATABLE )]
+class Metadata {
+	/**
+	 * Constructor.
+	 *
+	 * @param string                     $name  Identifier for this entry. Must be unique per element across all Metadata subclasses applied to it.
+	 * @param bool|int|float|string|null $value Scalar payload exposed to clients via `_apiMetadata`.
+	 */
+	public function __construct(
+		private string $name,
+		private bool|int|float|string|null $value,
+	) {
+	}
+
+	/**
+	 * The entry's name (e.g. `internal`, `beta`, `owner`).
+	 */
+	public function get_name(): string {
+		return $this->name;
+	}
+
+	/**
+	 * The entry's scalar value.
+	 */
+	public function get_value(): bool|int|float|string|null {
+		return $this->value;
+	}
+
+	/**
+	 * Transform the GraphQL `description` of the element this attribute is
+	 * applied to.
+	 *
+	 * The base implementation is a no-op; the general `#[Metadata]` mechanism
+	 * does not modify descriptions. Subclasses opt into the description-mirror
+	 * convention by overriding this method — typically to prefix the input
+	 * with a marker (`[Internal] `, `[Experimental] `, …) and supply a default
+	 * body when the element has no `#[Description]` of its own.
+	 *
+	 * Conventions for overrides:
+	 *  - An empty `$description` means the element has no `#[Description]`.
+	 *    If the subclass wants the marker to still reach stock introspection,
+	 *    it should supply a sensible default text and prefix it as usual.
+	 *  - A non-empty `$description` may be either the developer's own text or
+	 *    the output of a previous attribute's transform; the subclass should
+	 *    not try to distinguish. Wrap-only (prefix the input, don't replace).
+	 *
+	 * When more than one transforming attribute is applied to the same
+	 * element, ApiBuilder calls `transform_description()` once per attribute
+	 * in PHP reflection (source) order, threading each return value into the
+	 * next call. Because each subclass prefixes the input, the last attribute
+	 * in source ends up as the outermost prefix in the final string. Order
+	 * the attributes accordingly when the reading order matters.
+	 *
+	 * @internal Called by ApiBuilder; not part of any caller-visible contract.
+	 *
+	 * @param string $description The current description text (`''` when the element has no `#[Description]`, or the previous transform's output when chained).
+	 */
+	public function transform_description( string $description ): string {
+		return $description;
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Utils/SchemaHandle.php b/plugins/woocommerce/src/Api/Utils/SchemaHandle.php
new file mode 100644
index 00000000000..1a2b7f5e68b
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Utils/SchemaHandle.php
@@ -0,0 +1,232 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Utils;
+
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\EnumType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\HasFieldsType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\InputObjectType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\Type;
+
+/**
+ * Opaque handle to a dual-API GraphQL schema, exposing the runtime inspection
+ * operations the dual-API surface supports.
+ *
+ * The handle wraps the live engine schema but does not expose it. Clients
+ * therefore depend only on the methods this class declares — never on the
+ * underlying engine type — which keeps a future engine swap as a non-public
+ * API change.
+ *
+ * Construction is reserved for the dual-API infrastructure. Obtain a handle
+ * via your dual-API `GraphQLController`'s `get_schema()` method:
+ *
+ *     $schema = wc_get_container()
+ *         ->get( \Automattic\WooCommerce\Internal\Api\GraphQLController::class )
+ *         ->get_schema();
+ *
+ * WooCommerce plugins implementing their own dual API reach a handle through
+ * their own concrete controller the same way.
+ *
+ * The current public surface is the metadata channel: {@see self::get_all_metadata()}
+ * returns every metadata row in the schema, {@see self::find_metadata()} applies
+ * filter-narrows semantics over the same set.
+ *
+ * @phpstan-type MetadataRow array{
+ *     type: string,
+ *     field: ?string,
+ *     argument: ?string,
+ *     enumValue: ?string,
+ *     entries: array<string, bool|int|float|string|null>
+ * }
+ */
+final class SchemaHandle {
+
+	/**
+	 * The wrapped engine schema. Typed as `object` (rather than the engine's
+	 * `Schema` class) so the class signature carries no engine-specific
+	 * symbol; the inspection methods cast to engine APIs internally.
+	 *
+	 * @var object
+	 */
+	private object $engine_schema;
+
+	/**
+	 * Wrap an engine schema in a handle.
+	 *
+	 * @internal Reserved for dual-API infrastructure (the controller's `get_schema()` accessor and similarly placed code). Plugins obtain a handle through their own controller, not by instantiating directly.
+	 *
+	 * @param object $engine_schema Engine-specific schema instance the handle wraps.
+	 */
+	public function __construct( object $engine_schema ) {
+		$this->engine_schema = $engine_schema;
+	}
+
+	/**
+	 * Return every metadata row in the schema (introspection types excluded).
+	 *
+	 * Each row describes one *target* (a type, a field, an argument, or an
+	 * enum value) and carries the name=>value entries declared on it. The
+	 * same row shape is used for every target kind; the three nullable
+	 * position fields (`field`, `argument`, `enumValue`) discriminate.
+	 *
+	 * @return list<array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>}>
+	 */
+	public function get_all_metadata(): array {
+		$rows   = array();
+		$schema = $this->engine_schema;
+
+		foreach ( $schema->getTypeMap() as $type_name => $type ) {
+			if ( self::is_introspection_name( $type_name ) ) {
+				continue;
+			}
+
+			$type_metadata = self::read_type_metadata( $type );
+			if ( ! empty( $type_metadata ) ) {
+				$rows[] = self::make_row( $type_name, null, null, null, $type_metadata );
+			}
+
+			if ( $type instanceof HasFieldsType ) {
+				foreach ( $type->getFields() as $field_name => $field ) {
+					$field_metadata = self::read_element_metadata( $field );
+					if ( ! empty( $field_metadata ) ) {
+						$rows[] = self::make_row( $type_name, $field_name, null, null, $field_metadata );
+					}
+
+					foreach ( $field->args as $arg ) {
+						$arg_metadata = self::read_element_metadata( $arg );
+						if ( ! empty( $arg_metadata ) ) {
+							$rows[] = self::make_row( $type_name, $field_name, $arg->name, null, $arg_metadata );
+						}
+					}
+				}
+				continue;
+			}
+
+			if ( $type instanceof InputObjectType ) {
+				foreach ( $type->getFields() as $field_name => $field ) {
+					$field_metadata = self::read_element_metadata( $field );
+					if ( ! empty( $field_metadata ) ) {
+						$rows[] = self::make_row( $type_name, $field_name, null, null, $field_metadata );
+					}
+				}
+				continue;
+			}
+
+			if ( $type instanceof EnumType ) {
+				foreach ( $type->getValues() as $value ) {
+					$value_metadata = self::read_element_metadata( $value );
+					if ( ! empty( $value_metadata ) ) {
+						$rows[] = self::make_row( $type_name, null, null, $value->name, $value_metadata );
+					}
+				}
+			}
+		}
+
+		return $rows;
+	}
+
+	/**
+	 * Filter-narrows view over {@see self::get_all_metadata()}.
+	 *
+	 * Each filter argument independently restricts the result set; supplying
+	 * multiple composes as AND. When `$name` is supplied, the surviving rows
+	 * have their `entries` trimmed to the single matching entry; so a caller
+	 * asking "which elements are marked X" gets focused rows back, not the
+	 * full multi-entry shape.
+	 *
+	 * @param ?string $name  Optional metadata name to match. When set, only rows containing this entry survive and their `entries` are trimmed to it.
+	 * @param ?string $type  Optional GraphQL type name to match.
+	 * @param ?string $field Optional GraphQL field name to match.
+	 *
+	 * @return list<array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>}>
+	 */
+	public function find_metadata( ?string $name = null, ?string $type = null, ?string $field = null ): array {
+		$rows = $this->get_all_metadata();
+
+		$result = array();
+		foreach ( $rows as $row ) {
+			if ( null !== $type && $row['type'] !== $type ) {
+				continue;
+			}
+			if ( null !== $field && $row['field'] !== $field ) {
+				continue;
+			}
+			if ( null !== $name ) {
+				if ( ! array_key_exists( $name, $row['entries'] ) ) {
+					continue;
+				}
+				$row['entries'] = array( $name => $row['entries'][ $name ] );
+			}
+			$result[] = $row;
+		}
+
+		return $result;
+	}
+
+	/**
+	 * Read type-level metadata from a wrapped engine type.
+	 *
+	 * The wrapper subclasses in `Internal/Api/Schema/` expose `get_metadata()`;
+	 * non-wrapper types (e.g. the built-in scalars, the introspection types we
+	 * already filtered out) don't carry metadata and contribute an empty array.
+	 *
+	 * @param Type $type The GraphQL type to inspect.
+	 * @return array<string, bool|int|float|string|null>
+	 */
+	private static function read_type_metadata( Type $type ): array {
+		if ( method_exists( $type, 'get_metadata' ) ) {
+			$metadata = $type->get_metadata();
+			return is_array( $metadata ) ? $metadata : array();
+		}
+		return array();
+	}
+
+	/**
+	 * Read field-/arg-/enum-value-level metadata from the original config array.
+	 *
+	 * FieldDefinition, Argument, InputObjectField and EnumValueDefinition all
+	 * preserve their construction config in a public `$config` property, so
+	 * the `metadata` key emitted by ApiBuilder is reachable here without any
+	 * wrapper-side plumbing.
+	 *
+	 * @param object $element FieldDefinition | Argument | InputObjectField | EnumValueDefinition.
+	 * @return array<string, bool|int|float|string|null>
+	 */
+	private static function read_element_metadata( object $element ): array {
+		if ( ! property_exists( $element, 'config' ) ) {
+			return array();
+		}
+		$metadata = $element->config['metadata'] ?? array();
+		return is_array( $metadata ) ? $metadata : array();
+	}
+
+	/**
+	 * Build a metadata row in the standard shape.
+	 *
+	 * @param string                                    $type       GraphQL type name.
+	 * @param ?string                                   $field      Field name when the row describes a field; null otherwise.
+	 * @param ?string                                   $argument   Argument name when the row describes a field argument; null otherwise.
+	 * @param ?string                                   $enum_value Enum value name when the row describes an enum value; null otherwise.
+	 * @param array<string, bool|int|float|string|null> $entries    Name=>value entries to attach to the row.
+	 * @return array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>}
+	 */
+	private static function make_row( string $type, ?string $field, ?string $argument, ?string $enum_value, array $entries ): array {
+		return array(
+			'type'      => $type,
+			'field'     => $field,
+			'argument'  => $argument,
+			'enumValue' => $enum_value,
+			'entries'   => $entries,
+		);
+	}
+
+	/**
+	 * Whether a type name belongs to GraphQL's introspection system (and so should be skipped).
+	 *
+	 * @param string $name Type name.
+	 */
+	private static function is_introspection_name( string $name ): bool {
+		return str_starts_with( $name, '__' );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/RootQueryType.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/RootQueryType.php
index 92f8cecd7d6..e1f15a579bc 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/RootQueryType.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/RootQueryType.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries\ListProduct
 use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries\GetProduct;
 use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries\GetCoupon;
 use Automattic\WooCommerce\Internal\Api\Autogenerated\GraphQLQueries\ListCoupons;
+use Automattic\WooCommerce\Internal\Api\MetadataController;
 use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;

 class RootQueryType {
@@ -20,10 +21,11 @@ class RootQueryType {
 				array(
 					'name'   => 'Query',
 					'fields' => fn() => array(
-						'products' => ListProducts::get_field_definition(),
-						'product'  => GetProduct::get_field_definition(),
-						'coupon'   => GetCoupon::get_field_definition(),
-						'coupons'  => ListCoupons::get_field_definition(),
+						'products'                     => ListProducts::get_field_definition(),
+						'product'                      => GetProduct::get_field_definition(),
+						'coupon'                       => GetCoupon::get_field_definition(),
+						'coupons'                      => ListCoupons::get_field_definition(),
+						MetadataController::FIELD_NAME => MetadataController::get_field_definition(),
 					),
 				)
 			);
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
index b612678aa4d..b1c719ab326 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
@@ -1 +1 @@
-2026-05-06T16:48:36+00:00
\ No newline at end of file
+2026-05-12T08:46:18+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
index 0a2ea5661e9..0a3ac414825 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Api\Pagination\Connection;
 use Automattic\WooCommerce\Api\Attributes\Deprecated;
 use Automattic\WooCommerce\Api\Attributes\Description;
 use Automattic\WooCommerce\Api\Attributes\Ignore;
+use Automattic\WooCommerce\Api\Attributes\Metadata;
 use Automattic\WooCommerce\Api\Attributes\Name;
 use Automattic\WooCommerce\Api\Attributes\Parameter;
 use Automattic\WooCommerce\Api\Attributes\ParameterDescription;
@@ -155,6 +156,17 @@ class ApiBuilder {
 	/** @var string[] Errors collected during validation */
 	private array $errors = array();

+	/**
+	 * Cache of harvested metadata, keyed by the context label that
+	 * {@see self::harvest_metadata()} is called with. Populated by the
+	 * pre-generate {@see self::validate_metadata()} pass and re-used by
+	 * the generate phase, so duplicate-name errors surface before the
+	 * autogenerated tree is wiped and each conflict is only recorded once.
+	 *
+	 * @var array<string, array<string, bool|int|float|string|null>>
+	 */
+	private array $metadata_cache = array();
+
 	/** @var string[] Warnings collected during build */
 	private array $warnings = array();

@@ -229,7 +241,9 @@ class ApiBuilder {
 		$this->detect_status_resolver();
 		$this->discover_authorization_attributes();
 		$this->discover();
+		$this->check_attribute_resolutions();
 		$this->validate();
+		$this->validate_metadata();

 		if ( ! empty( $this->errors ) ) {
 			fwrite( STDERR, "Build failed with errors:\n" );
@@ -1247,6 +1261,7 @@ class ApiBuilder {
 				'case_name'          => $case->getName(),
 				'description'        => $this->get_description( $case ),
 				'deprecation_reason' => ! empty( $deprecation ) ? $deprecation[0]->newInstance()->reason : null,
+				'metadata'           => $this->harvest_metadata( $case, "{$ref->getShortName()}::{$case->getName()}" ),
 			);
 		}

@@ -1260,6 +1275,7 @@ class ApiBuilder {
 				'enum_fqcn'    => $fqcn,
 				'enum_alias'   => $enum_alias,
 				'values'       => $values,
+				'metadata'     => $this->harvest_metadata( $ref, $ref->getShortName() ),
 			)
 		);

@@ -1284,6 +1300,7 @@ class ApiBuilder {
 				'description'  => $description,
 				'scalar_fqcn'  => $fqcn,
 				'scalar_alias' => $scalar_alias,
+				'metadata'     => $this->harvest_metadata( $ref, $ref->getShortName() ),
 			)
 		);

@@ -1333,6 +1350,7 @@ class ApiBuilder {
 				'use_statements' => array_unique( $use_stmts ),
 				'fields'         => $fields,
 				'type_map'       => $type_map,
+				'metadata'       => $this->harvest_metadata( $ref, $ref->getShortName() ),
 			)
 		);

@@ -1383,6 +1401,7 @@ class ApiBuilder {
 				'use_statements' => array_unique( $use_stmts ),
 				'interfaces'     => $interfaces,
 				'fields'         => $fields,
+				'metadata'       => $this->harvest_metadata( $ref, $ref->getShortName() ),
 			)
 		);

@@ -1426,6 +1445,7 @@ class ApiBuilder {
 				'description'    => $description,
 				'use_statements' => array_unique( $use_stmts ),
 				'fields'         => $fields,
+				'metadata'       => $this->harvest_metadata( $ref, $ref->getShortName() ),
 			)
 		);

@@ -1525,6 +1545,7 @@ class ApiBuilder {
 				'description' => $param_description,
 				'has_default' => $param->isDefaultValueAvailable(),
 				'default'     => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null,
+				'metadata'    => $this->harvest_metadata( $param, "{$ref->getShortName()}::execute() parameter \${$param_name}" ),
 			);
 			$args[]    = $arg_entry;

@@ -1634,6 +1655,7 @@ class ApiBuilder {
 				'attribute_expr'                   => $attribute_expr,
 				'compute_preauthorized_param_type' => $compute_preauthorized_param_type,
 				'scalar_return'                    => $scalar_return,
+				'metadata'                         => $this->harvest_metadata( $ref, $ref->getShortName() ),
 			)
 		);

@@ -1905,6 +1927,7 @@ class ApiBuilder {
 			// Description from attribute.
 			$desc_attrs  = $prop->getAttributes( Description::class );
 			$description = ! empty( $desc_attrs ) ? $desc_attrs[0]->newInstance()->description : '';
+			$description = $this->apply_description_transforms( $description, $prop );

 			// Default value.
 			$has_default = $prop->hasDefaultValue();
@@ -1933,6 +1956,14 @@ class ApiBuilder {
 				'description' => $description,
 				'has_default' => $has_default,
 				'default'     => $default,
+				// Unrolled args inherit metadata from the source property: the
+				// unroll is a renaming of input fields into top-level args, so
+				// any `#[Metadata]` on the property travels with the rename.
+				// Use the declaring class for the label so the cache populated
+				// by `validate_metadata()`'s property walk (which keys by the
+				// declaring class) hits the same entry and conflicts are only
+				// recorded once.
+				'metadata'    => $this->harvest_metadata( $prop, "{$prop->getDeclaringClass()->getShortName()}::\${$prop_name}" ),
 			);

 			// Value expression for the constructor call.
@@ -2227,19 +2258,220 @@ class ApiBuilder {
 	}

 	private function get_description( \ReflectionClass|\ReflectionEnum|\ReflectionEnumUnitCase $ref ): string {
-		$attrs = $ref->getAttributes( Description::class );
-		return ! empty( $attrs ) ? $attrs[0]->newInstance()->description : '';
+		$attrs       = $ref->getAttributes( Description::class );
+		$description = ! empty( $attrs ) ? $attrs[0]->newInstance()->description : '';
+		return $this->apply_description_transforms( $description, $ref );
 	}

 	private function get_param_description( \ReflectionParameter $param ): string {
-		$attrs = $param->getAttributes( Description::class );
-		return ! empty( $attrs ) ? $attrs[0]->newInstance()->description : '';
+		$attrs       = $param->getAttributes( Description::class );
+		$description = ! empty( $attrs ) ? $attrs[0]->newInstance()->description : '';
+		return $this->apply_description_transforms( $description, $param );
+	}
+
+	/**
+	 * Thread the description through {@see Metadata::transform_description()}
+	 * on every `Metadata`-derived attribute applied to the element.
+	 *
+	 * The base `Metadata` implementation is a no-op; subclasses opt into the
+	 * description-mirror convention by overriding (`#[Internal]` prefixes
+	 * `[Internal] ` and supplies a default body, `#[Experimental]` does the
+	 * analogous thing, etc.). Calls happen in PHP reflection (source) order,
+	 * threading each return value into the next, so the last attribute in
+	 * source ends up as the outermost prefix.
+	 *
+	 * The returned text is consumed verbatim by the templates and wrapped in
+	 * `__( ..., 'woocommerce' )`, so any default description supplied by a
+	 * subclass flows through the usual translation pipeline like any other
+	 * description.
+	 *
+	 * @param string                                                                                                              $description Original description text (empty when the element has no `#[Description]`).
+	 * @param \ReflectionClass|\ReflectionEnum|\ReflectionEnumUnitCase|\ReflectionMethod|\ReflectionProperty|\ReflectionParameter $source      Reflector whose `Metadata`-derived attributes drive the transforms.
+	 */
+	private function apply_description_transforms( string $description, $source ): string {
+		foreach ( $source->getAttributes( Metadata::class, \ReflectionAttribute::IS_INSTANCEOF ) as $attribute ) {
+			$description = $attribute->newInstance()->transform_description( $description );
+		}
+		return $description;
+	}
+
+	/**
+	 * Walk every discovered class and emit a build-time warning for any
+	 * attribute reference whose class doesn't autoload.
+	 *
+	 * Symptom this catches: a developer writes `#[Metadata( … )]` (or any
+	 * other attribute) without importing the class, so PHP resolves the
+	 * reference relative to the current namespace and lands on a non-existent
+	 * class. ApiBuilder filters attributes by class via reflection and
+	 * silently ignores unresolved ones, so the attribute disappears from the
+	 * generated tree without any visible error — confusing to debug from the
+	 * runtime side. The warning surfaces the typo / missing import at build
+	 * time instead.
+	 *
+	 * The check is intentionally untyped: it warns about every attribute it
+	 * can't resolve, regardless of which `Metadata`/`Description`/`Internal`/etc.
+	 * the developer intended. The warning text quotes the unresolved FQCN so
+	 * the developer can see exactly what PHP tried to load.
+	 */
+	private function check_attribute_resolutions(): void {
+		foreach ( $this->classes as $info ) {
+			$ref = $info['class'];
+			$this->check_attributes_on( $ref, $ref->getShortName() );
+
+			foreach ( $ref->getProperties() as $prop ) {
+				$this->check_attributes_on( $prop, $ref->getShortName() . '::$' . $prop->getName() );
+			}
+
+			foreach ( $ref->getMethods() as $method ) {
+				if ( $method->getDeclaringClass()->getName() !== $ref->getName() ) {
+					continue;
+				}
+				$this->check_attributes_on( $method, $ref->getShortName() . '::' . $method->getName() . '()' );
+
+				foreach ( $method->getParameters() as $param ) {
+					$this->check_attributes_on(
+						$param,
+						$ref->getShortName() . '::' . $method->getName() . '($' . $param->getName() . ')'
+					);
+				}
+			}
+
+			if ( $ref instanceof \ReflectionEnum ) {
+				foreach ( $ref->getCases() as $case ) {
+					$this->check_attributes_on( $case, $ref->getShortName() . '::' . $case->getName() );
+				}
+			}
+		}
+	}
+
+	/**
+	 * Check every attribute applied to a single reflector and accumulate a
+	 * warning per unresolvable class.
+	 *
+	 * @param \ReflectionClass|\ReflectionEnum|\ReflectionEnumUnitCase|\ReflectionMethod|\ReflectionProperty|\ReflectionParameter $source        Reflector to scan.
+	 * @param string                                                                                                              $context_label Human-readable label used in the warning.
+	 */
+	private function check_attributes_on( $source, string $context_label ): void {
+		foreach ( $source->getAttributes() as $attribute ) {
+			$name = $attribute->getName();
+			if ( ! class_exists( $name ) ) {
+				$this->warnings[] = "{$context_label}: attribute `{$name}` could not be resolved (likely a missing `use` statement). The attribute was silently ignored during generation.";
+			}
+		}
+	}
+
+	/**
+	 * Collect {@see Metadata}-derived attributes from a reflector into a
+	 * `name => value` map.
+	 *
+	 * Two attributes producing the same `name` on the same element are a
+	 * generation-time error: surprising metadata in production is worse than a
+	 * loud build failure. The check spans subclasses too (e.g. a future
+	 * `#[Beta]` that also yielded `name = 'internal'` would conflict with a
+	 * sibling `#[Internal]`), which is why we match by `instanceof Metadata`
+	 * rather than by attribute class.
+	 *
+	 * @param \ReflectionClass|\ReflectionEnum|\ReflectionEnumUnitCase|\ReflectionMethod|\ReflectionProperty|\ReflectionParameter $source        Reflector to read attributes from.
+	 * @param string                                                                                                              $context_label Human-readable label for the source, used in error messages (e.g. `"Coupon::$lock_state"`).
+	 *
+	 * @return array<string, bool|int|float|string|null>
+	 */
+	private function harvest_metadata( $source, string $context_label ): array {
+		if ( array_key_exists( $context_label, $this->metadata_cache ) ) {
+			return $this->metadata_cache[ $context_label ];
+		}
+
+		$entries = array();
+		foreach ( $source->getAttributes( Metadata::class, \ReflectionAttribute::IS_INSTANCEOF ) as $attribute ) {
+			$instance = $attribute->newInstance();
+			$name     = $instance->get_name();
+			if ( array_key_exists( $name, $entries ) ) {
+				$this->errors[] = "{$context_label}: duplicate metadata name '{$name}'.";
+				continue;
+			}
+			$entries[ $name ] = $instance->get_value();
+		}
+		$this->metadata_cache[ $context_label ] = $entries;
+		return $entries;
+	}
+
+	/**
+	 * Pre-generate pass that walks every reflector the generate phase will
+	 * later harvest metadata from, populating {@see self::$metadata_cache}
+	 * and recording duplicate-name conflicts in {@see self::$errors}.
+	 *
+	 * Without this pass, conflicts only surface inside `generate_*` — which
+	 * runs after {@see self::wipe_autogenerated()} — so a bad attribute set
+	 * would silently rewrite the autogenerated tree (keeping the first-seen
+	 * value) and the error would be lost because nothing reads `$errors`
+	 * after generate(). Running the walk up front lets the existing
+	 * build-level error check catch duplicates before any file is touched.
+	 */
+	private function validate_metadata(): void {
+		foreach ( $this->classes as $info ) {
+			if ( $info['ignored'] || 'pagination' === $info['kind'] ) {
+				continue;
+			}
+
+			$ref  = $info['class'];
+			$kind = $info['kind'];
+
+			// Type-level metadata on every kind that emits a webonyx type config.
+			if ( in_array( $kind, array( 'type', 'input_type', 'enum', 'interface', 'scalar', 'query', 'mutation' ), true ) ) {
+				$this->harvest_metadata( $ref, $ref->getShortName() );
+			}
+
+			// Per-property metadata on output/input/interface types. The label must
+			// match `build_field_definition()`'s, which uses the property's
+			// declaring class — that handles inherited public properties cleanly.
+			if ( in_array( $kind, array( 'type', 'input_type', 'interface' ), true ) ) {
+				foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+					if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+						continue;
+					}
+					$label = $prop->getDeclaringClass()->getShortName() . '::$' . $prop->getName();
+					$this->harvest_metadata( $prop, $label );
+				}
+			}
+
+			// Enum cases.
+			if ( 'enum' === $kind && $ref instanceof \ReflectionEnum ) {
+				foreach ( $ref->getCases() as $case ) {
+					$this->harvest_metadata( $case, "{$ref->getShortName()}::{$case->getName()}" );
+				}
+			}
+
+			// Query/mutation execute() parameters. Skip infrastructure parameters
+			// (`_principal`, `_query_info`, `_preauthorized`) — they have no
+			// matching schema element and never carry `#[Metadata]`. Unrolled
+			// parameters reach harvest_metadata through their backing input type's
+			// properties, which the property walk above already covers.
+			if ( in_array( $kind, array( 'query', 'mutation' ), true ) && $ref->hasMethod( 'execute' ) ) {
+				foreach ( $ref->getMethod( 'execute' )->getParameters() as $param ) {
+					$param_name = $param->getName();
+					if ( '' !== $param_name && '_' === $param_name[0] ) {
+						continue;
+					}
+					$this->harvest_metadata( $param, "{$ref->getShortName()}::execute() parameter \${$param_name}" );
+				}
+			}
+		}
 	}

 	private function get_class_info( string $class_name ): ?array {
 		return $this->classes[ $class_name ] ?? null;
 	}

+	/**
+	 * Build the field-definition array consumed by templates.
+	 *
+	 * Per-field metadata is harvested from the property itself. Per-argument
+	 * metadata for `#[Parameter]`-declared arguments on output fields is *not*
+	 * supported in the MVP because the `Parameter` attribute carries the arg
+	 * shape inline; there is no separate target to decorate with `#[Metadata]`.
+	 * Root-operation arguments (declared as `execute()` parameters) do get
+	 * per-argument metadata — that path runs through {@see self::generate_resolver()}.
+	 */
 	private function build_field_definition( \ReflectionProperty $prop, string $context, array &$use_stmts ): ?array {
 		$type        = $prop->getType();
 		$type_name   = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
@@ -2256,6 +2488,7 @@ class ApiBuilder {
 		if ( ! empty( $desc_attrs ) ) {
 			$description = $desc_attrs[0]->newInstance()->description;
 		}
+		$description = $this->apply_description_transforms( $description, $prop );

 		$deprecation = $prop->getAttributes( Deprecated::class );

@@ -2279,6 +2512,10 @@ class ApiBuilder {
 					'name'        => $param_inst->name,
 					'type_expr'   => $arg_type_expr,
 					'description' => $param_inst->description,
+					// `#[Parameter]`-declared args carry their shape inline and
+					// have no separate reflector to read `#[Metadata]` from, so
+					// the MVP exposes them without metadata.
+					'metadata'    => array(),
 				);
 				if ( $param_inst->has_default ) {
 					$arg_entry['default'] = $param_inst->default;
@@ -2307,6 +2544,8 @@ class ApiBuilder {
 		$has_pagination       = $is_connection && ! empty( $args );
 		$paginated_connection = $context === 'output' && $has_pagination;

+		$field_context_label = $prop->getDeclaringClass()->getShortName() . '::$' . $prop->getName();
+
 		return array(
 			'name'                 => $prop->getName(),
 			'type_expr'            => $type_expr,
@@ -2314,6 +2553,7 @@ class ApiBuilder {
 			'args'                 => $args,
 			'deprecation_reason'   => ! empty( $deprecation ) ? $deprecation[0]->newInstance()->reason : null,
 			'paginated_connection' => $paginated_connection,
+			'metadata'             => $this->harvest_metadata( $prop, $field_context_label ),
 		);
 	}

diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/EnumTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/EnumTypeTemplate.php
index ef99bdb95cf..3fe3d00df54 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/EnumTypeTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/EnumTypeTemplate.php
@@ -8,7 +8,8 @@
  * @var string $description
  * @var string $enum_fqcn
  * @var string $enum_alias
- * @var array  $values - each: ['graphql_name', 'case_name', 'description', 'deprecation_reason' => ?string]
+ * @var array  $values - each: ['graphql_name', 'case_name', 'description', 'deprecation_reason' => ?string, 'metadata' => array]
+ * @var array  $metadata - type-level metadata, name => scalar value.
  */

 $escaped_description = addslashes( $description );
@@ -34,6 +35,13 @@ class <?php echo $class_name; ?> {
 					'name' => '<?php echo $graphql_name; ?>',
 <?php if ( $description !== '' ) : ?>
 					'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+<?php if ( ! empty( $metadata ) ) : ?>
+					'metadata' => array(
+<?php foreach ( $metadata as $meta_name => $meta_value ) : ?>
+						<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+					),
 <?php endif; ?>
 					'values' => array(
 <?php foreach ( $values as $val ) : ?>
@@ -44,6 +52,13 @@ class <?php echo $class_name; ?> {
 <?php endif; ?>
 	<?php if ( ! empty( $val['deprecation_reason'] ) ) : ?>
 							'deprecationReason' => '<?php echo addslashes( $val['deprecation_reason'] ); ?>',
+<?php endif; ?>
+	<?php if ( ! empty( $val['metadata'] ) ) : ?>
+							'metadata' => array(
+		<?php foreach ( $val['metadata'] as $meta_name => $meta_value ) : ?>
+								<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+							),
 <?php endif; ?>
 						),
 <?php endforeach; ?>
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InputObjectTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InputObjectTypeTemplate.php
index 6ee83867b05..123c37f8c43 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InputObjectTypeTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InputObjectTypeTemplate.php
@@ -7,7 +7,8 @@
  * @var string $graphql_name
  * @var string $description
  * @var array  $use_statements
- * @var array  $fields - each: ['name', 'type_expr', 'description']
+ * @var array  $fields - each: ['name', 'type_expr', 'description', 'metadata' => array]
+ * @var array  $metadata - type-level metadata, name => scalar value.
  */

 $escaped_description = addslashes( $description );
@@ -61,6 +62,13 @@ class <?php echo $class_name; ?> {
 					'name' => '<?php echo $graphql_name; ?>',
 <?php if ( $description !== '' ) : ?>
 					'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+<?php if ( ! empty( $metadata ) ) : ?>
+					'metadata' => array(
+<?php foreach ( $metadata as $meta_name => $meta_value ) : ?>
+						<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+					),
 <?php endif; ?>
 					'fields' => fn() => array(
 <?php foreach ( $fields as $field ) : ?>
@@ -68,6 +76,13 @@ class <?php echo $class_name; ?> {
 							'type' => <?php echo $field['type_expr']; ?>,
 	<?php if ( ! empty( $field['description'] ) ) : ?>
 							'description' => __( '<?php echo addslashes( $field['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+	<?php if ( ! empty( $field['metadata'] ) ) : ?>
+							'metadata' => array(
+		<?php foreach ( $field['metadata'] as $meta_name => $meta_value ) : ?>
+								<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+							),
 <?php endif; ?>
 						),
 <?php endforeach; ?>
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InterfaceTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InterfaceTypeTemplate.php
index ae4987c820f..83cdd7fcf4e 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InterfaceTypeTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/InterfaceTypeTemplate.php
@@ -7,8 +7,9 @@
  * @var string $graphql_name
  * @var string $description
  * @var array  $use_statements
- * @var array  $fields - each: ['name', 'type_expr', 'description', 'args' => [], 'deprecation_reason' => ?string]
+ * @var array  $fields - each: ['name', 'type_expr', 'description', 'args' => [], 'deprecation_reason' => ?string, 'metadata' => array]
  * @var array  $type_map - each: ['fqcn' => string, 'alias' => string] mapping PHP FQCN to generated ObjectType alias
+ * @var array  $metadata - type-level metadata, name => scalar value.
  */

 $escaped_description = addslashes( $description );
@@ -62,6 +63,13 @@ class <?php echo $class_name; ?> {
 					'name' => '<?php echo $graphql_name; ?>',
 <?php if ( $description !== '' ) : ?>
 					'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+<?php if ( ! empty( $metadata ) ) : ?>
+					'metadata' => array(
+<?php foreach ( $metadata as $meta_name => $meta_value ) : ?>
+						<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+					),
 <?php endif; ?>
 					'fields' => fn() => array(
 <?php foreach ( $fields as $field ) : ?>
@@ -69,6 +77,13 @@ class <?php echo $class_name; ?> {
 							'type' => <?php echo $field['type_expr']; ?>,
 	<?php if ( ! empty( $field['description'] ) ) : ?>
 							'description' => __( '<?php echo addslashes( $field['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+	<?php if ( ! empty( $field['metadata'] ) ) : ?>
+							'metadata' => array(
+		<?php foreach ( $field['metadata'] as $meta_name => $meta_value ) : ?>
+								<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+							),
 <?php endif; ?>
 	<?php if ( ! empty( $field['args'] ) ) : ?>
 							'args' => array(
@@ -80,6 +95,13 @@ class <?php echo $class_name; ?> {
 <?php endif; ?>
 			<?php if ( ! empty( $arg['description'] ) ) : ?>
 									'description' => __( '<?php echo addslashes( $arg['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+			<?php if ( ! empty( $arg['metadata'] ) ) : ?>
+									'metadata' => array(
+				<?php foreach ( $arg['metadata'] as $meta_name => $meta_value ) : ?>
+										<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+									),
 <?php endif; ?>
 								),
 <?php endforeach; ?>
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ObjectTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ObjectTypeTemplate.php
index 89692d0e473..eb2348a59b0 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ObjectTypeTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ObjectTypeTemplate.php
@@ -8,7 +8,8 @@
  * @var string $description
  * @var array  $use_statements
  * @var array  $interfaces - each: ['alias' => string]
- * @var array  $fields - each: ['name', 'type_expr', 'description', 'args' => [], 'deprecation_reason' => ?string, 'paginated_connection' => bool]
+ * @var array  $fields - each: ['name', 'type_expr', 'description', 'args' => [], 'deprecation_reason' => ?string, 'paginated_connection' => bool, 'metadata' => array]
+ * @var array  $metadata - type-level metadata, name => scalar value.
  */

 $escaped_description = addslashes( $description );
@@ -78,6 +79,13 @@ class <?php echo $class_name; ?> {
 <?php if ( $description !== '' ) : ?>
 					'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
 <?php endif; ?>
+<?php if ( ! empty( $metadata ) ) : ?>
+					'metadata' => array(
+<?php foreach ( $metadata as $meta_name => $meta_value ) : ?>
+						<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+					),
+<?php endif; ?>
 <?php if ( ! empty( $interfaces ) ) : ?>
 					'interfaces' => fn() => array(
 	<?php foreach ( $interfaces as $iface ) : ?>
@@ -91,6 +99,13 @@ class <?php echo $class_name; ?> {
 							'type' => <?php echo $field['type_expr']; ?>,
 	<?php if ( ! empty( $field['description'] ) ) : ?>
 							'description' => __( '<?php echo addslashes( $field['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+	<?php if ( ! empty( $field['metadata'] ) ) : ?>
+							'metadata' => array(
+		<?php foreach ( $field['metadata'] as $meta_name => $meta_value ) : ?>
+								<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+							),
 <?php endif; ?>
 	<?php if ( ! empty( $field['args'] ) ) : ?>
 							'args' => array(
@@ -102,6 +117,13 @@ class <?php echo $class_name; ?> {
 <?php endif; ?>
 			<?php if ( ! empty( $arg['description'] ) ) : ?>
 									'description' => __( '<?php echo addslashes( $arg['description'] ); ?>', 'woocommerce' ),
+<?php endif; ?>
+			<?php if ( ! empty( $arg['metadata'] ) ) : ?>
+									'metadata' => array(
+				<?php foreach ( $arg['metadata'] as $meta_name => $meta_value ) : ?>
+										<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+									),
 <?php endif; ?>
 								),
 <?php endforeach; ?>
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php
index 4dffd3f2f22..e42972dcac0 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php
@@ -10,7 +10,8 @@
  * @var string $command_alias
  * @var string $return_type_expr
  * @var array  $use_statements
- * @var array  $args - each: ['name', 'type_expr', 'description', 'has_default', 'default']
+ * @var array  $args - each: ['name', 'type_expr', 'description', 'has_default', 'default', 'metadata' => array]
+ * @var array  $metadata - root-field-level metadata, name => scalar value.
  * @var bool   $has_connection_of
  * @var string $connection_type_alias
  * @var bool   $standalone_attribute_check - true when authorize() is absent and the attribute_expr is the sole authorization gate
@@ -91,6 +92,13 @@ class <?php echo $class_name; ?> {
 <?php endif; ?>
 <?php if ( $description !== '' ) : ?>
 			'description' => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+<?php if ( ! empty( $metadata ) ) : ?>
+			'metadata' => array(
+<?php foreach ( $metadata as $meta_name => $meta_value ) : ?>
+				<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+			),
 <?php endif; ?>
 			'args' => array(
 <?php foreach ( $args as $arg ) : ?>
@@ -101,6 +109,13 @@ class <?php echo $class_name; ?> {
 <?php endif; ?>
 	<?php if ( $arg['has_default'] ) : ?>
 					'defaultValue' => <?php echo var_export( $arg['default'], true ); ?>,
+<?php endif; ?>
+	<?php if ( ! empty( $arg['metadata'] ) ) : ?>
+					'metadata' => array(
+		<?php foreach ( $arg['metadata'] as $meta_name => $meta_value ) : ?>
+						<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+					),
 <?php endif; ?>
 				),
 <?php endforeach; ?>
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootQueryTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootQueryTypeTemplate.php
index fea9414ed2c..55c8c3531ff 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootQueryTypeTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/RootQueryTypeTemplate.php
@@ -2,6 +2,11 @@
 /**
  * Template for generating the RootQueryType class.
  *
+ * Besides the autogenerated query fields, the root `Query` type also gets the
+ * hand-written `_apiMetadata` field contributed by {@see \Automattic\WooCommerce\Internal\Api\MetadataController}.
+ * That class lives in the shared infrastructure namespace, so plugins reusing
+ * this template inherit the metadata-discovery field for free.
+ *
  * @var string $namespace
  * @var array  $queries - each: ['class_name', 'fqcn', 'graphql_name']
  */
@@ -17,6 +22,7 @@ namespace <?php echo $namespace; ?>;
 <?php foreach ( $queries as $query ) : ?>
 use <?php echo $query['fqcn']; ?>;
 <?php endforeach; ?>
+use Automattic\WooCommerce\Internal\Api\MetadataController;
 use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;

 class RootQueryType {
@@ -31,6 +37,7 @@ class RootQueryType {
 <?php foreach ( $queries as $query ) : ?>
 						'<?php echo $query['graphql_name']; ?>' => <?php echo $query['class_name']; ?>::get_field_definition(),
 <?php endforeach; ?>
+						MetadataController::FIELD_NAME => MetadataController::get_field_definition(),
 					),
 				)
 			);
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ScalarTypeTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ScalarTypeTemplate.php
index 58a4bfdac90..42b6011405f 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ScalarTypeTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/ScalarTypeTemplate.php
@@ -8,6 +8,7 @@
  * @var string $description
  * @var string $scalar_fqcn
  * @var string $scalar_alias
+ * @var array  $metadata - type-level metadata, name => scalar value.
  */

 $escaped_description = addslashes( $description );
@@ -33,6 +34,13 @@ class <?php echo $class_name; ?> {
 					'name'         => '<?php echo $graphql_name; ?>',
 <?php if ( $description !== '' ) : ?>
 					'description'  => __( '<?php echo $escaped_description; ?>', 'woocommerce' ),
+<?php endif; ?>
+<?php if ( ! empty( $metadata ) ) : ?>
+					'metadata'     => array(
+<?php foreach ( $metadata as $meta_name => $meta_value ) : ?>
+						<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
+<?php endforeach; ?>
+					),
 <?php endif; ?>
 					'serialize'    => fn( $value ) => <?php echo $scalar_alias; ?>::serialize( $value ),
 					'parseValue'   => function ( $value ) {
diff --git a/plugins/woocommerce/src/Internal/Api/GraphQLController.php b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
index 3e108608a0b..b814a3fb554 100644
--- a/plugins/woocommerce/src/Internal/Api/GraphQLController.php
+++ b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Automattic\WooCommerce\Internal\Api;

 use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\Utils\SchemaHandle;
 use Automattic\WooCommerce\Vendor\GraphQL\GraphQL;
 use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode;
 use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FieldNode;
@@ -63,6 +64,13 @@ abstract class GraphQLController {
 	 */
 	private ?Schema $schema = null;

+	/**
+	 * Cached public-facing schema handle wrapping {@see self::$schema}.
+	 *
+	 * @var ?SchemaHandle
+	 */
+	private ?SchemaHandle $schema_handle = null;
+
 	/**
 	 * Query cache / APQ resolver.
 	 *
@@ -392,7 +400,7 @@ abstract class GraphQLController {
 		}

 		// 5. Load schema.
-		$schema = $this->get_schema();
+		$schema = $this->get_engine_schema();

 		// 6. Build validation rules.
 		// A single complexity-rule instance is kept so its computed score can
@@ -504,9 +512,28 @@ abstract class GraphQLController {
 	}

 	/**
-	 * Build and cache the GraphQL schema.
+	 * Public handle to the live GraphQL schema for runtime inspection.
+	 *
+	 * Returns an opaque {@see SchemaHandle}; callers reach metadata (and any
+	 * future schema-inspection operations) through methods on that object
+	 * rather than touching the underlying engine type. The handle is cached
+	 * and wraps the same engine schema this controller uses to serve real
+	 * requests.
+	 */
+	public function get_schema(): SchemaHandle {
+		if ( null === $this->schema_handle ) {
+			$this->schema_handle = new SchemaHandle( $this->get_engine_schema() );
+		}
+		return $this->schema_handle;
+	}
+
+	/**
+	 * Build and cache the engine-typed GraphQL schema used internally to
+	 * serve requests. Kept private to keep the engine type out of the
+	 * controller's public surface; consumers should reach {@see SchemaHandle}
+	 * through {@see self::get_schema()} instead.
 	 */
-	private function get_schema(): Schema {
+	private function get_engine_schema(): Schema {
 		if ( null === $this->schema ) {
 			$this->schema = $this->build_schema();
 		}
diff --git a/plugins/woocommerce/src/Internal/Api/MetadataController.php b/plugins/woocommerce/src/Internal/Api/MetadataController.php
new file mode 100644
index 00000000000..73ca0b3c250
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/MetadataController.php
@@ -0,0 +1,258 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+use Automattic\WooCommerce\Api\Utils\SchemaHandle;
+use Automattic\WooCommerce\Internal\Api\Schema\CustomScalarType;
+use Automattic\WooCommerce\Internal\Api\Schema\Error;
+use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\BooleanValueNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FloatValueNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\IntValueNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\NullValueNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\StringValueNode;
+
+/**
+ * Hand-written controller that contributes the `_apiMetadata` root query field
+ * and the supporting `MetadataEntry`, `MetadataTarget`, and `MetadataValue`
+ * types to the generated schema.
+ *
+ * The autogenerated `RootQueryType` references this controller alongside the
+ * autogenerated query resolvers, so the field appears on the root `Query`
+ * type without any special wiring at the controller level. The resolver
+ * delegates to {@see MetadataUtils::find()} for the schema walk and filter
+ * application, then reshapes the rows so each entry is exposed as the
+ * `{ name, value }` pair that `MetadataEntry` expects.
+ *
+ * @internal Reserved for the GraphQL autogeneration infrastructure. The
+ *           `_apiMetadata` field is the public-facing surface; this class is
+ *           how it is wired in.
+ */
+class MetadataController {
+	/**
+	 * Memoised `MetadataValue` scalar type.
+	 *
+	 * @var ?CustomScalarType
+	 */
+	private static ?CustomScalarType $value_scalar = null;
+
+	/**
+	 * Memoised `MetadataEntry` output type.
+	 *
+	 * @var ?ObjectType
+	 */
+	private static ?ObjectType $entry_type = null;
+
+	/**
+	 * Memoised `MetadataTarget` output type.
+	 *
+	 * @var ?ObjectType
+	 */
+	private static ?ObjectType $target_type = null;
+
+	/**
+	 * GraphQL field name used on the root `Query` type.
+	 */
+	public const FIELD_NAME = '_apiMetadata';
+
+	/**
+	 * Field definition for the root `_apiMetadata` query, in the shape the
+	 * autogenerated `RootQueryType` expects (same as every autogenerated
+	 * resolver's `get_field_definition()`).
+	 *
+	 * @return array<string, mixed>
+	 */
+	public static function get_field_definition(): array {
+		return array(
+			'type'        => Type::nonNull( Type::listOf( Type::nonNull( self::get_target_type() ) ) ),
+			'description' => __(
+				'Lists metadata attached to elements of this schema. All filter arguments are optional; supplying multiple narrows the result. Use this to discover internal-use APIs, beta features, ownership, etc., or to ask "can I use this specific element?".',
+				'woocommerce'
+			),
+			'args'        => array(
+				'name'  => array(
+					'type'        => Type::string(),
+					'description' => __( 'Match rows that carry a metadata entry with this name. Surviving rows have their entries trimmed to the matching one.', 'woocommerce' ),
+				),
+				'type'  => array(
+					'type'        => Type::string(),
+					'description' => __( 'Match rows whose target type equals this name.', 'woocommerce' ),
+				),
+				'field' => array(
+					'type'        => Type::string(),
+					'description' => __( 'Match rows whose target field equals this name.', 'woocommerce' ),
+				),
+			),
+			'resolve'     => array( self::class, 'resolve' ),
+		);
+	}
+
+	/**
+	 * Resolver for the `_apiMetadata` root field. Signature matches the
+	 * webonyx resolver contract; `$root` and `$context` are unused here
+	 * (root operations have no parent, and metadata is principal-independent).
+	 *
+	 * @param ?array      $root    Webonyx passes null for root resolvers.
+	 * @param array       $args    GraphQL arguments (`name`, `type`, `field`).
+	 * @param array       $context Per-request context the controller builds.
+	 * @param ResolveInfo $info    Carries the schema instance to walk.
+	 * @return list<array<string, mixed>>
+	 */
+	public static function resolve( ?array $root, array $args, array $context, ResolveInfo $info ): array {
+		unset( $root, $context );
+
+		// Wrap the resolver's engine-typed schema into the same handle clients
+		// receive from `GraphQLController::get_schema()`, so the resolver and
+		// PHP-side callers share a single inspection surface.
+		$schema = new SchemaHandle( $info->schema );
+
+		$rows = $schema->find_metadata(
+			$args['name'] ?? null,
+			$args['type'] ?? null,
+			$args['field'] ?? null,
+		);
+
+		// SchemaHandle returns entries as an associative `name => value` map,
+		// which is the natural shape for filtering and PHP-side consumers. The
+		// GraphQL `MetadataEntry` type instead exposes each entry as a
+		// `{ name, value }` object so clients can `entries { name value }` over
+		// a list. Reshape here.
+		return array_map(
+			static function ( array $row ): array {
+				$row['entries'] = array_map(
+					static fn( string $entry_name, $entry_value ): array => array(
+						'name'  => $entry_name,
+						'value' => $entry_value,
+					),
+					array_keys( $row['entries'] ),
+					array_values( $row['entries'] ),
+				);
+				return $row;
+			},
+			$rows
+		);
+	}
+
+	/**
+	 * The `MetadataTarget` output type, lazily built and cached.
+	 */
+	private static function get_target_type(): ObjectType {
+		if ( null === self::$target_type ) {
+			self::$target_type = new ObjectType(
+				array(
+					'name'        => 'MetadataTarget',
+					'description' => __(
+						'One element of the schema with its attached metadata. Type-level rows have `field`, `argument` and `enumValue` set to null; field-level rows set `field` (and `argument` when the target is a field argument); enum-value rows set `enumValue`.',
+						'woocommerce'
+					),
+					'fields'      => fn() => array(
+						'type'      => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'Name of the GraphQL type this row describes.', 'woocommerce' ),
+						),
+						'field'     => array(
+							'type'        => Type::string(),
+							'description' => __( 'Field name when this row describes a field (or a field argument); null for type-level rows.', 'woocommerce' ),
+						),
+						'argument'  => array(
+							'type'        => Type::string(),
+							'description' => __( 'Argument name when this row describes a field argument; null otherwise.', 'woocommerce' ),
+						),
+						'enumValue' => array(
+							'type'        => Type::string(),
+							'description' => __( 'Enum value name when this row describes one specific enum value; null otherwise.', 'woocommerce' ),
+						),
+						'entries'   => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( self::get_entry_type() ) ) ),
+							'description' => __( 'Metadata entries attached to the target.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$target_type;
+	}
+
+	/**
+	 * The `MetadataEntry` output type, lazily built and cached.
+	 */
+	private static function get_entry_type(): ObjectType {
+		if ( null === self::$entry_type ) {
+			self::$entry_type = new ObjectType(
+				array(
+					'name'        => 'MetadataEntry',
+					'description' => __( 'One metadata entry: a `name` plus a scalar `value`.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'name'  => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'Identifier of the entry (e.g. `internal`, `beta`).', 'woocommerce' ),
+						),
+						'value' => array(
+							// Nullable: `MetadataValue` itself permits a null payload (e.g.
+							// `#[Metadata( 'deprecated_reason', null )]`), so the wrapping
+							// must allow it through.
+							'type'        => self::get_value_scalar(),
+							'description' => __( 'Scalar payload associated with the entry. Null when the metadata entry carries a null value.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$entry_type;
+	}
+
+	/**
+	 * The `MetadataValue` custom scalar, accepting any GraphQL-compatible scalar.
+	 *
+	 * Webonyx's autogenerated scalar template hard-codes acceptance of string
+	 * literals only, so this scalar is hand-built rather than going through
+	 * ApiBuilder. `parseLiteral` walks the AST node types and `parseValue`
+	 * accepts the already-decoded PHP scalar that variables-mode delivers.
+	 */
+	private static function get_value_scalar(): CustomScalarType {
+		if ( null === self::$value_scalar ) {
+			self::$value_scalar = new CustomScalarType(
+				array(
+					'name'         => 'MetadataValue',
+					'description'  => __(
+						'Scalar payload of a metadata entry. Accepts a string, integer, float, boolean, or null.',
+						'woocommerce'
+					),
+					// Resolvers return the raw PHP scalar; webonyx serialises it as JSON directly.
+					'serialize'    => static fn( $value ) => $value,
+					'parseValue'   => static function ( $value ) {
+						if ( null === $value || is_bool( $value ) || is_int( $value ) || is_float( $value ) || is_string( $value ) ) {
+							return $value;
+						}
+						throw new Error( 'MetadataValue must be a string, integer, float, boolean, or null.' );
+					},
+					'parseLiteral' => static function ( $value_node, ?array $variables = null ) {
+						unset( $variables );
+
+						if ( $value_node instanceof StringValueNode ) {
+							return $value_node->value;
+						}
+						if ( $value_node instanceof BooleanValueNode ) {
+							return $value_node->value;
+						}
+						if ( $value_node instanceof IntValueNode ) {
+							return (int) $value_node->value;
+						}
+						if ( $value_node instanceof FloatValueNode ) {
+							return (float) $value_node->value;
+						}
+						if ( $value_node instanceof NullValueNode ) {
+							return null;
+						}
+						throw new Error( 'MetadataValue must be a string, integer, float, boolean, or null literal.' );
+					},
+				)
+			);
+		}
+		return self::$value_scalar;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Schema/CustomScalarType.php b/plugins/woocommerce/src/Internal/Api/Schema/CustomScalarType.php
index 6e145ea48fd..c9ed0815841 100644
--- a/plugins/woocommerce/src/Internal/Api/Schema/CustomScalarType.php
+++ b/plugins/woocommerce/src/Internal/Api/Schema/CustomScalarType.php
@@ -12,7 +12,18 @@ namespace Automattic\WooCommerce\Internal\Api\Schema;
  * documents (keys: `name`, `description`, `serialize`, `parseValue`,
  * `parseLiteral`).
  *
+ * A `metadata` config key may also be provided; see
+ * {@see ObjectType::get_metadata()} for the semantics.
+ *
  * @internal Reserved for autogenerated resolver code.
  */
 class CustomScalarType extends \Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\CustomScalarType {
+	/**
+	 * Type-level metadata entries declared in the config, keyed by name.
+	 *
+	 * @return array<string, bool|int|float|string|null>
+	 */
+	public function get_metadata(): array {
+		return $this->config['metadata'] ?? array();
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/Api/Schema/EnumType.php b/plugins/woocommerce/src/Internal/Api/Schema/EnumType.php
index 67e284188c2..24365992c9b 100644
--- a/plugins/woocommerce/src/Internal/Api/Schema/EnumType.php
+++ b/plugins/woocommerce/src/Internal/Api/Schema/EnumType.php
@@ -10,7 +10,18 @@ namespace Automattic\WooCommerce\Internal\Api\Schema;
  * The constructor accepts the same associative-array config webonyx
  * documents (keys: `name`, `description`, `values`).
  *
+ * A `metadata` config key may also be provided; see
+ * {@see ObjectType::get_metadata()} for the semantics.
+ *
  * @internal Reserved for autogenerated resolver code.
  */
 class EnumType extends \Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\EnumType {
+	/**
+	 * Type-level metadata entries declared in the config, keyed by name.
+	 *
+	 * @return array<string, bool|int|float|string|null>
+	 */
+	public function get_metadata(): array {
+		return $this->config['metadata'] ?? array();
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/Api/Schema/InputObjectType.php b/plugins/woocommerce/src/Internal/Api/Schema/InputObjectType.php
index cc4d40ed76a..11945a6a442 100644
--- a/plugins/woocommerce/src/Internal/Api/Schema/InputObjectType.php
+++ b/plugins/woocommerce/src/Internal/Api/Schema/InputObjectType.php
@@ -11,7 +11,18 @@ namespace Automattic\WooCommerce\Internal\Api\Schema;
  * The constructor accepts the same associative-array config webonyx
  * documents (keys: `name`, `description`, `fields`).
  *
+ * A `metadata` config key may also be provided; see
+ * {@see ObjectType::get_metadata()} for the semantics.
+ *
  * @internal Reserved for autogenerated resolver code.
  */
 class InputObjectType extends \Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\InputObjectType {
+	/**
+	 * Type-level metadata entries declared in the config, keyed by name.
+	 *
+	 * @return array<string, bool|int|float|string|null>
+	 */
+	public function get_metadata(): array {
+		return $this->config['metadata'] ?? array();
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/Api/Schema/InterfaceType.php b/plugins/woocommerce/src/Internal/Api/Schema/InterfaceType.php
index 67b1191f4c7..759b9aa7f1c 100644
--- a/plugins/woocommerce/src/Internal/Api/Schema/InterfaceType.php
+++ b/plugins/woocommerce/src/Internal/Api/Schema/InterfaceType.php
@@ -11,7 +11,18 @@ namespace Automattic\WooCommerce\Internal\Api\Schema;
  * The constructor accepts the same associative-array config webonyx
  * documents (keys: `name`, `description`, `fields`, `resolveType`).
  *
+ * A `metadata` config key may also be provided; see
+ * {@see ObjectType::get_metadata()} for the semantics.
+ *
  * @internal Reserved for autogenerated resolver code.
  */
 class InterfaceType extends \Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\InterfaceType {
+	/**
+	 * Type-level metadata entries declared in the config, keyed by name.
+	 *
+	 * @return array<string, bool|int|float|string|null>
+	 */
+	public function get_metadata(): array {
+		return $this->config['metadata'] ?? array();
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/Api/Schema/ObjectType.php b/plugins/woocommerce/src/Internal/Api/Schema/ObjectType.php
index 50fc98cb4af..f4693581b42 100644
--- a/plugins/woocommerce/src/Internal/Api/Schema/ObjectType.php
+++ b/plugins/woocommerce/src/Internal/Api/Schema/ObjectType.php
@@ -13,7 +13,21 @@ namespace Automattic\WooCommerce\Internal\Api\Schema;
  * `fields` entry is either an array or a callable returning an array of
  * field definitions.
  *
+ * The wrapper also recognises a `metadata` key — an associative array
+ * mapping metadata `name` => scalar `value` — that ApiBuilder emits for
+ * types carrying {@see \Automattic\WooCommerce\Api\Attributes\Metadata}
+ * attributes. Webonyx ignores unknown config keys, so this rides through
+ * the engine untouched and is surfaced by {@see self::get_metadata()}.
+ *
  * @internal Reserved for autogenerated resolver code.
  */
 class ObjectType extends \Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\ObjectType {
+	/**
+	 * Type-level metadata entries declared in the config, keyed by name.
+	 *
+	 * @return array<string, bool|int|float|string|null>
+	 */
+	public function get_metadata(): array {
+		return $this->config['metadata'] ?? array();
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php
index 10692225bb5..216b70fc2cc 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/CreateWidget.php
@@ -22,7 +22,7 @@ class CreateWidget {
 				'input' => array(
 					'type' => Type::nonNull(CreateWidgetInput::get()),
 						'description' => __( 'The data for the new widget', 'woocommerce' ),
-					),
+						),
 			),
 			'resolve' => array( self::class, 'resolve' ),
 		);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php
index 62f6dcaff40..efe9ae96bac 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/DeleteWidget.php
@@ -21,12 +21,12 @@ class DeleteWidget {
 				'id' => array(
 					'type' => Type::nonNull(Type::int()),
 						'description' => __( 'The widget id to delete', 'woocommerce' ),
-					),
+						),
 				'force' => array(
 					'type' => Type::nonNull(Type::boolean()),
 						'description' => __( 'When true, ignore \"not found\" errors', 'woocommerce' ),
 						'defaultValue' => false,
-				),
+					),
 			),
 			'resolve' => array( self::class, 'resolve' ),
 		);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php
index cf84fcfc40d..bd37ee56f13 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/Increment.php
@@ -25,12 +25,12 @@ class Increment {
 				'value' => array(
 					'type' => Type::nonNull(Type::int()),
 						'description' => __( 'The starting value', 'woocommerce' ),
-					),
+						),
 				'by' => array(
 					'type' => Type::nonNull(Type::int()),
 						'description' => __( 'How much to add', 'woocommerce' ),
 						'defaultValue' => 1,
-				),
+					),
 			),
 			'resolve' => array( self::class, 'resolve' ),
 		);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php
index 3f3931282b3..adf93ca4d60 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeOnlyQuery.php
@@ -24,7 +24,7 @@ class AuthorizeOnlyQuery {
 			'args' => array(
 				'allow' => array(
 					'type' => Type::nonNull(Type::boolean()),
-						),
+							),
 			),
 			'resolve' => array( self::class, 'resolve' ),
 		);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php
index a1df3ea0f3c..cad9f89e4aa 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/AuthorizeThrowsQuery.php
@@ -25,7 +25,7 @@ class AuthorizeThrowsQuery {
 				'kind' => array(
 					'type' => Type::nonNull(Type::string()),
 						'description' => __( 'Which exception class authorize() should raise.', 'woocommerce' ),
-					),
+						),
 			),
 			'resolve' => array( self::class, 'resolve' ),
 		);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php
index 72bbe67efb3..cb7dec96f50 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/FailingQuery.php
@@ -26,7 +26,7 @@ class FailingQuery {
 					'type' => Type::nonNull(Type::string()),
 						'description' => __( 'What kind of failure to raise', 'woocommerce' ),
 						'defaultValue' => 'invalid_argument',
-				),
+					),
 			),
 			'resolve' => array( self::class, 'resolve' ),
 		);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php
index 3e35dfd66c9..2f872f71761 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetGreeting.php
@@ -26,7 +26,7 @@ class GetGreeting {
 					'type' => Type::string(),
 						'description' => __( 'Who to greet (defaults to \"world\")', 'woocommerce' ),
 						'defaultValue' => NULL,
-				),
+					),
 			),
 			'resolve' => array( self::class, 'resolve' ),
 		);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php
index f50d55792c9..ad7790e4318 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetIdentifiable.php
@@ -21,7 +21,7 @@ class GetIdentifiable {
 				'kind' => array(
 					'type' => Type::nonNull(Type::string()),
 						'description' => __( 'Which kind of object to return', 'woocommerce' ),
-					),
+						),
 			),
 			'resolve' => array( self::class, 'resolve' ),
 		);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php
index 967bb912fe2..429b3dcba03 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php
@@ -21,7 +21,7 @@ class GetWidget {
 				'id' => array(
 					'type' => Type::nonNull(Type::int()),
 						'description' => __( 'The ID of the widget to fetch', 'woocommerce' ),
-					),
+						),
 			),
 			'resolve' => array( self::class, 'resolve' ),
 		);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php
index 72450194515..1dc13852a1f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgets.php
@@ -24,37 +24,37 @@ class ListWidgets {
 					'type' => Type::int(),
 						'description' => __( 'Return the first N results. Must be between 0 and 100.', 'woocommerce' ),
 						'defaultValue' => NULL,
-				),
+					),
 				'last' => array(
 					'type' => Type::int(),
 						'description' => __( 'Return the last N results. Must be between 0 and 100.', 'woocommerce' ),
 						'defaultValue' => NULL,
-				),
+					),
 				'after' => array(
 					'type' => Type::string(),
 						'description' => __( 'Return results after this cursor.', 'woocommerce' ),
 						'defaultValue' => NULL,
-				),
+					),
 				'before' => array(
 					'type' => Type::string(),
 						'description' => __( 'Return results before this cursor.', 'woocommerce' ),
 						'defaultValue' => NULL,
-				),
+					),
 				'search' => array(
 					'type' => Type::string(),
 						'description' => __( 'A free-text search term', 'woocommerce' ),
 						'defaultValue' => NULL,
-				),
+					),
 				'color' => array(
 					'type' => ColorType::get(),
 						'description' => __( 'Filter widgets by color', 'woocommerce' ),
 						'defaultValue' => NULL,
-				),
+					),
 				'min_priority' => array(
 					'type' => PriorityType::get(),
 						'description' => __( 'A second filter applied after the unrolled ones', 'woocommerce' ),
 						'defaultValue' => NULL,
-				),
+					),
 			),
 			'complexity' => Utils::complexity_from_pagination(...),
 			'resolve' => array( self::class, 'resolve' ),
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Color.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Color.php
index 18975d49020..a09d7074537 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Color.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Color.php
@@ -21,14 +21,14 @@ class Color {
 						'RED' => array(
 							'value' => ColorEnum::Red,
 								'description' => __( 'Red', 'woocommerce' ),
-							),
+								),
 						'GREEN' => array(
 							'value' => ColorEnum::Green,
 								'description' => __( 'Green', 'woocommerce' ),
-							),
+								),
 						'BLUE' => array(
 							'value' => ColorEnum::Blue,
-								),
+									),
 					),
 				)
 			);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Priority.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Priority.php
index 20e02c61827..c2062fd6ce6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Priority.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Enums/Priority.php
@@ -21,15 +21,15 @@ class Priority {
 						'LOW' => array(
 							'value' => PriorityEnum::Low,
 								'description' => __( 'Low priority', 'woocommerce' ),
-							),
+								),
 						'NORMAL_PRIORITY' => array(
 							'value' => PriorityEnum::Normal,
-								),
+									),
 						'HIGH' => array(
 							'value' => PriorityEnum::High,
 								'description' => __( 'High priority', 'woocommerce' ),
 								'deprecationReason' => 'Use NORMAL_PRIORITY instead.',
-						),
+							),
 					),
 				)
 			);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/CreateWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/CreateWidget.php
index 5bef5f2d4bf..eee91f9f3f6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/CreateWidget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/CreateWidget.php
@@ -23,23 +23,23 @@ class CreateWidget {
 						'label' => array(
 							'type' => Type::nonNull(Type::string()),
 								'description' => __( 'The widget label', 'woocommerce' ),
-						),
+							),
 						'weight' => array(
 							'type' => Type::int(),
 								'description' => __( 'Optional weight in grams', 'woocommerce' ),
-						),
+							),
 						'color' => array(
 							'type' => Type::nonNull(ColorType::get()),
 								'description' => __( 'The widget color', 'woocommerce' ),
-						),
+							),
 						'tag_ids' => array(
 							'type' => Type::listOf(Type::nonNull(Type::int())),
 								'description' => __( 'Tag IDs to attach to the widget', 'woocommerce' ),
-						),
+							),
 						'expires_at' => array(
 							'type' => DummyDateTimeType::get(),
 								'description' => __( 'When the widget should expire', 'woocommerce' ),
-						),
+							),
 					),
 				)
 			);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/WidgetFilter.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/WidgetFilter.php
index e5c1f42a1b9..5fcae20c1ef 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/WidgetFilter.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Input/WidgetFilter.php
@@ -22,11 +22,11 @@ class WidgetFilter {
 						'search' => array(
 							'type' => Type::string(),
 								'description' => __( 'A free-text search term', 'woocommerce' ),
-						),
+							),
 						'color' => array(
 							'type' => ColorType::get(),
 								'description' => __( 'Filter widgets by color', 'woocommerce' ),
-						),
+							),
 					),
 				)
 			);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Identifiable.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Identifiable.php
index 20b583cf83f..71bb3ba425b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Identifiable.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Identifiable.php
@@ -22,7 +22,7 @@ class Identifiable {
 						'id' => array(
 							'type' => Type::nonNull(Type::int()),
 								'description' => __( 'The unique numeric identifier', 'woocommerce' ),
-								),
+									),
 					),
 					'resolveType' => function ( $value ) {
 						$class = get_class( $value );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Named.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Named.php
index 890f0216b81..563b6b61295 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Named.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Interfaces/Named.php
@@ -23,11 +23,11 @@ class Named {
 						'label' => array(
 							'type' => Type::nonNull(Type::string()),
 								'description' => __( 'The display label for this object', 'woocommerce' ),
-								),
+									),
 						'id' => array(
 							'type' => Type::nonNull(Type::int()),
 								'description' => __( 'The unique numeric identifier', 'woocommerce' ),
-								),
+									),
 					),
 					'resolveType' => function ( $value ) {
 						$class = get_class( $value );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Gadget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Gadget.php
index 5dc297f3f23..8a138f3fb80 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Gadget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Gadget.php
@@ -25,15 +25,15 @@ class Gadget {
 						'parts_count' => array(
 							'type' => Type::nonNull(Type::int()),
 								'description' => __( 'How many parts the gadget contains', 'woocommerce' ),
-									),
+										),
 						'label' => array(
 							'type' => Type::nonNull(Type::string()),
 								'description' => __( 'The display label for this object', 'woocommerce' ),
-									),
+										),
 						'id' => array(
 							'type' => Type::nonNull(Type::int()),
 								'description' => __( 'The unique numeric identifier', 'woocommerce' ),
-									),
+										),
 					),
 				)
 			);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/OperationResult.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/OperationResult.php
index c48b7828c2f..cb0e94353b3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/OperationResult.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/OperationResult.php
@@ -21,11 +21,11 @@ class OperationResult {
 						'success' => array(
 							'type' => Type::nonNull(Type::boolean()),
 								'description' => __( 'Whether the operation succeeded', 'woocommerce' ),
-									),
+										),
 						'message' => array(
 							'type' => Type::nonNull(Type::string()),
 								'description' => __( 'A human-readable status message', 'woocommerce' ),
-									),
+										),
 					),
 				)
 			);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php
index ee1bf977259..a58f4da92ae 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/Widget.php
@@ -30,59 +30,59 @@ class Widget {
 						'slug' => array(
 							'type' => Type::nonNull(Type::string()),
 								'description' => __( 'A short slug', 'woocommerce' ),
-									),
+										),
 						'caption' => array(
 							'type' => Type::string(),
 								'description' => __( 'An optional caption', 'woocommerce' ),
-									),
+										),
 						'color' => array(
 							'type' => Type::nonNull(ColorType::get()),
 								'description' => __( 'The widget color', 'woocommerce' ),
-									),
+										),
 						'priority' => array(
 							'type' => Type::nonNull(PriorityType::get()),
 								'description' => __( 'Priority assigned to this widget', 'woocommerce' ),
-									),
+										),
 						'tag_ids' => array(
 							'type' => Type::nonNull(Type::listOf(Type::nonNull(Type::int()))),
 								'description' => __( 'Tag IDs assigned to this widget', 'woocommerce' ),
-									),
+										),
 						'featured_reviews' => array(
 							'type' => Type::nonNull(Type::listOf(Type::nonNull(WidgetReview::get()))),
 								'description' => __( 'Notable comments left on this widget', 'woocommerce' ),
-									),
+										),
 						'reviews' => array(
 							'type' => Type::nonNull(WidgetReviewConnectionType::get()),
 								'description' => __( 'Reviews of the widget', 'woocommerce' ),
-									),
+										),
 						'date_created' => array(
 							'type' => DummyDateTimeType::get(),
 								'description' => __( 'When the widget was created', 'woocommerce' ),
-									),
+										),
 						'price' => array(
 							'type' => Type::nonNull(Type::string()),
 								'description' => __( 'The widget price', 'woocommerce' ),
-								'args' => array(
+									'args' => array(
 										'formatted' => array(
 									'type' => Type::boolean(),
 												'defaultValue' => false,
 												'description' => __( 'When true, prepend a $ sign', 'woocommerce' ),
-								),
+											),
 							),
 								),
 						'legacy_price' => array(
 							'type' => Type::nonNull(Type::string()),
 								'description' => __( 'A field flagged for removal', 'woocommerce' ),
-									'deprecationReason' => 'Use price instead.',
+										'deprecationReason' => 'Use price instead.',
 							),
 						'label' => array(
 							'type' => Type::nonNull(Type::string()),
 								'description' => __( 'The display label for this object', 'woocommerce' ),
-									),
+										),
 						'id' => array(
 							'type' => Type::nonNull(Type::int()),
 								'description' => __( 'The unique numeric identifier', 'woocommerce' ),
-									),
+										),
 					),
 				)
 			);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/WidgetReview.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/WidgetReview.php
index ea3c9cd971e..97b86a77992 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/WidgetReview.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/WidgetReview.php
@@ -25,15 +25,15 @@ class WidgetReview {
 						'body' => array(
 							'type' => Type::nonNull(Type::string()),
 								'description' => __( 'The body of the review', 'woocommerce' ),
-									),
+										),
 						'score' => array(
 							'type' => Type::nonNull(Type::int()),
 								'description' => __( 'A score between 0 and 5', 'woocommerce' ),
-									),
+										),
 						'id' => array(
 							'type' => Type::nonNull(Type::int()),
 								'description' => __( 'The unique numeric identifier', 'woocommerce' ),
-									),
+										),
 					),
 				)
 			);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php
index 1b8a297a8ab..3d9ccc9cd2c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootQueryType.php
@@ -21,6 +21,7 @@ use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\Gra
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\FailingQuery;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\ListWidgets;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\GetGreeting;
+use Automattic\WooCommerce\Internal\Api\MetadataController;
 use Automattic\WooCommerce\Internal\Api\Schema\ObjectType;

 class RootQueryType {
@@ -48,6 +49,7 @@ class RootQueryType {
 						'failing' => FailingQuery::get_field_definition(),
 						'widgets' => ListWidgets::get_field_definition(),
 						'greeting' => GetGreeting::get_field_definition(),
+						MetadataController::FIELD_NAME => MetadataController::get_field_definition(),
 					),
 				)
 			);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt
index 860bcd5e761..2ccbfb4edf3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_generation_date.txt
@@ -1 +1 @@
-2026-05-06T16:48:37+00:00
\ No newline at end of file
+2026-05-12T08:46:19+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/MetadataAttributeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/MetadataAttributeTest.php
new file mode 100644
index 00000000000..b2abdd73d52
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/MetadataAttributeTest.php
@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Api\Attributes\Internal;
+use Automattic\WooCommerce\Api\Attributes\Metadata;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for the {@see Metadata} base attribute and the {@see Internal}
+ * convenience subclass. ApiBuilder identifies metadata-bearing attributes via
+ * `instanceof Metadata`, so the contract these tests pin is the
+ * `get_name()` / `get_value()` pair on the base class.
+ */
+class MetadataAttributeTest extends WC_Unit_Test_Case {
+	/**
+	 * @return array<string, array{bool|int|float|string|null}>
+	 */
+	public function provider_scalar_values(): array {
+		return array(
+			'bool true'  => array( true ),
+			'bool false' => array( false ),
+			'int'        => array( 42 ),
+			'float'      => array( 3.14 ),
+			'string'     => array( 'core-team' ),
+			'null'       => array( null ),
+		);
+	}
+
+	/**
+	 * @testdox Metadata round-trips name and value for every supported scalar type.
+	 *
+	 * @dataProvider provider_scalar_values
+	 * @param bool|int|float|string|null $value Value to round-trip.
+	 */
+	public function test_round_trip_for_scalar_values( bool|int|float|string|null $value ): void {
+		$metadata = new Metadata( 'sample', $value );
+
+		$this->assertSame( 'sample', $metadata->get_name() );
+		$this->assertSame( $value, $metadata->get_value() );
+	}
+
+	/**
+	 * @testdox Internal subclass produces a Metadata entry named "internal" with value true.
+	 */
+	public function test_internal_subclass_carries_internal_true(): void {
+		$internal = new Internal();
+
+		$this->assertInstanceOf( Metadata::class, $internal );
+		$this->assertSame( 'internal', $internal->get_name() );
+		$this->assertTrue( $internal->get_value() );
+	}
+
+	/**
+	 * @testdox Metadata is repeatable so multiple distinct names can decorate one element.
+	 */
+	public function test_metadata_attribute_is_repeatable(): void {
+		$reflection = new \ReflectionClass( Metadata::class );
+		$attributes = $reflection->getAttributes( \Attribute::class );
+
+		$this->assertNotEmpty( $attributes, 'Metadata should be decorated with #[Attribute].' );
+
+		$attribute = $attributes[0]->newInstance();
+		$this->assertNotSame( 0, $attribute->flags & \Attribute::IS_REPEATABLE );
+	}
+}