Commit 6d98cc3fdc0 for woocommerce

commit 6d98cc3fdc01bece1040ea48600cd98516c89e0c
Author: Aarón Fas <yo@aaron.com.es>
Date:   Tue Nov 3 16:32:28 2026 +0100

    Omit absent values from Core ability output (#70398)

    * Omit absent values from ability output

    The ability output contract needs absent values omitted rather than returned as null. Keep zero and false values, and preserve null input deletion for extension fields without changing extension registrations.

    Include the regressions and release note with this contract change. Nested schemas and unions of multiple value types remain outside this slice.

    * Keep unexpected nulls visible to ability output validation

    Only the documented optional Core fields may be absent. Broad null filtering also hid invalid product names and order payment methods, allowing invalid output to appear successful.

    Remove nulls only from the named optional fields and add an order note date only when present. Regression tests cover unexpected null validation, optional dates, and preservation of false and empty strings.

    * Return saved ability values and retain invalid dates for validation

    Write responses must reflect persisted state, including modification dates that are not populated on a newly created product object. Read products and orders again after writes before formatting the response.

    Build optional fields with direct conditions instead of creating nulls and removing a field list. Product and order dates remain visible to output validation. Regressions cover saved values, creation dates and invalid null dates.

    * Omit empty external product output fields

    An empty external URL does not satisfy its URI schema. Include external URLs and button text only when configured, and describe their omission in the output schema. Cover both unset and configured values through the query ability.

    * Match order quantity output to the store stock precision

    Stores that support fractional stock return fractional line item quantities. An integer-only output schema rejects the entire order query. Use the existing stock precision rule and cover a query returning quantity 1.5.

    * Check nested Core output schema branches and enum values

    The single-type regression must also inspect additionalProperties, anyOf and oneOf, and reject null enum members. Keep extension-owned properties outside this Core-only contract. A temporary nested null enum passed the old check and failed the expanded check.

    * Omit unavailable ability dates after timezone formatting

    The timezone formatter returns null for absent or unsupported filtered dates. Keep those optional keys out of string-only output schemas so a saved product update or order note does not fail output validation.

    Cover persisted writes and queries with null, string, timestamp and other unsupported date values. Retain valid timezone offsets and rejection of unrelated invalid fields.

    * Keep saved ability responses when reloading fails

    A failed read after a successful write must not replace the saved entity with null and trigger output validation errors. Reuse the product helper pattern from PR #70412 and add the order equivalent, falling back to the supplied saved object.

    Cover failed reloads and validate the fallback objects against their output schemas. Successful reloads still return persisted values.

    * Add only available dates to ability responses

    Build the date-only output separately using the formatter string-or-null contract, rather than inserting null keys and removing them afterward. Document that an unavailable order-note creation date is omitted.

    * Accept fractional order quantities with identity stock filters

    The stock precision probe tests integer 1, which an identity filter preserves even when fractional quantities are allowed. Declare line item quantities as number so both integers and fractions satisfy the output schema.

    Cover both float-converting and type-preserving stock filters with quantity 1.5.

    * Describe omitted product and order ability dates

    Clients inspecting the output schemas should know that unavailable dates are omitted. Document all four date fields for products and orders, including whether each value uses the store timezone or UTC.

diff --git a/plugins/woocommerce/changelog/fix-ability-nullable-output b/plugins/woocommerce/changelog/fix-ability-nullable-output
new file mode 100644
index 00000000000..0fa340e3e6a
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-ability-nullable-output
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Return saved Core ability values with single-type output schemas and omit only optional fields.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
index caf469eb270..c23fc6c6e16 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderAddNote.php
@@ -105,7 +105,7 @@ class OrderAddNote extends AbstractDomainAbility implements AbilityDefinition {

 		return array(
 			'note_id' => (int) $note_id,
-			'order'   => self::format_order_for_response( $order, false ),
+			'order'   => self::format_saved_order_for_response( $order ),
 		);
 	}

diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderNotesQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderNotesQuery.php
index 562e0f6eb14..974b93ff1a5 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderNotesQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderNotesQuery.php
@@ -90,13 +90,16 @@ class OrderNotesQuery extends AbstractDomainAbility implements AbilityDefinition
 		return array(
 			'notes' => array_map(
 				static function ( $note ): array {
-					return array(
+					$data = array(
 						'id'            => (int) $note->id,
 						'author'        => (string) $note->added_by,
-						'date_created'  => $note->date_created ? $note->date_created->format( 'c' ) : null,
 						'note'          => (string) $note->content,
 						'customer_note' => (bool) $note->customer_note,
 					);
+					if ( $note->date_created ) {
+						$data['date_created'] = $note->date_created->format( 'c' );
+					}
+					return $data;
 				},
 				$notes
 			),
@@ -140,8 +143,9 @@ class OrderNotesQuery extends AbstractDomainAbility implements AbilityDefinition
 							'id'            => array( 'type' => 'integer' ),
 							'author'        => array( 'type' => 'string' ),
 							'date_created'  => array(
-								'type'   => array( 'string', 'null' ),
-								'format' => 'date-time',
+								'type'        => 'string',
+								'format'      => 'date-time',
+								'description' => __( 'Note creation date. Omitted when no creation date is available.', 'woocommerce' ),
 							),
 							'note'          => array( 'type' => 'string' ),
 							'customer_note' => array( 'type' => 'boolean' ),
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
index cfa594c4c16..6e25a9eac62 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
@@ -210,7 +210,7 @@ class OrderUpdateStatus extends ActionableAbility {
 	 */
 	public function prepare_response( $subject ): array {
 		return array(
-			'order' => self::format_order_for_response( $subject, false ),
+			'order' => self::format_saved_order_for_response( $subject ),
 		);
 	}

diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
index 43991e12675..60bb2a1bc65 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductCreate.php
@@ -116,7 +116,7 @@ class ProductCreate extends ActionableAbility {
 	 */
 	protected function prepare_response( $subject ): array {
 		return array(
-			'product' => self::format_product_for_response( $subject ),
+			'product' => self::format_saved_product_for_response( $subject ),
 		);
 	}

diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
index aaffcdadbdc..649191ea868 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductUpdate.php
@@ -165,7 +165,7 @@ class ProductUpdate extends ActionableAbility {
 	 */
 	protected function prepare_response( $subject ): array {
 		return array(
-			'product' => self::format_product_for_response( $subject ),
+			'product' => self::format_saved_product_for_response( $subject ),
 		);
 	}

diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
index 1be100ff5ea..cf17c3c421c 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
@@ -142,26 +142,31 @@ trait OrderAbilityTrait {
 				'total'                => array( 'type' => 'string' ),
 				'customer_id'          => array( 'type' => 'integer' ),
 				'billing_email'        => array(
-					'type'   => array( 'string', 'null' ),
-					'format' => 'email',
+					'type'        => 'string',
+					'format'      => 'email',
+					'description' => __( 'Billing email. Omitted when no billing email is set.', 'woocommerce' ),
 				),
 				'payment_method'       => array( 'type' => 'string' ),
 				'payment_method_title' => array( 'type' => 'string' ),
 				'date_created'         => array(
-					'type'   => array( 'string', 'null' ),
-					'format' => 'date-time',
+					'type'        => 'string',
+					'format'      => 'date-time',
+					'description' => __( 'Order creation date in the store timezone. Omitted when no creation date is available.', 'woocommerce' ),
 				),
 				'date_created_gmt'     => array(
-					'type'   => array( 'string', 'null' ),
-					'format' => 'date-time',
+					'type'        => 'string',
+					'format'      => 'date-time',
+					'description' => __( 'Order creation date in UTC. Omitted when no creation date is available.', 'woocommerce' ),
 				),
 				'date_modified'        => array(
-					'type'   => array( 'string', 'null' ),
-					'format' => 'date-time',
+					'type'        => 'string',
+					'format'      => 'date-time',
+					'description' => __( 'Order modification date in the store timezone. Omitted when no modification date is available.', 'woocommerce' ),
 				),
 				'date_modified_gmt'    => array(
-					'type'   => array( 'string', 'null' ),
-					'format' => 'date-time',
+					'type'        => 'string',
+					'format'      => 'date-time',
+					'description' => __( 'Order modification date in UTC. Omitted when no modification date is available.', 'woocommerce' ),
 				),
 				'line_items'           => array(
 					'type'        => 'array',
@@ -174,7 +179,7 @@ trait OrderAbilityTrait {
 								'name'         => array( 'type' => 'string' ),
 								'product_id'   => array( 'type' => 'integer' ),
 								'variation_id' => array( 'type' => 'integer' ),
-								'quantity'     => array( 'type' => 'integer' ),
+								'quantity'     => array( 'type' => 'number' ),
 								'subtotal'     => array( 'type' => 'string' ),
 								'total'        => array( 'type' => 'string' ),
 							),
@@ -272,6 +277,18 @@ trait OrderAbilityTrait {
 			&& wc_rest_check_post_permissions( $type, $context, $order_id );
 	}

+	/**
+	 * Format an order after a save, retaining the saved object if reloading fails.
+	 *
+	 * @param \WC_Order $order Saved order object.
+	 * @return array
+	 */
+	protected static function format_saved_order_for_response( \WC_Order $order ): array {
+		$stored = wc_get_order( $order->get_id() );
+
+		return self::format_order_for_response( $stored instanceof \WC_Order ? $stored : $order, false );
+	}
+
 	/**
 	 * Format an order for ability output.
 	 *
@@ -292,13 +309,8 @@ trait OrderAbilityTrait {
 			),
 			'total'                => $order->get_total(),
 			'customer_id'          => $order->get_customer_id(),
-			'billing_email'        => '' === $billing_email ? null : $billing_email,
 			'payment_method'       => $order->get_payment_method(),
 			'payment_method_title' => $order->get_payment_method_title(),
-			'date_created'         => self::format_date_for_response( $order->get_date_created(), false ),
-			'date_created_gmt'     => self::format_date_for_response( $order->get_date_created() ),
-			'date_modified'        => self::format_date_for_response( $order->get_date_modified(), false ),
-			'date_modified_gmt'    => self::format_date_for_response( $order->get_date_modified() ),
 		);

 		if ( $include_line_items ) {
@@ -325,6 +337,19 @@ trait OrderAbilityTrait {
 			}
 		}

+		if ( '' !== $billing_email ) {
+			$data['billing_email'] = $billing_email;
+		}
+
+		$data += array_filter(
+			array(
+				'date_created'      => self::format_date_for_response( $order->get_date_created(), false ),
+				'date_created_gmt'  => self::format_date_for_response( $order->get_date_created() ),
+				'date_modified'     => self::format_date_for_response( $order->get_date_modified(), false ),
+				'date_modified_gmt' => self::format_date_for_response( $order->get_date_modified() ),
+			)
+		);
+
 		return ExtensibleAbilities::add_fields_to_object( $data, 'order', $order );
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index 0e794db8bb3..11368de472e 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -740,6 +740,18 @@ trait ProductAbilityTrait {
 		return null;
 	}

+	/**
+	 * Format a product after a save, retaining the saved object if reloading fails.
+	 *
+	 * @param \WC_Product $product Saved product object.
+	 * @return array
+	 */
+	protected static function format_saved_product_for_response( \WC_Product $product ): array {
+		$stored = wc_get_product( $product->get_id() );
+
+		return self::format_product_for_response( $stored instanceof \WC_Product ? $stored : $product );
+	}
+
 	/**
 	 * Format a product for ability output.
 	 *
@@ -751,33 +763,51 @@ trait ProductAbilityTrait {
 		$permalink      = $product->get_permalink();

 		$data = array(
-			'id'                => $product->get_id(),
-			'name'              => $product->get_name(),
-			'slug'              => $product->get_slug(),
-			'permalink'         => false === $permalink ? null : $permalink,
-			'type'              => $product->get_type(),
-			'status'            => $product->get_status(),
-			'sku'               => $product->get_sku(),
-			'currency'          => get_woocommerce_currency(),
-			'currency_symbol'   => html_entity_decode(
+			'id'               => $product->get_id(),
+			'name'             => $product->get_name(),
+			'slug'             => $product->get_slug(),
+			'type'             => $product->get_type(),
+			'status'           => $product->get_status(),
+			'sku'              => $product->get_sku(),
+			'currency'         => get_woocommerce_currency(),
+			'currency_symbol'  => html_entity_decode(
 				get_woocommerce_currency_symbol(),
 				ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
 			),
-			'price'             => $product->get_price(),
-			'regular_price'     => $product->get_regular_price(),
-			'sale_price'        => $product->get_sale_price(),
-			'stock_status'      => $product->get_stock_status(),
-			'stock_quantity'    => null === $stock_quantity ? null : wc_stock_amount( $stock_quantity ),
-			'manage_stock'      => (bool) $product->get_manage_stock(),
-			'virtual'           => (bool) $product->get_virtual(),
-			'downloadable'      => (bool) $product->get_downloadable(),
-			'external_url'      => $product instanceof \WC_Product_External ? $product->get_product_url() : null,
-			'button_text'       => $product instanceof \WC_Product_External ? $product->get_button_text() : null,
-			'grouped_products'  => $product instanceof \WC_Product_Grouped ? array_map( 'absint', $product->get_children( 'edit' ) ) : array(),
-			'date_created'      => self::format_date_for_response( $product->get_date_created(), false ),
-			'date_created_gmt'  => self::format_date_for_response( $product->get_date_created() ),
-			'date_modified'     => self::format_date_for_response( $product->get_date_modified(), false ),
-			'date_modified_gmt' => self::format_date_for_response( $product->get_date_modified() ),
+			'price'            => $product->get_price(),
+			'regular_price'    => $product->get_regular_price(),
+			'sale_price'       => $product->get_sale_price(),
+			'stock_status'     => $product->get_stock_status(),
+			'manage_stock'     => (bool) $product->get_manage_stock(),
+			'virtual'          => (bool) $product->get_virtual(),
+			'downloadable'     => (bool) $product->get_downloadable(),
+			'grouped_products' => $product instanceof \WC_Product_Grouped ? array_map( 'absint', $product->get_children( 'edit' ) ) : array(),
+		);
+
+		if ( false !== $permalink ) {
+			$data['permalink'] = $permalink;
+		}
+		if ( null !== $stock_quantity ) {
+			$data['stock_quantity'] = wc_stock_amount( $stock_quantity );
+		}
+		if ( $product instanceof \WC_Product_External ) {
+			$external_url = $product->get_product_url();
+			$button_text  = $product->get_button_text();
+			if ( '' !== $external_url ) {
+				$data['external_url'] = $external_url;
+			}
+			if ( '' !== $button_text ) {
+				$data['button_text'] = $button_text;
+			}
+		}
+
+		$data += array_filter(
+			array(
+				'date_created'      => self::format_date_for_response( $product->get_date_created(), false ),
+				'date_created_gmt'  => self::format_date_for_response( $product->get_date_created() ),
+				'date_modified'     => self::format_date_for_response( $product->get_date_modified(), false ),
+				'date_modified_gmt' => self::format_date_for_response( $product->get_date_modified() ),
+			)
 		);

 		return ExtensibleAbilities::add_fields_to_object( $data, 'product', $product );
@@ -797,8 +827,8 @@ trait ProductAbilityTrait {
 					'name'              => array( 'type' => 'string' ),
 					'slug'              => array( 'type' => 'string' ),
 					'permalink'         => array(
-						'type'        => array( 'string', 'null' ),
-						'description' => __( 'Product permalink, or null when no public permalink is available.', 'woocommerce' ),
+						'type'        => 'string',
+						'description' => __( 'Product permalink. Omitted when no public permalink is available.', 'woocommerce' ),
 						'format'      => 'uri',
 					),
 					'type'              => array(
@@ -833,20 +863,20 @@ trait ProductAbilityTrait {
 						'enum' => array_keys( wc_get_product_stock_status_options() ),
 					),
 					'stock_quantity'    => array(
-						'type'        => array( self::get_product_stock_quantity_schema_type(), 'null' ),
-						'description' => __( 'Current stock quantity, or null when no stock quantity is set.', 'woocommerce' ),
+						'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'        => array( 'string', 'null' ),
-						'description' => __( 'External product URL for external products.', 'woocommerce' ),
+						'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'        => array( 'string', 'null' ),
-						'description' => __( 'Button text for external products.', 'woocommerce' ),
+						'type'        => 'string',
+						'description' => __( 'Button text. Omitted when the product is not external or no button text is set.', 'woocommerce' ),
 					),
 					'grouped_products'  => array(
 						'type'        => 'array',
@@ -854,20 +884,24 @@ trait ProductAbilityTrait {
 						'items'       => array( 'type' => 'integer' ),
 					),
 					'date_created'      => array(
-						'type'   => array( 'string', 'null' ),
-						'format' => 'date-time',
+						'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'   => array( 'string', 'null' ),
-						'format' => 'date-time',
+						'type'        => 'string',
+						'format'      => 'date-time',
+						'description' => __( 'Product creation date in UTC. Omitted when no creation date is available.', 'woocommerce' ),
 					),
 					'date_modified'     => array(
-						'type'   => array( 'string', 'null' ),
-						'format' => 'date-time',
+						'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'   => array( 'string', 'null' ),
-						'format' => 'date-time',
+						'type'        => 'string',
+						'format'      => 'date-time',
+						'description' => __( 'Product modification date in UTC. Omitted when no modification date is available.', 'woocommerce' ),
 					),
 				),
 				'additionalProperties' => false,
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index 2c58d839b29..ea3978baa0d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -343,10 +343,10 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		$product       = $output_schema['properties']['product']['properties'] ?? array();

 		$this->assertSame( 'uri', $product['permalink']['format'] ?? null );
-		$this->assertSame( array( 'string', 'null' ), $product['permalink']['type'] ?? null );
+		$this->assertSame( 'string', $product['permalink']['type'] ?? null );
 		$this->assertContains( get_woocommerce_currency(), $product['currency']['enum'] ?? array() );
 		$this->assertSame(
-			array( wc_is_stock_amount_integer() ? 'integer' : 'number', 'null' ),
+			wc_is_stock_amount_integer() ? 'integer' : 'number',
 			$product['stock_quantity']['type'] ?? null
 		);
 	}
@@ -439,7 +439,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		$this->assertContains( 'trash', $order['status']['enum'] ?? array() );
 		$this->assertContains( 'checkout-draft', $order['status']['enum'] ?? array() );
 		$this->assertContains( get_woocommerce_currency(), $order['currency']['enum'] ?? array() );
-		$this->assertSame( array( 'string', 'null' ), $order['billing_email']['type'] ?? null );
+		$this->assertSame( 'string', $order['billing_email']['type'] ?? null );
 		$this->assertSame( 'email', $order['billing_email']['format'] ?? null );
 		$this->assertNotEmpty( $order['line_items']['description'] ?? '' );
 		$this->assertSame( 'array', $order['line_items']['type'] ?? null );
@@ -2010,7 +2010,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {

 			$this->assertSame( 'number', $input_schema['properties']['stock_quantity']['type'] ?? null );
 			$this->assertSame(
-				array( 'number', 'null' ),
+				'number',
 				$output_schema['properties']['product']['properties']['stock_quantity']['type'] ?? null
 			);

@@ -2032,7 +2032,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should return null for non-date filter values without failing a product update.
+	 * @testdox Should omit non-date filter values without failing a product update.
 	 * @testWith ["2026-07-15T10:00:00"]
 	 *           [1784109600]
 	 *           ["1784109600"]
@@ -2046,12 +2046,11 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		update_option( 'timezone_string', 'Europe/Amsterdam' );
 		$product                     = \WC_Helper_Product::create_simple_product();
 		$this->created_product_ids[] = $product->get_id();
-		add_filter(
-			'woocommerce_product_get_date_created',
-			static function () use ( $date ) {
-				return $date;
-			}
-		);
+		$filter                      = static function () use ( $date ) {
+			return $date;
+		};
+		add_filter( 'woocommerce_product_get_date_created', $filter );
+		add_filter( 'woocommerce_product_get_date_modified', $filter );

 		$result = wp_get_ability( 'woocommerce/product-update' )->execute(
 			array(
@@ -2061,8 +2060,12 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		);

 		$this->assertNotWPError( $result );
-		$this->assertNull( $result['product']['date_created'] );
-		$this->assertNull( $result['product']['date_created_gmt'] );
+		$query = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => $product->get_id() ) );
+		$this->assertNotWPError( $query );
+		foreach ( array( 'date_created', 'date_created_gmt', 'date_modified', 'date_modified_gmt' ) as $field ) {
+			$this->assertArrayNotHasKey( $field, $result['product'] );
+			$this->assertArrayNotHasKey( $field, $query['products'][0] );
+		}
 		$this->assertSame( 'Filtered date update', wc_get_product( $product->get_id() )->get_name() );
 	}

@@ -2183,6 +2186,314 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should return fractional order quantities when the store supports them.
+	 * @testWith [false]
+	 *           [true]
+	 * @param bool $preserve_type Whether the stock filter preserves the input type.
+	 */
+	public function test_order_output_allows_fractional_quantities( bool $preserve_type ): void {
+		$filter = static function ( $amount ) use ( $preserve_type ) {
+			return $preserve_type ? $amount : (float) $amount;
+		};
+		$this->unregister_domain_abilities();
+		remove_filter( 'woocommerce_stock_amount', 'intval' );
+		add_filter( 'woocommerce_stock_amount', $filter );
+
+		try {
+			$this->register_domain_abilities();
+			$order                     = \WC_Helper_Order::create_order();
+			$this->created_order_ids[] = $order->get_id();
+			foreach ( $order->get_items() as $item ) {
+				$item->set_quantity( 1.5 );
+				$item->save();
+			}
+
+			$result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+				array(
+					'id'                 => $order->get_id(),
+					'include_line_items' => true,
+				)
+			);
+			$this->assertNotWPError( $result );
+			$this->assertSame( 1.5, $result['orders'][0]['line_items'][0]['quantity'] );
+		} finally {
+			remove_filter( 'woocommerce_stock_amount', $filter );
+			add_filter( 'woocommerce_stock_amount', 'intval' );
+		}
+	}
+
+	/**
+	 * @testdox Should omit empty external product fields and preserve configured values.
+	 */
+	public function test_external_product_output_omits_empty_fields(): void {
+		$product = new \WC_Product_External();
+		$product->set_name( 'External product' );
+		$product->save();
+		$this->created_product_ids[] = $product->get_id();
+
+		$result = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => $product->get_id() ) );
+		$this->assertNotWPError( $result );
+		$this->assertArrayNotHasKey( 'external_url', $result['products'][0] );
+		$this->assertArrayNotHasKey( 'button_text', $result['products'][0] );
+
+		$product->set_product_url( 'https://example.com/buy' );
+		$product->set_button_text( 'Buy elsewhere' );
+		$product->save();
+		$result = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => $product->get_id() ) );
+		$this->assertNotWPError( $result );
+		$this->assertSame( 'https://example.com/buy', $result['products'][0]['external_url'] );
+		$this->assertSame( 'Buy elsewhere', $result['products'][0]['button_text'] );
+	}
+
+	/**
+	 * @testdox Should retain the saved product response when reloading fails.
+	 */
+	public function test_saved_product_response_falls_back_when_reload_fails(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$id      = $product->get_id();
+		wp_delete_post( $id, true );
+		$this->assertFalse( wc_get_product( $id ) );
+
+		$formatter = new class() {
+			use \Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
+			use \Automattic\WooCommerce\Internal\Abilities\Domain\Traits\ProductAbilityTrait {
+				format_saved_product_for_response as public format;
+			}
+		};
+		$result    = $formatter::format( $product );
+
+		$this->assertSame( $id, $result['id'] );
+		$this->assertSame( $product->get_name(), $result['name'] );
+		$this->assertTrue( rest_validate_value_from_schema( $result, wp_get_ability( 'woocommerce/product-create' )->get_output_schema()['properties']['product'] ) );
+	}
+
+	/**
+	 * @testdox Should retain the saved order response when reloading fails.
+	 */
+	public function test_saved_order_response_falls_back_when_reload_fails(): void {
+		$order = \WC_Helper_Order::create_order();
+		$id    = $order->get_id();
+		wc_get_order( $id )->delete( true );
+		$this->assertFalse( wc_get_order( $id ) );
+
+		$formatter = new class() {
+			use \Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
+			use \Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait {
+				format_saved_order_for_response as public format;
+			}
+		};
+		$result    = $formatter::format( $order );
+
+		$this->assertSame( $id, $result['id'] );
+		$this->assertSame( $order->get_status(), $result['status'] );
+		$this->assertTrue( rest_validate_value_from_schema( $result, wp_get_ability( 'woocommerce/order-add-note' )->get_output_schema()['properties']['order'] ) );
+	}
+
+	/**
+	 * @testdox Should return saved product values and dates after a write.
+	 * @testWith ["product-create"]
+	 *           ["product-update"]
+	 *
+	 * @param string $ability_name Product write ability.
+	 */
+	public function test_product_write_returns_saved_state( string $ability_name ): void {
+		$input = array( 'name' => 'Saved name' );
+		if ( 'product-update' === $ability_name ) {
+			$input['id'] = \WC_Helper_Product::create_simple_product()->get_id();
+		}
+		$change_in_memory = static function ( $product ) {
+			$product->set_name( 'Unsaved name' );
+		};
+		add_action( 'woocommerce_after_product_object_save', $change_in_memory );
+		try {
+			$result = wp_get_ability( 'woocommerce/' . $ability_name )->execute( $input );
+		} finally {
+			remove_action( 'woocommerce_after_product_object_save', $change_in_memory );
+		}
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( 'Saved name', $result['product']['name'] );
+		foreach ( array( 'date_created', 'date_created_gmt', 'date_modified', 'date_modified_gmt' ) as $field ) {
+			$this->assertNotEmpty( $result['product'][ $field ] ?? null, $field );
+		}
+	}
+
+	/**
+	 * @testdox Should return saved order values after a status change or note.
+	 * @testWith ["order-update-status"]
+	 *           ["order-add-note"]
+	 *
+	 * @param string $ability_name Order write ability.
+	 */
+	public function test_order_write_returns_saved_state( string $ability_name ): void {
+		$order = wc_create_order( array( 'status' => 'pending' ) );
+		$order->set_payment_method( 'bacs' );
+		$order->save();
+		$change_in_memory = static function ( $value, $subject ) {
+			$subject->set_payment_method( 'unsaved' );
+			return $value;
+		};
+		$hook             = 'order-update-status' === $ability_name ? 'woocommerce_valid_order_statuses_for_payment' : 'woocommerce_order_note_added';
+		add_filter( $hook, $change_in_memory, PHP_INT_MAX, 2 );
+		$input = array( 'id' => $order->get_id() );
+		if ( 'order-update-status' === $ability_name ) {
+			$input['status'] = 'on-hold';
+		} else {
+			$input['note'] = 'Test note';
+		}
+		try {
+			$result = wp_get_ability( 'woocommerce/' . $ability_name )->execute( $input );
+		} finally {
+			remove_filter( $hook, $change_in_memory, PHP_INT_MAX );
+		}
+
+		$this->assertNotWPError( $result );
+		$this->assertSame( 'bacs', $result['order']['payment_method'] );
+	}
+
+	/**
+	 * @testdox Should omit absent product values while keeping zero and false.
+	 */
+	public function test_product_output_omits_null_values(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$product->set_sale_price( '' );
+		$product->set_manage_stock( true );
+		$product->set_stock_quantity( 0 );
+		$product->save();
+		$result = wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => $product->get_id() ) )['products'][0];
+
+		foreach ( array( 'external_url', 'button_text' ) as $field ) {
+			$this->assertArrayNotHasKey( $field, $result );
+		}
+		$this->assertSame( 0, $result['stock_quantity'] );
+		$this->assertFalse( $result['virtual'] );
+		$this->assertSame( '', $result['sale_price'] );
+
+		$product->set_manage_stock( false );
+		$product->set_stock_quantity( '' );
+		$product->save();
+		$this->assertArrayNotHasKey( 'stock_quantity', wp_get_ability( 'woocommerce/products-query' )->execute( array( 'id' => $product->get_id() ) )['products'][0] );
+	}
+
+	/**
+	 * @testdox Should reject unexpected null output instead of hiding the invalid field.
+	 * @testWith ["product", "woocommerce_product_get_name", "name"]
+	 *           ["order", "woocommerce_order_get_payment_method", "payment_method"]
+	 *
+	 * @param string $resource_name Resource to query.
+	 * @param string $hook     Getter filter to return null.
+	 * @param string $field    Invalid output field.
+	 */
+	public function test_query_rejects_unexpected_null_output( string $resource_name, string $hook, string $field ): void {
+		$object = 'product' === $resource_name ? \WC_Helper_Product::create_simple_product() : \WC_Helper_Order::create_order();
+		add_filter( $hook, '__return_null' );
+
+		try {
+			$result = wp_get_ability( 'woocommerce/' . $resource_name . 's-query' )->execute( array( 'id' => $object->get_id() ) );
+		} finally {
+			remove_filter( $hook, '__return_null' );
+		}
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'ability_invalid_output', $result->get_error_code() );
+		$this->assertStringContainsString( $field, $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should omit non-date filter values without failing an order note write.
+	 * @testWith ["2026-07-15T10:00:00"]
+	 *           [1784109600]
+	 *           ["1784109600"]
+	 *           ["invalid date"]
+	 *           [[]]
+	 *           [false]
+	 *           [null]
+	 * @param mixed $date Filtered date.
+	 */
+	public function test_order_output_handles_filtered_dates( $date ): void {
+		$order  = \WC_Helper_Order::create_order();
+		$filter = static function () use ( $date ) {
+			return $date;
+		};
+		add_filter( 'woocommerce_order_get_date_created', $filter );
+		add_filter( 'woocommerce_order_get_date_modified', $filter );
+
+		$result = wp_get_ability( 'woocommerce/order-add-note' )->execute(
+			array(
+				'id'   => $order->get_id(),
+				'note' => 'Filtered date note',
+			)
+		);
+		$this->assertNotWPError( $result );
+		$query = wp_get_ability( 'woocommerce/orders-query' )->execute( array( 'id' => $order->get_id() ) );
+		$this->assertNotWPError( $query );
+		foreach ( array( 'date_created', 'date_created_gmt', 'date_modified', 'date_modified_gmt' ) as $field ) {
+			$this->assertArrayNotHasKey( $field, $result['order'] );
+			$this->assertArrayNotHasKey( $field, $query['orders'][0] );
+		}
+		$this->assertSame( 'Filtered date note', wc_get_order_note( $result['note_id'] )->content );
+	}
+
+	/**
+	 * @testdox Should omit an absent order note date while preserving false and empty strings.
+	 */
+	public function test_order_note_output_omits_absent_date(): void {
+		$order = \WC_Helper_Order::create_order();
+		$order->add_order_note( 'Internal note.' );
+		$omit_date = static function ( $note ) {
+			$note['date_created'] = null;
+			$note['added_by']     = '';
+			return $note;
+		};
+		add_filter( 'woocommerce_get_order_note', $omit_date );
+
+		try {
+			$result = wp_get_ability( 'woocommerce/order-notes-query' )->execute( array( 'id' => $order->get_id() ) );
+		} finally {
+			remove_filter( 'woocommerce_get_order_note', $omit_date );
+		}
+
+		$this->assertNotWPError( $result );
+		$this->assertNotEmpty( $result['notes'] );
+		foreach ( $result['notes'] as $note ) {
+			$this->assertArrayNotHasKey( 'date_created', $note );
+			$this->assertSame( '', $note['author'] );
+			$this->assertFalse( $note['customer_note'] );
+		}
+	}
+
+	/**
+	 * @testdox Should give each Core output field a single type.
+	 */
+	public function test_core_output_schemas_have_single_types(): void {
+		$check = function ( array $schema ) use ( &$check ): void {
+			if ( isset( $schema['type'] ) ) {
+				$this->assertIsString( $schema['type'] );
+				$this->assertNotSame( 'null', $schema['type'] );
+			}
+			$this->assertNotContains( null, $schema['enum'] ?? array() );
+			foreach ( $schema['properties'] ?? array() as $name => $property ) {
+				if ( 'extensions' !== $name ) {
+					$check( $property );
+				}
+			}
+			foreach ( array( 'items', 'additionalProperties' ) as $keyword ) {
+				if ( is_array( $schema[ $keyword ] ?? null ) ) {
+					$check( $schema[ $keyword ] );
+				}
+			}
+			foreach ( array( 'anyOf', 'oneOf' ) as $keyword ) {
+				foreach ( $schema[ $keyword ] ?? array() as $branch ) {
+					$check( $branch );
+				}
+			}
+		};
+		foreach ( self::CANONICAL_ABILITY_IDS as $name ) {
+			$check( wp_get_ability( $name )->get_output_schema() );
+		}
+	}
+
 	/**
 	 * @testdox Should update a per-type field such as regular_price without a type in the input.
 	 */
@@ -2591,7 +2902,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {

 		$this->assertNotWPError( $result );
 		$this->assertSame( 'auto-draft', $result['products'][0]['status'] );
-		$this->assertNull( $result['products'][0]['permalink'] );
+		$this->assertArrayNotHasKey( 'permalink', $result['products'][0] );
 	}

 	/**
@@ -2844,9 +3155,9 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should return null for absent billing email values.
+	 * @testdox Should omit absent billing email values.
 	 */
-	public function test_orders_query_returns_null_for_absent_billing_email(): void {
+	public function test_orders_query_omits_absent_billing_email(): void {
 		$order = wc_create_order();
 		$this->assertNotWPError( $order );
 		$order->save();
@@ -2859,7 +3170,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		);

 		$this->assertNotWPError( $result );
-		$this->assertNull( $result['orders'][0]['billing_email'] );
+		$this->assertArrayNotHasKey( 'billing_email', $result['orders'][0] );
 	}

 	/**