Commit a87d93c4064 for woocommerce

commit a87d93c40649a4033e0fd49da44075824e808150
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Fri May 22 10:26:20 2026 +0200

    Add granular type- and field-level GraphQL authorization (#65235)

diff --git a/plugins/woocommerce/bin/api-builder/ApiBuilder.php b/plugins/woocommerce/bin/api-builder/ApiBuilder.php
index 0d87ef90cd2..b8dd394b835 100644
--- a/plugins/woocommerce/bin/api-builder/ApiBuilder.php
+++ b/plugins/woocommerce/bin/api-builder/ApiBuilder.php
@@ -210,17 +210,22 @@ class ApiBuilder {
 	private string $principal_type = Principal::class;

 	/**
-	 * Map of FQCN => `['takes_principal' => bool]` for every autodiscovered authorization attribute.
+	 * Map of FQCN => per-attribute slot flags for every autodiscovered authorization attribute.
 	 *
 	 * An attribute class qualifies when it lives in `Api/Attributes/` and declares an
-	 * `authorize()` method whose signature matches `(?<PrincipalType>): bool` or
-	 * `(): bool`. The flag captures whether the attribute's authorize() takes the
-	 * principal parameter (true) or opts out (false) — used by {@see self::resolve_authorization()}
-	 * to emit the runtime call with the right arity.
+	 * `authorize()` method whose signature matches the contract enforced by
+	 * {@see self::validate_attribute_authorize_shape()}. Each flag records whether the
+	 * `authorize()` method declared the corresponding opt-in slot, so call-site
+	 * emission can thread only the slots the attribute asked for:
+	 *
+	 *   - `takes_principal` — the principal positional parameter.
+	 *   - `takes_metadata`  — `array $_metadata` (the harvested metadata slices).
+	 *   - `takes_args`      — `array $_args` (the GraphQL field arguments at the call site).
+	 *   - `takes_parent`    — `mixed $_parent` (the parent value being resolved).
 	 *
 	 * Populated by {@see self::discover_authorization_attributes()}.
 	 *
-	 * @var array<class-string, array{takes_principal: bool}>
+	 * @var array<class-string, array{takes_principal: bool, takes_metadata: bool, takes_args: bool, takes_parent: bool}>
 	 */
 	private array $authorization_attribute_fqcns = array();

@@ -603,96 +608,164 @@ class ApiBuilder {
 		if ( ! $ref->hasMethod( 'authorize' ) ) {
 			return;
 		}
-		$method = $ref->getMethod( 'authorize' );
-		if ( ! $this->validate_attribute_authorize_shape( $fqcn, $method ) ) {
+		$method    = $ref->getMethod( 'authorize' );
+		$validated = $this->validate_attribute_authorize_shape( $fqcn, $method );
+		if ( null === $validated ) {
 			return;
 		}
-		if ( ! $this->attribute_principal_type_is_compatible( $method ) ) {
+		if ( null !== $validated['principal_param']
+			&& ! $this->is_principal_type_compatible( self::param_type_name( $validated['principal_param'] ) )
+		) {
 			if ( ! $is_core ) {
-				$this->record_attribute_principal_mismatch_error( $fqcn, $method );
+				$this->record_attribute_principal_mismatch_error( $fqcn, $validated['principal_param'] );
 			}
 			return;
 		}
 		$this->authorization_attribute_fqcns[ $fqcn ] = array(
-			'takes_principal' => count( $method->getParameters() ) > 0,
+			'takes_principal' => null !== $validated['principal_param'],
+			'takes_metadata'  => $validated['takes_metadata'],
+			'takes_args'      => $validated['takes_args'],
+			'takes_parent'    => $validated['takes_parent'],
 		);
 	}

 	/**
 	 * Validate the *shape* of an attribute's `authorize()` method — return type
-	 * and arity — independent of principal-type compatibility.
+	 * and per-parameter contract — independent of principal-type compatibility.
+	 *
+	 * Accepted parameters (any order, any subset):
+	 *   - One positional principal parameter: any name that does not start with
+	 *     `_`. Must be non-nullable and typed. Type compatibility with the
+	 *     registered principal type is checked separately by the caller.
+	 *   - `array $_metadata`: opt-in metadata slices (`query`, `type`, `field`).
+	 *   - `array $_args`: opt-in GraphQL field arguments at the call site.
+	 *   - `mixed $_parent` (or untyped): opt-in parent value at the call site.
 	 *
-	 * The accepted shapes are:
-	 *   - 0 parameters, returns bool. The attribute opts out of using the principal.
-	 *   - 1 non-nullable parameter, returns bool. (Type compatibility with the registered
-	 *     principal type is checked separately by {@see self::attribute_principal_type_is_compatible()}
-	 *     so call sites can decide whether mismatch is a hard error or a silent skip.)
+	 * Any other `_`-prefixed parameter name, more than one principal candidate,
+	 * a wrongly typed infra parameter, or a malformed return type produces a
+	 * build-time error.
 	 *
-	 * Records build errors for genuinely malformed signatures and returns false.
+	 * @return array{principal_param: ?\ReflectionParameter, takes_metadata: bool, takes_args: bool, takes_parent: bool}|null
+	 *         Structured per-parameter classification on success, or `null` on validation failure.
 	 */
-	private function validate_attribute_authorize_shape( string $fqcn, \ReflectionMethod $method ): bool {
+	private function validate_attribute_authorize_shape( string $fqcn, \ReflectionMethod $method ): ?array {
 		if ( $method->isStatic() || ! $method->isPublic() ) {
 			$this->errors[] = "Authorization attribute {$fqcn}::authorize() must be a public non-static method.";
-			return false;
+			return null;
 		}

 		$return_type = $method->getReturnType();
 		if ( ! $return_type instanceof \ReflectionNamedType || 'bool' !== $return_type->getName() ) {
 			$this->errors[] = "Authorization attribute {$fqcn}::authorize() must declare a `bool` return type.";
-			return false;
+			return null;
 		}

-		$params = $method->getParameters();
-		if ( count( $params ) > 1 ) {
-			$this->errors[] = "Authorization attribute {$fqcn}::authorize() must take 0 parameters or a single `<Principal>` parameter; got " . count( $params ) . '.';
-			return false;
-		}
-		if ( 0 === count( $params ) ) {
-			return true;
-		}
+		$principal_param = null;
+		$takes_metadata  = false;
+		$takes_args      = false;
+		$takes_parent    = false;

-		$param      = $params[0];
-		$param_type = $param->getType();
-		$type_name  = $param_type instanceof \ReflectionNamedType ? $param_type->getName() : 'mixed';
+		foreach ( $method->getParameters() as $param ) {
+			$name = $param->getName();

-		if ( ! $param_type instanceof \ReflectionNamedType || $param_type->allowsNull() ) {
-			$this->errors[] = "Authorization attribute {$fqcn}::authorize() parameter must be non-nullable (anonymous requests are represented by a sentinel principal, not null); got `?{$type_name}`.";
-			return false;
-		}
+			if ( '_metadata' === $name ) {
+				if ( ! self::param_type_is_named( $param, 'array' ) ) {
+					$this->errors[] = "Authorization attribute {$fqcn}::authorize() parameter \$_metadata must be typed `array`; got `" . self::param_type_name( $param ) . '`.';
+					return null;
+				}
+				$takes_metadata = true;
+				continue;
+			}

-		return true;
-	}
+			if ( '_args' === $name ) {
+				if ( ! self::param_type_is_named( $param, 'array' ) ) {
+					$this->errors[] = "Authorization attribute {$fqcn}::authorize() parameter \$_args must be typed `array`; got `" . self::param_type_name( $param ) . '`.';
+					return null;
+				}
+				$takes_args = true;
+				continue;
+			}

-	/**
-	 * Whether an attribute's `authorize()` parameter type is compatible with the
-	 * registered principal type.
-	 *
-	 * Zero-arg methods are trivially compatible. Otherwise the parameter type
-	 * must satisfy {@see self::is_principal_type_compatible()}. Used by
-	 * {@see self::try_register_authorization_attribute()} to decide whether to
-	 * include the attribute in the autodiscovered set.
-	 */
-	private function attribute_principal_type_is_compatible( \ReflectionMethod $method ): bool {
-		$params = $method->getParameters();
-		if ( 0 === count( $params ) ) {
-			return true;
+			if ( '_parent' === $name ) {
+				$takes_parent = true;
+				continue;
+			}
+
+			if ( '' !== $name && '_' === $name[0] ) {
+				$this->errors[] = "Authorization attribute {$fqcn}::authorize() has unknown infrastructure parameter \${$name}; accepted: \$_metadata, \$_args, \$_parent.";
+				return null;
+			}
+
+			if ( null !== $principal_param ) {
+				$this->errors[] = "Authorization attribute {$fqcn}::authorize() may declare at most one principal parameter; got \${$principal_param->getName()} and \${$name}.";
+				return null;
+			}
+
+			$param_type = $param->getType();
+			$type_name  = self::param_type_name( $param );
+			if ( ! $param_type instanceof \ReflectionNamedType || $param_type->allowsNull() ) {
+				$this->errors[] = "Authorization attribute {$fqcn}::authorize() principal parameter must be non-nullable (anonymous requests are represented by a sentinel principal, not null); got `?{$type_name}`.";
+				return null;
+			}
+
+			$principal_param = $param;
 		}
-		$param_type = $params[0]->getType();
-		$type_name  = $param_type instanceof \ReflectionNamedType ? $param_type->getName() : 'mixed';
-		return $this->is_principal_type_compatible( $type_name );
+
+		return array(
+			'principal_param' => $principal_param,
+			'takes_metadata'  => $takes_metadata,
+			'takes_args'      => $takes_args,
+			'takes_parent'    => $takes_parent,
+		);
 	}

 	/**
 	 * Record a build error for a plugin-shipped attribute whose `authorize()`
-	 * parameter type is incompatible with the registered principal type.
+	 * principal parameter type is incompatible with the registered principal type.
 	 */
-	private function record_attribute_principal_mismatch_error( string $fqcn, \ReflectionMethod $method ): void {
-		$param_type     = $method->getParameters()[0]->getType();
-		$type_name      = $param_type instanceof \ReflectionNamedType ? $param_type->getName() : 'mixed';
+	private function record_attribute_principal_mismatch_error( string $fqcn, \ReflectionParameter $principal_param ): void {
+		$type_name      = self::param_type_name( $principal_param );
 		$expected       = 'object' === $this->principal_type
 			? '`object` (or any object class)'
 			: "`{$this->principal_type}` (or `object`)";
-		$this->errors[] = "Authorization attribute {$fqcn}::authorize() parameter must be typed as {$expected}; got `{$type_name}`.";
+		$this->errors[] = "Authorization attribute {$fqcn}::authorize() principal parameter must be typed as {$expected}; got `{$type_name}`.";
+	}
+
+	/**
+	 * Whether a parameter declares the named type (and only the named type).
+	 *
+	 * Used by the authorize-method validator to enforce that opt-in
+	 * infrastructure parameters declare the type the contract expects
+	 * (`array` for `$_metadata` and `$_args`; `$_parent` has no required type).
+	 */
+	private static function param_type_is_named( \ReflectionParameter $param, string $expected ): bool {
+		$type = $param->getType();
+		return $type instanceof \ReflectionNamedType && $type->getName() === $expected;
+	}
+
+	/**
+	 * Return a printable name for a parameter's declared type.
+	 *
+	 * `mixed` is returned for untyped parameters and for union/intersection
+	 * types; the validator does not currently inspect those shapes.
+	 */
+	private static function param_type_name( \ReflectionParameter $param ): string {
+		$type = $param->getType();
+		return $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
+	}
+
+	/**
+	 * Render a scalar/array value as a valid PHP expression that, when
+	 * evaluated, reproduces the value. Used by the resolver-template emission
+	 * to inline build-time-known data (e.g. metadata slices) into the
+	 * generated code.
+	 *
+	 * The output is `var_export()`-style and is later normalised by phpcbf
+	 * during the generated-file lint pass, so callers do not need to worry
+	 * about formatting.
+	 */
+	private static function render_php_literal( mixed $value ): string {
+		return var_export( $value, true );
 	}

 	// ========================================================================
@@ -1049,16 +1122,32 @@ class ApiBuilder {
 		} elseif ( $has_public_access ) {
 			$attribute_expr = 'true';
 		} else {
+			// Command-level metadata literal — `['query' => harvest_metadata($ref)]`.
+			// `$_args` and `$_parent` are field-level concepts; at the command
+			// level they carry neutral defaults (`[]` and `null`).
+			$query_metadata_literal = self::render_php_literal( array( 'query' => $this->harvest_metadata( $ref, $ref->getShortName() ) ) );
+
 			$expressions    = array_map(
-				function ( $u ) {
-					$arg = $this->authorization_attribute_fqcns[ $u['fqcn'] ]['takes_principal']
-						? ' $principal '
-						: '';
+				function ( $u ) use ( $query_metadata_literal ) {
+					$flags = $this->authorization_attribute_fqcns[ $u['fqcn'] ];
+					$args  = array();
+					if ( $flags['takes_principal'] ) {
+						$args[] = '$principal';
+					}
+					if ( $flags['takes_metadata'] ) {
+						$args[] = '_metadata: ' . $query_metadata_literal;
+					}
+					if ( $flags['takes_args'] ) {
+						$args[] = '_args: array()';
+					}
+					if ( $flags['takes_parent'] ) {
+						$args[] = '_parent: null';
+					}
 					return sprintf(
 						'( new \%s(%s) )->authorize(%s)',
 						$u['fqcn'],
 						$u['args_php'],
-						$arg
+						empty( $args ) ? '' : ' ' . implode( ', ', $args ) . ' '
 					);
 				},
 				$usages
@@ -1071,6 +1160,7 @@ class ApiBuilder {
 			'has_public_access' => $has_public_access,
 			'attribute_expr'    => $attribute_expr,
 			'error'             => $error,
+			'descriptors'       => $this->harvest_authorization_descriptors( $ref ),
 		);
 	}

@@ -1082,11 +1172,15 @@ class ApiBuilder {
 	 * supplied at each usage site, so the generated resolver can construct the
 	 * attribute with the same arguments.
 	 *
-	 * @param \ReflectionClass $source The class/trait/interface to read attributes from.
+	 * Accepts any reflector that supports `getAttributes()` (class, trait,
+	 * interface, or property) — class-level discovery, ancestor walks, and
+	 * field-level discovery share the same scanner.
+	 *
+	 * @param \ReflectionClass|\ReflectionProperty $source The reflector to read attributes from.
 	 *
 	 * @return array<int, array{fqcn: string, args_php: string, is_public_access: bool}>
 	 */
-	private function collect_authorization_usages( \ReflectionClass $source ): array {
+	private function collect_authorization_usages( $source ): array {
 		$usages = array();
 		foreach ( array_keys( $this->authorization_attribute_fqcns ) as $attr_fqcn ) {
 			foreach ( $source->getAttributes( $attr_fqcn ) as $attr ) {
@@ -1102,6 +1196,284 @@ class ApiBuilder {
 		return $usages;
 	}

+	/**
+	 * Whether the given reflector (class, property, parameter, …) should
+	 * appear in the `_apiMetadata` discovery query.
+	 *
+	 * The check is per-target and uniform across attribute kinds: walk
+	 * the reflector's direct attributes, and for each whose class
+	 * declares a `shows_in_metadata_query(): bool` method, call it. If
+	 * any returns `false`, the target is hidden — its `metadata` and
+	 * `authorization` keys are dropped from the emitted config, and
+	 * {@see \Automattic\WooCommerce\Api\Utils\SchemaHandle::get_all_metadata()}
+	 * does not emit a row for it.
+	 *
+	 * Despite the colloquial name, this is unrelated to native GraphQL
+	 * introspection (`__schema` / `__type`); those queries continue to
+	 * expose the schema's shape regardless of this flag. The marker
+	 * only affects the custom `_apiMetadata` channel.
+	 *
+	 * The runtime authorization gate is independent of this check: an
+	 * authorization attribute whose `shows_in_metadata_query()` returns
+	 * `false` still runs its `authorize()` method at request time. The
+	 * stock {@see \Automattic\WooCommerce\Api\Attributes\HiddenFromMetadataQuery}
+	 * marker is the recommended way to opt a target out without giving
+	 * the attribute any other behaviour.
+	 *
+	 * @param \Reflector $reflector Class, property, parameter, or enum case.
+	 */
+	private function is_target_metadata_visible( \Reflector $reflector ): bool {
+		foreach ( $reflector->getAttributes() as $attribute ) {
+			$name = $attribute->getName();
+			if ( ! class_exists( $name ) || ! method_exists( $name, 'shows_in_metadata_query' ) ) {
+				continue;
+			}
+			$instance = $attribute->newInstance();
+			if ( false === $instance->shows_in_metadata_query() ) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+	/**
+	 * Walk a class plus its parents / traits / interfaces (visit-set
+	 * guarded), invoking `$collect` on each reflector and accumulating the
+	 * returned descriptors. Shared between
+	 * {@see self::collect_class_authorization_usages()} and
+	 * {@see self::collect_class_authorization_descriptors()} so the gate
+	 * emission and the `_apiMetadata` discovery agree on what counts as
+	 * "from the type's hierarchy".
+	 *
+	 * Direct attributes shadow inherited ones — the same rule the existing
+	 * command-class resolution uses: if the class itself contributes a
+	 * non-empty list, ancestors are not walked.
+	 *
+	 * @param \ReflectionClass $ref     Class to walk.
+	 * @param callable         $collect Maps each reflector to a list.
+	 *
+	 * @return list<mixed>
+	 */
+	private function walk_class_hierarchy( \ReflectionClass $ref, callable $collect ): array {
+		$direct = $collect( $ref );
+		if ( ! empty( $direct ) ) {
+			return $direct;
+		}
+		$visited = array();
+		$stack   = array_merge(
+			$ref->getParentClass() ? array( $ref->getParentClass() ) : array(),
+			$ref->getTraits(),
+			$ref->getInterfaces(),
+		);
+		$out     = array();
+		while ( ! empty( $stack ) ) {
+			$source = array_shift( $stack );
+			$name   = $source->getName();
+			if ( in_array( $name, $visited, true ) ) {
+				continue;
+			}
+			$visited[] = $name;
+			$out       = array_merge( $out, $collect( $source ) );
+			if ( false !== $source->getParentClass() ) {
+				$stack[] = $source->getParentClass();
+			}
+			$stack = array_merge( $stack, $source->getTraits(), $source->getInterfaces() );
+		}
+		return $out;
+	}
+
+	/**
+	 * Collect class-level authorization usages from a type class, mirroring
+	 * {@see self::resolve_authorization()}'s inheritance walk (parent chain,
+	 * traits, interfaces — visit-set guarded). Direct attributes shadow
+	 * inherited ones; if the class declares any class-level usages directly,
+	 * ancestors are not walked.
+	 *
+	 * Used by {@see self::build_field_definition()} to AND a type's
+	 * class-level authorization into every one of its field gates, so an
+	 * attribute on `class Event` (or on a trait it uses, like an
+	 * `OrganizerOnlyTrait`) restricts read access to all of `Event`'s
+	 * fields without having to repeat the attribute on each property.
+	 *
+	 * @param \ReflectionClass $ref The type class to read class-level usages from.
+	 *
+	 * @return array<int, array{fqcn: string, args_php: string, is_public_access: bool}>
+	 */
+	private function collect_class_authorization_usages( \ReflectionClass $ref ): array {
+		return $this->walk_class_hierarchy(
+			$ref,
+			fn( \ReflectionClass $r ): array => $this->collect_authorization_usages( $r ),
+		);
+	}
+
+	/**
+	 * Inheritance-aware counterpart to
+	 * {@see self::harvest_authorization_descriptors()}: walks the class's
+	 * parents / traits / interfaces so trait-supplied class-level
+	 * authorization surfaces through `_apiMetadata`, not just through the
+	 * runtime gate. Direct descriptors shadow inherited ones (same rule
+	 * as the usages walk).
+	 *
+	 * @param \ReflectionClass $ref Class to walk.
+	 *
+	 * @return list<array{attribute: string, args: list<mixed>}>
+	 */
+	private function collect_class_authorization_descriptors( \ReflectionClass $ref ): array {
+		return $this->walk_class_hierarchy(
+			$ref,
+			fn( \ReflectionClass $r ): array => $this->harvest_authorization_descriptors( $r ),
+		);
+	}
+
+	/**
+	 * Harvest authorization-attribute descriptors for the `_apiMetadata`
+	 * discovery endpoint.
+	 *
+	 * Mirrors {@see self::collect_authorization_usages()} but emits
+	 * `{attribute, args}` records in the shape clients read through
+	 * `_apiMetadata { authorization { attribute args } }`. The per-target
+	 * `shows_in_metadata_query()` filter is applied by callers
+	 * ({@see self::build_field_definition()} and the `generate_*_type`
+	 * methods) — this method always returns every authorization attribute
+	 * found on the reflector, including those whose target opts out of
+	 * the metadata query. The caller decides whether to expose them.
+	 *
+	 * @param \ReflectionClass|\ReflectionProperty $reflector Source reflector.
+	 *
+	 * @return list<array{attribute: string, args: list<mixed>}>
+	 */
+	private function harvest_authorization_descriptors( $reflector ): array {
+		$descriptors = array();
+		foreach ( array_keys( $this->authorization_attribute_fqcns ) as $attr_fqcn ) {
+			foreach ( $reflector->getAttributes( $attr_fqcn ) as $attr ) {
+				$short_name    = ( false !== strrpos( $attr_fqcn, '\\' ) )
+					? substr( $attr_fqcn, strrpos( $attr_fqcn, '\\' ) + 1 )
+					: $attr_fqcn;
+				$descriptors[] = array(
+					'attribute' => $short_name,
+					'args'      => array_values( $attr->getArguments() ),
+				);
+			}
+		}
+		return $descriptors;
+	}
+
+	/**
+	 * Resolve authorization for a single property (output field, input field,
+	 * or trait property).
+	 *
+	 * Field-level gates differ from class-level gates in three ways:
+	 *
+	 *  - The attribute call expression references the runtime locals
+	 *    `$principal`, `$_metadata`, `$_args`, `$_parent` rather than baking
+	 *    `$_metadata` as a build-time literal. Step-5 emission creates these
+	 *    locals before invoking the expression.
+	 *  - `#[PublicAccess]` placed on a property is a build warning (not a
+	 *    hard error) and is treated as a no-op: it always grants, which is
+	 *    indistinguishable from the default allow-by-default semantics that
+	 *    apply to fields with no authorization attribute.
+	 *  - There is no inheritance walk: a field's gate is the attributes
+	 *    declared directly on the property reflector. Trait-declared
+	 *    properties carry their attributes onto every implementing class
+	 *    naturally through PHP's reflection.
+	 *
+	 * @return array{usages: list<array{fqcn: string, args_php: string, is_public_access: bool}>, attribute_expr: string}
+	 */
+	private function resolve_field_authorization( \ReflectionProperty $prop, array $type_level_usages = array(), array $type_level_descriptors = array() ): array {
+		$all_usages = $this->collect_authorization_usages( $prop );
+		$usages     = array();
+		foreach ( $all_usages as $usage ) {
+			if ( $usage['is_public_access'] ) {
+				$label            = $prop->getDeclaringClass()->getShortName() . '::$' . $prop->getName();
+				$this->warnings[] = "#[PublicAccess] on property {$label}: redundant with allow-by-default field semantics; the attribute is ignored at field level.";
+				continue;
+			}
+			$usages[] = $usage;
+		}
+
+		// AND in the type-class-level usages. PublicAccess at type level
+		// short-circuits to true, so it never contributes to a field gate; skip
+		// silently. Other type-level usages are concatenated with the field's
+		// own usages; the combined expression becomes the field's effective gate.
+		foreach ( $type_level_usages as $usage ) {
+			if ( $usage['is_public_access'] ) {
+				continue;
+			}
+			$usages[] = $usage;
+		}
+
+		// Descriptors advertised through `_apiMetadata` must mirror the
+		// *effective* gates: a no-op #[PublicAccess] on a field is dropped from
+		// $usages above, so drop it from the descriptors too — otherwise
+		// discovery would advertise a gate that never runs. $usages and the
+		// harvested descriptors are parallel (one per authorization attribute)
+		// and #[PublicAccess] is the only no-op, so keeping the descriptors whose
+		// attribute short name survives in $usages preserves every real gate and
+		// removes only the ignored ones.
+		$effective_shorts = array();
+		foreach ( $usages as $u ) {
+			$fqcn                       = $u['fqcn'];
+			$short                      = ( false !== strrpos( $fqcn, '\\' ) ) ? substr( $fqcn, strrpos( $fqcn, '\\' ) + 1 ) : $fqcn;
+			$effective_shorts[ $short ] = true;
+		}
+		$descriptors = array_values(
+			array_filter(
+				array_merge(
+					$this->harvest_authorization_descriptors( $prop ),
+					$type_level_descriptors,
+				),
+				static fn( array $d ): bool => isset( $effective_shorts[ $d['attribute'] ] ),
+			)
+		);
+
+		if ( empty( $usages ) ) {
+			return array(
+				'usages'                => array(),
+				'attribute_expr'        => 'true',
+				'first_attribute_short' => null,
+				'descriptors'           => $descriptors,
+			);
+		}
+
+		$expressions = array_map(
+			function ( $u ) {
+				$flags = $this->authorization_attribute_fqcns[ $u['fqcn'] ];
+				$args  = array();
+				if ( $flags['takes_principal'] ) {
+					$args[] = '$principal';
+				}
+				if ( $flags['takes_metadata'] ) {
+					$args[] = '_metadata: $_metadata';
+				}
+				if ( $flags['takes_args'] ) {
+					$args[] = '_args: $_args';
+				}
+				if ( $flags['takes_parent'] ) {
+					$args[] = '_parent: $_parent';
+				}
+				return sprintf(
+					'( new \%s(%s) )->authorize(%s)',
+					$u['fqcn'],
+					$u['args_php'],
+					empty( $args ) ? '' : ' ' . implode( ', ', $args ) . ' '
+				);
+			},
+			$usages
+		);
+
+		$first_fqcn  = $usages[0]['fqcn'];
+		$first_short = ( false !== strrpos( $first_fqcn, '\\' ) )
+			? substr( $first_fqcn, strrpos( $first_fqcn, '\\' ) + 1 )
+			: $first_fqcn;
+
+		return array(
+			'usages'                => $usages,
+			'attribute_expr'        => implode( ' && ', $expressions ),
+			'first_attribute_short' => $first_short,
+			'descriptors'           => $descriptors,
+		);
+	}
+
 	/**
 	 * Render an attribute's runtime arguments as a comma-separated PHP literal list.
 	 *
@@ -1318,17 +1690,19 @@ class ApiBuilder {
 	// ------ Interface ------

 	private function generate_interface( string $fqcn, \ReflectionClass $ref ): void {
-		$graphql_name = $this->graphql_names[ $fqcn ];
-		$description  = $this->get_description( $ref );
-		$use_stmts    = array();
-		$fields       = array();
+		$graphql_name           = $this->graphql_names[ $fqcn ];
+		$description            = $this->get_description( $ref );
+		$use_stmts              = array();
+		$fields                 = array();
+		$type_level_usages      = $this->collect_class_authorization_usages( $ref );
+		$type_level_descriptors = $this->collect_class_authorization_descriptors( $ref );

 		foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
 			if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
 				continue;
 			}

-			$field = $this->build_field_definition( $prop, 'output', $use_stmts );
+			$field = $this->build_field_definition( $prop, 'output', $use_stmts, $type_level_usages, $type_level_descriptors );
 			if ( $field !== null ) {
 				$fields[] = $field;
 			}
@@ -1346,7 +1720,8 @@ class ApiBuilder {
 			);
 		}

-		$code = $this->render_template(
+		$type_metadata_visible = $this->is_target_metadata_visible( $ref );
+		$code                  = $this->render_template(
 			'InterfaceTypeTemplate.php',
 			array(
 				'namespace'      => $this->autogenerated_namespace . '\\GraphQLTypes\\Interfaces',
@@ -1356,7 +1731,8 @@ class ApiBuilder {
 				'use_statements' => array_unique( $use_stmts ),
 				'fields'         => $fields,
 				'type_map'       => $type_map,
-				'metadata'       => $this->harvest_metadata( $ref, $ref->getShortName() ),
+				'metadata'       => $type_metadata_visible ? $this->harvest_metadata( $ref, $ref->getShortName() ) : array(),
+				'authorization'  => $type_metadata_visible ? $this->collect_class_authorization_descriptors( $ref ) : array(),
 			)
 		);

@@ -1368,18 +1744,20 @@ class ApiBuilder {
 	// ------ Output Type ------

 	private function generate_output_type( string $fqcn, \ReflectionClass $ref ): void {
-		$graphql_name = $this->graphql_names[ $fqcn ];
-		$description  = $this->get_description( $ref );
-		$use_stmts    = array();
-		$interfaces   = array();
-		$fields       = array();
+		$graphql_name           = $this->graphql_names[ $fqcn ];
+		$description            = $this->get_description( $ref );
+		$use_stmts              = array();
+		$interfaces             = array();
+		$fields                 = array();
+		$type_level_usages      = $this->collect_class_authorization_usages( $ref );
+		$type_level_descriptors = $this->collect_class_authorization_descriptors( $ref );

 		foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
 			if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
 				continue;
 			}

-			$field = $this->build_field_definition( $prop, 'output', $use_stmts );
+			$field = $this->build_field_definition( $prop, 'output', $use_stmts, $type_level_usages, $type_level_descriptors );
 			if ( $field !== null ) {
 				$fields[] = $field;
 			}
@@ -1397,17 +1775,25 @@ class ApiBuilder {
 			}
 		}

-		$code = $this->render_template(
+		$type_metadata_visible = $this->is_target_metadata_visible( $ref );
+		$type_metadata_full    = $this->harvest_metadata( $ref, $ref->getShortName() );
+		$code                  = $this->render_template(
 			'ObjectTypeTemplate.php',
 			array(
-				'namespace'      => $this->autogenerated_namespace . '\\GraphQLTypes\\Output',
-				'class_name'     => $ref->getShortName(),
-				'graphql_name'   => $graphql_name,
-				'description'    => $description,
-				'use_statements' => array_unique( $use_stmts ),
-				'interfaces'     => $interfaces,
-				'fields'         => $fields,
-				'metadata'       => $this->harvest_metadata( $ref, $ref->getShortName() ),
+				'namespace'        => $this->autogenerated_namespace . '\\GraphQLTypes\\Output',
+				'class_name'       => $ref->getShortName(),
+				'graphql_name'     => $graphql_name,
+				'description'      => $description,
+				'use_statements'   => array_unique( $use_stmts ),
+				'interfaces'       => $interfaces,
+				'fields'           => $fields,
+				// `metadata` feeds the `_apiMetadata` discovery channel and so respects
+				// the per-target `shows_in_metadata_query()` opt-out; `metadata_runtime`
+				// always carries the full set because field gates thread it into the
+				// `$_metadata['type']` slice and that opt-out is discovery-only.
+				'metadata'         => $type_metadata_visible ? $type_metadata_full : array(),
+				'metadata_runtime' => $type_metadata_full,
+				'authorization'    => $type_metadata_visible ? $this->collect_class_authorization_descriptors( $ref ) : array(),
 			)
 		);

@@ -1427,22 +1813,25 @@ class ApiBuilder {
 			$gen_class_name = substr( $gen_class_name, 0, -5 );
 		}

-		$description = $this->get_description( $ref );
-		$use_stmts   = array();
-		$fields      = array();
+		$description            = $this->get_description( $ref );
+		$use_stmts              = array();
+		$fields                 = array();
+		$type_level_usages      = $this->collect_class_authorization_usages( $ref );
+		$type_level_descriptors = $this->collect_class_authorization_descriptors( $ref );

 		foreach ( $ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
 			if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
 				continue;
 			}

-			$field = $this->build_field_definition( $prop, 'input', $use_stmts );
+			$field = $this->build_field_definition( $prop, 'input', $use_stmts, $type_level_usages, $type_level_descriptors );
 			if ( $field !== null ) {
 				$fields[] = $field;
 			}
 		}

-		$code = $this->render_template(
+		$type_metadata_visible = $this->is_target_metadata_visible( $ref );
+		$code                  = $this->render_template(
 			'InputObjectTypeTemplate.php',
 			array(
 				'namespace'      => $this->autogenerated_namespace . '\\GraphQLTypes\\Input',
@@ -1451,7 +1840,8 @@ class ApiBuilder {
 				'description'    => $description,
 				'use_statements' => array_unique( $use_stmts ),
 				'fields'         => $fields,
-				'metadata'       => $this->harvest_metadata( $ref, $ref->getShortName() ),
+				'metadata'       => $type_metadata_visible ? $this->harvest_metadata( $ref, $ref->getShortName() ) : array(),
+				'authorization'  => $type_metadata_visible ? $this->collect_class_authorization_descriptors( $ref ) : array(),
 			)
 		);

@@ -1537,6 +1927,7 @@ class ApiBuilder {
 					'conversion'        => null,
 					'is_infrastructure' => false,
 					'unroll'            => $unroll,
+					'input_fqcn'        => $unroll['fqcn'],
 				);
 				continue;
 			}
@@ -1577,6 +1968,7 @@ class ApiBuilder {
 				'name'              => $param_name,
 				'conversion'        => $conversion,
 				'is_infrastructure' => false,
+				'input_fqcn'        => ( null !== $conversion && null !== $input_info && 'input_type' === $input_info['kind'] ) ? $type_name : null,
 			);
 		}

@@ -1598,6 +1990,54 @@ class ApiBuilder {
 			}
 		}

+		// Collect input-side authorization gate descriptors. For every
+		// execute() parameter whose runtime value is an input class instance
+		// (either unrolled, or built by an input-converter), walk that class's
+		// public properties for field-level authorization attributes. The
+		// emitted gate fires only for *provided* fields so an unset/optional
+		// property doesn't trigger a check, mirroring the
+		// {@see \Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields}
+		// convention that input types already follow.
+		$input_side_gates = array();
+		foreach ( $execute_params as $param ) {
+			if ( empty( $param['input_fqcn'] ) || ! class_exists( $param['input_fqcn'] ) ) {
+				continue;
+			}
+			$input_ref              = new \ReflectionClass( $param['input_fqcn'] );
+			$input_short            = $input_ref->getShortName();
+			$type_metadata_lit      = self::render_php_literal( $this->harvest_metadata( $input_ref, $input_short ) );
+			$input_type_usages      = $this->collect_class_authorization_usages( $input_ref );
+			$input_type_descriptors = $this->collect_class_authorization_descriptors( $input_ref );
+			$field_descriptors      = array();
+			foreach ( $input_ref->getProperties( \ReflectionProperty::IS_PUBLIC ) as $prop ) {
+				if ( ! empty( $prop->getAttributes( Ignore::class ) ) ) {
+					continue;
+				}
+				$field_auth = $this->resolve_field_authorization( $prop, $input_type_usages, $input_type_descriptors );
+				if ( 'true' === $field_auth['attribute_expr'] ) {
+					continue;
+				}
+				$field_metadata_lit  = self::render_php_literal(
+					$this->harvest_metadata( $prop, $input_short . '::$' . $prop->getName() )
+				);
+				$field_descriptors[] = array(
+					'field_name'             => $prop->getName(),
+					'attribute_expr'         => $field_auth['attribute_expr'],
+					'first_attribute_short'  => $field_auth['first_attribute_short'],
+					'type_metadata_literal'  => $type_metadata_lit,
+					'field_metadata_literal' => $field_metadata_lit,
+				);
+			}
+			if ( ! empty( $field_descriptors ) ) {
+				$input_side_gates[] = array(
+					'exec_arg_name'    => $param['name'],
+					'input_fqcn'       => $param['input_fqcn'],
+					'input_short_name' => $input_short,
+					'fields'           => $field_descriptors,
+				);
+			}
+		}
+
 		// Resolve the attribute-declared authorization. The `attribute_expr` is
 		// the AND of all autodiscovered authorization attributes' authorize()
 		// calls (or `'true'` when the only attribute is PublicAccess, or when
@@ -1661,7 +2101,14 @@ 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() ),
+				// `metadata` feeds the operation's `_apiMetadata` row (discovery, so it
+				// honours `shows_in_metadata_query()`); `metadata_runtime` is published
+				// into `$context['_query_metadata']` for downstream field gates and so
+				// always carries the full set (the opt-out is discovery-only).
+				'metadata'                         => $this->is_target_metadata_visible( $ref ) ? $this->harvest_metadata( $ref, $ref->getShortName() ) : array(),
+				'metadata_runtime'                 => $this->harvest_metadata( $ref, $ref->getShortName() ),
+				'input_side_gates'                 => $input_side_gates,
+				'authorization_descriptors'        => $this->is_target_metadata_visible( $ref ) ? $auth['descriptors'] : array(),
 			)
 		);

@@ -2482,7 +2929,7 @@ class ApiBuilder {
 	 * 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 {
+	private function build_field_definition( \ReflectionProperty $prop, string $context, array &$use_stmts, array $type_level_usages = array(), array $type_level_descriptors = array() ): ?array {
 		$type        = $prop->getType();
 		$type_name   = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
 		$nullable    = $type?->allowsNull() ?? false;
@@ -2556,6 +3003,24 @@ class ApiBuilder {

 		$field_context_label = $prop->getDeclaringClass()->getShortName() . '::$' . $prop->getName();

+		$metadata_visible = $this->is_target_metadata_visible( $prop );
+		$authorization    = $this->resolve_field_authorization( $prop, $type_level_usages, $type_level_descriptors );
+		$metadata_full    = $this->harvest_metadata( $prop, $field_context_label );
+		$metadata         = $metadata_full;
+
+		// Per-target `_apiMetadata` opt-out: when any attribute on the
+		// property declares `shows_in_metadata_query(): false`, the
+		// field's row is omitted entirely. The runtime gate is
+		// unaffected — `attribute_expr` stays populated so the resolver
+		// template still emits the `'resolve'` callback; we only blank
+		// the descriptors and metadata that feed the discovery channel.
+		// `metadata_runtime` keeps the full set: the gate threads it into
+		// `$_metadata['field']`, and the opt-out is discovery-only.
+		if ( ! $metadata_visible ) {
+			$metadata                     = array();
+			$authorization['descriptors'] = array();
+		}
+
 		return array(
 			'name'                 => $prop->getName(),
 			'type_expr'            => $type_expr,
@@ -2563,7 +3028,9 @@ 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 ),
+			'metadata'             => $metadata,
+			'metadata_runtime'     => $metadata_full,
+			'authorization'        => $authorization,
 		);
 	}

@@ -2741,6 +3208,20 @@ class ApiBuilder {
 			return $nullable ? $expr : "Type::nonNull({$expr})";
 		}

+		// Check for ArrayOf on the method (plain list return). Mirrors the
+		// property-/parameter-level ArrayOf handling: a method declared
+		// `: array` with `#[ArrayOf( X::class )]` becomes `[X!]!` (or `[X!]`
+		// when the return type is nullable). The element expression is
+		// resolved through the same helper the field path uses, so scalar
+		// and object element types are handled identically.
+		$array_of_attr = $method->getAttributes( ArrayOf::class );
+		if ( ! empty( $array_of_attr ) && 'array' === $type_name ) {
+			$item_type = $array_of_attr[0]->newInstance()->type;
+			$item_expr = $this->type_string_to_graphql_expr( $item_type, $use_stmts );
+			$expr      = "Type::listOf(Type::nonNull({$item_expr}))";
+			return $nullable ? $expr : "Type::nonNull({$expr})";
+		}
+
 		// Output type reference.
 		$class_info = $this->get_class_info( $type_name );
 		if ( $class_info !== null && $class_info['kind'] === 'type' ) {
diff --git a/plugins/woocommerce/bin/api-builder/code-templates/InputObjectTypeTemplate.php b/plugins/woocommerce/bin/api-builder/code-templates/InputObjectTypeTemplate.php
index 3335b37f6bc..d0d0c3a1336 100644
--- a/plugins/woocommerce/bin/api-builder/code-templates/InputObjectTypeTemplate.php
+++ b/plugins/woocommerce/bin/api-builder/code-templates/InputObjectTypeTemplate.php
@@ -69,6 +69,16 @@ class <?php echo $class_name; ?> {
 						<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
 <?php endforeach; ?>
 					),
+<?php endif; ?>
+<?php if ( ! empty( $authorization ) ) : ?>
+					'authorization' => array(
+<?php foreach ( $authorization as $descriptor ) : ?>
+						array(
+							'attribute' => <?php echo var_export( $descriptor['attribute'], true ); ?>,
+							'args'      => <?php echo var_export( $descriptor['args'], true ); ?>,
+						),
+<?php endforeach; ?>
+					),
 <?php endif; ?>
 					'fields' => fn() => array(
 <?php foreach ( $fields as $field ) : ?>
diff --git a/plugins/woocommerce/bin/api-builder/code-templates/InterfaceTypeTemplate.php b/plugins/woocommerce/bin/api-builder/code-templates/InterfaceTypeTemplate.php
index c9feb70dd83..1c447962df9 100644
--- a/plugins/woocommerce/bin/api-builder/code-templates/InterfaceTypeTemplate.php
+++ b/plugins/woocommerce/bin/api-builder/code-templates/InterfaceTypeTemplate.php
@@ -70,6 +70,16 @@ class <?php echo $class_name; ?> {
 						<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
 <?php endforeach; ?>
 					),
+<?php endif; ?>
+<?php if ( ! empty( $authorization ) ) : ?>
+					'authorization' => array(
+<?php foreach ( $authorization as $descriptor ) : ?>
+						array(
+							'attribute' => <?php echo var_export( $descriptor['attribute'], true ); ?>,
+							'args'      => <?php echo var_export( $descriptor['args'], true ); ?>,
+						),
+<?php endforeach; ?>
+					),
 <?php endif; ?>
 					'fields' => fn() => array(
 <?php foreach ( $fields as $field ) : ?>
diff --git a/plugins/woocommerce/bin/api-builder/code-templates/ObjectTypeTemplate.php b/plugins/woocommerce/bin/api-builder/code-templates/ObjectTypeTemplate.php
index 2d405157b73..75fd86a85bb 100644
--- a/plugins/woocommerce/bin/api-builder/code-templates/ObjectTypeTemplate.php
+++ b/plugins/woocommerce/bin/api-builder/code-templates/ObjectTypeTemplate.php
@@ -8,8 +8,9 @@
  * @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, 'metadata' => array]
- * @var array  $metadata - type-level metadata, name => scalar value.
+ * @var array  $fields - each: ['name', 'type_expr', 'description', 'args' => [], 'deprecation_reason' => ?string, 'paginated_connection' => bool, 'metadata' => array, 'metadata_runtime' => array]
+ * @var array  $metadata - type-level metadata for discovery (`_apiMetadata`); blank when the type opts out via shows_in_metadata_query().
+ * @var array  $metadata_runtime - full type-level metadata, threaded into field gates' $_metadata['type'] slice regardless of discovery opt-out.
  */

 $escaped_description = addslashes( $description );
@@ -24,18 +25,24 @@ namespace <?php echo $namespace; ?>;

 <?php
 $has_paginated_connection = false;
+$has_authorized_field     = false;
 foreach ( $fields as $f ) {
 	if ( ! empty( $f['paginated_connection'] ) ) {
 		$has_paginated_connection = true;
-		break;
+	}
+	if ( ! empty( $f['authorization']['attribute_expr'] ) && 'true' !== $f['authorization']['attribute_expr'] ) {
+		$has_authorized_field = true;
 	}
 }
+$needs_utils_import = $has_paginated_connection || $has_authorized_field;
 // Drop any caller-supplied import whose effective short name would collide
 // with one of the hardcoded imports emitted below, otherwise the generated
 // file wouldn't compile ("Cannot use ... because the name is already in use").
 $reserved_short_names = array( 'ObjectType', 'Type' );
 if ( $has_paginated_connection ) {
 	$reserved_short_names[] = 'Connection';
+}
+if ( $needs_utils_import ) {
 	$reserved_short_names[] = 'ResolverHelpers';
 }
 // PHP class-name resolution (including `use`) is case-insensitive, so the
@@ -61,8 +68,10 @@ $use_statements             = array_values(
 <?php foreach ( $use_statements as $use ) : ?>
 use <?php echo $use; ?>;
 <?php endforeach; ?>
-<?php if ( $has_paginated_connection ) : ?>
+<?php if ( $needs_utils_import ) : ?>
 use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+<?php endif; ?>
+<?php if ( $has_paginated_connection ) : ?>
 use Automattic\WooCommerce\Api\Pagination\Connection;
 <?php endif; ?>
 use Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType;
@@ -86,6 +95,16 @@ class <?php echo $class_name; ?> {
 <?php endforeach; ?>
 					),
 <?php endif; ?>
+<?php if ( ! empty( $authorization ) ) : ?>
+					'authorization' => array(
+<?php foreach ( $authorization as $descriptor ) : ?>
+						array(
+							'attribute' => <?php echo var_export( $descriptor['attribute'], true ); ?>,
+							'args'      => <?php echo var_export( $descriptor['args'], true ); ?>,
+						),
+<?php endforeach; ?>
+					),
+<?php endif; ?>
 <?php if ( ! empty( $interfaces ) ) : ?>
 					'interfaces' => fn() => array(
 	<?php foreach ( $interfaces as $iface ) : ?>
@@ -106,6 +125,16 @@ class <?php echo $class_name; ?> {
 								<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
 <?php endforeach; ?>
 							),
+<?php endif; ?>
+		<?php if ( ! empty( $field['authorization']['descriptors'] ) ) : ?>
+							'authorization' => array(
+		<?php foreach ( $field['authorization']['descriptors'] as $descriptor ) : ?>
+								array(
+									'attribute' => <?php echo var_export( $descriptor['attribute'], true ); ?>,
+									'args'      => <?php echo var_export( $descriptor['args'], true ); ?>,
+								),
+<?php endforeach; ?>
+							),
 <?php endif; ?>
 	<?php if ( ! empty( $field['args'] ) ) : ?>
 							'args' => array(
@@ -132,8 +161,35 @@ class <?php echo $class_name; ?> {
 	<?php if ( ! empty( $field['deprecation_reason'] ) ) : ?>
 							'deprecationReason' => '<?php echo addslashes( $field['deprecation_reason'] ); ?>',
 <?php endif; ?>
-	<?php if ( ! empty( $field['paginated_connection'] ) ) : ?>
+	<?php
+	$has_field_auth      = ! empty( $field['authorization']['attribute_expr'] ) && 'true' !== $field['authorization']['attribute_expr'];
+	$is_paginated        = ! empty( $field['paginated_connection'] );
+	$field_metadata_expr = var_export( $field['metadata_runtime'], true );
+	$type_metadata_expr  = var_export( $metadata_runtime, true );
+	?>
+	<?php if ( $is_paginated ) : ?>
 							'complexity' => ResolverHelpers::complexity_from_pagination(...),
+<?php endif; ?>
+		<?php if ( $has_field_auth ) : ?>
+							'resolve'    => function( $parent, $args, $context ) {
+								$principal = $context['principal'];
+								$_metadata = array(
+									'query' => $context['_query_metadata'] ?? array(),
+									'type'  => <?php echo $type_metadata_expr; ?>,
+									'field' => <?php echo $field_metadata_expr; ?>,
+								);
+								$_args     = $args;
+								$_parent   = $parent;
+								if ( ! ( <?php echo $field['authorization']['attribute_expr']; ?> ) ) {
+									throw ResolverHelpers::build_field_authorization_error( $principal, '<?php echo $graphql_name; ?>', '<?php echo $field['name']; ?>', '<?php echo $field['authorization']['first_attribute_short']; ?>' );
+								}
+								<?php if ( $is_paginated ) : ?>
+								return ResolverHelpers::translate_exceptions( fn() => $parent-><?php echo $field['name']; ?>->slice( $args ) );
+<?php else : ?>
+								return $parent-><?php echo $field['name']; ?>;
+<?php endif; ?>
+							},
+		<?php elseif ( $is_paginated ) : ?>
 							'resolve'    => fn( $parent, array $args ): Connection => ResolverHelpers::translate_exceptions( fn() => $parent-><?php echo $field['name']; ?>->slice( $args ) ),
 <?php endif; ?>
 						),
diff --git a/plugins/woocommerce/bin/api-builder/code-templates/QueryResolverTemplate.php b/plugins/woocommerce/bin/api-builder/code-templates/QueryResolverTemplate.php
index d304f6635bd..b758ab65e44 100644
--- a/plugins/woocommerce/bin/api-builder/code-templates/QueryResolverTemplate.php
+++ b/plugins/woocommerce/bin/api-builder/code-templates/QueryResolverTemplate.php
@@ -11,7 +11,8 @@
  * @var string $return_type_expr
  * @var array  $use_statements
  * @var array  $args - each: ['name', 'type_expr', 'description', 'has_default', 'default', 'metadata' => array]
- * @var array  $metadata - root-field-level metadata, name => scalar value.
+ * @var array  $metadata - root-field-level metadata for discovery (`_apiMetadata`); blank when the operation opts out via shows_in_metadata_query().
+ * @var array  $metadata_runtime - full root-field-level metadata, published into $context['_query_metadata'] for downstream field gates regardless of discovery opt-out.
  * @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
@@ -99,6 +100,16 @@ class <?php echo $class_name; ?> {
 				<?php echo var_export( $meta_name, true ); ?> => <?php echo var_export( $meta_value, true ); ?>,
 <?php endforeach; ?>
 			),
+<?php endif; ?>
+<?php if ( ! empty( $authorization_descriptors ) ) : ?>
+			'authorization' => array(
+<?php foreach ( $authorization_descriptors as $descriptor ) : ?>
+				array(
+					'attribute' => <?php echo var_export( $descriptor['attribute'], true ); ?>,
+					'args'      => <?php echo var_export( $descriptor['args'], true ); ?>,
+				),
+<?php endforeach; ?>
+			),
 <?php endif; ?>
 			'args' => array(
 <?php foreach ( $args as $arg ) : ?>
@@ -136,6 +147,13 @@ class <?php echo $class_name; ?> {
 		}

 <?php endif; ?>
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = <?php echo var_export( $metadata_runtime, true ); ?>;
+
+
 <?php if ( null !== $class_resolver_fqcn ) : ?>
 		$command = \<?php echo $class_resolver_fqcn; ?>::resolve_class( <?php echo $command_alias; ?>::class );
 <?php else : ?>
@@ -175,6 +193,26 @@ foreach ( $execute_params as $param ) :
 <?php endif; ?>
 <?php endforeach; ?>

+<?php foreach ( $input_side_gates as $gate_set ) : ?>
+		if ( isset( $execute_args['<?php echo $gate_set['exec_arg_name']; ?>'] ) && $execute_args['<?php echo $gate_set['exec_arg_name']; ?>'] instanceof \<?php echo $gate_set['input_fqcn']; ?> ) {
+			$_parent = $execute_args['<?php echo $gate_set['exec_arg_name']; ?>'];
+	<?php foreach ( $gate_set['fields'] as $field_gate ) : ?>
+			if ( $_parent->was_provided( '<?php echo $field_gate['field_name']; ?>' ) ) {
+				$principal = $context['principal'];
+				$_metadata = array(
+					'query' => $context['_query_metadata'] ?? array(),
+					'type'  => <?php echo $field_gate['type_metadata_literal']; ?>,
+					'field' => <?php echo $field_gate['field_metadata_literal']; ?>,
+				);
+				$_args     = $args;
+				if ( ! ( <?php echo $field_gate['attribute_expr']; ?> ) ) {
+					throw ResolverHelpers::build_field_authorization_error( $principal, '<?php echo $gate_set['input_short_name']; ?>', '<?php echo $field_gate['field_name']; ?>', '<?php echo $field_gate['first_attribute_short']; ?>' );
+				}
+			}
+	<?php endforeach; ?>
+		}
+<?php endforeach; ?>
+
 <?php if ( $has_authorize ) : ?>
 		if ( ! ResolverHelpers::authorize_command( $command, array(
 <?php foreach ( $authorize_param_names as $name ) : ?>
diff --git a/plugins/woocommerce/changelog/add-graphql-granular-authorization b/plugins/woocommerce/changelog/add-graphql-granular-authorization
new file mode 100644
index 00000000000..5543e074d0b
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-graphql-granular-authorization
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add granular type- and field-level authorization to the dual code+GraphQL API
diff --git a/plugins/woocommerce/changelog/fix-graphql-arrayof-return-type b/plugins/woocommerce/changelog/fix-graphql-arrayof-return-type
new file mode 100644
index 00000000000..1b29ce19d7b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-graphql-arrayof-return-type
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Honor `#[ArrayOf]` on query and mutation return types in the GraphQL API builder
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Api/Attributes/HiddenFromMetadataQuery.php b/plugins/woocommerce/src/Api/Attributes/HiddenFromMetadataQuery.php
new file mode 100644
index 00000000000..d7491f6e608
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Attributes/HiddenFromMetadataQuery.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Attributes;
+
+use Attribute;
+
+/**
+ * Marker attribute that opts a code-API target out of the `_apiMetadata`
+ * discovery query without affecting any other behaviour. Apply on a
+ * class (output / input type, query, mutation) or on a property to hide
+ * that target's row — and all its metadata / authorization descriptors —
+ * from the `_apiMetadata` endpoint.
+ *
+ * This is unrelated to native GraphQL introspection (`__schema` /
+ * `__type`); those queries continue to expose the schema's shape as
+ * usual. The marker only affects the custom `_apiMetadata` channel.
+ *
+ * The runtime authorization gates emitted into the generated resolvers
+ * are unaffected: an authorization attribute placed alongside this one
+ * still runs its `authorize()` method; this marker just removes the
+ * declarative shape from the discovery channel.
+ *
+ * A target's `_apiMetadata` visibility is the AND of every attribute's
+ * `shows_in_metadata_query()` on the target — so combining
+ * `#[HiddenFromMetadataQuery]` with any other attribute that returns
+ * `true` (or none at all) still hides the target.
+ */
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::TARGET_CLASS_CONSTANT )]
+final class HiddenFromMetadataQuery {
+	/**
+	 * Always returns `false`. ApiBuilder calls this during the per-target
+	 * `_apiMetadata` visibility check; the target is omitted from the
+	 * discovery output as a result.
+	 */
+	public function shows_in_metadata_query(): bool {
+		return false;
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Attributes/Metadata.php b/plugins/woocommerce/src/Api/Attributes/Metadata.php
index 46595fe5e19..39a548137a1 100644
--- a/plugins/woocommerce/src/Api/Attributes/Metadata.php
+++ b/plugins/woocommerce/src/Api/Attributes/Metadata.php
@@ -50,6 +50,28 @@ class Metadata {
 		return $this->value;
 	}

+	/**
+	 * Whether the element carrying this attribute should appear in the
+	 * `_apiMetadata` discovery query.
+	 *
+	 * Returning `false` removes the element's row entirely from
+	 * `_apiMetadata` — neither this metadata entry nor any other
+	 * descriptor on the same target surfaces. The runtime gates and any
+	 * description transforms are unaffected. Useful for plugins that
+	 * attach internal routing or feature hints they prefer not to
+	 * broadcast through the discovery channel.
+	 *
+	 * Despite the colloquial naming around it, this has nothing to do
+	 * with native GraphQL introspection (`__schema` / `__type`); those
+	 * queries continue to expose the schema's shape as usual.
+	 *
+	 * Because this is an instance method, subclasses can decide
+	 * conditionally based on their own constructor arguments.
+	 */
+	public function shows_in_metadata_query(): bool {
+		return true;
+	}
+
 	/**
 	 * Transform the GraphQL `description` of the element this attribute is
 	 * applied to.
diff --git a/plugins/woocommerce/src/Api/Attributes/PublicAccess.php b/plugins/woocommerce/src/Api/Attributes/PublicAccess.php
index 1f10cd5dd5d..7c59267934f 100644
--- a/plugins/woocommerce/src/Api/Attributes/PublicAccess.php
+++ b/plugins/woocommerce/src/Api/Attributes/PublicAccess.php
@@ -10,9 +10,14 @@ use Attribute;
  * Marks a query or mutation as publicly accessible without authentication.
  *
  * Mutually exclusive with #[RequiredCapability] (and any other authorization
- * attribute) on the same class.
+ * attribute) on the same class — this is a hard build error.
+ *
+ * Placement on a property (output field or input field) is accepted but is
+ * a build warning and a runtime no-op: it always grants, which is
+ * indistinguishable from the default allow-by-default semantics for fields
+ * that carry no authorization attribute.
  */
-#[Attribute( Attribute::TARGET_CLASS )]
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY )]
 final class PublicAccess {
 	/**
 	 * Always grants access.
diff --git a/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php b/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php
index d229d5df9bf..5668bbbc8c8 100644
--- a/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php
+++ b/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php
@@ -8,14 +8,22 @@ use Attribute;
 use Automattic\WooCommerce\Api\Infrastructure\Principal;

 /**
- * Declares a WordPress capability required to execute a query or mutation.
+ * Declares a WordPress capability required to execute a query or mutation,
+ * or to read an output field, or to set an input field on a mutation.
  *
  * This attribute is repeatable: apply it multiple times to require several
  * capabilities (logical AND).
  *
- * Mutually exclusive with #[PublicAccess] on the same class.
+ * Mutually exclusive with #[PublicAccess] at the class level. At the field
+ * level a `#[PublicAccess]` placement on the same property is a build
+ * warning and is treated as a no-op.
+ *
+ * Targets: class (query/mutation/output type) and property (output field,
+ * input field, or trait-declared property). Trait-declared properties
+ * carry the attribute onto every implementing class through PHP's
+ * reflection.
  */
-#[Attribute( Attribute::IS_REPEATABLE | Attribute::TARGET_CLASS )]
+#[Attribute( Attribute::IS_REPEATABLE | Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY )]
 final class RequiredCapability {
 	/**
 	 * Constructor.
diff --git a/plugins/woocommerce/src/Api/Infrastructure/GraphQLControllerBase.php b/plugins/woocommerce/src/Api/Infrastructure/GraphQLControllerBase.php
index 080193abcef..a44a12331d9 100644
--- a/plugins/woocommerce/src/Api/Infrastructure/GraphQLControllerBase.php
+++ b/plugins/woocommerce/src/Api/Infrastructure/GraphQLControllerBase.php
@@ -424,17 +424,24 @@ abstract class GraphQLControllerBase {
 			$validation_rules[] = new DisableIntrospection( DisableIntrospection::ENABLED );
 		}

-		// 7. Execute. The context value carries the resolved principal through
-		// to autogenerated resolvers, which expose it as the `_principal`
-		// infrastructure parameter when commands declare it on their
-		// authorize()/execute() methods. Request-derived data that resolvers
-		// need is carried by the principal class itself — populated by the
-		// PrincipalResolver, the only component wired to the HTTP transport.
+		// 7. Execute. The context value is an ArrayObject (not a plain array)
+		// so root resolvers can mutate it — specifically to thread the root
+		// query's metadata into `$context['_query_metadata']` for downstream
+		// field-level authorization gates. ArrayObject preserves the
+		// `$context['key']` read syntax via ArrayAccess. The context carries
+		// the resolved principal through to autogenerated resolvers, which
+		// expose it as the `_principal` infrastructure parameter when commands
+		// declare it on their authorize()/execute() methods. Request-derived
+		// data that resolvers need is carried by the principal class itself —
+		// populated by the PrincipalResolver, the only component wired to the
+		// HTTP transport.
 		$result = GraphQL::executeQuery(
 			schema: $schema,
 			source: $source,
-			contextValue: array(
-				'principal' => $principal,
+			contextValue: new \ArrayObject(
+				array(
+					'principal' => $principal,
+				)
 			),
 			variableValues: $variables,
 			operationName: $operation_name,
diff --git a/plugins/woocommerce/src/Api/Infrastructure/MetadataController.php b/plugins/woocommerce/src/Api/Infrastructure/MetadataController.php
index 6a2d9d5d7e8..960ab3f5c2a 100644
--- a/plugins/woocommerce/src/Api/Infrastructure/MetadataController.php
+++ b/plugins/woocommerce/src/Api/Infrastructure/MetadataController.php
@@ -17,16 +17,21 @@ 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.
+ * Hand-written controller that contributes the `_apiMetadata` root query
+ * field and the supporting `MetadataEntry`, `MetadataTarget`, `MetadataValue`
+ * and `AuthEntry` 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 SchemaHandle::find_metadata()} for the schema walk and
  * filter application, then reshapes the rows so each entry is exposed as the
- * `{ name, value }` pair that `MetadataEntry` expects.
+ * `{ name, value }` pair that `MetadataEntry` expects. Authorization
+ * descriptors (each row's `authorization` list) pass through unchanged.
+ *
+ * Access is gated by {@see self::can_query_metadata()}; once allowed, the
+ * returned content is principal-independent — the full declared shape of the
+ * schema, irrespective of who is calling.
  */
 class MetadataController {
 	/**
@@ -50,6 +55,14 @@ class MetadataController {
 	 */
 	private static ?ObjectType $target_type = null;

+	/**
+	 * Memoised `AuthEntry` output type — describes one authorization
+	 * attribute attached to a schema target.
+	 *
+	 * @var ?ObjectType
+	 */
+	private static ?ObjectType $auth_entry_type = null;
+
 	/**
 	 * GraphQL field name used on the root `Query` type.
 	 */
@@ -70,18 +83,22 @@ class MetadataController {
 				'woocommerce'
 			),
 			'args'        => array(
-				'name'  => 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'      => array(
 					'type'        => Type::string(),
 					'description' => __( 'Match rows whose target type equals this name.', 'woocommerce' ),
 				),
-				'field' => array(
+				'field'     => array(
 					'type'        => Type::string(),
 					'description' => __( 'Match rows whose target field equals this name.', 'woocommerce' ),
 				),
+				'attribute' => array(
+					'type'        => Type::string(),
+					'description' => __( 'Match rows whose authorization carries an attribute with this class short name. Surviving rows have their authorization trimmed to the matching descriptors.', 'woocommerce' ),
+				),
 			),
 			'resolve'     => array( self::class, 'resolve' ),
 		);
@@ -89,17 +106,25 @@ class MetadataController {

 	/**
 	 * Resolver for the `_apiMetadata` root field. Signature matches the
-	 * engine's resolver contract; `$root` and `$context` are unused here
-	 * (root operations have no parent, and metadata is principal-independent).
+	 * engine's resolver contract; `$root` is unused here (root operations
+	 * have no parent). `$context` is read for the principal so the
+	 * `can_query_metadata` ladder can run.
 	 *
 	 * @param ?array      $root    The engine passes null for root resolvers.
-	 * @param array       $args    GraphQL arguments (`name`, `type`, `field`).
-	 * @param array       $context Per-request context the controller builds.
+	 * @param array       $args    GraphQL arguments (`name`, `type`, `field`, `attribute`).
+	 * @param mixed       $context Per-request context — an ArrayObject wrapping {`principal`, `_query_metadata`}.
 	 * @param ResolveInfo $info    Carries the schema instance to walk.
 	 * @return list<array<string, mixed>>
+	 * @throws Error When the principal is not allowed to query `_apiMetadata`.
 	 */
-	public static function resolve( ?array $root, array $args, array $context, ResolveInfo $info ): array {
-		unset( $root, $context );
+	public static function resolve( ?array $root, array $args, mixed $context, ResolveInfo $info ): array {
+		unset( $root );
+
+		$principal = is_object( $context ) || is_array( $context ) ? ( $context['principal'] ?? null ) : null;
+		if ( ! self::can_query_metadata( $principal ) ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Static error message + machine code; serialized as JSON, not HTML.
+			throw self::build_metadata_query_authorization_error( $principal );
+		}

 		// Wrap the resolver's engine-typed schema into the same handle clients
 		// receive from `GraphQLControllerBase::get_schema()`, so the resolver and
@@ -110,6 +135,7 @@ class MetadataController {
 			$args['name'] ?? null,
 			$args['type'] ?? null,
 			$args['field'] ?? null,
+			$args['attribute'] ?? null,
 		);

 		// SchemaHandle returns entries as an associative `name => value` map,
@@ -133,6 +159,91 @@ class MetadataController {
 		);
 	}

+	/**
+	 * Whether the principal may run the `_apiMetadata` query.
+	 *
+	 * Tri-tier ladder, deliberately fail-closed:
+	 *
+	 *  1. If the principal declares `can_query_metadata(): bool`, use it.
+	 *     Plugins distinguish metadata-query access from native
+	 *     introspection access by declaring this method.
+	 *  2. Else if the principal declares `can_introspect(): bool`, fall
+	 *     back to it — one switch then gates both metadata and
+	 *     introspection, which is the common case.
+	 *  3. Else (neither method declared) deny. Plugin authors that don't
+	 *     opt their principal in get a locked-down endpoint rather than
+	 *     leaking schema shape and gate descriptors by default.
+	 *
+	 * The principal-derived decision is then passed through the
+	 * {@see 'woocommerce_graphql_can_query_metadata'} filter so sites
+	 * can grant or revoke access without subclassing the principal —
+	 * useful for per-request rules (specific IPs, headers, query
+	 * parameters, etc.).
+	 *
+	 * Fail-closed contract: null principal denies before the filter is
+	 * consulted; either method's return is checked with `=== true`; any
+	 * throw from the principal method or the filter denies; the filter
+	 * must likewise return strictly `true` to allow.
+	 *
+	 * @param ?object $principal The resolved principal, or null when principal resolution failed.
+	 */
+	private static function can_query_metadata( ?object $principal ): bool {
+		if ( null === $principal ) {
+			return false;
+		}
+
+		try {
+			if ( method_exists( $principal, 'can_query_metadata' ) ) {
+				$allowed = true === $principal->can_query_metadata();
+			} elseif ( method_exists( $principal, 'can_introspect' ) ) {
+				$allowed = true === $principal->can_introspect();
+			} else {
+				$allowed = false;
+			}
+
+			/**
+			 * Filters whether the current principal may run the `_apiMetadata` query.
+			 *
+			 * The filter receives the principal-derived decision (see the tri-tier
+			 * ladder in {@see MetadataController::can_query_metadata()}) and must
+			 * return strictly `true` to grant access; any other return value
+			 * denies. The filter is not invoked when principal resolution failed
+			 * (i.e. when the resolver receives a null principal) — that case
+			 * denies outright.
+			 *
+			 * @since 10.9.0
+			 *
+			 * @internal
+			 *
+			 * @param bool   $allowed   Whether the principal may query `_apiMetadata`.
+			 * @param object $principal The resolved principal.
+			 */
+			$allowed = apply_filters( 'woocommerce_graphql_can_query_metadata', $allowed, $principal );
+		} catch ( \Throwable $e ) {
+			return false;
+		}
+
+		return true === $allowed;
+	}
+
+	/**
+	 * Build the GraphQL error thrown when `_apiMetadata` is queried by a
+	 * principal that cannot. Mirrors
+	 * {@see ResolverHelpers::build_authorization_error()}'s
+	 * UNAUTHORIZED / FORBIDDEN distinction so clients can branch on
+	 * `extensions.code` the same way they do for field-level denies.
+	 *
+	 * @param ?object $principal The resolved principal (null when principal resolution failed).
+	 */
+	private static function build_metadata_query_authorization_error( ?object $principal ): Error {
+		$is_anonymous = null === $principal
+			|| ( method_exists( $principal, 'is_authenticated' ) && ! $principal->is_authenticated() );
+		return new Error(
+			$is_anonymous ? 'Authentication required.' : 'You do not have permission to perform this action.',
+			extensions: array( 'code' => $is_anonymous ? 'UNAUTHORIZED' : 'FORBIDDEN' )
+		);
+	}
+
 	/**
 	 * The `MetadataTarget` output type, lazily built and cached.
 	 */
@@ -146,26 +257,30 @@ class MetadataController {
 						'woocommerce'
 					),
 					'fields'      => fn() => array(
-						'type'      => array(
+						'type'          => array(
 							'type'        => Type::nonNull( Type::string() ),
 							'description' => __( 'Name of the GraphQL type this row describes.', 'woocommerce' ),
 						),
-						'field'     => array(
+						'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(
+						'argument'      => array(
 							'type'        => Type::string(),
 							'description' => __( 'Argument name when this row describes a field argument; null otherwise.', 'woocommerce' ),
 						),
-						'enumValue' => array(
+						'enumValue'     => array(
 							'type'        => Type::string(),
 							'description' => __( 'Enum value name when this row describes one specific enum value; null otherwise.', 'woocommerce' ),
 						),
-						'entries'   => array(
+						'entries'       => array(
 							'type'        => Type::nonNull( Type::listOf( Type::nonNull( self::get_entry_type() ) ) ),
 							'description' => __( 'Metadata entries attached to the target.', 'woocommerce' ),
 						),
+						'authorization' => array(
+							'type'        => Type::nonNull( Type::listOf( Type::nonNull( self::get_auth_entry_type() ) ) ),
+							'description' => __( 'Authorization attributes attached to the target (e.g. `RequiredCapability`, `PublicAccess`, or plugin-defined). Empty when the target carries no authorization attributes.', 'woocommerce' ),
+						),
 					),
 				)
 			);
@@ -173,6 +288,33 @@ class MetadataController {
 		return self::$target_type;
 	}

+	/**
+	 * The `AuthEntry` output type — one authorization attribute attached
+	 * to a target. Carries the attribute's short class name and the
+	 * scalar args supplied at the usage site.
+	 */
+	private static function get_auth_entry_type(): ObjectType {
+		if ( null === self::$auth_entry_type ) {
+			self::$auth_entry_type = new ObjectType(
+				array(
+					'name'        => 'AuthEntry',
+					'description' => __( 'One authorization attribute attached to a schema target.', 'woocommerce' ),
+					'fields'      => fn() => array(
+						'attribute' => array(
+							'type'        => Type::nonNull( Type::string() ),
+							'description' => __( 'Short class name of the authorization attribute (e.g. `RequiredCapability`).', 'woocommerce' ),
+						),
+						'args'      => array(
+							'type'        => Type::nonNull( Type::listOf( self::get_value_scalar() ) ),
+							'description' => __( 'Constructor arguments supplied at the usage site, in source order. Element type is the same scalar union as `MetadataValue`.', 'woocommerce' ),
+						),
+					),
+				)
+			);
+		}
+		return self::$auth_entry_type;
+	}
+
 	/**
 	 * The `MetadataEntry` output type, lazily built and cached.
 	 */
diff --git a/plugins/woocommerce/src/Api/Infrastructure/ResolverHelpers.php b/plugins/woocommerce/src/Api/Infrastructure/ResolverHelpers.php
index c6dab50f503..8516cae5c3c 100644
--- a/plugins/woocommerce/src/Api/Infrastructure/ResolverHelpers.php
+++ b/plugins/woocommerce/src/Api/Infrastructure/ResolverHelpers.php
@@ -139,6 +139,11 @@ class ResolverHelpers {
 	 * that don't define it fall through to FORBIDDEN — generated resolvers
 	 * still emit a coded error, just without the 401/403 distinction.
 	 *
+	 * Used for class-level denials (operation-level "you cannot call this
+	 * query/mutation"). For field-level denials that should carry a
+	 * structured `subject` payload (type / field / attribute), see
+	 * {@see self::build_field_authorization_error()}.
+	 *
 	 * @param object $principal The resolved request principal.
 	 */
 	public static function build_authorization_error( object $principal ): Error {
@@ -149,6 +154,40 @@ class ResolverHelpers {
 		);
 	}

+	/**
+	 * Like {@see self::build_authorization_error()} but carries a structured
+	 * `subject` payload identifying *what* was denied — the enclosing type,
+	 * the field (when applicable), and the attribute class name driving the
+	 * decision. Clients can branch on `extensions.subject.field` to tell a
+	 * field-level deny apart from an operation-level one.
+	 *
+	 * The error code (UNAUTHORIZED / FORBIDDEN) is preserved verbatim so
+	 * existing client handlers continue to work; the subject payload is
+	 * additive.
+	 *
+	 * @param object  $principal       The resolved request principal.
+	 * @param string  $type            GraphQL type name carrying the gate.
+	 * @param ?string $field           Field name when the deny is field-level; null for type/operation-level denies.
+	 * @param string  $attribute_short Short class name of the deciding authorization attribute (no namespace).
+	 */
+	public static function build_field_authorization_error( object $principal, string $type, ?string $field, string $attribute_short ): Error {
+		$is_anonymous = method_exists( $principal, 'is_authenticated' ) && ! $principal->is_authenticated();
+		$subject      = array(
+			'type'      => $type,
+			'attribute' => $attribute_short,
+		);
+		if ( null !== $field ) {
+			$subject['field'] = $field;
+		}
+		return new Error(
+			$is_anonymous ? 'Authentication required.' : 'You do not have permission to perform this action.',
+			extensions: array(
+				'code'    => $is_anonymous ? 'UNAUTHORIZED' : 'FORBIDDEN',
+				'subject' => $subject,
+			)
+		);
+	}
+
 	/**
 	 * Compute the value `_preauthorized` would carry for the given command and
 	 * principal (the AND of the autodiscovered authorization attributes'
@@ -168,6 +207,12 @@ class ResolverHelpers {
 	 * `_preauthorized` infrastructure parameter; this helper returns the value
 	 * that `_preauthorized` would carry, not the final `authorize()` outcome.
 	 *
+	 * Scope is class-level (queries / mutations). Field-level authorization
+	 * lives on output-type / input-type properties and is enforced inside
+	 * the generated resolvers. To inspect a field's declared authorization
+	 * from code, walk {@see \Automattic\WooCommerce\Api\Utils\SchemaHandle::find_metadata()}
+	 * and read the `authorization` slice on each row.
+	 *
 	 * @param string $command_fqcn Fully-qualified command class name.
 	 * @param object $principal    The resolved principal. Anonymous requests are represented by a sentinel principal (e.g. {@see \Automattic\WooCommerce\Api\Infrastructure\Principal} whose underlying WP_User has ID=0), not by null.
 	 *
@@ -210,11 +255,18 @@ class ResolverHelpers {
 			}
 		}

+		$query_metadata = self::harvest_class_metadata( $ref );
+
 		foreach ( $usages as $instance ) {
 			$auth_method = new \ReflectionMethod( $instance, 'authorize' );
-			$result      = $auth_method->getNumberOfParameters() > 0
-				? $instance->authorize( $principal )
-				: $instance->authorize();
+			$call_args   = self::build_authorize_call_args(
+				$auth_method,
+				$principal,
+				array( 'query' => $query_metadata ),
+				array(),
+				null
+			);
+			$result      = $instance->authorize( ...$call_args );
 			if ( ! $result ) {
 				return false;
 			}
@@ -222,6 +274,67 @@ class ResolverHelpers {
 		return true;
 	}

+	/**
+	 * Mirror of `ApiBuilder::harvest_metadata()` for the runtime path. Walks
+	 * {@see \Automattic\WooCommerce\Api\Attributes\Metadata}-subclass attributes
+	 * on a class reflector and returns `name => value`. Duplicate names are
+	 * resolved last-wins — the build-time validator already errors on
+	 * duplicates, so this is only relevant for in-process classes that
+	 * never went through a build.
+	 *
+	 * The per-target `_apiMetadata` opt-out (`shows_in_metadata_query()`)
+	 * is not applied here: the `$_metadata` slot threaded into a class-
+	 * level attribute's `authorize()` is for policy input, not discovery,
+	 * so attribute authors see every entry regardless of how it surfaces
+	 * through `_apiMetadata`.
+	 *
+	 * @param \ReflectionClass $ref The class to read metadata from.
+	 * @return array<string, bool|int|float|string|null>
+	 */
+	private static function harvest_class_metadata( \ReflectionClass $ref ): array {
+		$entries = array();
+		foreach ( $ref->getAttributes( \Automattic\WooCommerce\Api\Attributes\Metadata::class, \ReflectionAttribute::IS_INSTANCEOF ) as $attribute ) {
+			$instance                         = $attribute->newInstance();
+			$entries[ $instance->get_name() ] = $instance->get_value();
+		}
+		return $entries;
+	}
+
+	/**
+	 * Build the positional/named argument list for an attribute's `authorize()`
+	 * method based on which opt-in slots its signature declares.
+	 *
+	 * The principal is always passed first (positionally) when the method
+	 * declares a non-`_`-prefixed parameter; infrastructure parameters
+	 * (`$_metadata`, `$_args`, `$_parent`) are passed as named arguments so
+	 * the attribute can omit any subset without affecting the call shape.
+	 *
+	 * @param \ReflectionMethod $method    The attribute's `authorize()` method.
+	 * @param object            $principal The resolved principal to pass when the method takes one.
+	 * @param array             $metadata  Value for `$_metadata` (passed if the method declares it).
+	 * @param array             $args      Value for `$_args` (passed if the method declares it).
+	 * @param mixed             $parent    Value for `$_parent` (passed if the method declares it).
+	 *
+	 * @return array<int|string, mixed> Positional principal first (if any), then named infra slots. Use with `...` spread.
+	 */
+	private static function build_authorize_call_args( \ReflectionMethod $method, object $principal, array $metadata, array $args, mixed $parent ): array {
+		$call_args = array();
+		foreach ( $method->getParameters() as $param ) {
+			$name = $param->getName();
+			if ( '_metadata' === $name ) {
+				$call_args['_metadata'] = $metadata;
+			} elseif ( '_args' === $name ) {
+				$call_args['_args'] = $args;
+			} elseif ( '_parent' === $name ) {
+				$call_args['_parent'] = $parent;
+			} elseif ( '' === $name || '_' !== $name[0] ) {
+				// Principal — positional, must be the first entry in the spread.
+				array_unshift( $call_args, $principal );
+			}
+		}
+		return $call_args;
+	}
+
 	/**
 	 * Collect attribute instances declared on $source whose class declares an
 	 * authorization-shaped `authorize()` method.
@@ -252,9 +365,10 @@ class ResolverHelpers {

 	/**
 	 * Whether a method's shape matches the authorization-attribute contract:
-	 * public, non-static, returns bool, and takes either 0 parameters or
-	 * exactly 1 typed, non-nullable parameter (the principal — anonymous
-	 * requests use a sentinel non-null principal, so attributes never see null).
+	 * public, non-static, returns bool, and parameters drawn from the accepted
+	 * set — at most one principal (any non-`_`-prefixed name, non-nullable
+	 * typed) plus any subset of `$_metadata` (array), `$_args` (array), and
+	 * `$_parent` (any type).
 	 *
 	 * Mirrors the build-time `ApiBuilder::validate_attribute_authorize_shape()`
 	 * check so the runtime helper recognises the same set of attributes ApiBuilder
@@ -270,15 +384,34 @@ class ResolverHelpers {
 		if ( ! $return_type instanceof \ReflectionNamedType || 'bool' !== $return_type->getName() ) {
 			return false;
 		}
-		$params = $method->getParameters();
-		if ( count( $params ) > 1 ) {
-			return false;
-		}
-		if ( 0 === count( $params ) ) {
-			return true;
+
+		$principal_seen = false;
+		foreach ( $method->getParameters() as $param ) {
+			$name = $param->getName();
+			if ( '_metadata' === $name || '_args' === $name ) {
+				$type = $param->getType();
+				if ( ! $type instanceof \ReflectionNamedType || 'array' !== $type->getName() ) {
+					return false;
+				}
+				continue;
+			}
+			if ( '_parent' === $name ) {
+				continue;
+			}
+			if ( '' !== $name && '_' === $name[0] ) {
+				// Unknown infra parameter — reject.
+				return false;
+			}
+			if ( $principal_seen ) {
+				return false;
+			}
+			$type = $param->getType();
+			if ( ! $type instanceof \ReflectionNamedType || $type->allowsNull() ) {
+				return false;
+			}
+			$principal_seen = true;
 		}
-		$param_type = $params[0]->getType();
-		return $param_type instanceof \ReflectionNamedType && ! $param_type->allowsNull();
+		return true;
 	}

 	/**
diff --git a/plugins/woocommerce/src/Api/Utils/SchemaHandle.php b/plugins/woocommerce/src/Api/Utils/SchemaHandle.php
index 0df691c4b2e..9bbceb76629 100644
--- a/plugins/woocommerce/src/Api/Utils/SchemaHandle.php
+++ b/plugins/woocommerce/src/Api/Utils/SchemaHandle.php
@@ -28,16 +28,20 @@ use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\Type;
  * WooCommerce plugins implementing their own dual API reach a handle through
  * their own concrete autogenerated 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.
+ * The current public surface is the discovery channel: {@see self::get_all_metadata()}
+ * returns every row in the schema that carries either `#[Metadata]`-derived
+ * entries or authorization attributes, and {@see self::find_metadata()} applies
+ * filter-narrows semantics (`name`, `type`, `field`, `attribute`) over the same
+ * set. Authorization descriptors are exposed as a parallel `authorization`
+ * slice on each row, alongside the existing `entries`.
  *
  * @phpstan-type MetadataRow array{
  *     type: string,
  *     field: ?string,
  *     argument: ?string,
  *     enumValue: ?string,
- *     entries: array<string, bool|int|float|string|null>
+ *     entries: array<string, bool|int|float|string|null>,
+ *     authorization: list<array{attribute: string, args: list<mixed>}>
  * }
  */
 final class SchemaHandle {
@@ -70,7 +74,7 @@ final class SchemaHandle {
 	 * 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>}>
+	 * @return list<array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>, authorization: list<array{attribute: string, args: list<mixed>}>}>
 	 */
 	public function get_all_metadata(): array {
 		$rows   = array();
@@ -81,22 +85,24 @@ final class SchemaHandle {
 				continue;
 			}

-			$type_metadata = self::read_type_metadata( $type );
-			if ( ! empty( $type_metadata ) ) {
-				$rows[] = self::make_row( $type_name, null, null, null, $type_metadata );
+			$type_metadata      = self::read_type_metadata( $type );
+			$type_authorization = self::read_type_authorization( $type );
+			if ( ! empty( $type_metadata ) || ! empty( $type_authorization ) ) {
+				$rows[] = self::make_row( $type_name, null, null, null, $type_metadata, $type_authorization );
 			}

 			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 );
+					$field_metadata      = self::read_element_metadata( $field );
+					$field_authorization = self::read_element_authorization( $field );
+					if ( ! empty( $field_metadata ) || ! empty( $field_authorization ) ) {
+						$rows[] = self::make_row( $type_name, $field_name, null, null, $field_metadata, $field_authorization );
 					}

 					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 );
+							$rows[] = self::make_row( $type_name, $field_name, $arg->name, null, $arg_metadata, array() );
 						}
 					}
 				}
@@ -105,9 +111,10 @@ final class SchemaHandle {

 			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 );
+					$field_metadata      = self::read_element_metadata( $field );
+					$field_authorization = self::read_element_authorization( $field );
+					if ( ! empty( $field_metadata ) || ! empty( $field_authorization ) ) {
+						$rows[] = self::make_row( $type_name, $field_name, null, null, $field_metadata, $field_authorization );
 					}
 				}
 				continue;
@@ -117,7 +124,7 @@ final class SchemaHandle {
 				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 );
+						$rows[] = self::make_row( $type_name, null, null, $value->name, $value_metadata, array() );
 					}
 				}
 			}
@@ -135,13 +142,14 @@ final class SchemaHandle {
 	 * 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.
+	 * @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.
+	 * @param ?string $attribute Optional authorization-attribute short name to match. When set, only rows carrying this attribute survive and their `authorization` is trimmed to the matching descriptors.
 	 *
-	 * @return list<array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>}>
+	 * @return list<array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>, authorization: list<array{attribute: string, args: list<mixed>}>}>
 	 */
-	public function find_metadata( ?string $name = null, ?string $type = null, ?string $field = null ): array {
+	public function find_metadata( ?string $name = null, ?string $type = null, ?string $field = null, ?string $attribute = null ): array {
 		$rows = $this->get_all_metadata();

 		$result = array();
@@ -158,6 +166,18 @@ final class SchemaHandle {
 				}
 				$row['entries'] = array( $name => $row['entries'][ $name ] );
 			}
+			if ( null !== $attribute ) {
+				$matching = array_values(
+					array_filter(
+						$row['authorization'],
+						static fn( array $descriptor ): bool => ( $descriptor['attribute'] ?? null ) === $attribute,
+					)
+				);
+				if ( empty( $matching ) ) {
+					continue;
+				}
+				$row['authorization'] = $matching;
+			}
 			$result[] = $row;
 		}

@@ -201,23 +221,61 @@ final class SchemaHandle {
 		return is_array( $metadata ) ? $metadata : array();
 	}

+	/**
+	 * Read authorization descriptors attached to a wrapped engine type.
+	 *
+	 * The wrapper subclasses preserve the original config in `$type->config`;
+	 * authorization descriptors emitted by ApiBuilder live under the
+	 * `authorization` key as a list of `{attribute, args}` records.
+	 *
+	 * @param Type $type The GraphQL type to inspect.
+	 * @return list<array{attribute: string, args: list<mixed>}>
+	 */
+	private static function read_type_authorization( Type $type ): array {
+		if ( ! property_exists( $type, 'config' ) ) {
+			return array();
+		}
+		$authorization = $type->config['authorization'] ?? array();
+		return is_array( $authorization ) ? $authorization : array();
+	}
+
+	/**
+	 * Read authorization descriptors from a field-/arg-/enum-value-level config.
+	 *
+	 * Mirrors {@see self::read_element_metadata()} but pulls the
+	 * `authorization` key. Returns an empty list when the element carries
+	 * no authorization descriptors.
+	 *
+	 * @param object $element FieldDefinition | Argument | InputObjectField | EnumValueDefinition.
+	 * @return list<array{attribute: string, args: list<mixed>}>
+	 */
+	private static function read_element_authorization( object $element ): array {
+		if ( ! property_exists( $element, 'config' ) ) {
+			return array();
+		}
+		$authorization = $element->config['authorization'] ?? array();
+		return is_array( $authorization ) ? $authorization : 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>}
+	 * @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.
+	 * @param list<array{attribute: string, args: list<mixed>}> $authorization Authorization descriptors attached to the row, or an empty list.
+	 * @return array{type: string, field: ?string, argument: ?string, enumValue: ?string, entries: array<string, bool|int|float|string|null>, authorization: list<array{attribute: string, args: list<mixed>}>}
 	 */
-	private static function make_row( string $type, ?string $field, ?string $argument, ?string $enum_value, array $entries ): array {
+	private static function make_row( string $type, ?string $field, ?string $argument, ?string $enum_value, array $entries, array $authorization ): array {
 		return array(
-			'type'      => $type,
-			'field'     => $field,
-			'argument'  => $argument,
-			'enumValue' => $enum_value,
-			'entries'   => $entries,
+			'type'          => $type,
+			'field'         => $field,
+			'argument'      => $argument,
+			'enumValue'     => $enum_value,
+			'entries'       => $entries,
+			'authorization' => $authorization,
 		);
 	}

diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php
index bb53630757b..b9f424535ef 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php
@@ -16,15 +16,23 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class CreateCoupon {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull( CouponType::get() ),
-			'description' => __( 'Create a new coupon.', 'woocommerce' ),
-			'args'        => array(
+			'type'          => Type::nonNull( CouponType::get() ),
+			'description'   => __( 'Create a new coupon.', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'manage_woocommerce',
+					),
+				),
+			),
+			'args'          => array(
 				'input' => array(
 					'type'        => Type::nonNull( CreateCouponInput::get() ),
 					'description' => __( 'Data for the new coupon.', 'woocommerce' ),
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -35,6 +43,12 @@ class CreateCoupon {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( CreateCouponCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php
index 684b7ff071f..386100e6513 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php
@@ -16,15 +16,23 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class CreateProduct {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull( ProductInterface::get() ),
-			'description' => __( 'Create a new product.', 'woocommerce' ),
-			'args'        => array(
+			'type'          => Type::nonNull( ProductInterface::get() ),
+			'description'   => __( 'Create a new product.', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'edit_products',
+					),
+				),
+			),
+			'args'          => array(
 				'input' => array(
 					'type'        => Type::nonNull( CreateProductInput::get() ),
 					'description' => __( 'Data for the new product.', 'woocommerce' ),
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -35,6 +43,12 @@ class CreateProduct {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( CreateProductCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php
index 1c3b1803427..95c9ca7f98f 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php
@@ -15,9 +15,17 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class DeleteCoupon {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull( DeleteCouponResultType::get() ),
-			'description' => __( 'Delete a coupon.', 'woocommerce' ),
-			'args'        => array(
+			'type'          => Type::nonNull( DeleteCouponResultType::get() ),
+			'description'   => __( 'Delete a coupon.', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'manage_woocommerce',
+					),
+				),
+			),
+			'args'          => array(
 				'id'    => array(
 					'type'        => Type::nonNull( Type::int() ),
 					'description' => __( 'The ID of the coupon to delete.', 'woocommerce' ),
@@ -28,7 +36,7 @@ class DeleteCoupon {
 					'defaultValue' => false,
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -39,6 +47,12 @@ class DeleteCoupon {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( DeleteCouponCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php
index c09384b0b80..df9fa4d9b66 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class DeleteProduct {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull(
+			'type'          => Type::nonNull(
 				new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
 					array(
 						'name'   => 'DeleteProductResult',
@@ -24,8 +24,16 @@ class DeleteProduct {
 					)
 				)
 			),
-			'description' => __( 'Delete a product.', 'woocommerce' ),
-			'args'        => array(
+			'description'   => __( 'Delete a product.', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'manage_woocommerce',
+					),
+				),
+			),
+			'args'          => array(
 				'id'    => array(
 					'type'        => Type::nonNull( Type::int() ),
 					'description' => __( 'The ID of the product to delete.', 'woocommerce' ),
@@ -36,7 +44,7 @@ class DeleteProduct {
 					'defaultValue' => false,
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -47,6 +55,12 @@ class DeleteProduct {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( DeleteProductCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php
index 9fcce5708de..c7062f39e72 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php
@@ -16,15 +16,23 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class UpdateCoupon {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull( CouponType::get() ),
-			'description' => __( 'Update an existing coupon.', 'woocommerce' ),
-			'args'        => array(
+			'type'          => Type::nonNull( CouponType::get() ),
+			'description'   => __( 'Update an existing coupon.', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'manage_woocommerce',
+					),
+				),
+			),
+			'args'          => array(
 				'input' => array(
 					'type'        => Type::nonNull( UpdateCouponInput::get() ),
 					'description' => __( 'The fields to update.', 'woocommerce' ),
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -35,6 +43,12 @@ class UpdateCoupon {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( UpdateCouponCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php
index c529ee6a3e9..2c89deac4d2 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php
@@ -16,15 +16,23 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class UpdateProduct {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull( ProductInterface::get() ),
-			'description' => __( 'Update an existing product.', 'woocommerce' ),
-			'args'        => array(
+			'type'          => Type::nonNull( ProductInterface::get() ),
+			'description'   => __( 'Update an existing product.', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'manage_woocommerce',
+					),
+				),
+			),
+			'args'          => array(
 				'input' => array(
 					'type'        => Type::nonNull( UpdateProductInput::get() ),
 					'description' => __( 'The fields to update.', 'woocommerce' ),
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -35,6 +43,12 @@ class UpdateProduct {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( UpdateProductCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php
index 9bfbe5fc423..039ee59be8d 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php
@@ -15,9 +15,17 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class GetCoupon {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => CouponType::get(),
-			'description' => __( 'Retrieve a single coupon by ID or code. Exactly one of the two arguments must be provided.', 'woocommerce' ),
-			'args'        => array(
+			'type'          => CouponType::get(),
+			'description'   => __( 'Retrieve a single coupon by ID or code. Exactly one of the two arguments must be provided.', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'read_private_shop_coupons',
+					),
+				),
+			),
+			'args'          => array(
 				'id'   => array(
 					'type'         => Type::int(),
 					'description'  => __( 'The ID of the coupon to retrieve.', 'woocommerce' ),
@@ -29,7 +37,7 @@ class GetCoupon {
 					'defaultValue' => null,
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -40,6 +48,12 @@ class GetCoupon {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( GetCouponCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php
index fbb32249ada..9abc5643642 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php
@@ -15,19 +15,33 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class GetProduct {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => ProductInterface::get(),
-			'description' => __( 'Retrieve a single product by ID.', 'woocommerce' ),
-			'args'        => array(
+			'type'          => ProductInterface::get(),
+			'description'   => __( 'Retrieve a single product by ID.', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'read_product',
+					),
+				),
+			),
+			'args'          => array(
 				'id' => array(
 					'type'        => Type::nonNull( Type::int() ),
 					'description' => __( 'The ID of the product to retrieve.', 'woocommerce' ),
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( GetProductCommand::class );

 		$query_info   = QueryInfoExtractor::extract_from_info( $info, $args );
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php
index c80009d84c4..89b297a4d53 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php
@@ -16,9 +16,17 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class ListCoupons {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull( CouponConnectionType::get() ),
-			'description' => __( 'List coupons with cursor-based pagination.', 'woocommerce' ),
-			'args'        => array(
+			'type'          => Type::nonNull( CouponConnectionType::get() ),
+			'description'   => __( 'List coupons with cursor-based pagination.', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'read_private_shop_coupons',
+					),
+				),
+			),
+			'args'          => array(
 				'first'  => array(
 					'type'         => Type::int(),
 					'description'  => __( 'Return the first N results. Must be between 0 and 100.', 'woocommerce' ),
@@ -45,8 +53,8 @@ class ListCoupons {
 					'defaultValue' => null,
 				),
 			),
-			'complexity'  => ResolverHelpers::complexity_from_pagination( ... ),
-			'resolve'     => array( self::class, 'resolve' ),
+			'complexity'    => ResolverHelpers::complexity_from_pagination( ... ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -57,6 +65,12 @@ class ListCoupons {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( ListCouponsCommand::class );

 		$execute_args               = array();
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php
index 963bfabb1b5..b0c92452ff5 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php
@@ -18,9 +18,23 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class ListProducts {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull( ProductConnectionType::get() ),
-			'description' => __( 'List products with cursor-based pagination and optional filtering.', 'woocommerce' ),
-			'args'        => array(
+			'type'          => Type::nonNull( ProductConnectionType::get() ),
+			'description'   => __( 'List products with cursor-based pagination and optional filtering.', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'manage_woocommerce',
+					),
+				),
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'edit_products',
+					),
+				),
+			),
+			'args'          => array(
 				'first'        => array(
 					'type'         => Type::int(),
 					'description'  => __( 'Return the first N results. Must be between 0 and 100.', 'woocommerce' ),
@@ -62,8 +76,8 @@ class ListProducts {
 					'defaultValue' => null,
 				),
 			),
-			'complexity'  => ResolverHelpers::complexity_from_pagination( ... ),
-			'resolve'     => array( self::class, 'resolve' ),
+			'complexity'    => ResolverHelpers::complexity_from_pagination( ... ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -74,6 +88,12 @@ class ListProducts {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( ListProductsCommand::class );

 		$query_info                 = QueryInfoExtractor::extract_from_info( $info, $args );
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 70fe96ee5d7..a3da661a0ea 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-19T11:37:09+00:00
\ No newline at end of file
+2026-05-21T10:29:44+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
index 5ee939470e0..59f41b2e8ec 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
@@ -1 +1 @@
-664540c79e947e5fce1e6c925e7f028e7935641fa59ced226b9dba97141d50a6
\ No newline at end of file
+2965921ea12d55aff3aa621ff023f57cf0dde87517b5c2fabf7ad47752f8a128
\ No newline at end of file
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/AuthorizationAttributeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/AuthorizationAttributeTest.php
new file mode 100644
index 00000000000..e16c493bc94
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/AuthorizationAttributeTest.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for the contract surface of the authorization attributes
+ * shipped with the dual API ({@see PublicAccess} and
+ * {@see RequiredCapability}). Pins the {@code #[Attribute]} flag set so
+ * future widenings/narrowings are caught explicitly.
+ */
+class AuthorizationAttributeTest extends WC_Unit_Test_Case {
+	/**
+	 * @return array<string, array{class-string, int}>
+	 */
+	public function provider_attribute_targets(): array {
+		return array(
+			'PublicAccess accepts TARGET_CLASS'          => array( PublicAccess::class, \Attribute::TARGET_CLASS ),
+			'PublicAccess accepts TARGET_PROPERTY'       => array( PublicAccess::class, \Attribute::TARGET_PROPERTY ),
+			'RequiredCapability accepts TARGET_CLASS'    => array( RequiredCapability::class, \Attribute::TARGET_CLASS ),
+			'RequiredCapability accepts TARGET_PROPERTY' => array( RequiredCapability::class, \Attribute::TARGET_PROPERTY ),
+		);
+	}
+
+	/**
+	 * @testdox Authorization attribute accepts the target listed in the provider.
+	 *
+	 * @dataProvider provider_attribute_targets
+	 * @param class-string $attribute_class The attribute class under test.
+	 * @param int          $target_flag     A single {@see \Attribute} TARGET_* flag the class must accept.
+	 */
+	public function test_attribute_accepts_target( string $attribute_class, int $target_flag ): void {
+		$reflection = new \ReflectionClass( $attribute_class );
+		$attributes = $reflection->getAttributes( \Attribute::class );
+
+		$this->assertNotEmpty( $attributes, $attribute_class . ' should be decorated with #[Attribute].' );
+
+		$attribute = $attributes[0]->newInstance();
+		$this->assertNotSame(
+			0,
+			$attribute->flags & $target_flag,
+			$attribute_class . ' should accept the requested TARGET_* flag.'
+		);
+	}
+
+	/**
+	 * @testdox RequiredCapability remains repeatable after the property-target widening.
+	 */
+	public function test_required_capability_is_still_repeatable(): void {
+		$reflection = new \ReflectionClass( RequiredCapability::class );
+		$attributes = $reflection->getAttributes( \Attribute::class );
+
+		$attribute = $attributes[0]->newInstance();
+		$this->assertNotSame( 0, $attribute->flags & \Attribute::IS_REPEATABLE );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AuthorizationQueryDiscoveryTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AuthorizationQueryDiscoveryTest.php
new file mode 100644
index 00000000000..21587b96ab0
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AuthorizationQueryDiscoveryTest.php
@@ -0,0 +1,111 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Api\Utils\SchemaHandle;
+
+/**
+ * End-to-end coverage for the `_apiMetadata.authorization` surface:
+ *
+ *  - `Widget::$caption` carries `#[RequiredCapability('manage_woocommerce')]`
+ *    and no opt-out attribute, so its row exposes the descriptor.
+ *  - `Widget::$legacy_price` carries `#[HiddenFromMetadataQuery]`, which
+ *    hides the whole target — neither its metadata nor any
+ *    authorization descriptor reaches `_apiMetadata`.
+ *
+ * The discovery hide is independent of the runtime authorization gate:
+ * a target hidden from `_apiMetadata` can still be gated at resolution
+ * time (see {@see FieldAuthorizeTest} for the runtime coverage).
+ */
+class AuthorizationQueryDiscoveryTest extends AutogeneratedTestCase {
+	/**
+	 * @testdox find_metadata exposes authorization descriptors on a visible gated field.
+	 */
+	public function test_find_metadata_returns_authorization_descriptors(): void {
+		$handle = new SchemaHandle( $this->build_schema() );
+		$rows   = $handle->find_metadata( type: 'Widget', field: 'caption' );
+
+		$this->assertCount( 1, $rows );
+		$this->assertArrayHasKey( 'authorization', $rows[0] );
+
+		$attributes = array_column( $rows[0]['authorization'], 'attribute' );
+		$this->assertContains( 'RequiredCapability', $attributes );
+	}
+
+	/**
+	 * @testdox RequiredCapability args round-trip through the descriptor.
+	 */
+	public function test_descriptor_carries_constructor_args(): void {
+		$handle = new SchemaHandle( $this->build_schema() );
+		$rows   = $handle->find_metadata( type: 'Widget', field: 'caption' );
+
+		$required_cap = null;
+		foreach ( $rows[0]['authorization'] as $descriptor ) {
+			if ( 'RequiredCapability' === $descriptor['attribute'] ) {
+				$required_cap = $descriptor;
+				break;
+			}
+		}
+		$this->assertNotNull( $required_cap );
+		$this->assertSame( array( 'manage_woocommerce' ), $required_cap['args'] );
+	}
+
+	/**
+	 * @testdox find_metadata filters by attribute short name, trimming non-matching descriptors.
+	 */
+	public function test_find_metadata_filters_by_attribute(): void {
+		$handle = new SchemaHandle( $this->build_schema() );
+		$rows   = $handle->find_metadata( attribute: 'RequiredCapability' );
+
+		$this->assertNotEmpty( $rows );
+		foreach ( $rows as $row ) {
+			foreach ( $row['authorization'] as $descriptor ) {
+				$this->assertSame( 'RequiredCapability', $descriptor['attribute'] );
+			}
+		}
+	}
+
+	/**
+	 * @testdox A target with #[HiddenFromMetadataQuery] does not appear in _apiMetadata.
+	 */
+	public function test_hidden_target_is_omitted_from_apimetadata(): void {
+		$handle = new SchemaHandle( $this->build_schema() );
+		$rows   = $handle->find_metadata( type: 'Widget', field: 'legacy_price' );
+
+		$this->assertSame(
+			array(),
+			$rows,
+			'legacy_price carries #[HiddenFromMetadataQuery]; the row must not surface.'
+		);
+	}
+
+	/**
+	 * @testdox The _apiMetadata endpoint surfaces authorization through the GraphQL response.
+	 */
+	public function test_apimetadata_endpoint_returns_authorization(): void {
+		// `_apiMetadata` is gated by the principal's `can_query_metadata()` /
+		// `can_introspect()` ladder; the default WC Principal grants only on
+		// `manage_woocommerce`, so the test runs as an admin to clear the gate.
+		$admin = self::factory()->user->create_and_get( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin->ID );
+
+		$result = $this->execute_query(
+			'{ _apiMetadata(type: "Widget", field: "caption") { type field authorization { attribute args } } }'
+		);
+
+		$this->assertArrayNotHasKey( 'errors', $result );
+		$rows = $result['data']['_apiMetadata'];
+		$this->assertCount( 1, $rows );
+
+		$found = false;
+		foreach ( $rows[0]['authorization'] as $entry ) {
+			if ( 'RequiredCapability' === $entry['attribute'] ) {
+				$found = true;
+				$this->assertSame( array( 'manage_woocommerce' ), $entry['args'] );
+			}
+		}
+		$this->assertTrue( $found, 'RequiredCapability should appear in the _apiMetadata authorization list.' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AutogeneratedTestCase.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AutogeneratedTestCase.php
index 116c5fe4b92..305281f260a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AutogeneratedTestCase.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AutogeneratedTestCase.php
@@ -82,8 +82,10 @@ abstract class AutogeneratedTestCase extends WC_Unit_Test_Case {
 		$result = GraphQL::executeQuery(
 			schema: $this->build_schema(),
 			source: $query,
-			contextValue: array(
-				'principal' => new Principal( wp_get_current_user() ),
+			contextValue: new \ArrayObject(
+				array(
+					'principal' => new Principal( wp_get_current_user() ),
+				)
 			),
 			variableValues: $vars,
 		);
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/FieldAuthorizeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/FieldAuthorizeTest.php
new file mode 100644
index 00000000000..46a03c7fa96
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/FieldAuthorizeTest.php
@@ -0,0 +1,112 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+/**
+ * End-to-end coverage for per-field authorization gates emitted into the
+ * generated ObjectType resolvers.
+ *
+ * Fixture surface:
+ *
+ *  - `Widget::$caption` carries `#[RequiredCapability('manage_woocommerce')]`,
+ *    so reading this field is gated by the WP `manage_woocommerce` capability.
+ *  - `Widget::$tag_ids` carries `#[PublicAccess]`, which at field level emits
+ *    a build warning and is treated as a no-op (allow-by-default). The
+ *    generated resolver therefore has no gate on `tag_ids`.
+ *
+ * Field-level denies surface as a per-field error in the GraphQL response;
+ * the field value becomes `null` and the rest of the type resolves normally.
+ */
+class FieldAuthorizeTest extends AutogeneratedTestCase {
+	/**
+	 * @testdox An admin can read a gated field — the gate grants and the value is returned.
+	 */
+	public function test_admin_can_read_gated_field(): void {
+		$admin = self::factory()->user->create_and_get( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin->ID );
+
+		$result = $this->execute_query( '{ publicWidget { caption } }' );
+
+		$this->assertArrayNotHasKey( 'errors', $result, 'Admin should not see any errors when reading a gated field.' );
+		$this->assertArrayHasKey( 'data', $result );
+		$this->assertArrayHasKey( 'publicWidget', $result['data'] );
+		// Caption is nullable in the fixture; admin still gets through the gate.
+		$this->assertArrayHasKey( 'caption', $result['data']['publicWidget'] );
+	}
+
+	/**
+	 * @testdox An anonymous principal hits a field-level deny when reading a gated field.
+	 */
+	public function test_anonymous_is_denied_on_gated_field(): void {
+		wp_set_current_user( 0 );
+
+		$result = $this->execute_query( '{ publicWidget { caption } }' );
+
+		$this->assertArrayHasKey( 'errors', $result, 'Anonymous reader should hit the field-level gate.' );
+		$this->assertNotEmpty( $result['errors'] );
+		$this->assertArrayHasKey( 'extensions', $result['errors'][0] );
+		$this->assertSame( 'UNAUTHORIZED', $result['errors'][0]['extensions']['code'] );
+
+		// The field value is null but other fields on the same type resolve normally.
+		$this->assertArrayHasKey( 'data', $result );
+		$this->assertNull( $result['data']['publicWidget']['caption'] );
+	}
+
+	/**
+	 * @testdox An authenticated principal without the required capability hits a FORBIDDEN deny.
+	 */
+	public function test_subscriber_is_forbidden_on_gated_field(): void {
+		$subscriber = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) );
+		wp_set_current_user( $subscriber->ID );
+
+		$result = $this->execute_query( '{ publicWidget { caption } }' );
+
+		$this->assertArrayHasKey( 'errors', $result );
+		$this->assertSame( 'FORBIDDEN', $result['errors'][0]['extensions']['code'] );
+		$this->assertNull( $result['data']['publicWidget']['caption'] );
+	}
+
+	/**
+	 * @testdox A field-level deny carries a structured subject payload identifying type, field, and attribute.
+	 */
+	public function test_field_deny_carries_subject_payload(): void {
+		wp_set_current_user( 0 );
+
+		$result = $this->execute_query( '{ publicWidget { caption } }' );
+
+		$this->assertArrayHasKey( 'errors', $result );
+		$this->assertArrayHasKey( 'extensions', $result['errors'][0] );
+		$this->assertArrayHasKey( 'subject', $result['errors'][0]['extensions'] );
+
+		$subject = $result['errors'][0]['extensions']['subject'];
+		$this->assertSame( 'Widget', $subject['type'] );
+		$this->assertSame( 'caption', $subject['field'] );
+		$this->assertSame( 'RequiredCapability', $subject['attribute'] );
+	}
+
+	/**
+	 * @testdox A query that does not select the gated field is unaffected by its gate.
+	 */
+	public function test_unselected_gated_field_does_not_fire_gate(): void {
+		wp_set_current_user( 0 );
+
+		$result = $this->execute_query( '{ publicWidget { id slug } }' );
+
+		$this->assertArrayNotHasKey( 'errors', $result );
+		$this->assertSame( 'alpha', $result['data']['publicWidget']['slug'] );
+	}
+
+	/**
+	 * @testdox A field decorated with #[PublicAccess] alone has no gate (warned and ignored at build).
+	 */
+	public function test_public_access_property_has_no_gate(): void {
+		wp_set_current_user( 0 );
+
+		$result = $this->execute_query( '{ publicWidget { tag_ids } }' );
+
+		$this->assertArrayNotHasKey( 'errors', $result, 'PublicAccess at field level is a build-only marker; no runtime gate should fire.' );
+		$this->assertSame( array( 1, 2, 3 ), $result['data']['publicWidget']['tag_ids'] );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MetadataQueryHidingTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MetadataQueryHidingTest.php
new file mode 100644
index 00000000000..1dc50f33792
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MetadataQueryHidingTest.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Api\Utils\SchemaHandle;
+
+/**
+ * End-to-end coverage for the per-target `_apiMetadata` opt-out:
+ *
+ *  - `Widget::$caption` carries `#[VisibleSampleMetadata]` and no
+ *    opt-out attribute. The generated schema preserves the entry; a
+ *    `_apiMetadata` row appears for the field.
+ *  - `Widget::$legacy_price` carries `#[HiddenFromMetadataQuery]` (the
+ *    stock marker). Any attribute on a target whose
+ *    `shows_in_metadata_query()` returns false hides the whole target —
+ *    the field does not appear in `_apiMetadata` at all, regardless of
+ *    what other metadata / authorization is declared on it.
+ */
+class MetadataQueryHidingTest extends AutogeneratedTestCase {
+	/**
+	 * @testdox Metadata attached to a visible target reaches `_apiMetadata`.
+	 */
+	public function test_visible_metadata_appears_in_schema(): void {
+		$handle = new SchemaHandle( $this->build_schema() );
+		$rows   = $handle->find_metadata( type: 'Widget', field: 'caption' );
+
+		$this->assertCount( 1, $rows, 'Expected exactly one metadata row for Widget.caption.' );
+		$this->assertArrayHasKey( 'visible_sample', $rows[0]['entries'] );
+		$this->assertSame( 'visible', $rows[0]['entries']['visible_sample'] );
+	}
+
+	/**
+	 * @testdox A target carrying any attribute whose shows_in_metadata_query() returns false is omitted entirely.
+	 */
+	public function test_target_with_hidden_attribute_is_omitted(): void {
+		$handle = new SchemaHandle( $this->build_schema() );
+		$rows   = $handle->find_metadata( type: 'Widget', field: 'legacy_price' );
+
+		$this->assertSame(
+			array(),
+			$rows,
+			'legacy_price carries #[HiddenFromMetadataQuery] and must not surface through _apiMetadata.'
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MutationInputAuthorizeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MutationInputAuthorizeTest.php
new file mode 100644
index 00000000000..c8a8db621ec
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/MutationInputAuthorizeTest.php
@@ -0,0 +1,95 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+/**
+ * End-to-end coverage for input-side authorization gates. Fixture surface:
+ *
+ *  - `publicCreateWidget` is a `#[PublicAccess]` mutation that takes a
+ *    `CreateWidgetInput`. The class-level gate grants for any principal so
+ *    the test can exercise the input-side path in isolation.
+ *  - `CreateWidgetInput::$weight` carries `#[RequiredCapability('manage_woocommerce')]`.
+ *    The generated resolver emits a gate that runs only when `weight` is
+ *    actually present in the GraphQL request (mirroring
+ *    {@see \Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields}).
+ */
+class MutationInputAuthorizeTest extends AutogeneratedTestCase {
+	/**
+	 * @testdox An admin can create a widget while providing the gated input field.
+	 */
+	public function test_admin_can_provide_gated_input_field(): void {
+		$admin = self::factory()->user->create_and_get( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin->ID );
+
+		$result = $this->execute_query(
+			'mutation { publicCreateWidget( input: { label: "Test", color: RED, weight: 100 } ) { slug } }'
+		);
+
+		$this->assertArrayNotHasKey( 'errors', $result );
+		$this->assertArrayHasKey( 'data', $result );
+		$this->assertArrayHasKey( 'publicCreateWidget', $result['data'] );
+	}
+
+	/**
+	 * @testdox An anonymous caller can create a widget without providing the gated field.
+	 */
+	public function test_anonymous_can_create_without_gated_field(): void {
+		wp_set_current_user( 0 );
+
+		$result = $this->execute_query(
+			'mutation { publicCreateWidget( input: { label: "TestAnon", color: GREEN } ) { slug } }'
+		);
+
+		$this->assertArrayNotHasKey( 'errors', $result, 'A non-provided gated input field should not trigger the gate.' );
+		$this->assertArrayHasKey( 'data', $result );
+		$this->assertArrayHasKey( 'publicCreateWidget', $result['data'] );
+	}
+
+	/**
+	 * @testdox An anonymous caller hits the input-side gate when providing the gated field.
+	 */
+	public function test_anonymous_is_denied_when_providing_gated_input_field(): void {
+		wp_set_current_user( 0 );
+
+		$result = $this->execute_query(
+			'mutation { publicCreateWidget( input: { label: "TestDenied", color: GREEN, weight: 50 } ) { slug } }'
+		);
+
+		$this->assertArrayHasKey( 'errors', $result );
+		$this->assertSame( 'UNAUTHORIZED', $result['errors'][0]['extensions']['code'] );
+	}
+
+	/**
+	 * @testdox An input-side deny carries a structured subject payload identifying input type, field, and attribute.
+	 */
+	public function test_input_deny_carries_subject_payload(): void {
+		wp_set_current_user( 0 );
+
+		$result = $this->execute_query(
+			'mutation { publicCreateWidget( input: { label: "TestSubject", color: GREEN, weight: 99 } ) { slug } }'
+		);
+
+		$this->assertArrayHasKey( 'errors', $result );
+		$subject = $result['errors'][0]['extensions']['subject'];
+		$this->assertSame( 'CreateWidgetInput', $subject['type'] );
+		$this->assertSame( 'weight', $subject['field'] );
+		$this->assertSame( 'RequiredCapability', $subject['attribute'] );
+	}
+
+	/**
+	 * @testdox A subscriber gets FORBIDDEN when providing a gated input field they lack the capability for.
+	 */
+	public function test_subscriber_is_forbidden_when_providing_gated_input_field(): void {
+		$subscriber = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) );
+		wp_set_current_user( $subscriber->ID );
+
+		$result = $this->execute_query(
+			'mutation { publicCreateWidget( input: { label: "TestSub", color: RED, weight: 75 } ) { slug } }'
+		);
+
+		$this->assertArrayHasKey( 'errors', $result );
+		$this->assertSame( 'FORBIDDEN', $result['errors'][0]['extensions']['code'] );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/RuntimeMetadataVisibilityTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/RuntimeMetadataVisibilityTest.php
new file mode 100644
index 00000000000..d609c94b608
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/RuntimeMetadataVisibilityTest.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+use Automattic\WooCommerce\Api\Utils\SchemaHandle;
+
+/**
+ * Regression coverage for the `shows_in_metadata_query()` / `#[HiddenFromMetadataQuery]`
+ * opt-out being *discovery-only*: a target hidden from `_apiMetadata` must still
+ * feed its metadata into the runtime `$_metadata` slices that field gates read.
+ *
+ * `HiddenFlaggedQuery` (hidden, carries `runtime_flag`) returns `RuntimeMetaProbe`
+ * (hidden type, carries `runtime_flag`), whose three gated fields each read a
+ * different slice via `GrantsIfMetadataFlag`:
+ *
+ *  - `by_type`  → `$_metadata['type']`  (the hidden type's metadata)
+ *  - `by_field` → `$_metadata['field']` (the hidden field's own metadata)
+ *  - `by_query` → `$_metadata['query']` (the hidden query's metadata)
+ *
+ * All three must grant. Before the runtime/discovery split, each slice was
+ * blanked when its carrying target opted out, so every field would have denied.
+ */
+class RuntimeMetadataVisibilityTest extends AutogeneratedTestCase {
+	/**
+	 * @testdox runtime $_metadata slices stay populated for hidden targets (query / type / field).
+	 */
+	public function test_hidden_targets_keep_runtime_metadata(): void {
+		wp_set_current_user( 0 );
+
+		$result = $this->execute_query( '{ hiddenFlagged { by_type by_field by_query } }' );
+
+		$this->assertArrayNotHasKey(
+			'errors',
+			$result,
+			'A discovery opt-out must not starve the runtime gates: ' . wp_json_encode( $result['errors'] ?? array() )
+		);
+		$this->assertSame( 'type-ok', $result['data']['hiddenFlagged']['by_type'], 'type slice was blanked at runtime' );
+		$this->assertSame( 'field-ok', $result['data']['hiddenFlagged']['by_field'], 'field slice was blanked at runtime' );
+		$this->assertSame( 'query-ok', $result['data']['hiddenFlagged']['by_query'], 'query slice was blanked at runtime' );
+	}
+
+	/**
+	 * @testdox the discovery opt-out still hides the target from _apiMetadata.
+	 */
+	public function test_hidden_field_absent_from_discovery(): void {
+		$handle = new SchemaHandle( $this->build_schema() );
+
+		// by_field carries metadata + an authorization attribute but is hidden,
+		// so it must not surface through _apiMetadata at all — confirming the
+		// runtime fix did not re-expose hidden targets in discovery.
+		$this->assertSame(
+			array(),
+			$handle->find_metadata( type: 'RuntimeMetaProbe', field: 'by_field' ),
+			'by_field carries #[HiddenFromMetadataQuery] and must stay out of _apiMetadata.'
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/SchemaShapeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/SchemaShapeTest.php
index 99fa9b53bb9..ecf0115ac76 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/SchemaShapeTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/SchemaShapeTest.php
@@ -50,6 +50,7 @@ class SchemaShapeTest extends AutogeneratedTestCase {
 		$fields = $root_query->getFields();
 		$this->assertArrayHasKey( 'widget', $fields );
 		$this->assertArrayHasKey( 'widgets', $fields );
+		$this->assertArrayHasKey( 'widgetList', $fields );
 		$this->assertArrayHasKey( 'greeting', $fields );
 		$this->assertArrayHasKey( 'namedThing', $fields );
 		$this->assertArrayHasKey( 'failing', $fields );
@@ -58,6 +59,19 @@ class SchemaShapeTest extends AutogeneratedTestCase {
 		$this->assertArrayNotHasKey( 'IgnoredQuery', $fields );
 	}

+	/**
+	 * @testdox a query returning #[ArrayOf( X::class )] array emits a [X!]! list field.
+	 */
+	public function test_array_of_return_type_emits_non_null_list(): void {
+		// ListWidgetsArray::execute() is declared `: array` with
+		// `#[ArrayOf( Widget::class )]`. The builder must turn that into a
+		// non-null list of non-null Widget — not the scalar fallback. Regression
+		// guard for the return-type ArrayOf path (#[ConnectionOf] is covered by
+		// the `widgets` field separately).
+		$field = RootQueryType::get()->getFields()['widgetList'];
+		$this->assertSame( '[Widget!]!', (string) $field->getType() );
+	}
+
 	/**
 	 * @testdox RootMutationType exposes every mutation and uses the right field names.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/ComputePreauthorizedTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/ComputePreauthorizedTest.php
index 2dd9323bfc3..f654c44fc85 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/ComputePreauthorizedTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/ComputePreauthorizedTest.php
@@ -7,6 +7,8 @@ namespace Automattic\WooCommerce\Tests\Internal\Api;
 use Automattic\WooCommerce\Api\Infrastructure\Principal;
 use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\ComposedAuthorizeQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\MetadataAwareInternalQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\MetadataAwareNoFlagQuery;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance\InheritedCapQuery;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance\InheritedPublicQuery;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Inheritance\MergedCapsQuery;
@@ -14,6 +16,8 @@ use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\Gra
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\InheritedCapQuery as InheritedCapResolver;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\InheritedPublicQuery as InheritedPublicResolver;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\MergedCapsQuery as MergedCapsResolver;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\MetadataAwareInternalQuery as MetadataAwareInternalResolver;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\MetadataAwareNoFlagQuery as MetadataAwareNoFlagResolver;
 use WC_Unit_Test_Case;

 /**
@@ -166,4 +170,37 @@ class ComputePreauthorizedTest extends WC_Unit_Test_Case {
 		$this->expectException( \InvalidArgumentException::class );
 		ResolverHelpers::compute_preauthorized( '\\Definitely\\Not\\A\\Class', $this->principal_with_role( null ) );
 	}
+
+	/**
+	 * @testdox the autogen-emitted helper threads the class's #[Metadata] entries into $_metadata.
+	 */
+	public function test_autogen_helper_threads_metadata_slot(): void {
+		$principal = $this->principal_with_role( 'administrator' );
+
+		// The RequiresInternalFlag attribute grants only when $_metadata['query']['internal'] === true.
+		$this->assertTrue(
+			MetadataAwareInternalResolver::compute_preauthorized( $principal ),
+			'#[Internal] metadata should reach the attribute via $_metadata.'
+		);
+		$this->assertFalse(
+			MetadataAwareNoFlagResolver::compute_preauthorized( $principal ),
+			'A command with no #[Internal] should produce an empty `query` slice and deny.'
+		);
+	}
+
+	/**
+	 * @testdox ResolverHelpers::compute_preauthorized threads $_metadata to agree with the autogen helper.
+	 */
+	public function test_utils_helper_threads_metadata_slot(): void {
+		$principal = $this->principal_with_role( 'administrator' );
+
+		$this->assertSame(
+			MetadataAwareInternalResolver::compute_preauthorized( $principal ),
+			ResolverHelpers::compute_preauthorized( MetadataAwareInternalQuery::class, $principal )
+		);
+		$this->assertSame(
+			MetadataAwareNoFlagResolver::compute_preauthorized( $principal ),
+			ResolverHelpers::compute_preauthorized( MetadataAwareNoFlagQuery::class, $principal )
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Attributes/GrantsIfMetadataFlag.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Attributes/GrantsIfMetadataFlag.php
new file mode 100644
index 00000000000..5b53d8f6cb7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Attributes/GrantsIfMetadataFlag.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes;
+
+use Attribute;
+use Automattic\WooCommerce\Api\Infrastructure\Principal;
+
+/**
+ * Fixture authorization attribute that grants iff a `runtime_flag` metadata
+ * entry (value `true`) is present in the requested `$_metadata` slice
+ * (`query`, `type`, or `field`).
+ *
+ * Used to prove that the runtime `$_metadata` slices stay populated even when
+ * the target carrying the metadata opts out of `_apiMetadata` via
+ * `#[HiddenFromMetadataQuery]` — that opt-out is discovery-only and must not
+ * starve the runtime gate.
+ */
+#[Attribute( Attribute::TARGET_PROPERTY )]
+final class GrantsIfMetadataFlag {
+	/**
+	 * @param string $slice Which `$_metadata` slice to read: `query`, `type`, or `field`.
+	 */
+	public function __construct( public readonly string $slice ) {
+	}
+
+	/**
+	 * @param Principal $principal The resolved principal (unused; the decision is metadata-driven).
+	 * @param array     $_metadata The harvested metadata slices.
+	 */
+	public function authorize( Principal $principal, array $_metadata ): bool {
+		unset( $principal );
+		return true === ( $_metadata[ $this->slice ]['runtime_flag'] ?? null );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Attributes/RequiresInternalFlag.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Attributes/RequiresInternalFlag.php
new file mode 100644
index 00000000000..f1971645637
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Attributes/RequiresInternalFlag.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes;
+
+use Attribute;
+use Automattic\WooCommerce\Api\Infrastructure\Principal;
+
+/**
+ * Fixture authorization attribute that exercises the opt-in `$_metadata`
+ * slot. Grants iff the surrounding command class carries an `#[Internal]`
+ * metadata entry (i.e. `$_metadata['query']['internal'] === true`).
+ *
+ * Declaring both the principal and `$_metadata` covers the
+ * happy-path mixed-positional/named call shape ApiBuilder and
+ * {@see \Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers::compute_preauthorized()}
+ * must produce.
+ */
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY )]
+final class RequiresInternalFlag {
+	public function authorize( Principal $principal, array $_metadata ): bool {
+		unset( $principal );
+		return true === ( $_metadata['query']['internal'] ?? null );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/InputTypes/CreateWidgetInput.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/InputTypes/CreateWidgetInput.php
index e03f80ae64a..d83aeca82a2 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/InputTypes/CreateWidgetInput.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/InputTypes/CreateWidgetInput.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes

 use Automattic\WooCommerce\Api\Attributes\ArrayOf;
 use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
 use Automattic\WooCommerce\Api\Attributes\ScalarType;
 use Automattic\WooCommerce\Api\InputTypes\TracksProvidedFields;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Color;
@@ -22,6 +23,7 @@ class CreateWidgetInput {
 	public string $label;

 	#[Description( 'Optional weight in grams' )]
+	#[RequiredCapability( 'manage_woocommerce' )]
 	public ?int $weight = null;

 	#[Description( 'The widget color' )]
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Metadata/VisibleSampleMetadata.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Metadata/VisibleSampleMetadata.php
new file mode 100644
index 00000000000..305c48f9144
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Metadata/VisibleSampleMetadata.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Metadata;
+
+use Attribute;
+use Automattic\WooCommerce\Api\Attributes\Metadata;
+
+/**
+ * Plain fixture metadata subclass with the default
+ * `shows_in_metadata_query()` (returns `true`). Provides a known entry
+ * name (`visible_sample`) for tests that assert on the metadata query's
+ * output without bringing the side effects of stock subclasses like
+ * `#[Internal]` (which prefixes descriptions).
+ */
+#[Attribute( Attribute::TARGET_PROPERTY )]
+final class VisibleSampleMetadata extends Metadata {
+	public function __construct() {
+		parent::__construct( 'visible_sample', 'visible' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/PublicCreateWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/PublicCreateWidget.php
new file mode 100644
index 00000000000..953941b90bd
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Mutations/PublicCreateWidget.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Mutations;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\CreateWidgetInput;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Widget;
+
+/**
+ * Publicly-accessible widget creator used by input-level authorization tests.
+ * Anyone can invoke the mutation (no class-level gate), but the
+ * `CreateWidgetInput::$weight` property carries a `#[RequiredCapability]`, so
+ * the input-side gate fires only when `weight` is provided in the request.
+ */
+#[Name( 'publicCreateWidget' )]
+#[Description( 'Create a widget without query-level gating; input-side gates apply.' )]
+#[PublicAccess]
+class PublicCreateWidget {
+	public function execute( CreateWidgetInput $input ): Widget {
+		$widget = Store::create_widget( $input->label, $input->color );
+		if ( null !== $input->weight ) {
+			$widget->caption = sprintf( 'weighs %d g', $input->weight );
+		}
+		return $widget;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/MetadataAwareInternalQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/MetadataAwareInternalQuery.php
new file mode 100644
index 00000000000..28a0e61b5ce
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/MetadataAwareInternalQuery.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Internal;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\RequiresInternalFlag;
+
+/**
+ * Carries an `#[Internal]` metadata entry and is gated by
+ * {@see RequiresInternalFlag}, which reads `$_metadata['query']['internal']`.
+ * The gate should grant: this is the happy path for the metadata slot.
+ */
+#[Name( 'metadataAwareInternalQuery' )]
+#[Description( 'Exercises RequiresInternalFlag against a class that carries #[Internal].' )]
+#[Internal]
+#[RequiresInternalFlag]
+class MetadataAwareInternalQuery {
+	public function execute(): string {
+		return 'ok-internal';
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/MetadataAwareNoFlagQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/MetadataAwareNoFlagQuery.php
new file mode 100644
index 00000000000..aaf2d6639a4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/MetadataAwareNoFlagQuery.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\RequiresInternalFlag;
+
+/**
+ * Gated by {@see RequiresInternalFlag} but carries no `#[Internal]`
+ * metadata. The gate should deny: this is the negative path for the
+ * metadata slot — `$_metadata['query']` is empty, so the attribute's
+ * "is internal?" check returns false.
+ */
+#[Name( 'metadataAwareNoFlagQuery' )]
+#[Description( 'Exercises RequiresInternalFlag without the matching #[Internal] entry.' )]
+#[RequiresInternalFlag]
+class MetadataAwareNoFlagQuery {
+	public function execute(): string {
+		return 'ok-no-internal';
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/PublicWidgetAccess.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/PublicWidgetAccess.php
new file mode 100644
index 00000000000..edf0cb24b41
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/PublicWidgetAccess.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Widget;
+
+/**
+ * Publicly-accessible Widget query used by the field-level authorization tests
+ * to reach a `Widget` instance without first passing through the class-level
+ * `#[RequiredCapability]` gate on `GetWidget`. Lets the field-level gates on
+ * `Widget`'s properties be exercised in isolation.
+ */
+#[Name( 'publicWidget' )]
+#[Description( 'Fetch a widget without query-level gating; field-level gates apply.' )]
+#[PublicAccess]
+class PublicWidgetAccess {
+	public function execute(): ?Widget {
+		return Store::get_widget( 1 );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/HiddenFlaggedQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/HiddenFlaggedQuery.php
new file mode 100644
index 00000000000..46a3c62ba85
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/HiddenFlaggedQuery.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries;
+
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\HiddenFromMetadataQuery;
+use Automattic\WooCommerce\Api\Attributes\Metadata;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\RuntimeMetaProbe;
+
+/**
+ * Public query that is itself hidden from `_apiMetadata` and carries a
+ * `runtime_flag` metadata entry. The `by_query` field on its returned type is
+ * gated on `$_metadata['query']['runtime_flag']`, so it only grants if the
+ * hidden query's metadata still reaches the runtime gate (the discovery
+ * opt-out must not blank the published `_query_metadata`).
+ */
+#[Name( 'hiddenFlagged' )]
+#[Description( 'Probe query for runtime metadata visibility under #[HiddenFromMetadataQuery].' )]
+#[PublicAccess]
+#[Metadata( 'runtime_flag', true )]
+#[HiddenFromMetadataQuery]
+class HiddenFlaggedQuery {
+	public function execute(): RuntimeMetaProbe {
+		$probe           = new RuntimeMetaProbe();
+		$probe->by_type  = 'type-ok';
+		$probe->by_field = 'field-ok';
+		$probe->by_query = 'query-ok';
+		return $probe;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/ListWidgetsArray.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/ListWidgetsArray.php
new file mode 100644
index 00000000000..528309bf2d4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/ListWidgetsArray.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries;
+
+use Automattic\WooCommerce\Api\Attributes\ArrayOf;
+use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types\Widget;
+
+/**
+ * Returns a plain (non-paginated) list of widgets — exercises the
+ * `#[ArrayOf]` element-type declaration on a query's `execute()` *return*
+ * value, which the generator turns into `[Widget!]!`.
+ *
+ * Regression coverage for the return-type list path: distinct from
+ * `#[ConnectionOf]` (which {@see ListWidgets} covers) and from `#[ArrayOf]`
+ * on properties / parameters (which {@see Widget} and the mutations cover).
+ * Before this was fixed, an `array` return with `#[ArrayOf]` fell through to
+ * the scalar fallback and emitted `String!`.
+ */
+#[Name( 'widgetList' )]
+#[Description( 'List widgets without pagination.' )]
+#[PublicAccess]
+class ListWidgetsArray {
+	/**
+	 * @return Widget[]
+	 */
+	#[ArrayOf( Widget::class )]
+	public function execute(): array {
+		return array();
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/RuntimeMetaProbe.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/RuntimeMetaProbe.php
new file mode 100644
index 00000000000..793fb68200c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/RuntimeMetaProbe.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Types;
+
+use Automattic\WooCommerce\Api\Attributes\HiddenFromMetadataQuery;
+use Automattic\WooCommerce\Api\Attributes\Metadata;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\GrantsIfMetadataFlag;
+
+/**
+ * Output type for the runtime-metadata-visibility regression. The type, one of
+ * its fields, and the query that returns it ({@see \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\HiddenFlaggedQuery})
+ * all opt out of `_apiMetadata` via `#[HiddenFromMetadataQuery]` while carrying
+ * a `runtime_flag` metadata entry. Each gated field reads a different
+ * `$_metadata` slice and must still grant, proving the discovery opt-out does
+ * not blank the runtime metadata.
+ */
+#[Metadata( 'runtime_flag', true )]
+#[HiddenFromMetadataQuery]
+class RuntimeMetaProbe {
+	/**
+	 * Reads the `type` slice: the enclosing (hidden) type carries `runtime_flag`.
+	 */
+	#[GrantsIfMetadataFlag( 'type' )]
+	public ?string $by_type;
+
+	/**
+	 * Reads the `field` slice: this field is itself hidden and carries `runtime_flag`.
+	 */
+	#[Metadata( 'runtime_flag', true )]
+	#[HiddenFromMetadataQuery]
+	#[GrantsIfMetadataFlag( 'field' )]
+	public ?string $by_field;
+
+	/**
+	 * Reads the `query` slice: the (hidden) query that resolves this type carries `runtime_flag`.
+	 */
+	#[GrantsIfMetadataFlag( 'query' )]
+	public ?string $by_query;
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/Widget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/Widget.php
index 875dedba044..33762826444 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/Widget.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Types/Widget.php
@@ -8,14 +8,18 @@ use Automattic\WooCommerce\Api\Attributes\ArrayOf;
 use Automattic\WooCommerce\Api\Attributes\ConnectionOf;
 use Automattic\WooCommerce\Api\Attributes\Deprecated;
 use Automattic\WooCommerce\Api\Attributes\Description;
+use Automattic\WooCommerce\Api\Attributes\HiddenFromMetadataQuery;
 use Automattic\WooCommerce\Api\Attributes\Ignore;
 use Automattic\WooCommerce\Api\Attributes\Parameter;
 use Automattic\WooCommerce\Api\Attributes\ParameterDescription;
+use Automattic\WooCommerce\Api\Attributes\PublicAccess;
+use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
 use Automattic\WooCommerce\Api\Attributes\ScalarType;
 use Automattic\WooCommerce\Api\Pagination\Connection;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Color;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Enums\Priority;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Interfaces\Named;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Metadata\VisibleSampleMetadata;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Scalars\DummyDateTime;

 /**
@@ -29,6 +33,8 @@ class Widget {
 	public string $slug;

 	#[Description( 'An optional caption' )]
+	#[VisibleSampleMetadata]
+	#[RequiredCapability( 'manage_woocommerce' )]
 	public ?string $caption;

 	#[Description( 'The widget color' )]
@@ -39,6 +45,7 @@ class Widget {

 	#[Description( 'Tag IDs assigned to this widget' )]
 	#[ArrayOf( 'int' )]
+	#[PublicAccess]
 	public array $tag_ids;

 	#[Description( 'Notable comments left on this widget' )]
@@ -66,6 +73,7 @@ class Widget {

 	#[Description( 'A field flagged for removal' )]
 	#[Deprecated( 'Use price instead.' )]
+	#[HiddenFromMetadataQuery]
 	public string $legacy_price;

 	#[Ignore]
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 ea1b75bc835..45487720e96 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
@@ -16,9 +16,17 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class CreateWidget {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull( WidgetType::get() ),
-			'description' => __( 'Create a new widget', 'woocommerce' ),
-			'args'        => array(
+			'type'          => Type::nonNull( WidgetType::get() ),
+			'description'   => __( 'Create a new widget', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'manage_options',
+					),
+				),
+			),
+			'args'          => array(
 				'input'          => array(
 					'type'        => Type::nonNull( CreateWidgetInput::get() ),
 					'description' => __( 'The data for the new widget', 'woocommerce' ),
@@ -29,7 +37,7 @@ class CreateWidget {
 					'defaultValue' => null,
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -40,6 +48,12 @@ class CreateWidget {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( CreateWidgetCommand::class );

 		$execute_args = array();
@@ -50,6 +64,22 @@ class CreateWidget {
 			$execute_args['related_inputs'] = $args['related_inputs'];
 		}

+		if ( isset( $execute_args['input'] ) && $execute_args['input'] instanceof \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\CreateWidgetInput ) {
+			$_parent = $execute_args['input'];
+			if ( $_parent->was_provided( 'weight' ) ) {
+				$principal = $context['principal'];
+				$_metadata = array(
+					'query' => $context['_query_metadata'] ?? array(),
+					'type'  => array(),
+					'field' => array(),
+				);
+				$_args     = $args;
+				if ( ! ( ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'manage_woocommerce' ) )->authorize( $principal ) ) ) {
+					throw ResolverHelpers::build_field_authorization_error( $principal, 'CreateWidgetInput', 'weight', 'RequiredCapability' );
+				}
+			}
+		}
+
 		$result = ResolverHelpers::execute_command( $command, $execute_args );

 		return $result;
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 d7bb11c41c5..b8464304a1f 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
@@ -15,9 +15,17 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class DeleteWidget {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull( OperationResultType::get() ),
-			'description' => __( 'Delete a widget', 'woocommerce' ),
-			'args'        => array(
+			'type'          => Type::nonNull( OperationResultType::get() ),
+			'description'   => __( 'Delete a widget', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'manage_options',
+					),
+				),
+			),
+			'args'          => array(
 				'id'    => array(
 					'type'        => Type::nonNull( Type::int() ),
 					'description' => __( 'The widget id to delete', 'woocommerce' ),
@@ -28,7 +36,7 @@ class DeleteWidget {
 					'defaultValue' => false,
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -39,6 +47,12 @@ class DeleteWidget {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( DeleteWidgetCommand::class );

 		$execute_args = array();
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 a8d7f501ff6..8afdff89e90 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
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class Increment {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull(
+			'type'          => Type::nonNull(
 				new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
 					array(
 						'name'   => 'IncrementResult',
@@ -24,8 +24,14 @@ class Increment {
 					)
 				)
 			),
-			'description' => __( 'Increment a value by an optional amount', 'woocommerce' ),
-			'args'        => array(
+			'description'   => __( 'Increment a value by an optional amount', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'PublicAccess',
+					'args'      => array(),
+				),
+			),
+			'args'          => array(
 				'value' => array(
 					'type'        => Type::nonNull( Type::int() ),
 					'description' => __( 'The starting value', 'woocommerce' ),
@@ -36,11 +42,17 @@ class Increment {
 					'defaultValue' => 1,
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( IncrementCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/PublicCreateWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/PublicCreateWidget.php
new file mode 100644
index 00000000000..baa07014195
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLMutations/PublicCreateWidget.php
@@ -0,0 +1,112 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Mutations\PublicCreateWidget as PublicCreateWidgetCommand;
+use Automattic\WooCommerce\Api\Infrastructure\QueryInfoExtractor;
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget as WidgetType;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Input\CreateWidget as CreateWidgetInput;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\ResolveInfo;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
+
+class PublicCreateWidget {
+	public static function get_field_definition(): array {
+		return array(
+			'type'          => Type::nonNull( WidgetType::get() ),
+			'description'   => __( 'Create a widget without query-level gating; input-side gates apply.', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'PublicAccess',
+					'args'      => array(),
+				),
+			),
+			'args'          => array(
+				'input' => array(
+					'type' => Type::nonNull( CreateWidgetInput::get() ),
+				),
+			),
+			'resolve'       => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( PublicCreateWidgetCommand::class );
+
+		$execute_args = array();
+		if ( array_key_exists( 'input', $args ) ) {
+			$execute_args['input'] = self::convert_create_widget_input( $args['input'] );
+		}
+
+		if ( isset( $execute_args['input'] ) && $execute_args['input'] instanceof \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\CreateWidgetInput ) {
+			$_parent = $execute_args['input'];
+			if ( $_parent->was_provided( 'weight' ) ) {
+				$principal = $context['principal'];
+				$_metadata = array(
+					'query' => $context['_query_metadata'] ?? array(),
+					'type'  => array(),
+					'field' => array(),
+				);
+				$_args     = $args;
+				if ( ! ( ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'manage_woocommerce' ) )->authorize( $principal ) ) ) {
+					throw ResolverHelpers::build_field_authorization_error( $principal, 'CreateWidgetInput', 'weight', 'RequiredCapability' );
+				}
+			}
+		}
+
+		$result = ResolverHelpers::execute_command( $command, $execute_args );
+
+		return $result;
+	}
+
+	/**
+	 * Compute the value `_preauthorized` would carry for a given principal —
+	 * the AND of the autodiscovered authorization attributes' authorize()
+	 * outcomes on this command. Single source of truth for both the resolver's
+	 * own gates and external (code-API) callers asking about authorization
+	 * without going through GraphQL execution.
+	 *
+	 * Returns true vacuously when the command has no authorization attributes
+	 * (in that case authorize() on the command is the sole guard, and that
+	 * method should be consulted instead).
+	 */
+	public static function compute_preauthorized( \Automattic\WooCommerce\Api\Infrastructure\Principal $principal ): bool {
+		return true;
+	}
+
+	private static function convert_create_widget_input( array $data ): \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\CreateWidgetInput {
+		$input = new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\InputTypes\CreateWidgetInput();
+
+		if ( array_key_exists( 'label', $data ) ) {
+			$input->mark_provided( 'label' );
+				$input->label = $data['label'];
+		}
+		if ( array_key_exists( 'weight', $data ) ) {
+			$input->mark_provided( 'weight' );
+					$input->weight = $data['weight'];
+		}
+		if ( array_key_exists( 'color', $data ) ) {
+			$input->mark_provided( 'color' );
+					$input->color = $data['color'];
+		}
+		if ( array_key_exists( 'tag_ids', $data ) ) {
+			$input->mark_provided( 'tag_ids' );
+					$input->tag_ids = $data['tag_ids'];
+		}
+		if ( array_key_exists( 'expires_at', $data ) ) {
+			$input->mark_provided( 'expires_at' );
+					$input->expires_at = $data['expires_at'];
+		}
+
+		return $input;
+	}
+}
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 1504871e30b..0d77556b7c0 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
@@ -35,6 +35,12 @@ class AuthorizeOnlyQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( AuthorizeOnlyQueryCommand::class );

 		$execute_args = array();
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 b8780269009..307633fcd51 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
@@ -36,6 +36,12 @@ class AuthorizeThrowsQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( AuthorizeThrowsQueryCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ComposedAuthorizeQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ComposedAuthorizeQuery.php
index f90591de93e..d3b6b81c34b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ComposedAuthorizeQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ComposedAuthorizeQuery.php
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class ComposedAuthorizeQuery {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull(
+			'type'          => Type::nonNull(
 				new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
 					array(
 						'name'   => 'ComposedAuthorizeQueryResult',
@@ -24,13 +24,27 @@ class ComposedAuthorizeQuery {
 					)
 				)
 			),
-			'description' => __( 'Composes #[RequiredCapability] with authorize() via $_preauthorized', 'woocommerce' ),
-			'args'        => array(),
-			'resolve'     => array( self::class, 'resolve' ),
+			'description'   => __( 'Composes #[RequiredCapability] with authorize() via $_preauthorized', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'manage_options',
+					),
+				),
+			),
+			'args'          => array(),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( ComposedAuthorizeQueryCommand::class );

 		$execute_args = array();
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 d803d5be750..726e944a899 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
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class FailingQuery {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull(
+			'type'          => Type::nonNull(
 				new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
 					array(
 						'name'   => 'FailingQueryResult',
@@ -24,19 +24,31 @@ class FailingQuery {
 					)
 				)
 			),
-			'description' => __( 'Always throws an exception', 'woocommerce' ),
-			'args'        => array(
+			'description'   => __( 'Always throws an exception', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'PublicAccess',
+					'args'      => array(),
+				),
+			),
+			'args'          => array(
 				'kind' => array(
 					'type'         => Type::nonNull( Type::string() ),
 					'description'  => __( 'What kind of failure to raise', 'woocommerce' ),
 					'defaultValue' => 'invalid_argument',
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( FailingQueryCommand::class );

 		$execute_args = array();
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 a4fdd7e9147..f47a54ff2ce 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
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class GetGreeting {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull(
+			'type'          => Type::nonNull(
 				new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
 					array(
 						'name'   => 'GetGreetingResult',
@@ -24,19 +24,31 @@ class GetGreeting {
 					)
 				)
 			),
-			'description' => __( 'Build a greeting', 'woocommerce' ),
-			'args'        => array(
+			'description'   => __( 'Build a greeting', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'PublicAccess',
+					'args'      => array(),
+				),
+			),
+			'args'          => array(
 				'name' => array(
 					'type'         => Type::string(),
 					'description'  => __( 'Who to greet (defaults to \"world\")', 'woocommerce' ),
 					'defaultValue' => null,
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( GetGreetingCommand::class );

 		$execute_args = array();
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 42bc6b4aa35..c45f8bb0b68 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
@@ -15,19 +15,31 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class GetIdentifiable {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull( NamedInterface::get() ),
-			'description' => __( 'Return either a Widget or a Gadget, both of which implement Named', 'woocommerce' ),
-			'args'        => array(
+			'type'          => Type::nonNull( NamedInterface::get() ),
+			'description'   => __( 'Return either a Widget or a Gadget, both of which implement Named', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'PublicAccess',
+					'args'      => array(),
+				),
+			),
+			'args'          => array(
 				'kind' => array(
 					'type'        => Type::nonNull( Type::string() ),
 					'description' => __( 'Which kind of object to return', 'woocommerce' ),
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( GetIdentifiableCommand::class );

 		$execute_args = array();
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 29bd884abae..77bf79f5b05 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
@@ -15,15 +15,23 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class GetWidget {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => WidgetType::get(),
-			'description' => __( 'Fetch a single widget by ID', 'woocommerce' ),
-			'args'        => array(
+			'type'          => WidgetType::get(),
+			'description'   => __( 'Fetch a single widget by ID', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'manage_options',
+					),
+				),
+			),
+			'args'          => array(
 				'id' => array(
 					'type'        => Type::nonNull( Type::int() ),
 					'description' => __( 'The ID of the widget to fetch', 'woocommerce' ),
 				),
 			),
-			'resolve'     => array( self::class, 'resolve' ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -34,6 +42,12 @@ class GetWidget {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( GetWidgetCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/HiddenFlaggedQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/HiddenFlaggedQuery.php
new file mode 100644
index 00000000000..7558220b79c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/HiddenFlaggedQuery.php
@@ -0,0 +1,57 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\HiddenFlaggedQuery as HiddenFlaggedQueryCommand;
+use Automattic\WooCommerce\Api\Infrastructure\QueryInfoExtractor;
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\RuntimeMetaProbe as RuntimeMetaProbeType;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\ResolveInfo;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
+
+class HiddenFlaggedQuery {
+	public static function get_field_definition(): array {
+		return array(
+			'type'        => Type::nonNull( RuntimeMetaProbeType::get() ),
+			'description' => __( 'Probe query for runtime metadata visibility under #[HiddenFromMetadataQuery].', 'woocommerce' ),
+			'args'        => array(),
+			'resolve'     => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array(
+			'runtime_flag' => true,
+		);
+
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( HiddenFlaggedQueryCommand::class );
+
+		$execute_args = array();
+
+		$result = ResolverHelpers::execute_command( $command, $execute_args );
+
+		return $result;
+	}
+
+	/**
+	 * Compute the value `_preauthorized` would carry for a given principal —
+	 * the AND of the autodiscovered authorization attributes' authorize()
+	 * outcomes on this command. Single source of truth for both the resolver's
+	 * own gates and external (code-API) callers asking about authorization
+	 * without going through GraphQL execution.
+	 *
+	 * Returns true vacuously when the command has no authorization attributes
+	 * (in that case authorize() on the command is the sole guard, and that
+	 * method should be consulted instead).
+	 */
+	public static function compute_preauthorized( \Automattic\WooCommerce\Api\Infrastructure\Principal $principal ): bool {
+		return true;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/IgnoredAuthorizeQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/IgnoredAuthorizeQuery.php
index 68b176032a9..49759a0e7b6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/IgnoredAuthorizeQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/IgnoredAuthorizeQuery.php
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class IgnoredAuthorizeQuery {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull(
+			'type'          => Type::nonNull(
 				new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
 					array(
 						'name'   => 'IgnoredAuthorizeQueryResult',
@@ -24,9 +24,17 @@ class IgnoredAuthorizeQuery {
 					)
 				)
 			),
-			'description' => __( 'authorize() with #[Ignore] is skipped; the cap check applies', 'woocommerce' ),
-			'args'        => array(),
-			'resolve'     => array( self::class, 'resolve' ),
+			'description'   => __( 'authorize() with #[Ignore] is skipped; the cap check applies', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'manage_options',
+					),
+				),
+			),
+			'args'          => array(),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -37,6 +45,12 @@ class IgnoredAuthorizeQuery {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( IgnoredAuthorizeQueryCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedCapQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedCapQuery.php
index 6db04a5175b..d20ceed9050 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedCapQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedCapQuery.php
@@ -37,6 +37,12 @@ class InheritedCapQuery {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( InheritedCapQueryCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedFromInterfaceQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedFromInterfaceQuery.php
index e24513f1b1c..773de3237c8 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedFromInterfaceQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedFromInterfaceQuery.php
@@ -37,6 +37,12 @@ class InheritedFromInterfaceQuery {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( InheritedFromInterfaceQueryCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedPublicQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedPublicQuery.php
index e342897a22f..db5fac27d19 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedPublicQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/InheritedPublicQuery.php
@@ -31,6 +31,12 @@ class InheritedPublicQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( InheritedPublicQueryCommand::class );

 		$execute_args = array();
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 b518a932fdc..081ff2c36fa 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
@@ -17,9 +17,23 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class ListWidgets {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull( WidgetConnectionType::get() ),
-			'description' => __( 'List widgets with cursor-based pagination', 'woocommerce' ),
-			'args'        => array(
+			'type'          => Type::nonNull( WidgetConnectionType::get() ),
+			'description'   => __( 'List widgets with cursor-based pagination', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'manage_options',
+					),
+				),
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'edit_posts',
+					),
+				),
+			),
+			'args'          => array(
 				'first'        => array(
 					'type'         => Type::int(),
 					'description'  => __( 'Return the first N results. Must be between 0 and 100.', 'woocommerce' ),
@@ -56,8 +70,8 @@ class ListWidgets {
 					'defaultValue' => null,
 				),
 			),
-			'complexity'  => ResolverHelpers::complexity_from_pagination( ... ),
-			'resolve'     => array( self::class, 'resolve' ),
+			'complexity'    => ResolverHelpers::complexity_from_pagination( ... ),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -68,6 +82,12 @@ class ListWidgets {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( ListWidgetsCommand::class );

 		$query_info                 = QueryInfoExtractor::extract_from_info( $info, $args );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgetsArray.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgetsArray.php
new file mode 100644
index 00000000000..0458a0d91c0
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/ListWidgetsArray.php
@@ -0,0 +1,61 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\ListWidgetsArray as ListWidgetsArrayCommand;
+use Automattic\WooCommerce\Api\Infrastructure\QueryInfoExtractor;
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\ResolveInfo;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
+
+class ListWidgetsArray {
+	public static function get_field_definition(): array {
+		return array(
+			'type'          => Type::nonNull( Type::listOf( Type::nonNull( Widget::get() ) ) ),
+			'description'   => __( 'List widgets without pagination.', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'PublicAccess',
+					'args'      => array(),
+				),
+			),
+			'args'          => array(),
+			'resolve'       => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( ListWidgetsArrayCommand::class );
+
+		$execute_args = array();
+
+		$result = ResolverHelpers::execute_command( $command, $execute_args );
+
+		return $result;
+	}
+
+	/**
+	 * Compute the value `_preauthorized` would carry for a given principal —
+	 * the AND of the autodiscovered authorization attributes' authorize()
+	 * outcomes on this command. Single source of truth for both the resolver's
+	 * own gates and external (code-API) callers asking about authorization
+	 * without going through GraphQL execution.
+	 *
+	 * Returns true vacuously when the command has no authorization attributes
+	 * (in that case authorize() on the command is the sole guard, and that
+	 * method should be consulted instead).
+	 */
+	public static function compute_preauthorized( \Automattic\WooCommerce\Api\Infrastructure\Principal $principal ): bool {
+		return true;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MergedCapsQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MergedCapsQuery.php
index 450a8d511b9..49913ff5fd8 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MergedCapsQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MergedCapsQuery.php
@@ -37,6 +37,12 @@ class MergedCapsQuery {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( MergedCapsQueryCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MetadataAwareInternalQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MetadataAwareInternalQuery.php
new file mode 100644
index 00000000000..52ed18eb0b6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MetadataAwareInternalQuery.php
@@ -0,0 +1,88 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\MetadataAwareInternalQuery as MetadataAwareInternalQueryCommand;
+use Automattic\WooCommerce\Api\Infrastructure\QueryInfoExtractor;
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\ResolveInfo;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
+
+class MetadataAwareInternalQuery {
+	public static function get_field_definition(): array {
+		return array(
+			'type'          => Type::nonNull(
+				new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
+					array(
+						'name'   => 'MetadataAwareInternalQueryResult',
+						'fields' => array(
+							'result' => array( 'type' => Type::nonNull( Type::string() ) ),
+						),
+					)
+				)
+			),
+			'description'   => __( '[Internal] Exercises RequiresInternalFlag against a class that carries #[Internal].', 'woocommerce' ),
+			'metadata'      => array(
+				'internal' => true,
+			),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiresInternalFlag',
+					'args'      => array(),
+				),
+			),
+			'args'          => array(),
+			'resolve'       => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Standalone authorization gate: no authorize() method on the command,
+		// so the autodiscovered authorization attributes are the sole guard.
+		if ( ! self::compute_preauthorized( $context['principal'] ) ) {
+			throw ResolverHelpers::build_authorization_error( $context['principal'] );
+		}
+
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array(
+			'internal' => true,
+		);
+
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( MetadataAwareInternalQueryCommand::class );
+
+		$execute_args = array();
+
+		$result = ResolverHelpers::execute_command( $command, $execute_args );
+
+		return array( 'result' => $result );
+	}
+
+	/**
+	 * Compute the value `_preauthorized` would carry for a given principal —
+	 * the AND of the autodiscovered authorization attributes' authorize()
+	 * outcomes on this command. Single source of truth for both the resolver's
+	 * own gates and external (code-API) callers asking about authorization
+	 * without going through GraphQL execution.
+	 *
+	 * Returns true vacuously when the command has no authorization attributes
+	 * (in that case authorize() on the command is the sole guard, and that
+	 * method should be consulted instead).
+	 */
+	public static function compute_preauthorized( \Automattic\WooCommerce\Api\Infrastructure\Principal $principal ): bool {
+		return ( new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\RequiresInternalFlag() )->authorize(
+			$principal,
+			_metadata: array(
+				'query' =>
+				array(
+					'internal' => true,
+				),
+			)
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MetadataAwareNoFlagQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MetadataAwareNoFlagQuery.php
new file mode 100644
index 00000000000..176a15b6e9b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/MetadataAwareNoFlagQuery.php
@@ -0,0 +1,81 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\MetadataAwareNoFlagQuery as MetadataAwareNoFlagQueryCommand;
+use Automattic\WooCommerce\Api\Infrastructure\QueryInfoExtractor;
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\ResolveInfo;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
+
+class MetadataAwareNoFlagQuery {
+	public static function get_field_definition(): array {
+		return array(
+			'type'          => Type::nonNull(
+				new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
+					array(
+						'name'   => 'MetadataAwareNoFlagQueryResult',
+						'fields' => array(
+							'result' => array( 'type' => Type::nonNull( Type::string() ) ),
+						),
+					)
+				)
+			),
+			'description'   => __( 'Exercises RequiresInternalFlag without the matching #[Internal] entry.', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiresInternalFlag',
+					'args'      => array(),
+				),
+			),
+			'args'          => array(),
+			'resolve'       => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Standalone authorization gate: no authorize() method on the command,
+		// so the autodiscovered authorization attributes are the sole guard.
+		if ( ! self::compute_preauthorized( $context['principal'] ) ) {
+			throw ResolverHelpers::build_authorization_error( $context['principal'] );
+		}
+
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( MetadataAwareNoFlagQueryCommand::class );
+
+		$execute_args = array();
+
+		$result = ResolverHelpers::execute_command( $command, $execute_args );
+
+		return array( 'result' => $result );
+	}
+
+	/**
+	 * Compute the value `_preauthorized` would carry for a given principal —
+	 * the AND of the autodiscovered authorization attributes' authorize()
+	 * outcomes on this command. Single source of truth for both the resolver's
+	 * own gates and external (code-API) callers asking about authorization
+	 * without going through GraphQL execution.
+	 *
+	 * Returns true vacuously when the command has no authorization attributes
+	 * (in that case authorize() on the command is the sole guard, and that
+	 * method should be consulted instead).
+	 */
+	public static function compute_preauthorized( \Automattic\WooCommerce\Api\Infrastructure\Principal $principal ): bool {
+		return ( new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\RequiresInternalFlag() )->authorize(
+			$principal,
+			_metadata: array(
+				'query' =>
+				array(),
+			)
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenAuthorizeQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenAuthorizeQuery.php
index 0d7fdd15935..18c1e5c0d1d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenAuthorizeQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenAuthorizeQuery.php
@@ -31,6 +31,12 @@ class OverriddenAuthorizeQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( OverriddenAuthorizeQueryCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenCapQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenCapQuery.php
index 33ad3b802b9..95240d9935c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenCapQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/OverriddenCapQuery.php
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
 class OverriddenCapQuery {
 	public static function get_field_definition(): array {
 		return array(
-			'type'        => Type::nonNull(
+			'type'          => Type::nonNull(
 				new \Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType(
 					array(
 						'name'   => 'OverriddenCapQueryResult',
@@ -24,9 +24,17 @@ class OverriddenCapQuery {
 					)
 				)
 			),
-			'description' => __( 'Overrides the inherited manage_options with manage_categories', 'woocommerce' ),
-			'args'        => array(),
-			'resolve'     => array( self::class, 'resolve' ),
+			'description'   => __( 'Overrides the inherited manage_options with manage_categories', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'RequiredCapability',
+					'args'      => array(
+						0 => 'manage_categories',
+					),
+				),
+			),
+			'args'          => array(),
+			'resolve'       => array( self::class, 'resolve' ),
 		);
 	}

@@ -37,6 +45,12 @@ class OverriddenCapQuery {
 			throw ResolverHelpers::build_authorization_error( $context['principal'] );
 		}

+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( OverriddenCapQueryCommand::class );

 		$execute_args = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PrincipalAwareQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PrincipalAwareQuery.php
index 71a443d2657..4d1c6cd6d8e 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PrincipalAwareQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PrincipalAwareQuery.php
@@ -31,6 +31,12 @@ class PrincipalAwareQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
 		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( PrincipalAwareQueryCommand::class );

 		$execute_args               = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PublicWidgetAccess.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PublicWidgetAccess.php
new file mode 100644
index 00000000000..9c3c66a9a66
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PublicWidgetAccess.php
@@ -0,0 +1,61 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries;
+
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\PublicWidgetAccess as PublicWidgetAccessCommand;
+use Automattic\WooCommerce\Api\Infrastructure\QueryInfoExtractor;
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output\Widget as WidgetType;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\ResolveInfo;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
+
+class PublicWidgetAccess {
+	public static function get_field_definition(): array {
+		return array(
+			'type'          => WidgetType::get(),
+			'description'   => __( 'Fetch a widget without query-level gating; field-level gates apply.', 'woocommerce' ),
+			'authorization' => array(
+				array(
+					'attribute' => 'PublicAccess',
+					'args'      => array(),
+				),
+			),
+			'args'          => array(),
+			'resolve'       => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		// Publish the root query's metadata so downstream field-level
+		// authorization gates can read it via `$_metadata['query']`.
+		// $context is an ArrayObject (see GraphQLController::process_request())
+		// so the mutation propagates to nested resolvers.
+		$context['_query_metadata'] = array();
+
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( PublicWidgetAccessCommand::class );
+
+		$execute_args = array();
+
+		$result = ResolverHelpers::execute_command( $command, $execute_args );
+
+		return $result;
+	}
+
+	/**
+	 * Compute the value `_preauthorized` would carry for a given principal —
+	 * the AND of the autodiscovered authorization attributes' authorize()
+	 * outcomes on this command. Single source of truth for both the resolver's
+	 * own gates and external (code-API) callers asking about authorization
+	 * without going through GraphQL execution.
+	 *
+	 * Returns true vacuously when the command has no authorization attributes
+	 * (in that case authorize() on the command is the sole guard, and that
+	 * method should be consulted instead).
+	 */
+	public static function compute_preauthorized( \Automattic\WooCommerce\Api\Infrastructure\Principal $principal ): bool {
+		return true;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/RuntimeMetaProbe.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/RuntimeMetaProbe.php
new file mode 100644
index 00000000000..d75e5c44fca
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLTypes/Output/RuntimeMetaProbe.php
@@ -0,0 +1,102 @@
+<?php
+declare(strict_types=1);
+
+// THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Output;
+
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType;
+use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;
+
+class RuntimeMetaProbe {
+	private static ?ObjectType $instance = null;
+
+	public static function get(): ObjectType {
+		if ( null === self::$instance ) {
+			self::$instance = new ObjectType(
+				array(
+					'name'   => 'RuntimeMetaProbe',
+					'fields' => fn() => array(
+						'by_type'  => array(
+							'type'          => Type::string(),
+							'authorization' => array(
+								array(
+									'attribute' => 'GrantsIfMetadataFlag',
+									'args'      => array(
+										0 => 'type',
+									),
+								),
+							),
+							'resolve'       => function ( $parent, $args, $context ) {
+								$principal = $context['principal'];
+								$_metadata = array(
+									'query' => $context['_query_metadata'] ?? array(),
+									'type'  => array(
+										'runtime_flag' => true,
+									),
+									'field' => array(),
+								);
+								$_args     = $args;
+								$_parent   = $parent;
+								if ( ! ( ( new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\GrantsIfMetadataFlag( 'type' ) )->authorize( $principal, _metadata: $_metadata ) ) ) {
+									throw ResolverHelpers::build_field_authorization_error( $principal, 'RuntimeMetaProbe', 'by_type', 'GrantsIfMetadataFlag' );
+								}
+																return $parent->by_type;
+							},
+						),
+						'by_field' => array(
+							'type'    => Type::string(),
+							'resolve' => function ( $parent, $args, $context ) {
+								$principal = $context['principal'];
+								$_metadata = array(
+									'query' => $context['_query_metadata'] ?? array(),
+									'type'  => array(
+										'runtime_flag' => true,
+									),
+									'field' => array(
+										'runtime_flag' => true,
+									),
+								);
+								$_args     = $args;
+								$_parent   = $parent;
+								if ( ! ( ( new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\GrantsIfMetadataFlag( 'field' ) )->authorize( $principal, _metadata: $_metadata ) ) ) {
+									throw ResolverHelpers::build_field_authorization_error( $principal, 'RuntimeMetaProbe', 'by_field', 'GrantsIfMetadataFlag' );
+								}
+																return $parent->by_field;
+							},
+						),
+						'by_query' => array(
+							'type'          => Type::string(),
+							'authorization' => array(
+								array(
+									'attribute' => 'GrantsIfMetadataFlag',
+									'args'      => array(
+										0 => 'query',
+									),
+								),
+							),
+							'resolve'       => function ( $parent, $args, $context ) {
+								$principal = $context['principal'];
+								$_metadata = array(
+									'query' => $context['_query_metadata'] ?? array(),
+									'type'  => array(
+										'runtime_flag' => true,
+									),
+									'field' => array(),
+								);
+								$_args     = $args;
+								$_parent   = $parent;
+								if ( ! ( ( new \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Attributes\GrantsIfMetadataFlag( 'query' ) )->authorize( $principal, _metadata: $_metadata ) ) ) {
+									throw ResolverHelpers::build_field_authorization_error( $principal, 'RuntimeMetaProbe', 'by_query', 'GrantsIfMetadataFlag' );
+								}
+																return $parent->by_query;
+							},
+						),
+					),
+				)
+			);
+		}
+		return self::$instance;
+	}
+}
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 8e9cd75b08c..f4fa31ea6a3 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
@@ -11,6 +11,7 @@ use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\Gra
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Pagination\WidgetReviewConnection as WidgetReviewConnectionType;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Scalars\DummyDateTime as DummyDateTimeType;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLTypes\Interfaces\Named as NamedInterface;
+use Automattic\WooCommerce\Api\Infrastructure\ResolverHelpers;
 use Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType;
 use Automattic\WooCommerce\Api\Infrastructure\Schema\Type;

@@ -32,8 +33,35 @@ class Widget {
 							'description' => __( 'A short slug', 'woocommerce' ),
 						),
 						'caption'          => array(
-							'type'        => Type::string(),
-							'description' => __( 'An optional caption', 'woocommerce' ),
+							'type'          => Type::string(),
+							'description'   => __( 'An optional caption', 'woocommerce' ),
+							'metadata'      => array(
+								'visible_sample' => 'visible',
+							),
+							'authorization' => array(
+								array(
+									'attribute' => 'RequiredCapability',
+									'args'      => array(
+										0 => 'manage_woocommerce',
+									),
+								),
+							),
+							'resolve'       => function ( $parent, $args, $context ) {
+								$principal = $context['principal'];
+								$_metadata = array(
+									'query' => $context['_query_metadata'] ?? array(),
+									'type'  => array(),
+									'field' => array(
+										'visible_sample' => 'visible',
+									),
+								);
+								$_args     = $args;
+								$_parent   = $parent;
+								if ( ! ( ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'manage_woocommerce' ) )->authorize( $principal ) ) ) {
+									throw ResolverHelpers::build_field_authorization_error( $principal, 'Widget', 'caption', 'RequiredCapability' );
+								}
+																return $parent->caption;
+							},
 						),
 						'color'            => array(
 							'type'        => Type::nonNull( ColorType::get() ),
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootMutationType.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootMutationType.php
index 218c7615d56..442384b392a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootMutationType.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/RootMutationType.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerat

 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations\Increment;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations\CreateWidget;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations\PublicCreateWidget;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLMutations\DeleteWidget;
 use Automattic\WooCommerce\Api\Infrastructure\Schema\ObjectType;

@@ -19,9 +20,10 @@ class RootMutationType {
 				array(
 					'name'   => 'Mutation',
 					'fields' => fn() => array(
-						'increment'    => Increment::get_field_definition(),
-						'createWidget' => CreateWidget::get_field_definition(),
-						'deleteWidget' => DeleteWidget::get_field_definition(),
+						'increment'          => Increment::get_field_definition(),
+						'createWidget'       => CreateWidget::get_field_definition(),
+						'publicCreateWidget' => PublicCreateWidget::get_field_definition(),
+						'deleteWidget'       => DeleteWidget::get_field_definition(),
 					),
 				)
 			);
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 94c43ee40f8..e27b1c8bd43 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
@@ -12,13 +12,18 @@ use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\Gra
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\MergedCapsQuery;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\InheritedPublicQuery;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\GetWidget;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\PublicWidgetAccess;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\AuthorizeThrowsQuery;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\PrincipalAwareQuery;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\ComposedAuthorizeQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\MetadataAwareInternalQuery;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\IgnoredAuthorizeQuery;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\OverriddenAuthorizeQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\MetadataAwareNoFlagQuery;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\AuthorizeOnlyQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\HiddenFlaggedQuery;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\FailingQuery;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\ListWidgetsArray;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\ListWidgets;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\GetGreeting;
 use Automattic\WooCommerce\Api\Infrastructure\MetadataController;
@@ -40,13 +45,18 @@ class RootQueryType {
 						'mergedCaps'                   => MergedCapsQuery::get_field_definition(),
 						'inheritedPublic'              => InheritedPublicQuery::get_field_definition(),
 						'widget'                       => GetWidget::get_field_definition(),
+						'publicWidget'                 => PublicWidgetAccess::get_field_definition(),
 						'authorizeThrows'              => AuthorizeThrowsQuery::get_field_definition(),
 						'principalAware'               => PrincipalAwareQuery::get_field_definition(),
 						'composedAuthorize'            => ComposedAuthorizeQuery::get_field_definition(),
+						'metadataAwareInternalQuery'   => MetadataAwareInternalQuery::get_field_definition(),
 						'ignoredAuthorize'             => IgnoredAuthorizeQuery::get_field_definition(),
 						'overriddenAuthorize'          => OverriddenAuthorizeQuery::get_field_definition(),
+						'metadataAwareNoFlagQuery'     => MetadataAwareNoFlagQuery::get_field_definition(),
 						'authorizeOnly'                => AuthorizeOnlyQuery::get_field_definition(),
+						'hiddenFlagged'                => HiddenFlaggedQuery::get_field_definition(),
 						'failing'                      => FailingQuery::get_field_definition(),
+						'widgetList'                   => ListWidgetsArray::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 ce84cafa7c8..bc8bcfebc84 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-19T11:37:11+00:00
\ No newline at end of file
+2026-05-21T11:44:02+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_source_hash.txt b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_source_hash.txt
index 80286b03311..a2a92acab36 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_source_hash.txt
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/api_source_hash.txt
@@ -1 +1 @@
-ec363c5fbb3e6b00910ae834390b114cba4d75123da6217f239a95618e5a270f
\ No newline at end of file
+f18a5e93805642a14bacb2ad90ab3c7db3e1731a75187d032992eaa872f67533
\ 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
index b2abdd73d52..5f1af955ca3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/MetadataAttributeTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/MetadataAttributeTest.php
@@ -65,4 +65,28 @@ class MetadataAttributeTest extends WC_Unit_Test_Case {
 		$attribute = $attributes[0]->newInstance();
 		$this->assertNotSame( 0, $attribute->flags & \Attribute::IS_REPEATABLE );
 	}
+
+	/**
+	 * @testdox Metadata::shows_in_metadata_query() defaults to true so existing entries surface through `_apiMetadata`.
+	 */
+	public function test_shows_in_metadata_query_defaults_to_true(): void {
+		$metadata = new Metadata( 'sample', 'value' );
+		$this->assertTrue( $metadata->shows_in_metadata_query() );
+	}
+
+	/**
+	 * @testdox A Metadata subclass can override shows_in_metadata_query() to opt out of `_apiMetadata` exposure.
+	 */
+	public function test_shows_in_metadata_query_can_be_overridden_to_false(): void {
+		$hidden = new class('hidden', 'value') extends Metadata {
+			/**
+			 * Opt the carrying target out of the `_apiMetadata` query.
+			 */
+			public function shows_in_metadata_query(): bool {
+				return false;
+			}
+		};
+
+		$this->assertFalse( $hidden->shows_in_metadata_query() );
+	}
 }