Commit 6dcc1570305 for woocommerce

commit 6dcc157030598005573d4db01ffbb9e51f8190a3
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Tue Nov 3 17:23:45 2026 -0300

    Tell agents why an extension field in an extensible ability cannot be written (#70439)

    * 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>

    * Tell agents why an extension field cannot be written

    When the input schema refuses the input, name every bad extension field
    in one message: an unregistered namespace, an unknown field with the
    writable fields listed, a read-only field, or a bad value. A bad field on
    a `variations` item starts with the item, as `variations[0]: `.

    The variation `extensions` schema lists only the fields that apply to a
    variation, so a field that does not apply fails the closed schema with a
    generic message. Treat that field as unknown on the variation, and list
    only the writable fields that apply to a variation, the same filter that
    the schema uses.

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

    * Ignore the describe_bad_fields() call that PHPStan checks in the ProductsQuery context

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

    * Move the variation field refusals out of the shared product trait

    ProductsQuery uses ProductAbilityTrait but does not extend
    ActionableAbility, so it has no describe_bad_fields(). Only the
    product create and update abilities need the refusals, so they now use
    a trait of their own, and the PHPStan ignore is gone.

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

    * Read string booleans in the product ability inputs as booleans

    The ability schema accepts "false", "0" and 0 as booleans and passes them
    through unchanged. The product abilities read the variation delete flag,
    the delete force flag and the attribute flags with empty() or a (bool)
    cast, so "delete": "false" deleted the variation and "force": "false"
    deleted the product for good. Read them with wc_string_to_bool(), as the
    product setters and the query abilities already do.

    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/wooai-1629-explain-unwritable-field b/plugins/woocommerce/changelog/wooai-1629-explain-unwritable-field
new file mode 100644
index 00000000000..1f144aa9c1f
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1629-explain-unwritable-field
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Ability errors for extension fields now say what is wrong and what to do, for every bad field in one message: an unregistered namespace, an unknown field with the writable fields listed, a read-only field, or a bad value.
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 10beaecfd72..062588099e5 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -376,6 +376,101 @@ abstract class ActionableAbility extends \WP_Ability {
 		}
 	}

+	/**
+	 * Check the input schema. When it refuses the input, name every bad extension
+	 * field in one message, because an MCP client gets only the first message of an
+	 * error. A failure with no bad extension field keeps the error of the schema.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param mixed $input Ability input.
+	 * @return true|\WP_Error
+	 */
+	public function validate_input( $input = null ) {
+		$result = parent::validate_input( $input );
+		if ( ! is_wp_error( $result ) || ! is_array( $input ) ) {
+			return $result;
+		}
+		$refusals = $this->get_field_refusals( $input );
+		if ( empty( $refusals ) ) {
+			return $result;
+		}
+		return new \WP_Error( 'woocommerce_ability_invalid_fields', implode( ' ', $refusals ), array( 'status' => 400 ) );
+	}
+
+	/**
+	 * Say what is wrong with each bad extension field of the input. An ability
+	 * whose input has more `extensions`, such as one per item, adds those here.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param array $input Ability input that the schema refused.
+	 * @return string[]
+	 */
+	protected function get_field_refusals( array $input ): array {
+		if ( ! isset( $this->get_input_schema()['properties']['extensions'] ) ) {
+			return array();
+		}
+		return self::describe_bad_fields( $input['extensions'] ?? null, $this->get_resource() );
+	}
+
+	/**
+	 * Say what is wrong with each unknown namespace, unknown field, read-only field
+	 * and bad value. A value of the wrong shape is left to the input schema.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param mixed         $values        Values keyed by namespace, then field.
+	 * @param string        $resource_name Resource of the object that gets the values.
+	 * @param callable|null $field_filter  Optional. Gets the arguments of a field and returns whether the object can take it. A field it refuses counts as unknown.
+	 * @return string[]
+	 */
+	protected static function describe_bad_fields( $values, string $resource_name, ?callable $field_filter = null ): array {
+		$registered = ExtensibleAbilities::get_fields( $resource_name );
+		$refusals   = array();
+		foreach ( is_array( $values ) ? $values : array() as $namespace => $fields ) {
+			if ( ! isset( $registered[ $namespace ] ) ) {
+				/* translators: %s: Extension namespace. */
+				$refusals[] = sprintf( __( 'No extension registered the namespace "%s" for this ability. Remove it from the input, or use a namespace from the input schema.', 'woocommerce' ), $namespace );
+				continue;
+			}
+			$candidates = null === $field_filter ? $registered[ $namespace ] : array_filter( $registered[ $namespace ], $field_filter );
+			foreach ( is_array( $fields ) ? $fields : array() as $field => $value ) {
+				$args = $candidates[ $field ] ?? null;
+				if ( null === $args ) {
+					$refusals[] = self::describe_unknown_field( $candidates, (string) $namespace, (string) $field );
+				} elseif ( ! isset( $args['set_callback'] ) ) {
+					/* translators: 1: Extension namespace. 2: Field name. */
+					$refusals[] = sprintf( __( 'The extension field "%1$s.%2$s" is read-only. Remove it from the input.', 'woocommerce' ), $namespace, $field );
+				} else {
+					$valid = rest_validate_value_from_schema( $value, $args['input_schema'], 'extensions.' . $namespace . '.' . $field );
+					if ( is_wp_error( $valid ) ) {
+						$refusals[] = $valid->get_error_message();
+					}
+				}
+			}
+		}
+		return $refusals;
+	}
+
+	/**
+	 * Say that a namespace has no such field, and list the fields that the agent can write.
+	 *
+	 * @param array<string, array> $fields              Fields of the namespace that the object can take.
+	 * @param string               $extension_namespace Extension namespace from the input.
+	 * @param string               $field               Field name from the input.
+	 * @return string
+	 */
+	private static function describe_unknown_field( array $fields, string $extension_namespace, string $field ): string {
+		$writable = array_keys( array_filter( $fields, static fn( array $args ): bool => isset( $args['set_callback'] ) ) );
+		if ( empty( $writable ) ) {
+			/* translators: 1: Extension namespace. 2: Field name. */
+			return sprintf( __( 'The extension namespace "%1$s" has no field "%2$s", and it has no fields you can write. Remove "%1$s.%2$s" from the input.', 'woocommerce' ), $extension_namespace, $field );
+		}
+		/* translators: 1: Extension namespace. 2: Field name. 3: Comma-separated list of field names. */
+		return sprintf( __( 'The extension namespace "%1$s" has no field "%2$s". The fields you can write in this namespace are: %3$s. Use one of them, or remove "%1$s.%2$s" from the input.', 'woocommerce' ), $extension_namespace, $field, implode( ', ', $writable ) );
+	}
+
 	/**
 	 * Use the change as the execute callback, and add the `extensions` input
 	 * when the feature is on.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
index 3ef40f0791e..27d7ba1bc0c 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
@@ -12,6 +12,7 @@ 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;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductFieldRefusalsTrait;

 defined( 'ABSPATH' ) || exit;

@@ -22,6 +23,7 @@ class ProductCreate extends ActionableAbility {

 	use DomainAbilityHelpers;
 	use ProductAbilityTrait;
+	use ProductFieldRefusalsTrait;

 	/**
 	 * Ability name.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
index 9b7480a8e4a..6dfdc75c358 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductDelete.php
@@ -132,7 +132,7 @@ class ProductDelete extends ActionableAbility {
 	 */
 	protected function load( array $input ) {
 		$this->product_id = 0;
-		$this->force      = ! empty( $input['force'] );
+		$this->force      = wc_string_to_bool( $input['force'] ?? false );
 		$this->parent     = null;
 		$this->deleting   = array();
 		$this->deleted    = array();
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
index e86f412d6c2..09b66ae53d4 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
@@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\Internal\Abilities\Domain;
 use Automattic\WooCommerce\Abilities\ActionableAbility;
 use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
 use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductFieldRefusalsTrait;

 defined( 'ABSPATH' ) || exit;

@@ -20,6 +21,7 @@ class ProductUpdate extends ActionableAbility {

 	use DomainAbilityHelpers;
 	use ProductAbilityTrait;
+	use ProductFieldRefusalsTrait;

 	/**
 	 * Ability name.
@@ -246,7 +248,7 @@ class ProductUpdate extends ActionableAbility {
 	 */
 	public static function can_update_product( $input = array() ): bool {
 		$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' ) ) );
+		$deletes    = is_array( $input ) && is_array( $input['variations'] ?? null ) && ! empty( array_filter( array_map( 'wc_string_to_bool', array_column( $input['variations'], 'delete' ) ) ) );

 		return $product_id > 0
 			&& wc_rest_check_post_permissions( 'product', 'edit', $product_id )
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index ea9e6f7cbec..dd0c21b6603 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -833,8 +833,9 @@ trait ProductAbilityTrait {
 		foreach ( array_values( $items ) as $index => $item ) {
 			$position = $index + 1;
 			$id       = (int) ( $item['id'] ?? 0 );
+			$delete   = wc_string_to_bool( $item['delete'] ?? false );

-			if ( array_key_exists( 'delete', $item ) && ( $id <= 0 || count( $item ) > 2 ) ) {
+			if ( $delete && ( $id <= 0 || count( $item ) > 2 ) ) {
 				return new \WP_Error(
 					'woocommerce_product_variation_invalid',
 					/* translators: %d: variation position. */
@@ -862,7 +863,7 @@ trait ProductAbilityTrait {
 					);
 				}
 				$named[ $id ] = true;
-				if ( ! empty( $item['delete'] ) ) {
+				if ( $delete ) {
 					$plan['delete'][] = $variation;
 					continue;
 				}
@@ -1073,7 +1074,7 @@ trait ProductAbilityTrait {
 			$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'] ) ) ) {
+			if ( '' === $taxonomy && ( ! empty( $item['id'] ) || wc_string_to_bool( $item['global'] ?? false ) ) ) {
 				return new \WP_Error(
 					'woocommerce_product_attributes_invalid',
 					sprintf(
@@ -1100,8 +1101,8 @@ trait ProductAbilityTrait {
 			$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 ) );
+			$attribute->set_visible( $item['visible'] ?? true );
+			$attribute->set_variation( $item['variation'] ?? true );
 			$attributes[ sanitize_title( $name ) ] = $attribute;
 		}

@@ -1130,7 +1131,7 @@ trait ProductAbilityTrait {
 		}

 		$name = trim( $item['name'] ?? '' );
-		if ( empty( $item['global'] ) || '' === $name ) {
+		if ( ! wc_string_to_bool( $item['global'] ?? false ) || '' === $name ) {
 			return '';
 		}

diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductFieldRefusalsTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductFieldRefusalsTrait.php
new file mode 100644
index 00000000000..c0e02aa236e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductFieldRefusalsTrait.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * Product field refusals trait file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;
+
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Extension field refusals for the product abilities that write a product and
+ * its `variations` items. The using class extends `ActionableAbility` and uses
+ * `ProductAbilityTrait`.
+ */
+trait ProductFieldRefusalsTrait {
+
+	/**
+	 * Say what is wrong with each bad extension field of the input and of its
+	 * `variations` items.
+	 *
+	 * @param array $input Ability input that the schema refused.
+	 * @return string[]
+	 */
+	protected function get_field_refusals( array $input ): array {
+		return array_merge( parent::get_field_refusals( $input ), self::get_variation_field_refusals( $input ) );
+	}
+
+	/**
+	 * Say what is wrong with each bad extension field of the `variations` items.
+	 * The message starts with the item, as `variations[0]: `.
+	 *
+	 * @param array $input Ability input that the schema refused.
+	 * @return string[]
+	 */
+	private static function get_variation_field_refusals( array $input ): array {
+		if ( ! ExtensibleAbilities::is_enabled() || ! is_array( $input['variations'] ?? null ) ) {
+			return array();
+		}
+		$refusals = array();
+		foreach ( $input['variations'] as $index => $item ) {
+			foreach ( self::describe_bad_fields( $item['extensions'] ?? null, 'product', array( self::class, 'field_applies_to_variations' ) ) as $message ) {
+				$refusals[] = 'variations[' . $index . ']: ' . $message;
+			}
+		}
+		return $refusals;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index bdad93604e9..84e804719a0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -2522,6 +2522,103 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		$this->assertSame( array( $created['product']['variation_ids'][0] ), wc_get_product( $created['product']['id'] )->get_children() );
 	}

+	/**
+	 * Delete flag values that the boolean schema accepts and that mean false.
+	 *
+	 * @return array<string, array{0: bool|string|int}>
+	 */
+	public function provider_false_delete_flags(): array {
+		return array(
+			'boolean false' => array( false ),
+			'string false'  => array( 'false' ),
+			'string zero'   => array( '0' ),
+			'integer zero'  => array( 0 ),
+		);
+	}
+
+	/**
+	 * @testdox Should change a variation through the parent when its item sends a false delete flag.
+	 * @dataProvider provider_false_delete_flags
+	 *
+	 * @param bool|string|int $flag Delete flag value.
+	 */
+	public function test_a_variation_item_with_a_false_delete_flag_is_a_change( $flag ): 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];
+
+		$result = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'         => $created['product']['id'],
+				'variations' => array(
+					array(
+						'id'            => $variation_id,
+						'delete'        => $flag,
+						'regular_price' => '9.00',
+					),
+				),
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertNotNull( get_post( $variation_id ) );
+		$this->assertSame( '9.00', wc_get_product( $variation_id )->get_regular_price() );
+
+		$kept = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'         => $created['product']['id'],
+				'variations' => array(
+					array(
+						'id'     => $variation_id,
+						'delete' => $flag,
+					),
+				),
+			)
+		);
+
+		$this->assertNotWPError( $kept );
+		$this->assertNotNull( get_post( $variation_id ) );
+	}
+
+	/**
+	 * @testdox Should move a product to the trash when force is the string false.
+	 */
+	public function test_product_delete_with_force_string_false_trashes(): void {
+		$product                     = \WC_Helper_Product::create_simple_product();
+		$this->created_product_ids[] = $product->get_id();
+
+		$result = wp_get_ability( 'woocommerce/product-delete' )->execute(
+			array(
+				'id'    => $product->get_id(),
+				'force' => 'false',
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( 'trash', get_post_status( $product->get_id() ) );
+	}
+
+	/**
+	 * @testdox Should read string false on the attribute flags of a product as false.
+	 */
+	public function test_product_create_reads_string_false_attribute_flags(): void {
+		$input                               = $this->get_shirt_input();
+		$input['attributes'][0]['visible']   = 'false';
+		$input['attributes'][0]['variation'] = 'false';
+		$input['attributes'][0]['global']    = 'false';
+		unset( $input['variations'] );
+
+		$created = wp_get_ability( 'woocommerce/product-create' )->execute( $input );
+
+		$this->assertNotWPError( $created );
+		$this->created_product_ids[] = $created['product']['id'];
+		$attribute                   = $created['product']['attributes'][0];
+		$this->assertFalse( $attribute['visible'] );
+		$this->assertFalse( $attribute['variation'] );
+		$this->assertSame( 0, $attribute['id'] );
+	}
+
 	/**
 	 * @testdox Should fail a variation delete through the parent when pre_delete_post blocks it.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 9df42a17544..c6d6fe951ce 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -2158,7 +2158,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {

 			$this->assertSame( 400, $response->get_status(), wp_json_encode( $extensions ) );
 			if ( isset( $extensions['other_ext']['count'] ) && 'many' === $extensions['other_ext']['count'] ) {
-				$this->assertStringContainsString( 'input[extensions][other_ext][count] is not of type integer,null.', $response->get_data()['message'] );
+				$this->assertSame( 'extensions.other_ext.count is not of type integer,null.', $response->get_data()['message'] );
 			}
 		}
 		$saved = wc_get_product( $product->get_id() );
@@ -2166,6 +2166,224 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		$this->assertFalse( $saved->meta_exists( '_test_color' ) );
 	}

+	/**
+	 * @testdox Should say what is wrong with one bad extension field, and save nothing.
+	 * @testWith [{"nope":{"x":"red"}}, "No extension registered the namespace \"nope\" for this ability. Remove it from the input, or use a namespace from the input schema."]
+	 *           [{"nope":"red"}, "No extension registered the namespace \"nope\" for this ability. Remove it from the input, or use a namespace from the input schema."]
+	 *           [{"nope":{}}, "No extension registered the namespace \"nope\" for this ability. Remove it from the input, or use a namespace from the input schema."]
+	 *           [{"other_ext":{"cuont":3}}, "The extension namespace \"other_ext\" has no field \"cuont\". The fields you can write in this namespace are: count, size. Use one of them, or remove \"other_ext.cuont\" from the input."]
+	 *           [{"test-ext":{"code":"A2"}}, "The extension field \"test-ext.code\" is read-only. Remove it from the input."]
+	 *           [{"other_ext":{"size":"huge"}}, "extensions.other_ext.size is not one of small, large, and null."]
+	 *
+	 * @param array  $extensions Value of the `extensions` input.
+	 * @param string $message    Expected error message.
+	 */
+	public function test_a_bad_extension_field_is_refused_by_name( array $extensions, string $message ): void {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+
+		$result = wp_get_ability( 'woocommerce/product-update' )->validate_input(
+			array(
+				'id'         => $product->get_id(),
+				'name'       => 'Pencil',
+				'extensions' => $extensions,
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( array( 'woocommerce_ability_invalid_fields' ), $result->get_error_codes() );
+		$this->assertSame( $message, $result->get_error_message() );
+		$this->assertSame( 400, $result->get_error_data()['status'] );
+		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	}
+
+	/**
+	 * @testdox Should say that a namespace has no writable fields when a field does not exist and all its fields are read-only.
+	 */
+	public function test_an_unknown_field_in_a_read_only_namespace_is_refused(): void {
+		$this->add_test_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				ExtensibleAbilities::register_field(
+					array(
+						'resource'      => 'product',
+						'namespace'     => 'read_only_ext',
+						'field'         => 'code',
+						'output_schema' => array( 'type' => 'string' ),
+						'get_callback'  => static fn() => 'A1',
+					)
+				);
+			}
+		);
+		$this->reset_registries();
+		AbilitiesLoader::init_extensions();
+		$product = \WC_Helper_Product::create_simple_product();
+
+		$result = wp_get_ability( 'woocommerce/product-update' )->validate_input(
+			array(
+				'id'         => $product->get_id(),
+				'extensions' => array( 'read_only_ext' => array( 'cdoe' => 'A2' ) ),
+			)
+		);
+
+		$this->assertSame( 'The extension namespace "read_only_ext" has no field "cdoe", and it has no fields you can write. Remove "read_only_ext.cdoe" from the input.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should name every bad extension field in the first message of one error, for a client that reads only that message.
+	 */
+	public function test_every_bad_extension_field_is_in_the_first_message(): void {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+
+		$result = wp_get_ability( 'woocommerce/product-update' )->execute(
+			array(
+				'id'         => $product->get_id(),
+				'name'       => 'Pencil',
+				'extensions' => array(
+					'nope'      => array( 'x' => 'y' ),
+					'test-ext'  => array( 'code' => 'A2' ),
+					'other_ext' => array(
+						'cuont' => 3,
+						'count' => 'many',
+					),
+				),
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertCount( 1, $result->get_error_messages() );
+		$this->assertSame(
+			implode(
+				' ',
+				array(
+					'No extension registered the namespace "nope" for this ability. Remove it from the input, or use a namespace from the input schema.',
+					'The extension field "test-ext.code" is read-only. Remove it from the input.',
+					'The extension namespace "other_ext" has no field "cuont". The fields you can write in this namespace are: count, size. Use one of them, or remove "other_ext.cuont" from the input.',
+					'extensions.other_ext.count is not of type integer,null.',
+				)
+			),
+			$result->get_error_message()
+		);
+		$this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+	}
+
+	/**
+	 * @testdox Should name the bad fields of the product and of its variations in one message on $ability, and run no validator.
+	 * @testWith ["woocommerce/product-create"]
+	 *           ["woocommerce/product-update"]
+	 *
+	 * @param string $ability Ability name.
+	 */
+	public function test_bad_fields_on_the_product_and_its_variations_are_refused_together( string $ability ): void {
+		$validated = 0;
+		$this->register_validator(
+			'product',
+			'test-ext',
+			static function () use ( &$validated ) {
+				++$validated;
+				return null;
+			}
+		);
+		$input = $this->get_shirt_input();
+		if ( 'woocommerce/product-update' === $ability ) {
+			$input['id'] = \WC_Helper_Product::create_variation_product()->get_id();
+		}
+		$input['extensions']                  = array( 'other_ext' => array( 'size' => 'huge' ) );
+		$input['variations'][0]['extensions'] = array( 'other_ext' => array( 'count' => 'many' ) );
+		$input['variations'][1]['extensions'] = array( 'nope' => array( 'x' => 'y' ) );
+
+		$result = wp_get_ability( $ability )->execute( $input );
+
+		$this->assertSame(
+			'extensions.other_ext.size is not one of small, large, and null. variations[0]: extensions.other_ext.count is not of type integer,null. variations[1]: No extension registered the namespace "nope" for this ability. Remove it from the input, or use a namespace from the input schema.',
+			$result->get_error_message()
+		);
+		$this->assertSame( 0, $validated );
+		$this->assertSame( 0, wc_get_product_id_by_sku( 'shirt-s' ) );
+	}
+
+	/**
+	 * @testdox Should suggest only the fields that apply to a variation.
+	 */
+	public function test_an_unknown_field_on_a_variation_suggests_only_variation_fields(): void {
+		$this->add_test_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				$fields = array(
+					'simple_only'    => 'simple',
+					'variation_only' => 'variation',
+				);
+				foreach ( $fields as $field => $object_type ) {
+					ExtensibleAbilities::register_field(
+						array(
+							'resource'      => 'product',
+							'namespace'     => 'typed-ext',
+							'field'         => $field,
+							'output_schema' => array( 'type' => 'string' ),
+							'input_schema'  => array( 'type' => 'string' ),
+							'object_types'  => array( $object_type ),
+							'get_callback'  => static fn() => 'value',
+							'set_callback'  => static function () {},
+						)
+					);
+				}
+			}
+		);
+		$this->reset_registries();
+		AbilitiesLoader::init_extensions();
+		$input = $this->get_shirt_input();
+
+		$input['variations'][0]['extensions'] = array( 'typed-ext' => array( 'typo' => 'x' ) );
+
+		$result = wp_get_ability( 'woocommerce/product-create' )->execute( $input );
+
+		$this->assertSame(
+			'variations[0]: The extension namespace "typed-ext" has no field "typo". The fields you can write in this namespace are: variation_only. Use one of them, or remove "typed-ext.typo" from the input.',
+			$result->get_error_message()
+		);
+	}
+
+	/**
+	 * @testdox Should keep the schema error when no extension field is at fault.
+	 * @testWith [{"not_a_core_field":"x"}]
+	 *           [{"extensions":{"test-ext":"red"}}]
+	 *           [{"extensions":"red"}]
+	 *
+	 * @param array $bad_input Input that only the schema refuses.
+	 */
+	public function test_the_schema_error_stands_without_a_bad_extension_field( array $bad_input ): void {
+		$product = \WC_Helper_Product::create_simple_product();
+
+		$result = wp_get_ability( 'woocommerce/product-update' )->validate_input( array( 'id' => $product->get_id() ) + $bad_input );
+
+		$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should keep the extensions input schema closed.
+	 */
+	public function test_the_extensions_input_schema_stays_closed(): void {
+		$extensions = $this->get_ability( 'woocommerce/product-update' )['input_schema']['properties']['extensions'];
+
+		$this->assertFalse( $extensions['additionalProperties'] );
+		$this->assertFalse( $extensions['properties']['test-ext']['additionalProperties'] );
+	}
+
+	/**
+	 * @testdox Should write valid extension fields on the product and on its variations.
+	 */
+	public function test_valid_extension_fields_on_the_product_and_its_variations_are_written(): void {
+		$input = $this->get_shirt_input();
+
+		$input['extensions']                  = array( 'test-ext' => array( 'color' => 'red' ) );
+		$input['variations'][0]['extensions'] = array( 'other_ext' => array( 'count' => 3 ) );
+
+		$this->run_ability( 'woocommerce/product-create', $input );
+
+		$this->assertSame( 'red', self::$received['color'] ?? null );
+		$this->assertSame( 3, self::$received['count'] ?? null );
+		$this->assertGreaterThan( 0, wc_get_product_id_by_sku( 'shirt-s' ) );
+	}
+
 	/**
 	 * @testdox Should refuse the change and save nothing when an update callback throws.
 	 */
@@ -3358,8 +3576,47 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 		$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( 'woocommerce_ability_invalid_fields', $refused->get_data()['code'] );
+		$this->assertSame( 'variations[0]: The extension namespace "typed-ext" has no field "simple_only", and it has no fields you can write. Remove "typed-ext.simple_only" from the input.', $refused->get_data()['message'] );
+		$this->assertSame( 0, wc_get_product_id_by_sku( 'shirt-s' ) );
+	}
+
+	/**
+	 * @testdox Should name a variation field that does not apply to a variation and list the fields that do.
+	 */
+	public function test_a_variation_field_that_does_not_apply_lists_the_variation_fields(): void {
+		$this->add_test_action(
+			'woocommerce_extensible_abilities_init',
+			static function () {
+				$fields = array(
+					'simple_only'    => 'simple',
+					'variation_only' => 'variation',
+				);
+				foreach ( $fields as $field => $object_type ) {
+					ExtensibleAbilities::register_field(
+						array(
+							'resource'      => 'product',
+							'namespace'     => 'typed-ext',
+							'field'         => $field,
+							'output_schema' => array( 'type' => 'string' ),
+							'input_schema'  => array( 'type' => 'string' ),
+							'object_types'  => array( $object_type ),
+							'get_callback'  => static fn() => 'value',
+							'set_callback'  => static function () {},
+						)
+					);
+				}
+			}
+		);
+		$this->reset_registries();
+
+		$input                                = $this->get_shirt_input();
+		$input['variations'][1]['extensions'] = array( 'typed-ext' => array( 'simple_only' => 'nope' ) );
+		$refused                              = $this->run_ability_response( 'woocommerce/product-create', $input );
+
+		$this->assertSame( 400, $refused->get_status() );
+		$this->assertSame( 'woocommerce_ability_invalid_fields', $refused->get_data()['code'] );
+		$this->assertSame( 'variations[1]: The extension namespace "typed-ext" has no field "simple_only". The fields you can write in this namespace are: variation_only. Use one of them, or remove "typed-ext.simple_only" from the input.', $refused->get_data()['message'] );
 		$this->assertSame( 0, wc_get_product_id_by_sku( 'shirt-s' ) );
 	}