Commit a35808365d0 for woocommerce

commit a35808365d0d4a33944224cb4cdaef7530108b1e
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Wed Oct 21 14:54:39 2026 +0200

    Subscriptions engine: report validation errors with codes, fields and translated messages (#70037)

    Report subscriptions engine validation errors with codes and translations

    - Throw coded InvalidInputException from Core with a dot-path field and params, and LogicException for internal invariants
    - Translate errors on the Api side through ValidationException, with field-free messages in the woocommerce domain
    - Report every invalid argument and entity value of a write at once, not only the first
    - Return plan REST refusals as rest_invalid_param with params, details and an errors map keyed by field path
    - Split argument checks into Core ArgumentValidator and unknown-key filtering into ArgumentFilter

diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-validation-error-codes b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-validation-error-codes
new file mode 100644
index 00000000000..25cbb7bb0be
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/changelog/update-subscriptions-engine-validation-error-codes
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Report validation errors in the unreleased subscriptions engine with stable codes, fields and translated messages; no changelog entry needed.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/ContractActions.php b/packages/php/woocommerce-subscriptions-engine/src/Api/ContractActions.php
index 024523dec4e..6888d189d07 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/ContractActions.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/ContractActions.php
@@ -10,7 +10,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Api;

 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Rest\ContractActionRegistry;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentFilter;

 defined( 'ABSPATH' ) || exit;

@@ -53,7 +53,7 @@ final class ContractActions {
 	 *                                             `is_available`: `callable( ContractView $contract ): bool`, default always.
 	 */
 	public static function register( string $extension_slug, string $action, array $args ): void {
-		$filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::REGISTRATION_KEYS, 'argument' );
+		$filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::REGISTRATION_KEYS, 'argument' );
 		$callback      = $filtered_args['callback'] ?? null;
 		$permission    = $filtered_args['permission'] ?? null;
 		$description   = $filtered_args['description'] ?? '';
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
index 9f72b0a4853..894b23c4b83 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Contracts.php
@@ -19,16 +19,17 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Api;

-use DomainException;
 use InvalidArgumentException;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\View\CycleView;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ArgumentValidator;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PaymentInstrumentRef;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\DuplicateCycleException;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentFilter;

 defined( 'ABSPATH' ) || exit;

@@ -36,6 +37,8 @@ defined( 'ABSPATH' ) || exit;
  * Public contract facade: reads and writes.
  *
  * Final and static-only: a stateless entry point, not an extension seam.
+ *
+ * @phpstan-type ContractFields array{customer_id?: int|null, currency?: string|null, selling_plan_id?: int|null, origin_order_id?: int|null, status?: string, payment_method?: string|null, payment_method_title?: string|null, payment_token_id?: int|null, start_gmt?: string|null, next_payment_gmt?: string|null, last_payment_gmt?: string|null, last_attempt_gmt?: string|null, trial_end_gmt?: string|null, end_gmt?: string|null, schedule_source?: string, billing_total?: string, discount_total?: string, shipping_total?: string, tax_total?: string, items?: array<int, array<string, mixed>>, addresses?: array<string, array<string, mixed>>}
  */
 final class Contracts {

@@ -137,6 +140,7 @@ final class Contracts {
 		return ( new ContractRepository() )->acquire_lock( $contract_id, $operation );
 	}

+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
 	 * Create a contract locked from birth: finish building it (cycles, status) with the returned
 	 * lock, then release it. A `creation_key` (1-100 characters, trimmed) makes the create
@@ -152,14 +156,32 @@ final class Contracts {
 	 * @param array<string, mixed> $options `operation`: the lock's label (1-64 of `[a-z0-9_-]`, default `create`).
 	 * @return array{contract: ContractView, lock: ContractLock}|null The contract as written (no re-read) and its
 	 *                                                                lock; null when `creation_key` is taken.
-	 * @throws InvalidArgumentException If `extension_slug` is missing, or a value or the operation label is invalid.
+	 * @throws ValidationException If `extension_slug` is missing, a value is invalid or the operation label is not a
+	 *                             string, with every invalid value reported.
+	 * @throws InvalidArgumentException If the operation label is not 1-64 of `[a-z0-9_-]`: the lock refuses it, alone.
 	 */
 	public static function create( array $args, array $options = array() ): ?array {
-		$filtered_options = ArgumentValidator::filter_known_keys( __METHOD__, $options, self::CREATE_OPTION_KEYS, 'option' );
-		$operation        = ArgumentValidator::validate_string( 'operation', $filtered_options['operation'] ?? self::CREATE_OPERATION );
-		$contract         = self::build( $args );
+		$filtered_options = ArgumentFilter::filter_known_keys( __METHOD__, $options, self::CREATE_OPTION_KEYS, 'option' );
+		$filtered_args    = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::CREATE_KEYS );
+
+		$validator      = new ArgumentValidator();
+		$operation      = $validator->get_string( 'operation', $filtered_options['operation'] ?? self::CREATE_OPERATION );
+		$extension_slug = $validator->get_nullable_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
+		$creation_key   = $validator->get_nullable_string( 'creation_key', $filtered_args['creation_key'] ?? null );
+		$fields         = self::get_contract_fields( $validator, $filtered_args );
+		try {
+			$validator->throw_errors();
+			$contract = Contract::create(
+				array(
+					'extension_slug' => $extension_slug,
+					'creation_key'   => $creation_key,
+				) + $fields
+			);
+		} catch ( InvalidInputException $e ) {
+			throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
+		}

-		$lock = ( new ContractRepository() )->insert( $contract, $operation );
+		$lock = ( new ContractRepository() )->insert( $contract, (string) $operation );
 		if ( null === $lock ) {
 			return null;
 		}
@@ -169,20 +191,23 @@ final class Contracts {
 			'lock'     => $lock,
 		);
 	}
+	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

 	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
 	 * Write the given fields (the keys of {@see self::create()} except `extension_slug`) to an
 	 * existing contract. Only the present keys are written; `items` and `addresses` replace the
 	 * whole set; `null` clears a nullable field (a money field to 0). Unknown keys raise a
-	 * `_doing_it_wrong()` notice and are ignored.
+	 * `_doing_it_wrong()` notice and are ignored. The arguments are checked before the contract is
+	 * read, so invalid arguments throw even for a missing contract.
 	 *
 	 * @param int                  $contract_id Contract id.
 	 * @param array<string, mixed> $args        Fields to write.
 	 * @param ContractLock|null    $lock        The caller's lock on this contract, or null for an internal one.
 	 * @return ContractView|null The row as read before the write plus the written fields; null
 	 *                           when the contract does not exist (also when it is deleted before the write).
-	 * @throws InvalidArgumentException If a value is invalid, or the lock is for another contract.
+	 * @throws ValidationException If a value is invalid, with every invalid value reported.
+	 * @throws InvalidArgumentException If the lock is for another contract.
 	 * @throws ContractLockedException If another operation holds the lock, or `$lock` was lost.
 	 */
 	public static function update( int $contract_id, array $args, ?ContractLock $lock = null ): ?ContractView {
@@ -190,16 +215,25 @@ final class Contracts {
 			ContractRepository::assert_lock_for( $lock, $contract_id );
 		}

-		$filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::CONTRACT_KEYS );
-		$repository    = new ContractRepository();
+		$filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::CONTRACT_KEYS );

-		if ( array() === $filtered_args ) {
+		$validator = new ArgumentValidator();
+		$changes   = self::get_contract_fields( $validator, $filtered_args );
+		try {
+			$validator->throw_errors();
+		} catch ( InvalidInputException $e ) {
+			throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
+		}
+
+		$repository = new ContractRepository();
+
+		if ( array() === $changes ) {
 			$contract = $repository->find( $contract_id );
 			return null === $contract ? null : ContractView::from_contract( $contract, true );
 		}

 		if ( null !== $lock ) {
-			$contract = self::update_contract( $repository, $contract_id, $filtered_args, $lock );
+			$contract = self::update_contract( $repository, $contract_id, $changes, $lock );
 			return null === $contract ? null : ContractView::from_contract( $contract, true );
 		}

@@ -209,7 +243,7 @@ final class Contracts {
 			return null;
 		}
 		try {
-			$contract = self::update_contract( $repository, $contract_id, $filtered_args, $internal_lock );
+			$contract = self::update_contract( $repository, $contract_id, $changes, $internal_lock );
 		} finally {
 			$repository->release_internal_lock( $internal_lock );
 		}
@@ -226,28 +260,29 @@ final class Contracts {

 	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- @throws also lists exceptions raised by the methods called.
 	/**
-	 * Read the contract, apply and validate the fields, and write them behind {@see self::update()}.
+	 * Read the contract, apply the checked fields, and write them behind {@see self::update()}.
 	 *
-	 * @param ContractRepository   $repository    Repository.
-	 * @param int                  $contract_id   Contract id.
-	 * @param array<string, mixed> $filtered_args Known fields to write.
-	 * @param ContractLock         $lock          The held lock.
+	 * @param ContractRepository   $repository  Repository.
+	 * @param int                  $contract_id Contract id.
+	 * @param array<string, mixed> $changes     Normalized fields from {@see self::get_contract_fields()}.
+	 * @phpstan-param ContractFields $changes
+	 * @param ContractLock         $lock        The held lock.
 	 * @return Contract|null The contract with the written fields; null when it does not exist.
-	 * @throws InvalidArgumentException If a value is invalid.
+	 * @throws ValidationException If a value breaks an entity invariant.
 	 */
-	private static function update_contract( ContractRepository $repository, int $contract_id, array $filtered_args, ContractLock $lock ): ?Contract {
+	private static function update_contract( ContractRepository $repository, int $contract_id, array $changes, ContractLock $lock ): ?Contract {
 		$contract = $repository->find( $contract_id );
 		if ( null === $contract ) {
 			return null;
 		}

 		try {
-			self::apply( $contract, $filtered_args );
-		} catch ( DomainException $e ) {
-			throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+			self::apply( $contract, $changes );
+		} catch ( InvalidInputException $e ) {
+			throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
 		}

-		$updated = $repository->update_fields( $contract, array_keys( $filtered_args ), $lock );
+		$updated = $repository->update_fields( $contract, array_keys( $changes ), $lock );
 		if ( ! $updated ) {
 			return null;
 		}
@@ -274,30 +309,37 @@ final class Contracts {
 	 * @param ContractLock         $lock        The caller's lock on this contract, from {@see self::lock()},
 	 *                                          {@see self::create()} or the due handler's context.
 	 * @return CycleView|null The appended cycle, or null when the position or count is already taken.
-	 * @throws InvalidArgumentException If a required key is missing, a value is invalid, or the lock
-	 *                                  is for another contract.
+	 * @throws ValidationException If a required key is missing or a value is invalid, with every
+	 *                             invalid value reported (ill-typed arguments before the cycle is built).
+	 * @throws InvalidArgumentException If the lock is for another contract.
 	 * @throws ContractLockedException If `$lock` was lost.
 	 */
 	public static function add_cycle( int $contract_id, array $args, ContractLock $lock ): ?CycleView {
-		$filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::CYCLE_KEYS );
+		$filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::CYCLE_KEYS );

+		$validator  = new ArgumentValidator();
 		$cycle_args = array(
 			'contract_id'    => $contract_id,
 			'kind'           => $filtered_args['kind'] ?? null,
-			'sequence_no'    => ArgumentValidator::validate_nullable_id( 'sequence_no', $filtered_args['sequence_no'] ?? null ),
-			'count'          => ArgumentValidator::validate_nullable_id( 'count', $filtered_args['count'] ?? null ),
+			'sequence_no'    => $validator->get_nullable_id( 'sequence_no', $filtered_args['sequence_no'] ?? null ),
+			'count'          => $validator->get_nullable_id( 'count', $filtered_args['count'] ?? null ),
 			'status'         => $filtered_args['status'] ?? null,
-			'starts_at_gmt'  => ArgumentValidator::validate_nullable_date( 'starts_at_gmt', $filtered_args['starts_at_gmt'] ?? null ),
-			'ends_at_gmt'    => ArgumentValidator::validate_nullable_date( 'ends_at_gmt', $filtered_args['ends_at_gmt'] ?? null ),
-			'expected_total' => ArgumentValidator::validate_money( 'expected_total', $filtered_args['expected_total'] ?? null ),
-			'currency'       => ArgumentValidator::validate_currency( $filtered_args['currency'] ?? null ),
-			'order_id'       => ArgumentValidator::validate_nullable_id( 'order_id', $filtered_args['order_id'] ?? null ),
+			'starts_at_gmt'  => $validator->get_nullable_date( 'starts_at_gmt', $filtered_args['starts_at_gmt'] ?? null ),
+			'ends_at_gmt'    => $validator->get_nullable_date( 'ends_at_gmt', $filtered_args['ends_at_gmt'] ?? null ),
+			'expected_total' => $validator->get_money( 'expected_total', $filtered_args['expected_total'] ?? null ),
+			'currency'       => $validator->get_currency( $filtered_args['currency'] ?? null ),
+			'order_id'       => $validator->get_nullable_id( 'order_id', $filtered_args['order_id'] ?? null ),
 		);
+		try {
+			$validator->throw_errors();
+		} catch ( InvalidInputException $e ) {
+			throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
+		}

 		try {
 			$cycle = Cycle::create( $cycle_args );
-		} catch ( DomainException $e ) {
-			throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+		} catch ( InvalidInputException $e ) {
+			throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
 		}

 		try {
@@ -325,27 +367,31 @@ final class Contracts {
 	 *                                       {@see self::create()} or the due handler's context.
 	 * @return bool True when this call made the transition, or for a same-status call when the
 	 *              cycle carries the status and the values.
-	 * @throws InvalidArgumentException If a status is missing or unregistered, a value is invalid,
-	 *                                  the cycle does not exist, or it belongs to another contract than the lock.
+	 * @throws ValidationException If a status is missing or unregistered (field `to_status`), or a value is
+	 *                             invalid, with every invalid value reported.
+	 * @throws InvalidArgumentException If the cycle does not exist, or it belongs to another contract than the lock.
 	 * @throws ContractLockedException If `$lock` was lost.
 	 */
 	public static function transition_cycle( int $cycle_id, array $args, ContractLock $lock ): bool {
-		$filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::TRANSITION_KEYS );
-		$from_status   = ArgumentValidator::validate_non_empty_string( 'from_status', $filtered_args['from_status'] ?? null );
-		$to_status     = ArgumentValidator::validate_non_empty_string( 'to_status', $filtered_args['to_status'] ?? null );
-		$changes       = array();
+		$filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::TRANSITION_KEYS );

+		$validator   = new ArgumentValidator();
+		$from_status = $validator->get_non_empty_string( 'from_status', $filtered_args['from_status'] ?? null );
+		$to_status   = $validator->get_non_empty_string( 'to_status', $filtered_args['to_status'] ?? null );
+		$changes     = array();
 		if ( array_key_exists( 'order_id', $filtered_args ) ) {
-			$changes['order_id'] = ArgumentValidator::validate_nullable_id( 'order_id', $filtered_args['order_id'] );
+			$changes['order_id'] = $validator->get_nullable_id( 'order_id', $filtered_args['order_id'] );
 		}
 		if ( array_key_exists( 'reason', $filtered_args ) ) {
-			$changes['reason'] = ArgumentValidator::validate_nullable_string( 'reason', $filtered_args['reason'] );
+			$changes['reason'] = $validator->get_nullable_string( 'reason', $filtered_args['reason'] );
 		}

 		try {
-			return ( new ContractRepository() )->transition_cycle( $cycle_id, $from_status, $to_status, $changes, $lock );
-		} catch ( DomainException $e ) {
-			throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the repository message is not output.
+			$validator->throw_errors();
+
+			return ( new ContractRepository() )->transition_cycle( $cycle_id, (string) $from_status, (string) $to_status, $changes, $lock );
+		} catch ( InvalidInputException $e ) {
+			throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
 		}
 	}
 	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
@@ -559,136 +605,222 @@ final class Contracts {

 	/**
 	 * Fetch a window of the contract's billing cycles, newest first. A negative `$limit` or
-	 * `$offset` throws an InvalidArgumentException.
+	 * `$offset` throws a {@see ValidationException}.
 	 *
 	 * @param int $contract_id Contract id.
 	 * @param int $limit       Maximum cycles to return.
 	 * @param int $offset      Cycles to skip (for paging).
 	 * @return array<int, CycleView> Cycles newest first.
+	 * @throws ValidationException If `$limit` or `$offset` is negative, with both reported.
 	 */
 	public static function get_cycles( int $contract_id, int $limit = 20, int $offset = 0 ): array {
-		$limit  = ArgumentValidator::validate_non_negative_int( 'limit', $limit );
-		$offset = ArgumentValidator::validate_non_negative_int( 'offset', $offset );
+		$validator = new ArgumentValidator();
+		$limit     = (int) $validator->get_non_negative_int( 'limit', $limit );
+		$offset    = (int) $validator->get_non_negative_int( 'offset', $offset );
+		try {
+			$validator->throw_errors();
+		} catch ( InvalidInputException $e ) {
+			throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
+		}

 		return self::cycle_views( ( new ContractRepository() )->find_cycle_history( $contract_id, Cycle::KIND_BILLING, $limit, $offset ) );
 	}

 	/**
-	 * Validate create arguments into a new, unsaved contract.
-	 *
-	 * @param array<string, mixed> $args Caller fields.
-	 * @throws InvalidArgumentException If `extension_slug` is missing or a value is invalid.
+	 * Check the shape of the present contract fields with the collector and return them
+	 * normalized, `items` and `addresses` without unknown keys (a refused field is left
+	 * out or null; the collector holds its error).
+	 *
+	 * @param ArgumentValidator    $validator Argument collector.
+	 * @param array<string, mixed> $args      Caller fields (known keys only; `extension_slug` and `creation_key` are ignored).
+	 * @return array<string, mixed>
+	 * @phpstan-return ContractFields
 	 */
-	private static function build( array $args ): Contract {
-		$filtered_args  = ArgumentValidator::filter_known_keys( self::class . '::create', $args, self::CREATE_KEYS );
-		$extension_slug = ArgumentValidator::validate_nullable_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
-		$creation_key   = ArgumentValidator::validate_nullable_string( 'creation_key', $filtered_args['creation_key'] ?? null );
-		unset( $filtered_args['extension_slug'], $filtered_args['creation_key'] );
-
-		try {
-			$contract = Contract::create(
-				array(
-					'extension_slug' => $extension_slug,
-					'creation_key'   => $creation_key,
-				)
-			);
-			self::apply( $contract, $filtered_args );
-		} catch ( DomainException $e ) {
-			throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+	private static function get_contract_fields( ArgumentValidator $validator, array $args ): array {
+		$fields = array();
+		if ( array_key_exists( 'customer_id', $args ) ) {
+			$fields['customer_id'] = $validator->get_nullable_id( 'customer_id', $args['customer_id'] );
+		}
+		if ( array_key_exists( 'currency', $args ) ) {
+			$fields['currency'] = $validator->get_currency( $args['currency'] );
+		}
+		if ( array_key_exists( 'selling_plan_id', $args ) ) {
+			$fields['selling_plan_id'] = $validator->get_nullable_id( 'selling_plan_id', $args['selling_plan_id'] );
+		}
+		if ( array_key_exists( 'origin_order_id', $args ) ) {
+			$fields['origin_order_id'] = $validator->get_nullable_id( 'origin_order_id', $args['origin_order_id'] );
+		}
+		if ( array_key_exists( 'status', $args ) ) {
+			$value = $validator->get_string( 'status', $args['status'] );
+			if ( null !== $value ) {
+				$fields['status'] = $value;
+			}
+		}
+		if ( array_key_exists( 'payment_method', $args ) ) {
+			$fields['payment_method'] = $validator->get_nullable_string( 'payment_method', $args['payment_method'] );
+		}
+		if ( array_key_exists( 'payment_method_title', $args ) ) {
+			$fields['payment_method_title'] = $validator->get_nullable_string( 'payment_method_title', $args['payment_method_title'] );
+		}
+		if ( array_key_exists( 'payment_token_id', $args ) ) {
+			$fields['payment_token_id'] = $validator->get_nullable_id( 'payment_token_id', $args['payment_token_id'] );
+		}
+		if ( array_key_exists( 'start_gmt', $args ) ) {
+			$fields['start_gmt'] = $validator->get_nullable_date( 'start_gmt', $args['start_gmt'] );
+		}
+		if ( array_key_exists( 'next_payment_gmt', $args ) ) {
+			$fields['next_payment_gmt'] = $validator->get_nullable_date( 'next_payment_gmt', $args['next_payment_gmt'] );
+		}
+		if ( array_key_exists( 'last_payment_gmt', $args ) ) {
+			$fields['last_payment_gmt'] = $validator->get_nullable_date( 'last_payment_gmt', $args['last_payment_gmt'] );
+		}
+		if ( array_key_exists( 'last_attempt_gmt', $args ) ) {
+			$fields['last_attempt_gmt'] = $validator->get_nullable_date( 'last_attempt_gmt', $args['last_attempt_gmt'] );
+		}
+		if ( array_key_exists( 'trial_end_gmt', $args ) ) {
+			$fields['trial_end_gmt'] = $validator->get_nullable_date( 'trial_end_gmt', $args['trial_end_gmt'] );
+		}
+		if ( array_key_exists( 'end_gmt', $args ) ) {
+			$fields['end_gmt'] = $validator->get_nullable_date( 'end_gmt', $args['end_gmt'] );
+		}
+		if ( array_key_exists( 'schedule_source', $args ) ) {
+			$value = $validator->get_string( 'schedule_source', $args['schedule_source'] );
+			if ( null !== $value ) {
+				$fields['schedule_source'] = $value;
+			}
+		}
+		if ( array_key_exists( 'billing_total', $args ) ) {
+			$value = $validator->get_money( 'billing_total', $args['billing_total'] );
+			if ( null !== $value ) {
+				$fields['billing_total'] = $value;
+			}
+		}
+		if ( array_key_exists( 'discount_total', $args ) ) {
+			$value = $validator->get_money( 'discount_total', $args['discount_total'] );
+			if ( null !== $value ) {
+				$fields['discount_total'] = $value;
+			}
+		}
+		if ( array_key_exists( 'shipping_total', $args ) ) {
+			$value = $validator->get_money( 'shipping_total', $args['shipping_total'] );
+			if ( null !== $value ) {
+				$fields['shipping_total'] = $value;
+			}
+		}
+		if ( array_key_exists( 'tax_total', $args ) ) {
+			$value = $validator->get_money( 'tax_total', $args['tax_total'] );
+			if ( null !== $value ) {
+				$fields['tax_total'] = $value;
+			}
+		}
+		if ( array_key_exists( 'items', $args ) ) {
+			$items = $validator->get_list_of_arrays( 'items', $args['items'] );
+			if ( null !== $items ) {
+				$fields['items'] = ArgumentFilter::filter_known_keys_in_rows( self::class, $items, array_fill_keys( Contract::ITEM_FIELDS, true ), 'item key' );
+			}
+		}
+		if ( array_key_exists( 'addresses', $args ) ) {
+			$addresses = $validator->get_contract_addresses( $args['addresses'] );
+			if ( null !== $addresses ) {
+				$fields['addresses'] = ArgumentFilter::filter_known_keys_in_rows( self::class, $addresses, array_fill_keys( Contract::ADDRESS_FIELDS, true ), 'address key' );
+			}
 		}

-		return $contract;
+		return $fields;
 	}

-	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- the DomainException comes from the entity setters, not a throw in this method.
 	/**
-	 * Validate the caller's field shapes and apply them to a contract through its setters,
-	 * which enforce the entity invariants. Nothing is written to storage; an invalid value
-	 * throws before any write.
+	 * Apply normalized changes to a contract through its setters, which enforce the entity
+	 * invariants, then check that money has a currency. Every refusal is thrown together.
+	 * Nothing is written to storage.
 	 *
 	 * @param Contract             $contract Contract to change.
-	 * @param array<string, mixed> $args     Caller fields (known keys only, no `extension_slug`).
-	 * @throws InvalidArgumentException If a value has the wrong shape.
-	 * @throws DomainException If a value breaks an entity invariant (from the entity setters).
+	 * @param array<string, mixed> $changes  Normalized fields from {@see self::get_contract_fields()}.
+	 * @phpstan-param ContractFields $changes
+	 * @throws InvalidInputException If a value breaks an entity invariant.
 	 */
-	private static function apply( Contract $contract, array $args ): void {
-		$instrument = $contract->get_payment_instrument();
-		$token_id   = $instrument->get_token_id();
-		$gateway    = $instrument->get_gateway();
-		$title      = $instrument->get_title();
-
-		foreach ( $args as $key => $value ) {
-			switch ( $key ) {
-				case 'customer_id':
-					$contract->set_customer_id( ArgumentValidator::validate_nullable_id( $key, $value ) );
-					break;
-				case 'selling_plan_id':
-					$contract->set_selling_plan_id( ArgumentValidator::validate_nullable_id( $key, $value ) );
-					break;
-				case 'origin_order_id':
-					$contract->set_origin_order_id( ArgumentValidator::validate_nullable_id( $key, $value ) );
-					break;
-				case 'currency':
-					$contract->set_currency( ArgumentValidator::validate_currency( $value ) );
-					break;
-				case 'status':
-					$contract->set_status( ArgumentValidator::validate_string( $key, $value ) );
-					break;
-				case 'schedule_source':
-					$contract->set_schedule_source( ArgumentValidator::validate_string( $key, $value ) );
-					break;
-				case 'start_gmt':
-					$contract->set_start_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
-					break;
-				case 'next_payment_gmt':
-					$contract->set_next_payment_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
-					break;
-				case 'last_payment_gmt':
-					$contract->set_last_payment_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
-					break;
-				case 'last_attempt_gmt':
-					$contract->set_last_attempt_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
-					break;
-				case 'trial_end_gmt':
-					$contract->set_trial_end_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
-					break;
-				case 'end_gmt':
-					$contract->set_end_gmt( ArgumentValidator::validate_nullable_date( $key, $value ) );
-					break;
-				case 'billing_total':
-					$contract->set_billing_total( ArgumentValidator::validate_money( $key, $value ) );
-					break;
-				case 'discount_total':
-					$contract->set_discount_total( ArgumentValidator::validate_money( $key, $value ) );
-					break;
-				case 'shipping_total':
-					$contract->set_shipping_total( ArgumentValidator::validate_money( $key, $value ) );
-					break;
-				case 'tax_total':
-					$contract->set_tax_total( ArgumentValidator::validate_money( $key, $value ) );
-					break;
-				case 'items':
-					$contract->set_items( ArgumentValidator::validate_contract_items( $value, self::class ) );
-					break;
-				case 'addresses':
-					$contract->set_addresses( ArgumentValidator::validate_contract_addresses( $value, self::class ) );
-					break;
-				case 'payment_token_id':
-					$token_id = ArgumentValidator::validate_nullable_id( $key, $value );
-					break;
-				case 'payment_method':
-					$gateway = ArgumentValidator::validate_nullable_string( $key, $value );
-					break;
-				case 'payment_method_title':
-					$title = ArgumentValidator::validate_nullable_string( $key, $value );
-					break;
+	private static function apply( Contract $contract, array $changes ): void {
+		$errors = array();
+		if ( array_key_exists( 'customer_id', $changes ) ) {
+			$contract->set_customer_id( $changes['customer_id'] );
+		}
+		if ( array_key_exists( 'currency', $changes ) ) {
+			$contract->set_currency( $changes['currency'] );
+		}
+		if ( array_key_exists( 'selling_plan_id', $changes ) ) {
+			$contract->set_selling_plan_id( $changes['selling_plan_id'] );
+		}
+		if ( array_key_exists( 'origin_order_id', $changes ) ) {
+			$contract->set_origin_order_id( $changes['origin_order_id'] );
+		}
+		if ( array_key_exists( 'status', $changes ) ) {
+			try {
+				$contract->set_status( $changes['status'] );
+			} catch ( InvalidInputException $e ) {
+				$errors[] = $e;
 			}
 		}
+		if ( array_key_exists( 'schedule_source', $changes ) ) {
+			try {
+				$contract->set_schedule_source( $changes['schedule_source'] );
+			} catch ( InvalidInputException $e ) {
+				$errors[] = $e;
+			}
+		}
+		if ( array_key_exists( 'start_gmt', $changes ) ) {
+			$contract->set_start_gmt( $changes['start_gmt'] );
+		}
+		if ( array_key_exists( 'next_payment_gmt', $changes ) ) {
+			$contract->set_next_payment_gmt( $changes['next_payment_gmt'] );
+		}
+		if ( array_key_exists( 'last_payment_gmt', $changes ) ) {
+			$contract->set_last_payment_gmt( $changes['last_payment_gmt'] );
+		}
+		if ( array_key_exists( 'last_attempt_gmt', $changes ) ) {
+			$contract->set_last_attempt_gmt( $changes['last_attempt_gmt'] );
+		}
+		if ( array_key_exists( 'trial_end_gmt', $changes ) ) {
+			$contract->set_trial_end_gmt( $changes['trial_end_gmt'] );
+		}
+		if ( array_key_exists( 'end_gmt', $changes ) ) {
+			$contract->set_end_gmt( $changes['end_gmt'] );
+		}
+		if ( array_key_exists( 'billing_total', $changes ) ) {
+			$contract->set_billing_total( $changes['billing_total'] );
+		}
+		if ( array_key_exists( 'discount_total', $changes ) ) {
+			$contract->set_discount_total( $changes['discount_total'] );
+		}
+		if ( array_key_exists( 'shipping_total', $changes ) ) {
+			$contract->set_shipping_total( $changes['shipping_total'] );
+		}
+		if ( array_key_exists( 'tax_total', $changes ) ) {
+			$contract->set_tax_total( $changes['tax_total'] );
+		}
+		if ( array_key_exists( 'items', $changes ) ) {
+			$contract->set_items( $changes['items'] );
+		}
+		if ( array_key_exists( 'addresses', $changes ) ) {
+			$contract->set_addresses( $changes['addresses'] );
+		}
+
+		$instrument = $contract->get_payment_instrument();
+		$contract->set_payment_instrument(
+			new PaymentInstrumentRef(
+				array_key_exists( 'payment_token_id', $changes ) ? $changes['payment_token_id'] : $instrument->get_token_id(),
+				array_key_exists( 'payment_method', $changes ) ? $changes['payment_method'] : $instrument->get_gateway(),
+				array_key_exists( 'payment_method_title', $changes ) ? $changes['payment_method_title'] : $instrument->get_title()
+			)
+		);

-		$contract->set_payment_instrument( new PaymentInstrumentRef( $token_id, $gateway, $title ) );
-		$contract->assert_money_has_currency();
+		$money_error = $contract->get_money_currency_error();
+		if ( null !== $money_error ) {
+			$errors[] = $money_error;
+		}
+		if ( array() !== $errors ) {
+			throw InvalidInputException::from_errors( $errors ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- The caller converts it with ValidationException::from_core(), which escapes the message.
+		}
 	}
-	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

 	/**
 	 * A view of `$contract`, or null.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/PlanValidationException.php b/packages/php/woocommerce-subscriptions-engine/src/Api/PlanValidationException.php
deleted file mode 100644
index 78db1c0d60f..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/PlanValidationException.php
+++ /dev/null
@@ -1,48 +0,0 @@
-<?php
-/**
- * PlanValidationException - a plan write refused by the owning extension's plan validation.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Api
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
-
-use InvalidArgumentException;
-use WP_Error;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Thrown by {@see Plans} when a `woocommerce_subscriptions_engine_validate_plan`
- * callback adds errors. Carries the collected errors with their codes and data;
- * the message joins the error messages.
- */
-final class PlanValidationException extends InvalidArgumentException {
-
-	/**
-	 * Collected validation errors.
-	 *
-	 * @var WP_Error
-	 */
-	private $errors;
-
-	/**
-	 * Wrap the collected validation errors.
-	 *
-	 * @param WP_Error $errors Validation errors.
-	 */
-	public function __construct( WP_Error $errors ) {
-		parent::__construct( esc_html( implode( ' ', $errors->get_error_messages() ) ) );
-
-		$this->errors = $errors;
-	}
-
-	/**
-	 * The validation errors, with their codes and data.
-	 */
-	public function get_errors(): WP_Error {
-		return $this->errors;
-	}
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
index 66a6d07de19..6f6f4e992a2 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Plans.php
@@ -20,15 +20,17 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Api;

-use DomainException;
 use InvalidArgumentException;
 use RuntimeException;
 use Throwable;
 use WP_Error;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ArgumentValidator;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentFilter;

 defined( 'ABSPATH' ) || exit;

@@ -36,6 +38,8 @@ defined( 'ABSPATH' ) || exit;
  * Public plan facade: reads and writes.
  *
  * Final and static-only: a stateless entry point, not an extension seam.
+ *
+ * @phpstan-type PlanFields array{name?: string, status?: string, billing_policy?: array<int|string, mixed>|null, pricing_policy?: array<int|string, mixed>|null, delivery_policy?: array<int|string, mixed>|null}
  */
 final class Plans {

@@ -78,11 +82,14 @@ final class Plans {
 	 */
 	private const LOG_SOURCE = 'woocommerce-subscriptions-engine';

-	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- create() and update() also throw RuntimeException indirectly, through validate_with_extension() and the repository.
+	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- create() and update() also throw ValidationException and RuntimeException indirectly, through throw_refusals() and the repository.
 	/**
 	 * Create a plan.
 	 *
-	 * Unknown keys raise a `_doing_it_wrong()` notice and are ignored.
+	 * Unknown keys raise a `_doing_it_wrong()` notice and are ignored. Every invalid value is
+	 * reported at once: ill-typed arguments refuse the call before the plan is built; when
+	 * the arguments are well-typed, the plan's own refusals and the owning extension's
+	 * (`woocommerce_subscriptions_engine_validate_plan`) arrive together.
 	 *
 	 * @param array<string, mixed> $args Plan fields: `extension_slug` (required, the owning
 	 *                                   extension), `name` (required, non-empty), `status` (a
@@ -90,30 +97,34 @@ final class Plans {
 	 *                                   `billing_policy`, `pricing_policy`, `delivery_policy`
 	 *                                   (string-keyed arrays the owning extension interprets, or null).
 	 * @return PlanView The new plan, built from the written fields (no re-read).
-	 * @throws InvalidArgumentException If a required key is missing or a value is invalid, or a
-	 *                                  {@see PlanValidationException} when the owning extension refuses the plan.
+	 * @throws ValidationException If a required key is missing or a value is invalid (each error
+	 *                             with the engine code, `field` and `params`), or the owning extension
+	 *                             refuses the plan; entity and extension refusals arrive in one exception.
 	 * @throws RuntimeException If a validation callback throws (the callback's throwable is chained
 	 *                          as the previous exception) or the insert fails (no previous exception).
 	 */
 	public static function create( array $args ): PlanView {
-		$filtered_args  = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::PLAN_KEYS );
-		$extension_slug = ArgumentValidator::validate_nullable_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
-		unset( $filtered_args['extension_slug'] );
+		$filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::PLAN_KEYS );

+		$validator      = new ArgumentValidator();
+		$extension_slug = $validator->get_nullable_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
+		$fields         = self::get_plan_fields( $validator, $filtered_args + array( 'name' => '' ) );
 		try {
-			// The entity requires a name on create; apply() then sets every field, the name included.
-			$plan = Plan::create(
-				array(
-					'extension_slug' => $extension_slug,
-					'name'           => ArgumentValidator::validate_string( 'name', $filtered_args['name'] ?? '' ),
-				)
-			);
-			self::apply( $plan, $filtered_args );
-		} catch ( DomainException $e ) {
-			throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+			$validator->throw_errors();
+		} catch ( InvalidInputException $e ) {
+			throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
 		}

-		self::validate_with_extension( $plan );
+		$fields['extension_slug'] = $extension_slug;
+		$refusal                  = null;
+		try {
+			$plan = Plan::create( $fields );
+		} catch ( InvalidInputException $e ) {
+			$refusal = $e;
+			$plan    = Plan::draft( $fields );
+		}
+
+		self::throw_refusals( $plan, $refusal );

 		( new PlanRepository() )->insert( $plan );

@@ -130,10 +141,11 @@ final class Plans {
 	 * present plan fields are written, so fields a concurrent writer changed in between
 	 * keep its values. A present policy key replaces the whole payload (null clears it);
 	 * policies are never merged. Re-sending the stored status is accepted even when that
-	 * status is no longer registered (its extension was deactivated). Args with no plan
-	 * field return the stored plan without validating or writing. Unknown keys raise a
-	 * `_doing_it_wrong()` notice and are ignored. The engine opens no transaction: wrap
-	 * the call in one when it must be atomic with other writes.
+	 * status is no longer registered (its extension was deactivated). The arguments are
+	 * checked before the plan is read, so ill-typed arguments throw even for a missing plan.
+	 * Args with no plan field return the stored plan without validating or writing. Unknown
+	 * keys raise a `_doing_it_wrong()` notice and are ignored. The engine opens no
+	 * transaction: wrap the call in one when it must be atomic with other writes.
 	 *
 	 * @param int                  $plan_id Plan id.
 	 * @param array<string, mixed> $args    `extension_slug` (required, the owning extension)
@@ -142,36 +154,46 @@ final class Plans {
 	 *                       another writer changed meanwhile may be stale here, not in storage);
 	 *                       null when no plan of that extension has the id (also when it is
 	 *                       deleted before the write).
-	 * @throws InvalidArgumentException If `extension_slug` is missing or a value is invalid, or a
-	 *                                  {@see PlanValidationException} when the owning extension refuses the plan.
+	 * @throws ValidationException If `extension_slug` is missing or a value is invalid (each error
+	 *                             with the engine code, `field` and `params`), or the owning extension
+	 *                             refuses the plan; entity and extension refusals arrive in one exception.
 	 * @throws RuntimeException If a validation callback throws (the callback's throwable is chained
 	 *                          as the previous exception) or the update fails (no previous exception).
 	 */
 	public static function update( int $plan_id, array $args ): ?PlanView {
-		$filtered_args  = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::PLAN_KEYS );
-		$extension_slug = ArgumentValidator::validate_non_empty_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
-		unset( $filtered_args['extension_slug'] );
+		$filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::PLAN_KEYS );
+
+		$validator      = new ArgumentValidator();
+		$extension_slug = $validator->get_non_empty_string( 'extension_slug', $filtered_args['extension_slug'] ?? null );
+		$changes        = self::get_plan_fields( $validator, $filtered_args );
+		try {
+			$validator->throw_errors();
+		} catch ( InvalidInputException $e ) {
+			throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
+		}

 		$repository = new PlanRepository();
-		$plan       = $repository->find( $plan_id, $extension_slug );
+		$plan       = $repository->find( $plan_id, (string) $extension_slug );
 		if ( null === $plan ) {
 			return null;
 		}

-		$fields = array_keys( $filtered_args );
-		if ( array() === $fields ) {
+		if ( array() === $changes ) {
 			return PlanView::from_plan( $plan );
 		}

+		$refusal = null;
 		try {
-			self::apply( $plan, $filtered_args );
-		} catch ( DomainException $e ) {
-			throw new InvalidArgumentException( $e->getMessage(), 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the entity message is not output.
+			self::apply( $plan, $changes );
+		} catch ( InvalidInputException $e ) {
+			$refusal = $e;
+			$plan    = self::get_would_be_plan( $plan, $changes );
 		}

-		self::validate_with_extension( $plan );
+		self::throw_refusals( $plan, $refusal );

-		if ( ! $repository->update_fields( $plan, $fields ) ) {
+		$updated = $repository->update_fields( $plan, array_keys( $changes ) );
+		if ( ! $updated ) {
 			return null;
 		}

@@ -272,30 +294,43 @@ final class Plans {
 	 *     @type int             $offset         Plans to skip (for paging), a non-negative integer. Default 0.
 	 * }
 	 * @return array<int, PlanView>
-	 * @throws InvalidArgumentException If a value is invalid: an empty or non-string slug or status,
-	 *                                  `any` as a slug, a non-positive or non-integer id, limit or offset.
+	 * @throws ValidationException If a value is invalid, with every invalid value reported: an empty
+	 *                             or non-string slug or status, `any` as a slug, a non-positive or
+	 *                             non-integer id or limit, a negative or non-integer offset.
 	 */
 	public static function list( array $args = array() ): array {
-		$filtered_args = ArgumentValidator::filter_known_keys( __METHOD__, $args, self::LIST_KEYS );
+		$filtered_args = ArgumentFilter::filter_known_keys( __METHOD__, $args, self::LIST_KEYS );

-		$query = array(
+		$validator = new ArgumentValidator();
+		$query     = array(
 			'orderby' => 'id',
 			'order'   => 'asc',
-			'limit'   => ArgumentValidator::validate_nullable_id( 'limit', $filtered_args['limit'] ?? null ) ?? self::DEFAULT_LIST_LIMIT,
-			'offset'  => ArgumentValidator::validate_non_negative_int( 'offset', $filtered_args['offset'] ?? 0 ),
+			'limit'   => $validator->get_nullable_id( 'limit', $filtered_args['limit'] ?? null ) ?? self::DEFAULT_LIST_LIMIT,
+			'offset'  => $validator->get_non_negative_int( 'offset', $filtered_args['offset'] ?? 0 ),
 		);
 		if ( array_key_exists( 'extension_slug', $filtered_args ) ) {
-			$extension_slugs = array_values( array_unique( ArgumentValidator::validate_string_list( 'extension_slug', $filtered_args['extension_slug'] ) ) );
+			$extension_slugs = array_values( array_unique( (array) $validator->get_string_list( 'extension_slug', $filtered_args['extension_slug'] ) ) );
 			if ( in_array( 'any', $extension_slugs, true ) ) {
-				throw new InvalidArgumentException( '"extension_slug" must not be "any": leave it out to list every extension\'s plans.' );
+				$validator->add(
+					new InvalidInputException(
+						ValidationErrorCode::INVALID_VALUE,
+						'extension_slug',
+						'"extension_slug" must not be "any": leave it out to list every extension\'s plans.'
+					)
+				);
 			}
 			$query['extension_slugs'] = $extension_slugs;
 		}
 		if ( array_key_exists( 'status', $filtered_args ) ) {
-			$query['status'] = ArgumentValidator::validate_string_list( 'status', $filtered_args['status'] );
+			$query['status'] = $validator->get_string_list( 'status', $filtered_args['status'] );
 		}
 		if ( array_key_exists( 'ids', $filtered_args ) ) {
-			$query['ids'] = ArgumentValidator::validate_id_list( 'ids', $filtered_args['ids'] );
+			$query['ids'] = $validator->get_id_list( 'ids', $filtered_args['ids'] );
+		}
+		try {
+			$validator->throw_errors();
+		} catch ( InvalidInputException $e ) {
+			throw ValidationException::from_core( $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- from_core() escapes the message.
 		}

 		return array_map(
@@ -306,49 +341,121 @@ final class Plans {
 		);
 	}

-	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- the DomainException comes from the entity setters, not a throw in this method.
 	/**
-	 * Validate the caller's field shapes and apply them to a plan through its setters,
-	 * which enforce the entity invariants. Nothing is written to storage; an invalid value
-	 * throws before any write.
+	 * Check the shape of the present plan fields with the collector and return them
+	 * normalized (a refused field is left out, or null for a policy; the collector holds
+	 * its error).
 	 *
-	 * @param Plan                 $plan Plan to change.
-	 * @param array<string, mixed> $args Caller fields (known keys only, no `extension_slug`).
-	 * @throws InvalidArgumentException If a value has the wrong shape.
-	 * @throws DomainException If a value breaks an entity invariant (from the entity setters).
+	 * @param ArgumentValidator    $validator Argument collector.
+	 * @param array<string, mixed> $args      Caller fields (known keys only).
+	 * @return array<string, mixed>
+	 * @phpstan-return PlanFields
 	 */
-	private static function apply( Plan $plan, array $args ): void {
-		foreach ( $args as $key => $value ) {
-			switch ( $key ) {
-				case 'name':
-					$plan->set_name( trim( ArgumentValidator::validate_string( $key, $value ) ) );
-					break;
-				case 'status':
-					$plan->set_status( ArgumentValidator::validate_string( $key, $value ) );
-					break;
-				case 'billing_policy':
-					$plan->set_billing_policy( ArgumentValidator::validate_nullable_array( $key, $value ) );
-					break;
-				case 'pricing_policy':
-					$plan->set_pricing_policy( ArgumentValidator::validate_nullable_array( $key, $value ) );
-					break;
-				case 'delivery_policy':
-					$plan->set_delivery_policy( ArgumentValidator::validate_nullable_array( $key, $value ) );
-					break;
+	private static function get_plan_fields( ArgumentValidator $validator, array $args ): array {
+		$fields = array();
+		if ( array_key_exists( 'name', $args ) ) {
+			$name = $validator->get_string( 'name', $args['name'] );
+			if ( null !== $name ) {
+				$fields['name'] = trim( $name );
+			}
+		}
+		if ( array_key_exists( 'status', $args ) ) {
+			$status = $validator->get_string( 'status', $args['status'] );
+			if ( null !== $status ) {
+				$fields['status'] = $status;
+			}
+		}
+		foreach ( array( 'billing_policy', 'pricing_policy', 'delivery_policy' ) as $key ) {
+			if ( array_key_exists( $key, $args ) ) {
+				$fields[ $key ] = $validator->get_nullable_array( $key, $args[ $key ] );
 			}
 		}
+
+		return $fields;
 	}
-	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

 	/**
-	 * Let the plan's extension validate the would-be plan before it is written.
+	 * Apply normalized changes to a plan through its setters, which enforce the entity
+	 * invariants. Every setter runs; their refusals are thrown together. Nothing is written
+	 * to storage.
+	 *
+	 * @param Plan                 $plan    Plan to change.
+	 * @param array<string, mixed> $changes Normalized fields from {@see self::get_plan_fields()}.
+	 * @phpstan-param PlanFields $changes
+	 * @throws InvalidInputException If a value breaks an entity invariant.
+	 */
+	private static function apply( Plan $plan, array $changes ): void {
+		$errors = array();
+		if ( array_key_exists( 'name', $changes ) ) {
+			try {
+				$plan->set_name( $changes['name'] );
+			} catch ( InvalidInputException $e ) {
+				$errors[] = $e;
+			}
+		}
+		if ( array_key_exists( 'status', $changes ) ) {
+			try {
+				$plan->set_status( $changes['status'] );
+			} catch ( InvalidInputException $e ) {
+				$errors[] = $e;
+			}
+		}
+		if ( array_key_exists( 'billing_policy', $changes ) ) {
+			try {
+				$plan->set_billing_policy( $changes['billing_policy'] );
+			} catch ( InvalidInputException $e ) {
+				$errors[] = $e;
+			}
+		}
+		if ( array_key_exists( 'pricing_policy', $changes ) ) {
+			try {
+				$plan->set_pricing_policy( $changes['pricing_policy'] );
+			} catch ( InvalidInputException $e ) {
+				$errors[] = $e;
+			}
+		}
+		if ( array_key_exists( 'delivery_policy', $changes ) ) {
+			try {
+				$plan->set_delivery_policy( $changes['delivery_policy'] );
+			} catch ( InvalidInputException $e ) {
+				$errors[] = $e;
+			}
+		}
+
+		if ( array() !== $errors ) {
+			throw InvalidInputException::from_errors( $errors ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- The caller converts it with ValidationException::from_core(), which escapes the message.
+		}
+	}
+
+	/**
+	 * The would-be plan of a refused update: the stored plan with the attempted changes,
+	 * refused values included, for the validation callbacks. Never stored.
 	 *
-	 * @param Plan $plan The would-be plan (unsaved on create).
-	 * @throws PlanValidationException If a callback added errors.
+	 * @param Plan                 $plan    Stored plan.
+	 * @param array<string, mixed> $changes Normalized fields from {@see self::get_plan_fields()}.
+	 */
+	private static function get_would_be_plan( Plan $plan, array $changes ): Plan {
+		$stored = array(
+			'id'               => $plan->get_id(),
+			'date_created_gmt' => $plan->get_date_created_gmt(),
+			'date_updated_gmt' => $plan->get_date_updated_gmt(),
+		) + $plan->to_storage();
+
+		return Plan::draft( array_merge( $stored, $changes ) );
+	}
+
+	/**
+	 * Ask the plan's extension to validate the would-be plan before it is written, also when the
+	 * plan itself was refused, then throw both refusals together: the plan's errors first.
+	 * Returns only when neither refused.
+	 *
+	 * @param Plan                       $plan    The would-be plan (unsaved on create; unchecked when refused).
+	 * @param InvalidInputException|null $refusal The plan's own refusal, or null.
+	 * @throws ValidationException If the plan was refused or a callback added errors.
 	 * @throws RuntimeException If a callback threw, with the callback's throwable as the previous
 	 *                          exception. The REST controller relies on that to tell it from a failed write.
 	 */
-	private static function validate_with_extension( Plan $plan ): void {
+	private static function throw_refusals( Plan $plan, ?InvalidInputException $refusal ): void {
 		$errors         = new WP_Error();
 		$extension_slug = (string) $plan->get_extension_slug();

@@ -358,8 +465,16 @@ final class Plans {
 			 * including status-only updates, so the plan's extension can refuse it.
 			 *
 			 * Add errors to $errors to refuse the write; act only on your own $extension_slug.
+			 * Give an error `field` data (dot path, such as `billing_policy.interval`, with
+			 * optional `params`) and REST reports it under that top-level arg in `rest_invalid_param`,
+			 * with any `status` of its own folded into the 400. An error without `field` is
+			 * reported as a general 400; one with the code `rest_invalid_param` is reported
+			 * without its data.
 			 * The view is the would-be state after the write (its id is 0 on create) and is
-			 * read-only.
+			 * read-only. It also fires when the engine refuses the plan itself (an empty name,
+			 * an unregistered status): validate your own payloads and do not rely on `name`,
+			 * `status` or `extension_slug` being valid. The engine's errors and yours are
+			 * reported together. A callback that throws fails the write as a server error.
 			 *
 			 * Changed with plans as records: `$plan` is a read-only {@see PlanView}. Earlier
 			 * engine versions passed the Core `Plan` entity; a callback still typed on `Plan`
@@ -384,8 +499,19 @@ final class Plans {
 			throw new RuntimeException( 'Plans: the plan could not be validated.', 0, $e ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Literal message.
 		}

-		if ( $errors->has_errors() ) {
-			throw new PlanValidationException( $errors ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- The exception escapes its message.
+		if ( null === $refusal ) {
+			if ( $errors->has_errors() ) {
+				throw new ValidationException( $errors ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- The exception escapes its message.
+			}
+			return;
 		}
+
+		$engine_errors = ValidationException::from_core( $refusal );
+		$all_errors    = $engine_errors->get_errors();
+		$all_errors->merge_from( $errors );
+		$messages = array_merge( array( $engine_errors->getMessage() ), array_map( 'esc_html', $errors->get_error_messages() ) );
+
+		// The refusal is chained: it tells the REST controller which errors are the engine's.
+		throw new ValidationException( $all_errors, implode( ' ', $messages ), $refusal ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Both parts are escaped.
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
index 54994231399..f7e819c11bc 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/Rest/PlansController.php
@@ -12,14 +12,15 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Api\Rest;

 use Automattic\WooCommerce\SubscriptionsEngine\Api\Plans;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\PlanValidationException;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ValidationException;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\RESTPermissions;
-use InvalidArgumentException;
 use RuntimeException;
 use WP_Error;
 use WP_REST_Controller;
@@ -49,6 +50,17 @@ final class PlansController extends WP_REST_Controller {
 	 */
 	private const WRITE_FIELDS = array( 'name', 'status', 'billing_policy', 'pricing_policy', 'delivery_policy' );

+	/**
+	 * Arg options of a writable schema field: WordPress neither checks nor casts it, so
+	 * the facade (or the `extension_slug` check) reports it with every other refusal.
+	 *
+	 * @var array<string, null>
+	 */
+	private const UNCHECKED_ARG_OPTIONS = array(
+		'validate_callback' => null,
+		'sanitize_callback' => null,
+	);
+
 	/**
 	 * Logger source.
 	 */
@@ -138,7 +150,8 @@ final class PlansController extends WP_REST_Controller {
 							'description'       => __( 'Status of the plans to query (any registered plan status).', 'woocommerce-subscriptions-engine' ),
 							'type'              => 'string',
 							'required'          => false,
-							'validate_callback' => array( $this, 'validate_status_param' ),
+							// get_items() checks it, so its refusal carries the `errors` map.
+							'sanitize_callback' => null,
 						),
 						'orderby'        => array(
 							'description' => __( 'Order by field for the plan query.', 'woocommerce-subscriptions-engine' ),
@@ -201,24 +214,6 @@ final class PlansController extends WP_REST_Controller {
 		return $this->rest_permissions->require_admin_permission();
 	}

-	/**
-	 * Validate a status param: any registered plan status.
-	 *
-	 * @param mixed $value Param value.
-	 * @return true|WP_Error
-	 */
-	public function validate_status_param( $value ) {
-		if ( is_string( $value ) && PlanStatus::is_registered( $value ) ) {
-			return true;
-		}
-
-		return new WP_Error(
-			'rest_invalid_param',
-			__( 'status must be a registered plan status.', 'woocommerce-subscriptions-engine' ),
-			array( 'status' => 400 )
-		);
-	}
-
 	/**
 	 * Get a paginated plan list.
 	 *
@@ -231,6 +226,11 @@ final class PlansController extends WP_REST_Controller {
 			return $extension_slugs;
 		}

+		$status_error = $this->get_status_filter_error( $request->get_param( 'status' ) );
+		if ( null !== $status_error ) {
+			return $status_error;
+		}
+
 		$page     = max( 1, Coercion::coerce_int( $request->get_param( 'page' ), 1 ) );
 		$per_page = $this->get_per_page( $request );
 		$args     = array(
@@ -304,10 +304,8 @@ final class PlansController extends WP_REST_Controller {

 		try {
 			$plan = Plans::create( $args );
-		} catch ( PlanValidationException $e ) {
-			return $this->as_bad_request( $e->get_errors() );
-		} catch ( InvalidArgumentException $e ) {
-			return $this->invalid_error( $e->getMessage() );
+		} catch ( ValidationException $e ) {
+			return $this->get_validation_error( $e );
 		} catch ( RuntimeException $e ) {
 			return $this->write_failed_error( $e, 'woocommerce_subscriptions_engine_plan_create_failed' );
 		}
@@ -337,10 +335,8 @@ final class PlansController extends WP_REST_Controller {
 		try {
 			// A plan of another extension reads as missing: the facade scopes the update to the request's extension slug.
 			$plan = Plans::update( Coercion::coerce_int( $request->get_param( 'id' ) ), $args );
-		} catch ( PlanValidationException $e ) {
-			return $this->as_bad_request( $e->get_errors() );
-		} catch ( InvalidArgumentException $e ) {
-			return $this->invalid_error( $e->getMessage() );
+		} catch ( ValidationException $e ) {
+			return $this->get_validation_error( $e );
 		} catch ( RuntimeException $e ) {
 			return $this->write_failed_error( $e, 'woocommerce_subscriptions_engine_plan_update_failed' );
 		}
@@ -412,7 +408,7 @@ final class PlansController extends WP_REST_Controller {
 			'status'   => array(
 				'description'       => __( 'Limit result set to plans with a registered plan status.', 'woocommerce-subscriptions-engine' ),
 				'type'              => 'string',
-				'validate_callback' => array( $this, 'validate_status_param' ),
+				'sanitize_callback' => null,
 			),
 			'orderby'  => array(
 				'description'       => __( 'Sort collection by object attribute.', 'woocommerce-subscriptions-engine' ),
@@ -457,34 +453,37 @@ final class PlansController extends WP_REST_Controller {
 					'description' => __( 'Owning extension slug.', 'woocommerce-subscriptions-engine' ),
 					'type'        => array( 'string', 'null' ),
 					'context'     => array( 'view', 'edit' ),
+					'arg_options' => self::UNCHECKED_ARG_OPTIONS,
 				),
 				'status'           => array(
 					'description' => __( 'Plan status (any registered plan status).', 'woocommerce-subscriptions-engine' ),
 					'type'        => 'string',
 					'context'     => array( 'view', 'edit' ),
-					'arg_options' => array(
-						'validate_callback' => array( $this, 'validate_status_param' ),
-					),
+					'arg_options' => self::UNCHECKED_ARG_OPTIONS,
 				),
 				'name'             => array(
 					'description' => __( 'Display name.', 'woocommerce-subscriptions-engine' ),
 					'type'        => 'string',
 					'context'     => array( 'view', 'edit' ),
+					'arg_options' => self::UNCHECKED_ARG_OPTIONS,
 				),
 				'billing_policy'   => array(
 					'description' => __( 'Billing payload of the owning extension.', 'woocommerce-subscriptions-engine' ),
 					'type'        => array( 'object', 'null' ),
 					'context'     => array( 'view', 'edit' ),
+					'arg_options' => self::UNCHECKED_ARG_OPTIONS,
 				),
 				'pricing_policy'   => array(
 					'description' => __( 'Pricing payload of the owning extension.', 'woocommerce-subscriptions-engine' ),
 					'type'        => array( 'object', 'null' ),
 					'context'     => array( 'view', 'edit' ),
+					'arg_options' => self::UNCHECKED_ARG_OPTIONS,
 				),
 				'delivery_policy'  => array(
 					'description' => __( 'Delivery payload of the owning extension.', 'woocommerce-subscriptions-engine' ),
 					'type'        => array( 'object', 'null' ),
 					'context'     => array( 'view', 'edit' ),
+					'arg_options' => self::UNCHECKED_ARG_OPTIONS,
 				),
 				'date_created_gmt' => array(
 					'description' => __( 'Creation time (GMT).', 'woocommerce-subscriptions-engine' ),
@@ -520,7 +519,7 @@ final class PlansController extends WP_REST_Controller {

 	/**
 	 * Collect the present writable params as facade args, passed through as given
-	 * (the name is sanitized); the facade validates them.
+	 * (a string name is sanitized); the facade validates them.
 	 *
 	 * @param WP_REST_Request $request Request.
 	 * @return array<string, mixed>
@@ -529,7 +528,8 @@ final class PlansController extends WP_REST_Controller {
 		$args = array();
 		foreach ( self::WRITE_FIELDS as $field ) {
 			if ( $request->has_param( $field ) ) {
-				$args[ $field ] = 'name' === $field ? $this->get_string_param( $request, 'name' ) : $request->get_param( $field );
+				$value          = $request->get_param( $field );
+				$args[ $field ] = 'name' === $field && is_string( $value ) ? sanitize_text_field( $value ) : $value;
 			}
 		}

@@ -537,17 +537,17 @@ final class PlansController extends WP_REST_Controller {
 	}

 	/**
-	 * Present a stored policy as a JSON object: an empty payload serializes as `{}`.
+	 * Present a map (a stored policy, error params) as a JSON object: an empty one serializes as `{}`.
 	 *
-	 * @param array<string, mixed>|null $policy Policy payload.
-	 * @return array<string, mixed>|object|null
+	 * @param array<array-key, mixed>|null $map Map.
+	 * @return array<array-key, mixed>|object|null
 	 */
-	private static function as_json_object( ?array $policy ) {
-		if ( array() === $policy ) {
+	private static function as_json_object( ?array $map ) {
+		if ( array() === $map ) {
 			return (object) array();
 		}

-		return $policy;
+		return $map;
 	}

 	/**
@@ -580,6 +580,175 @@ final class PlansController extends WP_REST_Controller {
 		);
 	}

+	/**
+	 * Report validation errors as WordPress reports invalid request params: errors with a
+	 * `field` become one `rest_invalid_param` error (see {@see self::get_invalid_param_error()});
+	 * errors without one are added to it. With no `field` on any error, the errors return
+	 * as a plain 400.
+	 *
+	 * @param ValidationException $e The facade's refusal.
+	 */
+	private function get_validation_error( ValidationException $e ): WP_Error {
+		$paired = $this->get_paired_errors( $e );
+		if ( array() === $paired['fielded'] ) {
+			return $this->as_bad_request( $paired['fieldless'] );
+		}
+
+		$error = $this->get_invalid_param_error( $paired['fielded'] );
+		$this->merge_fieldless_errors( $error, $paired['fieldless'] );
+
+		return $error;
+	}
+
+	/**
+	 * Pair each error message with its own data, in `WP_Error` order. The engine's errors
+	 * always pair. An extension's error pairs only when every extension message of its code
+	 * carries data; otherwise that code's extension messages are fieldless, so a field never
+	 * lands on the wrong message.
+	 *
+	 * @param ValidationException $e The facade's refusal.
+	 * @return array{fielded: list<array{code: string, message: string, field: string, data: array<mixed>}>, fieldless: WP_Error}
+	 */
+	private function get_paired_errors( ValidationException $e ): array {
+		$errors    = $e->get_errors();
+		$fielded   = array();
+		$fieldless = new WP_Error();
+
+		// The chained Core refusal holds the engine's errors, which come first in their code and always carry data.
+		$engine_counts = array();
+		$refusal       = $e->getPrevious();
+		if ( $refusal instanceof InvalidInputException ) {
+			foreach ( $refusal->get_errors() as $error ) {
+				$engine_counts[ $error->get_error_code() ] = ( $engine_counts[ $error->get_error_code() ] ?? 0 ) + 1;
+			}
+		}
+
+		foreach ( $errors->get_error_codes() as $code ) {
+			$code     = (string) $code;
+			$messages = array_values( $errors->get_error_messages( $code ) );
+			$all_data = array_values( $errors->get_all_error_data( $code ) );
+			// `WP_Error` skips empty data, so when the counts differ the extension's data cannot be placed.
+			$pairs = count( $messages ) === count( $all_data );
+
+			foreach ( $messages as $index => $message ) {
+				$data = ( $pairs || $index < ( $engine_counts[ $code ] ?? 0 ) ) ? $all_data[ $index ] : null;
+				if ( is_array( $data ) && isset( $data['field'] ) && is_string( $data['field'] ) && '' !== $data['field'] ) {
+					$fielded[] = array(
+						'code'    => $code,
+						'message' => $message,
+						'field'   => $data['field'],
+						'data'    => $data,
+					);
+					continue;
+				}
+
+				$fieldless->add( $code, $message, $data );
+			}
+		}
+
+		return array(
+			'fielded'   => $fielded,
+			'fieldless' => $fieldless,
+		);
+	}
+
+	/**
+	 * The `rest_invalid_param` error for fielded errors. Keyed by top-level arg: `params`
+	 * holds the arg's sentences naming their fields, and `details` the arg's first error
+	 * (code, that sentence and its own data). `errors` maps each full dot path to its
+	 * errors' code, field-free message and params.
+	 *
+	 * @param list<array{code: string, message: string, field: string, data: array<mixed>}> $fielded Paired, fielded errors.
+	 */
+	private function get_invalid_param_error( array $fielded ): WP_Error {
+		$params       = array();
+		$details      = array();
+		$field_errors = array();
+		foreach ( $fielded as $error ) {
+			$arg     = $this->get_top_level_arg( $error['field'] );
+			$message = $this->get_field_message( $error['field'], $error['message'] );
+
+			$params[ $arg ][] = $message;
+			if ( ! isset( $details[ $arg ] ) ) {
+				$details[ $arg ] = array(
+					'code'    => $error['code'],
+					'message' => $message,
+					'data'    => $error['data'],
+				);
+				if ( isset( $error['data']['params'] ) && is_array( $error['data']['params'] ) ) {
+					$details[ $arg ]['data']['params'] = self::as_json_object( $error['data']['params'] );
+				}
+			}
+			$field_errors[ $error['field'] ][] = array(
+				'code'    => $error['code'],
+				'message' => $error['message'],
+				'params'  => self::as_json_object( isset( $error['data']['params'] ) && is_array( $error['data']['params'] ) ? $error['data']['params'] : array() ),
+			);
+		}
+
+		return new WP_Error(
+			'rest_invalid_param',
+			/* translators: %s: List of invalid parameters. */
+			sprintf( __( 'Invalid parameter(s): %s' ), implode( ', ', array_keys( $params ) ) ), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- WordPress core's own message for invalid request params, in its default domain.
+			array(
+				'status'  => 400,
+				'params'  => array_map(
+					static function ( array $messages ): string {
+						return implode( ' ', $messages );
+					},
+					$params
+				),
+				'details' => $details,
+				'errors'  => $field_errors,
+			)
+		);
+	}
+
+	/**
+	 * Add fieldless errors to the response error, each with a 400 status unless it has
+	 * its own. A `rest_invalid_param` one is added without its data, so it can never
+	 * replace the response's data.
+	 *
+	 * @param WP_Error $error     The `rest_invalid_param` response error.
+	 * @param WP_Error $fieldless Errors without a field.
+	 */
+	private function merge_fieldless_errors( WP_Error $error, WP_Error $fieldless ): void {
+		$fieldless = $this->as_bad_request( $fieldless );
+		foreach ( $fieldless->get_error_codes() as $code ) {
+			foreach ( $fieldless->get_error_messages( $code ) as $message ) {
+				$error->add( $code, $message );
+			}
+			if ( 'rest_invalid_param' === $code ) {
+				continue;
+			}
+			foreach ( $fieldless->get_all_error_data( $code ) as $data ) {
+				$error->add_data( $data, $code );
+			}
+		}
+	}
+
+	/**
+	 * The top-level request arg of a dot path: the path up to its first dot.
+	 *
+	 * @param string $field Field, in dot notation.
+	 */
+	private function get_top_level_arg( string $field ): string {
+		$dot = strpos( $field, '.' );
+
+		return false === $dot ? $field : substr( $field, 0, $dot );
+	}
+
+	/**
+	 * A validation message that names its field, for REST `params` and `details`.
+	 *
+	 * @param string $field   Field, in dot notation.
+	 * @param string $message Translated, field-free message.
+	 */
+	private function get_field_message( string $field, string $message ): string {
+		/* translators: 1: field name, such as billing_policy.interval, 2: validation message. */
+		return sprintf( __( '%1$s: %2$s', 'woocommerce' ), $field, $message );
+	}
+
 	/**
 	 * Give each error code without a status a 400 status.
 	 *
@@ -596,6 +765,30 @@ final class PlansController extends WP_REST_Controller {
 		return $errors;
 	}

+	/**
+	 * The list filter's `status` error, in the validation error shape: null when the
+	 * filter is absent or a registered plan status.
+	 *
+	 * @param mixed $value Param value.
+	 */
+	private function get_status_filter_error( $value ): ?WP_Error {
+		if ( null === $value ) {
+			return null;
+		}
+
+		if ( ! is_string( $value ) ) {
+			$error = new InvalidInputException( ValidationErrorCode::INVALID_TYPE, 'status', 'status must be a string.', array( 'type' => 'string' ) );
+		} elseif ( ! PlanStatus::is_registered( $value ) ) {
+			$error = new InvalidInputException( ValidationErrorCode::NOT_REGISTERED, 'status', 'status must be a registered plan status.', array( 'value' => $value ) );
+		} else {
+			return null;
+		}
+
+		$exception = ValidationException::from_core( $error );
+
+		return $this->get_validation_error( $exception );
+	}
+
 	/**
 	 * Get multiple, valid extension slugs from an incoming request.
 	 *
@@ -605,11 +798,11 @@ final class PlansController extends WP_REST_Controller {
 	private function get_multiple_extension_slugs( WP_REST_Request $request ) {
 		$raw = $request->get_param( 'extension_slug' );
 		if ( null === $raw ) {
-			return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
+			return $this->get_extension_slug_error( ValidationErrorCode::REQUIRED, 'extension_slug is required.' );
 		}
 		$raw_string = trim( Coercion::coerce_string( $raw ) );
 		if ( '' === $raw_string ) {
-			return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
+			return $this->get_extension_slug_error( ValidationErrorCode::REQUIRED, 'extension_slug is required.' );
 		}

 		if ( 'any' === $raw_string ) {
@@ -620,7 +813,7 @@ final class PlansController extends WP_REST_Controller {
 		foreach ( explode( ',', $raw_string ) as $possible_slug ) {
 			$slug = trim( $possible_slug );
 			if ( '' === $slug || 'any' === $slug || ! $this->is_valid_extension_slug( $slug ) ) {
-				return $this->invalid_error( __( 'extension_slug must be "any" or a comma-separated list of extension slugs.', 'woocommerce-subscriptions-engine' ) );
+				return $this->get_extension_slug_error( ValidationErrorCode::INVALID_VALUE, 'extension_slug must be "any" or a comma-separated list of extension slugs.' );
 			}

 			$slugs[ $slug ] = $slug;
@@ -638,38 +831,43 @@ final class PlansController extends WP_REST_Controller {
 	private function get_single_extension_slug( WP_REST_Request $request ) {
 		$raw = $request->get_param( 'extension_slug' );
 		if ( null === $raw ) {
-			return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
+			return $this->get_extension_slug_error( ValidationErrorCode::REQUIRED, 'extension_slug is required.' );
 		}
-		$raw_string = trim( Coercion::coerce_string( $raw ) );
+		if ( ! is_string( $raw ) ) {
+			return $this->get_extension_slug_error( ValidationErrorCode::INVALID_TYPE, 'extension_slug must be a string.', array( 'type' => 'string' ) );
+		}
+		$raw_string = trim( $raw );
 		if ( '' === $raw_string ) {
-			return $this->invalid_error( __( 'extension_slug is required.', 'woocommerce-subscriptions-engine' ) );
+			return $this->get_extension_slug_error( ValidationErrorCode::REQUIRED, 'extension_slug is required.' );
 		}

 		if ( 'any' === $raw_string || false !== strpos( $raw_string, ',' ) || ! $this->is_valid_extension_slug( $raw_string ) ) {
-			return $this->invalid_error( __( 'extension_slug must be a concrete extension slug.', 'woocommerce-subscriptions-engine' ) );
+			return $this->get_extension_slug_error( ValidationErrorCode::INVALID_VALUE, 'extension_slug must be a concrete extension slug.' );
 		}

 		return $raw_string;
 	}

 	/**
-	 * Whether a value is a valid extension slug.
+	 * The `extension_slug` param error, in the validation error shape.
 	 *
-	 * @param string $slug Possible extension slug.
+	 * @param string               $code    Error code.
+	 * @param string               $message English developer message.
+	 * @param array<string, mixed> $params  Error params.
 	 */
-	private function is_valid_extension_slug( string $slug ): bool {
-		return '' !== $slug && sanitize_key( $slug ) === $slug;
+	private function get_extension_slug_error( string $code, string $message, array $params = array() ): WP_Error {
+		$exception = ValidationException::from_core( new InvalidInputException( $code, 'extension_slug', $message, $params ) );
+
+		return $this->get_validation_error( $exception );
 	}

 	/**
-	 * A sanitized string param.
+	 * Whether a value is a valid extension slug.
 	 *
-	 * @param WP_REST_Request $request  Request.
-	 * @param string          $key      Param key.
-	 * @param string          $fallback Fallback.
+	 * @param string $slug Possible extension slug.
 	 */
-	private function get_string_param( WP_REST_Request $request, string $key, string $fallback = '' ): string {
-		return sanitize_text_field( Coercion::coerce_string( $request->get_param( $key ), $fallback ) );
+	private function is_valid_extension_slug( string $slug ): bool {
+		return '' !== $slug && sanitize_key( $slug ) === $slug;
 	}

 	/**
@@ -682,17 +880,4 @@ final class PlansController extends WP_REST_Controller {
 			array( 'status' => 404 )
 		);
 	}
-
-	/**
-	 * Invalid request error.
-	 *
-	 * @param string $message Message.
-	 */
-	private function invalid_error( string $message ): WP_Error {
-		return new WP_Error(
-			'woocommerce_subscriptions_engine_invalid_plan',
-			$message,
-			array( 'status' => 400 )
-		);
-	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Api/ValidationException.php b/packages/php/woocommerce-subscriptions-engine/src/Api/ValidationException.php
new file mode 100644
index 00000000000..ab64bfc7d24
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Api/ValidationException.php
@@ -0,0 +1,135 @@
+<?php
+/**
+ * ValidationException - a facade call refused because its input is not valid.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Api
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Api;
+
+use InvalidArgumentException;
+use Throwable;
+use WP_Error;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Thrown by the facades when they refuse input: an engine check (built with
+ * {@see self::from_core()}) or a `woocommerce_subscriptions_engine_validate_plan`
+ * callback. Callers read {@see self::get_errors()}: each error's code (a
+ * {@see ValidationErrorCode} or the extension's own), its translated message, and
+ * its data, with `field` (dot path) and `params` for engine errors.
+ * `getMessage()` stays English, for logs.
+ */
+final class ValidationException extends InvalidArgumentException {
+
+	/**
+	 * Collected validation errors.
+	 *
+	 * @var WP_Error
+	 */
+	private $errors;
+
+	/**
+	 * Wrap the collected validation errors.
+	 *
+	 * @param WP_Error       $errors   Validation errors.
+	 * @param string         $message  Escaped exception message; empty joins the error messages.
+	 * @param Throwable|null $previous The exception that caused the refusal.
+	 */
+	public function __construct( WP_Error $errors, string $message = '', ?Throwable $previous = null ) {
+		if ( '' === $message ) {
+			$message = esc_html( implode( ' ', $errors->get_error_messages() ) );
+		}
+
+		parent::__construct( $message, 0, $previous );
+
+		$this->errors = $errors;
+	}
+
+	/**
+	 * Convert a Core validation error into translated errors, one entry per Core error, each
+	 * with the engine code, a field-free message and `field` / `params` data. The Core
+	 * exception is chained as the previous one.
+	 *
+	 * @param InvalidInputException $e Core validation error.
+	 */
+	public static function from_core( InvalidInputException $e ): self {
+		$errors = new WP_Error();
+		foreach ( $e->get_errors() as $error ) {
+			$errors->add(
+				$error->get_error_code(),
+				self::get_translated_message( $error->get_error_code(), $error->get_params() ),
+				array(
+					'field'  => $error->get_field(),
+					'params' => $error->get_params(),
+				)
+			);
+		}
+
+		return new self( $errors, esc_html( $e->getMessage() ), $e );
+	}
+
+	/**
+	 * The validation errors, with their codes and data.
+	 */
+	public function get_errors(): WP_Error {
+		return $this->errors;
+	}
+
+	/**
+	 * Translated, field-free message for an error code. An unknown code, or params a
+	 * message cannot use, get the generic message, so a code a third party constructs
+	 * never fails here.
+	 *
+	 * @param string               $code   Error code.
+	 * @param array<string, mixed> $params Message values.
+	 */
+	private static function get_translated_message( string $code, array $params ): string {
+		$min = isset( $params['min'] ) && is_int( $params['min'] ) ? $params['min'] : null;
+		$max = isset( $params['max'] ) && is_int( $params['max'] ) ? $params['max'] : null;
+
+		switch ( $code ) {
+			case ValidationErrorCode::REQUIRED:
+				return __( 'This field is required.', 'woocommerce' );
+			case ValidationErrorCode::INVALID_TYPE:
+				if ( isset( $params['type'] ) && is_string( $params['type'] ) ) {
+					/* translators: %s: expected type, such as string or integer. */
+					return sprintf( __( 'Must be of type %s.', 'woocommerce' ), $params['type'] );
+				}
+				break;
+			case ValidationErrorCode::OUT_OF_BOUNDS:
+				if ( null !== $min && null !== $max ) {
+					/* translators: 1: lowest allowed number, 2: highest allowed number. */
+					return sprintf( __( 'Must be between %1$d and %2$d.', 'woocommerce' ), $min, $max );
+				}
+				if ( null !== $min ) {
+					/* translators: %d: lowest allowed number. */
+					return sprintf( __( 'Must be greater than or equal to %d.', 'woocommerce' ), $min );
+				}
+				if ( null !== $max ) {
+					/* translators: %d: highest allowed number. */
+					return sprintf( __( 'Must be less than or equal to %d.', 'woocommerce' ), $max );
+				}
+				break;
+			case ValidationErrorCode::NOT_IN_ENUM:
+				if ( isset( $params['allowed'] ) && is_array( $params['allowed'] ) ) {
+					/* translators: %l: list of allowed values, such as "day, week, and month". */
+					return wp_sprintf( __( 'Must be one of %l.', 'woocommerce' ), array_values( array_filter( $params['allowed'], 'is_string' ) ) );
+				}
+				break;
+			case ValidationErrorCode::NOT_REGISTERED:
+				if ( isset( $params['value'] ) && is_scalar( $params['value'] ) ) {
+					/* translators: %s: the status that is not registered. */
+					return sprintf( __( '"%s" is not registered.', 'woocommerce' ), (string) $params['value'] );
+				}
+				break;
+		}
+
+		return __( 'This value is not valid.', 'woocommerce' );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
index 35f9c649a6e..2af628e5883 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Contract.php
@@ -25,9 +25,10 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;

-use DomainException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\MoneyScale;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PaymentInstrumentRef;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;

@@ -306,10 +307,11 @@ final class Contract {

 	/**
 	 * Build a new, unsaved contract. `extension_slug` is required; `creation_key` is an
-	 * optional idempotency key of 1-100 characters, trimmed of surrounding whitespace.
+	 * optional idempotency key of 1-100 characters, trimmed of surrounding whitespace. Every
+	 * invalid attribute is reported in one exception.
 	 *
 	 * @param array<string, mixed> $args Contract attributes.
-	 * @throws DomainException If the contract attributes are not valid.
+	 * @throws InvalidInputException If the contract attributes are not valid.
 	 */
 	public static function create( array $args ): self {
 		// A new contract is always unsaved and unlocked; never adopt a caller-supplied id or lock.
@@ -320,35 +322,46 @@ final class Contract {
 			$args['creation_key'] = trim( $args['creation_key'] );
 		}

-		self::assert_creation_key( $args['creation_key'] ?? null );
+		$creation_key_error = self::get_creation_key_error( $args['creation_key'] ?? null );
+		if ( null !== $creation_key_error ) {
+			unset( $args['creation_key'] );
+		}

 		$contract = new self( $args );

-		self::assert_status( $contract->status );
-		self::assert_extension_slug( $contract->extension_slug );
-		self::assert_schedule_source( $contract->schedule_source );
-		$contract->assert_money_has_currency();
+		$errors = array_filter(
+			array(
+				$creation_key_error,
+				self::get_status_error( $contract->status ),
+				self::get_extension_slug_error( $contract->extension_slug ),
+				self::get_schedule_source_error( $contract->schedule_source ),
+				$contract->get_money_currency_error(),
+			)
+		);
+		if ( array() !== $errors ) {
+			throw InvalidInputException::from_errors( array_values( $errors ) );
+		}

 		return $contract;
 	}

 	/**
-	 * Refuse money without a currency: a contract with no currency must have every total at zero.
-	 * Cross-field, so callers that change several fields check it once after the last change.
-	 * Checked against the state the caller holds; the contract lock serializes facade writers.
-	 *
-	 * @throws DomainException If a total is non-zero while the currency is unset.
+	 * The error for money without a currency, or null: a contract with no currency must have
+	 * every total at zero. Cross-field, so callers that change several fields check it once
+	 * after the last change. Checked against the state the caller holds; the contract lock serializes facade writers.
 	 */
-	public function assert_money_has_currency(): void {
+	public function get_money_currency_error(): ?InvalidInputException {
 		if ( null !== $this->currency ) {
-			return;
+			return null;
 		}

 		foreach ( array( $this->billing_total, $this->discount_total, $this->shipping_total, $this->tax_total ) as $total ) {
 			if ( 0.0 !== (float) $total ) {
-				throw new DomainException( 'Contract: money totals require a currency.' );
+				return new InvalidInputException( ValidationErrorCode::REQUIRED, 'currency', 'Contract: money totals require a currency.' );
 			}
 		}
+
+		return null;
 	}

 	/**
@@ -412,14 +425,17 @@ final class Contract {
 	 * status is a no-op, so a hydrated unregistered status survives it.
 	 *
 	 * @param string $status Target status; must be registered.
-	 * @throws DomainException If `$status` is not a registered contract status.
+	 * @throws InvalidInputException If `$status` is not a registered contract status.
 	 */
 	public function set_status( string $status ): void {
 		if ( $status === $this->status ) {
 			return;
 		}

-		self::assert_status( $status );
+		$error = self::get_status_error( $status );
+		if ( null !== $error ) {
+			throw $error;
+		}

 		$this->status = $status;
 	}
@@ -736,10 +752,13 @@ final class Contract {
 	 *
 	 * @param string $schedule_source 'subscriptions_engine' or the slug of the gateway or
 	 *                                extension that keeps its own schedule.
-	 * @throws DomainException If `$schedule_source` is not a valid slug.
+	 * @throws InvalidInputException If `$schedule_source` is not a valid slug.
 	 */
 	public function set_schedule_source( string $schedule_source ): void {
-		self::assert_schedule_source( $schedule_source );
+		$error = self::get_schedule_source_error( $schedule_source );
+		if ( null !== $error ) {
+			throw $error;
+		}

 		$this->schedule_source = $schedule_source;
 	}
@@ -854,56 +873,73 @@ final class Contract {
 	}

 	/**
-	 * Refuse a creation key that is not null or a string of 1-100 characters.
+	 * The error for a creation key that is not null or a string of 1-100 characters, or null.
 	 *
 	 * @param mixed $creation_key Creation key to check.
-	 * @throws DomainException If `$creation_key` is not valid.
 	 */
-	private static function assert_creation_key( $creation_key ): void {
+	private static function get_creation_key_error( $creation_key ): ?InvalidInputException {
 		if ( null === $creation_key ) {
-			return;
+			return null;
+		}
+
+		if ( ! is_string( $creation_key ) ) {
+			return new InvalidInputException( ValidationErrorCode::INVALID_TYPE, 'creation_key', 'Contract: creation_key must be a string.', array( 'type' => 'string' ) );
 		}

-		if ( ! is_string( $creation_key ) || '' === $creation_key || mb_strlen( $creation_key ) > 100 ) {
-			throw new DomainException( 'Contract: creation_key must be a string of 1-100 characters.' );
+		if ( '' === $creation_key || mb_strlen( $creation_key ) > 100 ) {
+			return new InvalidInputException( ValidationErrorCode::INVALID_VALUE, 'creation_key', 'Contract: creation_key must be 1-100 characters.' );
 		}
+
+		return null;
 	}

 	/**
-	 * Refuse a status that is not a registered contract status.
+	 * The error for a status that is not a registered contract status, or null.
 	 *
 	 * @param string $status Status to check.
-	 * @throws DomainException If `$status` is not registered.
 	 */
-	private static function assert_status( string $status ): void {
+	private static function get_status_error( string $status ): ?InvalidInputException {
 		if ( ! ContractStatus::is_registered( $status ) ) {
-			throw new DomainException( sprintf( 'Contract: status "%s" is not registered.', $status ) );
+			return new InvalidInputException(
+				ValidationErrorCode::NOT_REGISTERED,
+				'status',
+				sprintf( 'Contract: status "%s" is not registered.', $status ),
+				array( 'value' => $status )
+			);
 		}
+
+		return null;
 	}

 	/**
-	 * Refuse a missing or empty owning extension slug.
+	 * The error for a missing or empty owning extension slug, or null.
 	 *
 	 * @param string|null $extension_slug Extension slug to check.
-	 * @throws DomainException If `$extension_slug` is null or empty.
 	 */
-	private static function assert_extension_slug( ?string $extension_slug ): void {
+	private static function get_extension_slug_error( ?string $extension_slug ): ?InvalidInputException {
 		if ( null === $extension_slug || '' === $extension_slug ) {
-			throw new DomainException( 'Contract: extension_slug is required and must be a non-empty string.' );
+			return new InvalidInputException( ValidationErrorCode::REQUIRED, 'extension_slug', 'Contract: extension_slug is required and must be a non-empty string.' );
 		}
+
+		return null;
 	}

 	/**
-	 * Refuse a schedule source that is not a slug of 1-64 lowercase letters, digits,
+	 * The error for a schedule source that is not a slug of 1-64 lowercase letters, digits,
 	 * underscores or hyphens (the column is VARCHAR(64), wide enough for extension and
-	 * gateway slugs).
+	 * gateway slugs), or null.
 	 *
 	 * @param string $schedule_source Schedule source to check.
-	 * @throws DomainException If `$schedule_source` is not a valid slug.
 	 */
-	private static function assert_schedule_source( string $schedule_source ): void {
+	private static function get_schedule_source_error( string $schedule_source ): ?InvalidInputException {
 		if ( 1 !== preg_match( '/^[a-z0-9_-]{1,64}$/D', $schedule_source ) ) {
-			throw new DomainException( sprintf( 'Contract: invalid schedule source "%s".', $schedule_source ) );
+			return new InvalidInputException(
+				ValidationErrorCode::INVALID_VALUE,
+				'schedule_source',
+				sprintf( 'Contract: invalid schedule source "%s".', $schedule_source )
+			);
 		}
+
+		return null;
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
index 5d59fd446bf..c60b989efbb 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Cycle.php
@@ -16,10 +16,11 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;

-use DomainException;
 use LogicException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\MoneyScale;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;

@@ -197,27 +198,41 @@ final class Cycle {
 	 * next position), `count` (absent or null for a non-counting cycle; the caller owns
 	 * the numbering), `status` (defaults to `pending`), `kind` (defaults to billing), `reason`,
 	 * `order_id`, `extension_slug`, `plan_snapshot_id`, `items_snapshot_id`. Snapshot value
-	 * objects are attached only when a stored cycle is read.
+	 * objects are attached only when a stored cycle is read. Every invalid attribute is
+	 * reported in one exception.
 	 *
 	 * @param array<string, mixed> $args Cycle attributes.
-	 * @throws DomainException If the attributes are not valid.
+	 * @throws InvalidInputException If the attributes are not valid.
 	 */
 	public static function create( array $args ): self {
 		// A new cycle is always unsaved; never adopt a caller-supplied id.
 		unset( $args['id'] );

+		// Checked before construction, which refuses a malformed status slug; a refused
+		// status is dropped so construction can still check the other attributes.
+		$status_error = self::get_status_error( $args['status'] ?? null );
+		if ( null !== $status_error ) {
+			unset( $args['status'] );
+		}
+
 		$cycle = new self( $args );

-		self::assert_contract_id( $cycle->contract_id );
-		self::assert_kind( $cycle->kind );
-		// Null is allowed here for auto-assign sequence_no during append operation.
-		if ( null !== $cycle->sequence_no ) {
-			self::assert_sequence_no( $cycle->sequence_no );
+		$errors = array_filter(
+			array(
+				self::get_contract_id_error( $cycle->contract_id ),
+				self::get_kind_error( $cycle->kind ),
+				// Null is allowed here for auto-assign sequence_no during append operation.
+				null === $cycle->sequence_no ? null : self::get_sequence_no_error( $cycle->sequence_no ),
+				self::get_starts_at_gmt_error( $cycle->starts_at_gmt ),
+				self::get_ends_at_gmt_error( $cycle->ends_at_gmt ),
+				self::get_currency_error( $cycle->currency ),
+				self::get_count_error( $cycle->count ),
+				$status_error,
+			)
+		);
+		if ( array() !== $errors ) {
+			throw InvalidInputException::from_errors( array_values( $errors ) );
 		}
-		self::assert_period( $cycle->starts_at_gmt, $cycle->ends_at_gmt );
-		self::assert_currency( $cycle->currency );
-		self::assert_count( $cycle->count );
-		self::assert_status( $cycle->status );

 		return $cycle;
 	}
@@ -229,7 +244,7 @@ final class Cycle {
 	 * written), so a value written by a since-deactivated extension round-trips unchanged.
 	 *
 	 * @param array<string, mixed> $row Cycle row.
-	 * @throws DomainException If the stored status is not a well-formed status slug.
+	 * @throws InvalidInputException If the stored status is not a well-formed status slug.
 	 */
 	public static function from_storage( array $row ): self {
 		return new self( $row );
@@ -286,13 +301,16 @@ final class Cycle {
 	 * @internal Used by the repository's append.
 	 *
 	 * @param int $sequence_no Position, monotonic from 1.
-	 * @throws DomainException If the position is already assigned or `$sequence_no` is not positive.
+	 * @throws LogicException If the position is already assigned ({@see InvalidInputException}, a LogicException, if `$sequence_no` is not positive).
 	 */
 	public function assign_sequence_no( int $sequence_no ): void {
 		if ( null !== $this->sequence_no ) {
-			throw new DomainException( 'Cycle: sequence_no is already assigned.' );
+			throw new LogicException( 'Cycle: sequence_no is already assigned.' );
+		}
+		$error = self::get_sequence_no_error( $sequence_no );
+		if ( null !== $error ) {
+			throw $error;
 		}
-		self::assert_sequence_no( $sequence_no );

 		$this->sequence_no = $sequence_no;
 	}
@@ -400,11 +418,11 @@ final class Cycle {
 	 * unset value object, never swaps the frozen snapshot.
 	 *
 	 * @param PlanSnapshot $plan_snapshot Snapshot value object.
-	 * @throws DomainException If a plan snapshot value object is already attached.
+	 * @throws LogicException If a plan snapshot value object is already attached.
 	 */
 	public function set_plan_snapshot( PlanSnapshot $plan_snapshot ): void {
 		if ( null !== $this->plan_snapshot ) {
-			throw new DomainException( 'Cycle: the plan snapshot is frozen and cannot be replaced.' );
+			throw new LogicException( 'Cycle: the plan snapshot is frozen and cannot be replaced.' );
 		}
 		$this->plan_snapshot = $plan_snapshot;
 	}
@@ -421,11 +439,11 @@ final class Cycle {
 	 * {@see self::set_plan_snapshot()}.
 	 *
 	 * @param ItemsSnapshot $items_snapshot Snapshot value object.
-	 * @throws DomainException If an items snapshot value object is already attached.
+	 * @throws LogicException If an items snapshot value object is already attached.
 	 */
 	public function set_items_snapshot( ItemsSnapshot $items_snapshot ): void {
 		if ( null !== $this->items_snapshot ) {
-			throw new DomainException( 'Cycle: the items snapshot is frozen and cannot be replaced.' );
+			throw new LogicException( 'Cycle: the items snapshot is frozen and cannot be replaced.' );
 		}
 		$this->items_snapshot = $items_snapshot;
 	}
@@ -457,93 +475,136 @@ final class Cycle {
 	}

 	/**
-	 * Refuse a missing (non-positive) owning contract id.
+	 * The error for a missing (non-positive) owning contract id, or null.
 	 *
 	 * @param int $contract_id Contract id to check.
-	 * @throws DomainException If `$contract_id` is not positive.
 	 */
-	private static function assert_contract_id( int $contract_id ): void {
+	private static function get_contract_id_error( int $contract_id ): ?InvalidInputException {
 		if ( $contract_id < 1 ) {
-			throw new DomainException( 'Cycle: contract_id is required.' );
+			return new InvalidInputException(
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'contract_id',
+				sprintf( 'Cycle: contract_id must be 1 or greater, got %d.', $contract_id ),
+				array( 'min' => 1 )
+			);
 		}
+
+		return null;
 	}

 	/**
-	 * Validate a cycle kind. Known-but-extensible (deliberately not a sealed enum):
-	 * any non-empty kind is accepted so a third party may introduce its own.
+	 * The error for an empty cycle kind, or null. Known-but-extensible (deliberately not a
+	 * sealed enum): any non-empty kind is accepted so a third party may introduce its own.
 	 *
-	 * @param string $kind Kind to validate.
-	 * @throws DomainException If `$kind` is empty.
+	 * @param string $kind Kind to check.
 	 */
-	private static function assert_kind( string $kind ): void {
+	private static function get_kind_error( string $kind ): ?InvalidInputException {
 		if ( '' === $kind ) {
-			throw new DomainException( 'Cycle: kind must not be empty.' );
+			return new InvalidInputException( ValidationErrorCode::REQUIRED, 'kind', 'Cycle: kind must not be empty.' );
 		}
+
+		return null;
 	}

 	/**
-	 * Refuse a cycle without a start or end moment.
+	 * The error for a missing start moment, or null.
 	 *
 	 * @param string $starts_at_gmt Start (GMT).
-	 * @param string $ends_at_gmt   End (GMT).
-	 * @throws DomainException If either moment is missing.
 	 */
-	private static function assert_period( string $starts_at_gmt, string $ends_at_gmt ): void {
-		if ( '' === $starts_at_gmt || '' === $ends_at_gmt ) {
-			throw new DomainException( 'Cycle: starts_at_gmt and ends_at_gmt are required.' );
+	private static function get_starts_at_gmt_error( string $starts_at_gmt ): ?InvalidInputException {
+		if ( '' === $starts_at_gmt ) {
+			return new InvalidInputException( ValidationErrorCode::REQUIRED, 'starts_at_gmt', 'Cycle: starts_at_gmt is required.' );
 		}
+
+		return null;
 	}

 	/**
-	 * Refuse a cycle without a currency.
+	 * The error for a missing end moment, or null.
+	 *
+	 * @param string $ends_at_gmt End (GMT).
+	 */
+	private static function get_ends_at_gmt_error( string $ends_at_gmt ): ?InvalidInputException {
+		if ( '' === $ends_at_gmt ) {
+			return new InvalidInputException( ValidationErrorCode::REQUIRED, 'ends_at_gmt', 'Cycle: ends_at_gmt is required.' );
+		}
+
+		return null;
+	}
+
+	/**
+	 * The error for a missing currency, or null.
 	 *
 	 * @param string $currency Currency code.
-	 * @throws DomainException If the currency is missing.
 	 */
-	private static function assert_currency( string $currency ): void {
+	private static function get_currency_error( string $currency ): ?InvalidInputException {
 		if ( '' === $currency ) {
-			throw new DomainException( 'Cycle: currency is required.' );
+			return new InvalidInputException( ValidationErrorCode::REQUIRED, 'currency', 'Cycle: currency is required.' );
 		}
+
+		return null;
 	}

 	/**
-	 * Validate a sequence number.
+	 * The error for a non-positive sequence number, or null.
 	 *
-	 * @param int $sequence_no Sequence number to validate.
-	 * @throws DomainException If `$sequence_no` is not positive.
+	 * @param int $sequence_no Sequence number to check.
 	 */
-	private static function assert_sequence_no( int $sequence_no ): void {
+	private static function get_sequence_no_error( int $sequence_no ): ?InvalidInputException {
 		if ( $sequence_no < 1 ) {
-			throw new DomainException(
-				sprintf( 'Cycle: sequence_no must be 1 or greater, got %d.', $sequence_no )
+			return new InvalidInputException(
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'sequence_no',
+				sprintf( 'Cycle: sequence_no must be 1 or greater, got %d.', $sequence_no ),
+				array( 'min' => 1 )
 			);
 		}
+
+		return null;
 	}

 	/**
-	 * Reject a status value that is not registered. Write path only
-	 * ({@see self::create()}); storage hydration keeps an
+	 * The error for a status input a new cycle cannot take, or null: a malformed status slug
+	 * (`invalid_value`) or one that is not registered (`not_registered`). Null means the
+	 * default `pending`. Write path only ({@see self::create()}); storage hydration keeps an
 	 * unregistered stored value verbatim.
 	 *
-	 * @param CycleStatus $status Status to check.
-	 * @throws DomainException If the status is not registered.
+	 * @param mixed $status Raw status value (a CycleStatus, null, or a status string).
 	 */
-	private static function assert_status( CycleStatus $status ): void {
-		if ( ! CycleStatus::is_registered( $status->get_value() ) ) {
-			throw new DomainException(
-				sprintf( 'Cycle: status "%s" is not registered.', $status->get_value() )
+	private static function get_status_error( $status ): ?InvalidInputException {
+		if ( null === $status ) {
+			return null;
+		}
+
+		$value = $status instanceof CycleStatus ? $status->get_value() : Coercion::coerce_string( $status );
+		if ( ! CycleStatus::is_valid( $value ) ) {
+			return new InvalidInputException(
+				ValidationErrorCode::INVALID_VALUE,
+				'status',
+				sprintf( 'CycleStatus: "%s" is not a valid status slug.', $value )
+			);
+		}
+		if ( ! CycleStatus::is_registered( $value ) ) {
+			return new InvalidInputException(
+				ValidationErrorCode::NOT_REGISTERED,
+				'status',
+				sprintf( 'Cycle: status "%s" is not registered.', $value ),
+				array( 'value' => $value )
 			);
 		}
+
+		return null;
 	}

 	/**
 	 * Resolve a status input into a typed {@see CycleStatus}. A `CycleStatus` passes
 	 * through; null defaults to `pending`; a string is wrapped (slug format checked).
-	 * Registration is checked by {@see self::create()}, not here, so storage hydration keeps an unregistered stored value.
+	 * {@see self::create()} checks the raw status first; registration is not checked here,
+	 * so storage hydration keeps an unregistered stored value.
 	 *
 	 * @param mixed $status Raw status value (a CycleStatus, null, or a status string).
 	 * @return CycleStatus
-	 * @throws DomainException If a status string is not a well-formed status slug.
+	 * @throws InvalidInputException If a status string is not a well-formed status slug.
 	 */
 	private static function coerce_status( $status ): CycleStatus {
 		if ( $status instanceof CycleStatus ) {
@@ -558,16 +619,21 @@ final class Cycle {
 	}

 	/**
-	 * Validate a chargeable count: null (a non-counting cycle) or a positive integer.
+	 * The error for a present count that is not positive, or null (a non-counting cycle
+	 * has no count).
 	 *
-	 * @param int|null $count Count to validate.
-	 * @throws DomainException If a present count is not positive.
+	 * @param int|null $count Count to check.
 	 */
-	private static function assert_count( ?int $count ): void {
+	private static function get_count_error( ?int $count ): ?InvalidInputException {
 		if ( null !== $count && $count < 1 ) {
-			throw new DomainException(
-				sprintf( 'Cycle: count must be 1 or greater when set, got %d.', $count )
+			return new InvalidInputException(
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'count',
+				sprintf( 'Cycle: count must be 1 or greater when set, got %d.', $count ),
+				array( 'min' => 1 )
 			);
 		}
+
+		return null;
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
index 75f2ec2fcf1..6633e8e42c6 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/CycleStatus.php
@@ -16,7 +16,8 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;

-use DomainException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;

 defined( 'ABSPATH' ) || exit;

@@ -47,11 +48,13 @@ final class CycleStatus {
 	 * Wrap a status slug.
 	 *
 	 * @param string $value Status slug.
-	 * @throws DomainException If `$value` is not a well-formed status slug.
+	 * @throws InvalidInputException If `$value` is not a well-formed status slug.
 	 */
 	public function __construct( string $value ) {
 		if ( ! self::is_valid( $value ) ) {
-			throw new DomainException(
+			throw new InvalidInputException(
+				ValidationErrorCode::INVALID_VALUE,
+				'status',
 				sprintf( 'CycleStatus: "%s" is not a valid status slug.', $value )
 			);
 		}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
index 4e51b175c81..9046c91dedc 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/Plan.php
@@ -10,16 +10,18 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;

-use DomainException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;

 defined( 'ABSPATH' ) || exit;

 /**
  * Plan entity.
  *
- * Construct via {@see self::create()} for a new (unsaved) plan or
- * {@see self::from_storage()} when hydrating a stored row.
+ * Construct via {@see self::create()} for a new (unsaved) plan,
+ * {@see self::from_storage()} when hydrating a stored row, or {@see self::draft()}
+ * for the unchecked view of a refused write.
  */
 final class Plan {

@@ -87,7 +89,7 @@ final class Plan {
 	private $date_updated_gmt;

 	/**
-	 * Use {@see self::create()} or {@see self::from_storage()}. Coerces each attribute
+	 * Use {@see self::create()}, {@see self::from_storage()} or {@see self::draft()}. Coerces each attribute
 	 * to its property type; unknown keys are ignored, missing keys take the default.
 	 *
 	 * @param array<string, mixed> $data Raw attributes keyed by property name.
@@ -106,28 +108,51 @@ final class Plan {

 	/**
 	 * Build a new, unsaved plan. `extension_slug` and a non-empty `name` are required.
+	 * Every invalid attribute is reported in one exception.
 	 *
 	 * @param array<string, mixed> $args Keys `name`, `status` (default {@see PlanStatus::ACTIVE}), `extension_slug`, `billing_policy`, `pricing_policy`, `delivery_policy`.
-	 * @throws DomainException If the plan attributes are not valid.
+	 * @throws InvalidInputException If the plan attributes are not valid.
 	 */
 	public static function create( array $args ): self {
 		// A new plan is always unsaved; never adopt a caller-supplied id or stored dates.
 		unset( $args['id'], $args['date_created_gmt'], $args['date_updated_gmt'] );

 		// Checked before construction, which coerces a non-array payload to null.
-		self::assert_policy( 'billing_policy', $args['billing_policy'] ?? null );
-		self::assert_policy( 'pricing_policy', $args['pricing_policy'] ?? null );
-		self::assert_policy( 'delivery_policy', $args['delivery_policy'] ?? null );
+		$policy_errors = array(
+			self::get_policy_error( 'billing_policy', $args['billing_policy'] ?? null ),
+			self::get_policy_error( 'pricing_policy', $args['pricing_policy'] ?? null ),
+			self::get_policy_error( 'delivery_policy', $args['delivery_policy'] ?? null ),
+		);

 		$plan = new self( $args );

-		self::assert_name( $plan->name );
-		self::assert_status( $plan->status );
-		self::assert_extension_slug( $plan->extension_slug );
+		$errors = array_filter(
+			array_merge(
+				array(
+					self::get_name_error( $plan->name ),
+					self::get_status_error( $plan->status ),
+					self::get_extension_slug_error( $plan->extension_slug ),
+				),
+				$policy_errors
+			)
+		);
+		if ( array() !== $errors ) {
+			throw InvalidInputException::from_errors( array_values( $errors ) );
+		}

 		return $plan;
 	}

+	/**
+	 * Build a plan from raw attributes without checking them: the would-be plan of a
+	 * refused write, shown to validation callbacks. Never store it.
+	 *
+	 * @param array<string, mixed> $data Raw attributes keyed by property name.
+	 */
+	public static function draft( array $data ): self {
+		return new self( $data );
+	}
+
 	/**
 	 * Hydrate from a stored row, without validation. Policy columns arrive JSON-decoded.
 	 *
@@ -167,10 +192,14 @@ final class Plan {
 	 * Set the display name.
 	 *
 	 * @param string $name Display name; must not be empty.
-	 * @throws DomainException If the name is empty.
+	 * @throws InvalidInputException If the name is empty.
 	 */
 	public function set_name( string $name ): void {
-		self::assert_name( $name );
+		$error = self::get_name_error( $name );
+		if ( null !== $error ) {
+			throw $error;
+		}
+
 		$this->name = $name;
 	}

@@ -186,14 +215,18 @@ final class Plan {
 	 * unregistered status survives it.
 	 *
 	 * @param string $status Plan status; must be registered.
-	 * @throws DomainException If the status is not registered.
+	 * @throws InvalidInputException If the status is not registered.
 	 */
 	public function set_status( string $status ): void {
 		if ( $status === $this->status ) {
 			return;
 		}

-		self::assert_status( $status );
+		$error = self::get_status_error( $status );
+		if ( null !== $error ) {
+			throw $error;
+		}
+
 		$this->status = $status;
 	}

@@ -217,10 +250,14 @@ final class Plan {
 	 * Replace the billing payload.
 	 *
 	 * @param array<array-key, mixed>|null $billing_policy Billing payload; must be string-keyed.
-	 * @throws DomainException If the payload is not an object (string-keyed array).
+	 * @throws InvalidInputException If the payload is not an object (string-keyed array).
 	 */
 	public function set_billing_policy( ?array $billing_policy ): void {
-		self::assert_policy( 'billing_policy', $billing_policy );
+		$error = self::get_policy_error( 'billing_policy', $billing_policy );
+		if ( null !== $error ) {
+			throw $error;
+		}
+
 		$this->billing_policy = Coercion::coerce_nullable_string_keyed( $billing_policy );
 	}

@@ -237,10 +274,14 @@ final class Plan {
 	 * Replace the pricing payload.
 	 *
 	 * @param array<array-key, mixed>|null $pricing_policy Pricing payload; must be string-keyed.
-	 * @throws DomainException If the payload is not an object (string-keyed array).
+	 * @throws InvalidInputException If the payload is not an object (string-keyed array).
 	 */
 	public function set_pricing_policy( ?array $pricing_policy ): void {
-		self::assert_policy( 'pricing_policy', $pricing_policy );
+		$error = self::get_policy_error( 'pricing_policy', $pricing_policy );
+		if ( null !== $error ) {
+			throw $error;
+		}
+
 		$this->pricing_policy = Coercion::coerce_nullable_string_keyed( $pricing_policy );
 	}

@@ -257,10 +298,14 @@ final class Plan {
 	 * Replace the delivery payload.
 	 *
 	 * @param array<array-key, mixed>|null $delivery_policy Delivery payload; must be string-keyed.
-	 * @throws DomainException If the payload is not an object (string-keyed array).
+	 * @throws InvalidInputException If the payload is not an object (string-keyed array).
 	 */
 	public function set_delivery_policy( ?array $delivery_policy ): void {
-		self::assert_policy( 'delivery_policy', $delivery_policy );
+		$error = self::get_policy_error( 'delivery_policy', $delivery_policy );
+		if ( null !== $error ) {
+			throw $error;
+		}
+
 		$this->delivery_policy = Coercion::coerce_nullable_string_keyed( $delivery_policy );
 	}

@@ -314,59 +359,73 @@ final class Plan {
 	}

 	/**
-	 * Refuse an empty or whitespace-only name.
+	 * The error for an empty or whitespace-only name, or null.
 	 *
 	 * @param string $name Name to check.
-	 * @throws DomainException If `$name` is empty.
 	 */
-	private static function assert_name( string $name ): void {
+	private static function get_name_error( string $name ): ?InvalidInputException {
 		if ( '' === trim( $name ) ) {
-			throw new DomainException( 'Plan: name is required and must be a non-empty string.' );
+			return new InvalidInputException( ValidationErrorCode::REQUIRED, 'name', 'Plan: name is required and must be a non-empty string.' );
 		}
+
+		return null;
 	}

 	/**
-	 * Refuse a status that is not a registered plan status.
+	 * The error for a status that is not a registered plan status, or null.
 	 *
 	 * @param string $status Status to check.
-	 * @throws DomainException If `$status` is not registered.
 	 */
-	private static function assert_status( string $status ): void {
+	private static function get_status_error( string $status ): ?InvalidInputException {
 		if ( ! PlanStatus::is_registered( $status ) ) {
-			throw new DomainException( sprintf( 'Plan: status "%s" is not registered.', $status ) );
+			return new InvalidInputException(
+				ValidationErrorCode::NOT_REGISTERED,
+				'status',
+				sprintf( 'Plan: status "%s" is not registered.', $status ),
+				array( 'value' => $status )
+			);
 		}
+
+		return null;
 	}

 	/**
-	 * Refuse a missing or empty owning extension slug.
+	 * The error for a missing or empty owning extension slug, or null.
 	 *
 	 * @param string|null $extension_slug Extension slug to check.
-	 * @throws DomainException If `$extension_slug` is null or empty.
 	 */
-	private static function assert_extension_slug( ?string $extension_slug ): void {
+	private static function get_extension_slug_error( ?string $extension_slug ): ?InvalidInputException {
 		if ( null === $extension_slug || '' === $extension_slug ) {
-			throw new DomainException( 'Plan: extension_slug is required and must be a non-empty string.' );
+			return new InvalidInputException( ValidationErrorCode::REQUIRED, 'extension_slug', 'Plan: extension_slug is required and must be a non-empty string.' );
 		}
+
+		return null;
 	}

 	/**
-	 * Refuse a policy payload that is not an object or null. Any keyed array is an object,
-	 * including numeric keys (a JSON `{"123": ...}` decodes to an int key); only a list (keys
-	 * 0..n-1), which would come back as a JSON array, is refused. An empty array is accepted
-	 * (a JSON `{}` decodes to it). The same rule for each of the three policies.
+	 * The error for a policy payload that is not an object or null, or null. Any keyed
+	 * array is an object, including numeric keys (a JSON `{"123": ...}` decodes to an int
+	 * key); only a list (keys 0..n-1), which would come back as a JSON array, is refused.
+	 * An empty array is accepted (a JSON `{}` decodes to it). One rule for the three policies.
 	 *
-	 * @param string $field Policy field name, for the error message.
+	 * @param string $field Policy field name, reported as the error's field.
 	 * @param mixed  $value Candidate payload.
-	 * @throws DomainException If the value is a non-empty list or not an array.
 	 */
-	private static function assert_policy( string $field, $value ): void {
+	private static function get_policy_error( string $field, $value ): ?InvalidInputException {
 		if ( null === $value ) {
-			return;
+			return null;
 		}

 		$is_list = is_array( $value ) && array() !== $value && array_keys( $value ) === range( 0, count( $value ) - 1 );
 		if ( ! is_array( $value ) || $is_list ) {
-			throw new DomainException( sprintf( 'Plan: %s must be an object or null.', $field ) );
+			return new InvalidInputException(
+				ValidationErrorCode::INVALID_TYPE,
+				$field,
+				sprintf( 'Plan: %s must be an object or null.', $field ),
+				array( 'type' => 'object' )
+			);
 		}
+
+		return null;
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ArgumentValidator.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ArgumentValidator.php
new file mode 100644
index 00000000000..b20cf12b349
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ArgumentValidator.php
@@ -0,0 +1,611 @@
+<?php
+/**
+ * Argument checks shared by the public facades. WordPress-free Core zone.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Support
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Support;
+
+use DateTimeImmutable;
+use DateTimeInterface;
+use DateTimeZone;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Validate caller argument values and return them normalized, in two forms:
+ * - the static `validate_*()` checks throw {@see InvalidInputException} (a
+ *   {@see ValidationErrorCode}, the field and params) for a refused value;
+ * - an instance collects: each `get_*()` runs the matching check, records its errors and
+ *   returns the normalized value or null, and {@see self::throw_errors()} throws them all.
+ *
+ * A check stops at a field's first failed rule; a list check reports every failed item.
+ *
+ * @internal Engine implementation detail shared by the `Api\` facades, not part of the public API.
+ */
+final class ArgumentValidator {
+
+	/**
+	 * Errors recorded by the collecting form, in recording order.
+	 *
+	 * @var list<InvalidInputException>
+	 */
+	private $errors = array();
+
+	/**
+	 * Record an error found outside the checks.
+	 *
+	 * @param InvalidInputException $e Validation error.
+	 */
+	public function add( InvalidInputException $e ): void {
+		foreach ( $e->get_errors() as $error ) {
+			$this->errors[] = $error;
+		}
+	}
+
+	/**
+	 * Throw every recorded error at once; do nothing when nothing failed.
+	 *
+	 * @throws InvalidInputException If an error was recorded.
+	 */
+	public function throw_errors(): void {
+		if ( array() !== $this->errors ) {
+			throw InvalidInputException::from_errors( $this->errors );
+		}
+	}
+
+	/**
+	 * Collecting {@see self::validate_currency()}: the value, or null when refused.
+	 *
+	 * @param mixed $value Caller value.
+	 */
+	public function get_currency( $value ): ?string {
+		try {
+			return self::validate_currency( $value );
+		} catch ( InvalidInputException $e ) {
+			$this->add( $e );
+			return null;
+		}
+	}
+
+	/**
+	 * Collecting {@see self::validate_string()}: the value, or null when refused.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 */
+	public function get_string( string $key, $value ): ?string {
+		try {
+			return self::validate_string( $key, $value );
+		} catch ( InvalidInputException $e ) {
+			$this->add( $e );
+			return null;
+		}
+	}
+
+	/**
+	 * Collecting {@see self::validate_non_empty_string()}: the value, or null when refused.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 */
+	public function get_non_empty_string( string $key, $value ): ?string {
+		try {
+			return self::validate_non_empty_string( $key, $value );
+		} catch ( InvalidInputException $e ) {
+			$this->add( $e );
+			return null;
+		}
+	}
+
+	/**
+	 * Collecting {@see self::validate_nullable_string()}: the value, or null when refused.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 */
+	public function get_nullable_string( string $key, $value ): ?string {
+		try {
+			return self::validate_nullable_string( $key, $value );
+		} catch ( InvalidInputException $e ) {
+			$this->add( $e );
+			return null;
+		}
+	}
+
+	/**
+	 * Collecting {@see self::validate_nullable_id()}: the value, or null when refused.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 */
+	public function get_nullable_id( string $key, $value ): ?int {
+		try {
+			return self::validate_nullable_id( $key, $value );
+		} catch ( InvalidInputException $e ) {
+			$this->add( $e );
+			return null;
+		}
+	}
+
+	/**
+	 * Collecting {@see self::validate_positive_int()}: the value, or null when refused.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 */
+	public function get_positive_int( string $key, $value ): ?int {
+		try {
+			return self::validate_positive_int( $key, $value );
+		} catch ( InvalidInputException $e ) {
+			$this->add( $e );
+			return null;
+		}
+	}
+
+	/**
+	 * Collecting {@see self::validate_non_negative_int()}: the value, or null when refused.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 */
+	public function get_non_negative_int( string $key, $value ): ?int {
+		try {
+			return self::validate_non_negative_int( $key, $value );
+		} catch ( InvalidInputException $e ) {
+			$this->add( $e );
+			return null;
+		}
+	}
+
+	/**
+	 * Collecting {@see self::validate_id_list()}: the list, or null when refused.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @return array<int, int>|null
+	 */
+	public function get_id_list( string $key, $value ): ?array {
+		try {
+			return self::validate_id_list( $key, $value );
+		} catch ( InvalidInputException $e ) {
+			$this->add( $e );
+			return null;
+		}
+	}
+
+	/**
+	 * Collecting {@see self::validate_string_list()}: the list, or null when refused.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @return array<int, string>|null
+	 */
+	public function get_string_list( string $key, $value ): ?array {
+		try {
+			return self::validate_string_list( $key, $value );
+		} catch ( InvalidInputException $e ) {
+			$this->add( $e );
+			return null;
+		}
+	}
+
+	/**
+	 * Collecting {@see self::validate_nullable_array()}: the value, or null when refused.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @return array<int|string, mixed>|null
+	 */
+	public function get_nullable_array( string $key, $value ): ?array {
+		try {
+			return self::validate_nullable_array( $key, $value );
+		} catch ( InvalidInputException $e ) {
+			$this->add( $e );
+			return null;
+		}
+	}
+
+	/**
+	 * Collecting {@see self::validate_nullable_date()}: the value, or null when refused.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 */
+	public function get_nullable_date( string $key, $value ): ?string {
+		try {
+			return self::validate_nullable_date( $key, $value );
+		} catch ( InvalidInputException $e ) {
+			$this->add( $e );
+			return null;
+		}
+	}
+
+	/**
+	 * Collecting {@see self::validate_money()}: the value, or null when refused.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 */
+	public function get_money( string $key, $value ): ?string {
+		try {
+			return self::validate_money( $key, $value );
+		} catch ( InvalidInputException $e ) {
+			$this->add( $e );
+			return null;
+		}
+	}
+
+	/**
+	 * Collecting {@see self::validate_list_of_arrays()}: the rows, or null when refused.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @return array<int, array<string, mixed>>|null
+	 */
+	public function get_list_of_arrays( string $key, $value ): ?array {
+		try {
+			return self::validate_list_of_arrays( $key, $value );
+		} catch ( InvalidInputException $e ) {
+			$this->add( $e );
+			return null;
+		}
+	}
+
+	/**
+	 * Collecting {@see self::validate_contract_addresses()}: the addresses, or null when refused.
+	 *
+	 * @param mixed $value Caller value.
+	 * @return array<string, array<int|string, mixed>>|null
+	 */
+	public function get_contract_addresses( $value ): ?array {
+		try {
+			return self::validate_contract_addresses( $value );
+		} catch ( InvalidInputException $e ) {
+			$this->add( $e );
+			return null;
+		}
+	}
+
+	/**
+	 * Validate and return a currency code, or null.
+	 *
+	 * @param mixed $value Caller value.
+	 * @throws InvalidInputException If the value is not null or a three-letter uppercase code.
+	 */
+	public static function validate_currency( $value ): ?string {
+		if ( null !== $value && ( ! is_string( $value ) || 1 !== preg_match( '/^[A-Z]{3}\z/', $value ) ) ) {
+			throw self::get_invalid_input( ValidationErrorCode::INVALID_VALUE, 'currency', 'must be null or a three-letter uppercase ISO-4217 code' );
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Validate and return a string.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @throws InvalidInputException If the value is not a string.
+	 */
+	public static function validate_string( string $key, $value ): string {
+		if ( ! is_string( $value ) ) {
+			throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be a string', array( 'type' => 'string' ) );
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Validate and return a non-empty string. Null (a missing argument) and '' are reported
+	 * as required.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @throws InvalidInputException If the value is missing, empty or not a string.
+	 */
+	public static function validate_non_empty_string( string $key, $value ): string {
+		if ( null === $value || '' === $value ) {
+			throw self::get_invalid_input( ValidationErrorCode::REQUIRED, $key, 'must be a non-empty string' );
+		}
+		if ( ! is_string( $value ) ) {
+			throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be a non-empty string', array( 'type' => 'string' ) );
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Validate and return a string, or null.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @throws InvalidInputException If the value is not null or a string.
+	 */
+	public static function validate_nullable_string( string $key, $value ): ?string {
+		if ( null !== $value && ! is_string( $value ) ) {
+			throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be null or a string', array( 'type' => 'string' ) );
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Validate and return a positive integer id (a digit string is cast), or null.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @throws InvalidInputException If the value is not null or a positive integer.
+	 */
+	public static function validate_nullable_id( string $key, $value ): ?int {
+		if ( null === $value ) {
+			return null;
+		}
+
+		return self::validate_int_at_least( $key, $value, 1, 'must be null or a positive integer' );
+	}
+
+	/**
+	 * Validate and return a positive integer (a digit string is cast).
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @throws InvalidInputException If the value is not a positive integer.
+	 */
+	public static function validate_positive_int( string $key, $value ): int {
+		return self::validate_int_at_least( $key, $value, 1, 'must be a positive integer' );
+	}
+
+	/**
+	 * Validate and return a non-negative integer (a digit string is cast).
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @throws InvalidInputException If the value is not a non-negative integer.
+	 */
+	public static function validate_non_negative_int( string $key, $value ): int {
+		return self::validate_int_at_least( $key, $value, 0, 'must be a non-negative integer' );
+	}
+
+	/**
+	 * Validate a list of positive integer ids (digit strings are cast) and return it.
+	 * Each failed item is reported by its index (`ids.1`).
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @return array<int, int>
+	 * @throws InvalidInputException If the value is not a list of positive integers.
+	 */
+	public static function validate_id_list( string $key, $value ): array {
+		if ( ! self::is_list( $value ) ) {
+			throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be a list of positive integers', array( 'type' => 'array' ) );
+		}
+
+		$items = new self();
+		$ids   = array();
+		foreach ( $value as $index => $id ) {
+			$ids[] = (int) $items->get_positive_int( self::get_item_field( $key, $index ), $id );
+		}
+		$items->throw_errors();
+
+		return $ids;
+	}
+
+	/**
+	 * Validate a non-empty string or a list of them and return it as a list. Each failed
+	 * item is reported by its index (`status.1`).
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @return array<int, string>
+	 * @throws InvalidInputException If the value is not a non-empty string or a list of them.
+	 */
+	public static function validate_string_list( string $key, $value ): array {
+		if ( is_string( $value ) ) {
+			return array( self::validate_non_empty_string( $key, $value ) );
+		}
+		if ( ! self::is_list( $value ) ) {
+			throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be a non-empty string or a list of them', array( 'type' => 'array' ) );
+		}
+
+		$items   = new self();
+		$strings = array();
+		foreach ( $value as $index => $item ) {
+			$strings[] = (string) $items->get_non_empty_string( self::get_item_field( $key, $index ), $item );
+		}
+		$items->throw_errors();
+
+		return $strings;
+	}
+
+	/**
+	 * Validate and return an array, or null.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @return array<int|string, mixed>|null
+	 * @throws InvalidInputException If the value is not null or an array.
+	 */
+	public static function validate_nullable_array( string $key, $value ): ?array {
+		if ( null !== $value && ! is_array( $value ) ) {
+			throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be null or an array', array( 'type' => 'object' ) );
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Validate a GMT datetime, or null, and return it as a UTC `Y-m-d H:i:s` string.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value `DateTimeInterface`, GMT `Y-m-d H:i:s` string, or null.
+	 * @throws InvalidInputException If the value is not a valid datetime.
+	 */
+	public static function validate_nullable_date( string $key, $value ): ?string {
+		if ( null === $value ) {
+			return null;
+		}
+
+		if ( $value instanceof DateTimeInterface ) {
+			return ( new DateTimeImmutable( '@' . $value->getTimestamp() ) )->format( 'Y-m-d H:i:s' );
+		}
+
+		if ( is_string( $value ) ) {
+			$parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $value, new DateTimeZone( 'UTC' ) );
+			if ( false !== $parsed && $parsed->format( 'Y-m-d H:i:s' ) === $value ) {
+				return $value;
+			}
+		}
+
+		throw self::get_invalid_input( ValidationErrorCode::INVALID_VALUE, $key, 'must be null, a DateTimeInterface, or a GMT "Y-m-d H:i:s" string' );
+	}
+
+	/**
+	 * Validate a money value and return it normalized to the storage scale; null is 0.
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Number, numeric string, or null.
+	 * @throws InvalidInputException If the value is not numeric.
+	 */
+	public static function validate_money( string $key, $value ): string {
+		if ( null === $value ) {
+			return MoneyScale::normalize_money( 0 );
+		}
+
+		if ( ! is_int( $value ) && ! is_float( $value ) && ! ( is_string( $value ) && is_numeric( $value ) ) ) {
+			throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be a number or a numeric string', array( 'type' => 'number' ) );
+		}
+
+		return MoneyScale::normalize_money( $value );
+	}
+
+	/**
+	 * Validate a list of arrays and return it with string-keyed rows. Each failed row is
+	 * reported by its index (`items.0`).
+	 *
+	 * @param string $key   Field name.
+	 * @param mixed  $value Caller value.
+	 * @return array<int, array<string, mixed>>
+	 * @throws InvalidInputException If the value is not a list of arrays.
+	 */
+	public static function validate_list_of_arrays( string $key, $value ): array {
+		if ( ! self::is_list( $value ) ) {
+			throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, 'must be a list of arrays', array( 'type' => 'array' ) );
+		}
+
+		$items = new self();
+		$rows  = array();
+		foreach ( $value as $index => $row ) {
+			if ( ! is_array( $row ) ) {
+				$items->add( self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, self::get_item_field( $key, $index ), 'must be an array', array( 'type' => 'object' ) ) );
+				continue;
+			}
+			$rows[] = Coercion::coerce_string_keyed( $row );
+		}
+		$items->throw_errors();
+
+		return $rows;
+	}
+
+	/**
+	 * Validate the shape of contract addresses: a map keyed `billing` / `shipping` with
+	 * array values. Each failed address is reported by its type (`addresses.billing`).
+	 *
+	 * @param mixed $value Caller value.
+	 * @return array<string, array<int|string, mixed>>
+	 * @throws InvalidInputException If the value is not such a map.
+	 */
+	public static function validate_contract_addresses( $value ): array {
+		if ( ! is_array( $value ) ) {
+			throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, 'addresses', 'must be an array keyed "billing" / "shipping"', array( 'type' => 'object' ) );
+		}
+
+		$types     = array( Contract::ADDRESS_BILLING, Contract::ADDRESS_SHIPPING );
+		$items     = new self();
+		$addresses = array();
+		foreach ( $value as $type => $address ) {
+			$field = self::get_item_field( 'addresses', $type );
+			if ( ! in_array( $type, $types, true ) ) {
+				$items->add( self::get_invalid_input( ValidationErrorCode::NOT_IN_ENUM, $field, 'is not an address type; use "billing" or "shipping"', array( 'allowed' => $types ) ) );
+			} elseif ( ! is_array( $address ) ) {
+				$items->add( self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $field, 'must be an array', array( 'type' => 'object' ) ) );
+			} else {
+				$addresses[ $type ] = $address;
+			}
+		}
+		$items->throw_errors();
+
+		return $addresses;
+	}
+
+	/**
+	 * Validate an integer (a digit string is cast) of at least `$min`.
+	 *
+	 * @param string $key         Field name.
+	 * @param mixed  $value       Caller value.
+	 * @param int    $min         Lowest allowed value.
+	 * @param string $requirement What the value must be, for the message.
+	 * @throws InvalidInputException If the value is not an integer, or is below `$min`.
+	 */
+	private static function validate_int_at_least( string $key, $value, int $min, string $requirement ): int {
+		$value = self::cast_digit_string( $value );
+		if ( ! is_int( $value ) ) {
+			throw self::get_invalid_input( ValidationErrorCode::INVALID_TYPE, $key, $requirement, array( 'type' => 'integer' ) );
+		}
+		if ( $value < $min ) {
+			throw self::get_invalid_input( ValidationErrorCode::OUT_OF_BOUNDS, $key, $requirement, array( 'min' => $min ) );
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Build the validation error for a refused value; the message quotes the field.
+	 *
+	 * @param string               $code        Error code.
+	 * @param string               $field       Field, in dot notation.
+	 * @param string               $requirement What the value must be, for the message.
+	 * @param array<string, mixed> $params      Message values.
+	 */
+	private static function get_invalid_input( string $code, string $field, string $requirement, array $params = array() ): InvalidInputException {
+		return new InvalidInputException( $code, $field, sprintf( '"%s" %s.', $field, $requirement ), $params );
+	}
+
+	/**
+	 * Dot path of a list item or map entry.
+	 *
+	 * @param string     $key   Field name.
+	 * @param int|string $index Item index or map key.
+	 */
+	private static function get_item_field( string $key, $index ): string {
+		return $key . '.' . $index;
+	}
+
+	/**
+	 * Whether a value is a list: an array with consecutive int keys from 0 (an empty array is one).
+	 *
+	 * @param mixed $value Caller value.
+	 * @phpstan-assert-if-true array<int, mixed> $value
+	 */
+	private static function is_list( $value ): bool {
+		return is_array( $value ) && ( array() === $value || array_keys( $value ) === range( 0, count( $value ) - 1 ) );
+	}
+
+	/**
+	 * Cast a digit string to an int; any other value is returned unchanged.
+	 *
+	 * @param mixed $value Caller value.
+	 * @return mixed
+	 */
+	private static function cast_digit_string( $value ) {
+		if ( is_string( $value ) && ctype_digit( $value ) ) {
+			return (int) $value;
+		}
+
+		return $value;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/InvalidInputException.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/InvalidInputException.php
new file mode 100644
index 00000000000..b1b977ac0d7
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/InvalidInputException.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * InvalidInputException - a refused input value, reported as a stable error code,
+ * the field it concerns and the values a message needs. WordPress-free Core zone.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Support
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Support;
+
+use DomainException;
+use LogicException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Validation error thrown by Core checks, entities and value objects.
+ *
+ * Carries one or more errors. Each has a code (one of {@see ValidationErrorCode}), a
+ * field as a dot path (`name`, `trial_duration.length`, `items.2`) and the params the
+ * code's message needs; {@see self::get_errors()} lists them, and the getters return the
+ * first one's. The message is an English developer message, for logs.
+ */
+final class InvalidInputException extends DomainException {
+
+	/**
+	 * Stable error code.
+	 *
+	 * @var string
+	 */
+	private $error_code;
+
+	/**
+	 * Field the error concerns, in dot notation.
+	 *
+	 * @var string
+	 */
+	private $field;
+
+	/**
+	 * Values the code's message needs, keyed as documented on the code.
+	 *
+	 * @var array<string, mixed>
+	 */
+	private $params;
+
+	/**
+	 * Every single error, when built from several; empty for a single error.
+	 *
+	 * @var list<InvalidInputException>
+	 */
+	private $errors = array();
+
+	/**
+	 * Build the exception.
+	 *
+	 * @param string               $error_code Stable error code, one of {@see ValidationErrorCode}.
+	 * @param string               $field      Field the error concerns, in dot notation.
+	 * @param string               $message    English developer message.
+	 * @param array<string, mixed> $params     Values the code's message needs.
+	 */
+	public function __construct( string $error_code, string $field, string $message, array $params = array() ) {
+		parent::__construct( $message );
+		$this->error_code = $error_code;
+		$this->field      = $field;
+		$this->params     = $params;
+	}
+
+	/**
+	 * Stable error code, one of {@see ValidationErrorCode}.
+	 */
+	public function get_error_code(): string {
+		return $this->error_code;
+	}
+
+	/**
+	 * Field the error concerns, in dot notation.
+	 */
+	public function get_field(): string {
+		return $this->field;
+	}
+
+	/**
+	 * Values the code's message needs.
+	 *
+	 * @return array<string, mixed>
+	 */
+	public function get_params(): array {
+		return $this->params;
+	}
+
+	/**
+	 * Combine errors into one exception. Each element's errors are flattened in, in order;
+	 * a single resulting error is returned as is. Several keep the first one's code, field
+	 * and params, and join the English messages.
+	 *
+	 * @param array<int, InvalidInputException> $errors Errors, at least one.
+	 * @phpstan-param list<InvalidInputException> $errors
+	 * @throws LogicException If `$errors` is empty (an engine bug, not input).
+	 */
+	public static function from_errors( array $errors ): self {
+		$flattened = array();
+		foreach ( $errors as $error ) {
+			foreach ( $error->get_errors() as $single ) {
+				$flattened[] = $single;
+			}
+		}
+
+		if ( array() === $flattened ) {
+			throw new LogicException( 'InvalidInputException::from_errors() needs at least one error.' );
+		}
+		if ( 1 === count( $flattened ) ) {
+			return $flattened[0];
+		}
+
+		$messages = array();
+		foreach ( $flattened as $single ) {
+			$messages[] = $single->getMessage();
+		}
+
+		$first            = $flattened[0];
+		$combined         = new self( $first->get_error_code(), $first->get_field(), implode( ' ', $messages ), $first->get_params() );
+		$combined->errors = $flattened;
+
+		return $combined;
+	}
+
+	/**
+	 * Every single error, in order: this exception itself when it carries one.
+	 *
+	 * @return array<int, InvalidInputException>
+	 * @phpstan-return non-empty-list<InvalidInputException>
+	 */
+	public function get_errors(): array {
+		return array() === $this->errors ? array( $this ) : $this->errors;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ValidationErrorCode.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ValidationErrorCode.php
new file mode 100644
index 00000000000..f4a8a6282e3
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Support/ValidationErrorCode.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * ValidationErrorCode - the stable reasons a validation error reports.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Support
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Support;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Validation error codes, named after WordPress REST's validation reasons.
+ *
+ * A code says why a value was refused and the error's field says where. Once
+ * released, a check's code and field are frozen and its params keys are
+ * additive-only: a more precise error becomes a new check with a new code.
+ */
+final class ValidationErrorCode {
+
+	/**
+	 * A value is missing or empty. No params.
+	 */
+	public const REQUIRED = 'required';
+
+	/**
+	 * A value has the wrong type. Params: `type`, a JSON-schema type
+	 * (`string`, `integer`, `number`, `boolean`, `array`, `object`).
+	 */
+	public const INVALID_TYPE = 'invalid_type';
+
+	/**
+	 * A number is outside its bounds. Params: `min` and/or `max` (int).
+	 */
+	public const OUT_OF_BOUNDS = 'out_of_bounds';
+
+	/**
+	 * A value is not one of a fixed set. Params: `allowed` (list of strings).
+	 */
+	public const NOT_IN_ENUM = 'not_in_enum';
+
+	/**
+	 * A well-typed value is malformed or forbidden. No params.
+	 */
+	public const INVALID_VALUE = 'invalid_value';
+
+	/**
+	 * A status is not registered. Params: `value` (the refused status).
+	 */
+	public const NOT_REGISTERED = 'not_registered';
+
+	/**
+	 * Constants only.
+	 */
+	private function __construct() {}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
index 41ee494bdd1..5ee5a6f9cfc 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/BillingPolicy.php
@@ -4,8 +4,8 @@
  *
  * An extension may use {@see self::from_array()} to parse its plan billing arrays and
  * compute due moments from them. The engine stores plan policies opaquely and does not
- * read them itself. A policy always has a usable cadence: construction refuses an unknown
- * period or a non-positive interval. The array shape it parses:
+ * read them itself. A policy always has a usable cadence and trial: construction refuses an
+ * unknown period or trial unit and a non-positive interval or trial length. The array shape it parses:
  *   {
  *     period:         'day' | 'week' | 'month' | 'year',
  *     interval:       int,
@@ -26,8 +26,9 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;

 use DateTimeImmutable;
 use DateTimeZone;
-use DomainException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;

 defined( 'ABSPATH' ) || exit;

@@ -75,69 +76,77 @@ final class BillingPolicy {
 	private $trial_duration;

 	/**
-	 * Build a billing policy.
+	 * Build a billing policy. Every invalid value is reported in one exception.
 	 *
 	 * @param string                                $period         One of 'day' | 'week' | 'month' | 'year'.
 	 * @param int                                   $interval       Period count.
 	 * @param int|null                              $min_cycles     Minimum cycles before cancellation is allowed.
 	 * @param int|null                              $max_cycles     Total cycles before the contract ends.
 	 * @param array{length: int, unit: string}|null $trial_duration Native trial; null if none.
-	 * @throws DomainException If the period is unknown, the interval is not positive, or the cycle bounds are invalid.
+	 * @throws InvalidInputException If the period is unknown, the interval is not positive, the cycle bounds or the trial are invalid.
 	 */
 	public function __construct( string $period, int $interval, ?int $min_cycles, ?int $max_cycles, ?array $trial_duration ) {
-		if ( $interval <= 0 ) {
-			throw new DomainException(
-				sprintf( 'BillingPolicy: interval must be positive, got %d.', $interval )
-			);
-		}
-		$this->normalize_unit( $period, 'period' );
-		$this->validate_min_max_cycles( $min_cycles, $max_cycles );
+		self::throw_found_errors(
+			array_merge(
+				array(
+					self::get_period_error( $period ),
+					self::get_interval_error( $interval ),
+				),
+				self::get_cycle_errors( $min_cycles, $max_cycles ),
+				self::get_trial_errors( $trial_duration )
+			)
+		);

 		$this->period         = $period;
 		$this->interval       = $interval;
 		$this->min_cycles     = $min_cycles;
 		$this->max_cycles     = $max_cycles;
-		$this->trial_duration = self::normalize_trial_duration( $trial_duration );
+		$this->trial_duration = null === $trial_duration ? null : array(
+			'length' => (int) $trial_duration['length'],
+			'unit'   => (string) $trial_duration['unit'],
+		);
 	}

 	/**
 	 * Hydrate from the JSON-decoded `billing_policy` column shape.
 	 *
 	 * Missing nullable keys default to null. `period` and `interval` are required, and
-	 * must form a usable cadence (see the constructor).
+	 * must form a usable cadence (see the constructor). Every invalid value is reported
+	 * in one exception.
 	 *
 	 * @param array<string, mixed> $data Decoded billing_policy row.
-	 * @throws DomainException If the data is not valid.
+	 * @throws InvalidInputException If the data is not valid.
 	 */
 	public static function from_array( array $data ): self {
-		if ( ! array_key_exists( 'period', $data ) ) {
-			throw new DomainException( 'BillingPolicy: period is required, but not supplied.' );
-		}
-		if ( ! array_key_exists( 'interval', $data ) ) {
-			throw new DomainException( 'BillingPolicy: interval is required, but not supplied.' );
-		}
-		if ( ! is_string( $data['period'] ) ) {
-			throw new DomainException( 'BillingPolicy: period must be a string, got ' . gettype( $data['period'] ) . '.' );
-		}
-		if ( ! is_int( $data['interval'] ) ) {
-			throw new DomainException( 'BillingPolicy: interval must be an integer, got ' . gettype( $data['interval'] ) . '.' );
-		}
-
-		$trial = $data['trial_duration'] ?? null;
-		if ( null !== $trial && ! is_array( $trial ) ) {
-			throw new DomainException(
-				sprintf( 'BillingPolicy: trial_duration must be null or an array, got %s.', gettype( $trial ) )
-			);
-		}
-
-		$trial = self::normalize_trial_duration( $trial );
+		$min_cycles = Coercion::coerce_nullable_int( $data['min_cycles'] ?? null );
+		$max_cycles = Coercion::coerce_nullable_int( $data['max_cycles'] ?? null );
+		$trial      = $data['trial_duration'] ?? null;
+
+		self::throw_found_errors(
+			array_merge(
+				array(
+					array_key_exists( 'period', $data )
+						? self::get_period_error( $data['period'] )
+						: new InvalidInputException( ValidationErrorCode::REQUIRED, 'period', 'BillingPolicy: period is required, but not supplied.' ),
+					array_key_exists( 'interval', $data )
+						? self::get_interval_error( $data['interval'] )
+						: new InvalidInputException( ValidationErrorCode::REQUIRED, 'interval', 'BillingPolicy: interval is required, but not supplied.' ),
+				),
+				self::get_cycle_errors( $min_cycles, $max_cycles ),
+				array( self::get_trial_duration_error( $trial ) ),
+				is_array( $trial ) ? self::get_trial_errors( $trial ) : array()
+			)
+		);

 		return new self(
-			(string) $data['period'],
-			(int) $data['interval'],
-			Coercion::coerce_nullable_int( $data['min_cycles'] ?? null ),
-			Coercion::coerce_nullable_int( $data['max_cycles'] ?? null ),
-			$trial
+			Coercion::coerce_string( $data['period'] ),
+			Coercion::coerce_int( $data['interval'] ),
+			$min_cycles,
+			$max_cycles,
+			is_array( $trial ) ? array(
+				'length' => Coercion::coerce_int( $trial['length'] ?? null ),
+				'unit'   => Coercion::coerce_string( $trial['unit'] ?? null ),
+			) : null
 		);
 	}

@@ -203,27 +212,15 @@ final class BillingPolicy {
 	 *
 	 * @param DateTimeImmutable $contract_start Moment the contract was created.
 	 * @return DateTimeImmutable The first renewal moment in UTC.
-	 * @throws DomainException If the trial length is not positive or the trial unit is unknown.
 	 */
 	public function compute_first_renewal_from( DateTimeImmutable $contract_start ): DateTimeImmutable {
 		if ( null === $this->trial_duration ) {
 			return $this->compute_next_renewal_from( $contract_start );
 		}

-		$length = (int) $this->trial_duration['length'];
-		$unit   = (string) $this->trial_duration['unit'];
-
-		if ( $length <= 0 ) {
-			throw new DomainException(
-				sprintf( 'BillingPolicy::compute_first_renewal_from(): trial length must be positive, got %d.', $length )
-			);
-		}
-
-		$normalized_unit = $this->normalize_unit( $unit, 'trial unit' );
-
 		return $contract_start
 			->setTimezone( new DateTimeZone( 'UTC' ) )
-			->modify( sprintf( '+%d %s', $length, $normalized_unit ) );
+			->modify( sprintf( '+%d %s', $this->trial_duration['length'], $this->trial_duration['unit'] ) );
 	}

 	/**
@@ -242,81 +239,234 @@ final class BillingPolicy {
 	}

 	/**
-	 * Validate and pass through a period/trial unit.
+	 * The error for a unit that is not one of day/week/month/year, or null.
 	 *
 	 * @param string $unit  The raw unit.
-	 * @param string $label Where the unit came from, for the error message.
-	 * @throws DomainException If the unit is not one of day/week/month/year.
+	 * @param string $field Field the unit came from, in dot notation.
 	 */
-	private function normalize_unit( string $unit, string $label ): string {
-		if ( ! in_array( $unit, array( 'day', 'week', 'month', 'year' ), true ) ) {
-			throw new DomainException(
-				sprintf( 'BillingPolicy: invalid %s "%s".', $label, $unit )
+	private static function get_unit_error( string $unit, string $field ): ?InvalidInputException {
+		$allowed = array( 'day', 'week', 'month', 'year' );
+		if ( ! in_array( $unit, $allowed, true ) ) {
+			return new InvalidInputException(
+				ValidationErrorCode::NOT_IN_ENUM,
+				$field,
+				sprintf( 'BillingPolicy: invalid %s "%s".', $field, $unit ),
+				array( 'allowed' => $allowed )
 			);
 		}

-		return $unit;
+		return null;
 	}

 	/**
-	 * Validate the min_cycles and max_cycles fields.
+	 * Throw the non-null errors at once; do nothing when there are none.
+	 *
+	 * @param array<int, InvalidInputException|null> $errors Errors, null where a check passed.
+	 * @throws InvalidInputException If an error is present.
+	 */
+	private static function throw_found_errors( array $errors ): void {
+		$errors = array_values( array_filter( $errors ) );
+		if ( array() !== $errors ) {
+			throw InvalidInputException::from_errors( $errors );
+		}
+	}
+
+	/**
+	 * The error for a period that is not a string or not a known unit, or null.
+	 *
+	 * @param mixed $period Candidate period.
+	 */
+	private static function get_period_error( $period ): ?InvalidInputException {
+		if ( ! is_string( $period ) ) {
+			return new InvalidInputException(
+				ValidationErrorCode::INVALID_TYPE,
+				'period',
+				'BillingPolicy: period must be a string, got ' . gettype( $period ) . '.',
+				array( 'type' => 'string' )
+			);
+		}
+
+		return self::get_unit_error( $period, 'period' );
+	}
+
+	/**
+	 * The error for an interval that is not a positive integer, or null.
+	 *
+	 * @param mixed $interval Candidate interval.
+	 */
+	private static function get_interval_error( $interval ): ?InvalidInputException {
+		if ( ! is_int( $interval ) ) {
+			return new InvalidInputException(
+				ValidationErrorCode::INVALID_TYPE,
+				'interval',
+				'BillingPolicy: interval must be an integer, got ' . gettype( $interval ) . '.',
+				array( 'type' => 'integer' )
+			);
+		}
+		if ( $interval <= 0 ) {
+			return new InvalidInputException(
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'interval',
+				sprintf( 'BillingPolicy: interval must be positive, got %d.', $interval ),
+				array( 'min' => 1 )
+			);
+		}
+
+		return null;
+	}
+
+	/**
+	 * The cycle bound errors: each bound's own, then `min_cycles` above `max_cycles` only
+	 * when both passed theirs.
 	 *
 	 * @param int|null $min_cycles Minimum cycles before cancellation is allowed.
 	 * @param int|null $max_cycles Total cycles before the contract ends.
-	 * @throws DomainException If min_cycles or max_cycles are not valid.
+	 * @return array<int, InvalidInputException|null>
 	 */
-	private function validate_min_max_cycles( ?int $min_cycles, ?int $max_cycles ): void {
+	private static function get_cycle_errors( ?int $min_cycles, ?int $max_cycles ): array {
+		$min_error = self::get_min_cycles_error( $min_cycles );
+		$max_error = self::get_max_cycles_error( $max_cycles );
+		if ( null !== $min_error || null !== $max_error ) {
+			return array( $min_error, $max_error );
+		}
+
+		return array( self::get_cycle_bounds_error( $min_cycles, $max_cycles ) );
+	}
+
+	/**
+	 * The error for a negative `min_cycles`, or null.
+	 *
+	 * @param int|null $min_cycles Minimum cycles before cancellation is allowed.
+	 */
+	private static function get_min_cycles_error( ?int $min_cycles ): ?InvalidInputException {
 		if ( null !== $min_cycles && $min_cycles < 0 ) {
-			throw new DomainException(
-				sprintf( 'BillingPolicy: min_cycles must be 0 or greater, got %d.', $min_cycles )
+			return new InvalidInputException(
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'min_cycles',
+				sprintf( 'BillingPolicy: min_cycles must be 0 or greater, got %d.', $min_cycles ),
+				array( 'min' => 0 )
 			);
 		}

+		return null;
+	}
+
+	/**
+	 * The error for a negative `max_cycles`, or null.
+	 *
+	 * @param int|null $max_cycles Total cycles before the contract ends.
+	 */
+	private static function get_max_cycles_error( ?int $max_cycles ): ?InvalidInputException {
 		if ( null !== $max_cycles && $max_cycles < 0 ) {
-			throw new DomainException(
-				sprintf( 'BillingPolicy: max_cycles must be 0 or greater, got %d.', $max_cycles )
+			return new InvalidInputException(
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'max_cycles',
+				sprintf( 'BillingPolicy: max_cycles must be 0 or greater, got %d.', $max_cycles ),
+				array( 'min' => 0 )
 			);
 		}

+		return null;
+	}
+
+	/**
+	 * The error for `min_cycles` above `max_cycles`, or null.
+	 *
+	 * @param int|null $min_cycles Minimum cycles before cancellation is allowed.
+	 * @param int|null $max_cycles Total cycles before the contract ends.
+	 */
+	private static function get_cycle_bounds_error( ?int $min_cycles, ?int $max_cycles ): ?InvalidInputException {
 		if ( null !== $min_cycles && null !== $max_cycles && $min_cycles > $max_cycles ) {
-			throw new DomainException(
-				sprintf( 'BillingPolicy: min_cycles cannot exceed max_cycles, got %d and %d.', $min_cycles, $max_cycles )
+			return new InvalidInputException(
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'min_cycles',
+				sprintf( 'BillingPolicy: min_cycles cannot exceed max_cycles, got %d and %d.', $min_cycles, $max_cycles ),
+				array( 'max' => $max_cycles )
+			);
+		}
+
+		return null;
+	}
+
+	/**
+	 * The error for a trial duration that is neither null nor an array, or null.
+	 *
+	 * @param mixed $trial_duration Candidate trial duration.
+	 */
+	private static function get_trial_duration_error( $trial_duration ): ?InvalidInputException {
+		if ( null !== $trial_duration && ! is_array( $trial_duration ) ) {
+			return new InvalidInputException(
+				ValidationErrorCode::INVALID_TYPE,
+				'trial_duration',
+				sprintf( 'BillingPolicy: trial_duration must be null or an array, got %s.', gettype( $trial_duration ) ),
+				array( 'type' => 'object' )
 			);
 		}
+
+		return null;
 	}

 	/**
-	 * Normalize the trial duration.
+	 * The trial errors: the length's, then the unit's. None for no trial.
 	 *
-	 * @param array<array-key, mixed>|null $trial_duration The trial duration.
-	 * @return array{length: int, unit: string}|null The normalized trial duration.
-	 * @throws DomainException If the trial duration is not valid.
+	 * @param array<array-key, mixed>|null $trial_duration Trial duration.
+	 * @return array<int, InvalidInputException|null>
 	 */
-	private static function normalize_trial_duration( ?array $trial_duration ): ?array {
+	private static function get_trial_errors( ?array $trial_duration ): array {
 		if ( null === $trial_duration ) {
-			return null;
+			return array();
 		}

+		return array( self::get_trial_length_error( $trial_duration ), self::get_trial_unit_error( $trial_duration ) );
+	}
+
+	/**
+	 * The error for a missing, non-integer or non-positive trial length, or null.
+	 *
+	 * @param array<array-key, mixed> $trial_duration Trial duration.
+	 */
+	private static function get_trial_length_error( array $trial_duration ): ?InvalidInputException {
 		if ( ! array_key_exists( 'length', $trial_duration ) ) {
-			throw new DomainException( "BillingPolicy: trial_duration['length'] is required." );
-		}
-		if ( ! array_key_exists( 'unit', $trial_duration ) ) {
-			throw new DomainException( "BillingPolicy: trial_duration['unit'] is required." );
+			return new InvalidInputException( ValidationErrorCode::REQUIRED, 'trial_duration.length', 'BillingPolicy: trial_duration.length is required.' );
 		}
 		if ( ! is_int( $trial_duration['length'] ) ) {
-			throw new DomainException(
-				sprintf( "BillingPolicy: trial_duration['length'] must be an integer, got %s.", gettype( $trial_duration['length'] ) )
+			return new InvalidInputException(
+				ValidationErrorCode::INVALID_TYPE,
+				'trial_duration.length',
+				sprintf( 'BillingPolicy: trial_duration.length must be an integer, got %s.', gettype( $trial_duration['length'] ) ),
+				array( 'type' => 'integer' )
+			);
+		}
+		if ( $trial_duration['length'] <= 0 ) {
+			return new InvalidInputException(
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'trial_duration.length',
+				sprintf( 'BillingPolicy: trial_duration.length must be positive, got %d.', $trial_duration['length'] ),
+				array( 'min' => 1 )
 			);
 		}
+
+		return null;
+	}
+
+	/**
+	 * The error for a missing, non-string or unknown trial unit, or null.
+	 *
+	 * @param array<array-key, mixed> $trial_duration Trial duration.
+	 */
+	private static function get_trial_unit_error( array $trial_duration ): ?InvalidInputException {
+		if ( ! array_key_exists( 'unit', $trial_duration ) ) {
+			return new InvalidInputException( ValidationErrorCode::REQUIRED, 'trial_duration.unit', 'BillingPolicy: trial_duration.unit is required.' );
+		}
 		if ( ! is_string( $trial_duration['unit'] ) ) {
-			throw new DomainException(
-				sprintf( "BillingPolicy: trial_duration['unit'] must be a string, got %s.", gettype( $trial_duration['unit'] ) )
+			return new InvalidInputException(
+				ValidationErrorCode::INVALID_TYPE,
+				'trial_duration.unit',
+				sprintf( 'BillingPolicy: trial_duration.unit must be a string, got %s.', gettype( $trial_duration['unit'] ) ),
+				array( 'type' => 'string' )
 			);
 		}

-		return array(
-			'length' => (int) $trial_duration['length'],
-			'unit'   => (string) $trial_duration['unit'],
-		);
+		return self::get_unit_error( $trial_duration['unit'], 'trial_duration.unit' );
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/ItemsSnapshot.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/ItemsSnapshot.php
index 1c96d8841d4..062b6a32b73 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/ItemsSnapshot.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/ItemsSnapshot.php
@@ -15,7 +15,7 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;

-use DomainException;
+use LogicException;

 defined( 'ABSPATH' ) || exit;

@@ -46,11 +46,11 @@ final class ItemsSnapshot {
 	 *
 	 * @param array<int, array<string, mixed>> $items          Ordered line items.
 	 * @param int                              $schema_version Payload-format version.
-	 * @throws DomainException If the schema version is not positive.
+	 * @throws LogicException If the schema version is not positive.
 	 */
 	private function __construct( array $items, int $schema_version ) {
 		if ( $schema_version < 1 ) {
-			throw new DomainException(
+			throw new LogicException(
 				sprintf( 'ItemsSnapshot: schema_version must be 1 or greater, got %d.', $schema_version )
 			);
 		}
@@ -64,7 +64,7 @@ final class ItemsSnapshot {
 	 *
 	 * @param array<int, array<string, mixed>> $items          Ordered line items.
 	 * @param int                              $schema_version Payload-format version. Defaults to 1.
-	 * @throws DomainException If the schema version is not positive.
+	 * @throws LogicException If the schema version is not positive.
 	 */
 	public static function from_items( array $items, int $schema_version = 1 ): self {
 		return new self( $items, $schema_version );
@@ -76,7 +76,7 @@ final class ItemsSnapshot {
 	 *
 	 * @param array<int, array<string, mixed>> $payload        Serialized ordered line items.
 	 * @param int                              $schema_version Payload-format version the payload was written in.
-	 * @throws DomainException If the schema version is not positive.
+	 * @throws LogicException If the schema version is not positive.
 	 */
 	public static function from_payload( array $payload, int $schema_version = 1 ): self {
 		return new self( $payload, $schema_version );
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
index c157de3a275..5f4b4a3f766 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/PlanSnapshot.php
@@ -16,7 +16,8 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;

-use DomainException;
+use LogicException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;

 defined( 'ABSPATH' ) || exit;
@@ -49,11 +50,11 @@ final class PlanSnapshot {
 	 *
 	 * @param array<string, mixed> $data           Plan terms payload.
 	 * @param int                  $schema_version Payload-format version.
-	 * @throws DomainException If the schema version is not positive.
+	 * @throws LogicException If the schema version is not positive.
 	 */
 	private function __construct( array $data, int $schema_version ) {
 		if ( $schema_version < 1 ) {
-			throw new DomainException(
+			throw new LogicException(
 				sprintf( 'PlanSnapshot: schema_version must be 1 or greater, got %d.', $schema_version )
 			);
 		}
@@ -67,7 +68,7 @@ final class PlanSnapshot {
 	 *
 	 * @param array<string, mixed> $data           Plan terms payload.
 	 * @param int                  $schema_version Payload-format version. Defaults to 1.
-	 * @throws DomainException If the schema version is not positive.
+	 * @throws LogicException If the schema version is not positive.
 	 */
 	public static function from_array( array $data, int $schema_version = 1 ): self {
 		return new self( $data, $schema_version );
@@ -79,7 +80,7 @@ final class PlanSnapshot {
 	 *
 	 * @param array<string, mixed> $payload        Serialized plan terms payload.
 	 * @param int                  $schema_version Payload-format version the payload was written in.
-	 * @throws DomainException If the schema version is not positive.
+	 * @throws LogicException If the schema version is not positive.
 	 */
 	public static function from_payload( array $payload, int $schema_version = 1 ): self {
 		return new self( $payload, $schema_version );
@@ -106,7 +107,7 @@ final class PlanSnapshot {
 	 * policy array; throws when one is present but unusable, so a caller can log why
 	 * before falling back.
 	 *
-	 * @throws DomainException If the stored policy does not parse or has no usable cadence.
+	 * @throws InvalidInputException If the stored policy does not parse or has no usable cadence.
 	 */
 	public function read_billing_policy(): ?BillingPolicy {
 		$policy = $this->data['billing_policy'] ?? null;
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
index 4099cf2da24..77a13162c1a 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Ownership/ConsumerRegistry.php
@@ -18,7 +18,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Ownership;

 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Dispatch\DueDispatcher;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentFilter;

 defined( 'ABSPATH' ) || exit;

@@ -72,7 +72,7 @@ final class ConsumerRegistry {
 			return;
 		}

-		$filtered_config = ArgumentValidator::filter_known_keys( __METHOD__, $config, self::CONFIG_KEYS );
+		$filtered_config = ArgumentFilter::filter_known_keys( __METHOD__, $config, self::CONFIG_KEYS );
 		$registration    = array();

 		if ( array_key_exists( 'contract_due_handler', $filtered_config ) ) {
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
index c6e8f66ba87..04e2f66a0de 100644
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/ContractRepository.php
@@ -32,6 +32,8 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;

@@ -1732,7 +1734,7 @@ final class ContractRepository {
 	 * @param array<string, mixed> $changes     Optional `order_id` (?int), `reason` (?string); present keys are written, null clears.
 	 * @param ContractLock         $lock        The caller's lock on the cycle's contract.
 	 * @return bool True when this caller won the transition (for a same-status call: the cycle holds the status and the values).
-	 * @throws \DomainException If `$to_status` is not a registered cycle status.
+	 * @throws InvalidInputException If `$to_status` is not a registered cycle status.
 	 * @throws \InvalidArgumentException If the cycle does not exist, or belongs to another contract than `$lock`.
 	 * @throws ContractLockedException If `$lock` was lost.
 	 * @throws \RuntimeException If the write fails.
@@ -1741,7 +1743,13 @@ final class ContractRepository {
 		global $wpdb;

 		if ( ! CycleStatus::is_registered( $to_status ) ) {
-			throw new \DomainException( esc_html( sprintf( 'ContractRepository::transition_cycle(): cycle status "%s" is not registered.', $to_status ) ) );
+			$error = new InvalidInputException(
+				ValidationErrorCode::NOT_REGISTERED,
+				'to_status',
+				sprintf( 'ContractRepository::transition_cycle(): cycle status "%s" is not registered.', $to_status ),
+				array( 'value' => $to_status )
+			);
+			throw $error; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the message is not output; the facade escapes it.
 		}

 		// The contract match is in the UPDATE's WHERE clause: this method knows only the cycle id.
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentFilter.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentFilter.php
new file mode 100644
index 00000000000..57ed95c15ee
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentFilter.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * Unknown-key filtering shared by the public facades.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Support
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Support;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Drop unknown argument keys with a `_doing_it_wrong()` notice. Values are checked by
+ * {@see \Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ArgumentValidator}.
+ *
+ * @internal Engine implementation detail shared by the `Api\` facades, not part of the public API.
+ */
+final class ArgumentFilter {
+
+	/**
+	 * Static helper only.
+	 */
+	private function __construct() {}
+
+	/**
+	 * Keep the keys in `$allowed`; each other key raises a `_doing_it_wrong()` notice and is dropped.
+	 *
+	 * @param string                   $method  Public facade method, for the notice.
+	 * @param array<int|string, mixed> $args    Caller arguments.
+	 * @param array<string, true>      $allowed Accepted keys, as a key map.
+	 * @param string                   $what    What the keys belong to, for the notice.
+	 * @return array<string, mixed> The arguments with known keys only.
+	 */
+	public static function filter_known_keys( string $method, array $args, array $allowed, string $what = 'key' ): array {
+		$filtered = array();
+		foreach ( $args as $key => $value ) {
+			if ( isset( $allowed[ $key ] ) ) {
+				$filtered[ (string) $key ] = $value;
+				continue;
+			}
+
+			_doing_it_wrong(
+				esc_html( $method ),
+				sprintf( 'Unknown %s "%s" ignored.', esc_html( $what ), esc_html( (string) $key ) ),
+				'0.0.1'
+			);
+		}
+
+		return $filtered;
+	}
+
+	/**
+	 * Apply {@see self::filter_known_keys()} to each row of a collection, keeping the row keys.
+	 *
+	 * @template TKey of array-key
+	 * @param string                                $method  Public facade method, for the notice.
+	 * @param array<TKey, array<int|string, mixed>> $rows    Rows, shape already checked.
+	 * @param array<string, true>                   $allowed Accepted row keys, as a key map.
+	 * @param string                                $what    What the keys belong to, for the notice.
+	 * @return array<TKey, array<string, mixed>>
+	 */
+	public static function filter_known_keys_in_rows( string $method, array $rows, array $allowed, string $what = 'key' ): array {
+		$filtered = array();
+		foreach ( $rows as $index => $row ) {
+			$filtered[ $index ] = self::filter_known_keys( $method, $row, $allowed, $what );
+		}
+
+		return $filtered;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
deleted file mode 100644
index 22b17374ad5..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/src/Integration/Support/ArgumentValidator.php
+++ /dev/null
@@ -1,354 +0,0 @@
-<?php
-/**
- * Argument validators shared by the public facades.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Support
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Support;
-
-use DateTimeImmutable;
-use DateTimeInterface;
-use DateTimeZone;
-use InvalidArgumentException;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\Coercion;
-use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\MoneyScale;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Validate caller argument values and return them normalized.
- *
- * @internal Engine implementation detail shared by the `Api\` facades, not part of the public API.
- */
-final class ArgumentValidator {
-
-	/**
-	 * Keep the keys in `$allowed`; each other key raises a `_doing_it_wrong()` notice and is dropped.
-	 *
-	 * @param string                   $method  Public facade method, for the notice.
-	 * @param array<int|string, mixed> $args    Caller arguments.
-	 * @param array<string, true>      $allowed Accepted keys, as a key map.
-	 * @param string                   $what    What the keys belong to, for the notice.
-	 * @return array<string, mixed> The arguments with known keys only.
-	 */
-	public static function filter_known_keys( string $method, array $args, array $allowed, string $what = 'key' ): array {
-		$filtered = array();
-		foreach ( $args as $key => $value ) {
-			if ( isset( $allowed[ $key ] ) ) {
-				$filtered[ (string) $key ] = $value;
-				continue;
-			}
-
-			_doing_it_wrong(
-				esc_html( $method ),
-				sprintf( 'Unknown %s "%s" ignored.', esc_html( $what ), esc_html( (string) $key ) ),
-				'0.0.1'
-			);
-		}
-
-		return $filtered;
-	}
-
-	/**
-	 * Validate and return a currency code, or null.
-	 *
-	 * @param mixed $value Caller value.
-	 * @throws InvalidArgumentException If the value is not null or a three-letter uppercase code.
-	 */
-	public static function validate_currency( $value ): ?string {
-		if ( null !== $value && ( ! is_string( $value ) || 1 !== preg_match( '/^[A-Z]{3}\z/', $value ) ) ) {
-			throw new InvalidArgumentException( '"currency" must be null or a three-letter uppercase ISO-4217 code.' );
-		}
-
-		return $value;
-	}
-
-	/**
-	 * Validate and return a string.
-	 *
-	 * @param string $key   Field name.
-	 * @param mixed  $value Caller value.
-	 * @throws InvalidArgumentException If the value is not a string.
-	 */
-	public static function validate_string( string $key, $value ): string {
-		if ( ! is_string( $value ) ) {
-			throw new InvalidArgumentException( sprintf( '"%s" must be a string.', esc_html( $key ) ) );
-		}
-
-		return $value;
-	}
-
-	/**
-	 * Validate and return a non-empty string.
-	 *
-	 * @param string $key   Field name.
-	 * @param mixed  $value Caller value.
-	 * @throws InvalidArgumentException If the value is not a non-empty string.
-	 */
-	public static function validate_non_empty_string( string $key, $value ): string {
-		if ( ! is_string( $value ) || '' === $value ) {
-			throw new InvalidArgumentException( sprintf( '"%s" must be a non-empty string.', esc_html( $key ) ) );
-		}
-
-		return $value;
-	}
-
-	/**
-	 * Validate and return a string, or null.
-	 *
-	 * @param string $key   Field name.
-	 * @param mixed  $value Caller value.
-	 * @throws InvalidArgumentException If the value is not null or a string.
-	 */
-	public static function validate_nullable_string( string $key, $value ): ?string {
-		if ( null !== $value && ! is_string( $value ) ) {
-			throw new InvalidArgumentException( sprintf( '"%s" must be null or a string.', esc_html( $key ) ) );
-		}
-
-		return $value;
-	}
-
-	/**
-	 * Validate and return a positive integer id (a digit string is cast), or null.
-	 *
-	 * @param string $key   Field name.
-	 * @param mixed  $value Caller value.
-	 * @throws InvalidArgumentException If the value is not null or a positive integer.
-	 */
-	public static function validate_nullable_id( string $key, $value ): ?int {
-		if ( null === $value ) {
-			return null;
-		}
-
-		$value = self::cast_digit_string( $value );
-		if ( ! is_int( $value ) || $value <= 0 ) {
-			throw new InvalidArgumentException( sprintf( '"%s" must be null or a positive integer.', esc_html( $key ) ) );
-		}
-
-		return $value;
-	}
-
-	/**
-	 * Validate and return a non-negative integer (a digit string is cast).
-	 *
-	 * @param string $key   Field name.
-	 * @param mixed  $value Caller value.
-	 * @throws InvalidArgumentException If the value is not a non-negative integer.
-	 */
-	public static function validate_non_negative_int( string $key, $value ): int {
-		$value = self::cast_digit_string( $value );
-		if ( ! is_int( $value ) || $value < 0 ) {
-			throw new InvalidArgumentException( sprintf( '"%s" must be a non-negative integer.', esc_html( $key ) ) );
-		}
-
-		return $value;
-	}
-
-	/**
-	 * Validate a list of positive integer ids (digit strings are cast) and return it.
-	 *
-	 * @param string $key   Field name.
-	 * @param mixed  $value Caller value.
-	 * @return array<int, int>
-	 * @throws InvalidArgumentException If the value is not a list of positive integers.
-	 */
-	public static function validate_id_list( string $key, $value ): array {
-		if ( ! self::is_list( $value ) ) {
-			throw new InvalidArgumentException( sprintf( '"%s" must be a list of positive integers.', esc_html( $key ) ) );
-		}
-
-		$ids = array();
-		foreach ( $value as $id ) {
-			$id = self::cast_digit_string( $id );
-			if ( ! is_int( $id ) || $id <= 0 ) {
-				throw new InvalidArgumentException( sprintf( '"%s" must be a list of positive integers.', esc_html( $key ) ) );
-			}
-			$ids[] = $id;
-		}
-
-		return $ids;
-	}
-
-	/**
-	 * Validate a non-empty string or a list of them and return it as a list.
-	 *
-	 * @param string $key   Field name.
-	 * @param mixed  $value Caller value.
-	 * @return array<int, string>
-	 * @throws InvalidArgumentException If the value is not a non-empty string or a list of them.
-	 */
-	public static function validate_string_list( string $key, $value ): array {
-		$values = is_string( $value ) ? array( $value ) : $value;
-		if ( ! self::is_list( $values ) ) {
-			throw new InvalidArgumentException( sprintf( '"%s" must be a non-empty string or a list of them.', esc_html( $key ) ) );
-		}
-
-		$strings = array();
-		foreach ( $values as $item ) {
-			if ( ! is_string( $item ) || '' === $item ) {
-				throw new InvalidArgumentException( sprintf( '"%s" must be a non-empty string or a list of them.', esc_html( $key ) ) );
-			}
-			$strings[] = $item;
-		}
-
-		return $strings;
-	}
-
-	/**
-	 * Validate and return an array, or null.
-	 *
-	 * @param string $key   Field name.
-	 * @param mixed  $value Caller value.
-	 * @return array<int|string, mixed>|null
-	 * @throws InvalidArgumentException If the value is not null or an array.
-	 */
-	public static function validate_nullable_array( string $key, $value ): ?array {
-		if ( null !== $value && ! is_array( $value ) ) {
-			throw new InvalidArgumentException( sprintf( '"%s" must be null or an array.', esc_html( $key ) ) );
-		}
-
-		return $value;
-	}
-
-	/**
-	 * Validate a GMT datetime, or null, and return it as a UTC `Y-m-d H:i:s` string.
-	 *
-	 * @param string $key   Field name.
-	 * @param mixed  $value `DateTimeInterface`, GMT `Y-m-d H:i:s` string, or null.
-	 * @throws InvalidArgumentException If the value is not a valid datetime.
-	 */
-	public static function validate_nullable_date( string $key, $value ): ?string {
-		if ( null === $value ) {
-			return null;
-		}
-
-		if ( $value instanceof DateTimeInterface ) {
-			return ( new DateTimeImmutable( '@' . $value->getTimestamp() ) )->format( 'Y-m-d H:i:s' );
-		}
-
-		if ( is_string( $value ) ) {
-			$parsed = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $value, new DateTimeZone( 'UTC' ) );
-			if ( false !== $parsed && $parsed->format( 'Y-m-d H:i:s' ) === $value ) {
-				return $value;
-			}
-		}
-
-		throw new InvalidArgumentException( sprintf( '"%s" must be null, a DateTimeInterface, or a GMT "Y-m-d H:i:s" string.', esc_html( $key ) ) );
-	}
-
-	/**
-	 * Validate a money value and return it normalized to the storage scale; null is 0.
-	 *
-	 * @param string $key   Field name.
-	 * @param mixed  $value Number, numeric string, or null.
-	 * @throws InvalidArgumentException If the value is not numeric.
-	 */
-	public static function validate_money( string $key, $value ): string {
-		if ( null === $value ) {
-			return MoneyScale::normalize_money( 0 );
-		}
-
-		if ( ! is_int( $value ) && ! is_float( $value ) && ! ( is_string( $value ) && is_numeric( $value ) ) ) {
-			throw new InvalidArgumentException( sprintf( '"%s" must be a number or a numeric string.', esc_html( $key ) ) );
-		}
-
-		return MoneyScale::normalize_money( $value );
-	}
-
-	/**
-	 * Validate a list of arrays and return it with string-keyed rows.
-	 *
-	 * @param string $key   Field name.
-	 * @param mixed  $value Caller value.
-	 * @return array<int, array<string, mixed>>
-	 * @throws InvalidArgumentException If the value is not a list of arrays.
-	 */
-	public static function validate_list_of_arrays( string $key, $value ): array {
-		if ( ! self::is_list( $value ) ) {
-			throw new InvalidArgumentException( sprintf( '"%s" must be a list of arrays.', esc_html( $key ) ) );
-		}
-
-		$rows = array();
-		foreach ( $value as $row ) {
-			if ( ! is_array( $row ) ) {
-				throw new InvalidArgumentException( sprintf( '"%s" must be a list of arrays.', esc_html( $key ) ) );
-			}
-			$rows[] = Coercion::coerce_string_keyed( $row );
-		}
-
-		return $rows;
-	}
-
-	/**
-	 * Validate contract item rows; unknown row keys are dropped with a notice.
-	 *
-	 * @param mixed  $value         Caller value.
-	 * @param string $function_name Function named in the notice; defaults to this method.
-	 * @return array<int, array<string, mixed>>
-	 * @throws InvalidArgumentException If the value is not a list of item rows.
-	 */
-	public static function validate_contract_items( $value, string $function_name = __METHOD__ ): array {
-		$allowed = array_fill_keys( Contract::ITEM_FIELDS, true );
-		$rows    = array();
-		foreach ( self::validate_list_of_arrays( 'items', $value ) as $row ) {
-			$rows[] = self::filter_known_keys( $function_name, $row, $allowed, 'item key' );
-		}
-
-		return $rows;
-	}
-
-	/**
-	 * Validate contract addresses keyed `billing` / `shipping`; unknown address keys are
-	 * dropped with a notice.
-	 *
-	 * @param mixed  $value         Caller value.
-	 * @param string $function_name Function named in the notice; defaults to this method.
-	 * @return array<string, array<string, mixed>>
-	 * @throws InvalidArgumentException If the map is not keyed `billing` / `shipping` with array values.
-	 */
-	public static function validate_contract_addresses( $value, string $function_name = __METHOD__ ): array {
-		if ( ! is_array( $value ) ) {
-			throw new InvalidArgumentException( '"addresses" must be an array keyed "billing" / "shipping".' );
-		}
-
-		$allowed   = array_fill_keys( Contract::ADDRESS_FIELDS, true );
-		$addresses = array();
-		foreach ( $value as $type => $address ) {
-			if ( ! in_array( $type, array( Contract::ADDRESS_BILLING, Contract::ADDRESS_SHIPPING ), true ) || ! is_array( $address ) ) {
-				throw new InvalidArgumentException( '"addresses" must be an array keyed "billing" / "shipping" with array values.' );
-			}
-
-			$addresses[ $type ] = self::filter_known_keys( $function_name, $address, $allowed, 'address key' );
-		}
-
-		return $addresses;
-	}
-
-	/**
-	 * Whether a value is a list: an array with consecutive int keys from 0 (an empty array is one).
-	 *
-	 * @param mixed $value Caller value.
-	 * @phpstan-assert-if-true array<int, mixed> $value
-	 */
-	private static function is_list( $value ): bool {
-		return is_array( $value ) && ( array() === $value || array_keys( $value ) === range( 0, count( $value ) - 1 ) );
-	}
-
-	/**
-	 * Cast a digit string to an int; any other value is returned unchanged.
-	 *
-	 * @param mixed $value Caller value.
-	 * @return mixed
-	 */
-	private static function cast_digit_string( $value ) {
-		if ( is_string( $value ) && ctype_digit( $value ) ) {
-			return (int) $value;
-		}
-
-		return $value;
-	}
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
index 835bf86db46..e37d94c67b0 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ContractsTest.php
@@ -11,12 +11,12 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;

 use DateTimeImmutable;
 use DateTimeZone;
-use DomainException;
 use EngineIntegrationTestCase;
 use InvalidArgumentException;
 use WC_Order;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\ContractLockedException;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ValidationException;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\View\ContractView;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\View\CycleView;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
@@ -24,14 +24,19 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\ContractRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\AssertsValidationErrors;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Contracts
  */
 class ContractsTest extends EngineIntegrationTestCase {

+	use AssertsValidationErrors;
+
 	private const EXTENSION_SLUG = 'acme-subs';

 	/**
@@ -319,13 +324,260 @@ class ContractsTest extends EngineIntegrationTestCase {
 					'status'         => 'nonsense',
 				)
 			);
-			$this->fail( 'Expected an InvalidArgumentException.' );
-		} catch ( InvalidArgumentException $e ) {
-			$this->assertInstanceOf( DomainException::class, $e->getPrevious() );
-			$this->assertSame( $e->getPrevious()->getMessage(), $e->getMessage() );
+			$this->fail( 'Expected a ValidationException.' );
+		} catch ( ValidationException $e ) {
+			$this->assertInstanceOf( InvalidInputException::class, $e->getPrevious() );
+			$this->assertSame( esc_html( $e->getPrevious()->getMessage() ), $e->getMessage() );
+			$this->assertSame( array( ValidationErrorCode::NOT_REGISTERED ), $e->get_errors()->get_error_codes() );
 		}
 	}

+	/**
+	 * @testdox Contract writes report the error code and field.
+	 */
+	public function test_contract_writes_report_the_error_code_and_field(): void {
+		$this->assert_validation_errors(
+			static function (): void {
+				Contracts::create(
+					array(
+						'extension_slug' => self::EXTENSION_SLUG,
+						'currency'       => 'usd',
+					)
+				);
+			},
+			array( array( ValidationErrorCode::INVALID_VALUE, 'currency' ) )
+		);
+		$this->assert_validation_errors(
+			static function (): void {
+				Contracts::create(
+					array(
+						'extension_slug' => self::EXTENSION_SLUG,
+						'billing_total'  => '10',
+					)
+				);
+			},
+			array( array( ValidationErrorCode::REQUIRED, 'currency' ) )
+		);
+		$this->assert_validation_errors(
+			static function (): void {
+				Contracts::create(
+					array(
+						'extension_slug'  => self::EXTENSION_SLUG,
+						'schedule_source' => 'Not a slug',
+					)
+				);
+			},
+			array( array( ValidationErrorCode::INVALID_VALUE, 'schedule_source' ) )
+		);
+
+		$id = $this->contract_with_currency();
+		$this->assert_validation_errors(
+			static function () use ( $id ): void {
+				Contracts::update( $id, array( 'addresses' => array( 'home' => array( 'first_name' => 'Ada' ) ) ) );
+			},
+			array( array( ValidationErrorCode::NOT_IN_ENUM, 'addresses.home' ) )
+		);
+	}
+
+	/**
+	 * @testdox Cycle writes and reads report the error code and field.
+	 */
+	public function test_cycle_writes_report_the_error_code_and_field(): void {
+		$id    = $this->contract_with_currency();
+		$cycle = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );
+
+		$this->assert_validation_errors(
+			function () use ( $id ): void {
+				$this->add_cycle_locked( $id, array( 'currency' => 'USD' ) );
+			},
+			array(
+				array( ValidationErrorCode::REQUIRED, 'starts_at_gmt' ),
+				array( ValidationErrorCode::REQUIRED, 'ends_at_gmt' ),
+			)
+		);
+		$this->assert_validation_errors(
+			function () use ( $cycle ): void {
+				$this->transition_cycle_locked(
+					$cycle->get_id(),
+					array(
+						'from_status' => CycleStatus::PENDING,
+						'to_status'   => 'never-registered',
+					)
+				);
+			},
+			array( array( ValidationErrorCode::NOT_REGISTERED, 'to_status' ) )
+		);
+		$this->assert_validation_errors(
+			static function () use ( $id ): void {
+				Contracts::get_cycles( $id, -1 );
+			},
+			array( array( ValidationErrorCode::OUT_OF_BOUNDS, 'limit' ) )
+		);
+	}
+
+	/**
+	 * @testdox create reports every ill-typed argument at once and writes nothing.
+	 */
+	public function test_create_reports_every_ill_typed_argument(): void {
+		$before = Contracts::count();
+
+		$this->assert_validation_errors(
+			static function (): void {
+				Contracts::create(
+					array(
+						'extension_slug' => self::EXTENSION_SLUG,
+						'currency'       => 'usd',
+						'customer_id'    => 'x',
+						'addresses'      => array( 'home' => array( 'first_name' => 'Ada' ) ),
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::INVALID_TYPE, 'customer_id', array( 'type' => 'integer' ) ),
+				array( ValidationErrorCode::INVALID_VALUE, 'currency', array() ),
+				array( ValidationErrorCode::NOT_IN_ENUM, 'addresses.home', array( 'allowed' => array( 'billing', 'shipping' ) ) ),
+			)
+		);
+		$this->assertSame( $before, Contracts::count() );
+	}
+
+	/**
+	 * @testdox create reports every contract refusal at once.
+	 */
+	public function test_create_reports_every_contract_refusal(): void {
+		$this->assert_validation_errors(
+			static function (): void {
+				Contracts::create(
+					array(
+						'extension_slug'  => self::EXTENSION_SLUG,
+						'creation_key'    => str_repeat( 'k', 101 ),
+						'status'          => 'never-registered',
+						'schedule_source' => 'Not a slug',
+						'billing_total'   => '10',
+					)
+				);
+			},
+			array(
+				// The facade error groups messages by code, so both `invalid_value` errors come first.
+				array( ValidationErrorCode::INVALID_VALUE, 'creation_key' ),
+				array( ValidationErrorCode::INVALID_VALUE, 'schedule_source' ),
+				array( ValidationErrorCode::NOT_REGISTERED, 'status' ),
+				array( ValidationErrorCode::REQUIRED, 'currency' ),
+			)
+		);
+	}
+
+	/**
+	 * @testdox update reports every contract refusal at once and writes nothing.
+	 */
+	public function test_update_reports_every_contract_refusal(): void {
+		$id     = $this->contract_with_currency();
+		$before = $this->entity( $id )->to_storage();
+
+		$this->assert_validation_errors(
+			static function () use ( $id ): void {
+				Contracts::update(
+					$id,
+					array(
+						'status'          => 'never-registered',
+						'schedule_source' => 'Not a slug',
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::NOT_REGISTERED, 'status' ),
+				array( ValidationErrorCode::INVALID_VALUE, 'schedule_source' ),
+			)
+		);
+		$this->assertSame( $before, $this->entity( $id )->to_storage() );
+	}
+
+	/**
+	 * @testdox update checks its arguments before reading, so invalid arguments throw for a missing contract.
+	 */
+	public function test_update_checks_its_arguments_before_reading(): void {
+		$this->assert_validation_errors(
+			static function (): void {
+				Contracts::update( 999999, array( 'customer_id' => 0 ) );
+			},
+			array( array( ValidationErrorCode::OUT_OF_BOUNDS, 'customer_id' ) )
+		);
+		$this->assertNull( Contracts::update( 999999, array( 'customer_id' => 5 ) ) );
+	}
+
+	/**
+	 * @testdox add_cycle reports every refusal at once: ill-typed arguments before the cycle is built.
+	 */
+	public function test_add_cycle_reports_every_refusal(): void {
+		$id = $this->contract_with_currency();
+
+		$this->assert_validation_errors(
+			function () use ( $id ): void {
+				$this->add_cycle_locked( $id, array( 'kind' => '' ) );
+			},
+			array(
+				array( ValidationErrorCode::REQUIRED, 'kind' ),
+				array( ValidationErrorCode::REQUIRED, 'starts_at_gmt' ),
+				array( ValidationErrorCode::REQUIRED, 'ends_at_gmt' ),
+				array( ValidationErrorCode::REQUIRED, 'currency' ),
+			)
+		);
+		$this->assert_validation_errors(
+			function () use ( $id ): void {
+				$this->add_cycle_locked(
+					$id,
+					array(
+						'count'         => 0,
+						'starts_at_gmt' => 'soon',
+						'ends_at_gmt'   => 'later',
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'count' ),
+				array( ValidationErrorCode::INVALID_VALUE, 'starts_at_gmt' ),
+				array( ValidationErrorCode::INVALID_VALUE, 'ends_at_gmt' ),
+			)
+		);
+		$this->assertSame( 0, $this->stored_cycle_count( $id ) );
+	}
+
+	/**
+	 * @testdox transition_cycle and get_cycles report every invalid value at once.
+	 */
+	public function test_cycle_tools_report_every_invalid_value(): void {
+		$id    = $this->contract_with_currency();
+		$cycle = $this->add_cycle( $id, $this->cycle_args( array( 'status' => CycleStatus::PENDING ) ) );
+
+		$this->assert_validation_errors(
+			function () use ( $cycle ): void {
+				$this->transition_cycle_locked(
+					$cycle->get_id(),
+					array(
+						'from_status' => '',
+						'to_status'   => CycleStatus::BILLED,
+						'order_id'    => 'x',
+						'reason'      => 5,
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::REQUIRED, 'from_status' ),
+				array( ValidationErrorCode::INVALID_TYPE, 'order_id' ),
+				array( ValidationErrorCode::INVALID_TYPE, 'reason' ),
+			)
+		);
+		$this->assert_validation_errors(
+			static function () use ( $id ): void {
+				Contracts::get_cycles( $id, -1, -2 );
+			},
+			array(
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'limit', array( 'min' => 0 ) ),
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'offset', array( 'min' => 0 ) ),
+			)
+		);
+	}
+
 	/**
 	 * @dataProvider provide_invalid_fields
 	 *
@@ -1477,11 +1729,17 @@ class ContractsTest extends EngineIntegrationTestCase {
 		$this->assertNotNull( $created );
 		$this->assertSame( 'create', $created['lock']->get_operation() );

-		foreach ( array( 'Checkout', 5 ) as $operation ) {
+		// A non-string label is an invalid argument; a malformed string is refused by the lock.
+		$labels = array(
+			ValidationException::class      => 5,
+			InvalidArgumentException::class => 'Checkout',
+		);
+		foreach ( $labels as $expected => $operation ) {
 			try {
 				Contracts::create( array( 'extension_slug' => self::EXTENSION_SLUG ), array( 'operation' => $operation ) );
 				$this->fail( 'Expected an invalid operation label to throw.' );
 			} catch ( InvalidArgumentException $e ) {
+				$this->assertSame( $expected, get_class( $e ) );
 				$this->assertStringContainsString( 'operation', $e->getMessage() );
 			}
 		}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PlansTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PlansTest.php
index c68a368c62d..2061cf8e354 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PlansTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/PlansTest.php
@@ -9,26 +9,30 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;

-use DomainException;
 use EngineIntegrationTestCase;
 use InvalidArgumentException;
 use RuntimeException;
 use WP_Error;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\Plans;
-use Automattic\WooCommerce\SubscriptionsEngine\Api\PlanValidationException;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ValidationException;
 use Automattic\WooCommerce\SubscriptionsEngine\Api\View\PlanView;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\PlanRepository;
 use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\SchemaInstaller;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\AssertsValidationErrors;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\Plans
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\PlanValidationException
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\ValidationException
  */
 class PlansTest extends EngineIntegrationTestCase {

+	use AssertsValidationErrors;
+
 	private const OWNER = 'acme-subs';

 	private const HOOK = 'woocommerce_subscriptions_engine_validate_plan';
@@ -144,37 +148,40 @@ class PlansTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @return array<string, array{0: array<string, mixed>}>
+	 * @return array<string, array{0: array<string, mixed>, 1: string, 2: string}>
 	 */
 	public function provide_invalid_create_args(): array {
 		return array(
-			'unregistered status' => array( array( 'status' => 'seasonal' ) ),
-			'non-string status'   => array( array( 'status' => 5 ) ),
-			'empty name'          => array( array( 'name' => '' ) ),
-			'whitespace name'     => array( array( 'name' => '   ' ) ),
-			'non-string name'     => array( array( 'name' => 12 ) ),
-			'list policy'         => array( array( 'pricing_policy' => array( 'a', 'b' ) ) ),
-			'scalar policy'       => array( array( 'billing_policy' => 'monthly' ) ),
-			'missing slug'        => array( array( 'extension_slug' => null ) ),
-			'empty slug'          => array( array( 'extension_slug' => '' ) ),
+			'unregistered status' => array( array( 'status' => 'seasonal' ), ValidationErrorCode::NOT_REGISTERED, 'status' ),
+			'non-string status'   => array( array( 'status' => 5 ), ValidationErrorCode::INVALID_TYPE, 'status' ),
+			'empty name'          => array( array( 'name' => '' ), ValidationErrorCode::REQUIRED, 'name' ),
+			'whitespace name'     => array( array( 'name' => '   ' ), ValidationErrorCode::REQUIRED, 'name' ),
+			'non-string name'     => array( array( 'name' => 12 ), ValidationErrorCode::INVALID_TYPE, 'name' ),
+			'list policy'         => array( array( 'pricing_policy' => array( 'a', 'b' ) ), ValidationErrorCode::INVALID_TYPE, 'pricing_policy' ),
+			'scalar policy'       => array( array( 'billing_policy' => 'monthly' ), ValidationErrorCode::INVALID_TYPE, 'billing_policy' ),
+			'missing slug'        => array( array( 'extension_slug' => null ), ValidationErrorCode::REQUIRED, 'extension_slug' ),
+			'empty slug'          => array( array( 'extension_slug' => '' ), ValidationErrorCode::REQUIRED, 'extension_slug' ),
+			'non-string slug'     => array( array( 'extension_slug' => 5 ), ValidationErrorCode::INVALID_TYPE, 'extension_slug' ),
 		);
 	}

 	/**
-	 * @testdox create rejects invalid args and stores nothing.
+	 * @testdox create rejects invalid args with the code and field, and stores nothing.
 	 * @dataProvider provide_invalid_create_args
 	 *
 	 * @param array<string, mixed> $overrides Invalid overrides.
+	 * @param string               $code      Expected error code.
+	 * @param string               $field     Expected field.
 	 */
-	public function test_create_rejects_invalid_args_and_stores_nothing( array $overrides ): void {
+	public function test_create_rejects_invalid_args_and_stores_nothing( array $overrides, string $code, string $field ): void {
 		$before = $this->plan_count();

-		try {
-			$this->create( $overrides );
-			$this->fail( 'Expected InvalidArgumentException.' );
-		} catch ( InvalidArgumentException $e ) {
-			$this->assertNotInstanceOf( PlanValidationException::class, $e );
-		}
+		$this->assert_validation_errors(
+			function () use ( $overrides ): void {
+				$this->create( $overrides );
+			},
+			array( array( $code, $field ) )
+		);

 		$this->assertSame( $before, $this->plan_count() );
 	}
@@ -183,35 +190,42 @@ class PlansTest extends EngineIntegrationTestCase {
 	 * @testdox an entity invariant failure is reported as invalid input, on create and on update.
 	 */
 	public function test_an_entity_invariant_failure_is_reported_as_invalid_input(): void {
-		try {
-			$this->create( array( 'status' => 'nonsense' ) );
-			$this->fail( 'Expected an InvalidArgumentException.' );
-		} catch ( InvalidArgumentException $e ) {
-			$this->assertInstanceOf( DomainException::class, $e->getPrevious() );
-			$this->assertSame( $e->getPrevious()->getMessage(), $e->getMessage() );
-		}
-
-		try {
-			Plans::update(
-				$this->create(),
-				array(
-					'extension_slug' => self::OWNER,
-					'name'           => ' ',
-				)
-			);
-			$this->fail( 'Expected an InvalidArgumentException.' );
-		} catch ( InvalidArgumentException $e ) {
-			$this->assertInstanceOf( DomainException::class, $e->getPrevious() );
-		}
+		$e = $this->assert_validation_errors(
+			function (): void {
+				$this->create( array( 'status' => 'nonsense' ) );
+			},
+			array( array( ValidationErrorCode::NOT_REGISTERED, 'status' ) )
+		);
+		$this->assertSame( '"nonsense" is not registered.', $e->get_errors()->get_error_message( ValidationErrorCode::NOT_REGISTERED ) );
+		$previous = $e->getPrevious();
+		$this->assertInstanceOf( InvalidInputException::class, $previous );
+		$this->assertSame( esc_html( $previous->getMessage() ), $e->getMessage(), 'The exception message stays the English Core message.' );
+
+		$e = $this->assert_validation_errors(
+			function (): void {
+				Plans::update(
+					$this->create(),
+					array(
+						'extension_slug' => self::OWNER,
+						'name'           => ' ',
+					)
+				);
+			},
+			array( array( ValidationErrorCode::REQUIRED, 'name' ) )
+		);
+		$this->assertSame( 'This field is required.', $e->get_errors()->get_error_message( ValidationErrorCode::REQUIRED ) );
 	}

 	/**
 	 * @testdox create requires a name.
 	 */
 	public function test_create_requires_a_name(): void {
-		$this->expectException( InvalidArgumentException::class );
-
-		Plans::create( array( 'extension_slug' => self::OWNER ) );
+		$this->assert_validation_errors(
+			static function (): void {
+				Plans::create( array( 'extension_slug' => self::OWNER ) );
+			},
+			array( array( ValidationErrorCode::REQUIRED, 'name' ) )
+		);
 	}

 	/**
@@ -458,28 +472,31 @@ class PlansTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @return array<string, array{0: array<string, mixed>}>
+	 * @return array<string, array{0: array<string, mixed>, 1: string}>
 	 */
 	public function provide_invalid_update_scopes(): array {
 		return array(
-			'missing slug'    => array( array( 'name' => 'Changed' ) ),
+			'missing slug'    => array( array( 'name' => 'Changed' ), ValidationErrorCode::REQUIRED ),
 			'null slug'       => array(
 				array(
 					'extension_slug' => null,
 					'name'           => 'Changed',
 				),
+				ValidationErrorCode::REQUIRED,
 			),
 			'empty slug'      => array(
 				array(
 					'extension_slug' => '',
 					'name'           => 'Changed',
 				),
+				ValidationErrorCode::REQUIRED,
 			),
 			'non-string slug' => array(
 				array(
 					'extension_slug' => 5,
 					'name'           => 'Changed',
 				),
+				ValidationErrorCode::INVALID_TYPE,
 			),
 		);
 	}
@@ -489,17 +506,18 @@ class PlansTest extends EngineIntegrationTestCase {
 	 * @dataProvider provide_invalid_update_scopes
 	 *
 	 * @param array<string, mixed> $args Update args without a valid extension slug.
+	 * @param string               $code Expected error code.
 	 */
-	public function test_update_without_a_valid_extension_slug_throws( array $args ): void {
+	public function test_update_without_a_valid_extension_slug_throws( array $args, string $code ): void {
 		$id     = $this->create();
 		$before = $this->stored( $id )->to_storage();

-		try {
-			Plans::update( $id, $args );
-			$this->fail( 'Expected InvalidArgumentException.' );
-		} catch ( InvalidArgumentException $e ) {
-			$this->assertNotInstanceOf( PlanValidationException::class, $e );
-		}
+		$this->assert_validation_errors(
+			static function () use ( $args, $id ): void {
+				Plans::update( $id, $args );
+			},
+			array( array( $code, 'extension_slug' ) )
+		);

 		$this->assertSame( $before, $this->stored( $id )->to_storage() );
 	}
@@ -554,22 +572,24 @@ class PlansTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @return array<string, array{0: array<string, mixed>}>
+	 * @return array<string, array{0: array<string, mixed>, 1: string, 2: string}>
 	 */
 	public function provide_invalid_update_args(): array {
 		return array(
-			'unregistered status' => array( array( 'status' => 'seasonal' ) ),
-			'non-string status'   => array( array( 'status' => 5 ) ),
-			'empty name'          => array( array( 'name' => '' ) ),
-			'whitespace name'     => array( array( 'name' => '   ' ) ),
-			'non-string name'     => array( array( 'name' => 12 ) ),
-			'list policy'         => array( array( 'pricing_policy' => array( 'a', 'b' ) ) ),
-			'scalar policy'       => array( array( 'billing_policy' => 'monthly' ) ),
+			'unregistered status' => array( array( 'status' => 'seasonal' ), ValidationErrorCode::NOT_REGISTERED, 'status' ),
+			'non-string status'   => array( array( 'status' => 5 ), ValidationErrorCode::INVALID_TYPE, 'status' ),
+			'empty name'          => array( array( 'name' => '' ), ValidationErrorCode::REQUIRED, 'name' ),
+			'whitespace name'     => array( array( 'name' => '   ' ), ValidationErrorCode::REQUIRED, 'name' ),
+			'non-string name'     => array( array( 'name' => 12 ), ValidationErrorCode::INVALID_TYPE, 'name' ),
+			'list policy'         => array( array( 'pricing_policy' => array( 'a', 'b' ) ), ValidationErrorCode::INVALID_TYPE, 'pricing_policy' ),
+			'scalar policy'       => array( array( 'billing_policy' => 'monthly' ), ValidationErrorCode::INVALID_TYPE, 'billing_policy' ),
 			'valid then invalid'  => array(
 				array(
 					'name'   => 'Changed',
 					'status' => 'seasonal',
 				),
+				ValidationErrorCode::NOT_REGISTERED,
+				'status',
 			),
 		);
 	}
@@ -578,18 +598,20 @@ class PlansTest extends EngineIntegrationTestCase {
 	 * @testdox update rejects invalid args and leaves the plan unchanged.
 	 * @dataProvider provide_invalid_update_args
 	 *
-	 * @param array<string, mixed> $args Invalid update args.
+	 * @param array<string, mixed> $args  Invalid update args.
+	 * @param string               $code  Expected error code.
+	 * @param string               $field Expected field.
 	 */
-	public function test_update_rejects_invalid_args_and_leaves_the_plan_unchanged( array $args ): void {
+	public function test_update_rejects_invalid_args_and_leaves_the_plan_unchanged( array $args, string $code, string $field ): void {
 		$id     = $this->create();
 		$before = $this->stored( $id )->to_storage();

-		try {
-			Plans::update( $id, array( 'extension_slug' => self::OWNER ) + $args );
-			$this->fail( 'Expected InvalidArgumentException.' );
-		} catch ( InvalidArgumentException $e ) {
-			$this->assertNotInstanceOf( PlanValidationException::class, $e );
-		}
+		$this->assert_validation_errors(
+			static function () use ( $args, $id ): void {
+				Plans::update( $id, array( 'extension_slug' => self::OWNER ) + $args );
+			},
+			array( array( $code, $field ) )
+		);

 		$this->assertSame( $before, $this->stored( $id )->to_storage() );
 	}
@@ -763,11 +785,12 @@ class PlansTest extends EngineIntegrationTestCase {

 		try {
 			$this->create();
-			$this->fail( 'Expected PlanValidationException.' );
-		} catch ( PlanValidationException $e ) {
+			$this->fail( 'Expected ValidationException.' );
+		} catch ( ValidationException $e ) {
 			$this->assertSame( array( 'acme_bad_pricing', 'acme_bad_billing' ), $e->get_errors()->get_error_codes() );
 			$this->assertSame( array( 'status' => 400 ), $e->get_errors()->get_error_data( 'acme_bad_pricing' ) );
 			$this->assertSame( 'Pricing is wrong. Billing is wrong.', $e->getMessage() );
+			$this->assertNull( $e->getPrevious(), 'An extension error has no Core error behind it.' );
 		}

 		$this->assertSame( $before, $this->plan_count() );
@@ -793,8 +816,8 @@ class PlansTest extends EngineIntegrationTestCase {
 					'name'           => 'Refused',
 				)
 			);
-			$this->fail( 'Expected PlanValidationException.' );
-		} catch ( PlanValidationException $e ) {
+			$this->fail( 'Expected ValidationException.' );
+		} catch ( ValidationException $e ) {
 			$this->assertSame( array( 'acme_no' ), $e->get_errors()->get_error_codes() );
 		}

@@ -823,6 +846,163 @@ class PlansTest extends EngineIntegrationTestCase {
 		$this->assertSame( $before, $this->plan_count() );
 	}

+	/**
+	 * @testdox create reports every ill-typed argument at once and does not ask the extension.
+	 */
+	public function test_create_reports_every_ill_typed_argument_without_asking_the_extension(): void {
+		$calls = 0;
+		add_action(
+			self::HOOK,
+			static function () use ( &$calls ): void {
+				++$calls;
+			}
+		);
+
+		$this->assert_validation_errors(
+			function (): void {
+				$this->create(
+					array(
+						'name'           => 5,
+						'billing_policy' => 'monthly',
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::INVALID_TYPE, 'name', array( 'type' => 'string' ) ),
+				array( ValidationErrorCode::INVALID_TYPE, 'billing_policy', array( 'type' => 'object' ) ),
+			)
+		);
+		$this->assertSame( 0, $calls );
+	}
+
+	/**
+	 * @testdox create reports the plan's refusals and the extension's together, the plan's first.
+	 */
+	public function test_create_reports_the_plan_and_extension_refusals_together(): void {
+		$before = $this->plan_count();
+		$views  = array();
+		add_action(
+			self::HOOK,
+			static function ( WP_Error $errors, PlanView $plan ) use ( &$views ): void {
+				$views[] = $plan;
+				$errors->add( 'out_of_bounds', 'Must not exceed 100 for a percentage policy.', array( 'field' => 'pricing_policy.policies.0.value' ) );
+			},
+			10,
+			2
+		);
+
+		$e = $this->assert_validation_errors(
+			function (): void {
+				$this->create(
+					array(
+						'name'   => '',
+						'status' => 'never-registered',
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::REQUIRED, 'name' ),
+				array( ValidationErrorCode::NOT_REGISTERED, 'status' ),
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'pricing_policy.policies.0.value' ),
+			)
+		);
+
+		$this->assertCount( 1, $views );
+		$this->assertSame( '', $views[0]->get_name() );
+		$this->assertSame( 'never-registered', $views[0]->get_status() );
+		$this->assertInstanceOf( InvalidInputException::class, $e->getPrevious() );
+		$this->assertStringContainsString( 'Must not exceed 100 for a percentage policy.', $e->getMessage() );
+		$this->assertSame( $before, $this->plan_count() );
+	}
+
+	/**
+	 * @testdox update asks the extension about the would-be plan when the plan refuses, and writes nothing.
+	 */
+	public function test_update_asks_the_extension_about_the_refused_would_be_plan(): void {
+		$id     = $this->create( array( 'pricing_policy' => array( 'policies' => array() ) ) );
+		$before = $this->stored( $id )->to_storage();
+		$views  = array();
+		add_action(
+			self::HOOK,
+			static function ( WP_Error $errors, PlanView $plan ) use ( &$views ): void {
+				$views[] = $plan;
+				$errors->add( 'acme_refused', 'Refused.', array( 'field' => 'pricing_policy' ) );
+			},
+			10,
+			2
+		);
+
+		$this->assert_validation_errors(
+			static function () use ( $id ): void {
+				Plans::update(
+					$id,
+					array(
+						'extension_slug' => self::OWNER,
+						'name'           => '',
+						'pricing_policy' => array( 'policies' => array( 'x' ) ),
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::REQUIRED, 'name' ),
+				array( 'acme_refused', 'pricing_policy' ),
+			)
+		);
+
+		$this->assertCount( 1, $views );
+		$this->assertSame( $id, $views[0]->get_id() );
+		$this->assertSame( '', $views[0]->get_name() );
+		$this->assertSame( array( 'policies' => array( 'x' ) ), $views[0]->get_pricing_policy() );
+		$this->assertSame( $before['billing_policy'], $views[0]->get_billing_policy() );
+		$this->assertSame( $before, $this->stored( $id )->to_storage() );
+	}
+
+	/**
+	 * @testdox update checks its arguments before reading, so ill-typed arguments throw for a missing plan.
+	 */
+	public function test_update_checks_its_arguments_before_reading(): void {
+		$this->assert_validation_errors(
+			static function (): void {
+				Plans::update(
+					999999,
+					array(
+						'extension_slug' => self::OWNER,
+						'name'           => 5,
+					)
+				);
+			},
+			array( array( ValidationErrorCode::INVALID_TYPE, 'name' ) )
+		);
+		$this->assertNull(
+			Plans::update(
+				999999,
+				array(
+					'extension_slug' => self::OWNER,
+					'name'           => 'Fine',
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox a throwing callback is a runtime exception even when the plan itself is refused.
+	 */
+	public function test_a_throwing_callback_wins_over_a_plan_refusal(): void {
+		add_action(
+			self::HOOK,
+			static function (): void {
+				throw new \LogicException( 'boom' );
+			}
+		);
+
+		try {
+			$this->create( array( 'name' => '' ) );
+			$this->fail( 'Expected RuntimeException.' );
+		} catch ( RuntimeException $e ) {
+			$this->assertInstanceOf( \LogicException::class, $e->getPrevious() );
+		}
+	}
+
 	/**
 	 * @testdox a failed insert throws a runtime exception without a previous exception.
 	 */
@@ -1086,27 +1266,60 @@ class PlansTest extends EngineIntegrationTestCase {
 	 * @testdox list rejects an invalid value.
 	 * @dataProvider provide_invalid_list_args
 	 *
-	 * @param array<string, mixed> $args List args.
+	 * @param array<string, mixed> $args  List args.
+	 * @param string               $code  Expected error code.
+	 * @param string               $field Expected field.
 	 */
-	public function test_list_rejects_an_invalid_value( array $args ): void {
-		$this->expectException( InvalidArgumentException::class );
+	public function test_list_rejects_an_invalid_value( array $args, string $code, string $field ): void {
+		$this->assert_validation_errors(
+			static function () use ( $args ): void {
+				Plans::list( $args );
+			},
+			array( array( $code, $field ) )
+		);
+	}

-		Plans::list( $args );
+	/**
+	 * @testdox list reports every invalid value at once.
+	 */
+	public function test_list_reports_every_invalid_value(): void {
+		$this->assert_validation_errors(
+			static function (): void {
+				Plans::list(
+					array(
+						'limit'          => 0,
+						'offset'         => -1,
+						'extension_slug' => array( 'a', 'any' ),
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'limit', array( 'min' => 1 ) ),
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'offset', array( 'min' => 0 ) ),
+				array( ValidationErrorCode::INVALID_VALUE, 'extension_slug', array() ),
+			)
+		);
+		$this->assert_validation_errors(
+			static function (): void {
+				Plans::list( array( 'ids' => array( 1, 0 ) ) );
+			},
+			array( array( ValidationErrorCode::OUT_OF_BOUNDS, 'ids.1', array( 'min' => 1 ) ) )
+		);
 	}

 	/**
-	 * @return array<string, array{0: array<string, mixed>}>
+	 * @return array<string, array{0: array<string, mixed>, 1: string, 2: string}>
 	 */
 	public function provide_invalid_list_args(): array {
 		return array(
-			'empty extension slug' => array( array( 'extension_slug' => '' ) ),
-			'any extension slug'   => array( array( 'extension_slug' => 'any' ) ),
-			'any in a slug list'   => array( array( 'extension_slug' => array( self::OWNER, 'any' ) ) ),
-			'status not a string'  => array( array( 'status' => 5 ) ),
-			'id not positive'      => array( array( 'ids' => array( 1, 0 ) ) ),
-			'ids not a list'       => array( array( 'ids' => array( 'a' => 1 ) ) ),
-			'zero limit'           => array( array( 'limit' => 0 ) ),
-			'negative offset'      => array( array( 'offset' => -1 ) ),
+			'empty extension slug' => array( array( 'extension_slug' => '' ), ValidationErrorCode::REQUIRED, 'extension_slug' ),
+			'any extension slug'   => array( array( 'extension_slug' => 'any' ), ValidationErrorCode::INVALID_VALUE, 'extension_slug' ),
+			'any in a slug list'   => array( array( 'extension_slug' => array( self::OWNER, 'any' ) ), ValidationErrorCode::INVALID_VALUE, 'extension_slug' ),
+			'status not a string'  => array( array( 'status' => 5 ), ValidationErrorCode::INVALID_TYPE, 'status' ),
+			'id not positive'      => array( array( 'ids' => array( 0, 1 ) ), ValidationErrorCode::OUT_OF_BOUNDS, 'ids.0' ),
+			'ids not a list'       => array( array( 'ids' => array( 'a' => 1 ) ), ValidationErrorCode::INVALID_TYPE, 'ids' ),
+			'zero limit'           => array( array( 'limit' => 0 ), ValidationErrorCode::OUT_OF_BOUNDS, 'limit' ),
+			'negative offset'      => array( array( 'offset' => -1 ), ValidationErrorCode::OUT_OF_BOUNDS, 'offset' ),
 		);
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php
index ff41ae8a449..1ff3ac39196 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/Rest/PlansControllerTest.php
@@ -295,11 +295,9 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 	/**
 	 * @dataProvider provide_non_object_pricing_policies
 	 *
-	 * @param mixed  $invalid     Non-object pricing payload.
-	 * @param string $create_code Create error code: core schema validation rejects a scalar first.
-	 * @param string $patch_code  PATCH error code: the route has no arg schema, so the facade rejects.
+	 * @param mixed $invalid Non-object pricing payload.
 	 */
-	public function test_non_object_pricing_policy_is_rejected( $invalid, string $create_code, string $patch_code ): void {
+	public function test_non_object_pricing_policy_is_rejected( $invalid ): void {
 		wp_set_current_user( $this->admin_id );

 		$id = $this->create_plan( 'Patch target' );
@@ -317,10 +315,9 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 				'pricing_policy' => $invalid,
 			)
 		);
-		$this->assertSame( 400, $created->get_status() );
-		$this->assertSame( $create_code, $this->response_data( $created )['code'] );
+		$this->assert_invalid_param( $created, 'pricing_policy', 'invalid_type' );

-		$patched = $this->request(
+		$patched      = $this->request(
 			'PATCH',
 			self::BASE . '/' . $id,
 			array(
@@ -328,13 +325,8 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 				'pricing_policy' => $invalid,
 			)
 		);
-		$this->assertSame( 400, $patched->get_status() );
-		$patched_data = $this->response_data( $patched );
-		$this->assertSame( $patch_code, $patched_data['code'] );
-		$this->assertIsString( $patched_data['message'] );
-		if ( 'woocommerce_subscriptions_engine_invalid_plan' === $patch_code ) {
-			$this->assertStringContainsString( 'pricing_policy', $patched_data['message'], 'REST errors carry the facade message, which names the invalid field.' );
-		}
+		$patched_data = $this->assert_invalid_param( $patched, 'pricing_policy', 'invalid_type' );
+		$this->assertSame( 'pricing_policy: Must be of type object.', $this->nested( $patched_data, array( 'data', 'params', 'pricing_policy' ) ) );

 		// The rejected writes left the plan untouched and created nothing.
 		$fetched = $this->request( 'GET', self::BASE . '/' . $id, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) );
@@ -346,16 +338,17 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 	}

 	/**
-	 * @return array<string, array{0: mixed, 1: string, 2: string}>
+	 * @return array<string, array{0: mixed}>
 	 */
 	public function provide_non_object_pricing_policies(): array {
 		return array(
-			'list'   => array( array( array( 'type' => 'bogo' ) ), 'woocommerce_subscriptions_engine_invalid_plan', 'woocommerce_subscriptions_engine_invalid_plan' ),
-			'string' => array( 'bogo', 'rest_invalid_param', 'woocommerce_subscriptions_engine_invalid_plan' ),
+			'list'         => array( array( array( 'type' => 'bogo' ) ) ),
+			'string'       => array( 'bogo' ),
+			'empty string' => array( '' ),
 		);
 	}

-	public function test_update_rejects_an_empty_name_with_a_message_naming_the_field(): void {
+	public function test_an_empty_name_is_reported_per_field_on_create_and_update(): void {
 		wp_set_current_user( $this->admin_id );
 		$id = $this->create_plan( 'Monthly' );

@@ -367,12 +360,571 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 				'name'           => '',
 			)
 		);
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => '',
+			)
+		);
+
+		foreach ( array( $patched, $created ) as $response ) {
+			$data = $this->assert_invalid_param( $response, 'name', 'required' );
+			$this->assertSame( 'Invalid parameter(s): name', $data['message'] );
+			$this->assertSame( array( 'name' => 'name: This field is required.' ), $this->nested( $data, array( 'data', 'params' ) ) );
+			$this->assertEquals(
+				array(
+					'name' => array(
+						'code'    => 'required',
+						'message' => 'name: This field is required.',
+						'data'    => array(
+							'field'  => 'name',
+							'params' => (object) array(),
+						),
+					),
+				),
+				$this->nested( $data, array( 'data', 'details' ) )
+			);
+			$this->assertEquals(
+				array(
+					'name' => array(
+						array(
+							'code'    => 'required',
+							'message' => 'This field is required.',
+							'params'  => (object) array(),
+						),
+					),
+				),
+				$this->nested( $data, array( 'data', 'errors' ) )
+			);
+		}
+	}
+
+	public function test_empty_error_params_serialize_as_a_json_object(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => '',
+			)
+		);
+		$json    = (string) wp_json_encode( rest_get_server()->response_to_data( $created, false ) );
+
+		$this->assertStringContainsString( '"data":{"field":"name","params":{}}', $json );
+		$this->assertStringContainsString( '"errors":{"name":[{"code":"required","message":"This field is required.","params":{}}]}', $json );
+		$this->assertStringNotContainsString( '"params":[]', $json );
+	}
+
+	public function test_create_reports_an_unregistered_status_with_every_other_error(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => '',
+				'status'         => 'never-registered',
+			)
+		);
+
+		$data = $this->assert_invalid_param( $created, 'status', 'not_registered' );
+		$this->assertSame( 'required', $this->nested( $data, array( 'data', 'details', 'name', 'code' ) ) );
+		$this->assertSame(
+			array(
+				'name'   => 'name: This field is required.',
+				'status' => 'status: "never-registered" is not registered.',
+			),
+			$this->nested( $data, array( 'data', 'params' ) )
+		);
+		$this->assertSame( array( 'name', 'status' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+		$this->assertSame( 'required', $this->nested( $data, array( 'data', 'errors', 'name', 0, 'code' ) ) );
+		$this->assertSame( 'not_registered', $this->nested( $data, array( 'data', 'errors', 'status', 0, 'code' ) ) );
+	}
+
+	public function test_create_reports_every_field_type_error_together(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 5,
+				'status'         => 5,
+				'billing_policy' => 'x',
+			)
+		);
+
+		$data = $this->assert_invalid_param( $created, 'billing_policy', 'invalid_type' );
+		foreach ( array( 'name', 'status', 'billing_policy' ) as $field ) {
+			$this->assertIsString( $this->nested( $data, array( 'data', 'params', $field ) ) );
+			$this->assertSame( 'invalid_type', $this->nested( $data, array( 'data', 'details', $field, 'code' ) ) );
+			$this->assertSame( 'invalid_type', $this->nested( $data, array( 'data', 'errors', $field, 0, 'code' ) ) );
+		}
+		$this->assertEqualsCanonicalizing( array( 'name', 'status', 'billing_policy' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+		$this->assertSame( '0', $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => self::EXTENSION_SLUG ) )->get_headers()['X-WP-Total'] );
+	}
+
+	public function test_create_reports_every_field_invariant_error_together(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => '',
+				'status'         => 'never-registered',
+				'billing_policy' => array( 'x' ),
+			)
+		);
+
+		// A list passes the argument type check; the plan refuses it as a non-object policy.
+		$data = $this->assert_invalid_param( $created, 'billing_policy', 'invalid_type' );
+		$this->assertEqualsCanonicalizing( array( 'name', 'status', 'billing_policy' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'params' ) ) );
+		$this->assertSame( 'required', $this->nested( $data, array( 'data', 'details', 'name', 'code' ) ) );
+		$this->assertSame( 'not_registered', $this->nested( $data, array( 'data', 'details', 'status', 'code' ) ) );
+		$this->assertEqualsCanonicalizing( array( 'name', 'status', 'billing_policy' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+	}
+
+	public function test_a_non_string_extension_slug_is_reported_in_the_validation_shape(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => 5,
+				'name'           => 'Typed slug',
+			)
+		);
+
+		$data = $this->assert_invalid_param( $created, 'extension_slug', 'invalid_type' );
+		$this->assertSame( array( 'type' => 'string' ), $this->nested( $data, array( 'data', 'errors', 'extension_slug', 0, 'params' ) ) );
+	}
+
+	public function test_create_reports_a_non_string_status_through_the_facade(): void {
+		wp_set_current_user( $this->admin_id );
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Typed',
+				'status'         => 5,
+			)
+		);
+
+		$data = $this->assert_invalid_param( $created, 'status', 'invalid_type' );
+		$this->assertSame( array( 'type' => 'string' ), $this->nested( $data, array( 'data', 'errors', 'status', 0, 'params' ) ) );
+	}
+
+	public function test_the_per_field_message_is_translated(): void {
+		wp_set_current_user( $this->admin_id );
+		add_filter(
+			'gettext',
+			static function ( $translation, $text, $domain ) {
+				return 'woocommerce' === $domain && 'This field is required.' === $text ? 'Ce champ est obligatoire.' : $translation;
+			},
+			10,
+			3
+		);
+
+		$created = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => '',
+			)
+		);
+
+		$data = $this->assert_invalid_param( $created, 'name', 'required' );
+		$this->assertSame( 'name: Ce champ est obligatoire.', $this->nested( $data, array( 'data', 'params', 'name' ) ) );
+	}
+
+	public function test_a_callback_error_with_a_field_is_reported_under_its_top_level_arg(): void {
+		wp_set_current_user( $this->admin_id );
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors ): void {
+				$errors->add(
+					'out_of_bounds',
+					'Interval must be at least 1.',
+					array(
+						'field'  => 'billing_policy.interval',
+						'params' => array( 'min' => 1 ),
+					)
+				);
+			}
+		);
+
+		$data = $this->assert_invalid_param( $this->create_rejected_plan(), 'billing_policy', 'out_of_bounds' );
+
+		$this->assertSame( 'Invalid parameter(s): billing_policy', $data['message'] );
+		$this->assertSame( array( 'billing_policy' => 'billing_policy.interval: Interval must be at least 1.' ), $this->nested( $data, array( 'data', 'params' ) ) );
+		$this->assertSame(
+			array(
+				'code'    => 'out_of_bounds',
+				'message' => 'billing_policy.interval: Interval must be at least 1.',
+				'data'    => array(
+					'field'  => 'billing_policy.interval',
+					'params' => array( 'min' => 1 ),
+				),
+			),
+			$this->nested( $data, array( 'data', 'details', 'billing_policy' ) )
+		);
+	}
+
+	public function test_two_same_code_callback_errors_on_different_fields_are_both_reported(): void {
+		wp_set_current_user( $this->admin_id );
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors ): void {
+				$errors->add( 'invalid_value', 'Bad interval.', array( 'field' => 'billing_policy.interval' ) );
+				$errors->add( 'invalid_value', 'Bad amount.', array( 'field' => 'pricing_policy.one_time_fees.0.amount' ) );
+			}
+		);
+
+		$data = $this->assert_invalid_param( $this->create_rejected_plan(), 'billing_policy', 'invalid_value' );
+
+		$this->assertSame(
+			array(
+				'billing_policy' => 'billing_policy.interval: Bad interval.',
+				'pricing_policy' => 'pricing_policy.one_time_fees.0.amount: Bad amount.',
+			),
+			$this->nested( $data, array( 'data', 'params' ) )
+		);
+		$this->assertSame( 'pricing_policy.one_time_fees.0.amount', $this->nested( $data, array( 'data', 'details', 'pricing_policy', 'data', 'field' ) ) );
+	}
+
+	public function test_fielded_and_fieldless_callback_errors_return_one_400_with_both(): void {
+		wp_set_current_user( $this->admin_id );
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors ): void {
+				$errors->add( 'acme_general', 'Something is off.' );
+				$errors->add( 'invalid_value', 'Bad interval.', array( 'field' => 'billing_policy.interval' ) );
+			}
+		);
+
+		$data = $this->assert_invalid_param( $this->create_rejected_plan(), 'billing_policy', 'invalid_value' );
+
+		$this->assertSame( array( 'billing_policy' => 'billing_policy.interval: Bad interval.' ), $this->nested( $data, array( 'data', 'params' ) ) );
+		$this->assertSame(
+			array(
+				array(
+					'code'    => 'acme_general',
+					'message' => 'Something is off.',
+					'data'    => array( 'status' => 400 ),
+				),
+			),
+			$data['additional_errors']
+		);
+	}
+
+	public function test_errors_map_and_first_error_details_per_arg(): void {
+		wp_set_current_user( $this->admin_id );
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors ): void {
+				$errors->add(
+					'out_of_bounds',
+					'Must be less than or equal to 3.',
+					array(
+						'field'  => 'billing_policy.min_cycles',
+						'params' => array( 'max' => 3 ),
+					)
+				);
+				foreach ( array( 0, 1 ) as $index ) {
+					$errors->add(
+						'out_of_bounds',
+						'Must not exceed 100 for a percentage policy.',
+						array(
+							'field'  => 'pricing_policy.policies.' . $index . '.value',
+							'params' => array( 'max' => 100 ),
+						)
+					);
+				}
+			}
+		);
+
+		$data = $this->assert_invalid_param( $this->create_rejected_plan(), 'billing_policy', 'out_of_bounds' );
+
+		$this->assertSame( 'Invalid parameter(s): billing_policy, pricing_policy', $data['message'] );
+		$this->assertSame( 400, $this->nested( $data, array( 'data', 'status' ) ) );
+		$this->assertSame(
+			array(
+				'billing_policy' => 'billing_policy.min_cycles: Must be less than or equal to 3.',
+				'pricing_policy' => 'pricing_policy.policies.0.value: Must not exceed 100 for a percentage policy. pricing_policy.policies.1.value: Must not exceed 100 for a percentage policy.',
+			),
+			$this->nested( $data, array( 'data', 'params' ) )
+		);
+		$this->assertSame(
+			array(
+				'billing_policy' => array(
+					'code'    => 'out_of_bounds',
+					'message' => 'billing_policy.min_cycles: Must be less than or equal to 3.',
+					'data'    => array(
+						'field'  => 'billing_policy.min_cycles',
+						'params' => array( 'max' => 3 ),
+					),
+				),
+				'pricing_policy' => array(
+					'code'    => 'out_of_bounds',
+					'message' => 'pricing_policy.policies.0.value: Must not exceed 100 for a percentage policy.',
+					'data'    => array(
+						'field'  => 'pricing_policy.policies.0.value',
+						'params' => array( 'max' => 100 ),
+					),
+				),
+			),
+			$this->nested( $data, array( 'data', 'details' ) )
+		);
+		$this->assertSame(
+			array(
+				'billing_policy.min_cycles'       => array(
+					array(
+						'code'    => 'out_of_bounds',
+						'message' => 'Must be less than or equal to 3.',
+						'params'  => array( 'max' => 3 ),
+					),
+				),
+				'pricing_policy.policies.0.value' => array(
+					array(
+						'code'    => 'out_of_bounds',
+						'message' => 'Must not exceed 100 for a percentage policy.',
+						'params'  => array( 'max' => 100 ),
+					),
+				),
+				'pricing_policy.policies.1.value' => array(
+					array(
+						'code'    => 'out_of_bounds',
+						'message' => 'Must not exceed 100 for a percentage policy.',
+						'params'  => array( 'max' => 100 ),
+					),
+				),
+			),
+			$this->nested( $data, array( 'data', 'errors' ) )
+		);
+	}
+
+	public function test_details_carry_the_first_error_of_an_arg_with_its_own_data(): void {
+		wp_set_current_user( $this->admin_id );
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors ): void {
+				$errors->add( 'invalid_value', 'First.', array( 'field' => 'pricing_policy.policies.0.type' ) );
+				$errors->add( 'invalid_value', 'Second.', array( 'field' => 'pricing_policy.policies.1.type' ) );
+			}
+		);
+
+		$data = $this->assert_invalid_param( $this->create_rejected_plan(), 'pricing_policy', 'invalid_value' );
+
+		$this->assertSame( 'pricing_policy.policies.0.type: First.', $this->nested( $data, array( 'data', 'details', 'pricing_policy', 'message' ) ) );
+		$this->assertSame( array( 'field' => 'pricing_policy.policies.0.type' ), $this->nested( $data, array( 'data', 'details', 'pricing_policy', 'data' ) ) );
+		$this->assertSame( array( 'pricing_policy.policies.0.type', 'pricing_policy.policies.1.type' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+	}
+
+	public function test_a_fieldless_rest_invalid_param_error_never_replaces_the_response_data(): void {
+		wp_set_current_user( $this->admin_id );
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors ): void {
+				$errors->add( 'rest_invalid_param', 'Something general.', array( 'params' => array( 'acme' => 'Hijack.' ) ) );
+				$errors->add( 'invalid_value', 'Bad interval.', array( 'field' => 'billing_policy.interval' ) );
+			}
+		);
+
+		$data = $this->assert_invalid_param( $this->create_rejected_plan(), 'billing_policy', 'invalid_value' );
+
+		$this->assertSame( array( 'billing_policy' => 'billing_policy.interval: Bad interval.' ), $this->nested( $data, array( 'data', 'params' ) ) );
+		$this->assertSame( array( 'billing_policy' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'details' ) ) );
+		$this->assertSame( array( 'billing_policy.interval' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+
+		$additional = $this->array_value( $data, 'additional_errors' );
+		$this->assertCount( 1, $additional );
+		$this->assertSame( 'rest_invalid_param', $this->nested( $additional, array( 0, 'code' ) ) );
+		$this->assertSame( 'Something general.', $this->nested( $additional, array( 0, 'message' ) ) );
+	}
+
+	public function test_a_parent_and_a_child_field_are_separate_errors_under_one_arg(): void {
+		wp_set_current_user( $this->admin_id );
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors ): void {
+				$errors->add( 'out_of_bounds', 'Too high.', array( 'field' => 'pricing_policy.policies.0.value' ) );
+			}
+		);
+
+		$response = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Parent and child',
+				'pricing_policy' => array( array( 'type' => 'bogo' ) ),
+			)
+		);
+		$data     = $this->assert_invalid_param( $response, 'pricing_policy', 'invalid_type' );
+
+		$this->assertSame(
+			'pricing_policy: Must be of type object. pricing_policy.policies.0.value: Too high.',
+			$this->nested( $data, array( 'data', 'params', 'pricing_policy' ) )
+		);
+		$this->assertSame( array( 'pricing_policy', 'pricing_policy.policies.0.value' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+	}
+
+	public function test_engine_and_extension_errors_arrive_in_one_400(): void {
+		wp_set_current_user( $this->admin_id );
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors ): void {
+				$errors->add(
+					'out_of_bounds',
+					'Must not exceed 100 for a percentage policy.',
+					array(
+						'field'  => 'pricing_policy.policies.0.value',
+						'params' => array( 'max' => 100 ),
+					)
+				);
+			}
+		);
+
+		$response = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => '',
+				'pricing_policy' => array( 'policies' => array( array( 'value' => 150 ) ) ),
+			)
+		);
+		$data     = $this->assert_invalid_param( $response, 'name', 'required' );
+
+		$this->assertSame( 'Invalid parameter(s): name, pricing_policy', $data['message'] );
+		$this->assertSame(
+			array(
+				'name'           => 'name: This field is required.',
+				'pricing_policy' => 'pricing_policy.policies.0.value: Must not exceed 100 for a percentage policy.',
+			),
+			$this->nested( $data, array( 'data', 'params' ) )
+		);
+		$this->assertSame( array( 'name', 'pricing_policy.policies.0.value' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+	}
+
+	public function test_an_extension_error_without_data_under_an_engine_code_keeps_the_engine_field(): void {
+		wp_set_current_user( $this->admin_id );
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors ): void {
+				$errors->add( 'required', 'Something is missing.' );
+			}
+		);
+
+		$response = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => '',
+			)
+		);
+		$data     = $this->assert_invalid_param( $response, 'name', 'required' );
+
+		$this->assertSame( array( 'name' => 'name: This field is required.' ), $this->nested( $data, array( 'data', 'params' ) ) );
+		$this->assertSame( array( 'name' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+		$this->assertSame(
+			array(
+				array(
+					'code'    => 'required',
+					'message' => 'Something is missing.',
+					'data'    => array( 'status' => 400 ),
+				),
+			),
+			$data['additional_errors']
+		);
+	}
+
+	public function test_a_data_less_extension_error_before_a_fielded_one_makes_its_code_fieldless(): void {
+		wp_set_current_user( $this->admin_id );
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors ): void {
+				$errors->add( 'acme_invalid', 'A general problem.' );
+				$errors->add( 'acme_invalid', 'Bad interval.', array( 'field' => 'billing_policy.interval' ) );
+			}
+		);
+
+		$response = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Ambiguous',
+			)
+		);
+
+		$this->assertSame( 400, $response->get_status() );
+		$data = $this->response_data( $response );
+		$this->assertSame( 'acme_invalid', $data['code'] );
+		$this->assertSame( 'A general problem.', $data['message'] );
+		$this->assertSame( array( 'status' => 400 ), $data['data'], 'The field is not put on the wrong message.' );
+		$this->assertSame(
+			array(
+				array(
+					'code'    => 'acme_invalid',
+					'message' => 'Bad interval.',
+					'data'    => array( 'status' => 400 ),
+				),
+			),
+			$data['additional_errors']
+		);
+	}
+
+	public function test_a_data_less_extension_error_before_a_fielded_one_under_an_engine_code_keeps_the_engine_field(): void {
+		wp_set_current_user( $this->admin_id );
+		add_action(
+			'woocommerce_subscriptions_engine_validate_plan',
+			static function ( WP_Error $errors ): void {
+				$errors->add( 'required', 'Something is missing.' );
+				$errors->add( 'required', 'The interval is missing.', array( 'field' => 'billing_policy.interval' ) );
+			}
+		);
+
+		$response = $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => '',
+			)
+		);
+		$data     = $this->assert_invalid_param( $response, 'name', 'required' );

-		$this->assertSame( 400, $patched->get_status() );
-		$data = $this->response_data( $patched );
-		$this->assertSame( 'woocommerce_subscriptions_engine_invalid_plan', $data['code'] );
-		$this->assertIsString( $data['message'] );
-		$this->assertStringContainsString( 'name', $data['message'] );
+		$this->assertSame( array( 'name' => 'name: This field is required.' ), $this->nested( $data, array( 'data', 'params' ) ) );
+		$this->assertSame( array( 'name' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );
+		$this->assertSame(
+			array(
+				array(
+					'code'    => 'required',
+					'message' => 'Something is missing.',
+					'data'    => array( 'status' => 400 ),
+				),
+				array(
+					'code'    => 'required',
+					'message' => 'The interval is missing.',
+					'data'    => array( 'status' => 400 ),
+				),
+			),
+			$data['additional_errors']
+		);
 	}

 	public function test_validate_action_receives_errors_a_plan_view_and_slug(): void {
@@ -744,8 +1296,11 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 		foreach ( array( 'any,' . self::EXTENSION_SLUG, self::EXTENSION_SLUG . ',', ',' . self::EXTENSION_SLUG, 'lite,,test' ) as $extension_slug ) {
 			$list = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => $extension_slug ) );

-			$this->assertSame( 400, $list->get_status(), 'Failed for extension_slug=' . $extension_slug );
+			$this->assert_invalid_param( $list, 'extension_slug', 'invalid_value' );
 		}
+
+		$blank = $this->request( 'GET', self::BASE, array(), array( 'extension_slug' => ' ' ) );
+		$this->assert_invalid_param( $blank, 'extension_slug', 'required' );
 	}

 	public function test_list_with_any_extension_slug_returns_all_plans(): void {
@@ -839,7 +1394,32 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 				'status'         => 'never-registered',
 			)
 		);
-		$this->assertSame( 400, $unknown->get_status() );
+		$data    = $this->assert_invalid_param( $unknown, 'status', 'not_registered' );
+		$this->assertSame( 'status: "never-registered" is not registered.', $this->nested( $data, array( 'data', 'params', 'status' ) ) );
+		$this->assertSame(
+			array(
+				'status' => array(
+					array(
+						'code'    => 'not_registered',
+						'message' => '"never-registered" is not registered.',
+						'params'  => array( 'value' => 'never-registered' ),
+					),
+				),
+			),
+			$this->nested( $data, array( 'data', 'errors' ) )
+		);
+
+		$not_a_string = $this->request(
+			'GET',
+			self::BASE,
+			array(),
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'status'         => array( 'seasonal' ),
+			)
+		);
+		$data         = $this->assert_invalid_param( $not_a_string, 'status', 'invalid_type' );
+		$this->assertSame( array( 'status' ), array_keys( $this->array_value( $this->array_value( $data, 'data' ), 'errors' ) ) );

 		$bad_patch = $this->request(
 			'PATCH',
@@ -849,7 +1429,9 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 				'status'         => 'never-registered',
 			)
 		);
-		$this->assertSame( 400, $bad_patch->get_status() );
+		$data      = $this->assert_invalid_param( $bad_patch, 'status', 'not_registered' );
+		$this->assertSame( 'not_registered', $this->nested( $data, array( 'data', 'errors', 'status', 0, 'code' ) ) );
+		$this->assertSame( array( 'value' => 'never-registered' ), $this->nested( $data, array( 'data', 'errors', 'status', 0, 'params' ) ) );
 	}

 	public function test_single_plan_routes_reject_wildcard_and_list_extension_slugs(): void {
@@ -921,8 +1503,12 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 				)
 			);

-			$this->assertSame( 400, $response->get_status() );
+			$this->assert_invalid_param( $response, 'extension_slug', 'invalid_value' );
 		}
+
+		$missing = $this->request( 'POST', self::BASE, array( 'name' => 'No scope' ) );
+		$data    = $this->assert_invalid_param( $missing, 'extension_slug', 'required' );
+		$this->assertSame( 'extension_slug: This field is required.', $this->nested( $data, array( 'data', 'params', 'extension_slug' ) ) );
 	}

 	public function test_create_validates_the_status_and_keeps_the_callback_out_of_the_schema(): void {
@@ -937,8 +1523,8 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 				'status'         => 'never-registered',
 			)
 		);
-		$this->assertSame( 400, $created->get_status() );
-		$this->assertSame( 'rest_invalid_param', $this->response_data( $created )['code'] );
+		$data    = $this->assert_invalid_param( $created, 'status', 'not_registered' );
+		$this->assertSame( 'status: "never-registered" is not registered.', $this->nested( $data, array( 'data', 'params', 'status' ) ) );

 		$schema = ( new PlansController() )->get_public_item_schema();
 		$this->assertIsArray( $schema['properties']['status'] );
@@ -1063,6 +1649,44 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 		$this->assertContains( $response->get_status(), array( 404, 405 ), 'DELETE must not remove plans.' );
 	}

+	/**
+	 * Assert a WordPress-shaped invalid param error: 400 `rest_invalid_param` with a
+	 * message under `params[ $arg ]` and the error code under `details[ $arg ]`.
+	 *
+	 * @param WP_REST_Response $response Response.
+	 * @param string           $arg      Top-level arg.
+	 * @param string           $code     Expected detail error code.
+	 * @return array<array-key, mixed> The response data.
+	 */
+	private function assert_invalid_param( WP_REST_Response $response, string $arg, string $code ): array {
+		$this->assertSame( 400, $response->get_status() );
+		$data = $this->response_data( $response );
+		$this->assertSame( 'rest_invalid_param', $data['code'] );
+		$this->assertIsString( $this->nested( $data, array( 'data', 'params', $arg ) ) );
+		$this->assertNotSame( '', $this->nested( $data, array( 'data', 'params', $arg ) ) );
+		$this->assertSame( $code, $this->nested( $data, array( 'data', 'details', $arg, 'code' ) ) );
+
+		return $data;
+	}
+
+	/**
+	 * POST a valid plan that a validate_plan callback is expected to refuse.
+	 */
+	private function create_rejected_plan(): WP_REST_Response {
+		return $this->request(
+			'POST',
+			self::BASE,
+			array(
+				'extension_slug' => self::EXTENSION_SLUG,
+				'name'           => 'Rejected',
+				'billing_policy' => array(
+					'period'   => 'month',
+					'interval' => 1,
+				),
+			)
+		);
+	}
+
 	/**
 	 * Create a basic plan and return its id.
 	 *
@@ -1138,6 +1762,24 @@ class PlansControllerTest extends EngineIntegrationTestCase {
 		return $ids;
 	}

+	/**
+	 * Get a value at a key path, asserting each level is an array that has the key.
+	 *
+	 * @param array<array-key, mixed> $data Data.
+	 * @param array<int, array-key>   $path Keys, outermost first.
+	 * @return mixed
+	 */
+	private function nested( array $data, array $path ) {
+		$value = $data;
+		foreach ( $path as $key ) {
+			$this->assertIsArray( $value );
+			$this->assertArrayHasKey( $key, $value );
+			$value = $value[ $key ];
+		}
+
+		return $value;
+	}
+
 	/**
 	 * Get a nested array value.
 	 *
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ValidationExceptionTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ValidationExceptionTest.php
new file mode 100644
index 00000000000..ccbd4be3206
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Api/ValidationExceptionTest.php
@@ -0,0 +1,223 @@
+<?php
+/**
+ * Integration tests for the facade validation exception and its message map.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Api;
+
+use EngineIntegrationTestCase;
+use InvalidArgumentException;
+use ReflectionClass;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ValidationException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Api\ValidationException
+ */
+class ValidationExceptionTest extends EngineIntegrationTestCase {
+
+	/**
+	 * Params a message for each code needs.
+	 */
+	private const SAMPLE_PARAMS = array(
+		ValidationErrorCode::REQUIRED       => array(),
+		ValidationErrorCode::INVALID_TYPE   => array( 'type' => 'string' ),
+		ValidationErrorCode::OUT_OF_BOUNDS  => array( 'min' => 1 ),
+		ValidationErrorCode::NOT_IN_ENUM    => array( 'allowed' => array( 'day', 'week' ) ),
+		ValidationErrorCode::INVALID_VALUE  => array(),
+		ValidationErrorCode::NOT_REGISTERED => array( 'value' => 'seasonal' ),
+	);
+
+	/**
+	 * Translated message for a Core error.
+	 *
+	 * @param string               $code   Error code.
+	 * @param string               $field  Field.
+	 * @param array<string, mixed> $params Params.
+	 */
+	private static function message_for( string $code, string $field, array $params = array() ): string {
+		$e = ValidationException::from_core( new InvalidInputException( $code, $field, 'English.', $params ) );
+
+		return $e->get_errors()->get_error_message( $code );
+	}
+
+	/**
+	 * @testdox Every error code has its own message, and no message names the field.
+	 */
+	public function test_every_error_code_has_a_message(): void {
+		$codes   = ( new ReflectionClass( ValidationErrorCode::class ) )->getConstants();
+		$generic = self::message_for( 'acme_unknown', 'name' );
+
+		$this->assertNotEmpty( $codes );
+		foreach ( $codes as $code ) {
+			$this->assertIsString( $code );
+			$this->assertArrayHasKey( $code, self::SAMPLE_PARAMS, sprintf( 'Add sample params and a message for "%s".', $code ) );
+
+			$message = self::message_for( $code, 'acme_field', self::SAMPLE_PARAMS[ $code ] );
+
+			$this->assertStringNotContainsString( 'acme_field', $message, $code );
+			if ( ValidationErrorCode::INVALID_VALUE !== $code ) {
+				$this->assertNotSame( $generic, $message, sprintf( '"%s" falls back to the generic message.', $code ) );
+			}
+		}
+	}
+
+	/**
+	 * @testdox The messages interpolate the params.
+	 */
+	public function test_the_messages_interpolate_the_params(): void {
+		$this->assertSame( 'This field is required.', self::message_for( ValidationErrorCode::REQUIRED, 'name' ) );
+		$this->assertSame( 'Must be of type integer.', self::message_for( ValidationErrorCode::INVALID_TYPE, 'interval', array( 'type' => 'integer' ) ) );
+		$this->assertSame( 'This value is not valid.', self::message_for( ValidationErrorCode::INVALID_VALUE, 'currency' ) );
+		$this->assertSame( '"seasonal" is not registered.', self::message_for( ValidationErrorCode::NOT_REGISTERED, 'status', array( 'value' => 'seasonal' ) ) );
+	}
+
+	/**
+	 * @testdox not_in_enum lists the allowed values with translated separators.
+	 */
+	public function test_not_in_enum_lists_the_allowed_values_with_translated_separators(): void {
+		$allowed = array( 'allowed' => array( 'day', 'week', 'month', 'year' ) );
+
+		$this->assertSame( wp_sprintf( 'Must be one of %l.', $allowed['allowed'] ), self::message_for( ValidationErrorCode::NOT_IN_ENUM, 'period', $allowed ) );
+		$this->assertSame( 'Must be one of day, week, month, and year.', self::message_for( ValidationErrorCode::NOT_IN_ENUM, 'period', $allowed ) );
+
+		add_filter(
+			'gettext',
+			static function ( $translation, $text, $domain ) {
+				if ( 'default' !== $domain ) {
+					return $translation;
+				}
+				if ( '%1$s, %2$s' === $text ) {
+					return '%1$s; %2$s';
+				}
+
+				return '%1$s, and %2$s' === $text ? '%1$s or %2$s' : $translation;
+			},
+			10,
+			3
+		);
+
+		$this->assertSame( 'Must be one of day; week; month or year.', self::message_for( ValidationErrorCode::NOT_IN_ENUM, 'period', $allowed ) );
+	}
+
+	/**
+	 * @testdox out_of_bounds picks its message by the bounds present.
+	 */
+	public function test_out_of_bounds_picks_its_message_by_the_bounds_present(): void {
+		$this->assertSame( 'Must be greater than or equal to 1.', self::message_for( ValidationErrorCode::OUT_OF_BOUNDS, 'limit', array( 'min' => 1 ) ) );
+		$this->assertSame( 'Must be less than or equal to 2.', self::message_for( ValidationErrorCode::OUT_OF_BOUNDS, 'min_cycles', array( 'max' => 2 ) ) );
+		$this->assertSame(
+			'Must be between 0 and 100.',
+			self::message_for(
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'value',
+				array(
+					'min' => 0,
+					'max' => 100,
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox An unknown code, or params a message cannot use, get the generic message.
+	 */
+	public function test_an_unknown_code_gets_the_generic_message(): void {
+		$this->assertSame( 'This value is not valid.', self::message_for( 'acme_unknown', 'name' ) );
+		$this->assertSame( 'This value is not valid.', self::message_for( ValidationErrorCode::OUT_OF_BOUNDS, 'limit' ) );
+		$this->assertSame( 'This value is not valid.', self::message_for( ValidationErrorCode::INVALID_TYPE, 'name', array( 'type' => 3 ) ) );
+		$this->assertSame( 'This value is not valid.', self::message_for( ValidationErrorCode::NOT_IN_ENUM, 'period', array( 'allowed' => 'day' ) ) );
+	}
+
+	/**
+	 * @testdox from_core keeps the English message, chains the Core error and carries field and params data.
+	 *
+	 * @throws ValidationException Thrown and caught as an InvalidArgumentException.
+	 */
+	public function test_from_core_keeps_the_english_message_and_chains_the_core_error(): void {
+		$core = new InvalidInputException( ValidationErrorCode::NOT_REGISTERED, 'status', 'Plan: status "<b>" is not registered.', array( 'value' => '<b>' ) );
+
+		try {
+			throw ValidationException::from_core( $core );
+		} catch ( InvalidArgumentException $caught ) {
+			$e = $caught;
+		}
+
+		$this->assertInstanceOf( ValidationException::class, $e, 'Callers catching InvalidArgumentException keep working.' );
+		$this->assertSame( esc_html( $core->getMessage() ), $e->getMessage() );
+		$this->assertSame( $core, $e->getPrevious() );
+		$this->assertSame( array( ValidationErrorCode::NOT_REGISTERED ), $e->get_errors()->get_error_codes() );
+		$this->assertSame(
+			array(
+				'field'  => 'status',
+				'params' => array( 'value' => '<b>' ),
+			),
+			$e->get_errors()->get_error_data( ValidationErrorCode::NOT_REGISTERED )
+		);
+	}
+
+	/**
+	 * @testdox from_core adds one entry per Core error, each with its own field and params.
+	 */
+	public function test_from_core_adds_one_entry_per_core_error(): void {
+		$core = InvalidInputException::from_errors(
+			array(
+				new InvalidInputException( ValidationErrorCode::OUT_OF_BOUNDS, 'interval', 'Interval.', array( 'min' => 1 ) ),
+				new InvalidInputException( ValidationErrorCode::REQUIRED, 'name', 'Name.' ),
+				new InvalidInputException( ValidationErrorCode::OUT_OF_BOUNDS, 'min_cycles', 'Min cycles.', array( 'max' => 2 ) ),
+			)
+		);
+
+		$e      = ValidationException::from_core( $core );
+		$errors = $e->get_errors();
+
+		$this->assertSame( $core, $e->getPrevious() );
+		$this->assertSame( 'Interval. Name. Min cycles.', $e->getMessage() );
+		$this->assertSame( array( ValidationErrorCode::OUT_OF_BOUNDS, ValidationErrorCode::REQUIRED ), $errors->get_error_codes() );
+		$this->assertSame(
+			array( 'Must be greater than or equal to 1.', 'Must be less than or equal to 2.' ),
+			$errors->get_error_messages( ValidationErrorCode::OUT_OF_BOUNDS )
+		);
+		$this->assertSame(
+			array(
+				array(
+					'field'  => 'interval',
+					'params' => array( 'min' => 1 ),
+				),
+				array(
+					'field'  => 'min_cycles',
+					'params' => array( 'max' => 2 ),
+				),
+			),
+			$errors->get_all_error_data( ValidationErrorCode::OUT_OF_BOUNDS )
+		);
+		$this->assertSame(
+			array(
+				'field'  => 'name',
+				'params' => array(),
+			),
+			$errors->get_error_data( ValidationErrorCode::REQUIRED )
+		);
+	}
+
+	/**
+	 * @testdox The message is translated in the woocommerce text domain.
+	 */
+	public function test_the_message_is_translated(): void {
+		add_filter(
+			'gettext',
+			static function ( $translation, $text, $domain ) {
+				return 'woocommerce' === $domain && 'This field is required.' === $text ? 'Ce champ est obligatoire.' : $translation;
+			},
+			10,
+			3
+		);
+
+		$this->assertSame( 'Ce champ est obligatoire.', self::message_for( ValidationErrorCode::REQUIRED, 'name' ) );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/AssertsValidationErrors.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/AssertsValidationErrors.php
new file mode 100644
index 00000000000..30cddeab3a6
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/AssertsValidationErrors.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * Assertion helper for facade validation errors.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration;
+
+use WP_Error;
+use Automattic\WooCommerce\SubscriptionsEngine\Api\ValidationException;
+
+/**
+ * Asserts that a facade call is refused with given codes, fields and params.
+ */
+trait AssertsValidationErrors {
+
+	/**
+	 * Run the operation and assert it throws a ValidationException carrying exactly the
+	 * expected errors, in `WP_Error` order (codes in first-seen order, then each code's
+	 * errors in addition order). Each expected entry is `array( code, field )`, or
+	 * `array( code, field, params )` to compare the params too; a fieldless error has a
+	 * null field. Every error must carry data: `WP_Error` skips empty data, so a data-less
+	 * error cannot be paired and fails the assertion.
+	 *
+	 * @param callable          $operation Operation expected to throw.
+	 * @param array<int, mixed> $expected  Expected errors.
+	 * @return ValidationException The caught exception, for further assertions.
+	 */
+	protected function assert_validation_errors( callable $operation, array $expected ): ValidationException {
+		try {
+			$operation();
+		} catch ( ValidationException $e ) {
+			$this->assertSame( $expected, self::describe_validation_errors( $e->get_errors(), $expected ), 'Errors' );
+			return $e;
+		}
+
+		$this->fail( sprintf( 'Expected a ValidationException with %d errors.', count( $expected ) ) );
+	}
+
+	/**
+	 * The errors in the expected entries' shape, each message paired with its own data.
+	 *
+	 * @param WP_Error          $errors   Validation errors.
+	 * @param array<int, mixed> $expected Expected errors, for which entries compare params.
+	 * @return array<int, array<int, mixed>>
+	 */
+	private static function describe_validation_errors( WP_Error $errors, array $expected ): array {
+		$actual = array();
+		foreach ( $errors->get_error_codes() as $code ) {
+			$all_data = array_values( $errors->get_all_error_data( $code ) );
+			$messages = array_values( $errors->get_error_messages( $code ) );
+			self::assertCount( count( $messages ), $all_data, sprintf( 'Every "%s" error must carry data to be paired with it.', $code ) );
+
+			foreach ( $messages as $index => $message ) {
+				self::assertNotSame( '', $message, sprintf( 'An error "%s" has no message.', $code ) );
+
+				$data  = $all_data[ $index ];
+				$entry = array( $code, is_array( $data ) && isset( $data['field'] ) ? $data['field'] : null );
+				$slot  = $expected[ count( $actual ) ] ?? null;
+				if ( is_array( $slot ) && array_key_exists( 2, $slot ) ) {
+					$entry[] = is_array( $data ) && isset( $data['params'] ) ? $data['params'] : null;
+				}
+				$actual[] = $entry;
+			}
+		}
+
+		return $actual;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
index 010c71c9475..114a8192af9 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/ContractRepositoryTest.php
@@ -18,6 +18,8 @@ use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PaymentInstrumentRef;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
@@ -1517,8 +1519,11 @@ class ContractRepositoryTest extends EngineIntegrationTestCase {

 		try {
 			$this->transition_cycle_row_locked( $cycle_id, CycleStatus::PENDING, 'never-registered', array( 'order_id' => 4242 ) );
-			$this->fail( 'Expected a DomainException for an unregistered target status.' );
-		} catch ( \DomainException $e ) {
+			$this->fail( 'Expected an InvalidInputException for an unregistered target status.' );
+		} catch ( InvalidInputException $e ) {
+			$this->assertSame( ValidationErrorCode::NOT_REGISTERED, $e->get_error_code() );
+			$this->assertSame( 'to_status', $e->get_field() );
+			$this->assertSame( array( 'value' => 'never-registered' ), $e->get_params() );
 			$this->assertSame( CycleStatus::PENDING, $this->find_cycle( $cycle_id )->get_status()->get_value() );
 		}
 	}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentFilterTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentFilterTest.php
new file mode 100644
index 00000000000..e0bea828928
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentFilterTest.php
@@ -0,0 +1,70 @@
+<?php
+/**
+ * Integration tests for the shared facade unknown-key filtering.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Support;
+
+use EngineIntegrationTestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentFilter;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentFilter
+ */
+class ArgumentFilterTest extends EngineIntegrationTestCase {
+
+	public function test_filter_known_keys_drops_unknown_keys_with_a_notice(): void {
+		$this->setExpectedIncorrectUsage( 'Acme::write' );
+		$messages = array();
+		add_action(
+			'doing_it_wrong_run',
+			static function ( $function_name, $message ) use ( &$messages ): void {
+				$messages[] = $message;
+			},
+			10,
+			2
+		);
+
+		$filtered = ArgumentFilter::filter_known_keys(
+			'Acme::write',
+			array(
+				'known' => 1,
+				'other' => 2,
+			),
+			array( 'known' => true ),
+			'item key'
+		);
+
+		$this->assertSame( array( 'known' => 1 ), $filtered );
+		$this->assertSame( array( 'Unknown item key "other" ignored.' ), $messages );
+	}
+
+	public function test_rows_keep_their_keys_and_drop_unknown_keys_with_a_notice(): void {
+		$this->setExpectedIncorrectUsage( 'facade' );
+
+		$rows = ArgumentFilter::filter_known_keys_in_rows(
+			'facade',
+			array(
+				'billing'  => array(
+					'first_name' => 'Ada',
+					'zip'        => '1',
+				),
+				'shipping' => array( 'first_name' => 'Grace' ),
+			),
+			array( 'first_name' => true ),
+			'address key'
+		);
+
+		$this->assertSame(
+			array(
+				'billing'  => array( 'first_name' => 'Ada' ),
+				'shipping' => array( 'first_name' => 'Grace' ),
+			),
+			$rows
+		);
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
deleted file mode 100644
index 9671edfa1bc..00000000000
--- a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Support/ArgumentValidatorTest.php
+++ /dev/null
@@ -1,164 +0,0 @@
-<?php
-/**
- * Integration tests for the shared facade argument validators.
- *
- * @package Automattic\WooCommerce\SubscriptionsEngine
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Support;
-
-use DateTimeImmutable;
-use DateTimeZone;
-use EngineIntegrationTestCase;
-use InvalidArgumentException;
-use Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator;
-
-/**
- * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Support\ArgumentValidator
- */
-class ArgumentValidatorTest extends EngineIntegrationTestCase {
-
-	/**
-	 * @dataProvider provide_valid_values
-	 *
-	 * @param string            $method   Validator method.
-	 * @param array<int, mixed> $args     Validator arguments.
-	 * @param mixed             $expected Normalized value.
-	 */
-	public function test_a_valid_value_is_returned_normalized( string $method, array $args, $expected ): void {
-		$this->assertSame( $expected, ArgumentValidator::$method( ...$args ) );
-	}
-
-	/**
-	 * @return array<string, array{0: string, 1: array<int, mixed>, 2: mixed}>
-	 */
-	public function provide_valid_values(): array {
-		return array(
-			'currency'              => array( 'validate_currency', array( 'EUR' ), 'EUR' ),
-			'string'                => array( 'validate_string', array( 'status', 'active' ), 'active' ),
-			'nullable string'       => array( 'validate_nullable_string', array( 'title', null ), null ),
-			'nullable id as digits' => array( 'validate_nullable_id', array( 'order_id', '42' ), 42 ),
-			'nullable date object'  => array(
-				'validate_nullable_date',
-				array( 'start_gmt', new DateTimeImmutable( '2026-01-01 03:00:00', new DateTimeZone( 'Europe/Berlin' ) ) ),
-				'2026-01-01 02:00:00',
-			),
-			'money'                 => array( 'validate_money', array( 'tax_total', '1.5' ), '1.50000000' ),
-			'money null is zero'    => array( 'validate_money', array( 'tax_total', null ), '0.00000000' ),
-			'list of arrays'        => array( 'validate_list_of_arrays', array( 'items', array( array( 'name' => 'a' ) ) ), array( array( 'name' => 'a' ) ) ),
-			'non-negative int zero' => array( 'validate_non_negative_int', array( 'offset', 0 ), 0 ),
-			'non-negative digits'   => array( 'validate_non_negative_int', array( 'offset', '7' ), 7 ),
-			'id list'               => array( 'validate_id_list', array( 'ids', array( 3, '4' ) ), array( 3, 4 ) ),
-			'empty id list'         => array( 'validate_id_list', array( 'ids', array() ), array() ),
-			'string as list'        => array( 'validate_string_list', array( 'status', 'active' ), array( 'active' ) ),
-			'string list'           => array( 'validate_string_list', array( 'status', array( 'active', 'archived' ) ), array( 'active', 'archived' ) ),
-			'nullable array'        => array( 'validate_nullable_array', array( 'billing_policy', array( 'period' => 'month' ) ), array( 'period' => 'month' ) ),
-			'nullable array null'   => array( 'validate_nullable_array', array( 'billing_policy', null ), null ),
-		);
-	}
-
-	/**
-	 * @dataProvider provide_invalid_values
-	 *
-	 * @param string            $method  Validator method.
-	 * @param array<int, mixed> $args    Validator arguments.
-	 * @param string            $message Expected exception message.
-	 */
-	public function test_an_invalid_value_is_rejected_naming_the_field( string $method, array $args, string $message ): void {
-		$this->expectException( InvalidArgumentException::class );
-		$this->expectExceptionMessage( $message );
-
-		ArgumentValidator::$method( ...$args );
-	}
-
-	/**
-	 * @return array<string, array{0: string, 1: array<int, mixed>, 2: string}>
-	 */
-	public function provide_invalid_values(): array {
-		return array(
-			'currency'         => array( 'validate_currency', array( 'eur' ), '"currency" must be null or a three-letter uppercase ISO-4217 code.' ),
-			'string'           => array( 'validate_string', array( 'status', 5 ), '"status" must be a string.' ),
-			'nullable string'  => array( 'validate_nullable_string', array( 'title', 5 ), '"title" must be null or a string.' ),
-			'nullable id'      => array( 'validate_nullable_id', array( 'order_id', 0 ), '"order_id" must be null or a positive integer.' ),
-			'nullable date'    => array( 'validate_nullable_date', array( 'start_gmt', '2026-02-30 00:00:00' ), '"start_gmt" must be null, a DateTimeInterface, or a GMT "Y-m-d H:i:s" string.' ),
-			'money'            => array( 'validate_money', array( 'tax_total', 'ten' ), '"tax_total" must be a number or a numeric string.' ),
-			'list of arrays'   => array( 'validate_list_of_arrays', array( 'items', array( 'a' => array() ) ), '"items" must be a list of arrays.' ),
-			'negative int'     => array( 'validate_non_negative_int', array( 'offset', -1 ), '"offset" must be a non-negative integer.' ),
-			'id list entry'    => array( 'validate_id_list', array( 'ids', array( 3, 0 ) ), '"ids" must be a list of positive integers.' ),
-			'id list null'     => array( 'validate_id_list', array( 'ids', array( null ) ), '"ids" must be a list of positive integers.' ),
-			'id list map'      => array( 'validate_id_list', array( 'ids', array( 'a' => 3 ) ), '"ids" must be a list of positive integers.' ),
-			'id newline'       => array( 'validate_nullable_id', array( 'order_id', "5\n" ), '"order_id" must be null or a positive integer.' ),
-			'currency newline' => array( 'validate_currency', array( "EUR\n" ), '"currency" must be null or a three-letter uppercase ISO-4217 code.' ),
-			'string list'      => array( 'validate_string_list', array( 'status', array( 'active', '' ) ), '"status" must be a non-empty string or a list of them.' ),
-			'string list int'  => array( 'validate_string_list', array( 'status', 5 ), '"status" must be a non-empty string or a list of them.' ),
-			'nullable array'   => array( 'validate_nullable_array', array( 'billing_policy', 'monthly' ), '"billing_policy" must be null or an array.' ),
-		);
-	}
-
-	public function test_filter_known_keys_drops_unknown_keys_with_a_notice(): void {
-		$this->setExpectedIncorrectUsage( 'Acme::write' );
-		$messages = array();
-		add_action(
-			'doing_it_wrong_run',
-			static function ( $function_name, $message ) use ( &$messages ): void {
-				$messages[] = $message;
-			},
-			10,
-			2
-		);
-
-		$filtered = ArgumentValidator::filter_known_keys(
-			'Acme::write',
-			array(
-				'known' => 1,
-				'other' => 2,
-			),
-			array( 'known' => true ),
-			'item key'
-		);
-
-		$this->assertSame( array( 'known' => 1 ), $filtered );
-		$this->assertSame( array( 'Unknown item key "other" ignored.' ), $messages );
-	}
-
-	public function test_contract_items_keep_item_fields_and_drop_unknown_keys_with_a_notice(): void {
-		$this->setExpectedIncorrectUsage( 'facade' );
-
-		$rows = ArgumentValidator::validate_contract_items(
-			array(
-				array(
-					'item_name' => 'Coffee',
-					'price'     => '1',
-				),
-			),
-			'facade'
-		);
-
-		$this->assertSame( array( array( 'item_name' => 'Coffee' ) ), $rows );
-	}
-
-	public function test_contract_addresses_reject_an_unknown_address_type(): void {
-		$this->expectException( InvalidArgumentException::class );
-		$this->expectExceptionMessage( '"addresses" must be an array keyed "billing" / "shipping" with array values.' );
-
-		ArgumentValidator::validate_contract_addresses( array( 'home' => array( 'first_name' => 'Ada' ) ) );
-	}
-
-	public function test_contract_addresses_keep_address_fields_and_drop_unknown_keys_with_a_notice(): void {
-		$this->setExpectedIncorrectUsage( 'facade' );
-
-		$addresses = ArgumentValidator::validate_contract_addresses(
-			array(
-				'billing' => array(
-					'first_name' => 'Ada',
-					'zip'        => '1',
-				),
-			),
-			'facade'
-		);
-
-		$this->assertSame( array( 'billing' => array( 'first_name' => 'Ada' ) ), $addresses );
-	}
-}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/AssertsInternalInvariant.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/AssertsInternalInvariant.php
new file mode 100644
index 00000000000..ea9fe31b021
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/AssertsInternalInvariant.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Assertion helper for internal invariants.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit;
+
+use LogicException;
+
+/**
+ * Asserts that an operation breaks an internal invariant: a plain LogicException, not a
+ * validation error (which is a LogicException subclass too).
+ */
+trait AssertsInternalInvariant {
+
+	/**
+	 * Run the operation and assert it throws exactly a LogicException.
+	 *
+	 * @param callable $operation Operation expected to throw.
+	 * @return LogicException The caught exception, for further assertions.
+	 */
+	protected function assert_internal_invariant( callable $operation ): LogicException {
+		try {
+			$operation();
+		} catch ( LogicException $e ) {
+			$this->assertSame( LogicException::class, get_class( $e ), 'An internal invariant throws a plain LogicException.' );
+			return $e;
+		}
+
+		$this->fail( 'Expected a LogicException.' );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/AssertsInvalidInput.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/AssertsInvalidInput.php
new file mode 100644
index 00000000000..777e6cff73a
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/AssertsInvalidInput.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * Assertion helper for Core validation errors.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+
+/**
+ * Asserts that an operation is refused with given codes, fields and params.
+ */
+trait AssertsInvalidInput {
+
+	/**
+	 * Run the operation and assert it throws an InvalidInputException carrying exactly one
+	 * error, with the given code, field and params.
+	 *
+	 * @param callable             $operation Operation expected to throw.
+	 * @param string               $code      Expected error code.
+	 * @param string               $field     Expected field.
+	 * @param array<string, mixed> $params    Expected params.
+	 * @return InvalidInputException The caught exception, for further assertions.
+	 */
+	protected function assert_invalid_input( callable $operation, string $code, string $field, array $params = array() ): InvalidInputException {
+		return $this->assert_invalid_inputs( $operation, array( array( $code, $field, $params ) ) );
+	}
+
+	/**
+	 * Run the operation and assert it throws an InvalidInputException carrying exactly the
+	 * expected errors, in order. Each expected entry is `array( code, field )`, or
+	 * `array( code, field, params )` to compare the params too.
+	 *
+	 * @param callable          $operation Operation expected to throw.
+	 * @param array<int, mixed> $expected  Expected errors.
+	 * @return InvalidInputException The caught exception, for further assertions.
+	 */
+	protected function assert_invalid_inputs( callable $operation, array $expected ): InvalidInputException {
+		try {
+			$operation();
+		} catch ( InvalidInputException $e ) {
+			$this->assertSame( $expected, self::describe_errors( $e, $expected ), 'Errors' );
+			return $e;
+		}
+
+		$this->fail( sprintf( 'Expected an InvalidInputException with %d errors.', count( $expected ) ) );
+	}
+
+	/**
+	 * The exception's errors in the expected entries' shape.
+	 *
+	 * @param InvalidInputException $e        Caught exception.
+	 * @param array<int, mixed>     $expected Expected errors, for which entries compare params.
+	 * @return array<int, array<int, mixed>>
+	 */
+	private static function describe_errors( InvalidInputException $e, array $expected ): array {
+		$actual = array();
+		foreach ( $e->get_errors() as $index => $error ) {
+			$entry = array( $error->get_error_code(), $error->get_field() );
+			if ( isset( $expected[ $index ] ) && is_array( $expected[ $index ] ) && array_key_exists( 2, $expected[ $index ] ) ) {
+				$entry[] = $error->get_params();
+			}
+			$actual[] = $entry;
+		}
+
+		return $actual;
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
index 5dc3ddbe532..8d3c744bb12 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/ContractTest.php
@@ -15,19 +15,23 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;

-use DomainException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\ContractStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PaymentInstrumentRef;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInvalidInput;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract
  */
 class ContractTest extends TestCase {

+	use AssertsInvalidInput;
+
 	protected function tearDown(): void {
 		StatusRegistry::reset();
 		parent::tearDown();
@@ -101,15 +105,20 @@ class ContractTest extends TestCase {
 	 * @testdox create() rejects a non-zero total without a currency.
 	 */
 	public function test_create_rejects_a_non_zero_total_without_a_currency(): void {
-		$this->expectException( DomainException::class );
-		$this->expectExceptionMessage( 'money totals require a currency' );
-
-		Contract::create(
-			array(
-				'extension_slug' => 'acme-subs',
-				'billing_total'  => '10',
-			)
+		$e = $this->assert_invalid_input(
+			static function (): void {
+				Contract::create(
+					array(
+						'extension_slug' => 'acme-subs',
+						'billing_total'  => '10',
+					)
+				);
+			},
+			ValidationErrorCode::REQUIRED,
+			'currency'
 		);
+
+		$this->assertStringContainsString( 'money totals require a currency', $e->getMessage() );
 	}

 	/**
@@ -123,7 +132,7 @@ class ContractTest extends TestCase {
 			)
 		);

-		$contract->assert_money_has_currency();
+		$this->assertNull( $contract->get_money_currency_error() );
 		$this->assertNull( $contract->get_currency() );
 	}

@@ -140,27 +149,61 @@ class ContractTest extends TestCase {
 		);
 		$contract->set_currency( null );

-		$this->expectException( DomainException::class );
-		$contract->assert_money_has_currency();
+		$error = $contract->get_money_currency_error();
+		$this->assertInstanceOf( InvalidInputException::class, $error );
+		$this->assertSame( ValidationErrorCode::REQUIRED, $error->get_error_code() );
+		$this->assertSame( 'currency', $error->get_field() );
+	}
+
+	/**
+	 * @testdox create() reports every invalid attribute in one exception.
+	 */
+	public function test_create_reports_every_invalid_attribute(): void {
+		$this->assert_invalid_inputs(
+			static function (): void {
+				Contract::create(
+					array(
+						'extension_slug'  => 'acme-subs',
+						'status'          => 'never-registered',
+						'schedule_source' => 'Not a slug',
+						'billing_total'   => '10',
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::NOT_REGISTERED, 'status', array( 'value' => 'never-registered' ) ),
+				array( ValidationErrorCode::INVALID_VALUE, 'schedule_source', array() ),
+				array( ValidationErrorCode::REQUIRED, 'currency', array() ),
+			)
+		);
 	}

 	/**
 	 * @testdox create() requires an extension_slug.
 	 */
 	public function test_create_requires_an_extension_slug(): void {
-		$this->expectException( DomainException::class );
-		$this->expectExceptionMessage( 'Contract: extension_slug is required' );
+		$e = $this->assert_invalid_input(
+			static function (): void {
+				Contract::create( array() );
+			},
+			ValidationErrorCode::REQUIRED,
+			'extension_slug'
+		);

-		Contract::create( array() );
+		$this->assertStringContainsString( 'Contract: extension_slug is required', $e->getMessage() );
 	}

 	/**
 	 * @testdox create() rejects an empty extension_slug.
 	 */
 	public function test_create_rejects_an_empty_extension_slug(): void {
-		$this->expectException( DomainException::class );
-
-		Contract::create( array( 'extension_slug' => '' ) );
+		$this->assert_invalid_input(
+			static function (): void {
+				Contract::create( array( 'extension_slug' => '' ) );
+			},
+			ValidationErrorCode::REQUIRED,
+			'extension_slug'
+		);
 	}

 	/**
@@ -218,10 +261,15 @@ class ContractTest extends TestCase {
 	public function test_set_schedule_source_rejects_an_invalid_slug( string $schedule_source ): void {
 		$contract = Contract::create( array( 'extension_slug' => 'acme-subs' ) );

-		$this->expectException( DomainException::class );
-		$this->expectExceptionMessage( sprintf( 'Contract: invalid schedule source "%s".', $schedule_source ) );
+		$e = $this->assert_invalid_input(
+			static function () use ( $contract, $schedule_source ): void {
+				$contract->set_schedule_source( $schedule_source );
+			},
+			ValidationErrorCode::INVALID_VALUE,
+			'schedule_source'
+		);

-		$contract->set_schedule_source( $schedule_source );
+		$this->assertSame( sprintf( 'Contract: invalid schedule source "%s".', $schedule_source ), $e->getMessage() );
 	}

 	/**
@@ -346,18 +394,23 @@ class ContractTest extends TestCase {
 	 * @testdox create() rejects an invalid status.
 	 */
 	public function test_create_rejects_an_invalid_status(): void {
-		$this->expectException( DomainException::class );
-
-		Contract::create(
-			array(
-				'extension_slug'  => 'acme-subs',
-				'customer_id'     => 1,
-				'currency'        => 'USD',
-				'selling_plan_id' => 2,
-				'origin_order_id' => 3,
-				'start_gmt'       => '2026-01-01 00:00:00',
-				'status'          => 'nonsense',
-			)
+		$this->assert_invalid_input(
+			static function (): void {
+				Contract::create(
+					array(
+						'extension_slug'  => 'acme-subs',
+						'customer_id'     => 1,
+						'currency'        => 'USD',
+						'selling_plan_id' => 2,
+						'origin_order_id' => 3,
+						'start_gmt'       => '2026-01-01 00:00:00',
+						'status'          => 'nonsense',
+					)
+				);
+			},
+			ValidationErrorCode::NOT_REGISTERED,
+			'status',
+			array( 'value' => 'nonsense' )
 		);
 	}

@@ -365,18 +418,22 @@ class ContractTest extends TestCase {
 	 * @testdox create() rejects an invalid schedule source.
 	 */
 	public function test_create_rejects_an_invalid_schedule_source(): void {
-		$this->expectException( DomainException::class );
-
-		Contract::create(
-			array(
-				'extension_slug'  => 'acme-subs',
-				'customer_id'     => 1,
-				'currency'        => 'USD',
-				'selling_plan_id' => 2,
-				'origin_order_id' => 3,
-				'start_gmt'       => '2026-01-01 00:00:00',
-				'schedule_source' => 'Not a slug',
-			)
+		$this->assert_invalid_input(
+			static function (): void {
+				Contract::create(
+					array(
+						'extension_slug'  => 'acme-subs',
+						'customer_id'     => 1,
+						'currency'        => 'USD',
+						'selling_plan_id' => 2,
+						'origin_order_id' => 3,
+						'start_gmt'       => '2026-01-01 00:00:00',
+						'schedule_source' => 'Not a slug',
+					)
+				);
+			},
+			ValidationErrorCode::INVALID_VALUE,
+			'schedule_source'
 		);
 	}

@@ -632,8 +689,11 @@ class ContractTest extends TestCase {

 		try {
 			$contract->set_status( 'never-registered' );
-			$this->fail( 'Expected a DomainException for an unregistered status.' );
-		} catch ( DomainException $e ) {
+			$this->fail( 'Expected an InvalidInputException for an unregistered status.' );
+		} catch ( InvalidInputException $e ) {
+			$this->assertSame( ValidationErrorCode::NOT_REGISTERED, $e->get_error_code() );
+			$this->assertSame( 'status', $e->get_field() );
+			$this->assertSame( array( 'value' => 'never-registered' ), $e->get_params() );
 			$this->assertSame( ContractStatus::ACTIVE, $contract->get_status() );
 		}
 	}
@@ -711,31 +771,38 @@ class ContractTest extends TestCase {
 	 * @testdox create() rejects an empty, over-long or non-string creation key.
 	 * @dataProvider invalid_creation_keys
 	 *
-	 * @param mixed $creation_key Invalid creation key.
-	 */
-	public function test_create_rejects_an_invalid_creation_key( $creation_key ): void {
-		$this->expectException( DomainException::class );
-
-		Contract::create(
-			array(
-				'extension_slug' => 'acme-subs',
-				'creation_key'   => $creation_key,
-			)
+	 * @param mixed                $creation_key Invalid creation key.
+	 * @param string               $code         Expected error code.
+	 * @param array<string, mixed> $params       Expected error params.
+	 */
+	public function test_create_rejects_an_invalid_creation_key( $creation_key, string $code, array $params ): void {
+		$this->assert_invalid_input(
+			static function () use ( $creation_key ): void {
+				Contract::create(
+					array(
+						'extension_slug' => 'acme-subs',
+						'creation_key'   => $creation_key,
+					)
+				);
+			},
+			$code,
+			'creation_key',
+			$params
 		);
 	}

 	/**
 	 * Invalid creation keys.
 	 *
-	 * @return array<string, array{0: mixed}>
+	 * @return array<string, array{0: mixed, 1: string, 2: array<string, mixed>}>
 	 */
 	public function invalid_creation_keys(): array {
 		return array(
-			'empty'      => array( '' ),
-			'only space' => array( "  \t" ),
-			'192 chars'  => array( str_repeat( 'k', 192 ) ),
-			'101 chars'  => array( str_repeat( 'k', 101 ) ),
-			'non-string' => array( 42 ),
+			'empty'      => array( '', ValidationErrorCode::INVALID_VALUE, array() ),
+			'only space' => array( "  \t", ValidationErrorCode::INVALID_VALUE, array() ),
+			'192 chars'  => array( str_repeat( 'k', 192 ), ValidationErrorCode::INVALID_VALUE, array() ),
+			'101 chars'  => array( str_repeat( 'k', 101 ), ValidationErrorCode::INVALID_VALUE, array() ),
+			'non-string' => array( 42, ValidationErrorCode::INVALID_TYPE, array( 'type' => 'string' ) ),
 		);
 	}

diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
index a5878a1fd91..45becb5c33a 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleStatusTest.php
@@ -9,16 +9,19 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;

-use DomainException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInvalidInput;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus
  */
 class CycleStatusTest extends TestCase {

+	use AssertsInvalidInput;
+
 	protected function tearDown(): void {
 		StatusRegistry::reset();
 		parent::tearDown();
@@ -64,9 +67,13 @@ class CycleStatusTest extends TestCase {
 	 * @param string $slug Malformed slug.
 	 */
 	public function test_constructor_rejects_a_malformed_slug( string $slug ): void {
-		$this->expectException( DomainException::class );
-
-		new CycleStatus( $slug );
+		$this->assert_invalid_input(
+			static function () use ( $slug ): void {
+				new CycleStatus( $slug );
+			},
+			ValidationErrorCode::INVALID_VALUE,
+			'status'
+		);
 	}

 	/**
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
index 6797c6623d4..21e5e91e683 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/CycleTest.php
@@ -9,20 +9,24 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;

-use DomainException;
-use LogicException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\CycleStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInvalidInput;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInternalInvariant;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Cycle
  */
 class CycleTest extends TestCase {

+	use AssertsInternalInvariant;
+	use AssertsInvalidInput;
+
 	protected function tearDown(): void {
 		StatusRegistry::reset();
 		parent::tearDown();
@@ -87,16 +91,21 @@ class CycleTest extends TestCase {
 	}

 	public function test_create_requires_a_contract_id(): void {
-		$this->expectException( DomainException::class );
-
-		Cycle::create(
-			array(
-				'sequence_no'    => 1,
-				'starts_at_gmt'  => '2026-02-01 00:00:00',
-				'ends_at_gmt'    => '2026-03-01 00:00:00',
-				'expected_total' => '20.00',
-				'currency'       => 'USD',
-			)
+		$this->assert_invalid_input(
+			function (): void {
+				Cycle::create(
+					array(
+						'sequence_no'    => 1,
+						'starts_at_gmt'  => '2026-02-01 00:00:00',
+						'ends_at_gmt'    => '2026-03-01 00:00:00',
+						'expected_total' => '20.00',
+						'currency'       => 'USD',
+					)
+				);
+			},
+			ValidationErrorCode::OUT_OF_BOUNDS,
+			'contract_id',
+			array( 'min' => 1 )
 		);
 	}

@@ -115,35 +124,67 @@ class CycleTest extends TestCase {
 	}

 	public function test_create_rejects_an_empty_kind(): void {
-		$this->expectException( DomainException::class );
-
-		$this->make_pending( array( 'kind' => '' ) );
+		$this->assert_invalid_input(
+			function (): void {
+				$this->make_pending( array( 'kind' => '' ) );
+			},
+			ValidationErrorCode::REQUIRED,
+			'kind'
+		);
 	}

 	public function test_create_rejects_a_non_positive_sequence_no(): void {
-		$this->expectException( DomainException::class );
-
-		$this->make_pending( array( 'sequence_no' => 0 ) );
+		$this->assert_invalid_input(
+			function (): void {
+				$this->make_pending( array( 'sequence_no' => 0 ) );
+			},
+			ValidationErrorCode::OUT_OF_BOUNDS,
+			'sequence_no',
+			array( 'min' => 1 )
+		);
 	}

-	public function test_create_requires_a_start_and_an_end(): void {
-		$this->expectException( DomainException::class );
-		$this->expectExceptionMessage( 'starts_at_gmt and ends_at_gmt are required' );
+	public function test_create_requires_a_start(): void {
+		$this->assert_invalid_input(
+			function (): void {
+				$this->make_pending( array( 'starts_at_gmt' => null ) );
+			},
+			ValidationErrorCode::REQUIRED,
+			'starts_at_gmt'
+		);
+	}

-		$this->make_pending( array( 'ends_at_gmt' => null ) );
+	public function test_create_requires_an_end(): void {
+		$this->assert_invalid_input(
+			function (): void {
+				$this->make_pending( array( 'ends_at_gmt' => null ) );
+			},
+			ValidationErrorCode::REQUIRED,
+			'ends_at_gmt'
+		);
 	}

 	public function test_create_requires_a_currency(): void {
-		$this->expectException( DomainException::class );
-		$this->expectExceptionMessage( 'currency is required' );
+		$e = $this->assert_invalid_input(
+			function (): void {
+				$this->make_pending( array( 'currency' => null ) );
+			},
+			ValidationErrorCode::REQUIRED,
+			'currency'
+		);

-		$this->make_pending( array( 'currency' => null ) );
+		$this->assertStringContainsString( 'currency is required', $e->getMessage() );
 	}

 	public function test_create_rejects_a_non_positive_count(): void {
-		$this->expectException( DomainException::class );
-
-		$this->make_pending( array( 'count' => 0 ) );
+		$this->assert_invalid_input(
+			function (): void {
+				$this->make_pending( array( 'count' => 0 ) );
+			},
+			ValidationErrorCode::OUT_OF_BOUNDS,
+			'count',
+			array( 'min' => 1 )
+		);
 	}

 	public function test_create_allows_a_null_count_for_a_non_counting_cycle(): void {
@@ -155,9 +196,14 @@ class CycleTest extends TestCase {
 	}

 	public function test_create_rejects_an_invalid_status(): void {
-		$this->expectException( DomainException::class );
-
-		$this->make_pending( array( 'status' => 'nonsense' ) );
+		$this->assert_invalid_input(
+			function (): void {
+				$this->make_pending( array( 'status' => 'nonsense' ) );
+			},
+			ValidationErrorCode::NOT_REGISTERED,
+			'status',
+			array( 'value' => 'nonsense' )
+		);
 	}

 	public function test_create_carries_order_id_and_owner(): void {
@@ -213,9 +259,14 @@ class CycleTest extends TestCase {
 	}

 	public function test_create_rejects_an_unregistered_status_instance(): void {
-		$this->expectException( DomainException::class );
-
-		$this->make_pending( array( 'status' => new CycleStatus( 'nope' ) ) );
+		$this->assert_invalid_input(
+			function (): void {
+				$this->make_pending( array( 'status' => new CycleStatus( 'nope' ) ) );
+			},
+			ValidationErrorCode::NOT_REGISTERED,
+			'status',
+			array( 'value' => 'nope' )
+		);
 	}

 	public function test_period_boundaries_are_frozen_at_construction(): void {
@@ -241,16 +292,22 @@ class CycleTest extends TestCase {
 		$cycle = $this->make_pending();
 		$cycle->set_plan_snapshot( PlanSnapshot::from_array( array( 'selling_plan_id' => 9 ) ) );

-		$this->expectException( DomainException::class );
-		$cycle->set_plan_snapshot( PlanSnapshot::from_array( array( 'selling_plan_id' => 10 ) ) );
+		$this->assert_internal_invariant(
+			static function () use ( $cycle ): void {
+				$cycle->set_plan_snapshot( PlanSnapshot::from_array( array( 'selling_plan_id' => 10 ) ) );
+			}
+		);
 	}

 	public function test_items_snapshot_vo_cannot_be_replaced(): void {
 		$cycle = $this->make_pending();
 		$cycle->set_items_snapshot( ItemsSnapshot::from_items( array() ) );

-		$this->expectException( DomainException::class );
-		$cycle->set_items_snapshot( ItemsSnapshot::from_items( array( array( 'product_id' => 1 ) ) ) );
+		$this->assert_internal_invariant(
+			static function () use ( $cycle ): void {
+				$cycle->set_items_snapshot( ItemsSnapshot::from_items( array( array( 'product_id' => 1 ) ) ) );
+			}
+		);
 	}

 	public function test_from_storage_hydrates_a_persisted_cycle(): void {
@@ -375,6 +432,47 @@ class CycleTest extends TestCase {
 		);
 	}

+	public function test_create_reports_every_invalid_attribute(): void {
+		$this->assert_invalid_inputs(
+			static function (): void {
+				Cycle::create(
+					array(
+						'contract_id' => 0,
+						'status'      => 'Not A Slug',
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'contract_id', array( 'min' => 1 ) ),
+				array( ValidationErrorCode::REQUIRED, 'starts_at_gmt', array() ),
+				array( ValidationErrorCode::REQUIRED, 'ends_at_gmt', array() ),
+				array( ValidationErrorCode::REQUIRED, 'currency', array() ),
+				array( ValidationErrorCode::INVALID_VALUE, 'status', array() ),
+			)
+		);
+	}
+
+	public function test_create_reports_an_unregistered_status_with_the_other_errors(): void {
+		$this->assert_invalid_inputs(
+			static function (): void {
+				Cycle::create(
+					array(
+						'contract_id'   => 7,
+						'starts_at_gmt' => '2026-02-01 00:00:00',
+						'ends_at_gmt'   => '2026-03-01 00:00:00',
+						'currency'      => 'USD',
+						'count'         => 0,
+						'status'        => 'never-registered',
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'count', array( 'min' => 1 ) ),
+				array( ValidationErrorCode::NOT_REGISTERED, 'status', array( 'value' => 'never-registered' ) ),
+			)
+		);
+	}
+
 	public function test_create_without_a_sequence_no_leaves_it_to_the_append(): void {
 		$this->assertTrue( $this->make_unpositioned()->awaits_sequence_no() );
 		$this->assertFalse( $this->make_pending()->awaits_sequence_no() );
@@ -389,9 +487,11 @@ class CycleTest extends TestCase {
 	}

 	public function test_an_unassigned_sequence_no_cannot_be_read(): void {
-		$this->expectException( LogicException::class );
-
-		$this->make_unpositioned()->get_sequence_no();
+		$this->assert_internal_invariant(
+			function (): void {
+				$this->make_unpositioned()->get_sequence_no();
+			}
+		);
 	}

 	public function test_the_sequence_no_can_be_assigned_once(): void {
@@ -404,15 +504,22 @@ class CycleTest extends TestCase {
 	}

 	public function test_assign_sequence_no_rejects_a_non_positive_value(): void {
-		$this->expectException( DomainException::class );
-
-		$this->make_unpositioned()->assign_sequence_no( 0 );
+		$this->assert_invalid_input(
+			function (): void {
+				$this->make_unpositioned()->assign_sequence_no( 0 );
+			},
+			ValidationErrorCode::OUT_OF_BOUNDS,
+			'sequence_no',
+			array( 'min' => 1 )
+		);
 	}

 	public function test_an_assigned_sequence_no_cannot_be_reassigned(): void {
-		$this->expectException( DomainException::class );
-
-		$this->make_pending()->assign_sequence_no( 2 );
+		$this->assert_internal_invariant(
+			function (): void {
+				$this->make_pending()->assign_sequence_no( 2 );
+			}
+		);
 	}

 	public function test_id_can_be_stamped_after_persistence(): void {
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php
index a66398c4ef4..8927f847213 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/PlanTest.php
@@ -9,17 +9,20 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;

-use DomainException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\PlanStatus;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\StatusRegistry;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInvalidInput;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan
  */
 class PlanTest extends TestCase {

+	use AssertsInvalidInput;
+
 	protected function tearDown(): void {
 		StatusRegistry::reset();
 		parent::tearDown();
@@ -147,16 +150,22 @@ class PlanTest extends TestCase {
 	 * @param mixed  $value Bad value.
 	 */
 	public function test_create_rejects_a_non_object_policy( string $field, $value ): void {
-		$this->expectException( DomainException::class );
-		$this->expectExceptionMessage( $field );
-
-		Plan::create(
-			array(
-				'extension_slug' => 'my-ext',
-				'name'           => 'Bad',
-				$field           => $value,
-			)
+		$e = $this->assert_invalid_input(
+			static function () use ( $field, $value ): void {
+				Plan::create(
+					array(
+						'extension_slug' => 'my-ext',
+						'name'           => 'Bad',
+						$field           => $value,
+					)
+				);
+			},
+			ValidationErrorCode::INVALID_TYPE,
+			$field,
+			array( 'type' => 'object' )
 		);
+
+		$this->assertStringContainsString( $field, $e->getMessage() );
 	}

 	/**
@@ -206,29 +215,78 @@ class PlanTest extends TestCase {
 	 *
 	 * @param array<string, mixed> $args    Create args.
 	 * @param string               $message Expected message.
+	 * @param string               $field   Expected field.
 	 */
-	public function test_create_requires_a_name_and_an_extension_slug( array $args, string $message ): void {
-		$this->expectException( DomainException::class );
-		$this->expectExceptionMessage( $message );
+	public function test_create_requires_a_name_and_an_extension_slug( array $args, string $message, string $field ): void {
+		$e = $this->assert_invalid_input(
+			static function () use ( $args ): void {
+				Plan::create( $args );
+			},
+			ValidationErrorCode::REQUIRED,
+			$field
+		);

-		Plan::create( $args );
+		$this->assertSame( $message, $e->getMessage() );
 	}

 	/**
-	 * @return array<string, array{0: array<string, mixed>, 1: string}>
+	 * @return array<string, array{0: array<string, mixed>, 1: string, 2: string}>
 	 */
 	public function provide_missing_required_args(): array {
 		return array(
-			'no name'           => array( array( 'extension_slug' => 'my-ext' ), 'Plan: name is required and must be a non-empty string.' ),
+			'no name'           => array( array( 'extension_slug' => 'my-ext' ), 'Plan: name is required and must be a non-empty string.', 'name' ),
 			'blank name'        => array(
 				array(
 					'extension_slug' => 'my-ext',
 					'name'           => '   ',
 				),
 				'Plan: name is required and must be a non-empty string.',
+				'name',
 			),
-			'no extension slug' => array( array( 'name' => 'Box' ), 'Plan: extension_slug is required and must be a non-empty string.' ),
+			'no extension slug' => array( array( 'name' => 'Box' ), 'Plan: extension_slug is required and must be a non-empty string.', 'extension_slug' ),
+		);
+	}
+
+	/**
+	 * @testdox create reports every invalid attribute in one exception.
+	 */
+	public function test_create_reports_every_invalid_attribute(): void {
+		$this->assert_invalid_inputs(
+			static function (): void {
+				Plan::create(
+					array(
+						'name'           => '',
+						'status'         => 'never-registered',
+						'pricing_policy' => array( 'a', 'b' ),
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::REQUIRED, 'name', array() ),
+				array( ValidationErrorCode::NOT_REGISTERED, 'status', array( 'value' => 'never-registered' ) ),
+				array( ValidationErrorCode::REQUIRED, 'extension_slug', array() ),
+				array( ValidationErrorCode::INVALID_TYPE, 'pricing_policy', array( 'type' => 'object' ) ),
+			)
+		);
+	}
+
+	/**
+	 * @testdox draft keeps refused values without checking them.
+	 */
+	public function test_draft_keeps_refused_values(): void {
+		$plan = Plan::draft(
+			array(
+				'id'             => 4,
+				'name'           => '',
+				'status'         => 'never-registered',
+				'extension_slug' => 'my-ext',
+			)
 		);
+
+		$this->assertSame( 4, $plan->get_id() );
+		$this->assertSame( '', $plan->get_name() );
+		$this->assertSame( 'never-registered', $plan->get_status() );
+		$this->assertSame( 'my-ext', $plan->get_extension_slug() );
 	}

 	/**
@@ -237,9 +295,13 @@ class PlanTest extends TestCase {
 	public function test_set_name_rejects_an_empty_name(): void {
 		$plan = self::create_plan( 'Named' );

-		$this->expectException( DomainException::class );
-
-		$plan->set_name( '' );
+		$this->assert_invalid_input(
+			static function () use ( $plan ): void {
+				$plan->set_name( '' );
+			},
+			ValidationErrorCode::REQUIRED,
+			'name'
+		);
 	}

 	/**
@@ -252,9 +314,14 @@ class PlanTest extends TestCase {
 		$plan   = self::create_plan( 'Bad' );
 		$setter = 'set_' . $field;

-		$this->expectException( DomainException::class );
-
-		$plan->{$setter}( array( 'a', 'b' ) );
+		$this->assert_invalid_input(
+			static function () use ( $plan, $setter ): void {
+				$plan->{$setter}( array( 'a', 'b' ) );
+			},
+			ValidationErrorCode::INVALID_TYPE,
+			$field,
+			array( 'type' => 'object' )
+		);
 	}

 	/**
@@ -287,14 +354,19 @@ class PlanTest extends TestCase {
 	 * @testdox create rejects an unregistered status.
 	 */
 	public function test_create_rejects_an_unregistered_status(): void {
-		$this->expectException( DomainException::class );
-
-		Plan::create(
-			array(
-				'extension_slug' => 'my-ext',
-				'name'           => 'Unknown',
-				'status'         => 'seasonal',
-			)
+		$this->assert_invalid_input(
+			static function (): void {
+				Plan::create(
+					array(
+						'extension_slug' => 'my-ext',
+						'name'           => 'Unknown',
+						'status'         => 'seasonal',
+					)
+				);
+			},
+			ValidationErrorCode::NOT_REGISTERED,
+			'status',
+			array( 'value' => 'seasonal' )
 		);
 	}

@@ -304,9 +376,14 @@ class PlanTest extends TestCase {
 	public function test_set_status_rejects_an_unregistered_status(): void {
 		$plan = self::create_plan( 'Unknown' );

-		$this->expectException( DomainException::class );
-
-		$plan->set_status( 'seasonal' );
+		$this->assert_invalid_input(
+			static function () use ( $plan ): void {
+				$plan->set_status( 'seasonal' );
+			},
+			ValidationErrorCode::NOT_REGISTERED,
+			'status',
+			array( 'value' => 'seasonal' )
+		);
 	}

 	/**
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/ArgumentValidatorTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/ArgumentValidatorTest.php
new file mode 100644
index 00000000000..370480302a0
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/ArgumentValidatorTest.php
@@ -0,0 +1,269 @@
+<?php
+/**
+ * Unit tests for the Core argument checks. Running under the WordPress-free unit
+ * bootstrap proves they stay in the Core zone.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Support;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ArgumentValidator;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInvalidInput;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ArgumentValidator
+ */
+class ArgumentValidatorTest extends TestCase {
+
+	use AssertsInvalidInput;
+
+	/**
+	 * @testdox A valid value is returned normalized.
+	 * @dataProvider provide_valid_values
+	 *
+	 * @param string            $method   Validator method.
+	 * @param array<int, mixed> $args     Validator arguments.
+	 * @param mixed             $expected Normalized value.
+	 */
+	public function test_a_valid_value_is_returned_normalized( string $method, array $args, $expected ): void {
+		$this->assertSame( $expected, ArgumentValidator::$method( ...$args ) );
+	}
+
+	/**
+	 * @return array<string, array{0: string, 1: array<int, mixed>, 2: mixed}>
+	 */
+	public function provide_valid_values(): array {
+		return array(
+			'currency'              => array( 'validate_currency', array( 'EUR' ), 'EUR' ),
+			'null currency'         => array( 'validate_currency', array( null ), null ),
+			'string'                => array( 'validate_string', array( 'status', 'active' ), 'active' ),
+			'non-empty string'      => array( 'validate_non_empty_string', array( 'extension_slug', 'acme' ), 'acme' ),
+			'nullable string'       => array( 'validate_nullable_string', array( 'title', null ), null ),
+			'nullable id as digits' => array( 'validate_nullable_id', array( 'order_id', '42' ), 42 ),
+			'nullable id null'      => array( 'validate_nullable_id', array( 'order_id', null ), null ),
+			'nullable date object'  => array(
+				'validate_nullable_date',
+				array( 'start_gmt', new DateTimeImmutable( '2026-01-01 03:00:00', new DateTimeZone( 'Europe/Berlin' ) ) ),
+				'2026-01-01 02:00:00',
+			),
+			'nullable date string'  => array( 'validate_nullable_date', array( 'start_gmt', '2026-02-28 00:00:00' ), '2026-02-28 00:00:00' ),
+			'money'                 => array( 'validate_money', array( 'tax_total', '1.5' ), '1.50000000' ),
+			'money null is zero'    => array( 'validate_money', array( 'tax_total', null ), '0.00000000' ),
+			'list of arrays'        => array( 'validate_list_of_arrays', array( 'items', array( array( 'name' => 'a' ) ) ), array( array( 'name' => 'a' ) ) ),
+			'positive int'          => array( 'validate_positive_int', array( 'quantity', 900 ), 900 ),
+			'positive int digits'   => array( 'validate_positive_int', array( 'quantity', '60' ), 60 ),
+			'non-negative int zero' => array( 'validate_non_negative_int', array( 'offset', 0 ), 0 ),
+			'non-negative digits'   => array( 'validate_non_negative_int', array( 'offset', '7' ), 7 ),
+			'id list'               => array( 'validate_id_list', array( 'ids', array( 3, '4' ) ), array( 3, 4 ) ),
+			'empty id list'         => array( 'validate_id_list', array( 'ids', array() ), array() ),
+			'string as list'        => array( 'validate_string_list', array( 'status', 'active' ), array( 'active' ) ),
+			'string list'           => array( 'validate_string_list', array( 'status', array( 'active', 'archived' ) ), array( 'active', 'archived' ) ),
+			'nullable array'        => array( 'validate_nullable_array', array( 'billing_policy', array( 'period' => 'month' ) ), array( 'period' => 'month' ) ),
+			'nullable array null'   => array( 'validate_nullable_array', array( 'billing_policy', null ), null ),
+			'addresses'             => array(
+				'validate_contract_addresses',
+				array( array( 'billing' => array( 'first_name' => 'Ada' ) ) ),
+				array( 'billing' => array( 'first_name' => 'Ada' ) ),
+			),
+		);
+	}
+
+	/**
+	 * @testdox An invalid value is refused with its code, field and params.
+	 * @dataProvider provide_invalid_values
+	 *
+	 * @param string               $method Validator method.
+	 * @param array<int, mixed>    $args   Validator arguments.
+	 * @param string               $code   Expected error code.
+	 * @param string               $field  Expected field.
+	 * @param array<string, mixed> $params Expected params.
+	 */
+	public function test_an_invalid_value_is_refused_with_its_code( string $method, array $args, string $code, string $field, array $params ): void {
+		$this->assert_invalid_input(
+			static function () use ( $method, $args ): void {
+				ArgumentValidator::$method( ...$args );
+			},
+			$code,
+			$field,
+			$params
+		);
+	}
+
+	/**
+	 * @testdox The English message quotes the field and says what the value must be.
+	 */
+	public function test_the_english_message_quotes_the_field(): void {
+		$e = $this->assert_invalid_input(
+			static function (): void {
+				ArgumentValidator::validate_positive_int( 'quantity', 0 );
+			},
+			ValidationErrorCode::OUT_OF_BOUNDS,
+			'quantity',
+			array( 'min' => 1 )
+		);
+		$this->assertSame( '"quantity" must be a positive integer.', $e->getMessage() );
+
+		$e = $this->assert_invalid_input(
+			static function (): void {
+				ArgumentValidator::validate_id_list( 'ids', array( 3, 0 ) );
+			},
+			ValidationErrorCode::OUT_OF_BOUNDS,
+			'ids.1',
+			array( 'min' => 1 )
+		);
+		$this->assertSame( '"ids.1" must be a positive integer.', $e->getMessage() );
+	}
+
+	/**
+	 * @return array<string, array{0: string, 1: array<int, mixed>, 2: string, 3: string, 4: array<string, mixed>}>
+	 */
+	public function provide_invalid_values(): array {
+		$type_string  = array( 'type' => 'string' );
+		$type_integer = array( 'type' => 'integer' );
+		$type_array   = array( 'type' => 'array' );
+		$type_object  = array( 'type' => 'object' );
+		$min_one      = array( 'min' => 1 );
+
+		return array(
+			'currency'              => array( 'validate_currency', array( 'eur' ), ValidationErrorCode::INVALID_VALUE, 'currency', array() ),
+			'currency newline'      => array( 'validate_currency', array( "EUR\n" ), ValidationErrorCode::INVALID_VALUE, 'currency', array() ),
+			'string'                => array( 'validate_string', array( 'status', 5 ), ValidationErrorCode::INVALID_TYPE, 'status', $type_string ),
+			'nullable string'       => array( 'validate_nullable_string', array( 'title', 5 ), ValidationErrorCode::INVALID_TYPE, 'title', $type_string ),
+			'non-empty not string'  => array( 'validate_non_empty_string', array( 'extension_slug', 5 ), ValidationErrorCode::INVALID_TYPE, 'extension_slug', $type_string ),
+			'non-empty null'        => array( 'validate_non_empty_string', array( 'extension_slug', null ), ValidationErrorCode::REQUIRED, 'extension_slug', array() ),
+			'non-empty empty'       => array( 'validate_non_empty_string', array( 'extension_slug', '' ), ValidationErrorCode::REQUIRED, 'extension_slug', array() ),
+			'positive int type'     => array( 'validate_positive_int', array( 'quantity', null ), ValidationErrorCode::INVALID_TYPE, 'quantity', $type_integer ),
+			'positive int zero'     => array( 'validate_positive_int', array( 'quantity', 0 ), ValidationErrorCode::OUT_OF_BOUNDS, 'quantity', $min_one ),
+			'nullable id type'      => array( 'validate_nullable_id', array( 'order_id', "5\n" ), ValidationErrorCode::INVALID_TYPE, 'order_id', $type_integer ),
+			'nullable id zero'      => array( 'validate_nullable_id', array( 'order_id', 0 ), ValidationErrorCode::OUT_OF_BOUNDS, 'order_id', $min_one ),
+			'non-negative type'     => array( 'validate_non_negative_int', array( 'offset', 1.5 ), ValidationErrorCode::INVALID_TYPE, 'offset', $type_integer ),
+			'non-negative negative' => array( 'validate_non_negative_int', array( 'offset', -1 ), ValidationErrorCode::OUT_OF_BOUNDS, 'offset', array( 'min' => 0 ) ),
+			'id list map'           => array( 'validate_id_list', array( 'ids', array( 'a' => 3 ) ), ValidationErrorCode::INVALID_TYPE, 'ids', $type_array ),
+			'id list entry zero'    => array( 'validate_id_list', array( 'ids', array( 3, 0 ) ), ValidationErrorCode::OUT_OF_BOUNDS, 'ids.1', $min_one ),
+			'id list entry null'    => array( 'validate_id_list', array( 'ids', array( null ) ), ValidationErrorCode::INVALID_TYPE, 'ids.0', $type_integer ),
+			'string list not list'  => array( 'validate_string_list', array( 'status', 5 ), ValidationErrorCode::INVALID_TYPE, 'status', $type_array ),
+			'string list empty'     => array( 'validate_string_list', array( 'status', '' ), ValidationErrorCode::REQUIRED, 'status', array() ),
+			'string list entry'     => array( 'validate_string_list', array( 'status', array( 'active', '' ) ), ValidationErrorCode::REQUIRED, 'status.1', array() ),
+			'string list entry int' => array( 'validate_string_list', array( 'status', array( 5 ) ), ValidationErrorCode::INVALID_TYPE, 'status.0', $type_string ),
+			'nullable array'        => array( 'validate_nullable_array', array( 'billing_policy', 'monthly' ), ValidationErrorCode::INVALID_TYPE, 'billing_policy', $type_object ),
+			'list of arrays map'    => array( 'validate_list_of_arrays', array( 'items', array( 'a' => array() ) ), ValidationErrorCode::INVALID_TYPE, 'items', $type_array ),
+			'list of arrays entry'  => array( 'validate_list_of_arrays', array( 'items', array( 'a' ) ), ValidationErrorCode::INVALID_TYPE, 'items.0', $type_object ),
+			'money'                 => array( 'validate_money', array( 'tax_total', 'ten' ), ValidationErrorCode::INVALID_TYPE, 'tax_total', array( 'type' => 'number' ) ),
+			'nullable date'         => array( 'validate_nullable_date', array( 'start_gmt', '2026-02-30 00:00:00' ), ValidationErrorCode::INVALID_VALUE, 'start_gmt', array() ),
+			'addresses not array'   => array( 'validate_contract_addresses', array( 'home' ), ValidationErrorCode::INVALID_TYPE, 'addresses', $type_object ),
+			'address unknown type'  => array(
+				'validate_contract_addresses',
+				array( array( 'home' => array( 'first_name' => 'Ada' ) ) ),
+				ValidationErrorCode::NOT_IN_ENUM,
+				'addresses.home',
+				array( 'allowed' => array( 'billing', 'shipping' ) ),
+			),
+			'address not array'     => array( 'validate_contract_addresses', array( array( 'billing' => 'Ada' ) ), ValidationErrorCode::INVALID_TYPE, 'addresses.billing', $type_object ),
+		);
+	}
+
+	/**
+	 * @testdox A list check reports every failed item.
+	 */
+	public function test_a_list_check_reports_every_failed_item(): void {
+		$this->assert_invalid_inputs(
+			static function (): void {
+				ArgumentValidator::validate_id_list( 'ids', array( 1, 0, 'x' ) );
+			},
+			array(
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'ids.1', array( 'min' => 1 ) ),
+				array( ValidationErrorCode::INVALID_TYPE, 'ids.2', array( 'type' => 'integer' ) ),
+			)
+		);
+		$this->assert_invalid_inputs(
+			static function (): void {
+				ArgumentValidator::validate_string_list( 'status', array( '', 'active', 5 ) );
+			},
+			array(
+				array( ValidationErrorCode::REQUIRED, 'status.0' ),
+				array( ValidationErrorCode::INVALID_TYPE, 'status.2' ),
+			)
+		);
+		$this->assert_invalid_inputs(
+			static function (): void {
+				ArgumentValidator::validate_list_of_arrays( 'items', array( 'a', array(), 'b' ) );
+			},
+			array(
+				array( ValidationErrorCode::INVALID_TYPE, 'items.0' ),
+				array( ValidationErrorCode::INVALID_TYPE, 'items.2' ),
+			)
+		);
+		$this->assert_invalid_inputs(
+			static function (): void {
+				ArgumentValidator::validate_contract_addresses(
+					array(
+						'home'    => array(),
+						'billing' => 'x',
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::NOT_IN_ENUM, 'addresses.home', array( 'allowed' => array( 'billing', 'shipping' ) ) ),
+				array( ValidationErrorCode::INVALID_TYPE, 'addresses.billing', array( 'type' => 'object' ) ),
+			)
+		);
+	}
+
+	/**
+	 * @testdox A value that is not a list reports only the key itself.
+	 */
+	public function test_a_non_list_reports_only_the_key(): void {
+		$this->assert_invalid_inputs(
+			static function (): void {
+				ArgumentValidator::validate_id_list( 'ids', array( 'a' => 0 ) );
+			},
+			array( array( ValidationErrorCode::INVALID_TYPE, 'ids', array( 'type' => 'array' ) ) )
+		);
+	}
+
+	/**
+	 * @testdox The collector returns normalized values and throws nothing when nothing failed.
+	 */
+	public function test_the_collector_returns_normalized_values(): void {
+		$validator = new ArgumentValidator();
+
+		$this->assertSame( 42, $validator->get_nullable_id( 'order_id', '42' ) );
+		$this->assertSame( array( 3, 4 ), $validator->get_id_list( 'ids', array( 3, '4' ) ) );
+		$this->assertSame( '1.50000000', $validator->get_money( 'tax_total', '1.5' ) );
+		$this->assertSame( 'EUR', $validator->get_currency( 'EUR' ) );
+
+		$validator->throw_errors();
+	}
+
+	/**
+	 * @testdox The collector returns null for a refused value and throws every error in call order.
+	 */
+	public function test_the_collector_throws_every_error_in_call_order(): void {
+		$validator = new ArgumentValidator();
+
+		$this->assertNull( $validator->get_string( 'name', 5 ) );
+		$this->assertNull( $validator->get_positive_int( 'limit', 0 ) );
+		$validator->add( new InvalidInputException( ValidationErrorCode::INVALID_VALUE, 'extension_slug', 'Not "any".' ) );
+		$this->assertNull( $validator->get_nullable_array( 'billing_policy', 'monthly' ) );
+
+		$this->assert_invalid_inputs(
+			static function () use ( $validator ): void {
+				$validator->throw_errors();
+			},
+			array(
+				array( ValidationErrorCode::INVALID_TYPE, 'name', array( 'type' => 'string' ) ),
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'limit', array( 'min' => 1 ) ),
+				array( ValidationErrorCode::INVALID_VALUE, 'extension_slug', array() ),
+				array( ValidationErrorCode::INVALID_TYPE, 'billing_policy', array( 'type' => 'object' ) ),
+			)
+		);
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/InvalidInputExceptionTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/InvalidInputExceptionTest.php
new file mode 100644
index 00000000000..7b8c8d86b35
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/InvalidInputExceptionTest.php
@@ -0,0 +1,115 @@
+<?php
+/**
+ * Unit tests for the Core validation exception.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Support;
+
+use DomainException;
+use LogicException;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException
+ */
+class InvalidInputExceptionTest extends TestCase {
+
+	/**
+	 * @testdox The getters return the constructor values and the message stays English.
+	 *
+	 * @throws InvalidInputException Thrown and caught as a DomainException.
+	 */
+	public function test_getters_return_the_constructor_values(): void {
+		try {
+			throw new InvalidInputException(
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'trial_duration.length',
+				'BillingPolicy: trial length must be positive.',
+				array( 'min' => 1 )
+			);
+		} catch ( DomainException $caught ) {
+			$e = $caught;
+		}
+
+		$this->assertInstanceOf( InvalidInputException::class, $e, 'Callers catching DomainException keep working.' );
+		$this->assertSame( 'out_of_bounds', $e->get_error_code() );
+		$this->assertSame( 'trial_duration.length', $e->get_field() );
+		$this->assertSame( array( 'min' => 1 ), $e->get_params() );
+		$this->assertSame( 'BillingPolicy: trial length must be positive.', $e->getMessage() );
+	}
+
+	/**
+	 * @testdox Params default to an empty array.
+	 */
+	public function test_params_default_to_empty(): void {
+		$e = new InvalidInputException( ValidationErrorCode::REQUIRED, 'name', 'Plan: name is required.' );
+
+		$this->assertSame( array(), $e->get_params() );
+	}
+
+	/**
+	 * @testdox A single exception lists itself as its only error.
+	 */
+	public function test_a_single_exception_lists_itself(): void {
+		$e = new InvalidInputException( ValidationErrorCode::REQUIRED, 'name', 'Plan: name is required.' );
+
+		$this->assertSame( array( $e ), $e->get_errors() );
+	}
+
+	/**
+	 * @testdox from_errors returns a single error as is.
+	 */
+	public function test_from_errors_returns_a_single_error_as_is(): void {
+		$e = new InvalidInputException( ValidationErrorCode::REQUIRED, 'name', 'Plan: name is required.' );
+
+		$this->assertSame( $e, InvalidInputException::from_errors( array( $e ) ) );
+	}
+
+	/**
+	 * @testdox from_errors keeps the first error's getters, joins the messages and lists every error in order.
+	 */
+	public function test_from_errors_combines_several_errors(): void {
+		$name     = new InvalidInputException( ValidationErrorCode::REQUIRED, 'name', 'Plan: name is required.' );
+		$interval = new InvalidInputException( ValidationErrorCode::OUT_OF_BOUNDS, 'interval', 'Interval too low.', array( 'min' => 1 ) );
+
+		$e = InvalidInputException::from_errors( array( $name, $interval ) );
+
+		$this->assertSame( ValidationErrorCode::REQUIRED, $e->get_error_code() );
+		$this->assertSame( 'name', $e->get_field() );
+		$this->assertSame( array(), $e->get_params() );
+		$this->assertSame( 'Plan: name is required. Interval too low.', $e->getMessage() );
+		$this->assertSame( array( $name, $interval ), $e->get_errors() );
+	}
+
+	/**
+	 * @testdox from_errors flattens a combined error.
+	 */
+	public function test_from_errors_flattens_a_combined_error(): void {
+		$a = new InvalidInputException( ValidationErrorCode::REQUIRED, 'a', 'A.' );
+		$b = new InvalidInputException( ValidationErrorCode::REQUIRED, 'b', 'B.' );
+		$c = new InvalidInputException( ValidationErrorCode::REQUIRED, 'c', 'C.' );
+
+		$e = InvalidInputException::from_errors( array( InvalidInputException::from_errors( array( $a, $b ) ), $c ) );
+
+		$this->assertSame( array( $a, $b, $c ), $e->get_errors() );
+		$this->assertSame( 'A. B. C.', $e->getMessage() );
+	}
+
+	/**
+	 * @testdox from_errors refuses an empty list as an engine bug.
+	 */
+	public function test_from_errors_refuses_an_empty_list(): void {
+		try {
+			InvalidInputException::from_errors( array() );
+			$this->fail( 'Expected a LogicException.' );
+		} catch ( LogicException $e ) {
+			$this->assertSame( LogicException::class, get_class( $e ) );
+		}
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/NoPlainDomainExceptionTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/NoPlainDomainExceptionTest.php
new file mode 100644
index 00000000000..d85d11a2670
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Support/NoPlainDomainExceptionTest.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * Guards that every validation throw carries a code.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Support;
+
+use PHPUnit\Framework\TestCase;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use SplFileInfo;
+
+/**
+ * Scans the Core and Integration zones for plain DomainException throws.
+ *
+ * @coversNothing
+ */
+class NoPlainDomainExceptionTest extends TestCase {
+
+	/**
+	 * @testdox Core and Integration throw no plain DomainException: validation errors carry a code.
+	 */
+	public function test_core_and_integration_throw_no_plain_domain_exception(): void {
+		$offenders = array();
+		$src       = dirname( __DIR__, 4 ) . '/src';
+
+		foreach ( array( $src . '/Core', $src . '/Integration' ) as $dir ) {
+			$files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ) );
+			foreach ( $files as $file ) {
+				if ( ! $file instanceof SplFileInfo || 'php' !== $file->getExtension() ) {
+					continue;
+				}
+
+				$contents = (string) file_get_contents( $file->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local source file.
+				if ( 1 === preg_match( '/new\s+\\\\?DomainException\s*\(/', $contents ) ) {
+					$offenders[] = substr( $file->getPathname(), strlen( $src ) + 1 );
+				}
+			}
+		}
+
+		$this->assertSame( array(), $offenders, 'Throw Core\Support\InvalidInputException (validation) or LogicException (internal invariant) instead.' );
+	}
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php
index 2ca3627a253..c727ec96aca 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/BillingPolicyTest.php
@@ -11,15 +11,234 @@ namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject

 use DateTimeImmutable;
 use DateTimeZone;
-use DomainException;
 use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\ValidationErrorCode;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInvalidInput;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy
  */
 class BillingPolicyTest extends TestCase {

+	use AssertsInvalidInput;
+
+	/**
+	 * @testdox Each refusal reports its code, field and params.
+	 * @dataProvider provide_refusals
+	 *
+	 * @param callable             $operation Operation expected to be refused.
+	 * @param string               $code      Expected error code.
+	 * @param string               $field     Expected field.
+	 * @param array<string, mixed> $params    Expected params.
+	 */
+	public function test_each_refusal_reports_its_code_field_and_params( callable $operation, string $code, string $field, array $params ): void {
+		$this->assert_invalid_input( $operation, $code, $field, $params );
+	}
+
+	/**
+	 * @return array<string, array{0: callable, 1: string, 2: string, 3: array<string, mixed>}>
+	 */
+	public function provide_refusals(): array {
+		$units = array( 'allowed' => array( 'day', 'week', 'month', 'year' ) );
+
+		return array(
+			'interval not positive'     => array(
+				static function (): void {
+					new BillingPolicy( 'month', 0, null, null, null );
+				},
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'interval',
+				array( 'min' => 1 ),
+			),
+			'unknown period'            => array(
+				static function (): void {
+					new BillingPolicy( 'fortnight', 1, null, null, null );
+				},
+				ValidationErrorCode::NOT_IN_ENUM,
+				'period',
+				$units,
+			),
+			'period missing'            => array(
+				static function (): void {
+					BillingPolicy::from_array( array( 'interval' => 1 ) );
+				},
+				ValidationErrorCode::REQUIRED,
+				'period',
+				array(),
+			),
+			'interval missing'          => array(
+				static function (): void {
+					BillingPolicy::from_array( array( 'period' => 'month' ) );
+				},
+				ValidationErrorCode::REQUIRED,
+				'interval',
+				array(),
+			),
+			'period not a string'       => array(
+				static function (): void {
+					BillingPolicy::from_array(
+						array(
+							'period'   => 1,
+							'interval' => 1,
+						)
+					);
+				},
+				ValidationErrorCode::INVALID_TYPE,
+				'period',
+				array( 'type' => 'string' ),
+			),
+			'interval not an integer'   => array(
+				static function (): void {
+					BillingPolicy::from_array(
+						array(
+							'period'   => 'month',
+							'interval' => '2',
+						)
+					);
+				},
+				ValidationErrorCode::INVALID_TYPE,
+				'interval',
+				array( 'type' => 'integer' ),
+			),
+			'trial not an object'       => array(
+				static function (): void {
+					BillingPolicy::from_array(
+						array(
+							'period'         => 'month',
+							'interval'       => 1,
+							'trial_duration' => 'P7D',
+						)
+					);
+				},
+				ValidationErrorCode::INVALID_TYPE,
+				'trial_duration',
+				array( 'type' => 'object' ),
+			),
+			'min_cycles negative'       => array(
+				static function (): void {
+					new BillingPolicy( 'month', 1, -1, null, null );
+				},
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'min_cycles',
+				array( 'min' => 0 ),
+			),
+			'max_cycles negative'       => array(
+				static function (): void {
+					new BillingPolicy( 'month', 1, null, -1, null );
+				},
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'max_cycles',
+				array( 'min' => 0 ),
+			),
+			'min_cycles above max'      => array(
+				static function (): void {
+					new BillingPolicy( 'month', 1, 5, 2, null );
+				},
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'min_cycles',
+				array( 'max' => 2 ),
+			),
+			'trial length missing'      => array(
+				static function (): void {
+					BillingPolicy::from_array(
+						array(
+							'period'         => 'month',
+							'interval'       => 1,
+							'trial_duration' => array( 'unit' => 'day' ),
+						)
+					);
+				},
+				ValidationErrorCode::REQUIRED,
+				'trial_duration.length',
+				array(),
+			),
+			'trial unit missing'        => array(
+				static function (): void {
+					BillingPolicy::from_array(
+						array(
+							'period'         => 'month',
+							'interval'       => 1,
+							'trial_duration' => array( 'length' => 7 ),
+						)
+					);
+				},
+				ValidationErrorCode::REQUIRED,
+				'trial_duration.unit',
+				array(),
+			),
+			'trial length not integer'  => array(
+				static function (): void {
+					BillingPolicy::from_array(
+						array(
+							'period'         => 'month',
+							'interval'       => 1,
+							'trial_duration' => array(
+								'length' => '7',
+								'unit'   => 'day',
+							),
+						)
+					);
+				},
+				ValidationErrorCode::INVALID_TYPE,
+				'trial_duration.length',
+				array( 'type' => 'integer' ),
+			),
+			'trial unit not a string'   => array(
+				static function (): void {
+					BillingPolicy::from_array(
+						array(
+							'period'         => 'month',
+							'interval'       => 1,
+							'trial_duration' => array(
+								'length' => 7,
+								'unit'   => 1,
+							),
+						)
+					);
+				},
+				ValidationErrorCode::INVALID_TYPE,
+				'trial_duration.unit',
+				array( 'type' => 'string' ),
+			),
+			'trial length not positive' => array(
+				static function (): void {
+					new BillingPolicy(
+						'month',
+						1,
+						null,
+						null,
+						array(
+							'length' => 0,
+							'unit'   => 'day',
+						)
+					);
+				},
+				ValidationErrorCode::OUT_OF_BOUNDS,
+				'trial_duration.length',
+				array( 'min' => 1 ),
+			),
+			'unknown trial unit'        => array(
+				static function (): void {
+					new BillingPolicy(
+						'month',
+						1,
+						null,
+						null,
+						array(
+							'length' => 7,
+							'unit'   => 'fortnight',
+						)
+					);
+				},
+				ValidationErrorCode::NOT_IN_ENUM,
+				'trial_duration.unit',
+				$units,
+			),
+		);
+	}
+
 	/**
 	 * @testdox from_array refuses an unusable cadence.
 	 * @testWith ["decade", 1, "BillingPolicy: invalid period \"decade\"."]
@@ -36,7 +255,7 @@ class BillingPolicyTest extends TestCase {
 			'interval' => $interval,
 		);

-		$this->expectException( DomainException::class );
+		$this->expectException( InvalidInputException::class );
 		$this->expectExceptionMessage( $message );

 		BillingPolicy::from_array( $data );
@@ -142,25 +361,90 @@ class BillingPolicyTest extends TestCase {
 	}

 	/**
-	 * @testdox construction refuses an unknown period.
+	 * @testdox from_array reports every invalid value in one exception.
 	 */
-	public function test_construction_refuses_an_unknown_period(): void {
-		$this->expectException( DomainException::class );
+	public function test_from_array_reports_every_invalid_value(): void {
+		$this->assert_invalid_inputs(
+			static function (): void {
+				BillingPolicy::from_array(
+					array(
+						'interval'       => 0,
+						'min_cycles'     => -1,
+						'trial_duration' => array( 'length' => 'x' ),
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::REQUIRED, 'period', array() ),
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'interval', array( 'min' => 1 ) ),
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'min_cycles', array( 'min' => 0 ) ),
+				array( ValidationErrorCode::INVALID_TYPE, 'trial_duration.length', array( 'type' => 'integer' ) ),
+				array( ValidationErrorCode::REQUIRED, 'trial_duration.unit', array() ),
+			)
+		);
+	}

-		new BillingPolicy( 'fortnight', 1, null, null, null );
+	/**
+	 * @testdox from_array reports a bad period, trial length and trial unit together.
+	 */
+	public function test_from_array_reports_the_trial_values_with_the_other_fields(): void {
+		$this->assert_invalid_inputs(
+			static function (): void {
+				BillingPolicy::from_array(
+					array(
+						'period'         => 'fortnight',
+						'interval'       => 1,
+						'trial_duration' => array(
+							'length' => 0,
+							'unit'   => 'fortnight',
+						),
+					)
+				);
+			},
+			array(
+				array( ValidationErrorCode::NOT_IN_ENUM, 'period', array( 'allowed' => array( 'day', 'week', 'month', 'year' ) ) ),
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'trial_duration.length', array( 'min' => 1 ) ),
+				array( ValidationErrorCode::NOT_IN_ENUM, 'trial_duration.unit', array( 'allowed' => array( 'day', 'week', 'month', 'year' ) ) ),
+			)
+		);
 	}

 	/**
-	 * @testdox construction refuses a non-positive interval.
+	 * @testdox The construction reports every invalid value in one exception.
 	 */
-	public function test_construction_refuses_a_non_positive_interval(): void {
-		$this->expectException( DomainException::class );
+	public function test_construction_reports_every_invalid_value(): void {
+		$this->assert_invalid_inputs(
+			static function (): void {
+				new BillingPolicy( 'fortnight', 0, null, -2, null );
+			},
+			array(
+				array( ValidationErrorCode::NOT_IN_ENUM, 'period', array( 'allowed' => array( 'day', 'week', 'month', 'year' ) ) ),
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'interval', array( 'min' => 1 ) ),
+				array( ValidationErrorCode::OUT_OF_BOUNDS, 'max_cycles', array( 'min' => 0 ) ),
+			)
+		);
+	}

-		new BillingPolicy( 'month', 0, null, null, null );
+	/**
+	 * @testdox min_cycles above max_cycles is checked only when both bounds pass their own checks.
+	 */
+	public function test_the_cycle_bounds_check_runs_only_on_valid_bounds(): void {
+		$this->assert_invalid_inputs(
+			static function (): void {
+				new BillingPolicy( 'month', 1, 5, 2, null );
+			},
+			array( array( ValidationErrorCode::OUT_OF_BOUNDS, 'min_cycles', array( 'max' => 2 ) ) )
+		);
+		$this->assert_invalid_inputs(
+			static function (): void {
+				new BillingPolicy( 'month', 1, -1, 2, null );
+			},
+			array( array( ValidationErrorCode::OUT_OF_BOUNDS, 'min_cycles', array( 'min' => 0 ) ) )
+		);
 	}

 	public function test_non_array_trial_duration_throws(): void {
-		$this->expectException( DomainException::class );
+		$this->expectException( InvalidInputException::class );
 		$this->expectExceptionMessage( 'BillingPolicy: trial_duration must be null or an array, got string.' );

 		BillingPolicy::from_array(
@@ -180,7 +464,7 @@ class BillingPolicyTest extends TestCase {
 	 */
 	public function test_min_and_max_cycles_validation( ?string $expected_exception_message, ?int $min_cycles, ?int $max_cycles ): void {
 		if ( null !== $expected_exception_message ) {
-			$this->expectException( DomainException::class );
+			$this->expectException( InvalidInputException::class );
 			$this->expectExceptionMessage( $expected_exception_message );
 		}

diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/ItemsSnapshotTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/ItemsSnapshotTest.php
index fc62bb0a0cf..057eeaf815f 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/ItemsSnapshotTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/ItemsSnapshotTest.php
@@ -9,15 +9,17 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject;

-use DomainException;
 use PHPUnit\Framework\TestCase;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInternalInvariant;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\ItemsSnapshot
  */
 class ItemsSnapshotTest extends TestCase {

+	use AssertsInternalInvariant;
+
 	/**
 	 * A representative two-item set.
 	 *
@@ -85,8 +87,10 @@ class ItemsSnapshotTest extends TestCase {
 	}

 	public function test_negative_schema_version_throws(): void {
-		$this->expectException( DomainException::class );
-
-		ItemsSnapshot::from_items( $this->sample_items(), -1 );
+		$this->assert_internal_invariant(
+			function (): void {
+				ItemsSnapshot::from_items( $this->sample_items(), -1 );
+			}
+		);
 	}
 }
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
index 3f727a58230..384c456d33d 100644
--- a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/PlanSnapshotTest.php
@@ -9,16 +9,19 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject;

-use DomainException;
 use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Support\InvalidInputException;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\BillingPolicy;
 use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot;
+use Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\AssertsInternalInvariant;

 /**
  * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\PlanSnapshot
  */
 class PlanSnapshotTest extends TestCase {

+	use AssertsInternalInvariant;
+
 	public function test_schema_version_defaults_to_one(): void {
 		$snapshot = PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ) );

@@ -91,9 +94,11 @@ class PlanSnapshotTest extends TestCase {
 	}

 	public function test_negative_schema_version_throws(): void {
-		$this->expectException( DomainException::class );
-
-		PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ), 0 );
+		$this->assert_internal_invariant(
+			static function (): void {
+				PlanSnapshot::from_array( array( 'selling_plan_id' => 7 ), 0 );
+			}
+		);
 	}

 	/**
@@ -133,7 +138,7 @@ class PlanSnapshotTest extends TestCase {
 			)
 		);

-		$this->expectException( DomainException::class );
+		$this->expectException( InvalidInputException::class );
 		$snapshot->read_billing_policy();
 	}

@@ -159,7 +164,7 @@ class PlanSnapshotTest extends TestCase {
 			)
 		);

-		$this->expectException( DomainException::class );
+		$this->expectException( InvalidInputException::class );
 		$snapshot->read_billing_policy();
 	}