Commit 46a71c5b6cc for woocommerce

commit 46a71c5b6ccd3181c2b307fe26a4290bd6f48609
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Thu May 7 14:38:53 2026 +0200

    Flexibilize the dual API authentication and authorization mechanism (#64647)

    Flexibilize authentication and authorization for the dual code+GraphQL API

    Reworks the dual API's auth surface so plugins reusing the GraphQL
    infrastructure can authenticate against something other than WordPress
    users and authorize against something other than WP capabilities,
    without giving up the WP-user defaults that WooCommerce core itself
    relies on.

diff --git a/plugins/woocommerce/changelog/flexibilize-graphql-auth b/plugins/woocommerce/changelog/flexibilize-graphql-auth
new file mode 100644
index 00000000000..03f384fdd19
--- /dev/null
+++ b/plugins/woocommerce/changelog/flexibilize-graphql-auth
@@ -0,0 +1,6 @@
+Significance: minor
+Type: dev
+
+Add extra flexibility to the authentication and authorization stages of the dual code+GraphQL API.
+
+
diff --git a/plugins/woocommerce/src/Api/Attributes/PublicAccess.php b/plugins/woocommerce/src/Api/Attributes/PublicAccess.php
index ef9b5d51257..1f10cd5dd5d 100644
--- a/plugins/woocommerce/src/Api/Attributes/PublicAccess.php
+++ b/plugins/woocommerce/src/Api/Attributes/PublicAccess.php
@@ -9,11 +9,15 @@ use Attribute;
 /**
  * Marks a query or mutation as publicly accessible without authentication.
  *
- * When present, the generated resolver skips all capability checks, allowing
- * any user (including unauthenticated visitors) to execute the operation.
- *
- * Mutually exclusive with #[RequiredCapability] on the same class.
+ * Mutually exclusive with #[RequiredCapability] (and any other authorization
+ * attribute) on the same class.
  */
 #[Attribute( Attribute::TARGET_CLASS )]
 final class PublicAccess {
+	/**
+	 * Always grants access.
+	 */
+	public function authorize(): bool {
+		return true;
+	}
 }
diff --git a/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php b/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php
index 27459014277..d229d5df9bf 100644
--- a/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php
+++ b/plugins/woocommerce/src/Api/Attributes/RequiredCapability.php
@@ -5,14 +5,13 @@ declare(strict_types=1);
 namespace Automattic\WooCommerce\Api\Attributes;

 use Attribute;
+use Automattic\WooCommerce\Api\Infrastructure\Principal;

 /**
  * Declares a WordPress capability required to execute a query or mutation.
  *
- * The generated resolver checks `current_user_can()` for every declared
- * capability before invoking the command. If any check fails, an
- * UNAUTHORIZED error is returned. This attribute is repeatable: apply it
- * multiple times to require several capabilities.
+ * This attribute is repeatable: apply it multiple times to require several
+ * capabilities (logical AND).
  *
  * Mutually exclusive with #[PublicAccess] on the same class.
  */
@@ -28,4 +27,17 @@ final class RequiredCapability {
 		public readonly string $capability,
 	) {
 	}
+
+	/**
+	 * Decide whether the given principal holds the required capability.
+	 *
+	 * Reads the WordPress user from the principal wrapper and delegates to
+	 * {@see \user_can()}. Anonymous principals (the WP user has `ID === 0`)
+	 * never hold any capability, so the check returns false naturally.
+	 *
+	 * @param Principal $principal The resolved request principal.
+	 */
+	public function authorize( Principal $principal ): bool {
+		return user_can( $principal->user, $this->capability );
+	}
 }
diff --git a/plugins/woocommerce/src/Api/AuthorizationException.php b/plugins/woocommerce/src/Api/AuthorizationException.php
deleted file mode 100644
index d8cc405d2b4..00000000000
--- a/plugins/woocommerce/src/Api/AuthorizationException.php
+++ /dev/null
@@ -1,26 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Automattic\WooCommerce\Api;
-
-/**
- * Thrown from an authorize() method to deny access with a custom error message.
- *
- * Uses a fixed UNAUTHORIZED error code and 401 status. The message defaults to
- * a generic denial but can be overridden for more specific feedback.
- */
-class AuthorizationException extends ApiException {
-	/**
-	 * Constructor.
-	 *
-	 * @param string      $message  The error message.
-	 * @param ?\Throwable $previous The previous throwable for chaining.
-	 */
-	public function __construct(
-		string $message = 'You do not have permission to perform this action.',
-		?\Throwable $previous = null,
-	) {
-		parent::__construct( $message, 'UNAUTHORIZED', array(), 401, $previous );
-	}
-}
diff --git a/plugins/woocommerce/src/Api/Container.php b/plugins/woocommerce/src/Api/Container.php
deleted file mode 100644
index fe4a2ae2193..00000000000
--- a/plugins/woocommerce/src/Api/Container.php
+++ /dev/null
@@ -1,31 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Automattic\WooCommerce\Api;
-
-/**
- * Resolver container for code-API command classes.
- *
- * Autogenerated GraphQL query and mutation resolvers look up the corresponding
- * code-API command through {@see self::get()}. WooCommerce core delegates every
- * lookup to the main WooCommerce DI container.
- *
- * Sibling plugins that reuse the GraphQL infrastructure and want their command
- * classes instantiated through a container of their own can ship their own
- * Container class at `<plugin-api-namespace>\Container` with the same public
- * signature: ApiBuilder detects it during generation and routes the generated
- * resolvers through it. When no such class is present, resolvers fall back to
- * `new $command_class()`.
- */
-final class Container {
-	/**
-	 * Resolve a command class to an instance.
-	 *
-	 * @param string $class_name Fully-qualified name of a code-API command class.
-	 * @return object An instance of $class_name.
-	 */
-	public static function get( string $class_name ): object {
-		return wc_get_container()->get( $class_name );
-	}
-}
diff --git a/plugins/woocommerce/src/Api/ForbiddenException.php b/plugins/woocommerce/src/Api/ForbiddenException.php
new file mode 100644
index 00000000000..9c3ea7b3fc1
--- /dev/null
+++ b/plugins/woocommerce/src/Api/ForbiddenException.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api;
+
+/**
+ * Thrown to signal that the caller is authenticated but lacks permission to
+ * perform the requested operation, e.g. the right user but the wrong role,
+ * scope, or capability.
+ *
+ * Use this for "I know who you are, but you can't do this." For "you need to
+ * authenticate first," prefer {@see UnauthorizedException}.
+ *
+ * Wire shape: `extensions.code = 'FORBIDDEN'`, HTTP status 403.
+ */
+class ForbiddenException extends ApiException {
+	/**
+	 * Constructor.
+	 *
+	 * @param string      $message    The error message.
+	 * @param array       $extensions Additional error metadata to surface in the GraphQL `extensions` object.
+	 * @param ?\Throwable $previous   The previous throwable for chaining.
+	 */
+	public function __construct(
+		string $message = 'Forbidden.',
+		array $extensions = array(),
+		?\Throwable $previous = null,
+	) {
+		parent::__construct( $message, 'FORBIDDEN', $extensions, 403, $previous );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Infrastructure/ClassResolver.php b/plugins/woocommerce/src/Api/Infrastructure/ClassResolver.php
new file mode 100644
index 00000000000..ce96ff2b8fd
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Infrastructure/ClassResolver.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Infrastructure;
+
+/**
+ * Class resolver for code-API command classes and other infrastructure classes.
+ *
+ * Plugins that implement their own API and want their command and infrastructure
+ * classes instantiated through a container of their own can ship their own
+ * ClassResolver class at `<plugin-api-namespace>\Infrastructure\ClassResolver`
+ * with the same public signature: ApiBuilder detects it during generation
+ * and routes the generated resolvers through it. When no such class is present,
+ * resolvers fall back to `new $class_name()`.
+ */
+final class ClassResolver {
+	/**
+	 * Resolve a class to an instance.
+	 *
+	 * @param string $class_name Fully qualified name of the class to resolve.
+	 * @return object An instance of $class_name.
+	 */
+	public static function resolve_class( string $class_name ): object {
+		return wc_get_container()->get( $class_name );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Infrastructure/Principal.php b/plugins/woocommerce/src/Api/Infrastructure/Principal.php
new file mode 100644
index 00000000000..d16bd5d4208
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Infrastructure/Principal.php
@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Infrastructure;
+
+/**
+ * Default principal class for the WooCommerce dual code+GraphQL API.
+ *
+ * Plugins that authenticate against something other than WordPress users must ship
+ * their own principal class at `<plugin-api-namespace>\Infrastructure\...` together
+ * with a matching `PrincipalResolver`. Plugins that build on WP-user auth can either
+ * use this class directly (no resolver needed; the controller falls back to
+ * `new Principal( wp_get_current_user() )`) or extend it to add their own
+ * fields.
+ */
+class Principal {
+	/**
+	 * Constructor.
+	 *
+	 * @param \WP_User $user The WordPress user behind the request. For anonymous requests this is a `WP_User` with `ID === 0`, as returned by {@see \wp_get_current_user()}.
+	 */
+	public function __construct(
+		public readonly \WP_User $user,
+	) {
+	}
+
+	/**
+	 * Whether the underlying WP user is authenticated.
+	 *
+	 * Convenience for `$principal->user->ID > 0`, the canonical anonymous
+	 * marker in WordPress. Use this in `authorize()` / `execute()` bodies that
+	 * need to distinguish anonymous from authenticated callers.
+	 */
+	public function is_authenticated(): bool {
+		return $this->user->ID > 0;
+	}
+
+	/**
+	 * Whether this principal may run GraphQL schema introspection on the endpoint.
+	 *
+	 * Implementing `can_introspect()` is opt-in for plugin principal classes,
+	 * a principal that doesn't define it is denied by default. Plugins building
+	 * authenticated endpoints should make an explicit decision per principal
+	 * model rather than inheriting an introspection policy by accident.
+	 */
+	public function can_introspect(): bool {
+		return user_can( $this->user, 'manage_woocommerce' );
+	}
+
+	/**
+	 * Whether this principal may activate GraphQL debug mode on the endpoint.
+	 *
+	 * Implementing `can_use_debug_mode()` is opt-in for plugin principal classes,
+	 * a principal that doesn't define it is denied by default. Plugins building
+	 * authenticated endpoints should make an explicit decision per principal
+	 * model rather than inheriting a debug mode policy by accident.
+	 *
+	 * Note that this method's outcome is necessary but not sufficient for debug
+	 * mode to be active: the controller also requires either `WP_DEBUG` to be
+	 * defined and true, or the request to carry `_debug=1`. There's also a
+	 * developer escape hatch that bypasses this method entirely — when the
+	 * controller's `is_local_environment()` check is true, debug mode is
+	 * available without consulting the principal.
+	 */
+	public function can_use_debug_mode(): bool {
+		return user_can( $this->user, 'manage_options' );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Infrastructure/PrincipalResolver.php b/plugins/woocommerce/src/Api/Infrastructure/PrincipalResolver.php
new file mode 100644
index 00000000000..8353002f1c0
--- /dev/null
+++ b/plugins/woocommerce/src/Api/Infrastructure/PrincipalResolver.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api\Infrastructure;
+
+/**
+ * Default principal resolver for the WooCommerce dual code+GraphQL API.
+ *
+ * Plugins that implement their own API and authenticate against something
+ * other than WordPress users (e.g. an app token) must ship their own resolver
+ * at `<plugin-api-namespace>\Infrastructure\PrincipalResolver` with a
+ * `resolve_principal( \WP_REST_Request ): T` (or zero-arg) method whose
+ * return type is the plugin's own principal class. ApiBuilder detects it
+ * during generation and routes the autogenerated controller through it.
+ *
+ * The \WP_REST_Request parameter is optional; the default resolver doesn't
+ * inspect headers (WordPress's auth pipeline has already populated the global
+ * current user by the time this fires).
+ */
+final class PrincipalResolver {
+	/**
+	 * Resolve the request principal.
+	 *
+	 * Anonymous requests are signalled by a Principal whose underlying
+	 * `WP_User` has `ID === 0` (see {@see Principal::is_authenticated()}).
+	 */
+	public function resolve_principal(): Principal {
+		return new Principal( wp_get_current_user() );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/InvalidTokenException.php b/plugins/woocommerce/src/Api/InvalidTokenException.php
new file mode 100644
index 00000000000..b9dd509f86e
--- /dev/null
+++ b/plugins/woocommerce/src/Api/InvalidTokenException.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api;
+
+/**
+ * Thrown to signal that authentication credentials were supplied but are
+ * invalid, e.g. an unrecognised API token, a malformed Authorization header,
+ * or expired credentials.
+ *
+ * Use this when the caller *did* attempt to authenticate but the credentials
+ * themselves were rejected. For "no credentials at all" use
+ * {@see UnauthorizedException}.
+ *
+ * Wire shape: `extensions.code = 'INVALID_TOKEN'`, HTTP status 401.
+ */
+class InvalidTokenException extends ApiException {
+	/**
+	 * Constructor.
+	 *
+	 * @param string      $message    The error message.
+	 * @param array       $extensions Additional error metadata to surface in the GraphQL `extensions` object.
+	 * @param ?\Throwable $previous   The previous throwable for chaining.
+	 */
+	public function __construct(
+		string $message = 'Invalid credentials.',
+		array $extensions = array(),
+		?\Throwable $previous = null,
+	) {
+		parent::__construct( $message, 'INVALID_TOKEN', $extensions, 401, $previous );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/NotFoundException.php b/plugins/woocommerce/src/Api/NotFoundException.php
new file mode 100644
index 00000000000..fdcc4488fd8
--- /dev/null
+++ b/plugins/woocommerce/src/Api/NotFoundException.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api;
+
+/**
+ * Thrown to signal that the requested resource doesn't exist.
+ *
+ * Note: when the existence of a resource is itself sensitive (e.g. an order
+ * the caller has no business knowing about), prefer {@see UnauthorizedException}
+ * instead: leaking a 404 vs 401 distinction lets callers probe for resource
+ * existence.
+ *
+ * Wire shape: `extensions.code = 'NOT_FOUND'`, HTTP status 404.
+ */
+class NotFoundException extends ApiException {
+	/**
+	 * Constructor.
+	 *
+	 * @param string      $message    The error message.
+	 * @param array       $extensions Additional error metadata to surface in the GraphQL `extensions` object.
+	 * @param ?\Throwable $previous   The previous throwable for chaining.
+	 */
+	public function __construct(
+		string $message = 'Resource not found.',
+		array $extensions = array(),
+		?\Throwable $previous = null,
+	) {
+		parent::__construct( $message, 'NOT_FOUND', $extensions, 404, $previous );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/Queries/Products/GetProduct.php b/plugins/woocommerce/src/Api/Queries/Products/GetProduct.php
index e6b4d35f804..79626cafd26 100644
--- a/plugins/woocommerce/src/Api/Queries/Products/GetProduct.php
+++ b/plugins/woocommerce/src/Api/Queries/Products/GetProduct.php
@@ -8,14 +8,14 @@ use Automattic\WooCommerce\Api\Attributes\Description;
 use Automattic\WooCommerce\Api\Attributes\Name;
 use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
 use Automattic\WooCommerce\Api\Attributes\ReturnType;
-use Automattic\WooCommerce\Api\AuthorizationException;
+use Automattic\WooCommerce\Api\UnauthorizedException;
 use Automattic\WooCommerce\Api\Interfaces\Product;
 use Automattic\WooCommerce\Api\Utils\Products\ProductMapper;

 /**
  * Query to retrieve a single product by ID.
  *
- * Demonstrates: authorize(), $_query_info, AuthorizationException.
+ * Demonstrates: authorize(), $_query_info, UnauthorizedException.
  *
  * Authorization logic: admins (manage_woocommerce) can read any product,
  * non-admin users can only read their own products.
@@ -30,7 +30,7 @@ class GetProduct {
 	 * Admins can read any product. Non-admin users can only read products
 	 * they authored themselves.
 	 *
-	 * Every inaccessible case throws `AuthorizationException('Product not
+	 * Every inaccessible case throws `UnauthorizedException('Product not
 	 * found.')` — whether the ID doesn't exist, points at a non-product
 	 * post type, or points at a product the caller doesn't own. This
 	 * prevents callers from enumerating product IDs vs non-product post
@@ -40,7 +40,7 @@ class GetProduct {
 	 * @param int  $id              The product ID.
 	 * @param bool $_preauthorized  Whether the declared capability check passed.
 	 * @return bool Whether the current user can read this product.
-	 * @throws AuthorizationException When the product is not accessible.
+	 * @throws UnauthorizedException When the product is not accessible.
 	 */
 	public function authorize( int $id, bool $_preauthorized ): bool {
 		// Reject non-positive IDs up front. `get_post( 0 )` inside a
@@ -48,13 +48,13 @@ class GetProduct {
 		// `get_post( $id )` below would accidentally operate on whatever
 		// global post was set upstream of this request.
 		if ( $id <= 0 ) {
-			throw new AuthorizationException( 'Product not found.' );
+			throw new UnauthorizedException( 'Product not found.' );
 		}

 		$post = get_post( $id );

 		if ( ! $post || 'product' !== $post->post_type ) {
-			throw new AuthorizationException( 'Product not found.' );
+			throw new UnauthorizedException( 'Product not found.' );
 		}

 		// Honor the declared #[RequiredCapability] (read_product).
@@ -87,7 +87,7 @@ class GetProduct {
 		// anonymous callers for those products.
 		$current_user_id = get_current_user_id();
 		if ( 0 === $current_user_id || $current_user_id !== (int) $post->post_author ) {
-			throw new AuthorizationException( 'Product not found.' );
+			throw new UnauthorizedException( 'Product not found.' );
 		}

 		return true;
diff --git a/plugins/woocommerce/src/Api/UnauthorizedException.php b/plugins/woocommerce/src/Api/UnauthorizedException.php
new file mode 100644
index 00000000000..f75c27fb2f2
--- /dev/null
+++ b/plugins/woocommerce/src/Api/UnauthorizedException.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api;
+
+/**
+ * Thrown to deny access with a 401 Unauthorized status.
+ *
+ * Use when authentication is required but missing, or when an `authorize()`
+ * method needs to deny access without distinguishing further. For credentials
+ * that are present but rejected, prefer {@see InvalidTokenException}; for
+ * "authenticated but not allowed", prefer {@see ForbiddenException}.
+ *
+ * Wire shape: `extensions.code = 'UNAUTHORIZED'`, HTTP status 401.
+ */
+class UnauthorizedException extends ApiException {
+	/**
+	 * Constructor.
+	 *
+	 * @param string      $message    The error message.
+	 * @param array       $extensions Additional error metadata to surface in the GraphQL `extensions` object.
+	 * @param ?\Throwable $previous   The previous throwable for chaining.
+	 */
+	public function __construct(
+		string $message = 'Authentication required.',
+		array $extensions = array(),
+		?\Throwable $previous = null,
+	) {
+		parent::__construct( $message, 'UNAUTHORIZED', $extensions, 401, $previous );
+	}
+}
diff --git a/plugins/woocommerce/src/Api/ValidationException.php b/plugins/woocommerce/src/Api/ValidationException.php
new file mode 100644
index 00000000000..1bebbf5ee67
--- /dev/null
+++ b/plugins/woocommerce/src/Api/ValidationException.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Api;
+
+/**
+ * Thrown to signal that input is well-formed but failed business-rule
+ * validation, e.g. a required field was empty, two fields contradict each
+ * other, a value violates a domain constraint.
+ *
+ * For purely structural input errors (wrong type, malformed shape) prefer
+ * letting the framework's `\InvalidArgumentException` handling do the work:
+ * `Utils::translate_exceptions()` already maps it to `INVALID_ARGUMENT` (400).
+ *
+ * Wire shape: `extensions.code = 'VALIDATION_ERROR'`, HTTP status 422.
+ */
+class ValidationException extends ApiException {
+	/**
+	 * Constructor.
+	 *
+	 * @param string      $message    The error message.
+	 * @param array       $extensions Additional error metadata to surface in the GraphQL `extensions` object.
+	 * @param ?\Throwable $previous   The previous throwable for chaining.
+	 */
+	public function __construct(
+		string $message = 'Validation failed.',
+		array $extensions = array(),
+		?\Throwable $previous = null,
+	) {
+		parent::__construct( $message, 'VALIDATION_ERROR', $extensions, 422, $previous );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLController.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLController.php
index c04a4295e19..ad23dca4df8 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLController.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLController.php
@@ -17,4 +17,16 @@ class GraphQLController extends \Automattic\WooCommerce\Internal\Api\GraphQLCont
 			)
 		);
 	}
+
+	protected function get_class_resolver_fqcn(): ?string {
+		return \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::class;
+	}
+
+	protected function get_principal_resolver_fqcn(): ?string {
+		return \Automattic\WooCommerce\Api\Infrastructure\PrincipalResolver::class;
+	}
+
+	protected function principal_resolver_takes_request(): bool {
+		return false;
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php
index d3fbad4c49a..1a1dbaa3013 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateCoupon.php
@@ -29,9 +29,13 @@ class CreateCoupon {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_woocommerce' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Api\Container::get( CreateCouponCommand::class );
+		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( CreateCouponCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'input', $args ) ) {
@@ -43,6 +47,21 @@ class CreateCoupon {
 		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 ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'manage_woocommerce' ) )->authorize( $principal );
+	}
+
 	private static function convert_create_coupon_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Coupons\CreateCouponInput {
 		$input = new \Automattic\WooCommerce\Api\InputTypes\Coupons\CreateCouponInput();

diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php
index 5a3344ffd27..4eb3c06d60f 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/CreateProduct.php
@@ -29,9 +29,13 @@ class CreateProduct {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'edit_products' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Api\Container::get( CreateProductCommand::class );
+		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( CreateProductCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'input', $args ) ) {
@@ -43,6 +47,21 @@ class CreateProduct {
 		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 ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'edit_products' ) )->authorize( $principal );
+	}
+
 	private static function convert_dimensions_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Products\DimensionsInput {
 		$input = new \Automattic\WooCommerce\Api\InputTypes\Products\DimensionsInput();

diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php
index 05e242e2f79..8895b1f24e9 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteCoupon.php
@@ -33,9 +33,13 @@ class DeleteCoupon {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_woocommerce' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Api\Container::get( DeleteCouponCommand::class );
+		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( DeleteCouponCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'id', $args ) ) {
@@ -49,4 +53,19 @@ class DeleteCoupon {

 		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 ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'manage_woocommerce' ) )->authorize( $principal );
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php
index 7790f9bf36e..0c8c43afd68 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/DeleteProduct.php
@@ -41,9 +41,13 @@ class DeleteProduct {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_woocommerce' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Api\Container::get( DeleteProductCommand::class );
+		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( DeleteProductCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'id', $args ) ) {
@@ -57,4 +61,19 @@ class DeleteProduct {

 		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\Api\Attributes\RequiredCapability( 'manage_woocommerce' ) )->authorize( $principal );
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php
index 86c284a2a36..a01fdff954d 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateCoupon.php
@@ -29,9 +29,13 @@ class UpdateCoupon {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_woocommerce' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Api\Container::get( UpdateCouponCommand::class );
+		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( UpdateCouponCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'input', $args ) ) {
@@ -43,6 +47,21 @@ class UpdateCoupon {
 		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 ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'manage_woocommerce' ) )->authorize( $principal );
+	}
+
 	private static function convert_update_coupon_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Coupons\UpdateCouponInput {
 		$input = new \Automattic\WooCommerce\Api\InputTypes\Coupons\UpdateCouponInput();

diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php
index c3fb86c5952..015ffd1c68f 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLMutations/UpdateProduct.php
@@ -29,9 +29,13 @@ class UpdateProduct {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_woocommerce' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Api\Container::get( UpdateProductCommand::class );
+		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( UpdateProductCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'input', $args ) ) {
@@ -43,6 +47,21 @@ class UpdateProduct {
 		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 ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'manage_woocommerce' ) )->authorize( $principal );
+	}
+
 	private static function convert_dimensions_input( array $data ): \Automattic\WooCommerce\Api\InputTypes\Products\DimensionsInput {
 		$input = new \Automattic\WooCommerce\Api\InputTypes\Products\DimensionsInput();

diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php
index fe649064cfc..3668feb20be 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetCoupon.php
@@ -34,9 +34,13 @@ class GetCoupon {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'read_private_shop_coupons' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Api\Container::get( GetCouponCommand::class );
+		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( GetCouponCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'id', $args ) ) {
@@ -50,4 +54,19 @@ class GetCoupon {

 		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 ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'read_private_shop_coupons' ) )->authorize( $principal );
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php
index f69702986f4..1173d72f47c 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/GetProduct.php
@@ -28,29 +28,42 @@ class GetProduct {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		$command = \Automattic\WooCommerce\Api\Container::get( GetProductCommand::class );
+		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( GetProductCommand::class );

+		$query_info   = QueryInfoExtractor::extract_from_info( $info, $args );
 		$execute_args = array();
 		if ( array_key_exists( 'id', $args ) ) {
 			$execute_args['id'] = $args['id'];
 		}
-		$execute_args['_query_info'] = QueryInfoExtractor::extract_from_info( $info, $args );
+		$execute_args['_query_info'] = $query_info;

 		if ( ! Utils::authorize_command(
 			$command,
 			array(
 				'id'             => $execute_args['id'],
-				'_preauthorized' => current_user_can( 'read_product' ),
+				'_preauthorized' => self::compute_preauthorized( $context['principal'] ),
 			)
 		) ) {
-			throw new \Automattic\WooCommerce\Internal\Api\Schema\Error(
-				'You do not have permission to perform this action.',
-				extensions: array( 'code' => 'UNAUTHORIZED' )
-			);
+			throw Utils::build_authorization_error( $context['principal'] );
 		}

 		$result = Utils::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 ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'read_product' ) )->authorize( $principal );
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php
index a1a6563fc3d..1896ca25d64 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListCoupons.php
@@ -51,9 +51,13 @@ class ListCoupons {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'read_private_shop_coupons' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Api\Container::get( ListCouponsCommand::class );
+		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( ListCouponsCommand::class );

 		$execute_args               = array();
 		$execute_args['pagination'] = Utils::create_pagination_params( $args );
@@ -65,4 +69,19 @@ class ListCoupons {

 		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 ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'read_private_shop_coupons' ) )->authorize( $principal );
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php
index 84a57d886fa..c80522b35e0 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/GraphQLQueries/ListProducts.php
@@ -68,11 +68,15 @@ class ListProducts {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_woocommerce' );
-		Utils::check_current_user_can( 'edit_products' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Api\Container::get( ListProductsCommand::class );
+		$command = \Automattic\WooCommerce\Api\Infrastructure\ClassResolver::resolve_class( ListProductsCommand::class );

+		$query_info                 = QueryInfoExtractor::extract_from_info( $info, $args );
 		$execute_args               = array();
 		$execute_args['pagination'] = Utils::create_pagination_params( $args );
 		$execute_args['filters']    = Utils::create_input(
@@ -85,10 +89,25 @@ class ListProducts {
 		if ( array_key_exists( 'product_type', $args ) ) {
 			$execute_args['product_type'] = $args['product_type'];
 		}
-		$execute_args['_query_info'] = QueryInfoExtractor::extract_from_info( $info, $args );
+		$execute_args['_query_info'] = $query_info;

 		$result = Utils::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 ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'manage_woocommerce' ) )->authorize( $principal ) && ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability( 'edit_products' ) )->authorize( $principal );
+	}
 }
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 774b4294dea..b612678aa4d 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-04-24T07:48:05+00:00
\ No newline at end of file
+2026-05-06T16:48:36+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
index 7e6b529c747..e3c2a57f050 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Scripts/ApiBuilder.php
@@ -18,6 +18,7 @@ use Automattic\WooCommerce\Api\Attributes\RequiredCapability;
 use Automattic\WooCommerce\Api\Attributes\ReturnType;
 use Automattic\WooCommerce\Api\Attributes\ScalarType;
 use Automattic\WooCommerce\Api\Attributes\Unroll;
+use Automattic\WooCommerce\Api\Infrastructure\Principal;

 /**
  * Scans the public API classes and generates the GraphQL schema and resolver code.
@@ -163,8 +164,50 @@ class ApiBuilder {
 	/** @var array<string, string[]> Map of interface trait FQCN => list of output type FQCNs that use it */
 	private array $interface_implementors = array();

-	/** @var ?string Optional FQCN of a user-provided `<api_namespace>\Container` class with a public static `get(string): object` method; null when absent. */
-	private ?string $container_fqcn = null;
+	/** @var ?string Optional FQCN of a user-provided `<api_namespace>\Infrastructure\ClassResolver` class with a public static `resolve_class(string): object` method; null when absent. */
+	private ?string $class_resolver_fqcn = null;
+
+	/** @var ?string Optional FQCN of a user-provided `<api_namespace>\Infrastructure\PrincipalResolver` class; null when absent. */
+	private ?string $principal_resolver_fqcn = null;
+
+	/**
+	 * Whether the detected PrincipalResolver's `resolve_principal()` declares the
+	 * \WP_REST_Request parameter (true) or omits it (false).
+	 *
+	 * Captured at build time so the generated controller subclass can call the
+	 * resolver with the right arity without runtime reflection. Meaningless when
+	 * {@see self::$principal_resolver_fqcn} is null.
+	 */
+	private bool $principal_resolver_takes_request = false;
+
+	/**
+	 * Principal type captured from the detected PrincipalResolver's `resolve_principal()` return type.
+	 *
+	 * Either `'object'` (when a plugin's resolver explicitly returns `object`) or a class FQCN.
+	 * Used to type-check `_principal` parameters across commands and authorization attributes.
+	 * Defaults to {@see Principal} — the principal class shipped by core that the controller's
+	 * fallback returns when no plugin `PrincipalResolver` is detected — so the build-time check
+	 * matches the runtime payload (a `_principal: WP_User` parameter would otherwise validate
+	 * here but `TypeError` at runtime).
+	 *
+	 * @var string
+	 */
+	private string $principal_type = Principal::class;
+
+	/**
+	 * Map of FQCN => `['takes_principal' => bool]` 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.
+	 *
+	 * Populated by {@see self::discover_authorization_attributes()}.
+	 *
+	 * @var array<class-string, array{takes_principal: bool}>
+	 */
+	private array $authorization_attribute_fqcns = array();

 	// Counters for summary.
 	private int $query_count      = 0;
@@ -178,7 +221,9 @@ class ApiBuilder {
 	public function build( bool $skip_linter = false ): void {
 		echo "Scanning {$this->api_dir} for code API classes...\n";

-		$this->detect_container();
+		$this->detect_class_resolver();
+		$this->detect_principal_resolver();
+		$this->discover_authorization_attributes();
 		$this->discover();
 		$this->validate();

@@ -227,37 +272,43 @@ class ApiBuilder {
 	}

 	// ========================================================================
-	// Container detection
+	// ClassResolver detection
 	// ========================================================================

 	/**
-	 * Detect an optional user-provided resolver container.
+	 * Detect an optional user-provided class resolver.
 	 *
-	 * If a class `<api_namespace>\Container` exists and exposes a `public
-	 * static function get(string): object` method, autogenerated resolvers
-	 * route command lookups through it. Otherwise they fall back to
-	 * `new $command_class()`.
+	 * If a class `<api_namespace>\Infrastructure\ClassResolver` exists and exposes
+	 * a `public static function resolve_class(string): object` method, autogenerated
+	 * resolvers route command lookups through it. Other infrastructure classes
+	 * recognised by convention (currently {@see PrincipalResolver}) are also
+	 * resolved through it. Otherwise commands fall back to `new $command_class()`.
 	 *
-	 * WooCommerce core ships such a container at
-	 * `Automattic\WooCommerce\Api\Container` delegating to `wc_get_container()`.
-	 * Sibling plugins can ship their own with the same signature.
+	 * WooCommerce core ships such a resolver at
+	 * `Automattic\WooCommerce\Api\Infrastructure\ClassResolver` delegating to
+	 * `wc_get_container()`. Sibling plugins can ship their own with the same signature.
+	 *
+	 * Misshapen explicit-registration: a class at the conventional path that does
+	 * not match the expected signature is a hard build-time error rather than a
+	 * silent fallback — auth-related infrastructure is too important to let a
+	 * typo route through the default.
 	 */
-	private function detect_container(): void {
-		$candidate = $this->api_namespace . '\\Container';
+	private function detect_class_resolver(): void {
+		$candidate = $this->api_namespace . '\\Infrastructure\\ClassResolver';
 		if ( ! class_exists( $candidate ) ) {
 			return;
 		}
-		if ( ! method_exists( $candidate, 'get' ) ) {
-			$this->warnings[] = "Container class {$candidate} has no get() method; ignoring.";
+		if ( ! method_exists( $candidate, 'resolve_class' ) ) {
+			$this->errors[] = "ClassResolver class {$candidate} has no resolve_class() method.";
 			return;
 		}
-		$method = new \ReflectionMethod( $candidate, 'get' );
+		$method = new \ReflectionMethod( $candidate, 'resolve_class' );
 		if ( ! $method->isStatic() || ! $method->isPublic() ) {
-			$this->warnings[] = "Container class {$candidate}::get() must be public static; ignoring.";
+			$this->errors[] = "ClassResolver class {$candidate}::resolve_class() must be public static.";
 			return;
 		}

-		// Validate signature `get(string): object`. A wrong signature would pass
+		// Validate signature `resolve_class(string): object`. A wrong signature would pass
 		// the checks above but fail at request time inside the generated
 		// resolvers with a type error far removed from its cause.
 		$params                       = $method->getParameters();
@@ -270,12 +321,272 @@ class ApiBuilder {
 			&& 'string' === $param_type->getName();
 		$object_return                = $return_type instanceof \ReflectionNamedType && 'object' === $return_type->getName();
 		if ( ! $single_required_string_param || ! $object_return ) {
-			$this->warnings[] = "Container class {$candidate}::get() must have signature `get(string): object`; ignoring.";
+			$this->errors[] = "ClassResolver class {$candidate}::resolve_class() must have signature `resolve_class(string): object`.";
 			return;
 		}

-		$this->container_fqcn = $candidate;
-		echo "  Using resolver container: {$candidate}.\n";
+		$this->class_resolver_fqcn = $candidate;
+		echo "  Using class resolver: {$candidate}.\n";
+	}
+
+	/**
+	 * Detect an optional user-provided principal resolver.
+	 *
+	 * If a class `<api_namespace>\Infrastructure\PrincipalResolver` exists and exposes
+	 * a `resolve_principal(): T` or `resolve_principal( \WP_REST_Request ): T`
+	 * method, autogenerated controllers route per-request principal resolution
+	 * through it. Otherwise the controller falls back to a default
+	 * {@see \Automattic\WooCommerce\Api\Infrastructure\Principal} wrapping
+	 * `wp_get_current_user()`.
+	 *
+	 * The return type (a class FQCN, or `object`) becomes the principal type
+	 * used to type-check `_principal` parameters across commands and
+	 * authorization attributes. The return type must be non-null — anonymous
+	 * requests are represented by a sentinel principal (e.g. a Principal whose
+	 * underlying WP_User has `ID === 0`); the resolver never returns null.
+	 *
+	 * The request parameter is optional — resolvers that don't need to inspect
+	 * the request (the default WP-user resolver, for example) can declare a
+	 * zero-arg method. The runtime call site reads the actual arity via the
+	 * autogenerated controller's `principal_resolver_takes_request()` override.
+	 *
+	 * Misshapen explicit-registration is a hard build-time error (see
+	 * {@see self::detect_class_resolver()} for the rationale).
+	 */
+	private function detect_principal_resolver(): void {
+		$candidate = $this->api_namespace . '\\Infrastructure\\PrincipalResolver';
+		if ( ! class_exists( $candidate ) ) {
+			return;
+		}
+		if ( ! method_exists( $candidate, 'resolve_principal' ) ) {
+			$this->errors[] = "PrincipalResolver class {$candidate} has no resolve_principal() method.";
+			return;
+		}
+		$method = new \ReflectionMethod( $candidate, 'resolve_principal' );
+		if ( $method->isStatic() || ! $method->isPublic() ) {
+			$this->errors[] = "PrincipalResolver class {$candidate}::resolve_principal() must be a public non-static method.";
+			return;
+		}
+
+		// Validate signature: zero parameters, OR a single \WP_REST_Request parameter.
+		// Return type must be non-null — anonymous is signalled by a sentinel
+		// principal, not by null. The captured type is used at request time to
+		// type-check `_principal` parameters when WordPress IS loaded.
+		$params      = $method->getParameters();
+		$return_type = $method->getReturnType();
+
+		if ( count( $params ) > 1 ) {
+			$this->errors[] = "PrincipalResolver class {$candidate}::resolve_principal() must take 0 parameters or a single \\WP_REST_Request parameter; got " . count( $params ) . '.';
+			return;
+		}
+		if ( 1 === count( $params ) ) {
+			$param_type          = $params[0]->getType();
+			$valid_request_param = ! $params[0]->isVariadic()
+				&& ! $params[0]->isOptional()
+				&& $param_type instanceof \ReflectionNamedType
+				&& 'WP_REST_Request' === ltrim( $param_type->getName(), '\\' );
+			if ( ! $valid_request_param ) {
+				$this->errors[] = "PrincipalResolver class {$candidate}::resolve_principal() parameter must be typed as `\\WP_REST_Request`.";
+				return;
+			}
+		}
+
+		if ( ! $return_type instanceof \ReflectionNamedType || $return_type->allowsNull() ) {
+			$this->errors[] = "PrincipalResolver class {$candidate}::resolve_principal() must declare a non-nullable return type (anonymous requests are represented by a sentinel principal, not null).";
+			return;
+		}
+
+		$return_type_name = $return_type->getName();
+
+		// Reject scalar / void-ish keywords. We can't enforce class_exists() at
+		// build time because the WordPress runtime — and any of its classes the
+		// principal might be typed as, e.g. WP_User — is not loaded by the
+		// build script. The string is captured verbatim and used at request
+		// time to type-check `_principal` parameters when WordPress IS loaded.
+		$rejected_type_names = array( 'void', 'never', 'null', 'mixed', 'string', 'int', 'float', 'bool', 'array', 'iterable', 'callable', 'self', 'static', 'parent' );
+		if ( in_array( $return_type_name, $rejected_type_names, true ) ) {
+			$this->errors[] = "PrincipalResolver class {$candidate}::resolve_principal() return type must be `object` or `<ClassName>`, got `{$return_type_name}`.";
+			return;
+		}
+
+		$this->principal_resolver_fqcn          = $candidate;
+		$this->principal_type                   = $return_type_name;
+		$this->principal_resolver_takes_request = 1 === count( $params );
+		echo "  Using principal resolver: {$candidate} (principal type: {$return_type_name}).\n";
+	}
+
+	// ========================================================================
+	// Authorization attribute discovery
+	// ========================================================================
+
+	/**
+	 * Discover the set of attribute classes that participate in autodiscovered
+	 * authorization.
+	 *
+	 * An attribute class qualifies when it declares an `authorize()` method
+	 * whose signature is one of:
+	 *   - `authorize( ?<PrincipalType> $principal ): bool`
+	 *   - `authorize( ?object $principal ): bool`
+	 *   - `authorize(): bool`
+	 *
+	 * The set is the union of:
+	 *
+	 *   1. WooCommerce core's well-known authorization attributes
+	 *      ({@see RequiredCapability}, {@see PublicAccess}) — always included so
+	 *      sibling plugin builds inherit them without redeclaring.
+	 *   2. Attributes discovered by scanning `<api_dir>/Attributes/` for classes
+	 *      that declare an authorize() method.
+	 *
+	 * Plain attributes without an `authorize()` method (e.g. {@see \Automattic\WooCommerce\Api\Attributes\Description})
+	 * are skipped. Misshapen `authorize()` methods are a hard build-time error.
+	 */
+	private function discover_authorization_attributes(): void {
+		// 1. Well-known core authorization attributes — always available via the
+		// shared autoloader, regardless of whether the build is for core itself
+		// or a sibling plugin. Auto-included with `is_core = true`, so a plugin
+		// whose principal type makes a core attribute inapplicable (e.g. a
+		// non-WP-User principal vs. RequiredCapability's ?WP_User) gets it
+		// silently skipped rather than a hard build error from infrastructure
+		// the plugin didn't even ask for.
+		$core_attribute_fqcns = array(
+			RequiredCapability::class,
+			PublicAccess::class,
+		);
+		foreach ( $core_attribute_fqcns as $fqcn ) {
+			$this->try_register_authorization_attribute( $fqcn, true );
+		}
+
+		// 2. Plugin-supplied attributes living under <api_dir>/Attributes.
+		// `is_core = false`: principal-type incompatibility on a plugin-owned
+		// attribute is a developer error and surfaces as a build failure.
+		$attributes_dir = $this->api_dir . '/Attributes';
+		if ( ! is_dir( $attributes_dir ) ) {
+			return;
+		}
+		$iterator = new \RecursiveIteratorIterator(
+			new \RecursiveDirectoryIterator( $attributes_dir, \FilesystemIterator::SKIP_DOTS )
+		);
+		foreach ( $iterator as $file ) {
+			if ( 'php' !== $file->getExtension() ) {
+				continue;
+			}
+			$fqcn = $this->file_to_fqcn( $file->getPathname() );
+			if ( null === $fqcn ) {
+				continue;
+			}
+			$this->try_register_authorization_attribute( $fqcn, false );
+		}
+	}
+
+	/**
+	 * Register an attribute class as an authorization attribute when its
+	 * `authorize()` signature matches the contract; do nothing otherwise.
+	 *
+	 * @param string $fqcn    FQCN of the candidate attribute class.
+	 * @param bool   $is_core True for core attributes auto-included regardless of whether the
+	 *                        plugin uses them — principal-type incompatibility is silent here so
+	 *                        a non-WP-User plugin doesn't fail the build over a core attribute it
+	 *                        never imported. False for plugin-supplied attributes; principal-type
+	 *                        incompatibility surfaces as a developer-facing build error.
+	 */
+	private function try_register_authorization_attribute( string $fqcn, bool $is_core ): void {
+		if ( ! class_exists( $fqcn ) ) {
+			return;
+		}
+		$ref = new \ReflectionClass( $fqcn );
+		if ( ! $ref->hasMethod( 'authorize' ) ) {
+			return;
+		}
+		$method = $ref->getMethod( 'authorize' );
+		if ( ! $this->validate_attribute_authorize_shape( $fqcn, $method ) ) {
+			return;
+		}
+		if ( ! $this->attribute_principal_type_is_compatible( $method ) ) {
+			if ( ! $is_core ) {
+				$this->record_attribute_principal_mismatch_error( $fqcn, $method );
+			}
+			return;
+		}
+		$this->authorization_attribute_fqcns[ $fqcn ] = array(
+			'takes_principal' => count( $method->getParameters() ) > 0,
+		);
+	}
+
+	/**
+	 * Validate the *shape* of an attribute's `authorize()` method — return type
+	 * and arity — independent of principal-type compatibility.
+	 *
+	 * 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.)
+	 *
+	 * Records build errors for genuinely malformed signatures and returns false.
+	 */
+	private function validate_attribute_authorize_shape( string $fqcn, \ReflectionMethod $method ): bool {
+		if ( $method->isStatic() || ! $method->isPublic() ) {
+			$this->errors[] = "Authorization attribute {$fqcn}::authorize() must be a public non-static method.";
+			return false;
+		}
+
+		$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;
+		}
+
+		$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;
+		}
+
+		$param      = $params[0];
+		$param_type = $param->getType();
+		$type_name  = $param_type instanceof \ReflectionNamedType ? $param_type->getName() : 'mixed';
+
+		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;
+		}
+
+		return true;
+	}
+
+	/**
+	 * 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;
+		}
+		$param_type = $params[0]->getType();
+		$type_name  = $param_type instanceof \ReflectionNamedType ? $param_type->getName() : 'mixed';
+		return $this->is_principal_type_compatible( $type_name );
+	}
+
+	/**
+	 * Record a build error for a plugin-shipped attribute whose `authorize()`
+	 * 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';
+		$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}`.";
 	}

 	// ========================================================================
@@ -298,7 +609,7 @@ class ApiBuilder {
 			}

 			$kind = $this->classify_by_namespace( $fqcn );
-			if ( $kind === null || $kind === 'attribute' || $kind === 'exception' ) {
+			if ( $kind === null || $kind === 'attribute' || $kind === 'exception' || $kind === 'infrastructure' ) {
 				continue;
 			}

@@ -360,16 +671,17 @@ class ApiBuilder {
 		$top_dir  = $parts[0];

 		return match ( $top_dir ) {
-			'Queries'    => 'query',
-			'Mutations'  => 'mutation',
-			'Types'      => 'type',
-			'InputTypes' => 'input_type',
-			'Enums'      => 'enum',
-			'Interfaces' => 'interface',
-			'Scalars'    => 'scalar',
-			'Pagination' => 'pagination',
-			'Attributes' => 'attribute',
-			default      => $fqcn === $this->api_namespace . '\\ApiException' ? 'exception' : null,
+			'Queries'        => 'query',
+			'Mutations'      => 'mutation',
+			'Types'          => 'type',
+			'InputTypes'     => 'input_type',
+			'Enums'          => 'enum',
+			'Interfaces'     => 'interface',
+			'Scalars'        => 'scalar',
+			'Pagination'     => 'pagination',
+			'Attributes'     => 'attribute',
+			'Infrastructure' => 'infrastructure',
+			default          => $fqcn === $this->api_namespace . '\\ApiException' ? 'exception' : null,
 		};
 	}

@@ -405,14 +717,17 @@ class ApiBuilder {
 			return;
 		}

-		// Authorization check: must have RequiredCapability, PublicAccess, or a non-ignored authorize() method.
-		// A direct attribute on the class takes precedence over inherited ones.
+		// Authorization check: must have an autodiscovered authorization attribute
+		// (e.g. RequiredCapability, PublicAccess, or any plugin-supplied attribute
+		// whose authorize() method matches the principal type), or a non-ignored
+		// authorize() method on the command itself. Direct attributes on the
+		// class take precedence over inherited ones.
 		$auth          = $this->resolve_authorization( $ref );
 		$has_authorize = $ref->hasMethod( 'authorize' )
 			&& empty( $ref->getMethod( 'authorize' )->getAttributes( Ignore::class ) );

-		if ( null === $auth['error'] && empty( $auth['caps'] ) && ! $auth['public'] && ! $has_authorize ) {
-			$this->errors[] = "Query/Mutation \"{$ref->getShortName()}\" has no RequiredCapability attribute (directly or inherited), no PublicAccess attribute, and no authorize() method.";
+		if ( null === $auth['error'] && empty( $auth['usages'] ) && ! $has_authorize ) {
+			$this->errors[] = "Query/Mutation \"{$ref->getShortName()}\" has no authorization attribute (directly or inherited) and no authorize() method.";
 		}

 		if ( null !== $auth['error'] ) {
@@ -457,10 +772,15 @@ class ApiBuilder {
 	 * @param \ReflectionClass $ref  The reflection of the class.
 	 */
 	private function check_for_ignored_auth_attribute( string $fqcn, \ReflectionClass $ref ): void {
-		$has_direct_cap    = ! empty( $ref->getAttributes( RequiredCapability::class ) );
-		$has_direct_public = ! empty( $ref->getAttributes( PublicAccess::class ) );
+		$has_direct_auth_attribute = false;
+		foreach ( array_keys( $this->authorization_attribute_fqcns ) as $attr_fqcn ) {
+			if ( ! empty( $ref->getAttributes( $attr_fqcn ) ) ) {
+				$has_direct_auth_attribute = true;
+				break;
+			}
+		}

-		if ( ! $has_direct_cap && ! $has_direct_public ) {
+		if ( ! $has_direct_auth_attribute ) {
 			return;
 		}

@@ -553,61 +873,153 @@ class ApiBuilder {
 	 * Resolve the authorization strategy for a query/mutation class.
 	 *
 	 * A direct attribute on the class itself takes precedence over inherited ones.
-	 * Having both RequiredCapability and PublicAccess on the same class is an error,
-	 * but a derived class may override an inherited attribute of the other kind.
+	 * Having PublicAccess together with any other authorization attribute on the same
+	 * class is an error, but a derived class may override an inherited attribute.
 	 *
 	 * @param \ReflectionClass $ref The class to inspect.
-	 * @return array{caps: string[], public: bool, error: ?string}
+	 *
+	 * @return array{
+	 *     usages: array<int, array{fqcn: string, args_php: string, is_public_access: bool}>,
+	 *     has_public_access: bool,
+	 *     attribute_expr: string,
+	 *     error: ?string,
+	 * }
 	 */
 	private function resolve_authorization( \ReflectionClass $ref ): array {
-		$direct_caps   = array();
-		$direct_public = ! empty( $ref->getAttributes( PublicAccess::class ) );
+		$direct_usages = $this->collect_authorization_usages( $ref );
+
+		$usages = $direct_usages;
+		if ( empty( $usages ) ) {
+			// No direct attribute — collect from the entire ancestor tree:
+			// the parent chain plus each ancestor's traits and interfaces
+			// (recursively). All inherited sources contribute as peers; the
+			// only thing direct attributes shadow is the inherited tree as a
+			// whole. A visited-set guards against trait diamonds and
+			// interface inheritance loops.
+			$visited = array();
+			$stack   = array_merge(
+				$ref->getParentClass() ? array( $ref->getParentClass() ) : array(),
+				$ref->getTraits(),
+				$ref->getInterfaces(),
+			);
+			while ( ! empty( $stack ) ) {
+				$source = array_shift( $stack );
+				$name   = $source->getName();
+				if ( in_array( $name, $visited, true ) ) {
+					continue;
+				}
+				$visited[] = $name;
+				$usages    = array_merge( $usages, $this->collect_authorization_usages( $source ) );
+				if ( false !== $source->getParentClass() ) {
+					$stack[] = $source->getParentClass();
+				}
+				$stack = array_merge( $stack, $source->getTraits(), $source->getInterfaces() );
+			}
+		}

-		foreach ( $ref->getAttributes( RequiredCapability::class ) as $attr ) {
-			$direct_caps[] = $attr->newInstance()->capability;
+		$has_public_access = false;
+		$other_count       = 0;
+		foreach ( $usages as $usage ) {
+			if ( $usage['is_public_access'] ) {
+				$has_public_access = true;
+			} else {
+				++$other_count;
+			}
 		}

-		// Same class has both — always an error.
-		if ( ! empty( $direct_caps ) && $direct_public ) {
-			return array(
-				'caps'   => array(),
-				'public' => false,
-				'error'  => 'cannot have both RequiredCapability and PublicAccess.',
-			);
+		$error = null;
+		if ( $has_public_access && $other_count > 0 ) {
+			$error = 'cannot have PublicAccess together with any other authorization attribute.';
 		}

-		// Direct attribute found — use it, ignore inherited.
-		if ( ! empty( $direct_caps ) || $direct_public ) {
-			return array(
-				'caps'   => array_unique( $direct_caps ),
-				'public' => $direct_public,
-				'error'  => null,
+		// Build the PHP expression that the resolver's compute_preauthorized()
+		// helper returns. The expression references a local `$principal` (the
+		// helper's parameter), so resolve() can pass `$context['principal']`
+		// in and code-API callers can pass any principal directly. PublicAccess
+		// short-circuits to `true` since its authorize() always returns true;
+		// combining it with another attribute is rejected above.
+		if ( empty( $usages ) ) {
+			$attribute_expr = 'true';
+		} elseif ( $has_public_access ) {
+			$attribute_expr = 'true';
+		} else {
+			$expressions    = array_map(
+				function ( $u ) {
+					$arg = $this->authorization_attribute_fqcns[ $u['fqcn'] ]['takes_principal']
+						? ' $principal '
+						: '';
+					return sprintf(
+						'( new \%s(%s) )->authorize(%s)',
+						$u['fqcn'],
+						$u['args_php'],
+						$arg
+					);
+				},
+				$usages
 			);
+			$attribute_expr = implode( ' && ', $expressions );
 		}

-		// No direct attribute — inherit from parents, traits, and interfaces.
-		$inherited_caps   = array();
-		$inherited_public = false;
-		$sources          = array_merge(
-			$ref->getParentClass() ? array( $ref->getParentClass() ) : array(),
-			$ref->getTraits(),
-			$ref->getInterfaces(),
+		return array(
+			'usages'            => $usages,
+			'has_public_access' => $has_public_access,
+			'attribute_expr'    => $attribute_expr,
+			'error'             => $error,
 		);
+	}

-		foreach ( $sources as $source ) {
-			foreach ( $source->getAttributes( RequiredCapability::class ) as $attr ) {
-				$inherited_caps[] = $attr->newInstance()->capability;
-			}
-			if ( ! empty( $source->getAttributes( PublicAccess::class ) ) ) {
-				$inherited_public = true;
+	/**
+	 * Collect authorization-attribute usages declared directly on a reflector.
+	 *
+	 * Iterates the autodiscovered authorization-attribute FQCN set
+	 * ({@see self::$authorization_attribute_fqcns}) and reads the runtime args
+	 * 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.
+	 *
+	 * @return array<int, array{fqcn: string, args_php: string, is_public_access: bool}>
+	 */
+	private function collect_authorization_usages( \ReflectionClass $source ): array {
+		$usages = array();
+		foreach ( array_keys( $this->authorization_attribute_fqcns ) as $attr_fqcn ) {
+			foreach ( $source->getAttributes( $attr_fqcn ) as $attr ) {
+				$args     = $attr->getArguments();
+				$args_php = $this->render_attribute_args_php( $attr_fqcn, $args );
+				$usages[] = array(
+					'fqcn'             => $attr_fqcn,
+					'args_php'         => $args_php,
+					'is_public_access' => ( PublicAccess::class === $attr_fqcn ),
+				);
 			}
 		}
+		return $usages;
+	}

-		return array(
-			'caps'   => array_unique( $inherited_caps ),
-			'public' => $inherited_public,
-			'error'  => null,
-		);
+	/**
+	 * Render an attribute's runtime arguments as a comma-separated PHP literal list.
+	 *
+	 * Used by the resolver template to instantiate the attribute with the same
+	 * arguments declared at the usage site. Handles the scalar/array shapes that
+	 * PHP allows in attribute arguments via {@see \var_export()}; named arguments
+	 * are emitted as `name: value` so attributes whose constructors are
+	 * called with named args still round-trip.
+	 *
+	 * @param string $attr_fqcn FQCN of the attribute, for error messages.
+	 * @param array  $args      Args from {@see \ReflectionAttribute::getArguments()}.
+	 */
+	private function render_attribute_args_php( string $attr_fqcn, array $args ): string {
+		$pieces = array();
+		foreach ( $args as $key => $value ) {
+			$exported = var_export( $value, true );
+			if ( is_string( $key ) ) {
+				$pieces[] = "{$key}: {$exported}";
+			} else {
+				$pieces[] = $exported;
+			}
+		}
+		unset( $attr_fqcn );
+		return implode( ', ', $pieces );
 	}

 	// ========================================================================
@@ -963,16 +1375,39 @@ class ApiBuilder {
 		$execute_params   = array();
 		$input_converters = array();

+		// Track infrastructure params declared on execute() so the resolver
+		// template can wire them in alongside _query_info. _preauthorized is
+		// not allowed on execute() (it's an authorize-only signal); see
+		// validate_infra_param().
+		$execute_principal_arg  = null;
+		$execute_query_info_arg = false;
+
 		foreach ( $params as $param ) {
 			$param_name = $param->getName();

-			// Infrastructure parameters.
-			if ( $param_name === '_query_info' ) {
+			// Infrastructure parameters: validated and emitted with is_infrastructure=true.
+			$infra = $this->validate_infra_param( $param, false, $fqcn );
+			if ( null !== $infra ) {
 				$execute_params[] = array(
 					'name'              => $param_name,
 					'conversion'        => null,
 					'is_infrastructure' => true,
 				);
+				switch ( $param_name ) {
+					case '_principal':
+						$execute_principal_arg = array(
+							'type_name' => $infra['type_name'],
+						);
+						break;
+					case '_query_info':
+						$execute_query_info_arg = true;
+						break;
+				}
+				continue;
+			}
+			// Skip params that started with `_` but were rejected — error is
+			// already recorded by validate_infra_param().
+			if ( '' !== $param_name && '_' === $param_name[0] ) {
 				continue;
 			}

@@ -1033,44 +1468,54 @@ class ApiBuilder {
 		}

 		// Authorization: check for authorize() method.
-		$authorize_param_names = null;
-		$has_preauthorized     = false;
+		$authorize_param_names    = null;
+		$has_preauthorized        = false;
+		$authorize_principal_arg  = null;
+		$authorize_query_info_arg = false;
 		if ( $ref->hasMethod( 'authorize' ) ) {
 			$authorize_method  = $ref->getMethod( 'authorize' );
 			$authorize_ignored = ! empty( $authorize_method->getAttributes( Ignore::class ) );

 			if ( ! $authorize_ignored ) {
-				$validated             = $this->validate_authorize_method( $fqcn, $execute_method, $authorize_method );
-				$authorize_param_names = $validated['domain_params'];
-				$has_preauthorized     = $validated['has_preauthorized'];
+				$validated                = $this->validate_authorize_method( $fqcn, $execute_method, $authorize_method );
+				$authorize_param_names    = $validated['domain_params'];
+				$has_preauthorized        = $validated['has_preauthorized'];
+				$authorize_principal_arg  = $validated['principal_arg'];
+				$authorize_query_info_arg = $validated['query_info_arg'];
 			}
 		}

-		// Resolve the attribute-declared authorization. When authorize() is
-		// present, these values are only used to compute the $_preauthorized
-		// flag passed to the method; check_capabilities() is not generated
-		// in that case (authorize() is the sole guard).
-		$auth          = $this->resolve_authorization( $ref );
-		$caps          = $auth['caps'];
-		$public_access = $auth['public'];
-
-		// Build the PHP expression that evaluates to the $_preauthorized
-		// boolean at runtime. Only meaningful when authorize() declares the
-		// $_preauthorized parameter; otherwise left at 'false' and unused.
-		$preauthorized_expr = 'false';
-		if ( $has_preauthorized ) {
-			if ( $public_access ) {
-				$preauthorized_expr = 'true';
-			} elseif ( ! empty( $caps ) ) {
-				$preauthorized_expr = implode(
-					' && ',
-					array_map(
-						fn( $cap ) => sprintf( "current_user_can( '%s' )", addslashes( $cap ) ),
-						$caps
-					)
-				);
-			}
-		}
+		// 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
+		// there are no attributes — in which case the command's own authorize()
+		// is the sole guard). The expression goes into a generated static
+		// `compute_preauthorized()` helper that's the single source of truth
+		// for both the resolver's own gates and external (code-API) callers
+		// asking "would `_preauthorized` be true for this caller?".
+		$auth               = $this->resolve_authorization( $ref );
+		$attribute_expr     = $auth['attribute_expr'];
+		$has_attribute_expr = ! empty( $auth['usages'] );
+		$has_authorize      = null !== $authorize_param_names;
+		// Skip the standalone gate when the attribute expression is trivially `true`
+		// (PublicAccess) — emitting `if (! (true))` is dead code that the linter would
+		// flag.
+		$standalone_attribute_check = $has_attribute_expr && ! $has_authorize && 'true' !== $attribute_expr;
+
+		// `_preauthorized` is only meaningful when authorize() declares the
+		// $_preauthorized parameter; otherwise the variable is unused and stays
+		// at 'false'. When the authorize() method isn't declared at all, the
+		// expression gets emitted directly as a standalone gate (see
+		// `standalone_attribute_check`).
+		$preauthorized_expr = $has_preauthorized ? "self::compute_preauthorized( \$context['principal'] )" : 'false';
+
+		// Signature for the public compute_preauthorized() helper. `<PrincipalType>`
+		// when a PrincipalResolver was detected, else `object` (catch-all when no
+		// resolver constrains the runtime type). Always non-null — anonymous is
+		// represented by a sentinel principal, not by null.
+		$compute_preauthorized_param_type = 'object' === $this->principal_type
+			? 'object'
+			: '\\' . ltrim( $this->principal_type, '\\' );

 		$dir_name  = $kind === 'query' ? 'GraphQLQueries' : 'GraphQLMutations';
 		$namespace = $this->autogenerated_namespace . '\\' . $dir_name;
@@ -1078,26 +1523,31 @@ class ApiBuilder {
 		$code = $this->render_template(
 			'QueryResolverTemplate.php',
 			array(
-				'namespace'             => $namespace,
-				'class_name'            => $ref->getShortName(),
-				'graphql_name'          => $graphql_name,
-				'description'           => $description,
-				'command_fqcn'          => $fqcn,
-				'command_alias'         => $command_alias,
-				'container_fqcn'        => $this->container_fqcn,
-				'return_type_expr'      => $return_type_expr,
-				'use_statements'        => array_unique( $use_stmts ),
-				'args'                  => $args,
-				'capabilities'          => $caps,
-				'public_access'         => $public_access,
-				'has_connection_of'     => $has_connection_of,
-				'connection_type_alias' => '',
-				'execute_params'        => $execute_params,
-				'input_converters'      => array_values( $input_converters ),
-				'authorize_param_names' => $authorize_param_names,
-				'has_preauthorized'     => $has_preauthorized,
-				'preauthorized_expr'    => $preauthorized_expr,
-				'scalar_return'         => $scalar_return,
+				'namespace'                        => $namespace,
+				'class_name'                       => $ref->getShortName(),
+				'graphql_name'                     => $graphql_name,
+				'description'                      => $description,
+				'command_fqcn'                     => $fqcn,
+				'command_alias'                    => $command_alias,
+				'class_resolver_fqcn'              => $this->class_resolver_fqcn,
+				'return_type_expr'                 => $return_type_expr,
+				'use_statements'                   => array_unique( $use_stmts ),
+				'args'                             => $args,
+				'has_connection_of'                => $has_connection_of,
+				'connection_type_alias'            => '',
+				'execute_params'                   => $execute_params,
+				'execute_principal_arg'            => $execute_principal_arg,
+				'execute_query_info_arg'           => $execute_query_info_arg,
+				'input_converters'                 => array_values( $input_converters ),
+				'authorize_param_names'            => $authorize_param_names,
+				'has_preauthorized'                => $has_preauthorized,
+				'preauthorized_expr'               => $preauthorized_expr,
+				'authorize_principal_arg'          => $authorize_principal_arg,
+				'authorize_query_info_arg'         => $authorize_query_info_arg,
+				'standalone_attribute_check'       => $standalone_attribute_check,
+				'attribute_expr'                   => $attribute_expr,
+				'compute_preauthorized_param_type' => $compute_preauthorized_param_type,
+				'scalar_return'                    => $scalar_return,
 			)
 		);

@@ -1112,14 +1562,134 @@ class ApiBuilder {
 	}

 	/**
-	 * Validate that an authorize() method's parameters are a subset of execute().
+	 * Recognised infrastructure parameter names (allowed on authorize() / execute() / attribute-authorize()).
 	 *
-	 * In addition to the domain parameters (which must appear in execute()),
-	 * authorize() may declare a single optional infrastructure parameter
-	 * `bool $_preauthorized`. When present, the generated resolver passes
-	 * `true` if the attribute-declared authorization would have granted access.
+	 * - `_query_info`: parsed selection tree, populated from the resolver $info argument.
+	 * - `_preauthorized`: bool result of attribute-declared authorization, populated by the resolver.
+	 * - `_principal`: per-request principal resolved by the configured PrincipalResolver.
 	 *
-	 * @return array{domain_params: string[], has_preauthorized: bool}
+	 * The principal is the only channel through which request-derived data
+	 * reaches commands; anything the resolver wants to expose to authorize() /
+	 * execute() (custom headers, request id, locale, etc.) goes onto the
+	 * principal class. This keeps commands network-agnostic and unit-testable.
+	 */
+	private const INFRA_PARAM_NAMES = array( '_query_info', '_preauthorized', '_principal' );
+
+	/**
+	 * Validate one infrastructure parameter on authorize() / execute().
+	 *
+	 * Returns null when the parameter isn't infra (caller treats it as a domain
+	 * parameter). Returns an info array describing the valid infra param when
+	 * recognised. Records an error and returns null when the parameter is
+	 * misshapen (wrong type, or unknown `_`-prefixed name) — letting the build
+	 * fail loudly rather than silently accepting a typoed `$_priincipal` as a
+	 * GraphQL arg.
+	 *
+	 * @param \ReflectionParameter $param        The parameter to inspect.
+	 * @param bool                 $on_authorize True when validating authorize(); false for execute().
+	 * @param string               $fqcn         Owning class FQCN, for error messages.
+	 *
+	 * @return ?array{name: string, type_name: string}
+	 */
+	private function validate_infra_param( \ReflectionParameter $param, bool $on_authorize, string $fqcn ): ?array {
+		$name = $param->getName();
+		if ( '' === $name || '_' !== $name[0] ) {
+			return null;
+		}
+
+		$type      = $param->getType();
+		$type_name = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
+		$nullable  = null !== $type && $type->allowsNull();
+		$method    = $on_authorize ? 'authorize' : 'execute';
+		$info      = array(
+			'name'      => $name,
+			'type_name' => $type_name,
+		);
+
+		switch ( $name ) {
+			case '_query_info':
+				return $info;
+
+			case '_preauthorized':
+				if ( ! $on_authorize ) {
+					$this->errors[] = "{$fqcn}: \$_preauthorized is only allowed on authorize().";
+					return null;
+				}
+				if ( 'bool' !== $type_name ) {
+					$this->errors[] = "{$fqcn}: {$method}() parameter \$_preauthorized must be typed as bool.";
+					return null;
+				}
+				return $info;
+
+			case '_principal':
+				if ( $nullable ) {
+					$this->errors[] = "{$fqcn}: {$method}() parameter \$_principal must be non-nullable (anonymous requests are represented by a sentinel principal, not null); got `?{$type_name}`.";
+					return null;
+				}
+				if ( ! $this->is_principal_type_compatible( $type_name ) ) {
+					$expected       = 'object' === $this->principal_type
+						? '`object` (or any object class)'
+						: "`{$this->principal_type}` or a supertype, or `object`";
+					$this->errors[] = "{$fqcn}: {$method}() parameter \$_principal must be typed as {$expected}; got `{$type_name}`.";
+					return null;
+				}
+				return $info;
+
+			default:
+				$allowed        = '$' . implode( ', $', self::INFRA_PARAM_NAMES );
+				$this->errors[] = "{$fqcn}: {$method}() parameter \${$name} starts with `_` but is not a recognised infrastructure parameter (allowed: {$allowed}).";
+				return null;
+		}
+	}
+
+	/**
+	 * Whether a declared `_principal` type is compatible with the registered principal type.
+	 *
+	 * Compatibility rules:
+	 *  - `object` (the declared parameter type) is always accepted (catch-all supertype).
+	 *  - When the registered principal type is `object` (the plugin's resolver explicitly
+	 *    returned `object`), any object-like class name is accepted; the runtime type is
+	 *    the plugin's responsibility.
+	 *  - Otherwise the declared type must equal the registered principal type. Inheritance-
+	 *    based supertype matching isn't enforced at build time because the principal class
+	 *    (e.g. WP_User) typically isn't autoloadable in the build environment; users that
+	 *    want supertype polymorphism should declare `object` and narrow at runtime.
+	 *
+	 * @param string $declared_type Type name from the parameter's ReflectionNamedType (no leading backslash).
+	 */
+	private function is_principal_type_compatible( string $declared_type ): bool {
+		$declared_type = ltrim( $declared_type, '\\' );
+		if ( 'object' === $declared_type ) {
+			return true;
+		}
+
+		$rejected_scalars = array( 'void', 'never', 'null', 'mixed', 'string', 'int', 'float', 'bool', 'array', 'iterable', 'callable', 'self', 'static', 'parent' );
+		if ( in_array( $declared_type, $rejected_scalars, true ) ) {
+			return false;
+		}
+
+		// No resolver registered → any object-like type is acceptable.
+		if ( 'object' === $this->principal_type ) {
+			return true;
+		}
+
+		return $declared_type === ltrim( $this->principal_type, '\\' );
+	}
+
+	/**
+	 * Validate that an authorize() method's parameters are a subset of execute()'s domain
+	 * parameters, plus a recognised set of infrastructure parameters.
+	 *
+	 * Domain parameters on authorize() must appear on execute() with the same type.
+	 * Infrastructure parameters are validated independently — see
+	 * {@see self::validate_infra_param()} for the recognised set and the rules.
+	 *
+	 * @return array{
+	 *     domain_params: string[],
+	 *     has_preauthorized: bool,
+	 *     principal_arg: ?array{type_name: string},
+	 *     query_info_arg: bool,
+	 * }
 	 */
 	private function validate_authorize_method(
 		string $fqcn,
@@ -1128,26 +1698,45 @@ class ApiBuilder {
 	): array {
 		$execute_params = array();
 		foreach ( $execute_method->getParameters() as $p ) {
+			if ( in_array( $p->getName(), self::INFRA_PARAM_NAMES, true ) ) {
+				continue;
+			}
 			$type                            = $p->getType();
 			$execute_params[ $p->getName() ] = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
 		}

 		$domain_params     = array();
 		$has_preauthorized = false;
+		$principal_arg     = null;
+		$query_info_arg    = false;

 		foreach ( $authorize_method->getParameters() as $p ) {
-			$name      = $p->getName();
-			$type      = $p->getType();
-			$type_name = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';
-
-			if ( '_preauthorized' === $name ) {
-				if ( 'bool' !== $type_name ) {
-					$this->errors[] = "{$fqcn}: authorize() parameter \$_preauthorized must be typed as bool.";
-					continue;
+			$infra = $this->validate_infra_param( $p, true, $fqcn );
+			if ( null !== $infra ) {
+				switch ( $infra['name'] ) {
+					case '_preauthorized':
+						$has_preauthorized = true;
+						break;
+					case '_principal':
+						$principal_arg = array(
+							'type_name' => $infra['type_name'],
+						);
+						break;
+					case '_query_info':
+						$query_info_arg = true;
+						break;
 				}
-				$has_preauthorized = true;
 				continue;
 			}
+			// Skip params that started with `_` but were rejected — error is
+			// already recorded by validate_infra_param().
+			if ( '' !== $p->getName() && '_' === $p->getName()[0] ) {
+				continue;
+			}
+
+			$name      = $p->getName();
+			$type      = $p->getType();
+			$type_name = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed';

 			if ( ! array_key_exists( $name, $execute_params ) ) {
 				$this->errors[] = "{$fqcn}: authorize() parameter \${$name} does not exist in execute().";
@@ -1165,6 +1754,8 @@ class ApiBuilder {
 		return array(
 			'domain_params'     => $domain_params,
 			'has_preauthorized' => $has_preauthorized,
+			'principal_arg'     => $principal_arg,
+			'query_info_arg'    => $query_info_arg,
 		);
 	}

@@ -1519,7 +2110,10 @@ class ApiBuilder {
 		$code = $this->render_template(
 			'GraphQLControllerTemplate.php',
 			array(
-				'namespace' => $this->autogenerated_namespace,
+				'namespace'                        => $this->autogenerated_namespace,
+				'class_resolver_fqcn'              => $this->class_resolver_fqcn,
+				'principal_resolver_fqcn'          => $this->principal_resolver_fqcn,
+				'principal_resolver_takes_request' => $this->principal_resolver_takes_request,
 			)
 		);

diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/GraphQLControllerTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/GraphQLControllerTemplate.php
index 04c36587b22..41759271593 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/GraphQLControllerTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/GraphQLControllerTemplate.php
@@ -7,7 +7,16 @@
  * build_schema() to reference the root types that ApiBuilder has just
  * generated in the same autogenerated namespace.
  *
- * @var string $namespace
+ * When the build detected a `<api_namespace>\Infrastructure\ClassResolver` or
+ * `<api_namespace>\Infrastructure\PrincipalResolver` convention class, this
+ * template also emits an override that returns the FQCN, so the base
+ * controller routes class instantiation and per-request principal
+ * resolution through it.
+ *
+ * @var string  $namespace
+ * @var ?string $class_resolver_fqcn              - FQCN of the detected ClassResolver, or null.
+ * @var ?string $principal_resolver_fqcn          - FQCN of the detected PrincipalResolver, or null.
+ * @var bool    $principal_resolver_takes_request - true when resolve_principal() declares its \WP_REST_Request parameter; false when zero-arg.
  */
 ?>
 <?php echo '<?php'; ?>
@@ -30,4 +39,20 @@ class GraphQLController extends \Automattic\WooCommerce\Internal\Api\GraphQLCont
 			)
 		);
 	}
+<?php if ( null !== $class_resolver_fqcn ) : ?>
+
+	protected function get_class_resolver_fqcn(): ?string {
+		return \<?php echo $class_resolver_fqcn; ?>::class;
+	}
+<?php endif; ?>
+<?php if ( null !== $principal_resolver_fqcn ) : ?>
+
+	protected function get_principal_resolver_fqcn(): ?string {
+		return \<?php echo $principal_resolver_fqcn; ?>::class;
+	}
+
+	protected function principal_resolver_takes_request(): bool {
+		return <?php echo $principal_resolver_takes_request ? 'true' : 'false'; ?>;
+	}
+<?php endif; ?>
 }
diff --git a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php
index 9a974d8d047..4dffd3f2f22 100644
--- a/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php
+++ b/plugins/woocommerce/src/Internal/Api/DesignTime/Templates/QueryResolverTemplate.php
@@ -11,22 +11,27 @@
  * @var string $return_type_expr
  * @var array  $use_statements
  * @var array  $args - each: ['name', 'type_expr', 'description', 'has_default', 'default']
- * @var array  $capabilities
- * @var bool   $public_access
  * @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
+ * @var string $attribute_expr - PHP expression (referencing local `$principal`) that evaluates to true iff the autodiscovered authorization attributes grant access
+ * @var string $compute_preauthorized_param_type - typed parameter declaration for the generated compute_preauthorized() helper (e.g. `object` or `\WP_User`)
  * @var array  $execute_params - each: ['name', 'conversion' => ?string, 'is_infrastructure' => bool, 'unroll' => ?array]
+ * @var ?array $execute_principal_arg - if non-null, ['type_name' => string]: execute() declares a $_principal infra param
+ * @var bool   $execute_query_info_arg - true when execute() declares a $_query_info infra param
  * @var array  $input_converters - each: ['method_name', 'input_fqcn', 'input_class', 'properties' => [['name', 'conversion']]]
  * @var ?array $authorize_param_names - if non-null, the authorize() method param names (subset of execute params)
  * @var bool   $has_preauthorized - true when authorize() declares a bool $_preauthorized infrastructure param
  * @var string $preauthorized_expr - PHP expression that evaluates to the $_preauthorized bool at runtime
+ * @var ?array $authorize_principal_arg - if non-null, ['type_name' => string]: authorize() declares a $_principal infra param
+ * @var bool   $authorize_query_info_arg - true when authorize() declares a $_query_info infra param
  * @var bool    $scalar_return - true when execute() returns a scalar (bool, int, float, string)
- * @var ?string $container_fqcn - FQCN of a user-provided container with static get(string): object; null for direct `new` instantiation
+ * @var ?string $class_resolver_fqcn - FQCN of a user-provided class resolver with static resolve_class(string): object; null for direct `new` instantiation
  */

 $escaped_description = addslashes( $description );
 $has_authorize       = $authorize_param_names !== null;
-$has_cap_check       = ! $has_authorize && ! $public_access && ! empty( $capabilities );
+$any_query_info_arg  = $execute_query_info_arg || $authorize_query_info_arg;
 ?>
 <?php echo '<?php'; ?>

@@ -108,18 +113,23 @@ class <?php echo $class_name; ?> {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-<?php if ( $has_cap_check ) : ?>
-<?php foreach ( $capabilities as $cap ) : ?>
-		Utils::check_current_user_can( '<?php echo addslashes( $cap ); ?>' );
-<?php endforeach; ?>
+<?php if ( $standalone_attribute_check ) : ?>
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

 <?php endif; ?>
-<?php if ( null !== $container_fqcn ) : ?>
-		$command = \<?php echo $container_fqcn; ?>::get( <?php echo $command_alias; ?>::class );
+<?php if ( null !== $class_resolver_fqcn ) : ?>
+		$command = \<?php echo $class_resolver_fqcn; ?>::resolve_class( <?php echo $command_alias; ?>::class );
 <?php else : ?>
 		$command = new <?php echo $command_alias; ?>();
 <?php endif; ?>

+<?php if ( $any_query_info_arg ) : ?>
+		$query_info = QueryInfoExtractor::extract_from_info( $info, $args );
+<?php endif; ?>
 		$execute_args = array();
 <?php
 $pagination_fqcn = 'Automattic\\WooCommerce\\Api\\Pagination\\PaginationParams';
@@ -136,7 +146,9 @@ foreach ( $execute_params as $param ) :
 			)
 		);
 <?php elseif ( $param['is_infrastructure'] && $param['name'] === '_query_info' ) : ?>
-		$execute_args['_query_info'] = QueryInfoExtractor::extract_from_info( $info, $args );
+		$execute_args['_query_info'] = $query_info;
+<?php elseif ( $param['is_infrastructure'] && $param['name'] === '_principal' ) : ?>
+		$execute_args['_principal'] = $context['principal'];
 <?php elseif ( ! empty( $param['conversion'] ) ) : ?>
 		if ( array_key_exists( '<?php echo $param['name']; ?>', $args ) ) {
 			$execute_args['<?php echo $param['name']; ?>'] = <?php echo $param['conversion']; ?>;
@@ -153,14 +165,17 @@ foreach ( $execute_params as $param ) :
 <?php foreach ( $authorize_param_names as $name ) : ?>
 			'<?php echo $name; ?>' => $execute_args['<?php echo $name; ?>'],
 <?php endforeach; ?>
+<?php if ( null !== $authorize_principal_arg ) : ?>
+			'_principal' => $context['principal'],
+<?php endif; ?>
+<?php if ( $authorize_query_info_arg ) : ?>
+			'_query_info' => $query_info,
+<?php endif; ?>
 <?php if ( $has_preauthorized ) : ?>
 			'_preauthorized' => <?php echo $preauthorized_expr; ?>,
 <?php endif; ?>
 		) ) ) {
-			throw new \Automattic\WooCommerce\Internal\Api\Schema\Error(
-				'You do not have permission to perform this action.',
-				extensions: array( 'code' => 'UNAUTHORIZED' )
-			);
+			throw Utils::build_authorization_error( $context['principal'] );
 		}

 <?php endif; ?>
@@ -172,6 +187,21 @@ foreach ( $execute_params as $param ) :
 		return $result;
 <?php endif; ?>
 	}
+
+	/**
+	 * 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( <?php echo $compute_preauthorized_param_type; ?> $principal ): bool {
+		return <?php echo $attribute_expr; ?>;
+	}
 <?php foreach ( $input_converters as $converter ) : ?>

 	private static function <?php echo $converter['method_name']; ?>( array $data ): \<?php echo $converter['input_fqcn']; ?> {
diff --git a/plugins/woocommerce/src/Internal/Api/GraphQLController.php b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
index ecab8a1b243..d8bc4f9b161 100644
--- a/plugins/woocommerce/src/Internal/Api/GraphQLController.php
+++ b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
@@ -188,15 +188,24 @@ abstract class GraphQLController {
 	/**
 	 * Handle an incoming GraphQL request.
 	 *
+	 * Resolves the principal first so debug-mode / introspection checks can
+	 * consult it from inside both `process_request()` and the top-level
+	 * exception formatter. When `resolve_request_principal()` itself throws
+	 * (e.g. an InvalidTokenException from a plugin's PrincipalResolver),
+	 * `$principal` stays null and the resulting error response carries no
+	 * debug info — by design, since the caller failed to authenticate.
+	 *
 	 * @param \WP_REST_Request $request The REST request.
 	 */
 	public function handle_request( \WP_REST_Request $request ): \WP_REST_Response {
+		$principal = null;
 		try {
-			return $this->process_request( $request );
+			$principal = $this->resolve_request_principal( $request );
+			return $this->process_request( $request, $principal );
 		} catch ( \Throwable $e ) {
 			$output = array(
 				'errors' => array(
-					$this->format_exception( $e, $request ),
+					$this->format_exception( $e, $request, $principal ),
 				),
 			);

@@ -209,9 +218,10 @@ abstract class GraphQLController {
 	 * Process the GraphQL request. Extracted so that handle_request() can
 	 * wrap everything in a single try/catch that respects debug mode.
 	 *
-	 * @param \WP_REST_Request $request The REST request.
+	 * @param \WP_REST_Request $request   The REST request.
+	 * @param object           $principal The principal resolved by handle_request(); never null when this is reached.
 	 */
-	private function process_request( \WP_REST_Request $request ): \WP_REST_Response {
+	private function process_request( \WP_REST_Request $request, object $principal ): \WP_REST_Response {
 		// 2. Parse request. GET query-string `variables` and `extensions`
 		// arrive as JSON strings; decode_json_param() unifies them with the
 		// already-decoded-array path from POST bodies and rejects malformed
@@ -254,14 +264,22 @@ abstract class GraphQLController {
 		$validation_rules   = array_values( DocumentValidator::allRules() );
 		$validation_rules[] = new QueryDepthRule( self::get_max_query_depth() );
 		$validation_rules[] = $complexity_rule;
-		if ( ! $this->is_introspection_allowed( $request ) ) {
+		if ( ! $this->is_introspection_allowed( $principal, $request ) ) {
 			$validation_rules[] = new DisableIntrospection( DisableIntrospection::ENABLED );
 		}

-		// 7. Execute.
+		// 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.
 		$result = GraphQL::executeQuery(
 			schema: $schema,
 			source: $source,
+			contextValue: array(
+				'principal' => $principal,
+			),
 			variableValues: $variables,
 			operationName: $operation_name,
 			validationRules: $validation_rules,
@@ -281,7 +299,7 @@ abstract class GraphQLController {
 		// chain so wrapped errors (e.g. a \ValueError caught by a resolver and
 		// re-thrown as INTERNAL_ERROR) stay visible to the developer instead
 		// of being masked behind the generic "Internal server error" message.
-		$debug_mode = $this->is_debug_mode( $request );
+		$debug_mode = $this->is_debug_mode( $principal, $request );
 		$result->setErrorFormatter(
 			function ( \Throwable $error ) use ( $debug_mode ): array {
 				$formatted = \Automattic\WooCommerce\Vendor\GraphQL\Error\FormattedError::createFromException( $error );
@@ -320,12 +338,12 @@ abstract class GraphQLController {
 			}
 		);

-		$debug_flags = $this->get_debug_flags( $request );
+		$debug_flags = $this->get_debug_flags( $request, $principal );
 		$output      = $result->toArray( $debug_flags );

 		// 8. Debug-mode metrics: expose the computed complexity and depth so
 		// clients tuning queries can see what the server scored the request at.
-		if ( $this->is_debug_mode( $request ) ) {
+		if ( $this->is_debug_mode( $principal, $request ) ) {
 			if ( ! isset( $output['extensions'] ) ) {
 				$output['extensions'] = array();
 			}
@@ -367,6 +385,92 @@ abstract class GraphQLController {
 	 */
 	abstract protected function build_schema(): Schema;

+	/**
+	 * FQCN of the user-provided ClassResolver, or null when none was detected.
+	 *
+	 * The autogenerated subclass overrides this to return its plugin's
+	 * `<api_namespace>\Infrastructure\ClassResolver` when ApiBuilder detected
+	 * one. When null, classes are instantiated with `new $class()`.
+	 */
+	protected function get_class_resolver_fqcn(): ?string {
+		return null;
+	}
+
+	/**
+	 * FQCN of the user-provided PrincipalResolver, or null when none was detected.
+	 *
+	 * The autogenerated subclass overrides this to return its plugin's
+	 * `<api_namespace>\Infrastructure\PrincipalResolver` when ApiBuilder detected
+	 * one. When null, the controller falls back to {@see \wp_get_current_user()}
+	 * (anonymous → null) to populate the request principal.
+	 */
+	protected function get_principal_resolver_fqcn(): ?string {
+		return null;
+	}
+
+	/**
+	 * Whether the configured PrincipalResolver's `resolve_principal()` declares
+	 * the \WP_REST_Request parameter (true) or omits it (false).
+	 *
+	 * Captured at build time and emitted as an override on the autogenerated
+	 * controller subclass, so the call below uses the right arity without
+	 * runtime reflection. Default is irrelevant when {@see self::get_principal_resolver_fqcn()}
+	 * returns null (the inline `wp_get_current_user()` fallback applies instead).
+	 */
+	protected function principal_resolver_takes_request(): bool {
+		return false;
+	}
+
+	/**
+	 * Resolve a class to an instance via the configured ClassResolver, or `new`.
+	 *
+	 * Used internally to instantiate the PrincipalResolver per request. The
+	 * autogenerated resolver classes use the detected ClassResolver directly
+	 * (the FQCN is baked in at build time), so this helper is only the runtime
+	 * path for infrastructure classes that the controller itself has to load.
+	 *
+	 * @param string $class_name Fully-qualified name of the class to resolve.
+	 */
+	private function resolve_class( string $class_name ): object {
+		$resolver = $this->get_class_resolver_fqcn();
+		if ( null === $resolver ) {
+			return new $class_name();
+		}
+		return $resolver::resolve_class( $class_name );
+	}
+
+	/**
+	 * Resolve the request principal once per HTTP request.
+	 *
+	 * Invoked eagerly at the top of {@see self::process_request()}, so a
+	 * principal resolver throwing {@see ApiException} fails the request before
+	 * any resolver runs (single coded error in the response, no `data`).
+	 *
+	 * The principal is never null — anonymous requests are signalled by a
+	 * principal whose authentication state is unauthenticated (for the default
+	 * {@see \Automattic\WooCommerce\Api\Infrastructure\Principal}, that's
+	 * `Principal::$user->ID === 0`). Plugin resolvers can also signal "invalid
+	 * credentials" by throwing ApiException.
+	 *
+	 * The configured resolver's `resolve_principal()` may declare its
+	 * \WP_REST_Request parameter or omit it; the autogenerated subclass
+	 * overrides {@see self::principal_resolver_takes_request()} so this call
+	 * uses the right arity without runtime reflection.
+	 *
+	 * @param \WP_REST_Request $request The incoming REST request.
+	 * @throws ApiException When the configured PrincipalResolver rejects the request.
+	 */
+	private function resolve_request_principal( \WP_REST_Request $request ): object {
+		$fqcn = $this->get_principal_resolver_fqcn();
+		if ( null === $fqcn ) {
+			return new \Automattic\WooCommerce\Api\Infrastructure\Principal( wp_get_current_user() );
+		}
+		$resolver = $this->resolve_class( $fqcn );
+		return $this->principal_resolver_takes_request()
+			? $resolver->resolve_principal( $request )
+			: $resolver->resolve_principal();
+	}
+
 	/**
 	 * Decode an optional JSON-object param (`variables` / `extensions`) into an array.
 	 *
@@ -419,12 +523,13 @@ abstract class GraphQLController {
 	}

 	/**
-	 * Determine debug flags based on WP_DEBUG, user role, and query string.
+	 * Determine debug flags based on WP_DEBUG, principal, and query string.
 	 *
-	 * @param \WP_REST_Request $request The REST request.
+	 * @param \WP_REST_Request $request   The REST request.
+	 * @param ?object          $principal The resolved principal, or null if resolution itself failed.
 	 */
-	private function get_debug_flags( \WP_REST_Request $request ): int {
-		if ( ! $this->is_debug_mode( $request ) ) {
+	private function get_debug_flags( \WP_REST_Request $request, ?object $principal ): int {
+		if ( ! $this->is_debug_mode( $principal, $request ) ) {
 			return DebugFlag::NONE;
 		}
 		return DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE;
@@ -435,32 +540,43 @@ abstract class GraphQLController {
 	 *
 	 * Introspection is permitted if either condition holds:
 	 * - The request is in debug mode ({@see self::is_debug_mode()}).
-	 * - The caller has the `manage_woocommerce` capability.
-	 *
-	 * Gating on capability rather than mere authentication keeps the full
-	 * schema (including admin-only mutations) hidden from low-privilege
-	 * roles such as `customer`, which every storefront account is assigned
-	 * at checkout — while still allowing admin tooling (e.g. GraphiQL-like
-	 * explorers) to query it.
+	 * - The principal opts in via a `can_introspect(): bool` method.
 	 *
-	 * @param \WP_REST_Request $request The REST request.
+	 * @param ?object          $principal The resolved principal, or null if resolution failed.
+	 * @param \WP_REST_Request $request   The REST request.
 	 */
-	private function is_introspection_allowed( \WP_REST_Request $request ): bool {
-		return $this->is_debug_mode( $request ) || current_user_can( 'manage_woocommerce' );
+	private function is_introspection_allowed( ?object $principal, \WP_REST_Request $request ): bool {
+		if ( $this->is_debug_mode( $principal, $request ) ) {
+			return true;
+		}
+		return null !== $principal
+			&& method_exists( $principal, 'can_introspect' )
+			&& $principal->can_introspect();
 	}

 	/**
 	 * Check if debug mode is active.
 	 *
-	 * Debug mode is active when either:
-	 * - WP_DEBUG is enabled AND the current user is an administrator (or in a local environment).
-	 * - The current user is an administrator (or in a local environment) AND `_debug=1` is in the query string.
+	 * Debug mode is active when both:
+	 * - The principal can use it (opted in via `can_use_debug_mode(): bool`,
+	 *   or a local environment is detected — the developer escape hatch).
+	 * - WP_DEBUG is enabled OR `_debug=1` is set on the request.
 	 *
-	 * @param \WP_REST_Request $request The REST request.
+	 * The principal-method check follows the same opt-in convention as
+	 * {@see self::is_introspection_allowed()} — plugin principals must
+	 * declare `can_use_debug_mode()` to grant access; absence denies.
+	 *
+	 * @param ?object          $principal The resolved principal, or null if resolution failed.
+	 * @param \WP_REST_Request $request   The REST request.
 	 */
-	private function is_debug_mode( \WP_REST_Request $request ): bool {
-		if ( ! $this->is_local_environment() && ! current_user_can( 'manage_options' ) ) {
-			return false;
+	private function is_debug_mode( ?object $principal, \WP_REST_Request $request ): bool {
+		if ( ! $this->is_local_environment() ) {
+			$allowed = null !== $principal
+				&& method_exists( $principal, 'can_use_debug_mode' )
+				&& $principal->can_use_debug_mode();
+			if ( ! $allowed ) {
+				return false;
+			}
 		}

 		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
@@ -473,10 +589,11 @@ abstract class GraphQLController {
 	/**
 	 * Format a caught exception into a GraphQL error array.
 	 *
-	 * @param \Throwable       $e       The caught exception.
-	 * @param \WP_REST_Request $request The REST request.
+	 * @param \Throwable       $e         The caught exception.
+	 * @param \WP_REST_Request $request   The REST request.
+	 * @param ?object          $principal The resolved principal, or null when the exception came from principal resolution itself.
 	 */
-	private function format_exception( \Throwable $e, \WP_REST_Request $request ): array {
+	private function format_exception( \Throwable $e, \WP_REST_Request $request, ?object $principal ): array {
 		if ( $e instanceof ApiException ) {
 			// Caller-supplied extensions come first so the canonical
 			// getErrorCode() can't be silently overridden by an extensions
@@ -501,7 +618,7 @@ abstract class GraphQLController {
 			);
 		}

-		if ( $this->is_debug_mode( $request ) ) {
+		if ( $this->is_debug_mode( $principal, $request ) ) {
 			$error['extensions']['debug'] = array(
 				'message' => $e->getMessage(),
 				'file'    => $e->getFile(),
@@ -551,6 +668,7 @@ abstract class GraphQLController {
 	 */
 	private const ERROR_STATUS_MAP = array(
 		'UNAUTHORIZED'              => 401,
+		'INVALID_TOKEN'             => 401,
 		'FORBIDDEN'                 => 403,
 		'NOT_FOUND'                 => 404,
 		'METHOD_NOT_ALLOWED'        => 405,
diff --git a/plugins/woocommerce/src/Internal/Api/Utils.php b/plugins/woocommerce/src/Internal/Api/Utils.php
index 24e2ebed1c9..734a16692f2 100644
--- a/plugins/woocommerce/src/Internal/Api/Utils.php
+++ b/plugins/woocommerce/src/Internal/Api/Utils.php
@@ -8,26 +8,6 @@ namespace Automattic\WooCommerce\Internal\Api;
  * Shared utilities for the auto-generated GraphQL resolvers.
  */
 class Utils {
-	/**
-	 * Assert that the current user has the given WordPress capability.
-	 *
-	 * Throws a GraphQL UNAUTHORIZED error if the check fails. Intended to
-	 * be called from generated resolver methods so the capability-check
-	 * boilerplate doesn't have to be repeated in every resolver.
-	 *
-	 * @param string $capability A WordPress capability slug.
-	 *
-	 * @throws \Automattic\WooCommerce\Vendor\GraphQL\Error\Error When the current user lacks the capability.
-	 */
-	public static function check_current_user_can( string $capability ): void {
-		if ( ! current_user_can( $capability ) ) {
-			throw new \Automattic\WooCommerce\Vendor\GraphQL\Error\Error(
-				'You do not have permission to perform this action.',
-				extensions: array( 'code' => 'UNAUTHORIZED' )
-			);
-		}
-	}
-
 	/**
 	 * Compute the complexity cost of a paginated connection field.
 	 *
@@ -120,7 +100,7 @@ class Utils {
 	 * into spec-compliant GraphQL errors.
 	 *
 	 * Mirror of execute_command() for the authorize step. Needed because an
-	 * authorize() call can throw an ApiException (e.g. AuthorizationException
+	 * authorize() call can throw an ApiException (e.g. UnauthorizedException
 	 * when a target record does not exist); without this wrapper the
 	 * exception would propagate up to webonyx and lose its error code and
 	 * user-visible message on its way through the generic error formatter.
@@ -137,6 +117,163 @@ class Utils {
 		);
 	}

+	/**
+	 * Build the GraphQL error to throw when an authorization check fails.
+	 *
+	 * Distinguishes the two HTTP-correct shapes:
+	 *  - **UNAUTHORIZED (401)** when the principal is anonymous — the caller
+	 *    could plausibly fix it by authenticating, so the response invites
+	 *    re-auth.
+	 *  - **FORBIDDEN (403)** otherwise — the principal is recognised but
+	 *    isn't allowed; re-authenticating wouldn't help.
+	 *
+	 * The "anonymous" check is opt-in by convention: the principal's
+	 * `is_authenticated(): bool` method, when present, decides. Principals
+	 * that don't define it fall through to FORBIDDEN — generated resolvers
+	 * still emit a coded error, just without the 401/403 distinction.
+	 *
+	 * @param object $principal The resolved request principal.
+	 */
+	public static function build_authorization_error( object $principal ): \Automattic\WooCommerce\Internal\Api\Schema\Error {
+		$is_anonymous = method_exists( $principal, 'is_authenticated' ) && ! $principal->is_authenticated();
+		return new \Automattic\WooCommerce\Internal\Api\Schema\Error(
+			$is_anonymous ? 'Authentication required.' : 'You do not have permission to perform this action.',
+			extensions: array( 'code' => $is_anonymous ? 'UNAUTHORIZED' : 'FORBIDDEN' )
+		);
+	}
+
+	/**
+	 * Compute the value `_preauthorized` would carry for the given command and
+	 * principal (the AND of the autodiscovered authorization attributes'
+	 * authorize() outcomes).
+	 *
+	 * Lets code-API callers (and tests) ask "would this command's attribute-based
+	 * authorization grant access to this principal?" without going through the
+	 * GraphQL pipeline.
+	 *
+	 * Note that it returns true when the command has no authorization attributes
+	 * (in that case the command's own `authorize()` method, if any, is the sole
+	 * guard; and consulting it requires running the command, which this helper
+	 * deliberately doesn't do).
+	 *
+	 * Note: this provides the attribute-level authorization only. A command with
+	 * both attributes and an `authorize()` method composes the two via the
+	 * `_preauthorized` infrastructure parameter; this helper returns the value
+	 * that `_preauthorized` would carry, not the final `authorize()` outcome.
+	 *
+	 * @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.
+	 *
+	 * @throws \InvalidArgumentException When `$command_fqcn` does not name an existing class.
+	 */
+	public static function compute_preauthorized( string $command_fqcn, object $principal ): bool {
+		if ( ! class_exists( $command_fqcn ) ) {
+			throw new \InvalidArgumentException(
+				sprintf( 'Class %s does not exist.', esc_html( $command_fqcn ) )
+			);
+		}
+		$ref    = new \ReflectionClass( $command_fqcn );
+		$direct = self::collect_authorization_instances( $ref );
+		$usages = $direct;
+		if ( empty( $usages ) ) {
+			// No direct attribute — collect from the entire ancestor tree:
+			// the parent chain plus each ancestor's traits and interfaces
+			// (recursively). All inherited sources contribute as peers; the
+			// only thing direct attributes shadow is the inherited tree as a
+			// whole. Mirrors
+			// {@see \Automattic\WooCommerce\Internal\Api\DesignTime\Scripts\ApiBuilder::resolve_authorization()}.
+			$visited = array();
+			$stack   = array_merge(
+				$ref->getParentClass() ? array( $ref->getParentClass() ) : array(),
+				$ref->getTraits(),
+				$ref->getInterfaces(),
+			);
+			while ( ! empty( $stack ) ) {
+				$source = array_shift( $stack );
+				$name   = $source->getName();
+				if ( in_array( $name, $visited, true ) ) {
+					continue;
+				}
+				$visited[] = $name;
+				$usages    = array_merge( $usages, self::collect_authorization_instances( $source ) );
+				if ( false !== $source->getParentClass() ) {
+					$stack[] = $source->getParentClass();
+				}
+				$stack = array_merge( $stack, $source->getTraits(), $source->getInterfaces() );
+			}
+		}
+
+		foreach ( $usages as $instance ) {
+			$auth_method = new \ReflectionMethod( $instance, 'authorize' );
+			$result      = $auth_method->getNumberOfParameters() > 0
+				? $instance->authorize( $principal )
+				: $instance->authorize();
+			if ( ! $result ) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+	/**
+	 * Collect attribute instances declared on $source whose class declares an
+	 * authorization-shaped `authorize()` method.
+	 *
+	 * Mirrors {@see \Automattic\WooCommerce\Internal\Api\DesignTime\Scripts\ApiBuilder::collect_authorization_usages()}
+	 * for the runtime path: same direct-then-inherited precedence, same
+	 * "any class with a bool-returning authorize() method qualifies" rule.
+	 *
+	 * @param \ReflectionClass $source Class/trait/interface to read attributes from.
+	 *
+	 * @return array<int, object>
+	 */
+	private static function collect_authorization_instances( \ReflectionClass $source ): array {
+		$instances = array();
+		foreach ( $source->getAttributes() as $attr ) {
+			$name = $attr->getName();
+			if ( ! class_exists( $name ) || ! method_exists( $name, 'authorize' ) ) {
+				continue;
+			}
+			$method = new \ReflectionMethod( $name, 'authorize' );
+			if ( ! self::authorize_method_shape_is_valid( $method ) ) {
+				continue;
+			}
+			$instances[] = $attr->newInstance();
+		}
+		return $instances;
+	}
+
+	/**
+	 * 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).
+	 *
+	 * Mirrors the build-time `ApiBuilder::validate_attribute_authorize_shape()`
+	 * check so the runtime helper recognises the same set of attributes ApiBuilder
+	 * would have emitted into a resolver.
+	 *
+	 * @param \ReflectionMethod $method The method to inspect.
+	 */
+	private static function authorize_method_shape_is_valid( \ReflectionMethod $method ): bool {
+		if ( $method->isStatic() || ! $method->isPublic() ) {
+			return false;
+		}
+		$return_type = $method->getReturnType();
+		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;
+		}
+		$param_type = $params[0]->getType();
+		return $param_type instanceof \ReflectionNamedType && ! $param_type->allowsNull();
+	}
+
 	/**
 	 * Invoke a callable, translating any thrown exception into a
 	 * spec-compliant GraphQL error with a machine-readable code.
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AttributeInheritanceTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AttributeInheritanceTest.php
index 31ea18a11ee..971740c9fd9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AttributeInheritanceTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AttributeInheritanceTest.php
@@ -25,12 +25,43 @@ class AttributeInheritanceTest extends AutogeneratedTestCase {
 		return (string) file_get_contents( $path );
 	}

+	/**
+	 * Assert that the generated resolver source contains an instantiation of
+	 * RequiredCapability with the given capability slug.
+	 *
+	 * Generated form: `( new \...\RequiredCapability( 'cap' ) )->authorize( ... )`.
+	 * Phrased loosely so it still matches if the linter shifts whitespace.
+	 *
+	 * @param string $capability The capability slug.
+	 * @param string $source     The generated resolver source code.
+	 */
+	private function assertGeneratesRequiredCapability( string $capability, string $source ): void {
+		$this->assertMatchesRegularExpression(
+			'/new \\\\Automattic\\\\WooCommerce\\\\Api\\\\Attributes\\\\RequiredCapability\\(\s*\'' . preg_quote( $capability, '/' ) . '\'\s*\\)/',
+			$source
+		);
+	}
+
+	/**
+	 * Assert that the generated resolver source does NOT contain an instantiation
+	 * of RequiredCapability with the given capability slug.
+	 *
+	 * @param string $capability The capability slug.
+	 * @param string $source     The generated resolver source code.
+	 */
+	private function assertDoesNotGenerateRequiredCapability( string $capability, string $source ): void {
+		$this->assertDoesNotMatchRegularExpression(
+			'/new \\\\Automattic\\\\WooCommerce\\\\Api\\\\Attributes\\\\RequiredCapability\\(\s*\'' . preg_quote( $capability, '/' ) . '\'\s*\\)/',
+			$source
+		);
+	}
+
 	/**
 	 * @testdox a query class inherits #[RequiredCapability] from its abstract parent.
 	 */
 	public function test_required_capability_inherited_from_parent(): void {
 		$source = $this->read_generated_resolver( 'InheritedCapQuery' );
-		$this->assertStringContainsString( "Utils::check_current_user_can( 'manage_options' )", $source );
+		$this->assertGeneratesRequiredCapability( 'manage_options', $source );

 		$admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
 		wp_set_current_user( $admin );
@@ -39,6 +70,7 @@ class AttributeInheritanceTest extends AutogeneratedTestCase {

 		wp_set_current_user( 0 );
 		$rejected = $this->execute_query( '{ inheritedCap { result } }' );
+		// Anonymous -> UNAUTHORIZED (the caller could authenticate to gain access).
 		$this->assertSame( 'UNAUTHORIZED', $rejected['errors'][0]['extensions']['code'] ?? null );
 	}

@@ -47,7 +79,10 @@ class AttributeInheritanceTest extends AutogeneratedTestCase {
 	 */
 	public function test_public_access_inherited_from_trait(): void {
 		$source = $this->read_generated_resolver( 'InheritedPublicQuery' );
-		$this->assertStringNotContainsString( 'check_current_user_can', $source );
+		// PublicAccess inheritance short-circuits the attribute expression to
+		// `true`, so the generated resolver carries no RequiredCapability
+		// instantiation.
+		$this->assertStringNotContainsString( 'RequiredCapability(', $source );

 		wp_set_current_user( 0 );
 		$result = $this->execute_query( '{ inheritedPublic { result } }' );
@@ -61,10 +96,11 @@ class AttributeInheritanceTest extends AutogeneratedTestCase {
 	 */
 	public function test_required_capability_inherited_from_interface(): void {
 		$source = $this->read_generated_resolver( 'InheritedFromInterfaceQuery' );
-		$this->assertStringContainsString( "Utils::check_current_user_can( 'manage_options' )", $source );
+		$this->assertGeneratesRequiredCapability( 'manage_options', $source );

 		wp_set_current_user( 0 );
 		$result = $this->execute_query( '{ inheritedFromInterface { result } }' );
+		// Anonymous -> UNAUTHORIZED.
 		$this->assertSame( 'UNAUTHORIZED', $result['errors'][0]['extensions']['code'] ?? null );
 	}

@@ -73,8 +109,8 @@ class AttributeInheritanceTest extends AutogeneratedTestCase {
 	 */
 	public function test_direct_attribute_overrides_inherited_capability(): void {
 		$source = $this->read_generated_resolver( 'OverriddenCapQuery' );
-		$this->assertStringContainsString( "Utils::check_current_user_can( 'manage_categories' )", $source );
-		$this->assertStringNotContainsString( "Utils::check_current_user_can( 'manage_options' )", $source );
+		$this->assertGeneratesRequiredCapability( 'manage_categories', $source );
+		$this->assertDoesNotGenerateRequiredCapability( 'manage_options', $source );

 		// `editor` has manage_categories but NOT manage_options. If the
 		// inherited cap were used, this would fail.
@@ -86,7 +122,7 @@ class AttributeInheritanceTest extends AutogeneratedTestCase {
 		// And the same editor still fails the inherited-cap variant — proving
 		// the difference is real, not a permissive role.
 		$inherited = $this->execute_query( '{ inheritedCap { result } }' );
-		$this->assertSame( 'UNAUTHORIZED', $inherited['errors'][0]['extensions']['code'] ?? null );
+		$this->assertSame( 'FORBIDDEN', $inherited['errors'][0]['extensions']['code'] ?? null );
 	}

 	/**
@@ -96,14 +132,14 @@ class AttributeInheritanceTest extends AutogeneratedTestCase {
 		$source = $this->read_generated_resolver( 'MergedCapsQuery' );
 		// Both the parent's manage_options and the trait's edit_posts must
 		// be present in the generated checks.
-		$this->assertStringContainsString( "Utils::check_current_user_can( 'manage_options' )", $source );
-		$this->assertStringContainsString( "Utils::check_current_user_can( 'edit_posts' )", $source );
+		$this->assertGeneratesRequiredCapability( 'manage_options', $source );
+		$this->assertGeneratesRequiredCapability( 'edit_posts', $source );

-		// `editor` has edit_posts but NOT manage_options → must fail.
+		// `editor` has edit_posts but NOT manage_options -> must fail.
 		$editor = self::factory()->user->create( array( 'role' => 'editor' ) );
 		wp_set_current_user( $editor );
 		$rejected = $this->execute_query( '{ mergedCaps { result } }' );
-		$this->assertSame( 'UNAUTHORIZED', $rejected['errors'][0]['extensions']['code'] ?? null );
+		$this->assertSame( 'FORBIDDEN', $rejected['errors'][0]['extensions']['code'] ?? null );

 		$admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
 		wp_set_current_user( $admin );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AttributesTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AttributesTest.php
index b159c37e317..138c23cba8d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AttributesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AttributesTest.php
@@ -42,18 +42,18 @@ class AttributesTest extends AutogeneratedTestCase {
 	 * @testdox #[Name] at the class level renames the GraphQL type.
 	 */
 	public function test_name_attribute_renames_classes(): void {
-		// Object types: Gadget → GadgetType (renamed), Widget unchanged.
+		// Object types: Gadget -> GadgetType (renamed), Widget unchanged.
 		$this->assertSame( 'GadgetType', GadgetType::get()->name );
 		$this->assertSame( 'Widget', WidgetType::get()->name );

-		// Enums: Priority → TaskPriority (renamed), Color unchanged.
+		// Enums: Priority -> TaskPriority (renamed), Color unchanged.
 		$this->assertSame( 'TaskPriority', PriorityEnumType::get()->name );

-		// Input types: WidgetFilterInput → WidgetFilterArgs (renamed), CreateWidgetInput unchanged.
+		// Input types: WidgetFilterInput -> WidgetFilterArgs (renamed), CreateWidgetInput unchanged.
 		$this->assertSame( 'WidgetFilterArgs', WidgetFilterInputType::get()->name );
 		$this->assertSame( 'CreateWidgetInput', CreateWidgetInputType::get()->name );

-		// Interfaces: Identifiable → HasId (renamed), Named unchanged.
+		// Interfaces: Identifiable -> HasId (renamed), Named unchanged.
 		$this->assertSame( 'HasId', IdentifiableInterfaceType::get()->name );
 		$this->assertSame( 'Named', NamedInterfaceType::get()->name );

@@ -74,8 +74,9 @@ class AttributesTest extends AutogeneratedTestCase {
 	 */
 	public function test_name_attribute_renames_enum_cases(): void {
 		$names = array_map( static fn( $v ) => $v->name, PriorityEnumType::get()->getValues() );
-		// Default case-name → SCREAMING_SNAKE_CASE: 'Low' → 'LOW', 'High' → 'HIGH'.
-		// Renamed via #[Name('NORMAL_PRIORITY')]: 'Normal' → 'NORMAL_PRIORITY'.
+		// Cases without #[Name] follow the default SCREAMING_SNAKE_CASE conversion
+		// (Low becomes LOW, High becomes HIGH); cases carrying #[Name(...)] use
+		// that exact value (Normal carries #[Name('NORMAL_PRIORITY')]).
 		$this->assertContains( 'LOW', $names );
 		$this->assertContains( 'NORMAL_PRIORITY', $names );
 		$this->assertContains( 'HIGH', $names );
@@ -196,6 +197,7 @@ class AttributesTest extends AutogeneratedTestCase {
 		$result = $this->execute_query( '{ widget(id: 1) { id } }' );

 		$this->assertArrayHasKey( 'errors', $result );
+		// Anonymous -> UNAUTHORIZED.
 		$this->assertSame( 'UNAUTHORIZED', $result['errors'][0]['extensions']['code'] ?? null );
 	}

diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AuthorizeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AuthorizeTest.php
index 877b4409ddc..6a25adc6d6e 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AuthorizeTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AuthorizeTest.php
@@ -35,6 +35,7 @@ class AuthorizeTest extends AutogeneratedTestCase {
 		$this->assertSame( 'allowed', $allowed['data']['authorizeOnly']['result'] ?? null );

 		$denied = $this->execute_query( '{ authorizeOnly(allow: false) { result } }' );
+		// Anonymous principal + denied → UNAUTHORIZED (re-authenticating could change the outcome).
 		$this->assertSame( 'UNAUTHORIZED', $denied['errors'][0]['extensions']['code'] ?? null );
 	}

@@ -43,8 +44,11 @@ class AuthorizeTest extends AutogeneratedTestCase {
 	 */
 	public function test_ignored_authorize_method_is_skipped(): void {
 		$source = $this->read_generated_resolver( 'IgnoredAuthorizeQuery' );
-		// Cap check is generated; authorize_command is not.
-		$this->assertStringContainsString( "Utils::check_current_user_can( 'manage_options' )", $source );
+		// The autodiscovered RequiredCapability attribute is wired in; authorize_command is not.
+		$this->assertMatchesRegularExpression(
+			'/new \\\\Automattic\\\\WooCommerce\\\\Api\\\\Attributes\\\\RequiredCapability\\(\s*\'manage_options\'\s*\\)/',
+			$source
+		);
 		$this->assertStringNotContainsString( 'authorize_command', $source );

 		// Even though the fixture's authorize() returns false, an admin must
@@ -56,6 +60,7 @@ class AuthorizeTest extends AutogeneratedTestCase {

 		wp_set_current_user( 0 );
 		$rejected = $this->execute_query( '{ ignoredAuthorize { result } }' );
+		// Anonymous → UNAUTHORIZED.
 		$this->assertSame( 'UNAUTHORIZED', $rejected['errors'][0]['extensions']['code'] ?? null );
 	}

@@ -80,7 +85,7 @@ class AuthorizeTest extends AutogeneratedTestCase {
 		$subscriber = self::factory()->user->create( array( 'role' => 'subscriber' ) );
 		wp_set_current_user( $subscriber );
 		$rejected = $this->execute_query( '{ overriddenAuthorize { result } }' );
-		$this->assertSame( 'UNAUTHORIZED', $rejected['errors'][0]['extensions']['code'] ?? null );
+		$this->assertSame( 'FORBIDDEN', $rejected['errors'][0]['extensions']['code'] ?? null );
 	}

 	/**
@@ -88,15 +93,21 @@ class AuthorizeTest extends AutogeneratedTestCase {
 	 */
 	public function test_composed_authorize_receives_preauthorized_flag(): void {
 		$source = $this->read_generated_resolver( 'ComposedAuthorizeQuery' );
-		// The cap is NOT directly checked; instead its result is passed to
-		// authorize() as `_preauthorized`.
-		$this->assertStringContainsString(
-			"'_preauthorized' => current_user_can( 'manage_options' )",
+		// The cap is NOT enforced as a standalone gate; instead its result is
+		// passed to authorize() as `_preauthorized` via the generated
+		// `compute_preauthorized()` helper that wraps the attribute call.
+		$this->assertMatchesRegularExpression(
+			'/\'_preauthorized\'\s*=>\s*self::compute_preauthorized\\(\s*\\$context\\[\'principal\'\\]\s*\\)/',
+			$source
+		);
+		// And no standalone authorization gate — authorize() stays the sole
+		// guard, with the attribute outcome flowing in via _preauthorized.
+		$this->assertStringNotContainsString( 'Standalone authorization gate', $source );
+		// The attribute call lives inside the compute_preauthorized() helper.
+		$this->assertMatchesRegularExpression(
+			'/public static function compute_preauthorized\\([^)]*\\): bool\\s*\\{\\s*return \\(\\s*new \\\\Automattic\\\\WooCommerce\\\\Api\\\\Attributes\\\\RequiredCapability\\(\s*\'manage_options\'\s*\\)\s*\\)->authorize\\(\s*\\$principal\s*\\);/',
 			$source
 		);
-		// And no top-level check_current_user_can is generated — authorize()
-		// stays the sole guard.
-		$this->assertStringNotContainsString( 'Utils::check_current_user_can', $source );

 		// admin: has manage_options → _preauthorized=true → authorize returns true.
 		$admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
@@ -111,7 +122,7 @@ class AuthorizeTest extends AutogeneratedTestCase {
 		$editor_ok = $this->execute_query( '{ composedAuthorize { result } }' );
 		$this->assertSame( 'composed', $editor_ok['data']['composedAuthorize']['result'] ?? null );

-		// anonymous: neither cap → authorize returns false → rejected.
+		// anonymous: neither cap → authorize returns false → UNAUTHORIZED.
 		wp_set_current_user( 0 );
 		$rejected = $this->execute_query( '{ composedAuthorize { result } }' );
 		$this->assertSame( 'UNAUTHORIZED', $rejected['errors'][0]['extensions']['code'] ?? null );
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 386a2233d9b..05ad3a56392 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AutogeneratedTestCase.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/AutogeneratedTestCase.php
@@ -4,7 +4,8 @@ declare(strict_types=1);

 namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;

-use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container as DummyContainer;
+use Automattic\WooCommerce\Api\Infrastructure\Principal;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver as DummyContainer;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\RootMutationType;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\RootQueryType;
@@ -66,6 +67,12 @@ abstract class AutogeneratedTestCase extends WC_Unit_Test_Case {
 	/**
 	 * Execute a GraphQL query against the dummy schema.
 	 *
+	 * Mirrors {@see \Automattic\WooCommerce\Internal\Api\GraphQLController::process_request()}
+	 * by populating `contextValue['principal']` with a {@see Principal} wrapping
+	 * {@see \wp_get_current_user()} (anonymous → underlying WP_User has ID=0).
+	 * Tests that need to run as a specific user should call `wp_set_current_user($id)`
+	 * before invoking this helper.
+	 *
 	 * @param string $query  The query source.
 	 * @param array  $vars   Variable values.
 	 *
@@ -75,6 +82,9 @@ 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() ),
+			),
 			variableValues: $vars,
 		);
 		return $result->toArray();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/PrincipalParamTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/PrincipalParamTest.php
new file mode 100644
index 00000000000..f867a3a726b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/PrincipalParamTest.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;
+
+/**
+ * Tests for the `_principal` infrastructure parameter on authorize() and execute().
+ *
+ * The principal flows in through the resolver `$context` populated by the
+ * controller (or, in these tests, by {@see AutogeneratedTestCase::execute_query()}
+ * mirroring the controller). The principal channel is non-nullable end-to-end:
+ * anonymous requests are represented by a sentinel principal (a Principal
+ * wrapping a WP_User with ID === 0), not by null.
+ */
+class PrincipalParamTest extends AutogeneratedTestCase {
+	/**
+	 * @testdox $_principal on execute() receives the resolved Principal carrying the authenticated user.
+	 */
+	public function test_principal_aware_query_receives_authenticated_user(): void {
+		$user_id = self::factory()->user->create(
+			array(
+				'user_login' => 'alice',
+				'role'       => 'subscriber',
+			)
+		);
+		wp_set_current_user( $user_id );
+
+		$result = $this->execute_query( '{ principalAware { result } }' );
+
+		$this->assertArrayNotHasKey( 'errors', $result );
+		$this->assertSame( 'alice', $result['data']['principalAware']['result'] ?? null );
+	}
+
+	/**
+	 * @testdox $_principal on execute() receives a sentinel anonymous principal for unauthenticated requests.
+	 */
+	public function test_principal_aware_query_receives_anonymous_principal(): void {
+		wp_set_current_user( 0 );
+
+		$result = $this->execute_query( '{ principalAware { result } }' );
+
+		$this->assertArrayNotHasKey( 'errors', $result );
+		$this->assertSame( 'anonymous', $result['data']['principalAware']['result'] ?? null );
+	}
+
+	/**
+	 * @testdox the autogenerated resolver wires `_principal` into execute_args from $context.
+	 */
+	public function test_resolver_source_wires_principal_from_context(): void {
+		$source = (string) file_get_contents( __DIR__ . '/../Fixtures/DummyApiAutogenerated/GraphQLQueries/PrincipalAwareQuery.php' );
+		$this->assertStringContainsString(
+			"\$execute_args['_principal'] = \$context['principal'];",
+			$source
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/QueryExecutionTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/QueryExecutionTest.php
index a208dc06518..0615d39f961 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/QueryExecutionTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Autogenerated/QueryExecutionTest.php
@@ -4,7 +4,7 @@ declare(strict_types=1);

 namespace Automattic\WooCommerce\Tests\Internal\Api\Autogenerated;

-use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container as DummyContainer;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver as DummyContainer;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\GetWidget;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;

diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/ComputePreauthorizedTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/ComputePreauthorizedTest.php
new file mode 100644
index 00000000000..537cf2d52ee
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/ComputePreauthorizedTest.php
@@ -0,0 +1,169 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Api\Infrastructure\Principal;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Authorization\ComposedAuthorizeQuery;
+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;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\ComposedAuthorizeQuery as ComposedAuthorizeResolver;
+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 WC_Unit_Test_Case;
+
+/**
+ * Tests for the dual `compute_preauthorized` paths:
+ *
+ *   1. The static helper emitted on each autogenerated resolver.
+ *   2. The runtime `Utils::compute_preauthorized()` helper that walks the
+ *      command class's attributes via Reflection.
+ *
+ * Both paths must agree on every command — they implement the same
+ * AND-of-attributes semantics, just at different stages of the build.
+ *
+ * Anonymous principals are represented by a {@see Principal} wrapping a
+ * `WP_User` with `ID === 0`, mirroring what the controller passes through
+ * the resolver context.
+ */
+class ComputePreauthorizedTest extends WC_Unit_Test_Case {
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		wp_set_current_user( 0 );
+	}
+
+	/**
+	 * Tear down.
+	 */
+	public function tearDown(): void {
+		wp_set_current_user( 0 );
+		parent::tearDown();
+	}
+
+	/**
+	 * Build a {@see Principal} for the given role, or anonymous when null.
+	 *
+	 * @param ?string $role A WP role slug, or null to construct an anonymous principal.
+	 */
+	private function principal_with_role( ?string $role ): Principal {
+		if ( null === $role ) {
+			return new Principal( new \WP_User() );
+		}
+		$user = self::factory()->user->create_and_get( array( 'role' => $role ) );
+		return new Principal( $user );
+	}
+
+	/**
+	 * @testdox the autogen-emitted helper returns true for an admin on a single-cap command.
+	 */
+	public function test_autogen_helper_grants_for_admin_on_inherited_cap(): void {
+		$admin     = $this->principal_with_role( 'administrator' );
+		$rejected  = $this->principal_with_role( 'subscriber' );
+		$anonymous = $this->principal_with_role( null );
+
+		$this->assertTrue( InheritedCapResolver::compute_preauthorized( $admin ) );
+		$this->assertFalse( InheritedCapResolver::compute_preauthorized( $rejected ) );
+		$this->assertFalse( InheritedCapResolver::compute_preauthorized( $anonymous ) );
+	}
+
+	/**
+	 * @testdox the autogen-emitted helper short-circuits to true for PublicAccess.
+	 */
+	public function test_autogen_helper_returns_true_for_public_access(): void {
+		$this->assertTrue( InheritedPublicResolver::compute_preauthorized( $this->principal_with_role( null ) ) );
+		$this->assertTrue( InheritedPublicResolver::compute_preauthorized( $this->principal_with_role( 'administrator' ) ) );
+	}
+
+	/**
+	 * @testdox the autogen-emitted helper ANDs multiple inherited capabilities.
+	 */
+	public function test_autogen_helper_ands_merged_caps(): void {
+		// The MergedCapsQuery requires manage_options AND edit_posts.
+		// `editor` has edit_posts but not manage_options.
+		$admin      = $this->principal_with_role( 'administrator' );
+		$editor     = $this->principal_with_role( 'editor' );
+		$subscriber = $this->principal_with_role( 'subscriber' );
+
+		$this->assertTrue( MergedCapsResolver::compute_preauthorized( $admin ) );
+		$this->assertFalse( MergedCapsResolver::compute_preauthorized( $editor ) );
+		$this->assertFalse( MergedCapsResolver::compute_preauthorized( $subscriber ) );
+	}
+
+	/**
+	 * @testdox Utils::compute_preauthorized agrees with the autogen helper for inherited capabilities.
+	 */
+	public function test_utils_helper_matches_autogen_for_inherited_cap(): void {
+		foreach ( array( 'administrator', 'subscriber', null ) as $role ) {
+			$principal = $this->principal_with_role( $role );
+			$autogen   = InheritedCapResolver::compute_preauthorized( $principal );
+			$utils     = Utils::compute_preauthorized( InheritedCapQuery::class, $principal );
+			$this->assertSame(
+				$autogen,
+				$utils,
+				'Drift between autogen and Utils for InheritedCapQuery, role: ' . ( $role ?? '<anonymous>' )
+			);
+		}
+	}
+
+	/**
+	 * @testdox Utils::compute_preauthorized agrees with the autogen helper for PublicAccess.
+	 */
+	public function test_utils_helper_matches_autogen_for_public_access(): void {
+		$anonymous = $this->principal_with_role( null );
+		$this->assertTrue( Utils::compute_preauthorized( InheritedPublicQuery::class, $anonymous ) );
+		$this->assertSame(
+			InheritedPublicResolver::compute_preauthorized( $anonymous ),
+			Utils::compute_preauthorized( InheritedPublicQuery::class, $anonymous )
+		);
+	}
+
+	/**
+	 * @testdox Utils::compute_preauthorized agrees with the autogen helper for merged caps.
+	 */
+	public function test_utils_helper_matches_autogen_for_merged_caps(): void {
+		foreach ( array( 'administrator', 'editor', null ) as $role ) {
+			$principal = $this->principal_with_role( $role );
+			$this->assertSame(
+				MergedCapsResolver::compute_preauthorized( $principal ),
+				Utils::compute_preauthorized( MergedCapsQuery::class, $principal )
+			);
+		}
+	}
+
+	/**
+	 * @testdox Utils::compute_preauthorized works on a command with both attributes and authorize().
+	 */
+	public function test_utils_helper_for_command_with_attribute_and_authorize_method(): void {
+		// ComposedAuthorizeQuery has both an attribute AND an authorize() method.
+		// compute_preauthorized() answers the attribute-level question only.
+		$admin = $this->principal_with_role( 'administrator' );
+
+		$this->assertTrue( ComposedAuthorizeResolver::compute_preauthorized( $admin ) );
+		$this->assertSame(
+			ComposedAuthorizeResolver::compute_preauthorized( $admin ),
+			Utils::compute_preauthorized( ComposedAuthorizeQuery::class, $admin )
+		);
+
+		$anonymous = $this->principal_with_role( null );
+		$this->assertFalse( ComposedAuthorizeResolver::compute_preauthorized( $anonymous ) );
+		$this->assertSame(
+			ComposedAuthorizeResolver::compute_preauthorized( $anonymous ),
+			Utils::compute_preauthorized( ComposedAuthorizeQuery::class, $anonymous )
+		);
+	}
+
+	/**
+	 * @testdox Utils::compute_preauthorized rejects an unknown class.
+	 */
+	public function test_utils_helper_rejects_unknown_class(): void {
+		$this->expectException( \InvalidArgumentException::class );
+		Utils::compute_preauthorized( '\\Definitely\\Not\\A\\Class', $this->principal_with_role( null ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Container.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Infrastructure/ClassResolver.php
similarity index 72%
rename from plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Container.php
rename to plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Infrastructure/ClassResolver.php
index a9786801de3..5afa47cc407 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Container.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Infrastructure/ClassResolver.php
@@ -2,18 +2,18 @@

 declare(strict_types=1);

-namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi;
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure;

 /**
- * Resolver container for the dummy code-API used by the GraphQL infrastructure
+ * Class resolver for the dummy code-API used by the GraphQL infrastructure
  * tests.
  *
  * Mirrors the public signature ApiBuilder requires: a public static
- * `get(string): object` method. Tests can swap the underlying instances via
+ * `resolve_class(string): object` method. Tests can swap the underlying instances via
  * {@see self::set_instance()} so a single resolver dispatch can be observed
  * with a known command instance.
  */
-final class Container {
+final class ClassResolver {
 	/**
 	 * @var array<class-string, object>
 	 */
@@ -27,7 +27,7 @@ final class Container {
 		self::$instances = array();
 	}

-	public static function get( string $class_name ): object {
+	public static function resolve_class( string $class_name ): object {
 		if ( isset( self::$instances[ $class_name ] ) ) {
 			return self::$instances[ $class_name ];
 		}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/AuthorizeThrowsQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/AuthorizeThrowsQuery.php
index 0b1aa456824..aa26cae358a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/AuthorizeThrowsQuery.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/AuthorizeThrowsQuery.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Queries\Au
 use Automattic\WooCommerce\Api\ApiException;
 use Automattic\WooCommerce\Api\Attributes\Description;
 use Automattic\WooCommerce\Api\Attributes\Name;
+use Automattic\WooCommerce\Api\InvalidTokenException;

 /**
  * Authorization is decided solely by `authorize()`, which always throws. The
@@ -39,6 +40,9 @@ class AuthorizeThrowsQuery {
 		if ( 'api_exception' === $kind ) {
 			throw new ApiException( 'Authorize failed.', 'AUTH_FAILURE', array( 'detail' => 'extra' ), 403 );
 		}
+		if ( 'invalid_token' === $kind ) {
+			throw new InvalidTokenException();
+		}
 		throw new \RuntimeException( 'Internals leaked from authorize.' );
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/PrincipalAwareQuery.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/PrincipalAwareQuery.php
new file mode 100644
index 00000000000..9f67ab8d4bc
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApi/Queries/Authorization/PrincipalAwareQuery.php
@@ -0,0 +1,36 @@
+<?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\Infrastructure\Principal;
+
+/**
+ * Exercises the `_principal` infrastructure parameter on both authorize() and
+ * execute().
+ *
+ * Returns the principal's user_login when authenticated, or 'anonymous' when
+ * not (the underlying WP_User has ID === 0). Authorize() returns true
+ * unconditionally — the test isn't gating access, just verifying the principal
+ * flows through the typed channel.
+ */
+#[Name( 'principalAware' )]
+#[Description( 'Echoes the principal user_login (or "anonymous").' )]
+class PrincipalAwareQuery {
+	public function execute( Principal $_principal ): string {
+		return $_principal->is_authenticated() ? $_principal->user->user_login : 'anonymous';
+	}
+
+	/**
+	 * Authorize the call. Always allows; the test reads the value out via execute().
+	 *
+	 * @param Principal $_principal The resolved principal.
+	 */
+	public function authorize( Principal $_principal ): bool {
+		unset( $_principal );
+		return true;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLController.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLController.php
index 2a4e04ceeb6..97849dd4440 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLController.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLController.php
@@ -17,4 +17,8 @@ class GraphQLController extends \Automattic\WooCommerce\Internal\Api\GraphQLCont
 			)
 		);
 	}
+
+	protected function get_class_resolver_fqcn(): ?string {
+		return \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::class;
+	}
 }
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 6c0ad6ef37b..10692225bb5 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
@@ -29,9 +29,13 @@ class CreateWidget {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_options' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( CreateWidgetCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( CreateWidgetCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'input', $args ) ) {
@@ -43,6 +47,21 @@ class CreateWidget {
 		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 ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability('manage_options') )->authorize( $principal );
+	}
+
 	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();

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 2a4777704d8..62f6dcaff40 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
@@ -33,9 +33,13 @@ class DeleteWidget {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_options' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( DeleteWidgetCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( DeleteWidgetCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'id', $args ) ) {
@@ -49,4 +53,19 @@ class DeleteWidget {

 		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 ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability('manage_options') )->authorize( $principal );
+	}
 }
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 8d64d705ad7..cf84fcfc40d 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
@@ -37,7 +37,7 @@ class Increment {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( IncrementCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( IncrementCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'value', $args ) ) {
@@ -51,4 +51,19 @@ class Increment {

 		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 true;
+	}
 }
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 900ff78a09c..3f3931282b3 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
@@ -31,7 +31,7 @@ class AuthorizeOnlyQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( AuthorizeOnlyQueryCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( AuthorizeOnlyQueryCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'allow', $args ) ) {
@@ -41,14 +41,26 @@ class AuthorizeOnlyQuery {
 		if ( ! Utils::authorize_command( $command, array(
 			'allow' => $execute_args['allow'],
 		) ) ) {
-			throw new \Automattic\WooCommerce\Internal\Api\Schema\Error(
-				'You do not have permission to perform this action.',
-				extensions: array( 'code' => 'UNAUTHORIZED' )
-			);
+			throw Utils::build_authorization_error( $context['principal'] );
 		}

 		$result = Utils::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 true;
+	}
 }
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 9b7d28c4513..a1df3ea0f3c 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
@@ -32,7 +32,7 @@ class AuthorizeThrowsQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( AuthorizeThrowsQueryCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( AuthorizeThrowsQueryCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'kind', $args ) ) {
@@ -42,14 +42,26 @@ class AuthorizeThrowsQuery {
 		if ( ! Utils::authorize_command( $command, array(
 			'kind' => $execute_args['kind'],
 		) ) ) {
-			throw new \Automattic\WooCommerce\Internal\Api\Schema\Error(
-				'You do not have permission to perform this action.',
-				extensions: array( 'code' => 'UNAUTHORIZED' )
-			);
+			throw Utils::build_authorization_error( $context['principal'] );
 		}

 		$result = Utils::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 true;
+	}
 }
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 7c91c99374f..45b21f78147 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
@@ -28,21 +28,33 @@ class ComposedAuthorizeQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( ComposedAuthorizeQueryCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( ComposedAuthorizeQueryCommand::class );

 		$execute_args = array();

 		if ( ! Utils::authorize_command( $command, array(
-			'_preauthorized' => current_user_can( 'manage_options' ),
+			'_preauthorized' => self::compute_preauthorized( $context['principal'] ),
 		) ) ) {
-			throw new \Automattic\WooCommerce\Internal\Api\Schema\Error(
-				'You do not have permission to perform this action.',
-				extensions: array( 'code' => 'UNAUTHORIZED' )
-			);
+			throw Utils::build_authorization_error( $context['principal'] );
 		}

 		$result = Utils::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\Api\Attributes\RequiredCapability('manage_options') )->authorize( $principal );
+	}
 }
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 96b59e2e42f..72bbe67efb3 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
@@ -33,7 +33,7 @@ class FailingQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( FailingQueryCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( FailingQueryCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'kind', $args ) ) {
@@ -44,4 +44,19 @@ class FailingQuery {

 		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 true;
+	}
 }
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 c04747839e8..3e35dfd66c9 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
@@ -33,7 +33,7 @@ class GetGreeting {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( GetGreetingCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( GetGreetingCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'name', $args ) ) {
@@ -44,4 +44,19 @@ class GetGreeting {

 		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 true;
+	}
 }
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 863cee24529..f50d55792c9 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
@@ -28,7 +28,7 @@ class GetIdentifiable {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( GetIdentifiableCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( GetIdentifiableCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'kind', $args ) ) {
@@ -39,4 +39,19 @@ class GetIdentifiable {

 		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/GetWidget.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/GetWidget.php
index bede69da448..967bb912fe2 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
@@ -28,9 +28,13 @@ class GetWidget {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_options' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( GetWidgetCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( GetWidgetCommand::class );

 		$execute_args = array();
 		if ( array_key_exists( 'id', $args ) ) {
@@ -41,4 +45,19 @@ class GetWidget {

 		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 ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability('manage_options') )->authorize( $principal );
+	}
 }
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 5cd388528a8..cc3e49a8910 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
@@ -28,9 +28,13 @@ class IgnoredAuthorizeQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_options' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( IgnoredAuthorizeQueryCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( IgnoredAuthorizeQueryCommand::class );

 		$execute_args = array();

@@ -38,4 +42,19 @@ class IgnoredAuthorizeQuery {

 		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\Api\Attributes\RequiredCapability('manage_options') )->authorize( $principal );
+	}
 }
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 31ffe499fc8..0b3126907da 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
@@ -28,9 +28,13 @@ class InheritedCapQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_options' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( InheritedCapQueryCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( InheritedCapQueryCommand::class );

 		$execute_args = array();

@@ -38,4 +42,19 @@ class InheritedCapQuery {

 		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\Api\Attributes\RequiredCapability('manage_options') )->authorize( $principal );
+	}
 }
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 2e4c8fb8aed..0c773dc536c 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
@@ -28,9 +28,13 @@ class InheritedFromInterfaceQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_options' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( InheritedFromInterfaceQueryCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( InheritedFromInterfaceQueryCommand::class );

 		$execute_args = array();

@@ -38,4 +42,19 @@ class InheritedFromInterfaceQuery {

 		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\Api\Attributes\RequiredCapability('manage_options') )->authorize( $principal );
+	}
 }
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 341b94904b4..5f340e690a3 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
@@ -28,7 +28,7 @@ class InheritedPublicQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( InheritedPublicQueryCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( InheritedPublicQueryCommand::class );

 		$execute_args = array();

@@ -36,4 +36,19 @@ class InheritedPublicQuery {

 		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 true;
+	}
 }
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 efb4f2246df..72450194515 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
@@ -62,11 +62,15 @@ class ListWidgets {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_options' );
-		Utils::check_current_user_can( 'edit_posts' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( ListWidgetsCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( ListWidgetsCommand::class );

+		$query_info = QueryInfoExtractor::extract_from_info( $info, $args );
 		$execute_args = array();
 		$execute_args['pagination'] = Utils::create_pagination_params( $args );
 		$execute_args['filters'] = Utils::create_input(
@@ -78,10 +82,25 @@ class ListWidgets {
 		if ( array_key_exists( 'min_priority', $args ) ) {
 			$execute_args['min_priority'] = $args['min_priority'];
 		}
-		$execute_args['_query_info'] = QueryInfoExtractor::extract_from_info( $info, $args );
+		$execute_args['_query_info'] = $query_info;

 		$result = Utils::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 ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability('manage_options') )->authorize( $principal ) && ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability('edit_posts') )->authorize( $principal );
+	}
 }
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 91dcbcde4fe..a1f9d7933ac 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
@@ -28,10 +28,13 @@ class MergedCapsQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_options' );
-		Utils::check_current_user_can( 'edit_posts' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( MergedCapsQueryCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( MergedCapsQueryCommand::class );

 		$execute_args = array();

@@ -39,4 +42,19 @@ class MergedCapsQuery {

 		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\Api\Attributes\RequiredCapability('manage_options') )->authorize( $principal ) && ( new \Automattic\WooCommerce\Api\Attributes\RequiredCapability('edit_posts') )->authorize( $principal );
+	}
 }
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 2d2ef0fb553..dcaa3c43b3c 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
@@ -28,20 +28,32 @@ class OverriddenAuthorizeQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( OverriddenAuthorizeQueryCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( OverriddenAuthorizeQueryCommand::class );

 		$execute_args = array();

 		if ( ! Utils::authorize_command( $command, array(
 		) ) ) {
-			throw new \Automattic\WooCommerce\Internal\Api\Schema\Error(
-				'You do not have permission to perform this action.',
-				extensions: array( 'code' => 'UNAUTHORIZED' )
-			);
+			throw Utils::build_authorization_error( $context['principal'] );
 		}

 		$result = Utils::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\Api\Attributes\RequiredCapability('manage_options') )->authorize( $principal );
+	}
 }
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 817339cd9c6..667871568ad 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
@@ -28,9 +28,13 @@ class OverriddenCapQuery {
 	}

 	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
-		Utils::check_current_user_can( 'manage_categories' );
+		// 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 Utils::build_authorization_error( $context['principal'] );
+		}

-		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container::get( OverriddenCapQueryCommand::class );
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( OverriddenCapQueryCommand::class );

 		$execute_args = array();

@@ -38,4 +42,19 @@ class OverriddenCapQuery {

 		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\Api\Attributes\RequiredCapability('manage_categories') )->authorize( $principal );
+	}
 }
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
new file mode 100644
index 00000000000..c1907850d51
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/DummyApiAutogenerated/GraphQLQueries/PrincipalAwareQuery.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\PrincipalAwareQuery as PrincipalAwareQueryCommand;
+use Automattic\WooCommerce\Internal\Api\QueryInfoExtractor;
+use Automattic\WooCommerce\Internal\Api\Utils;
+use Automattic\WooCommerce\Internal\Api\Schema\ResolveInfo;
+use Automattic\WooCommerce\Internal\Api\Schema\Type;
+
+class PrincipalAwareQuery {
+	public static function get_field_definition(): array {
+		return array(
+			'type' => Type::nonNull(new \Automattic\WooCommerce\Internal\Api\Schema\ObjectType(array(
+				'name' => 'PrincipalAwareQueryResult',
+				'fields' => array(
+					'result' => array( 'type' => Type::nonNull(Type::string()) ),
+				),
+			))),
+			'description' => __( 'Echoes the principal user_login (or \"anonymous\").', 'woocommerce' ),
+			'args' => array(
+			),
+			'resolve' => array( self::class, 'resolve' ),
+		);
+	}
+
+	public static function resolve( mixed $root, array $args, mixed $context, ResolveInfo $info ): mixed {
+		$command = \Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver::resolve_class( PrincipalAwareQueryCommand::class );
+
+		$execute_args = array();
+		$execute_args['_principal'] = $context['principal'];
+
+		if ( ! Utils::authorize_command( $command, array(
+			'_principal' => $context['principal'],
+		) ) ) {
+			throw Utils::build_authorization_error( $context['principal'] );
+		}
+
+		$result = Utils::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 true;
+	}
+}
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 6015e141949..1b8a297a8ab 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
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\Gra
 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\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\IgnoredAuthorizeQuery;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLQueries\OverriddenAuthorizeQuery;
@@ -39,6 +40,7 @@ class RootQueryType {
 						'inheritedPublic' => InheritedPublicQuery::get_field_definition(),
 						'widget' => GetWidget::get_field_definition(),
 						'authorizeThrows' => AuthorizeThrowsQuery::get_field_definition(),
+						'principalAware' => PrincipalAwareQuery::get_field_definition(),
 						'composedAuthorize' => ComposedAuthorizeQuery::get_field_definition(),
 						'ignoredAuthorize' => IgnoredAuthorizeQuery::get_field_definition(),
 						'overriddenAuthorize' => OverriddenAuthorizeQuery::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 5fa7d06253b..860bcd5e761 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-04-28T11:35:12+00:00
\ No newline at end of file
+2026-05-06T16:48:37+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php
index 1e5242907c1..f03689fe5d2 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php
@@ -6,7 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Api;

 use Automattic\WooCommerce\Internal\Api\GraphQLController;
 use Automattic\WooCommerce\Internal\Api\QueryCache;
-use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container as DummyContainer;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver as DummyContainer;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLController as DummyGraphQLController;
 use WC_REST_Unit_Test_Case;
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
index 8f44a3c3465..2879774ec8e 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
@@ -6,7 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Api;

 use Automattic\WooCommerce\Internal\Api\GraphQLController;
 use Automattic\WooCommerce\Internal\Api\QueryCache;
-use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container as DummyContainer;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver as DummyContainer;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLController as DummyGraphQLController;
 use WC_REST_Unit_Test_Case;
@@ -155,10 +155,29 @@ class GraphQLControllerExecutionTest extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox handle_request maps UNAUTHORIZED errors to HTTP 401.
+	 * @testdox handle_request maps an authenticated-but-denied authorization to HTTP 403.
 	 */
-	public function test_handle_request_maps_unauthorized_to_401(): void {
-		// `widget` requires manage_options.
+	public function test_handle_request_maps_authorization_failure_to_403(): void {
+		// `widget` requires manage_options. An editor is authenticated but lacks the
+		// cap → attribute denies → FORBIDDEN/403 (the caller is recognised; re-auth
+		// won't help).
+		$editor = self::factory()->user->create( array( 'role' => 'editor' ) );
+		wp_set_current_user( $editor );
+
+		$response = $this->sut->handle_request( $this->post_request( array( 'query' => '{ widget(id: 1) { id } }' ) ) );
+
+		$this->assertSame( 403, $response->get_status() );
+		$this->assertSame( 'FORBIDDEN', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+	}
+
+	/**
+	 * @testdox handle_request maps an anonymous-rejected authorization to HTTP 401.
+	 */
+	public function test_handle_request_maps_anonymous_authorization_failure_to_401(): void {
+		// `widget` requires manage_options. Anonymous caller → attribute denies but
+		// the principal isn't authenticated → UNAUTHORIZED/401 (re-auth might help).
+		wp_set_current_user( 0 );
+
 		$response = $this->sut->handle_request( $this->post_request( array( 'query' => '{ widget(id: 1) { id } }' ) ) );

 		$this->assertSame( 401, $response->get_status() );
@@ -415,4 +434,43 @@ class GraphQLControllerExecutionTest extends WC_REST_Unit_Test_Case {
 		$this->assertSame( 400, $response->get_status() );
 		$this->assertSame( 'BAD_USER_INPUT', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
 	}
+
+	/**
+	 * @testdox handle_request resolves the request principal eagerly and threads it into resolvers.
+	 *
+	 * The dummy fixture's principalAware query echoes the principal user_login.
+	 * Because no PrincipalResolver convention class is shipped under the fixture,
+	 * the controller falls back to a {@see \Automattic\WooCommerce\Api\Infrastructure\Principal}
+	 * wrapping `wp_get_current_user()` — the same default WC core's own endpoint uses.
+	 */
+	public function test_handle_request_threads_principal_into_resolver(): void {
+		$user_id = self::factory()->user->create(
+			array(
+				'user_login' => 'carol',
+				'role'       => 'subscriber',
+			)
+		);
+		wp_set_current_user( $user_id );
+
+		$response = $this->sut->handle_request(
+			$this->post_request( array( 'query' => '{ principalAware { result } }' ) )
+		);
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'carol', $response->get_data()['data']['principalAware']['result'] ?? null );
+	}
+
+	/**
+	 * @testdox handle_request threads an anonymous-marker principal for unauthenticated requests.
+	 */
+	public function test_handle_request_threads_anonymous_principal(): void {
+		wp_set_current_user( 0 );
+
+		$response = $this->sut->handle_request(
+			$this->post_request( array( 'query' => '{ principalAware { result } }' ) )
+		);
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'anonymous', $response->get_data()['data']['principalAware']['result'] ?? null );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/HelperExceptionsTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/HelperExceptionsTest.php
new file mode 100644
index 00000000000..4f4ed71f455
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/HelperExceptionsTest.php
@@ -0,0 +1,75 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Api\ApiException;
+use Automattic\WooCommerce\Api\ForbiddenException;
+use Automattic\WooCommerce\Api\InvalidTokenException;
+use Automattic\WooCommerce\Api\NotFoundException;
+use Automattic\WooCommerce\Api\UnauthorizedException;
+use Automattic\WooCommerce\Api\ValidationException;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the helper ApiException subclasses, each pinning a specific
+ * (error code, HTTP status) pair so callers don't have to spell them out at
+ * the throw site.
+ *
+ * The actual code → status mapping that turns these into HTTP responses lives
+ * in {@see \Automattic\WooCommerce\Internal\Api\GraphQLController} and is
+ * exercised end-to-end via {@see SecurityTest::test_invalid_token_error_code_maps_to_401()}
+ * and similar; this file just verifies the exception classes themselves carry
+ * the right metadata.
+ */
+class HelperExceptionsTest extends WC_Unit_Test_Case {
+	/**
+	 * @return array<string, array{class-string, string, int}>
+	 */
+	public function provider_helper_exceptions(): array {
+		return array(
+			'unauthorized'  => array( UnauthorizedException::class, 'UNAUTHORIZED', 401 ),
+			'invalid_token' => array( InvalidTokenException::class, 'INVALID_TOKEN', 401 ),
+			'forbidden'     => array( ForbiddenException::class, 'FORBIDDEN', 403 ),
+			'not_found'     => array( NotFoundException::class, 'NOT_FOUND', 404 ),
+			'validation'    => array( ValidationException::class, 'VALIDATION_ERROR', 422 ),
+		);
+	}
+
+	/**
+	 * @testdox each helper extends ApiException and pins the expected code and HTTP status.
+	 *
+	 * @dataProvider provider_helper_exceptions
+	 *
+	 * @param class-string $class       The helper exception class.
+	 * @param string       $code        The expected error code.
+	 * @param int          $status_code The expected HTTP status code.
+	 */
+	public function test_helper_exception_carries_code_and_status( string $class, string $code, int $status_code ): void {
+		$exception = new $class();
+
+		$this->assertInstanceOf( ApiException::class, $exception );
+		$this->assertSame( $code, $exception->getErrorCode() );
+		$this->assertSame( $status_code, $exception->getStatusCode() );
+		$this->assertNotEmpty( $exception->getMessage() );
+		$this->assertSame( array(), $exception->getExtensions() );
+	}
+
+	/**
+	 * @testdox each helper accepts a custom message, extensions, and previous throwable.
+	 *
+	 * @dataProvider provider_helper_exceptions
+	 *
+	 * @param class-string $class The helper exception class.
+	 */
+	public function test_helper_exception_accepts_custom_args( string $class ): void {
+		$previous = new \RuntimeException( 'inner' );
+
+		$exception = new $class( 'Custom message.', array( 'detail' => 'extra' ), $previous );
+
+		$this->assertSame( 'Custom message.', $exception->getMessage() );
+		$this->assertSame( array( 'detail' => 'extra' ), $exception->getExtensions() );
+		$this->assertSame( $previous, $exception->getPrevious() );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/SecurityTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/SecurityTest.php
index 310de04183f..3137eaf9394 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/SecurityTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/SecurityTest.php
@@ -6,7 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Api;

 use Automattic\WooCommerce\Internal\Api\GraphQLController;
 use Automattic\WooCommerce\Internal\Api\QueryCache;
-use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Container as DummyContainer;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver as DummyContainer;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLController as DummyGraphQLController;
 use WC_REST_Unit_Test_Case;
@@ -77,8 +77,8 @@ class SecurityTest extends WC_REST_Unit_Test_Case {
 			$this->post_request( array( 'query' => '{ widget(id: 1) { id } }' ) )
 		);

-		$this->assertSame( 401, $response->get_status() );
-		$this->assertSame( 'UNAUTHORIZED', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
+		$this->assertSame( 403, $response->get_status() );
+		$this->assertSame( 'FORBIDDEN', $response->get_data()['errors'][0]['extensions']['code'] ?? null );
 	}

 	/**
@@ -113,19 +113,19 @@ class SecurityTest extends WC_REST_Unit_Test_Case {
 			)
 		);

-		$this->assertSame( 401, $response->get_status() );
+		$this->assertSame( 403, $response->get_status() );

-		$data               = $response->get_data();
-		$codes              = array_map(
+		$data            = $response->get_data();
+		$codes           = array_map(
 			static fn( array $err ): ?string => $err['extensions']['code'] ?? null,
 			$data['errors'] ?? array()
 		);
-		$unauthorized_count = count(
-			array_filter( $codes, static fn( ?string $code ): bool => 'UNAUTHORIZED' === $code )
+		$forbidden_count = count(
+			array_filter( $codes, static fn( ?string $code ): bool => 'FORBIDDEN' === $code )
 		);
 		$this->assertGreaterThanOrEqual(
 			2,
-			$unauthorized_count,
+			$forbidden_count,
 			'Both aliased resolutions must enforce the cap independently.'
 		);
 	}
@@ -146,6 +146,29 @@ class SecurityTest extends WC_REST_Unit_Test_Case {
 		$this->assertSame( 'extra', $data['errors'][0]['extensions']['detail'] ?? null );
 	}

+	/**
+	 * @testdox an INVALID_TOKEN error code maps to HTTP 401.
+	 *
+	 * Regression test: INVALID_TOKEN is the canonical code for plugin-supplied
+	 * principal resolvers signalling "credentials present but wrong" (vs.
+	 * UNAUTHORIZED for "credentials missing"). Without an entry in the
+	 * status map, it would default to HTTP 500 — which is what callers saw
+	 * before this entry was added.
+	 */
+	public function test_invalid_token_error_code_maps_to_401(): void {
+		wp_set_current_user( 0 );
+
+		$response = $this->sut->handle_request(
+			$this->post_request( array( 'query' => '{ authorizeThrows(kind: "invalid_token") { result } }' ) )
+		);
+
+		$this->assertSame( 401, $response->get_status() );
+		$this->assertSame(
+			'INVALID_TOKEN',
+			$response->get_data()['errors'][0]['extensions']['code'] ?? null
+		);
+	}
+
 	/**
 	 * @testdox an authorize() that throws an arbitrary exception is masked behind INTERNAL_ERROR.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/UtilsTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/UtilsTest.php
index d87ad610b10..e301cde4e73 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/UtilsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/UtilsTest.php
@@ -12,41 +12,9 @@ use WC_Unit_Test_Case;

 /**
  * Tests for {@see Utils} — the shared helper layer that autogenerated
- * resolvers route capability checks, pagination construction, and exception
- * translation through.
+ * resolvers route pagination construction and exception translation through.
  */
 class UtilsTest extends WC_Unit_Test_Case {
-	/**
-	 * @testdox check_current_user_can throws an UNAUTHORIZED GraphQL error when the cap is missing.
-	 */
-	public function test_check_current_user_can_throws_unauthorized_when_cap_is_missing(): void {
-		wp_set_current_user( 0 );
-
-		try {
-			Utils::check_current_user_can( 'manage_options' );
-			$this->fail( 'Expected GraphQLError to be thrown.' );
-		} catch ( GraphQLError $e ) {
-			$this->assertSame( 'You do not have permission to perform this action.', $e->getMessage() );
-			$extensions = $e->getExtensions();
-			$this->assertIsArray( $extensions );
-			$this->assertSame( 'UNAUTHORIZED', $extensions['code'] ?? null );
-		}
-	}
-
-	/**
-	 * @testdox check_current_user_can returns silently when the cap is held.
-	 */
-	public function test_check_current_user_can_passes_when_cap_is_held(): void {
-		$admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
-		wp_set_current_user( $admin );
-
-		// Should not throw.
-		Utils::check_current_user_can( 'manage_options' );
-
-		$this->assertTrue( true );
-		wp_set_current_user( 0 );
-	}
-
 	/**
 	 * @testdox complexity_from_pagination scales the cost by the requested page size.
 	 */