Commit cb815f4c86e for woocommerce
commit cb815f4c86e482c698d68ff98f1da84a496be7f2
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Thu May 28 14:53:58 2026 +0200
Add documentation for the dual code+GraphQL API (#65259)
diff --git a/docs/apis/README.md b/docs/apis/README.md
index 1b542cccd82..86e39b90049 100644
--- a/docs/apis/README.md
+++ b/docs/apis/README.md
@@ -20,6 +20,12 @@ The Store API provides public REST API endpoints for the development of customer
Explore the [Store API](./store-api/README.md) documentation.
+## Dual API (code + GraphQL, experimental)
+
+The dual API is an experimental, code-first API: you write plain PHP classes (the code API) and a build script generates a matching GraphQL endpoint from them. WooCommerce core ships its own dual API, and the underlying infrastructure can be reused by plugins to build their own.
+
+Explore the [Dual API](./dual-api/README.md) documentation.
+
## Other Resources
Beyond the powerful REST APIs, WooCommerce offers a suite of PHP-based APIs designed for developers to deeply integrate and extend the core functionality of their store. These APIs allow for direct interaction with WooCommerce classes, enabling custom behaviors for settings, payment gateways, shipping methods, and more.
diff --git a/docs/apis/dual-api/README.md b/docs/apis/dual-api/README.md
new file mode 100644
index 00000000000..d2e86757d0d
--- /dev/null
+++ b/docs/apis/dual-api/README.md
@@ -0,0 +1,65 @@
+---
+post_title: 'Dual API (code + GraphQL)'
+sidebar_label: 'Dual API'
+sidebar_position: 0
+---
+
+# WooCommerce Dual API
+
+The **dual API** is a code-first API architecture: you write plain PHP classes (the **code API**), and a build script generates a fully functional **GraphQL API** that mirrors them. The two are kept in sync from a single, manually maintained source (the code API) so there is one place to add behavior and two ways to consume it (in-process PHP calls and GraphQL-over-HTTP).
+
+WooCommerce core ships its own dual API, but the underlying infrastructure is reusable: a plugin can define its own code API and get a matching GraphQL endpoint with the same tooling.
+
+## Status: experimental, and a proof of concept
+
+> **This feature is experimental.** Everything under the `Automattic\WooCommerce\Api` namespace can change in backwards-incompatible ways, or be removed, in any release. Do not use it in production extensions.
+
+There are two separate parts to understand:
+
+- **The infrastructure** (the build tooling, attributes, authorization model, engine-decoupling layer): Implementing a robust and stable infrastructure has been for now the main focus of the development efforts.
+- **WooCommerce core's own code API** (the `coupons` and `products` queries/mutations): This is a **proof of concept**. It exists to exercise the infrastructure and will likely change significantly or be replaced in the short term. Treat it as an example, not a contract.
+
+This dual API, both the infrastructure and the proof of concept code API, has been introduced as an experimental feature in WooCommerce 10.9.
+
+## Requirements
+
+- **PHP 8.1+.** The code API uses enums, named arguments, and PHP 8 attributes. On PHP 8.0 or older the GraphQL endpoint is not registered.
+- **The `dual_code_graphql_api` feature flag.** It is hidden (not shown on the Features settings page). Enable it with:
+
+ ```bash
+ wp option update woocommerce_feature_dual_code_graphql_api_enabled yes
+ ```
+
+When the flag is off, no GraphQL route is registered. This gates **every** dual-API endpoint, the one in WooCommerce core **and** any registered by plugins. Code that touches the code API classes directly should guard on `FeaturesUtil::feature_is_enabled( 'dual_code_graphql_api' )`. The settings and filters are likewise site-wide and shared across all dual-API endpoints (see [Settings and caching](./caching-and-settings.md#scope-what-applies-where)).
+
+## Which document do I need?
+
+| Your question | Start here |
+| --- | --- |
+| What is this and how does it fit together? | [Architecture](./architecture.md) |
+| How do I add to or change WooCommerce's code API? | [Extending the code API](./extending-the-code-api.md) |
+| How do I paginate a list query? | [Relay-style pagination](./pagination.md) |
+| How do I build my own dual API in a plugin? | [Creating a dual API in a plugin](./creating-a-dual-api-in-a-plugin.md) |
+| How does authentication and authorization work? | [Authentication and authorization](./authentication-and-authorization.md) |
+| How do I attach and query schema metadata? | [Metadata and discovery](./metadata.md) |
+| How do I configure the endpoint and caching? | [Settings and caching](./caching-and-settings.md) |
+| How do I regenerate the GraphQL code, and what is the staleness check? | [Building and staleness checks](./building-and-staleness.md) |
+| The infrastructure or the builder is missing something, how do I change it safely? | [Extending the infrastructure](./extending-the-infrastructure.md) |
+
+Reference material (lookup tables, exact signatures):
+
+- [Recognized directories](./reference/directories.md)
+- [Attributes](./reference/attributes.md)
+- [Recognized methods and parameters](./reference/recognized-methods-and-parameters.md)
+- [Infrastructure classes](./reference/infrastructure-classes.md)
+- [Exceptions](./reference/exceptions.md)
+
+## Audience
+
+The primary audience for this documentation is **maintainers of WooCommerce's code API** and **developers building their own dual API in a plugin**. The secondary audience is **maintainers of the dual-API infrastructure** itself.
+
+Throughout these docs, rules introduced as "in a plugin" generally apply equally when extending WooCommerce core's own code API; where a rule is core-only or plugin-only, that is called out explicitly.
+
+## A working example
+
+The [`woocommerce-simple-events`](https://github.com/woocommerce/woocommerce-simple-events) plugin is a runnable reference that exercises the infrastructure end to end: custom authentication, custom authorization attributes, granular field-level gates, pagination, scalars, and more. These docs link to it for complete, copy-pasteable examples.
diff --git a/docs/apis/dual-api/_category_.json b/docs/apis/dual-api/_category_.json
new file mode 100644
index 00000000000..674bb00fef7
--- /dev/null
+++ b/docs/apis/dual-api/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Dual API (experimental)",
+ "position": 3
+}
diff --git a/docs/apis/dual-api/architecture.md b/docs/apis/dual-api/architecture.md
new file mode 100644
index 00000000000..1a606ad7983
--- /dev/null
+++ b/docs/apis/dual-api/architecture.md
@@ -0,0 +1,85 @@
+---
+post_title: 'Dual API architecture'
+sidebar_label: 'Architecture'
+sidebar_position: 1
+---
+
+# Dual API architecture
+
+This document explains how the pieces fit together. For how to actually write classes, see [Extending the code API](./extending-the-code-api.md).
+
+## Two halves, one source
+
+The dual API has two halves:
+
+- **The code API**: plain PHP classes under `src/Api/`. They are GraphQL-agnostic: they import nothing from any GraphQL library and work as a standalone, in-process PHP API. This is the **authoritative, manually maintained source**.
+- **The autogenerated GraphQL layer**: code under `src/Internal/Api/Autogenerated/` produced by a build script from the code API. It is committed to source control but **never hand-edited**. It powers a GraphQL endpoint (by default `POST|GET /wp-json/wc/graphql`).
+
+The build script reads the code API and (re)generates the GraphQL layer. The relationship is one-directional: you change PHP classes, then regenerate.
+
+```text
+src/Api/ ──(build:api)──▶ src/Internal/Api/Autogenerated/ ──▶ /wp-json/wc/graphql
+(you edit this) (generated, committed, never edited) (GraphQL endpoint)
+```
+
+Because the generated tree is committed to source code, regenerating it after a source change is mandatory; a [staleness check](./building-and-staleness.md) enforces this in GitHub's CI pipeline for pull requests.
+
+## Code-first and the command pattern
+
+The code API is organized around the [**command pattern**](https://en.wikipedia.org/wiki/Command_pattern): each query or mutation is a class with a single `execute()` method (plus an optional `authorize()` method). Output types, input types, enums, interfaces, and scalars are likewise plain classes/enums.
+
+```php
+#[Name( 'product' )]
+#[Description( 'Retrieve a single product by ID.' )]
+#[RequiredCapability( 'read_product' )]
+class GetProduct {
+ #[ReturnType( Product::class )]
+ public function execute( int $id ): ?object {
+ // ...
+ }
+}
+```
+
+The build script infers as much as it can from code structure and uses [**PHP 8 attributes**](https://www.php.net/manual/en/language.attributes.php) only where structure is not enough.
+
+## Convention over configuration
+
+Two conventions drive most behavior:
+
+- **Directory placement determines role.** A class in `Queries/` becomes a GraphQL query; one in `Types/` becomes an output type; one in `Enums/` becomes an enum; and so on. Arbitrary nested subdirectories are allowed for organization (e.g. `Queries/Coupons/GetCoupon.php`) - nesting does not change the role. See [Recognized directories](./reference/directories.md).
+- **Names are derived, then overridable.** GraphQL type names default to the PHP class name; query/mutation names to its camelCase form; fields to property names as-is; enum values from PascalCase to `SCREAMING_SNAKE_CASE`. Any of these can be overridden with `#[Name( '...' )]`.
+
+Attributes fill the gaps that conventions cannot: descriptions, authorization, type shaping (arrays, connections, custom scalars), deprecation, and metadata. See the [Attributes reference](./reference/attributes.md).
+
+## The GraphQL engine is an implementation detail
+
+The GraphQL endpoint is currently powered by the [webonyx/graphql-php](https://github.com/webonyx/graphql-php) package, vendored and re-namespaced to `Automattic\WooCommerce\Vendor\GraphQL\*` to avoid version conflicts with other plugins.
+
+This is deliberately hidden from code-API authors. The autogenerated code never references `Vendor\GraphQL\*` directly: it references only a thin, WooCommerce-owned **schema surface** under `Api\Infrastructure\Schema\*`. That surface is the single point of contact with the engine, so the engine could be replaced in the future without breaking already-committed generated code in plugins. As a code-API author you never see GraphQL types at all; as an infrastructure maintainer, see [Extending the infrastructure](./extending-the-infrastructure.md).
+
+## Where things live
+
+| Path | Contents | Edit? |
+| --- | --- | --- |
+| `src/Api/` | The code API: attributes, queries, mutations, types, input types, enums, interfaces, scalars, pagination, utils | Yes, this is the source |
+| `src/Api/Infrastructure/` | Public, engine-decoupled runtime surface and convention classes (`Principal`, `ClassResolver`, `GraphQLControllerBase`, the `Schema\*` wrappers, ...) | Rarely; infrastructure only |
+| `src/Internal/Api/Autogenerated/` | Generated GraphQL resolvers and type definitions | No, regenerate instead |
+| `src/Internal/Api/` | Internal runtime not referenced by external code (`QueryCache`, `Settings`, endpoint registrar, query rules) | Rarely; core only |
+| `bin/api-builder/` | The build tooling (`ApiBuilder`, `build-api.php`, staleness checker, templates). Not shipped in release builds | Rarely; infrastructure only |
+
+The generated tree mirrors the role directories: `Autogenerated/GraphQLQueries/`, `GraphQLMutations/`, and `GraphQLTypes/{Output,Input,Enums,Interfaces,Scalars,Pagination}/`, plus a `RootQueryType`, `RootMutationType`, and `TypeRegistry`.
+
+## Request lifecycle (summarized)
+
+When a GraphQL request hits the endpoint, the controller (a generated subclass of `GraphQLControllerBase`):
+
+1. Resolves a **principal** for the request (who is calling) via the configured `PrincipalResolver`.
+2. Parses and validates the query (depth and complexity limits; optional caching of the parsed AST).
+3. Runs the resolvers, which look up the corresponding command class through the `ClassResolver`, check authorization, and call `execute()`.
+4. Formats the result (or errors) and picks an HTTP status code (optionally via a plugin-supplied `HttpStatusResolver`).
+
+Each of these steps is a documented extension point; see [Authentication and authorization](./authentication-and-authorization.md), [Settings and caching](./caching-and-settings.md), and [Infrastructure classes](./reference/infrastructure-classes.md).
+
+## Reusable by plugins
+
+Everything above applies to a plugin that wants its own dual API. A plugin defines its own `src/Api/` tree, runs the same builder against it, commits the generated output to its own repo, and registers a dedicated GraphQL endpoint. It reuses WooCommerce's infrastructure and can supply its own convention classes (authentication, class resolution, status codes) and attributes where it needs to diverge from the defaults. See [Creating a dual API in a plugin](./creating-a-dual-api-in-a-plugin.md).
diff --git a/docs/apis/dual-api/authentication-and-authorization.md b/docs/apis/dual-api/authentication-and-authorization.md
new file mode 100644
index 00000000000..9935926320a
--- /dev/null
+++ b/docs/apis/dual-api/authentication-and-authorization.md
@@ -0,0 +1,141 @@
+---
+post_title: 'Authentication and authorization'
+sidebar_label: 'Authentication/Authorization'
+sidebar_position: 4
+---
+
+# Authentication and authorization
+
+Authentication and authorization in the dual API revolve around a [**security principal**](https://en.wikipedia.org/wiki/Principal_(computer_security)): a per-request object representing who is calling. Authentication produces the principal; authorization decides what that principal may do, expressed through **attributes**.
+
+## The principal
+
+Each request resolves to exactly one principal, produced once by a `PrincipalResolver`. The default core resolver wraps the current WordPress user:
+
+```php
+final class PrincipalResolver {
+ public function resolve_principal(): Principal {
+ return new Principal( wp_get_current_user() );
+ }
+}
+```
+
+The default `Principal` carries the `WP_User` and exposes:
+
+- `is_authenticated(): bool`: `true` when `user->ID > 0`. Anonymous requests are **not** signalled by `null`; they're a real principal whose user has ID 0.
+- `can_introspect(): bool`: defaults to true only when the user has the `manage_woocommerce` capability.
+- `can_use_debug_mode(): bool`: defaults to true only when the user has the `manage_options` capability.
+
+Plugins authenticating against something else (app token, signed webhook, ...) ship their own `PrincipalResolver` and principal class. The resolver's **return type declares the plugin's principal type**, which ApiBuilder uses to type-check `authorize()`/`$_principal` signatures at build time. A resolver may take an optional `\WP_REST_Request $request` parameter, or none. To reject bad credentials, throw `UnauthorizedException` or `InvalidTokenException` from the resolver. See [Creating a dual API in a plugin](./creating-a-dual-api-in-a-plugin.md) and [Infrastructure classes](./reference/infrastructure-classes.md).
+
+## Authorization attributes
+
+Authorization is declarative. Core ships two attributes:
+
+- `#[PublicAccess]`: no authentication required (`authorize()` always returns `true`).
+- `#[RequiredCapability( 'capability-name' )]`: requires the principal to hold a WordPress capability. Repeatable; multiple capabilities are ANDed (so all the capabilities are required in the user for authorization to succeed).
+
+```php
+#[RequiredCapability( 'read_private_shop_coupons' )]
+class ListCoupons { /* ... */ }
+```
+
+An attribute is recognized as an authorization attribute by **convention**: it declares a public `authorize()` method returning `bool`. The first non-underscore parameter receives the principal:
+
+```php
+public function authorize( MyPrincipal $principal ): bool { /* ... */ }
+// or, for unconditional access:
+public function authorize(): bool { return true; }
+```
+
+Plugins define their own authorization attributes (e.g. `#[RequiresScope( 'events:read' )]`) the same way, see the [Attributes reference](./reference/attributes.md). This is the recommended approach; it keeps authorization separate from business logic.
+
+### The `authorize()` method on commands
+
+For logic that doesn't fit an attribute, a query/mutation class can declare its own `authorize()` method. Compose it with the attribute decision via the `bool $_preauthorized` parameter (which will receive `true` if the attribute gates already grant):
+
+```php
+public function authorize( int $id, bool $_preauthorized, MyPrincipal $_principal ): bool {
+ return $_preauthorized || $_principal->owns( $id );
+}
+```
+
+## Granular (type- and field-level) authorization
+
+Authorization attributes apply at four levels:
+
+| Target | Effect |
+| --- | --- |
+| **Query / mutation** (class) | Gates the whole operation. |
+| **Output type** (class) | AND-composed into every field gate of that type (including via a trait the type uses). |
+| **Output field** (property) | Gates that field; re-evaluated per item when the field is a list. |
+| **Input field** (property) | Gates the field, but only when it was actually provided in the request. |
+
+`#[PublicAccess]` on a property is a no-op (it always grants) and produces a build warning.
+
+`authorize()` methods can opt into three more context parameters, supplied per call site, detected by name, in any order:
+
+- `array $_metadata`: `#[Metadata]` entries visible at the call site, in up to three slices: `['query']` (originating operation), `['type']` (enclosing type), `['field']` (the gated field).
+- `array $_args`: the GraphQL arguments at the call site.
+- `mixed $_parent`: the enclosing object being resolved (for an output-field gate, the parent object; lets you implement owner-or-scope checks).
+
+```php
+#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY )]
+final class OwnerOrScope {
+ public function __construct( public readonly string $scope ) {}
+
+ public function authorize( EventsPrincipal $principal, mixed $_parent ): bool {
+ return $principal->has_scope( $this->scope )
+ || ( is_object( $_parent ) && $_parent->organizer_login === $principal->user_login );
+ }
+}
+```
+
+### Deny shape and HTTP status
+
+When a gate denies:
+
+- **Operation-level** denies produce the bare authorization error.
+- **Field-level** denies attach `extensions.subject = { type, field, attribute }` alongside the preserved `extensions.code`.
+
+The error code and HTTP status depend on whether the principal is authenticated:
+
+- **Anonymous** principal (`is_authenticated()` returns `false`) → `UNAUTHORIZED` / **401** (authenticating might help).
+- **Authenticated** principal, or one that doesn't expose `is_authenticated()` → `FORBIDDEN` / **403** (authenticating won't help).
+
+Credential problems surfaced by the resolver use `UNAUTHORIZED` (401) or `INVALID_TOKEN` (401). See [Exceptions](./reference/exceptions.md).
+
+## Introspection, debug mode, and metadata gating
+
+Three sensitive surfaces are gated independently, each by a combination of a principal method, a filter, and a fail-closed default:
+
+| Surface | Principal method | Filter | Default if method absent |
+| --- | --- | --- | --- |
+| Native introspection (`__schema`, `__type`) | `can_introspect()` | `woocommerce_graphql_can_introspect` | deny |
+| Debug mode (also requires `_debug=1`) | `can_use_debug_mode()` | `woocommerce_graphql_can_use_debug_mode` | deny |
+| `_apiMetadata` discovery | `can_query_metadata()`, else falls back to `can_introspect()` | `woocommerce_graphql_can_query_metadata` | deny |
+
+All three gates **fail closed**:
+
+- A `null`/unresolved principal denies.
+- The principal method's return is checked with `=== true` (a truthy non-bool denies).
+- A throw from the method or filter is caught and treated as a deny.
+- Filters must return strictly `true` to grant; loose values like `1` or `'yes'` deny.
+
+The filters receive `( bool $decision, ?object $principal, \WP_REST_Request $request )`. They are **not** invoked when principal resolution itself failed. They are also **site-wide**: a callback affects every dual-API endpoint on the site (core and plugins), so branch on the `$request` route if it should apply to only one; see [Scope: what applies where](./caching-and-settings.md#scope-what-applies-where). The core `Principal` declares `can_introspect()` (gated on `manage_woocommerce`), which also governs `_apiMetadata` since it has no `can_query_metadata()` - so admin access to both works out of the box, and other principals are denied unless they opt in.
+
+Example override:
+
+```php
+add_filter(
+ 'woocommerce_graphql_can_introspect',
+ fn( bool $can, $principal, \WP_REST_Request $request ): bool =>
+ $can || 'true' === $request->get_param( 'x-allow-introspection' ),
+ 10,
+ 3
+);
+```
+
+## Pre-authorization for code-API callers
+
+Code that calls the code API directly (not through GraphQL) can ask whether the attribute gates would grant access for a principal, without executing the command, via `ResolverHelpers::compute_preauthorized( string $command_fqcn, object $principal ): bool`.
diff --git a/docs/apis/dual-api/building-and-staleness.md b/docs/apis/dual-api/building-and-staleness.md
new file mode 100644
index 00000000000..c6812def25a
--- /dev/null
+++ b/docs/apis/dual-api/building-and-staleness.md
@@ -0,0 +1,63 @@
+---
+post_title: 'Building and staleness checks'
+sidebar_label: 'Building'
+sidebar_position: 7
+---
+
+# Building and staleness checks
+
+The GraphQL layer is generated from the code API by a build script and **committed to source control**. This document covers the commands and the check that keeps the committed output in sync with its source.
+
+## Regenerating
+
+From `plugins/woocommerce/`:
+
+```bash
+# Regenerate core's GraphQL layer from src/Api/
+pnpm --filter=@woocommerce/plugin-woocommerce build:api
+
+# Regenerate the test fixture's tree (the DummyApi used by the test suite)
+pnpm --filter=@woocommerce/plugin-woocommerce build:api:test
+
+# Check whether the committed output is stale (used by CI)
+pnpm --filter=@woocommerce/plugin-woocommerce build:api:check
+```
+
+`build:api` runs `php bin/api-builder/build-api.php`. It **wipes and regenerates** the output directory, formats the result with `phpcbf`, refreshes the Composer autoloader, and writes the staleness-tracking files. The build tooling under `bin/api-builder/` is excluded from release builds.
+
+After regenerating, commit the `src/Api/` change and the regenerated `src/Internal/Api/Autogenerated/` tree **together**.
+
+## `build-api.php` flags
+
+`build-api.php` with no flags produces core's output. The four path/namespace flags are **all-or-nothing** - provide all four or none:
+
+| Flag | Meaning |
+| --- | --- |
+| `--api-dir=PATH` | Directory of code-API source classes to scan. |
+| `--autogen-dir=PATH` | Output directory (**wiped each run**). |
+| `--api-namespace=NS` | PSR-4 namespace mapping to `--api-dir`. |
+| `--autogen-namespace=NS` | PSR-4 namespace mapping to `--autogen-dir`. |
+| `--composer-working-dir=DIR` | Where to run `composer dump-autoload`. |
+| `--phpcbf-path=PATH` | Path to the `phpcbf` binary used for formatting. |
+| `--no-linter` | Skip the `phpcbf` formatting pass. |
+
+For fast local iteration, pass `--no-linter`: the `phpcbf` pass is the slowest step in the build, and it only affects whitespace in the generated PHP — the code is functionally identical with or without it. Run a full build (without the flag) before committing so the formatted output is what lands in source control. `ApiBuilder::run_for_plugin()` also honours `--no-linter` from `argv`, so the same shortcut works for plugin builds: `WC_PATH=… php bin/build-api.php --no-linter`.
+
+Plugins generally don't call the other flags directly; they use `ApiBuilder::run_for_plugin()` (see [Creating a dual API in a plugin](./creating-a-dual-api-in-a-plugin.md)).
+
+## The staleness check
+
+`build:api:check` (`php bin/api-builder/check-api-staleness.php`) fails when the committed generated tree doesn't match the current source.
+
+The check is **content-based**, not timestamp-based: `build:api` writes a SHA-256 hash of every `.php` file under the source dir (each file hashed as `relative_path \0 contents \0`, files sorted by path) into `api_source_hash.txt` in the output directory. `StalenessChecker::is_stale()` recomputes that hash and compares. Because it ignores mtimes and filesystem iteration order, it behaves identically on fresh clones, in CI, and during active development. (`api_generation_date.txt` is also written, for human reference only.)
+
+## CI enforcement
+
+The `.github/workflows/api-staleness.yml` workflow (**GraphQL API Staleness Check**) runs `build:api:check` on PRs and pushes that touch:
+
+- `plugins/woocommerce/src/Api/**`
+- `plugins/woocommerce/src/Internal/Api/Autogenerated/**`
+- `plugins/woocommerce/bin/api-builder/**`
+- the workflow file itself
+
+If the source was changed without regenerating, the job fails with `Generated GraphQL API code is out of date.` Regenerate, commit, and push to clear it. Plugins that maintain their own dual API should add an equivalent check to their own CI.
diff --git a/docs/apis/dual-api/caching-and-settings.md b/docs/apis/dual-api/caching-and-settings.md
new file mode 100644
index 00000000000..cbe663f7b70
--- /dev/null
+++ b/docs/apis/dual-api/caching-and-settings.md
@@ -0,0 +1,61 @@
+---
+post_title: 'Settings and caching'
+sidebar_label: 'Settings and caching'
+sidebar_position: 6
+---
+
+# Settings and caching
+
+WooCommerce core's GraphQL endpoint is configured under **WooCommerce → Settings → Advanced → GraphQL**. The section appears only when the `dual_code_graphql_api` feature flag is on.
+
+These settings are **site-wide, not per-endpoint**: every setting below except **Endpoint URL** applies to *every* dual-API endpoint on the site, including those registered by plugins. See [Scope: what applies where](#scope-what-applies-where).
+
+## Settings
+
+| Setting | Option name (`Main::` constant) | Type | Default | Effect |
+| --- | --- | --- | --- | --- |
+| Endpoint URL | `woocommerce_graphql_endpoint_url` (`OPTION_ENDPOINT_URL`) | text | `wc/graphql` | **Core's `/wc/graphql` only.** Path under `/wp-json/`. Must be at least two segments (`namespace/route`); validated and normalized on save. Plugins set their own route when they register an endpoint, so this setting does not affect them. |
+| Enable GET endpoint | `woocommerce_graphql_get_endpoint_enabled` (`OPTION_GET_ENDPOINT_ENABLED`) | checkbox | `yes` | When off, the endpoint accepts POST only; GET returns 404. Mutations are always rejected over GET. |
+| Maximum query depth | `woocommerce_graphql_max_query_depth` (`OPTION_MAX_QUERY_DEPTH`) | number | `15` | Rejects queries nested deeper than this during validation. Falls back to default when unset or non-positive. |
+| Maximum query complexity | `woocommerce_graphql_max_query_complexity` (`OPTION_MAX_QUERY_COMPLEXITY`) | number | `1000` | Rejects queries whose computed complexity score exceeds this. Connection fields multiply child cost by page size. |
+| Parsed query cache TTL | `woocommerce_graphql_query_cache_ttl` (`OPTION_QUERY_CACHE_TTL`) | number | `86400` | Seconds before cached parsed queries expire (object cache and APQ paths). |
+| Enable OPcache-based caching | `woocommerce_graphql_opcache_enabled` (`OPTION_OPCACHE_ENABLED`) | checkbox | `yes` | Cache parsed ASTs as PHP files served from OPcache shared memory. |
+| Enable ObjectCache-based caching | `woocommerce_graphql_object_cache_enabled` (`OPTION_OBJECT_CACHE_ENABLED`) | checkbox | `yes` | Cache parsed ASTs in the WP object cache. |
+| Enable APQ caching | `woocommerce_graphql_apq_enabled` (`OPTION_APQ_ENABLED`) | checkbox | `yes` | Support the Apollo Automatic Persisted Queries protocol (`persistedQuery` extension). When off, hash-only requests are rejected. |
+
+The depth and complexity metrics are observable on a request by appending `?_debug=1` (when the principal may use debug mode); the response carries `extensions.debug.depth` and `extensions.debug.complexity`.
+
+## Scope: what applies where
+
+The dual API has one set of switches and filters shared by every endpoint on the site, there is no per-plugin configuration surface. Concretely:
+
+- **The `dual_code_graphql_api` feature flag gates every dual-API endpoint.** When it's off, neither core's `/wc/graphql` nor any plugin endpoint is registered (`Main::register_graphql_endpoint()` is a no-op). PHP 8.1+ is required the same way.
+- **Every setting except Endpoint URL applies to all endpoints.** The GET toggle, max depth, max complexity, the three caching toggles, and the cache TTL are read from the shared infrastructure, so a plugin endpoint honours them exactly as core's does (for example, plugin endpoints reject GET when the GET toggle is off). **Endpoint URL is the exception**: it only configures core's `/wc/graphql`; a plugin chooses its own route at registration.
+- **The filters below are global.** A callback added to any of them affects *every* dual-API endpoint on the site, core and plugins alike. Each filter receives the `\WP_REST_Request`, so a callback that should apply to only one endpoint must branch on the request's route itself.
+
+## Query caching
+
+Parsing a GraphQL query into an AST is the expensive, repeatable step, so the framework caches parsed ASTs. On each request the resolution chain is:
+
+1. **OPcache file backend**: when its toggle is on, the OPcache extension is loaded, and the cache directory is writable. Parsed ASTs are written as `return [...];` PHP files under `wp-content/uploads/wc-graphql-cache/v<engine-version>/`; OPcache serves them as compiled bytecode (no string parse, no `unserialize`, no remote cache call).
+2. **WP object cache**: otherwise, when its toggle is on.
+3. **No cache**: parse on every request.
+
+Notes:
+
+- The cache key/version is tied to the query string and the parser version, so there's no correctness TTL concern on the file backend; the configurable TTL applies to the object-cache and APQ paths.
+- OPcache writes are atomic (temp file + `rename()`), drop a deny-all `.htaccess`, and pre-warm the bytecode. Expired files are cleaned up via a scheduled `woocommerce_graphql_opcache_cleanup` action.
+- APQ always uses the object cache for hash-only lookups, regardless of the standard-query toggles, preserving persisted-query semantics.
+
+## Relevant filters
+
+| Filter | Signature | Purpose |
+| --- | --- | --- |
+| `woocommerce_graphql_opcache_cache_dir` | `( string $dir )` | Override the OPcache file directory (default `{uploads}/wc-graphql-cache/v<n>`). Empty strings and stream wrappers are rejected. |
+| `woocommerce_graphql_can_introspect` | `( bool, ?object $principal, \WP_REST_Request )` | Gate native introspection. See [Authentication and authorization](./authentication-and-authorization.md). |
+| `woocommerce_graphql_can_use_debug_mode` | `( bool, ?object $principal, \WP_REST_Request )` | Gate debug mode. |
+| `woocommerce_graphql_can_query_metadata` | `( bool, ?object $principal, \WP_REST_Request )` | Gate `_apiMetadata`. See [Metadata](./metadata.md). |
+
+## Customizing the response HTTP status
+
+A plugin can override the HTTP status of any response (for example, always return 200) by shipping an `HttpStatusResolver` convention class. Core ships none, so its per-error-code mapping is the default. See [Creating a dual API in a plugin](./creating-a-dual-api-in-a-plugin.md) and [Infrastructure classes](./reference/infrastructure-classes.md).
diff --git a/docs/apis/dual-api/creating-a-dual-api-in-a-plugin.md b/docs/apis/dual-api/creating-a-dual-api-in-a-plugin.md
new file mode 100644
index 00000000000..da544256523
--- /dev/null
+++ b/docs/apis/dual-api/creating-a-dual-api-in-a-plugin.md
@@ -0,0 +1,113 @@
+---
+post_title: 'Creating a dual API in a plugin'
+sidebar_label: 'Dual API in a plugin'
+sidebar_position: 8
+---
+
+# Creating a dual API in a plugin
+
+A plugin can define its own code API and get a matching GraphQL endpoint using WooCommerce's infrastructure. The plugin writes its own classes under `src/Api/`, runs the same builder against them, commits the generated tree to its own repo, and registers a dedicated endpoint.
+
+> The full, runnable reference for everything here is the [`woocommerce-simple-events`](https://github.com/woocommerce/woocommerce-simple-events) plugin. The snippets below are condensed; see that repo for complete files.
+
+## Prerequisites
+
+- WooCommerce installed with the `dual_code_graphql_api` feature flag enabled, on PHP 8.1+.
+- The plugin's own Composer autoloader (PSR-4) and a `vendor/autoload.php`.
+
+The endpoint is **dedicated**: each plugin registers its own REST route. You cannot federate into core's `/wc/graphql`.
+
+## 1. Lay out the code API
+
+Use the same [directory conventions](./reference/directories.md) as core, under your plugin's namespace:
+
+```text
+my-plugin/
+├── bin/build-api.php
+├── src/Api/
+│ ├── Queries/ Mutations/ Types/ InputTypes/
+│ ├── Enums/ Interfaces/ Scalars/
+│ ├── Attributes/ ← custom attributes (optional)
+│ └── Infrastructure/ ← custom convention classes (optional)
+└── src/Internal/Api/Autogenerated/ ← generated; committed
+```
+
+Writing the code-API classes is identical to core, see [Extending the code API](./extending-the-code-api.md).
+
+## 2. Add the build script
+
+A plugin's `bin/build-api.php` is a thin wrapper around `ApiBuilder::run_for_plugin()`:
+
+```php
+<?php
+require_once $wc_path . '/vendor/autoload.php'; // dev-mode WC autoloader
+
+use Automattic\WooCommerce\Api\Infrastructure\DesignTime\ApiBuilder;
+
+ApiBuilder::run_for_plugin( dirname( __DIR__ ), 'Automattic\\MyPlugin' );
+```
+
+`run_for_plugin( $plugin_root, $namespace_prefix )` derives the conventional dirs/namespaces: source at `$plugin_root/src/Api` (namespace `<prefix>\Api`), output at `$plugin_root/src/Internal/Api/Autogenerated` (namespace `<prefix>\Internal\Api\Autogenerated`). Run it with `WC_PATH=<path-to-woocommerce> php bin/build-api.php` (or a `package.json` script), and commit the generated tree. See [Building and staleness checks](./building-and-staleness.md), and add an equivalent staleness check to your CI.
+
+> `ApiBuilder` lives under WooCommerce's `bin/api-builder/` and is registered via `autoload-dev`, so it is only resolvable from a dev-mode WooCommerce install. It is not shipped in release builds.
+
+## 3. Register the endpoint
+
+In your plugin bootstrap, register the route through core's `Main`:
+
+```php
+use Automattic\WooCommerce\Api\Infrastructure\Main as WooCommerceApiMain;
+
+add_action( 'plugins_loaded', static function () {
+ if ( ! method_exists( WooCommerceApiMain::class, 'register_graphql_endpoint' ) ) {
+ return; // WooCommerce too old, or feature/PHP unavailable
+ }
+ WooCommerceApiMain::register_graphql_endpoint( __DIR__, 'my-plugin', '/graphql' );
+} );
+```
+
+The first argument may be your plugin directory (the controller class is resolved by convention) or the fully-qualified controller class name. This is a no-op when the feature flag is off or PHP is < 8.1. Your endpoint goes through the same request pipeline as core's and inherits the core [GraphQL settings](./caching-and-settings.md).
+
+## 4. Reuse or replace the convention classes
+
+ApiBuilder detects a small set of **convention classes** at `<your-namespace>\Api\Infrastructure\*`. Ship one only when you need to diverge from the default; otherwise core's default applies. The same overriding mechanism is what core itself uses.
+
+| Class | Default | Ship your own to… |
+| --- | --- | --- |
+| `ClassResolver` | `wc_get_container()->get()` | Instantiate commands through your own DI container. |
+| `PrincipalResolver` | wraps `wp_get_current_user()` | Authenticate against something other than WP users. Its return type declares your principal type. |
+| `Principal` | wraps `WP_User` | Carry your own identity/permission data. Add `is_authenticated()`, and `can_introspect()`/`can_query_metadata()`/`can_use_debug_mode()` to opt into those surfaces. |
+| `HttpStatusResolver` | none (per-error-code map) | Override response HTTP status, e.g. always return 200. |
+
+See [Infrastructure classes](./reference/infrastructure-classes.md) for exact signatures.
+
+Custom authentication example (HTTP basic against a fixed credential, role in a header):
+
+```php
+namespace Automattic\MyPlugin\Api\Infrastructure;
+
+use Automattic\WooCommerce\Api\InvalidTokenException;
+
+final class PrincipalResolver {
+ public function resolve_principal( \WP_REST_Request $request ): EventsPrincipal {
+ $user = $_SERVER['PHP_AUTH_USER'] ?? null;
+ $pass = $_SERVER['PHP_AUTH_PW'] ?? null;
+ if ( null === $user || null === $pass ) {
+ return EventsPrincipal::anonymous();
+ }
+ if ( 'password' !== $pass || ! isset( EventsPrincipal::SCOPES_BY_ROLE[ $user ] ) ) {
+ throw new InvalidTokenException();
+ }
+ return new EventsPrincipal( $user, $user, EventsPrincipal::SCOPES_BY_ROLE[ $user ] );
+ }
+}
+```
+
+## 5. Define custom attributes and exceptions (optional)
+
+- **Attributes:** a class in your `Api/Attributes/` becomes an authorization attribute by declaring `authorize( <PrincipalType> $principal ): bool`, a metadata attribute by extending `Metadata`, and so on. See [Attributes reference](./reference/attributes.md). Authorization attributes can gate operations, types, fields, and arguments.
+- **Exceptions:** extend `ApiException` (or a subclass) to pin your own `(error code, HTTP status)`. See [Exceptions reference](./reference/exceptions.md).
+
+## 6. Engine-decoupling guarantee
+
+Your committed generated tree references only WooCommerce's public `Api\Infrastructure\*` surface, never the underlying GraphQL engine (`Vendor\GraphQL\*`). If WooCommerce ever swaps engines, that surface absorbs the change and **your already-committed generated code keeps working**. The flip side: never write code (generated or hand-written) that imports from `Vendor\GraphQL\*` or from `Internal\Api\*`. See [Architecture](./architecture.md) and [Extending the infrastructure](./extending-the-infrastructure.md).
diff --git a/docs/apis/dual-api/extending-the-code-api.md b/docs/apis/dual-api/extending-the-code-api.md
new file mode 100644
index 00000000000..464cc685b50
--- /dev/null
+++ b/docs/apis/dual-api/extending-the-code-api.md
@@ -0,0 +1,143 @@
+---
+post_title: 'Extending the code API'
+sidebar_label: 'Extending the code API'
+sidebar_position: 2
+---
+
+# Extending the code API
+
+This guide covers how to add to or change the code API (the PHP classes the GraphQL layer is generated from). It applies both to WooCommerce core's own code API and to the ones implemented by plugins (see [Creating a dual API in a plugin](./creating-a-dual-api-in-a-plugin.md) for the plugin-specific bootstrap).
+
+> Reminder: core's `coupons`/`products` API is a proof of concept and may change. Use it as a pattern, not a stable contract.
+
+## The workflow
+
+1. Add or edit classes under `src/Api/`.
+2. Regenerate the GraphQL layer: `pnpm --filter=@woocommerce/plugin-woocommerce build:api`.
+3. Run the tests and the [staleness check](./building-and-staleness.md).
+4. Commit the source change **and** the regenerated `Autogenerated/` tree together.
+
+You never edit the generated tree by hand. If a generated file looks wrong, fix the source class or the underlying templates and regenerate.
+
+## Queries and mutations
+
+A query or mutation is a class with one public `execute()` method. Place it under `Queries/` or `Mutations/` (nested subdirectories are fine). The GraphQL field name defaults to the camelCase form of the class name; override with `#[Name]`.
+
+```php
+#[Name( 'coupon' )]
+#[Description( 'Retrieve a single coupon by ID or code.' )]
+#[RequiredCapability( 'read_private_shop_coupons' )]
+class GetCoupon {
+ public function execute(
+ #[Description( 'The ID of the coupon to retrieve.' )]
+ ?int $id = null,
+ #[Description( 'The coupon code to look up.' )]
+ ?string $code = null,
+ ): ?Coupon {
+ // ...
+ }
+}
+```
+
+- **Arguments** come from `execute()` parameters; their GraphQL types are inferred from the PHP type declarations. A non-nullable parameter (`int $id`) becomes a non-null argument (`Int!`); a nullable parameter (`?int $id`) becomes a nullable argument (`Int`). An argument is **optional** (the client may omit it) when it is nullable **or** has a default value; so `?int $id` is optional even without a default, and only a non-nullable parameter with no default is **required**. A default value is additionally exposed as the argument's GraphQL default. Add per-argument docs with `#[Description]` on the parameter.
+- **Return type** comes from the PHP return type. When `execute()` returns a GraphQL interface - which in the code API is implemented as a PHP trait (see [Enums, interfaces, scalars](#enums-interfaces-scalars) below), and a trait can't be used as a return type hint - declare it with `#[ReturnType( SomeInterface::class )]` and return `object`.
+- **Errors:** throw a plain `\InvalidArgumentException` for malformed input (will be mapped to `INVALID_ARGUMENT` / 400), or one of the [exception classes](./reference/exceptions.md) to pin a specific error code and HTTP status.
+
+Mutations are identical except for the directory. They typically take a single input-type argument and return an output type or a dedicated result type.
+
+## Output types
+
+Classes under `Types/` become GraphQL output types. Public properties become fields, named as-is (snake_case is preserved). Type mapping is inferred from the PHP property type.
+
+```php
+#[Description( 'Represents a WooCommerce discount coupon.' )]
+class Coupon {
+ use ObjectWithId; // contributes the `id` field
+
+ #[Description( 'The coupon code.' )]
+ public string $code;
+
+ #[Description( 'The type of discount.' )]
+ public DiscountType $discount_type; // enum
+
+ #[Description( 'The date the coupon was created.' )]
+ #[ScalarType( DateTime::class )]
+ public ?string $date_created; // custom scalar
+
+ #[Description( 'Product IDs the coupon can be applied to.' )]
+ #[ArrayOf( 'int' )]
+ public array $product_ids; // list type
+}
+```
+
+Useful attributes on properties:
+
+- `#[ArrayOf( 'int' )]` / `#[ArrayOf( SomeType::class )]`: element type of an `array` property.
+- `#[ScalarType( DateTime::class )]`: render a property through a custom scalar. The property is typically a `string` holding the scalar's raw form (e.g. an ISO date), but it isn't required to be: any value the scalar's `serialize()` accepts works; the property's nullability still controls the field's nullability.
+- `#[ConnectionOf( SomeType::class )]` on a `Connection`-typed property: a nested paginated connection field (see [Relay-style pagination](./pagination.md)).
+- `#[Deprecated( 'reason' )]`: mark the field as deprecated (will be visible as such in [GraphQL introspection](https://graphql.org/learn/introspection/)).
+- `#[Ignore]`: exclude the property from the schema.
+- `#[Parameter( ... )]` / `#[ParameterDescription( ... )]`: give a field computed arguments (e.g. a `formatted` flag on a price field).
+
+See the [Attributes reference](./reference/attributes.md) for exact signatures.
+
+## Input types
+
+Classes under `InputTypes/` become GraphQL input types. A field is optional when its type is nullable **or** it has a default value; a non-nullable field with no default is required. (The example below uses nullable-with-default for optional fields, which is the common shape.)
+
+Use the `TracksProvidedFields` trait to distinguish "field omitted" (leave unchanged) from "field explicitly set to null" (clear it) - essential for patch-style update mutations:
+
+```php
+class CreateCouponInput {
+ use TracksProvidedFields;
+
+ public string $code; // required
+ public ?string $description = null; // optional
+}
+```
+
+In the consuming `execute()`, call `$input->was_provided( 'description' )` to check whether the client actually sent the field. This works on any input type that uses the trait, whether it's an argument to a mutation (the common case, for patch-style updates) or to a query - the operation resolver populates the tracker when it builds the input object. The exception is an `#[Unroll]`ed input parameter: its fields are flattened into separate arguments and the object is rebuilt through a different path, so `was_provided()` isn't populated there.
+
+## Enums, interfaces, scalars
+
+- **Enums** (`Enums/`) are backed PHP enums. Case names convert from PascalCase to `SCREAMING_SNAKE_CASE` (e.g. `FixedCart` → `FIXED_CART`); override with `#[Name]`. Add `#[Description]` to the enum and each case. A common pattern is an `Other` case plus a `raw_*` field on the type, so plugin-added values don't break the enum.
+- **Interfaces** (`Interfaces/`) are PHP **traits** marked with `#[Name]`/`#[Description]`. A type that `use`s the trait implements the interface. Traits can compose other traits (e.g. `Product` uses `ObjectWithId`).
+- **Scalars** (`Scalars/`) are classes with static `serialize( mixed $value ): string` (PHP → transport) and `parse( string $value ): mixed` (client → PHP, throwing `\InvalidArgumentException` on bad input). Apply one to a field with `#[ScalarType]`.
+
+### Why interfaces are PHP traits
+
+GraphQL interfaces are modeled as PHP **traits** rather than PHP `interface`s for a concrete reason: in the code API a type's fields are its public **properties**, and a PHP interface can only declare methods, not properties. A trait, by contrast, can declare the shared properties *and* inject them into every type that `use`s it; so a single trait both defines the interface's field set and physically contributes those fields to each implementer. The builder treats a trait placed under `Interfaces/` as a GraphQL interface and registers every output type that uses it as an implementer. (This is also why a query/mutation returning an interface can't type-hint it directly - a trait isn't a usable return type - and instead uses `#[ReturnType]`; see [Queries and mutations](#queries-and-mutations).)
+
+A trait that lives **outside** `Interfaces/` is just an ordinary code-sharing mixin: the builder does not turn it into a GraphQL type. This matters for **input types**: an input type may `use` traits to share fields or behavior (for example `TracksProvidedFields`, or a shared base of common input fields), but doing so never produces an "input interface". GraphQL defines interfaces only for output object types (there is no input-interface concept in the GraphQL specification) so there is nothing for the builder to generate. Interface modeling applies to output types only.
+
+## Pagination (connections)
+
+List queries handle [pagination](https://graphql.org/learn/pagination/) with [Relay-style cursor connections](https://relay.dev/graphql/connections.htm): return a `Connection` and declare the node type with `#[ConnectionOf( <NodeType>::class )]`, taking a `PaginationParams` argument (which `#[Unroll]`s into `first` / `last` / `after` / `before`).
+
+```php
+#[Name( 'coupons' )]
+#[RequiredCapability( 'read_private_shop_coupons' )]
+class ListCoupons {
+ #[ConnectionOf( Coupon::class )]
+ public function execute( PaginationParams $pagination, ?CouponStatus $status = null ): Connection {
+ // build Edge[] with cursors, a PageInfo, and a total_count
+ }
+}
+```
+
+This is a whole topic of its own: cursors, `PageInfo` semantics, the page-size cap, nested connections, and the two ways to build a `Connection`. See **[Relay-style pagination](./pagination.md)**.
+
+## Infrastructure parameters
+
+`execute()` and `authorize()` can declare specially named, underscore-prefixed parameters that the framework injects. They are optional, detected by name, and may appear in any order; declare only the ones you need:
+
+- `?array $_query_info`: the selection tree of the current query, for resolve-time optimization (e.g. skipping expensive joins for unrequested fields).
+- `<PrincipalType> $_principal`: the resolved principal for the request.
+- `bool $_preauthorized`: in `authorize()`, whether the attribute-based gates already grant access (lets you compose custom logic on top).
+- `array $_metadata`, `array $_args`, `mixed $_parent`: context for `authorize()` in granular (type/field) authorization.
+
+See [Recognized methods and parameters](./reference/recognized-methods-and-parameters.md) for the full contract, and [Authentication and authorization](./authentication-and-authorization.md) for how authorization is wired.
+
+## After you change anything
+
+Regenerate and commit the generated tree. The CI [staleness check](./building-and-staleness.md) fails any PR whose `src/Api/` source doesn't match its committed `Autogenerated/` output.
diff --git a/docs/apis/dual-api/extending-the-infrastructure.md b/docs/apis/dual-api/extending-the-infrastructure.md
new file mode 100644
index 00000000000..68ce7f8619e
--- /dev/null
+++ b/docs/apis/dual-api/extending-the-infrastructure.md
@@ -0,0 +1,58 @@
+---
+post_title: 'Extending the infrastructure'
+sidebar_label: 'Extending the infrastructure'
+sidebar_position: 9
+---
+
+# Extending the infrastructure
+
+This document is for maintainers of the dual-API infrastructure itself (the build tooling and the engine-integration layer), not for code-API authors. It is intentionally a high-level map; **the code itself is the primary source of truth** for the details. Key entry points:
+
+- Build tooling: `plugins/woocommerce/bin/api-builder/` (`ApiBuilder.php`, templates under `code-templates/`, `StalenessChecker.php`).
+- Engine surface: `plugins/woocommerce/src/Api/Infrastructure/Schema/` and its `README.md`.
+- Runtime helpers: `plugins/woocommerce/src/Api/Infrastructure/` (`GraphQLControllerBase`, `ResolverHelpers`, `MetadataController`, `QueryInfoExtractor`).
+
+## The engine-decoupling surface
+
+The GraphQL engine (currently `webonyx/graphql-php`, vendored as `Automattic\WooCommerce\Vendor\GraphQL\*`) is treated as a replaceable implementation detail. The contract that makes this possible:
+
+> **Generated code, and any public signature on an `Api\Infrastructure\*` class, may reference the `Schema\*` surface but never `Vendor\GraphQL\*` directly.**
+
+`src/Api/Infrastructure/Schema/` is the single point of contact with the engine. Generated resolvers, types, and root types import only from there. This matters because plugins commit their generated trees to their own repos: routing every engine reference through this surface means a future engine swap in WooCommerce doesn't break already-committed plugin code. Method *bodies* may touch vendor symbols: that's WooCommerce's concern when the engine changes, not the plugin's.
+
+The surface uses three patterns (see `Schema/README.md`):
+
+- **Subclass** (`Schema`, `ObjectType`, `InputObjectType`, `EnumType`, `InterfaceType`, `CustomScalarType`, `Error`): empty subclasses of the engine class today; a future migration translates the config in the constructor.
+- **Static facade** (`Type`): delegates `int()`, `string()`, `nonNull()`, `listOf()`, etc.; return types intentionally omitted so the concrete class can change.
+- **Class alias** (`ResolveInfo`, `AST\StringValueNode`): used where the engine constructs the instances; registered eagerly in `aliases.php` (wired via `composer.json`'s `autoload.files`).
+
+### Adding a symbol to the surface
+
+1. Add a subclass / facade method / alias in the matching style.
+2. Update the template that needs it to import from `Api\Infrastructure\Schema\*`.
+3. Regenerate core (`build:api`) and the fixture (`build:api:test`); confirm the `Autogenerated/` diff is imports-only.
+4. Add a row to the table in `Schema/README.md`.
+
+**Versioning is implicit in the namespace.** If a change would break already-committed plugin code, add a sibling namespace (e.g. `Schema\V2`) and teach the templates to emit against it; keep the current surface until the last dependent plugin migrates. An engine-migration checklist lives in `Schema/README.md`.
+
+## ApiBuilder (in brief)
+
+`ApiBuilder` scans the code-API directory, reflects over each class (placement, type declarations, attributes), and renders the matching template into the output tree. It also:
+
+- Detects the per-plugin convention classes (`ClassResolver`, `PrincipalResolver`/its principal type, `HttpStatusResolver`) and wires them into the generated controller subclass.
+- Harvests authorization and `#[Metadata]` attributes into the generated resolvers and the `_apiMetadata` data.
+- Emits per-field authorization gates and the input-side "only if provided" gates.
+- Warns at build time about unresolvable attribute references (e.g. a missing `use` import) and errors on duplicate metadata names.
+
+It is **not** unit-tested directly; it's validated end-to-end against a comprehensive dummy code-API fixture under `tests/php/src/Internal/Api/Fixtures/DummyApi/`, whose generated output is committed alongside it. When you change the builder or templates, update the dummy API if needed and regenerate both core and the fixture (`build:api` + `build:api:test`), then run the `wc-phpunit-graphql-infra` test suite. Treat a non-imports-only diff in the generated trees as a signal to review.
+
+## Runtime helpers
+
+- `GraphQLControllerBase`: abstract base for the generated controller. Owns the request lifecycle: principal resolution, validation (depth/complexity), execution, error formatting, and HTTP status selection (`pick_status()`, optionally via a plugin `HttpStatusResolver`). Its public `build_schema()` returns the `Schema\Schema` wrapper, never the engine type.
+- `ResolverHelpers`: static helpers the generated resolvers call: exception translation, pagination construction, authorization checks, and `compute_preauthorized()`.
+- `MetadataController`: contributes the hand-written `_apiMetadata` field and its supporting types (which don't fit the standard templates).
+- `QueryInfoExtractor`: turns the engine's `ResolveInfo` into the `_query_info` tree.
+
+## What stays internal
+
+`QueryCache`, `Settings`, the endpoint registrar, and the query depth/complexity rules remain under `Internal\Api\*`. No external code references them; they're wired by `Main` through the DI container. Keep them there unless an external consumer genuinely needs them - at which point move only the public-facing surface, following the same engine-decoupling rule.
diff --git a/docs/apis/dual-api/metadata.md b/docs/apis/dual-api/metadata.md
new file mode 100644
index 00000000000..c47d984171c
--- /dev/null
+++ b/docs/apis/dual-api/metadata.md
@@ -0,0 +1,62 @@
+---
+post_title: 'Metadata and discovery'
+sidebar_label: 'Metadata'
+sidebar_position: 5
+---
+
+# Metadata and discovery
+
+The dual API can attach machine-readable **metadata** to schema elements (types, fields, arguments, enum values) and expose it for discovery. The first built-in uses are marking elements as internal or experimental, but the mechanism is general: plugins ship their own categories without infrastructure changes.
+
+## Attaching metadata
+
+The base `#[Metadata( name, value )]` attribute attaches one name/value entry. It is repeatable and targets classes, properties, parameters, and enum cases. Values are restricted to `bool|int|float|string|null`.
+
+```php
+#[Metadata( 'owner', 'payments-team' )]
+#[Metadata( 'beta', true )]
+class SomeType { /* ... */ }
+```
+
+Core ships two convenience subclasses:
+
+- `#[Internal]` — `name = 'internal'`, `value = true`. For WooCommerce-core-only elements.
+- `#[Experimental]` — `name = 'experimental'`, `value = true`.
+
+Duplicate names on the same target are a build-time error (no silent merge or last-wins). Type-level metadata is **not** auto-propagated to fields; consumers apply the "subfields inherit" rule themselves if they want it.
+
+## Description mirroring
+
+A metadata subclass can mirror its marking into the human-readable description, so it's visible in tools (like stock GraphiQL) that don't know about the discovery channel. Override `transform_description()`:
+
+- `#[Internal]` prefixes the description with `[Internal] ` and supplies a default body when none exists.
+- `#[Experimental]` does the same with `[Experimental] `.
+
+When several transforming attributes apply to one element, their transforms chain in PHP source order (last-in-source wraps outermost), and the text flows through the standard `__( ..., 'woocommerce' )` translation pipeline. The plain `#[Metadata]` base does not modify descriptions. To define your own description-mirroring category, subclass `Metadata` and override `transform_description()`; see the [Attributes reference](./reference/attributes.md).
+
+## Discovery via GraphQL: `_apiMetadata`
+
+Every generated schema gains a root field:
+
+```graphql
+_apiMetadata(name: String, type: String, field: String, attribute: String): [MetadataTarget!]!
+```
+
+Each `MetadataTarget` carries two parallel slices: the collected metadata `entries`, and an `authorization` slice describing the authorization gates on that target. Arguments narrow independently (combined with AND): `name` trims surviving rows to the matching metadata entry, and `attribute` trims the authorization slice to a specific attribute short name.
+
+### Access is gated
+
+`_apiMetadata` is gated like introspection, see [Authentication and authorization](./authentication-and-authorization.md). The resolver consults `can_query_metadata()` on the principal if present, otherwise falls back to `can_introspect()`, otherwise denies; the `woocommerce_graphql_can_query_metadata` filter can override. This prevents anonymous callers from enumerating the schema's authorization gates.
+
+### Opting a target out
+
+Apply `#[HiddenFromMetadataQuery]` to a class or property to omit it (and its descriptors) from `_apiMetadata`. This is recognized by a duck-typed `shows_in_metadata_query(): bool` returning `false`; a target's visibility is the AND of that method across all its attributes. It does **not** affect native introspection or the runtime authorization gates: an attribute hidden from discovery still runs its `authorize()`.
+
+## Discovery via PHP: `SchemaHandle`
+
+For in-process inspection, `GraphQLControllerBase::get_schema()` returns an opaque `SchemaHandle` (`Automattic\WooCommerce\Api\Utils\SchemaHandle`) with:
+
+- `get_all_metadata(): array`: every metadata row in the schema.
+- `find_metadata( ?string $name, ?string $type, ?string $field ): array`: the same filter-narrows semantics as the GraphQL field.
+
+The handle never exposes the underlying engine type in its public signature, so PHP callers don't depend on the GraphQL engine. It's the natural home for future schema-inspection operations.
diff --git a/docs/apis/dual-api/pagination.md b/docs/apis/dual-api/pagination.md
new file mode 100644
index 00000000000..16811b5cf25
--- /dev/null
+++ b/docs/apis/dual-api/pagination.md
@@ -0,0 +1,119 @@
+---
+post_title: 'Relay-style pagination'
+sidebar_label: 'Pagination'
+sidebar_position: 3
+---
+
+# Relay-style pagination
+
+List queries in the dual API paginate with **cursor-based connections** following the [Relay Cursor Connections specification](https://relay.dev/graphql/connections.htm). You write a command that returns a `Connection`; the builder generates the matching GraphQL `Connection`, `Edge`, and shared `PageInfo` types. The building blocks live in `Automattic\WooCommerce\Api\Pagination` and are reused by core and plugins alike.
+
+## The connection shape
+
+For a node type `Coupon`, a `#[ConnectionOf( Coupon::class )]` query produces this GraphQL shape:
+
+```graphql
+type CouponConnection {
+ edges: [CouponEdge!]! # each item paired with its cursor
+ nodes: [Coupon!]! # the items alone, a convenience shortcut
+ page_info: PageInfo!
+ total_count: Int! # total matches before the page window
+}
+
+type CouponEdge {
+ cursor: String!
+ node: Coupon!
+}
+
+type PageInfo {
+ has_next_page: Boolean!
+ has_previous_page: Boolean!
+ start_cursor: String
+ end_cursor: String
+}
+```
+
+`edges` and `nodes` carry the same items; `edges` adds the per-item `cursor`, while `nodes` is there for clients that just want the data. `PageInfo` is a single shared type across every connection.
+
+## Writing a paginated query
+
+Place the query under `Queries/`, return a `Connection`, and annotate `execute()` with `#[ConnectionOf( <NodeType>::class )]`. Take an argument of type `PaginationParams` - this type carries `#[Unroll]`, so its properties expand into individual GraphQL arguments rather than a nested input object:
+
+```php
+#[Name( 'coupons' )]
+#[Description( 'List coupons with cursor-based pagination.' )]
+#[RequiredCapability( 'read_private_shop_coupons' )]
+class ListCoupons {
+ #[ConnectionOf( Coupon::class )]
+ public function execute( PaginationParams $pagination, ?CouponStatus $status = null ): Connection {
+ // 1. query your data store, fetching one extra row to detect a next page
+ // 2. build an Edge per item (cursor + node)
+ // 3. populate a PageInfo and total_count
+ // 4. return the Connection
+ }
+}
+```
+
+The resulting field accepts the four standard arguments plus any others you declare (like `status` above):
+
+```graphql
+coupons(first: Int, last: Int, after: String, before: String, status: CouponStatus) { ... }
+```
+
+## The pagination arguments
+
+`PaginationParams` defines the forward/backward window:
+
+| Argument | Meaning |
+| --- | --- |
+| `first` | Return the first N items (forward pagination). |
+| `after` | Return items after this cursor. |
+| `last` | Return the last N items (backward pagination). |
+| `before` | Return items before this cursor. |
+
+Bounds are enforced: `first`/`last` must be between `0` and `PaginationParams::MAX_PAGE_SIZE`; a negative or over-cap value throws `INVALID_ARGUMENT` (HTTP 400). When neither `first` nor `last` is given, `PaginationParams::get_default_page_size()` applies. The same bounds are enforced on nested connection fields via `PaginationParams::validate_args()`, so a deeply nested `first: 1000` can't slip past the cap.
+
+These maximum and default page sizes are currently hardcoded to 100, but may become configurable in future versions of WooCommerce.
+
+## Cursors
+
+Cursors are **opaque strings** to the client, never construct or parse them on the client side. Beyond that opacity, the engine mandates nothing about their format: any stable, encodable key works. The current core proof-of-concept happens to encode the node's numeric id as base64 (`base64_encode( (string) $id )`) and decode it with `IdCursorFilter::decode_id_cursor()`, which validates the input and throws `INVALID_ARGUMENT` (400) on a malformed cursor rather than silently returning unfiltered results. That scheme is a choice of the PoC code, not a requirement; your own connections are free to use a different encoding - just keep cursors opaque and validate them on decode.
+
+`IdCursorFilter` (in the `Api\Pagination` namespace) is a helper the PoC uses to window WordPress post queries on the `ID` column, via a lazy `posts_where` filter and two query vars:
+
+- `IdCursorFilter::AFTER_ID` (`wc_api_after_id`) → `AND ID > X`
+- `IdCursorFilter::BEFORE_ID` (`wc_api_before_id`) → `AND ID < X`
+
+Set whichever you need on your `WP_Query` args and call `IdCursorFilter::ensure_registered()` once before running the query. None of this is mandated by the engine: a plugin paginating its own post-backed data may find it useful to reuse `IdCursorFilter` (or follow the same `ID`-cursor pattern), but it's specific to `WP_Query` sources, and a connection over any other data store won't touch it.
+
+## PageInfo semantics
+
+- `start_cursor` / `end_cursor` are the cursors of the first and last edges in the returned page (or `null` for an empty page).
+- `has_next_page` / `has_previous_page` follow the Relay rules. In **forward** pagination (`first`), `has_next_page` is true when more items exist after the window - the common "fetch N+1 and check" trick. In **backward** pagination (`last`), the roles mirror. The framework computes these for you when it slices; if you pre-slice, you set them yourself.
+
+## Building the Connection: two paths
+
+`Connection` supports both a performant pre-paginated path and a slice-it-for-me path, and it guards against being sliced twice (so it's safe whether or not the generated resolver also calls `slice()`):
+
+- **`Connection::pre_sliced( array $edges, PageInfo $page_info, int $total_count )`**: use when your data store already applied the limits (the recommended path for real databases: push `first`/`after` into the SQL query). The returned connection is marked sliced, so the framework leaves it untouched.
+- **`$connection->slice( array $args )`**: build a `Connection` over a larger (or full) result set and let it apply the Relay algorithm: narrow by `after`, then `before`, then take `first` or `last`. It recomputes `PageInfo` and returns a new, sliced connection. Convenient for in-memory or small result sets.
+
+## Nested connections
+
+A `Connection`-typed **property** on an output type, annotated with `#[ConnectionOf]`, becomes a paginated field on that type; for example `Product.reviews`:
+
+```php
+#[Description( 'Customer reviews for this product.' )]
+#[ConnectionOf( ProductReview::class )]
+public Connection $reviews;
+```
+
+The generated resolver slices the property per the field's own pagination arguments, enforcing the same `MAX_PAGE_SIZE` cap as top-level queries.
+
+## Complexity
+
+Connection fields contribute to a query's computed complexity: a connection's cost multiplies its children's cost by the requested page size. This is what the **Maximum query complexity** limit guards against, see [Settings and caching](./caching-and-settings.md).
+
+## Reusing the building blocks
+
+`Connection`, `Edge`, `PageInfo`, and `PaginationParams` are part of the public `Api\Pagination` surface, so a plugin can return them directly without redefining its own. The [`woocommerce-simple-events`](https://github.com/woocommerce/woocommerce-simple-events) plugin's `eventsConnection` query is a minimal, in-memory working example (it builds edges over the full set and calls `slice()`); core's `ListCoupons` shows the `WP_Query` + `IdCursorFilter` database path.
diff --git a/docs/apis/dual-api/reference/_category_.json b/docs/apis/dual-api/reference/_category_.json
new file mode 100644
index 00000000000..75879876ee2
--- /dev/null
+++ b/docs/apis/dual-api/reference/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Reference",
+ "position": 10
+}
diff --git a/docs/apis/dual-api/reference/attributes.md b/docs/apis/dual-api/reference/attributes.md
new file mode 100644
index 00000000000..9e63acd4d66
--- /dev/null
+++ b/docs/apis/dual-api/reference/attributes.md
@@ -0,0 +1,81 @@
+---
+post_title: 'Reference: attributes'
+sidebar_label: 'Attributes'
+sidebar_position: 2
+---
+
+# Reference: attributes
+
+PHP 8 attributes supply the metadata the builder can't infer from code structure. All built-in attributes live in `Automattic\WooCommerce\Api\Attributes`. Plugins define their own under their `Api\Attributes\` namespace, following the [conventions](#conventions-for-custom-attributes) below; those conventions also apply when adding attributes to core.
+
+## Naming and description
+
+| Attribute | Constructor | Targets | Purpose |
+| --- | --- | --- | --- |
+| `Name` | `( string $name )` | all | Override the derived GraphQL name of a type, field, query/mutation, or enum value. |
+| `Description` | `( string $description )` | all | Human-readable description, surfaced in the schema. |
+| `ParameterDescription` | `( string $name, string $description )` | all, repeatable | Describe a single argument by name (e.g. a computed field's `#[Parameter]`). |
+
+## Type shaping
+
+| Attribute | Constructor | Targets | Purpose |
+| --- | --- | --- | --- |
+| `ArrayOf` | `( string $type )` | all | Element type of an `array` property/return: a scalar name (`'int'`, `'string'`, `'float'`, `'bool'`) or a class name. |
+| `ScalarType` | `( string $type )` | all | Render a property through a custom scalar class (e.g. `DateTime::class`). |
+| `ConnectionOf` | `( string $type )` | all | Mark a `Connection` return or property as a connection of the given node type; generates `<Type>Connection`/`<Type>Edge`. |
+| `ReturnType` | `( string $type )` | method | Declare the GraphQL return type when `execute()` returns an interface (PHP can't type-hint a trait). |
+| `Parameter` | see below | all, repeatable | Declare an explicit argument. Used to give an output field computed arguments, or to shape/`unroll` a query argument. |
+| `Unroll` | `()` | class, parameter | Expand a class's public properties into individual flat arguments instead of one input object. |
+
+`Parameter` full signature:
+
+```php
+public function __construct(
+ public readonly string $name = '',
+ public readonly string $type = '',
+ public readonly bool $nullable = false,
+ public readonly bool $array = false,
+ public readonly mixed $default = null,
+ public readonly string $description = '',
+ bool $has_default = false,
+ public readonly bool $unroll = false,
+)
+```
+
+## Lifecycle
+
+| Attribute | Constructor | Targets | Purpose |
+| --- | --- | --- | --- |
+| `Deprecated` | `( string $reason )` | all | Mark a field or enum value deprecated (shown in introspection). |
+| `Ignore` | `()` | all | Exclude the class or property from the schema entirely. |
+
+## Authorization
+
+| Attribute | Constructor | Targets | Purpose |
+| --- | --- | --- | --- |
+| `PublicAccess` | `()` | class, property | No authentication required. `authorize()` returns `true`. A no-op (and build warning) on a property. |
+| `RequiredCapability` | `( string $capability )` | class, property, repeatable | Require a WordPress capability; `authorize( Principal $principal )` checks `user_can()`. Multiple are ANDed. |
+
+Both can gate queries/mutations (class), output/input types (class), and output/input fields (property). A class-level gate AND-composes into every field gate of the type. See [Authentication and authorization](../authentication-and-authorization.md).
+
+## Metadata
+
+| Attribute | Constructor | Targets | Purpose |
+| --- | --- | --- | --- |
+| `Metadata` | `( string $name, bool\|int\|float\|string\|null $value )` | class, property, parameter, enum case, repeatable | Attach one name/value entry. Base class for custom categories. |
+| `Internal` | `()` | class, property, enum case | `Metadata( 'internal', true )` + `[Internal] ` description prefix. |
+| `Experimental` | `()` | class, property, enum case | `Metadata( 'experimental', true )` + `[Experimental] ` description prefix. |
+| `HiddenFromMetadataQuery` | `()` | class, property, parameter, enum case | Omit the target from `_apiMetadata` discovery (`shows_in_metadata_query()` returns `false`). Does not affect native introspection or runtime gates. |
+
+`Metadata` methods: `get_name()`, `get_value()`, and the overridable `transform_description( string $description ): string` (no-op in the base). Duplicate names on one target are a build error. See [Metadata and discovery](../metadata.md).
+
+## Conventions for custom attributes
+
+The builder recognizes custom attributes by **duck-typed conventions**, not by a base class or interface (except metadata). Declare the PHP `#[Attribute(...)]` targets you want to support.
+
+- **Authorization attribute**: declares a public `authorize(): bool` method. Its first non-underscore parameter receives the principal; the parameter type should be the registered principal type. It may also declare the opt-in context parameters `array $_metadata`, `array $_args`, `mixed $_parent` (see [Recognized methods and parameters](./recognized-methods-and-parameters.md)). To gate fields/arguments as well as operations, include `Attribute::TARGET_PROPERTY` in the `#[Attribute(...)]` declaration.
+- **Metadata attribute**: extends `Metadata` and calls `parent::__construct( $name, $value )`. Discoverable through `_apiMetadata`.
+- **Description-mirroring attribute**: a `Metadata` subclass that overrides `transform_description()`. Transforms chain in source order.
+- **Metadata-query opt-out**: declares `shows_in_metadata_query(): bool` returning `false` (what `#[HiddenFromMetadataQuery]` does).
+
+If you reference an attribute without importing it, PHP resolves it to a non-existent class in the current namespace and silently ignores it; the builder emits a warning naming the FQCN it tried to load, so add the missing `use`.
diff --git a/docs/apis/dual-api/reference/directories.md b/docs/apis/dual-api/reference/directories.md
new file mode 100644
index 00000000000..0400c3a67b9
--- /dev/null
+++ b/docs/apis/dual-api/reference/directories.md
@@ -0,0 +1,29 @@
+---
+post_title: 'Reference: recognized directories'
+sidebar_label: 'Directories'
+sidebar_position: 1
+---
+
+# Reference: recognized directories
+
+The builder determines a class' role from the directory it lives in, relative to the code-API root (`src/Api/` for core, `<plugin>/src/Api/` for a plugin). Arbitrary nested subdirectories are allowed for organization and do **not** change the role; e.g. `Queries/Coupons/GetCoupon.php` and `Queries/GetCoupon.php` are both queries.
+
+| Directory | Role | What it holds |
+| --- | --- | --- |
+| `Queries/` | GraphQL query | Command classes with an `execute()` method. Name defaults to camelCase of the class name. |
+| `Mutations/` | GraphQL mutation | Command classes with an `execute()` method. Rejected over GET. |
+| `Types/` | Output type | Plain classes whose public properties become fields. |
+| `InputTypes/` | Input type | Plain classes used as `execute()` arguments; a field is optional when its type is nullable or it has a default. |
+| `Enums/` | Enum type | Backed PHP enums. Case names become `SCREAMING_SNAKE_CASE`. |
+| `Interfaces/` | Interface | PHP **traits** marked `#[Name]`/`#[Description]`; types `use` them to implement. |
+| `Scalars/` | Custom scalar | Classes with static `serialize()` / `parse()`. Applied to fields via `#[ScalarType]`. |
+| `Pagination/` | Pagination support | `Connection`, `Edge`, `PageInfo`, `PaginationParams`, cursor helpers (provided by core; reused, not redefined). |
+| `Attributes/` | Attribute definitions | Custom PHP 8 attributes (authorization, metadata, …). See [Attributes](./attributes.md). |
+| `Infrastructure/` | Convention classes | Optional per-plugin `ClassResolver`, `PrincipalResolver`, principal class, `HttpStatusResolver`. See [Infrastructure classes](./infrastructure-classes.md). |
+| `Utils/` | Helpers | Mappers, repositories, and other plain helpers. Not exposed in the schema. |
+
+Notes:
+
+- Classes the builder shouldn't expose can be excluded with `#[Ignore]` regardless of placement (e.g. a helper that happens to live under a scanned directory).
+- The generated output mirrors these roles under `Internal/Api/Autogenerated/` (`GraphQLQueries/`, `GraphQLMutations/`, `GraphQLTypes/{Output,Input,Enums,Interfaces,Scalars,Pagination}/`), but you never edit that tree; see [Building and staleness checks](../building-and-staleness.md).
+- These conventions are identical for core and for plugins.
diff --git a/docs/apis/dual-api/reference/exceptions.md b/docs/apis/dual-api/reference/exceptions.md
new file mode 100644
index 00000000000..c5c49bc7650
--- /dev/null
+++ b/docs/apis/dual-api/reference/exceptions.md
@@ -0,0 +1,66 @@
+---
+post_title: 'Reference: exceptions'
+sidebar_label: 'Exceptions'
+sidebar_position: 5
+---
+
+# Reference: exceptions
+
+Throwing an exception from `execute()` or `authorize()` is how the code API surfaces errors. The framework translates each into a GraphQL error with a machine-readable `extensions.code` and a matching HTTP status. All built-in exceptions live in `Automattic\WooCommerce\Api`.
+
+## The base: `ApiException`
+
+```php
+public function __construct(
+ string $message,
+ private readonly string $error_code = 'INTERNAL_ERROR',
+ private readonly array $extensions = array(),
+ int $status_code = 500,
+ ?\Throwable $previous = null,
+)
+```
+
+It extends `\RuntimeException` and exposes `getErrorCode()`, `getExtensions()`, and `getStatusCode()`. The controller merges your `extensions` with `{ code: <error_code> }` (the code can't be overridden by an extensions entry), and uses `status_code` as the HTTP status.
+
+## Built-in subclasses
+
+Each fixes a `(code, status)` pair; all share the signature `( string $message = <default>, array $extensions = [], ?\Throwable $previous = null )`.
+
+| Class | `extensions.code` | HTTP | Use when |
+| --- | --- | --- | --- |
+| `UnauthorizedException` | `UNAUTHORIZED` | 401 | Authentication is required but missing; or a generic auth denial where re-authenticating might help. |
+| `InvalidTokenException` | `INVALID_TOKEN` | 401 | Credentials were supplied but rejected (bad/expired token, malformed header). |
+| `ForbiddenException` | `FORBIDDEN` | 403 | Authenticated, but lacks permission ("I know who you are, but you can't do this") |
+| `NotFoundException` | `NOT_FOUND` | 404 | The resource doesn't exist. (When existence is sensitive, prefer `UnauthorizedException` to avoid leaking it.) |
+| `ValidationException` | `VALIDATION_ERROR` | 422 | Input is well-formed but fails a business rule. |
+
+## Other translated throwables
+
+| Thrown | Becomes |
+| --- | --- |
+| `\InvalidArgumentException` | `INVALID_ARGUMENT` / 400 - use for malformed/structural input (wrong type, contradictory args). |
+| any other `\Throwable` | `INTERNAL_ERROR` / 500 - message masked; the original is attached as `previous` and shown only in debug mode. |
+
+The framework also maps engine-level issues itself (e.g. an out-of-range `Int` output → `BAD_USER_INPUT` / 400; depth/complexity violations → 400).
+
+## Authorization-failure status
+
+When an authorization gate denies (rather than throwing), the framework picks the status from the principal: **401 `UNAUTHORIZED`** for anonymous principals (`is_authenticated()` is `false`), **403 `FORBIDDEN`** for authenticated ones or principals that don't expose `is_authenticated()`. See [Authentication and authorization](../authentication-and-authorization.md).
+
+## Creating a custom exception (in a plugin or core)
+
+Extend `ApiException` (or a subclass when its behavior fits) and pin your own code and status:
+
+```php
+namespace Automattic\MyPlugin\Api;
+
+use Automattic\WooCommerce\Api\ApiException;
+
+class QuotaExceededException extends ApiException {
+ public function __construct( string $message = 'Quota exceeded.', array $extensions = array(), ?\Throwable $previous = null ) {
+ parent::__construct( $message, 'QUOTA_EXCEEDED', $extensions, 429, $previous );
+ }
+}
+```
+
+Throw it from a command; the `code` and `status_code` surface automatically, and any `extensions` you pass appear alongside `code` in the response. Use a sensible standard HTTP status for your domain.
diff --git a/docs/apis/dual-api/reference/infrastructure-classes.md b/docs/apis/dual-api/reference/infrastructure-classes.md
new file mode 100644
index 00000000000..c41db452272
--- /dev/null
+++ b/docs/apis/dual-api/reference/infrastructure-classes.md
@@ -0,0 +1,102 @@
+---
+post_title: 'Reference: infrastructure classes'
+sidebar_label: 'Infrastructure classes'
+sidebar_position: 4
+---
+
+# Reference: infrastructure classes
+
+These classes live in `Automattic\WooCommerce\Api\Infrastructure` (and `Api\Utils`). Some are **convention classes** that ApiBuilder detects per plugin; the rest are runtime helpers. Plugin override rules apply equally when adjusting core's own behavior.
+
+## Convention classes
+
+ApiBuilder looks for these at `<api-namespace>\Infrastructure\*` and wires whatever it finds into the generated controller. Ship one only to diverge from the default; otherwise the default applies. The signature must match exactly.
+
+### `ClassResolver`
+
+```php
+public static function resolve_class( string $class_name ): object
+```
+
+Instantiates command and infrastructure classes. **Default:** `wc_get_container()->get( $class_name )`. Ship your own to route through a different DI container. When no resolver is present at all, generated resolvers fall back to `new $class_name()`.
+
+### `PrincipalResolver`
+
+```php
+public function resolve_principal(): Principal
+// or
+public function resolve_principal( \WP_REST_Request $request ): Principal
+```
+
+Resolves the per-request principal once. **Default:** returns `new Principal( wp_get_current_user() )` (no `$request` parameter). The **return type declares the plugin's principal type**, which the builder uses to type-check `authorize()`/`$_principal` against. Throw `UnauthorizedException`/`InvalidTokenException` to reject credentials. Anonymous requests are a resolved principal (not `null`).
+
+### `Principal`
+
+The default principal wraps a `WP_User`:
+
+```php
+public function __construct( public readonly \WP_User $user )
+public function is_authenticated(): bool // user->ID > 0
+public function can_introspect(): bool // user_can( $user, 'manage_woocommerce' )
+public function can_use_debug_mode(): bool // user_can( $user, 'manage_options' )
+```
+
+A custom principal can be any class. Recognized (all optional, duck-typed) methods:
+
+| Method | If declared | If absent |
+| --- | --- | --- |
+| `is_authenticated(): bool` | distinguishes 401 vs 403 on denial; used by your own code | denials default to 403 (`FORBIDDEN`) |
+| `can_introspect(): bool` | gates native introspection (and `_apiMetadata`, as fallback) | introspection denied |
+| `can_use_debug_mode(): bool` | gates debug mode (with `_debug=1`) | debug mode denied |
+| `can_query_metadata(): bool` | gates `_apiMetadata` specifically | falls back to `can_introspect()`, else deny |
+
+Core's `Principal` deliberately omits `can_query_metadata()`, so `_apiMetadata` follows `can_introspect()`.
+
+### `HttpStatusResolver`
+
+```php
+public function resolve_status( int $default_status, array $output, \WP_REST_Request $request ): int
+```
+
+Optional. Override the framework-computed HTTP status for any response (e.g. always 200), or return `$default_status` to defer. Called for both success and error responses. **Must not throw**: any throw is converted to a fixed 500 `INTERNAL_ERROR`. **Default:** core ships none, so its per-error-code mapping applies. See [Settings and caching](../caching-and-settings.md).
+
+## Runtime helpers
+
+You generally don't call these directly (generated code does), but they're public for advanced use.
+
+### `GraphQLControllerBase`
+
+Abstract base for the generated controller; owns the request lifecycle. Notable public members:
+
+- `get_schema(): SchemaHandle`: schema handle for metadata inspection.
+- `build_schema(): Schema\Schema`: returns the engine-decoupled wrapper, never the engine type.
+- Static config accessors `get_endpoint_url()`, `get_max_query_depth()`, `get_max_query_complexity()`.
+
+### `ResolverHelpers`
+
+Static helpers used by generated resolvers: exception translation, pagination construction, authorization checks, and the public `compute_preauthorized( string $command_fqcn, object $principal ): bool`.
+
+### `Main`
+
+Bootstrap and registration:
+
+- `is_enabled(): bool`: checks PHP 8.1+ and the `dual_code_graphql_api` flag.
+- `register_graphql_endpoint( string $plugin_dir_or_controller_class, string $route_namespace, string $route, array $methods = ['GET','POST'] ): void`: register a plugin endpoint. No-op when the feature is off.
+- `instantiate_graphql_controller( string $controller_class_name ): ?GraphQLControllerBase`.
+
+### `MetadataController`, `QueryInfoExtractor`
+
+Hand-written runtime pieces: the `_apiMetadata` field/types, and the `ResolveInfo` → `_query_info` extraction. See [Extending the infrastructure](../extending-the-infrastructure.md).
+
+## `SchemaHandle` (`Api\Utils`)
+
+Opaque, engine-independent handle returned by `get_schema()`:
+
+```php
+public function get_all_metadata(): array
+public function find_metadata( ?string $name = null, ?string $type = null, ?string $field = null ): array
+```
+
+## Utility classes
+
+Plain helpers (mappers, repositories) live under `Api/Utils/` and are not exposed in the schema, e.g. `Utils\Products\ProductRepository` (`find( int $id ): ?\WC_Product`, `save( \WC_Product $product ): void`). Inject them into commands via the `ClassResolver`/DI container. Plugins place their own helpers under their `Api/Utils/`.
diff --git a/docs/apis/dual-api/reference/recognized-methods-and-parameters.md b/docs/apis/dual-api/reference/recognized-methods-and-parameters.md
new file mode 100644
index 00000000000..44b76d167e6
--- /dev/null
+++ b/docs/apis/dual-api/reference/recognized-methods-and-parameters.md
@@ -0,0 +1,55 @@
+---
+post_title: 'Reference: recognized methods and parameters'
+sidebar_label: 'Methods and parameters'
+sidebar_position: 3
+---
+
+# Reference: recognized methods and parameters
+
+The builder recognizes certain method names on command and attribute classes, and certain specially named parameters that it injects at runtime. These conventions are identical for core and plugins.
+
+## Methods on command classes (queries/mutations)
+
+| Method | Signature | Notes |
+| --- | --- | --- |
+| `execute` | `execute( ...args ): <return type>` | Required. Parameters become GraphQL arguments; the return type becomes the GraphQL return type (use `#[ReturnType]` for interface returns). |
+| `authorize` | `authorize( ...args ): bool` | Optional. Custom authorization for the operation; return `false` to deny. Compose with attributes via `$_preauthorized`. |
+
+## Methods on attribute classes
+
+| Method | Signature | Makes the attribute… |
+| --- | --- | --- |
+| `authorize` | `authorize( <PrincipalType> $principal, ... ): bool` | an authorization attribute. |
+| `get_name` / `get_value` | `get_name(): string` / `get_value(): bool\|int\|float\|string\|null` | (on `Metadata` subclasses) expose the metadata entry. |
+| `transform_description` | `transform_description( string $description ): string` | a description-mirroring metadata attribute. |
+| `shows_in_metadata_query` | `shows_in_metadata_query(): bool` | able to opt its target out of `_apiMetadata` (when it returns `false`). |
+
+## Methods on custom scalar classes
+
+| Method | Signature | Purpose |
+| --- | --- | --- |
+| `serialize` | `static serialize( mixed $value ): string` | PHP value → transport string. |
+| `parse` | `static parse( string $value ): mixed` | Client string → PHP value; throw `\InvalidArgumentException` on bad input. |
+
+## Recognized parameters
+
+These are optional, underscore-prefixed parameters detected **by name**. They may appear in any order; declare only the ones you use. The underscore prefix also keeps them out of the GraphQL argument list. (`provided_fields` on input types uses the same underscore-invisibility idea for an internal property.)
+
+| Parameter | Type | Available on | Value |
+| --- | --- | --- | --- |
+| `$_principal` | the registered principal type | `execute()`, `authorize()` | The resolved principal for the request. |
+| `$_preauthorized` | `bool` | `authorize()` (command) | Whether the attribute-based gates already grant access — compose your custom check on top. |
+| `$_query_info` | `?array` | `execute()` | The selection tree of the current query, for resolve-time optimization. Provided via `QueryInfoExtractor`. |
+| `$_metadata` | `array` | `authorize()` (attribute) | `#[Metadata]` entries at the call site, in slices `['query']`, `['type']`, `['field']` (each `array<string, scalar>`). At the operation level only `['query']` is populated. |
+| `$_args` | `array` | `authorize()` (attribute) | The GraphQL arguments at the call site. |
+| `$_parent` | `mixed` | `authorize()` (attribute) | The enclosing object being resolved, for output-field gates (enables owner-or-scope checks). |
+
+For how these combine in granular authorization, see [Authentication and authorization](../authentication-and-authorization.md).
+
+## Public PHP-side helpers
+
+| Call | Purpose |
+| --- | --- |
+| `ResolverHelpers::compute_preauthorized( string $command_fqcn, object $principal ): bool` | Ask whether attribute gates would grant access, without executing the command. |
+| `GraphQLControllerBase::get_schema(): SchemaHandle` | Obtain the schema handle for PHP-side metadata inspection. |
+| `SchemaHandle::get_all_metadata()` / `find_metadata( ?name, ?type, ?field )` | Read collected metadata. See [Metadata and discovery](../metadata.md). |