Commit 24bda7e3f40 for woocommerce

commit 24bda7e3f4062e8af5dfb8321417e73a514191fc
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Tue May 12 13:53:19 2026 +0300

    Add woocommerce-store-api skill (#64594)

    * Add woocommerce-store-api skill

    New skill at `.ai/skills/woocommerce-store-api/` covering Store API route
    development. Captures patterns surfaced while building the shopper-lists
    endpoints in #64472 — authentication conventions, schema/response
    alignment, REST URL design, server-authoritative variation handling,
    tombstone patterns, and where to apply cache priming.

    The skill complements `woocommerce-backend-dev` (general PHP conventions)
    and cross-links to `woocommerce-performance` for the underlying priming
    patterns.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Drop tombstones topic and references

    Tombstone-style deleted-product handling isn't an established pattern
    across the Store API — it's specific to features like saved-for-later
    that are still in flight. Generalising it as project guidance overreaches.

    Removes the dedicated tombstones.md file. Cross-references in
    schema-design.md (sanitisation parity) and variation-handling.md
    (consequences of trusting client variation payloads) are rephrased to
    keep the underlying point without naming a pattern that doesn't yet
    exist in trunk.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Generalise schema-design.md examples

    Swap shopper-list-specific examples for ones drawn from real Store API
    routes or framed as clearly hypothetical, so the file teaches general
    schema-design patterns rather than feature-specific anecdotes.

    - "Schema must match all status paths" → reframe around the cart precedent
      (CartUpdateItem/CartApplyCoupon return the whole cart; CartItems POST
      returns the added member).
    - "Don't expose two fields for the same data" → use abstract `name` vs
      `name_raw` instead of `name` vs `product_title_at_save`.
    - "Don't accept fields the route ignores" → drop the quantity-coerced-to-1
      example for a generic phrasing.
    - "Document non-obvious behaviour" → use CartUpdateItem's real `quantity`
      arg with `wc_stock_amount` sanitization and `max_purchase_quantity`
      clamping as the illustration.
    - "Don't ship dead fields" → swap `is_public: false` for a generic
      feature-flag example.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Use RFC-accurate wording for GET request bodies

    RFC 9110 §9.3.1 doesn't forbid GET bodies — it leaves their semantics
    undefined and SHOULD NOTs the practice. Reword "HTTP forbids it" to
    match the actual standard, and lean on the practical reasons (servers,
    proxies, CDNs may reject; browsers' fetch refuses to send) which are
    the real reason to avoid GET bodies anyway.

    Per PR feedback.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Remove ShopperLists feature references from store-api skill

    * Clarify requires_nonce() Cart-Token bypass in store-api skill

    * Reference Store API auth code by symbol instead of line number

    * Document variation handling against cart and product trunk patterns

    * Fix markdownlint MD060 and MD040 violations in store-api skill

    * Apply suggestion from @ralucaStan

    ---------

    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

diff --git a/.ai/skills/woocommerce-store-api/SKILL.md b/.ai/skills/woocommerce-store-api/SKILL.md
new file mode 100644
index 00000000000..5535c0b2fbf
--- /dev/null
+++ b/.ai/skills/woocommerce-store-api/SKILL.md
@@ -0,0 +1,42 @@
+---
+name: woocommerce-store-api
+description: Add or modify routes in the WooCommerce Store API (`/wc/store/v1/*`). Use when creating new Store API endpoints, modifying existing ones, or designing the schemas blocks and external integrations consume. Covers authentication, REST URL design, schema/response alignment, variation handling, idempotency, and common pitfalls.
+---
+
+# WooCommerce Store API
+
+The Store API is the public REST surface used by Woo blocks (Cart, Checkout, Mini-Cart) and external integrations. It lives under `/wc/store/v1/*` and has its own conventions distinct from the older `/wc/v3/*` admin REST API.
+
+## When to use this skill
+
+- Adding a new route under `/wc/store/v1/`.
+- Modifying an existing route's response shape, status codes, or argument schema.
+- Designing the schema for a new resource the frontend will consume.
+- Wiring a block (or iAPI store) to a Store API endpoint.
+
+This skill complements `woocommerce-backend-dev` (general PHP conventions) and `woocommerce-performance` (cache priming patterns). Read those first for general conventions; this skill covers Store-API-specific decisions.
+
+## Topics
+
+- [authentication.md](authentication.md) — `permission_callback` styles, nonce enforcement via `AbstractCartRoute`, when to extend which abstract.
+- [rest-conventions.md](rest-conventions.md) — Path/body/query separation, collection vs item vs action routes, status codes, idempotency.
+- [schema-design.md](schema-design.md) — Schema as public contract, field discipline, response-shape alignment.
+- [variation-handling.md](variation-handling.md) — Server-authoritative variation reconciliation.
+- [performance.md](performance.md) — Where to apply cache priming in Store API responses. Cross-links to `woocommerce-performance` for the underlying patterns.
+
+## Key principles
+
+- **Schemas are the public contract.** What the schema declares must be what the route returns. Don't bolt fields onto a response after the schema produced it.
+- **Pick the right abstract.** Routes that mutate per-user state via cookies must extend `AbstractCartRoute` (which enforces nonces) or implement equivalent CSRF protection. `AbstractRoute` alone is for read-only or own-auth routes.
+- **GET is safe.** No side effects, no body. Auto-create-on-read is allowed only as in-memory materialisation; persist on the first explicit write.
+- **Server is authoritative on identity.** Don't trust client-supplied attribute payloads for variations; derive them from the variation product.
+- **Don't ship dead fields.** Schema properties that have only one possible value forever, or that mirror data already exposed elsewhere, are public-API surface that's easier to add later than to retract.
+
+## Reference files
+
+- [Authentication.php](../../../plugins/woocommerce/src/StoreApi/Authentication.php) — global Store API auth filter; deliberately bypasses WP's cookie-nonce check.
+- [AbstractCartRoute.php](../../../plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php) — nonce enforcement, cart-session loading, `Nonce`/`Cart-Token` response headers.
+- [AbstractRoute.php](../../../plugins/woocommerce/src/StoreApi/Routes/V1/AbstractRoute.php) — base class for routes that don't need cart-session/nonce machinery.
+- [AbstractSchema.php](../../../plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractSchema.php) — `prepare_html_response()`, `prepare_money_response()`, response-formatting helpers.
+- [CartItems.php](../../../plugins/woocommerce/src/StoreApi/Routes/V1/CartItems.php) — canonical example of collection-style POST/DELETE routes.
+- [CartController.php](../../../plugins/woocommerce/src/StoreApi/Utilities/CartController.php) — variation reconciliation reference (`parse_variation_data()`).
diff --git a/.ai/skills/woocommerce-store-api/authentication.md b/.ai/skills/woocommerce-store-api/authentication.md
new file mode 100644
index 00000000000..9db08ae0827
--- /dev/null
+++ b/.ai/skills/woocommerce-store-api/authentication.md
@@ -0,0 +1,87 @@
+# Authentication and permission callbacks
+
+The Store API has its own authentication model distinct from the rest of the WP REST API. Read this before adding any new route, especially one that mutates state.
+
+## Where Store API auth lives
+
+`Authentication::check_authentication()` is hooked on `rest_authentication_errors` for every `/wc/store/v1/*` request. It deliberately returns `true` to override WP's default cookie-nonce check, so **WP's built-in CSRF protection does not apply** to Store API routes.
+
+```php
+// plugins/woocommerce/src/StoreApi/Authentication.php
+public function check_authentication( $result ) {
+    // Enable Rate Limiting for logged-in users without 'edit posts' capability.
+    if ( ! current_user_can( 'edit_posts' ) ) {
+        $result = $this->apply_rate_limiting( $result );
+    }
+    return ! empty( $result ) ? $result : true;
+}
+```
+
+The class docblock literally says *"The Store API does not require authentication"* — meaning it doesn't enforce nonces or capabilities globally. Each route is responsible for its own auth model.
+
+## Nonce enforcement is in `AbstractCartRoute`
+
+State-changing routes get their CSRF protection from `AbstractCartRoute::check_nonce()`, which:
+
+- Is invoked on every request via [`get_response()`](../../../plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php), gated by `requires_nonce()` — which returns true on non-GET requests that don't carry a valid `Cart-Token` header. Cart-token-bearing requests are authenticated via the token instead and skip the nonce check.
+- Verifies a `Nonce` header against the `wc_store_api` action inside [`check_nonce()`](../../../plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php).
+- Rejects with `401 woocommerce_rest_missing_nonce` or `403 woocommerce_rest_invalid_nonce`.
+- Hands back a fresh `Nonce` response header on every response (set in [`add_response_headers()`](../../../plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php)) that the client echoes back on the next state-changing request.
+
+Routes that extend `AbstractRoute` directly do **not** get this. They will accept any logged-in cookie session without a nonce check, which is a real CSRF surface.
+
+## When to extend which abstract
+
+| Use case | Extend | Why |
+| --- | --- | --- |
+| Read-only routes (catalog, public data) | `AbstractRoute` | No state to protect from CSRF. |
+| Cart-related state mutation | `AbstractCartRoute` | Existing precedent; nonce + cart-session machinery wired up. |
+| Login-required mutation routes (per-user preferences, account-scoped writes) | `AbstractCartRoute`, **or** implement equivalent nonce protection on `AbstractRoute` | Mutation via cookie auth without CSRF protection is unacceptable. |
+| Read routes with own auth model (e.g. order ownership) | `AbstractRoute` + custom `permission_callback` | The auth check is the protection. |
+
+If you find yourself extending `AbstractRoute` for a route that POSTs/DELETEs based on cookie auth, stop and reconsider. Either inherit from `AbstractCartRoute`, or document and implement an equivalent nonce flow.
+
+## `permission_callback` conventions
+
+| Use case | Pattern | Reference |
+| --- | --- | --- |
+| Guest-accessible | `'__return_true'` | [Cart.php](../../../plugins/woocommerce/src/StoreApi/Routes/V1/Cart.php), most cart routes |
+| Login-required | `function () { return is_user_logged_in(); }` | [Patterns.php](../../../plugins/woocommerce/src/StoreApi/Routes/V1/Patterns.php) |
+| Owner-only access | `[ $this, 'is_authorized' ]` | [Order.php](../../../plugins/woocommerce/src/StoreApi/Routes/V1/Order.php) |
+
+**Don't use bare callable strings** like `'is_user_logged_in'`. They work but diverge from the codebase convention. Reviewers will look for the closure form. The closure also gives you a place to add capability checks later without changing the callback type.
+
+```php
+// ❌ Don't
+'permission_callback' => 'is_user_logged_in',
+
+// ✅ Do
+'permission_callback' => function () {
+    return is_user_logged_in();
+},
+```
+
+## Application Passwords vs cookie sessions
+
+Store API routes accept two auth methods, and they have different testing implications:
+
+- **Application Passwords** (HTTP Basic Auth): authenticated as the user; **does not carry a cart session cookie**. Useful for testing routes that don't need cart state. Bypasses cookie nonce flows.
+- **Cookie session**: full user identity + cart session. Required for any flow that reads from `WC()->cart` (e.g., `cart_item_key` lookups). Subject to nonce enforcement on cart-route mutations.
+
+If a route needs to read `WC()->cart->cart_contents`, document that Application Password testing won't work for it — clients must use cookie auth.
+
+## Anti-patterns to avoid
+
+- **Routes that mutate state with `permission_callback => '__return_true'` and extend `AbstractRoute`.** No auth, no nonce — anyone can mutate. Only acceptable for guest carts where the user-id boundary is the cart token.
+- **Routes that mutate state extending `AbstractRoute` with login-only auth and no nonce.** A logged-in shopper visiting a malicious site can be silently POSTed at via their cookie. Use `AbstractCartRoute` or implement equivalent nonce checks.
+- **Bare-string `permission_callback`.** Stylistically inconsistent and makes future capability-check additions awkward.
+- **Calling `Authentication::check_authentication()` directly.** It's an internal filter, not an API. Use the abstract base classes.
+
+## Test that auth is wired correctly
+
+For any route requiring auth, add tests covering:
+
+1. **Unauthenticated request** → 401.
+2. **Authenticated request without nonce** (for state-changing routes via cookie auth) → 401 `woocommerce_rest_missing_nonce`.
+3. **Authenticated request with invalid nonce** → 403 `woocommerce_rest_invalid_nonce`.
+4. **Cross-user access** (where applicable) → 403 or 404.
diff --git a/.ai/skills/woocommerce-store-api/performance.md b/.ai/skills/woocommerce-store-api/performance.md
new file mode 100644
index 00000000000..ac70f282827
--- /dev/null
+++ b/.ai/skills/woocommerce-store-api/performance.md
@@ -0,0 +1,108 @@
+# Performance patterns for Store API responses
+
+This file is a thin wrapper over the `woocommerce-performance` skill, framed for Store API contexts. Read [`cache-priming.md`](../woocommerce-performance/cache-priming.md) in that skill for the underlying patterns; this file covers where to apply them in Store API code.
+
+## When this matters
+
+Any schema that serialises a **collection** of post-based objects (products, orders, attachments) is a candidate for cache priming. Store API list responses are the most common case:
+
+- `GET /cart` returns N cart line items, each loaded via `wc_get_product()`.
+- Any future route returning a collection of products, orders, or attachment-backed resources — each item loaded individually inside the per-item loop.
+
+Without priming, each per-item lookup hits the database individually — classic N+1.
+
+## Where to apply priming in a Store API route
+
+The priming must run **before** the per-item loop and at the level that has the full collection of IDs. Two structural choices:
+
+### Prime in `Schema::get_item_response()` (preferred)
+
+When the schema receives the full collection (e.g., a parent-resource schema's `get_item_response()` receives `$collection['items']` as an array), prime there:
+
+```php
+public function get_item_response( $collection ) {
+    $items = $collection['items'] ?? array();
+
+    $product_ids = array_filter( array_map(
+        static fn( $item ) => (int) ( $item['variation_id'] ?: $item['product_id'] ),
+        $items
+    ) );
+
+    if ( ! empty( $product_ids ) ) {
+        _prime_post_caches( array_unique( $product_ids ) );
+    }
+
+    return array(
+        // ...
+        'items' => array_map(
+            fn( $item ) => $this->item_schema->get_item_response( $item ),
+            $items
+        ),
+    );
+}
+```
+
+This makes the schema the single owner of priming logic. Any consumer that calls `Schema::get_item_response()` — the route, an internal block, an admin tool — gets the prime for free without remembering to do it themselves.
+
+### Don't prime in single-item routes
+
+Routes that return one item (POST returning the added member, single-item GET) don't need priming. `wc_get_product()` on one ID is one query whether you prime or not. Priming a "batch of one" is the same query count with extra ceremony.
+
+### Don't duplicate priming across the route and the schema
+
+If the schema already primes, the route shouldn't prime again. Pick one — schema is preferred. Otherwise the same `_prime_post_caches()` call runs twice on the same IDs (the second is a no-op on warm cache, but it's noise).
+
+## Two-phase priming for products and their images
+
+Products primed via `_prime_post_caches()` does **not** prime their thumbnail attachments. If your schema renders product images (most do), you need a second pass.
+
+See `woocommerce-performance/cache-priming.md` Pattern #2 for the canonical form. Adapted for Store API:
+
+```php
+if ( ! empty( $product_ids ) ) {
+    $product_ids = array_unique( $product_ids );
+    _prime_post_caches( $product_ids );
+
+    $thumbnail_ids = array_filter( array_map(
+        static fn( $id ) => (int) get_post_thumbnail_id( $id ),
+        $product_ids
+    ) );
+
+    if ( ! empty( $thumbnail_ids ) ) {
+        _prime_post_caches( array_unique( $thumbnail_ids ), true, true );
+    }
+}
+```
+
+The `true, true` arguments to the second `_prime_post_caches()` enable update_term_cache and update_meta_cache — the latter is critical because `wp_get_attachment_image_src()` reads `_wp_attachment_metadata` from postmeta. Without `update_meta_cache = true`, you've primed the post rows but not the attachment metadata, leaving an N+1 inside the image render loop.
+
+Alternative: `update_post_thumbnail_cache( $wp_query )` is a WordPress-idiomatic helper for this exact pattern. Construct a synthetic `WP_Query` with `posts = [ array of WP_Post ]` and call it. Slightly more ceremony but uses the WP-blessed code path.
+
+## Common N+1 patterns to look for
+
+When reviewing a new Store API schema, check whether each per-item operation has a corresponding batch prime up front:
+
+| Per-item operation | Required prime |
+| --- | --- |
+| `wc_get_product( $id )` in a loop | `_prime_post_caches( $product_ids )` |
+| `get_post_thumbnail_id()` + image rendering | Plus `_prime_post_caches( $thumbnail_ids, true, true )` |
+| `get_term( $id )` for taxonomy attributes | `_prime_term_caches( $term_ids )` (or use `update_term_cache = true` on the post prime) |
+| `get_user_meta()` per user in a list | `update_meta_cache( 'user', $user_ids )` |
+| Multiple `get_option()` calls | `wp_prime_option_caches( $keys )` — see `woocommerce-performance/options-cache-priming.md` |
+
+When in doubt, ask: "What's the per-item DB cost?" If a single response triggers N reads of the same kind, there's a prime opportunity.
+
+## What this skill doesn't cover
+
+- **General priming patterns and edge cases** — see [`cache-priming.md`](../woocommerce-performance/cache-priming.md) in `woocommerce-performance`.
+- **Options priming** — see [`options-cache-priming.md`](../woocommerce-performance/options-cache-priming.md).
+- **OrderCache and other Woo-specific cache layers** — `woocommerce-performance/cache-priming.md` covers these.
+
+This file is just the Store-API-specific framing. The substantive patterns live in the performance skill.
+
+## Anti-patterns to avoid
+
+- **Priming inside the per-item loop.** Defeats the point. The prime call must happen once, before the loop, on the full ID list.
+- **Priming products without their images.** Half-fix; the image render loop is still N+1. Apply two-phase priming when rendering product collections that include images.
+- **Priming on single-item routes.** Adds code without saving any queries. Skip.
+- **Forgetting `update_meta_cache = true` when priming attachments.** Primes the attachment posts but leaves `_wp_attachment_metadata` un-cached, which is what `wp_get_attachment_image_src()` actually reads.
diff --git a/.ai/skills/woocommerce-store-api/rest-conventions.md b/.ai/skills/woocommerce-store-api/rest-conventions.md
new file mode 100644
index 00000000000..4b04218dc96
--- /dev/null
+++ b/.ai/skills/woocommerce-store-api/rest-conventions.md
@@ -0,0 +1,114 @@
+# REST URL and response conventions
+
+The Store API follows REST conventions more strictly than the older `/wc/v3/*` admin API. Read this before designing a new route.
+
+## Where data goes
+
+| Where | What it's for |
+| --- | --- |
+| **Path** (`/items/{key}`) | Identifies *which* resource. Required for any single-resource operation. |
+| **Body** (JSON) | Payload for create/update. POST, PUT, PATCH. |
+| **Query string** (`?since=...`) | Filters, pagination, sort options on GET. |
+
+**Don't accept POST data via query string.** WordPress's `WP_REST_Request::get_param()` is permissive — it'll find values from any source. That's a debugging convenience, not a design statement. Production clients should send JSON bodies; document the canonical shape in the schema.
+
+**Don't put a body on a GET.** RFC 9110 §9.3.1 leaves GET-body semantics undefined and SHOULD NOT's the practice; servers, proxies, and CDNs are free to reject or ignore the body. Browsers' `fetch()` refuses to send one outright. Filter via the query string instead.
+
+**GET must not have side effects.** Caches, prefetchers, browser history, retries, security scanners — anything that thinks GET is safe will silently repeat the request. Auto-create-on-read patterns are allowed only as in-memory materialisation; persist on the first explicit write, never inside a GET handler.
+
+## Collection vs item vs action routes
+
+Store API routes split into three shapes, each with a different response convention:
+
+| Shape | URL | Returns | Example |
+| --- | --- | --- | --- |
+| Collection-add | `POST /items` | The added single item, status 201 | `CartItems::get_route_post_response()` |
+| Collection-delete | `DELETE /items/{key}` | 204 with null body | `CartItemsByKey::get_route_delete_response()` (in `CartItemsByKey.php`) |
+| Action on parent | `POST /cart/add-item`, `POST /cart/apply-coupon` | The whole parent resource | `CartAddItem`, `CartApplyCoupon` |
+
+The split matters: action routes return the parent because they're "do something to the parent" — the client needs the new aggregate state (cart totals, coupon discounts) in one round-trip. Collection routes return the new/deleted member; clients reconcile by splicing the response into local state.
+
+Don't mix the two. "POST /items returns the whole collection" is awkward, breaks client-side reconciliation patterns, and locks the schema into declaring a field (the items array) that doesn't fit the resource shape of the route.
+
+## Status codes
+
+| Code | When |
+| --- | --- |
+| `200` | GET success; PATCH/PUT success returning the updated resource. |
+| `201 Created` | POST that creates a new resource. Body is the new resource. |
+| `204 No Content` | DELETE success. Empty body. |
+| `400 Bad Request` | Input validation failure (schema rejected the args, or a custom validator threw). |
+| `401 Unauthorized` | Missing auth, missing nonce, expired session. |
+| `403 Forbidden` | Auth present but insufficient (wrong user, wrong cap, invalid nonce). |
+| `404 Not Found` | Resource doesn't exist (`<feature>_not_found`) **or** route doesn't exist (`rest_no_route`). |
+| `409 Conflict` | State precondition failed (rare; e.g. duplicate that the API refuses to merge). |
+
+`rest_no_route` (404) and `<feature>_not_found` (404) are different things. The first means "WP couldn't match your URL to any registered route" — usually a typo. The second means "the route matched but the resource doesn't exist." Distinguish them in error messages.
+
+## Idempotency
+
+State-changing routes that can plausibly be retried (POST creates, DELETE removes) should produce stable identifiers and behave deterministically.
+
+**For collection-add routes:**
+
+- Use a deterministic hash of the resource's identity tuple as the storage key.
+- `ksort()` any input arrays before hashing — JSON object key order isn't guaranteed across clients, so `{a:1, b:2}` and `{b:2, a:1}` must produce the same key.
+- Mirror existing patterns: `WC_Cart::generate_cart_id()` is the reference for cart-line identity; replicate its `ksort` step.
+- Decide and document re-save semantics: replace, sum, or reject. Surface this in the schema description for the `quantity` (or equivalent) field.
+
+```php
+private static function generate_key( int $product_id, int $variation_id, array $variation ): string {
+    $id_parts = array( $product_id );
+
+    if ( $variation_id ) {
+        $id_parts[] = $variation_id;
+    }
+
+    if ( ! empty( $variation ) ) {
+        ksort( $variation );  // canonicalise for stable hashing
+        $variation_key = '';
+        foreach ( $variation as $k => $v ) {
+            $variation_key .= trim( (string) $k ) . trim( (string) $v );
+        }
+        $id_parts[] = $variation_key;
+    }
+
+    return md5( implode( '_', $id_parts ) );
+}
+```
+
+**Pin the contract with a test.** Without one, a future refactor (e.g. switching to `wp_json_encode`) can silently regress idempotency.
+
+## URL hierarchy
+
+Resource identifiers in the path should follow the resource hierarchy:
+
+```text
+/{collection}                               ← collection of parents
+/{collection}/{slug}                        ← one specific parent
+/{collection}/{slug}/items                  ← items collection inside a parent
+/{collection}/{slug}/items/{key}            ← one specific item
+```
+
+Each segment adds one identifier. Avoid flat URLs like `/items/{key}` when keys are scoped per-parent — the same identifier can collide across parents and the URL can't disambiguate.
+
+## Anti-patterns to avoid
+
+- **Auto-creating a resource on GET.** A GET that triggers a database write is surprising and breaks under any retry/cache scenario. Materialise lazily in memory and persist on the first POST.
+- **Returning the parent on POST `/items`.** Mixes the collection-add and action shapes. Clients can't reconcile without re-parsing the whole parent. Stick to "POST returns the added member."
+- **Bolting fields onto the response after the schema produced it.** The schema and the wire format diverge; introspection lies. Add the field to `get_properties()` and populate it inside `get_item_response()`.
+- **Accepting JSON-encoded strings in query parameters.** A request like `?variation={...}` arrives as a string; the validator can't usefully coerce it. Send structured data in the body.
+- **Mixing stable identifiers with debug aliases.** Don't accept `?product_id=42` AND `{"product_id": 42}` in the body for production clients. Pick the canonical shape, document it, treat the other as undocumented behaviour.
+
+## Reference routes
+
+Use these as canonical examples for new routes:
+
+- **Read collection:** [`Cart::get_route_response`](../../../plugins/woocommerce/src/StoreApi/Routes/V1/Cart.php).
+- **Read single item:** [`CartCouponsByCode::get_route_response`](../../../plugins/woocommerce/src/StoreApi/Routes/V1/CartCouponsByCode.php).
+- **Collection POST:** [`CartItems::get_route_post_response`](../../../plugins/woocommerce/src/StoreApi/Routes/V1/CartItems.php).
+- **Single-item DELETE (204 + null):** [`CartItemsByKey::get_route_delete_response`](../../../plugins/woocommerce/src/StoreApi/Routes/V1/CartItemsByKey.php).
+- **Collection DELETE (200 + empty array, clears all):** [`CartItems::get_route_delete_response`](../../../plugins/woocommerce/src/StoreApi/Routes/V1/CartItems.php) — note this is "empty everything" semantics, not "delete one member."
+- **Action route:** [`CartAddItem`](../../../plugins/woocommerce/src/StoreApi/Routes/V1/CartAddItem.php), [`CartApplyCoupon`](../../../plugins/woocommerce/src/StoreApi/Routes/V1/CartApplyCoupon.php).
+
+When in doubt, copy the cart precedent. The Store API was built around the cart's response patterns, so aligning with cart routes minimises surprise for both reviewers and frontend consumers.
diff --git a/.ai/skills/woocommerce-store-api/schema-design.md b/.ai/skills/woocommerce-store-api/schema-design.md
new file mode 100644
index 00000000000..b3375bfe6cb
--- /dev/null
+++ b/.ai/skills/woocommerce-store-api/schema-design.md
@@ -0,0 +1,174 @@
+# Schema design
+
+The schema is the public contract for a Store API resource. It declares what the response contains, drives input validation, and is what introspection tools (OpenAPI generators, `OPTIONS` requests, type generators) read. Get it right at design time — schema fields are easier to add later than to retract.
+
+## The schema is the public contract
+
+`AbstractSchema::get_properties()` declares the response shape. Anything in the wire response **must** be declared here. Conversely, anything declared here **must** appear in the response.
+
+When the two diverge, every consumer pays:
+
+- WP's REST introspection (`OPTIONS /your-route`, the `/wp-json/wc/store/v1` index) reports a shape that doesn't match reality.
+- A frontend developer reading the schema docs assumes there's no `extra_field` on the response and either misses it or works around it.
+- Future tools that auto-generate clients/types from the schema produce broken types.
+- The next person reviewing the route asks "is this an undeclared field on purpose, or did someone forget to update the schema?" — every time.
+
+## Single source of truth: build responses inside `get_item_response()`
+
+Don't build a response by appending fields to the schema's output:
+
+```php
+// ❌ Don't — schema doesn't know about the extra field
+$response          = $schema->get_item_response( $resource );
+$response['extra'] = compute_extra( $resource );
+return new \WP_REST_Response( $response );
+```
+
+Add the field to `get_properties()` and populate it inside the schema's own `get_item_response()`:
+
+```php
+// ✅ Do — schema declares and produces the field
+public function get_properties() {
+    return array(
+        // ...
+        'extra' => array(
+            'description' => __( 'Computed extra value.', 'woocommerce' ),
+            'type'        => 'string',
+            'context'     => array( 'view', 'edit' ),
+            'readonly'    => true,
+        ),
+    );
+}
+
+public function get_item_response( $resource ) {
+    return array(
+        // ...
+        'extra' => $this->compute_extra( $resource ),
+    );
+}
+```
+
+The route then becomes a one-liner: `return new \WP_REST_Response( $schema->get_item_response( $resource ), 201 );`. No bolting, no drift.
+
+## Schema must match all routes that use it
+
+Different routes sharing a schema can return different shapes. The schema must cover all of them. The cart is the established precedent:
+
+- Cart-mutating routes (`CartUpdateItem`, `CartApplyCoupon`, `CartSelectShippingRate`, etc.) all return the full `CartSchema` shape on every successful response. The client gets fresh aggregate state (totals, fees, coupons applied) in one round-trip.
+- Item-collection routes (`CartItems` POST returns the added member, `CartItemsByKey` DELETE returns 204) follow REST collection semantics — the response is the affected member, not the parent.
+
+If a route ever returns a shape that the schema doesn't declare, fix it one of three ways:
+
+1. Add the missing field(s) to the schema as additive properties so the union shape is documented and introspectable.
+2. Split into separate schemas for read and write responses if the shapes differ structurally enough that union'ing them would force most fields to be optional.
+3. Reshape the route's response to match an existing pattern (return the parent for state mutations, the member for collection adds). See [rest-conventions.md](rest-conventions.md).
+
+When in doubt, copy the cart's precedent — its response patterns are the most-consumed shapes in the Store API and aligning with them minimises surprise for both reviewers and frontend consumers.
+
+## Field discipline
+
+### Don't ship fields with only one possible value
+
+A schema property whose value is hardcoded forever is dead surface. Examples:
+
+- A boolean feature-flag field always returning `false` because the underlying behaviour hasn't shipped.
+- A `read_only: true` field on a resource where every field is read-only.
+- An `errors: []` or `warnings: []` array that's always empty.
+
+Add the field when the underlying behaviour ships. Adding fields is backwards-compatible; removing them is a breaking change.
+
+### Don't expose two fields representing the same data
+
+Don't ship both a sanitized field and its raw counterpart for the same value — e.g., a `name` field run through `prepare_html_response()` alongside a `name_raw` field exposing the same string unescaped. The asymmetry is an XSS vector: frontends read whichever field they reach first, and the unescaped one is reachable.
+
+Pick one. If a raw value is needed internally (e.g., to populate a fallback when the underlying resource is unavailable), keep it server-side: use it to populate the public field via the response builder, but don't surface it as its own schema property.
+
+### Don't expose internal storage fields
+
+Snapshot/cache/computed fields that exist for the implementation's benefit shouldn't leak. A field that only powers a server-side fallback doesn't need to be in the response — it's used at response-build time and that's it.
+
+### Don't expose unstable values
+
+A `date_created_gmt` field that changes on every read until the first write is worse than no field at all. Either persist eagerly so the value is stable, or omit until persistence is meaningful.
+
+### Don't accept fields the route ignores
+
+If a route declares an argument that the handler always overrides, ignores, or coerces to a fixed value, drop it from `get_args()`. Accepting input the route silently discards is misleading — clients reading the schema (or the introspection endpoint) reasonably assume their value matters.
+
+## Sanitisation
+
+All string fields must run through escaping before output. The Store API has dedicated helpers:
+
+- `prepare_html_response( $string )` — for any string that might be rendered as HTML. Strips disallowed tags via `wp_kses_post`-equivalent rules.
+- `prepare_money_response( $price, $decimals )` — for any monetary value. Produces minor-units integer-strings (e.g. `"1999"` for $19.99 in a 2-decimal currency). This is the canonical Store API money format.
+- `wc_rest_prepare_date_response( $datetime )` — for date fields. Produces ISO-8601 strings.
+
+**Apply the same sanitisation in every code path that produces a field.** If one branch escapes a string via `prepare_html_response()` but a fallback branch sets it raw from storage, the fallback is an XSS vector. Run every branch through the same escapers and formatters, or funnel them through one builder so they can't drift.
+
+**Document units on the schema description.** A field documented as just "Price" is ambiguous. Specify "Price in minor units (e.g. cents)" or "Price as decimal string (e.g. 19.99)" so consumers know how to format.
+
+## Per-context properties
+
+`'context' => array( 'view', 'edit' )` controls when a property is included in the response. Use sparingly — most Store API responses are single-context. Don't use context to hide implementation details; use it for genuine view-vs-edit differentiation (e.g., admin-only fields).
+
+## Schema validation pulls double duty
+
+The schema's `type` and `required` declarations drive WP's argument validation:
+
+- `'type' => 'integer'` rejects non-integer inputs with a 400 before the route handler runs.
+- `'minimum' => 1, 'maximum' => 999` enforces bounds.
+- `'enum' => [...]` restricts to a known set.
+
+Use these instead of validating inside the handler. The schema is the contract, and the validator runs before any of your code — letting it do the work means you can't accidentally skip it.
+
+When you need bounds-checking that the schema can't express (e.g., "must reference an existing product"), validate early in the handler and throw a `RouteException` with a meaningful error code:
+
+```php
+$product = wc_get_product( $product_id );
+if ( ! $product ) {
+    throw new RouteException(
+        'woocommerce_rest_unknown_product',
+        esc_html__( 'No product exists for the supplied ID.', 'woocommerce' ),
+        404
+    );
+}
+```
+
+## Document non-obvious behaviour
+
+If a field has merge semantics, sanitisation that affects the stored value, server-side clamping, or interactions with other fields, spell them out in the `description`. `CartUpdateItem` is a real example — its `quantity` arg uses `wc_stock_amount` to sanitize and is bounded by the product's `max_purchase_quantity` server-side:
+
+```php
+'quantity' => array(
+    'description' => __( 'New quantity of the item in the cart.', 'woocommerce' ),
+    'type'        => 'number',
+    'arg_options' => array(
+        'sanitize_callback' => 'wc_stock_amount',
+    ),
+),
+```
+
+The current description is short. A more honest one would surface the sanitisation and clamping:
+
+```php
+'description' => __(
+    'New quantity of the item in the cart. Values are sanitized via `wc_stock_amount` and clamped against the product\'s `max_purchase_quantity` server-side; the response reflects the value actually applied.',
+    'woocommerce'
+),
+```
+
+The schema description is what API clients read. It's the only place server-side sanitisation, clamping, or other "the value you sent isn't necessarily the value applied" behaviours can be documented for consumers.
+
+## Anti-patterns to avoid
+
+- **Bolting fields onto the response post-schema** — diverges schema and reality.
+- **Schema declares a field; route conditionally omits it** — clients can't tell whether the field is missing because of context or a bug.
+- **Documenting in the route what should be on the schema** — clients reading the schema (or its introspection) won't see it.
+- **`description` strings that don't actually describe** (`'description' => __('Slug.', ...)`) — wasted opportunity to encode behaviour and units.
+- **Optional fields with `default`-via-PHP-fallback inside `get_item_response()`** — declare `default` on the schema instead so it's introspectable.
+
+## Reference
+
+- [`AbstractSchema`](../../../plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractSchema.php) — base class, helpers (`prepare_html_response`, `prepare_money_response`).
+- [`CartSchema`](../../../plugins/woocommerce/src/StoreApi/Schemas/V1/CartSchema.php) — canonical example of a non-trivial schema.
+- [`CartItemSchema`](../../../plugins/woocommerce/src/StoreApi/Schemas/V1/CartItemSchema.php) — demonstrates `ProductItemTrait` usage and the live-branch response pattern.
diff --git a/.ai/skills/woocommerce-store-api/variation-handling.md b/.ai/skills/woocommerce-store-api/variation-handling.md
new file mode 100644
index 00000000000..5171faa7f50
--- /dev/null
+++ b/.ai/skills/woocommerce-store-api/variation-handling.md
@@ -0,0 +1,119 @@
+# Variation attribute handling
+
+Variable products and their variations are the most error-prone surface in the Store API. A request can take several shapes depending on the UX, the server has to reconcile the client's claim about which variation is being referenced, and the same logical variation can be expressed multiple ways. Get any of that wrong and you store ambiguous data, break idempotency, or return 500s on routine input.
+
+This page documents the cart's handling so any new route that accepts variation references can mirror it.
+
+## Why server-authoritative reconciliation matters
+
+A typical Store API request looks like:
+
+```json
+{ "id": 99, "variation": { "attribute_pa_color": "blue" } }
+```
+
+Two things are claimed: variation 99 exists, and its colour is blue. **The first is verifiable; the second is not, unless you check.** A client (malicious or buggy) can send `id: 99` with `variation: { color: red }` while variation 99 is actually blue — and a route that trusts the client verbatim will store the wrong attributes.
+
+This produces two downstream problems:
+
+1. **Stored data lies.** The persisted row claims red but the variation product says blue. Reads return wrong data.
+2. **Idempotency breaks.** If a route hashes the client's attribute payload to produce a storage key, two POSTs with different attribute orderings (or different values for the same logical variation) produce different keys — duplicate rows for the same item.
+
+The fix is to derive canonical attributes from the variation product itself. Treat the client's payload as a hypothesis, not a source of truth.
+
+## The two input shapes
+
+WooCommerce front-end UX produces two shapes of variation input, both of which the cart accepts:
+
+**Variation ID (resolved upstream).**
+
+```json
+{ "id": 99, "variation": { "attribute_pa_color": "blue" } }
+```
+
+The client has already picked a specific variation; `id` is its post ID. The `variation` array is a claim about its attributes that the server validates.
+
+**Variable parent + attributes (resolved by the server).**
+
+```json
+{ "id": 42, "variation": [ {"attribute": "pa_color", "value": "blue"}, {"attribute": "pa_size", "value": "medium"} ] }
+```
+
+This is what the standard product page posts: `id` is the variable parent product, and the user's dropdown selections come along as the variation array. The server resolves to the matching variation via `WC_Data_Store::find_matching_product_variation()`.
+
+Simple (non-variable) products have `variation = []` and skip the reconciliation entirely.
+
+## The reconciliation flow
+
+`CartController::parse_variation_data()` is the canonical pattern. The flow:
+
+1. **Skip if the product isn't variable.** Simple products have no variation context — clear `variation` and return.
+2. **Sanitise the posted variation array.** Map attribute names to the canonical `attribute_<slug>` form so all comparisons run against the same vocabulary.
+3. **If the input is a variable parent ID, resolve it to a specific variation.** `get_variation_id_from_variation_data()` looks up which variation matches the posted attributes.
+4. **With a variation ID in hand, validate posted attributes against the variation's expected attributes.** `wc_get_product_variation_attributes( $id )` returns the variation's expected slug for each variable attribute (or `''` for an "Any" slot).
+5. **`ksort` the resulting variation array** so the same logical variation always serialises to the same key — important for idempotency keys hashed from this array.
+
+## Specific values vs "Any" attributes
+
+Each variable attribute on a variation is either a specific value (the variation pins `pa_color = blue`) or "Any" (the variation accepts any of the parent's allowed values for `pa_size`). They need different handling:
+
+- **Specific-value attribute posted by the client** → must equal the expected slug, otherwise 400.
+- **Specific-value attribute not posted** → the server fills in the expected value. The variation has a default; trust it.
+- **"Any" attribute posted by the client** → must be in the parent's allowed slugs (`WC_Product_Attribute::get_slugs()`), otherwise 400.
+- **"Any" attribute not posted** → 400 with "missing variation data". The server can't pick on the user's behalf — for "Color: blue, Size: any", the user has to choose a size.
+
+`wc_get_product_variation_attributes()` returns slugs like `[ 'attribute_pa_color' => 'blue', 'attribute_pa_size' => '' ]`. The empty string is the marker for "Any".
+
+## Slug canonicalisation
+
+WooCommerce stores attribute values as **lowercase taxonomy term slugs**. Both sides of any comparison come from canonicalised storage, so use strict `===`/`!==`. Don't add case-insensitive matching — the cart doesn't, and consistency matters for predictable client behaviour.
+
+Direct API clients (using `id` + `variation`) need to send slugs in their canonical form. The cart already canonicalises on its way into `cart_contents`, so server-side round-trips are clean.
+
+## Throwing the right exception
+
+Validation failures should throw `RouteException` directly with a 400 status. The Store API framework catches it and returns a structured error response — no wrapper or translation layer needed:
+
+```php
+throw new RouteException(
+    'woocommerce_rest_invalid_variation_data',
+    sprintf(
+        /* translators: %1$s: Attribute name, %2$s: Allowed values. */
+        esc_html__( 'Invalid value posted for %1$s. Allowed values: %2$s', 'woocommerce' ),
+        esc_html( $attribute_label ),
+        esc_html( implode( ', ', $attribute->get_slugs() ) )
+    ),
+    400
+);
+```
+
+The cart uses two error codes for variation issues:
+
+- `woocommerce_rest_invalid_variation_data` — a posted attribute has a value the variation doesn't accept. Surface the allowed values in the message.
+- `woocommerce_rest_missing_variation_data` — an "Any" attribute wasn't posted. Surface the missing attribute label.
+
+Both return 400. Don't throw a generic `\InvalidArgumentException` — exceptions that aren't `RouteException` fall through to the abstract route's generic handler, which returns a 500 with `woocommerce_rest_unknown_server_error` and obscures the real problem from the client.
+
+## Test coverage
+
+Any route that accepts variation references should have tests for:
+
+1. **Variation ID input** → success, attributes validated against the variation.
+2. **Variable parent + matching attributes** → success, server resolves to the correct variation.
+3. **Variable parent + unmatchable attributes** → 400 with a meaningful error.
+4. **Specific attribute defaulted by the server** — client sends nothing, server fills in correctly.
+5. **"Any" attribute with a valid posted slug** → success.
+6. **"Any" attribute with a slug not in the parent's allowed list** → 400 with allowed values listed.
+7. **Mismatched specific-value posted by the client** → 400 with allowed values listed.
+8. **Unposted "Any" attribute** → 400 with the missing attribute name.
+9. **Simple product** → variation array clears to empty.
+
+The variation path is where future regressions are most likely to land. Tests are the only durable lock on the reconciliation behaviour.
+
+## Reference
+
+- [`CartController::parse_variation_data()`](../../../plugins/woocommerce/src/StoreApi/Utilities/CartController.php) — the canonical reconciliation pattern; mirror this in any new route that accepts variation references.
+- [`CartController::get_variation_id_from_variation_data()`](../../../plugins/woocommerce/src/StoreApi/Utilities/CartController.php) — resolves a variable parent + posted attributes to a specific variation ID.
+- `wc_get_product_variation_attributes()` (WooCommerce core) — returns canonical slugs for a variation, with `''` for "Any" slots.
+- `WC_Product_Attribute::get_slugs()` (WooCommerce core) — returns the allowed slug list for an attribute on the parent product.
+- `WC_Data_Store::find_matching_product_variation()` (WooCommerce core) — underlying lookup used by `get_variation_id_from_variation_data()`.