Commit 88961728ce8 for woocommerce

commit 88961728ce8268b0e21a22fc5c37b801308f826e
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Tue Nov 3 16:16:09 2026 -0300

    Create and update variable products with their variations in the product abilities (#70203)

    * Spike: accept registered product types in the product abilities

    Replace product_type_alias with the product type registry, and add
    virtual and downloadable as fields of their own.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Use one flat input schema for product-update

    The update schema lists id, type and the fields of every supported
    product type. A client no longer sends type to change a per-type field
    such as regular_price. After load() resolves the product, the update
    refuses a field that the product type does not support, with
    woocommerce_product_field_unsupported. product-create keeps one oneOf
    branch for each product type.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Let the owner of a product type choose its Core fields

    The product class still decides the default list of Core fields, and the
    new woocommerce_ability_product_type_fields filter lets the owner of a
    type add or remove one, such as a price for a bundle.

    Also from the review of the spike:

    - Leave a type without a class out of the create and update schemas
      instead of listing it and refusing it at run time.
    - Read the config of a loaded product from its class, not from its type.
    - Sanitize the virtual and downloadable query filters with
      rest_sanitize_boolean(), so "false" is false.
    - Drop the two config checks in ProductUpdate::load() that change()
      repeats.
    - Say in the create schema that a product without a type is simple.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * List the fields of each product type in one flat input schema

    product-create had one oneOf branch per product type. A wrong field
    gave the message "input[type] is not simple", and every branch carried
    a stub for the extensions property. product-create now has the same
    flat schema as product-update. The description of type lists the fields
    that each type takes, and the run-time check names the type, the fields
    it refused, and the fields the type takes.

    The woocommerce_ability_product_type_fields filter also carries the
    writable extension fields of the product resource, as namespace.field.
    The owner of an extension field removes it from the types it does not
    apply to, so the schema and the error name the right fields for each
    type. register_field() stays resource-neutral. The filter no longer
    receives the class name, because the owner of a type knows its class.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Keep the field list out of the type description

    The description of type listed the fields of every product type. That
    is a long sentence an agent has to parse, and it repeats what the
    run-time error already says. The schema now lists every field once,
    and the error names the fields the type takes.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Let a field name the object types it applies to

    An extension field that applies to some types of a resource had no
    structured way to say so. The product filter carried extension fields
    as namespace.field strings, which was product-specific and loose.

    ExtensibleAbilities::register_field() takes object_types: the types,
    as get_type() of the object returns them, that the field applies to.
    Core leaves the field out of other objects and refuses a write to them
    with woocommerce_ability_field_object_type, for every resource. The
    woocommerce_ability_product_type_fields filter goes back to Core
    fields only.

    Also: document why a type whose class is WC_Product_Simple itself is
    refused, and cast the boolean query filters with wc_string_to_bool so
    phpstan passes.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Keep the object_types diagnostic untranslated

    Developer diagnostics passed to wc_doing_it_wrong() stay in English, as
    the lint rule requires.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Shorten the changelog entry

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Refuse variations in product-update and restricted fields on unknown types

    A product-update call with a variation ID could edit the variation with the
    simple product fields, and with a type it could write a simple product type
    on the variation post. product-update now refuses variations with the same
    woocommerce_product_type_unsupported error as products-query and
    product-delete, and the product class config refuses variation classes.

    A field with object_types no longer applies to an object whose type is
    unknown, and get_type() is only called when it is callable and the field is
    restricted.

    Addresses the CodeRabbit review on #70050.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Rename the changelog entry

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Refuse a type change on a variable product

    product-update replaced the loaded product with one of the requested
    type before checking that the current type is supported, so a variable
    product could be saved as simple and lose its variations. The current
    type is checked first, as CodeRabbit pointed out.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Apply forced product properties only when the type changes

    A repeated `type` cleared the stock of a stocked grouped product. The
    forced properties now apply only when `type` differs from the stored type.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Tell agents which fields each product type takes

    The description of `type` lists the Core fields of each type, and an extension
    field with `object_types` says which types it applies to. The field list was
    removed in b669c71c74 because it looked long and repeated the error. A test with
    6 models and 8 tasks showed that it helps: 87% of calls were right on the first
    try with this guidance and 74% without it, with no wrong saves. Refusals stay
    the same.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Say on each Core field which product types take it

    A field that not every type takes now says "Applies to: ..." in its description.
    This is the shape that won a final test with 6 models and 10 clear tasks, set
    against the `type` field list alone: 96% of calls were right on the first try
    with both, and 92% with the list alone, with no wrong saves. The rule for
    replacing the list alone was fixed before the run.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Remove checks that repeat the input schema in the product abilities

    The input schema already limits the product type, so the abilities no
    longer check it again after validation. The product type fields filter
    result and get_type() of a subject are used as returned.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Keep the object_types diagnostic message in English

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Prototype: create variable products with their variations in one call

    Item 3 of the extensible abilities plan for 11.4, built on the custom
    product types spike.

    Product abilities:
    - product-create accepts `attributes` on a variable type and an optional
      `variations` list. Core builds every variation in memory, checks the
      attribute combinations and SKUs across the call, runs extension fields
      and validators on each one, then saves the parent and the variations.
    - A variation is a product with `type: "variation"`. product-update,
      product-delete and products-query accept its ID, and product-create
      makes one from a `parent_id`. products-query filters by `parent_id`.
    - The output gains `parent_id`, `attributes` and `variation_ids`.

    Pipeline:
    - ActionableAbility wraps save() in a database transaction and rolls it
      back when save() returns an error or throws. pending_objects() lets an
      ability name the other objects it saves, so the field writes and the
      validators run on each of them before the first save.
    - ExtensibleAbilities::get_input_schema() exposes the `extensions` input
      schema of a resource, so an ability can accept it on nested objects.

    Transactions:
    - wc_transaction_query() counts its nesting. The first start opens a
      transaction, deeper starts set savepoints, and each commit or rollback
      ends its own level.
    - The test bootstrap no longer turns transactions off. It enters the
      transaction that the test case owns one time, so every transaction in
      the code under test becomes a savepoint, and a rollback is real inside
      a test.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Drop the object caches of the objects a rolled back save touched

    The saves fill the post and order caches with rows the rollback
    removes, so a read in the same request would still see them.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Change, add and delete variations through the parent update

    product-update on a variable type takes the `variations` list too. An
    item with an id changes that variation, an item without one creates
    one, and `delete` removes one. Variations the input does not name stay
    as they are. The named variations save in the same transaction as the
    parent, with one parent sync, so a price change across many variations
    is one call that either lands whole or not at all.

    A single variation stays reachable as a product with `type: "variation"`
    in the other abilities.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Check variation permissions on the parent and write up the design

    A variation follows its parent: a published variation of a draft parent
    is as hidden as the parent, and whoever may edit the parent may edit its
    variations. The query, update and delete abilities now check the parent
    of a variation id, as Wholesale Suite's own ability does.

    The register_field() docblock says where a write that must touch a
    second object belongs: a save hook of the object, which runs inside the
    transaction of the ability. ActionableAbility says the same.

    docs/abilities/variable-products.md describes the API, the mechanism,
    the test setup, what reading Wholesale Suite changed, and the open items.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Fire woocommerce_ability_after_save once per call, inside the transaction

    After the last save and before the commit, ActionableAbility fires the
    action with every saved object, the subject first, and the ability. An
    extension writes there what the saved objects imply elsewhere, such as
    the price range a variable parent derives from its variations, or a row
    in a table of its own. A WC_Data_Exception refuses the call with its
    code and message, any other exception refuses it with a fixed message,
    and either way every save of the call rolls back. The action does not
    fire with the feature off.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Return the secondary objects from change() and replace the after-save action with syncs

    An ability that saves more than one object used to build the list in
    change() and hand it over from a second method, pending_objects(), through
    instance state. Now change() returns the list. Each secondary object
    carries its extensions input and, when it differs from the ability's
    resource, the name of its resource, so an order ability can return order
    items and each object gets the fields and validators of its own resource.

    The woocommerce_ability_after_save action becomes
    ExtensibleAbilities::register_sync(). A sync is registered on a resource
    with a namespace, like a validator, runs once per call after every object
    is saved and only when an object of its resource was saved, and refuses
    the call by returning a WP_Error, which the action could not do. The
    error carries the namespace.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Warn when a secondary object names an unregistered resource

    A resource name on a secondary object is a string the ability types by
    hand. An unregistered name used to get no fields and no validators with
    no sign of it. The pipeline now calls wc_doing_it_wrong() for it and still
    runs the call. The register_sync() docblock also says that the saved
    objects can belong to more than one resource.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Drop type from variable product updates

    product-update now has one flat schema, so an update of a variation or
    of the variations of a parent no longer sends type. The tests and the
    design doc follow, and a test checks that variations on a simple
    product is refused.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Use global attributes in the product abilities

    An attributes item whose name or id matches a global attribute now
    builds a taxonomy attribute, as the REST API does: the attribute id,
    the taxonomy name and the term ids of its options. Options without a
    term keep their name, so the save creates the term inside the
    transaction. A variation matches the parent's local attribute first,
    then its global one, and stores the term slug. The output gives each
    attribute its global id, or 0 for a local attribute.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Change a variation's options only through its parent

    A direct product-update on a variation cannot check its options against
    the other variations, so it could give two variations the same options.
    The parent's variations list checks them against every sibling and can
    move an existing variation to new options. A direct update of a
    variation now refuses attributes, and the update schema takes the
    parent shape only. product-create with type variation still validates
    its attributes against the parent.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Check a new variation against its siblings on create

    product-create with type "variation" checked the options against the
    parent's attributes only, so it could add a second variation with the
    same options as an existing one. The sibling comparison of
    plan_variations() now lives in check_variation_siblings(), and the
    single-variation create runs it too.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Build the variable product inputs on the flat schema

    product-create no longer has a branch per type, so the fields of a
    variation create, parent_id and attributes, sit on the one flat object.
    A variation has no name of its own, so the create checks the name at
    run time instead of requiring it in the schema. The attributes input of
    a create takes both shapes, the parent's and the variation's, and the
    update takes the parent's only.

    Also type the variation plan for phpstan.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Apply the Wholesale Suite review of the variable product abilities

    Josh Kohlbach tested PR 70203 with Wholesale Prices and reported eight
    gaps. This commit closes them.

    - Sibling checks and variation_ids read the children from the data
      store. WC_Product_Variable::get_children() runs the
      woocommerce_get_children filter, which a plugin can use to hide
      variations from a user, and then a duplicate slipped past the check.
    - product-delete runs on the ActionableAbility pipeline. Validators can
      refuse a delete, the syncs see the deleted objects, a variation delete
      syncs its parent, and a failure rolls back. A delete writes no
      extension fields, so it gets no extensions input.
    - A sync receives a context with the deleted objects, as they were
      before the delete, and the input of the call. A sync of a resource
      also runs when an object of that resource was deleted.
    - A variation matches any option of an attribute with a null option.
    - A parent attribute takes variation: false for an attribute that shows
      on the product page only. The output reports the flag.
    - A name is a local attribute unless global is true or an id names a
      global attribute. An unknown global attribute is refused.

    The response is built after the syncs from the primary object, which
    the doc now states.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Return the created and changed variations from the product writes

    A client that changed fifty prices through the parent had to read the
    variations back with products-query. product-create and product-update
    now return them under variations, in input order, next to the product.
    Deleted variations are not listed.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Rename closure parameters that use a reserved word

    The branch lint warns on a parameter named object, and the lint job
    fails on warnings.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Shorten the changelog entry

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Close the gaps an adversarial review found in the variable product abilities

    - A variation create checks that the user may edit the parent.
    - product-update refuses a type change into or out of a variable product
      or a variation, because WooCommerce deletes the variations on that
      change, and a type that does not fit the post is an error, not an
      exception.
    - A variations item cannot repeat an id, or delete and change at once,
      or delete without an id. A new variation gets its parent id before the
      validators run. A variation is published or private, which is what
      WooCommerce counts as a child.
    - The sibling check reads no children for a parent that has no id yet.
    - product-delete gets no extensions input, and lists every child it
      deletes, whatever its status.
    - A global attribute is found by its label as well as its slug, and a
      local attribute may send id 0, so the output of attributes is valid
      input.
    - The update ability clears its deleted objects between calls.
    - The transaction test no longer commits the test transaction.

    Also drop the design doc, which lived outside the published docs tree,
    and rename the changelog entry.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Tell syncs apart what was trashed from what was deleted

    A sync saw a trashed product in the deleted list and had no way to know
    it could come back, short of reading the force flag of the input. The
    context now carries trashed objects under their own key, so a sync keeps
    what a restore needs. Josh Kohlbach raised it on PR 70203.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * List only the fields that apply to a variation in the variation schemas

    The variations input and output repeated every product field, including
    fields whose object_types rule out a variation. The schema advertised them for
    nothing and grew with each extension.

    The filtered input schema accepts other fields, so the write step still refuses
    them with the error that names the field and the types it applies to.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Keep the variation input schema closed and refuse wrong-type fields in validate_input

    The variation input accepted any field so that the write step could name the
    problem. The schema stays closed now, and the product write abilities refuse a
    field that cannot apply to a variation before the schema runs.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Support variation subclasses in the product abilities

    An extension can have its own variation class. Core only knew `variation`, so reading such a variation failed the output schema, and a new variation was always plain, which refused a field limited to the subclass.

    The product output no longer limits `type` to a list, because Core cannot list every class that the factory builds. A new `woocommerce_ability_variation_product_type` filter lets the owner of a variable type name the type of its new variations. The factory picks a saved variation's class from its post, and a new variation has no post while the abilities check it in memory. The check for fields on variations now resolves a type through the factory, so `woocommerce_product_class` counts.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Accept variation subclass types in the products query

    Product output can say a type like `subscription_variation`, but
    `products-query` refused that value as input. The list of types now
    also has the type that each variable product type builds for its
    variations.

    A variation has no product type term, so the query looks for variations
    whose parent has one of the parent types. A subquery does this inside
    the database query, so paging stays correct. `type: variation` still
    returns plain and subclass variations.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Prefix the codes of a sync refusal with its namespace

    A sync refusal returned the bare code of the extension, so two extensions
    could return the same code. The client now sees `<namespace>:<code>`, the
    format that validator refusals get.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Assert refusal codes that #70308 prefixes by their end

    These refusals keep the bare code on this branch and get `<namespace>:` from
    #70308, so the assertions hold with and without it.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Give validators the primary object and run them on deleted variations

    A new variation has parent ID 0 on product-create, because the parent is not
    saved yet, so a rule that passes on update behaved differently on create. A
    validator now gets `$context['primary']` as its third argument. Validators
    that take two arguments keep working.

    Validators also skipped variations that the `variations` input deletes and
    the variations of a deleted variable parent. They now run on these too, before
    any save, with `$context['deleting']` true. A refusal deletes nothing.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Return the stored slug from product-create

    The data store writes post_name but never sets it back on the object, so the
    output had an empty slug although the post had one. The mutation response now
    reads the slug of the stored post. The data store does not change, because
    that would change every product save.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Keep the new sync and secondary object notices in English

    Trunk now forbids translation calls in wc_doing_it_wrong() messages.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Treat a variation attribute with no option as any option

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Adapt the variable product abilities to the base's schema checks

    The base no longer has get_supported_product_types() or
    get_product_type_config(), and get_object_type() always returns a string.
    The products query builds its type list from the supported type configs,
    and product-create relies on the input schema for the type. The rebase
    also brings back the product type notes and the object type notes that
    the base still has.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Remove checks that repeat the schema or the contract in variable product writes

    The variation extensions input and output use the same product schema as
    the parent, and apply_fields() refuses a field that does not apply to a
    variation, so the filtered variation schema and its validate_input()
    pre-check go away. A sync refusal keeps its code, as a validator refusal
    does. register_validator() and register_sync() share one argument check.

    product-create refuses a variation whose parent is not a variable product
    in load(), and a variation attribute item with options. The variation
    plan stores each variation with its extensions input. Guards on input the
    schema already checked, on arrays Core builds, and on the saved SKU check
    that set_sku() already does are removed.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * List only the fields that apply to a variation in the variation schemas

    The variation extensions input and output repeated every product field,
    which doubled the extension bytes of product-create and product-update.
    get_input_schema() and add_fields_schema() take an optional field filter
    again, and the variation schemas pass field_applies_to_variations(). A
    field that does not apply is refused by the closed variation schema, and
    apply_fields() still refuses a field that does not match the exact
    variation type.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Refuse a secondary object whose resource is not registered

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Close the gaps in nested deletes, parent syncs, rollbacks and validator context

    - A variations item with delete needs the delete permission on the parent,
      the same as product-delete.
    - A variation create or update returns its parent as a secondary object and
      syncs that instance, so validators and syncs see the saved parent.
    - product-delete stores its snapshots before delete(), so a throwing delete
      hook still lets the rollback drop the product caches.
    - The rollback drops the order caches again.
    - Every field write runs before the first validator. Validators get the
      saved objects in $context['objects'] and the deleted ones in
      $context['objects_to_delete'], so a rule can compare the variations of
      one call.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Make the ProductDelete steps protected

    The base class made load(), change(), save() and prepare_response()
    protected, so the delete ability follows it.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Set savepoints only inside a transaction that an ability owns

    wc_transaction_query() counted its nesting for every caller. A caller
    that starts a transaction and never commits it, such as Mix and Match's
    delete_product(), then left the counter above 0, so every later commit
    in the request became RELEASE SAVEPOINT and nothing was saved.

    wc_transaction_query() works as on trunk again. DatabaseTransaction::run()
    owns a transaction: inside it, wc_transaction_query() sets savepoints, and
    run() always ends with the COMMIT or ROLLBACK of the level it opened. The
    product abilities use run(), so a variable product and its variations
    still save together. The test bootstrap owns the test case's transaction.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Give the variation attribute option one type

    The input took `option` as a string or null, and the output returned
    null for a variation that matches any option. A missing option already
    meant any option on input. Now the input takes a string only, and the
    output leaves `option` out for an any-option variation, so an agent can
    send back what it read, and every client gets one type.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Refuse a type change into or out of a variable subclass in product-update

    The guard compared type names, so a WC_Product_Variable subclass with a
    type of its own, such as an extension's variable subscription, could
    change to simple, which left its variations without a variable parent.
    Check the class of the current product and of the requested type.

    Use a prefix check instead of a regular expression in the transaction
    test.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Resolve the target class with the product ID in the product-update type guard

    The guard asked for the class of the requested type with ID 0, while
    the conversion uses the product's ID. A woocommerce_product_class filter
    that depends on the ID could return a variable class only for the saved
    product, and the guard then let the change through.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Keep a caller's transaction open when an ability runs inside it

    A caller that started a transaction with wc_transaction_query() lost it when an
    ability ran: run() sent START TRANSACTION, which commits the caller's earlier
    writes, so the caller's later rollback undid nothing.

    wc_transaction_query() now records a transaction it starts outside run(). Inside
    such a transaction, run() works in a savepoint, and the caller's commit or
    rollback decides the outcome, as on trunk, where abilities used no transaction.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Check publish_products for a variation status change through the parent

    product-update refused a direct status change to a published status without
    publish_products, but a variation item of the parent's variations input skipped
    the check. Both paths now use the same check.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Drop the meta and product caches of rolled back objects

    After a rollback, a fresh product object could still return a rejected
    extension value from the WC_Data meta cache, which clean_post_cache() does not
    clear. The cleanup now also drops the meta cache and the product instance cache.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Fail a variation delete through the parent when the row stays

    WC_Data::delete() returns true even when pre_delete_post blocks the delete, so
    the call succeeded and reported the variation as deleted to the syncs. The
    nested delete now checks the row, as the standalone delete does.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Refuse a variation that repeats the SKU of its parent

    The sibling check compared the SKUs of the variations with one another, but not
    with the parent's SKU, which is not saved yet when set_sku() checks it.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Apply the product type fields filter to variation items

    A variation item of the parent's variations input used the unfiltered variation
    fields, so a field that woocommerce_ability_product_type_fields removed was
    refused on a direct update but saved through the parent.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Check the publish permission for new variations added through the parent

    A user without publish_products could add a published variation through
    product-update, because the check ran only for existing variations.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
    Co-authored-by: Luis Felipe Zaguini <luis.felipe.zaguini@a8c.com>

diff --git a/plugins/woocommerce/changelog/variable-products b/plugins/woocommerce/changelog/variable-products
new file mode 100644
index 00000000000..12746f8d7c1
--- /dev/null
+++ b/plugins/woocommerce/changelog/variable-products
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Product abilities: create, update and delete variable products with their variations in one call, with every save of a call rolled back when one fails.
diff --git a/plugins/woocommerce/includes/wc-core-functions.php b/plugins/woocommerce/includes/wc-core-functions.php
index c0c83d873df..69baceece9e 100644
--- a/plugins/woocommerce/includes/wc-core-functions.php
+++ b/plugins/woocommerce/includes/wc-core-functions.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
 use Automattic\WooCommerce\Enums\DefaultCustomerAddress;
 use Automattic\WooCommerce\Utilities\NumberUtil;
 use Automattic\WooCommerce\Internal\Logging\OrderLogsCleanupHelper;
+use Automattic\WooCommerce\Internal\Utilities\DatabaseTransaction;

 if ( ! defined( 'ABSPATH' ) ) {
 	exit;
@@ -1378,7 +1379,11 @@ function wc_array_cartesian( $input ) {
 /**
  * Run a MySQL transaction query, if supported.
  *
+ * Inside a transaction that DatabaseTransaction::run() owns, a start sets a
+ * savepoint, and the matching commit or rollback releases or rolls back to it.
+ *
  * @since 2.5.0
+ * @since 11.4.0 Sets savepoints inside a transaction that DatabaseTransaction::run() owns.
  * @param string $type Types: start (default), commit, rollback.
  * @param bool   $force use of transactions.
  * @return void
@@ -1391,6 +1396,16 @@ function wc_transaction_query( $type = 'start', $force = false ) {
 	wc_maybe_define_constant( 'WC_USE_TRANSACTIONS', true );

 	if ( Constants::is_true( 'WC_USE_TRANSACTIONS' ) || $force ) {
+		if ( DatabaseTransaction::is_open() ) {
+			if ( 'commit' === $type || 'rollback' === $type ) {
+				DatabaseTransaction::close_latest( 'commit' === $type );
+			} else {
+				DatabaseTransaction::start();
+			}
+			return;
+		}
+
+		DatabaseTransaction::track_caller( $type );
 		switch ( $type ) {
 			case 'commit':
 				$wpdb->query( 'COMMIT' );
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 3dd1da798b6..25450173e66 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -7,18 +7,22 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\Abilities;

+use Automattic\WooCommerce\Internal\Caches\ProductCache;
+use Automattic\WooCommerce\Internal\Utilities\DatabaseTransaction;
+
 defined( 'ABSPATH' ) || exit;

 /**
- * An ability that changes one object and saves it one time. Register it with
- * a subclass as the `ability_class`. The execute callback loads the object,
- * checks the `extensions` input against the field input schemas, changes the object
- * in memory, writes the fields with their `set_callback`, runs the validators
- * that ExtensibleAbilities::register_validator() adds, saves it, and then
- * prepares the response. A rejection before the save saves nothing. When
- * prepare_response() returns a WP_Error, the change is already saved, so the
- * error message names the saved object, and the error data gets `saved` set
- * to true and the `id` of the object.
+ * An ability that changes a primary object, and optionally secondary objects,
+ * and saves them in one call. Register it with a subclass as the
+ * `ability_class`. The execute callback loads the primary object, checks the
+ * `extensions` input against the field input schemas, changes the objects in memory,
+ * writes the fields with their `set_callback`, runs the validators that
+ * ExtensibleAbilities::register_validator() adds, saves, and then prepares the
+ * response. A rejection before the save saves nothing. When prepare_response()
+ * returns a WP_Error, the change is already saved, so the error message names
+ * the saved object, and the error data gets `saved` set to true and the `id`
+ * of the object.
  *
  * The input schema gets the `extensions` property of the resource, so the
  * ability accepts the fields with no extra code.
@@ -27,8 +31,24 @@ defined( 'ABSPATH' ) || exit;
  * input is ignored. An ability whose own input schema sets
  * `additionalProperties` to false rejects it instead.
  *
- * Only save() saves. The other steps must not save, send email or make HTTP
- * requests.
+ * The object that load() returns is the primary object. An ability that saves
+ * more objects returns them from change(). These are the secondary objects. The field
+ * writes of every object run first, then the validators of each object's own
+ * resource, before the first save. The objects that get_objects_to_delete() lists get the
+ * validators too.
+ *
+ * Only save() saves, inside a database transaction. A WP_Error or an exception
+ * from save() rolls back every row it wrote, so an ability that saves secondary
+ * objects leaves nothing behind when a later save fails. After the last save,
+ * still inside the transaction, the syncs that ExtensibleAbilities::register_sync()
+ * adds write what the saved objects imply elsewhere, such as the price range a
+ * variable parent derives from its variations, or a row in a table of the
+ * extension. A sync also sees the objects the call deleted and the input of
+ * the call. A refusal there rolls the call back too. A rollback drops the
+ * object caches of the objects the call saved or deleted. It does not undo what a sync
+ * did outside the database, such as an HTTP request, and it does nothing on a
+ * table engine without transactions. The other steps must not save, send email
+ * or make HTTP requests.
  *
  * The API is experimental while the `extensible_abilities` feature exists.
  *
@@ -56,13 +76,20 @@ abstract class ActionableAbility extends \WP_Ability {
 	abstract protected function load( array $input );

 	/**
-	 * Change the object in memory.
+	 * Change the primary object in memory, and build the secondary objects that
+	 * save() also writes. Return the secondary objects, each with its
+	 * `extensions` input and, when it differs from get_resource(), the name of
+	 * its resource. The field writes and the validators of that resource run on
+	 * each one after the primary object, before save(). An ability that saves
+	 * only the primary object returns null. The resource must be registered,
+	 * otherwise the call is refused with `woocommerce_ability_resource_unknown`
+	 * and nothing is saved.
 	 *
 	 * @since 11.3.0
 	 *
-	 * @param \WC_Data $subject Object to change.
+	 * @param \WC_Data $subject Primary object to change.
 	 * @param array    $input   Ability input.
-	 * @return null|\WP_Error A WP_Error rejects the change, and nothing is saved.
+	 * @return null|\WP_Error|array<int, array{0: \WC_Data, 1: array, 2?: string}> A WP_Error rejects the change, and nothing is saved. An array lists the secondary objects.
 	 */
 	abstract protected function change( $subject, array $input );

@@ -108,6 +135,56 @@ abstract class ActionableAbility extends \WP_Ability {
 		return $subject->get_id() > 0 ? null : self::save_failed();
 	}

+	/**
+	 * Whether the ability writes the extension fields of its resource. An
+	 * ability that does not, such as a delete, gets no `extensions` input.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @return bool
+	 */
+	protected function writes_extension_fields(): bool {
+		return true;
+	}
+
+	/**
+	 * The objects that save() deleted for good, for the syncs. An ability that
+	 * deletes objects collects them in save() and returns them here.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @return \WC_Data[]
+	 */
+	protected function get_deleted_objects(): array {
+		return array();
+	}
+
+	/**
+	 * The objects that save() moved to the trash, for the syncs. A trashed
+	 * object can be restored, so a sync keeps what a restore needs.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @return \WC_Data[]
+	 */
+	protected function get_trashed_objects(): array {
+		return array();
+	}
+
+	/**
+	 * The objects that save() will delete or trash, for the validators. Each one
+	 * goes through the validators of the resource before the first save, with
+	 * `deleting` in the context, also when no other object of the call has it.
+	 * Its value is read after change().
+	 *
+	 * @since 11.4.0
+	 *
+	 * @return \WC_Data[]
+	 */
+	protected function get_objects_to_delete(): array {
+		return array();
+	}
+
 	/**
 	 * Run the change. This is the execute callback of the ability.
 	 *
@@ -115,6 +192,7 @@ abstract class ActionableAbility extends \WP_Ability {
 	 *
 	 * @param mixed $input Ability input.
 	 * @return mixed|\WP_Error The output of prepare_response(), or the WP_Error of the step that refused the change.
+	 * @throws \Throwable When save() throws, after the rollback.
 	 */
 	public function execute_change( $input = null ) {
 		$input   = is_array( $input ) ? $input : array();
@@ -123,13 +201,11 @@ abstract class ActionableAbility extends \WP_Ability {
 			return $subject;
 		}

-		$updates = array();
-		if ( ExtensibleAbilities::is_enabled() ) {
-			$extensions = is_array( $input['extensions'] ?? null ) ? $input['extensions'] : array();
-			$updates    = $this->prepare_fields( $extensions );
-			if ( is_wp_error( $updates ) ) {
-				return $updates;
-			}
+		$enabled = ExtensibleAbilities::is_enabled();
+		$writes  = $enabled && $this->writes_extension_fields();
+		$updates = $writes ? $this->prepare_fields( $input['extensions'] ?? array(), $this->get_resource() ) : array();
+		if ( is_wp_error( $updates ) ) {
+			return $updates;
 		}

 		$changed = $this->change( $subject, $input );
@@ -137,17 +213,83 @@ abstract class ActionableAbility extends \WP_Ability {
 			return $changed;
 		}

+		$objects = array( array( $subject, $updates, $this->get_resource() ) );
+		foreach ( (array) $changed as $secondary ) {
+			$resource_name = $secondary[2] ?? $this->get_resource();
+			if ( $enabled && ! ExtensibleAbilities::is_resource_registered( $resource_name ) ) {
+				wc_doing_it_wrong(
+					get_class( $this ) . '::change',
+					sprintf(
+						'The "%s" resource of a secondary object is not registered, so the call is refused. Call ExtensibleAbilities::register_resource() first.',
+						$resource_name
+					),
+					'11.4.0'
+				);
+				return new \WP_Error(
+					'woocommerce_ability_resource_unknown',
+					sprintf(
+						/* translators: 1: resource name, 2: ability name. */
+						__( 'The "%1$s" resource is not registered, so the "%2$s" ability cannot save the change.', 'woocommerce' ),
+						$resource_name,
+						$this->get_name()
+					),
+					array(
+						'status'   => 500,
+						'resource' => $resource_name,
+						'ability'  => $this->get_name(),
+					)
+				);
+			}
+			$fields = $writes ? $this->prepare_fields( $secondary[1], $resource_name ) : array();
+			if ( is_wp_error( $fields ) ) {
+				return $fields;
+			}
+			$objects[] = array( $secondary[0], $fields, $resource_name );
+		}
+
 		$refused = new \WP_Error();
-		$this->apply_fields( $subject, $updates, $refused );
-		if ( ExtensibleAbilities::is_enabled() ) {
-			$this->validate( $subject, $refused );
+		foreach ( $objects as list( $object, $fields ) ) {
+			$this->apply_fields( $object, $fields, $refused );
+		}
+
+		$deleting = $this->get_objects_to_delete();
+		$staged   = array_column( $objects, 0 );
+		$to_check = $objects;
+		foreach ( $deleting as $object ) {
+			if ( ! in_array( $object, $staged, true ) ) {
+				$to_check[] = array( $object, array(), $this->get_resource() );
+			}
+		}
+		foreach ( $enabled ? $to_check : array() as list( $object, , $resource_name ) ) {
+			$this->validate(
+				$object,
+				$resource_name,
+				array(
+					'primary'           => $subject,
+					'deleting'          => in_array( $object, $deleting, true ),
+					'objects'           => $staged,
+					'objects_to_delete' => $deleting,
+				),
+				$refused
+			);
 		}
 		if ( $refused->has_errors() ) {
 			return $refused;
 		}

-		$saved = $this->save( $subject );
+		try {
+			$saved = DatabaseTransaction::run(
+				function () use ( $subject, $objects, $input, $enabled ) {
+					$saved = $this->save( $subject );
+					return ! is_wp_error( $saved ) && $enabled ? $this->sync( $objects, $input ) : $saved;
+				}
+			);
+		} catch ( \Throwable $e ) {
+			self::forget_rolled_back_objects( $objects, array_merge( $this->get_deleted_objects(), $this->get_trashed_objects() ) );
+			throw $e;
+		}
 		if ( is_wp_error( $saved ) ) {
+			self::forget_rolled_back_objects( $objects, array_merge( $this->get_deleted_objects(), $this->get_trashed_objects() ) );
 			return $saved;
 		}

@@ -159,6 +301,77 @@ abstract class ActionableAbility extends \WP_Ability {
 		return $response;
 	}

+	/**
+	 * Run the syncs of each resource that the call saved or deleted, once,
+	 * after the last save and before the commit. A deleted or trashed object
+	 * is not in the saved list, even when it was the primary object.
+	 *
+	 * @param array<int, array{0: \WC_Data, 1: array, 2: string}> $objects The primary object and the secondary objects, with their resources.
+	 * @param array                                               $input   Ability input.
+	 * @return \WP_Error|null A WP_Error when a sync refused, and then the call rolls back.
+	 */
+	private function sync( array $objects, array $input ): ?\WP_Error {
+		$deleted   = $this->get_deleted_objects();
+		$trashed   = $this->get_trashed_objects();
+		$gone      = array_map( static fn( $item ) => get_class( $item ) . ':' . $item->get_id(), array_merge( $deleted, $trashed ) );
+		$saved     = array_values(
+			array_filter(
+				array_column( $objects, 0 ),
+				static fn( $item ) => $item->get_id() > 0 && ! in_array( get_class( $item ) . ':' . $item->get_id(), $gone, true )
+			)
+		);
+		$resources = array_column( $objects, 2 );
+		if ( ! empty( $deleted ) || ! empty( $trashed ) ) {
+			$resources[] = $this->get_resource();
+		}
+		$context = array(
+			'deleted' => array_values( $deleted ),
+			'trashed' => array_values( $trashed ),
+			'input'   => $input,
+		);
+		foreach ( array_unique( $resources ) as $resource_name ) {
+			foreach ( ExtensibleAbilities::get_syncs( $resource_name ) as $args ) {
+				$rejected = self::run_callback( $args['callback'], array( $saved, $this, $context ), array( 'namespace' => $args['namespace'] ) );
+				if ( null !== $rejected ) {
+					return $rejected;
+				}
+			}
+		}
+		return null;
+	}
+
+	/**
+	 * Drop the object caches of the objects a rolled back save() touched. The
+	 * saves filled the caches with rows the rollback removed, so a read in the
+	 * same request would still see them. The `WC_Data` meta cache and the
+	 * product instance cache are separate from the post cache.
+	 *
+	 * @param array<int, array{0: \WC_Data, 1: array, 2: string}> $objects The primary object and the secondary objects.
+	 * @param \WC_Data[]                                          $deleted The objects the call deleted.
+	 */
+	private static function forget_rolled_back_objects( array $objects, array $deleted = array() ): void {
+		$product_cache = wc_get_container()->get( ProductCache::class );
+		foreach ( array_merge( $objects, array_map( static fn( $item ) => array( $item ), $deleted ) ) as list( $object ) ) {
+			$id = $object->get_id();
+			if ( $id > 0 ) {
+				$object->delete_meta_cache();
+			}
+			if ( $id > 0 && $object instanceof \WC_Product ) {
+				clean_post_cache( $id );
+				wc_delete_product_transients( $id );
+				$product_cache->remove( $id );
+				if ( $object->get_parent_id() > 0 ) {
+					clean_post_cache( $object->get_parent_id() );
+					wc_delete_product_transients( $object->get_parent_id() );
+					$product_cache->remove( $object->get_parent_id() );
+				}
+			} elseif ( $id > 0 && $object instanceof \WC_Abstract_Order ) {
+				wc_get_container()->get( \Automattic\WooCommerce\Caches\OrderCache::class )->remove( $id );
+				clean_post_cache( $id );
+			}
+		}
+	}
+
 	/**
 	 * Use the change as the execute callback, and add the `extensions` input
 	 * when the feature is on.
@@ -168,7 +381,7 @@ abstract class ActionableAbility extends \WP_Ability {
 	 */
 	protected function prepare_properties( array $args ): array {
 		$args['execute_callback'] = array( $this, 'execute_change' );
-		if ( is_array( $args['input_schema'] ?? null ) && ExtensibleAbilities::is_enabled() ) {
+		if ( is_array( $args['input_schema'] ?? null ) && ExtensibleAbilities::is_enabled() && $this->writes_extension_fields() ) {
 			$args['input_schema'] = $this->add_extensions_input_schema( $args['input_schema'] );
 		}
 		return parent::prepare_properties( $args );
@@ -179,11 +392,12 @@ abstract class ActionableAbility extends \WP_Ability {
 	 * schema of each field, and cast them. No callback runs here. A null value
 	 * passes only where the input schema declares null.
 	 *
-	 * @param array<string, mixed> $values Values keyed by namespace, then field.
+	 * @param array  $values        Values keyed by namespace, then field.
+	 * @param string $resource_name Resource of the object that gets the values.
 	 * @return array|\WP_Error A list of field arguments and value pairs, or a WP_Error when a value is rejected.
 	 */
-	private function prepare_fields( array $values ) {
-		$registered = ExtensibleAbilities::get_fields( $this->get_resource() );
+	private function prepare_fields( array $values, string $resource_name ) {
+		$registered = ExtensibleAbilities::get_fields( $resource_name );
 		$updates    = array();
 		foreach ( $values as $namespace => $fields ) {
 			foreach ( (array) $fields as $field => $value ) {
@@ -244,15 +458,17 @@ abstract class ActionableAbility extends \WP_Ability {
 	}

 	/**
-	 * Run every validator of the resource on the changed object, also after a
+	 * Run every validator of a resource on the changed object, also after a
 	 * field or another validator refuses.
 	 *
-	 * @param \WC_Data  $subject Changed object.
-	 * @param \WP_Error $refused Collects the refusals.
+	 * @param \WC_Data  $subject       Changed object.
+	 * @param string    $resource_name Resource of the object.
+	 * @param array     $context       The primary object of the call, whether the call deletes or trashes the object, the objects the call saves, and the objects it deletes or trashes.
+	 * @param \WP_Error $refused       Collects the refusals.
 	 */
-	private function validate( $subject, \WP_Error $refused ): void {
-		foreach ( ExtensibleAbilities::get_validators( $this->get_resource() ) as $args ) {
-			$rejected = self::run_callback( $args['callback'], array( $subject, $this ), array( 'namespace' => $args['namespace'] ) );
+	private function validate( $subject, string $resource_name, array $context, \WP_Error $refused ): void {
+		foreach ( ExtensibleAbilities::get_validators( $resource_name ) as $args ) {
+			$rejected = self::run_callback( $args['callback'], array( $subject, $this, $context ), array( 'namespace' => $args['namespace'] ) );
 			if ( null !== $rejected ) {
 				self::add_refusal( $rejected, $args['namespace'], $refused );
 			}
@@ -290,7 +506,7 @@ abstract class ActionableAbility extends \WP_Ability {
 	 * logged and becomes a 500 with a fixed message, so its text never reaches
 	 * the client.
 	 *
-	 * @param callable             $callback Field set_callback or validator.
+	 * @param callable             $callback Field set_callback, validator or sync.
 	 * @param array                $args     Callback arguments.
 	 * @param array<string, mixed> $data     Data that names the field or the namespace.
 	 * @return \WP_Error|null
@@ -316,50 +532,15 @@ abstract class ActionableAbility extends \WP_Ability {

 	/**
 	 * Add the `extensions` property one time, at the top level of the input
-	 * schema. It lists the fields that have a set_callback, each with its input
-	 * schema as declared, and says which object types a field with `object_types`
-	 * applies to. Each `oneOf`, `anyOf` and `allOf` branch only accepts the
-	 * key, so a branch with `additionalProperties` set to false does not reject
-	 * it, and the schema does not grow with the number of branches.
+	 * schema. Each `oneOf`, `anyOf` and `allOf` branch only accepts the key, so
+	 * a branch with `additionalProperties` set to false does not reject it, and
+	 * the schema does not grow with the number of branches.
 	 *
 	 * @param array<string, mixed> $schema Input schema.
 	 * @return array<string, mixed>
 	 */
 	private function add_extensions_input_schema( array $schema ): array {
-		$extensions = array(
-			'type'                 => 'object',
-			'description'          => __( 'Values to write to the fields that extensions add, keyed by extension namespace, then field.', 'woocommerce' ),
-			'additionalProperties' => false,
-		);
-		foreach ( ExtensibleAbilities::get_fields( $this->get_resource() ) as $namespace => $fields ) {
-			$writable = array_filter(
-				$fields,
-				static function ( array $args ): bool {
-					return isset( $args['set_callback'] );
-				}
-			);
-			if ( empty( $writable ) ) {
-				continue;
-			}
-			$extensions['properties'][ $namespace ] = array(
-				'type'                 => 'object',
-				'properties'           => array_map(
-					static function ( array $args ): array {
-						$field_schema = $args['input_schema'];
-						if ( ! empty( $args['object_types'] ) ) {
-							/* translators: %s: Comma-separated list of object types. */
-							$applies_to                  = sprintf( __( 'Applies to: %s.', 'woocommerce' ), implode( ', ', $args['object_types'] ) );
-							$field_schema['description'] = trim( ( $field_schema['description'] ?? '' ) . ' ' . $applies_to );
-						}
-						return $field_schema;
-					},
-					$writable
-				),
-				'additionalProperties' => false,
-			);
-		}
-
-		$schema['properties']['extensions'] = $extensions;
+		$schema['properties']['extensions'] = ExtensibleAbilities::get_input_schema( $this->get_resource() );
 		foreach ( array( 'oneOf', 'anyOf', 'allOf' ) as $keyword ) {
 			foreach ( array_keys( $schema[ $keyword ] ?? array() ) as $index ) {
 				$schema[ $keyword ][ $index ]['properties']['extensions'] = array(
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index 26878c0c66a..ac1e248e09c 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -54,6 +54,13 @@ class ExtensibleAbilities {
 	 */
 	private static array $validators = array();

+	/**
+	 * Syncs keyed by resource.
+	 *
+	 * @var array<string, array<int, array<string, mixed>>>
+	 */
+	private static array $syncs = array();
+
 	/**
 	 * Whether the store enabled the experimental feature.
 	 *
@@ -151,6 +158,12 @@ class ExtensibleAbilities {
 	 *                                  2. It receives the value already cast to `input_schema`.
 	 *                                  3. A null value, where `input_schema` declares null, deletes the value
 	 *                                     of the field.
+	 *                                  4. It writes one object: the one it receives. Data that a
+	 *                                     write must keep on another object, such as a price range
+	 *                                     that a variable parent derives from its variations, is
+	 *                                     written by a sync that register_sync() adds. A sync runs
+	 *                                     once per call after every object is saved and inside the
+	 *                                     transaction of the ability.
 	 * }
 	 */
 	public static function register_field( array $args ): void {
@@ -505,15 +518,26 @@ class ExtensibleAbilities {
 	 *
 	 *     @type string   $resource  Resource that an ActionableAbility changes, such as `product`.
 	 *     @type string   $namespace Slug of the extension that owns the validator, such as `subscriptions`.
-	 *     @type callable $callback  Receives the changed object and the ability, after Core's changes and the
-	 *                               field callbacks, before the one save. It returns a WP_Error to refuse the
-	 *                               change, and then nothing is saved. The other validators still run, and
-	 *                               the client gets every refusal in one error. The client sees the code
-	 *                               prefixed with the namespace, as `<namespace>:<code>`. A code that another
-	 *                               validator in the same namespace already used gets a numeric suffix, as
-	 *                               `<namespace>:<code>:2`. The error gets the
-	 *                               namespace in its data, and the 400 status when it has no status. Its
-	 *                               message can name the ability or screen to use instead. Validators also
+	 *     @type callable $callback  Receives the changed object, the ability and `array $context`, after Core's
+	 *                               changes and the field callbacks, before the one save. A callback that only
+	 *                               needs the object or the ability can take fewer arguments.
+	 *                               `$context['primary']` is the primary object of the call. It differs from the
+	 *                               changed object when the object is a variation that the call saves through
+	 *                               its parent. On product-create that parent is not saved yet, so the variation
+	 *                               has no parent ID and a rule reads the parent from here.
+	 *                               `$context['deleting']` is true when the call deletes or trashes the object,
+	 *                               such as a variation of a deleted parent, or an item with `delete` in the
+	 *                               `variations` input. `$context['objects']` is every object the call saves,
+	 *                               the primary object first, with the field writes of all of them applied, so
+	 *                               a rule can compare a variation with the other variations of the same call.
+	 *                               `$context['objects_to_delete']` is every object the call deletes or
+	 *                               trashes. It returns a WP_Error to refuse the change, and then nothing is
+	 *                               saved. The other validators still run, and the client gets every refusal in
+	 *                               one error. The client sees the code prefixed with the namespace, as
+	 *                               `<namespace>:<code>`. A code that another validator in the same namespace
+	 *                               already used gets a numeric suffix, as `<namespace>:<code>:2`. The error
+	 *                               gets the namespace in its data, and the 400 status when it has no status.
+	 *                               Its message can name the ability or screen to use instead. Validators also
 	 *                               run after a field callback refuses a value, so the client gets the field
 	 *                               refusals and the validator refusals together. Rules:
 	 *                               1. It only reads. It does not change the object, save, send email or
@@ -523,40 +547,89 @@ class ExtensibleAbilities {
 	 * }
 	 */
 	public static function register_validator( array $args ): void {
-		if ( did_action( 'woocommerce_extensible_abilities_init' ) && ! doing_action( 'woocommerce_extensible_abilities_init' ) ) {
-			wc_doing_it_wrong( __METHOD__, 'Register a validator inside the woocommerce_extensible_abilities_init action.', '11.3.0' );
+		if ( ! self::check_callback_args( $args, __METHOD__, 'validator', '11.3.0' ) ) {
+			return;
+		}
+
+		self::$validators[ $args['resource'] ][] = $args;
+	}
+
+	/**
+	 * Register a sync on a resource. A sync runs once per ability call, after
+	 * every object of the call is saved, and writes what the saved objects imply
+	 * elsewhere, such as the price range a variable parent derives from its
+	 * variations, or a row in a table of the extension.
+	 *
+	 * Call it inside the `woocommerce_extensible_abilities_init` action. Syncs
+	 * run only when the `extensible_abilities` feature is on, inside the
+	 * transaction of the ActionableAbility.
+	 *
+	 * A sync registered for a resource runs only when an object of that resource
+	 * was saved, deleted or trashed in the call. It runs once per call, not once per
+	 * object. The saved objects can belong to more than one resource, so a sync
+	 * reads the objects it owns and ignores the rest.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param array $args {
+	 *     Sync arguments.
+	 *
+	 *     @type string   $resource  Resource that an ActionableAbility saves, such as `product`.
+	 *     @type string   $namespace Slug of the extension that owns the sync, such as `subscriptions`.
+	 *     @type callable $callback  Receives `\WC_Data[] $saved`, the ActionableAbility and `array $context`.
+	 *                               `$saved` is every saved object of the call, the primary object first
+	 *                               unless the call deleted it.
+	 *                               `$context['deleted']` is every object the call deleted for good, as it
+	 *                               was before the delete, with its ID. `$context['trashed']` is every object
+	 *                               the call moved to the trash; it can be restored, so a sync keeps what a
+	 *                               restore needs. And
+	 *                               `$context['input']` is the input of the call, so a sync can skip work
+	 *                               when the call did not write its fields. It returns a WP_Error to
+	 *                               refuse, and then every save of the call is undone. A thrown exception
+	 *                               refuses it too. The error gets the namespace in its data, and the 400
+	 *                               status when it has no status, as a validator's error does. A sync must not
+	 *                               send email or make HTTP requests, because an undo does not reach outside
+	 *                               the database.
+	 * }
+	 */
+	public static function register_sync( array $args ): void {
+		if ( ! self::check_callback_args( $args, __METHOD__, 'sync', '11.4.0' ) ) {
 			return;
 		}
+
+		self::$syncs[ $args['resource'] ][] = $args;
+	}
+
+	/**
+	 * Check the arguments of a validator or a sync, and report the first
+	 * problem with wc_doing_it_wrong().
+	 *
+	 * @param array  $args    Validator or sync arguments.
+	 * @param string $method  Method that registers the callback.
+	 * @param string $kind    `validator` or `sync`, for the message.
+	 * @param string $version Version that added the method.
+	 * @return bool Whether the arguments are valid.
+	 */
+	private static function check_callback_args( array $args, string $method, string $kind, string $version ): bool {
+		if ( did_action( 'woocommerce_extensible_abilities_init' ) && ! doing_action( 'woocommerce_extensible_abilities_init' ) ) {
+			wc_doing_it_wrong( $method, sprintf( 'Register a %s inside the woocommerce_extensible_abilities_init action.', $kind ), $version );
+			return false;
+		}
 		foreach ( array( 'resource', 'namespace' ) as $key ) {
 			if ( ! is_string( $args[ $key ] ?? null ) || '' === $args[ $key ] || sanitize_key( $args[ $key ] ) !== $args[ $key ] ) {
-				wc_doing_it_wrong(
-					__METHOD__,
-					sprintf(
-						'The "%s" argument must be a slug of lowercase letters, numbers, dashes and underscores.',
-						$key
-					),
-					'11.3.0'
-				);
-				return;
+				wc_doing_it_wrong( $method, sprintf( 'The "%s" argument must be a slug of lowercase letters, numbers, dashes and underscores.', $key ), $version );
+				return false;
 			}
 		}
 		if ( ! is_callable( $args['callback'] ?? null ) ) {
-			wc_doing_it_wrong( __METHOD__, 'The "callback" argument must be callable.', '11.3.0' );
-			return;
+			wc_doing_it_wrong( $method, 'The "callback" argument must be callable.', $version );
+			return false;
 		}
 		if ( ! isset( self::$resources[ $args['resource'] ] ) ) {
-			wc_doing_it_wrong(
-				__METHOD__,
-				sprintf(
-					'The "%s" resource is not registered. Call ExtensibleAbilities::register_resource() first.',
-					$args['resource']
-				),
-				'11.3.0'
-			);
-			return;
+			wc_doing_it_wrong( $method, sprintf( 'The "%s" resource is not registered. Call ExtensibleAbilities::register_resource() first.', $args['resource'] ), $version );
+			return false;
 		}
-
-		self::$validators[ $args['resource'] ][] = $args;
+		return true;
 	}

 	/**
@@ -645,11 +718,12 @@ class ExtensibleAbilities {
 	 *
 	 * @since 11.3.0
 	 *
-	 * @param array<string, mixed> $schema   Object schema.
+	 * @param array<string, mixed> $schema        Object schema.
 	 * @param string               $resource_name Resource.
+	 * @param callable|null        $field_filter  Optional. Gets the arguments of a field and returns whether the schema lists it.
 	 * @return array<string, mixed>
 	 */
-	public static function add_fields_schema( array $schema, string $resource_name ): array {
+	public static function add_fields_schema( array $schema, string $resource_name, ?callable $field_filter = null ): array {
 		if ( ! self::is_enabled() ) {
 			return $schema;
 		}
@@ -660,13 +734,16 @@ class ExtensibleAbilities {
 			'additionalProperties' => false,
 		);
 		foreach ( self::$fields[ $resource_name ] ?? array() as $namespace => $fields ) {
-			$readable = array_filter(
+			$listed = array_filter(
 				$fields,
 				static function ( array $args ): bool {
 					return isset( $args['get_callback'] );
 				}
 			);
-			if ( empty( $readable ) ) {
+			if ( null !== $field_filter ) {
+				$listed = array_filter( $listed, $field_filter );
+			}
+			if ( empty( $listed ) ) {
 				continue;
 			}
 			$extensions['properties'][ $namespace ] = array(
@@ -675,7 +752,7 @@ class ExtensibleAbilities {
 					static function ( array $args ): array {
 						return $args['output_schema'];
 					},
-					$readable
+					$listed
 				),
 				'additionalProperties' => false,
 			);
@@ -687,6 +764,55 @@ class ExtensibleAbilities {
 		return $schema;
 	}

+	/**
+	 * The schema of the `extensions` input of a resource. It lists the fields
+	 * that have a set_callback, each with its input schema as declared, and says
+	 * which object types a field with `object_types` applies to. ActionableAbility adds it to the input schema of every
+	 * ability. An ability that writes other objects of the resource in the same
+	 * call, such as the variations of a product, adds it to their input too.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string        $resource_name Resource.
+	 * @param callable|null $field_filter  Optional. Gets the arguments of a field and returns whether the schema lists it.
+	 * @return array<string, mixed>
+	 */
+	public static function get_input_schema( string $resource_name, ?callable $field_filter = null ): array {
+		$extensions = array(
+			'type'                 => 'object',
+			'description'          => __( 'Values to write to the fields that extensions add, keyed by extension namespace, then field.', 'woocommerce' ),
+			'additionalProperties' => false,
+		);
+		foreach ( self::get_fields( $resource_name ) as $namespace => $fields ) {
+			$writable = array_filter(
+				null === $field_filter ? $fields : array_filter( $fields, $field_filter ),
+				static function ( array $args ): bool {
+					return isset( $args['set_callback'] );
+				}
+			);
+			if ( empty( $writable ) ) {
+				continue;
+			}
+			$extensions['properties'][ $namespace ] = array(
+				'type'                 => 'object',
+				'properties'           => array_map(
+					static function ( array $args ): array {
+						$field_schema = $args['input_schema'];
+						if ( ! empty( $args['object_types'] ) ) {
+							/* translators: %s: Comma-separated list of object types. */
+							$applies_to                  = sprintf( __( 'Applies to: %s.', 'woocommerce' ), implode( ', ', $args['object_types'] ) );
+							$field_schema['description'] = trim( ( $field_schema['description'] ?? '' ) . ' ' . $applies_to );
+						}
+						return $field_schema;
+					},
+					$writable
+				),
+				'additionalProperties' => false,
+			);
+		}
+		return $extensions;
+	}
+
 	/**
 	 * Fields of a resource, keyed by namespace, then field.
 	 *
@@ -713,6 +839,32 @@ class ExtensibleAbilities {
 		return self::$validators[ $resource_name ] ?? array();
 	}

+	/**
+	 * Whether a resource is registered.
+	 *
+	 * @internal
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string $resource_name Resource.
+	 * @return bool
+	 */
+	public static function is_resource_registered( string $resource_name ): bool {
+		return isset( self::$resources[ $resource_name ] );
+	}
+
+	/**
+	 * Syncs of a resource.
+	 *
+	 * @internal
+	 *
+	 * @param string $resource_name Resource.
+	 * @return array<int, array<string, mixed>>
+	 */
+	public static function get_syncs( string $resource_name ): array {
+		return self::$syncs[ $resource_name ] ?? array();
+	}
+
 	/**
 	 * Mark an error from a write that saved before it failed. The message names
 	 * the saved object, because MCP clients get the message and not the data.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
index 3a352336ff3..3ef40f0791e 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
@@ -8,6 +8,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Internal\Abilities\Domain;

 use Automattic\WooCommerce\Abilities\ActionableAbility;
+use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
 use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;
@@ -27,6 +28,23 @@ class ProductCreate extends ActionableAbility {
 	 */
 	public const ABILITY_NAME = 'woocommerce/product-create';

+	/**
+	 * The variations that change() planned, saved after their parent by save().
+	 *
+	 * @var array{save: array<int, array{0: \WC_Product_Variation, 1: array}>, delete: \WC_Product_Variation[]}
+	 */
+	private array $variations = array(
+		'save'   => array(),
+		'delete' => array(),
+	);
+
+	/**
+	 * The parent of a variation that the call creates on its own, loaded by load().
+	 *
+	 * @var \WC_Product_Variable|null
+	 */
+	private ?\WC_Product_Variable $variation_parent = null;
+
 	/**
 	 * Get the ability registration arguments.
 	 *
@@ -43,7 +61,7 @@ class ProductCreate extends ActionableAbility {
 			),
 			'category'            => 'woocommerce',
 			'input_schema'        => self::build_input_schema(),
-			'output_schema'       => self::get_entity_output_schema( 'product', self::get_product_output_schema() ),
+			'output_schema'       => self::get_product_mutation_output_schema(),
 			'permission_callback' => array( __CLASS__, 'can_create_product' ),
 			'meta'                => array(
 				'show_in_rest' => true,
@@ -76,36 +94,111 @@ class ProductCreate extends ActionableAbility {
 	 * @return \WC_Product|\WP_Error
 	 */
 	protected function load( array $input ) {
-		return self::get_product_object_for_type( $input['type'] ?? ProductType::SIMPLE );
+		$this->variations = array(
+			'save'   => array(),
+			'delete' => array(),
+		);
+
+		$this->variation_parent = null;
+
+		$product_type = $input['type'] ?? ProductType::SIMPLE;
+
+		if ( ProductType::VARIATION !== $product_type && '' === trim( (string) ( $input['name'] ?? '' ) ) ) {
+			return new \WP_Error(
+				'woocommerce_product_name_required',
+				__( 'A product needs a name.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		if ( ProductType::VARIATION === $product_type ) {
+			$parent = wc_get_product( (int) ( $input['parent_id'] ?? 0 ) );
+			if ( ! $parent instanceof \WC_Product_Variable ) {
+				return new \WP_Error(
+					'woocommerce_product_parent_invalid',
+					__( 'The parent must be a variable product.', 'woocommerce' ),
+					array( 'status' => 400 )
+				);
+			}
+			$this->variation_parent = $parent;
+			return self::get_new_variation( $parent );
+		}
+
+		return self::get_product_object_for_type( $product_type );
 	}

 	/**
-	 * Set the product properties in memory.
+	 * Set the product properties in memory. A variable product also gets its
+	 * variations built in memory, and a variation must belong to a saved
+	 * variable product.
 	 *
 	 * @param \WC_Product $subject Product.
 	 * @param array       $input   Ability input.
-	 * @return null|\WP_Error
+	 * @return null|\WP_Error|array<int, array{0: \WC_Product, 1: array}> For a variable product, its variations, each with the `extensions` input of its `variations` item. For a variation, its parent.
 	 */
 	protected function change( $subject, array $input ) {
-		$product_config = self::get_supported_product_type_configs()[ $subject->get_type() ];
+		$product_config = self::get_product_config_for_product( $subject, true );
+
+		if ( is_wp_error( $product_config ) ) {
+			return $product_config;
+		}

 		try {
 			self::apply_product_type_config( $subject, $product_config );

-			return self::set_product_props_from_input( $subject, $input, $product_config );
+			$error = self::set_product_props_from_input( $subject, $input, $product_config );
+			if ( is_wp_error( $error ) ) {
+				return $error;
+			}
+
+			if ( $subject instanceof \WC_Product_Variation && null !== $this->variation_parent ) {
+				if ( ! $subject->get_status( 'edit' ) ) {
+					$subject->set_status( ProductStatus::PUBLISH );
+				}
+				$error = self::check_variation_attributes( $subject, $this->variation_parent );
+				if ( is_wp_error( $error ) ) {
+					return $error;
+				}
+				$error = self::check_variation_siblings( $this->variation_parent, array( $subject ) );
+				return is_wp_error( $error ) ? $error : array( array( $this->variation_parent, array() ) );
+			}
+
+			if ( $subject instanceof \WC_Product_Variable ) {
+				$plan = self::plan_variations( $subject, $input['variations'] ?? array() );
+				if ( is_wp_error( $plan ) ) {
+					return $plan;
+				}
+				$this->variations = $plan;
+				return $plan['save'];
+			}
+
+			return null;
 		} catch ( \WC_Data_Exception $exception ) {
 			return self::get_product_data_exception_error( $exception );
 		}
 	}

 	/**
-	 * Save the product.
+	 * Save the product, then its variations.
 	 *
 	 * @param \WC_Product $subject Changed product.
 	 * @return null|\WP_Error
 	 */
 	protected function save( $subject ) {
-		return self::save_product( $subject, 'woocommerce_product_create_failed' );
+		$error = self::save_product( $subject, 'woocommerce_product_create_failed' );
+		if ( is_wp_error( $error ) ) {
+			return $error;
+		}
+
+		if ( $subject instanceof \WC_Product_Variable ) {
+			return self::save_variations( $subject, $this->variations, 'woocommerce_product_create_failed' );
+		}
+
+		if ( null !== $this->variation_parent ) {
+			self::sync_variable_parent( $this->variation_parent );
+		}
+
+		return null;
 	}

 	/**
@@ -115,12 +208,7 @@ class ProductCreate extends ActionableAbility {
 	 * @return array|\WP_Error
 	 */
 	protected function prepare_response( $subject ) {
-		$product = self::format_saved_product_for_response( $subject );
-		if ( is_wp_error( $product ) ) {
-			return $product;
-		}
-
-		return array( 'product' => $product );
+		return self::format_product_mutation_response( $subject, array_column( $this->variations['save'], 0 ) );
 	}

 	/**
@@ -132,10 +220,17 @@ class ProductCreate extends ActionableAbility {
 	 * @since 10.9.0
 	 */
 	public static function can_create_product( $input = array() ): bool {
-		// Cap is not object-scoped for create.
-		unset( $input );
+		if ( ! wc_rest_check_post_permissions( 'product', 'create' ) ) {
+			return false;
+		}
+
+		if ( ProductType::VARIATION !== ( $input['type'] ?? '' ) ) {
+			return true;
+		}
+
+		$parent_id = (int) ( $input['parent_id'] ?? 0 );

-		return wc_rest_check_post_permissions( 'product', 'create' );
+		return $parent_id > 0 && wc_rest_check_post_permissions( 'product', 'edit', $parent_id );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
index 1d674a673ab..9b7480a8e4a 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
@@ -7,29 +7,71 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\Internal\Abilities\Domain;

-use Automattic\WooCommerce\Abilities\AbilityDefinition;
-use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Abilities\ActionableAbility;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
 use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;

 defined( 'ABSPATH' ) || exit;

 /**
  * Registers the WooCommerce product delete ability.
+ *
+ * The delete runs on the write pipeline of ActionableAbility, so the
+ * validators of the product resource can refuse it, the syncs see the deleted
+ * objects, and a failure rolls everything back. A deleted variable product
+ * deletes its variations, and a deleted variation syncs its parent.
  */
-class ProductDelete extends AbstractDomainAbility implements AbilityDefinition {
+class ProductDelete extends ActionableAbility {

+	use DomainAbilityHelpers;
 	use ProductAbilityTrait;

 	/**
-	 * Get the ability name.
+	 * Ability name.
+	 */
+	public const ABILITY_NAME = 'woocommerce/product-delete';
+
+	/**
+	 * ID of the product the call deletes, kept because a forced delete clears the object's ID.
 	 *
-	 * @return string
+	 * @var int
+	 */
+	private int $product_id = 0;
+
+	/**
+	 * Whether the call deletes for good instead of trashing.
 	 *
-	 * @since 10.9.0
+	 * @var bool
 	 */
-	public static function get_name(): string {
-		return 'woocommerce/product-delete';
-	}
+	private bool $force = false;
+
+	/**
+	 * The parent of a deleted variation, synced after the delete.
+	 *
+	 * @var \WC_Product_Variable|null
+	 */
+	private ?\WC_Product_Variable $parent = null;
+
+	/**
+	 * The product and, for a variable product, its variations, which the call will delete or trash.
+	 *
+	 * @var \WC_Product[]
+	 */
+	private array $deleting = array();
+
+	/**
+	 * The objects the call deleted for good.
+	 *
+	 * @var \WC_Product[]
+	 */
+	private array $deleted = array();
+
+	/**
+	 * The objects the call moved to the trash.
+	 *
+	 * @var \WC_Product[]
+	 */
+	private array $trashed = array();

 	/**
 	 * Get the ability registration arguments.
@@ -46,9 +88,8 @@ class ProductDelete extends AbstractDomainAbility implements AbilityDefinition {
 				'woocommerce'
 			),
 			'category'            => 'woocommerce',
-			'input_schema'        => self::get_input_schema(),
+			'input_schema'        => self::build_input_schema(),
 			'output_schema'       => self::get_delete_output_schema(),
-			'execute_callback'    => array( __CLASS__, 'execute' ),
 			'permission_callback' => array( __CLASS__, 'can_delete_product' ),
 			'meta'                => array(
 				'show_in_rest' => true,
@@ -66,31 +107,43 @@ class ProductDelete extends AbstractDomainAbility implements AbilityDefinition {
 	}

 	/**
-	 * Delete a product.
+	 * The resource the ability changes.
 	 *
-	 * @param array $input Ability input.
-	 * @return array|\WP_Error
+	 * @return string
+	 */
+	public function get_resource(): string {
+		return 'product';
+	}
+
+	/**
+	 * A delete writes no extension fields.
 	 *
-	 * @since 10.9.0
+	 * @return bool
+	 */
+	protected function writes_extension_fields(): bool {
+		return false;
+	}
+
+	/**
+	 * Load the product and check that it can be trashed when the call does not force a delete.
+	 *
+	 * @param array $input Ability input.
+	 * @return \WC_Product|\WP_Error
 	 */
-	public static function execute( array $input ) {
+	protected function load( array $input ) {
+		$this->product_id = 0;
+		$this->force      = ! empty( $input['force'] );
+		$this->parent     = null;
+		$this->deleting   = array();
+		$this->deleted    = array();
+		$this->trashed    = array();
+
 		$product = self::get_product_from_input( $input );

 		if ( is_wp_error( $product ) ) {
 			return $product;
 		}

-		if ( $product->is_type( ProductType::VARIATION ) ) {
-			return new \WP_Error(
-				'woocommerce_product_type_unsupported',
-				__( 'Product type is not supported by this ability.', 'woocommerce' ),
-				array( 'status' => 400 )
-			);
-		}
-
-		$product_id = $product->get_id();
-		$force      = (bool) ( $input['force'] ?? false );
-
 		/**
 		 * Filter whether a product supports trashing in WooCommerce domain abilities.
 		 *
@@ -101,7 +154,7 @@ class ProductDelete extends AbstractDomainAbility implements AbilityDefinition {
 		 */
 		$supports_trash = apply_filters( 'woocommerce_product_object_trashable', EMPTY_TRASH_DAYS > 0, $product );

-		if ( ! $force && ! $supports_trash ) {
+		if ( ! $this->force && ! $supports_trash ) {
 			return new \WP_Error(
 				'woocommerce_trash_not_supported',
 				__( 'Trash is disabled on this site. Pass force: true to permanently delete.', 'woocommerce' ),
@@ -109,7 +162,48 @@ class ProductDelete extends AbstractDomainAbility implements AbilityDefinition {
 			);
 		}

-		$deleted = $product->delete( $force );
+		$this->product_id = $product->get_id();
+
+		return $product;
+	}
+
+	/**
+	 * Nothing changes in memory. The parent of a variation is a secondary
+	 * object, because the delete syncs and saves it.
+	 *
+	 * @param \WC_Product $subject Product to delete.
+	 * @param array       $input   Ability input.
+	 * @return null|array<int, array{0: \WC_Product_Variable, 1: array}>
+	 */
+	protected function change( $subject, array $input ) {
+		unset( $input );
+
+		$this->deleting = array_merge( array( $subject ), $subject instanceof \WC_Product_Variable ? self::get_variations_to_delete( $subject ) : array() );
+
+		$this->parent = self::get_variable_parent( $subject );
+
+		return null === $this->parent ? null : array( array( $this->parent, array() ) );
+	}
+
+	/**
+	 * Delete or trash the product, with the variations of a variable product,
+	 * then sync the parent of a deleted variation.
+	 *
+	 * @param \WC_Product $subject Product to delete.
+	 * @return null|\WP_Error
+	 */
+	protected function save( $subject ) {
+		$force      = $this->force;
+		$product_id = $this->product_id;
+
+		$removed = array_map( static fn( \WC_Product $product ) => clone $product, $this->deleting );
+		if ( $force ) {
+			$this->deleted = $removed;
+		} else {
+			$this->trashed = $removed;
+		}
+
+		$deleted = $subject->delete( $force );

 		if (
 			! $deleted
@@ -123,32 +217,97 @@ class ProductDelete extends AbstractDomainAbility implements AbilityDefinition {
 			);
 		}

+		if ( null !== $this->parent ) {
+			self::sync_variable_parent( $this->parent );
+		}
+
+		return null;
+	}
+
+	/**
+	 * Every variation of a variable product, in any status.
+	 *
+	 * @param \WC_Product_Variable $subject Variable product.
+	 * @return \WC_Product_Variation[]
+	 */
+	private static function get_variations_to_delete( \WC_Product_Variable $subject ): array {
+		$variation_ids = get_posts(
+			array(
+				'post_parent' => $subject->get_id(),
+				'post_type'   => 'product_variation',
+				'post_status' => array( 'any', 'trash', 'auto-draft' ),
+				'fields'      => 'ids',
+				'numberposts' => -1, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_numberposts -- Every variation of one product.
+			)
+		);
+
+		/** @var \WC_Product_Variation[] $variations */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
+		$variations = array_map( 'wc_get_product', $variation_ids );
+
+		return $variations;
+	}
+
+	/**
+	 * The product and, for a variable product, its variations, for the validators.
+	 *
+	 * @return \WC_Product[]
+	 */
+	protected function get_objects_to_delete(): array {
+		return $this->deleting;
+	}
+
+	/**
+	 * The product and, for a variable product, its variations, as they were
+	 * before a forced delete, which clears the ID of the live object.
+	 *
+	 * @return \WC_Product[]
+	 */
+	protected function get_deleted_objects(): array {
+		return $this->deleted;
+	}
+
+	/**
+	 * The product and, for a variable product, its variations, when the call trashed them.
+	 *
+	 * @return \WC_Product[]
+	 */
+	protected function get_trashed_objects(): array {
+		return $this->trashed;
+	}
+
+	/**
+	 * The response of a delete.
+	 *
+	 * @param \WC_Product $subject Deleted product.
+	 * @return array
+	 */
+	protected function prepare_response( $subject ): array {
+		unset( $subject );
+
 		return array(
-			'deleted' => (bool) $deleted,
-			'id'      => $product_id,
+			'deleted' => true,
+			'id'      => $this->product_id,
 		);
 	}

 	/**
-	 * Check product deletion access.
+	 * Check whether the current user can delete the product.
 	 *
-	 * @param mixed $input Ability input.
+	 * @param array $input Ability input.
 	 * @return bool
-	 *
-	 * @since 10.9.0
 	 */
 	public static function can_delete_product( $input = array() ): bool {
-		$product_id = self::get_id_from_input( $input );
+		$product_id = self::get_product_permission_id( self::get_id_from_input( $input ) );

 		return $product_id > 0 && wc_rest_check_post_permissions( 'product', 'delete', $product_id );
 	}

 	/**
-	 * Get the ability input schema.
+	 * Get the input schema.
 	 *
 	 * @return array
 	 */
-	private static function get_input_schema(): array {
+	private static function build_input_schema(): array {
 		return array(
 			'type'                 => 'object',
 			'properties'           => array(
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
index d15e7ddfdea..e86f412d6c2 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
@@ -8,7 +8,6 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Internal\Abilities\Domain;

 use Automattic\WooCommerce\Abilities\ActionableAbility;
-use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
 use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;

@@ -27,6 +26,16 @@ class ProductUpdate extends ActionableAbility {
 	 */
 	public const ABILITY_NAME = 'woocommerce/product-update';

+	/**
+	 * The variations that change() planned, saved after their parent by save().
+	 *
+	 * @var array{save: array<int, array{0: \WC_Product_Variation, 1: array}>, delete: \WC_Product_Variation[]}
+	 */
+	private array $variations = array(
+		'save'   => array(),
+		'delete' => array(),
+	);
+
 	/**
 	 * Get the ability registration arguments.
 	 *
@@ -43,7 +52,7 @@ class ProductUpdate extends ActionableAbility {
 			),
 			'category'            => 'woocommerce',
 			'input_schema'        => self::build_input_schema(),
-			'output_schema'       => self::get_entity_output_schema( 'product', self::get_product_output_schema() ),
+			'output_schema'       => self::get_product_mutation_output_schema(),
 			'permission_callback' => array( __CLASS__, 'can_update_product' ),
 			'meta'                => array(
 				'show_in_rest' => true,
@@ -76,16 +85,19 @@ class ProductUpdate extends ActionableAbility {
 	 * @return \WC_Product|\WP_Error
 	 */
 	protected function load( array $input ) {
+		$this->variations = array(
+			'save'   => array(),
+			'delete' => array(),
+		);
+		$this->deleted    = array();
+		$this->parent     = null;
+
 		$product = self::get_product_from_input( $input );

 		if ( is_wp_error( $product ) ) {
 			return $product;
 		}

-		if ( $product instanceof \WC_Product_Variation ) {
-			return self::get_unsupported_product_type_error();
-		}
-
 		if ( empty( array_diff( array_keys( $input ), array( 'id' ) ) ) ) {
 			return new \WP_Error(
 				'woocommerce_product_update_no_fields',
@@ -94,27 +106,21 @@ class ProductUpdate extends ActionableAbility {
 			);
 		}

-		if (
-			isset( $input['status'] )
-			&& in_array(
-				sanitize_key( $input['status'] ),
-				array( ProductStatus::PUBLISH, ProductStatus::FUTURE, ProductStatus::PRIVATE ),
-				true
-			)
-			&& sanitize_key( $input['status'] ) !== $product->get_status()
-			&& ! self::current_user_can_publish_products()
-		) {
-			return new \WP_Error(
-				'woocommerce_product_publish_forbidden',
-				__( 'You are not allowed to publish products.', 'woocommerce' ),
-				array( 'status' => rest_authorization_required_code() )
-			);
+		$error = self::check_publish_permission( $product, $input );
+		if ( is_wp_error( $error ) ) {
+			return $error;
 		}

-		if ( isset( $input['type'] ) ) {
-			$current_config = self::get_product_config_for_product( $product );
-			if ( is_wp_error( $current_config ) ) {
-				return $current_config;
+		if ( isset( $input['type'] ) && (string) $input['type'] !== $product->get_type() ) {
+			$is_variable = static function ( $class_or_object ): bool {
+				return is_a( $class_or_object, \WC_Product_Variable::class, true ) || is_a( $class_or_object, \WC_Product_Variation::class, true );
+			};
+			if ( $is_variable( \WC_Product_Factory::get_product_classname( $product->get_id(), (string) $input['type'] ) ) || $is_variable( $product ) ) {
+				return new \WP_Error(
+					'woocommerce_product_type_unsupported',
+					__( 'A product cannot change into or out of a variable product or a variation through this ability. WooCommerce would delete its variations.', 'woocommerce' ),
+					array( 'status' => 400 )
+				);
 			}
 			return self::get_product_object_for_type( $input['type'], $product->get_id() );
 		}
@@ -127,7 +133,7 @@ class ProductUpdate extends ActionableAbility {
 	 *
 	 * @param \WC_Product $subject Product.
 	 * @param array       $input   Ability input.
-	 * @return null|\WP_Error
+	 * @return null|\WP_Error|array<int, array{0: \WC_Product, 1: array}> The variations that the input names, each with the `extensions` input of its `variations` item. For a variation, its parent.
 	 */
 	protected function change( $subject, array $input ) {
 		$product_config = self::get_product_config_for_product( $subject );
@@ -141,20 +147,83 @@ class ProductUpdate extends ActionableAbility {
 				self::apply_product_type_config( $subject, $product_config );
 			}

-			return self::set_product_props_from_input( $subject, $input, $product_config );
+			$error = self::set_product_props_from_input( $subject, $input, $product_config );
+			if ( is_wp_error( $error ) ) {
+				return $error;
+			}
+
+			if ( $subject instanceof \WC_Product_Variable && isset( $input['variations'] ) ) {
+				$plan = self::plan_variations( $subject, $input['variations'] );
+				if ( is_wp_error( $plan ) ) {
+					return $plan;
+				}
+				$this->variations = $plan;
+				return $plan['save'];
+			}
+
+			$this->parent = self::get_variable_parent( $subject );
+
+			return null === $this->parent ? null : array( array( $this->parent, array() ) );
 		} catch ( \WC_Data_Exception $exception ) {
 			return self::get_product_data_exception_error( $exception );
 		}
 	}

 	/**
-	 * Save the product.
+	 * The variations that the `variations` input marked for deletion, as they
+	 * were before the delete.
+	 *
+	 * @var \WC_Product_Variation[]
+	 */
+	private array $deleted = array();
+
+	/**
+	 * The parent of an updated variation, synced after the save.
+	 *
+	 * @var \WC_Product_Variable|null
+	 */
+	private ?\WC_Product_Variable $parent = null;
+
+	/**
+	 * The variations that the `variations` input marked for deletion.
+	 *
+	 * @return \WC_Product_Variation[]
+	 */
+	protected function get_deleted_objects(): array {
+		return $this->deleted;
+	}
+
+	/**
+	 * The variations that the `variations` input marked for deletion, for the validators.
+	 *
+	 * @return \WC_Product_Variation[]
+	 */
+	protected function get_objects_to_delete(): array {
+		return $this->variations['delete'];
+	}
+
+	/**
+	 * Save the product, then the variations that the input names.
 	 *
 	 * @param \WC_Product $subject Changed product.
 	 * @return null|\WP_Error
 	 */
 	protected function save( $subject ) {
-		return self::save_product( $subject, 'woocommerce_product_update_failed' );
+		$error = self::save_product( $subject, 'woocommerce_product_update_failed' );
+		if ( is_wp_error( $error ) ) {
+			return $error;
+		}
+
+		if ( $subject instanceof \WC_Product_Variable ) {
+			$this->deleted = array_map( static fn( \WC_Product_Variation $variation ) => clone $variation, $this->variations['delete'] );
+			return self::save_variations( $subject, $this->variations, 'woocommerce_product_update_failed' );
+		}
+
+		if ( null !== $this->parent ) {
+			self::sync_variable_parent( $this->parent );
+		}
+
+		return null;
 	}

 	/**
@@ -164,12 +233,7 @@ class ProductUpdate extends ActionableAbility {
 	 * @return array|\WP_Error
 	 */
 	protected function prepare_response( $subject ) {
-		$product = self::format_saved_product_for_response( $subject );
-		if ( is_wp_error( $product ) ) {
-			return $product;
-		}
-
-		return array( 'product' => $product );
+		return self::format_product_mutation_response( $subject, array_column( $this->variations['save'], 0 ) );
 	}

 	/**
@@ -181,19 +245,12 @@ class ProductUpdate extends ActionableAbility {
 	 * @since 10.9.0
 	 */
 	public static function can_update_product( $input = array() ): bool {
-		$product_id = self::get_id_from_input( $input );
+		$product_id = self::get_product_permission_id( self::get_id_from_input( $input ) );
+		$deletes    = is_array( $input ) && is_array( $input['variations'] ?? null ) && ! empty( array_filter( array_column( $input['variations'], 'delete' ) ) );

-		return $product_id > 0 && wc_rest_check_post_permissions( 'product', 'edit', $product_id );
-	}
-
-	/**
-	 * Check whether the current user can publish products.
-	 *
-	 * @return bool
-	 */
-	private static function current_user_can_publish_products(): bool {
-		// phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce registers the publish_products capability.
-		return current_user_can( 'publish_products' );
+		return $product_id > 0
+			&& wc_rest_check_post_permissions( 'product', 'edit', $product_id )
+			&& ( ! $deletes || wc_rest_check_post_permissions( 'product', 'delete', $product_id ) );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
index d38e284805f..f50c7e30b8b 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductsQuery.php
@@ -22,6 +22,11 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {

 	use ProductAbilityTrait;

+	/**
+	 * Query var that limits a query to the children of these product types.
+	 */
+	private const PARENT_TYPES_QUERY_VAR = 'woocommerce_ability_parent_types';
+
 	/**
 	 * Taxonomies of the term filters, keyed by input property.
 	 */
@@ -100,14 +105,6 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 				return $product;
 			}

-			if ( $product->is_type( ProductType::VARIATION ) ) {
-				return new \WP_Error(
-					'woocommerce_product_type_unsupported',
-					__( 'Product type is not supported by this ability.', 'woocommerce' ),
-					array( 'status' => 400 )
-				);
-			}
-
 			$products = self::format_products_for_response( array( $product ) );
 			if ( is_wp_error( $products ) ) {
 				return $products;
@@ -138,6 +135,17 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {

 		if ( isset( $input['type'] ) ) {
 			$args['type'] = $input['type'];
+			$parent_types = self::get_variation_subclass_parent_types()[ $input['type'] ] ?? array();
+
+			if ( ! empty( $parent_types ) ) {
+				$args['type']                         = ProductType::VARIATION;
+				$args[ self::PARENT_TYPES_QUERY_VAR ] = $parent_types;
+			}
+		}
+
+		if ( ! empty( $input['parent_id'] ) ) {
+			$args['parent'] = (int) $input['parent_id'];
+			$args['type']   = $args['type'] ?? ProductType::VARIATION;
 		}

 		foreach ( array( 'virtual', 'downloadable' ) as $field ) {
@@ -167,7 +175,10 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {

 		$args[ AbilitiesLoader::EXTENSION_QUERY_VAR ] = $extension_args;

-		$results  = wc_get_products( $args );
+		add_filter( 'posts_where', array( __CLASS__, 'limit_to_parent_types' ), 10, 2 );
+		$results = wc_get_products( $args );
+		remove_filter( 'posts_where', array( __CLASS__, 'limit_to_parent_types' ) );
+
 		$products = is_object( $results ) && isset( $results->products ) ? $results->products : array();
 		$pages    = is_object( $results ) && isset( $results->max_num_pages ) ? (int) $results->max_num_pages : ( count( $products ) > 0 ? 1 : 0 );

@@ -189,6 +200,45 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 		);
 	}

+	/**
+	 * Limit a query to the children of the product types in its query var.
+	 *
+	 * The limit is a subquery, so paging counts the right rows and no parent IDs
+	 * load into memory. Other queries do not have the query var, so a query that a
+	 * hook runs inside this ability's query stays the same.
+	 *
+	 * @internal
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string    $where WHERE clause.
+	 * @param \WP_Query $query Query.
+	 * @return string
+	 */
+	public static function limit_to_parent_types( $where, $query ) {
+		global $wpdb;
+
+		$parent_types = $query->get( self::PARENT_TYPES_QUERY_VAR );
+
+		if ( empty( $parent_types ) ) {
+			return $where;
+		}
+
+		$placeholders = implode( ', ', array_fill( 0, count( $parent_types ), '%s' ) );
+
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+		return $where . $wpdb->prepare(
+			" AND {$wpdb->posts}.post_parent IN (
+				SELECT tr.object_id FROM {$wpdb->term_relationships} tr
+				INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
+				INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
+				WHERE tt.taxonomy = 'product_type' AND t.slug IN ( {$placeholders} )
+			)",
+			$parent_types
+		);
+		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+	}
+
 	/**
 	 * Check product read access.
 	 *
@@ -198,7 +248,7 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 	 * @since 10.9.0
 	 */
 	public static function can_query_products( $input = array() ): bool {
-		$product_id = self::get_id_from_input( $input );
+		$product_id = self::get_product_permission_id( self::get_id_from_input( $input ) );

 		return wc_rest_check_post_permissions( 'product', 'read', $product_id );
 	}
@@ -225,7 +275,12 @@ class ProductsQuery extends AbstractDomainAbility implements AbilityDefinition {
 					'type' => 'string',
 					'enum' => self::get_product_query_status_slugs(),
 				),
-				'type'         => self::get_product_type_schema( array_keys( wc_get_product_types() ) ),
+				'type'         => self::get_product_type_schema( array_values( array_unique( array_merge( array_keys( self::get_supported_product_type_configs() ), array_keys( self::get_variation_subclass_parent_types() ) ) ) ) ),
+				'parent_id'    => array(
+					'type'        => 'integer',
+					'description' => __( 'Limit results to the variations of this variable product.', 'woocommerce' ),
+					'minimum'     => 1,
+				),
 				'virtual'      => array(
 					'type'        => 'boolean',
 					'description' => __( 'Limit results to products that are not shipped (true) or that are shipped (false).', 'woocommerce' ),
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index d27b9fe2768..ea9e6f7cbec 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -50,7 +50,7 @@ trait ProductAbilityTrait {
 	 * @return array
 	 */
 	private static function get_product_mutation_input_schema( bool $creating ): array {
-		$configs     = self::get_supported_product_type_configs();
+		$configs     = self::get_supported_product_type_configs( $creating );
 		$fields      = array_merge( ...array_values( array_column( $configs, 'fields' ) ) );
 		$type_schema = self::get_product_type_schema( array_keys( $configs ) );
 		$lines       = array();
@@ -78,10 +78,154 @@ trait ProductAbilityTrait {
 			'properties'           => array_merge(
 				$properties,
 				array( 'type' => $type_schema ),
-				self::add_product_type_notes( self::get_product_mutation_field_schemas_for_fields( $fields ), $configs )
+				self::add_product_type_notes(
+					array_merge(
+						self::get_product_mutation_field_schemas_for_fields( $fields, false, $creating ),
+						$creating && in_array( 'attributes', $fields, true ) ? array( 'attributes' => self::get_product_create_attributes_schema() ) : array()
+					),
+					$configs
+				)
 			),
-			'required'             => array( $creating ? 'name' : 'id' ),
 			'additionalProperties' => false,
+		) + ( $creating ? array() : array( 'required' => array( 'id' ) ) );
+	}
+
+	/**
+	 * Get the schema of the `attributes` input of a create. A variable product
+	 * takes attributes with their options, and a variation takes the option it
+	 * picks for each attribute. The ability checks the shape against the type.
+	 * An update takes the parent shape only: a variation's options change
+	 * through the `variations` input of its parent.
+	 *
+	 * @return array
+	 */
+	private static function get_product_create_attributes_schema(): array {
+		return array(
+			'type'        => 'array',
+			'description' => __( 'On a variable product, the attributes with their options. On a variation, the option it picks for each attribute of its parent.', 'woocommerce' ),
+			'items'       => array(
+				'anyOf' => array(
+					self::get_product_attributes_schema( false )['items'],
+					self::get_product_attributes_schema( true )['items'],
+				),
+			),
+		);
+	}
+
+	/**
+	 * Get the schema of the `attributes` input and output.
+	 *
+	 * A parent product lists each attribute with its options. A variation lists
+	 * the one option it picks for each attribute of its parent. An attribute
+	 * with `global: true` or an `id` is a global attribute.
+	 *
+	 * @param bool $for_variation Whether the schema is for a variation.
+	 * @return array
+	 */
+	private static function get_product_attributes_schema( bool $for_variation ): array {
+		if ( $for_variation ) {
+			return array(
+				'type'        => 'array',
+				'description' => __( 'The option this variation picks for each attribute of its parent, by attribute name or global attribute id. For a global attribute the option is the term slug. An attribute with no option matches any option of the attribute.', 'woocommerce' ),
+				'items'       => array(
+					'type'                 => 'object',
+					'properties'           => array(
+						'id'     => self::get_global_attribute_id_schema(),
+						'name'   => array( 'type' => 'string' ),
+						'option' => array( 'type' => 'string' ),
+					),
+					'additionalProperties' => false,
+				),
+			);
+		}
+
+		return array(
+			'type'        => 'array',
+			'description' => __( 'Attributes of the product, such as Size with the options S, M and L. Each variation picks one option of every attribute that is for variations. A name is an attribute of this product only, unless global is true or an id names a global attribute. The options of a global attribute are term names or slugs, and missing terms are created.', 'woocommerce' ),
+			'items'       => array(
+				'type'                 => 'object',
+				'properties'           => array(
+					'id'        => self::get_global_attribute_id_schema(),
+					'name'      => array( 'type' => 'string' ),
+					'global'    => array(
+						'type'        => 'boolean',
+						'description' => __( 'Use the global attribute with this name. The input is refused when there is none. Defaults to false.', 'woocommerce' ),
+					),
+					'options'   => array(
+						'type'     => 'array',
+						'items'    => array( 'type' => 'string' ),
+						'minItems' => 1,
+					),
+					'visible'   => array(
+						'type'        => 'boolean',
+						'description' => __( 'Whether the attribute shows on the product page. Defaults to true.', 'woocommerce' ),
+					),
+					'variation' => array(
+						'type'        => 'boolean',
+						'description' => __( 'Whether the variations pick an option of this attribute. Defaults to true. An attribute that is not for variations only shows on the product page.', 'woocommerce' ),
+					),
+				),
+				'required'             => array( 'options' ),
+				'additionalProperties' => false,
+			),
+		);
+	}
+
+	/**
+	 * Get the schema of the `id` of an attribute.
+	 *
+	 * @return array
+	 */
+	private static function get_global_attribute_id_schema(): array {
+		return array(
+			'type'        => 'integer',
+			'description' => __( 'ID of a global attribute. 0 or absent for an attribute of this product only.', 'woocommerce' ),
+			'minimum'     => 0,
+		);
+	}
+
+	/**
+	 * Get the schema of the `variations` input of a variable product. Each
+	 * item takes the fields of a variation and, when the feature is on, the
+	 * `extensions` input of the product resource. On an update an item with an
+	 * `id` changes that variation, an item without one creates a variation,
+	 * and `delete` removes one. Variations that the input does not name stay
+	 * as they are.
+	 *
+	 * @param bool $creating Whether the schema is for a create.
+	 * @return array
+	 */
+	private static function get_product_variations_input_schema( bool $creating ): array {
+		$properties = array();
+		if ( ! $creating ) {
+			$properties['id']     = array(
+				'type'        => 'integer',
+				'description' => __( 'ID of the variation to change. Leave it out to create a variation.', 'woocommerce' ),
+				'minimum'     => 1,
+			);
+			$properties['delete'] = array(
+				'type'        => 'boolean',
+				'description' => __( 'Delete the variation with this id.', 'woocommerce' ),
+			);
+		}
+		$properties = array_merge( $properties, self::get_product_mutation_field_schemas_for_fields( self::get_variation_config()['fields'], true ) );
+
+		if ( ExtensibleAbilities::is_enabled() ) {
+			$properties['extensions'] = ExtensibleAbilities::get_input_schema( 'product', array( self::class, 'field_applies_to_variations' ) );
+		}
+
+		return array(
+			'type'        => 'array',
+			'description' => $creating
+				? __( 'Variations to create with the product. Each one picks one option of every attribute, and no two pick the same options.', 'woocommerce' )
+				: __( 'Variations to create, change or delete. An item with an id changes or deletes that variation, an item without one creates a variation. Other variations stay as they are.', 'woocommerce' ),
+			'maxItems'    => 100,
+			'items'       => array(
+				'type'                 => 'object',
+				'properties'           => $properties,
+				'required'             => $creating ? array( 'attributes' ) : array(),
+				'additionalProperties' => false,
+			),
 		);
 	}

@@ -127,11 +271,22 @@ trait ProductAbilityTrait {
 	/**
 	 * Get product mutation field schemas keyed by input field.
 	 *
+	 * @param bool $for_variation Whether the `attributes` schema is the one of a variation.
 	 * @return array<string, array>
 	 */
-	private static function get_product_mutation_field_schemas(): array {
+	private static function get_product_mutation_field_schemas( bool $for_variation = false ): array {
 		return array(
-			'name'              => array( 'type' => 'string' ),
+			'name'              => array(
+				'type'        => 'string',
+				'description' => __( 'Required, except on a variation, which takes its name from its parent.', 'woocommerce' ),
+			),
+			'parent_id'         => array(
+				'type'        => 'integer',
+				'description' => __( 'ID of the variable product this variation belongs to.', 'woocommerce' ),
+				'minimum'     => 1,
+			),
+			'attributes'        => self::get_product_attributes_schema( $for_variation ),
+			'variations'        => array( 'type' => 'array' ),
 			'sku'               => array( 'type' => 'string' ),
 			'regular_price'     => array(
 				'type'        => 'string',
@@ -153,7 +308,7 @@ trait ProductAbilityTrait {
 			),
 			'status'            => array(
 				'type' => 'string',
-				'enum' => self::get_product_mutation_status_slugs(),
+				'enum' => $for_variation ? self::get_variation_status_slugs() : self::get_product_mutation_status_slugs(),
 			),
 			'virtual'           => array(
 				'type'        => 'boolean',
@@ -195,16 +350,18 @@ trait ProductAbilityTrait {
 	/**
 	 * Get product mutation field schemas for a set of fields.
 	 *
-	 * @param array<int, string> $fields Field names.
+	 * @param array<int, string> $fields        Field names.
+	 * @param bool               $for_variation Whether the `attributes` schema is the one of a variation.
+	 * @param bool               $creating      Whether the `variations` schema is the one of a create.
 	 * @return array<string, array>
 	 */
-	private static function get_product_mutation_field_schemas_for_fields( array $fields ): array {
-		$schemas = self::get_product_mutation_field_schemas();
+	private static function get_product_mutation_field_schemas_for_fields( array $fields, bool $for_variation = false, bool $creating = false ): array {
+		$schemas = self::get_product_mutation_field_schemas( $for_variation );
 		$result  = array();

 		foreach ( $schemas as $field => $schema ) {
 			if ( in_array( $field, $fields, true ) ) {
-				$result[ $field ] = $schema;
+				$result[ $field ] = 'variations' === $field ? self::get_product_variations_input_schema( $creating ) : $schema;
 			}
 		}

@@ -214,13 +371,14 @@ trait ProductAbilityTrait {
 	/**
 	 * Get the configuration of every supported product type, keyed by type.
 	 *
+	 * @param bool $creating Whether the fields are for a create.
 	 * @return array<string, array{fields: array<int, string>, product_props: array<string, mixed>}>
 	 */
-	private static function get_supported_product_type_configs(): array {
+	private static function get_supported_product_type_configs( bool $creating = false ): array {
 		$configs = array();

-		foreach ( array_keys( wc_get_product_types() ) as $product_type ) {
-			$config = self::get_product_class_config( \WC_Product_Factory::get_product_classname( 0, $product_type ), $product_type );
+		foreach ( array_merge( array_keys( wc_get_product_types() ), array( ProductType::VARIATION ) ) as $product_type ) {
+			$config = self::get_product_class_config( \WC_Product_Factory::get_product_classname( 0, $product_type ), $product_type, $creating );

 			if ( ! is_wp_error( $config ) ) {
 				$configs[ $product_type ] = $config;
@@ -230,12 +388,29 @@ trait ProductAbilityTrait {
 		return $configs;
 	}

+	/**
+	 * The fields of a variation, as an item of the `variations` input of its
+	 * parent. As a product of its own it also takes `parent_id` on create.
+	 *
+	 * @return array{fields: array<int, string>, product_props: array<string, mixed>}
+	 */
+	protected static function get_variation_config(): array {
+		return array(
+			'fields'        => array( 'sku', 'description', 'status', 'virtual', 'downloadable', 'attributes', 'regular_price', 'sale_price', 'manage_stock', 'stock_quantity', 'stock_status' ),
+			'product_props' => array(),
+		);
+	}
+
 	/**
 	 * Get the configuration of a product class.
 	 *
 	 * The product class decides which Core fields apply. A class that extends
-	 * a Core class gets the fields of that class. The owner of a type changes
-	 * the list with the `woocommerce_ability_product_type_fields` filter.
+	 * a Core class gets the fields of that class. A variable type accepts
+	 * `attributes` and `variations`. A variation accepts `parent_id` and
+	 * `attributes` on create only: on update its options change through the
+	 * `variations` input of its parent. An item of that input takes the create
+	 * fields without `parent_id`. The owner of a type changes the list with the
+	 * `woocommerce_ability_product_type_fields` filter.
 	 *
 	 * `WC_Product_Factory` returns `WC_Product_Simple` itself for a type that
 	 * has no class. Such a type is refused. A class that extends
@@ -243,16 +418,10 @@ trait ProductAbilityTrait {
 	 *
 	 * @param string $classname    Product class.
 	 * @param string $product_type Product type.
+	 * @param bool   $creating     Whether the fields are for a create.
 	 * @return array{fields: array<int, string>, product_props: array<string, mixed>}|\WP_Error
 	 */
-	private static function get_product_class_config( string $classname, string $product_type ) {
-		if (
-			is_a( $classname, \WC_Product_Variable::class, true )
-			|| is_a( $classname, \WC_Product_Variation::class, true )
-		) {
-			return self::get_unsupported_product_type_error();
-		}
-
+	private static function get_product_class_config( string $classname, string $product_type, bool $creating = false ) {
 		if ( \WC_Product_Simple::class === $classname && ProductType::SIMPLE !== $product_type ) {
 			return self::get_invalid_product_type_error();
 		}
@@ -260,8 +429,18 @@ trait ProductAbilityTrait {
 		$fields        = self::get_common_product_mutation_fields();
 		$product_props = array();
 		$price_fields  = array( 'regular_price', 'sale_price' );
-
-		if ( is_a( $classname, \WC_Product_External::class, true ) ) {
+		$stock_fields  = array( 'manage_stock', 'stock_quantity', 'stock_status' );
+
+		if ( is_a( $classname, \WC_Product_Variation::class, true ) ) {
+			$fields = self::get_variation_config()['fields'];
+			if ( $creating ) {
+				$fields[] = 'parent_id';
+			} else {
+				$fields = array_values( array_diff( $fields, array( 'attributes' ) ) );
+			}
+		} elseif ( is_a( $classname, \WC_Product_Variable::class, true ) ) {
+			$fields = array_merge( $fields, array( 'attributes', 'variations' ) );
+		} elseif ( is_a( $classname, \WC_Product_External::class, true ) ) {
 			$fields        = array_merge( $fields, $price_fields, array( 'external_url', 'button_text' ) );
 			$product_props = array(
 				'virtual'        => false,
@@ -280,7 +459,7 @@ trait ProductAbilityTrait {
 			$fields = array_merge( $fields, array( 'virtual', 'downloadable' ) );

 			if ( is_a( $classname, \WC_Product_Simple::class, true ) ) {
-				$fields = array_merge( $fields, $price_fields, array( 'manage_stock', 'stock_quantity', 'stock_status' ) );
+				$fields = array_merge( $fields, $price_fields, $stock_fields );
 			}
 		}

@@ -351,11 +530,12 @@ trait ProductAbilityTrait {
 	/**
 	 * Get the configuration for a product object, from its class.
 	 *
-	 * @param \WC_Product $product Product object.
+	 * @param \WC_Product $product  Product object.
+	 * @param bool        $creating Whether the fields are for a create.
 	 * @return array|\WP_Error
 	 */
-	protected static function get_product_config_for_product( \WC_Product $product ) {
-		return self::get_product_class_config( get_class( $product ), $product->get_type() );
+	protected static function get_product_config_for_product( \WC_Product $product, bool $creating = false ) {
+		return self::get_product_class_config( get_class( $product ), $product->get_type(), $creating );
 	}

 	/**
@@ -381,6 +561,16 @@ trait ProductAbilityTrait {
 		);
 	}

+	/**
+	 * The statuses a variation can have. WooCommerce counts a variation as a
+	 * child of its parent only when it is published or private.
+	 *
+	 * @return array<int, string>
+	 */
+	protected static function get_variation_status_slugs(): array {
+		return array( ProductStatus::PUBLISH, ProductStatus::PRIVATE );
+	}
+
 	/**
 	 * Allowed product post-status slugs for product output.
 	 *
@@ -462,6 +652,7 @@ trait ProductAbilityTrait {
 		}

 		$setters = array(
+			'parent_id'         => 'set_parent_id',
 			'name'              => 'set_name',
 			'sku'               => 'set_sku',
 			'regular_price'     => 'set_regular_price',
@@ -477,6 +668,7 @@ trait ProductAbilityTrait {
 			'external_url'      => 'set_product_url',
 			'button_text'       => 'set_button_text',
 			'grouped_products'  => 'set_children',
+			'attributes'        => 'set_attributes',
 		);

 		foreach ( $setters as $field => $setter ) {
@@ -488,7 +680,9 @@ trait ProductAbilityTrait {
 				continue;
 			}

-			$prepared_value = self::prepare_product_field_value( $field, $input[ $field ] );
+			$prepared_value = 'attributes' === $field
+				? self::prepare_product_attributes( $product, $input[ $field ] )
+				: self::prepare_product_field_value( $field, $input[ $field ] );
 			if ( is_wp_error( $prepared_value ) ) {
 				return $prepared_value;
 			}
@@ -499,6 +693,573 @@ trait ProductAbilityTrait {
 		return null;
 	}

+	/**
+	 * Refuse a status change to a published status when the current user
+	 * cannot publish products. Keeping the current status is allowed.
+	 *
+	 * @param \WC_Product|null $product Saved product or variation, or null for a new one.
+	 * @param array            $input   Input that may carry a `status`.
+	 * @return null|\WP_Error
+	 */
+	protected static function check_publish_permission( ?\WC_Product $product, array $input ) {
+		$status = isset( $input['status'] ) ? sanitize_key( (string) $input['status'] ) : '';
+		if (
+			! in_array( $status, array( ProductStatus::PUBLISH, ProductStatus::FUTURE, ProductStatus::PRIVATE ), true )
+			|| ( $product && $product->get_status() === $status )
+			// phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce registers the publish_products capability.
+			|| current_user_can( 'publish_products' )
+		) {
+			return null;
+		}
+
+		return new \WP_Error(
+			'woocommerce_product_publish_forbidden',
+			__( 'You are not allowed to publish products.', 'woocommerce' ),
+			array( 'status' => rest_authorization_required_code() )
+		);
+	}
+
+	/**
+	 * The product whose permissions decide access to a product ID. A variation
+	 * follows its parent: a published variation of a draft parent is as hidden
+	 * as the parent, and whoever may edit the parent may edit its variations.
+	 *
+	 * @param int $product_id Product or variation ID.
+	 * @return int The parent ID for a variation, else the ID itself.
+	 */
+	protected static function get_product_permission_id( int $product_id ): int {
+		if ( $product_id > 0 && 'product_variation' === get_post_type( $product_id ) ) {
+			$parent_id = wp_get_post_parent_id( $product_id );
+			return $parent_id > 0 ? $parent_id : $product_id;
+		}
+
+		return $product_id;
+	}
+
+	/**
+	 * The variable parent of a saved variation. The call syncs and saves it
+	 * after the variation, so it is a secondary object of the call.
+	 *
+	 * @param \WC_Product $product Product.
+	 * @return \WC_Product_Variable|null Null when the product is not a variation of a variable product.
+	 */
+	protected static function get_variable_parent( \WC_Product $product ): ?\WC_Product_Variable {
+		$parent = $product instanceof \WC_Product_Variation && $product->get_parent_id() > 0 ? wc_get_product( $product->get_parent_id() ) : null;
+
+		return $parent instanceof \WC_Product_Variable ? $parent : null;
+	}
+
+	/**
+	 * Build a new variation of a parent, in memory.
+	 *
+	 * The product factory picks the class of a saved variation from its post,
+	 * and a new variation has no post yet. The owner of a variable type names
+	 * the type of its variations here, so the fields and validators of that type
+	 * run before the save.
+	 *
+	 * @param \WC_Product_Variable $parent_product Parent, saved or not.
+	 * @return \WC_Product_Variation|\WP_Error
+	 */
+	protected static function get_new_variation( \WC_Product_Variable $parent_product ) {
+		/**
+		 * Filter the product type of a new variation of a variable product.
+		 *
+		 * The product factory must build a `WC_Product_Variation` subclass for
+		 * the type, by its class name or through the `woocommerce_product_class`
+		 * filter. The subclass must have a data store and answer true to
+		 * `is_type( 'variation' )`, as WooCommerce expects of any variation.
+		 *
+		 * @since 11.4.0
+		 *
+		 * @param string               $variation_type Product type. Default `variation`.
+		 * @param \WC_Product_Variable $parent_product Parent, saved or not.
+		 */
+		$variation_type = (string) apply_filters( 'woocommerce_ability_variation_product_type', ProductType::VARIATION, $parent_product );
+		$variation      = self::get_product_object_for_type( $variation_type );
+
+		if ( is_wp_error( $variation ) || $variation instanceof \WC_Product_Variation ) {
+			return $variation;
+		}
+
+		return self::get_invalid_product_type_error();
+	}
+
+	/**
+	 * Get the variable product types behind each variation subclass.
+	 *
+	 * A variation has no product type term, so only the type of its parent tells
+	 * which subclass it is. `variation` itself is not listed, because it covers
+	 * every variation.
+	 *
+	 * @return array<string, array<int, string>> Parent types, keyed by variation type.
+	 */
+	protected static function get_variation_subclass_parent_types(): array {
+		$parent_types = array();
+
+		foreach ( array_keys( wc_get_product_types() ) as $product_type ) {
+			$product_type = (string) $product_type;
+			$parent       = is_a( \WC_Product_Factory::get_product_classname( 0, $product_type ), \WC_Product_Variable::class, true ) ? self::get_product_object_for_type( $product_type ) : null;
+			$variation    = $parent instanceof \WC_Product_Variable ? self::get_new_variation( $parent ) : null;
+
+			if ( $variation instanceof \WC_Product_Variation && ProductType::VARIATION !== $variation->get_type() ) {
+				$parent_types[ $variation->get_type() ][] = $product_type;
+			}
+		}
+
+		return $parent_types;
+	}
+
+	/**
+	 * Build the variations that a `variations` input names, in memory. An item
+	 * with an `id` loads that variation of the parent and changes it, or marks
+	 * it for deletion. An item without one builds a new variation. Each saved
+	 * variation must pick one option of every attribute of the parent, and no
+	 * two variations of the parent may pick the same options or carry the same
+	 * SKU. The database SKU check runs on each save and does not see the other
+	 * new variations, so the call checks them here.
+	 *
+	 * @param \WC_Product_Variable $parent_product Parent, with its attributes set. Saved or not.
+	 * @param array                $items          Items of the `variations` input.
+	 * @return array{save: array<int, array{0: \WC_Product_Variation, 1: array}>, delete: \WC_Product_Variation[]}|\WP_Error A variation to save comes with the `extensions` input of its item.
+	 * @throws \WC_Data_Exception When a variation setter rejects a value.
+	 */
+	protected static function plan_variations( \WC_Product_Variable $parent_product, array $items ) {
+		$plan  = array(
+			'save'   => array(),
+			'delete' => array(),
+		);
+		$named = array();
+
+		foreach ( array_values( $items ) as $index => $item ) {
+			$position = $index + 1;
+			$id       = (int) ( $item['id'] ?? 0 );
+
+			if ( array_key_exists( 'delete', $item ) && ( $id <= 0 || count( $item ) > 2 ) ) {
+				return new \WP_Error(
+					'woocommerce_product_variation_invalid',
+					/* translators: %d: variation position. */
+					sprintf( __( 'Variation %d: an item with delete takes an id and nothing else.', 'woocommerce' ), $position ),
+					array( 'status' => 400 )
+				);
+			}
+
+			if ( $id > 0 ) {
+				$variation = wc_get_product( $id );
+				if ( ! $variation instanceof \WC_Product_Variation || $variation->get_parent_id() !== $parent_product->get_id() ) {
+					return new \WP_Error(
+						'woocommerce_product_variation_not_found',
+						/* translators: %d: variation position. */
+						sprintf( __( 'Variation %d is not a variation of this product.', 'woocommerce' ), $position ),
+						array( 'status' => 404 )
+					);
+				}
+				if ( isset( $named[ $id ] ) ) {
+					return new \WP_Error(
+						'woocommerce_product_variation_invalid',
+						/* translators: %d: variation position. */
+						sprintf( __( 'Variation %d names a variation that an earlier item already names.', 'woocommerce' ), $position ),
+						array( 'status' => 400 )
+					);
+				}
+				$named[ $id ] = true;
+				if ( ! empty( $item['delete'] ) ) {
+					$plan['delete'][] = $variation;
+					continue;
+				}
+				$error = self::check_publish_permission( $variation, $item );
+				if ( is_wp_error( $error ) ) {
+					return $error;
+				}
+			} else {
+				$variation = self::get_new_variation( $parent_product );
+				if ( is_wp_error( $variation ) ) {
+					return $variation;
+				}
+				if ( $parent_product->get_id() > 0 ) {
+					$variation->set_parent_id( $parent_product->get_id() );
+				}
+				if ( ! $variation->get_status( 'edit' ) ) {
+					$variation->set_status( ProductStatus::PUBLISH );
+				}
+				$error = self::check_publish_permission( null, array( 'status' => $item['status'] ?? $variation->get_status( 'edit' ) ) );
+				if ( is_wp_error( $error ) ) {
+					return $error;
+				}
+			}
+
+			$config = self::get_product_class_config( get_class( $variation ), $variation->get_type(), true );
+			if ( is_wp_error( $config ) ) {
+				return $config;
+			}
+			$config['fields'] = array_values( array_diff( $config['fields'], array( 'parent_id' ) ) );
+
+			$error = self::set_product_props_from_input( $variation, array_diff_key( $item, array_flip( array( 'id', 'delete', 'extensions' ) ) ), $config );
+			if ( is_wp_error( $error ) ) {
+				return $error;
+			}
+
+			$error = self::check_variation_attributes( $variation, $parent_product, $position );
+			if ( is_wp_error( $error ) ) {
+				return $error;
+			}
+
+			$plan['save'][] = array( $variation, $item['extensions'] ?? array() );
+		}
+
+		$error = self::check_variation_siblings( $parent_product, array_column( $plan['save'], 0 ), $named );
+		if ( is_wp_error( $error ) ) {
+			return $error;
+		}
+
+		return $plan;
+	}
+
+	/**
+	 * The variations of a parent, from the data store. `WC_Product_Variable::get_children()`
+	 * runs the `woocommerce_get_children` filter, which a plugin can use to hide
+	 * variations from a user, so checks and output read the data store instead.
+	 *
+	 * @param \WC_Product_Variable $parent_product Parent product.
+	 * @param bool                 $force_read     Whether to skip the children cache.
+	 * @return array{all: array<int, int>, visible: array<int, int>}
+	 */
+	private static function read_variation_children( \WC_Product_Variable $parent_product, bool $force_read ): array {
+		return $parent_product->get_data_store()->read_children( $parent_product, $force_read ); // @phpstan-ignore method.notFound (WC_Data_Store forwards the call to the variable product data store)
+	}
+
+	/**
+	 * Check that no variation picks the same options as another variation in
+	 * the list or a saved variation of the parent, and that no variation in the
+	 * list carries the SKU of the parent or of another variation in the list. A
+	 * saved variation that the input names is left out, because the input
+	 * changes or deletes it. `set_sku()` already checks a SKU against the saved
+	 * products, but not against the parent's SKU before the parent is saved.
+	 *
+	 * @param \WC_Product_Variable    $parent_product Parent product.
+	 * @param \WC_Product_Variation[] $variations     Variations to save, in input order.
+	 * @param array<int, true>        $named          IDs of the saved variations that the input names.
+	 * @return null|\WP_Error
+	 */
+	protected static function check_variation_siblings( \WC_Product_Variable $parent_product, array $variations, array $named = array() ) {
+		$combinations = array();
+		$parent_sku   = $parent_product->get_sku( 'edit' );
+		$skus         = '' === $parent_sku ? array() : array( $parent_sku => true );
+		foreach ( $parent_product->get_id() > 0 ? self::read_variation_children( $parent_product, true )['all'] : array() as $child_id ) {
+			$child = isset( $named[ $child_id ] ) ? null : wc_get_product( $child_id );
+			if ( $child instanceof \WC_Product_Variation ) {
+				$combinations[ self::get_variation_combination( $child ) ] = true;
+			}
+		}
+		foreach ( array_values( $variations ) as $index => $variation ) {
+			$position    = $index + 1;
+			$combination = self::get_variation_combination( $variation );
+			if ( isset( $combinations[ $combination ] ) ) {
+				return new \WP_Error(
+					'woocommerce_product_variation_duplicate',
+					/* translators: %d: variation position. */
+					sprintf( __( 'Variation %d picks the same options as another variation.', 'woocommerce' ), $position ),
+					array( 'status' => 400 )
+				);
+			}
+			$combinations[ $combination ] = true;
+
+			$sku = $variation->get_sku( 'edit' );
+			if ( '' !== $sku && isset( $skus[ $sku ] ) ) {
+				return new \WP_Error(
+					'woocommerce_product_variation_sku_duplicate',
+					/* translators: %d: variation position. */
+					sprintf( __( 'Variation %d repeats the SKU of the product or of another variation. Give each one its own SKU.', 'woocommerce' ), $position ),
+					array( 'status' => 400 )
+				);
+			}
+			$skus[ $sku ] = true;
+		}
+
+		return null;
+	}
+
+	/**
+	 * The options a variation picks, as one string to compare variations with.
+	 *
+	 * @param \WC_Product_Variation $variation Variation.
+	 * @return string
+	 */
+	private static function get_variation_combination( \WC_Product_Variation $variation ): string {
+		$attributes = $variation->get_attributes();
+		ksort( $attributes );
+		return (string) wp_json_encode( $attributes );
+	}
+
+	/**
+	 * Save the planned variations of a saved parent, delete the ones marked
+	 * for deletion, then sync the parent with its variations. A failed save
+	 * returns an error, and the transaction of the pipeline rolls back the
+	 * earlier saves.
+	 *
+	 * @param \WC_Product_Variable $parent_product Saved parent.
+	 * @param array                $plan           The plan from plan_variations().
+	 * @param string               $failure_code   Error code to use when a save fails.
+	 * @return null|\WP_Error
+	 */
+	protected static function save_variations( \WC_Product_Variable $parent_product, array $plan, string $failure_code ) {
+		foreach ( $plan['save'] as list( $variation ) ) {
+			$variation->set_parent_id( $parent_product->get_id() );
+			$error = self::save_product( $variation, $failure_code );
+			if ( is_wp_error( $error ) ) {
+				return $error;
+			}
+		}
+
+		foreach ( $plan['delete'] as $variation ) {
+			$variation_id = $variation->get_id();
+			if ( ! $variation->delete( true ) || null !== get_post( $variation_id ) ) {
+				return new \WP_Error( $failure_code, __( 'Failed to delete a variation.', 'woocommerce' ), array( 'status' => 500 ) );
+			}
+		}
+
+		if ( empty( $plan['save'] ) && empty( $plan['delete'] ) ) {
+			return null;
+		}
+
+		self::sync_variable_parent( $parent_product );
+
+		return null;
+	}
+
+	/**
+	 * Sync a parent with its variations after they changed: reload its
+	 * children from the data store, then sync its price range and stock status.
+	 *
+	 * @param \WC_Product_Variable $parent_product Saved parent.
+	 */
+	protected static function sync_variable_parent( \WC_Product_Variable $parent_product ): void {
+		$children = self::read_variation_children( $parent_product, true );
+		$parent_product->set_children( $children['all'] );
+		$parent_product->set_visible_children( $children['visible'] );
+		\WC_Product_Variable::sync( $parent_product );
+		$parent_product->set_price( (string) get_post_meta( $parent_product->get_id(), '_price', true ) );
+	}
+
+	/**
+	 * Build the attributes of a product from input. A parent gets
+	 * WC_Product_Attribute objects: a taxonomy attribute with term ids for a
+	 * global attribute, like the REST API builds, else a local attribute. An
+	 * option without a term keeps its name, and the save creates the term. A
+	 * variation gets the option it picks for each attribute, keyed by the
+	 * attribute slug, or by the taxonomy for a global attribute id.
+	 *
+	 * @param \WC_Product $product Product object.
+	 * @param array       $value   The `attributes` input.
+	 * @return array|\WP_Error
+	 */
+	private static function prepare_product_attributes( \WC_Product $product, array $value ) {
+		if ( $product instanceof \WC_Product_Variation ) {
+			$attributes = array();
+			foreach ( $value as $item ) {
+				if ( isset( $item['options'] ) ) {
+					return self::get_invalid_product_attributes_error();
+				}
+				$key = empty( $item['id'] ) ? sanitize_title( (string) ( $item['name'] ?? '' ) ) : wc_attribute_taxonomy_name_by_id( (int) $item['id'] );
+				if ( '' === $key ) {
+					return self::get_invalid_product_attributes_error();
+				}
+				$attributes[ $key ] = isset( $item['option'] ) ? wc_clean( (string) $item['option'] ) : '';
+			}
+			return $attributes;
+		}
+
+		$attributes = array();
+		foreach ( $value as $position => $item ) {
+			$options  = array_map( 'sanitize_text_field', $item['options'] ?? array() );
+			$taxonomy = self::get_global_attribute_taxonomy( $item );
+			$name     = '' === $taxonomy ? sanitize_text_field( trim( (string) ( $item['name'] ?? '' ) ) ) : $taxonomy;
+			if ( '' === $taxonomy && ( ! empty( $item['id'] ) || ! empty( $item['global'] ) ) ) {
+				return new \WP_Error(
+					'woocommerce_product_attributes_invalid',
+					sprintf(
+						/* translators: %s: attribute name or id. */
+						__( 'There is no global attribute "%s".', 'woocommerce' ),
+						(string) ( $item['id'] ?? $item['name'] ?? '' )
+					),
+					array( 'status' => 400 )
+				);
+			}
+			if ( '' === $name || empty( $options ) ) {
+				return self::get_invalid_product_attributes_error();
+			}
+			$attribute = new \WC_Product_Attribute();
+			if ( '' !== $taxonomy ) {
+				$attribute->set_id( wc_attribute_taxonomy_id_by_name( $taxonomy ) );
+				foreach ( $options as $index => $option ) {
+					$term              = get_term_by( 'slug', $option, $taxonomy );
+					$term              = $term instanceof \WP_Term ? $term : get_term_by( 'name', $option, $taxonomy );
+					$options[ $index ] = $term instanceof \WP_Term ? $term->term_id : $option;
+				}
+				$options = array_values( array_unique( $options ) );
+			}
+			$attribute->set_name( $name );
+			$attribute->set_options( $options );
+			$attribute->set_position( (int) $position );
+			$attribute->set_visible( (bool) ( $item['visible'] ?? true ) );
+			$attribute->set_variation( (bool) ( $item['variation'] ?? true ) );
+			$attributes[ sanitize_title( $name ) ] = $attribute;
+		}
+
+		if ( count( $attributes ) !== count( $value ) ) {
+			return new \WP_Error(
+				'woocommerce_product_attributes_invalid',
+				__( 'Two attributes have the same name.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		return $attributes;
+	}
+
+	/**
+	 * The taxonomy of the global attribute that an `attributes` item names: by
+	 * `id`, or by a `name` that matches a global attribute, with or without the
+	 * `pa_` prefix.
+	 *
+	 * @param array $item Item of the `attributes` input.
+	 * @return string The taxonomy, such as `pa_size`, or an empty string for a local attribute.
+	 */
+	private static function get_global_attribute_taxonomy( array $item ): string {
+		if ( ! empty( $item['id'] ) ) {
+			return wc_attribute_taxonomy_name_by_id( (int) $item['id'] );
+		}
+
+		$name = trim( $item['name'] ?? '' );
+		if ( empty( $item['global'] ) || '' === $name ) {
+			return '';
+		}
+
+		if ( wc_attribute_taxonomy_id_by_name( $name ) > 0 ) {
+			return wc_attribute_taxonomy_name( wc_attribute_taxonomy_slug( $name ) );
+		}
+
+		foreach ( wc_get_attribute_taxonomies() as $taxonomy ) {
+			if ( 0 === strcasecmp( (string) $taxonomy->attribute_label, $name ) ) {
+				return wc_attribute_taxonomy_name( (string) $taxonomy->attribute_name );
+			}
+		}
+
+		return '';
+	}
+
+	/**
+	 * The values a variation can pick for an attribute: the term slugs of a
+	 * global attribute, or the options of a local one. An option of a global
+	 * attribute that has no term yet gives the slug that the save creates.
+	 *
+	 * @param \WC_Product_Attribute $attribute Attribute of the parent.
+	 * @return array<int, string>
+	 */
+	private static function get_attribute_option_slugs( \WC_Product_Attribute $attribute ): array {
+		if ( ! $attribute->is_taxonomy() ) {
+			return array_map( 'strval', $attribute->get_options() );
+		}
+
+		$slugs = array();
+		foreach ( $attribute->get_options() as $option ) {
+			$term    = is_int( $option ) ? get_term( $option, $attribute->get_name() ) : null;
+			$slugs[] = $term instanceof \WP_Term ? $term->slug : sanitize_title( (string) $option );
+		}
+
+		return $slugs;
+	}
+
+	/**
+	 * Check that a variation picks one option of every variation attribute of
+	 * its parent, and nothing else. An attribute name matches the parent's
+	 * local attribute first, then its global attribute. The variation then
+	 * stores each option as the parent lists it: the term slug for a global
+	 * attribute, as `WC_Product_Variation` stores it.
+	 *
+	 * @param \WC_Product_Variation $variation      Variation, with its attributes set from input.
+	 * @param \WC_Product_Variable  $parent_product Parent product, saved or not.
+	 * @param int                   $position       Position of the variation in the input, for the message. 0 for a single variation.
+	 * @return null|\WP_Error
+	 */
+	protected static function check_variation_attributes( \WC_Product_Variation $variation, \WC_Product_Variable $parent_product, int $position = 0 ) {
+		$options = array();
+		foreach ( $parent_product->get_attributes() as $slug => $attribute ) {
+			if ( $attribute->get_variation() ) {
+				$options[ $slug ] = self::get_attribute_option_slugs( $attribute );
+			}
+		}
+
+		$attributes = array();
+		foreach ( $variation->get_attributes() as $slug => $option ) {
+			if ( ! isset( $options[ $slug ] ) && isset( $options[ wc_attribute_taxonomy_name( $slug ) ] ) ) {
+				$slug = wc_attribute_taxonomy_name( $slug );
+			}
+			$attributes[ $slug ] = (string) $option;
+		}
+
+		$missing = array_diff_key( $options, $attributes );
+		if ( empty( $options ) || ! empty( $missing ) || count( $attributes ) !== count( $options ) ) {
+			return new \WP_Error(
+				'woocommerce_product_variation_attributes_invalid',
+				sprintf(
+					/* translators: 1: "Variation N" or "The variation", 2: comma-separated attribute names. */
+					__( '%1$s must pick one option of each of these attributes: %2$s.', 'woocommerce' ),
+					self::get_variation_label( $position ),
+					implode( ', ', array_keys( $options ) )
+				),
+				array( 'status' => 400 )
+			);
+		}
+
+		foreach ( $attributes as $slug => $option ) {
+			if ( '' === $option || in_array( $option, $options[ $slug ], true ) ) {
+				continue;
+			}
+			if ( ! in_array( sanitize_title( $option ), $options[ $slug ], true ) ) {
+				return new \WP_Error(
+					'woocommerce_product_variation_attributes_invalid',
+					sprintf(
+						/* translators: 1: "Variation N" or "The variation", 2: attribute name, 3: option. */
+						__( '%1$s picks the option "%3$s" that the attribute "%2$s" does not have.', 'woocommerce' ),
+						self::get_variation_label( $position ),
+						$slug,
+						$option
+					),
+					array( 'status' => 400 )
+				);
+			}
+			$attributes[ $slug ] = sanitize_title( $option );
+		}
+
+		$variation->set_attributes( $attributes );
+
+		return null;
+	}
+
+	/**
+	 * How an error names a variation: by its position in a list, or alone.
+	 *
+	 * @param int $position Position in the input, or 0 for a single variation.
+	 * @return string
+	 */
+	private static function get_variation_label( int $position ): string {
+		/* translators: %d: variation position in the input. */
+		return $position > 0 ? sprintf( __( 'Variation %d', 'woocommerce' ), $position ) : __( 'The variation', 'woocommerce' );
+	}
+
+	/**
+	 * Get the error for an `attributes` input of the wrong shape.
+	 *
+	 * @return \WP_Error
+	 */
+	private static function get_invalid_product_attributes_error(): \WP_Error {
+		return new \WP_Error(
+			'woocommerce_product_attributes_invalid',
+			__( 'Each attribute needs a name and, on a parent product, at least one option. On a variation, the option is a single value, and no option matches any option.', 'woocommerce' ),
+			array( 'status' => 400 )
+		);
+	}
+
 	/**
 	 * Apply product type configuration to a product object.
 	 *
@@ -821,6 +1582,9 @@ trait ProductAbilityTrait {
 			'tax_status'        => $product->get_tax_status(),
 			'tax_class'         => $product->get_tax_class(),
 			'backorders'        => $product->get_backorders(),
+			'parent_id'         => (int) $product->get_parent_id(),
+			'attributes'        => self::format_product_attributes( $product ),
+			'variation_ids'     => $product instanceof \WC_Product_Variable ? self::read_variation_children( $product, false )['all'] : array(),
 		);

 		if ( false !== $permalink ) {
@@ -855,6 +1619,106 @@ trait ProductAbilityTrait {
 		return ExtensibleAbilities::add_fields_to_object( $data, 'product', $product );
 	}

+	/**
+	 * Format the attributes of a product for output, in the shape of the
+	 * `attributes` input: options on a parent, one option on a variation.
+	 *
+	 * @param \WC_Product $product Product object.
+	 * @return array<int, array<string, mixed>>
+	 */
+	private static function format_product_attributes( \WC_Product $product ): array {
+		$output = array();
+
+		foreach ( $product->get_attributes() as $slug => $attribute ) {
+			if ( $attribute instanceof \WC_Product_Attribute ) {
+				$output[] = array(
+					'id'        => $attribute->get_id(),
+					'name'      => wc_attribute_label( $attribute->get_name(), $product ),
+					'options'   => self::get_attribute_option_slugs( $attribute ),
+					'visible'   => (bool) $attribute->get_visible(),
+					'variation' => (bool) $attribute->get_variation(),
+				);
+			} else {
+				$picked = array(
+					'id'   => taxonomy_is_product_attribute( (string) $slug ) ? wc_attribute_taxonomy_id_by_name( (string) $slug ) : 0,
+					'name' => wc_attribute_label( (string) $slug, $product ),
+				);
+				if ( '' !== (string) $attribute ) {
+					$picked['option'] = (string) $attribute;
+				}
+				$output[] = $picked;
+			}
+		}
+
+		return $output;
+	}
+
+	/**
+	 * Get the output schema of product-create and product-update: the product,
+	 * and the variations the call created or changed.
+	 *
+	 * @return array
+	 */
+	protected static function get_product_mutation_output_schema(): array {
+		$schema = self::get_entity_output_schema( 'product', self::get_product_output_schema() );
+
+		$schema['properties']['variations'] = array(
+			'type'        => 'array',
+			'description' => __( 'The variations the call created or changed, in input order. Deleted variations are not listed.', 'woocommerce' ),
+			'items'       => ExtensibleAbilities::add_fields_schema( self::get_single_product_output_schema(), 'product', array( self::class, 'field_applies_to_variations' ) ),
+		);
+
+		return $schema;
+	}
+
+	/**
+	 * Build the response of product-create and product-update. The slug comes
+	 * from the stored post, because the data store does not set the slug that
+	 * WordPress generates back on the object.
+	 *
+	 * @param \WC_Product             $product    Saved product.
+	 * @param \WC_Product_Variation[] $variations Variations the call saved, in input order.
+	 * @return array|\WP_Error A WP_Error when an extension field value does not match its schema.
+	 */
+	protected static function format_product_mutation_response( \WC_Product $product, array $variations ) {
+		$formatted = array();
+		foreach ( array_merge( array( $product ), array_values( $variations ) ) as $saved ) {
+			$data = self::format_saved_product_for_response( $saved );
+			if ( is_wp_error( $data ) ) {
+				return $data;
+			}
+			$formatted[] = array_merge( $data, array( 'slug' => (string) get_post_field( 'post_name', $saved->get_id() ) ) );
+		}
+
+		return array(
+			'product'    => array_shift( $formatted ),
+			'variations' => $formatted,
+		);
+	}
+
+	/**
+	 * Whether a product field can apply to a variation, from its `object_types`.
+	 *
+	 * The variation schemas list a field for every `WC_Product_Variation`
+	 * subclass type, such as one from the `woocommerce_ability_variation_product_type`
+	 * filter. A write still refuses the field when the variation's exact type is
+	 * not in `object_types`.
+	 *
+	 * @param array<string, mixed> $args Field arguments from `register_field()`.
+	 * @return bool
+	 */
+	public static function field_applies_to_variations( array $args ): bool {
+		if ( empty( $args['object_types'] ) ) {
+			return true;
+		}
+		foreach ( $args['object_types'] as $object_type ) {
+			if ( is_a( \WC_Product_Factory::get_product_classname( 0, $object_type ), \WC_Product_Variation::class, true ) ) {
+				return true;
+			}
+		}
+		return false;
+	}
+
 	/**
 	 * Terms of a product in one taxonomy. Loading a product caches its terms, so this adds no query per product.
 	 *
@@ -929,167 +1793,210 @@ trait ProductAbilityTrait {
 	 * @return array
 	 */
 	protected static function get_product_output_schema(): array {
-		return ExtensibleAbilities::add_fields_schema(
-			array(
-				'type'                 => 'object',
-				'properties'           => array(
-					'id'                => array( 'type' => 'integer' ),
-					'name'              => array( 'type' => 'string' ),
-					'slug'              => array( 'type' => 'string' ),
-					'permalink'         => array(
-						'type'        => 'string',
-						'description' => __( 'Product permalink. Omitted when no public permalink is available.', 'woocommerce' ),
-						'format'      => 'uri',
-					),
-					'type'              => array(
-						'type'        => 'string',
-						'description' => __( 'Internal product type slug, such as simple, external, grouped, or variable.', 'woocommerce' ),
-						'enum'        => array_keys( wc_get_product_types() ),
-					),
-					'status'            => array(
-						'type' => 'string',
-						'enum' => self::get_product_output_status_slugs(),
-					),
-					'sku'               => array( 'type' => 'string' ),
-					'currency'          => array(
-						'type' => 'string',
-						'enum' => array_keys( get_woocommerce_currencies() ),
-					),
-					'currency_symbol'   => array( 'type' => 'string' ),
-					'price'             => array(
-						'type'        => 'string',
-						'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
-					),
-					'regular_price'     => array(
-						'type'        => 'string',
-						'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
-					),
-					'sale_price'        => array(
-						'type'        => 'string',
-						'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
-					),
-					'on_sale'           => array(
-						'type'        => 'boolean',
-						'description' => __( 'Whether the sale price applies now, taking the sale dates into account.', 'woocommerce' ),
-					),
-					'stock_status'      => array(
-						'type' => 'string',
-						'enum' => array_keys( wc_get_product_stock_status_options() ),
-					),
-					'stock_quantity'    => array(
-						'type'        => self::get_product_stock_quantity_schema_type(),
-						'description' => __( 'Current stock quantity. Omitted when no stock quantity is set.', 'woocommerce' ),
-					),
-					'manage_stock'      => array( 'type' => 'boolean' ),
-					'virtual'           => array( 'type' => 'boolean' ),
-					'downloadable'      => array( 'type' => 'boolean' ),
-					'external_url'      => array(
-						'type'        => 'string',
-						'description' => __( 'External product URL. Omitted when the product is not external or no URL is set.', 'woocommerce' ),
-						'format'      => 'uri',
-					),
-					'button_text'       => array(
-						'type'        => 'string',
-						'description' => __( 'Button text. Omitted when the product is not external or no button text is set.', 'woocommerce' ),
-					),
-					'grouped_products'  => array(
-						'type'        => 'array',
-						'description' => __( 'Product IDs included as children of a grouped product.', 'woocommerce' ),
-						'items'       => array( 'type' => 'integer' ),
-					),
-					'categories'        => self::get_product_terms_output_schema(),
-					'tags'              => self::get_product_terms_output_schema(),
-					'brands'            => self::get_product_terms_output_schema(),
-					'description'       => array( 'type' => 'string' ),
-					'short_description' => array( 'type' => 'string' ),
-					'images'            => array(
-						'type'        => 'array',
-						'description' => __( 'Featured image first, then the gallery.', 'woocommerce' ),
-						'items'       => array(
-							'type'                 => 'object',
-							'properties'           => array(
-								'id'   => array( 'type' => 'integer' ),
-								'src'  => array(
-									'type'   => 'string',
-									'format' => 'uri',
-								),
-								'name' => array( 'type' => 'string' ),
-								'alt'  => array( 'type' => 'string' ),
+		return ExtensibleAbilities::add_fields_schema( self::get_single_product_output_schema(), 'product' );
+	}
+
+	/**
+	 * Get the schema of a product in a response.
+	 *
+	 * @return array
+	 */
+	private static function get_single_product_output_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id'                => array( 'type' => 'integer' ),
+				'name'              => array( 'type' => 'string' ),
+				'slug'              => array( 'type' => 'string' ),
+				'permalink'         => array(
+					'type'        => 'string',
+					'description' => __( 'Product permalink. Omitted when no public permalink is available.', 'woocommerce' ),
+					'format'      => 'uri',
+				),
+				'type'              => array(
+					'type'        => 'string',
+					'description' => __( 'Internal product type slug, such as simple, external, grouped, variable or variation.', 'woocommerce' ),
+				),
+				'status'            => array(
+					'type' => 'string',
+					'enum' => self::get_product_output_status_slugs(),
+				),
+				'sku'               => array( 'type' => 'string' ),
+				'currency'          => array(
+					'type' => 'string',
+					'enum' => array_keys( get_woocommerce_currencies() ),
+				),
+				'currency_symbol'   => array( 'type' => 'string' ),
+				'price'             => array(
+					'type'        => 'string',
+					'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+				),
+				'regular_price'     => array(
+					'type'        => 'string',
+					'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+				),
+				'sale_price'        => array(
+					'type'        => 'string',
+					'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+				),
+				'on_sale'           => array(
+					'type'        => 'boolean',
+					'description' => __( 'Whether the sale price applies now, taking the sale dates into account.', 'woocommerce' ),
+				),
+				'stock_status'      => array(
+					'type' => 'string',
+					'enum' => array_keys( wc_get_product_stock_status_options() ),
+				),
+				'stock_quantity'    => array(
+					'type'        => self::get_product_stock_quantity_schema_type(),
+					'description' => __( 'Current stock quantity. Omitted when no stock quantity is set.', 'woocommerce' ),
+				),
+				'manage_stock'      => array( 'type' => 'boolean' ),
+				'virtual'           => array( 'type' => 'boolean' ),
+				'downloadable'      => array( 'type' => 'boolean' ),
+				'external_url'      => array(
+					'type'        => 'string',
+					'description' => __( 'External product URL. Omitted when the product is not external or no URL is set.', 'woocommerce' ),
+					'format'      => 'uri',
+				),
+				'button_text'       => array(
+					'type'        => 'string',
+					'description' => __( 'Button text. Omitted when the product is not external or no button text is set.', 'woocommerce' ),
+				),
+				'grouped_products'  => array(
+					'type'        => 'array',
+					'description' => __( 'Product IDs included as children of a grouped product.', 'woocommerce' ),
+					'items'       => array( 'type' => 'integer' ),
+				),
+				'parent_id'         => array(
+					'type'        => 'integer',
+					'description' => __( 'ID of the variable product of a variation, or 0.', 'woocommerce' ),
+				),
+				'attributes'        => array(
+					'type'        => 'array',
+					'description' => __( 'Attributes with their options on a product, or the option each attribute picks on a variation. For a global attribute the options are term slugs.', 'woocommerce' ),
+					'items'       => array(
+						'type'                 => 'object',
+						'properties'           => array(
+							'id'        => array(
+								'type'        => 'integer',
+								'description' => __( 'ID of the global attribute, or 0 for an attribute of this product only.', 'woocommerce' ),
+							),
+							'name'      => array( 'type' => 'string' ),
+							'options'   => array(
+								'type'  => 'array',
+								'items' => array( 'type' => 'string' ),
+							),
+							'option'    => array(
+								'type'        => 'string',
+								'description' => __( 'The option a variation picks. Omitted when the variation matches any option.', 'woocommerce' ),
+							),
+							'visible'   => array( 'type' => 'boolean' ),
+							'variation' => array(
+								'type'        => 'boolean',
+								'description' => __( 'Whether the variations pick an option of this attribute.', 'woocommerce' ),
 							),
-							'additionalProperties' => false,
-						),
-					),
-					'weight'            => array(
-						'type'        => 'string',
-						'description' => sprintf(
-							/* translators: %s: weight unit, such as kg. */
-							__( 'Decimal string in %s, or empty when not set.', 'woocommerce' ),
-							get_option( 'woocommerce_weight_unit' )
 						),
+						'required'             => array( 'name' ),
+						'additionalProperties' => false,
 					),
-					'dimensions'        => array(
+				),
+				'categories'        => self::get_product_terms_output_schema(),
+				'tags'              => self::get_product_terms_output_schema(),
+				'brands'            => self::get_product_terms_output_schema(),
+				'description'       => array( 'type' => 'string' ),
+				'short_description' => array( 'type' => 'string' ),
+				'images'            => array(
+					'type'        => 'array',
+					'description' => __( 'Featured image first, then the gallery.', 'woocommerce' ),
+					'items'       => array(
 						'type'                 => 'object',
-						'description'          => sprintf(
-							/* translators: %s: dimension unit, such as cm. */
-							__( 'Decimal strings in %s, each empty when not set.', 'woocommerce' ),
-							get_option( 'woocommerce_dimension_unit' )
-						),
 						'properties'           => array(
-							'length' => array( 'type' => 'string' ),
-							'width'  => array( 'type' => 'string' ),
-							'height' => array( 'type' => 'string' ),
+							'id'   => array( 'type' => 'integer' ),
+							'src'  => array(
+								'type'   => 'string',
+								'format' => 'uri',
+							),
+							'name' => array( 'type' => 'string' ),
+							'alt'  => array( 'type' => 'string' ),
 						),
 						'additionalProperties' => false,
 					),
-					'shipping_required' => array( 'type' => 'boolean' ),
-					'shipping_class'    => array(
-						'type'        => 'string',
-						'description' => __( 'Shipping class slug, or empty when the product has none.', 'woocommerce' ),
-					),
-					'shipping_class_id' => array(
-						'type'        => 'integer',
-						'description' => __( 'Shipping class term ID, or 0 when the product has none.', 'woocommerce' ),
-					),
-					'tax_status'        => array(
-						'type' => 'string',
-						'enum' => array( ProductTaxStatus::TAXABLE, ProductTaxStatus::SHIPPING, ProductTaxStatus::NONE ),
-					),
-					'tax_class'         => array(
-						'type'        => 'string',
-						'description' => __( 'Tax class slug, or empty for the standard rate.', 'woocommerce' ),
-					),
-					'backorders'        => array(
-						'type' => 'string',
-						'enum' => array_keys( wc_get_product_backorder_options() ),
-					),
-					'low_stock_amount'  => array(
-						'type'        => self::get_product_stock_quantity_schema_type(),
-						'description' => __( 'Low stock threshold of the product. Omitted when the store-wide threshold applies.', 'woocommerce' ),
-					),
-					'date_created'      => array(
-						'type'        => 'string',
-						'format'      => 'date-time',
-						'description' => __( 'Product creation date in the store timezone. Omitted when no creation date is available.', 'woocommerce' ),
-					),
-					'date_created_gmt'  => array(
-						'type'        => 'string',
-						'format'      => 'date-time',
-						'description' => __( 'Product creation date in UTC. Omitted when no creation date is available.', 'woocommerce' ),
+				),
+				'weight'            => array(
+					'type'        => 'string',
+					'description' => sprintf(
+						/* translators: %s: weight unit, such as kg. */
+						__( 'Decimal string in %s, or empty when not set.', 'woocommerce' ),
+						get_option( 'woocommerce_weight_unit' )
 					),
-					'date_modified'     => array(
-						'type'        => 'string',
-						'format'      => 'date-time',
-						'description' => __( 'Product modification date in the store timezone. Omitted when no modification date is available.', 'woocommerce' ),
+				),
+				'dimensions'        => array(
+					'type'                 => 'object',
+					'description'          => sprintf(
+						/* translators: %s: dimension unit, such as cm. */
+						__( 'Decimal strings in %s, each empty when not set.', 'woocommerce' ),
+						get_option( 'woocommerce_dimension_unit' )
 					),
-					'date_modified_gmt' => array(
-						'type'        => 'string',
-						'format'      => 'date-time',
-						'description' => __( 'Product modification date in UTC. Omitted when no modification date is available.', 'woocommerce' ),
+					'properties'           => array(
+						'length' => array( 'type' => 'string' ),
+						'width'  => array( 'type' => 'string' ),
+						'height' => array( 'type' => 'string' ),
 					),
+					'additionalProperties' => false,
+				),
+				'shipping_required' => array( 'type' => 'boolean' ),
+				'shipping_class'    => array(
+					'type'        => 'string',
+					'description' => __( 'Shipping class slug, or empty when the product has none.', 'woocommerce' ),
+				),
+				'shipping_class_id' => array(
+					'type'        => 'integer',
+					'description' => __( 'Shipping class term ID, or 0 when the product has none.', 'woocommerce' ),
+				),
+				'tax_status'        => array(
+					'type' => 'string',
+					'enum' => array( ProductTaxStatus::TAXABLE, ProductTaxStatus::SHIPPING, ProductTaxStatus::NONE ),
+				),
+				'tax_class'         => array(
+					'type'        => 'string',
+					'description' => __( 'Tax class slug, or empty for the standard rate.', 'woocommerce' ),
+				),
+				'backorders'        => array(
+					'type' => 'string',
+					'enum' => array_keys( wc_get_product_backorder_options() ),
+				),
+				'low_stock_amount'  => array(
+					'type'        => self::get_product_stock_quantity_schema_type(),
+					'description' => __( 'Low stock threshold of the product. Omitted when the store-wide threshold applies.', 'woocommerce' ),
+				),
+				'variation_ids'     => array(
+					'type'        => 'array',
+					'description' => __( 'IDs of the variations of a variable product.', 'woocommerce' ),
+					'items'       => array( 'type' => 'integer' ),
+				),
+				'date_created'      => array(
+					'type'        => 'string',
+					'format'      => 'date-time',
+					'description' => __( 'Product creation date in the store timezone. Omitted when no creation date is available.', 'woocommerce' ),
+				),
+				'date_created_gmt'  => array(
+					'type'        => 'string',
+					'format'      => 'date-time',
+					'description' => __( 'Product creation date in UTC. Omitted when no creation date is available.', 'woocommerce' ),
+				),
+				'date_modified'     => array(
+					'type'        => 'string',
+					'format'      => 'date-time',
+					'description' => __( 'Product modification date in the store timezone. Omitted when no modification date is available.', 'woocommerce' ),
+				),
+				'date_modified_gmt' => array(
+					'type'        => 'string',
+					'format'      => 'date-time',
+					'description' => __( 'Product modification date in UTC. Omitted when no modification date is available.', 'woocommerce' ),
 				),
-				'additionalProperties' => false,
 			),
-			'product'
+			'additionalProperties' => false,
 		);
 	}

diff --git a/plugins/woocommerce/src/Internal/Utilities/DatabaseTransaction.php b/plugins/woocommerce/src/Internal/Utilities/DatabaseTransaction.php
new file mode 100644
index 00000000000..fd2024d27a7
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Utilities/DatabaseTransaction.php
@@ -0,0 +1,143 @@
+<?php
+/**
+ * DatabaseTransaction class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Utilities;
+
+use Automattic\Jetpack\Constants;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * A database transaction that one caller owns from start to end.
+ *
+ * While the transaction is open, wc_transaction_query() sets savepoints instead of starting and ending
+ * transactions, so a caller that opens and closes its own transaction inside it does not end it early.
+ * When run() ends, it always commits or rolls back what it opened, even when a caller inside it left a
+ * transaction open. Outside run(), wc_transaction_query() works as it always did.
+ *
+ * When a caller started a transaction with wc_transaction_query() before run(), run() works in a savepoint
+ * inside it, so the caller's commit or rollback decides the outcome, as on code without run().
+ *
+ * Raw START TRANSACTION, COMMIT or ROLLBACK queries and DDL statements still end the transaction, because
+ * MySQL commits before them.
+ *
+ * @internal
+ * @since 11.4.0
+ */
+final class DatabaseTransaction {
+
+	/**
+	 * Number of open levels: the transaction, then one per savepoint. 0 when no transaction is owned.
+	 *
+	 * @var int
+	 */
+	private static $depth = 0;
+
+	/**
+	 * Whether wc_transaction_query() started a transaction outside run() that it has not ended yet.
+	 *
+	 * @var bool
+	 */
+	private static $caller_open = false;
+
+	/**
+	 * Run the callback in a transaction. Commit when it returns, roll back when it returns a WP_Error or
+	 * throws. Inside another run(), or inside a transaction that wc_transaction_query() started, the callback
+	 * runs in a savepoint.
+	 *
+	 * @param callable $callback Callback to run.
+	 * @return mixed What the callback returns.
+	 * @throws \Throwable What the callback throws, after the rollback.
+	 */
+	public static function run( callable $callback ) {
+		wc_maybe_define_constant( 'WC_USE_TRANSACTIONS', true );
+		if ( ! Constants::is_true( 'WC_USE_TRANSACTIONS' ) ) {
+			return $callback();
+		}
+
+		$joined      = 0 === self::$depth && self::$caller_open;
+		$level       = $joined ? 1 : self::$depth;
+		self::$depth = $level;
+		self::start();
+		try {
+			$result = $callback();
+		} catch ( \Throwable $e ) {
+			self::end( $level, false );
+			self::$depth = $joined ? 0 : self::$depth;
+			throw $e;
+		}
+		self::end( $level, ! is_wp_error( $result ) );
+		self::$depth = $joined ? 0 : self::$depth;
+
+		return $result;
+	}
+
+	/**
+	 * Whether run() owns an open transaction.
+	 *
+	 * @return bool
+	 */
+	public static function is_open(): bool {
+		return self::$depth > 0;
+	}
+
+	/**
+	 * Record a start, commit or rollback that wc_transaction_query() sent outside run().
+	 *
+	 * @param string $type Types: start, commit, rollback.
+	 */
+	public static function track_caller( string $type ): void {
+		self::$caller_open = 'commit' !== $type && 'rollback' !== $type;
+	}
+
+	/**
+	 * Start the transaction, or set a savepoint inside the open one. Other code uses run(). wc_transaction_query()
+	 * calls this method inside an open transaction, and the test bootstrap calls it one time, because the test
+	 * case owns the transaction that every test runs in.
+	 */
+	public static function start(): void {
+		self::query( 0 === self::$depth ? 'START TRANSACTION' : 'SAVEPOINT wc_' . self::$depth );
+		++self::$depth;
+	}
+
+	/**
+	 * Release or roll back the latest savepoint for wc_transaction_query(). At the first level, commit or roll
+	 * back the transaction, as wc_transaction_query() always did.
+	 *
+	 * @param bool $commit Whether to keep the changes.
+	 */
+	public static function close_latest( bool $commit ): void {
+		self::end( self::$depth - 1, $commit );
+	}
+
+	/**
+	 * Close the given level and every level that a caller left open inside it.
+	 *
+	 * @param int  $level  Level to close. 0 is the transaction itself.
+	 * @param bool $commit Whether to keep the changes.
+	 */
+	private static function end( int $level, bool $commit ): void {
+		$level = max( 0, $level );
+		if ( 0 === $level ) {
+			self::query( $commit ? 'COMMIT' : 'ROLLBACK' );
+		} else {
+			self::query( ( $commit ? 'RELEASE SAVEPOINT wc_' : 'ROLLBACK TO SAVEPOINT wc_' ) . $level );
+		}
+		self::$depth = $level;
+	}
+
+	/**
+	 * Send a transaction statement.
+	 *
+	 * @param string $sql Statement. The only variable part is an integer level.
+	 */
+	private static function query( string $sql ): void {
+		global $wpdb;
+
+		$wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The only variable part is an integer level.
+	}
+}
diff --git a/plugins/woocommerce/tests/legacy/bootstrap.php b/plugins/woocommerce/tests/legacy/bootstrap.php
index 1457d0af958..32ef1aa9548 100644
--- a/plugins/woocommerce/tests/legacy/bootstrap.php
+++ b/plugins/woocommerce/tests/legacy/bootstrap.php
@@ -7,6 +7,7 @@
  */

 use Automattic\WooCommerce\Internal\Admin\FeaturePlugin;
+use Automattic\WooCommerce\Internal\Utilities\DatabaseTransaction;
 use Automattic\WooCommerce\Internal\VariationGallery\Migration as VariationGalleryMigration;
 use Automattic\WooCommerce\Testing\Tools\CodeHacking\CodeHacker;
 use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\StaticMockerHack;
@@ -259,7 +260,6 @@ class WC_Unit_Tests_Bootstrap {
 	 */
 	public function load_wc() {
 		define( 'WC_TAX_ROUNDING_MODE', 'auto' );
-		define( 'WC_USE_TRANSACTIONS', false );

 		update_option( 'woocommerce_enable_coupons', 'yes' );
 		update_option( 'woocommerce_calc_taxes', 'yes' );
@@ -267,6 +267,11 @@ class WC_Unit_Tests_Bootstrap {

 		require_once $this->plugin_dir . '/woocommerce.php';
 		FeaturePlugin::instance()->init();
+
+		// The test case wraps each test in a transaction that it rolls back. Own
+		// that transaction one time, so every transaction in a test becomes a
+		// savepoint inside it, and a rollback in the code under test is real.
+		DatabaseTransaction::start();
 	}

 	/**
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-tests-core-functions.php b/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-tests-core-functions.php
index d3262296b76..3e8bbeb91fe 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-tests-core-functions.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-tests-core-functions.php
@@ -899,15 +899,6 @@ class WC_Tests_Core_Functions extends WC_Unit_Test_Case {
 		wc_transaction_query( 'commit', true );
 		$col = $wpdb->get_col( "SElECT option_value FROM {$wpdb->prefix}options WHERE option_name = 'transaction_test'" );
 		$this->assertEquals( '0', $col[0] );
-
-		// The commits above made the row permanent, so the cleanup has to be committed as well.
-		$wpdb->delete(
-			$wpdb->prefix . 'options',
-			array(
-				'option_name' => 'transaction_test',
-			)
-		);
-		wc_transaction_query( 'commit', true );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index 496e0df13f9..bdad93604e9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -384,9 +384,9 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		$create_schema = wp_get_ability( 'woocommerce/product-create' )->get_input_schema();
 		$update_schema = wp_get_ability( 'woocommerce/product-update' )->get_input_schema();

-		$expected_types = array( 'simple', 'grouped', 'external' );
+		$expected_types = array( 'simple', 'grouped', 'external', 'variable', 'variation' );

-		$this->assertSame( array( 'simple', 'grouped', 'external', 'variable' ), $query_schema['properties']['type']['enum'] ?? array() );
+		$this->assertSame( $expected_types, $query_schema['properties']['type']['enum'] ?? array() );
 		$this->assertSame( $expected_types, $create_schema['properties']['type']['enum'] ?? array() );
 		$this->assertSame( $expected_types, $update_schema['properties']['type']['enum'] ?? array() );
 		$this->assertSame( 'simple', $create_schema['properties']['type']['default'] ?? null );
@@ -421,7 +421,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 			$this->assertStringNotContainsString( 'Applies to: ', $schema['properties']['sku']['description'] ?? '' );
 		}

-		$this->assertSame( array( 'name' ), $create_schema['required'] );
+		$this->assertArrayNotHasKey( 'required', $create_schema );
 		$this->assertArrayNotHasKey( 'id', $create_schema['properties'] );
 		$this->assertStringContainsString( 'A product without a type is simple.', $create_schema['properties']['type']['description'] );
 		$this->assertSame( array( 'id' ), $update_schema['required'] );
@@ -1886,6 +1886,1022 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		return $fields;
 	}

+	/**
+	 * @testdox Should create a variable product with its variations in one call and sync the parent.
+	 */
+	public function test_product_create_builds_a_variable_product_with_its_variations(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $this->get_shirt_input() );
+
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+
+		$parent = wc_get_product( $created['product']['id'] );
+		$this->assertInstanceOf( \WC_Product_Variable::class, $parent );
+		$this->assertSame( 'variable', $created['product']['type'] );
+		$this->assertCount( 2, $created['product']['variation_ids'] );
+		$this->assertSame( $parent->get_children(), $created['product']['variation_ids'] );
+		$this->assertSame( $created['product']['variation_ids'], array_column( $created['variations'], 'id' ) );
+		$this->assertSame( array( 'shirt-s', 'shirt-m' ), array_column( $created['variations'], 'sku' ) );
+		$this->assertSame(
+			array(
+				array(
+					'id'        => 0,
+					'name'      => 'Size',
+					'options'   => array( 'S', 'M' ),
+					'visible'   => true,
+					'variation' => true,
+				),
+			),
+			$created['product']['attributes']
+		);
+		$this->assertSame( '10.00', $created['product']['price'] );
+
+		$small = wc_get_product( $created['product']['variation_ids'][0] );
+		$this->assertInstanceOf( \WC_Product_Variation::class, $small );
+		$this->assertSame( $parent->get_id(), $small->get_parent_id() );
+		$this->assertSame( 'shirt-s', $small->get_sku() );
+		$this->assertSame( array( 'size' => 'S' ), $small->get_attributes() );
+		$this->assertSame( '10.00', $small->get_regular_price() );
+
+		$medium = wc_get_product( $created['product']['variation_ids'][1] );
+		$this->assertTrue( $medium->get_manage_stock() );
+		$this->assertSame( 3, $medium->get_stock_quantity() );
+	}
+
+	/**
+	 * @testdox Should leave no row behind when a later variation fails to save.
+	 */
+	public function test_product_create_rolls_back_every_save_when_a_variation_fails(): void {
+		global $wpdb;
+
+		$queries = array();
+		$log     = static function ( $query ) use ( &$queries ) {
+			$queries[] = $query;
+			return $query;
+		};
+		$fail    = static function ( $product ) {
+			if ( $product instanceof \WC_Product_Variation && 'shirt-m' === $product->get_sku( 'edit' ) ) {
+				throw new \RuntimeException( 'Disk full.' );
+			}
+		};
+		add_filter( 'query', $log );
+		add_action( 'woocommerce_before_product_object_save', $fail );
+		try {
+			$result = wp_get_ability( 'woocommerce/product-create' )->execute_change( $this->get_shirt_input() );
+		} finally {
+			remove_filter( 'query', $log );
+			remove_action( 'woocommerce_before_product_object_save', $fail );
+		}
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_create_failed', $result->get_error_code() );
+		$this->assertContains( 'SAVEPOINT wc_1', $queries );
+		$this->assertContains( 'ROLLBACK TO SAVEPOINT wc_1', $queries );
+		$this->assertNotContains( 'RELEASE SAVEPOINT wc_1', $queries );
+		$this->assertSame(
+			'0',
+			$wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' ) AND post_title LIKE 'Shirt%'" )
+		);
+		$this->assertSame( 0, wc_get_product_id_by_sku( 'shirt-s' ) );
+	}
+
+	/**
+	 * @testdox Should refuse variations that do not match the attributes of the parent, and save nothing.
+	 */
+	public function test_product_create_refuses_variations_that_do_not_match_the_attributes(): void {
+		$ability = wp_get_ability( 'woocommerce/product-create' );
+		$input   = $this->get_shirt_input();
+
+		$input['variations'][1]['attributes'][0]['option'] = 'S';
+		$duplicate = $ability->execute_change( $input );
+
+		$input['variations'][1]['attributes'][0]['option'] = 'XL';
+		$unknown_option                                    = $ability->execute_change( $input );
+
+		$input['variations'][1]['attributes'] = array();
+		$missing                              = $ability->execute_change( $input );
+
+		$input['variations'][1]['attributes'] = array(
+			array(
+				'name'   => 'Size',
+				'option' => 'M',
+			),
+		);
+		$input['variations'][1]['sku']        = 'shirt-s';
+		$same_sku                             = $ability->execute_change( $input );
+
+		$this->assertWPError( $duplicate );
+		$this->assertSame( 'woocommerce_product_variation_duplicate', $duplicate->get_error_code() );
+		$this->assertWPError( $unknown_option );
+		$this->assertSame( 'woocommerce_product_variation_attributes_invalid', $unknown_option->get_error_code() );
+		$this->assertStringContainsString( 'Variation 2', $unknown_option->get_error_message() );
+		$this->assertWPError( $missing );
+		$this->assertSame( 'woocommerce_product_variation_attributes_invalid', $missing->get_error_code() );
+		$this->assertWPError( $same_sku );
+		$this->assertSame( 'woocommerce_product_variation_sku_duplicate', $same_sku->get_error_code() );
+		$this->assertSame( 0, wc_get_product_id_by_sku( 'shirt-s' ) );
+	}
+
+	/**
+	 * @testdox Should refuse a new variation that repeats the options or the SKU of an existing variation.
+	 */
+	public function test_product_create_checks_a_new_variation_against_its_siblings(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $this->get_shirt_input() );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$parent_id                   = $created['product']['id'];
+
+		$resized = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'         => $parent_id,
+				'attributes' => array(
+					array(
+						'name'    => 'Size',
+						'options' => array( 'S', 'M', 'L' ),
+					),
+				),
+			)
+		);
+		$this->assertNotWPError( $resized );
+
+		$same_options = wp_get_ability( 'woocommerce/product-create' )->execute_change(
+			array(
+				'type'       => 'variation',
+				'parent_id'  => $parent_id,
+				'attributes' => array(
+					array(
+						'name'   => 'Size',
+						'option' => 'M',
+					),
+				),
+			)
+		);
+		$same_sku     = wp_get_ability( 'woocommerce/product-create' )->execute_change(
+			array(
+				'type'       => 'variation',
+				'parent_id'  => $parent_id,
+				'attributes' => array(
+					array(
+						'name'   => 'Size',
+						'option' => 'L',
+					),
+				),
+				'sku'        => 'shirt-s',
+			)
+		);
+
+		$this->assertWPError( $same_options );
+		$this->assertSame( 'woocommerce_product_variation_duplicate', $same_options->get_error_code() );
+		$this->assertWPError( $same_sku );
+		$this->assertSame( 'product_invalid_sku', $same_sku->get_error_code() );
+		$this->assertCount( 2, wc_get_product( $parent_id )->get_children() );
+	}
+
+	/**
+	 * @testdox Should treat a variation as a product in update, create, query and delete.
+	 */
+	public function test_variations_are_products_in_the_other_abilities(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $this->get_shirt_input() );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$parent_id                   = $created['product']['id'];
+		$small_id                    = $created['product']['variation_ids'][0];
+
+		$updated = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'            => $small_id,
+				'regular_price' => '8.00',
+			)
+		);
+		$this->assertNotWPError( $updated );
+		$this->assertSame( 'variation', $updated['product']['type'] );
+		$this->assertSame( $parent_id, $updated['product']['parent_id'] );
+		$this->assertSame( '8.00', $updated['product']['regular_price'] );
+		$this->assertSame(
+			array(
+				array(
+					'id'     => 0,
+					'name'   => 'Size',
+					'option' => 'S',
+				),
+			),
+			$updated['product']['attributes']
+		);
+		$this->assertSame( '8.00', wc_get_product( $parent_id )->get_price() );
+
+		$direct_options = wp_get_ability( 'woocommerce/product-update' )->execute_change(
+			array(
+				'id'         => $small_id,
+				'attributes' => array(
+					array(
+						'name'   => 'Size',
+						'option' => 'M',
+					),
+				),
+			)
+		);
+		$this->assertWPError( $direct_options );
+		$this->assertSame( 'woocommerce_product_field_unsupported', $direct_options->get_error_code() );
+		$this->assertStringContainsString( 'attributes', $direct_options->get_error_message() );
+
+		$resized = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'         => $parent_id,
+				'attributes' => array(
+					array(
+						'name'    => 'Size',
+						'options' => array( 'S', 'M', 'L' ),
+					),
+				),
+			)
+		);
+		$this->assertNotWPError( $resized );
+		$large = wp_get_ability( 'woocommerce/product-create' )->execute(
+			array(
+				'type'          => 'variation',
+				'parent_id'     => $parent_id,
+				'attributes'    => array(
+					array(
+						'name'   => 'Size',
+						'option' => 'L',
+					),
+				),
+				'regular_price' => '15.00',
+			)
+		);
+		$this->assertNotWPError( $large );
+		$this->assertSame( $parent_id, $large['product']['parent_id'] );
+		$this->assertContains( $large['product']['id'], wc_get_product( $parent_id )->get_children() );
+
+		$orphan = wp_get_ability( 'woocommerce/product-create' )->execute_change(
+			array(
+				'type'       => 'variation',
+				'parent_id'  => $small_id,
+				'attributes' => array(
+					array(
+						'name'   => 'Size',
+						'option' => 'L',
+					),
+				),
+			)
+		);
+		$this->assertWPError( $orphan );
+		$this->assertSame( 'woocommerce_product_parent_invalid', $orphan->get_error_code() );
+
+		$listed = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'parent_id' => $parent_id ) );
+		$this->assertNotWPError( $listed );
+		$this->assertCount( 3, $listed['products'] );
+		$this->assertSame( array( 'variation' ), array_values( array_unique( array_column( $listed['products'], 'type' ) ) ) );
+
+		$deleted = wp_get_ability( 'woocommerce/product-delete' )->execute(
+			array(
+				'id'    => $large['product']['id'],
+				'force' => true,
+			)
+		);
+		$this->assertNotWPError( $deleted );
+		$this->assertNull( get_post( $large['product']['id'] ) );
+		$this->assertNotContains( $large['product']['id'], wc_get_product( $parent_id )->get_children() );
+
+		$taken = wp_get_ability( 'woocommerce/product-update' )->execute_change(
+			array(
+				'id'         => $parent_id,
+				'variations' => array(
+					array(
+						'id'         => $small_id,
+						'attributes' => array(
+							array(
+								'name'   => 'Size',
+								'option' => 'M',
+							),
+						),
+					),
+				),
+			)
+		);
+		$this->assertWPError( $taken );
+		$this->assertSame( 'woocommerce_product_variation_duplicate', $taken->get_error_code() );
+
+		$reslotted = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'         => $parent_id,
+				'variations' => array(
+					array(
+						'id'         => $small_id,
+						'attributes' => array(
+							array(
+								'name'   => 'Size',
+								'option' => 'L',
+							),
+						),
+					),
+				),
+			)
+		);
+		$this->assertNotWPError( $reslotted );
+		$this->assertSame( 'L', wc_get_product( $small_id )->get_attribute( 'size' ) );
+	}
+
+	/**
+	 * @testdox Should change, add and delete variations through the parent, and leave the others alone.
+	 */
+	public function test_product_update_changes_adds_and_deletes_variations(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $this->get_shirt_input() );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[]  = $created['product']['id'];
+		$parent_id                    = $created['product']['id'];
+		list( $small_id, $medium_id ) = $created['product']['variation_ids'];
+
+		$updated = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'         => $parent_id,
+				'attributes' => array(
+					array(
+						'name'    => 'Size',
+						'options' => array( 'S', 'M', 'L' ),
+					),
+				),
+				'variations' => array(
+					array(
+						'id'            => $small_id,
+						'regular_price' => '8.00',
+					),
+					array(
+						'attributes'    => array(
+							array(
+								'name'   => 'Size',
+								'option' => 'L',
+							),
+						),
+						'regular_price' => '15.00',
+					),
+				),
+			)
+		);
+		$this->assertNotWPError( $updated );
+		$this->assertCount( 3, $updated['product']['variation_ids'] );
+		$this->assertSame( array( '8.00', '15.00' ), array_column( $updated['variations'], 'regular_price' ), 'The response carries the changed and the new variation, in input order.' );
+		$this->assertSame( $small_id, $updated['variations'][0]['id'] );
+		$this->assertSame( '8.00', $updated['product']['price'] );
+		$this->assertSame( '8.00', wc_get_product( $small_id )->get_regular_price() );
+		$this->assertSame( '12.00', wc_get_product( $medium_id )->get_regular_price() );
+		$large_id = $updated['product']['variation_ids'][2];
+		$this->assertSame( '15.00', wc_get_product( $large_id )->get_regular_price() );
+
+		$deleted = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'         => $parent_id,
+				'variations' => array(
+					array(
+						'id'     => $medium_id,
+						'delete' => true,
+					),
+				),
+			)
+		);
+		$this->assertNotWPError( $deleted );
+		$this->assertSame( array( $small_id, $large_id ), $deleted['product']['variation_ids'] );
+		$this->assertSame( array(), $deleted['variations'] );
+		$this->assertNull( get_post( $medium_id ) );
+
+		$other                       = \WC_Helper_Product::create_variation_product();
+		$this->created_product_ids[] = $other->get_id();
+		$foreign                     = wp_get_ability( 'woocommerce/product-update' )->execute_change(
+			array(
+				'id'         => $parent_id,
+				'variations' => array( array( 'id' => $other->get_children()[0] ) ),
+			)
+		);
+		$this->assertWPError( $foreign );
+		$this->assertSame( 'woocommerce_product_variation_not_found', $foreign->get_error_code() );
+
+		$duplicate = wp_get_ability( 'woocommerce/product-update' )->execute_change(
+			array(
+				'id'         => $parent_id,
+				'variations' => array(
+					array(
+						'attributes' => array(
+							array(
+								'name'   => 'Size',
+								'option' => 'L',
+							),
+						),
+					),
+				),
+			)
+		);
+		$this->assertWPError( $duplicate );
+		$this->assertSame( 'woocommerce_product_variation_duplicate', $duplicate->get_error_code() );
+
+		$simple                      = \WC_Helper_Product::create_simple_product();
+		$this->created_product_ids[] = $simple->get_id();
+		$not_variable                = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'         => $simple->get_id(),
+				'variations' => array( array( 'regular_price' => '1.00' ) ),
+			)
+		);
+		$this->assertWPError( $not_variable );
+		$this->assertSame( 'woocommerce_product_field_unsupported', $not_variable->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should leave every variation as it was when a later save of an update fails.
+	 */
+	public function test_product_update_rolls_back_every_save_when_a_variation_fails(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $this->get_shirt_input() );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$small_id                    = $created['product']['variation_ids'][0];
+
+		$fail = static function ( $product ) {
+			if ( $product instanceof \WC_Product_Variation && 'shirt-l' === $product->get_sku( 'edit' ) ) {
+				throw new \RuntimeException( 'Disk full.' );
+			}
+		};
+		add_action( 'woocommerce_before_product_object_save', $fail );
+		try {
+			$result = wp_get_ability( 'woocommerce/product-update' )->execute_change(
+				array(
+					'id'         => $created['product']['id'],
+					'name'       => 'Renamed shirt',
+					'attributes' => array(
+						array(
+							'name'    => 'Size',
+							'options' => array( 'S', 'M', 'L' ),
+						),
+					),
+					'variations' => array(
+						array(
+							'id'            => $small_id,
+							'regular_price' => '8.00',
+						),
+						array(
+							'attributes'    => array(
+								array(
+									'name'   => 'Size',
+									'option' => 'L',
+								),
+							),
+							'sku'           => 'shirt-l',
+							'regular_price' => '15.00',
+						),
+					),
+				)
+			);
+		} finally {
+			remove_action( 'woocommerce_before_product_object_save', $fail );
+		}
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_update_failed', $result->get_error_code() );
+		$this->assertSame( 'Shirt', get_post( $created['product']['id'] )->post_title );
+		$this->assertSame( '10.00', get_post_meta( $small_id, '_regular_price', true ) );
+		$this->assertSame( 0, wc_get_product_id_by_sku( 'shirt-l' ) );
+	}
+
+	/**
+	 * @testdox Should check the permissions of a variation on its parent.
+	 */
+	public function test_variation_permissions_follow_the_parent(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $this->get_shirt_input() );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$parent_id                   = $created['product']['id'];
+		$small_id                    = $created['product']['variation_ids'][0];
+
+		$checked = array();
+		$capture = static function ( $caps, $cap, $user_id, $args ) use ( &$checked ) {
+			if ( in_array( $cap, array( 'edit_post', 'delete_post', 'read_private_products' ), true ) ) {
+				$checked[ $cap ] = (int) ( $args[0] ?? 0 );
+			}
+			return $caps;
+		};
+		add_filter( 'map_meta_cap', $capture, 10, 4 );
+		try {
+			$this->assertTrue( ProductUpdate::can_update_product( array( 'id' => $small_id ) ) );
+			$this->assertTrue( ProductDelete::can_delete_product( array( 'id' => $small_id ) ) );
+			$this->assertTrue( ProductsQuery::can_query_products( array( 'id' => $small_id ) ) );
+		} finally {
+			remove_filter( 'map_meta_cap', $capture, 10 );
+		}
+
+		$this->assertSame( $parent_id, $checked['edit_post'] ?? null );
+		$this->assertSame( $parent_id, $checked['delete_post'] ?? null );
+	}
+
+	/**
+	 * @testdox Should require the delete permission on the parent for a variation item with delete.
+	 */
+	public function test_variation_delete_through_the_parent_requires_the_delete_permission(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $this->get_shirt_input() );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$update                      = array(
+			'id'         => $created['product']['id'],
+			'variations' => array(
+				array(
+					'id'            => $created['product']['variation_ids'][0],
+					'regular_price' => '9.00',
+				),
+			),
+		);
+		$delete                      = array(
+			'id'         => $created['product']['id'],
+			'variations' => array(
+				array(
+					'id'     => $created['product']['variation_ids'][0],
+					'delete' => true,
+				),
+			),
+		);
+
+		$deny = static fn( $caps, $cap ) => 'delete_post' === $cap ? array( 'do_not_allow' ) : $caps;
+		add_filter( 'map_meta_cap', $deny, 10, 2 );
+		try {
+			$this->assertTrue( ProductUpdate::can_update_product( $update ) );
+			$this->assertFalse( ProductUpdate::can_update_product( $delete ) );
+		} finally {
+			remove_filter( 'map_meta_cap', $deny, 10 );
+		}
+
+		$this->assertTrue( ProductUpdate::can_update_product( $delete ) );
+	}
+
+	/**
+	 * @testdox Should refuse to publish a variation through the parent without publish_products.
+	 */
+	public function test_variation_publish_through_the_parent_requires_publish_cap(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $this->get_shirt_input() );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$variation_id                = $created['product']['variation_ids'][0];
+		$variation                   = wc_get_product( $variation_id );
+		$variation->set_status( 'private' );
+		$variation->save();
+
+		$user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+		$user    = get_user_by( 'id', $user_id );
+		$user->add_cap( 'edit_products' );
+		$user->add_cap( 'edit_others_products' );
+		$user->add_cap( 'edit_published_products' );
+		wp_set_current_user( $user_id );
+
+		$result = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'         => $created['product']['id'],
+				'variations' => array(
+					array(
+						'id'     => $variation_id,
+						'status' => 'publish',
+					),
+				),
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_publish_forbidden', $result->get_error_code() );
+		$this->assertSame( 'private', get_post_status( $variation_id ) );
+	}
+
+	/**
+	 * Statuses of a new variation added through the parent.
+	 *
+	 * @return array
+	 */
+	public function new_variation_status_provider(): array {
+		return array(
+			'status omitted' => array( null ),
+			'publish'        => array( 'publish' ),
+			'private'        => array( 'private' ),
+		);
+	}
+
+	/**
+	 * @testdox Should refuse a new published variation through the parent without publish_products.
+	 * @dataProvider new_variation_status_provider
+	 *
+	 * @param string|null $status Status of the new variation, or null to omit it.
+	 */
+	public function test_new_variation_through_the_parent_requires_publish_cap( ?string $status ): void {
+		$input = $this->get_shirt_input();
+		array_pop( $input['variations'] );
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $input );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+
+		$user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+		$user    = get_user_by( 'id', $user_id );
+		$user->add_cap( 'edit_products' );
+		$user->add_cap( 'edit_others_products' );
+		$user->add_cap( 'edit_published_products' );
+		wp_set_current_user( $user_id );
+
+		$item = array(
+			'attributes'    => array(
+				array(
+					'name'   => 'Size',
+					'option' => 'M',
+				),
+			),
+			'regular_price' => '12.00',
+		);
+		if ( null !== $status ) {
+			$item['status'] = $status;
+		}
+
+		$result = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'         => $created['product']['id'],
+				'variations' => array( $item ),
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_publish_forbidden', $result->get_error_code() );
+		$this->assertSame( array( $created['product']['variation_ids'][0] ), wc_get_product( $created['product']['id'] )->get_children() );
+	}
+
+	/**
+	 * @testdox Should fail a variation delete through the parent when pre_delete_post blocks it.
+	 */
+	public function test_a_blocked_variation_delete_through_the_parent_fails(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $this->get_shirt_input() );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$variation_id                = $created['product']['variation_ids'][0];
+		$block                       = static fn( $check, $post ) => 'product_variation' === $post->post_type ? false : $check;
+
+		add_filter( 'pre_delete_post', $block, 10, 2 );
+		try {
+			$result = wp_get_ability( 'woocommerce/product-update' )->execute_change(
+				array(
+					'id'         => $created['product']['id'],
+					'variations' => array(
+						array(
+							'id'     => $variation_id,
+							'delete' => true,
+						),
+					),
+				)
+			);
+		} finally {
+			remove_filter( 'pre_delete_post', $block, 10 );
+		}
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_update_failed', $result->get_error_code() );
+		$this->assertNotNull( get_post( $variation_id ) );
+	}
+
+	/**
+	 * @testdox Should refuse a variation that repeats the SKU of its parent.
+	 */
+	public function test_product_create_refuses_a_variation_with_the_parent_sku(): void {
+		$input                         = $this->get_shirt_input();
+		$input['sku']                  = 'shirt';
+		$input['variations'][1]['sku'] = 'shirt';
+
+		$result = wp_get_ability( 'woocommerce/product-create' )->execute_change( $input );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_variation_sku_duplicate', $result->get_error_code() );
+		$this->assertStringContainsString( 'Variation 2', $result->get_error_message() );
+		$this->assertSame( 0, wc_get_product_id_by_sku( 'shirt' ) );
+	}
+
+	/**
+	 * @testdox Should apply the product type fields filter to a variation item of the parent.
+	 */
+	public function test_variation_items_follow_the_product_type_fields_filter(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $this->get_shirt_input() );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$variation_id                = $created['product']['variation_ids'][0];
+		$drop                        = static fn( array $fields, string $type ) => 'variation' === $type ? array_values( array_diff( $fields, array( 'regular_price' ) ) ) : $fields;
+
+		add_filter( 'woocommerce_ability_product_type_fields', $drop, 10, 2 );
+		try {
+			$nested = wp_get_ability( 'woocommerce/product-update' )->execute_change(
+				array(
+					'id'         => $created['product']['id'],
+					'variations' => array(
+						array(
+							'id'            => $variation_id,
+							'regular_price' => '9.00',
+						),
+					),
+				)
+			);
+			$direct = wp_get_ability( 'woocommerce/product-update' )->execute_change(
+				array(
+					'id'            => $variation_id,
+					'regular_price' => '9.00',
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_ability_product_type_fields', $drop, 10 );
+		}
+
+		$this->assertWPError( $nested );
+		$this->assertSame( 'woocommerce_product_field_unsupported', $nested->get_error_code() );
+		$this->assertWPError( $direct );
+		$this->assertSame( 'woocommerce_product_field_unsupported', $direct->get_error_code() );
+		$this->assertSame( '10.00', wc_get_product( $variation_id )->get_regular_price() );
+	}
+
+	/**
+	 * @testdox Should treat a name as a local attribute unless global is true, and refuse an unknown global attribute.
+	 */
+	public function test_product_create_uses_a_global_attribute_only_when_asked(): void {
+		\WC_Helper_Product::create_attribute( 'size', array( 'small', 'large' ) );
+		$input = $this->get_shirt_input();
+
+		$local                            = wp_get_ability( 'woocommerce/product-create' )->execute( $input );
+		$input['attributes'][0]['global'] = true;
+		$input['attributes'][0]['name']   = 'Color';
+		$unknown                          = wp_get_ability( 'woocommerce/product-create' )->execute_change( $input );
+
+		$this->assertNotWPError( $local );
+		$this->created_product_ids[] = $local['product']['id'];
+		$this->assertSame( 0, $local['product']['attributes'][0]['id'] );
+		$this->assertArrayHasKey( 'size', wc_get_product( $local['product']['id'] )->get_attributes() );
+		$this->assertWPError( $unknown );
+		$this->assertSame( 'woocommerce_product_attributes_invalid', $unknown->get_error_code() );
+		$this->assertSame( 'There is no global attribute "Color".', $unknown->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should treat a variation attribute with no option as any option.
+	 */
+	public function test_a_variation_attribute_with_no_option_matches_any_option(): void {
+		$input                                  = $this->get_shirt_input();
+		$input['attributes'][]                  = array(
+			'name'    => 'Color',
+			'options' => array( 'Red', 'Blue' ),
+		);
+		$input['variations'][0]['attributes'][] = array( 'name' => 'Color' );
+		$input['variations'][1]['attributes'][] = array(
+			'name'   => 'Color',
+			'option' => 'Red',
+		);
+
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $input );
+
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$this->assertSame( '', wc_get_product( $created['product']['variation_ids'][0] )->get_attributes()['color'] );
+	}
+
+	/**
+	 * @testdox Should let a variation match any option when it sends no option, leave the option out of the output, and refuse a null option.
+	 */
+	public function test_product_create_accepts_any_option_variations(): void {
+		$input                                  = $this->get_shirt_input();
+		$input['attributes'][]                  = array(
+			'name'    => 'Color',
+			'options' => array( 'Red', 'Blue' ),
+		);
+		$input['variations'][0]['attributes'][] = array( 'name' => 'Color' );
+		$input['variations'][1]['attributes'][] = array(
+			'name'   => 'Color',
+			'option' => 'Red',
+		);
+
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $input );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+
+		$small = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => $created['product']['variation_ids'][0] ) );
+		$this->assertNotWPError( $small );
+		$this->assertSame(
+			array(
+				array(
+					'id'     => 0,
+					'name'   => 'Size',
+					'option' => 'S',
+				),
+				array(
+					'id'   => 0,
+					'name' => 'Color',
+				),
+			),
+			$small['products'][0]['attributes']
+		);
+		$this->assertSame( '', wc_get_product( $created['product']['variation_ids'][0] )->get_attributes()['color'] );
+
+		$input['variations'][0]['attributes'][1]['option'] = null;
+		$input['sku']                                      = 'shirt-null-option';
+		$refused = wp_get_ability( 'woocommerce/product-create' )->execute( $input );
+		$this->assertWPError( $refused );
+		$this->assertSame( 'ability_invalid_input', $refused->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should not require a variation to pick an attribute that is not for variations.
+	 */
+	public function test_product_create_accepts_attributes_that_are_not_for_variations(): void {
+		$input                 = $this->get_shirt_input();
+		$input['attributes'][] = array(
+			'name'      => 'Material',
+			'options'   => array( 'Cotton' ),
+			'variation' => false,
+		);
+
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $input );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+
+		$this->assertCount( 2, $created['product']['variation_ids'] );
+		$this->assertSame( array( true, false ), array_column( $created['product']['attributes'], 'variation' ) );
+		$this->assertFalse( wc_get_product( $created['product']['id'] )->get_attributes()['material']->get_variation() );
+	}
+
+	/**
+	 * @testdox Should check siblings and list variation ids from the data store, not through the woocommerce_get_children filter.
+	 */
+	public function test_variation_checks_ignore_the_get_children_filter(): void {
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $this->get_shirt_input() );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+
+		add_filter( 'woocommerce_get_children', '__return_empty_array' );
+		try {
+			$duplicate = wp_get_ability( 'woocommerce/product-create' )->execute_change(
+				array(
+					'type'       => 'variation',
+					'parent_id'  => $created['product']['id'],
+					'attributes' => array(
+						array(
+							'name'   => 'Size',
+							'option' => 'S',
+						),
+					),
+				)
+			);
+			$read      = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => $created['product']['id'] ) );
+		} finally {
+			remove_filter( 'woocommerce_get_children', '__return_empty_array' );
+		}
+
+		$this->assertWPError( $duplicate );
+		$this->assertSame( 'woocommerce_product_variation_duplicate', $duplicate->get_error_code() );
+		$this->assertNotWPError( $read );
+		$this->assertSame( $created['product']['variation_ids'], $read['products'][0]['variation_ids'] );
+	}
+
+	/**
+	 * @testdox Should create a variable product with a global attribute, its terms and two variations.
+	 */
+	public function test_product_create_uses_a_global_attribute(): void {
+		$attribute = \WC_Helper_Product::create_attribute( 'size', array( 'small', 'large' ) );
+		$created   = wp_get_ability( 'woocommerce/product-create' )->execute( $this->get_global_size_shirt_input( $attribute['attribute_id'], 'large' ) );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+
+		$product = wc_get_product( $created['product']['id'] );
+		$stored  = $product->get_attributes()['pa_size'] ?? null;
+		$medium  = get_term_by( 'slug', 'medium', 'pa_size' );
+		$this->assertInstanceOf( \WC_Product_Attribute::class, $stored );
+		$this->assertTrue( $stored->is_taxonomy() );
+		$this->assertSame( $attribute['attribute_id'], $stored->get_id() );
+		$this->assertInstanceOf( \WP_Term::class, $medium );
+		$this->assertEqualsCanonicalizing( array_merge( $attribute['term_ids'], array( $medium->term_id ) ), $stored->get_options() );
+		$this->assertEqualsCanonicalizing( array( 'small', 'large', 'medium' ), wp_get_post_terms( $product->get_id(), 'pa_size', array( 'fields' => 'slugs' ) ) );
+
+		list( $small_id, $large_id ) = $created['product']['variation_ids'];
+		$this->assertSame( 'small', get_post_meta( $small_id, 'attribute_pa_size', true ) );
+		$this->assertSame( 'large', get_post_meta( $large_id, 'attribute_pa_size', true ) );
+		$this->assertSame( $attribute['attribute_id'], $created['product']['attributes'][0]['id'] );
+		$this->assertSame( 'size', $created['product']['attributes'][0]['name'] );
+	}
+
+	/**
+	 * @testdox Should refuse a variation whose option is not a term of the global attribute.
+	 */
+	public function test_product_create_refuses_a_variation_option_that_is_not_a_term(): void {
+		$attribute = \WC_Helper_Product::create_attribute( 'size', array( 'small', 'large' ) );
+		$result    = wp_get_ability( 'woocommerce/product-create' )->execute_change( $this->get_global_size_shirt_input( $attribute['attribute_id'], 'xl' ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_product_variation_attributes_invalid', $result->get_error_code() );
+		$this->assertFalse( get_term_by( 'slug', 'xl', 'pa_size' ) );
+	}
+
+	/**
+	 * @testdox Should return the global attribute id, name and term slugs from products-query.
+	 */
+	public function test_products_query_returns_global_attributes(): void {
+		$attribute = \WC_Helper_Product::create_attribute( 'size', array( 'small', 'large' ) );
+		$created   = wp_get_ability( 'woocommerce/product-create' )->execute( $this->get_global_size_shirt_input( $attribute['attribute_id'], 'large' ) );
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+
+		$parent     = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => $created['product']['id'] ) );
+		$variations = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'parent_id' => $created['product']['id'] ) );
+		$this->assertNotWPError( $parent );
+		$this->assertNotWPError( $variations );
+
+		$parent_attribute = $parent['products'][0]['attributes'][0];
+		$this->assertSame( $attribute['attribute_id'], $parent_attribute['id'] );
+		$this->assertSame( 'size', $parent_attribute['name'] );
+		$this->assertEqualsCanonicalizing( array( 'small', 'large', 'medium' ), $parent_attribute['options'] );
+
+		$options = array();
+		foreach ( $variations['products'] as $variation ) {
+			$this->assertSame( $attribute['attribute_id'], $variation['attributes'][0]['id'] );
+			$this->assertSame( 'size', $variation['attributes'][0]['name'] );
+			$options[] = $variation['attributes'][0]['option'];
+		}
+		$this->assertEqualsCanonicalizing( array( 'small', 'large' ), $options );
+	}
+
+	/**
+	 * The input of a variable shirt with the global size attribute and two
+	 * variations. The parent names the attribute by name and adds the term
+	 * Medium, which does not exist yet. The first variation names the
+	 * attribute by name, the second by id.
+	 *
+	 * @param int    $attribute_id  ID of the global size attribute.
+	 * @param string $second_option Option of the second variation.
+	 * @return array
+	 */
+	private function get_global_size_shirt_input( int $attribute_id, string $second_option ): array {
+		return array(
+			'name'       => 'Global shirt',
+			'type'       => 'variable',
+			'attributes' => array(
+				array(
+					'name'    => 'size',
+					'global'  => true,
+					'options' => array( 'small', 'Large', 'Medium' ),
+				),
+			),
+			'variations' => array(
+				array(
+					'attributes'    => array(
+						array(
+							'name'   => 'size',
+							'option' => 'small',
+						),
+					),
+					'regular_price' => '10.00',
+				),
+				array(
+					'attributes'    => array(
+						array(
+							'id'     => $attribute_id,
+							'option' => $second_option,
+						),
+					),
+					'regular_price' => '12.00',
+				),
+			),
+		);
+	}
+
+	/**
+	 * The input of a variable shirt with two variations.
+	 *
+	 * @return array
+	 */
+	private function get_shirt_input(): array {
+		return array(
+			'name'       => 'Shirt',
+			'type'       => 'variable',
+			'attributes' => array(
+				array(
+					'name'    => 'Size',
+					'options' => array( 'S', 'M' ),
+				),
+			),
+			'variations' => array(
+				array(
+					'attributes'    => array(
+						array(
+							'name'   => 'Size',
+							'option' => 'S',
+						),
+					),
+					'sku'           => 'shirt-s',
+					'regular_price' => '10.00',
+				),
+				array(
+					'attributes'     => array(
+						array(
+							'name'   => 'Size',
+							'option' => 'M',
+						),
+					),
+					'sku'            => 'shirt-m',
+					'regular_price'  => '12.00',
+					'manage_stock'   => true,
+					'stock_quantity' => 3,
+				),
+			),
+		);
+	}
+
 	/**
 	 * Add test product types to the product type registry.
 	 *
@@ -2697,9 +3713,9 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should reject product variation IDs when deleting products.
+	 * @testdox Should delete a product variation by ID.
 	 */
-	public function test_product_delete_rejects_variation_ids(): void {
+	public function test_product_delete_accepts_variation_ids(): void {
 		$variation_id = $this->create_variation_product_id_for_test();

 		$result = wp_get_ability( 'woocommerce/product-delete' )->execute(
@@ -2709,54 +3725,9 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 			)
 		);

-		$this->assertWPError( $result );
-		$this->assertSame( 'woocommerce_product_type_unsupported', $result->get_error_code() );
-		$this->assertNotNull( wc_get_product( $variation_id ) );
-	}
-
-	/**
-	 * @testdox Should refuse a type change on a variable product, because WooCommerce would delete its variations.
-	 */
-	public function test_product_update_rejects_a_type_change_on_a_variable_product(): void {
-		$variable                    = \WC_Helper_Product::create_variation_product();
-		$this->created_product_ids[] = $variable->get_id();
-		$children                    = $variable->get_children();
-
-		$result = wp_get_ability( 'woocommerce/product-update' )->execute(
-			array(
-				'id'   => $variable->get_id(),
-				'type' => 'simple',
-			)
-		);
-
-		$this->assertWPError( $result );
-		$this->assertSame( 'woocommerce_product_type_unsupported', $result->get_error_code() );
-		$this->assertSame( 'variable', wc_get_product( $variable->get_id() )->get_type() );
-		$this->assertSame( $children, wc_get_product( $variable->get_id() )->get_children() );
-	}
-
-	/**
-	 * @testdox Should reject product variation IDs when updating products, with and without a type.
-	 */
-	public function test_product_update_rejects_variation_ids(): void {
-		$variation_id = $this->create_variation_product_id_for_test();
-
-		foreach ( array( array(), array( 'type' => 'simple' ) ) as $type_input ) {
-			$result = wp_get_ability( 'woocommerce/product-update' )->execute(
-				array_merge(
-					array(
-						'id'   => $variation_id,
-						'name' => 'Changed variation',
-					),
-					$type_input
-				)
-			);
-
-			$this->assertWPError( $result );
-			$this->assertSame( 'woocommerce_product_type_unsupported', $result->get_error_code() );
-		}
-
-		$this->assertSame( 'variation', wc_get_product( $variation_id )->get_type() );
+		$this->assertNotWPError( $result );
+		$this->assertTrue( $result['deleted'] );
+		$this->assertNull( get_post( $variation_id ) );
 	}

 	/**
@@ -2878,15 +3849,18 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should reject product variation IDs when querying products by ID.
+	 * @testdox Should return a product variation by ID with its parent and attributes.
 	 */
-	public function test_products_query_rejects_variation_ids(): void {
+	public function test_products_query_accepts_variation_ids(): void {
 		$variation_id = $this->create_variation_product_id_for_test();
+		$variation    = wc_get_product( $variation_id );

 		$result = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => $variation_id ) );

-		$this->assertWPError( $result );
-		$this->assertSame( 'woocommerce_product_type_unsupported', $result->get_error_code() );
+		$this->assertNotWPError( $result );
+		$this->assertSame( 'variation', $result['products'][0]['type'] );
+		$this->assertSame( $variation->get_parent_id(), $result['products'][0]['parent_id'] );
+		$this->assertSame( count( $variation->get_attributes() ), count( $result['products'][0]['attributes'] ) );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index bfb29b013b0..2f51ce0f46f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -2236,321 +2236,1545 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should call WC_Order::update_status() with the feature off, and refuse an order class that only overrides it with the feature on.
+	 * @testdox Should run a sync once with every saved object, the primary object first, inside the transaction.
 	 */
-	public function test_the_feature_off_path_calls_update_status(): void {
-		$calls        = 0;
-		$order_class  = get_class(
-			new class() extends \WC_Order {
-				/**
-				 * Calls of update_status().
-				 *
-				 * @var int
-				 */
-				public static $calls = 0;
+	public function test_a_sync_runs_once_with_every_saved_object(): void {
+		$calls = array();
+		$this->register_sync(
+			'product',
+			'test-ext',
+			static function ( array $saved, ActionableAbility $ability ) use ( &$calls ) {
+				$calls[] = array( $ability->get_name(), array_map( 'get_class', $saved ), array_map( static fn( \WC_Data $saved_object ) => $saved_object->get_id(), $saved ) );
+				$prices  = array();
+				foreach ( $saved as $object ) {
+					if ( $object instanceof \WC_Product_Variation ) {
+						$prices[] = (float) $object->get_regular_price();
+					}
+				}
+				if ( $prices && $saved[0] instanceof \WC_Product_Variable ) {
+					$saved[0]->update_meta_data( '_test_min_price', min( $prices ) );
+					$saved[0]->save();
+				}
+				return null;
+			}
+		);

-				/**
-				 * Count the call, then update the status.
-				 *
-				 * @param string $new_status    Status to change the order to.
-				 * @param string $note          Optional note.
-				 * @param bool   $manual_update Whether a person made the change.
-				 * @return bool
-				 */
-				public function update_status( $new_status, $note = '', $manual_update = false ) {
-					++self::$calls;
-					return parent::update_status( $new_status, $note, $manual_update );
+		$created = $this->run_ability( 'woocommerce/product-create', $this->get_shirt_input() );
+
+		$this->assertCount( 1, $calls );
+		$this->assertSame( 'woocommerce/product-create', $calls[0][0] );
+		$this->assertSame( array( 'WC_Product_Variable', 'WC_Product_Variation', 'WC_Product_Variation' ), $calls[0][1] );
+		$this->assertSame( array_merge( array( $created['product']['id'] ), $created['product']['variation_ids'] ), $calls[0][2] );
+		$this->assertSame( '10', wc_get_product( $created['product']['id'] )->get_meta( '_test_min_price' ) );
+	}
+
+	/**
+	 * @testdox Should give a sync the deleted objects and the input of the call.
+	 */
+	public function test_a_sync_gets_the_deleted_objects_and_the_input(): void {
+		$contexts = array();
+		$this->register_sync(
+			'product',
+			'test-ext',
+			static function ( array $saved, ActionableAbility $ability, array $context ) use ( &$contexts ) {
+				$contexts[] = array(
+					'saved'   => array_map( static fn( \WC_Data $item ) => $item->get_id(), $saved ),
+					'deleted' => array_map( static fn( \WC_Data $item ) => $item->get_id(), $context['deleted'] ),
+					'trashed' => array_map( static fn( \WC_Data $item ) => $item->get_id(), $context['trashed'] ),
+					'input'   => $context['input'],
+				);
+				return null;
+			}
+		);
+
+		$created = $this->run_ability( 'woocommerce/product-create', $this->get_shirt_input() );
+		$input   = array(
+			'id'         => $created['product']['id'],
+			'variations' => array(
+				array(
+					'id'     => $created['product']['variation_ids'][1],
+					'delete' => true,
+				),
+			),
+		);
+		$this->run_ability( 'woocommerce/product-update', $input );
+		$this->run_ability(
+			'woocommerce/product-delete',
+			array(
+				'id'    => $created['product']['variation_ids'][0],
+				'force' => true,
+			)
+		);
+		$this->run_ability( 'woocommerce/product-delete', array( 'id' => $created['product']['id'] ) );
+		$this->run_ability(
+			'woocommerce/product-delete',
+			array(
+				'id'    => $created['product']['id'],
+				'force' => true,
+			)
+		);
+
+		$this->assertCount( 5, $contexts );
+		$this->assertSame( array(), $contexts[0]['deleted'] );
+		$this->assertSame( array( $created['product']['id'] ), $contexts[1]['saved'] );
+		$this->assertSame( array( $created['product']['variation_ids'][1] ), $contexts[1]['deleted'] );
+		$this->assertSame( $input, $contexts[1]['input'] );
+		$this->assertSame( array( $created['product']['id'] ), $contexts[2]['saved'], 'A variation delete saves the parent.' );
+		$this->assertSame( array( $created['product']['variation_ids'][0] ), $contexts[2]['deleted'] );
+		$this->assertSame( array(), $contexts[3]['saved'] );
+		$this->assertSame( array(), $contexts[3]['deleted'], 'A trash deletes nothing for good.' );
+		$this->assertSame( array( $created['product']['id'] ), $contexts[3]['trashed'], 'A trash reports the parent as trashed.' );
+		$this->assertSame( array( $created['product']['id'] ), $contexts[4]['deleted'], 'A parent delete reports the parent; its variations were deleted before.' );
+		$this->assertSame( array(), $contexts[4]['trashed'] );
+	}
+
+	/**
+	 * @testdox Should give a sync the parent that a variation update saves.
+	 */
+	public function test_a_sync_gets_the_parent_of_an_updated_variation(): void {
+		$saved = array();
+		$this->register_sync(
+			'product',
+			'test-ext',
+			static function ( array $objects ) use ( &$saved ) {
+				$saved = array_map( static fn( \WC_Data $item ) => $item->get_id(), $objects );
+				return null;
+			}
+		);
+		$created = $this->run_ability( 'woocommerce/product-create', $this->get_shirt_input() );
+
+		$this->run_ability(
+			'woocommerce/product-update',
+			array(
+				'id'            => $created['product']['variation_ids'][0],
+				'regular_price' => '8.00',
+			)
+		);
+
+		$this->assertSame( array( $created['product']['variation_ids'][0], $created['product']['id'] ), $saved );
+		$this->assertSame( '8.00', wc_get_product( $created['product']['id'] )->get_price() );
+	}
+
+	/**
+	 * @testdox Should give a validator every object of the call, with the field writes of all of them applied.
+	 */
+	public function test_a_validator_sees_the_other_variations_of_the_call(): void {
+		$seen = array();
+		$this->register_validator(
+			'product',
+			'test-ext',
+			static function ( \WC_Product $product, ActionableAbility $ability, array $context ) use ( &$seen ) {
+				if ( $product instanceof \WC_Product_Variation ) {
+					$seen[ $product->get_sku() ] = array_map( static fn( \WC_Product $item ) => $item->get_meta( '_test_color' ), $context['objects'] );
 				}
+				return null;
 			}
 		);
-		$filter_class = static fn() => $order_class;
-		add_filter( 'woocommerce_order_class', $filter_class, 100 );
+		$input                                = $this->get_shirt_input();
+		$input['variations'][0]['extensions'] = array( 'test-ext' => array( 'color' => 'red' ) );
+		$input['variations'][1]['extensions'] = array( 'test-ext' => array( 'color' => 'blue' ) );

-		foreach ( array( false, true ) as $enabled ) {
-			$this->set_feature( $enabled );
-			$this->reset_registries();
-			$order_class::$calls = 0;
-			$order               = \WC_Helper_Order::create_order();
+		$this->run_ability( 'woocommerce/product-create', $input );
+
+		$this->assertSame( array( '', 'red', 'blue' ), $seen['shirt-s'], 'The first variation sees the color of the second one.' );
+		$this->assertSame( array( '', 'red', 'blue' ), $seen['shirt-m'] );
+	}
+
+	/**
+	 * @testdox Should drop the cache of a deleted product when a delete hook throws.
+	 */
+	public function test_a_throwing_delete_hook_drops_the_cache_of_the_product(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$cleaned = array();
+		$record  = static function ( $id ) use ( &$cleaned ) {
+			$cleaned[] = (int) $id;
+		};
+		$this->add_test_action(
+			'woocommerce_delete_product',
+			static function () use ( $record ) {
+				add_action( 'clean_post_cache', $record );
+				throw new \RuntimeException( 'Delete hook failed.' );
+			}
+		);
+
+		try {
+			$result = wp_get_ability( 'woocommerce/product-delete' )->execute(
+				array(
+					'id'    => $product->get_id(),
+					'force' => true,
+				)
+			);
+		} finally {
+			remove_action( 'clean_post_cache', $record );
+		}
+
+		$this->assertWPError( $result );
+		$this->assertContains( $product->get_id(), $cleaned );
+	}
+
+	/**
+	 * @testdox Should drop the cache of an order when a sync refuses the change.
+	 */
+	public function test_a_sync_refusal_drops_the_cache_of_the_order(): void {
+		$this->register_order_status_ability();
+		$order   = \WC_Helper_Order::create_order();
+		$cleaned = array();
+		$record  = static function ( $id ) use ( &$cleaned ) {
+			$cleaned[] = (int) $id;
+		};
+		$this->register_sync(
+			'order',
+			'test-ext',
+			static function () use ( $record ) {
+				add_action( 'clean_post_cache', $record );
+				return new \WP_Error( 'test_refused', 'Refused.' );
+			}
+		);

+		try {
 			$response = $this->run_ability_response(
-				'woocommerce/order-update-status',
+				'test-ext/order-status',
 				array(
 					'id'     => $order->get_id(),
 					'status' => 'completed',
 				)
 			);
-
-			$calls = $order_class::$calls;
-			if ( $enabled ) {
-				$this->assertSame( 400, $response->get_status() );
-				$this->assertSame( 'woocommerce_order_status_staging_unsupported', $response->get_data()['code'] );
-				$this->assertSame( 0, $calls );
-				$this->assertSame( 'pending', wc_get_order( $order->get_id() )->get_status() );
-			} else {
-				$this->assertSame( 200, $response->get_status() );
-				$this->assertSame( 1, $calls );
-				$this->assertSame( 'completed', wc_get_order( $order->get_id() )->get_status() );
-			}
+		} finally {
+			remove_action( 'clean_post_cache', $record );
 		}

-		remove_filter( 'woocommerce_order_class', $filter_class, 100 );
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertContains( $order->get_id(), $cleaned );
+		$this->assertNotSame( 'completed', wc_get_order( $order->get_id() )->get_status() );
 	}

 	/**
-	 * @testdox Should accept null for a field with an enum, to delete its value, and still refuse a value outside the enum.
+	 * @testdox Should drop the meta cache of a product when a sync refuses the change.
 	 */
-	public function test_a_null_value_deletes_the_value_of_an_enum_field(): void {
+	public function test_a_sync_refusal_drops_the_meta_cache_of_the_product(): void {
 		$product = \WC_Helper_Product::create_simple_product();
-		$product->update_meta_data( '_test_size', 'small' );
+		$product->update_meta_data( '_test_color', 'red' );
 		$product->save();
+		$this->register_sync(
+			'product',
+			'test-ext',
+			static function ( array $saved ) {
+				wc_get_product( $saved[0]->get_id() )->get_meta( '_test_color' );
+				return new \WP_Error( 'test_refused', 'Refused.' );
+			}
+		);

-		$schema   = $this->get_ability( 'woocommerce/product-update' )['input_schema']['properties']['extensions']['properties']['other_ext']['properties']['size'];
-		$rejected = $this->run_ability_response(
+		$response = $this->run_ability_response(
 			'woocommerce/product-update',
 			array(
 				'id'         => $product->get_id(),
-				'extensions' => array( 'other_ext' => array( 'size' => 'medium' ) ),
+				'extensions' => array( 'test-ext' => array( 'color' => 'green' ) ),
 			)
 		);
-		$this->run_ability(
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'red', get_post_meta( $product->get_id(), '_test_color', true ) );
+		$this->assertSame( 'red', wc_get_product( $product->get_id() )->get_meta( '_test_color' ) );
+	}
+
+	/**
+	 * @testdox Should let a validator refuse a delete, and then delete nothing.
+	 */
+	public function test_a_validator_can_refuse_a_delete(): void {
+		$this->register_validator(
+			'product',
+			'test-ext',
+			static fn( \WC_Product $product, ActionableAbility $ability ) => 'woocommerce/product-delete' === $ability->get_name() && 'keep' === $product->get_sku()
+				? new \WP_Error( 'test_keep', 'Keep it.' )
+				: null
+		);
+		$created = $this->run_ability( 'woocommerce/product-create', $this->get_shirt_input() );
+		$kept    = \WC_Helper_Product::create_simple_product( true, array( 'sku' => 'keep' ) );
+
+		$refused = $this->run_ability_response(
+			'woocommerce/product-delete',
+			array(
+				'id'    => $kept->get_id(),
+				'force' => true,
+			)
+		);
+		$deleted = $this->run_ability(
+			'woocommerce/product-delete',
+			array(
+				'id'    => $created['product']['id'],
+				'force' => true,
+			)
+		);
+
+		$this->assertSame( 400, $refused->get_status() );
+		$this->assertStringEndsWith( 'test_keep', $refused->get_data()['code'] );
+		$this->assertInstanceOf( \WC_Product::class, wc_get_product( $kept->get_id() ) );
+		$this->assertTrue( $deleted['deleted'] );
+		$this->assertFalse( wc_get_product( $created['product']['id'] ) );
+		$this->assertNull( get_post( $created['product']['variation_ids'][0] ), 'The variations go with the parent.' );
+	}
+
+	/**
+	 * @testdox Should give a validator the primary object, so a new variation can read its unsaved parent.
+	 */
+	public function test_a_validator_gets_the_primary_object(): void {
+		$seen = array();
+		$this->register_validator(
+			'product',
+			'test-ext',
+			static function ( \WC_Product $product, ActionableAbility $ability, array $context ) use ( &$seen ) {
+				if ( $product instanceof \WC_Product_Variation ) {
+					$seen[] = array( $product->get_parent_id(), $context['primary']->get_name(), $context['deleting'] );
+				}
+				return null;
+			}
+		);
+
+		$this->run_ability( 'woocommerce/product-create', $this->get_shirt_input() );
+
+		$this->assertSame(
+			array(
+				array( 0, 'Shirt', false ),
+				array( 0, 'Shirt', false ),
+			),
+			$seen
+		);
+	}
+
+	/**
+	 * @testdox Should run validators on a variation that a parent update deletes, and delete nothing when one refuses.
+	 */
+	public function test_a_validator_can_refuse_deleting_a_variation_in_an_update(): void {
+		$created = $this->run_ability( 'woocommerce/product-create', $this->get_shirt_input() );
+		$kept    = $created['product']['variation_ids'][0];
+		$other   = $created['product']['variation_ids'][1];
+		$seen    = array();
+		$this->register_validator(
+			'product',
+			'test-ext',
+			static function ( \WC_Product $product, ActionableAbility $ability, array $context ) use ( &$seen, $kept ) {
+				$seen[ $product->get_id() ] = $context['deleting'];
+				return $context['deleting'] && $kept === $product->get_id()
+					? new \WP_Error( 'test_keep', 'Keep it.' )
+					: null;
+			}
+		);
+		$this->reset_registries();
+		AbilitiesLoader::init_extensions();
+
+		$refused = $this->run_ability_response(
 			'woocommerce/product-update',
 			array(
-				'id'         => $product->get_id(),
-				'extensions' => array( 'other_ext' => array( 'size' => null ) ),
+				'id'         => $created['product']['id'],
+				'name'       => 'Renamed',
+				'variations' => array(
+					array(
+						'id'     => $kept,
+						'delete' => true,
+					),
+				),
+			)
+		);
+		$deleted = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'         => $created['product']['id'],
+				'variations' => array(
+					array(
+						'id'     => $other,
+						'delete' => true,
+					),
+				),
+			)
+		);
+
+		$this->assertSame( 400, $refused->get_status() );
+		$this->assertSame( 'Keep it.', $refused->get_data()['message'] );
+		$this->assertSame( 'Shirt', wc_get_product( $created['product']['id'] )->get_name() );
+		$this->assertInstanceOf( \WC_Product_Variation::class, wc_get_product( $kept ) );
+		$this->assertSame( 200, $deleted->get_status() );
+		$this->assertTrue( $seen[ $other ] );
+		$this->assertFalse( $seen[ $created['product']['id'] ] );
+		$this->assertNull( get_post( $other ) );
+	}
+
+	/**
+	 * @testdox Should run validators on the variations of a deleted parent, and delete nothing when one refuses.
+	 */
+	public function test_a_validator_can_refuse_deleting_a_variable_parent(): void {
+		$created = $this->run_ability( 'woocommerce/product-create', $this->get_shirt_input() );
+		$kept    = $created['product']['variation_ids'][1];
+		$seen    = array();
+		$refuse  = true;
+		$this->register_validator(
+			'product',
+			'test-ext',
+			static function ( \WC_Product $product, ActionableAbility $ability, array $context ) use ( &$seen, &$refuse, $kept ) {
+				$seen[ $product->get_id() ] = array( $ability->get_name(), $context['deleting'] );
+				return $refuse && $kept === $product->get_id()
+					? new \WP_Error( 'test_keep', 'Keep it.' )
+					: null;
+			}
+		);
+		$this->reset_registries();
+		AbilitiesLoader::init_extensions();
+		$input = array(
+			'id'    => $created['product']['id'],
+			'force' => true,
+		);
+
+		$refused = $this->run_ability_response( 'woocommerce/product-delete', $input );
+		$after   = wc_get_product( $created['product']['id'] );
+		$refuse  = false;
+		$deleted = $this->run_ability( 'woocommerce/product-delete', $input );
+
+		$this->assertSame( 400, $refused->get_status() );
+		$this->assertSame( 'Keep it.', $refused->get_data()['message'] );
+		$this->assertInstanceOf( \WC_Product_Variable::class, $after );
+		$this->assertTrue( $deleted['deleted'] );
+		foreach ( $created['product']['variation_ids'] as $variation_id ) {
+			$this->assertSame( array( 'woocommerce/product-delete', true ), $seen[ $variation_id ] );
+		}
+		$this->assertSame( array( 'woocommerce/product-delete', true ), $seen[ $created['product']['id'] ] );
+	}
+
+	/**
+	 * @testdox Should return the stored slug from product-create, for the product and its variations.
+	 */
+	public function test_product_create_returns_the_stored_slug(): void {
+		$created = $this->run_ability(
+			'woocommerce/product-create',
+			array_merge( $this->get_shirt_input(), array( 'status' => 'publish' ) )
+		);
+
+		$this->assertSame( 'shirt', $created['product']['slug'] );
+		$this->assertSame( get_post_field( 'post_name', $created['variations'][0]['id'] ), $created['variations'][0]['slug'] );
+	}
+
+	/**
+	 * @testdox Should undo every save of the call when a sync returns a WP_Error.
+	 */
+	public function test_a_refusal_in_a_sync_rolls_back_the_call(): void {
+		$this->register_sync(
+			'product',
+			'test-ext',
+			static function ( array $saved ) {
+				return $saved[0] instanceof \WC_Product_Variable
+					? new \WP_Error( 'test_range_refused', 'The range is not allowed.', array( 'status' => 409 ) )
+					: null;
+			}
+		);
+
+		$response = $this->run_ability_response( 'woocommerce/product-create', $this->get_shirt_input() );
+
+		$this->assertSame( 409, $response->get_status() );
+		$this->assertStringEndsWith( 'test_range_refused', $response->get_data()['code'] );
+		$this->assertSame( 'test-ext', $response->get_data()['data']['namespace'] );
+		$this->assertSame( 0, wc_get_product_id_by_sku( 'shirt-s' ) );
+		$this->assertSame(
+			array(),
+			get_posts(
+				array(
+					'post_type'   => array( 'product', 'product_variation' ),
+					'post_status' => 'any',
+					'fields'      => 'ids',
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should not run a sync with the feature off.
+	 */
+	public function test_a_sync_does_not_run_with_the_feature_off(): void {
+		$fired = 0;
+		$this->register_sync(
+			'product',
+			'test-ext',
+			static function () use ( &$fired ) {
+				++$fired;
+			}
+		);
+		$this->reset_registries();
+		AbilitiesLoader::init_extensions();
+		$this->set_feature( false );
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+
+		$output = $this->run_ability(
+			'woocommerce/product-update',
+			array(
+				'id'   => $product->get_id(),
+				'name' => 'Pencil',
+			)
+		);
+
+		$this->assertNotEmpty( ExtensibleAbilities::get_syncs( 'product' ) );
+		$this->assertSame( 'Pencil', $output['product']['name'] );
+		$this->assertSame( 0, $fired );
+	}
+
+	/**
+	 * @testdox Should not run a sync of a resource that the call did not save.
+	 */
+	public function test_a_sync_of_a_resource_that_was_not_saved_does_not_run(): void {
+		$fired = 0;
+		$this->register_sync(
+			'order_item',
+			'test-ext',
+			static function () use ( &$fired ) {
+				++$fired;
+			}
+		);
+
+		$this->run_ability( 'woocommerce/product-create', $this->get_shirt_input() );
+
+		$this->assertSame( 0, $fired );
+	}
+
+	/**
+	 * @testdox Should refuse a sync on a resource that is not registered.
+	 */
+	public function test_a_sync_on_an_unregistered_resource_is_refused(): void {
+		$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_sync' );
+		$this->register_sync( 'refund', 'test-ext', '__return_null' );
+		$this->reset_registries();
+		AbilitiesLoader::init_extensions();
+
+		$this->assertEmpty( ExtensibleAbilities::get_syncs( 'refund' ) );
+	}
+
+	/**
+	 * @testdox Should run the validators of a secondary object's own resource, and not those of the primary object's resource.
+	 */
+	public function test_a_secondary_object_gets_the_validators_of_its_own_resource(): void {
+		$checked = array(
+			'product'    => array(),
+			'order_item' => array(),
+		);
+		foreach ( array_keys( $checked ) as $resource_name ) {
+			$this->register_validator(
+				$resource_name,
+				'test-ext',
+				static function ( \WC_Data $subject ) use ( &$checked, $resource_name ) {
+					$checked[ $resource_name ][] = get_class( $subject );
+					return null;
+				}
+			);
+		}
+		$this->register_product_with_item_ability();
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+
+		$this->run_ability(
+			'test-ext/product-with-item',
+			array(
+				'id'   => $product->get_id(),
+				'name' => 'Pencil',
+			)
+		);
+
+		$this->assertSame( array( 'WC_Product_Simple' ), $checked['product'] );
+		$this->assertSame( array( 'WC_Order_Item_Product' ), $checked['order_item'] );
+	}
+
+	/**
+	 * @testdox Should refuse the call and save nothing when a secondary object names a resource that is not registered.
+	 */
+	public function test_a_secondary_object_with_an_unregistered_resource_is_refused(): void {
+		$this->setExpectedIncorrectUsage( TestProductWithItemAbility::class . '::change' );
+		$this->register_product_with_item_ability();
+		TestProductWithItemAbility::$item_resource = 'refund';
+		$product                                   = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+
+		try {
+			$response = $this->run_ability_response(
+				'test-ext/product-with-item',
+				array(
+					'id'   => $product->get_id(),
+					'name' => 'Pencil',
+				)
+			);
+		} finally {
+			TestProductWithItemAbility::$item_resource = 'order_item';
+		}
+
+		$this->assertSame( 500, $response->get_status() );
+		$this->assertSame( 'woocommerce_ability_resource_unknown', $response->get_data()['code'] );
+		$this->assertSame( 'refund', $response->get_data()['data']['resource'] );
+		$this->assertSame( 'test-ext/product-with-item', $response->get_data()['data']['ability'] );
+		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	}
+
+	/**
+	 * Register the test ability that renames a product and returns an order item as a secondary object.
+	 */
+	private function register_product_with_item_ability(): void {
+		$this->add_test_action(
+			'wp_abilities_api_init',
+			static function () {
+				wp_register_ability(
+					'test-ext/product-with-item',
+					array(
+						'label'               => 'Rename product with an item',
+						'description'         => 'Renames a product and changes an order item.',
+						'category'            => 'woocommerce',
+						'input_schema'        => array(
+							'type'       => 'object',
+							'properties' => array(
+								'id'   => array( 'type' => 'integer' ),
+								'name' => array( 'type' => 'string' ),
+							),
+						),
+						'output_schema'       => array( 'type' => 'object' ),
+						'permission_callback' => '__return_true',
+						'ability_class'       => TestProductWithItemAbility::class,
+						'meta'                => array( 'show_in_rest' => true ),
+					)
+				);
+			},
+			20
+		);
+	}
+
+	/**
+	 * The input of a variable shirt with two variations.
+	 *
+	 * @return array
+	 */
+	private function get_shirt_input(): array {
+		return array(
+			'name'       => 'Shirt',
+			'type'       => 'variable',
+			'attributes' => array(
+				array(
+					'name'    => 'Size',
+					'options' => array( 'S', 'M' ),
+				),
+			),
+			'variations' => array(
+				array(
+					'attributes'    => array(
+						array(
+							'name'   => 'Size',
+							'option' => 'S',
+						),
+					),
+					'sku'           => 'shirt-s',
+					'regular_price' => '10.00',
+				),
+				array(
+					'attributes'    => array(
+						array(
+							'name'   => 'Size',
+							'option' => 'M',
+						),
+					),
+					'sku'           => 'shirt-m',
+					'regular_price' => '12.00',
+				),
+			),
+		);
+	}
+
+	/**
+	 * @testdox Should write extension fields to each variation and run validators on them before any save.
+	 */
+	public function test_variations_get_extension_fields_and_validators(): void {
+		$this->register_validator(
+			'product',
+			'other_ext',
+			static function ( \WC_Product $subject ) {
+				return $subject instanceof \WC_Product_Variation && 'red' === $subject->get_meta( '_test_color' )
+					? new \WP_Error( 'test_red_variation', 'No red variations.' )
+					: null;
+			}
+		);
+		$input = array(
+			'name'       => 'Shirt',
+			'type'       => 'variable',
+			'attributes' => array(
+				array(
+					'name'    => 'Size',
+					'options' => array( 'S', 'M' ),
+				),
+			),
+			'variations' => array(
+				array(
+					'attributes' => array(
+						array(
+							'name'   => 'Size',
+							'option' => 'S',
+						),
+					),
+					'extensions' => array( 'test-ext' => array( 'color' => 'blue' ) ),
+				),
+				array(
+					'attributes' => array(
+						array(
+							'name'   => 'Size',
+							'option' => 'M',
+						),
+					),
+					'extensions' => array( 'test-ext' => array( 'color' => 'red' ) ),
+				),
+			),
+		);
+
+		$schema  = wp_get_ability( 'woocommerce/product-create' )->get_input_schema();
+		$refused = $this->run_ability_response( 'woocommerce/product-create', $input );
+		$saved   = get_posts(
+			array(
+				'post_type'   => array( 'product', 'product_variation' ),
+				'post_status' => 'any',
+				'fields'      => 'ids',
+			)
+		);
+
+		$input['variations'][1]['extensions']['test-ext']['color'] = 'green';
+		$created = $this->run_ability( 'woocommerce/product-create', $input );
+
+		$this->assertArrayHasKey( 'color', $schema['properties']['variations']['items']['properties']['extensions']['properties']['test-ext']['properties'] );
+		$this->assertSame( 400, $refused->get_status() );
+		$this->assertStringEndsWith( 'test_red_variation', $refused->get_data()['code'] );
+		$this->assertSame( array(), $saved );
+
+		$colors = array_map(
+			static function ( int $variation_id ): string {
+				return wc_get_product( $variation_id )->get_meta( '_test_color' );
+			},
+			$created['product']['variation_ids']
+		);
+		$this->assertSame( array( 'blue', 'green' ), $colors );
+	}
+
+	/**
+	 * @testdox Should call WC_Order::update_status() with the feature off, and refuse an order class that only overrides it with the feature on.
+	 */
+	public function test_the_feature_off_path_calls_update_status(): void {
+		$calls        = 0;
+		$order_class  = get_class(
+			new class() extends \WC_Order {
+				/**
+				 * Calls of update_status().
+				 *
+				 * @var int
+				 */
+				public static $calls = 0;
+
+				/**
+				 * Count the call, then update the status.
+				 *
+				 * @param string $new_status    Status to change the order to.
+				 * @param string $note          Optional note.
+				 * @param bool   $manual_update Whether a person made the change.
+				 * @return bool
+				 */
+				public function update_status( $new_status, $note = '', $manual_update = false ) {
+					++self::$calls;
+					return parent::update_status( $new_status, $note, $manual_update );
+				}
+			}
+		);
+		$filter_class = static fn() => $order_class;
+		add_filter( 'woocommerce_order_class', $filter_class, 100 );
+
+		foreach ( array( false, true ) as $enabled ) {
+			$this->set_feature( $enabled );
+			$this->reset_registries();
+			$order_class::$calls = 0;
+			$order               = \WC_Helper_Order::create_order();
+
+			$response = $this->run_ability_response(
+				'woocommerce/order-update-status',
+				array(
+					'id'     => $order->get_id(),
+					'status' => 'completed',
+				)
+			);
+
+			$calls = $order_class::$calls;
+			if ( $enabled ) {
+				$this->assertSame( 400, $response->get_status() );
+				$this->assertSame( 'woocommerce_order_status_staging_unsupported', $response->get_data()['code'] );
+				$this->assertSame( 0, $calls );
+				$this->assertSame( 'pending', wc_get_order( $order->get_id() )->get_status() );
+			} else {
+				$this->assertSame( 200, $response->get_status() );
+				$this->assertSame( 1, $calls );
+				$this->assertSame( 'completed', wc_get_order( $order->get_id() )->get_status() );
+			}
+		}
+
+		remove_filter( 'woocommerce_order_class', $filter_class, 100 );
+	}
+
+	/**
+	 * @testdox Should accept null for a field with an enum, to delete its value, and still refuse a value outside the enum.
+	 */
+	public function test_a_null_value_deletes_the_value_of_an_enum_field(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$product->update_meta_data( '_test_size', 'small' );
+		$product->save();
+
+		$schema   = $this->get_ability( 'woocommerce/product-update' )['input_schema']['properties']['extensions']['properties']['other_ext']['properties']['size'];
+		$rejected = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'extensions' => array( 'other_ext' => array( 'size' => 'medium' ) ),
+			)
+		);
+		$this->run_ability(
+			'woocommerce/product-update',
+			array(
+				'id'         => $product->get_id(),
+				'extensions' => array( 'other_ext' => array( 'size' => null ) ),
+			)
+		);
+
+		$this->assertSame( array( 'small', 'large', null ), $schema['enum'] );
+		$this->assertSame( 400, $rejected->get_status() );
+		$this->assertFalse( wc_get_product( $product->get_id() )->meta_exists( '_test_size' ) );
+	}
+
+	/**
+	 * @testdox Should let an ActionableAbility of an extension accept the writable fields with no extra code.
+	 */
+	public function test_an_extension_actionable_ability_writes_the_fields(): void {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		$this->register_rename_ability();
+
+		$schema = $this->get_ability( 'test-ext/product-rename' )['input_schema']['properties']['extensions'];
+		$output = $this->run_ability(
+			'test-ext/product-rename',
+			array(
+				'id'         => $product->get_id(),
+				'name'       => 'Pencil',
+				'extensions' => array( 'test-ext' => array( 'color' => 'green' ) ),
+			)
+		);
+
+		$this->assertSame( array( 'string', 'null' ), $schema['properties']['test-ext']['properties']['color']['type'] );
+		$this->assertArrayNotHasKey( 'code', $schema['properties']['test-ext']['properties'] );
+		$this->assertSame( 'Pencil', wc_get_product( $product->get_id() )->get_name() );
+		$this->assertSame( array( 'color' => 'green' ), $output['extensions']['test-ext'] );
+	}
+
+	/**
+	 * @testdox Should return a fixed 500 error when the default save of an ActionableAbility throws.
+	 */
+	public function test_a_failed_default_save_returns_a_fixed_error(): void {
+		$product  = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		$register = static function () {
+			wp_register_ability(
+				'test-ext/product-rename',
+				array(
+					'label'               => 'Rename product',
+					'description'         => 'Renames a product.',
+					'category'            => 'woocommerce',
+					'input_schema'        => array(
+						'type'       => 'object',
+						'properties' => array(
+							'id'   => array( 'type' => 'integer' ),
+							'name' => array( 'type' => 'string' ),
+						),
+					),
+					'output_schema'       => array( 'type' => 'object' ),
+					'permission_callback' => '__return_true',
+					'ability_class'       => TestProductRenameAbility::class,
+					'meta'                => array( 'show_in_rest' => true ),
+				)
+			);
+		};
+		$throw    = static function () {
+			throw new \RuntimeException( 'Storage text.' );
+		};
+		add_action( 'wp_abilities_api_init', $register, 20 );
+		add_action( 'woocommerce_before_product_object_save', $throw );
+
+		$response = $this->run_ability_response(
+			'test-ext/product-rename',
+			array(
+				'id'   => $product->get_id(),
+				'name' => 'Pencil',
+			)
+		);
+
+		remove_action( 'woocommerce_before_product_object_save', $throw );
+		remove_action( 'wp_abilities_api_init', $register, 20 );
+		$this->assertSame( 500, $response->get_status() );
+		$this->assertSame( 'woocommerce_ability_save_failed', $response->get_data()['code'] );
+		$this->assertStringNotContainsString( 'Storage text.', wp_json_encode( $response->get_data() ) );
+		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	}
+
+	/**
+	 * @testdox Should return a fixed 500 error when the order write fails, though the order's save() catches the exception.
+	 */
+	public function test_a_failed_order_write_returns_a_fixed_error(): void {
+		$this->register_order_status_ability();
+		$order = \WC_Helper_Order::create_order();
+		$order->set_status( 'pending' );
+		$order->save();
+		$throw = static function () {
+			throw new \RuntimeException( 'Storage text.' );
+		};
+		add_action( 'woocommerce_before_order_object_save', $throw );
+
+		$response = $this->run_ability_response(
+			'test-ext/order-status',
+			array(
+				'id'     => $order->get_id(),
+				'status' => 'completed',
+			)
+		);
+
+		remove_action( 'woocommerce_before_order_object_save', $throw );
+		$this->assertSame( 500, $response->get_status() );
+		$this->assertSame( 'woocommerce_ability_save_failed', $response->get_data()['code'] );
+		$this->assertStringNotContainsString( 'Storage text.', wp_json_encode( $response->get_data() ) );
+		$this->assertSame( 'pending', wc_get_order( $order->get_id() )->get_status() );
+	}
+
+	/**
+	 * @testdox Should list the extensions key in anyOf and allOf branches that refuse other properties, and accept extension fields through an anyOf branch.
+	 */
+	public function test_combinator_branches_accept_the_extensions_key(): void {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+		$branch  = array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id'   => array( 'type' => 'integer' ),
+				'name' => array( 'type' => 'string' ),
+			),
+			'additionalProperties' => false,
+		);
+		foreach ( array( 'anyOf', 'allOf' ) as $keyword ) {
+			$name     = 'test-ext/product-rename-' . strtolower( $keyword );
+			$register = static function () use ( $name, $keyword, $branch ) {
+				wp_register_ability(
+					$name,
+					array(
+						'label'               => 'Rename product',
+						'description'         => 'Renames a product.',
+						'category'            => 'woocommerce',
+						'input_schema'        => array(
+							'type'   => 'object',
+							$keyword => array( $branch ),
+						),
+						'output_schema'       => ExtensibleAbilities::add_fields_schema( array( 'type' => 'object' ), 'product' ),
+						'permission_callback' => '__return_true',
+						'ability_class'       => TestProductRenameAbility::class,
+						'meta'                => array( 'show_in_rest' => true ),
+					)
+				);
+			};
+			add_action( 'wp_abilities_api_init', $register, 20 );
+			$this->reset_registries();
+
+			$schema = $this->get_ability( $name )['input_schema'];
+			$this->assertSame( 'object', $schema[ $keyword ][0]['properties']['extensions']['type'] ?? null, $keyword );
+
+			if ( 'anyOf' === $keyword ) {
+				$output = $this->run_ability(
+					$name,
+					array(
+						'id'         => $product->get_id(),
+						'name'       => 'Pencil',
+						'extensions' => array( 'test-ext' => array( 'color' => 'blue' ) ),
+					)
+				);
+				$this->assertSame( array( 'color' => 'blue' ), $output['extensions']['test-ext'] );
+			}
+
+			remove_action( 'wp_abilities_api_init', $register, 20 );
+		}
+	}
+
+	/**
+	 * @testdox Should list the writable fields one time at the top level of the product input schemas, plus one time for the variations.
+	 */
+	public function test_input_schemas_list_writable_fields_one_time(): void {
+		foreach ( array( 'woocommerce/product-create', 'woocommerce/product-update' ) as $name ) {
+			$schema     = $this->get_ability( $name )['input_schema'];
+			$extensions = $schema['properties']['extensions']['properties'];
+
+			$this->assertSame( array( 'string', 'null' ), $extensions['test-ext']['properties']['color']['type'] );
+			$this->assertSame( array( 'integer', 'null' ), $extensions['other_ext']['properties']['count']['type'] );
+			$this->assertArrayNotHasKey( 'code', $extensions['test-ext']['properties'] );
+			$this->assertSame( 2, substr_count( wp_json_encode( $schema ), '"color"' ) );
+			$this->assertArrayNotHasKey( 'oneOf', $schema );
+		}
+		$order = $this->get_ability( 'woocommerce/order-update-status' )['input_schema'];
+		$this->assertSame( array( 'string', 'null' ), $order['properties']['extensions']['properties']['test-ext']['properties']['gift_message']['type'] );
+		$delete = $this->get_ability( 'woocommerce/product-delete' )['input_schema'];
+		$this->assertArrayNotHasKey( 'extensions', $delete['properties'], 'A delete writes no extension fields.' );
+	}
+
+	/**
+	 * @testdox Should leave a field with object_types out of the other objects and refuse a write to them.
+	 */
+	public function test_object_types_limit_a_field_to_some_objects(): void {
+		$register = static function () {
+			ExtensibleAbilities::register_field(
+				array(
+					'resource'      => 'product',
+					'namespace'     => 'test-ext',
+					'field'         => 'plan',
+					'output_schema' => array( 'type' => 'string' ),
+					'input_schema'  => array( 'type' => 'string' ),
+					'object_types'  => array( 'simple' ),
+					'get_callback'  => static fn() => 'monthly',
+					'set_callback'  => static function ( \WC_Product $product, $value ) {
+						$product->update_meta_data( '_test_plan', $value );
+					},
+				)
+			);
+		};
+		add_action( 'woocommerce_extensible_abilities_init', $register );
+		$simple  = \WC_Helper_Product::create_simple_product();
+		$grouped = \WC_Helper_Product::create_grouped_product();
+
+		try {
+			$schema   = $this->get_ability( 'woocommerce/product-create' )['input_schema'];
+			$rejected = $this->run_ability_response(
+				'woocommerce/product-update',
+				array(
+					'id'         => $grouped->get_id(),
+					'extensions' => array( 'test-ext' => array( 'plan' => 'yearly' ) ),
+				)
+			);
+			$updated  = $this->run_ability(
+				'woocommerce/product-update',
+				array(
+					'id'         => $simple->get_id(),
+					'extensions' => array( 'test-ext' => array( 'plan' => 'yearly' ) ),
+				)
+			);
+			$read     = $this->run_ability( 'woocommerce/products-query', array( 'id' => $grouped->get_id() ) );
+		} finally {
+			remove_action( 'woocommerce_extensible_abilities_init', $register );
+		}
+
+		$this->assertSame( 'Applies to: simple.', $schema['properties']['extensions']['properties']['test-ext']['properties']['plan']['description'] );
+		$this->assertArrayHasKey( 'plan', $schema['properties']['extensions']['properties']['test-ext']['properties'] );
+		$this->assertSame( 400, $rejected->get_status() );
+		$this->assertSame( 'test-ext:woocommerce_ability_field_object_type', $rejected->get_data()['code'] );
+		$this->assertSame( 'The extensions.test-ext.plan field does not apply to the grouped type. It applies to simple.', $rejected->get_data()['message'] );
+		$this->assertSame( '', wc_get_product( $grouped->get_id() )->get_meta( '_test_plan' ) );
+		$this->assertSame( 'monthly', $updated['product']['extensions']['test-ext']['plan'] );
+		$this->assertSame( 'yearly', wc_get_product( $simple->get_id() )->get_meta( '_test_plan' ) );
+		$this->assertArrayNotHasKey( 'plan', $read['products'][0]['extensions']['test-ext'] ?? array() );
+	}
+
+	/**
+	 * @testdox Should list in the variation schemas only the fields that can apply to a variation.
+	 */
+	public function test_variation_schemas_list_only_the_fields_that_apply_to_a_variation(): void {
+		$this->add_test_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				$fields = array(
+					'simple_only'    => array( 'simple' ),
+					'variation_only' => array( 'variation' ),
+				);
+				foreach ( $fields as $field => $object_types ) {
+					ExtensibleAbilities::register_field(
+						array(
+							'resource'      => 'product',
+							'namespace'     => 'typed-ext',
+							'field'         => $field,
+							'output_schema' => array( 'type' => 'string' ),
+							'input_schema'  => array( 'type' => 'string' ),
+							'object_types'  => $object_types,
+							'get_callback'  => static fn() => 'value',
+							'set_callback'  => static function ( \WC_Product $product, $value ) use ( $field ) {
+								$product->update_meta_data( '_test_' . $field, $value );
+							},
+						)
+					);
+				}
+				ExtensibleAbilities::register_field(
+					array(
+						'resource'      => 'product',
+						'namespace'     => 'simple-only-ext',
+						'field'         => 'plan',
+						'output_schema' => array( 'type' => 'string' ),
+						'object_types'  => array( 'simple' ),
+						'get_callback'  => static fn() => 'value',
+					)
+				);
+			}
+		);
+		$this->reset_registries();
+
+		$ability = $this->get_ability( 'woocommerce/product-create' );
+		$output  = $this->run_ability( 'woocommerce/product-create', $this->get_shirt_input() );
+
+		$input_namespaces  = $ability['input_schema']['properties']['variations']['items']['properties']['extensions']['properties'];
+		$output_namespaces = $ability['output_schema']['properties']['variations']['items']['properties']['extensions']['properties'];
+		$product_input     = $ability['input_schema']['properties']['extensions']['properties'];
+		$this->assertArrayHasKey( 'simple_only', $product_input['typed-ext']['properties'] );
+		$this->assertArrayHasKey( 'variation_only', $input_namespaces['typed-ext']['properties'] );
+		$this->assertFalse( $ability['input_schema']['properties']['variations']['items']['properties']['extensions']['additionalProperties'] );
+		$this->assertFalse( $input_namespaces['typed-ext']['additionalProperties'] );
+		$this->assertArrayNotHasKey( 'simple_only', $input_namespaces['typed-ext']['properties'] );
+		$this->assertArrayHasKey( 'color', $input_namespaces['test-ext']['properties'], 'A field without object_types stays.' );
+		$this->assertArrayHasKey( 'variation_only', $output_namespaces['typed-ext']['properties'] );
+		$this->assertArrayNotHasKey( 'simple_only', $output_namespaces['typed-ext']['properties'] );
+		$this->assertArrayNotHasKey( 'simple-only-ext', $output_namespaces, 'A namespace with no field for a variation goes away.' );
+		$this->assertSame( 'value', $output['variations'][0]['extensions']['typed-ext']['variation_only'] );
+	}
+
+	/**
+	 * @testdox Should refuse a variation field that does not apply to a variation through the closed variation schema.
+	 */
+	public function test_refuses_a_variation_field_that_does_not_apply_to_a_variation(): void {
+		$this->add_test_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				ExtensibleAbilities::register_field(
+					array(
+						'resource'      => 'product',
+						'namespace'     => 'typed-ext',
+						'field'         => 'simple_only',
+						'output_schema' => array( 'type' => 'string' ),
+						'input_schema'  => array( 'type' => 'string' ),
+						'object_types'  => array( 'simple' ),
+						'get_callback'  => static fn() => 'value',
+						'set_callback'  => static function ( \WC_Product $product, $value ) {
+							$product->update_meta_data( '_test_simple_only', $value );
+						},
+					)
+				);
+			}
+		);
+		$this->reset_registries();
+
+		$input                                = $this->get_shirt_input();
+		$input['variations'][0]['extensions'] = array( 'typed-ext' => array( 'simple_only' => 'nope' ) );
+		$schema                               = $this->get_ability( 'woocommerce/product-create' )['input_schema'];
+		$refused                              = $this->run_ability_response( 'woocommerce/product-create', $input );
+
+		$this->assertArrayNotHasKey( 'typed-ext', $schema['properties']['variations']['items']['properties']['extensions']['properties'] );
+		$this->assertFalse( $schema['properties']['variations']['items']['properties']['extensions']['additionalProperties'] );
+		$this->assertSame( 400, $refused->get_status() );
+		$this->assertSame( 'ability_invalid_input', $refused->get_data()['code'] );
+		$this->assertSame( 'Ability "woocommerce/product-create" has invalid input. Reason: typed-ext is not a valid property of Object.', $refused->get_data()['message'] );
+		$this->assertSame( 0, wc_get_product_id_by_sku( 'shirt-s' ) );
+	}
+
+	/**
+	 * Register a variable product type with a variation class of its own, the way
+	 * an extension does, and a field limited to that variation class.
+	 *
+	 * @internal
+	 */
+	private function register_variation_subclass(): void {
+		$this->add_test_action(
+			'product_type_selector',
+			static fn( array $types ): array => $types + array( 'test-variable' => 'Test variable' )
+		);
+		$this->add_test_action(
+			'woocommerce_data_stores',
+			static fn( array $stores ): array => $stores + array(
+				'product-test-variable'  => 'WC_Product_Variable_Data_Store_CPT',
+				'product-test_variation' => 'WC_Product_Variation_Data_Store_CPT',
+			)
+		);
+		$this->add_test_action(
+			'woocommerce_product_class',
+			static function ( $classname, $product_type, $post_type, $product_id ) {
+				if ( 'test-variable' === $product_type ) {
+					return TestVariableProduct::class;
+				}
+				if ( 'test_variation' === $product_type ) {
+					return TestVariationProduct::class;
+				}
+				if ( 'variation' === $product_type && $product_id > 0 && has_term( 'test-variable', 'product_type', (int) wp_get_post_parent_id( $product_id ) ) ) {
+					return TestVariationProduct::class;
+				}
+				return $classname;
+			},
+			10,
+			4
+		);
+		$this->add_test_action(
+			'woocommerce_ability_variation_product_type',
+			static fn( string $type, \WC_Product_Variable $parent_product ): string => 'test-variable' === $parent_product->get_type() ? 'test_variation' : $type,
+			10,
+			2
+		);
+		$this->add_test_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				ExtensibleAbilities::register_field(
+					array(
+						'resource'      => 'product',
+						'namespace'     => 'test-ext',
+						'field'         => 'plan',
+						'output_schema' => array( 'type' => 'string' ),
+						'input_schema'  => array( 'type' => 'string' ),
+						'object_types'  => array( 'test_variation' ),
+						'get_callback'  => static fn( \WC_Product $product ) => $product->get_meta( '_test_plan' ),
+						'set_callback'  => static function ( \WC_Product $product, $value ) {
+							$product->update_meta_data( '_test_plan', $value );
+						},
+					)
+				);
+			}
+		);
+	}
+
+	/**
+	 * @testdox Should read a saved variation whose class extends the variation class.
+	 */
+	public function test_reads_a_variation_of_a_subclass(): void {
+		$this->register_variation_subclass();
+
+		$parent_product = new TestVariableProduct();
+		$parent_product->set_name( 'Test variable' );
+		$parent_product->save();
+		$variation = new \WC_Product_Variation();
+		$variation->set_parent_id( $parent_product->get_id() );
+		$variation->save();
+
+		$read = $this->run_ability( 'woocommerce/products-query', array( 'id' => $variation->get_id() ) );
+
+		$this->assertInstanceOf( TestVariationProduct::class, wc_get_product( $variation->get_id() ) );
+		$this->assertSame( 'test_variation', $read['products'][0]['type'] );
+	}
+
+	/**
+	 * @testdox Should refuse a type change into or out of a variable product type that an extension registers.
+	 */
+	public function test_refuses_a_type_change_into_or_out_of_a_variable_subclass(): void {
+		$this->register_variation_subclass();
+
+		$input         = $this->get_shirt_input();
+		$input['type'] = 'test-variable';
+		$created       = $this->run_ability( 'woocommerce/product-create', $input );
+		$simple        = \WC_Helper_Product::create_simple_product();
+
+		$out_of = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'   => $created['product']['id'],
+				'type' => 'simple',
+			)
+		);
+		$into   = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'   => $simple->get_id(),
+				'type' => 'test-variable',
+			)
+		);
+
+		$this->assertSame( 'woocommerce_product_type_unsupported', $out_of->get_data()['code'] );
+		$this->assertSame( 'woocommerce_product_type_unsupported', $into->get_data()['code'] );
+		$this->assertInstanceOf( TestVariableProduct::class, wc_get_product( $created['product']['id'] ) );
+		$this->assertCount( 2, wc_get_product( $created['product']['id'] )->get_children() );
+		$this->assertSame( 'simple', wc_get_product( $simple->get_id() )->get_type() );
+	}
+
+	/**
+	 * @testdox Should refuse a type change into a variable class that a class filter returns only for a saved product.
+	 */
+	public function test_refuses_a_type_change_when_the_class_filter_depends_on_the_product_id(): void {
+		$this->add_test_action(
+			'product_type_selector',
+			static fn( array $types ): array => $types + array( 'test-variable' => 'Test variable' )
+		);
+		$this->add_test_action(
+			'woocommerce_data_stores',
+			static fn( array $stores ): array => $stores + array( 'product-test-variable' => 'WC_Product_Variable_Data_Store_CPT' )
+		);
+		$unsaved_class = get_class(
+			new class() extends \WC_Product_Simple {
+				/**
+				 * Get the product type.
+				 *
+				 * @return string
+				 */
+				public function get_type() {
+					return 'test-variable';
+				}
+			}
+		);
+		$this->add_test_action(
+			'woocommerce_product_class',
+			static function ( $classname, $product_type, $post_type, $product_id ) use ( $unsaved_class ) {
+				if ( 'test-variable' !== $product_type ) {
+					return $classname;
+				}
+				return $product_id > 0 ? TestVariableProduct::class : $unsaved_class;
+			},
+			10,
+			4
+		);
+		$simple = \WC_Helper_Product::create_simple_product();
+
+		$refused = $this->run_ability_response(
+			'woocommerce/product-update',
+			array(
+				'id'   => $simple->get_id(),
+				'type' => 'test-variable',
+			)
+		);
+
+		$this->assertSame( 'woocommerce_product_type_unsupported', $refused->get_data()['code'] ?? null );
+		$this->assertSame( 'simple', wc_get_product( $simple->get_id() )->get_type() );
+	}
+
+	/**
+	 * @testdox Should build a new variation with the class that the parent type asks for, and write a field limited to it.
+	 */
+	public function test_creates_variations_of_a_subclass_with_their_fields(): void {
+		$this->register_variation_subclass();
+
+		$input                                = $this->get_shirt_input();
+		$input['type']                        = 'test-variable';
+		$input['attributes'][0]['options'][]  = 'L';
+		$input['variations'][0]['extensions'] = array( 'test-ext' => array( 'plan' => 'monthly' ) );
+		$created                              = $this->run_ability( 'woocommerce/product-create', $input );
+		$new_item                             = array(
+			'attributes' => array(
+				array(
+					'name'   => 'Size',
+					'option' => 'L',
+				),
+			),
+			'extensions' => array( 'test-ext' => array( 'plan' => 'yearly' ) ),
+		);
+		$updated                              = $this->run_ability(
+			'woocommerce/product-update',
+			array(
+				'id'         => $created['product']['id'],
+				'variations' => array( $new_item ),
+			)
+		);
+		$variation_id                         = $created['variations'][0]['id'];
+
+		$this->assertSame( 'test-variable', $created['product']['type'] );
+		$this->assertSame( 'test_variation', $created['variations'][0]['type'] );
+		$this->assertSame( 'monthly', $created['variations'][0]['extensions']['test-ext']['plan'] );
+		$this->assertInstanceOf( TestVariationProduct::class, wc_get_product( $variation_id ) );
+		$this->assertSame( 'product_variation', get_post_type( $variation_id ) );
+		$this->assertSame( 'monthly', wc_get_product( $variation_id )->get_meta( '_test_plan' ) );
+		$this->assertSame( 'test_variation', $updated['variations'][0]['type'] );
+		$this->assertSame( 'yearly', wc_get_product( $updated['variations'][0]['id'] )->get_meta( '_test_plan' ) );
+	}
+
+	/**
+	 * @testdox Should build the variation class of the parent when a variation is created on its own.
+	 */
+	public function test_creates_a_single_variation_of_a_subclass(): void {
+		$this->register_variation_subclass();
+
+		$input               = $this->get_shirt_input();
+		$input['type']       = 'test-variable';
+		$input['variations'] = array();
+		$parent_id           = $this->run_ability( 'woocommerce/product-create', $input )['product']['id'];
+
+		$created = $this->run_ability(
+			'woocommerce/product-create',
+			array(
+				'type'       => 'variation',
+				'parent_id'  => $parent_id,
+				'attributes' => array(
+					array(
+						'name'   => 'Size',
+						'option' => 'S',
+					),
+				),
+				'extensions' => array( 'test-ext' => array( 'plan' => 'monthly' ) ),
 			)
 		);

-		$this->assertSame( array( 'small', 'large', null ), $schema['enum'] );
-		$this->assertSame( 400, $rejected->get_status() );
-		$this->assertFalse( wc_get_product( $product->get_id() )->meta_exists( '_test_size' ) );
+		$this->assertSame( 'test_variation', $created['product']['type'] );
+		$this->assertSame( 'monthly', wc_get_product( $created['product']['id'] )->get_meta( '_test_plan' ) );
 	}

 	/**
-	 * @testdox Should let an ActionableAbility of an extension accept the writable fields with no extra code.
+	 * @testdox Should keep plain variations for a plain variable product, and refuse a field limited to a subclass on them.
 	 */
-	public function test_an_extension_actionable_ability_writes_the_fields(): void {
-		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
-		$this->register_rename_ability();
+	public function test_plain_variable_product_builds_plain_variations(): void {
+		$this->register_variation_subclass();

-		$schema = $this->get_ability( 'test-ext/product-rename' )['input_schema']['properties']['extensions'];
-		$output = $this->run_ability(
-			'test-ext/product-rename',
-			array(
-				'id'         => $product->get_id(),
-				'name'       => 'Pencil',
-				'extensions' => array( 'test-ext' => array( 'color' => 'green' ) ),
-			)
-		);
+		$created = $this->run_ability( 'woocommerce/product-create', $this->get_shirt_input() );

-		$this->assertSame( array( 'string', 'null' ), $schema['properties']['test-ext']['properties']['color']['type'] );
-		$this->assertArrayNotHasKey( 'code', $schema['properties']['test-ext']['properties'] );
-		$this->assertSame( 'Pencil', wc_get_product( $product->get_id() )->get_name() );
-		$this->assertSame( array( 'color' => 'green' ), $output['extensions']['test-ext'] );
+		$input = $this->get_shirt_input();
+		unset( $input['sku'], $input['variations'][0]['sku'], $input['variations'][1]['sku'] );
+		$input['variations'][0]['extensions'] = array( 'test-ext' => array( 'plan' => 'monthly' ) );
+		$refused                              = $this->run_ability_response( 'woocommerce/product-create', $input );
+
+		$this->assertSame( 'variation', $created['variations'][0]['type'] );
+		$this->assertSame( \WC_Product_Variation::class, get_class( wc_get_product( $created['variations'][0]['id'] ) ) );
+		$this->assertSame( 400, $refused->get_status() );
+		$this->assertStringEndsWith( 'woocommerce_ability_field_object_type', $refused->get_data()['code'] );
 	}

 	/**
-	 * @testdox Should return a fixed 500 error when the default save of an ActionableAbility throws.
+	 * Create a variable product of the subclass type and a plain one, with two variations each.
+	 *
+	 * @return array{subclass: array, plain: array}
 	 */
-	public function test_a_failed_default_save_returns_a_fixed_error(): void {
-		$product  = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
-		$register = static function () {
-			wp_register_ability(
-				'test-ext/product-rename',
-				array(
-					'label'               => 'Rename product',
-					'description'         => 'Renames a product.',
-					'category'            => 'woocommerce',
-					'input_schema'        => array(
-						'type'       => 'object',
-						'properties' => array(
-							'id'   => array( 'type' => 'integer' ),
-							'name' => array( 'type' => 'string' ),
-						),
-					),
-					'output_schema'       => array( 'type' => 'object' ),
-					'permission_callback' => '__return_true',
-					'ability_class'       => TestProductRenameAbility::class,
-					'meta'                => array( 'show_in_rest' => true ),
-				)
-			);
-		};
-		$throw    = static function () {
-			throw new \RuntimeException( 'Storage text.' );
-		};
-		add_action( 'wp_abilities_api_init', $register, 20 );
-		add_action( 'woocommerce_before_product_object_save', $throw );
+	private function create_subclass_and_plain_products(): array {
+		$this->register_variation_subclass();

-		$response = $this->run_ability_response(
-			'test-ext/product-rename',
-			array(
-				'id'   => $product->get_id(),
-				'name' => 'Pencil',
-			)
+		$input         = $this->get_shirt_input();
+		$input['type'] = 'test-variable';
+		foreach ( $input['variations'] as $index => $variation ) {
+			$input['variations'][ $index ]['sku'] = 'test-' . $variation['sku'];
+		}
+
+		return array(
+			'plain'    => $this->run_ability( 'woocommerce/product-create', $this->get_shirt_input() ),
+			'subclass' => $this->run_ability( 'woocommerce/product-create', $input ),
 		);
+	}

-		remove_action( 'woocommerce_before_product_object_save', $throw );
-		remove_action( 'wp_abilities_api_init', $register, 20 );
-		$this->assertSame( 500, $response->get_status() );
-		$this->assertSame( 'woocommerce_ability_save_failed', $response->get_data()['code'] );
-		$this->assertStringNotContainsString( 'Storage text.', wp_json_encode( $response->get_data() ) );
-		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	/**
+	 * @testdox Should accept a variation subclass type in a query and return only the variations of its parent type.
+	 */
+	public function test_queries_variations_by_subclass_type(): void {
+		$created = $this->create_subclass_and_plain_products();
+
+		$found = $this->run_ability( 'woocommerce/products-query', array( 'type' => 'test_variation' ) )['products'];
+
+		$this->assertEqualsCanonicalizing( array_column( $created['subclass']['variations'], 'id' ), array_column( $found, 'id' ) );
+		$this->assertSame( array( 'test_variation' ), array_values( array_unique( array_column( $found, 'type' ) ) ) );
 	}

 	/**
-	 * @testdox Should return a fixed 500 error when the order write fails, though the order's save() catches the exception.
+	 * @testdox Should page a variation subclass query inside the database query.
 	 */
-	public function test_a_failed_order_write_returns_a_fixed_error(): void {
-		$this->register_order_status_ability();
-		$order = \WC_Helper_Order::create_order();
-		$order->set_status( 'pending' );
-		$order->save();
-		$throw = static function () {
-			throw new \RuntimeException( 'Storage text.' );
-		};
-		add_action( 'woocommerce_before_order_object_save', $throw );
+	public function test_pages_a_variation_subclass_query(): void {
+		$created = $this->create_subclass_and_plain_products();
+		$query   = static fn( int $page ): array => array(
+			'type'     => 'test_variation',
+			'per_page' => 1,
+			'page'     => $page,
+		);
+
+		$first  = $this->run_ability( 'woocommerce/products-query', $query( 1 ) );
+		$second = $this->run_ability( 'woocommerce/products-query', $query( 2 ) );
+		$third  = $this->run_ability( 'woocommerce/products-query', $query( 3 ) );
+
+		$this->assertSame( 2, $first['total_pages'] );
+		$this->assertCount( 1, $first['products'] );
+		$this->assertCount( 1, $second['products'] );
+		$this->assertSame( array(), $third['products'] );
+		$this->assertEqualsCanonicalizing(
+			array_column( $created['subclass']['variations'], 'id' ),
+			array( $first['products'][0]['id'], $second['products'][0]['id'] )
+		);
+	}

-		$response = $this->run_ability_response(
-			'test-ext/order-status',
+	/**
+	 * @testdox Should combine a variation subclass type with parent_id and extension query filters.
+	 */
+	public function test_combines_a_variation_subclass_query_with_other_limits(): void {
+		$created = $this->create_subclass_and_plain_products();
+		$picked  = array( $created['subclass']['variations'][0]['id'], $created['plain']['variations'][0]['id'] );
+		$this->add_test_action(
+			'woocommerce_extensible_abilities_init',
+			static function () use ( $picked ) {
+				ExtensibleAbilities::register_query_filter(
+					array(
+						'resource'  => 'product',
+						'namespace' => 'test-ext',
+						'filter'    => 'picked',
+						'schema'    => array( 'type' => 'boolean' ),
+						'callback'  => static fn() => array( 'include_ids' => $picked ),
+					)
+				);
+			}
+		);
+		$this->reset_registries();
+
+		$own_parent   = $this->run_ability(
+			'woocommerce/products-query',
 			array(
-				'id'     => $order->get_id(),
-				'status' => 'completed',
+				'type'      => 'test_variation',
+				'parent_id' => $created['subclass']['product']['id'],
+			)
+		);
+		$other_parent = $this->run_ability(
+			'woocommerce/products-query',
+			array(
+				'type'      => 'test_variation',
+				'parent_id' => $created['plain']['product']['id'],
+			)
+		);
+		$filtered     = $this->run_ability(
+			'woocommerce/products-query',
+			array(
+				'type'    => 'test_variation',
+				'filters' => array( 'extensions' => array( 'test-ext' => array( 'picked' => true ) ) ),
 			)
 		);

-		remove_action( 'woocommerce_before_order_object_save', $throw );
-		$this->assertSame( 500, $response->get_status() );
-		$this->assertSame( 'woocommerce_ability_save_failed', $response->get_data()['code'] );
-		$this->assertStringNotContainsString( 'Storage text.', wp_json_encode( $response->get_data() ) );
-		$this->assertSame( 'pending', wc_get_order( $order->get_id() )->get_status() );
+		$this->assertEqualsCanonicalizing( array_column( $created['subclass']['variations'], 'id' ), array_column( $own_parent['products'], 'id' ) );
+		$this->assertSame( array(), $other_parent['products'] );
+		$this->assertSame( array( $picked[0] ), array_column( $filtered['products'], 'id' ) );
 	}

 	/**
-	 * @testdox Should list the extensions key in anyOf and allOf branches that refuse other properties, and accept extension fields through an anyOf branch.
+	 * @testdox Should still return plain and subclass variations for the variation type.
 	 */
-	public function test_combinator_branches_accept_the_extensions_key(): void {
-		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
-		$branch  = array(
-			'type'                 => 'object',
-			'properties'           => array(
-				'id'   => array( 'type' => 'integer' ),
-				'name' => array( 'type' => 'string' ),
-			),
-			'additionalProperties' => false,
+	public function test_variation_type_query_returns_every_variation(): void {
+		$created = $this->create_subclass_and_plain_products();
+
+		$found = $this->run_ability( 'woocommerce/products-query', array( 'type' => 'variation' ) )['products'];
+
+		$this->assertEqualsCanonicalizing(
+			array_merge( array_column( $created['subclass']['variations'], 'id' ), array_column( $created['plain']['variations'], 'id' ) ),
+			array_column( $found, 'id' )
 		);
-		foreach ( array( 'anyOf', 'allOf' ) as $keyword ) {
-			$name     = 'test-ext/product-rename-' . strtolower( $keyword );
-			$register = static function () use ( $name, $keyword, $branch ) {
-				wp_register_ability(
-					$name,
-					array(
-						'label'               => 'Rename product',
-						'description'         => 'Renames a product.',
-						'category'            => 'woocommerce',
-						'input_schema'        => array(
-							'type'   => 'object',
-							$keyword => array( $branch ),
-						),
-						'output_schema'       => ExtensibleAbilities::add_fields_schema( array( 'type' => 'object' ), 'product' ),
-						'permission_callback' => '__return_true',
-						'ability_class'       => TestProductRenameAbility::class,
-						'meta'                => array( 'show_in_rest' => true ),
-					)
-				);
-			};
-			add_action( 'wp_abilities_api_init', $register, 20 );
-			$this->reset_registries();
+		$this->assertEqualsCanonicalizing( array( 'test_variation', 'variation' ), array_values( array_unique( array_column( $found, 'type' ) ) ) );
+	}

-			$schema = $this->get_ability( $name )['input_schema'];
-			$this->assertSame( 'object', $schema[ $keyword ][0]['properties']['extensions']['type'] ?? null, $keyword );
+	/**
+	 * @testdox Should refuse a query type that no registered variable type builds.
+	 */
+	public function test_refuses_a_query_type_that_is_not_listed(): void {
+		$unregistered = $this->run_ability_response( 'woocommerce/products-query', array( 'type' => 'test_variation' ) );

-			if ( 'anyOf' === $keyword ) {
-				$output = $this->run_ability(
-					$name,
-					array(
-						'id'         => $product->get_id(),
-						'name'       => 'Pencil',
-						'extensions' => array( 'test-ext' => array( 'color' => 'blue' ) ),
-					)
-				);
-				$this->assertSame( array( 'color' => 'blue' ), $output['extensions']['test-ext'] );
-			}
+		$this->register_variation_subclass();
+		$this->reset_registries();
+		$unknown = $this->run_ability_response( 'woocommerce/products-query', array( 'type' => 'other_variation' ) );

-			remove_action( 'wp_abilities_api_init', $register, 20 );
-		}
+		$this->assertSame( 400, $unregistered->get_status() );
+		$this->assertSame( 400, $unknown->get_status() );
 	}

 	/**
-	 * @testdox Should list the writable fields one time at the top level of the product input schemas.
+	 * @testdox Should list a variation subclass type on the query input only.
 	 */
-	public function test_input_schemas_list_writable_fields_one_time(): void {
-		foreach ( array( 'woocommerce/product-create', 'woocommerce/product-update' ) as $name ) {
-			$schema     = $this->get_ability( $name )['input_schema'];
-			$extensions = $schema['properties']['extensions']['properties'];
+	public function test_lists_a_variation_subclass_type_on_the_query_input_only(): void {
+		$type_list = fn( string $name ): array => $this->get_ability( $name )['input_schema']['properties']['type']['enum'];
+		$create    = $type_list( 'woocommerce/product-create' );
+		$update    = $type_list( 'woocommerce/product-update' );

-			$this->assertSame( array( 'string', 'null' ), $extensions['test-ext']['properties']['color']['type'] );
-			$this->assertSame( array( 'integer', 'null' ), $extensions['other_ext']['properties']['count']['type'] );
-			$this->assertArrayNotHasKey( 'code', $extensions['test-ext']['properties'] );
-			$this->assertSame( 1, substr_count( wp_json_encode( $schema ), '"color"' ) );
-			$this->assertArrayNotHasKey( 'oneOf', $schema );
-		}
-		$order = $this->get_ability( 'woocommerce/order-update-status' )['input_schema'];
-		$this->assertSame( array( 'string', 'null' ), $order['properties']['extensions']['properties']['test-ext']['properties']['gift_message']['type'] );
+		$this->register_variation_subclass();
+		$this->reset_registries();
+
+		$this->assertContains( 'test_variation', $type_list( 'woocommerce/products-query' ) );
+		$this->assertSame( $create, array_values( array_diff( $type_list( 'woocommerce/product-create' ), array( 'test-variable' ) ) ) );
+		$this->assertSame( $update, array_values( array_diff( $type_list( 'woocommerce/product-update' ), array( 'test-variable' ) ) ) );
 	}

 	/**
-	 * @testdox Should leave a field with object_types out of the other objects and refuse a write to them.
+	 * @testdox Should refuse a variation type that does not build a variation.
 	 */
-	public function test_object_types_limit_a_field_to_some_objects(): void {
-		$register = static function () {
-			ExtensibleAbilities::register_field(
-				array(
-					'resource'      => 'product',
-					'namespace'     => 'test-ext',
-					'field'         => 'plan',
-					'output_schema' => array( 'type' => 'string' ),
-					'input_schema'  => array( 'type' => 'string' ),
-					'object_types'  => array( 'simple' ),
-					'get_callback'  => static fn() => 'monthly',
-					'set_callback'  => static function ( \WC_Product $product, $value ) {
-						$product->update_meta_data( '_test_plan', $value );
-					},
-				)
-			);
-		};
-		add_action( 'woocommerce_extensible_abilities_init', $register );
-		$simple  = \WC_Helper_Product::create_simple_product();
-		$grouped = \WC_Helper_Product::create_grouped_product();
+	public function test_refuses_a_variation_type_that_is_not_a_variation(): void {
+		$this->add_test_action( 'woocommerce_ability_variation_product_type', static fn(): string => 'simple' );

-		try {
-			$schema   = $this->get_ability( 'woocommerce/product-create' )['input_schema'];
-			$rejected = $this->run_ability_response(
-				'woocommerce/product-update',
-				array(
-					'id'         => $grouped->get_id(),
-					'extensions' => array( 'test-ext' => array( 'plan' => 'yearly' ) ),
-				)
-			);
-			$updated  = $this->run_ability(
-				'woocommerce/product-update',
-				array(
-					'id'         => $simple->get_id(),
-					'extensions' => array( 'test-ext' => array( 'plan' => 'yearly' ) ),
-				)
-			);
-			$read     = $this->run_ability( 'woocommerce/products-query', array( 'id' => $grouped->get_id() ) );
-		} finally {
-			remove_action( 'woocommerce_extensible_abilities_init', $register );
-		}
+		$refused = $this->run_ability_response( 'woocommerce/product-create', $this->get_shirt_input() );

-		$this->assertSame( 'Applies to: simple.', $schema['properties']['extensions']['properties']['test-ext']['properties']['plan']['description'] );
-		$this->assertArrayHasKey( 'plan', $schema['properties']['extensions']['properties']['test-ext']['properties'] );
-		$this->assertSame( 400, $rejected->get_status() );
-		$this->assertSame( 'test-ext:woocommerce_ability_field_object_type', $rejected->get_data()['code'] );
-		$this->assertSame( 'The extensions.test-ext.plan field does not apply to the grouped type. It applies to simple.', $rejected->get_data()['message'] );
-		$this->assertSame( '', wc_get_product( $grouped->get_id() )->get_meta( '_test_plan' ) );
-		$this->assertSame( 'monthly', $updated['product']['extensions']['test-ext']['plan'] );
-		$this->assertSame( 'yearly', wc_get_product( $simple->get_id() )->get_meta( '_test_plan' ) );
-		$this->assertArrayNotHasKey( 'plan', $read['products'][0]['extensions']['test-ext'] ?? array() );
+		$this->assertSame( 400, $refused->get_status() );
+		$this->assertSame( 'woocommerce_invalid_product_type', $refused->get_data()['code'] );
 	}

 	/**
@@ -3405,9 +4629,10 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	 * @param string   $hook     Action name.
 	 * @param callable $callback Callback.
 	 * @param int      $priority Priority.
+	 * @param int      $accepted_args Number of arguments the callback takes.
 	 */
-	private function add_test_action( string $hook, callable $callback, int $priority = 10 ): void {
-		add_action( $hook, $callback, $priority );
+	private function add_test_action( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 ): void {
+		add_action( $hook, $callback, $priority, $accepted_args );
 		$this->test_actions[] = array( $hook, $callback, $priority );
 	}

@@ -3433,6 +4658,28 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Register a sync inside woocommerce_extensible_abilities_init, the way an extension does.
+	 *
+	 * @param string   $resource_name       Resource.
+	 * @param string   $extension_namespace Extension namespace.
+	 * @param callable $callback            Sync.
+	 */
+	private function register_sync( string $resource_name, string $extension_namespace, callable $callback ): void {
+		$this->add_test_action(
+			'woocommerce_extensible_abilities_init',
+			static function () use ( $resource_name, $extension_namespace, $callback ) {
+				ExtensibleAbilities::register_sync(
+					array(
+						'resource'  => $resource_name,
+						'namespace' => $extension_namespace,
+						'callback'  => $callback,
+					)
+				);
+			}
+		);
+	}
+
 	/**
 	 * Register test-ext/order-status, an ActionableAbility of an extension that changes orders.
 	 */
@@ -3519,9 +4766,11 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	 * @return \WP_REST_Response
 	 */
 	private function run_ability_response( string $name, array $input ): \WP_REST_Response {
-		$readonly = ! empty( wp_get_ability( $name )->get_meta()['annotations']['readonly'] );
-		$request  = new \WP_REST_Request( $readonly ? 'GET' : 'POST', '/wp-abilities/v1/abilities/' . $name . '/run' );
-		if ( $readonly ) {
+		$annotations = wp_get_ability( $name )->get_meta()['annotations'] ?? array();
+		$readonly    = ! empty( $annotations['readonly'] );
+		$method      = $readonly ? 'GET' : ( ! empty( $annotations['destructive'] ) && ! empty( $annotations['idempotent'] ) ? 'DELETE' : 'POST' );
+		$request     = new \WP_REST_Request( $method, '/wp-abilities/v1/abilities/' . $name . '/run' );
+		if ( 'POST' !== $method ) {
 			$request->set_query_params( array( 'input' => $input ) );
 		} else {
 			$request->set_header( 'Content-Type', 'application/json' );
@@ -3569,7 +4818,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	private function reset_registries(): void {
 		global $wp_actions;

-		foreach ( array( 'fields', 'validators', 'filters' ) as $property ) {
+		foreach ( array( 'fields', 'validators', 'syncs', 'filters' ) as $property ) {
 			$reflection = new \ReflectionProperty( ExtensibleAbilities::class, $property );
 			$reflection->setAccessible( true );
 			$reflection->setValue( null, array() );
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestProductWithItemAbility.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestProductWithItemAbility.php
new file mode 100644
index 00000000000..a1d52a90d21
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestProductWithItemAbility.php
@@ -0,0 +1,33 @@
+<?php
+/**
+ * TestProductWithItemAbility class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;
+
+/**
+ * An extension's write ability that renames a product and changes an order item, a secondary object of another resource.
+ */
+class TestProductWithItemAbility extends TestProductRenameAbility {
+
+	/**
+	 * Resource name that the secondary object carries.
+	 *
+	 * @var string
+	 */
+	public static string $item_resource = 'order_item';
+
+	/**
+	 * Rename the product in memory, and return a new order item as a secondary object of the `$item_resource` resource.
+	 *
+	 * @param \WC_Product $subject Product.
+	 * @param array       $input   Ability input.
+	 * @return array<int, array{0: \WC_Data, 1: array, 2: string}>
+	 */
+	public function change( $subject, array $input ) {
+		parent::change( $subject, $input );
+		return array( array( new \WC_Order_Item_Product(), array(), self::$item_resource ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestVariableProduct.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestVariableProduct.php
new file mode 100644
index 00000000000..9871c79162c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestVariableProduct.php
@@ -0,0 +1,23 @@
+<?php
+/**
+ * TestVariableProduct class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;
+
+/**
+ * A variable product type of an extension, whose variations have a class of their own.
+ */
+class TestVariableProduct extends \WC_Product_Variable {
+
+	/**
+	 * Get the product type.
+	 *
+	 * @return string
+	 */
+	public function get_type() {
+		return 'test-variable';
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestVariationProduct.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestVariationProduct.php
new file mode 100644
index 00000000000..2f7e73e5411
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestVariationProduct.php
@@ -0,0 +1,33 @@
+<?php
+/**
+ * TestVariationProduct class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;
+
+/**
+ * A variation class of an extension.
+ */
+class TestVariationProduct extends \WC_Product_Variation {
+
+	/**
+	 * Get the product type.
+	 *
+	 * @return string
+	 */
+	public function get_type() {
+		return 'test_variation';
+	}
+
+	/**
+	 * A variation of this class is also a variation for WooCommerce.
+	 *
+	 * @param string|string[] $type Type or types to check.
+	 * @return bool
+	 */
+	public function is_type( $type ) {
+		return 'variation' === $type || ( is_array( $type ) && in_array( 'variation', $type, true ) ) || parent::is_type( $type );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseTransactionTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseTransactionTest.php
new file mode 100644
index 00000000000..1d7b8ab2974
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseTransactionTest.php
@@ -0,0 +1,198 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Utilities;
+
+use Automattic\WooCommerce\Internal\Utilities\DatabaseTransaction;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for DatabaseTransaction and wc_transaction_query().
+ *
+ * The test bootstrap owns the transaction that every test runs in. These tests reset the owned depth to 0,
+ * and replace each transaction statement with SELECT 1, so they can read the statements of a request that
+ * owns no transaction without ending the test transaction.
+ */
+class DatabaseTransactionTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Transaction statements, in order.
+	 *
+	 * @var string[]
+	 */
+	private $statements = array();
+
+	/**
+	 * Owned depth before the test.
+	 *
+	 * @var int
+	 */
+	private $depth;
+
+	/**
+	 * Whether a caller transaction was open before the test.
+	 *
+	 * @var bool
+	 */
+	private $caller_open;
+
+	/**
+	 * Start every test with no owned transaction, and record the transaction statements instead of sending them.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->depth       = $this->set_depth( 0 );
+		$this->caller_open = $this->set_caller_open( false );
+		add_filter( 'query', array( $this, 'record_statement' ), 1 );
+	}
+
+	/**
+	 * Restore the owned depth of the test bootstrap.
+	 */
+	public function tearDown(): void {
+		remove_filter( 'query', array( $this, 'record_statement' ), 1 );
+		$this->set_depth( $this->depth );
+		$this->set_caller_open( $this->caller_open );
+		parent::tearDown();
+	}
+
+	/**
+	 * Record a transaction statement, and send SELECT 1 in its place.
+	 *
+	 * @param string $query Query.
+	 * @return string
+	 */
+	public function record_statement( $query ) {
+		foreach ( array( 'START TRANSACTION', 'COMMIT', 'ROLLBACK', 'SAVEPOINT', 'RELEASE SAVEPOINT' ) as $prefix ) {
+			if ( 0 === strpos( $query, $prefix ) ) {
+				$this->statements[] = $query;
+				return 'SELECT 1';
+			}
+		}
+		return $query;
+	}
+
+	/**
+	 * @testdox wc_transaction_query() should send plain transaction statements when no transaction is owned.
+	 */
+	public function test_transaction_query_works_as_before_outside_an_owned_transaction(): void {
+		wc_transaction_query( 'start' );
+		wc_transaction_query( 'start' );
+		wc_transaction_query( 'commit' );
+		wc_transaction_query( 'rollback' );
+
+		$this->assertSame( array( 'START TRANSACTION', 'START TRANSACTION', 'COMMIT', 'ROLLBACK' ), $this->statements );
+		$this->assertFalse( DatabaseTransaction::is_open() );
+	}
+
+	/**
+	 * @testdox run() should own the transaction, and wc_transaction_query() inside it should set savepoints.
+	 */
+	public function test_run_turns_nested_transactions_into_savepoints(): void {
+		$result = DatabaseTransaction::run(
+			static function () {
+				wc_transaction_query( 'start' );
+				wc_transaction_query( 'start' );
+				wc_transaction_query( 'rollback' );
+				wc_transaction_query( 'commit' );
+				return 'done';
+			}
+		);
+
+		$this->assertSame( 'done', $result );
+		$this->assertSame(
+			array( 'START TRANSACTION', 'SAVEPOINT wc_1', 'SAVEPOINT wc_2', 'ROLLBACK TO SAVEPOINT wc_2', 'RELEASE SAVEPOINT wc_1', 'COMMIT' ),
+			$this->statements
+		);
+		$this->assertFalse( DatabaseTransaction::is_open() );
+	}
+
+	/**
+	 * @testdox run() should commit what it opened when a caller inside it leaves a transaction open.
+	 */
+	public function test_run_closes_a_transaction_that_a_caller_left_open(): void {
+		DatabaseTransaction::run(
+			static function () {
+				wc_transaction_query( 'start' );
+				return null;
+			}
+		);
+		wc_transaction_query( 'start' );
+
+		$this->assertSame( array( 'START TRANSACTION', 'SAVEPOINT wc_1', 'COMMIT', 'START TRANSACTION' ), $this->statements );
+	}
+
+	/**
+	 * @testdox run() should use a savepoint inside a transaction that wc_transaction_query() started, and leave the end to the caller.
+	 */
+	public function test_run_joins_a_transaction_that_a_caller_started(): void {
+		wc_transaction_query( 'start' );
+		DatabaseTransaction::run(
+			static function () {
+				wc_transaction_query( 'start' );
+				wc_transaction_query( 'commit' );
+				return null;
+			}
+		);
+		$this->assertFalse( DatabaseTransaction::is_open() );
+		DatabaseTransaction::run( static fn() => new \WP_Error( 'refused', 'Refused.' ) );
+		wc_transaction_query( 'rollback' );
+		DatabaseTransaction::run( static fn() => null );
+
+		$this->assertSame(
+			array( 'START TRANSACTION', 'SAVEPOINT wc_1', 'SAVEPOINT wc_2', 'RELEASE SAVEPOINT wc_2', 'RELEASE SAVEPOINT wc_1', 'SAVEPOINT wc_1', 'ROLLBACK TO SAVEPOINT wc_1', 'ROLLBACK', 'START TRANSACTION', 'COMMIT' ),
+			$this->statements
+		);
+		$this->assertFalse( DatabaseTransaction::is_open() );
+	}
+
+	/**
+	 * @testdox run() should roll back when the callback returns a WP_Error or throws.
+	 */
+	public function test_run_rolls_back_on_error(): void {
+		$result = DatabaseTransaction::run( static fn() => new \WP_Error( 'refused', 'Refused.' ) );
+		$this->assertWPError( $result );
+
+		try {
+			DatabaseTransaction::run(
+				static function () {
+					throw new \RuntimeException( 'Disk full.' );
+				}
+			);
+			$this->fail( 'The exception should reach the caller.' );
+		} catch ( \RuntimeException $e ) {
+			$this->assertSame( 'Disk full.', $e->getMessage() );
+		}
+
+		$this->assertSame( array( 'START TRANSACTION', 'ROLLBACK', 'START TRANSACTION', 'ROLLBACK' ), $this->statements );
+		$this->assertFalse( DatabaseTransaction::is_open() );
+	}
+
+	/**
+	 * Set whether a caller transaction is open.
+	 *
+	 * @param bool $open New value.
+	 * @return bool The value before.
+	 */
+	private function set_caller_open( bool $open ): bool {
+		$property = new \ReflectionProperty( DatabaseTransaction::class, 'caller_open' );
+		$property->setAccessible( true );
+		$before = $property->getValue();
+		$property->setValue( null, $open );
+		return $before;
+	}
+
+	/**
+	 * Set the owned depth.
+	 *
+	 * @param int $depth New depth.
+	 * @return int The depth before.
+	 */
+	private function set_depth( int $depth ): int {
+		$property = new \ReflectionProperty( DatabaseTransaction::class, 'depth' );
+		$property->setAccessible( true );
+		$before = $property->getValue();
+		$property->setValue( null, $depth );
+		return $before;
+	}
+}